Render image and video in galleries as a grid (#5929)

This commit is contained in:
Mauro
2026-07-24 18:47:39 +02:00
committed by GitHub
parent 5d0343c480
commit 274da99787
39 changed files with 464 additions and 5 deletions
@@ -11,15 +11,20 @@ struct ContentScannerServiceMockConfiguration {
/// The verdict reported for every source: `true` when safe, `false` when unsafe, or `nil` to
/// never resolve the scan so that the scanning state remains visible.
var scanResult: Bool?
/// Overrides `scanResult` on a per-source basis, using the same `true`/`false`/`nil` semantics.
var perSourceScanResult: (@Sendable (MediaSourceProxy) -> Bool?)?
}
extension ContentScannerServiceMock {
convenience init(_ configuration: ContentScannerServiceMockConfiguration) {
self.init()
scanResultFromSourceReturnValue = configuration.scanResult
loadScanResultFromSourceClosure = { _ in
guard let scanResult = configuration.scanResult else {
let blanketScanResult = configuration.scanResult
let resolve: @Sendable (MediaSourceProxy) -> Bool? = configuration.perSourceScanResult ?? { _ in blanketScanResult }
scanResultFromSourceClosure = { resolve($0) }
loadScanResultFromSourceClosure = { source in
guard let scanResult = resolve(source) else {
try? await Task.sleep(for: .seconds(3600))
return .failure(.failedScanning)
}
@@ -55,6 +55,7 @@ enum TestablePreviewsDictionary {
"FormattedBodyText_Previews" : FormattedBodyText_Previews.self,
"FormattingToolbar_Previews" : FormattingToolbar_Previews.self,
"FullscreenDialog_Previews" : FullscreenDialog_Previews.self,
"GalleryRoomTimelineView_Previews" : GalleryRoomTimelineView_Previews.self,
"HighlightedTimelineItemModifier_Previews" : HighlightedTimelineItemModifier_Previews.self,
"HomeScreenEmptyStateView_Previews" : HomeScreenEmptyStateView_Previews.self,
"HomeScreenInviteCell_Previews" : HomeScreenInviteCell_Previews.self,
@@ -180,6 +180,10 @@ private extension TimelineItemSendInfo {
switch message {
case is ImageRoomTimelineItem, is VideoRoomTimelineItem:
.overlay(capsuleStyle: !message.hasMediaCaption)
case is GalleryRoomTimelineItem:
// Without a caption, append the send info below the grid rather than overlaying
// a capsule on top of the media.
message.hasMediaCaption ? .overlay(capsuleStyle: false) : .vertical()
case is AudioRoomTimelineItem, is FileRoomTimelineItem:
// swiftlint:disable:next void_function_in_ternary
message.hasMediaCaption ? .overlay(capsuleStyle: false) : .horizontal(spacing: 0) // No spacing as the content already contains it.
@@ -0,0 +1,83 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct GalleryGridView: View {
let items: [GalleryItem]
let mediaProvider: MediaProviderProtocol?
let contentScannerService: ContentScannerServiceProtocol?
let onItemTap: (Int) -> Void
static let spacing: CGFloat = 4
static let groupWidth: CGFloat = 264
static let maxVisible = 5
private var visibleCount: Int {
min(items.count, Self.maxVisible)
}
/// Number shown on the overflow tile when items.count > maxVisible.
private var overflowCount: Int {
// Represents the last visible item + the rest.
items.count > Self.maxVisible ? items.count - Self.maxVisible + 1 : 0
}
private struct Row {
let indices: [Int]
let height: CGFloat
}
/// Hero-style layout: 1 = full-width, 2 = two full-width stacked rows, 3 = full-width hero
/// over two squares, 4 = 2×2 squares, 5+ = top row two squares, bottom row three squares.
private var rows: [Row] {
switch visibleCount {
case 1: return [Row(indices: [0], height: 130)]
case 2: return [Row(indices: [0], height: 130),
Row(indices: [1], height: 130)]
case 3: return [Row(indices: [0], height: 130),
Row(indices: [1, 2], height: 130)]
case 4: return [Row(indices: [0, 1], height: 130),
Row(indices: [2, 3], height: 130)]
case 5: return [Row(indices: [0, 1], height: 130),
Row(indices: [2, 3, 4], height: 85)]
default: return []
}
}
var body: some View {
VStack(spacing: Self.spacing) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
let totalSpacing = CGFloat(row.indices.count - 1) * Self.spacing
let tileWidth = (Self.groupWidth - totalSpacing) / CGFloat(row.indices.count)
HStack(spacing: Self.spacing) {
ForEach(row.indices, id: \.self) { index in
tile(at: index, size: CGSize(width: tileWidth, height: row.height))
}
}
}
}
.clipShape(RoundedRectangle(cornerRadius: 6))
}
@ViewBuilder
private func tile(at index: Int, size: CGSize) -> some View {
if items.indices.contains(index) {
let isLastVisible = index == visibleCount - 1
let overflow = (overflowCount > 0 && isLastVisible) ? overflowCount : 0
GalleryItemTileView(item: items[index],
mediaProvider: mediaProvider,
contentScannerService: contentScannerService,
overflowCount: overflow) {
onItemTap(index)
}
.frame(width: size.width, height: size.height)
}
}
}
@@ -0,0 +1,117 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct GalleryItemTileView: View {
let item: GalleryItem
let mediaProvider: MediaProviderProtocol?
let contentScannerService: ContentScannerServiceProtocol?
let overflowCount: Int
let onTap: () -> Void
var body: some View {
// Overflow above the scanner: shows on any scan state, survives content updates.
ZStack {
// containerShowsFailure: false keeps an unsafe tile local, not the whole bubble.
ContentScanningView(contentScannerService: contentScannerService,
mediaSource: item.mediaSource,
thumbnailSource: item.thumbnailSource,
containerShowsFailure: false) {
// Clear base so the image fills/clips to the grid's tile size, not its own.
Color.clear
.overlay { content }
.clipped()
.overlay(alignment: .bottom) {
if item.isVideo {
videoOverlay
}
}
.contentShape(Rectangle())
.onTapGesture { onTap() }
.accessibilityElement(children: .ignore)
.accessibilityLabel(item.filename)
} scanningContent: {
ScanningMediaEventsTimelineView()
} unsafeContent: { failure in
UnsafeMediaEventsTimelineView(failure: failure)
}
if overflowCount > 0 {
overflowOverlay
}
}
}
@ViewBuilder
private var content: some View {
if let source = item.thumbnailSource ?? item.mediaSource {
LoadableImage(mediaSource: source,
mediaType: .timelineItem(uniqueID: item.id.timelineItemUniqueID),
blurhash: item.blurhash,
size: item.size,
mediaProvider: mediaProvider) { imageView in
imageView
.aspectRatio(contentMode: .fill)
} placeholder: {
placeholder
}
} else {
iconFallback
}
}
private var placeholder: some View {
Color.compound.bgSubtleSecondary.opacity(0.9)
}
private var iconFallback: some View {
ZStack {
Color.compound.bgSubtleSecondary
CompoundIcon(item.isAudio ? \.audio : \.attachment)
.foregroundStyle(.compound.iconPrimary)
}
}
private var videoOverlay: some View {
HStack(spacing: 0) {
CompoundIcon(\.videoCallSolid,
size: .xSmall,
relativeTo: .compound.bodyXSSemibold)
Spacer(minLength: 0)
if let duration = item.duration {
Text(formatted(duration: duration))
.lineLimit(1)
}
}
.padding(8)
.frame(maxWidth: .infinity)
.font(.compound.bodyXSSemibold)
.foregroundStyle(.compound.textPrimary)
.background {
LinearGradient(colors: [.clear, .compound.bgCanvasDefault],
startPoint: .top,
endPoint: .bottom)
}
}
private var overflowOverlay: some View {
ZStack {
Color.compound.bgCanvasDefault.opacity(0.7)
Text("+\(overflowCount)")
.font(.compound.headingSMSemibold)
.foregroundStyle(.compound.textPrimary)
}
}
private func formatted(duration: TimeInterval) -> String {
let duration = Duration.seconds(duration)
let pattern: Duration.TimeFormatStyle.Pattern = duration >= .seconds(3600) ? .hourMinuteSecond : .minuteSecond
return duration.formatted(.time(pattern: pattern))
}
}
@@ -9,14 +9,118 @@ import Compound
import SwiftUI
struct GalleryRoomTimelineView: View {
@Environment(\.timelineContext) private var context
let timelineItem: GalleryRoomTimelineItem
private var hasMediaCaption: Bool {
timelineItem.content.caption?.isBlank == false
}
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
// Placeholder rendering — the grid/list media views land in a follow-up.
FormattedBodyText(text: timelineItem.content.caption ?? timelineItem.content.body,
VStack(alignment: .leading, spacing: 8) {
GalleryGridView(items: timelineItem.content.items,
mediaProvider: context?.mediaProvider,
contentScannerService: context?.contentScannerService) { _ in
// Tapping to open the full-screen preview lands in a follow-up.
}
if hasMediaCaption {
caption
}
}
.frame(width: GalleryGridView.groupWidth, alignment: .leading)
}
}
@ViewBuilder
private var caption: some View {
if let attributedCaption = timelineItem.content.formattedCaption {
FormattedBodyText(attributedString: attributedCaption,
trailingReservedSize: timelineItem.trailingReservedSize,
boostFontSize: timelineItem.shouldBoost)
} else if let caption = timelineItem.content.caption {
FormattedBodyText(text: caption,
trailingReservedSize: timelineItem.trailingReservedSize,
boostFontSize: timelineItem.shouldBoost)
}
}
}
// MARK: - Previews
struct GalleryRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
// Fixed sources keyed by the mock scanner below. Safe is loadable (real thumbnail); others just need a distinct URL.
nonisolated static let safeSource = ImageInfoProxy.mockImage.source
// swiftlint:disable force_try
nonisolated static let unsafeSource = try! MediaSourceProxy(url: .mockMXCUnsafe, mimeType: "image/jpeg")
nonisolated static let scanningSource = try! MediaSourceProxy(url: .mockMXCScanning, mimeType: "image/jpeg")
// swiftlint:enable force_try
static let mixedScanViewModel = TimelineViewModel.mock(contentScannerService: ContentScannerServiceMock(.init { source in
if source.url == unsafeSource.url {
false
} else if source.url == scanningSource.url {
nil
} else {
true
}
}))
static var previews: some View {
makeView(makeItem(itemCount: 1), viewModel).previewDisplayName("1 image")
makeView(makeItem(itemCount: 2), viewModel).previewDisplayName("2 images")
makeView(makeItem(itemCount: 3), viewModel).previewDisplayName("3 images")
makeView(makeItem(itemCount: 4), viewModel).previewDisplayName("4 images")
makeView(makeItem(itemCount: 5), viewModel).previewDisplayName("5 images")
makeView(makeItem(itemCount: 9, caption: "A trip to remember 🌅"), viewModel).previewDisplayName("6+ images with caption")
makeView(makeMixedScanItem(), mixedScanViewModel).previewDisplayName("Mixed content scan")
}
static func makeView(_ item: GalleryRoomTimelineItem, _ viewModel: TimelineViewModel) -> some View {
GalleryRoomTimelineView(timelineItem: item)
.padding(20)
.environmentObject(viewModel.context)
.environment(\.timelineContext, viewModel.context)
}
private static func makeItem(itemCount: Int, caption: String? = nil) -> GalleryRoomTimelineItem {
let items: [GalleryItem] = (0..<itemCount).map { index in
index.isMultiple(of: 3)
? .mockVideo(index: index, filename: "clip-\(index).mp4")
: .mockImage(index: index, filename: "image-\(index).jpg")
}
return GalleryRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "Bob"),
content: .init(body: "Gallery (\(itemCount) items)",
caption: caption,
items: items),
properties: .init())
}
private static func makeMixedScanItem() -> GalleryRoomTimelineItem {
// >maxVisible items with the overflow tile (index 4) unsafe, to show "+N" over a failed scan.
let sources = [safeSource, unsafeSource, scanningSource, safeSource, unsafeSource, safeSource, safeSource]
let items: [GalleryItem] = sources.enumerated().map { index, source in
.mockImage(index: index, filename: "image-\(index).jpg", source: source, thumbnailSource: source)
}
return GalleryRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "Bob"),
content: .init(body: "Gallery (\(sources.count) items)",
caption: "Mixed scan states",
items: items),
properties: .init())
}
}
@@ -278,6 +278,15 @@ nonisolated struct VideoInfoProxy: Hashable {
mimeType: nil,
fileSize: 45_167_000)
}
static func mockVideo(duration: TimeInterval) -> VideoInfoProxy {
.init(source: mockVideo.source,
duration: duration,
size: .init(width: 1920, height: 1080),
aspectRatio: 1.78,
mimeType: nil,
fileSize: 45_167_000)
}
}
nonisolated struct ImageInfoProxy: Hashable {
@@ -143,3 +143,36 @@ nonisolated enum GalleryItem: Hashable, Identifiable {
isImage || thumbnailSource != nil
}
}
// MARK: - Mocks
nonisolated extension GalleryItemID {
static func mock(_ index: Int) -> GalleryItemID {
.init(timelineItemUniqueID: .init("gallery-mock"), mediaIndex: index)
}
}
nonisolated extension GalleryItem {
static func mockImage(index: Int = 0,
filename: String = "image.jpg",
source: MediaSourceProxy = ImageInfoProxy.mockImage.source,
thumbnailSource: MediaSourceProxy? = ImageInfoProxy.mockThumbnail.source,
blurhash: String? = "L%KUc%kqS$RP?Ks,WEf8OlrqaekW",
contentType: UTType? = .jpeg) -> GalleryItem {
let imageInfo = ImageInfoProxy(source: source, width: 1920, height: 1080, mimeType: nil, fileSize: nil)
let thumbnailInfo = thumbnailSource.map { ImageInfoProxy(source: $0, width: 1920, height: 1080, mimeType: nil, fileSize: nil) }
return .image(id: .mock(index),
.init(filename: filename, imageInfo: imageInfo, thumbnailInfo: thumbnailInfo, blurhash: blurhash, contentType: contentType))
}
static func mockVideo(index: Int = 0,
filename: String = "clip.mp4",
thumbnailSource: MediaSourceProxy? = ImageInfoProxy.mockVideoThumbnail.source,
duration: TimeInterval = 42,
blurhash: String? = "L%KUc%kqS$RP?Ks,WEf8OlrqaekW",
contentType: UTType? = .mpeg4Movie) -> GalleryItem {
let thumbnailInfo = thumbnailSource.map { ImageInfoProxy(source: $0, width: 1920, height: 1080, mimeType: nil, fileSize: nil) }
return .video(id: .mock(index),
.init(filename: filename, videoInfo: .mockVideo(duration: duration), thumbnailInfo: thumbnailInfo, blurhash: blurhash, contentType: contentType))
}
}