diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index d705b853b..0c0eeca75 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -217,6 +217,7 @@ 2955F4C160CFD7794D819C64 /* EffectsScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024F7398C5FC12586FB10E9D /* EffectsScene.swift */; }; 298F9EC30E918F12AB7F1EE8 /* TypingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81F0325E252B057FAEEE1B2D /* TypingIndicatorView.swift */; }; 29EE1791E0AFA1ABB7F23D2F /* SwiftState in Frameworks */ = {isa = PBXBuildFile; productRef = 3853B78FB8531B83936C5DA6 /* SwiftState */; }; + 2A56B00B070F83E0FE571193 /* TimelineMediaPreviewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B18A454132A5A5247802821E /* TimelineMediaPreviewDataSource.swift */; }; 2A864BB12A8501B47805D828 /* AuthenticationFlowCoordinatorUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 295E28C3B9EAADF519BF2F44 /* AuthenticationFlowCoordinatorUITests.swift */; }; 2AAB2A77F1762A2648078A30 /* InteractiveQuickLook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638A81B97D51591D0FCFA598 /* InteractiveQuickLook.swift */; }; 2AB9D4146C8748CF1D007B67 /* test_pdf.pdf in Resources */ = {isa = PBXBuildFile; fileRef = BE98688578F8B0541D853695 /* test_pdf.pdf */; }; @@ -955,6 +956,7 @@ BFEB24336DFD5F196E6F3456 /* IntentionalMentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DF5CBAF69BDF5DF31C661E1 /* IntentionalMentions.swift */; }; C0090506A52A1991BAF4BA68 /* NotificationSettingsChatType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07579F9C29001E40715F3014 /* NotificationSettingsChatType.swift */; }; C022284E2774A5E1EF683B4D /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DF593C3F7AF4B2FBAEB05D /* FileManager.swift */; }; + C02DE5F62C81FB9E173C3D2F /* TimelineMediaPreviewDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED0AD0C652385F69FA90FAF5 /* TimelineMediaPreviewDataSourceTests.swift */; }; C051475DFF4C8EBDDF4DC8E4 /* StartChatScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B99E13633862847D8B7E2815 /* StartChatScreenModels.swift */; }; C08AAE7563E0722C9383F51C /* RoomMembersListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B8E176484A89BAC389D4076 /* RoomMembersListScreen.swift */; }; C0B97FFEC0083F3A36609E61 /* TimelineItemMacContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = A243A6E6207297123E60DE48 /* TimelineItemMacContextMenu.swift */; }; @@ -2179,6 +2181,7 @@ B14B1DE3E2D5D26732C49036 /* RoomChangeRolesScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomChangeRolesScreenViewModel.swift; sourceTree = ""; }; B16048D30F0438731C41F775 /* StateRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateRoomTimelineItem.swift; sourceTree = ""; }; B172057567E049007A5C4D92 /* Strings+SAS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Strings+SAS.swift"; sourceTree = ""; }; + B18A454132A5A5247802821E /* TimelineMediaPreviewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewDataSource.swift; sourceTree = ""; }; B1E227F34BE43B08E098796E /* TestablePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestablePreview.swift; sourceTree = ""; }; B251F5B4511D1CA0BA8361FE /* CoordinatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinatorProtocol.swift; sourceTree = ""; }; B2AD8A56CD37E23071A2F4BF /* PHGPostHogMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogMock.swift; sourceTree = ""; }; @@ -2462,6 +2465,7 @@ ECD5FCBA169B6A82F501CA1B /* AnalyticsSettingsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreenViewModelProtocol.swift; sourceTree = ""; }; ECF79FB25E2D4BD6F50CE7C9 /* RoomMembersListScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenViewModel.swift; sourceTree = ""; }; ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRoomCell.swift; sourceTree = ""; }; + ED0AD0C652385F69FA90FAF5 /* TimelineMediaPreviewDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewDataSourceTests.swift; sourceTree = ""; }; ED0CBEAB5F796BEFBAF7BB6A /* VideoRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRoomTimelineView.swift; sourceTree = ""; }; ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = ""; }; ED33988DA4FD4FC666800106 /* SessionVerificationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenViewModel.swift; sourceTree = ""; }; @@ -3527,6 +3531,7 @@ 638A81B97D51591D0FCFA598 /* InteractiveQuickLook.swift */, E7495E1119753B06FF2C2279 /* PhotoLibraryManager.swift */, E3A62FBD3007312311C14DD8 /* TimelineMediaPreviewCoordinator.swift */, + B18A454132A5A5247802821E /* TimelineMediaPreviewDataSource.swift */, 2A2BB38DF61F5100B8723112 /* TimelineMediaPreviewModels.swift */, 53F41CEAAE2BB4E74CDC2278 /* TimelineMediaPreviewViewModel.swift */, 5EC4A8482DA110602FE6DF42 /* View */, @@ -4249,6 +4254,7 @@ 2CEBCB9676FCD1D0F13188DD /* StringTests.swift */, 2AB2C848BB9A7A9B618B7B89 /* TextBasedRoomTimelineTests.swift */, 9AA3AF94A06D319BB37E52DA /* TimelineItemFactoryTests.swift */, + ED0AD0C652385F69FA90FAF5 /* TimelineMediaPreviewDataSourceTests.swift */, 5C1F000589F2CEE6B03ECFAB /* TimelineMediaPreviewViewModelTests.swift */, 6509708F54FC883604DFDC95 /* TimelineViewModelTests.swift */, 76310030C831D4610A705603 /* URLComponentsTests.swift */, @@ -6729,6 +6735,7 @@ E75CE800B3E64D0F7F8E228D /* TemplateScreenViewModelTests.swift in Sources */, 3A7DD0D13B0FB8876D69D829 /* TextBasedRoomTimelineTests.swift in Sources */, 0D4EB2ABAA5FE8CB10FDBCB8 /* TimelineItemFactoryTests.swift in Sources */, + C02DE5F62C81FB9E173C3D2F /* TimelineMediaPreviewDataSourceTests.swift in Sources */, F6BF52CB027393EE03CEC523 /* TimelineMediaPreviewViewModelTests.swift in Sources */, 2F6207CB5C4715FE313B1E95 /* TimelineViewModelTests.swift in Sources */, 8E650379587C31D7912ED67B /* UNNotification+Creator.swift in Sources */, @@ -7568,6 +7575,7 @@ EFBBD44C0A16F017C32D2099 /* TimelineItemStatusView.swift in Sources */, 562EFB9AB62B38830D9AA778 /* TimelineMediaFrame.swift in Sources */, FE43747C116CA3D8D6B92F5F /* TimelineMediaPreviewCoordinator.swift in Sources */, + 2A56B00B070F83E0FE571193 /* TimelineMediaPreviewDataSource.swift in Sources */, 12EC6BC99F373FE5C6EB9B64 /* TimelineMediaPreviewDetailsView.swift in Sources */, 4ED764A24F2A715C25CF07F1 /* TimelineMediaPreviewFileExportPicker.swift in Sources */, 77FB08C303F4C74C0E8577E2 /* TimelineMediaPreviewModels.swift in Sources */, diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift new file mode 100644 index 000000000..be19b1137 --- /dev/null +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift @@ -0,0 +1,264 @@ +// +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import Foundation +import QuickLook + +/// A dedicated data source for QLPreviewController to support timeline updates. This was added to +/// workaround the fact that calling `reloadData` on the controller **always** reloads the current +/// item (even if hasn't changed), so any interaction (zoom, media playback, scroll position) would be +/// lost. +/// +/// This data source pads the initial array with 100 spaces before and after, adding any pagination into +/// this fixed space. This removes the need to reload the data and preserves the current item's index +/// in the data. +class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource { + /// All of the items in the timeline that can be previewed. + private(set) var previewItems: [TimelineMediaPreviewItem] + let previewItemsPaginationPublisher = PassthroughSubject() + + private let initialItem: EventBasedMessageTimelineItemProtocol + /// The index of the initial item inside of `previewItems` that is to be shown. + let initialItemIndex: Int + + /// The media item that is currently being previewed. + 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) + self.initialItem = initialItem + + let initialItemArrayIndex = previewItems.firstIndex { $0.id == initialItem.id } ?? 0 + initialItemIndex = initialItemArrayIndex + initialPadding + currentItem = previewItems[initialItemArrayIndex] + + backwardPadding = initialPadding + forwardPadding = initialPadding + } + + 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 } + + // If an item already exists use that instead to preserve the file handle, download error etc. + if let oldItem = previewItems.first(where: { $0.id == newItem.id }) { + oldItem.timelineItem = newItem.timelineItem + return oldItem + } + + return newItem + } + + var hasPaginated = false + if let range = newItems.map(\.id).firstRange(of: previewItems.map(\.id)) { + let backPaginationCount = range.lowerBound + let forwardPaginationCount = newItems.indices.upperBound - range.upperBound + + // Don't worry about negative padding here. Turns out that it just limits + // the displayable items from growing any more, but makes sure that the + // current item doesn't jump around so we don't need to reload anything. + backwardPadding -= backPaginationCount + forwardPadding -= forwardPaginationCount + + if backPaginationCount > 0 || forwardPaginationCount > 0 { + 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 + // media but this is probably less jarring overall. I hate QLPreviewController! + } + + previewItems = newItems + + if hasPaginated { + previewItemsPaginationPublisher.send() + } + } + + // MARK: - QLPreviewControllerDataSource + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + previewItems.count + backwardPadding + forwardPadding + } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> any QLPreviewItem { + let arrayIndex = index - backwardPadding + + if arrayIndex >= 0, arrayIndex < previewItems.count { + return previewItems[arrayIndex] + } else { + return TimelineMediaPreviewLoadingItem.shared + } + } +} + +// 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? + + 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 + } + + 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 + } + } +} + +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 ecf9dcdaf..9e0aa0a73 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift @@ -6,7 +6,6 @@ // import Combine -import QuickLook import SwiftUI enum TimelineMediaPreviewViewModelAction: Equatable { @@ -15,13 +14,11 @@ enum TimelineMediaPreviewViewModelAction: Equatable { } struct TimelineMediaPreviewViewState: BindableState { - /// All of the items in the timeline that can be previewed. - var previewItems: [TimelineMediaPreviewItem] - /// The index of the initial item inside of `previewItems` that is to be shown. - let initialItemIndex: Int + /// The data source for all of the preview-able items. + var dataSource: TimelineMediaPreviewDataSource /// The media item that is currently being previewed. - var currentItem: TimelineMediaPreviewItem + var currentItem: TimelineMediaPreviewItem? { dataSource.currentItem } /// All of the available actions for the current item. var currentItemActions: TimelineItemMenuActions? @@ -48,156 +45,8 @@ enum TimelineMediaPreviewAlertType { case authorizationRequired } -/// Wraps a media file and title to be previewed with QuickLook. -class TimelineMediaPreviewItem: NSObject, QLPreviewItem, Identifiable { - let timelineItem: EventBasedMessageTimelineItemProtocol - var fileHandle: MediaFileHandleProxy? - var downloadError: Error? - - 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 - } - - 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 - } - } -} - enum TimelineMediaPreviewViewAction { - case updateCurrentItem(TimelineMediaPreviewItem) + case updateCurrentItem(TimelineMediaPreviewItem?) case showCurrentItemDetails case menuAction(TimelineItemMenuAction, item: TimelineMediaPreviewItem) case redactConfirmation(item: TimelineMediaPreviewItem) diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift index 6b0e28059..2ad7f6157 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift @@ -35,24 +35,28 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { self.userIndicatorController = userIndicatorController self.appMediator = appMediator - let previewItems = timelineViewModel.context.viewState.timelineState.itemViewStates.compactMap(TimelineMediaPreviewItem.init) - let initialItemIndex = previewItems.firstIndex { $0.id == context.item.id } ?? 0 - let currentItem = previewItems[initialItemIndex] - - super.init(initialViewState: TimelineMediaPreviewViewState(previewItems: previewItems, - initialItemIndex: initialItemIndex, - currentItem: currentItem, + super.init(initialViewState: TimelineMediaPreviewViewState(dataSource: .init(itemViewStates: timelineViewModel.context.viewState.timelineState.itemViewStates, + initialItem: context.item), transitionNamespace: context.namespace), mediaProvider: mediaProvider) rebuildCurrentItemActions() - timelineViewModel.context.$viewState.map(\.canCurrentUserRedactSelf) - .merge(with: timelineViewModel.context.$viewState.map(\.canCurrentUserRedactOthers)) + let canRedactSelfPublisher = timelineViewModel.context.$viewState.map(\.canCurrentUserRedactSelf) + let canRedactOthersPublisher = timelineViewModel.context.$viewState.map(\.canCurrentUserRedactOthers) + + canRedactSelfPublisher.merge(with: canRedactOthersPublisher) .sink { [weak self] _ in self?.rebuildCurrentItemActions() } .store(in: &cancellables) + + timelineViewModel.context.$viewState.map(\.timelineState.itemViewStates) + .removeDuplicates() + .sink { [weak self] itemViewStates in + self?.state.dataSource.updatePreviewItems(itemViewStates: itemViewStates) + } + .store(in: &cancellables) } override func process(viewAction: TimelineMediaPreviewViewAction) { @@ -79,42 +83,48 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { } } - private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async { - previewItem.downloadError = nil // Clear any existing error. - state.currentItem = previewItem - currentItemIDHandler?(previewItem.id) - + private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem?) async { + previewItem?.downloadError = nil // Clear any existing error. + state.dataSource.updateCurrentItem(previewItem) rebuildCurrentItemActions() - if previewItem.fileHandle == nil, let source = previewItem.mediaSource { - switch await mediaProvider.loadFileFromSource(source, filename: previewItem.filename) { - case .success(let handle): - previewItem.fileHandle = handle - state.fileLoadedPublisher.send(previewItem.id) - case .failure(let error): - MXLog.error("Failed loading media: \(error)") - context.objectWillChange.send() // Manually trigger the SwiftUI view update. - previewItem.downloadError = error + if let previewItem { + currentItemIDHandler?(previewItem.id) + + if previewItem.fileHandle == nil, let source = previewItem.mediaSource { + switch await mediaProvider.loadFileFromSource(source, filename: previewItem.filename) { + case .success(let handle): + previewItem.fileHandle = handle + state.fileLoadedPublisher.send(previewItem.id) + case .failure(let error): + MXLog.error("Failed loading media: \(error)") + context.objectWillChange.send() // Manually trigger the SwiftUI view update. + previewItem.downloadError = error + } } } } private func rebuildCurrentItemActions() { let timelineContext = timelineViewModel.context - let provider = TimelineItemMenuActionProvider(timelineItem: state.currentItem.timelineItem, - canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf, - canCurrentUserRedactOthers: timelineContext.viewState.canCurrentUserRedactOthers, - canCurrentUserPin: timelineContext.viewState.canCurrentUserPin, - pinnedEventIDs: timelineContext.viewState.pinnedEventIDs, - isDM: timelineContext.viewState.isEncryptedOneToOneRoom, - isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled, - timelineKind: timelineContext.viewState.timelineKind, - emojiProvider: timelineContext.viewState.emojiProvider) - state.currentItemActions = provider.makeActions() + state.currentItemActions = if let currentItem = state.currentItem { + TimelineItemMenuActionProvider(timelineItem: currentItem.timelineItem, + canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf, + canCurrentUserRedactOthers: timelineContext.viewState.canCurrentUserRedactOthers, + canCurrentUserPin: timelineContext.viewState.canCurrentUserPin, + pinnedEventIDs: timelineContext.viewState.pinnedEventIDs, + isDM: timelineContext.viewState.isEncryptedOneToOneRoom, + isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled, + timelineKind: timelineContext.viewState.timelineKind, + emojiProvider: timelineContext.viewState.emojiProvider) + .makeActions() + } else { + nil + } } private func saveCurrentItem() async { - guard let fileURL = state.currentItem.fileHandle?.url else { + guard let currentItem = state.currentItem, let fileURL = currentItem.fileHandle?.url else { MXLog.error("Unable to save an item without a URL, the button shouldn't be visible.") return } @@ -123,7 +133,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { state.bindings.mediaDetailsItem = nil do { - switch state.currentItem.timelineItem { + switch currentItem.timelineItem { case is AudioRoomTimelineItem, is FileRoomTimelineItem: state.bindings.fileToExport = .init(url: fileURL) return // Don't show the indicator. diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift index 4b1327a16..04ef6bf0c 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift @@ -177,27 +177,29 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie static let presentedOnRoomViewModel = makeViewModel(isPresentedOnRoomScreen: true) static var previews: some View { - TimelineMediaPreviewDetailsView(item: viewModel.state.currentItem, + // 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 }) - TimelineMediaPreviewDetailsView(item: loadingViewModel.state.currentItem, + TimelineMediaPreviewDetailsView(item: loadingViewModel.state.currentItem!, context: loadingViewModel.context) .previewDisplayName("Loading") .snapshotPreferences(expect: loadingViewModel.context.$viewState.map { state in state.currentItemActions?.secondaryActions.contains(.redact) ?? false }) - TimelineMediaPreviewDetailsView(item: unknownTypeViewModel.state.currentItem, + TimelineMediaPreviewDetailsView(item: unknownTypeViewModel.state.currentItem!, context: unknownTypeViewModel.context) .previewDisplayName("Unknown type") - TimelineMediaPreviewDetailsView(item: presentedOnRoomViewModel.state.currentItem, + TimelineMediaPreviewDetailsView(item: presentedOnRoomViewModel.state.currentItem!, context: presentedOnRoomViewModel.context) .previewDisplayName("Incoming on Room") + // swiftlint:enable force_unwrapping } 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 a0b7799d7..b4b2e4525 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift @@ -125,7 +125,8 @@ struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, Tes static let viewModel = makeViewModel(contentType: .jpeg) static var previews: some View { - TimelineMediaPreviewRedactConfirmationView(item: viewModel.state.currentItem, context: viewModel.context) + // swiftlint:disable:next force_unwrapping + TimelineMediaPreviewRedactConfirmationView(item: viewModel.state.currentItem!, 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 8668ab721..a55c0c1d9 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewScreen.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewScreen.swift @@ -17,7 +17,7 @@ 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 } var body: some View { NavigationStack { @@ -40,7 +40,7 @@ struct TimelineMediaPreviewScreen: View { .onDisappear { itemIDHandler?(nil) } - .zoomTransition(sourceID: currentItem.id, in: context.viewState.transitionNamespace) + .zoomTransition(sourceID: currentItem?.id, in: context.viewState.transitionNamespace) } var quickLookPreview: some View { @@ -55,22 +55,25 @@ struct TimelineMediaPreviewScreen: View { .safeAreaInset(edge: .bottom, spacing: 0) { caption } } + @ViewBuilder private var fullScreenButton: some View { - Button { - withAnimation { isFullScreen.toggle() } - } label: { - CompoundIcon(isFullScreen ? \.collapse : \.expand, size: .xSmall, relativeTo: .compound.bodyLG) - .padding(6) - .background(.thinMaterial, in: Circle()) + if currentItem != nil { + Button { + withAnimation { isFullScreen.toggle() } + } label: { + CompoundIcon(isFullScreen ? \.collapse : \.expand, size: .xSmall, relativeTo: .compound.bodyLG) + .padding(6) + .background(.thinMaterial, in: Circle()) + } + .tint(.compound.textActionPrimary) + .padding(.top, 12) + .padding(.trailing, 14) } - .tint(.compound.textActionPrimary) - .padding(.top, 12) - .padding(.trailing, 14) } @ViewBuilder private var downloadStatusIndicator: some View { - if currentItem.downloadError != nil { + if currentItem?.downloadError != nil { VStack(spacing: 24) { CompoundIcon(\.error, size: .custom(48), relativeTo: .compound.headingLG) .foregroundStyle(.compound.iconCriticalPrimary) @@ -91,7 +94,7 @@ struct TimelineMediaPreviewScreen: View { .padding(.horizontal, 24) .padding(.vertical, 40) .background(.compound.bgSubtlePrimary, in: RoundedRectangle(cornerRadius: 14)) - } else if currentItem.fileHandle == nil { + } else if currentItem?.fileHandle == nil { ProgressView() .controlSize(.large) .tint(.compound.iconPrimary) @@ -100,7 +103,7 @@ struct TimelineMediaPreviewScreen: View { @ViewBuilder private var caption: some View { - if let caption = currentItem.caption, !isFullScreen { + if let caption = currentItem?.caption, !isFullScreen { Text(caption) .font(.compound.bodyLG) .foregroundStyle(.compound.textPrimary) @@ -130,23 +133,32 @@ struct TimelineMediaPreviewScreen: View { toolbarHeader } - ToolbarItem(placement: .primaryAction) { - Button { context.send(viewAction: .showCurrentItemDetails) } label: { - CompoundIcon(\.info) + if currentItem != nil { + ToolbarItem(placement: .primaryAction) { + Button { context.send(viewAction: .showCurrentItemDetails) } label: { + CompoundIcon(\.info) + } + .tint(.compound.textActionPrimary) } - .tint(.compound.textActionPrimary) } } + @ViewBuilder private var toolbarHeader: some View { - VStack(spacing: 0) { - Text(currentItem.sender.displayName ?? currentItem.sender.id) + if let currentItem { + VStack(spacing: 0) { + Text(currentItem.sender.displayName ?? currentItem.sender.id) + .font(.compound.bodySMSemibold) + .foregroundStyle(.compound.textPrimary) + Text(currentItem.timestamp.formatted(date: .abbreviated, time: .omitted)) + .font(.compound.bodyXS) + .foregroundStyle(.compound.textPrimary) + .textCase(.uppercase) + } + } else { + Text(L10n.commonLoadingMore) .font(.compound.bodySMSemibold) .foregroundStyle(.compound.textPrimary) - Text(currentItem.timestamp.formatted(date: .abbreviated, time: .omitted)) - .font(.compound.bodyXS) - .foregroundStyle(.compound.textPrimary) - .textCase(.uppercase) } } } @@ -156,14 +168,11 @@ struct TimelineMediaPreviewScreen: View { private struct QuickLookView: UIViewControllerRepresentable { let viewModelContext: TimelineMediaPreviewViewModel.Context - func makeUIViewController(context: Context) -> PreviewController { - let fileLoadedPublisher = viewModelContext.viewState.fileLoadedPublisher.eraseToAnyPublisher() - let controller = PreviewController(coordinator: context.coordinator, fileLoadedPublisher: fileLoadedPublisher) - controller.currentPreviewItemIndex = viewModelContext.viewState.initialItemIndex - return controller + func makeUIViewController(context: Context) -> QLPreviewController { + context.coordinator.previewController } - func updateUIViewController(_ uiViewController: PreviewController, context: Context) { } + func updateUIViewController(_ uiViewController: QLPreviewController, context: Context) { } func makeCoordinator() -> Coordinator { Coordinator(viewModelContext: viewModelContext) @@ -171,55 +180,57 @@ private struct QuickLookView: UIViewControllerRepresentable { // MARK: Coordinator - class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate { + @MainActor class Coordinator { + let previewController = QLPreviewController() + private let viewModelContext: TimelineMediaPreviewViewModel.Context + private var cancellables: Set = [] + init(viewModelContext: TimelineMediaPreviewViewModel.Context) { self.viewModelContext = viewModelContext - } - - func updateCurrentItem(_ item: TimelineMediaPreviewItem) { - viewModelContext.send(viewAction: .updateCurrentItem(item)) - } - - func numberOfPreviewItems(in controller: QLPreviewController) -> Int { - viewModelContext.viewState.previewItems.count - } - - func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { - viewModelContext.viewState.previewItems[index] - } - } - - // MARK: UIKit - - class PreviewController: QLPreviewController { - private var cancellables: Set = [] - - init(coordinator: Coordinator, fileLoadedPublisher: AnyPublisher) { - super.init(nibName: nil, bundle: nil) - - dataSource = coordinator - delegate = coordinator // Observation of currentPreviewItem doesn't work, so use the index instead. - publisher(for: \.currentPreviewItemIndex) + previewController.publisher(for: \.currentPreviewItemIndex) .sink { [weak self] _ in - guard let self, let currentPreviewItem = currentPreviewItem as? TimelineMediaPreviewItem else { return } - coordinator.updateCurrentItem(currentPreviewItem) + // This isn't removing duplicates which may try to download and/or write to disk concurrently???? + self?.loadCurrentItem() } .store(in: &cancellables) - fileLoadedPublisher - .sink { [weak self] itemID in - guard let self, (currentPreviewItem as? TimelineMediaPreviewItem)?.id == itemID else { return } - refreshCurrentPreviewItem() + viewModelContext.viewState.dataSource.previewItemsPaginationPublisher + .sink { [weak self] in + self?.handleUpdatedItems() } .store(in: &cancellables) + + viewModelContext.viewState.fileLoadedPublisher + .sink { [weak self] itemID in + self?.handleFileLoaded(itemID: itemID) + } + .store(in: &cancellables) + + previewController.dataSource = viewModelContext.viewState.dataSource + previewController.currentPreviewItemIndex = viewModelContext.viewState.dataSource.initialItemIndex } - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + private func loadCurrentItem() { + viewModelContext.send(viewAction: .updateCurrentItem(previewController.currentPreviewItem as? TimelineMediaPreviewItem)) + } + + private func handleUpdatedItems() { + if previewController.currentPreviewItem is TimelineMediaPreviewLoadingItem { + let dataSource = viewModelContext.viewState.dataSource + if dataSource.previewController(previewController, previewItemAt: previewController.currentPreviewItemIndex) is TimelineMediaPreviewItem { + previewController.refreshCurrentPreviewItem() // This will trigger loadCurrentItem automatically. + } + } + } + + private func handleFileLoaded(itemID: TimelineItemIdentifier) { + guard (previewController.currentPreviewItem as? TimelineMediaPreviewItem)?.id == itemID else { return } + previewController.refreshCurrentPreviewItem() + } } } diff --git a/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift b/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift new file mode 100644 index 000000000..ed2e09f5e --- /dev/null +++ b/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift @@ -0,0 +1,171 @@ +// +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +@testable import ElementX +import QuickLook +import XCTest + +@MainActor +class TimelineMediaPreviewDataSourceTests: XCTestCase { + var initialMediaItems: [EventBasedMessageTimelineItemProtocol]! + var initialMediaViewStates: [RoomTimelineItemViewState]! + let initialItemIndex = 2 + + var initialPadding = 100 + let previewController = QLPreviewController() + + override func setUp() { + initialMediaItems = newChunk() + initialMediaViewStates = initialMediaItems.map { RoomTimelineItemViewState(item: $0, groupStyle: .single) } + } + + func testInitialItems() -> TimelineMediaPreviewDataSource { + // Given a data source built with the initial items. + let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates, + initialItem: initialMediaItems[initialItemIndex], + initialPadding: initialPadding) + + // When the preview controller displays the data. + let previewItemCount = dataSource.numberOfPreviewItems(in: previewController) + let displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem + + // 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.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.") + + return dataSource + } + + func testCurrentUpdateItem() { + // Given a data source built with the initial items. + let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates, initialItem: initialMediaItems[initialItemIndex]) + + // 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) + + // Then the data source should reflect the change of item. + XCTAssertEqual(dataSource.currentItem?.id, 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) + + // Then the data source should indicate that no item is being displayed. + XCTAssertNil(dataSource.currentItem, "The current item should be nil.") + } + + func testUpdatedItems() async throws { + // Given a data source built with the initial items. + let dataSource = testInitialItems() + + // When one of the items changes but no pagination has occurred. + let deferred = deferFailure(dataSource.previewItemsPaginationPublisher, timeout: 1) { _ in true } + dataSource.updatePreviewItems(itemViewStates: initialMediaViewStates) + + // Then no pagination should be detected and none of the data should have changed. + try await deferred.fulfill() + + let previewItemCount = dataSource.numberOfPreviewItems(in: previewController) + let displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem + 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.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.") + } + + func testPagination() async throws { + // Given a data source built with the initial items. + let dataSource = testInitialItems() + + // When more items are loaded in a back pagination. + var deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true } + let backPaginationChunk = newChunk().map { RoomTimelineItemViewState(item: $0, groupStyle: .single) } + var newViewStates = backPaginationChunk + initialMediaViewStates + dataSource.updatePreviewItems(itemViewStates: newViewStates) + + // Then the new items should be added but the displayed item should not change or move in the array. + try await deferred.fulfill() + 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 + 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(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") + + // When more items are loaded in a forward pagination or sync. + deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true } + let forwardPaginationChunk = newChunk().map { RoomTimelineItemViewState(item: $0, groupStyle: .single) } + newViewStates += forwardPaginationChunk + dataSource.updatePreviewItems(itemViewStates: newViewStates) + + // Then the new items should be added but the displayed item should not change or move in the array. + try await deferred.fulfill() + 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 + 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(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") + } + + func testPaginationLimits() async throws { + // Given a data source with a small amount of padding remaining. + initialPadding = 2 + let dataSource = testInitialItems() + + // When paginating backwards by more than the available padding. + var deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true } + let backPaginationChunk = newChunk().map { RoomTimelineItemViewState(item: $0, groupStyle: .single) } + var newViewStates = backPaginationChunk + initialMediaViewStates + XCTAssertTrue(newViewStates.count > initialPadding) + dataSource.updatePreviewItems(itemViewStates: newViewStates) + + // Then all the items should be added but the preview-able count shouldn't grow and displayed item should not change or move. + try await deferred.fulfill() + 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 + 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(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") + + // When paginating forwards by more than the available padding. + deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true } + let forwardPaginationChunk = newChunk().map { RoomTimelineItemViewState(item: $0, groupStyle: .single) } + newViewStates += forwardPaginationChunk + dataSource.updatePreviewItems(itemViewStates: newViewStates) + + // Then all the items should be added but the preview-able count shouldn't grow and displayed item should not change or move. + try await deferred.fulfill() + 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 + 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(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") + } + + // MARK: Helpers + + func newChunk() -> [EventBasedMessageTimelineItemProtocol] { + RoomTimelineItemFixtures.mediaChunk + .compactMap { $0 as? EventBasedMessageTimelineItemProtocol } + .filter(\.supportsMediaCaption) // Voice messages can't be previewed (and don't support captions). + } +} diff --git a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift index bfc20196a..15e13b09e 100644 --- a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift +++ b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift @@ -9,6 +9,7 @@ import Combine import MatrixRustSDK +import QuickLook import SwiftUI import XCTest @@ -24,7 +25,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { // Given a fresh view model. setupViewModel() XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled) - XCTAssertEqual(context.viewState.currentItem, context.viewState.previewItems[0]) + XCTAssertEqual(context.viewState.currentItem, context.viewState.dataSource.previewItems[0]) XCTAssertNotNil(context.viewState.currentItemActions) // When the preview controller sets the current item. @@ -32,27 +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.previewItems[0]) + XCTAssertEqual(context.viewState.currentItem, 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 { + XCTFail("There should be a current item") + return + } + XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled) - XCTAssertEqual(context.viewState.currentItem, context.viewState.previewItems[0]) - XCTAssertNil(context.viewState.currentItem.downloadError) + XCTAssertEqual(currentItem, context.viewState.dataSource.previewItems[0]) + XCTAssertNil(currentItem.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.previewItems[0])) + context.send(viewAction: .updateCurrentItem(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(context.viewState.currentItem, context.viewState.previewItems[0]) - XCTAssertNotNil(context.viewState.currentItem.downloadError) + XCTAssertEqual(currentItem, context.viewState.dataSource.previewItems[0]) + XCTAssertNotNil(currentItem.downloadError) } func testSwipingBetweenItems() async throws { @@ -61,21 +67,57 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { // When swiping to another item. let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true } - context.send(viewAction: .updateCurrentItem(context.viewState.previewItems[1])) + context.send(viewAction: .updateCurrentItem(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.previewItems[1]) + XCTAssertEqual(context.viewState.currentItem, 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.previewItems[0])) + context.send(viewAction: .updateCurrentItem(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.previewItems[0]) + XCTAssertEqual(context.viewState.currentItem, context.viewState.dataSource.previewItems[0]) + } + + func testLoadingMoreItem() async throws { + // Given a view model with a loaded item. + try await testLoadingItem() + + // When swiping to a "loading more" item. + let deferred = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true } + context.send(viewAction: .updateCurrentItem(nil)) + 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) + } + + func testPagination() async throws { + // Given a view model with a loaded item. + try await testLoadingItem() + XCTAssertEqual(context.viewState.dataSource.previewItems.count, 3) + + // When more items are added via a back pagination. + let deferred = deferFulfillment(context.viewState.dataSource.previewItemsPaginationPublisher) { _ in true } + timelineController.backPaginationResponses.append(makeItems()) + _ = await timelineController.paginateBackwards(requestSize: 20) + try await deferred.fulfill() + + // 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])) + try await failure.fulfill() + + // Then the current item shouldn't need to be reloaded. + XCTAssertEqual(context.viewState.dataSource.previewItems.count, 6) + XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 1) } func testViewInRoomTimeline() async throws { @@ -83,7 +125,11 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { try await testLoadingItem() // When choosing to view the current item in the timeline. - let item = context.viewState.currentItem + guard let item = 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)) @@ -126,28 +172,35 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { func testSaveImage() async throws { // Given a view model with a loaded image. try await testLoadingItem() - XCTAssertEqual(viewModel.state.currentItem.contentType, "JPEG image") + guard let currentItem = context.viewState.currentItem else { + XCTFail("There should be a current item") + return + } + XCTAssertEqual(currentItem.contentType, "JPEG image") // When choosing to save the image. - let item = context.viewState.currentItem - context.send(viewAction: .menuAction(.save, item: item)) + context.send(viewAction: .menuAction(.save, item: currentItem)) 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, item.fileHandle?.url) + XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, currentItem.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() - XCTAssertEqual(viewModel.state.currentItem.contentType, "JPEG image") + guard let currentItem = context.viewState.currentItem else { + XCTFail("There should be a current item") + return + } + XCTAssertEqual(currentItem.contentType, "JPEG image") // When choosing to save the image. let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil } - context.send(viewAction: .menuAction(.save, item: context.viewState.currentItem)) + context.send(viewAction: .menuAction(.save, item: currentItem)) try await deferred.fulfill() // Then the user should be prompted to allow access. @@ -159,34 +212,40 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { // Given a view model with a loaded video. setupViewModel(initialItemIndex: 1) try await loadInitialItem() - XCTAssertEqual(viewModel.state.currentItem.contentType, "MPEG-4 movie") + guard let currentItem = context.viewState.currentItem else { + XCTFail("There should be a current item") + return + } + XCTAssertEqual(currentItem.contentType, "MPEG-4 movie") // When choosing to save the video. - let item = context.viewState.currentItem - context.send(viewAction: .menuAction(.save, item: item)) + context.send(viewAction: .menuAction(.save, item: currentItem)) 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, item.fileHandle?.url) + XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, currentItem.fileHandle?.url) } func testSaveFile() async throws { // Given a view model with a loaded file. setupViewModel(initialItemIndex: 2) try await loadInitialItem() - XCTAssertEqual(viewModel.state.currentItem.contentType, "PDF document") + guard let currentItem = context.viewState.currentItem else { + XCTFail("There should be a current item") + return + } + XCTAssertEqual(currentItem.contentType, "PDF document") // When choosing to save the file. - let item = context.viewState.currentItem - context.send(viewAction: .menuAction(.save, item: item)) + context.send(viewAction: .menuAction(.save, item: currentItem)) 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, item.fileHandle?.url) + XCTAssertEqual(context.fileToExport?.url, currentItem.fileHandle?.url) } func testDismiss() async throws { @@ -205,20 +264,27 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { private func loadInitialItem() async throws { let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true } - context.send(viewAction: .updateCurrentItem(context.viewState.previewItems[context.viewState.initialItemIndex])) + let initialItem = context.viewState.dataSource.previewController(QLPreviewController(), + previewItemAt: context.viewState.dataSource.initialItemIndex) + guard let initialPreviewItem = initialItem as? TimelineMediaPreviewItem else { + XCTFail("1234") + return + } + context.send(viewAction: .updateCurrentItem(initialPreviewItem)) try await deferred.fulfill() } @Namespace private var testNamespace private func setupViewModel(initialItemIndex: Int = 0, photoLibraryAuthorizationDenied: Bool = false) { + let initialItems = makeItems() timelineController = MockRoomTimelineController(timelineKind: .media(.mediaFilesScreen)) - timelineController.timelineItems = items + timelineController.timelineItems = initialItems mediaProvider = MediaProviderMock(configuration: .init()) photoLibraryManager = PhotoLibraryManagerMock(.init(authorizationDenied: photoLibraryAuthorizationDenied)) - viewModel = TimelineMediaPreviewViewModel(context: .init(item: items[initialItemIndex], + viewModel = TimelineMediaPreviewViewModel(context: .init(item: initialItems[initialItemIndex], viewModel: TimelineViewModel.mock(timelineKind: .media(.mediaFilesScreen), timelineController: timelineController), namespace: testNamespace), @@ -228,41 +294,43 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { appMediator: AppMediatorMock()) } - private let items: [EventBasedMessageTimelineItemProtocol] = [ - ImageRoomTimelineItem(id: .randomEvent, - timestamp: .mock, - isOutgoing: false, - isEditable: false, - canBeRepliedTo: true, - isThreaded: false, - sender: .init(id: "", displayName: "Sally Sanderson"), - content: .init(filename: "Amazing image.jpeg", - caption: "A caption goes right here.", - imageInfo: .mockImage, - thumbnailInfo: .mockThumbnail, - contentType: .jpeg)), - VideoRoomTimelineItem(id: .randomEvent, - timestamp: .mock, - isOutgoing: false, - isEditable: false, - canBeRepliedTo: true, - isThreaded: false, - sender: .init(id: ""), - content: .init(filename: "Super video.mp4", - videoInfo: .mockVideo, - thumbnailInfo: .mockThumbnail, - contentType: .mpeg4Movie)), - FileRoomTimelineItem(id: .randomEvent, - timestamp: .mock, - isOutgoing: false, - isEditable: false, - canBeRepliedTo: true, - isThreaded: false, - sender: .init(id: ""), - content: .init(filename: "Important file.pdf", - source: try? .init(url: .mockMXCFile, mimeType: "document/pdf"), - fileSize: 2453, - thumbnailSource: nil, - contentType: .pdf)) - ] + private func makeItems() -> [EventBasedMessageTimelineItemProtocol] { + [ + ImageRoomTimelineItem(id: .randomEvent, + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + isThreaded: false, + sender: .init(id: "", displayName: "Sally Sanderson"), + content: .init(filename: "Amazing image.jpeg", + caption: "A caption goes right here.", + imageInfo: .mockImage, + thumbnailInfo: .mockThumbnail, + contentType: .jpeg)), + VideoRoomTimelineItem(id: .randomEvent, + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + isThreaded: false, + sender: .init(id: ""), + content: .init(filename: "Super video.mp4", + videoInfo: .mockVideo, + thumbnailInfo: .mockThumbnail, + contentType: .mpeg4Movie)), + FileRoomTimelineItem(id: .randomEvent, + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + isThreaded: false, + sender: .init(id: ""), + content: .init(filename: "Important file.pdf", + source: try? .init(url: .mockMXCFile, mimeType: "document/pdf"), + fileSize: 2453, + thumbnailSource: nil, + contentType: .pdf)) + ] + } }