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
@@ -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)
}