From d412c103524bd38f3536dc7a73ef0a2124c0ed3a Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Wed, 29 Jan 2025 15:07:23 +0000 Subject: [PATCH] Detect the timeline start/end when swiping through media files. (#3714) --- .../en-US.lproj/Localizable.strings | 4 +- .../en.lproj/Localizable.strings | 4 +- ElementX/Sources/Generated/Strings.swift | 13 +- .../TimelineMediaPreviewDataSource.swift | 338 ++++++++++-------- .../TimelineMediaPreviewModels.swift | 15 +- .../TimelineMediaPreviewViewModel.swift | 57 ++- .../TimelineMediaPreviewDetailsView.swift | 46 +-- ...neMediaPreviewRedactConfirmationView.swift | 7 +- .../View/TimelineMediaPreviewScreen.swift | 65 +++- .../Services/Timeline/TimelineProxy.swift | 5 +- .../TimelineMediaPreviewDataSourceTests.swift | 62 ++-- .../TimelineMediaPreviewViewModelTests.swift | 88 ++--- 12 files changed, 402 insertions(+), 302 deletions(-) diff --git a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings index fafa1f692..d885dbb82 100644 --- a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings @@ -414,6 +414,8 @@ "screen_knock_requests_list_title" = "Requests to join"; "screen_media_details_file_format" = "File format"; "screen_media_details_filename" = "File name"; +"screen_media_details_no_more_files_to_show" = "No more files to show"; +"screen_media_details_no_more_media_to_show" = "No more media to show"; "screen_media_details_redact_confirmation_message" = "This file will be removed from the room and members won’t have access to it."; "screen_media_details_redact_confirmation_title" = "Delete file?"; "screen_media_details_uploaded_by" = "Uploaded by"; @@ -478,8 +480,6 @@ "screen_timeline_item_menu_send_failure_changed_identity" = "Message not sent because %1$@’s verified identity has changed."; "screen_timeline_item_menu_send_failure_unsigned_device" = "Message not sent because %1$@ has not verified all devices."; "screen_timeline_item_menu_send_failure_you_unsigned_device" = "Message not sent because you have not verified one or more of your devices."; -"screen.media_details.no_more_files_to_show" = "No more files to show"; -"screen.media_details.no_more_media_to_show" = "No more media to show"; "screen_account_provider_form_hint" = "Homeserver address"; "screen_account_provider_form_notice" = "Enter a search term or a domain address."; "screen_account_provider_form_subtitle" = "Search for a company, community, or private server."; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index a039bee00..43168bef8 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -414,6 +414,8 @@ "screen_knock_requests_list_title" = "Requests to join"; "screen_media_details_file_format" = "File format"; "screen_media_details_filename" = "File name"; +"screen_media_details_no_more_files_to_show" = "No more files to show"; +"screen_media_details_no_more_media_to_show" = "No more media to show"; "screen_media_details_redact_confirmation_message" = "This file will be removed from the room and members won’t have access to it."; "screen_media_details_redact_confirmation_title" = "Delete file?"; "screen_media_details_uploaded_by" = "Uploaded by"; @@ -478,8 +480,6 @@ "screen_timeline_item_menu_send_failure_changed_identity" = "Message not sent because %1$@’s verified identity has changed."; "screen_timeline_item_menu_send_failure_unsigned_device" = "Message not sent because %1$@ has not verified all devices."; "screen_timeline_item_menu_send_failure_you_unsigned_device" = "Message not sent because you have not verified one or more of your devices."; -"screen.media_details.no_more_files_to_show" = "No more files to show"; -"screen.media_details.no_more_media_to_show" = "No more media to show"; "screen_account_provider_form_hint" = "Homeserver address"; "screen_account_provider_form_notice" = "Enter a search term or a domain address."; "screen_account_provider_form_subtitle" = "Search for a company, community, or private server."; diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index b879e413b..41777ce32 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -1458,6 +1458,10 @@ internal enum L10n { internal static var screenMediaDetailsFileFormat: String { return L10n.tr("Localizable", "screen_media_details_file_format") } /// File name internal static var screenMediaDetailsFilename: String { return L10n.tr("Localizable", "screen_media_details_filename") } + /// No more files to show + internal static var screenMediaDetailsNoMoreFilesToShow: String { return L10n.tr("Localizable", "screen_media_details_no_more_files_to_show") } + /// No more media to show + internal static var screenMediaDetailsNoMoreMediaToShow: String { return L10n.tr("Localizable", "screen_media_details_no_more_media_to_show") } /// This file will be removed from the room and members won’t have access to it. internal static var screenMediaDetailsRedactConfirmationMessage: String { return L10n.tr("Localizable", "screen_media_details_redact_confirmation_message") } /// Delete file? @@ -2804,15 +2808,6 @@ internal enum L10n { /// You internal static var you: String { return L10n.tr("Localizable", "common.you") } } - - internal enum Screen { - internal enum MediaDetails { - /// No more files to show - internal static var noMoreFilesToShow: String { return L10n.tr("Localizable", "screen.media_details.no_more_files_to_show") } - /// No more media to show - internal static var noMoreMediaToShow: String { return L10n.tr("Localizable", "screen.media_details.no_more_media_to_show") } - } - } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift index be19b1137..eb7c3c408 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift @@ -19,7 +19,7 @@ import QuickLook /// in the data. class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource { /// All of the items in the timeline that can be previewed. - private(set) var previewItems: [TimelineMediaPreviewItem] + private(set) var previewItems: [TimelineMediaPreviewItem.Media] let previewItemsPaginationPublisher = PassthroughSubject() private let initialItem: EventBasedMessageTimelineItemProtocol @@ -27,30 +27,37 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource { let initialItemIndex: Int /// The media item that is currently being previewed. - private(set) var currentItem: TimelineMediaPreviewItem? + private(set) var currentItem: TimelineMediaPreviewItem private var backwardPadding: Int private var forwardPadding: Int - init(itemViewStates: [RoomTimelineItemViewState], initialItem: EventBasedMessageTimelineItemProtocol, initialPadding: Int = 100) { - previewItems = itemViewStates.compactMap(TimelineMediaPreviewItem.init) + var paginationState: PaginationState + + init(itemViewStates: [RoomTimelineItemViewState], + initialItem: EventBasedMessageTimelineItemProtocol, + initialPadding: Int = 100, + paginationState: PaginationState) { + previewItems = itemViewStates.compactMap(TimelineMediaPreviewItem.Media.init) self.initialItem = initialItem let initialItemArrayIndex = previewItems.firstIndex { $0.id == initialItem.id } ?? 0 initialItemIndex = initialItemArrayIndex + initialPadding - currentItem = previewItems[initialItemArrayIndex] + currentItem = .media(previewItems[initialItemArrayIndex]) backwardPadding = initialPadding forwardPadding = initialPadding + + self.paginationState = paginationState } - func updateCurrentItem(_ item: TimelineMediaPreviewItem?) { + func updateCurrentItem(_ item: TimelineMediaPreviewItem) { currentItem = item } func updatePreviewItems(itemViewStates: [RoomTimelineItemViewState]) { - let newItems: [TimelineMediaPreviewItem] = itemViewStates.compactMap { itemViewState in - guard let newItem = TimelineMediaPreviewItem(roomTimelineItemViewState: itemViewState) else { return nil } + 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 }) { @@ -91,6 +98,9 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource { // MARK: - QLPreviewControllerDataSource + var firstPreviewItemIndex: Int { backwardPadding } + var lastPreviewItemIndex: Int { backwardPadding + previewItems.count - 1 } + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { previewItems.count + backwardPadding + forwardPadding } @@ -98,167 +108,183 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource { func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> any QLPreviewItem { let arrayIndex = index - backwardPadding - if arrayIndex >= 0, arrayIndex < previewItems.count { - return previewItems[arrayIndex] + if index < firstPreviewItemIndex { + return paginationState.backward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineStart : .paginating + } else if index > lastPreviewItemIndex { + return paginationState.forward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineEnd : .paginating } else { - return TimelineMediaPreviewLoadingItem.shared + return previewItems[arrayIndex] } } } // MARK: - TimelineMediaPreviewItem -/// Wraps a media file and title to be previewed with QuickLook. -class TimelineMediaPreviewItem: NSObject, QLPreviewItem, Identifiable { - fileprivate(set) var timelineItem: EventBasedMessageTimelineItemProtocol - var fileHandle: MediaFileHandleProxy? - var downloadError: Error? +enum TimelineMediaPreviewItem: Equatable { + case media(Media) + case loading(Loading) - init(timelineItem: EventBasedMessageTimelineItemProtocol) { - self.timelineItem = timelineItem - } - - init?(roomTimelineItemViewState: RoomTimelineItemViewState) { - switch roomTimelineItemViewState.type { - case .audio(let audioRoomTimelineItem): - timelineItem = audioRoomTimelineItem - case .file(let fileRoomTimelineItem): - timelineItem = fileRoomTimelineItem - case .image(let imageRoomTimelineItem): - timelineItem = imageRoomTimelineItem - case .video(let videoRoomTimelineItem): - timelineItem = videoRoomTimelineItem - default: - return nil - } - } - - // MARK: Identifiable - - var id: TimelineItemIdentifier { timelineItem.id } - - // MARK: QLPreviewItem - - var previewItemURL: URL? { - // Falling back to a clear image allows the presentation animation to work when - // the item is in the event cache and just needs to be loaded from the store. - fileHandle?.url ?? Bundle.main.url(forResource: "clear", withExtension: "png") - } - - var previewItemTitle: String? { - filename - } - - // MARK: Event details - - var sender: TimelineItemSender { - timelineItem.sender - } - - var timestamp: Date { - timelineItem.timestamp - } - - // MARK: Media details - - var mediaSource: MediaSourceProxy? { - switch timelineItem { - case let audioItem as AudioRoomTimelineItem: - audioItem.content.source - case let fileItem as FileRoomTimelineItem: - fileItem.content.source - case let imageItem as ImageRoomTimelineItem: - imageItem.content.imageInfo.source - case let videoItem as VideoRoomTimelineItem: - videoItem.content.videoInfo.source - default: - nil - } - } - - var thumbnailMediaSource: MediaSourceProxy? { - switch timelineItem { - case let fileItem as FileRoomTimelineItem: - fileItem.content.thumbnailSource - case let imageItem as ImageRoomTimelineItem: - imageItem.content.thumbnailInfo?.source - case let videoItem as VideoRoomTimelineItem: - videoItem.content.thumbnailInfo?.source - default: - nil - } - } - - var filename: String? { - switch timelineItem { - case let audioItem as AudioRoomTimelineItem: - audioItem.content.filename - case let fileItem as FileRoomTimelineItem: - fileItem.content.filename - case let imageItem as ImageRoomTimelineItem: - imageItem.content.filename - case let videoItem as VideoRoomTimelineItem: - videoItem.content.filename - default: - nil - } - } - - var fileSize: Double? { - previewItemURL.flatMap { try? FileManager.default.sizeForItem(at: $0) } ?? expectedFileSize - } - - private var expectedFileSize: Double? { - let fileSize: UInt? = switch timelineItem { - case let audioItem as AudioRoomTimelineItem: - audioItem.content.fileSize - case let fileItem as FileRoomTimelineItem: - fileItem.content.fileSize - case let imageItem as ImageRoomTimelineItem: - imageItem.content.imageInfo.fileSize - case let videoItem as VideoRoomTimelineItem: - videoItem.content.videoInfo.fileSize - default: - nil + /// Wraps a media file and title to be previewed with QuickLook. + class Media: NSObject, QLPreviewItem, Identifiable { + fileprivate(set) var timelineItem: EventBasedMessageTimelineItemProtocol + var fileHandle: MediaFileHandleProxy? + var downloadError: Error? + + init(timelineItem: EventBasedMessageTimelineItemProtocol) { + self.timelineItem = timelineItem } - return fileSize.map(Double.init) - } - - var caption: String? { - timelineItem.mediaCaption - } - - var contentType: String? { - switch timelineItem { - case let audioItem as AudioRoomTimelineItem: - audioItem.content.contentType?.localizedDescription - case let fileItem as FileRoomTimelineItem: - fileItem.content.contentType?.localizedDescription - case let imageItem as ImageRoomTimelineItem: - imageItem.content.contentType?.localizedDescription - case let videoItem as VideoRoomTimelineItem: - videoItem.content.contentType?.localizedDescription - default: - nil + init?(roomTimelineItemViewState: RoomTimelineItemViewState) { + switch roomTimelineItemViewState.type { + case .audio(let audioRoomTimelineItem): + timelineItem = audioRoomTimelineItem + case .file(let fileRoomTimelineItem): + timelineItem = fileRoomTimelineItem + case .image(let imageRoomTimelineItem): + timelineItem = imageRoomTimelineItem + case .video(let videoRoomTimelineItem): + timelineItem = videoRoomTimelineItem + default: + return nil + } + } + + // MARK: Identifiable + + var id: TimelineItemIdentifier { timelineItem.id } + + // MARK: QLPreviewItem + + var previewItemURL: URL? { + // Falling back to a clear image allows the presentation animation to work when + // the item is in the event cache and just needs to be loaded from the store. + fileHandle?.url ?? Bundle.main.url(forResource: "clear", withExtension: "png") + } + + var previewItemTitle: String? { + filename + } + + // MARK: Event details + + var sender: TimelineItemSender { + timelineItem.sender + } + + var timestamp: Date { + timelineItem.timestamp + } + + // MARK: Media details + + var mediaSource: MediaSourceProxy? { + switch timelineItem { + case let audioItem as AudioRoomTimelineItem: + audioItem.content.source + case let fileItem as FileRoomTimelineItem: + fileItem.content.source + case let imageItem as ImageRoomTimelineItem: + imageItem.content.imageInfo.source + case let videoItem as VideoRoomTimelineItem: + videoItem.content.videoInfo.source + default: + nil + } + } + + var thumbnailMediaSource: MediaSourceProxy? { + switch timelineItem { + case let fileItem as FileRoomTimelineItem: + fileItem.content.thumbnailSource + case let imageItem as ImageRoomTimelineItem: + imageItem.content.thumbnailInfo?.source + case let videoItem as VideoRoomTimelineItem: + videoItem.content.thumbnailInfo?.source + default: + nil + } + } + + var filename: String? { + switch timelineItem { + case let audioItem as AudioRoomTimelineItem: + audioItem.content.filename + case let fileItem as FileRoomTimelineItem: + fileItem.content.filename + case let imageItem as ImageRoomTimelineItem: + imageItem.content.filename + case let videoItem as VideoRoomTimelineItem: + videoItem.content.filename + default: + nil + } + } + + var fileSize: Double? { + previewItemURL.flatMap { try? FileManager.default.sizeForItem(at: $0) } ?? expectedFileSize + } + + private var expectedFileSize: Double? { + let fileSize: UInt? = switch timelineItem { + case let audioItem as AudioRoomTimelineItem: + audioItem.content.fileSize + case let fileItem as FileRoomTimelineItem: + fileItem.content.fileSize + case let imageItem as ImageRoomTimelineItem: + imageItem.content.imageInfo.fileSize + case let videoItem as VideoRoomTimelineItem: + videoItem.content.videoInfo.fileSize + default: + nil + } + + return fileSize.map(Double.init) + } + + var caption: String? { + timelineItem.mediaCaption + } + + var contentType: String? { + switch timelineItem { + case let audioItem as AudioRoomTimelineItem: + audioItem.content.contentType?.localizedDescription + case let fileItem as FileRoomTimelineItem: + fileItem.content.contentType?.localizedDescription + case let imageItem as ImageRoomTimelineItem: + imageItem.content.contentType?.localizedDescription + case let videoItem as VideoRoomTimelineItem: + videoItem.content.contentType?.localizedDescription + default: + nil + } + } + + var blurhash: String? { + switch timelineItem { + case let imageItem as ImageRoomTimelineItem: + imageItem.content.blurhash + case let videoItem as VideoRoomTimelineItem: + videoItem.content.blurhash + default: + nil + } } } - var blurhash: String? { - switch timelineItem { - case let imageItem as ImageRoomTimelineItem: - imageItem.content.blurhash - case let videoItem as VideoRoomTimelineItem: - videoItem.content.blurhash - default: - nil + class Loading: NSObject, QLPreviewItem { + static let paginating = Loading(state: .paginating) + static let timelineStart = Loading(state: .timelineStart) + static let timelineEnd = Loading(state: .timelineEnd) + + enum State { case paginating, timelineStart, timelineEnd } + let state: State + + let previewItemURL: URL? = nil + let previewItemTitle: String? = "" // Empty to force QLPreviewController to not show any text. + + init(state: State) { + self.state = state } } } - -class TimelineMediaPreviewLoadingItem: NSObject, QLPreviewItem { - static let shared = TimelineMediaPreviewLoadingItem() - - let previewItemURL: URL? = nil - let previewItemTitle: String? = "" // Empty to force QLPreviewController to not show any text. -} diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift index 9e0aa0a73..f9a3bec7a 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift @@ -18,7 +18,7 @@ struct TimelineMediaPreviewViewState: BindableState { var dataSource: TimelineMediaPreviewDataSource /// The media item that is currently being previewed. - var currentItem: TimelineMediaPreviewItem? { dataSource.currentItem } + var currentItem: TimelineMediaPreviewItem { dataSource.currentItem } /// All of the available actions for the current item. var currentItemActions: TimelineItemMenuActions? @@ -32,9 +32,9 @@ struct TimelineMediaPreviewViewState: BindableState { struct TimelineMediaPreviewViewStateBindings { /// A binding that will present the Details view for the specified item. - var mediaDetailsItem: TimelineMediaPreviewItem? + var mediaDetailsItem: TimelineMediaPreviewItem.Media? /// A binding that will present a confirmation to redact the specified item. - var redactConfirmationItem: TimelineMediaPreviewItem? + var redactConfirmationItem: TimelineMediaPreviewItem.Media? /// A binding that will present a document picker to export the specified file. var fileToExport: TimelineMediaPreviewFileExportPicker.File? @@ -46,9 +46,10 @@ enum TimelineMediaPreviewAlertType { } enum TimelineMediaPreviewViewAction { - case updateCurrentItem(TimelineMediaPreviewItem?) - case showCurrentItemDetails - case menuAction(TimelineItemMenuAction, item: TimelineMediaPreviewItem) - case redactConfirmation(item: TimelineMediaPreviewItem) + case updateCurrentItem(TimelineMediaPreviewItem) + case showItemDetails(TimelineMediaPreviewItem.Media) + case menuAction(TimelineItemMenuAction, item: TimelineMediaPreviewItem.Media) + case redactConfirmation(item: TimelineMediaPreviewItem.Media) + case timelineEndReached case dismiss } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift index 2ad7f6157..a6b42af6c 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift @@ -35,8 +35,11 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { self.userIndicatorController = userIndicatorController self.appMediator = appMediator - super.init(initialViewState: TimelineMediaPreviewViewState(dataSource: .init(itemViewStates: timelineViewModel.context.viewState.timelineState.itemViewStates, - initialItem: context.item), + let timelineState = timelineViewModel.context.viewState.timelineState + + super.init(initialViewState: TimelineMediaPreviewViewState(dataSource: .init(itemViewStates: timelineState.itemViewStates, + initialItem: context.item, + paginationState: timelineState.paginationState), transitionNamespace: context.namespace), mediaProvider: mediaProvider) @@ -57,14 +60,19 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { self?.state.dataSource.updatePreviewItems(itemViewStates: itemViewStates) } .store(in: &cancellables) + + timelineViewModel.context.$viewState.map(\.timelineState.paginationState) + .removeDuplicates() + .weakAssign(to: \.state.dataSource.paginationState, on: self) + .store(in: &cancellables) } override func process(viewAction: TimelineMediaPreviewViewAction) { switch viewAction { case .updateCurrentItem(let item): Task { await updateCurrentItem(item) } - case .showCurrentItemDetails: - state.bindings.mediaDetailsItem = state.currentItem + case .showItemDetails(let mediaItem): + state.bindings.mediaDetailsItem = mediaItem case .menuAction(let action, let item): switch action { case .viewInRoomTimeline: @@ -78,28 +86,32 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { } case .redactConfirmation(let item): redactItem(item) + case .timelineEndReached: + showTimelineEndIndicator() case .dismiss: actionsSubject.send(.dismiss) } } - private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem?) async { - previewItem?.downloadError = nil // Clear any existing error. + private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async { + if case let .media(item) = previewItem { + item.downloadError = nil // Clear any existing error. + } state.dataSource.updateCurrentItem(previewItem) rebuildCurrentItemActions() - if let previewItem { - currentItemIDHandler?(previewItem.id) + if case let .media(mediaItem) = previewItem { + currentItemIDHandler?(mediaItem.id) - if previewItem.fileHandle == nil, let source = previewItem.mediaSource { - switch await mediaProvider.loadFileFromSource(source, filename: previewItem.filename) { + if mediaItem.fileHandle == nil, let source = mediaItem.mediaSource { + switch await mediaProvider.loadFileFromSource(source, filename: mediaItem.filename) { case .success(let handle): - previewItem.fileHandle = handle - state.fileLoadedPublisher.send(previewItem.id) + mediaItem.fileHandle = handle + state.fileLoadedPublisher.send(mediaItem.id) case .failure(let error): MXLog.error("Failed loading media: \(error)") context.objectWillChange.send() // Manually trigger the SwiftUI view update. - previewItem.downloadError = error + mediaItem.downloadError = error } } } @@ -107,8 +119,9 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { private func rebuildCurrentItemActions() { let timelineContext = timelineViewModel.context - state.currentItemActions = if let currentItem = state.currentItem { - TimelineItemMenuActionProvider(timelineItem: currentItem.timelineItem, + state.currentItemActions = switch state.currentItem { + case .media(let mediaItem): + TimelineItemMenuActionProvider(timelineItem: mediaItem.timelineItem, canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf, canCurrentUserRedactOthers: timelineContext.viewState.canCurrentUserRedactOthers, canCurrentUserPin: timelineContext.viewState.canCurrentUserPin, @@ -118,13 +131,13 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { timelineKind: timelineContext.viewState.timelineKind, emojiProvider: timelineContext.viewState.emojiProvider) .makeActions() - } else { + case .loading: nil } } private func saveCurrentItem() async { - guard let currentItem = state.currentItem, let fileURL = currentItem.fileHandle?.url else { + guard case let .media(mediaItem) = state.currentItem, let fileURL = mediaItem.fileHandle?.url else { MXLog.error("Unable to save an item without a URL, the button shouldn't be visible.") return } @@ -133,7 +146,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { state.bindings.mediaDetailsItem = nil do { - switch currentItem.timelineItem { + switch mediaItem.timelineItem { case is AudioRoomTimelineItem, is FileRoomTimelineItem: state.bindings.fileToExport = .init(url: fileURL) return // Don't show the indicator. @@ -158,7 +171,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { } } - private func redactItem(_ item: TimelineMediaPreviewItem) { + private func redactItem(_ item: TimelineMediaPreviewItem.Media) { timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact)) state.bindings.redactConfirmationItem = nil state.bindings.mediaDetailsItem = nil @@ -189,5 +202,11 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { iconName: "xmark")) } + private func showTimelineEndIndicator() { + userIndicatorController.submitIndicator(UserIndicator(id: statusIndicatorID, + type: .toast, + title: L10n.screenMediaDetailsNoMoreMediaToShow)) + } + private var statusIndicatorID: String { "\(Self.self)-Status" } } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift index 04ef6bf0c..0f16f3ee0 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift @@ -9,7 +9,7 @@ import Compound import SwiftUI struct TimelineMediaPreviewDetailsView: View { - let item: TimelineMediaPreviewItem + let item: TimelineMediaPreviewItem.Media @ObservedObject var context: TimelineMediaPreviewViewModel.Context @State private var sheetHeight: CGFloat = .zero @@ -132,7 +132,7 @@ struct TimelineMediaPreviewDetailsView: View { } private struct ActionButton: View { - let item: TimelineMediaPreviewItem + let item: TimelineMediaPreviewItem.Media let action: TimelineItemMenuAction let context: TimelineMediaPreviewViewModel.Context @@ -177,29 +177,31 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie static let presentedOnRoomViewModel = makeViewModel(isPresentedOnRoomScreen: true) static var previews: some View { - // swiftlint:disable force_unwrapping - TimelineMediaPreviewDetailsView(item: viewModel.state.currentItem!, - context: viewModel.context) - .previewDisplayName("Image") - .snapshotPreferences(expect: viewModel.context.$viewState.map { state in - state.currentItemActions?.secondaryActions.contains(.redact) ?? false - }) + if case let .media(mediaItem) = viewModel.state.currentItem { + TimelineMediaPreviewDetailsView(item: mediaItem, context: viewModel.context) + .previewDisplayName("Image") + .snapshotPreferences(expect: viewModel.context.$viewState.map { state in + state.currentItemActions?.secondaryActions.contains(.redact) ?? false + }) + } - TimelineMediaPreviewDetailsView(item: loadingViewModel.state.currentItem!, - context: loadingViewModel.context) - .previewDisplayName("Loading") - .snapshotPreferences(expect: loadingViewModel.context.$viewState.map { state in - state.currentItemActions?.secondaryActions.contains(.redact) ?? false - }) + if case let .media(mediaItem) = loadingViewModel.state.currentItem { + TimelineMediaPreviewDetailsView(item: mediaItem, context: loadingViewModel.context) + .previewDisplayName("Loading") + .snapshotPreferences(expect: loadingViewModel.context.$viewState.map { state in + state.currentItemActions?.secondaryActions.contains(.redact) ?? false + }) + } - TimelineMediaPreviewDetailsView(item: unknownTypeViewModel.state.currentItem!, - context: unknownTypeViewModel.context) - .previewDisplayName("Unknown type") + if case let .media(mediaItem) = unknownTypeViewModel.state.currentItem { + TimelineMediaPreviewDetailsView(item: mediaItem, context: unknownTypeViewModel.context) + .previewDisplayName("Unknown type") + } - TimelineMediaPreviewDetailsView(item: presentedOnRoomViewModel.state.currentItem!, - context: presentedOnRoomViewModel.context) - .previewDisplayName("Incoming on Room") - // swiftlint:enable force_unwrapping + if case let .media(mediaItem) = presentedOnRoomViewModel.state.currentItem { + TimelineMediaPreviewDetailsView(item: mediaItem, context: presentedOnRoomViewModel.context) + .previewDisplayName("Incoming on Room") + } } static func makeViewModel(contentType: UTType? = nil, diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift index b4b2e4525..24f6757ea 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift @@ -11,7 +11,7 @@ import SwiftUI struct TimelineMediaPreviewRedactConfirmationView: View { @Environment(\.dismiss) private var dismiss - let item: TimelineMediaPreviewItem + let item: TimelineMediaPreviewItem.Media @ObservedObject var context: TimelineMediaPreviewViewModel.Context @State private var sheetHeight: CGFloat = .zero @@ -125,8 +125,9 @@ struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, Tes static let viewModel = makeViewModel(contentType: .jpeg) static var previews: some View { - // swiftlint:disable:next force_unwrapping - TimelineMediaPreviewRedactConfirmationView(item: viewModel.state.currentItem!, context: viewModel.context) + if case let .media(mediaItem) = viewModel.state.currentItem { + TimelineMediaPreviewRedactConfirmationView(item: mediaItem, context: viewModel.context) + } } static func makeViewModel(contentType: UTType? = nil) -> TimelineMediaPreviewViewModel { diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewScreen.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewScreen.swift index a55c0c1d9..29433b3a9 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewScreen.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewScreen.swift @@ -17,7 +17,18 @@ struct TimelineMediaPreviewScreen: View { @State private var isFullScreen = false private var toolbarVisibility: Visibility { isFullScreen ? .hidden : .visible } - private var currentItem: TimelineMediaPreviewItem? { context.viewState.currentItem } + private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem } + private var currentItemID: TimelineItemIdentifier? { + guard case .media(let mediaItem) = currentItem else { return nil } + return mediaItem.id + } + + private var shouldShowDownloadIndicator: Bool { + switch currentItem { + case .media(let mediaItem): mediaItem.fileHandle == nil + case .loading(let loadingItem): loadingItem.state == .paginating + } + } var body: some View { NavigationStack { @@ -40,7 +51,7 @@ struct TimelineMediaPreviewScreen: View { .onDisappear { itemIDHandler?(nil) } - .zoomTransition(sourceID: currentItem?.id, in: context.viewState.transitionNamespace) + .zoomTransition(sourceID: currentItemID, in: context.viewState.transitionNamespace) } var quickLookPreview: some View { @@ -57,7 +68,7 @@ struct TimelineMediaPreviewScreen: View { @ViewBuilder private var fullScreenButton: some View { - if currentItem != nil { + if case .media = currentItem { Button { withAnimation { isFullScreen.toggle() } } label: { @@ -73,7 +84,7 @@ struct TimelineMediaPreviewScreen: View { @ViewBuilder private var downloadStatusIndicator: some View { - if currentItem?.downloadError != nil { + if case let .media(mediaItem) = currentItem, mediaItem.downloadError != nil { VStack(spacing: 24) { CompoundIcon(\.error, size: .custom(48), relativeTo: .compound.headingLG) .foregroundStyle(.compound.iconCriticalPrimary) @@ -94,7 +105,7 @@ struct TimelineMediaPreviewScreen: View { .padding(.horizontal, 24) .padding(.vertical, 40) .background(.compound.bgSubtlePrimary, in: RoundedRectangle(cornerRadius: 14)) - } else if currentItem?.fileHandle == nil { + } else if shouldShowDownloadIndicator { ProgressView() .controlSize(.large) .tint(.compound.iconPrimary) @@ -103,7 +114,7 @@ struct TimelineMediaPreviewScreen: View { @ViewBuilder private var caption: some View { - if let caption = currentItem?.caption, !isFullScreen { + if case let .media(mediaItem) = currentItem, let caption = mediaItem.caption, !isFullScreen { Text(caption) .font(.compound.bodyLG) .foregroundStyle(.compound.textPrimary) @@ -133,9 +144,9 @@ struct TimelineMediaPreviewScreen: View { toolbarHeader } - if currentItem != nil { + if case let .media(mediaItem) = currentItem { ToolbarItem(placement: .primaryAction) { - Button { context.send(viewAction: .showCurrentItemDetails) } label: { + Button { context.send(viewAction: .showItemDetails(mediaItem)) } label: { CompoundIcon(\.info) } .tint(.compound.textActionPrimary) @@ -145,17 +156,18 @@ struct TimelineMediaPreviewScreen: View { @ViewBuilder private var toolbarHeader: some View { - if let currentItem { + switch currentItem { + case .media(let mediaItem): VStack(spacing: 0) { - Text(currentItem.sender.displayName ?? currentItem.sender.id) + Text(mediaItem.sender.displayName ?? mediaItem.sender.id) .font(.compound.bodySMSemibold) .foregroundStyle(.compound.textPrimary) - Text(currentItem.timestamp.formatted(date: .abbreviated, time: .omitted)) + Text(mediaItem.timestamp.formatted(date: .abbreviated, time: .omitted)) .font(.compound.bodyXS) .foregroundStyle(.compound.textPrimary) .textCase(.uppercase) } - } else { + case .loading: Text(L10n.commonLoadingMore) .font(.compound.bodySMSemibold) .foregroundStyle(.compound.textPrimary) @@ -215,20 +227,41 @@ private struct QuickLookView: UIViewControllerRepresentable { } private func loadCurrentItem() { - viewModelContext.send(viewAction: .updateCurrentItem(previewController.currentPreviewItem as? TimelineMediaPreviewItem)) + if let previewItem = previewController.currentPreviewItem as? TimelineMediaPreviewItem.Media { + viewModelContext.send(viewAction: .updateCurrentItem(.media(previewItem))) + } else if let loadingItem = previewController.currentPreviewItem as? TimelineMediaPreviewItem.Loading { + switch loadingItem.state { + case .paginating: + viewModelContext.send(viewAction: .updateCurrentItem(.loading(loadingItem))) + case .timelineStart: + Task { await returnToIndex(viewModelContext.viewState.dataSource.firstPreviewItemIndex) } + case .timelineEnd: + Task { await returnToIndex(viewModelContext.viewState.dataSource.lastPreviewItemIndex) } + } + } else { + MXLog.error("Unexpected preview item type: \(type(of: previewController.currentPreviewItem))") + } + } + + private func returnToIndex(_ index: Int) async { + // Sleep to fix a bug where the update didn't take effect when the swipe velocity was slow. + try? await Task.sleep(for: .seconds(0.1)) + + previewController.currentPreviewItemIndex = index + viewModelContext.send(viewAction: .timelineEndReached) } private func handleUpdatedItems() { - if previewController.currentPreviewItem is TimelineMediaPreviewLoadingItem { + if previewController.currentPreviewItem is TimelineMediaPreviewItem.Loading { let dataSource = viewModelContext.viewState.dataSource - if dataSource.previewController(previewController, previewItemAt: previewController.currentPreviewItemIndex) is TimelineMediaPreviewItem { + if dataSource.previewController(previewController, previewItemAt: previewController.currentPreviewItemIndex) is TimelineMediaPreviewItem.Media { previewController.refreshCurrentPreviewItem() // This will trigger loadCurrentItem automatically. } } } private func handleFileLoaded(itemID: TimelineItemIdentifier) { - guard (previewController.currentPreviewItem as? TimelineMediaPreviewItem)?.id == itemID else { return } + guard (previewController.currentPreviewItem as? TimelineMediaPreviewItem.Media)?.id == itemID else { return } previewController.refreshCurrentPreviewItem() } } diff --git a/ElementX/Sources/Services/Timeline/TimelineProxy.swift b/ElementX/Sources/Services/Timeline/TimelineProxy.swift index 0ce0f6588..8bd82d010 100644 --- a/ElementX/Sources/Services/Timeline/TimelineProxy.swift +++ b/ElementX/Sources/Services/Timeline/TimelineProxy.swift @@ -586,10 +586,13 @@ final class TimelineProxy: TimelineProxyProtocol { MXLog.error("Failed to subscribe to back pagination status with error: \(error)") } forwardPaginationStatusSubject.send(.timelineEndReached) - case .detached, .media: + case .detached: // Detached timelines don't support observation, set the initial state ourself. backPaginationStatusSubject.send(.idle) forwardPaginationStatusSubject.send(.idle) + case .media(let presentation): + backPaginationStatusSubject.send(.idle) + forwardPaginationStatusSubject.send(presentation == .mediaFilesScreen ? .timelineEndReached : .idle) case .pinned: backPaginationStatusSubject.send(.timelineEndReached) forwardPaginationStatusSubject.send(.timelineEndReached) diff --git a/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift b/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift index ed2e09f5e..f21433318 100644 --- a/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift +++ b/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift @@ -27,16 +27,17 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { // Given a data source built with the initial items. let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates, initialItem: initialMediaItems[initialItemIndex], - initialPadding: initialPadding) + initialPadding: initialPadding, + paginationState: .initial) // When the preview controller displays the data. let previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - let displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem + let displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media // Then the preview controller should be showing the initial item and the data source should reflect this. XCTAssertEqual(dataSource.initialItemIndex, initialItemIndex + initialPadding, "The initial item index should be padded for the preview controller.") XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should be the initial item.") - XCTAssertEqual(dataSource.currentItem?.id, initialMediaItems[initialItemIndex].id, "The current item should also be the initial item.") + XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should also be the initial item.") XCTAssertEqual(dataSource.previewItems.count, initialMediaViewStates.count, "The initial count of preview items should be correct.") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The initial item count should be padded for the preview controller.") @@ -46,23 +47,29 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { func testCurrentUpdateItem() { // Given a data source built with the initial items. - let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates, initialItem: initialMediaItems[initialItemIndex]) + let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates, + initialItem: initialMediaItems[initialItemIndex], + paginationState: .initial) // When a different item is displayed. - let previewItem = dataSource.previewController(previewController, previewItemAt: 1 + initialPadding) as? TimelineMediaPreviewItem - XCTAssertNotNil(previewItem, "A preview item should be found.") - dataSource.updateCurrentItem(previewItem) + guard let previewItem = dataSource.previewController(previewController, previewItemAt: 1 + initialPadding) as? TimelineMediaPreviewItem.Media else { + XCTFail("A preview item should be found.") + return + } + dataSource.updateCurrentItem(.media(previewItem)) // Then the data source should reflect the change of item. - XCTAssertEqual(dataSource.currentItem?.id, previewItem?.id, "The displayed item should be the initial item.") + XCTAssertEqual(dataSource.currentMediaItemID, previewItem.id, "The displayed item should be the initial item.") // When a loading item is displayed. - let loadingItem = dataSource.previewController(previewController, previewItemAt: initialPadding - 1) as? TimelineMediaPreviewLoadingItem - XCTAssertNotNil(loadingItem, "A loading item should be be returned.") - dataSource.updateCurrentItem(nil) + guard let loadingItem = dataSource.previewController(previewController, previewItemAt: initialPadding - 1) as? TimelineMediaPreviewItem.Loading else { + XCTFail("A loading item should be be returned.") + return + } + dataSource.updateCurrentItem(.loading(loadingItem)) - // Then the data source should indicate that no item is being displayed. - XCTAssertNil(dataSource.currentItem, "The current item should be nil.") + // Then the data source should show a loading item + XCTAssertEqual(dataSource.currentItem, .loading(loadingItem), "The displayed item should be the loading item.") } func testUpdatedItems() async throws { @@ -77,9 +84,9 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { try await deferred.fulfill() let previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - let displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem + let displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") - XCTAssertEqual(dataSource.currentItem?.id, initialMediaItems[initialItemIndex].id, "The current item should not change.") + XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.") XCTAssertEqual(dataSource.previewItems.count, initialMediaViewStates.count, "The number of items should not change.") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The padded number of items should not change.") @@ -100,9 +107,9 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") var previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - var displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem + var displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") - XCTAssertEqual(dataSource.currentItem?.id, initialMediaItems[initialItemIndex].id, "The current item should not change.") + XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") // When more items are loaded in a forward pagination or sync. @@ -116,9 +123,9 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem + displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") - XCTAssertEqual(dataSource.currentItem?.id, initialMediaItems[initialItemIndex].id, "The current item should not change.") + XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") } @@ -139,9 +146,9 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") var previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - var displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem + var displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") - XCTAssertEqual(dataSource.currentItem?.id, initialMediaItems[initialItemIndex].id, "The current item should not change.") + XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") // When paginating forwards by more than the available padding. @@ -155,9 +162,9 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem + displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") - XCTAssertEqual(dataSource.currentItem?.id, initialMediaItems[initialItemIndex].id, "The current item should not change.") + XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") } @@ -169,3 +176,12 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { .filter(\.supportsMediaCaption) // Voice messages can't be previewed (and don't support captions). } } + +private extension TimelineMediaPreviewDataSource { + var currentMediaItemID: TimelineItemIdentifier? { + switch currentItem { + case .media(let mediaItem): mediaItem.id + case .loading: nil + } + } +} diff --git a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift index 15e13b09e..d700b487e 100644 --- a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift +++ b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift @@ -25,7 +25,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { // Given a fresh view model. setupViewModel() XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled) - XCTAssertEqual(context.viewState.currentItem, context.viewState.dataSource.previewItems[0]) + XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[0])) XCTAssertNotNil(context.viewState.currentItemActions) // When the preview controller sets the current item. @@ -33,32 +33,32 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { // Then the view model should load the item and update its view state. XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled) - XCTAssertEqual(context.viewState.currentItem, context.viewState.dataSource.previewItems[0]) + XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[0])) XCTAssertNotNil(context.viewState.currentItemActions) } func testLoadingItemFailure() async throws { // Given a fresh view model. setupViewModel() - guard let currentItem = context.viewState.currentItem else { + guard case let .media(mediaItem) = context.viewState.currentItem else { XCTFail("There should be a current item") return } XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled) - XCTAssertEqual(currentItem, context.viewState.dataSource.previewItems[0]) - XCTAssertNil(currentItem.downloadError) + XCTAssertEqual(mediaItem, context.viewState.dataSource.previewItems[0]) + XCTAssertNil(mediaItem.downloadError) // When the preview controller sets an item that fails to load. mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) } let failure = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true } - context.send(viewAction: .updateCurrentItem(context.viewState.dataSource.previewItems[0])) + context.send(viewAction: .updateCurrentItem(.media(context.viewState.dataSource.previewItems[0]))) try await failure.fulfill() // Then the view model should load the item and update its view state. XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled) - XCTAssertEqual(currentItem, context.viewState.dataSource.previewItems[0]) - XCTAssertNotNil(currentItem.downloadError) + XCTAssertEqual(mediaItem, context.viewState.dataSource.previewItems[0]) + XCTAssertNotNil(mediaItem.downloadError) } func testSwipingBetweenItems() async throws { @@ -67,21 +67,21 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { // When swiping to another item. let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true } - context.send(viewAction: .updateCurrentItem(context.viewState.dataSource.previewItems[1])) + context.send(viewAction: .updateCurrentItem(.media(context.viewState.dataSource.previewItems[1]))) try await deferred.fulfill() // Then the view model should load the item and update its view state. XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 2) - XCTAssertEqual(context.viewState.currentItem, context.viewState.dataSource.previewItems[1]) + XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[1])) // When swiping back to the first item. let failure = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true } - context.send(viewAction: .updateCurrentItem(context.viewState.dataSource.previewItems[0])) + context.send(viewAction: .updateCurrentItem(.media(context.viewState.dataSource.previewItems[0]))) try await failure.fulfill() // Then the view model should not need to load the item, but should still update its view state. XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 2) - XCTAssertEqual(context.viewState.currentItem, context.viewState.dataSource.previewItems[0]) + XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[0])) } func testLoadingMoreItem() async throws { @@ -90,12 +90,12 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { // When swiping to a "loading more" item. let deferred = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true } - context.send(viewAction: .updateCurrentItem(nil)) + context.send(viewAction: .updateCurrentItem(.loading(.paginating))) try await deferred.fulfill() // Then there should no longer be a media preview and no attempt should be made to load one. XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 1) - XCTAssertNil(context.viewState.currentItem) + XCTAssertEqual(context.viewState.currentItem, .loading(.paginating)) } func testPagination() async throws { @@ -112,7 +112,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { // And the preview controller attempts to update the current item (now at a new index in the array but it hasn't changed in the data source). mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) } let failure = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true } - context.send(viewAction: .updateCurrentItem(context.viewState.dataSource.previewItems[3])) + context.send(viewAction: .updateCurrentItem(.media(context.viewState.dataSource.previewItems[3]))) try await failure.fulfill() // Then the current item shouldn't need to be reloaded. @@ -125,13 +125,13 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { try await testLoadingItem() // When choosing to view the current item in the timeline. - guard let item = context.viewState.currentItem else { + guard case let .media(mediaItem) = context.viewState.currentItem else { XCTFail("There should be a current item.") return } - let deferred = deferFulfillment(viewModel.actions) { $0 == .viewInRoomTimeline(item.id) } - context.send(viewAction: .menuAction(.viewInRoomTimeline, item: item)) + let deferred = deferFulfillment(viewModel.actions) { $0 == .viewInRoomTimeline(mediaItem.id) } + context.send(viewAction: .menuAction(.viewInRoomTimeline, item: mediaItem)) // Then the action should be sent upwards to make this happen. try await deferred.fulfill() @@ -142,27 +142,31 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { try await testLoadingItem() XCTAssertNil(context.redactConfirmationItem) XCTAssertFalse(timelineController.redactCalled) + guard case let .media(mediaItem) = context.viewState.currentItem else { + XCTFail("There should be a current item.") + return + } // When choosing to show the item details. - context.send(viewAction: .showCurrentItemDetails) + context.send(viewAction: .showItemDetails(mediaItem)) // Then the details sheet should be presented. - guard let item = context.mediaDetailsItem else { + guard let mediaDetailsItem = context.mediaDetailsItem else { XCTFail("The default of the current item should be presented") return } - XCTAssertEqual(context.mediaDetailsItem, context.viewState.currentItem) + XCTAssertEqual(.media(mediaDetailsItem), context.viewState.currentItem) // When choosing to redact the item. - context.send(viewAction: .menuAction(.redact, item: item)) + context.send(viewAction: .menuAction(.redact, item: mediaDetailsItem)) // Then the confirmation sheet should be presented. - XCTAssertEqual(context.redactConfirmationItem, item) + XCTAssertEqual(context.redactConfirmationItem, mediaDetailsItem) XCTAssertFalse(timelineController.redactCalled) // When confirming the redaction. let deferred = deferFulfillment(viewModel.actions) { $0 == .dismiss } - context.send(viewAction: .redactConfirmation(item: item)) + context.send(viewAction: .redactConfirmation(item: mediaDetailsItem)) // Then the item should be redacted and the view should be dismissed. try await deferred.fulfill() @@ -172,35 +176,35 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { func testSaveImage() async throws { // Given a view model with a loaded image. try await testLoadingItem() - guard let currentItem = context.viewState.currentItem else { + guard case let .media(mediaItem) = context.viewState.currentItem else { XCTFail("There should be a current item") return } - XCTAssertEqual(currentItem.contentType, "JPEG image") + XCTAssertEqual(mediaItem.contentType, "JPEG image") // When choosing to save the image. - context.send(viewAction: .menuAction(.save, item: currentItem)) + context.send(viewAction: .menuAction(.save, item: mediaItem)) try await Task.sleep(for: .seconds(0.5)) // Then the image should be saved as a photo to the user's photo library. XCTAssertTrue(photoLibraryManager.addResourceAtCalled) XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.type, .photo) - XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, currentItem.fileHandle?.url) + XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, mediaItem.fileHandle?.url) } func testSaveImageWithoutAuthorization() async throws { // Given a view model with a loaded image where the user has denied access to the photo library. setupViewModel(photoLibraryAuthorizationDenied: true) try await loadInitialItem() - guard let currentItem = context.viewState.currentItem else { + guard case let .media(mediaItem) = context.viewState.currentItem else { XCTFail("There should be a current item") return } - XCTAssertEqual(currentItem.contentType, "JPEG image") + XCTAssertEqual(mediaItem.contentType, "JPEG image") // When choosing to save the image. let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil } - context.send(viewAction: .menuAction(.save, item: currentItem)) + context.send(viewAction: .menuAction(.save, item: mediaItem)) try await deferred.fulfill() // Then the user should be prompted to allow access. @@ -212,40 +216,40 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { // Given a view model with a loaded video. setupViewModel(initialItemIndex: 1) try await loadInitialItem() - guard let currentItem = context.viewState.currentItem else { + guard case let .media(mediaItem) = context.viewState.currentItem else { XCTFail("There should be a current item") return } - XCTAssertEqual(currentItem.contentType, "MPEG-4 movie") + XCTAssertEqual(mediaItem.contentType, "MPEG-4 movie") // When choosing to save the video. - context.send(viewAction: .menuAction(.save, item: currentItem)) + context.send(viewAction: .menuAction(.save, item: mediaItem)) try await Task.sleep(for: .seconds(0.5)) // Then the video should be saved as a video in the user's photo library. XCTAssertTrue(photoLibraryManager.addResourceAtCalled) XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.type, .video) - XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, currentItem.fileHandle?.url) + XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, mediaItem.fileHandle?.url) } func testSaveFile() async throws { // Given a view model with a loaded file. setupViewModel(initialItemIndex: 2) try await loadInitialItem() - guard let currentItem = context.viewState.currentItem else { + guard case let .media(mediaItem) = context.viewState.currentItem else { XCTFail("There should be a current item") return } - XCTAssertEqual(currentItem.contentType, "PDF document") + XCTAssertEqual(mediaItem.contentType, "PDF document") // When choosing to save the file. - context.send(viewAction: .menuAction(.save, item: currentItem)) + context.send(viewAction: .menuAction(.save, item: mediaItem)) try await Task.sleep(for: .seconds(0.5)) // Then the binding should be set for the user to export the file to their specified location. XCTAssertFalse(photoLibraryManager.addResourceAtCalled) XCTAssertNotNil(context.fileToExport) - XCTAssertEqual(context.fileToExport?.url, currentItem.fileHandle?.url) + XCTAssertEqual(context.fileToExport?.url, mediaItem.fileHandle?.url) } func testDismiss() async throws { @@ -266,11 +270,11 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true } let initialItem = context.viewState.dataSource.previewController(QLPreviewController(), previewItemAt: context.viewState.dataSource.initialItemIndex) - guard let initialPreviewItem = initialItem as? TimelineMediaPreviewItem else { - XCTFail("1234") + guard let initialPreviewItem = initialItem as? TimelineMediaPreviewItem.Media else { + XCTFail("The initial item should be a media preview.") return } - context.send(viewAction: .updateCurrentItem(initialPreviewItem)) + context.send(viewAction: .updateCurrentItem(.media(initialPreviewItem))) try await deferred.fulfill() }