mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 13:37:11 +00:00
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.
This commit is contained in:
parent
45a630dd85
commit
3a82b88859
@ -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 = "<group>"; };
|
||||
00E5B2CBEF8F96424F095508 /* RoomDetailsEditScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModelTests.swift; sourceTree = "<group>"; };
|
||||
012A284622B32052015F1F89 /* ReadMarkerRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadMarkerRoomTimelineView.swift; sourceTree = "<group>"; };
|
||||
018194CAFBE80720FECCEDEE /* ZoomTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomTransition.swift; sourceTree = "<group>"; };
|
||||
01B795AAAB7B8747FE2FF311 /* LogViewerScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewerScreenModels.swift; sourceTree = "<group>"; };
|
||||
01C4C7DB37597D7D8379511A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
022E6BD64CB4610B9C95FC02 /* UserDetailsEditScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenViewModel.swift; sourceTree = "<group>"; };
|
||||
@ -1363,6 +1367,7 @@
|
||||
07579F9C29001E40715F3014 /* NotificationSettingsChatType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsChatType.swift; sourceTree = "<group>"; };
|
||||
077B01C13BBA2996272C5FB5 /* ProcessInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessInfo.swift; sourceTree = "<group>"; };
|
||||
077D7C3BE199B6E5DDEC07EC /* AppCoordinatorStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorStateMachine.swift; sourceTree = "<group>"; };
|
||||
07934EF08BB39353E4A94272 /* BlurEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurEffectView.swift; sourceTree = "<group>"; };
|
||||
07C6B0B087FE6601C3F77816 /* JoinedRoomProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinedRoomProxy.swift; sourceTree = "<group>"; };
|
||||
0825EAFD47332DD459DE893F /* SessionDirectoriesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDirectoriesTests.swift; sourceTree = "<group>"; };
|
||||
08283301736A6FE9D558B2CB /* AppLockScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
@ -1373,6 +1378,7 @@
|
||||
099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiProviderTests.swift; sourceTree = "<group>"; };
|
||||
0A3E77399BD262D301451BF2 /* RoomDetailsEditScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
0A459AE4B6566B2FA99E86B2 /* TimelineItemBubbledStylerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemBubbledStylerView.swift; sourceTree = "<group>"; };
|
||||
0A76B92984638A9B3104840D /* TimelineMediaPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewView.swift; sourceTree = "<group>"; };
|
||||
0B0E0B55E2EE75AF67029924 /* SwipeToReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeToReplyView.swift; sourceTree = "<group>"; };
|
||||
0B32BBA8887BD7A5C4ECF16F /* RoomModerationRole.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomModerationRole.swift; sourceTree = "<group>"; };
|
||||
0B987FC3FDBAA0E1C5AA235C /* PaginationIndicatorRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIndicatorRoomTimelineItem.swift; sourceTree = "<group>"; };
|
||||
@ -1384,6 +1390,7 @@
|
||||
0CB569EAA5017B5B23970655 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
0CCC6C31102E1D8B9106DEDE /* AppLockSetupBiometricsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupBiometricsScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
0D0B159AFFBBD8ECFD0E37FA /* LoginScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenModels.swift; sourceTree = "<group>"; };
|
||||
0D3615FD6460BC9C1DEF8659 /* MediaFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaFileManager.swift; sourceTree = "<group>"; };
|
||||
0D879FC4E881E748BB9B34DC /* RoomChangePermissionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomChangePermissionsScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
@ -1689,7 +1696,6 @@
|
||||
4959CECEC984B3995616F427 /* DataProtectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataProtectionManager.swift; sourceTree = "<group>"; };
|
||||
4999B5FD50AED7CB0F590FF8 /* AdvancedSettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsScreenModels.swift; sourceTree = "<group>"; };
|
||||
49ABAB186CF00B15C5521D04 /* MenuSheetLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuSheetLabelStyle.swift; sourceTree = "<group>"; };
|
||||
49D1DF63E340E81E45EAA68D /* TimelineMediaQuickLook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaQuickLook.swift; sourceTree = "<group>"; };
|
||||
49D2C8E66E83EA578A7F318A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
49E6066092ED45E36BB306F7 /* zh-Hant-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-Hant-TW"; path = "zh-Hant-TW.lproj/Localizable.stringsdict"; sourceTree = "<group>"; };
|
||||
49E751D7EDB6043238111D90 /* UNNotificationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UNNotificationRequest.swift; sourceTree = "<group>"; };
|
||||
@ -1790,7 +1796,6 @@
|
||||
61B33F23681660E940BA57F4 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/SAS.strings; sourceTree = "<group>"; };
|
||||
622D09D4ECE759189009AEAF /* MapLibreMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLibreMapView.swift; sourceTree = "<group>"; };
|
||||
624244C398804ADC885239AA /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
626271BD45A5CE50A4567C89 /* TimelineMediaPreviewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewController.swift; sourceTree = "<group>"; };
|
||||
627A8B5E798CC778C363655E /* KnockRequestsListEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockRequestsListEmptyStateView.swift; sourceTree = "<group>"; };
|
||||
62A81CCC2516D9CF9322DF01 /* MediaProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProviderTests.swift; sourceTree = "<group>"; };
|
||||
62B07B296D7A9D2F09120853 /* OrderedSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSet.swift; sourceTree = "<group>"; };
|
||||
@ -2380,6 +2385,7 @@
|
||||
E321E840DCC63790049984F4 /* ElementCallServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementCallServiceMock.swift; sourceTree = "<group>"; };
|
||||
E34685D186453E429ADEE58E /* ClientProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientProtocolTests.swift; sourceTree = "<group>"; };
|
||||
E36CB905A2B9EC2C92A2DA7C /* KeychainController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainController.swift; sourceTree = "<group>"; };
|
||||
E3A62FBD3007312311C14DD8 /* TimelineMediaPreviewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewCoordinator.swift; sourceTree = "<group>"; };
|
||||
E3B97591B2D3D4D67553506D /* AnalyticsClientProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsClientProtocol.swift; sourceTree = "<group>"; };
|
||||
E4103AB4340F2974D690A12A /* CallScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallScreen.swift; sourceTree = "<group>"; };
|
||||
E413F4CBD7BF0588F394A9DD /* RoomDetailsEditScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModel.swift; sourceTree = "<group>"; };
|
||||
@ -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 = "<group>";
|
||||
@ -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;
|
||||
};
|
||||
|
@ -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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -10,18 +10,24 @@ import SwiftUIIntrospect
|
||||
|
||||
extension PlatformViewVersionPredicate<TextFieldType, UITextField> {
|
||||
static var supportedVersions: Self {
|
||||
.iOS(.v16, .v17, .v18)
|
||||
.iOS(.v17, .v18)
|
||||
}
|
||||
}
|
||||
|
||||
extension PlatformViewVersionPredicate<ScrollViewType, UIScrollView> {
|
||||
static var supportedVersions: Self {
|
||||
.iOS(.v16, .v17, .v18)
|
||||
.iOS(.v17, .v18)
|
||||
}
|
||||
}
|
||||
|
||||
extension PlatformViewVersionPredicate<ViewControllerType, UIViewController> {
|
||||
static var supportedVersions: Self {
|
||||
.iOS(.v16, .v17, .v18)
|
||||
.iOS(.v17, .v18)
|
||||
}
|
||||
}
|
||||
|
||||
extension PlatformViewVersionPredicate<NavigationStackType, UINavigationController> {
|
||||
static var supportedVersions: Self {
|
||||
.iOS(.v17, .v18)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
22
ElementX/Sources/Other/SwiftUI/Views/BlurEffectView.swift
Normal file
22
ElementX/Sources/Other/SwiftUI/Views/BlurEffectView.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
@ -26,7 +26,6 @@ struct CallScreen: View {
|
||||
Image(systemSymbol: .chevronBackward)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
// .padding(.leading, -8) // Fixes the button alignment, but harder to tap.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
@ -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<AnyCancellable> = []
|
||||
|
||||
private let headerHostingController: UIHostingController<HeaderView>
|
||||
private let captionHostingController: UIHostingController<CaptionView>
|
||||
private let detailsHostingController: UIHostingController<TimelineMediaPreviewDetailsView>
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
@ -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<AnyCancellable>()
|
||||
|
||||
private let actionsSubject: PassthroughSubject<TimelineMediaPreviewCoordinatorAction, Never> = .init()
|
||||
var actionsPublisher: AnyPublisher<TimelineMediaPreviewCoordinatorAction, Never> {
|
||||
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))
|
||||
}
|
||||
}
|
@ -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<TimelineItemIdentifier, Never>()
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -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<AnyCancellable> = []
|
||||
|
||||
init(coordinator: Coordinator, fileLoadedPublisher: AnyPublisher<TimelineItemIdentifier, Never>) {
|
||||
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())
|
||||
}
|
||||
}
|
@ -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)"
|
||||
|
@ -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<TimelineMediaPreviewViewModel?>) -> some View {
|
||||
modifier(TimelineMediaQuickLookModifier(viewModel: viewModel))
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimelineMediaQuickLookModifier: ViewModifier {
|
||||
@Binding var viewModel: TimelineMediaPreviewViewModel?
|
||||
|
||||
@State private var dismissalPublisher = PassthroughSubject<Void, Never>()
|
||||
|
||||
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<Void, Never>
|
||||
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<Void, Never>,
|
||||
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 <QLPreviewController> from detached view controller <HostingController> is not supported,
|
||||
// and may result in incorrect safe area insets and a corrupt root presentation. Make sure <HostingController> 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())
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
||||
|
@ -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())
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -127,8 +127,6 @@ struct TimelineViewStateBindings {
|
||||
/// A media item that will be previewed with QuickLook.
|
||||
var mediaPreviewItem: MediaPreviewItem?
|
||||
|
||||
var mediaPreviewViewModel: TimelineMediaPreviewViewModel?
|
||||
|
||||
var alertInfo: AlertInfo<RoomScreenAlertInfoType>?
|
||||
|
||||
var debugInfo: TimelineItemDebugInfo?
|
||||
|
@ -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())
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user