mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
185 lines
8.9 KiB
Swift
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())
|
|
}
|
|
}
|