diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 9c329fe3b3..dbbd460752 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -273,6 +273,7 @@ 35E975CFDA60E05362A7CF79 /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = 1222DB76B917EB8A55365BA5 /* target.yml */; }; 36206F74DDEBF9BEAF6A6A1F /* ExtensionLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A8571A8A071FB41778C016 /* ExtensionLogger.swift */; }; 366D5BFE52CB79E804C7D095 /* CallScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD9547E47C58930E2CE8306 /* CallScreenViewModelTests.swift */; }; + 3684AD01C5FCB7616B28F629 /* TimelineMediaPreviewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDE60FEE95039CCCEEEE3B0 /* TimelineMediaPreviewController.swift */; }; 36926D795D6D19177C7812F8 /* EncryptionResetPasswordScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6935A55AB3B0C94BC566DD6 /* EncryptionResetPasswordScreenCoordinator.swift */; }; 369BF960E52BBEE61F8A5BD1 /* BlockedUsersScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED60E4D2CD678E1EBF16F77A /* BlockedUsersScreen.swift */; }; 36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E6D88E8AFFBF2C1D589C0FA /* UIConstants.swift */; }; @@ -493,6 +494,7 @@ 62A7FC3A0191BC7181AA432B /* AudioRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907FA4DE17DEA1A3738EFB83 /* AudioRecorder.swift */; }; 62C5876C4254C58C2086F0DE /* HomeScreenContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3B4B58B79A6FA250B24A1EC /* HomeScreenContent.swift */; }; 63780F9DA06573E38A471ECA /* GenericCallLinkWidgetDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28C202C1C7E330F124981A31 /* GenericCallLinkWidgetDriver.swift */; }; + 6386EA3C898AD1A4BC1DC8A5 /* TimelineMediaPreviewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FD40B92FCF20165658296AD /* TimelineMediaPreviewModifier.swift */; }; 63CDC201A5980F304F6D0A1C /* WaveformInteractionModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFEE91FB8ABB5F5884B6D940 /* WaveformInteractionModifier.swift */; }; 63E46D18B91D08E15FC04125 /* ExpiringTaskRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B25F959A434BB9923A3223F /* ExpiringTaskRunner.swift */; }; 642DF13C49ED4121C148230E /* TestablePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1E227F34BE43B08E098796E /* TestablePreview.swift */; }; @@ -770,7 +772,6 @@ 97969EF0B9C412CD38E5CA93 /* AppLockScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4005D82E9D27BAF006A8FE1 /* AppLockScreenViewModel.swift */; }; 97BAEDD9054FB5F233EE928B /* EncryptionResetScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 306AB507E1027D6C5C147EB6 /* EncryptionResetScreenModels.swift */; }; 981853650217B6C8ECDD998C /* NavigationRootCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */; }; - 9826A4DBBEFA7041A9E0EFAD /* TimelineMediaPreviewScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA57E8563C346B13DDE4A6F4 /* TimelineMediaPreviewScreen.swift */; }; 983896D611ABF52A5C37498D /* RoomSummaryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */; }; 9847B056C1A216C314D21E68 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A1AB5A84D843B6AC8D5F1E /* AuthenticationService.swift */; }; 988BA75A182738150894A23F /* UserIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8AE4B3273BA189FDCD4055C /* UserIndicator.swift */; }; @@ -952,7 +953,6 @@ BE8E5985771DF9137C6CE89A /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 077B01C13BBA2996272C5FB5 /* ProcessInfo.swift */; }; BEA646DF302711A753F0D420 /* MapTilerStyleBuilderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 225EFCA26877E75CDFE7F48D /* MapTilerStyleBuilderProtocol.swift */; }; BEC6DFEA506085D3027E353C /* MediaEventsTimelineScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 002399C6CB875C4EBB01CBC0 /* MediaEventsTimelineScreen.swift */; }; - BFDDAF1A36FBC7CF63DCB7DD /* clear.png in Resources */ = {isa = PBXBuildFile; fileRef = 17F7A723A46DF5C95BE15EBF /* clear.png */; }; 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 */; }; @@ -1254,7 +1254,6 @@ FDC67E8C0EDCB00ABC66C859 /* landscape_test_video.mov in Resources */ = {isa = PBXBuildFile; fileRef = 78BBDF7A05CF53B5CDC13682 /* landscape_test_video.mov */; }; FDD5B4B616D9FF4DE3E9A418 /* QRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92DB574F954CC2B40F7BE892 /* QRCodeScannerView.swift */; }; FDE47D4686BA0F86BB584633 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = CAA3B9DF998B397C9EE64E8B /* Collections */; }; - FE43747C116CA3D8D6B92F5F /* TimelineMediaPreviewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3A62FBD3007312311C14DD8 /* TimelineMediaPreviewCoordinator.swift */; }; FE4593FC2A02AAF92E089565 /* ElementAnimations.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF1593DD87F974F8509BB619 /* ElementAnimations.swift */; }; FEC03105D1BDE0F49BD7F243 /* PinnedEventsTimelineScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B6572E6EF5D5F4B0C338A40 /* PinnedEventsTimelineScreenModels.swift */; }; FEFD5290B31FCBA6999912C8 /* RoomChangePermissionsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2721D7B051F0159AA919DA05 /* RoomChangePermissionsScreenViewModelProtocol.swift */; }; @@ -1450,7 +1449,6 @@ 16D09C79746BDCD9173EB3A7 /* RoomDetailsEditScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenModels.swift; sourceTree = ""; }; 1715E3D7F53C0748AA50C91C /* PostHogAnalyticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalyticsClient.swift; sourceTree = ""; }; 17A8AA0DFA06012A9DAB951E /* TimelineProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineProxyMock.swift; sourceTree = ""; }; - 17F7A723A46DF5C95BE15EBF /* clear.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = clear.png; sourceTree = ""; }; 18486B87745B1811E7FBD3D2 /* AnalyticsPromptScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptScreenModels.swift; sourceTree = ""; }; 184CF8C196BE143AE226628D /* DecorationTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecorationTimelineItemProtocol.swift; sourceTree = ""; }; 18F2958E6D247AE2516BEEE8 /* ClientProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientProxy.swift; sourceTree = ""; }; @@ -1792,6 +1790,7 @@ 5B8F0ED874DF8C9A51B0AB6F /* SettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenCoordinator.swift; sourceTree = ""; }; 5C1F000589F2CEE6B03ECFAB /* TimelineMediaPreviewViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewViewModelTests.swift; sourceTree = ""; }; 5C7C7CFA6B2A62A685FF6CE3 /* DeveloperOptionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenCoordinator.swift; sourceTree = ""; }; + 5CDE60FEE95039CCCEEEE3B0 /* TimelineMediaPreviewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewController.swift; sourceTree = ""; }; 5CEEAE1BFAACD6C96B6DB731 /* PHGPostHogProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogProtocol.swift; sourceTree = ""; }; 5D26A086A8278D39B5756D6F /* project.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = project.yml; sourceTree = ""; }; 5D53754227CEBD06358956D7 /* PinnedEventsTimelineScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedEventsTimelineScreenCoordinator.swift; sourceTree = ""; }; @@ -2090,6 +2089,7 @@ 9F1DF3FFFE5ED2B8133F43A7 /* MessageComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposer.swift; sourceTree = ""; }; 9F40FB0A43DAECEC27C73722 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/SAS.strings; sourceTree = ""; }; 9F85164F9475FF2867F71AAA /* RoomTimelineController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineController.swift; sourceTree = ""; }; + 9FD40B92FCF20165658296AD /* TimelineMediaPreviewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewModifier.swift; sourceTree = ""; }; A00C7A331B72C0F05C00392F /* RoomScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenViewModelProtocol.swift; sourceTree = ""; }; A010B8EAD1A9F6B4686DF2F4 /* BlockedUsersScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedUsersScreenViewModel.swift; sourceTree = ""; }; A019A12C866D64CF072024B9 /* AppLockSetupPINScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupPINScreenViewModel.swift; sourceTree = ""; }; @@ -2134,7 +2134,6 @@ A9E88667D393612FD5D84718 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/SAS.strings; sourceTree = ""; }; A9FAFE1C2149E6AC8156ED2B /* Collection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; AA19C32BD97F45847724E09A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Untranslated.strings; sourceTree = ""; }; - AA57E8563C346B13DDE4A6F4 /* TimelineMediaPreviewScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewScreen.swift; sourceTree = ""; }; AAC9344689121887B74877AF /* UnitTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; AACE9B8E1A4AE79A7E2914F6 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = es; path = es.lproj/Localizable.stringsdict; sourceTree = ""; }; AAD01F7FC2BBAC7351948595 /* UserProfile+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserProfile+Mock.swift"; sourceTree = ""; }; @@ -2409,7 +2408,6 @@ E321E840DCC63790049984F4 /* ElementCallServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementCallServiceMock.swift; sourceTree = ""; }; E34685D186453E429ADEE58E /* ClientProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientProtocolTests.swift; sourceTree = ""; }; E36CB905A2B9EC2C92A2DA7C /* KeychainController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainController.swift; sourceTree = ""; }; - E3A62FBD3007312311C14DD8 /* TimelineMediaPreviewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewCoordinator.swift; sourceTree = ""; }; E3B97591B2D3D4D67553506D /* AnalyticsClientProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsClientProtocol.swift; sourceTree = ""; }; E4103AB4340F2974D690A12A /* CallScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallScreen.swift; sourceTree = ""; }; E413F4CBD7BF0588F394A9DD /* RoomDetailsEditScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModel.swift; sourceTree = ""; }; @@ -3033,7 +3031,6 @@ isa = PBXGroup; children = ( 01C4C7DB37597D7D8379511A /* Assets.xcassets */, - 17F7A723A46DF5C95BE15EBF /* clear.png */, A0C06C0F6A8621B22BFAEB56 /* Localizations */, 8AEA6A91159FA0D3EAFCCB0D /* Sounds */, ); @@ -3528,9 +3525,9 @@ children = ( 638A81B97D51591D0FCFA598 /* InteractiveQuickLook.swift */, E7495E1119753B06FF2C2279 /* PhotoLibraryManager.swift */, - E3A62FBD3007312311C14DD8 /* TimelineMediaPreviewCoordinator.swift */, B18A454132A5A5247802821E /* TimelineMediaPreviewDataSource.swift */, 2A2BB38DF61F5100B8723112 /* TimelineMediaPreviewModels.swift */, + 9FD40B92FCF20165658296AD /* TimelineMediaPreviewModifier.swift */, 53F41CEAAE2BB4E74CDC2278 /* TimelineMediaPreviewViewModel.swift */, 5EC4A8482DA110602FE6DF42 /* View */, ); @@ -3945,10 +3942,10 @@ 5EC4A8482DA110602FE6DF42 /* View */ = { isa = PBXGroup; children = ( + 5CDE60FEE95039CCCEEEE3B0 /* TimelineMediaPreviewController.swift */, 467498BEA681758BE2F80826 /* TimelineMediaPreviewDetailsView.swift */, 30856520F3263D0E195710D7 /* TimelineMediaPreviewFileExportPicker.swift */, C75FE3F524B575D53787868C /* TimelineMediaPreviewRedactConfirmationView.swift */, - AA57E8563C346B13DDE4A6F4 /* TimelineMediaPreviewScreen.swift */, ); path = View; sourceTree = ""; @@ -6354,7 +6351,6 @@ 5FCD8AFA364206EE32B909A3 /* Settings.bundle in Resources */, CE1694C7BB93C3311524EF28 /* Untranslated.strings in Resources */, 2797C9D9BA642370F1C85D78 /* Untranslated.stringsdict in Resources */, - BFDDAF1A36FBC7CF63DCB7DD /* clear.png in Resources */, 147597951DB07123A87AA1D1 /* landscape_test_image.jpg in Resources */, FDC67E8C0EDCB00ABC66C859 /* landscape_test_video.mov in Resources */, E67418DACEDBC29E988E6ACD /* message.caf in Resources */, @@ -7569,13 +7565,13 @@ 1B88BB631F7FC45A213BB554 /* TimelineItemSender.swift in Sources */, EFBBD44C0A16F017C32D2099 /* TimelineItemStatusView.swift in Sources */, 562EFB9AB62B38830D9AA778 /* TimelineMediaFrame.swift in Sources */, - FE43747C116CA3D8D6B92F5F /* TimelineMediaPreviewCoordinator.swift in Sources */, + 3684AD01C5FCB7616B28F629 /* TimelineMediaPreviewController.swift in Sources */, 2A56B00B070F83E0FE571193 /* TimelineMediaPreviewDataSource.swift in Sources */, 12EC6BC99F373FE5C6EB9B64 /* TimelineMediaPreviewDetailsView.swift in Sources */, 4ED764A24F2A715C25CF07F1 /* TimelineMediaPreviewFileExportPicker.swift in Sources */, 77FB08C303F4C74C0E8577E2 /* TimelineMediaPreviewModels.swift in Sources */, + 6386EA3C898AD1A4BC1DC8A5 /* TimelineMediaPreviewModifier.swift in Sources */, A32384E3D85CA65342D3A908 /* TimelineMediaPreviewRedactConfirmationView.swift in Sources */, - 9826A4DBBEFA7041A9E0EFAD /* TimelineMediaPreviewScreen.swift in Sources */, 86769B62BAE17601B3AE1B60 /* TimelineMediaPreviewViewModel.swift in Sources */, B818580464CFB5400A3EF6AE /* TimelineModels.swift in Sources */, E82E13CC3EB923CCB8F8273C /* TimelineProxy.swift in Sources */, diff --git a/ElementX/Resources/clear.png b/ElementX/Resources/clear.png deleted file mode 100644 index 07fc8418cb..0000000000 Binary files a/ElementX/Resources/clear.png and /dev/null differ diff --git a/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift index b91b2fe04e..656f39d3a6 100644 --- a/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift @@ -93,39 +93,17 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { let coordinator = MediaEventsTimelineScreenCoordinator(parameters: parameters) coordinator.actions - .sink { [weak self] action in - switch action { - case .viewItem(let previewContext): - self?.presentMediaPreview(for: previewContext) - } - } - .store(in: &cancellables) - - navigationStackCoordinator.push(coordinator) { [weak self] in - self?.actionsSubject.send(.finished) - } - } - - private func presentMediaPreview(for previewContext: TimelineMediaPreviewContext) { - let parameters = TimelineMediaPreviewCoordinatorParameters(context: previewContext, - mediaProvider: userSession.mediaProvider, - userIndicatorController: userIndicatorController, - appMediator: appMediator) - - let coordinator = TimelineMediaPreviewCoordinator(parameters: parameters) - coordinator.actionsPublisher .sink { [weak self] action in switch action { case .viewInRoomTimeline(let itemID): self?.navigationStackCoordinator.pop(animated: false) self?.actionsSubject.send(.viewInRoomTimeline(itemID)) - self?.navigationStackCoordinator.setFullScreenCoverCoordinator(nil) - case .dismiss: - self?.navigationStackCoordinator.setFullScreenCoverCoordinator(nil) } } .store(in: &cancellables) - navigationStackCoordinator.setFullScreenCoverCoordinator(coordinator) + navigationStackCoordinator.push(coordinator) { [weak self] in + self?.actionsSubject.send(.finished) + } } } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewCoordinator.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewCoordinator.swift deleted file mode 100644 index 4463041ab6..0000000000 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewCoordinator.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// Copyright 2024 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 SwiftUI - -struct TimelineMediaPreviewContext { - /// The initial item to preview from the provided timeline. - /// This item's `id` will be used as the navigation transition's `sourceID`. - let item: EventBasedMessageTimelineItemProtocol - /// The timeline that the preview comes from, to allow for swiping to other media. - let viewModel: TimelineViewModelProtocol - /// The namespace that the navigation transition's `sourceID` should be defined in. - let namespace: Namespace.ID - /// A closure to be called whenever a different preview item is shown. It should also - /// be called *after* the preview has been dismissed, with an ID of `nil`. - /// - /// This helps work around a bug caused by the flipped scrollview where the zoomed - /// thumbnail starts off upside down while loading the preview screen. - var itemIDHandler: ((TimelineItemIdentifier?) -> Void)? -} - -struct TimelineMediaPreviewCoordinatorParameters { - let context: TimelineMediaPreviewContext - let mediaProvider: MediaProviderProtocol - let userIndicatorController: UserIndicatorControllerProtocol - let appMediator: AppMediatorProtocol -} - -enum TimelineMediaPreviewCoordinatorAction { - case viewInRoomTimeline(TimelineItemIdentifier) - case dismiss -} - -final class TimelineMediaPreviewCoordinator: CoordinatorProtocol { - private let parameters: TimelineMediaPreviewCoordinatorParameters - private let viewModel: TimelineMediaPreviewViewModel - - private var cancellables = Set() - - private let actionsSubject: PassthroughSubject = .init() - var actionsPublisher: AnyPublisher { - actionsSubject.eraseToAnyPublisher() - } - - init(parameters: TimelineMediaPreviewCoordinatorParameters) { - self.parameters = parameters - - viewModel = TimelineMediaPreviewViewModel(context: parameters.context, - mediaProvider: parameters.mediaProvider, - photoLibraryManager: PhotoLibraryManager(), - userIndicatorController: parameters.userIndicatorController, - appMediator: parameters.appMediator) - } - - func start() { - viewModel.actions.sink { [weak self] action in - MXLog.info("Coordinator: received view model action: \(action)") - - guard let self else { return } - switch action { - case .viewInRoomTimeline(let itemID): - actionsSubject.send(.viewInRoomTimeline(itemID)) - case .dismiss: - actionsSubject.send(.dismiss) - } - } - .store(in: &cancellables) - } - - func toPresentable() -> AnyView { - // Calling the completion onDisappear isn't ideal, but we don't push away from the screen so it should be - // a good enough approximation of didDismiss, given that the only other option is our navigation callbacks - // which are essentially willDismiss callbacks and happen too early for this particular completion handler. - AnyView(TimelineMediaPreviewScreen(context: viewModel.context, itemIDHandler: parameters.context.itemIDHandler)) - } -} diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift index eb7c3c4085..f959e2e558 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift @@ -156,13 +156,14 @@ enum TimelineMediaPreviewItem: Equatable { // 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") + fileHandle?.url } var previewItemTitle: String? { - filename + switch fileHandle?.url { + case .some: filename + case .none: " " // Don't show any background text when the preview is still loading. + } } // MARK: Event details diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift index f9a3bec7a9..753bc21dde 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift @@ -13,6 +13,42 @@ enum TimelineMediaPreviewViewModelAction: Equatable { case dismiss } +enum TimelineMediaPreviewDriverAction { + case itemLoaded(TimelineItemIdentifier) + case showItemDetails(TimelineMediaPreviewItem.Media) + case exportFile(TimelineMediaPreviewFileExportPicker.File) + case authorizationRequired(appMediator: AppMediatorProtocol) + case dismissDetailsSheet + + var isItemLoaded: Bool { + switch self { + case .itemLoaded: true + default: false + } + } + + var isShowItemDetails: Bool { + switch self { + case .showItemDetails: true + default: false + } + } + + var isExportFile: Bool { + switch self { + case .exportFile: true + default: false + } + } + + var isAuthorizationRequired: Bool { + switch self { + case .authorizationRequired: true + default: false + } + } +} + struct TimelineMediaPreviewViewState: BindableState { /// The data source for all of the preview-able items. var dataSource: TimelineMediaPreviewDataSource @@ -22,23 +58,15 @@ struct TimelineMediaPreviewViewState: BindableState { /// All of the available actions for the current item. var currentItemActions: TimelineItemMenuActions? - /// The namespace used for the zoom transition. - let transitionNamespace: Namespace.ID - /// A publisher that the view model uses to signal to the QLPreviewController when the current item has been loaded. - let fileLoadedPublisher = PassthroughSubject() + /// A publisher that the view model uses to signal actions to the QLPreviewController. + let previewControllerDriver = PassthroughSubject() var bindings = TimelineMediaPreviewViewStateBindings() } struct TimelineMediaPreviewViewStateBindings { - /// A binding that will present the Details view for the specified item. - var mediaDetailsItem: TimelineMediaPreviewItem.Media? /// A binding that will present a confirmation to redact the specified item. var redactConfirmationItem: TimelineMediaPreviewItem.Media? - /// A binding that will present a document picker to export the specified file. - var fileToExport: TimelineMediaPreviewFileExportPicker.File? - - var alertInfo: AlertInfo? } enum TimelineMediaPreviewAlertType { @@ -51,5 +79,4 @@ enum TimelineMediaPreviewViewAction { case menuAction(TimelineItemMenuAction, item: TimelineMediaPreviewItem.Media) case redactConfirmation(item: TimelineMediaPreviewItem.Media) case timelineEndReached - case dismiss } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModifier.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModifier.swift new file mode 100644 index 0000000000..a4e2861014 --- /dev/null +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModifier.swift @@ -0,0 +1,188 @@ +// +// Copyright 2022-2024 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 QuickLook +import SwiftUI + +extension View { + /// Preview a media file using a QuickLook Preview Controller. The preview is interactive with + /// the dismiss gesture working as expected if it was presented from UIKit. + func timelineMediaPreview(viewModel: Binding) -> some View { + modifier(TimelineMediaPreviewModifier(viewModel: viewModel)) + } +} + +private struct TimelineMediaPreviewModifier: ViewModifier { + @Binding var viewModel: TimelineMediaPreviewViewModel? + + @State private var dismissalPublisher = PassthroughSubject() + + func body(content: Content) -> some View { + content.background { + if let viewModel { + MediaPreviewViewController(viewModel: viewModel, + dismissalPublisher: dismissalPublisher) { self.viewModel = nil } + .id(viewModel.instanceID) // Fixes a bug where opening a second preview too quickly can break presentation. + } else { + // Work around QLPreviewController dismissal issues, see below. + let _ = dismissalPublisher.send(()) + } + } + } +} + +private struct MediaPreviewViewController: UIViewControllerRepresentable { + let viewModel: TimelineMediaPreviewViewModel + let dismissalPublisher: PassthroughSubject + let onDismiss: () -> Void + + func makeUIViewController(context: Context) -> PreviewHostingController { + PreviewHostingController(viewModel: viewModel, + dismissalPublisher: dismissalPublisher, + onDismiss: onDismiss) + } + + func updateUIViewController(_ uiViewController: PreviewHostingController, context: Context) { } + + /// A view controller that hosts the QuickLook preview. + /// + /// This wrapper somehow allows the preview controller to do presentation/dismissal + /// animations and interactions which don't work if you represent it directly to SwiftUI 🤷‍♂️ + class PreviewHostingController: UIViewController, QLPreviewControllerDelegate { + let onDismiss: () -> Void + let sourceView = UIView() + + private let previewController: TimelineMediaPreviewController + private var hasBeenPresented = false + + private var dismissalObserver: AnyCancellable? + private var cancellables: Set = [] + + init(viewModel: TimelineMediaPreviewViewModel, + dismissalPublisher: PassthroughSubject, + onDismiss: @escaping () -> Void) { + self.onDismiss = onDismiss + previewController = TimelineMediaPreviewController(context: viewModel.context) + + super.init(nibName: nil, bundle: nil) + + // The QLPreviewController will not automatically dismiss itself when the underlying view is removed + // (e.g. switching rooms from a notification) and it continues to hold on to the whole hierarcy. + // Manually tell it to dismiss itself here. + dismissalObserver = dismissalPublisher.sink { [weak self] _ in + // Dispatching on main.async with weak self we avoid doing an extra dismiss if the view is presented on top of another modal + DispatchQueue.main.async { [weak self] in + self?.dismiss(animated: true) + } + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + view.backgroundColor = .clear + view.addSubview(sourceView) + + sourceView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + sourceView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + sourceView.centerYAnchor.constraint(equalTo: view.bottomAnchor), + sourceView.widthAnchor.constraint(equalToConstant: 100), + sourceView.heightAnchor.constraint(equalToConstant: 100) + ]) + } + + // Don't use viewWillAppear due to the following warning: + // Presenting view controller from detached view controller is not supported, + // and may result in incorrect safe area insets and a corrupt root presentation. Make sure is in + // the view controller hierarchy before presenting from it. Will become a hard exception in a future release. + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + guard !hasBeenPresented else { return } + + previewController.delegate = self + + present(previewController, animated: true) + + hasBeenPresented = true + } + + // MARK: QLPreviewControllerDelegate + + func previewController(_ controller: QLPreviewController, editingModeFor previewItem: QLPreviewItem) -> QLPreviewItemEditingMode { + .disabled + } + + func previewController(_ controller: QLPreviewController, transitionViewFor item: any QLPreviewItem) -> UIView? { + sourceView + } + + func previewControllerDidDismiss(_ controller: QLPreviewController) { + onDismiss() + } + } +} + +// MARK: - Previews + +struct TimelineMediaPreviewModifier_Previews: PreviewProvider { + static let viewModel = makeViewModel() + static let downloadingViewModel = makeViewModel(isDownloading: true) + static let downloadErrorViewModel = makeViewModel(isDownloadError: true) + + static var previews: some View { + MediaPreviewViewController(viewModel: viewModel, dismissalPublisher: .init()) { } + .previewDisplayName("Normal") + MediaPreviewViewController(viewModel: downloadingViewModel, dismissalPublisher: .init()) { } + .previewDisplayName("Downloading") + MediaPreviewViewController(viewModel: downloadErrorViewModel, dismissalPublisher: .init()) { } + .previewDisplayName("Download Error") + } + + static func makeViewModel(isDownloading: Bool = false, isDownloadError: Bool = false) -> TimelineMediaPreviewViewModel { + let item = FileRoomTimelineItem(id: .randomEvent, + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + isThreaded: false, + sender: .init(id: "", displayName: "Sally Sanderson"), + content: .init(filename: "Important document.pdf", + caption: "A caption goes right here.", + source: try? .init(url: .mockMXCFile, mimeType: nil), + fileSize: 3 * 1024 * 1024, + thumbnailSource: nil, + contentType: .pdf)) + + let timelineController = MockRoomTimelineController(timelineKind: .media(.mediaFilesScreen)) + timelineController.timelineItems = [item] + + let mediaProvider = MediaProviderMock(configuration: .init()) + + if isDownloading { + mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in + try? await Task.sleep(for: .seconds(3600)) + return .failure(.failedRetrievingFile) + } + } else if isDownloadError { + mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) } + } + + return TimelineMediaPreviewViewModel(initialItem: item, + timelineViewModel: TimelineViewModel.mock(timelineKind: timelineController.timelineKind, + timelineController: timelineController), + mediaProvider: mediaProvider, + photoLibraryManager: PhotoLibraryManagerMock(.init()), + userIndicatorController: UserIndicatorControllerMock(), + appMediator: AppMediatorMock()) + } +} diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift index a6b42af6ca..7f14d6c759 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift @@ -11,8 +11,9 @@ import Foundation typealias TimelineMediaPreviewViewModelType = StateStoreViewModel class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { + let instanceID = UUID() + private let timelineViewModel: TimelineViewModelProtocol - private let currentItemIDHandler: ((TimelineItemIdentifier?) -> Void)? private let mediaProvider: MediaProviderProtocol private let photoLibraryManager: PhotoLibraryManagerProtocol private let userIndicatorController: UserIndicatorControllerProtocol @@ -23,13 +24,13 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { actionsSubject.eraseToAnyPublisher() } - init(context: TimelineMediaPreviewContext, + init(initialItem: EventBasedMessageTimelineItemProtocol, + timelineViewModel: TimelineViewModelProtocol, mediaProvider: MediaProviderProtocol, photoLibraryManager: PhotoLibraryManagerProtocol, userIndicatorController: UserIndicatorControllerProtocol, appMediator: AppMediatorProtocol) { - timelineViewModel = context.viewModel - currentItemIDHandler = context.itemIDHandler + self.timelineViewModel = timelineViewModel self.mediaProvider = mediaProvider self.photoLibraryManager = photoLibraryManager self.userIndicatorController = userIndicatorController @@ -38,9 +39,8 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { let timelineState = timelineViewModel.context.viewState.timelineState super.init(initialViewState: TimelineMediaPreviewViewState(dataSource: .init(itemViewStates: timelineState.itemViewStates, - initialItem: context.item, - paginationState: timelineState.paginationState), - transitionNamespace: context.namespace), + initialItem: initialItem, + paginationState: timelineState.paginationState)), mediaProvider: mediaProvider) rebuildCurrentItemActions() @@ -72,10 +72,11 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { case .updateCurrentItem(let item): Task { await updateCurrentItem(item) } case .showItemDetails(let mediaItem): - state.bindings.mediaDetailsItem = mediaItem + state.previewControllerDriver.send(.showItemDetails(mediaItem)) case .menuAction(let action, let item): switch action { case .viewInRoomTimeline: + state.previewControllerDriver.send(.dismissDetailsSheet) actionsSubject.send(.viewInRoomTimeline(item.id)) case .save: Task { await saveCurrentItem() } @@ -88,8 +89,6 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { redactItem(item) case .timelineEndReached: showTimelineEndIndicator() - case .dismiss: - actionsSubject.send(.dismiss) } } @@ -101,13 +100,11 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { rebuildCurrentItemActions() if case let .media(mediaItem) = previewItem { - currentItemIDHandler?(mediaItem.id) - if mediaItem.fileHandle == nil, let source = mediaItem.mediaSource { switch await mediaProvider.loadFileFromSource(source, filename: mediaItem.filename) { case .success(let handle): mediaItem.fileHandle = handle - state.fileLoadedPublisher.send(mediaItem.id) + state.previewControllerDriver.send(.itemLoaded(mediaItem.id)) case .failure(let error): MXLog.error("Failed loading media: \(error)") context.objectWillChange.send() // Manually trigger the SwiftUI view update. @@ -143,12 +140,12 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { } // Dismiss the details sheet (nicer flow for images/video but _required_ in order to select a file directory). - state.bindings.mediaDetailsItem = nil + state.previewControllerDriver.send(.dismissDetailsSheet) do { switch mediaItem.timelineItem { case is AudioRoomTimelineItem, is FileRoomTimelineItem: - state.bindings.fileToExport = .init(url: fileURL) + state.previewControllerDriver.send(.exportFile(.init(url: fileURL))) return // Don't show the indicator. case is ImageRoomTimelineItem: try await photoLibraryManager.addResource(.photo, at: fileURL).get() @@ -161,10 +158,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { showSavedIndicator() } catch PhotoLibraryManagerError.notAuthorized { MXLog.error("Not authorised to save item to photo library") - state.bindings.alertInfo = .init(id: .authorizationRequired, - title: L10n.dialogPermissionPhotoLibraryTitleIos(InfoPlistReader.main.bundleDisplayName), - primaryButton: .init(title: L10n.commonSettings) { self.appMediator.openAppSettings() }, - secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil)) + state.previewControllerDriver.send(.authorizationRequired(appMediator: appMediator)) } catch { MXLog.error("Failed saving item: \(error)") showErrorIndicator() @@ -174,7 +168,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { private func redactItem(_ item: TimelineMediaPreviewItem.Media) { timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact)) state.bindings.redactConfirmationItem = nil - state.bindings.mediaDetailsItem = nil + state.previewControllerDriver.send(.dismissDetailsSheet) actionsSubject.send(.dismiss) showRedactedIndicator() } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewController.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewController.swift new file mode 100644 index 0000000000..d2bf80d225 --- /dev/null +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewController.swift @@ -0,0 +1,342 @@ +// +// Copyright 2022-2024 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 Compound +import QuickLook +import SwiftUI + +class TimelineMediaPreviewController: QLPreviewController { + private let context: TimelineMediaPreviewViewModel.Context + + private let headerHostingController: UIHostingController + private let detailsButtonHostingController: UIHostingController + private let captionHostingController: UIHostingController + private let downloadIndicatorHostingController: UIHostingController + private var detailsHostingController: UIHostingController? + + private var barButtonTimer: Timer? + + private var cancellables: Set = [] + + private var navigationBar: UINavigationBar? { view.subviews.first?.subviews.first { $0 is UINavigationBar } as? UINavigationBar } + private var toolbar: UIToolbar? { view.subviews.first?.subviews.last { $0 is UIToolbar } as? UIToolbar } + private var captionView: UIView { captionHostingController.view } + + override var overrideUserInterfaceStyle: UIUserInterfaceStyle { + get { .dark } + set { } + } + + init(context: TimelineMediaPreviewViewModel.Context) { + self.context = context + + headerHostingController = UIHostingController(rootView: HeaderView(context: context)) + headerHostingController.view.backgroundColor = .clear + headerHostingController.sizingOptions = .intrinsicContentSize + detailsButtonHostingController = UIHostingController(rootView: DetailsButton(context: context)) + detailsButtonHostingController.view.backgroundColor = .clear + detailsButtonHostingController.sizingOptions = .intrinsicContentSize + captionHostingController = UIHostingController(rootView: CaptionView(context: context)) + captionHostingController.view.backgroundColor = .clear + captionHostingController.sizingOptions = .intrinsicContentSize + downloadIndicatorHostingController = UIHostingController(rootView: DownloadIndicatorView(context: context)) + downloadIndicatorHostingController.view.backgroundColor = .clear + downloadIndicatorHostingController.sizingOptions = .intrinsicContentSize + + super.init(nibName: nil, bundle: nil) + + view.addSubview(captionView) + // Constraints added later as the toolbar isn't available yet. + + view.addSubview(downloadIndicatorHostingController.view) + downloadIndicatorHostingController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + downloadIndicatorHostingController.view.centerXAnchor.constraint(equalTo: view.centerXAnchor), + downloadIndicatorHostingController.view.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + + // Observation of currentPreviewItem doesn't work, so use the index instead. + publisher(for: \.currentPreviewItemIndex) + .sink { [weak self] _ in + // This isn't removing duplicates which may try to download and/or write to disk concurrently???? + self?.loadCurrentItem() + } + .store(in: &cancellables) + + context.viewState.dataSource.previewItemsPaginationPublisher + .sink { [weak self] in + self?.handleUpdatedItems() + } + .store(in: &cancellables) + + context.viewState.previewControllerDriver + .sink { [weak self] action in + switch action { + case .itemLoaded(let itemID): + self?.handleFileLoaded(itemID: itemID) + case .showItemDetails(let mediaItem): + self?.presentMediaDetails(for: mediaItem) + case .exportFile(let file): + self?.exportFile(file) + case .authorizationRequired(let appMediator): + self?.presentAuthorizationRequiredAlert(appMediator: appMediator) + case .dismissDetailsSheet: + self?.dismiss(animated: true) + } + } + .store(in: &cancellables) + + dataSource = context.viewState.dataSource + currentPreviewItemIndex = context.viewState.dataSource.initialItemIndex + } + + @available(*, unavailable) required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Layout + + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + + if let toolbar { + // Using the toolbar's visibility doesn't work so check its frame. + captionView.isHidden = toolbar.frame.minY >= view.frame.maxY + + if captionView.constraints.isEmpty { + captionHostingController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + captionView.bottomAnchor.constraint(equalTo: toolbar.topAnchor), + captionView.leadingAnchor.constraint(equalTo: toolbar.leadingAnchor), + captionView.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor) + ]) + } + } + + navigationBar?.topItem?.titleView = headerHostingController.view + + updateBarButtons() + + // Ridiculous hack to undo the controller's attempt to replace our info button with the list button. + if barButtonTimer == nil { + barButtonTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + self?.updateBarButtons() + } + } + } + + override func viewWillDisappear(_ animated: Bool) { + barButtonTimer?.invalidate() + barButtonTimer = nil + } + + private func updateBarButtons() { + guard let topItem = navigationBar?.topItem else { return } + + if topItem.leftBarButtonItem?.customView == nil { + let button = UIBarButtonItem(customView: detailsButtonHostingController.view) + navigationBar?.topItem?.leftBarButtonItem = button + } + } + + // MARK: Item loading + + private func loadCurrentItem() { + headerHostingController.view.sizeToFit() // Resizing isn't automatic in the toolbar 😒 + + if let previewItem = currentPreviewItem as? TimelineMediaPreviewItem.Media { + context.send(viewAction: .updateCurrentItem(.media(previewItem))) + } else if let loadingItem = currentPreviewItem as? TimelineMediaPreviewItem.Loading { + switch loadingItem.state { + case .paginating: + context.send(viewAction: .updateCurrentItem(.loading(loadingItem))) + case .timelineStart: + Task { await returnToIndex(context.viewState.dataSource.firstPreviewItemIndex) } + case .timelineEnd: + Task { await returnToIndex(context.viewState.dataSource.lastPreviewItemIndex) } + } + } else { + MXLog.error("Unexpected preview item type: \(type(of: currentPreviewItem))") + } + } + + private func returnToIndex(_ index: Int) async { + // Sleep to fix a bug where the update didn't take effect when the swipe velocity was slow. + try? await Task.sleep(for: .seconds(0.1)) + + currentPreviewItemIndex = index + context.send(viewAction: .timelineEndReached) + } + + private func handleUpdatedItems() { + if currentPreviewItem is TimelineMediaPreviewItem.Loading { + let dataSource = context.viewState.dataSource + if dataSource.previewController(self, previewItemAt: currentPreviewItemIndex) is TimelineMediaPreviewItem.Media { + refreshCurrentPreviewItem() // This will trigger loadCurrentItem automatically. + } + } + } + + private func handleFileLoaded(itemID: TimelineItemIdentifier) { + guard (currentPreviewItem as? TimelineMediaPreviewItem.Media)?.id == itemID else { return } + refreshCurrentPreviewItem() + } + + // MARK: - Actions + + private func presentMediaDetails(for mediaItem: TimelineMediaPreviewItem.Media) { + let safeArea = view.safeAreaInsets.bottom + let sheetHeightBinding = Binding { safeArea } set: { [weak self] newValue, _ in + self?.detailsHostingController?.sheetPresentationController?.detents = [.height(newValue + safeArea)] + } + + let hostingController = UIHostingController(rootView: TimelineMediaPreviewDetailsView(item: mediaItem, + context: context, + sheetHeight: sheetHeightBinding)) + hostingController.view.backgroundColor = .compound.bgCanvasDefault + hostingController.overrideUserInterfaceStyle = .dark + hostingController.sheetPresentationController?.detents = [.height(safeArea)] + hostingController.sheetPresentationController?.prefersGrabberVisible = true + + present(hostingController, animated: true) + + detailsHostingController = hostingController + } + + private func exportFile(_ file: TimelineMediaPreviewFileExportPicker.File) { + let hostingController = UIHostingController(rootView: TimelineMediaPreviewFileExportPicker(file: file)) + present(hostingController, animated: true) + } + + private func presentAuthorizationRequiredAlert(appMediator: AppMediatorProtocol) { + let alertController = UIAlertController(title: L10n.dialogPermissionPhotoLibraryTitleIos(InfoPlistReader.main.bundleDisplayName), + message: nil, + preferredStyle: .alert) + alertController.addAction(.init(title: L10n.commonSettings, style: .default) { _ in appMediator.openAppSettings() }) + alertController.addAction(.init(title: L10n.actionCancel, style: .cancel)) + + present(alertController, animated: true) + } +} + +// MARK: - Subviews + +private struct HeaderView: View { + @ObservedObject var context: TimelineMediaPreviewViewModel.Context + private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem } + + var body: some View { + switch currentItem { + case .media(let mediaItem): + VStack(spacing: 0) { + Text(mediaItem.sender.displayName ?? mediaItem.sender.id) + .font(.compound.bodySMSemibold) + .foregroundStyle(.compound.textPrimary) + Text(mediaItem.timestamp.formatted(date: .abbreviated, time: .omitted)) + .font(.compound.bodyXS) + .foregroundStyle(.compound.textPrimary) + .textCase(.uppercase) + } + .fixedSize(horizontal: true, vertical: false) + case .loading: + Text(L10n.commonLoadingMore) + .font(.compound.bodySMSemibold) + .foregroundStyle(.compound.textPrimary) + .fixedSize(horizontal: true, vertical: false) + } + } +} + +private struct DetailsButton: View { + @ObservedObject var context: TimelineMediaPreviewViewModel.Context + private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem } + + var isHidden: Bool { + switch currentItem { + case .media: false + case .loading: true + } + } + + var body: some View { + if case .media(let mediaItem) = currentItem { + Button { context.send(viewAction: .showItemDetails(mediaItem)) } label: { + CompoundIcon(\.info) + } + } + } +} + +private struct CaptionView: View { + @ObservedObject var context: TimelineMediaPreviewViewModel.Context + private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem } + + var body: some View { + if case let .media(mediaItem) = currentItem, let caption = mediaItem.caption { + Text(caption) + .font(.compound.bodyLG) + .foregroundStyle(.compound.textPrimary) + .lineLimit(5) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .padding(16) + .background { + BlurEffectView(style: .systemChromeMaterial) // Darkest material available, matches the bottom bar when content is beneath. + .ignoresSafeArea() + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } +} + +private struct DownloadIndicatorView: View { + @ObservedObject var context: TimelineMediaPreviewViewModel.Context + private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem } + + private var shouldShowDownloadIndicator: Bool { + switch currentItem { + case .media(let mediaItem): mediaItem.fileHandle == nil + case .loading(let loadingItem): loadingItem.state == .paginating + } + } + + var body: some View { + if case let .media(mediaItem) = currentItem, mediaItem.downloadError != nil { + VStack(spacing: 24) { + CompoundIcon(\.error, size: .custom(48), relativeTo: .compound.headingLG) + .foregroundStyle(.compound.iconCriticalPrimary) + .padding(.vertical, 24.5) + .padding(.horizontal, 28.5) + + VStack(spacing: 2) { + Text(L10n.commonDownloadFailed) + .font(.compound.headingMDBold) + .foregroundStyle(.compound.textPrimary) + .multilineTextAlignment(.center) + Text(L10n.screenMediaBrowserDownloadErrorMessage) + .font(.compound.bodyMD) + .foregroundStyle(.compound.textPrimary) + .multilineTextAlignment(.center) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 40) + .background(.compound.bgSubtlePrimary, in: RoundedRectangle(cornerRadius: 14)) + } else if shouldShowDownloadIndicator { + ProgressView() + .controlSize(.large) + .tint(.compound.iconPrimary) + } + } +} + +private extension UISheetPresentationController.Detent { + static func height(_ height: CGFloat) -> UISheetPresentationController.Detent { + .custom { _ in height } + } +} diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift index 0f16f3ee06..14788639b3 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift @@ -12,7 +12,7 @@ struct TimelineMediaPreviewDetailsView: View { let item: TimelineMediaPreviewItem.Media @ObservedObject var context: TimelineMediaPreviewViewModel.Context - @State private var sheetHeight: CGFloat = .zero + @Binding var sheetHeight: CGFloat private let topPadding: CGFloat = 19 var body: some View { @@ -169,16 +169,16 @@ struct TimelineMediaPreviewDetailsView: View { import UniformTypeIdentifiers struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePreview { - @Namespace private static var previewNamespace - static let viewModel = makeViewModel(contentType: .jpeg, isOutgoing: true) static let loadingViewModel = makeViewModel(contentType: .jpeg, isOutgoing: true, isDownloaded: false) static let unknownTypeViewModel = makeViewModel() static let presentedOnRoomViewModel = makeViewModel(isPresentedOnRoomScreen: true) + @State static var sheetHeight: CGFloat = .zero + static var previews: some View { if case let .media(mediaItem) = viewModel.state.currentItem { - TimelineMediaPreviewDetailsView(item: mediaItem, context: viewModel.context) + TimelineMediaPreviewDetailsView(item: mediaItem, context: viewModel.context, sheetHeight: $sheetHeight) .previewDisplayName("Image") .snapshotPreferences(expect: viewModel.context.$viewState.map { state in state.currentItemActions?.secondaryActions.contains(.redact) ?? false @@ -186,7 +186,7 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie } if case let .media(mediaItem) = loadingViewModel.state.currentItem { - TimelineMediaPreviewDetailsView(item: mediaItem, context: loadingViewModel.context) + TimelineMediaPreviewDetailsView(item: mediaItem, context: loadingViewModel.context, sheetHeight: $sheetHeight) .previewDisplayName("Loading") .snapshotPreferences(expect: loadingViewModel.context.$viewState.map { state in state.currentItemActions?.secondaryActions.contains(.redact) ?? false @@ -194,12 +194,12 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie } if case let .media(mediaItem) = unknownTypeViewModel.state.currentItem { - TimelineMediaPreviewDetailsView(item: mediaItem, context: unknownTypeViewModel.context) + TimelineMediaPreviewDetailsView(item: mediaItem, context: unknownTypeViewModel.context, sheetHeight: $sheetHeight) .previewDisplayName("Unknown type") } if case let .media(mediaItem) = presentedOnRoomViewModel.state.currentItem { - TimelineMediaPreviewDetailsView(item: mediaItem, context: presentedOnRoomViewModel.context) + TimelineMediaPreviewDetailsView(item: mediaItem, context: presentedOnRoomViewModel.context, sheetHeight: $sheetHeight) .previewDisplayName("Incoming on Room") } } @@ -226,10 +226,9 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie let timelineController = MockRoomTimelineController(timelineKind: timelineKind) timelineController.timelineItems = [item] - let viewModel = TimelineMediaPreviewViewModel(context: .init(item: item, - viewModel: TimelineViewModel.mock(timelineKind: timelineKind, - timelineController: timelineController), - namespace: previewNamespace), + let viewModel = TimelineMediaPreviewViewModel(initialItem: item, + timelineViewModel: TimelineViewModel.mock(timelineKind: timelineKind, + timelineController: timelineController), mediaProvider: MediaProviderMock(configuration: .init()), photoLibraryManager: PhotoLibraryManagerMock(.init()), userIndicatorController: UserIndicatorControllerMock(), diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift index 24f6757eab..5f5234ac0e 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift @@ -121,7 +121,6 @@ struct TimelineMediaPreviewRedactConfirmationView: View { import UniformTypeIdentifiers struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, TestablePreview { - @Namespace private static var previewNamespace static let viewModel = makeViewModel(contentType: .jpeg) static var previews: some View { @@ -147,10 +146,9 @@ struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, Tes let timelineController = MockRoomTimelineController(timelineKind: .media(.mediaFilesScreen)) timelineController.timelineItems = [item] - return TimelineMediaPreviewViewModel(context: .init(item: item, - viewModel: TimelineViewModel.mock(timelineKind: timelineController.timelineKind, - timelineController: timelineController), - namespace: previewNamespace), + return TimelineMediaPreviewViewModel(initialItem: item, + timelineViewModel: TimelineViewModel.mock(timelineKind: timelineController.timelineKind, + timelineController: timelineController), mediaProvider: MediaProviderMock(configuration: .init()), photoLibraryManager: PhotoLibraryManagerMock(.init()), userIndicatorController: UserIndicatorControllerMock(), diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewScreen.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewScreen.swift deleted file mode 100644 index 29433b3a98..0000000000 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewScreen.swift +++ /dev/null @@ -1,326 +0,0 @@ -// -// Copyright 2022-2024 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 Compound -import QuickLook -import SwiftUI - -struct TimelineMediaPreviewScreen: View { - @ObservedObject var context: TimelineMediaPreviewViewModel.Context - var itemIDHandler: ((TimelineItemIdentifier?) -> Void)? - - @State private var isFullScreen = false - private var toolbarVisibility: Visibility { isFullScreen ? .hidden : .visible } - - private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem } - private var currentItemID: TimelineItemIdentifier? { - guard case .media(let mediaItem) = currentItem else { return nil } - return mediaItem.id - } - - private var shouldShowDownloadIndicator: Bool { - switch currentItem { - case .media(let mediaItem): mediaItem.fileHandle == nil - case .loading(let loadingItem): loadingItem.state == .paginating - } - } - - var body: some View { - NavigationStack { - quickLookPreview - } - .introspect(.navigationStack, on: .supportedVersions) { - // Fixes a bug where the QuickLook view overrides the .toolbarBackground(.visible) after it loads the real item. - $0.navigationBar.scrollEdgeAppearance = $0.navigationBar.standardAppearance - $0.toolbar.scrollEdgeAppearance = $0.toolbar.standardAppearance - } - .sheet(item: $context.mediaDetailsItem) { item in - TimelineMediaPreviewDetailsView(item: item, context: context) - } - .sheet(item: $context.fileToExport) { file in - TimelineMediaPreviewFileExportPicker(file: file) - .preferredColorScheme(.dark) - } - .alert(item: $context.alertInfo) - .preferredColorScheme(.dark) - .onDisappear { - itemIDHandler?(nil) - } - .zoomTransition(sourceID: currentItemID, in: context.viewState.transitionNamespace) - } - - var quickLookPreview: some View { - Color.clear // A completely clear view breaks any SwiftUI gestures (such as drag to dismiss). - .background { QuickLookView(viewModelContext: context).ignoresSafeArea() } // Not the root view to stop QL hijacking the toolbar. - .overlay(alignment: .topTrailing) { fullScreenButton } - .overlay { downloadStatusIndicator } - .toolbar { toolbar } - .toolbar(toolbarVisibility, for: .navigationBar) - .toolbarBackground(.visible, for: .navigationBar) // The toolbar's scrollEdgeAppearance isn't aware of the quicklook view 🤷‍♂️ - .navigationBarTitleDisplayMode(.inline) - .safeAreaInset(edge: .bottom, spacing: 0) { caption } - } - - @ViewBuilder - private var fullScreenButton: some View { - if case .media = currentItem { - Button { - withAnimation { isFullScreen.toggle() } - } label: { - CompoundIcon(isFullScreen ? \.collapse : \.expand, size: .xSmall, relativeTo: .compound.bodyLG) - .padding(6) - .background(.thinMaterial, in: Circle()) - } - .tint(.compound.textActionPrimary) - .padding(.top, 12) - .padding(.trailing, 14) - } - } - - @ViewBuilder - private var downloadStatusIndicator: some View { - if case let .media(mediaItem) = currentItem, mediaItem.downloadError != nil { - VStack(spacing: 24) { - CompoundIcon(\.error, size: .custom(48), relativeTo: .compound.headingLG) - .foregroundStyle(.compound.iconCriticalPrimary) - .padding(.vertical, 24.5) - .padding(.horizontal, 28.5) - - VStack(spacing: 2) { - Text(L10n.commonDownloadFailed) - .font(.compound.headingMDBold) - .foregroundStyle(.compound.textPrimary) - .multilineTextAlignment(.center) - Text(L10n.screenMediaBrowserDownloadErrorMessage) - .font(.compound.bodyMD) - .foregroundStyle(.compound.textPrimary) - .multilineTextAlignment(.center) - } - } - .padding(.horizontal, 24) - .padding(.vertical, 40) - .background(.compound.bgSubtlePrimary, in: RoundedRectangle(cornerRadius: 14)) - } else if shouldShowDownloadIndicator { - ProgressView() - .controlSize(.large) - .tint(.compound.iconPrimary) - } - } - - @ViewBuilder - private var caption: some View { - if case let .media(mediaItem) = currentItem, let caption = mediaItem.caption, !isFullScreen { - Text(caption) - .font(.compound.bodyLG) - .foregroundStyle(.compound.textPrimary) - .lineLimit(5) - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) - .padding(16) - .background { - BlurEffectView(style: .systemChromeMaterial) // Darkest material available, matches the bottom bar when content is beneath. - .ignoresSafeArea() - } - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - } - - @ToolbarContentBuilder - private var toolbar: some ToolbarContent { - ToolbarItem(placement: .cancellationAction) { - Button { context.send(viewAction: .dismiss) } label: { - Image(systemSymbol: .chevronBackward) - .fontWeight(.semibold) - } - .tint(.compound.textActionPrimary) // These fix a bug where the light tint is shown when foregrounding the app. - } - - ToolbarItem(placement: .principal) { - toolbarHeader - } - - if case let .media(mediaItem) = currentItem { - ToolbarItem(placement: .primaryAction) { - Button { context.send(viewAction: .showItemDetails(mediaItem)) } label: { - CompoundIcon(\.info) - } - .tint(.compound.textActionPrimary) - } - } - } - - @ViewBuilder - private var toolbarHeader: some View { - switch currentItem { - case .media(let mediaItem): - VStack(spacing: 0) { - Text(mediaItem.sender.displayName ?? mediaItem.sender.id) - .font(.compound.bodySMSemibold) - .foregroundStyle(.compound.textPrimary) - Text(mediaItem.timestamp.formatted(date: .abbreviated, time: .omitted)) - .font(.compound.bodyXS) - .foregroundStyle(.compound.textPrimary) - .textCase(.uppercase) - } - case .loading: - Text(L10n.commonLoadingMore) - .font(.compound.bodySMSemibold) - .foregroundStyle(.compound.textPrimary) - } - } -} - -// MARK: - QuickLook - -private struct QuickLookView: UIViewControllerRepresentable { - let viewModelContext: TimelineMediaPreviewViewModel.Context - - func makeUIViewController(context: Context) -> QLPreviewController { - context.coordinator.previewController - } - - func updateUIViewController(_ uiViewController: QLPreviewController, context: Context) { } - - func makeCoordinator() -> Coordinator { - Coordinator(viewModelContext: viewModelContext) - } - - // MARK: Coordinator - - @MainActor class Coordinator { - let previewController = QLPreviewController() - - private let viewModelContext: TimelineMediaPreviewViewModel.Context - - private var cancellables: Set = [] - - init(viewModelContext: TimelineMediaPreviewViewModel.Context) { - self.viewModelContext = viewModelContext - - // Observation of currentPreviewItem doesn't work, so use the index instead. - previewController.publisher(for: \.currentPreviewItemIndex) - .sink { [weak self] _ in - // This isn't removing duplicates which may try to download and/or write to disk concurrently???? - self?.loadCurrentItem() - } - .store(in: &cancellables) - - viewModelContext.viewState.dataSource.previewItemsPaginationPublisher - .sink { [weak self] in - self?.handleUpdatedItems() - } - .store(in: &cancellables) - - viewModelContext.viewState.fileLoadedPublisher - .sink { [weak self] itemID in - self?.handleFileLoaded(itemID: itemID) - } - .store(in: &cancellables) - - previewController.dataSource = viewModelContext.viewState.dataSource - previewController.currentPreviewItemIndex = viewModelContext.viewState.dataSource.initialItemIndex - } - - private func loadCurrentItem() { - if let previewItem = previewController.currentPreviewItem as? TimelineMediaPreviewItem.Media { - viewModelContext.send(viewAction: .updateCurrentItem(.media(previewItem))) - } else if let loadingItem = previewController.currentPreviewItem as? TimelineMediaPreviewItem.Loading { - switch loadingItem.state { - case .paginating: - viewModelContext.send(viewAction: .updateCurrentItem(.loading(loadingItem))) - case .timelineStart: - Task { await returnToIndex(viewModelContext.viewState.dataSource.firstPreviewItemIndex) } - case .timelineEnd: - Task { await returnToIndex(viewModelContext.viewState.dataSource.lastPreviewItemIndex) } - } - } else { - MXLog.error("Unexpected preview item type: \(type(of: previewController.currentPreviewItem))") - } - } - - private func returnToIndex(_ index: Int) async { - // Sleep to fix a bug where the update didn't take effect when the swipe velocity was slow. - try? await Task.sleep(for: .seconds(0.1)) - - previewController.currentPreviewItemIndex = index - viewModelContext.send(viewAction: .timelineEndReached) - } - - private func handleUpdatedItems() { - if previewController.currentPreviewItem is TimelineMediaPreviewItem.Loading { - let dataSource = viewModelContext.viewState.dataSource - if dataSource.previewController(previewController, previewItemAt: previewController.currentPreviewItemIndex) is TimelineMediaPreviewItem.Media { - previewController.refreshCurrentPreviewItem() // This will trigger loadCurrentItem automatically. - } - } - } - - private func handleFileLoaded(itemID: TimelineItemIdentifier) { - guard (previewController.currentPreviewItem as? TimelineMediaPreviewItem.Media)?.id == itemID else { return } - previewController.refreshCurrentPreviewItem() - } - } -} - -// MARK: - Previews - -struct TimelineMediaPreviewScreen_Previews: PreviewProvider { - @Namespace private static var namespace - - static let viewModel = makeViewModel() - static let downloadingViewModel = makeViewModel(isDownloading: true) - static let downloadErrorViewModel = makeViewModel(isDownloadError: true) - - static var previews: some View { - TimelineMediaPreviewScreen(context: viewModel.context) - .previewDisplayName("Normal") - TimelineMediaPreviewScreen(context: downloadingViewModel.context) - .previewDisplayName("Downloading") - TimelineMediaPreviewScreen(context: downloadErrorViewModel.context) - .previewDisplayName("Download Error") - } - - static func makeViewModel(isDownloading: Bool = false, isDownloadError: Bool = false) -> TimelineMediaPreviewViewModel { - let item = FileRoomTimelineItem(id: .randomEvent, - timestamp: .mock, - isOutgoing: false, - isEditable: false, - canBeRepliedTo: true, - isThreaded: false, - sender: .init(id: "", displayName: "Sally Sanderson"), - content: .init(filename: "Important document.pdf", - caption: "A caption goes right here.", - source: try? .init(url: .mockMXCFile, mimeType: nil), - fileSize: 3 * 1024 * 1024, - thumbnailSource: nil, - contentType: .pdf)) - - let timelineController = MockRoomTimelineController(timelineKind: .media(.mediaFilesScreen)) - timelineController.timelineItems = [item] - - let mediaProvider = MediaProviderMock(configuration: .init()) - - if isDownloading { - mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in - try? await Task.sleep(for: .seconds(3600)) - return .failure(.failedRetrievingFile) - } - } else if isDownloadError { - mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) } - } - - return TimelineMediaPreviewViewModel(context: .init(item: item, - viewModel: TimelineViewModel.mock(timelineKind: timelineController.timelineKind, - timelineController: timelineController), - namespace: namespace), - mediaProvider: mediaProvider, - photoLibraryManager: PhotoLibraryManagerMock(.init()), - userIndicatorController: UserIndicatorControllerMock(), - appMediator: AppMediatorMock()) - } -} diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift index 974bba9478..f24c39c374 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift @@ -21,7 +21,7 @@ struct MediaEventsTimelineScreenCoordinatorParameters { } enum MediaEventsTimelineScreenCoordinatorAction { - case viewItem(TimelineMediaPreviewContext) + case viewInRoomTimeline(TimelineItemIdentifier) } final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol { @@ -63,13 +63,14 @@ final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol { viewModel = MediaEventsTimelineScreenViewModel(mediaTimelineViewModel: mediaTimelineViewModel, filesTimelineViewModel: filesTimelineViewModel, mediaProvider: parameters.mediaProvider, - userIndicatorController: parameters.userIndicatorController) + userIndicatorController: parameters.userIndicatorController, + appMediator: parameters.appMediator) viewModel.actionsPublisher .sink { [weak self] action in switch action { - case .viewItem(let previewContext): - self?.actionsSubject.send(.viewItem(previewContext)) + case .viewInRoomTimeline(let itemID): + self?.actionsSubject.send(.viewInRoomTimeline(itemID)) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift index 73869c6d8c..da00717a12 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift @@ -8,7 +8,7 @@ import SwiftUI enum MediaEventsTimelineScreenViewModelAction { - case viewItem(TimelineMediaPreviewContext) + case viewInRoomTimeline(TimelineItemIdentifier) } enum MediaEventsTimelineScreenMode { @@ -31,17 +31,16 @@ struct MediaEventsTimelineScreenViewState: BindableState { var activeTimelineContextProvider: (() -> TimelineViewModel.Context)! var bindings: MediaEventsTimelineScreenViewStateBindings - - var currentPreviewItemID: TimelineItemIdentifier? } struct MediaEventsTimelineScreenViewStateBindings { var screenMode: MediaEventsTimelineScreenMode + var mediaPreviewViewModel: TimelineMediaPreviewViewModel? } enum MediaEventsTimelineScreenViewAction { case changedScreenMode case oldestItemDidAppear case oldestItemDidDisappear - case tappedItem(item: RoomTimelineItemViewState, namespace: Namespace.ID) + case tappedItem(item: RoomTimelineItemViewState) } diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift index 6165f22ceb..efea757f65 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift @@ -13,7 +13,9 @@ typealias MediaEventsTimelineScreenViewModelType = StateStoreViewModel String { diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift index 276fbe0805..2055fcd659 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift @@ -11,8 +11,6 @@ import SwiftUI struct MediaEventsTimelineScreen: View { @ObservedObject var context: MediaEventsTimelineScreenViewModel.Context - @Namespace private var zoomTransition - var body: some View { mainContent .navigationBarTitleDisplayMode(.inline) @@ -25,6 +23,7 @@ struct MediaEventsTimelineScreen: View { .onChange(of: context.screenMode) { _, _ in context.send(viewAction: .changedScreenMode) } + .timelineMediaPreview(viewModel: $context.mediaPreviewViewModel) } // The scale effects do the following: @@ -65,9 +64,8 @@ struct MediaEventsTimelineScreen: View { tappedItem(item) } label: { viewForTimelineItem(item) - .scaleEffect(scale(for: item, isGridLayout: true)) + .scaleEffect(CGSize(width: -1, height: -1)) } - .zoomTransitionSource(id: item.identifier, in: zoomTransition) } } footer: { // Use a footer as the header because the scrollView is flipped @@ -92,9 +90,8 @@ struct MediaEventsTimelineScreen: View { tappedItem(item) } label: { viewForTimelineItem(item) - .scaleEffect(scale(for: item, isGridLayout: false)) + .scaleEffect(CGSize(width: 1, height: -1)) } - .zoomTransitionSource(id: item.identifier, in: zoomTransition) } .padding(.horizontal, 16) } @@ -216,16 +213,7 @@ struct MediaEventsTimelineScreen: View { } func tappedItem(_ item: RoomTimelineItemViewState) { - context.send(viewAction: .tappedItem(item: item, namespace: zoomTransition)) - } - - func scale(for item: RoomTimelineItemViewState, isGridLayout: Bool) -> CGSize { - if item.identifier == context.viewState.currentPreviewItemID, #available(iOS 18.0, *) { - // Remove the flip when presenting a preview so that the zoom transition is the right way up 🙃 - CGSize(width: 1, height: 1) - } else { - CGSize(width: isGridLayout ? -1 : 1, height: -1) - } + context.send(viewAction: .tappedItem(item: item)) } } @@ -265,7 +253,8 @@ struct MediaEventsTimelineScreen_Previews: PreviewProvider, TestablePreview { filesTimelineViewModel: makeTimelineViewModel(empty: empty), initialViewState: .init(bindings: .init(screenMode: screenMode)), mediaProvider: MediaProviderMock(configuration: .init()), - userIndicatorController: UserIndicatorControllerMock()) + userIndicatorController: UserIndicatorControllerMock(), + appMediator: AppMediatorMock()) } private static func makeTimelineViewModel(empty: Bool) -> TimelineViewModel { diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-en-GB.Loading.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-en-GB.Loading.png index e85193f21a..f6800d8ba8 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-en-GB.Loading.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-en-GB.Loading.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a30bdebdc1c816e6ca11cc4259749ec3e7f873e89c38e5cc64b23b5b7dcf7ebb -size 129684 +oid sha256:7c08d326daa4e6f1daafeff7eafa3bc81b456bc6471e0e65d61bf01232583f84 +size 126883 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-pseudo.Loading.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-pseudo.Loading.png index 65629de67c..9fab8ec3de 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-pseudo.Loading.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-pseudo.Loading.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:90f32840b85ff12b6de880e8ab1890f4442b873fac71083093324fc02e9700d3 -size 128067 +oid sha256:8a5e5762b6293e672af6521ee9608882ab58d0f855580685e52a33b288004f81 +size 126410 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-en-GB.Loading.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-en-GB.Loading.png index e4d0518d1e..0d776f398e 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-en-GB.Loading.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-en-GB.Loading.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:339faa15c493a6e36f0c567b89eb6d036010c5637a0be03f15f7915306612320 -size 82212 +oid sha256:4ae2436f59c028a6aa544b456d13135dc3b0b64cd14b1f964e8d8a03050ee2f6 +size 80579 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-pseudo.Loading.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-pseudo.Loading.png index 009ea838a8..5bbebee2a4 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-pseudo.Loading.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-pseudo.Loading.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:04241b16f28aa03f42de4e365eadf80fca0637494e891eb5c81a337d7693f476 -size 80107 +oid sha256:01cd56cad5b5a73e4b6c3edccac2b2e31fd6ea13829ae3f551e3272b28c03192 +size 78920 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPad-en-GB.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPad-en-GB.1.png index 4ed1d5e03d..032179491b 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPad-en-GB.1.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPad-en-GB.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:32ab5b9833a9213daf0a9688ac7d9db6fd112a312e725afe194d813951bb4268 -size 119892 +oid sha256:5c3a13b9e1975255bb09e0db46b67ea70fe6d88d5ce846a0d8e76b67cbee59f2 +size 118791 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPad-pseudo.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPad-pseudo.1.png index 47bf957234..e45821f9f9 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPad-pseudo.1.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPad-pseudo.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a7b3fc983e75283a83041f37db96689af1487828d249a989d7ff10ec5c73f0f2 -size 128217 +oid sha256:953d61772a749a0aeca48c1ffaf5769ef9930b030c0804bfc7c39c9a279b5a0d +size 127235 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPhone-16-en-GB.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPhone-16-en-GB.1.png index 5a795a925a..27e1fcfa15 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPhone-16-en-GB.1.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPhone-16-en-GB.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:825917a093ef369a8b6583d825d675c9a44c96f2f6ab9cab2ec5c19e7508b425 -size 74696 +oid sha256:bf416680c94639c9f1f3002b210c8d8871ef2268acb2f8f89b8fb14c530b4969 +size 73718 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPhone-16-pseudo.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPhone-16-pseudo.1.png index 646e6fde05..b2dca03b72 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPhone-16-pseudo.1.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPhone-16-pseudo.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ebc3ba85ab3bbd09db0a002fb3b729ebe1390d975061c6804c35d3da5822a1b7 -size 87336 +oid sha256:596cb5285a11299aa7cdbf2b55a44dfd45909d7c5ba95d90c34bc30929049a95 +size 86526 diff --git a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift index d700b487e7..312936bc07 100644 --- a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift +++ b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift @@ -51,7 +51,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { // 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 } + let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: 1) { $0.isItemLoaded } context.send(viewAction: .updateCurrentItem(.media(context.viewState.dataSource.previewItems[0]))) try await failure.fulfill() @@ -66,7 +66,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { try await testLoadingItem() // When swiping to another item. - let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true } + let deferred = deferFulfillment(viewModel.state.previewControllerDriver) { $0.isItemLoaded } context.send(viewAction: .updateCurrentItem(.media(context.viewState.dataSource.previewItems[1]))) try await deferred.fulfill() @@ -75,7 +75,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[1])) // When swiping back to the first item. - let failure = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true } + let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: 1) { $0.isItemLoaded } context.send(viewAction: .updateCurrentItem(.media(context.viewState.dataSource.previewItems[0]))) try await failure.fulfill() @@ -89,9 +89,9 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { try await testLoadingItem() // When swiping to a "loading more" item. - let deferred = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true } + let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: 1) { $0.isItemLoaded } context.send(viewAction: .updateCurrentItem(.loading(.paginating))) - try await deferred.fulfill() + try await failure.fulfill() // Then there should no longer be a media preview and no attempt should be made to load one. XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 1) @@ -111,7 +111,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { // And the preview controller attempts to update the current item (now at a new index in the array but it hasn't changed in the data source). mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) } - let failure = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true } + let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: 1) { $0.isItemLoaded } context.send(viewAction: .updateCurrentItem(.media(context.viewState.dataSource.previewItems[3]))) try await failure.fulfill() @@ -148,25 +148,27 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { } // When choosing to show the item details. + let deferredDriver = deferFulfillment(context.viewState.previewControllerDriver) { $0.isShowItemDetails } context.send(viewAction: .showItemDetails(mediaItem)) // Then the details sheet should be presented. - guard let mediaDetailsItem = context.mediaDetailsItem else { - XCTFail("The default of the current item should be presented") + let action = try await deferredDriver.fulfill() + guard case let .showItemDetails(mediaDetailsItem) = action else { + XCTFail("The action should include the media item.") return } XCTAssertEqual(.media(mediaDetailsItem), context.viewState.currentItem) // When choosing to redact the item. - context.send(viewAction: .menuAction(.redact, item: mediaDetailsItem)) + context.send(viewAction: .menuAction(.redact, item: mediaItem)) // Then the confirmation sheet should be presented. - XCTAssertEqual(context.redactConfirmationItem, mediaDetailsItem) + XCTAssertEqual(context.redactConfirmationItem, mediaItem) XCTAssertFalse(timelineController.redactCalled) // When confirming the redaction. let deferred = deferFulfillment(viewModel.actions) { $0 == .dismiss } - context.send(viewAction: .redactConfirmation(item: mediaDetailsItem)) + context.send(viewAction: .redactConfirmation(item: mediaItem)) // Then the item should be redacted and the view should be dismissed. try await deferred.fulfill() @@ -203,13 +205,12 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { XCTAssertEqual(mediaItem.contentType, "JPEG image") // When choosing to save the image. - let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil } + let deferred = deferFulfillment(context.viewState.previewControllerDriver) { $0.isAuthorizationRequired } context.send(viewAction: .menuAction(.save, item: mediaItem)) - try await deferred.fulfill() // Then the user should be prompted to allow access. + try await deferred.fulfill() XCTAssertTrue(photoLibraryManager.addResourceAtCalled) - XCTAssertEqual(context.alertInfo?.id, .authorizationRequired) } func testSaveVideo() async throws { @@ -243,31 +244,24 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { XCTAssertEqual(mediaItem.contentType, "PDF document") // When choosing to save the file. + let deferred = deferFulfillment(context.viewState.previewControllerDriver) { $0.isExportFile } context.send(viewAction: .menuAction(.save, item: mediaItem)) - try await Task.sleep(for: .seconds(0.5)) + let exportAction = try await deferred.fulfill() + + guard case let .exportFile(file) = exportAction else { + XCTFail("Unexpected action") + return + } // 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, mediaItem.fileHandle?.url) - } - - func testDismiss() async throws { - // Given a view model with a loaded item. - try await testLoadingItem() - - // When requesting to dismiss the view. - let deferred = deferFulfillment(viewModel.actions) { $0 == .dismiss } - context.send(viewAction: .dismiss) - - // Then the action should be sent upwards to make this happen. - try await deferred.fulfill() + XCTAssertEqual(file.url, mediaItem.fileHandle?.url) } // MARK: - Helpers private func loadInitialItem() async throws { - let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true } + let deferred = deferFulfillment(viewModel.state.previewControllerDriver) { $0.isItemLoaded } let initialItem = context.viewState.dataSource.previewController(QLPreviewController(), previewItemAt: context.viewState.dataSource.initialItemIndex) guard let initialPreviewItem = initialItem as? TimelineMediaPreviewItem.Media else { @@ -278,8 +272,6 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { 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)) @@ -288,10 +280,9 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { mediaProvider = MediaProviderMock(configuration: .init()) photoLibraryManager = PhotoLibraryManagerMock(.init(authorizationDenied: photoLibraryAuthorizationDenied)) - viewModel = TimelineMediaPreviewViewModel(context: .init(item: initialItems[initialItemIndex], - viewModel: TimelineViewModel.mock(timelineKind: .media(.mediaFilesScreen), - timelineController: timelineController), - namespace: testNamespace), + viewModel = TimelineMediaPreviewViewModel(initialItem: initialItems[initialItemIndex], + timelineViewModel: TimelineViewModel.mock(timelineKind: .media(.mediaFilesScreen), + timelineController: timelineController), mediaProvider: mediaProvider, photoLibraryManager: photoLibraryManager, userIndicatorController: UserIndicatorControllerMock(),