Use the new preview screen when tapping media on the room and pinned events screens. (#3736)

* Use the new TimelineMediaPreview modifier on the room and pinned timeline screens.

* Use the same presentation logic for all timeline media previews.

* Fix a bug with the detection of the timeline end.

* Send pagination requests from the media preview screen.

* Add SwiftLint to the Danger workflow (it is no longer installed on the runner).

* Put SwiftLint back on all of the GitHub runners too.

* Set the function_parameter_count lint rule to 10.

* Make sure to clean-up any previews when the coordinator is done.

* Handle the viewInRoomTimeline action more appropriately.
This commit is contained in:
Doug
2025-02-05 13:27:23 +00:00
committed by GitHub
parent 921d1c627d
commit cfaa1b455a
45 changed files with 490 additions and 202 deletions
@@ -41,9 +41,16 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
previewItems = itemViewStates.compactMap(TimelineMediaPreviewItem.Media.init)
self.initialItem = initialItem
let initialItemArrayIndex = previewItems.firstIndex { $0.id == initialItem.id } ?? 0
initialItemIndex = initialItemArrayIndex + initialPadding
currentItem = .media(previewItems[initialItemArrayIndex])
if let initialItemArrayIndex = previewItems.firstIndex(where: { $0.id == initialItem.id.eventOrTransactionID }) {
initialItemIndex = initialItemArrayIndex + initialPadding
currentItem = .media(previewItems[initialItemArrayIndex])
} else {
// The timeline hasn't loaded the initial item yet, so replace the whatever was loaded with
// the item the user wants to preview.
initialItemIndex = initialPadding
previewItems = [.init(timelineItem: initialItem)]
currentItem = .media(previewItems[0])
}
backwardPadding = initialPadding
forwardPadding = initialPadding
@@ -83,10 +90,18 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
hasPaginated = true
}
} else {
// Do nothing! Not ideal but if we reload the data source the current item will
// also be, reloaded resetting any interaction the user has made with it. If we
// ignore the pagination, then the next time they swipe they'll land on a different
// When the timeline is loading items from the store and the initial item is the only
// preview in the array, we don't want to wipe it out, so if the existing items aren't
// found within the new items then let's ignore the update for now. This comes with a
// tradeoff that when a media gets redacted, no more previews will be added to the viewer.
//
// Note for the future if anyone wants to fix the redaction issue: Reloading the data source,
// will also reload the current item resetting any interaction the user has made with it.
// If you ignore the pagination, then the next time they swipe they'll land on a different
// media but this is probably less jarring overall. I hate QLPreviewController!
MXLog.info("Ignoring update: unable to find existing preview items range.")
return
}
previewItems = newItems
@@ -109,9 +124,9 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
let arrayIndex = index - backwardPadding
if index < firstPreviewItemIndex {
return paginationState.backward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineStart : .paginating
return paginationState.backward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineStart : .paginatingBackwards
} else if index > lastPreviewItemIndex {
return paginationState.forward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineEnd : .paginating
return paginationState.forward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineEnd : .paginatingForwards
} else {
return previewItems[arrayIndex]
}
@@ -151,7 +166,15 @@ enum TimelineMediaPreviewItem: Equatable {
// MARK: Identifiable
var id: TimelineItemIdentifier { timelineItem.id }
/// The timeline item's event or transaction ID.
///
/// We're identifying items by this to ensure that all matching is made using only this part of the identifier. This is
/// because the unique ID will be different across timelines so when the initial item comes from a regular timeline and
/// we build a filtered timeline to fetch the other media items, it is impossible to match by the `TimelineItemIdentifier`.
var id: TimelineItemIdentifier.EventOrTransactionID {
guard let id = timelineItem.id.eventOrTransactionID else { fatalError("Virtual items cannot be previewed.") }
return id
}
// MARK: QLPreviewItem
@@ -274,11 +297,12 @@ enum TimelineMediaPreviewItem: Equatable {
}
class Loading: NSObject, QLPreviewItem {
static let paginating = Loading(state: .paginating)
static let paginatingBackwards = Loading(state: .paginating(.backwards))
static let paginatingForwards = Loading(state: .paginating(.forwards))
static let timelineStart = Loading(state: .timelineStart)
static let timelineEnd = Loading(state: .timelineEnd)
enum State { case paginating, timelineStart, timelineEnd }
enum State { case paginating(PaginationDirection), timelineStart, timelineEnd }
let state: State
let previewItemURL: URL? = nil
@@ -14,7 +14,7 @@ enum TimelineMediaPreviewViewModelAction: Equatable {
}
enum TimelineMediaPreviewDriverAction {
case itemLoaded(TimelineItemIdentifier)
case itemLoaded(TimelineItemIdentifier.EventOrTransactionID)
case showItemDetails(TimelineMediaPreviewItem.Media)
case exportFile(TimelineMediaPreviewFileExportPicker.File)
case authorizationRequired(appMediator: AppMediatorProtocol)
@@ -63,7 +63,11 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
timelineViewModel.context.$viewState.map(\.timelineState.paginationState)
.removeDuplicates()
.weakAssign(to: \.state.dataSource.paginationState, on: self)
.sink { [weak self] paginationState in
guard let self else { return }
state.dataSource.paginationState = paginationState
paginateIfNeeded()
}
.store(in: &cancellables)
}
@@ -77,7 +81,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
switch action {
case .viewInRoomTimeline:
state.previewControllerDriver.send(.dismissDetailsSheet)
actionsSubject.send(.viewInRoomTimeline(item.id))
actionsSubject.send(.viewInRoomTimeline(item.timelineItem.id))
case .save:
Task { await saveCurrentItem() }
case .redact:
@@ -111,6 +115,23 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
mediaItem.downloadError = error
}
}
} else {
paginateIfNeeded()
}
}
private func paginateIfNeeded() {
switch state.currentItem {
case .loading(.paginatingBackwards):
if state.dataSource.paginationState.backward == .idle {
timelineViewModel.context.send(viewAction: .paginateBackwards)
}
case .loading(.paginatingForwards):
if state.dataSource.paginationState.forward == .idle {
timelineViewModel.context.send(viewAction: .paginateForwards)
}
default:
break
}
}
@@ -166,7 +187,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
}
private func redactItem(_ item: TimelineMediaPreviewItem.Media) {
timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact))
timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.timelineItem.id, action: .redact))
state.bindings.redactConfirmationItem = nil
state.previewControllerDriver.send(.dismissDetailsSheet)
actionsSubject.send(.dismiss)
@@ -182,7 +182,7 @@ class TimelineMediaPreviewController: QLPreviewController {
}
}
private func handleFileLoaded(itemID: TimelineItemIdentifier) {
private func handleFileLoaded(itemID: TimelineItemIdentifier.EventOrTransactionID) {
guard (currentPreviewItem as? TimelineMediaPreviewItem.Media)?.id == itemID else { return }
refreshCurrentPreviewItem()
}
@@ -301,7 +301,8 @@ private struct DownloadIndicatorView: View {
private var shouldShowDownloadIndicator: Bool {
switch currentItem {
case .media(let mediaItem): mediaItem.fileHandle == nil
case .loading(let loadingItem): loadingItem.state == .paginating
case .loading(.paginatingBackwards), .loading(.paginatingForwards): true
case .loading: false
}
}
@@ -222,7 +222,7 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie
thumbnailInfo: .mockThumbnail,
contentType: contentType))
let timelineKind = TimelineKind.media(isPresentedOnRoomScreen ? .roomScreen : .mediaFilesScreen)
let timelineKind = TimelineKind.media(isPresentedOnRoomScreen ? .roomScreenLive : .mediaFilesScreen)
let timelineController = MockTimelineController(timelineKind: timelineKind)
timelineController.timelineItems = [item]
@@ -63,7 +63,7 @@ struct TimelineMediaPreviewRedactConfirmationView: View {
.scaledFrame(size: 40)
.background {
LoadableImage(mediaSource: mediaSource,
mediaType: .timelineItem(uniqueID: item.id.uniqueID),
mediaType: .generic,
blurhash: item.blurhash,
mediaProvider: context.mediaProvider) {
Color.compound.bgSubtleSecondary
@@ -79,6 +79,10 @@ final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol {
.store(in: &cancellables)
}
func stop() {
viewModel.stop()
}
func toPresentable() -> AnyView {
AnyView(MediaEventsTimelineScreen(context: viewModel.context))
}
@@ -64,6 +64,19 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
}
.store(in: &cancellables)
mediaTimelineViewModel.actions.sink { [weak self] action in
switch action {
case .displayMediaPreview(let mediaPreviewViewModel):
self?.displayMediaPreview(mediaPreviewViewModel)
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
.displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen,
.tappedOnSenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure,
.composer, .hasScrolled, .viewInRoomTimeline:
break
}
}
.store(in: &cancellables)
filesTimelineViewModel.context.$viewState.sink { [weak self] timelineViewState in
guard let self, state.bindings.screenMode == .files else {
return
@@ -73,6 +86,19 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
}
.store(in: &cancellables)
filesTimelineViewModel.actions.sink { [weak self] action in
switch action {
case .displayMediaPreview(let mediaPreviewViewModel):
self?.displayMediaPreview(mediaPreviewViewModel)
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
.displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen,
.tappedOnSenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure,
.composer, .hasScrolled, .viewInRoomTimeline:
break
}
}
.store(in: &cancellables)
updateWithTimelineViewState(activeTimelineViewModel.context.viewState)
}
@@ -90,10 +116,15 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
case .oldestItemDidDisappear:
isOldestItemVisible = false
case .tappedItem(let item):
handleItemTapped(item)
activeTimelineViewModel.context.send(viewAction: .mediaTapped(itemID: item.identifier))
}
}
func stop() {
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
state.bindings.mediaPreviewViewModel = nil
}
// MARK: - Private
private func updateWithTimelineViewState(_ timelineViewState: TimelineViewState) {
@@ -146,26 +177,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
}
}
private func handleItemTapped(_ item: RoomTimelineItemViewState) {
let item: EventBasedMessageTimelineItemProtocol? = switch item.type {
case .audio(let audioItem): audioItem
case .file(let fileItem): fileItem
case .image(let imageItem): imageItem
case .video(let videoItem): videoItem
default: nil
}
guard let item else {
MXLog.error("Unexpected item type tapped.")
return
}
let viewModel = TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: activeTimelineViewModel,
mediaProvider: mediaProvider,
photoLibraryManager: PhotoLibraryManager(),
userIndicatorController: userIndicatorController,
appMediator: appMediator)
private func displayMediaPreview(_ viewModel: TimelineMediaPreviewViewModel) {
viewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
@@ -11,4 +11,6 @@ import Combine
protocol MediaEventsTimelineScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<MediaEventsTimelineScreenViewModelAction, Never> { get }
var context: MediaEventsTimelineScreenViewModelType.Context { get }
func stop()
}
@@ -62,6 +62,9 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .viewInRoomTimeline(let itemID):
guard let eventID = itemID.eventID else { fatalError("A pinned event must have an event ID.") }
actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID))
case .dismiss:
self.actionsSubject.send(.dismiss)
}
@@ -77,6 +80,8 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.displayUser(userID: userID))
case .displayMessageForwarding(let forwardingItem):
actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem))
case .displayMediaPreview(let mediaPreviewViewModel):
viewModel.displayMediaPreview(mediaPreviewViewModel)
case .displayLocation(_, let geoURI, let description):
actionsSubject.send(.presentLocationViewer(geoURI: geoURI, description: description))
case .viewInRoomTimeline(let eventID):
@@ -91,6 +96,10 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
}
.store(in: &cancellables)
}
func stop() {
viewModel.stop()
}
func toPresentable() -> AnyView {
AnyView(PinnedEventsTimelineScreen(context: viewModel.context, timelineContext: timelineViewModel.context))
@@ -8,10 +8,18 @@
import Foundation
enum PinnedEventsTimelineScreenViewModelAction {
case viewInRoomTimeline(itemID: TimelineItemIdentifier)
case dismiss
}
struct PinnedEventsTimelineScreenViewState: BindableState { }
struct PinnedEventsTimelineScreenViewState: BindableState {
var bindings = PinnedEventsTimelineScreenViewStateBindings()
}
struct PinnedEventsTimelineScreenViewStateBindings {
/// The view model used to present a QuickLook media preview.
var mediaPreviewViewModel: TimelineMediaPreviewViewModel?
}
enum PinnedEventsTimelineScreenViewAction {
case close
@@ -34,4 +34,23 @@ class PinnedEventsTimelineScreenViewModel: PinnedEventsTimelineScreenViewModelTy
actionsSubject.send(.dismiss)
}
}
func stop() {
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
state.bindings.mediaPreviewViewModel = nil
}
func displayMediaPreview(_ mediaPreviewViewModel: TimelineMediaPreviewViewModel) {
mediaPreviewViewModel.actions.sink { [weak self] action in
switch action {
case .viewInRoomTimeline(let itemID):
self?.actionsSubject.send(.viewInRoomTimeline(itemID: itemID))
case .dismiss:
self?.state.bindings.mediaPreviewViewModel = nil
}
}
.store(in: &cancellables)
state.bindings.mediaPreviewViewModel = mediaPreviewViewModel
}
}
@@ -11,4 +11,8 @@ import Combine
protocol PinnedEventsTimelineScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<PinnedEventsTimelineScreenViewModelAction, Never> { get }
var context: PinnedEventsTimelineScreenViewModelType.Context { get }
func stop()
func displayMediaPreview(_ mediaPreviewViewModel: TimelineMediaPreviewViewModel)
}
@@ -27,7 +27,7 @@ struct PinnedEventsTimelineScreen: View {
.toolbar { toolbar }
.background(.compound.bgCanvasDefault)
.interactiveDismissDisabled()
.interactiveQuickLook(item: $timelineContext.mediaPreviewItem)
.timelineMediaPreview(viewModel: $context.mediaPreviewViewModel)
.sheet(item: $timelineContext.debugInfo) { TimelineItemDebugView(info: $0) }
.sheet(item: $timelineContext.actionMenuInfo) { info in
let actions = TimelineItemMenuActionProvider(timelineItem: info.item,
@@ -126,6 +126,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentMediaUploadPicker(.photoLibrary))
case .displayDocumentPicker:
actionsSubject.send(.presentMediaUploadPicker(.documents))
case .displayMediaPreview(let mediaPreviewViewModel):
roomViewModel.displayMediaPreview(mediaPreviewViewModel)
case .displayLocationPicker:
actionsSubject.send(.presentLocationPicker)
case .displayPollForm(let mode):
@@ -199,7 +201,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
func stop() {
composerViewModel.saveDraft()
timelineViewModel.stop()
roomViewModel.stop()
}
func toPresentable() -> AnyView {
@@ -67,7 +67,10 @@ struct RoomScreenViewState: BindableState {
var bindings: RoomScreenViewStateBindings
}
struct RoomScreenViewStateBindings { }
struct RoomScreenViewStateBindings {
/// The view model used to present a QuickLook media preview.
var mediaPreviewViewModel: TimelineMediaPreviewViewModel?
}
enum RoomScreenFooterViewAction {
case resolvePinViolation(userID: String)
@@ -115,6 +115,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
}
func stop() {
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
state.bindings.mediaPreviewViewModel = nil
}
func timelineHasScrolled(direction: ScrollDirection) {
state.lastScrollDirection = direction
}
@@ -123,6 +128,20 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.pinnedEventsBannerState.setSelectedPinnedEventID(eventID)
}
func displayMediaPreview(_ mediaPreviewViewModel: TimelineMediaPreviewViewModel) {
mediaPreviewViewModel.actions.sink { [weak self] action in
switch action {
case .viewInRoomTimeline:
fatalError("viewInRoomTimeline should not be visible on a room preview.")
case .dismiss:
self?.state.bindings.mediaPreviewViewModel = nil
}
}
.store(in: &cancellables)
state.bindings.mediaPreviewViewModel = mediaPreviewViewModel
}
// MARK: - Private
private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>) {
@@ -12,6 +12,9 @@ protocol RoomScreenViewModelProtocol {
var actions: AnyPublisher<RoomScreenViewModelAction, Never> { get }
var context: RoomScreenViewModel.Context { get }
func stop()
func timelineHasScrolled(direction: ScrollDirection)
func setSelectedPinnedEventID(_ eventID: String)
func displayMediaPreview(_ mediaPreviewViewModel: TimelineMediaPreviewViewModel)
}
@@ -94,7 +94,7 @@ struct RoomScreen: View {
ReadReceiptsSummaryView(orderedReadReceipts: $0.orderedReceipts)
.environmentObject(timelineContext)
}
.interactiveQuickLook(item: $timelineContext.mediaPreviewItem)
.timelineMediaPreview(viewModel: $roomContext.mediaPreviewViewModel)
.track(screen: .Room)
.onDrop(of: ["public.item", "public.file-url"], isTargeted: $dragOver) { providers -> Bool in
guard let provider = providers.first,
@@ -502,7 +502,7 @@ class TimelineInteractionHandler {
}
func processItemTap(_ itemID: TimelineItemIdentifier) async -> TimelineControllerAction {
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) else {
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) as? EventBasedMessageTimelineItemProtocol else {
return .none
}
@@ -510,8 +510,14 @@ class TimelineInteractionHandler {
case let item as LocationRoomTimelineItem:
guard let geoURI = item.content.geoURI else { return .none }
return .displayLocation(body: item.content.body, geoURI: geoURI, description: item.content.description)
case is ImageRoomTimelineItem,
is VideoRoomTimelineItem:
return await mediaPreviewAction(for: timelineItem, messageTypes: [.image, .video])
case is AudioRoomTimelineItem,
is FileRoomTimelineItem:
return await mediaPreviewAction(for: timelineItem, messageTypes: [.audio, .file])
default:
return await displayMediaActionIfPossible(timelineItem: timelineItem)
return .none
}
}
@@ -528,39 +534,57 @@ class TimelineInteractionHandler {
}
}
private func displayMediaActionIfPossible(timelineItem: RoomTimelineItemProtocol) async -> TimelineControllerAction {
var source: MediaSourceProxy?
var filename: String
var caption: String?
switch timelineItem {
case let item as ImageRoomTimelineItem:
source = item.content.imageInfo.source
filename = item.content.filename
caption = item.content.caption
case let item as VideoRoomTimelineItem:
source = item.content.videoInfo.source
filename = item.content.filename
caption = item.content.caption
case let item as FileRoomTimelineItem:
source = item.content.source
filename = item.content.filename
caption = item.content.caption
case let item as AudioRoomTimelineItem:
// For now we are just displaying audio messages with the File preview until we create a timeline player for them.
source = item.content.source
filename = item.content.filename
caption = item.content.caption
default:
return .none
private func mediaPreviewAction(for item: EventBasedMessageTimelineItemProtocol, messageTypes: [TimelineAllowedMessageType]) async -> TimelineControllerAction {
var newTimelineFocus: TimelineFocus?
var newTimelinePresentation: TimelineKind.MediaPresentation?
switch timelineController.timelineKind {
case .live:
newTimelineFocus = .live
newTimelinePresentation = .roomScreenLive
case .detached:
guard case let .event(_, eventOrTransactionID: .eventID(eventID)) = item.id else {
MXLog.error("Unexpected event type on a detached timeline.")
return .none
}
newTimelineFocus = .eventID(eventID)
newTimelinePresentation = .roomScreenDetached
case .pinned:
newTimelineFocus = .pinned
newTimelinePresentation = .pinnedEventsScreen
case .media:
break // We don't need to create a new timeline as it is already filtered.
}
guard let source else { return .none }
switch await mediaProvider.loadFileFromSource(source, filename: filename) {
case .success(let file):
return .displayMediaFile(file: file, title: caption ?? filename)
case .failure:
return .none
if let newTimelineFocus, let newTimelinePresentation {
let timelineItemFactory = RoomTimelineItemFactory(userID: roomProxy.ownUserID,
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
stateEventStringBuilder: RoomStateEventStringBuilder(userID: roomProxy.ownUserID))
guard case let .success(timelineController) = await timelineControllerFactory.buildMessageFilteredTimelineController(focus: newTimelineFocus,
allowedMessageTypes: messageTypes,
presentation: newTimelinePresentation,
roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory,
mediaProvider: mediaProvider) else {
MXLog.error("Failed presenting media timeline")
return .none
}
let timelineViewModel = TimelineViewModel(roomProxy: roomProxy,
timelineController: timelineController,
mediaProvider: mediaProvider,
mediaPlayerProvider: mediaPlayerProvider,
voiceMessageMediaManager: voiceMessageMediaManager,
userIndicatorController: userIndicatorController,
appMediator: appMediator,
appSettings: appSettings,
analyticsService: analyticsService,
emojiProvider: emojiProvider,
timelineControllerFactory: timelineControllerFactory)
return .displayMediaPreview(item: item, timelineViewModel: .new(timelineViewModel))
} else {
return .displayMediaPreview(item: item, timelineViewModel: .active)
}
}
}
@@ -21,6 +21,7 @@ enum TimelineViewModelAction {
case displayMediaUploadPreviewScreen(url: URL)
case tappedOnSenderDetails(userID: String)
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
case displayMediaPreview(TimelineMediaPreviewViewModel)
case displayLocation(body: String, geoURI: GeoURI, description: String?)
case displayResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy)
case composer(action: TimelineComposerAction)
@@ -123,9 +124,6 @@ struct TimelineViewStateBindings {
/// Key is itemID, value is the collapsed state.
var reactionsCollapsed: [TimelineItemIdentifier: Bool]
/// A media item that will be previewed with QuickLook.
var mediaPreviewItem: MediaPreviewItem?
var alertInfo: AlertInfo<RoomScreenAlertInfoType>?
var debugInfo: TimelineItemDebugInfo?
@@ -23,6 +23,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
private let roomProxy: JoinedRoomProxyProtocol
private let timelineController: TimelineControllerProtocol
private let mediaProvider: MediaProviderProtocol
private let mediaPlayerProvider: MediaPlayerProviderProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let appMediator: AppMediatorProtocol
@@ -56,6 +57,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
emojiProvider: EmojiProviderProtocol,
timelineControllerFactory: TimelineControllerFactoryProtocol) {
self.timelineController = timelineController
self.mediaProvider = mediaProvider
self.mediaPlayerProvider = mediaPlayerProvider
self.roomProxy = roomProxy
self.appSettings = appSettings
@@ -123,11 +125,6 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
// MARK: - Public
func stop() {
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
state.bindings.mediaPreviewItem = nil
}
override func process(viewAction: TimelineViewAction) {
switch viewAction {
case .itemAppeared(let id):
@@ -542,11 +539,13 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
private func handleMediaTapped(with itemID: TimelineItemIdentifier) async {
state.showLoading = true
let action = await timelineInteractionHandler.processItemTap(itemID)
switch action {
case .displayMediaFile(let file, let title):
case .displayMediaPreview(let item, let timelineViewModelKind):
actionsSubject.send(.composer(action: .removeFocus)) // Hide the keyboard otherwise a big white space is sometimes shown when dismissing the preview.
state.bindings.mediaPreviewItem = MediaPreviewItem(file: file, title: title)
let mediaPreviewViewModel = makeMediaPreviewViewModel(item: item, timelineViewModelKind: timelineViewModelKind)
actionsSubject.send(.displayMediaPreview(mediaPreviewViewModel))
case .displayLocation(let body, let geoURI, let description):
actionsSubject.send(.displayLocation(body: body, geoURI: geoURI, description: description))
case .none:
@@ -655,6 +654,21 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
analyticsService.trackComposer(inThread: false, isEditing: isEdit, isReply: isReply, startsThread: nil)
}
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: mediaProvider,
photoLibraryManager: PhotoLibraryManager(),
userIndicatorController: userIndicatorController,
appMediator: appMediator)
}
// MARK: - Timeline Item Building
private func buildTimelineViews(timelineItems: [RoomTimelineItemProtocol], isSwitchingTimelines: Bool = false) {
@@ -13,8 +13,8 @@ import SwiftUI
protocol TimelineViewModelProtocol {
var actions: AnyPublisher<TimelineViewModelAction, Never> { get }
var context: TimelineViewModel.Context { get }
func process(composerAction: ComposerToolbarViewModelAction)
/// Updates the timeline to show and highlight the item with the corresponding event ID.
func focusOnEvent(eventID: String) async
func stop()
}
@@ -38,7 +38,7 @@ struct TimelineItemMenuActionProvider {
var actions: [TimelineItemMenuAction] = []
var secondaryActions: [TimelineItemMenuAction] = []
if timelineKind == .pinned || timelineKind == .media(.mediaFilesScreen) {
if timelineKind == .pinned || timelineKind == .media(.mediaFilesScreen) || timelineKind == .media(.pinnedEventsScreen) {
actions.append(.viewInRoomTimeline)
}