mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-11 13:59:13 +00:00
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:
parent
b477a32d2e
commit
c6338064b6
@ -217,6 +217,7 @@
|
|||||||
2955F4C160CFD7794D819C64 /* EffectsScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024F7398C5FC12586FB10E9D /* EffectsScene.swift */; };
|
2955F4C160CFD7794D819C64 /* EffectsScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024F7398C5FC12586FB10E9D /* EffectsScene.swift */; };
|
||||||
298F9EC30E918F12AB7F1EE8 /* TypingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81F0325E252B057FAEEE1B2D /* TypingIndicatorView.swift */; };
|
298F9EC30E918F12AB7F1EE8 /* TypingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81F0325E252B057FAEEE1B2D /* TypingIndicatorView.swift */; };
|
||||||
29EE1791E0AFA1ABB7F23D2F /* SwiftState in Frameworks */ = {isa = PBXBuildFile; productRef = 3853B78FB8531B83936C5DA6 /* SwiftState */; };
|
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 */; };
|
2A864BB12A8501B47805D828 /* AuthenticationFlowCoordinatorUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 295E28C3B9EAADF519BF2F44 /* AuthenticationFlowCoordinatorUITests.swift */; };
|
||||||
2AAB2A77F1762A2648078A30 /* InteractiveQuickLook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638A81B97D51591D0FCFA598 /* InteractiveQuickLook.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 */; };
|
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 */; };
|
BFEB24336DFD5F196E6F3456 /* IntentionalMentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DF5CBAF69BDF5DF31C661E1 /* IntentionalMentions.swift */; };
|
||||||
C0090506A52A1991BAF4BA68 /* NotificationSettingsChatType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07579F9C29001E40715F3014 /* NotificationSettingsChatType.swift */; };
|
C0090506A52A1991BAF4BA68 /* NotificationSettingsChatType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07579F9C29001E40715F3014 /* NotificationSettingsChatType.swift */; };
|
||||||
C022284E2774A5E1EF683B4D /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DF593C3F7AF4B2FBAEB05D /* FileManager.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 */; };
|
C051475DFF4C8EBDDF4DC8E4 /* StartChatScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B99E13633862847D8B7E2815 /* StartChatScreenModels.swift */; };
|
||||||
C08AAE7563E0722C9383F51C /* RoomMembersListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B8E176484A89BAC389D4076 /* RoomMembersListScreen.swift */; };
|
C08AAE7563E0722C9383F51C /* RoomMembersListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B8E176484A89BAC389D4076 /* RoomMembersListScreen.swift */; };
|
||||||
C0B97FFEC0083F3A36609E61 /* TimelineItemMacContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = A243A6E6207297123E60DE48 /* TimelineItemMacContextMenu.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
ED33988DA4FD4FC666800106 /* SessionVerificationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenViewModel.swift; sourceTree = "<group>"; };
|
||||||
@ -3527,6 +3531,7 @@
|
|||||||
638A81B97D51591D0FCFA598 /* InteractiveQuickLook.swift */,
|
638A81B97D51591D0FCFA598 /* InteractiveQuickLook.swift */,
|
||||||
E7495E1119753B06FF2C2279 /* PhotoLibraryManager.swift */,
|
E7495E1119753B06FF2C2279 /* PhotoLibraryManager.swift */,
|
||||||
E3A62FBD3007312311C14DD8 /* TimelineMediaPreviewCoordinator.swift */,
|
E3A62FBD3007312311C14DD8 /* TimelineMediaPreviewCoordinator.swift */,
|
||||||
|
B18A454132A5A5247802821E /* TimelineMediaPreviewDataSource.swift */,
|
||||||
2A2BB38DF61F5100B8723112 /* TimelineMediaPreviewModels.swift */,
|
2A2BB38DF61F5100B8723112 /* TimelineMediaPreviewModels.swift */,
|
||||||
53F41CEAAE2BB4E74CDC2278 /* TimelineMediaPreviewViewModel.swift */,
|
53F41CEAAE2BB4E74CDC2278 /* TimelineMediaPreviewViewModel.swift */,
|
||||||
5EC4A8482DA110602FE6DF42 /* View */,
|
5EC4A8482DA110602FE6DF42 /* View */,
|
||||||
@ -4249,6 +4254,7 @@
|
|||||||
2CEBCB9676FCD1D0F13188DD /* StringTests.swift */,
|
2CEBCB9676FCD1D0F13188DD /* StringTests.swift */,
|
||||||
2AB2C848BB9A7A9B618B7B89 /* TextBasedRoomTimelineTests.swift */,
|
2AB2C848BB9A7A9B618B7B89 /* TextBasedRoomTimelineTests.swift */,
|
||||||
9AA3AF94A06D319BB37E52DA /* TimelineItemFactoryTests.swift */,
|
9AA3AF94A06D319BB37E52DA /* TimelineItemFactoryTests.swift */,
|
||||||
|
ED0AD0C652385F69FA90FAF5 /* TimelineMediaPreviewDataSourceTests.swift */,
|
||||||
5C1F000589F2CEE6B03ECFAB /* TimelineMediaPreviewViewModelTests.swift */,
|
5C1F000589F2CEE6B03ECFAB /* TimelineMediaPreviewViewModelTests.swift */,
|
||||||
6509708F54FC883604DFDC95 /* TimelineViewModelTests.swift */,
|
6509708F54FC883604DFDC95 /* TimelineViewModelTests.swift */,
|
||||||
76310030C831D4610A705603 /* URLComponentsTests.swift */,
|
76310030C831D4610A705603 /* URLComponentsTests.swift */,
|
||||||
@ -6729,6 +6735,7 @@
|
|||||||
E75CE800B3E64D0F7F8E228D /* TemplateScreenViewModelTests.swift in Sources */,
|
E75CE800B3E64D0F7F8E228D /* TemplateScreenViewModelTests.swift in Sources */,
|
||||||
3A7DD0D13B0FB8876D69D829 /* TextBasedRoomTimelineTests.swift in Sources */,
|
3A7DD0D13B0FB8876D69D829 /* TextBasedRoomTimelineTests.swift in Sources */,
|
||||||
0D4EB2ABAA5FE8CB10FDBCB8 /* TimelineItemFactoryTests.swift in Sources */,
|
0D4EB2ABAA5FE8CB10FDBCB8 /* TimelineItemFactoryTests.swift in Sources */,
|
||||||
|
C02DE5F62C81FB9E173C3D2F /* TimelineMediaPreviewDataSourceTests.swift in Sources */,
|
||||||
F6BF52CB027393EE03CEC523 /* TimelineMediaPreviewViewModelTests.swift in Sources */,
|
F6BF52CB027393EE03CEC523 /* TimelineMediaPreviewViewModelTests.swift in Sources */,
|
||||||
2F6207CB5C4715FE313B1E95 /* TimelineViewModelTests.swift in Sources */,
|
2F6207CB5C4715FE313B1E95 /* TimelineViewModelTests.swift in Sources */,
|
||||||
8E650379587C31D7912ED67B /* UNNotification+Creator.swift in Sources */,
|
8E650379587C31D7912ED67B /* UNNotification+Creator.swift in Sources */,
|
||||||
@ -7568,6 +7575,7 @@
|
|||||||
EFBBD44C0A16F017C32D2099 /* TimelineItemStatusView.swift in Sources */,
|
EFBBD44C0A16F017C32D2099 /* TimelineItemStatusView.swift in Sources */,
|
||||||
562EFB9AB62B38830D9AA778 /* TimelineMediaFrame.swift in Sources */,
|
562EFB9AB62B38830D9AA778 /* TimelineMediaFrame.swift in Sources */,
|
||||||
FE43747C116CA3D8D6B92F5F /* TimelineMediaPreviewCoordinator.swift in Sources */,
|
FE43747C116CA3D8D6B92F5F /* TimelineMediaPreviewCoordinator.swift in Sources */,
|
||||||
|
2A56B00B070F83E0FE571193 /* TimelineMediaPreviewDataSource.swift in Sources */,
|
||||||
12EC6BC99F373FE5C6EB9B64 /* TimelineMediaPreviewDetailsView.swift in Sources */,
|
12EC6BC99F373FE5C6EB9B64 /* TimelineMediaPreviewDetailsView.swift in Sources */,
|
||||||
4ED764A24F2A715C25CF07F1 /* TimelineMediaPreviewFileExportPicker.swift in Sources */,
|
4ED764A24F2A715C25CF07F1 /* TimelineMediaPreviewFileExportPicker.swift in Sources */,
|
||||||
77FB08C303F4C74C0E8577E2 /* TimelineMediaPreviewModels.swift in Sources */,
|
77FB08C303F4C74C0E8577E2 /* TimelineMediaPreviewModels.swift in Sources */,
|
||||||
|
@ -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.
|
||||||
|
}
|
@ -6,7 +6,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
import QuickLook
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum TimelineMediaPreviewViewModelAction: Equatable {
|
enum TimelineMediaPreviewViewModelAction: Equatable {
|
||||||
@ -15,13 +14,11 @@ enum TimelineMediaPreviewViewModelAction: Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct TimelineMediaPreviewViewState: BindableState {
|
struct TimelineMediaPreviewViewState: BindableState {
|
||||||
/// All of the items in the timeline that can be previewed.
|
/// The data source for all of the preview-able items.
|
||||||
var previewItems: [TimelineMediaPreviewItem]
|
var dataSource: TimelineMediaPreviewDataSource
|
||||||
/// 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.
|
/// 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.
|
/// All of the available actions for the current item.
|
||||||
var currentItemActions: TimelineItemMenuActions?
|
var currentItemActions: TimelineItemMenuActions?
|
||||||
|
|
||||||
@ -48,156 +45,8 @@ enum TimelineMediaPreviewAlertType {
|
|||||||
case authorizationRequired
|
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 {
|
enum TimelineMediaPreviewViewAction {
|
||||||
case updateCurrentItem(TimelineMediaPreviewItem)
|
case updateCurrentItem(TimelineMediaPreviewItem?)
|
||||||
case showCurrentItemDetails
|
case showCurrentItemDetails
|
||||||
case menuAction(TimelineItemMenuAction, item: TimelineMediaPreviewItem)
|
case menuAction(TimelineItemMenuAction, item: TimelineMediaPreviewItem)
|
||||||
case redactConfirmation(item: TimelineMediaPreviewItem)
|
case redactConfirmation(item: TimelineMediaPreviewItem)
|
||||||
|
@ -35,24 +35,28 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
|||||||
self.userIndicatorController = userIndicatorController
|
self.userIndicatorController = userIndicatorController
|
||||||
self.appMediator = appMediator
|
self.appMediator = appMediator
|
||||||
|
|
||||||
let previewItems = timelineViewModel.context.viewState.timelineState.itemViewStates.compactMap(TimelineMediaPreviewItem.init)
|
super.init(initialViewState: TimelineMediaPreviewViewState(dataSource: .init(itemViewStates: timelineViewModel.context.viewState.timelineState.itemViewStates,
|
||||||
let initialItemIndex = previewItems.firstIndex { $0.id == context.item.id } ?? 0
|
initialItem: context.item),
|
||||||
let currentItem = previewItems[initialItemIndex]
|
|
||||||
|
|
||||||
super.init(initialViewState: TimelineMediaPreviewViewState(previewItems: previewItems,
|
|
||||||
initialItemIndex: initialItemIndex,
|
|
||||||
currentItem: currentItem,
|
|
||||||
transitionNamespace: context.namespace),
|
transitionNamespace: context.namespace),
|
||||||
mediaProvider: mediaProvider)
|
mediaProvider: mediaProvider)
|
||||||
|
|
||||||
rebuildCurrentItemActions()
|
rebuildCurrentItemActions()
|
||||||
|
|
||||||
timelineViewModel.context.$viewState.map(\.canCurrentUserRedactSelf)
|
let canRedactSelfPublisher = timelineViewModel.context.$viewState.map(\.canCurrentUserRedactSelf)
|
||||||
.merge(with: timelineViewModel.context.$viewState.map(\.canCurrentUserRedactOthers))
|
let canRedactOthersPublisher = timelineViewModel.context.$viewState.map(\.canCurrentUserRedactOthers)
|
||||||
|
|
||||||
|
canRedactSelfPublisher.merge(with: canRedactOthersPublisher)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
self?.rebuildCurrentItemActions()
|
self?.rebuildCurrentItemActions()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.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) {
|
override func process(viewAction: TimelineMediaPreviewViewAction) {
|
||||||
@ -79,42 +83,48 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async {
|
private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem?) async {
|
||||||
previewItem.downloadError = nil // Clear any existing error.
|
previewItem?.downloadError = nil // Clear any existing error.
|
||||||
state.currentItem = previewItem
|
state.dataSource.updateCurrentItem(previewItem)
|
||||||
currentItemIDHandler?(previewItem.id)
|
|
||||||
|
|
||||||
rebuildCurrentItemActions()
|
rebuildCurrentItemActions()
|
||||||
|
|
||||||
if previewItem.fileHandle == nil, let source = previewItem.mediaSource {
|
if let previewItem {
|
||||||
switch await mediaProvider.loadFileFromSource(source, filename: previewItem.filename) {
|
currentItemIDHandler?(previewItem.id)
|
||||||
case .success(let handle):
|
|
||||||
previewItem.fileHandle = handle
|
if previewItem.fileHandle == nil, let source = previewItem.mediaSource {
|
||||||
state.fileLoadedPublisher.send(previewItem.id)
|
switch await mediaProvider.loadFileFromSource(source, filename: previewItem.filename) {
|
||||||
case .failure(let error):
|
case .success(let handle):
|
||||||
MXLog.error("Failed loading media: \(error)")
|
previewItem.fileHandle = handle
|
||||||
context.objectWillChange.send() // Manually trigger the SwiftUI view update.
|
state.fileLoadedPublisher.send(previewItem.id)
|
||||||
previewItem.downloadError = error
|
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() {
|
private func rebuildCurrentItemActions() {
|
||||||
let timelineContext = timelineViewModel.context
|
let timelineContext = timelineViewModel.context
|
||||||
let provider = TimelineItemMenuActionProvider(timelineItem: state.currentItem.timelineItem,
|
state.currentItemActions = if let currentItem = state.currentItem {
|
||||||
canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf,
|
TimelineItemMenuActionProvider(timelineItem: currentItem.timelineItem,
|
||||||
canCurrentUserRedactOthers: timelineContext.viewState.canCurrentUserRedactOthers,
|
canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf,
|
||||||
canCurrentUserPin: timelineContext.viewState.canCurrentUserPin,
|
canCurrentUserRedactOthers: timelineContext.viewState.canCurrentUserRedactOthers,
|
||||||
pinnedEventIDs: timelineContext.viewState.pinnedEventIDs,
|
canCurrentUserPin: timelineContext.viewState.canCurrentUserPin,
|
||||||
isDM: timelineContext.viewState.isEncryptedOneToOneRoom,
|
pinnedEventIDs: timelineContext.viewState.pinnedEventIDs,
|
||||||
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled,
|
isDM: timelineContext.viewState.isEncryptedOneToOneRoom,
|
||||||
timelineKind: timelineContext.viewState.timelineKind,
|
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled,
|
||||||
emojiProvider: timelineContext.viewState.emojiProvider)
|
timelineKind: timelineContext.viewState.timelineKind,
|
||||||
state.currentItemActions = provider.makeActions()
|
emojiProvider: timelineContext.viewState.emojiProvider)
|
||||||
|
.makeActions()
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveCurrentItem() async {
|
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.")
|
MXLog.error("Unable to save an item without a URL, the button shouldn't be visible.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -123,7 +133,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
|||||||
state.bindings.mediaDetailsItem = nil
|
state.bindings.mediaDetailsItem = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
switch state.currentItem.timelineItem {
|
switch currentItem.timelineItem {
|
||||||
case is AudioRoomTimelineItem, is FileRoomTimelineItem:
|
case is AudioRoomTimelineItem, is FileRoomTimelineItem:
|
||||||
state.bindings.fileToExport = .init(url: fileURL)
|
state.bindings.fileToExport = .init(url: fileURL)
|
||||||
return // Don't show the indicator.
|
return // Don't show the indicator.
|
||||||
|
@ -177,27 +177,29 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie
|
|||||||
static let presentedOnRoomViewModel = makeViewModel(isPresentedOnRoomScreen: true)
|
static let presentedOnRoomViewModel = makeViewModel(isPresentedOnRoomScreen: true)
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
TimelineMediaPreviewDetailsView(item: viewModel.state.currentItem,
|
// swiftlint:disable force_unwrapping
|
||||||
|
TimelineMediaPreviewDetailsView(item: viewModel.state.currentItem!,
|
||||||
context: viewModel.context)
|
context: viewModel.context)
|
||||||
.previewDisplayName("Image")
|
.previewDisplayName("Image")
|
||||||
.snapshotPreferences(expect: viewModel.context.$viewState.map { state in
|
.snapshotPreferences(expect: viewModel.context.$viewState.map { state in
|
||||||
state.currentItemActions?.secondaryActions.contains(.redact) ?? false
|
state.currentItemActions?.secondaryActions.contains(.redact) ?? false
|
||||||
})
|
})
|
||||||
|
|
||||||
TimelineMediaPreviewDetailsView(item: loadingViewModel.state.currentItem,
|
TimelineMediaPreviewDetailsView(item: loadingViewModel.state.currentItem!,
|
||||||
context: loadingViewModel.context)
|
context: loadingViewModel.context)
|
||||||
.previewDisplayName("Loading")
|
.previewDisplayName("Loading")
|
||||||
.snapshotPreferences(expect: loadingViewModel.context.$viewState.map { state in
|
.snapshotPreferences(expect: loadingViewModel.context.$viewState.map { state in
|
||||||
state.currentItemActions?.secondaryActions.contains(.redact) ?? false
|
state.currentItemActions?.secondaryActions.contains(.redact) ?? false
|
||||||
})
|
})
|
||||||
|
|
||||||
TimelineMediaPreviewDetailsView(item: unknownTypeViewModel.state.currentItem,
|
TimelineMediaPreviewDetailsView(item: unknownTypeViewModel.state.currentItem!,
|
||||||
context: unknownTypeViewModel.context)
|
context: unknownTypeViewModel.context)
|
||||||
.previewDisplayName("Unknown type")
|
.previewDisplayName("Unknown type")
|
||||||
|
|
||||||
TimelineMediaPreviewDetailsView(item: presentedOnRoomViewModel.state.currentItem,
|
TimelineMediaPreviewDetailsView(item: presentedOnRoomViewModel.state.currentItem!,
|
||||||
context: presentedOnRoomViewModel.context)
|
context: presentedOnRoomViewModel.context)
|
||||||
.previewDisplayName("Incoming on Room")
|
.previewDisplayName("Incoming on Room")
|
||||||
|
// swiftlint:enable force_unwrapping
|
||||||
}
|
}
|
||||||
|
|
||||||
static func makeViewModel(contentType: UTType? = nil,
|
static func makeViewModel(contentType: UTType? = nil,
|
||||||
|
@ -125,7 +125,8 @@ struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, Tes
|
|||||||
static let viewModel = makeViewModel(contentType: .jpeg)
|
static let viewModel = makeViewModel(contentType: .jpeg)
|
||||||
|
|
||||||
static var previews: some View {
|
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 {
|
static func makeViewModel(contentType: UTType? = nil) -> TimelineMediaPreviewViewModel {
|
||||||
|
@ -17,7 +17,7 @@ struct TimelineMediaPreviewScreen: View {
|
|||||||
@State private var isFullScreen = false
|
@State private var isFullScreen = false
|
||||||
private var toolbarVisibility: Visibility { isFullScreen ? .hidden : .visible }
|
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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@ -40,7 +40,7 @@ struct TimelineMediaPreviewScreen: View {
|
|||||||
.onDisappear {
|
.onDisappear {
|
||||||
itemIDHandler?(nil)
|
itemIDHandler?(nil)
|
||||||
}
|
}
|
||||||
.zoomTransition(sourceID: currentItem.id, in: context.viewState.transitionNamespace)
|
.zoomTransition(sourceID: currentItem?.id, in: context.viewState.transitionNamespace)
|
||||||
}
|
}
|
||||||
|
|
||||||
var quickLookPreview: some View {
|
var quickLookPreview: some View {
|
||||||
@ -55,22 +55,25 @@ struct TimelineMediaPreviewScreen: View {
|
|||||||
.safeAreaInset(edge: .bottom, spacing: 0) { caption }
|
.safeAreaInset(edge: .bottom, spacing: 0) { caption }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
private var fullScreenButton: some View {
|
private var fullScreenButton: some View {
|
||||||
Button {
|
if currentItem != nil {
|
||||||
withAnimation { isFullScreen.toggle() }
|
Button {
|
||||||
} label: {
|
withAnimation { isFullScreen.toggle() }
|
||||||
CompoundIcon(isFullScreen ? \.collapse : \.expand, size: .xSmall, relativeTo: .compound.bodyLG)
|
} label: {
|
||||||
.padding(6)
|
CompoundIcon(isFullScreen ? \.collapse : \.expand, size: .xSmall, relativeTo: .compound.bodyLG)
|
||||||
.background(.thinMaterial, in: Circle())
|
.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
|
@ViewBuilder
|
||||||
private var downloadStatusIndicator: some View {
|
private var downloadStatusIndicator: some View {
|
||||||
if currentItem.downloadError != nil {
|
if currentItem?.downloadError != nil {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
CompoundIcon(\.error, size: .custom(48), relativeTo: .compound.headingLG)
|
CompoundIcon(\.error, size: .custom(48), relativeTo: .compound.headingLG)
|
||||||
.foregroundStyle(.compound.iconCriticalPrimary)
|
.foregroundStyle(.compound.iconCriticalPrimary)
|
||||||
@ -91,7 +94,7 @@ struct TimelineMediaPreviewScreen: View {
|
|||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.vertical, 40)
|
.padding(.vertical, 40)
|
||||||
.background(.compound.bgSubtlePrimary, in: RoundedRectangle(cornerRadius: 14))
|
.background(.compound.bgSubtlePrimary, in: RoundedRectangle(cornerRadius: 14))
|
||||||
} else if currentItem.fileHandle == nil {
|
} else if currentItem?.fileHandle == nil {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
.tint(.compound.iconPrimary)
|
.tint(.compound.iconPrimary)
|
||||||
@ -100,7 +103,7 @@ struct TimelineMediaPreviewScreen: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var caption: some View {
|
private var caption: some View {
|
||||||
if let caption = currentItem.caption, !isFullScreen {
|
if let caption = currentItem?.caption, !isFullScreen {
|
||||||
Text(caption)
|
Text(caption)
|
||||||
.font(.compound.bodyLG)
|
.font(.compound.bodyLG)
|
||||||
.foregroundStyle(.compound.textPrimary)
|
.foregroundStyle(.compound.textPrimary)
|
||||||
@ -130,23 +133,32 @@ struct TimelineMediaPreviewScreen: View {
|
|||||||
toolbarHeader
|
toolbarHeader
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .primaryAction) {
|
if currentItem != nil {
|
||||||
Button { context.send(viewAction: .showCurrentItemDetails) } label: {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
CompoundIcon(\.info)
|
Button { context.send(viewAction: .showCurrentItemDetails) } label: {
|
||||||
|
CompoundIcon(\.info)
|
||||||
|
}
|
||||||
|
.tint(.compound.textActionPrimary)
|
||||||
}
|
}
|
||||||
.tint(.compound.textActionPrimary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
private var toolbarHeader: some View {
|
private var toolbarHeader: some View {
|
||||||
VStack(spacing: 0) {
|
if let currentItem {
|
||||||
Text(currentItem.sender.displayName ?? currentItem.sender.id)
|
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)
|
.font(.compound.bodySMSemibold)
|
||||||
.foregroundStyle(.compound.textPrimary)
|
.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 {
|
private struct QuickLookView: UIViewControllerRepresentable {
|
||||||
let viewModelContext: TimelineMediaPreviewViewModel.Context
|
let viewModelContext: TimelineMediaPreviewViewModel.Context
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> PreviewController {
|
func makeUIViewController(context: Context) -> QLPreviewController {
|
||||||
let fileLoadedPublisher = viewModelContext.viewState.fileLoadedPublisher.eraseToAnyPublisher()
|
context.coordinator.previewController
|
||||||
let controller = PreviewController(coordinator: context.coordinator, fileLoadedPublisher: fileLoadedPublisher)
|
|
||||||
controller.currentPreviewItemIndex = viewModelContext.viewState.initialItemIndex
|
|
||||||
return controller
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: PreviewController, context: Context) { }
|
func updateUIViewController(_ uiViewController: QLPreviewController, context: Context) { }
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator(viewModelContext: viewModelContext)
|
Coordinator(viewModelContext: viewModelContext)
|
||||||
@ -171,55 +180,57 @@ private struct QuickLookView: UIViewControllerRepresentable {
|
|||||||
|
|
||||||
// MARK: Coordinator
|
// MARK: Coordinator
|
||||||
|
|
||||||
class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate {
|
@MainActor class Coordinator {
|
||||||
|
let previewController = QLPreviewController()
|
||||||
|
|
||||||
private let viewModelContext: TimelineMediaPreviewViewModel.Context
|
private let viewModelContext: TimelineMediaPreviewViewModel.Context
|
||||||
|
|
||||||
|
private var cancellables: Set<AnyCancellable> = []
|
||||||
|
|
||||||
init(viewModelContext: TimelineMediaPreviewViewModel.Context) {
|
init(viewModelContext: TimelineMediaPreviewViewModel.Context) {
|
||||||
self.viewModelContext = viewModelContext
|
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.
|
// Observation of currentPreviewItem doesn't work, so use the index instead.
|
||||||
publisher(for: \.currentPreviewItemIndex)
|
previewController.publisher(for: \.currentPreviewItemIndex)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
guard let self, let currentPreviewItem = currentPreviewItem as? TimelineMediaPreviewItem else { return }
|
// This isn't removing duplicates which may try to download and/or write to disk concurrently????
|
||||||
coordinator.updateCurrentItem(currentPreviewItem)
|
self?.loadCurrentItem()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
fileLoadedPublisher
|
viewModelContext.viewState.dataSource.previewItemsPaginationPublisher
|
||||||
.sink { [weak self] itemID in
|
.sink { [weak self] in
|
||||||
guard let self, (currentPreviewItem as? TimelineMediaPreviewItem)?.id == itemID else { return }
|
self?.handleUpdatedItems()
|
||||||
refreshCurrentPreviewItem()
|
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.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)
|
private func loadCurrentItem() {
|
||||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
171
UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift
Normal file
171
UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift
Normal 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).
|
||||||
|
}
|
||||||
|
}
|
@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
import MatrixRustSDK
|
import MatrixRustSDK
|
||||||
|
import QuickLook
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@ -24,7 +25,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
|
|||||||
// Given a fresh view model.
|
// Given a fresh view model.
|
||||||
setupViewModel()
|
setupViewModel()
|
||||||
XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled)
|
XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled)
|
||||||
XCTAssertEqual(context.viewState.currentItem, context.viewState.previewItems[0])
|
XCTAssertEqual(context.viewState.currentItem, context.viewState.dataSource.previewItems[0])
|
||||||
XCTAssertNotNil(context.viewState.currentItemActions)
|
XCTAssertNotNil(context.viewState.currentItemActions)
|
||||||
|
|
||||||
// When the preview controller sets the current item.
|
// 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.
|
// Then the view model should load the item and update its view state.
|
||||||
XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled)
|
XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled)
|
||||||
XCTAssertEqual(context.viewState.currentItem, context.viewState.previewItems[0])
|
XCTAssertEqual(context.viewState.currentItem, context.viewState.dataSource.previewItems[0])
|
||||||
XCTAssertNotNil(context.viewState.currentItemActions)
|
XCTAssertNotNil(context.viewState.currentItemActions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLoadingItemFailure() async throws {
|
func testLoadingItemFailure() async throws {
|
||||||
// Given a fresh view model.
|
// Given a fresh view model.
|
||||||
setupViewModel()
|
setupViewModel()
|
||||||
|
guard let currentItem = context.viewState.currentItem else {
|
||||||
|
XCTFail("There should be a current item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled)
|
XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled)
|
||||||
XCTAssertEqual(context.viewState.currentItem, context.viewState.previewItems[0])
|
XCTAssertEqual(currentItem, context.viewState.dataSource.previewItems[0])
|
||||||
XCTAssertNil(context.viewState.currentItem.downloadError)
|
XCTAssertNil(currentItem.downloadError)
|
||||||
|
|
||||||
// When the preview controller sets an item that fails to load.
|
// When the preview controller sets an item that fails to load.
|
||||||
mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) }
|
mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) }
|
||||||
let failure = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true }
|
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()
|
try await failure.fulfill()
|
||||||
|
|
||||||
// Then the view model should load the item and update its view state.
|
// Then the view model should load the item and update its view state.
|
||||||
XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled)
|
XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled)
|
||||||
XCTAssertEqual(context.viewState.currentItem, context.viewState.previewItems[0])
|
XCTAssertEqual(currentItem, context.viewState.dataSource.previewItems[0])
|
||||||
XCTAssertNotNil(context.viewState.currentItem.downloadError)
|
XCTAssertNotNil(currentItem.downloadError)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSwipingBetweenItems() async throws {
|
func testSwipingBetweenItems() async throws {
|
||||||
@ -61,21 +67,57 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
// When swiping to another item.
|
// When swiping to another item.
|
||||||
let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true }
|
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()
|
try await deferred.fulfill()
|
||||||
|
|
||||||
// Then the view model should load the item and update its view state.
|
// Then the view model should load the item and update its view state.
|
||||||
XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 2)
|
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.
|
// When swiping back to the first item.
|
||||||
let failure = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true }
|
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()
|
try await failure.fulfill()
|
||||||
|
|
||||||
// Then the view model should not need to load the item, but should still update its view state.
|
// Then the view model should not need to load the item, but should still update its view state.
|
||||||
XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 2)
|
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 {
|
func testViewInRoomTimeline() async throws {
|
||||||
@ -83,7 +125,11 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
|
|||||||
try await testLoadingItem()
|
try await testLoadingItem()
|
||||||
|
|
||||||
// When choosing to view the current item in the timeline.
|
// 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) }
|
let deferred = deferFulfillment(viewModel.actions) { $0 == .viewInRoomTimeline(item.id) }
|
||||||
context.send(viewAction: .menuAction(.viewInRoomTimeline, item: item))
|
context.send(viewAction: .menuAction(.viewInRoomTimeline, item: item))
|
||||||
|
|
||||||
@ -126,28 +172,35 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
|
|||||||
func testSaveImage() async throws {
|
func testSaveImage() async throws {
|
||||||
// Given a view model with a loaded image.
|
// Given a view model with a loaded image.
|
||||||
try await testLoadingItem()
|
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.
|
// When choosing to save the image.
|
||||||
let item = context.viewState.currentItem
|
context.send(viewAction: .menuAction(.save, item: currentItem))
|
||||||
context.send(viewAction: .menuAction(.save, item: item))
|
|
||||||
try await Task.sleep(for: .seconds(0.5))
|
try await Task.sleep(for: .seconds(0.5))
|
||||||
|
|
||||||
// Then the image should be saved as a photo to the user's photo library.
|
// Then the image should be saved as a photo to the user's photo library.
|
||||||
XCTAssertTrue(photoLibraryManager.addResourceAtCalled)
|
XCTAssertTrue(photoLibraryManager.addResourceAtCalled)
|
||||||
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.type, .photo)
|
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.type, .photo)
|
||||||
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, item.fileHandle?.url)
|
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, currentItem.fileHandle?.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSaveImageWithoutAuthorization() async throws {
|
func testSaveImageWithoutAuthorization() async throws {
|
||||||
// Given a view model with a loaded image where the user has denied access to the photo library.
|
// Given a view model with a loaded image where the user has denied access to the photo library.
|
||||||
setupViewModel(photoLibraryAuthorizationDenied: true)
|
setupViewModel(photoLibraryAuthorizationDenied: true)
|
||||||
try await loadInitialItem()
|
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.
|
// When choosing to save the image.
|
||||||
let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil }
|
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()
|
try await deferred.fulfill()
|
||||||
|
|
||||||
// Then the user should be prompted to allow access.
|
// Then the user should be prompted to allow access.
|
||||||
@ -159,34 +212,40 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
|
|||||||
// Given a view model with a loaded video.
|
// Given a view model with a loaded video.
|
||||||
setupViewModel(initialItemIndex: 1)
|
setupViewModel(initialItemIndex: 1)
|
||||||
try await loadInitialItem()
|
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.
|
// When choosing to save the video.
|
||||||
let item = context.viewState.currentItem
|
context.send(viewAction: .menuAction(.save, item: currentItem))
|
||||||
context.send(viewAction: .menuAction(.save, item: item))
|
|
||||||
try await Task.sleep(for: .seconds(0.5))
|
try await Task.sleep(for: .seconds(0.5))
|
||||||
|
|
||||||
// Then the video should be saved as a video in the user's photo library.
|
// Then the video should be saved as a video in the user's photo library.
|
||||||
XCTAssertTrue(photoLibraryManager.addResourceAtCalled)
|
XCTAssertTrue(photoLibraryManager.addResourceAtCalled)
|
||||||
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.type, .video)
|
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.type, .video)
|
||||||
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, item.fileHandle?.url)
|
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, currentItem.fileHandle?.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSaveFile() async throws {
|
func testSaveFile() async throws {
|
||||||
// Given a view model with a loaded file.
|
// Given a view model with a loaded file.
|
||||||
setupViewModel(initialItemIndex: 2)
|
setupViewModel(initialItemIndex: 2)
|
||||||
try await loadInitialItem()
|
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.
|
// When choosing to save the file.
|
||||||
let item = context.viewState.currentItem
|
context.send(viewAction: .menuAction(.save, item: currentItem))
|
||||||
context.send(viewAction: .menuAction(.save, item: item))
|
|
||||||
try await Task.sleep(for: .seconds(0.5))
|
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.
|
// Then the binding should be set for the user to export the file to their specified location.
|
||||||
XCTAssertFalse(photoLibraryManager.addResourceAtCalled)
|
XCTAssertFalse(photoLibraryManager.addResourceAtCalled)
|
||||||
XCTAssertNotNil(context.fileToExport)
|
XCTAssertNotNil(context.fileToExport)
|
||||||
XCTAssertEqual(context.fileToExport?.url, item.fileHandle?.url)
|
XCTAssertEqual(context.fileToExport?.url, currentItem.fileHandle?.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDismiss() async throws {
|
func testDismiss() async throws {
|
||||||
@ -205,20 +264,27 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
private func loadInitialItem() async throws {
|
private func loadInitialItem() async throws {
|
||||||
let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true }
|
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()
|
try await deferred.fulfill()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Namespace private var testNamespace
|
@Namespace private var testNamespace
|
||||||
|
|
||||||
private func setupViewModel(initialItemIndex: Int = 0, photoLibraryAuthorizationDenied: Bool = false) {
|
private func setupViewModel(initialItemIndex: Int = 0, photoLibraryAuthorizationDenied: Bool = false) {
|
||||||
|
let initialItems = makeItems()
|
||||||
timelineController = MockRoomTimelineController(timelineKind: .media(.mediaFilesScreen))
|
timelineController = MockRoomTimelineController(timelineKind: .media(.mediaFilesScreen))
|
||||||
timelineController.timelineItems = items
|
timelineController.timelineItems = initialItems
|
||||||
|
|
||||||
mediaProvider = MediaProviderMock(configuration: .init())
|
mediaProvider = MediaProviderMock(configuration: .init())
|
||||||
photoLibraryManager = PhotoLibraryManagerMock(.init(authorizationDenied: photoLibraryAuthorizationDenied))
|
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),
|
viewModel: TimelineViewModel.mock(timelineKind: .media(.mediaFilesScreen),
|
||||||
timelineController: timelineController),
|
timelineController: timelineController),
|
||||||
namespace: testNamespace),
|
namespace: testNamespace),
|
||||||
@ -228,41 +294,43 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
|
|||||||
appMediator: AppMediatorMock())
|
appMediator: AppMediatorMock())
|
||||||
}
|
}
|
||||||
|
|
||||||
private let items: [EventBasedMessageTimelineItemProtocol] = [
|
private func makeItems() -> [EventBasedMessageTimelineItemProtocol] {
|
||||||
ImageRoomTimelineItem(id: .randomEvent,
|
[
|
||||||
timestamp: .mock,
|
ImageRoomTimelineItem(id: .randomEvent,
|
||||||
isOutgoing: false,
|
timestamp: .mock,
|
||||||
isEditable: false,
|
isOutgoing: false,
|
||||||
canBeRepliedTo: true,
|
isEditable: false,
|
||||||
isThreaded: false,
|
canBeRepliedTo: true,
|
||||||
sender: .init(id: "", displayName: "Sally Sanderson"),
|
isThreaded: false,
|
||||||
content: .init(filename: "Amazing image.jpeg",
|
sender: .init(id: "", displayName: "Sally Sanderson"),
|
||||||
caption: "A caption goes right here.",
|
content: .init(filename: "Amazing image.jpeg",
|
||||||
imageInfo: .mockImage,
|
caption: "A caption goes right here.",
|
||||||
thumbnailInfo: .mockThumbnail,
|
imageInfo: .mockImage,
|
||||||
contentType: .jpeg)),
|
thumbnailInfo: .mockThumbnail,
|
||||||
VideoRoomTimelineItem(id: .randomEvent,
|
contentType: .jpeg)),
|
||||||
timestamp: .mock,
|
VideoRoomTimelineItem(id: .randomEvent,
|
||||||
isOutgoing: false,
|
timestamp: .mock,
|
||||||
isEditable: false,
|
isOutgoing: false,
|
||||||
canBeRepliedTo: true,
|
isEditable: false,
|
||||||
isThreaded: false,
|
canBeRepliedTo: true,
|
||||||
sender: .init(id: ""),
|
isThreaded: false,
|
||||||
content: .init(filename: "Super video.mp4",
|
sender: .init(id: ""),
|
||||||
videoInfo: .mockVideo,
|
content: .init(filename: "Super video.mp4",
|
||||||
thumbnailInfo: .mockThumbnail,
|
videoInfo: .mockVideo,
|
||||||
contentType: .mpeg4Movie)),
|
thumbnailInfo: .mockThumbnail,
|
||||||
FileRoomTimelineItem(id: .randomEvent,
|
contentType: .mpeg4Movie)),
|
||||||
timestamp: .mock,
|
FileRoomTimelineItem(id: .randomEvent,
|
||||||
isOutgoing: false,
|
timestamp: .mock,
|
||||||
isEditable: false,
|
isOutgoing: false,
|
||||||
canBeRepliedTo: true,
|
isEditable: false,
|
||||||
isThreaded: false,
|
canBeRepliedTo: true,
|
||||||
sender: .init(id: ""),
|
isThreaded: false,
|
||||||
content: .init(filename: "Important file.pdf",
|
sender: .init(id: ""),
|
||||||
source: try? .init(url: .mockMXCFile, mimeType: "document/pdf"),
|
content: .init(filename: "Important file.pdf",
|
||||||
fileSize: 2453,
|
source: try? .init(url: .mockMXCFile, mimeType: "document/pdf"),
|
||||||
thumbnailSource: nil,
|
fileSize: 2453,
|
||||||
contentType: .pdf))
|
thumbnailSource: nil,
|
||||||
]
|
contentType: .pdf))
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user