Allow previews in the timeline to include filtered galleries (#5943)

* remove the usage of the timeline kind override in favour of getting it properly from the timeline view model

* allow the previews to include gallery items filtered by the allowed types when started outside the gallery

sss

asa

* fix for actions not being forwarded to the timeline right

* pr suggestions
This commit is contained in:
Mauro
2026-07-30 13:49:22 +02:00
committed by GitHub
parent 33f8ce2759
commit ae795df530
17 changed files with 243 additions and 110 deletions
@@ -107,6 +107,14 @@ class TimelineInteractionHandler {
// swiftlint:disable:next cyclomatic_complexity
func handleTimelineItemMenuAction(_ action: TimelineItemMenuAction, itemID: TimelineItemIdentifier) {
// Redacting needs the event alone, so it works even when the item isn't part of this timeline,
// such as one held by a media preview that was built from a different one.
if case .redact = action {
guard case let .event(_, eventOrTransactionID) = itemID else { fatalError() }
Task { await timelineController.redact(eventOrTransactionID) }
return
}
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID),
let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
return
@@ -156,8 +164,7 @@ class TimelineInteractionHandler {
UIPasteboard.general.url = permalinkURL
}
case .redact:
guard case let .event(_, eventOrTransactionID) = itemID else { fatalError() }
Task { await timelineController.redact(eventOrTransactionID) }
break // Handled above, before the timeline item is looked up.
case .reply:
guard let eventID = eventTimelineItem.id.eventID else { return }
@@ -560,29 +567,23 @@ class TimelineInteractionHandler {
timestamp: item.timestamp,
timeoutDate: item.content.timeoutDate)
return .displayLiveLocation(sender: item.sender, initialLiveLocationShare: initialLiveLocationShare)
// Galleries are included so that their attachments can be browsed as individual media.
case let item as ImageRoomTimelineItem:
return await mediaPreviewAction(for: item, messageTypes: [.image, .video])
return await mediaPreviewAction(for: item, messageTypes: [.image, .video, .gallery])
case let item as VideoRoomTimelineItem:
return await mediaPreviewAction(for: item, messageTypes: [.image, .video])
return await mediaPreviewAction(for: item, messageTypes: [.image, .video, .gallery])
case let item as AudioRoomTimelineItem:
return await mediaPreviewAction(for: item, messageTypes: [.audio, .file])
return await mediaPreviewAction(for: item, messageTypes: [.audio, .file, .gallery])
case let item as FileRoomTimelineItem:
return await mediaPreviewAction(for: item, messageTypes: [.audio, .file])
return await mediaPreviewAction(for: item, messageTypes: [.audio, .file, .gallery])
case let item as GalleryRoomTimelineItem:
// Only galleries are needed as the preview is scoped to the attachments of the tapped one.
return await mediaPreviewAction(for: item, messageTypes: [.gallery])
default:
return .none
}
}
/// Opens a media preview scoped to a single gallery's attachments. The preview pages
/// only between the items of that one gallery — siblings in the wider timeline aren't
/// reachable from this entry point (use the regular media tap for that).
func processGalleryItemTap(itemID: TimelineItemIdentifier, index: Int) -> TimelineControllerAction {
guard let galleryItem = timelineController.timelineItems.firstUsingStableID(itemID) as? GalleryRoomTimelineItem else {
return .none
}
return .displayGalleryPreview(galleryItem: galleryItem, initialIndex: index)
}
// MARK: - Private
private func buildReplyInfo(for item: EventBasedTimelineItemProtocol) -> ReplyInfo {
@@ -613,7 +614,10 @@ class TimelineInteractionHandler {
case .pinned:
newTimelineFocus = .pinned
newTimelinePresentation = .pinnedEventsScreen
case .media, .thread:
case .thread(let rootEventID):
newTimelineFocus = .thread(eventID: rootEventID)
newTimelinePresentation = .roomScreenThread
case .media:
break // We don't need to create a new timeline as it is already filtered.
}
@@ -644,11 +648,21 @@ class TimelineInteractionHandler {
linkMetadataProvider: linkMetadataProvider,
timelineControllerFactory: timelineControllerFactory)
return .displayMediaPreview(item: item, timelineViewModel: .new(timelineViewModel))
return previewAction(for: item, timelineViewModel: .new(timelineViewModel))
} else {
return .displayMediaPreview(item: item, timelineViewModel: .active)
return previewAction(for: item, timelineViewModel: .active)
}
}
/// A gallery is previewed scoped to its own attachments rather than the wider timeline's media.
private func previewAction(for item: EventBasedMessageTimelineItemProtocol,
timelineViewModel: TimelineControllerAction.TimelineViewModelKind) -> TimelineControllerAction {
guard let galleryItem = item as? GalleryRoomTimelineItem else {
return .displayMediaPreview(item: item, timelineViewModel: timelineViewModel)
}
return .displayGalleryPreview(galleryItem: galleryItem, timelineViewModel: timelineViewModel)
}
}
private struct ReplyInfo {
@@ -104,6 +104,8 @@ enum TimelineComposerAction {
struct TimelineViewState: BindableState {
let timelineKind: TimelineKind
/// The gallery attachments the timeline includes, or `nil` when it isn't filtered.
let allowedGalleryItemTypes: [TimelineAllowedGalleryItemType]?
var roomID: String
var members: [String: RoomMemberState] = [:]
var typingMembers: [String] = []
@@ -92,6 +92,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
true
}
super.init(initialViewState: TimelineViewState(timelineKind: timelineController.timelineKind,
allowedGalleryItemTypes: timelineController.allowedGalleryItemTypes,
roomID: roomProxy.id,
isDM: roomProxy.infoPublisher.value.isDM,
timelineState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }),
@@ -160,7 +161,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
case .mediaTapped(let id):
Task { await handleMediaTapped(with: id) }
case .galleryItemTapped(let id, let index):
handleGalleryItemTapped(itemID: id, index: index)
Task { await handleMediaTapped(with: id, galleryIndex: index) }
case .itemSendInfoTapped(let itemID):
handleItemSendInfoTapped(itemID: itemID)
case .toggleReaction(let emoji, let itemID):
@@ -700,7 +701,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
await timelineController.sendReadReceipt(for: lastVisibleItemID)
}
private func handleMediaTapped(with itemID: TimelineItemIdentifier) async {
private func handleMediaTapped(with itemID: TimelineItemIdentifier, galleryIndex: Int? = nil) async {
state.showLoading = true
let action = await timelineInteractionHandler.processItemTap(itemID)
@@ -710,8 +711,13 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
let mediaPreviewViewModel = makeMediaPreviewViewModel(item: item, timelineViewModelKind: timelineViewModelKind)
actionsSubject.send(.displayMediaPreview(mediaPreviewViewModel))
case .displayGalleryPreview:
fatalError("Galleries open through the dedicated galleryItemTapped path, which carries the tapped index.")
case .displayGalleryPreview(let galleryItem, let timelineViewModelKind):
actionsSubject.send(.composer(action: .removeFocus))
let mediaPreviewViewModel = makeGalleryPreviewViewModel(galleryItem: galleryItem,
timelineViewModelKind: timelineViewModelKind,
initialIndex: galleryIndex ?? 0)
actionsSubject.send(.displayMediaPreview(mediaPreviewViewModel))
case .displayLocation(let location):
actionsSubject.send(.displayLocation(location))
case .displayLiveLocation(let sender, let initialLiveLocationShare):
@@ -722,18 +728,6 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
state.showLoading = false
}
private func handleGalleryItemTapped(itemID: TimelineItemIdentifier, index: Int) {
if case let .displayGalleryPreview(galleryItem, initialIndex) = timelineInteractionHandler.processGalleryItemTap(itemID: itemID, index: index) {
displayGalleryPreview(galleryItem: galleryItem, initialIndex: initialIndex)
}
}
private func displayGalleryPreview(galleryItem: GalleryRoomTimelineItem, initialIndex: Int) {
actionsSubject.send(.composer(action: .removeFocus))
let mediaPreviewViewModel = makeGalleryPreviewViewModel(galleryItem: galleryItem, initialIndex: initialIndex)
actionsSubject.send(.displayMediaPreview(mediaPreviewViewModel))
}
private func handleItemSendInfoTapped(itemID: TimelineItemIdentifier) {
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) else {
MXLog.warning("Couldn't find timeline item.")
@@ -836,30 +830,33 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
private func makeMediaPreviewViewModel(item: EventBasedMessageTimelineItemProtocol,
timelineViewModelKind: TimelineControllerAction.TimelineViewModelKind) -> TimelineMediaPreviewViewModel {
let timelineViewModel = switch timelineViewModelKind {
case .active: self
case .new(let newViewModel): newViewModel
}
return TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: timelineViewModel,
mediaProvider: userSession.mediaProvider,
photoLibraryManager: PhotoLibraryManager(),
userIndicatorController: userIndicatorController,
appMediator: appMediator)
}
private func makeGalleryPreviewViewModel(galleryItem: GalleryRoomTimelineItem,
initialIndex: Int) -> TimelineMediaPreviewViewModel {
TimelineMediaPreviewViewModel(galleryItem: galleryItem,
initialIndex: initialIndex,
timelineViewModel: self,
TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: timelineViewModel(for: timelineViewModelKind),
mediaProvider: userSession.mediaProvider,
photoLibraryManager: PhotoLibraryManager(),
userIndicatorController: userIndicatorController,
appMediator: appMediator)
}
private func makeGalleryPreviewViewModel(galleryItem: GalleryRoomTimelineItem,
timelineViewModelKind: TimelineControllerAction.TimelineViewModelKind,
initialIndex: Int) -> TimelineMediaPreviewViewModel {
TimelineMediaPreviewViewModel(galleryItem: galleryItem,
initialIndex: initialIndex,
timelineViewModel: timelineViewModel(for: timelineViewModelKind),
mediaProvider: userSession.mediaProvider,
photoLibraryManager: PhotoLibraryManager(),
userIndicatorController: userIndicatorController,
appMediator: appMediator)
}
private func timelineViewModel(for kind: TimelineControllerAction.TimelineViewModelKind) -> TimelineViewModel {
switch kind {
case .active: self
case .new(let newViewModel): newViewModel
}
}
// MARK: - Timeline Item Building
private func buildTimelineViews(timelineItems: [RoomTimelineItemProtocol], isSwitchingTimelines: Bool = false) {
@@ -52,7 +52,7 @@ struct GalleryItemTileView: View {
private var content: some View {
if let source = item.thumbnailSource ?? item.mediaSource {
LoadableImage(mediaSource: source,
mediaType: .timelineItem(uniqueID: item.id.timelineItemUniqueID),
mediaType: .timelineItem(uniqueID: item.id.timelineItemID.uniqueID),
blurhash: item.blurhash,
size: item.size,
mediaProvider: mediaProvider) { imageView in
@@ -110,7 +110,7 @@ private struct GalleryListRow: View {
private var icon: some View {
if item.hasThumbnail, let source = item.thumbnailSource ?? item.mediaSource {
LoadableImage(mediaSource: source,
mediaType: .timelineItem(uniqueID: item.id.timelineItemUniqueID),
mediaType: .timelineItem(uniqueID: item.id.timelineItemID.uniqueID),
blurhash: item.blurhash,
size: item.size,
mediaProvider: mediaProvider) { imageView in