use the appropriate timeline kind (to be refactored)

This commit is contained in:
Mauro Romito
2026-07-29 14:24:51 +02:00
committed by Mauro
parent d8cb49e892
commit 68a277143a
13 changed files with 107 additions and 27 deletions
@@ -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,
@@ -13,7 +13,7 @@ enum MediaEventsTimelineScreenViewModelAction {
case viewInRoomTimeline(TimelineItemIdentifier)
}
enum MediaEventsTimelineScreenMode {
nonisolated enum MediaEventsTimelineScreenMode {
case media
case files
}
@@ -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)
}
}
}
@@ -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,
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:356f63709601d46ee1668e18dd2409233f7be96b9a88f7d9a18b0a9cab2e8469
size 156418
oid sha256:1ec3f3c099e9ad530b71aeba2715f76f45ff1b4ed7c2459df3da5c49f2e09400
size 159454
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:746eed66f8225346510d36ba0a7b4a2fa7b6a11758dc6cd4cdbef6662ce2f2b3
size 164016
oid sha256:eee23832fec2633137eb1c9d581f7c14ae5f8c1a179d8f265bfa68793ee219b3
size 167421
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4a38781ee98ea6056d3b5514f82f37f14da5bedeb914a8b065806f5a7ee95cfd
size 100047
oid sha256:522ceeb4fd72b4dcee8889505a83e193c6c5cd0e334ed1a11062c323d6a0bd66
size 101582
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:11c5f04a623ec21cf870c6101313d24f749f535b55d4f8221413fdb407b6405c
size 108263
oid sha256:491099140a46ab7c3a3fbd9623226202c6accdc1952a4b690941b7d06f20ba91
size 109177
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8570e21cc95a6121da7f1805c78dedd78ea9df082f60c0561bf767d0f4a0bfa3
size 746399
oid sha256:46ad69d119cecd7c343cd6b108a9c7839d1aa7ec743d4e1122d51e2fb2cf8e35
size 1089783
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:884c2061bab04d274b44296c24118b634be4c52c47c099b474870df565109a54
size 751705
oid sha256:f7310ef85ba8ddc2ff56541ff60dca46fd765f996b11bd55206202f4043ea124
size 1095236
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b1fe7781fd4623024860b9d98e7f1810a8f0ac9ae3bb58a53a76352029016f67
size 822595
oid sha256:9b4a12cc3b586ca0812f786a851243d4ac975f939f0109a29a49cfc99805d6b1
size 1293697
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f918cd8bc7462364c993b55e3af42c305b01e94e27a4edd44ec0e44acbf8e19c
size 827220
oid sha256:56b812e3d7c7af233cdcf9a4b0cd9de29cc826d94ed416b678f39a4038431f97
size 1295622
@@ -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