Open a full-screen preview when tapping a gallery item

Tapping a gallery tile/row opens the existing media preview scoped to that gallery's attachments, paging between them. Introduces a typed MediaPreviewItemID (replacing the stringly-typed preview identifier) and a gallery-scoped TimelineMediaPreviewDataSource; the tap routes through TimelineInteractionHandler/TimelineViewModel. Non-previewable (.other) items are skipped without shifting the tapped index.

Co-authored-by: svetoslav.mitov <svetoslav.mitov@sportinno.com>

just use id instead of previewID

removed horrible multiline ternary

pr suggestions

remove redundant code

remove empty caption display + gallery uses the appropriate media timeline actions
This commit is contained in:
Mauro Romito
2026-07-27 19:14:23 +02:00
committed by Mauro
parent b4f397ed6a
commit 7ea9aeb174
15 changed files with 343 additions and 112 deletions
@@ -22,6 +22,10 @@ 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
}
@@ -31,6 +35,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
actionsSubject.eraseToAnyPublisher()
}
/// Initialises a preview spanning the whole timeline's media, staying in sync with it as it paginates.
init(initialItem: EventBasedMessageTimelineItemProtocol,
timelineViewModel: TimelineViewModelProtocol,
mediaProvider: MediaProviderProtocol,
@@ -42,6 +47,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
self.photoLibraryManager = photoLibraryManager
self.userIndicatorController = userIndicatorController
self.appMediator = appMediator
menuTimelineKind = nil
let timelineState = timelineViewModel.context.viewState.timelineState
@@ -78,6 +84,35 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
.store(in: &cancellables)
}
/// Initialises the preview scoped to a single gallery's attachments. The data source is
/// built from the gallery's items directly and isn't kept in sync with the underlying
/// timeline — gallery contents don't change without the event being replaced or redacted.
init(galleryItem: GalleryRoomTimelineItem,
initialIndex: Int,
timelineViewModel: TimelineViewModelProtocol,
mediaProvider: MediaProviderProtocol,
photoLibraryManager: PhotoLibraryManagerProtocol,
userIndicatorController: UserIndicatorControllerProtocol,
appMediator: AppMediatorProtocol) {
self.timelineViewModel = timelineViewModel
self.mediaProvider = mediaProvider
self.photoLibraryManager = photoLibraryManager
self.userIndicatorController = userIndicatorController
self.appMediator = appMediator
menuTimelineKind = .media(.roomScreenLive)
super.init(initialViewState: TimelineMediaPreviewViewState(dataSource: .init(galleryItem: galleryItem,
initialIndex: initialIndex)),
mediaProvider: mediaProvider)
rebuildCurrentItemActions()
timelineViewModel.context.$viewState.map(\.canCurrentUserRedactSelf)
.merge(with: timelineViewModel.context.$viewState.map(\.canCurrentUserRedactOthers))
.sink { [weak self] _ in self?.rebuildCurrentItemActions() }
.store(in: &cancellables)
}
override func process(viewAction: TimelineMediaPreviewViewAction) {
switch viewAction {
case .updateCurrentItem(let item):
@@ -201,7 +236,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
pinnedEventIDs: timelineContext.viewState.pinnedEventIDs,
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled,
areThreadsEnabled: timelineContext.viewState.areThreadsEnabled,
timelineKind: timelineContext.viewState.timelineKind,
timelineKind: menuTimelineKind ?? timelineContext.viewState.timelineKind,
emojiProvider: timelineContext.viewState.emojiProvider)
.makeActions()
}
@@ -217,16 +252,14 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
state.previewControllerDriver.send(.dismissDetailsSheet)
do {
switch mediaItem.timelineItem {
case is AudioRoomTimelineItem, is FileRoomTimelineItem:
switch mediaItem.kind {
case .file:
state.previewControllerDriver.send(.exportFile(.init(url: fileURL)))
return // Don't show the indicator.
case is ImageRoomTimelineItem:
case .image:
try await photoLibraryManager.addResource(.photo, at: fileURL).get()
case is VideoRoomTimelineItem:
case .video:
try await photoLibraryManager.addResource(.video, at: fileURL).get()
default:
break
}
showSavedIndicator()