Files
element-x-ios/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryRoomTimelineView.swift
2026-08-03 14:33:12 +02:00

185 lines
8.9 KiB
Swift

//
// 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 GalleryRoomTimelineView: View {
@Environment(\.timelineContext) private var context
let timelineItem: GalleryRoomTimelineItem
private var hasMediaCaption: Bool {
timelineItem.content.caption?.isBlank == false
}
// List layout when any item is non-visual (file/audio); a grid reads badly for documents.
private var usesListLayout: Bool {
timelineItem.content.items.contains { !$0.isImage && !$0.isVideo }
}
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
VStack(alignment: .leading, spacing: 8) {
if usesListLayout {
GalleryListView(items: timelineItem.content.items,
mediaProvider: context?.mediaProvider,
contentScannerService: context?.contentScannerService,
onItemTap: tap)
} else {
GalleryGridView(items: timelineItem.content.items,
mediaProvider: context?.mediaProvider,
contentScannerService: context?.contentScannerService,
onItemTap: tap)
}
if hasMediaCaption {
if usesListLayout {
GalleryDivider()
}
caption
.accessibilityHidden(true) // Announced when entering the gallery, so not a stop of its own.
}
}
.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)
}
}
private func tap(index: Int) {
context?.send(viewAction: .galleryItemTapped(.init(timelineItemID: timelineItem.id, mediaIndex: index)))
}
}
// 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
switch source.url {
case .mockMXCUnsafe: false
case .mockMXCScanning: nil
default: 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")
makeView(makeFileListItem(caption: "Quarterly reports"), viewModel).previewDisplayName("File list")
makeView(makeMixedScanFileListItem(), mixedScanViewModel).previewDisplayName("Mixed content scan (files)")
}
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())
}
private static func makeFileListItem(caption: String?) -> GalleryRoomTimelineItem {
let items: [GalleryItem] = [
.mockImage(index: 0, filename: "photo.jpg"),
.mockFile(index: 1, filename: "report.pdf"),
.mockAudio(index: 2, filename: "meeting.m4a")
]
return GalleryRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "Bob"),
content: .init(body: "Mixed gallery",
caption: caption,
items: items),
properties: .init())
}
private static func makeMixedScanFileListItem() -> GalleryRoomTimelineItem {
// Safe/unsafe/scanning rows via the mixed-scan sources.
let items: [GalleryItem] = [
.mockFile(index: 0, filename: "report.zip", source: safeSource, contentType: .zip),
.mockFile(index: 1, filename: "contract.pdf", source: unsafeSource),
.mockAudio(index: 2, filename: "meeting.m4a", source: scanningSource)
]
return GalleryRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "Bob"),
content: .init(body: "Files",
caption: "Mixed scan states",
items: items),
properties: .init())
}
}