From 3a82b888594f6c69ba06886274269a6318c1f722 Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Mon, 16 Dec 2024 15:27:50 +0000 Subject: [PATCH] Rework the presentation of the media browser quick look view to use SwiftUI. (#3619) * Embed the media preview quick look inside a full screen cover With a zoom transition on iOS 18. * Use a the representable coordinator properly. * Fix a bug with the toolbar appearance. * Format * Try prevent the zoom transition being upside down. * Fix the snapshot test configuration. --- ElementX.xcodeproj/project.pbxproj | 28 ++- .../MediaEventsTimelineFlowCoordinator.swift | 28 ++- .../RoomFlowCoordinator.swift | 4 +- .../PlatformViewVersionPredicate.swift | 12 +- .../SwiftUI/Animation/ZoomTransition.swift | 30 +++ .../Other/SwiftUI/Views/BlurEffectView.swift | 22 ++ .../Screens/CallScreen/View/CallScreen.swift | 1 - .../FilePreviewScreen/MediaFileManager.swift | 8 + .../TimelineMediaPreviewController.swift | 180 --------------- .../TimelineMediaPreviewCoordinator.swift | 74 ++++++ .../TimelineMediaPreviewModels.swift | 34 +-- .../TimelineMediaPreviewView.swift | 212 ++++++++++++++++++ .../TimelineMediaPreviewViewModel.swift | 60 +++-- .../TimelineMediaQuickLook.swift | 156 ------------- .../TimelineMediaPreviewDetailsView.swift | 51 +++-- ...neMediaPreviewRedactConfirmationView.swift | 25 ++- ...MediaEventsTimelineScreenCoordinator.swift | 6 +- .../MediaEventsTimelineScreenModels.swift | 9 +- .../MediaEventsTimelineScreenViewModel.swift | 37 ++- .../View/MediaEventsTimelineScreen.swift | 28 ++- .../Screens/RoomScreen/View/RoomScreen.swift | 1 - .../Screens/Timeline/TimelineModels.swift | 2 - .../TimelineMediaPreviewViewModelTests.swift | 58 +++-- 23 files changed, 593 insertions(+), 473 deletions(-) create mode 100644 ElementX/Sources/Other/SwiftUI/Animation/ZoomTransition.swift create mode 100644 ElementX/Sources/Other/SwiftUI/Views/BlurEffectView.swift create mode 100644 ElementX/Sources/Screens/FilePreviewScreen/MediaFileManager.swift delete mode 100644 ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewController.swift create mode 100644 ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewCoordinator.swift create mode 100644 ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewView.swift delete mode 100644 ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaQuickLook.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 3397baeb8a..0c6e3eb9bf 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -419,6 +419,7 @@ 53F1196F9C69512306A2693F /* TextRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28C19F54A0C4FC9AB7ABD583 /* TextRoomTimelineItemContent.swift */; }; 54AE8860D668AFD96E7E177B /* UITestsScreenIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CEBE5EA91E8691EDF364EC2 /* UITestsScreenIdentifier.swift */; }; 54C774874BED4A8FAD1F22FE /* AnalyticsConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77B3D4950F1707E66E4A45A /* AnalyticsConfiguration.swift */; }; + 54FDA3625AACBD9E438D084D /* BlurEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07934EF08BB39353E4A94272 /* BlurEffectView.swift */; }; 5518DA4A6C9B4FC4B497EA9A /* LogViewerScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B795AAAB7B8747FE2FF311 /* LogViewerScreenModels.swift */; }; 558E2673B04FDD06A1A12DD3 /* LogViewerScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7463464054DDF194C54F0B04 /* LogViewerScreenViewModelProtocol.swift */; }; 558F37B1A8F2C4CC9B1ACEDA /* Compound in Frameworks */ = {isa = PBXBuildFile; productRef = 3262F08E1C3483C22A7A319F /* Compound */; }; @@ -640,6 +641,7 @@ 8015842CB4DE1BE414D2CDED /* AppLockSetupBiometricsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C62E07C1164F5120727A2A8 /* AppLockSetupBiometricsScreenCoordinator.swift */; }; 804C15D8ADE0EA7A5268F58A /* OverridableAvatarImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648DD1C10E4957CB791FE0B8 /* OverridableAvatarImage.swift */; }; 80DEA2A4B20F9E279EAE6B2B /* UserProfile+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD01F7FC2BBAC7351948595 /* UserProfile+Mock.swift */; }; + 80F1B442DB5E2C362ACDD8E2 /* ZoomTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018194CAFBE80720FECCEDEE /* ZoomTransition.swift */; }; 80F6C8EFCA4564B67F0D34B0 /* DeactivateAccountScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77F75B3E9F99864048A422A /* DeactivateAccountScreenViewModelTests.swift */; }; 81A7C020CB5F6232242A8414 /* UserSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F36C0A6D59717193F49EA986 /* UserSessionTests.swift */; }; 8285FF4B2C2331758C437FF7 /* ReportContentScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713B48DBF65DE4B0DD445D66 /* ReportContentScreenViewModelProtocol.swift */; }; @@ -864,6 +866,7 @@ AE1160076F663BF14E0E893A /* EffectsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4548A9BDE5CB3AB864BCA9F /* EffectsView.swift */; }; AE1A73B24D63DA3D63DC4EE3 /* SessionVerificationControllerProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 248649EBA5BC33DB93698734 /* SessionVerificationControllerProxyMock.swift */; }; AE5AAD9E32511544FDFA5560 /* WindowManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F27F588F9059128E17C669 /* WindowManagerProtocol.swift */; }; + AE69B349B0011D5EE2C13606 /* TimelineMediaPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A76B92984638A9B3104840D /* TimelineMediaPreviewView.swift */; }; AF19D65A9C60C6B2646F3210 /* RedactedRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E6BDF9D26DB05C88901416 /* RedactedRoomTimelineItem.swift */; }; AF2ABA2794E376B64104C964 /* MockSoftLogoutScreenState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5644919DB2022397D9D5825A /* MockSoftLogoutScreenState.swift */; }; AF33B9044498211C3D82F1E1 /* UNTextInputNotificationResponse+Creator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130ED565A078F7E0B59D9D25 /* UNTextInputNotificationResponse+Creator.swift */; }; @@ -950,7 +953,6 @@ C1910A16BDF131FECA77BE22 /* EmojiPickerScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEA38B9851CFCC4D67F5587D /* EmojiPickerScreenCoordinator.swift */; }; C1A5C386319835FB0C77736B /* ReportContentScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A16CD2C62CB7DB78A4238485 /* ReportContentScreenCoordinator.swift */; }; C1D0AB8222D7BAFC9AF9C8C0 /* MapLibreMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 622D09D4ECE759189009AEAF /* MapLibreMapView.swift */; }; - C20ADD95496C3328E8E22F36 /* TimelineMediaQuickLook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49D1DF63E340E81E45EAA68D /* TimelineMediaQuickLook.swift */; }; C26DB49C06C00B5DF1A991A5 /* InviteUsersScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1454CF3AABD242F55C8A2615 /* InviteUsersScreenModels.swift */; }; C2879369106A419A5071F1F8 /* VoiceMessageRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B0A96B8FE4849227945067 /* VoiceMessageRecorder.swift */; }; C32765D740C81AD4C42E8F50 /* CreateRoomFlowParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 935C2FB18EFB8EEE96B26330 /* CreateRoomFlowParameters.swift */; }; @@ -971,7 +973,6 @@ C5627BCC3EBBB96A943B6D93 /* RestorationTokenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7978C9EFBDD7DE39BD86726 /* RestorationTokenTests.swift */; }; C58E305C380D3ADDF7912180 /* StickerRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818695BED971753243FEF897 /* StickerRoomTimelineItem.swift */; }; C5A07E2D88BE7D51DCECD166 /* LoginScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D0B159AFFBBD8ECFD0E37FA /* LoginScreenModels.swift */; }; - C62B99D50A5A9BFD504B6774 /* TimelineMediaPreviewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626271BD45A5CE50A4567C89 /* TimelineMediaPreviewController.swift */; }; C67FCC854F3A6FC7A2EC04D0 /* MediaUploadPreviewScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70C86696AC9521F8ED88FBEB /* MediaUploadPreviewScreen.swift */; }; C6C06DDA8881260303FBA3A0 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2141693488CE5446BB391964 /* Date.swift */; }; C76892321558E75101E68ED6 /* ReadableFrameModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398817652FA8ABAE0A31AC6D /* ReadableFrameModifier.swift */; }; @@ -1221,6 +1222,7 @@ FA5A7E32B1920FCB4EEDC1BA /* RoomDetailsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6493AC9979CEB1410302BFE3 /* RoomDetailsScreenCoordinator.swift */; }; FA71CD334F2D2289BEF0D749 /* SecureBackupRecoveryKeyScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A2FCA3D0F239B9E911B966B /* SecureBackupRecoveryKeyScreen.swift */; }; FA9C427FFB11B1AA2DCC5602 /* RoomProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47111410B6E659A697D472B5 /* RoomProxyProtocol.swift */; }; + FAF12EF424E55377816149DB /* MediaFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D3615FD6460BC9C1DEF8659 /* MediaFileManager.swift */; }; FB0A9D06FC9122E37992D962 /* LayoutDirection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14D83B2B7CD5501A0089EFC /* LayoutDirection.swift */; }; FB53CD9B74A15B3B94F9F788 /* CreateRoomModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B849D2FF2CC12BA411A1651 /* CreateRoomModels.swift */; }; FB595EC9C00AB32F39034055 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A37E2FACFD041CE466223CD /* SceneDelegate.swift */; }; @@ -1238,6 +1240,7 @@ 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 */; }; @@ -1324,6 +1327,7 @@ 00AFC5F08734C2EA4EE79C59 /* IdentityConfirmationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityConfirmationScreen.swift; sourceTree = ""; }; 00E5B2CBEF8F96424F095508 /* RoomDetailsEditScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModelTests.swift; sourceTree = ""; }; 012A284622B32052015F1F89 /* ReadMarkerRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadMarkerRoomTimelineView.swift; sourceTree = ""; }; + 018194CAFBE80720FECCEDEE /* ZoomTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomTransition.swift; sourceTree = ""; }; 01B795AAAB7B8747FE2FF311 /* LogViewerScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewerScreenModels.swift; sourceTree = ""; }; 01C4C7DB37597D7D8379511A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 022E6BD64CB4610B9C95FC02 /* UserDetailsEditScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenViewModel.swift; sourceTree = ""; }; @@ -1363,6 +1367,7 @@ 07579F9C29001E40715F3014 /* NotificationSettingsChatType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsChatType.swift; sourceTree = ""; }; 077B01C13BBA2996272C5FB5 /* ProcessInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessInfo.swift; sourceTree = ""; }; 077D7C3BE199B6E5DDEC07EC /* AppCoordinatorStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorStateMachine.swift; sourceTree = ""; }; + 07934EF08BB39353E4A94272 /* BlurEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurEffectView.swift; sourceTree = ""; }; 07C6B0B087FE6601C3F77816 /* JoinedRoomProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinedRoomProxy.swift; sourceTree = ""; }; 0825EAFD47332DD459DE893F /* SessionDirectoriesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDirectoriesTests.swift; sourceTree = ""; }; 08283301736A6FE9D558B2CB /* AppLockScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -1373,6 +1378,7 @@ 099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiProviderTests.swift; sourceTree = ""; }; 0A3E77399BD262D301451BF2 /* RoomDetailsEditScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenCoordinator.swift; sourceTree = ""; }; 0A459AE4B6566B2FA99E86B2 /* TimelineItemBubbledStylerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemBubbledStylerView.swift; sourceTree = ""; }; + 0A76B92984638A9B3104840D /* TimelineMediaPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewView.swift; sourceTree = ""; }; 0B0E0B55E2EE75AF67029924 /* SwipeToReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeToReplyView.swift; sourceTree = ""; }; 0B32BBA8887BD7A5C4ECF16F /* RoomModerationRole.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomModerationRole.swift; sourceTree = ""; }; 0B987FC3FDBAA0E1C5AA235C /* PaginationIndicatorRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIndicatorRoomTimelineItem.swift; sourceTree = ""; }; @@ -1384,6 +1390,7 @@ 0CB569EAA5017B5B23970655 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/Localizable.strings; sourceTree = ""; }; 0CCC6C31102E1D8B9106DEDE /* AppLockSetupBiometricsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupBiometricsScreenViewModelProtocol.swift; sourceTree = ""; }; 0D0B159AFFBBD8ECFD0E37FA /* LoginScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenModels.swift; sourceTree = ""; }; + 0D3615FD6460BC9C1DEF8659 /* MediaFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaFileManager.swift; sourceTree = ""; }; 0D879FC4E881E748BB9B34DC /* RoomChangePermissionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomChangePermissionsScreenCoordinator.swift; sourceTree = ""; }; 0D8F620C8B314840D8602E3F /* NSE.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = NSE.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 0DBB08A95EFA668F2CF27211 /* AppLockSetupFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupFlowCoordinator.swift; sourceTree = ""; }; @@ -1689,7 +1696,6 @@ 4959CECEC984B3995616F427 /* DataProtectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataProtectionManager.swift; sourceTree = ""; }; 4999B5FD50AED7CB0F590FF8 /* AdvancedSettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsScreenModels.swift; sourceTree = ""; }; 49ABAB186CF00B15C5521D04 /* MenuSheetLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuSheetLabelStyle.swift; sourceTree = ""; }; - 49D1DF63E340E81E45EAA68D /* TimelineMediaQuickLook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaQuickLook.swift; sourceTree = ""; }; 49D2C8E66E83EA578A7F318A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 49E6066092ED45E36BB306F7 /* zh-Hant-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-Hant-TW"; path = "zh-Hant-TW.lproj/Localizable.stringsdict"; sourceTree = ""; }; 49E751D7EDB6043238111D90 /* UNNotificationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UNNotificationRequest.swift; sourceTree = ""; }; @@ -1790,7 +1796,6 @@ 61B33F23681660E940BA57F4 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/SAS.strings; sourceTree = ""; }; 622D09D4ECE759189009AEAF /* MapLibreMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLibreMapView.swift; sourceTree = ""; }; 624244C398804ADC885239AA /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; - 626271BD45A5CE50A4567C89 /* TimelineMediaPreviewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewController.swift; sourceTree = ""; }; 627A8B5E798CC778C363655E /* KnockRequestsListEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockRequestsListEmptyStateView.swift; sourceTree = ""; }; 62A81CCC2516D9CF9322DF01 /* MediaProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProviderTests.swift; sourceTree = ""; }; 62B07B296D7A9D2F09120853 /* OrderedSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSet.swift; sourceTree = ""; }; @@ -2380,6 +2385,7 @@ 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 = ""; }; @@ -3202,6 +3208,7 @@ 9A028783CFFF861C5E44FFB1 /* BadgeLabel.swift */, C1FA515B3B0D61EF1E907D2D /* BadgeView.swift */, D01FD1171FF40E34D707FD00 /* BigIcon.swift */, + 07934EF08BB39353E4A94272 /* BlurEffectView.swift */, 8CC23C63849452BC86EA2852 /* ButtonStyle.swift */, B590BD4507D4F0A377FDE01A /* LoadableAvatarImage.swift */, C352359663A0E52BA20761EE /* LoadableImage.swift */, @@ -3485,10 +3492,11 @@ isa = PBXGroup; children = ( 638A81B97D51591D0FCFA598 /* InteractiveQuickLook.swift */, - 626271BD45A5CE50A4567C89 /* TimelineMediaPreviewController.swift */, + 0D3615FD6460BC9C1DEF8659 /* MediaFileManager.swift */, + E3A62FBD3007312311C14DD8 /* TimelineMediaPreviewCoordinator.swift */, 2A2BB38DF61F5100B8723112 /* TimelineMediaPreviewModels.swift */, + 0A76B92984638A9B3104840D /* TimelineMediaPreviewView.swift */, 53F41CEAAE2BB4E74CDC2278 /* TimelineMediaPreviewViewModel.swift */, - 49D1DF63E340E81E45EAA68D /* TimelineMediaQuickLook.swift */, 5EC4A8482DA110602FE6DF42 /* View */, ); path = FilePreviewScreen; @@ -5565,6 +5573,7 @@ children = ( EF1593DD87F974F8509BB619 /* ElementAnimations.swift */, 97CE98208321C4D66E363612 /* ShimmerModifier.swift */, + 018194CAFBE80720FECCEDEE /* ZoomTransition.swift */, ); path = Animation; sourceTree = ""; @@ -6824,6 +6833,7 @@ 934051B17A884AB0635DF81B /* BlockedUsersScreenViewModel.swift in Sources */, A4B123C635F70DDD4BC2FAC9 /* BlockedUsersScreenViewModelProtocol.swift in Sources */, 5EE1D4E316D66943E97FDCF2 /* BloomView.swift in Sources */, + 54FDA3625AACBD9E438D084D /* BlurEffectView.swift in Sources */, B6DF6B6FA8734B70F9BF261E /* BlurHashDecode.swift in Sources */, E794AB6ABE1FF5AF0573FEA1 /* BlurHashEncode.swift in Sources */, A8FA7671948E3DF27F320026 /* BugReportFlowCoordinator.swift in Sources */, @@ -7085,6 +7095,7 @@ C11D4A49DC29D89CE2BB31B8 /* MediaEventsTimelineScreenViewModel.swift in Sources */, FD9777315A5D9CDC47458AD1 /* MediaEventsTimelineScreenViewModelProtocol.swift in Sources */, BCC864190651B3A3CF51E4DF /* MediaFileHandleProxy.swift in Sources */, + FAF12EF424E55377816149DB /* MediaFileManager.swift in Sources */, 208C19811613F9A10F8A7B75 /* MediaLoader.swift in Sources */, A2434D4DFB49A68E5CD0F53C /* MediaLoaderProtocol.swift in Sources */, 4E0D9E09B52CEC4C0E6211A8 /* MediaPickerScreenCoordinator.swift in Sources */, @@ -7478,12 +7489,12 @@ 1B88BB631F7FC45A213BB554 /* TimelineItemSender.swift in Sources */, EFBBD44C0A16F017C32D2099 /* TimelineItemStatusView.swift in Sources */, 562EFB9AB62B38830D9AA778 /* TimelineMediaFrame.swift in Sources */, - C62B99D50A5A9BFD504B6774 /* TimelineMediaPreviewController.swift in Sources */, + FE43747C116CA3D8D6B92F5F /* TimelineMediaPreviewCoordinator.swift in Sources */, 12EC6BC99F373FE5C6EB9B64 /* TimelineMediaPreviewDetailsView.swift in Sources */, 77FB08C303F4C74C0E8577E2 /* TimelineMediaPreviewModels.swift in Sources */, A32384E3D85CA65342D3A908 /* TimelineMediaPreviewRedactConfirmationView.swift in Sources */, + AE69B349B0011D5EE2C13606 /* TimelineMediaPreviewView.swift in Sources */, 86769B62BAE17601B3AE1B60 /* TimelineMediaPreviewViewModel.swift in Sources */, - C20ADD95496C3328E8E22F36 /* TimelineMediaQuickLook.swift in Sources */, B818580464CFB5400A3EF6AE /* TimelineModels.swift in Sources */, E82E13CC3EB923CCB8F8273C /* TimelineProxy.swift in Sources */, 16A1F6C703305FCAF4E14EC6 /* TimelineProxyMock.swift in Sources */, @@ -7585,6 +7596,7 @@ 66357ECB73B1290E5490A012 /* WebRegistrationScreenViewModelProtocol.swift in Sources */, 08CB4BD12CEEDE6AAE4A18DD /* WindowManager.swift in Sources */, AE5AAD9E32511544FDFA5560 /* WindowManagerProtocol.swift in Sources */, + 80F1B442DB5E2C362ACDD8E2 /* ZoomTransition.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift index 2aaddeb292..1d2878d98d 100644 --- a/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift @@ -95,8 +95,8 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { coordinator.actions .sink { [weak self] action in switch action { - case .viewInRoomTimeline(let itemID): - self?.actionsSubject.send(.viewInRoomTimeline(itemID)) + case .viewItem(let previewContext): + self?.presentMediaPreview(for: previewContext) } } .store(in: &cancellables) @@ -105,4 +105,28 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { self?.actionsSubject.send(.finished) } } + + private func presentMediaPreview(for previewContext: TimelineMediaPreviewContext) { + let parameters = TimelineMediaPreviewCoordinatorParameters(context: previewContext, + mediaProvider: userSession.mediaProvider, + userIndicatorController: userIndicatorController) + + 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) { + previewContext.completion?() + } + } } diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 8d4eee0580..09256d631d 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -1583,8 +1583,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { MXLog.error("Unable to present room timeline for event \(itemID)") return } - stateMachine.tryEvent(.dismissMediaEventsTimeline) - stateMachine.tryEvent(.presentRoom(presentationAction: .eventFocus(.init(eventID: eventID, shouldSetPin: false)))) + stateMachine.tryEvent(.presentRoom(presentationAction: .eventFocus(.init(eventID: eventID, shouldSetPin: false))), + userInfo: EventUserInfo(animated: false)) // No animation so the timeline visible when the preview animates away. case .finished: stateMachine.tryEvent(.dismissMediaEventsTimeline) } diff --git a/ElementX/Sources/Other/Extensions/PlatformViewVersionPredicate.swift b/ElementX/Sources/Other/Extensions/PlatformViewVersionPredicate.swift index 879af5a8ea..61900a3e08 100644 --- a/ElementX/Sources/Other/Extensions/PlatformViewVersionPredicate.swift +++ b/ElementX/Sources/Other/Extensions/PlatformViewVersionPredicate.swift @@ -10,18 +10,24 @@ import SwiftUIIntrospect extension PlatformViewVersionPredicate { static var supportedVersions: Self { - .iOS(.v16, .v17, .v18) + .iOS(.v17, .v18) } } extension PlatformViewVersionPredicate { static var supportedVersions: Self { - .iOS(.v16, .v17, .v18) + .iOS(.v17, .v18) } } extension PlatformViewVersionPredicate { static var supportedVersions: Self { - .iOS(.v16, .v17, .v18) + .iOS(.v17, .v18) + } +} + +extension PlatformViewVersionPredicate { + static var supportedVersions: Self { + .iOS(.v17, .v18) } } diff --git a/ElementX/Sources/Other/SwiftUI/Animation/ZoomTransition.swift b/ElementX/Sources/Other/SwiftUI/Animation/ZoomTransition.swift new file mode 100644 index 0000000000..4094542f6a --- /dev/null +++ b/ElementX/Sources/Other/SwiftUI/Animation/ZoomTransition.swift @@ -0,0 +1,30 @@ +// +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import SwiftUI + +extension View { + /// A convenience modifier to conditionally apply `.navigationTransition(.zoom(…))` when available. + @ViewBuilder + func zoomTransition(sourceID: some Hashable, in namespace: Namespace.ID) -> some View { + if #available(iOS 18.0, *) { + navigationTransition(.zoom(sourceID: sourceID, in: namespace)) + } else { + self + } + } + + /// A convenience modifier to conditionally apply `.matchedTransitionSource(…)` when available. + @ViewBuilder + func zoomTransitionSource(id: some Hashable, in namespace: Namespace.ID) -> some View { + if #available(iOS 18.0, *) { + matchedTransitionSource(id: id, in: namespace) + } else { + self + } + } +} diff --git a/ElementX/Sources/Other/SwiftUI/Views/BlurEffectView.swift b/ElementX/Sources/Other/SwiftUI/Views/BlurEffectView.swift new file mode 100644 index 0000000000..008bee983e --- /dev/null +++ b/ElementX/Sources/Other/SwiftUI/Views/BlurEffectView.swift @@ -0,0 +1,22 @@ +// +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import SwiftUI + +/// A view that renders a `UIBlurEffect` as there is a larger range of +/// effects available compared to using SwiftUI's `Material` type. +struct BlurEffectView: UIViewRepresentable { + var style: UIBlurEffect.Style + + func makeUIView(context: Context) -> UIVisualEffectView { + UIVisualEffectView(effect: UIBlurEffect(style: style)) + } + + func updateUIView(_ uiView: UIVisualEffectView, context: Context) { + uiView.effect = UIBlurEffect(style: style) + } +} diff --git a/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift b/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift index 2cd0d5559a..9a54f78e5d 100644 --- a/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift +++ b/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift @@ -26,7 +26,6 @@ struct CallScreen: View { Image(systemSymbol: .chevronBackward) .fontWeight(.semibold) } - // .padding(.leading, -8) // Fixes the button alignment, but harder to tap. } } } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/MediaFileManager.swift b/ElementX/Sources/Screens/FilePreviewScreen/MediaFileManager.swift new file mode 100644 index 0000000000..bad960796f --- /dev/null +++ b/ElementX/Sources/Screens/FilePreviewScreen/MediaFileManager.swift @@ -0,0 +1,8 @@ +// +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import Foundation diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewController.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewController.swift deleted file mode 100644 index b3332fead7..0000000000 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewController.swift +++ /dev/null @@ -1,180 +0,0 @@ -// -// Copyright 2024 New Vector Ltd. -// -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. -// - -import Combine -import Compound -import QuickLook -import SwiftUI - -class TimelineMediaPreviewController: QLPreviewController, QLPreviewControllerDataSource { - private let viewModel: TimelineMediaPreviewViewModel - - private var cancellables: Set = [] - - private let headerHostingController: UIHostingController - private let captionHostingController: UIHostingController - private let detailsHostingController: UIHostingController - - 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 } - - init(viewModel: TimelineMediaPreviewViewModel) { - self.viewModel = viewModel - - headerHostingController = UIHostingController(rootView: HeaderView(context: viewModel.context)) - headerHostingController.view.backgroundColor = .clear - headerHostingController.sizingOptions = .intrinsicContentSize - captionHostingController = UIHostingController(rootView: CaptionView(context: viewModel.context)) - captionHostingController.view.backgroundColor = .clear - captionHostingController.sizingOptions = .intrinsicContentSize - detailsHostingController = UIHostingController(rootView: TimelineMediaPreviewDetailsView(context: viewModel.context)) - detailsHostingController.view.backgroundColor = .compound.bgCanvasDefault - - // let materialView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial)) - // captionHostingController.view.insertMatchedSubview(materialView, at: 0) - - super.init(nibName: nil, bundle: nil) - - view.addSubview(captionView) - - // Observation of currentPreviewItem doesn't work, so use the index instead. - publisher(for: \.currentPreviewItemIndex) - .sink { [weak self] _ in - guard let self, let currentPreviewItem = currentPreviewItem as? TimelineMediaPreviewItem else { return } - Task { await self.viewModel.updateCurrentItem(currentPreviewItem) } - } - .store(in: &cancellables) - - viewModel.actions - .sink { [weak self] action in - switch action { - case .loadedMediaFile: - self?.refreshCurrentPreviewItem() - case .viewInRoomTimeline, .dismiss: - self?.dismiss(animated: true) // Dismiss the details sheet. - // And let the view model handle the rest. - } - } - .store(in: &cancellables) - - dataSource = self - } - - @available(*, unavailable) required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewWillLayoutSubviews() { - super.viewWillLayoutSubviews() - - overrideUserInterfaceStyle = .dark - - if let toolbar { - captionView.isHidden = toolbar.alpha == 0 - - if captionView.constraints.isEmpty { - captionView.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() - } - - // MARK: QLPreviewControllerDataSource - - func numberOfPreviewItems(in controller: QLPreviewController) -> Int { - viewModel.state.previewItems.count - } - - func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { - viewModel.state.previewItems[index] - } - - // MARK: Private - - @objc func presentMediaDetails() { - detailsHostingController.overrideUserInterfaceStyle = .dark - detailsHostingController.sheetPresentationController?.detents = [.medium()] - detailsHostingController.sheetPresentationController?.prefersGrabberVisible = true - - present(detailsHostingController, animated: true) - } - - private var detailsButtonIcon: UIImage { - guard let bundle = Bundle(url: Bundle.main.bundleURL.appending(path: "CompoundDesignTokens_CompoundDesignTokens.bundle")) else { - return UIImage(systemSymbol: .infoCircle) - } - - return UIImage(named: "info", in: bundle, compatibleWith: nil) ?? UIImage(systemSymbol: .infoCircle) - } - - private func updateBarButtons() { - if navigationBar?.topItem?.rightBarButtonItems?.count == 1 { - let button = UIBarButtonItem(image: detailsButtonIcon, style: .plain, target: self, action: #selector(presentMediaDetails)) - navigationBar?.topItem?.rightBarButtonItems?.append(button) - } - } -} - -// MARK: - Subviews - -private struct HeaderView: View { - @ObservedObject var context: TimelineMediaPreviewViewModel.Context - private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem } - - var body: some View { - 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) - } - } -} - -private struct CaptionView: View { - @ObservedObject var context: TimelineMediaPreviewViewModel.Context - private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem } - - var body: some View { - if let caption = currentItem.caption { - Text(caption) - .font(.compound.bodyLG) - .foregroundStyle(.compound.textPrimary) - .lineLimit(5) - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) - .padding(16) - .background { - BlurView(style: .systemChromeMaterial) // Darkest material available, matches the bottom bar when content is beneath. - } - } - } -} - -private struct BlurView: UIViewRepresentable { - var style: UIBlurEffect.Style - - func makeUIView(context: Context) -> UIVisualEffectView { - UIVisualEffectView(effect: UIBlurEffect(style: style)) - } - - func updateUIView(_ uiView: UIVisualEffectView, context: Context) { - uiView.effect = UIBlurEffect(style: style) - } -} diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewCoordinator.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewCoordinator.swift new file mode 100644 index 0000000000..2575108dbe --- /dev/null +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewCoordinator.swift @@ -0,0 +1,74 @@ +// +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE 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 completion to be called immediately *after* the preview has been dismissed. + /// + /// 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 completion: (() -> Void)? +} + +struct TimelineMediaPreviewCoordinatorParameters { + let context: TimelineMediaPreviewContext + let mediaProvider: MediaProviderProtocol + let userIndicatorController: UserIndicatorControllerProtocol +} + +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, + userIndicatorController: parameters.userIndicatorController) + } + + 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 { + AnyView(TimelineMediaPreviewView(context: viewModel.context)) + } +} diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift index 62400f799a..b1c790eb79 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift @@ -5,10 +5,11 @@ // Please see LICENSE in the repository root for full details. // +import Combine import QuickLook +import SwiftUI enum TimelineMediaPreviewViewModelAction: Equatable { - case loadedMediaFile case viewInRoomTimeline(TimelineItemIdentifier) case dismiss } @@ -18,15 +19,19 @@ struct TimelineMediaPreviewViewState: BindableState { var currentItem: TimelineMediaPreviewItem var currentItemActions: TimelineItemMenuActions? + let transitionNamespace: Namespace.ID + let fileLoadedPublisher = PassthroughSubject() + var bindings = TimelineMediaPreviewViewStateBindings() } struct TimelineMediaPreviewViewStateBindings { - var isPresentingRedactConfirmation = false + var mediaDetailsItem: TimelineMediaPreviewItem? + var redactConfirmationItem: TimelineMediaPreviewItem? } /// Wraps a media file and title to be previewed with QuickLook. -class TimelineMediaPreviewItem: NSObject, QLPreviewItem { +class TimelineMediaPreviewItem: NSObject, QLPreviewItem, Identifiable { let timelineItem: EventBasedMessageTimelineItemProtocol var fileHandle: MediaFileHandleProxy? @@ -34,6 +39,8 @@ class TimelineMediaPreviewItem: NSObject, QLPreviewItem { self.timelineItem = timelineItem } + // MARK: Identifiable + var id: TimelineItemIdentifier { timelineItem.id } // MARK: QLPreviewItem @@ -45,18 +52,7 @@ class TimelineMediaPreviewItem: NSObject, QLPreviewItem { } var previewItemTitle: 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 - } + filename } // MARK: Event details @@ -167,6 +163,10 @@ class TimelineMediaPreviewItem: NSObject, QLPreviewItem { } enum TimelineMediaPreviewViewAction { - case menuAction(TimelineItemMenuAction) - case redactConfirmation + case updateCurrentItem(TimelineMediaPreviewItem) + case saveCurrentItem + case showCurrentItemDetails + case menuAction(TimelineItemMenuAction, item: TimelineMediaPreviewItem) + case redactConfirmation(item: TimelineMediaPreviewItem) + case dismiss } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewView.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewView.swift new file mode 100644 index 0000000000..55ab2be85b --- /dev/null +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewView.swift @@ -0,0 +1,212 @@ +// +// Copyright 2022-2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import Combine +import Compound +import QuickLook +import SwiftUI + +struct TimelineMediaPreviewView: View { + @ObservedObject var context: TimelineMediaPreviewViewModel.Context + + private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem } + + var body: some View { + NavigationStack { + Color.clear + .overlay { QuickLookView(viewModelContext: context).ignoresSafeArea() } // Overlay to stop QL hijacking the toolbar. + .toolbar { toolbar } + .toolbarBackground(.visible, for: .navigationBar) // The toolbar's scrollEdgeAppearance isn't aware of the quicklook view 🤷‍♂️ + .toolbarBackground(.visible, for: .bottomBar) + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom, spacing: 0) { caption } + } + .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) + } + .preferredColorScheme(.dark) + .zoomTransition(sourceID: currentItem.id, in: context.viewState.transitionNamespace) + } + + @ViewBuilder + private var caption: some View { + if let caption = currentItem.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. + } + } + } + + @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 + } + + ToolbarItem(placement: .primaryAction) { + Button { context.send(viewAction: .showCurrentItemDetails) } label: { + CompoundIcon(\.info) + } + .tint(.compound.textActionPrimary) + } + + ToolbarItem(placement: .bottomBar) { + bottomBarContent + .tint(.compound.textActionPrimary) + } + } + + private var toolbarHeader: some View { + 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) + } + } + + private var bottomBarContent: some View { + HStack(spacing: 8) { + if let url = currentItem.fileHandle?.url { + ShareLink(item: url, subject: nil, message: currentItem.caption.map(Text.init)) { + CompoundIcon(\.shareIos) + } + + Spacer() + + Button { context.send(viewAction: .saveCurrentItem) } label: { + CompoundIcon(\.download) + } + } + } + } +} + +private struct QuickLookView: UIViewControllerRepresentable { + let viewModelContext: TimelineMediaPreviewViewModel.Context + + func makeUIViewController(context: Context) -> PreviewController { + PreviewController(coordinator: context.coordinator, + fileLoadedPublisher: viewModelContext.viewState.fileLoadedPublisher.eraseToAnyPublisher()) + } + + func updateUIViewController(_ uiViewController: PreviewController, context: Context) { } + + func makeCoordinator() -> Coordinator { + Coordinator(viewModelContext: viewModelContext) + } + + class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate { + private let viewModelContext: TimelineMediaPreviewViewModel.Context + + init(viewModelContext: TimelineMediaPreviewViewModel.Context) { + self.viewModelContext = viewModelContext + } + + func updateCurrentItem(_ item: TimelineMediaPreviewItem) { + viewModelContext.send(viewAction: .updateCurrentItem(item)) + } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + viewModelContext.viewState.previewItems.count + } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + viewModelContext.viewState.previewItems[index] + } + } + + class PreviewController: QLPreviewController { + let coordinator: Coordinator + + private var cancellables: Set = [] + + init(coordinator: Coordinator, fileLoadedPublisher: AnyPublisher) { + self.coordinator = coordinator + + super.init(nibName: nil, bundle: nil) + + dataSource = coordinator + delegate = coordinator + + // Observation of currentPreviewItem doesn't work, so use the index instead. + publisher(for: \.currentPreviewItemIndex) + .sink { [weak self] _ in + guard let self, let currentPreviewItem = currentPreviewItem as? TimelineMediaPreviewItem else { return } + coordinator.updateCurrentItem(currentPreviewItem) + } + .store(in: &cancellables) + + fileLoadedPublisher + .sink { [weak self] itemID in + guard let self, (currentPreviewItem as? TimelineMediaPreviewItem)?.id == itemID else { return } + refreshCurrentPreviewItem() + } + .store(in: &cancellables) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + } +} + +// MARK: - Previews + +struct TimelineMediaPreviewView_Previews: PreviewProvider { + @Namespace private static var namespace + + static let viewModel = makeViewModel() + + static var previews: some View { + QuickLookView(viewModelContext: viewModel.context) + } + + static func makeViewModel() -> 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)) + + return TimelineMediaPreviewViewModel(context: .init(item: item, + viewModel: TimelineViewModel.mock(timelineKind: .media(.mediaFilesScreen)), + namespace: namespace), + mediaProvider: MediaProviderMock(configuration: .init()), + userIndicatorController: UserIndicatorControllerMock()) + } +} diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift index f29a59da4d..c0079164fd 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift @@ -20,20 +20,20 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { actionsSubject.eraseToAnyPublisher() } - init(initialItem: EventBasedMessageTimelineItemProtocol, - timelineViewModel: TimelineViewModelProtocol, + init(context: TimelineMediaPreviewContext, mediaProvider: MediaProviderProtocol, userIndicatorController: UserIndicatorControllerProtocol) { - self.timelineViewModel = timelineViewModel + timelineViewModel = context.viewModel self.mediaProvider = mediaProvider // We might not want to inject this, instead creating a new instance with a custom position and colour scheme 🤔 self.userIndicatorController = userIndicatorController - let currentItem = TimelineMediaPreviewItem(timelineItem: initialItem) + let currentItem = TimelineMediaPreviewItem(timelineItem: context.item) super.init(initialViewState: TimelineMediaPreviewViewState(previewItems: [currentItem], - currentItem: currentItem), + currentItem: currentItem, + transitionNamespace: context.namespace), mediaProvider: mediaProvider) rebuildCurrentItemActions() @@ -48,23 +48,32 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { override func process(viewAction: TimelineMediaPreviewViewAction) { switch viewAction { - case .menuAction(let action): + case .updateCurrentItem(let item): + Task { await updateCurrentItem(item) } + case .saveCurrentItem: + Task { await saveCurrentItem() } + case .showCurrentItemDetails: + state.bindings.mediaDetailsItem = state.currentItem + case .menuAction(let action, let item): switch action { case .viewInRoomTimeline: - actionsSubject.send(.viewInRoomTimeline(state.currentItem.id)) + actionsSubject.send(.viewInRoomTimeline(item.id)) case .redact: - state.bindings.isPresentingRedactConfirmation = true + state.bindings.redactConfirmationItem = item default: MXLog.error("Received unexpected action: \(action)") } - case .redactConfirmation: - timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: state.currentItem.id, action: .redact)) - state.bindings.isPresentingRedactConfirmation = false - actionsSubject.send(.dismiss) // Will dismiss the details sheet and the QuickLook view. + case .redactConfirmation(let item): + timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact)) + state.bindings.redactConfirmationItem = nil + state.bindings.mediaDetailsItem = nil + actionsSubject.send(.dismiss) + case .dismiss: + actionsSubject.send(.dismiss) } } - func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async { + private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async { state.currentItem = previewItem rebuildCurrentItemActions() @@ -72,10 +81,10 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { showDownloadingIndicator(itemID: previewItem.id) defer { hideDownloadingIndicator(itemID: previewItem.id) } - switch await mediaProvider.loadFileFromSource(source) { + switch await mediaProvider.loadFileFromSource(source, filename: previewItem.filename) { case .success(let handle): previewItem.fileHandle = handle - actionsSubject.send(.loadedMediaFile) + state.fileLoadedPublisher.send(previewItem.id) case .failure(let error): MXLog.error("Failed loading media: \(error)") showDownloadErrorIndicator() @@ -83,7 +92,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { } } - func rebuildCurrentItemActions() { + private func rebuildCurrentItemActions() { let timelineContext = timelineViewModel.context let provider = TimelineItemMenuActionProvider(timelineItem: state.currentItem.timelineItem, canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf, @@ -98,6 +107,17 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { state.currentItemActions = provider.makeActions() } + private func saveCurrentItem() async { + guard let url = state.currentItem.fileHandle?.url else { + MXLog.error("Unable to save an item without a URL, the button shouldn't be visible.") + return + } + + showErrorIndicator() + } + + // MARK: - Indicators + private func showDownloadingIndicator(itemID: TimelineItemIdentifier) { let indicatorID = makeDownloadIndicatorID(itemID: itemID) userIndicatorController.submitIndicator(UserIndicator(id: indicatorID, @@ -120,6 +140,14 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { iconName: "exclamationmark.circle.fill")) } + private func showErrorIndicator() { + userIndicatorController.submitIndicator(UserIndicator(id: errorIndicatorID, + type: .modal, + title: L10n.errorUnknown, + iconName: "xmark")) + } + + private var errorIndicatorID: String { "\(Self.self)-Error" } private var downloadErrorIndicatorID: String { "\(Self.self)-DownloadError" } private func makeDownloadIndicatorID(itemID: TimelineItemIdentifier) -> String { "\(Self.self)-Download-\(itemID.uniqueID.id)" diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaQuickLook.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaQuickLook.swift deleted file mode 100644 index 5b5c0271bf..0000000000 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaQuickLook.swift +++ /dev/null @@ -1,156 +0,0 @@ -// -// Copyright 2022-2024 New Vector Ltd. -// -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE 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 timelineMediaQuickLook(viewModel: Binding) -> some View { - modifier(TimelineMediaQuickLookModifier(viewModel: viewModel)) - } -} - -private struct TimelineMediaQuickLookModifier: ViewModifier { - @Binding var viewModel: TimelineMediaPreviewViewModel? - - @State private var dismissalPublisher = PassthroughSubject() - - func body(content: Content) -> some View { - content.background { - if let viewModel { - EmbeddedQuickLookPresenter(viewModel: viewModel, dismissalPublisher: dismissalPublisher) { - self.viewModel = nil - } - } else { - // Work around QLPreviewController dismissal issues, see below. - let _ = dismissalPublisher.send(()) - } - } - } -} - -/// When this view is put in the background of a SwiftUI view hierarchy, -/// it will present a QLPreviewController on top of the entire app. -private struct EmbeddedQuickLookPresenter: UIViewControllerRepresentable { - let viewModel: TimelineMediaPreviewViewModel - let dismissalPublisher: PassthroughSubject - let onDismiss: () -> Void - - func makeUIViewController(context: Context) -> PresentingController { - PresentingController(viewModel: viewModel, dismissalPublisher: dismissalPublisher, onDismiss: onDismiss) - } - - func updateUIViewController(_ uiViewController: PresentingController, 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 PresentingController: UIViewController, QLPreviewControllerDelegate { - private let previewController: QLPreviewController - private let sourceView = UIView() - - private var hasPresented = false - private let onDismiss: () -> Void - private var dismissalObserver: AnyCancellable? - - init(viewModel: TimelineMediaPreviewViewModel, - dismissalPublisher: PassthroughSubject, - onDismiss: @escaping () -> Void) { - previewController = TimelineMediaPreviewController(viewModel: viewModel) - self.onDismiss = onDismiss - - 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: 200), - sourceView.heightAnchor.constraint(equalToConstant: 200) - ]) - } - - // 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 !hasPresented else { return } - - previewController.delegate = self - present(previewController, animated: true) - hasPresented = true - } - - // MARK: QLPreviewControllerDelegate - - func previewController(_ controller: QLPreviewController, transitionViewFor item: any QLPreviewItem) -> UIView? { - sourceView - } - - func previewControllerDidDismiss(_ controller: QLPreviewController) { - onDismiss() - } - } -} - -// MARK: - Previews - -struct TimelineMediaQuickLook_Previews: PreviewProvider { - static let viewModel = makeViewModel() - - static var previews: some View { - EmbeddedQuickLookPresenter(viewModel: viewModel, dismissalPublisher: .init()) { } - } - - static func makeViewModel() -> 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)) - - return TimelineMediaPreviewViewModel(initialItem: item, - timelineViewModel: TimelineViewModel.mock, - mediaProvider: MediaProviderMock(configuration: .init()), - userIndicatorController: UserIndicatorControllerMock()) - } -} diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift index adef393f7a..7b25c8f388 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift @@ -9,10 +9,9 @@ import Compound import SwiftUI struct TimelineMediaPreviewDetailsView: View { + let item: TimelineMediaPreviewItem @ObservedObject var context: TimelineMediaPreviewViewModel.Context - private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem } - var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { @@ -21,9 +20,13 @@ struct TimelineMediaPreviewDetailsView: View { } .frame(maxWidth: .infinity, alignment: .leading) } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) .padding(.top, 19) // For the drag indicator - .sheet(isPresented: $context.isPresentingRedactConfirmation) { - TimelineMediaPreviewRedactConfirmationView(context: context) + .presentationBackground(.compound.bgCanvasDefault) + .preferredColorScheme(.dark) + .sheet(item: $context.redactConfirmationItem) { item in + TimelineMediaPreviewRedactConfirmationView(item: item, context: context) } } @@ -31,20 +34,20 @@ struct TimelineMediaPreviewDetailsView: View { VStack(alignment: .leading, spacing: 24) { DetailsRow(title: L10n.screenMediaDetailsUploadedBy) { HStack(spacing: 8) { - LoadableAvatarImage(url: currentItem.sender.avatarURL, - name: currentItem.sender.displayName, - contentID: currentItem.sender.id, + LoadableAvatarImage(url: item.sender.avatarURL, + name: item.sender.displayName, + contentID: item.sender.id, avatarSize: .user(on: .mediaPreviewDetails), mediaProvider: context.mediaProvider) VStack(alignment: .leading, spacing: 0) { - if let displayName = currentItem.sender.displayName { + if let displayName = item.sender.displayName { Text(displayName) .font(.compound.bodyMDSemibold) - .foregroundStyle(.compound.decorativeColor(for: currentItem.sender.id).text) + .foregroundStyle(.compound.decorativeColor(for: item.sender.id).text) } - Text(currentItem.sender.id) + Text(item.sender.id) .font(.compound.bodySM) .foregroundStyle(.compound.textSecondary) } @@ -52,21 +55,21 @@ struct TimelineMediaPreviewDetailsView: View { } DetailsRow(title: L10n.screenMediaDetailsUploadedOn) { - Text(currentItem.timestamp.formatted(date: .abbreviated, time: .shortened)) + Text(item.timestamp.formatted(date: .abbreviated, time: .shortened)) .font(.compound.bodyMD) .foregroundStyle(.compound.textPrimary) } DetailsRow(title: L10n.screenMediaDetailsFilename) { - Text(currentItem.filename ?? "") + Text(item.filename ?? "") .font(.compound.bodyMD) .foregroundStyle(.compound.textPrimary) } - if let contentType = currentItem.contentType { + if let contentType = item.contentType { DetailsRow(title: L10n.screenMediaDetailsFileFormat) { Group { - if let fileSize = currentItem.fileSize { + if let fileSize = item.fileSize { Text(contentType) + Text(" – ") + Text(UInt(fileSize).formatted(.byteCount(style: .file))) } else { Text(contentType) @@ -93,7 +96,7 @@ struct TimelineMediaPreviewDetailsView: View { ForEach(actions.actions, id: \.self) { action in Button(role: action.isDestructive ? .destructive : nil) { - context.send(viewAction: .menuAction(action)) + context.send(viewAction: .menuAction(action, item: item)) } label: { action.label } @@ -107,7 +110,7 @@ struct TimelineMediaPreviewDetailsView: View { ForEach(actions.secondaryActions, id: \.self) { action in Button(role: action.isDestructive ? .destructive : nil) { - context.send(viewAction: .menuAction(action)) + context.send(viewAction: .menuAction(action, item: item)) } label: { action.label } @@ -139,19 +142,24 @@ 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 unknownTypeViewModel = makeViewModel() static let presentedOnRoomViewModel = makeViewModel(isPresentedOnRoomScreen: true) static var previews: some View { - TimelineMediaPreviewDetailsView(context: viewModel.context) + TimelineMediaPreviewDetailsView(item: viewModel.state.currentItem, + context: viewModel.context) .previewDisplayName("Image") .snapshotPreferences(delay: 0.1) - TimelineMediaPreviewDetailsView(context: unknownTypeViewModel.context) + TimelineMediaPreviewDetailsView(item: unknownTypeViewModel.state.currentItem, + context: unknownTypeViewModel.context) .previewDisplayName("Unknown type") .snapshotPreferences(delay: 0.1) - TimelineMediaPreviewDetailsView(context: presentedOnRoomViewModel.context) + TimelineMediaPreviewDetailsView(item: presentedOnRoomViewModel.state.currentItem, + context: presentedOnRoomViewModel.context) .previewDisplayName("Incoming on Room") .snapshotPreferences(delay: 0.1) } @@ -172,8 +180,9 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie contentType: contentType)) let timelineKind = TimelineKind.media(isPresentedOnRoomScreen ? .roomScreen : .mediaFilesScreen) - return TimelineMediaPreviewViewModel(initialItem: item, - timelineViewModel: TimelineViewModel.mock(timelineKind: timelineKind), + return TimelineMediaPreviewViewModel(context: .init(item: item, + viewModel: TimelineViewModel.mock(timelineKind: timelineKind), + namespace: previewNamespace), mediaProvider: MediaProviderMock(configuration: .init()), userIndicatorController: UserIndicatorControllerMock()) } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift index b33f512582..07b20988ce 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift @@ -11,10 +11,9 @@ import SwiftUI struct TimelineMediaPreviewRedactConfirmationView: View { @Environment(\.dismiss) private var dismiss + let item: TimelineMediaPreviewItem @ObservedObject var context: TimelineMediaPreviewViewModel.Context - private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem } - var body: some View { ScrollView { VStack(spacing: 0) { @@ -54,13 +53,13 @@ struct TimelineMediaPreviewRedactConfirmationView: View { @ViewBuilder private var preview: some View { HStack(spacing: 12) { - if let mediaSource = currentItem.thumbnailMediaSource { + if let mediaSource = item.thumbnailMediaSource { Color.clear .scaledFrame(size: 40) .background { LoadableImage(mediaSource: mediaSource, - mediaType: .timelineItem(uniqueID: currentItem.id.uniqueID.id), - blurhash: currentItem.blurhash, + mediaType: .timelineItem(uniqueID: item.id.uniqueID.id), + blurhash: item.blurhash, mediaProvider: context.mediaProvider) { Color.compound.bgSubtleSecondary } @@ -70,13 +69,13 @@ struct TimelineMediaPreviewRedactConfirmationView: View { } VStack(alignment: .leading, spacing: 4) { - Text(currentItem.filename ?? "") + Text(item.filename ?? "") .font(.compound.bodyMD) .foregroundStyle(.compound.textPrimary) - if let contentType = currentItem.contentType { + if let contentType = item.contentType { Group { - if let fileSize = currentItem.fileSize { + if let fileSize = item.fileSize { Text(contentType) + Text(" – ") + Text(UInt(fileSize).formatted(.byteCount(style: .file))) } else { Text(contentType) @@ -95,7 +94,7 @@ struct TimelineMediaPreviewRedactConfirmationView: View { private var buttons: some View { VStack(spacing: 16) { Button(L10n.actionRemove, role: .destructive) { - context.send(viewAction: .redactConfirmation) + context.send(viewAction: .redactConfirmation(item: item)) } .buttonStyle(.compound(.primary)) @@ -117,10 +116,11 @@ 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 { - TimelineMediaPreviewRedactConfirmationView(context: viewModel.context) + TimelineMediaPreviewRedactConfirmationView(item: viewModel.state.currentItem, context: viewModel.context) } static func makeViewModel(contentType: UTType? = nil) -> TimelineMediaPreviewViewModel { @@ -138,8 +138,9 @@ struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, Tes thumbnailInfo: .mockThumbnail, contentType: contentType)) - return TimelineMediaPreviewViewModel(initialItem: item, - timelineViewModel: TimelineViewModel.mock, + return TimelineMediaPreviewViewModel(context: .init(item: item, + viewModel: TimelineViewModel.mock, + namespace: previewNamespace), mediaProvider: MediaProviderMock(configuration: .init()), userIndicatorController: UserIndicatorControllerMock()) } diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift index 7ef7b9d843..b04688eefa 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift @@ -21,7 +21,7 @@ struct MediaEventsTimelineScreenCoordinatorParameters { } enum MediaEventsTimelineScreenCoordinatorAction { - case viewInRoomTimeline(TimelineItemIdentifier) + case viewItem(TimelineMediaPreviewContext) } final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol { @@ -68,8 +68,8 @@ final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol { viewModel.actionsPublisher .sink { [weak self] action in switch action { - case .viewInRoomTimeline(let itemID): - self?.actionsSubject.send(.viewInRoomTimeline(itemID)) + case .viewItem(let previewContext): + self?.actionsSubject.send(.viewItem(previewContext)) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift index 71e9411e68..38b12d8cbc 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift @@ -5,10 +5,10 @@ // Please see LICENSE in the repository root for full details. // -import Foundation +import SwiftUI enum MediaEventsTimelineScreenViewModelAction { - case viewInRoomTimeline(TimelineItemIdentifier) + case viewItem(TimelineMediaPreviewContext) } enum MediaEventsTimelineScreenMode { @@ -29,16 +29,17 @@ 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(RoomTimelineItemViewState) + case tappedItem(item: RoomTimelineItemViewState, namespace: Namespace.ID) } diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift index a550bdae68..09ede04cf1 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift @@ -84,8 +84,8 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType backPaginateIfNecessary(paginationStatus: activeTimelineViewModel.context.viewState.timelineState.paginationState.backward) case .oldestItemDidDisappear: isOldestItemVisible = false - case .tappedItem(let item): - handleItemTapped(item) + case .tappedItem(let item, let namespace): + handleItemTapped(item, namespace: namespace) } } @@ -140,7 +140,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType } } - private func handleItemTapped(_ item: RoomTimelineItemViewState) { + private func handleItemTapped(_ item: RoomTimelineItemViewState, namespace: Namespace.ID) { let item: EventBasedMessageTimelineItemProtocol? = switch item.type { case .audio(let audioItem): audioItem case .file(let fileItem): fileItem @@ -149,31 +149,20 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType default: nil } - guard let item, let mediaProvider = context.mediaProvider else { - MXLog.error("Unexpected item type (or the media provider is missing).") + guard let item else { + MXLog.error("Unexpected item type tapped.") return } - let viewModel = TimelineMediaPreviewViewModel(initialItem: item, - timelineViewModel: activeTimelineViewModel, - mediaProvider: mediaProvider, - userIndicatorController: userIndicatorController) + actionsSubject.send(.viewItem(.init(item: item, + viewModel: activeTimelineViewModel, + namespace: namespace, + completion: { [weak self] in + self?.state.currentPreviewItemID = nil + }))) - mediaPreviewCancellable = viewModel.actions - .sink { [weak self] action in - guard let self else { return } - switch action { - case .viewInRoomTimeline(let itemID): - state.bindings.mediaPreviewViewModel = nil - actionsSubject.send(.viewInRoomTimeline(itemID)) - case .dismiss: - state.bindings.mediaPreviewViewModel = nil - case .loadedMediaFile: - break // Handled by the preview controller - } - } - - state.bindings.mediaPreviewViewModel = viewModel + // Set the current item in the next run loop so that (hopefully) the presentation will be ready before we flip the thumbnail. + Task { state.currentPreviewItemID = item.id } } private func titleForDate(_ date: Date) -> String { diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift index 6a0274b058..d13523376a 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift @@ -11,6 +11,8 @@ import SwiftUI struct MediaEventsTimelineScreen: View { @ObservedObject var context: MediaEventsTimelineScreenViewModel.Context + @Namespace private var zoomTransition + var body: some View { mainContent .navigationBarTitleDisplayMode(.inline) @@ -30,7 +32,6 @@ struct MediaEventsTimelineScreen: View { .pickerStyle(.segmented) } } - .timelineMediaQuickLook(viewModel: $context.mediaPreviewViewModel) .environmentObject(context.viewState.activeTimelineContextProvider()) .environment(\.timelineContext, context.viewState.activeTimelineContextProvider()) } @@ -39,7 +40,7 @@ struct MediaEventsTimelineScreen: View { // * flip the scrollView vertically to keep the items // at the bottom and have pagination working properly // * flip the grid vertically to counteract the scroll view - // but also horizontally to preserve the corect item order + // but also horizontally to preserve the correct item order // * flip the items on both axes have them render correctly @ViewBuilder private var mainContent: some View { @@ -73,7 +74,7 @@ struct MediaEventsTimelineScreen: View { Section { ForEach(group.items) { item in Button { - context.send(viewAction: .tappedItem(item)) + tappedItem(item) } label: { Color.clear // Let the image aspect fill in place .aspectRatio(1, contentMode: .fill) @@ -81,8 +82,9 @@ struct MediaEventsTimelineScreen: View { viewForTimelineItem(item) } .clipped() - .scaleEffect(.init(width: -1, height: -1)) + .scaleEffect(scale(for: item, isGridLayout: true)) } + .zoomTransitionSource(id: item.identifier, in: zoomTransition) } } footer: { // Use a footer as the header because the scrollView is flipped @@ -104,11 +106,12 @@ struct MediaEventsTimelineScreen: View { Divider() Button { - context.send(viewAction: .tappedItem(item)) + tappedItem(item) } label: { viewForTimelineItem(item) - .scaleEffect(.init(width: 1, height: -1)) + .scaleEffect(scale(for: item, isGridLayout: false)) } + .zoomTransitionSource(id: item.identifier, in: zoomTransition) } .padding(.horizontal, 16) } @@ -207,6 +210,19 @@ struct MediaEventsTimelineScreen: View { .multilineTextAlignment(.center) } } + + func tappedItem(_ item: RoomTimelineItemViewState) { + context.send(viewAction: .tappedItem(item: item, namespace: zoomTransition)) + } + + func scale(for item: RoomTimelineItemViewState, isGridLayout: Bool) -> CGSize { + guard item.identifier != context.viewState.currentPreviewItemID else { + // Remove the flip when presenting a preview so that the zoom transition is the right way up 🙃 + return CGSize(width: 1, height: 1) + } + + return CGSize(width: isGridLayout ? -1 : 1, height: -1) + } } // MARK: - Previews diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 5787a1a980..7d855b524a 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -96,7 +96,6 @@ struct RoomScreen: View { .environmentObject(timelineContext) } .interactiveQuickLook(item: $timelineContext.mediaPreviewItem) - .timelineMediaQuickLook(viewModel: $timelineContext.mediaPreviewViewModel) .track(screen: .Room) .onDrop(of: ["public.item", "public.file-url"], isTargeted: $dragOver) { providers -> Bool in guard let provider = providers.first, diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index cff01156bf..e44cb865a1 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -127,8 +127,6 @@ struct TimelineViewStateBindings { /// A media item that will be previewed with QuickLook. var mediaPreviewItem: MediaPreviewItem? - var mediaPreviewViewModel: TimelineMediaPreviewViewModel? - var alertInfo: AlertInfo? var debugInfo: TimelineItemDebugInfo? diff --git a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift index 65d2ac3ec7..53be6df5c6 100644 --- a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift +++ b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift @@ -9,6 +9,7 @@ import Combine import MatrixRustSDK +import SwiftUI import XCTest @MainActor @@ -18,7 +19,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { var mediaProvider: MediaProviderMock! var timelineController: MockRoomTimelineController! - func testLoadingItem() async { + func testLoadingItem() async throws { // Given a fresh view model. setupViewModel() XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled) @@ -26,7 +27,9 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { XCTAssertNotNil(context.viewState.currentItemActions) // When the preview controller sets the current item. - await viewModel.updateCurrentItem(context.viewState.previewItems[0]) + let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true } + context.send(viewAction: .updateCurrentItem(context.viewState.previewItems[0])) + try await deferred.fulfill() // Then the view model should load the item and update its view state. XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled) @@ -36,12 +39,12 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { func testViewInRoomTimeline() async throws { // Given a view model with a loaded item. - await testLoadingItem() + try await testLoadingItem() // When choosing to view the current item in the timeline. - let currentItemID = context.viewState.currentItem.id - let deferred = deferFulfillment(viewModel.actions) { $0 == .viewInRoomTimeline(currentItemID) } - context.send(viewAction: .menuAction(.viewInRoomTimeline)) + let item = context.viewState.currentItem + let deferred = deferFulfillment(viewModel.actions) { $0 == .viewInRoomTimeline(item.id) } + context.send(viewAction: .menuAction(.viewInRoomTimeline, item: item)) // Then the action should be sent upwards to make this happen. try await deferred.fulfill() @@ -49,28 +52,52 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { func testRedactConfirmation() async throws { // Given a view model with a loaded item. - await testLoadingItem() - XCTAssertFalse(context.isPresentingRedactConfirmation) + try await testLoadingItem() + XCTAssertNil(context.redactConfirmationItem) XCTAssertFalse(timelineController.redactCalled) - // When choosing to redact the current item. - context.send(viewAction: .menuAction(.redact)) + // When choosing to show the item details. + context.send(viewAction: .showCurrentItemDetails) + + // Then the details sheet should be presented. + guard let item = context.mediaDetailsItem else { + XCTFail("The default of the current item should be presented") + return + } + XCTAssertEqual(context.mediaDetailsItem, context.viewState.currentItem) + + // When choosing to redact the item. + context.send(viewAction: .menuAction(.redact, item: item)) // Then the confirmation sheet should be presented. - XCTAssertTrue(context.isPresentingRedactConfirmation) + XCTAssertEqual(context.redactConfirmationItem, item) XCTAssertFalse(timelineController.redactCalled) // When confirming the redaction. let deferred = deferFulfillment(viewModel.actions) { $0 == .dismiss } - context.send(viewAction: .redactConfirmation) + context.send(viewAction: .redactConfirmation(item: item)) // Then the item should be redacted and the view should be dismissed. try await deferred.fulfill() XCTAssertTrue(timelineController.redactCalled) } + 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() + } + // MARK: - Helpers + @Namespace private var testNamespace + private func setupViewModel() { let item = ImageRoomTimelineItem(id: .randomEvent, timestamp: .mock, @@ -88,9 +115,10 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { timelineController.timelineItems = [item] mediaProvider = MediaProviderMock(configuration: .init()) - viewModel = TimelineMediaPreviewViewModel(initialItem: item, - timelineViewModel: TimelineViewModel.mock(timelineKind: .media(.mediaFilesScreen), - timelineController: timelineController), + viewModel = TimelineMediaPreviewViewModel(context: .init(item: item, + viewModel: TimelineViewModel.mock(timelineKind: .media(.mediaFilesScreen), + timelineController: timelineController), + namespace: testNamespace), mediaProvider: mediaProvider, userIndicatorController: UserIndicatorControllerMock()) }