From 68a277143a01b116dc78c05e70529cce99e02e8a Mon Sep 17 00:00:00 2001 From: Mauro Romito Date: Wed, 29 Jul 2026 14:24:51 +0200 Subject: [PATCH] use the appropriate timeline kind (to be refactored) --- .../MediaEventsTimelineFlowCoordinator.swift | 4 +- .../MediaEventsTimelineScreenModels.swift | 2 +- .../MediaEventsTimelineScreenViewModel.swift | 73 +++++++++++++++++-- .../Timeline/Fixtures/TimelineFixtures.swift | 22 +++++- ...aEventsTimelineScreen.Files-iPad-en-GB.png | 4 +- ...EventsTimelineScreen.Files-iPad-pseudo.png | 4 +- ...ventsTimelineScreen.Files-iPhone-en-GB.png | 4 +- ...entsTimelineScreen.Files-iPhone-pseudo.png | 4 +- ...aEventsTimelineScreen.Media-iPad-en-GB.png | 4 +- ...EventsTimelineScreen.Media-iPad-pseudo.png | 4 +- ...ventsTimelineScreen.Media-iPhone-en-GB.png | 4 +- ...entsTimelineScreen.Media-iPhone-pseudo.png | 4 +- .../TimelineMediaPreviewDataSourceTests.swift | 1 + 13 files changed, 107 insertions(+), 27 deletions(-) diff --git a/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift index c79668c73..30d5ba242 100644 --- a/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift @@ -59,7 +59,7 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { stateEventStringBuilder: RoomStateEventStringBuilder(userID: userSession.clientProxy.userID)) guard case let .success(mediaTimelineController) = await flowParameters.timelineControllerFactory.buildMessageFilteredTimelineController(focus: .live, - allowedMessageTypes: [.image, .video], + allowedMessageTypes: [.image, .video, .gallery], presentation: .mediaFilesScreen, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, @@ -69,7 +69,7 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { } guard case let .success(filesTimelineController) = await flowParameters.timelineControllerFactory.buildMessageFilteredTimelineController(focus: .live, - allowedMessageTypes: [.file, .audio], + allowedMessageTypes: [.file, .audio, .gallery], presentation: .mediaFilesScreen, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift index 6528c3332..4292c160c 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift @@ -13,7 +13,7 @@ enum MediaEventsTimelineScreenViewModelAction { case viewInRoomTimeline(TimelineItemIdentifier) } -enum MediaEventsTimelineScreenMode { +nonisolated enum MediaEventsTimelineScreenMode { case media case files } diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift index c73d28a8e..5fb46aee1 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift @@ -20,6 +20,15 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType private var isOldestItemVisible = false + /// The gallery attachment that a listed item was built from, keyed by that item's unique ID. + /// Interactions are forwarded using the gallery's own identifier, which is the one the timeline knows. + private struct GalleryItemLocation { + let parentID: TimelineItemIdentifier + let mediaIndex: Int + } + + private var galleryItemLookup = [TimelineItemIdentifier.UniqueID: GalleryItemLocation]() + private var activeTimelineViewModel: TimelineViewModelProtocol { switch state.screenMode { case .media: @@ -123,9 +132,15 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType case .oldestItemDidDisappear: isOldestItemVisible = false case .tappedItem(let item): - activeTimelineViewModel.context.send(viewAction: .mediaTapped(itemID: item.identifier)) + if let location = galleryItemLookup[item.id] { + activeTimelineViewModel.context.send(viewAction: .galleryItemTapped(itemID: location.parentID, index: location.mediaIndex)) + } else { + activeTimelineViewModel.context.send(viewAction: .mediaTapped(itemID: item.identifier)) + } case .longPressedItem(let item): - activeTimelineViewModel.context.send(viewAction: .displayTimelineItemMenu(itemID: item.identifier)) + // The menu acts on the gallery as a whole, as its actions can't apply to a single attachment. + let itemID = galleryItemLookup[item.id]?.parentID ?? item.identifier + activeTimelineViewModel.context.send(viewAction: .displayTimelineItemMenu(itemID: itemID)) } } @@ -177,17 +192,25 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType private func updateWithTimelineViewState(_ timelineViewState: TimelineViewState) { var newGroups = [MediaEventsTimelineGroup]() var currentItems = [RoomTimelineItemViewState]() + var newGalleryItemLookup = [TimelineItemIdentifier.UniqueID: GalleryItemLocation]() - timelineViewState.timelineState.itemViewStates.filter { itemViewState in + timelineViewState.timelineState.itemViewStates.flatMap { itemViewState -> [RoomTimelineItemViewState] in switch itemViewState.type { case .image, .video: - state.screenMode == .media + return state.screenMode == .media ? [itemViewState] : [] case .audio, .file, .voice: - state.screenMode == .files + return state.screenMode == .files ? [itemViewState] : [] case .separator: - true + return [itemViewState] + case .gallery(let galleryItem): + // A gallery's attachments are displayed flattened, listed individually. + let flattenedItems = galleryItem.itemsAsIndividualMessages(for: state.screenMode) + for (mediaIndex, item) in flattenedItems { + newGalleryItemLookup[item.id.uniqueID] = .init(parentID: galleryItem.id, mediaIndex: mediaIndex) + } + return flattenedItems.map { .init(item: $0.item, groupStyle: .single) } default: - false + return [] } }.reversed().forEach { item in if case .separator(let item) = item.type { @@ -212,6 +235,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType } state.groups = newGroups + galleryItemLookup = newGalleryItemLookup state.isBackPaginating = timelineViewState.timelineState.paginationState.backward == .paginating state.shouldShowEmptyState = newGroups.isEmpty && timelineViewState.timelineState.paginationState.backward == .endReached @@ -259,3 +283,38 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType } } } + +private extension GalleryRoomTimelineItem { + /// Represents the gallery's attachments as though each had been sent as an individual message, so + /// that the screen can list them alongside the room's other media, keeping only those that belong + /// in the given mode. Each one keeps the gallery's event ID but is given its own unique ID so that + /// the attachments remain distinct within the list. + func itemsAsIndividualMessages(for screenMode: MediaEventsTimelineScreenMode) -> [(mediaIndex: Int, item: EventBasedMessageTimelineItemProtocol)] { + guard let eventOrTransactionID = id.eventOrTransactionID else { return [] } + + return content.items.enumerated().compactMap { mediaIndex, galleryItem in + let itemID = TimelineItemIdentifier.event(uniqueID: .init("\(id.uniqueID.value)-\(mediaIndex)"), + eventOrTransactionID: eventOrTransactionID) + + let item: EventBasedMessageTimelineItemProtocol? = switch galleryItem { + case .image(_, let content) where screenMode == .media: + ImageRoomTimelineItem(id: itemID, timestamp: timestamp, isOutgoing: isOutgoing, isEditable: isEditable, + canBeRepliedTo: canBeRepliedTo, sender: sender, content: content, properties: properties) + case .video(_, let content) where screenMode == .media: + VideoRoomTimelineItem(id: itemID, timestamp: timestamp, isOutgoing: isOutgoing, isEditable: isEditable, + canBeRepliedTo: canBeRepliedTo, sender: sender, content: content, properties: properties) + case .audio(_, let content) where screenMode == .files: + AudioRoomTimelineItem(id: itemID, timestamp: timestamp, isOutgoing: isOutgoing, isEditable: isEditable, + canBeRepliedTo: canBeRepliedTo, sender: sender, content: content, properties: properties) + case .file(_, let content) where screenMode == .files: + FileRoomTimelineItem(id: itemID, timestamp: timestamp, isOutgoing: isOutgoing, isEditable: isEditable, + canBeRepliedTo: canBeRepliedTo, sender: sender, content: content, properties: properties) + default: + nil // Attachments of an unknown type, or that belong in the other mode. + } + + guard let item else { return nil } + return (mediaIndex, item) + } + } +} diff --git a/ElementX/Sources/Services/Timeline/Fixtures/TimelineFixtures.swift b/ElementX/Sources/Services/Timeline/Fixtures/TimelineFixtures.swift index c862718dc..c091efcdb 100644 --- a/ElementX/Sources/Services/Timeline/Fixtures/TimelineFixtures.swift +++ b/ElementX/Sources/Services/Timeline/Fixtures/TimelineFixtures.swift @@ -165,7 +165,15 @@ enum TimelineFixtures { VideoRoomTimelineItem(isOutgoing: false, caption: "Woah, it was incredible!"), VideoRoomTimelineItem(isOutgoing: true), VoiceMessageRoomTimelineItem(isOutgoing: false), - VoiceMessageRoomTimelineItem(isOutgoing: true) + VoiceMessageRoomTimelineItem(isOutgoing: true), + GalleryRoomTimelineItem(isOutgoing: false, + items: [.mockImage(index: 0), .mockVideo(index: 1), .mockImage(index: 2)]), + GalleryRoomTimelineItem(isOutgoing: true, + items: [.mockImage(index: 0), + .mockVideo(index: 1), + .mockFile(index: 2, source: try? .init(url: .mockMXCFile, mimeType: nil)), + .mockAudio(index: 3, source: try? .init(url: .mockMXCAudio, mimeType: nil))], + caption: "Everything from the trip!") ] } @@ -358,6 +366,18 @@ private extension FileRoomTimelineItem { } } +private extension GalleryRoomTimelineItem { + init(isOutgoing: Bool, items: [GalleryItem], caption: String? = nil) { + self.init(id: .randomEvent, + timestamp: .mock, + isOutgoing: isOutgoing, + isEditable: isOutgoing, + canBeRepliedTo: true, + sender: .init(id: isOutgoing ? "@alice:matrix.org" : "@bob:matrix.org"), + content: .init(body: "Gallery", caption: caption, items: items)) + } +} + private extension ImageRoomTimelineItem { init(isOutgoing: Bool, caption: String? = nil) { self.init(id: .randomEvent, diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPad-en-GB.png index 1c04846ca..b15c090bf 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:356f63709601d46ee1668e18dd2409233f7be96b9a88f7d9a18b0a9cab2e8469 -size 156418 +oid sha256:1ec3f3c099e9ad530b71aeba2715f76f45ff1b4ed7c2459df3da5c49f2e09400 +size 159454 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPad-pseudo.png index a55a769f9..0686f3552 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:746eed66f8225346510d36ba0a7b4a2fa7b6a11758dc6cd4cdbef6662ce2f2b3 -size 164016 +oid sha256:eee23832fec2633137eb1c9d581f7c14ae5f8c1a179d8f265bfa68793ee219b3 +size 167421 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPhone-en-GB.png index a39a775ff..46223760a 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a38781ee98ea6056d3b5514f82f37f14da5bedeb914a8b065806f5a7ee95cfd -size 100047 +oid sha256:522ceeb4fd72b4dcee8889505a83e193c6c5cd0e334ed1a11062c323d6a0bd66 +size 101582 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPhone-pseudo.png index c0efec7f4..5630000d9 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:11c5f04a623ec21cf870c6101313d24f749f535b55d4f8221413fdb407b6405c -size 108263 +oid sha256:491099140a46ab7c3a3fbd9623226202c6accdc1952a4b690941b7d06f20ba91 +size 109177 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPad-en-GB.png index 96ce3130a..ce1bac3cb 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8570e21cc95a6121da7f1805c78dedd78ea9df082f60c0561bf767d0f4a0bfa3 -size 746399 +oid sha256:46ad69d119cecd7c343cd6b108a9c7839d1aa7ec743d4e1122d51e2fb2cf8e35 +size 1089783 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPad-pseudo.png index b8f367662..a9b9aa30c 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:884c2061bab04d274b44296c24118b634be4c52c47c099b474870df565109a54 -size 751705 +oid sha256:f7310ef85ba8ddc2ff56541ff60dca46fd765f996b11bd55206202f4043ea124 +size 1095236 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPhone-en-GB.png index 5541e28cb..c4307e803 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b1fe7781fd4623024860b9d98e7f1810a8f0ac9ae3bb58a53a76352029016f67 -size 822595 +oid sha256:9b4a12cc3b586ca0812f786a851243d4ac975f939f0109a29a49cfc99805d6b1 +size 1293697 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPhone-pseudo.png index e57512734..fbdf63524 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f918cd8bc7462364c993b55e3af42c305b01e94e27a4edd44ec0e44acbf8e19c -size 827220 +oid sha256:56b812e3d7c7af233cdcf9a4b0cd9de29cc826d94ed416b678f39a4038431f97 +size 1295622 diff --git a/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift b/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift index 131b60028..6a1871691 100644 --- a/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift +++ b/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift @@ -340,6 +340,7 @@ struct TimelineMediaPreviewDataSourceTests { TimelineFixtures.mediaChunk .compactMap { $0 as? EventBasedMessageTimelineItemProtocol } .filter(\.supportsMediaCaption) // Voice messages can't be previewed (and don't support captions). + .filter { !($0 is GalleryRoomTimelineItem) } // Galleries are previewed through their own data source. } @discardableResult