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
@@ -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())
}
}