Media Browser: Listen to the timeline in the preview screen (#3707)

* Listen to the timeline to load more items in the media preview screen.

* Fix the view model tests.

* Fix tests
This commit is contained in:
Doug 2025-01-27 16:22:49 +00:00 committed by GitHub
parent b477a32d2e
commit c6338064b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 710 additions and 326 deletions

View File

@ -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 = "<group>"; };
B16048D30F0438731C41F775 /* StateRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateRoomTimelineItem.swift; sourceTree = "<group>"; };
B172057567E049007A5C4D92 /* Strings+SAS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Strings+SAS.swift"; sourceTree = "<group>"; };
B18A454132A5A5247802821E /* TimelineMediaPreviewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewDataSource.swift; sourceTree = "<group>"; };
B1E227F34BE43B08E098796E /* TestablePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestablePreview.swift; sourceTree = "<group>"; };
B251F5B4511D1CA0BA8361FE /* CoordinatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinatorProtocol.swift; sourceTree = "<group>"; };
B2AD8A56CD37E23071A2F4BF /* PHGPostHogMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogMock.swift; sourceTree = "<group>"; };
@ -2462,6 +2465,7 @@
ECD5FCBA169B6A82F501CA1B /* AnalyticsSettingsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreenViewModelProtocol.swift; sourceTree = "<group>"; };
ECF79FB25E2D4BD6F50CE7C9 /* RoomMembersListScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenViewModel.swift; sourceTree = "<group>"; };
ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRoomCell.swift; sourceTree = "<group>"; };
ED0AD0C652385F69FA90FAF5 /* TimelineMediaPreviewDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewDataSourceTests.swift; sourceTree = "<group>"; };
ED0CBEAB5F796BEFBAF7BB6A /* VideoRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRoomTimelineView.swift; sourceTree = "<group>"; };
ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = "<group>"; };
ED33988DA4FD4FC666800106 /* SessionVerificationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenViewModel.swift; sourceTree = "<group>"; };
@ -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 */,

View File

@ -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<Void, Never>()
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.
}

View File

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

View File

@ -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,13 +83,14 @@ 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 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):
@ -98,10 +103,12 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
}
}
}
}
private func rebuildCurrentItemActions() {
let timelineContext = timelineViewModel.context
let provider = TimelineItemMenuActionProvider(timelineItem: state.currentItem.timelineItem,
state.currentItemActions = if let currentItem = state.currentItem {
TimelineItemMenuActionProvider(timelineItem: currentItem.timelineItem,
canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf,
canCurrentUserRedactOthers: timelineContext.viewState.canCurrentUserRedactOthers,
canCurrentUserPin: timelineContext.viewState.canCurrentUserPin,
@ -110,11 +117,14 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled,
timelineKind: timelineContext.viewState.timelineKind,
emojiProvider: timelineContext.viewState.emojiProvider)
state.currentItemActions = provider.makeActions()
.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.

View File

@ -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,

View File

@ -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 {

View File

@ -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,7 +55,9 @@ struct TimelineMediaPreviewScreen: View {
.safeAreaInset(edge: .bottom, spacing: 0) { caption }
}
@ViewBuilder
private var fullScreenButton: some View {
if currentItem != nil {
Button {
withAnimation { isFullScreen.toggle() }
} label: {
@ -67,10 +69,11 @@ struct TimelineMediaPreviewScreen: View {
.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,6 +133,7 @@ struct TimelineMediaPreviewScreen: View {
toolbarHeader
}
if currentItem != nil {
ToolbarItem(placement: .primaryAction) {
Button { context.send(viewAction: .showCurrentItemDetails) } label: {
CompoundIcon(\.info)
@ -137,8 +141,11 @@ struct TimelineMediaPreviewScreen: View {
.tint(.compound.textActionPrimary)
}
}
}
@ViewBuilder
private var toolbarHeader: some View {
if let currentItem {
VStack(spacing: 0) {
Text(currentItem.sender.displayName ?? currentItem.sender.id)
.font(.compound.bodySMSemibold)
@ -148,6 +155,11 @@ struct TimelineMediaPreviewScreen: View {
.foregroundStyle(.compound.textPrimary)
.textCase(.uppercase)
}
} else {
Text(L10n.commonLoadingMore)
.font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textPrimary)
}
}
}
@ -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<AnyCancellable> = []
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<AnyCancellable> = []
init(coordinator: Coordinator, fileLoadedPublisher: AnyPublisher<TimelineItemIdentifier, Never>) {
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
viewModelContext.viewState.dataSource.previewItemsPaginationPublisher
.sink { [weak self] in
self?.handleUpdatedItems()
}
.store(in: &cancellables)
viewModelContext.viewState.fileLoadedPublisher
.sink { [weak self] itemID in
guard let self, (currentPreviewItem as? TimelineMediaPreviewItem)?.id == itemID else { return }
refreshCurrentPreviewItem()
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()
}
}
}

View File

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

View File

@ -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,7 +294,8 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
appMediator: AppMediatorMock())
}
private let items: [EventBasedMessageTimelineItemProtocol] = [
private func makeItems() -> [EventBasedMessageTimelineItemProtocol] {
[
ImageRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
@ -265,4 +332,5 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
thumbnailSource: nil,
contentType: .pdf))
]
}
}