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
@@ -35,13 +35,19 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
var paginationState: TimelinePaginationState
/// The gallery attachments to include, so that a gallery only contributes the media being browsed.
private let allowedGalleryItemTypes: [TimelineAllowedGalleryItemType]?
/// Builds a data source spanning every previewable attachment in the timeline, paginating
/// as the user swipes past the loaded range. Used when tapping a standalone media message.
init(itemViewStates: [RoomTimelineItemViewState],
initialItem: EventBasedMessageTimelineItemProtocol,
initialPadding: Int = 100,
paginationState: TimelinePaginationState) {
previewItems = itemViewStates.compactMap(TimelineMediaPreviewItem.Media.init)
paginationState: TimelinePaginationState,
allowedGalleryItemTypes: [TimelineAllowedGalleryItemType]? = nil) {
self.allowedGalleryItemTypes = allowedGalleryItemTypes
previewItems = itemViewStates.flatMap { $0.previewableMedia(allowedGalleryItemTypes: allowedGalleryItemTypes) }
let initialPreviewID = TimelineMediaPreviewItem.Media(timelineItem: initialItem).id
if let initialItemArrayIndex = previewItems.firstIndex(where: { $0.id == initialPreviewID }) {
@@ -66,6 +72,8 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
/// gallery and there's no timeline pagination to drive.
init(galleryItem: GalleryRoomTimelineItem,
initialIndex: Int) {
allowedGalleryItemTypes = nil // All of the gallery's attachments are shown.
let media = galleryItem.content.items.map { item in
TimelineMediaPreviewItem.Media(galleryParent: galleryItem, item: item)
}
@@ -93,17 +101,17 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
}
func updatePreviewItems(itemViewStates: [RoomTimelineItemViewState]) {
let newItems: [TimelineMediaPreviewItem.Media] = itemViewStates.compactMap { itemViewState in
guard let newItem = TimelineMediaPreviewItem.Media(roomTimelineItemViewState: itemViewState) else { return nil }
// If an item already exists use that instead to preserve the file handle, download error etc.
if let oldItem = previewItems.first(where: { $0.id == newItem.id }) {
oldItem.content = newItem.content
return oldItem
let newItems: [TimelineMediaPreviewItem.Media] = itemViewStates
.flatMap { $0.previewableMedia(allowedGalleryItemTypes: allowedGalleryItemTypes) }
.map { newItem in
// If an item already exists use that instead to preserve the file handle, download error etc.
if let oldItem = previewItems.first(where: { $0.id == newItem.id }) {
oldItem.content = newItem.content
return oldItem
}
return newItem
}
return newItem
}
var hasPaginated = false
if let range = newItems.map(\.id).firstRange(of: previewItems.map(\.id)) {
@@ -430,3 +438,23 @@ enum TimelineMediaPreviewItem: Equatable {
}
}
}
private extension RoomTimelineItemViewState {
/// The media of this item that can be previewed, flattening a gallery message into its individual
/// attachments so that they can be browsed alongside the timeline's other media.
/// - Parameter allowedGalleryItemTypes: Restricts a gallery's attachments to the media being
/// browsed, as the SDK can only filter the gallery event as a whole.
func previewableMedia(allowedGalleryItemTypes: [TimelineAllowedGalleryItemType]?) -> [TimelineMediaPreviewItem.Media] {
guard case .gallery(let galleryItem) = type else {
return [TimelineMediaPreviewItem.Media(roomTimelineItemViewState: self)].compactMap { $0 }
}
return galleryItem.content.items.filter { item in
guard let allowedItemType = item.allowedItemType else {
return false // Nothing to preview, and unlike a tile there's no index to line up with.
}
return allowedGalleryItemTypes?.contains(allowedItemType) ?? true
}.map { TimelineMediaPreviewItem.Media(galleryParent: galleryItem, item: $0) }
}
}
@@ -22,10 +22,6 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
private let userIndicatorController: UserIndicatorControllerProtocol
private let appMediator: AppMediatorProtocol
/// Overrides the timeline kind used to build the context menu. Gallery previews use `.media` so the
/// standard download/forward/delete actions appear, since their underlying room timeline is `.live`.
private let menuTimelineKind: TimelineKind?
private var contentScannerService: ContentScannerServiceProtocol? {
timelineViewModel.context.contentScannerService
}
@@ -47,13 +43,13 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
self.photoLibraryManager = photoLibraryManager
self.userIndicatorController = userIndicatorController
self.appMediator = appMediator
menuTimelineKind = nil
let timelineState = timelineViewModel.context.viewState.timelineState
super.init(initialViewState: TimelineMediaPreviewViewState(dataSource: .init(itemViewStates: timelineState.itemViewStates,
initialItem: initialItem,
paginationState: timelineState.paginationState)),
paginationState: timelineState.paginationState,
allowedGalleryItemTypes: timelineViewModel.context.viewState.allowedGalleryItemTypes)),
mediaProvider: mediaProvider)
rebuildCurrentItemActions()
@@ -99,7 +95,6 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
self.photoLibraryManager = photoLibraryManager
self.userIndicatorController = userIndicatorController
self.appMediator = appMediator
menuTimelineKind = .media(.roomScreenLive)
super.init(initialViewState: TimelineMediaPreviewViewState(dataSource: .init(galleryItem: galleryItem,
initialIndex: initialIndex)),
@@ -236,7 +231,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
pinnedEventIDs: timelineContext.viewState.pinnedEventIDs,
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled,
areThreadsEnabled: timelineContext.viewState.areThreadsEnabled,
timelineKind: menuTimelineKind ?? timelineContext.viewState.timelineKind,
timelineKind: timelineContext.viewState.timelineKind,
emojiProvider: timelineContext.viewState.emojiProvider)
.makeActions()
}
@@ -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