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