diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index bb143c3af..7fc893359 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -105,6 +105,7 @@ 12CCA59536EDD99A3272CF77 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC3F82523D6F48B926D6AF68 /* AppSettings.swift */; }; 12CD8B5CC30A05061228BF9E /* TimelineItemMenuActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E6065FC6BC4A1B4C629E08 /* TimelineItemMenuActionProvider.swift */; }; 12E6D052D055531A6783E21B /* portrait_test_image.jpg in Resources */ = {isa = PBXBuildFile; fileRef = BC51BF90469412ABDE658CDD /* portrait_test_image.jpg */; }; + 12EC6BC99F373FE5C6EB9B64 /* TimelineMediaPreviewDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 467498BEA681758BE2F80826 /* TimelineMediaPreviewDetailsView.swift */; }; 1307268DC41730E5BCF7D9A0 /* PollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638790D3F915F0909315C47A /* PollView.swift */; }; 1318721F4E5F307586D98112 /* VoiceMessageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8516302ACCA94A0E680AB3B /* VoiceMessageButton.swift */; }; 13C77FDF17C4C6627CFFC205 /* RoomTimelineItemFactoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D25A35764C7B3DB78954AB5 /* RoomTimelineItemFactoryProtocol.swift */; }; @@ -575,6 +576,7 @@ 77C1A2F49CD90D3EFDF376E5 /* MapTilerURLBuildersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376D941BF8BB294389C0DE24 /* MapTilerURLBuildersTests.swift */; }; 77D7DAA41AAB36800C1F2E2D /* RoomTimelineProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 095AED4CF56DFF3EB7BB84C8 /* RoomTimelineProviderProtocol.swift */; }; 77FACC29F98FE2E65BBB6A5F /* ServerSelectionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 054F469E433864CC6FE6EE8E /* ServerSelectionUITests.swift */; }; + 77FB08C303F4C74C0E8577E2 /* TimelineMediaPreviewModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A2BB38DF61F5100B8723112 /* TimelineMediaPreviewModels.swift */; }; 7807B1DEE32617896886A8E5 /* FormattingToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE1E6FAA3719E9B7A2D5510B /* FormattingToolbar.swift */; }; 784592335560C2E91D32D177 /* DeveloperOptionsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06B098A612DCB5A7358EECD5 /* DeveloperOptionsScreenModels.swift */; }; 785613C0C092B532198EB3BB /* TimelineStartRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44ECC9D66400727DFFEE12E8 /* TimelineStartRoomTimelineView.swift */; }; @@ -650,6 +652,7 @@ 8658F5034EAD7357CE7F9AC7 /* MatrixUserShareLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E31AB0E77BB70E2BC77463 /* MatrixUserShareLink.swift */; }; 865DD5CA474C6AE6C2BC008E /* NetworkMonitorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1575947B7A6FE08C57FE5EE4 /* NetworkMonitorProtocol.swift */; }; 86675910612A12409262DFBD /* SessionVerificationStateMachineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C22B1B5FA3A765EADB2CC9 /* SessionVerificationStateMachineTests.swift */; }; + 86769B62BAE17601B3AE1B60 /* TimelineMediaPreviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F41CEAAE2BB4E74CDC2278 /* TimelineMediaPreviewViewModel.swift */; }; 8691186F9B99BCDDB7CACDD8 /* KeychainController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36CB905A2B9EC2C92A2DA7C /* KeychainController.swift */; }; 86DFA58FBBEB0AF671D2A1E1 /* HomeScreenKnockedCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A103580EBA06155B1343EF16 /* HomeScreenKnockedCell.swift */; }; 86F9D3028A1F4AE819D75560 /* RoomChangePermissionsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D879FC4E881E748BB9B34DC /* RoomChangePermissionsScreenCoordinator.swift */; }; @@ -793,6 +796,7 @@ A2172B5A26976F9174228B8A /* AppHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E4AB573FAEBB7B853DD04C /* AppHooks.swift */; }; A23B8B27A1436A1049EEF68E /* InfoPlistReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A580295A56B55A856CC4084 /* InfoPlistReader.swift */; }; A2434D4DFB49A68E5CD0F53C /* MediaLoaderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A02406480C351B8C6E0682C /* MediaLoaderProtocol.swift */; }; + A32384E3D85CA65342D3A908 /* TimelineMediaPreviewRedactConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C75FE3F524B575D53787868C /* TimelineMediaPreviewRedactConfirmationView.swift */; }; A33784831AD880A670CAA9F9 /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DF593C3F7AF4B2FBAEB05D /* FileManager.swift */; }; A36AD251013402EDBD666C75 /* AppMediatorMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAC027034248429A438886B /* AppMediatorMock.swift */; }; A37EED79941AD3B7140B3822 /* UIDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 287FC98AF2664EAD79C0D902 /* UIDevice.swift */; }; @@ -931,6 +935,7 @@ C1910A16BDF131FECA77BE22 /* EmojiPickerScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEA38B9851CFCC4D67F5587D /* EmojiPickerScreenCoordinator.swift */; }; C1A5C386319835FB0C77736B /* ReportContentScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A16CD2C62CB7DB78A4238485 /* ReportContentScreenCoordinator.swift */; }; C1D0AB8222D7BAFC9AF9C8C0 /* MapLibreMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 622D09D4ECE759189009AEAF /* MapLibreMapView.swift */; }; + C20ADD95496C3328E8E22F36 /* TimelineMediaQuickLook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49D1DF63E340E81E45EAA68D /* TimelineMediaQuickLook.swift */; }; C26DB49C06C00B5DF1A991A5 /* InviteUsersScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1454CF3AABD242F55C8A2615 /* InviteUsersScreenModels.swift */; }; C2879369106A419A5071F1F8 /* VoiceMessageRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B0A96B8FE4849227945067 /* VoiceMessageRecorder.swift */; }; C32765D740C81AD4C42E8F50 /* CreateRoomFlowParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 935C2FB18EFB8EEE96B26330 /* CreateRoomFlowParameters.swift */; }; @@ -951,6 +956,7 @@ C5627BCC3EBBB96A943B6D93 /* RestorationTokenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7978C9EFBDD7DE39BD86726 /* RestorationTokenTests.swift */; }; C58E305C380D3ADDF7912180 /* StickerRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818695BED971753243FEF897 /* StickerRoomTimelineItem.swift */; }; C5A07E2D88BE7D51DCECD166 /* LoginScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D0B159AFFBBD8ECFD0E37FA /* LoginScreenModels.swift */; }; + C62B99D50A5A9BFD504B6774 /* TimelineMediaPreviewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626271BD45A5CE50A4567C89 /* TimelineMediaPreviewController.swift */; }; C67FCC854F3A6FC7A2EC04D0 /* MediaUploadPreviewScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70C86696AC9521F8ED88FBEB /* MediaUploadPreviewScreen.swift */; }; C6C06DDA8881260303FBA3A0 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2141693488CE5446BB391964 /* Date.swift */; }; C76892321558E75101E68ED6 /* ReadableFrameModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398817652FA8ABAE0A31AC6D /* ReadableFrameModifier.swift */; }; @@ -1174,6 +1180,7 @@ F66BBBE51B258BBB0B918C68 /* MatrixRustSDK in Frameworks */ = {isa = PBXBuildFile; productRef = C79D91A7F9F378CECEF64B5A /* MatrixRustSDK */; }; F66BCCC825D6CA51724A94D0 /* MediaPlayerProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8A1F98AE670377B20679FF5 /* MediaPlayerProvider.swift */; }; F697284B9B5F2C00CFEA3B12 /* EmojiDetectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58E93D91DE3288010390DEE /* EmojiDetectionTests.swift */; }; + F6BF52CB027393EE03CEC523 /* TimelineMediaPreviewViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1F000589F2CEE6B03ECFAB /* TimelineMediaPreviewViewModelTests.swift */; }; F6DFA23885980118AD7359C5 /* NotificationSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2389732B0E115A999A069083 /* NotificationSettingsScreenCoordinator.swift */; }; F6F49E37272AD7397CD29A01 /* HomeScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505208F28007C0FEC14E1FF0 /* HomeScreenViewModelTests.swift */; }; F7567DD6635434E8C563BF85 /* AnalyticsClientProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B97591B2D3D4D67553506D /* AnalyticsClientProtocol.swift */; }; @@ -1501,6 +1508,7 @@ 2910422CB628D3B2BBE47449 /* SeparatorRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorRoomTimelineView.swift; sourceTree = ""; }; 295E28C3B9EAADF519BF2F44 /* AuthenticationFlowCoordinatorUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationFlowCoordinatorUITests.swift; sourceTree = ""; }; 29A953B6C0C431DBF4DD00B4 /* RoomSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummary.swift; sourceTree = ""; }; + 2A2BB38DF61F5100B8723112 /* TimelineMediaPreviewModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewModels.swift; sourceTree = ""; }; 2A5C6FBF97B6EED3D4FA5EFF /* AttributedStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilder.swift; sourceTree = ""; }; 2AB2C848BB9A7A9B618B7B89 /* TextBasedRoomTimelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextBasedRoomTimelineTests.swift; sourceTree = ""; }; 2AC3FDB58F57386741A4FC7F /* DeactivateAccountScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeactivateAccountScreenViewModel.swift; sourceTree = ""; }; @@ -1633,6 +1641,7 @@ 45D8149FDDA0315CDC553B4B /* UserNotificationCenterProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationCenterProtocol.swift; sourceTree = ""; }; 4629710C0337ADD9C8909542 /* ka */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ka; path = ka.lproj/Localizable.strings; sourceTree = ""; }; 466C71A0FED9BFF287613C82 /* RoomDetailsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsScreenModels.swift; sourceTree = ""; }; + 467498BEA681758BE2F80826 /* TimelineMediaPreviewDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewDetailsView.swift; sourceTree = ""; }; 4691B8DE1D51DE152680098A /* HomeScreenSlidingSyncMigrationBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenSlidingSyncMigrationBanner.swift; sourceTree = ""; }; 46A2AD86F7E618F468F6FAF5 /* VoiceMessageRecordingButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingButton.swift; sourceTree = ""; }; 46C208DA43CE25D13E670F40 /* UITestsAppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestsAppCoordinator.swift; sourceTree = ""; }; @@ -1656,6 +1665,7 @@ 4959CECEC984B3995616F427 /* DataProtectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataProtectionManager.swift; sourceTree = ""; }; 4999B5FD50AED7CB0F590FF8 /* AdvancedSettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsScreenModels.swift; sourceTree = ""; }; 49ABAB186CF00B15C5521D04 /* MenuSheetLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuSheetLabelStyle.swift; sourceTree = ""; }; + 49D1DF63E340E81E45EAA68D /* TimelineMediaQuickLook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaQuickLook.swift; sourceTree = ""; }; 49D2C8E66E83EA578A7F318A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 49E6066092ED45E36BB306F7 /* zh-Hant-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-Hant-TW"; path = "zh-Hant-TW.lproj/Localizable.stringsdict"; sourceTree = ""; }; 49E751D7EDB6043238111D90 /* UNNotificationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UNNotificationRequest.swift; sourceTree = ""; }; @@ -1698,6 +1708,7 @@ 5351EBD7A0B9610548E4B7B2 /* EncryptedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptedRoomTimelineItem.swift; sourceTree = ""; }; 536C0E2178949B290776EA4E /* QRCodeLoginServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeLoginServiceProtocol.swift; sourceTree = ""; }; 536E72DCBEEC4A1FE66CFDCE /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; + 53F41CEAAE2BB4E74CDC2278 /* TimelineMediaPreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewViewModel.swift; sourceTree = ""; }; 53FD6D3D38F556CEAA280C58 /* test_animated_image.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = test_animated_image.gif; sourceTree = ""; }; 542D4F49FABA056DEEEB3400 /* RustTracing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RustTracing.swift; sourceTree = ""; }; 5445FCE0CE15E634FDC1A2E2 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; @@ -1732,6 +1743,7 @@ 5A43964330459965AF048A8C /* LoginScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenViewModelTests.swift; sourceTree = ""; }; 5AEA0B743847CFA5B3C38EE4 /* RoomMembersListScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenCoordinator.swift; sourceTree = ""; }; 5B8F0ED874DF8C9A51B0AB6F /* SettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenCoordinator.swift; sourceTree = ""; }; + 5C1F000589F2CEE6B03ECFAB /* TimelineMediaPreviewViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewViewModelTests.swift; sourceTree = ""; }; 5C7C7CFA6B2A62A685FF6CE3 /* DeveloperOptionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenCoordinator.swift; sourceTree = ""; }; 5CEEAE1BFAACD6C96B6DB731 /* PHGPostHogProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogProtocol.swift; sourceTree = ""; }; 5D26A086A8278D39B5756D6F /* project.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = project.yml; sourceTree = ""; }; @@ -1754,6 +1766,7 @@ 61B33F23681660E940BA57F4 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/SAS.strings; sourceTree = ""; }; 622D09D4ECE759189009AEAF /* MapLibreMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLibreMapView.swift; sourceTree = ""; }; 624244C398804ADC885239AA /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; + 626271BD45A5CE50A4567C89 /* TimelineMediaPreviewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewController.swift; sourceTree = ""; }; 627A8B5E798CC778C363655E /* KnockRequestsListEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockRequestsListEmptyStateView.swift; sourceTree = ""; }; 62A81CCC2516D9CF9322DF01 /* MediaProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProviderTests.swift; sourceTree = ""; }; 62B07B296D7A9D2F09120853 /* OrderedSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSet.swift; sourceTree = ""; }; @@ -2215,6 +2228,7 @@ C715CFE00686DACA59D836EA /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = fa.lproj/SAS.strings; sourceTree = ""; }; C729D95CB4588D4D9AAC3DFA /* RoomChangePermissionsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomChangePermissionsScreenModels.swift; sourceTree = ""; }; C75EF87651B00A176AB08E97 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + C75FE3F524B575D53787868C /* TimelineMediaPreviewRedactConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewRedactConfirmationView.swift; sourceTree = ""; }; C7661EFFCAA307A97D71132A /* HomeScreenRoomList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRoomList.swift; sourceTree = ""; }; C7D851A10FDA55579960DC61 /* WebRegistrationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRegistrationScreenCoordinator.swift; sourceTree = ""; }; C830A64609CBD152F06E0457 /* NotificationConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationConstants.swift; sourceTree = ""; }; @@ -3428,6 +3442,11 @@ isa = PBXGroup; children = ( 638A81B97D51591D0FCFA598 /* InteractiveQuickLook.swift */, + 626271BD45A5CE50A4567C89 /* TimelineMediaPreviewController.swift */, + 2A2BB38DF61F5100B8723112 /* TimelineMediaPreviewModels.swift */, + 53F41CEAAE2BB4E74CDC2278 /* TimelineMediaPreviewViewModel.swift */, + 49D1DF63E340E81E45EAA68D /* TimelineMediaQuickLook.swift */, + 5EC4A8482DA110602FE6DF42 /* View */, ); path = FilePreviewScreen; sourceTree = ""; @@ -3801,6 +3820,15 @@ path = View; sourceTree = ""; }; + 5EC4A8482DA110602FE6DF42 /* View */ = { + isa = PBXGroup; + children = ( + 467498BEA681758BE2F80826 /* TimelineMediaPreviewDetailsView.swift */, + C75FE3F524B575D53787868C /* TimelineMediaPreviewRedactConfirmationView.swift */, + ); + path = View; + sourceTree = ""; + }; 5F6CB68B44F6C587E463A934 /* View */ = { isa = PBXGroup; children = ( @@ -4107,6 +4135,7 @@ 2CEBCB9676FCD1D0F13188DD /* StringTests.swift */, 2AB2C848BB9A7A9B618B7B89 /* TextBasedRoomTimelineTests.swift */, 9AA3AF94A06D319BB37E52DA /* TimelineItemFactoryTests.swift */, + 5C1F000589F2CEE6B03ECFAB /* TimelineMediaPreviewViewModelTests.swift */, 6509708F54FC883604DFDC95 /* TimelineViewModelTests.swift */, 1734A445A58ED855B977A0A8 /* TracingConfigurationTests.swift */, 76310030C831D4610A705603 /* URLComponentsTests.swift */, @@ -6550,6 +6579,7 @@ E75CE800B3E64D0F7F8E228D /* TemplateScreenViewModelTests.swift in Sources */, 3A7DD0D13B0FB8876D69D829 /* TextBasedRoomTimelineTests.swift in Sources */, 0D4EB2ABAA5FE8CB10FDBCB8 /* TimelineItemFactoryTests.swift in Sources */, + F6BF52CB027393EE03CEC523 /* TimelineMediaPreviewViewModelTests.swift in Sources */, 2F6207CB5C4715FE313B1E95 /* TimelineViewModelTests.swift in Sources */, 282A5F3375DDC774AE09B0C3 /* TracingConfigurationTests.swift in Sources */, 8E650379587C31D7912ED67B /* UNNotification+Creator.swift in Sources */, @@ -7358,6 +7388,12 @@ 1B88BB631F7FC45A213BB554 /* TimelineItemSender.swift in Sources */, EFBBD44C0A16F017C32D2099 /* TimelineItemStatusView.swift in Sources */, 562EFB9AB62B38830D9AA778 /* TimelineMediaFrame.swift in Sources */, + C62B99D50A5A9BFD504B6774 /* TimelineMediaPreviewController.swift in Sources */, + 12EC6BC99F373FE5C6EB9B64 /* TimelineMediaPreviewDetailsView.swift in Sources */, + 77FB08C303F4C74C0E8577E2 /* TimelineMediaPreviewModels.swift in Sources */, + A32384E3D85CA65342D3A908 /* TimelineMediaPreviewRedactConfirmationView.swift in Sources */, + 86769B62BAE17601B3AE1B60 /* TimelineMediaPreviewViewModel.swift in Sources */, + C20ADD95496C3328E8E22F36 /* TimelineMediaQuickLook.swift in Sources */, B818580464CFB5400A3EF6AE /* TimelineModels.swift in Sources */, E82E13CC3EB923CCB8F8273C /* TimelineProxy.swift in Sources */, 16A1F6C703305FCAF4E14EC6 /* TimelineProxyMock.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index a95d5520c..b8ad13910 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -142,6 +142,7 @@ "common_developer_options" = "Developer options"; "common_device_id" = "Device ID"; "common_direct_chat" = "Direct chat"; +"common_downloading" = "Downloading"; "common_edited_suffix" = "(edited)"; "common_editing" = "Editing"; "common_editing_caption" = "Editing caption"; @@ -389,6 +390,8 @@ "screen_knock_requests_list_title" = "Requests to join"; "screen_media_details_file_format" = "File format"; "screen_media_details_filename" = "File name"; +"screen_media_details_redact_confirmation_message" = "This file will be removed from the room and members won’t have access to it."; +"screen_media_details_redact_confirmation_title" = "Delete file?"; "screen_media_details_uploaded_by" = "Uploaded by"; "screen_media_details_uploaded_on" = "Uploaded on"; "screen_media_upload_preview_caption_warning" = "Captions might not be visible to people using older apps."; diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 89644c414..d2ad0f461 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -322,6 +322,8 @@ internal enum L10n { internal static var commonDeviceId: String { return L10n.tr("Localizable", "common_device_id") } /// Direct chat internal static var commonDirectChat: String { return L10n.tr("Localizable", "common_direct_chat") } + /// Downloading + internal static var commonDownloading: String { return L10n.tr("Localizable", "common_downloading") } /// (edited) internal static var commonEditedSuffix: String { return L10n.tr("Localizable", "common_edited_suffix") } /// Editing @@ -1378,6 +1380,10 @@ internal enum L10n { internal static var screenMediaDetailsFileFormat: String { return L10n.tr("Localizable", "screen_media_details_file_format") } /// File name internal static var screenMediaDetailsFilename: String { return L10n.tr("Localizable", "screen_media_details_filename") } + /// This file will be removed from the room and members won’t have access to it. + internal static var screenMediaDetailsRedactConfirmationMessage: String { return L10n.tr("Localizable", "screen_media_details_redact_confirmation_message") } + /// Delete file? + internal static var screenMediaDetailsRedactConfirmationTitle: String { return L10n.tr("Localizable", "screen_media_details_redact_confirmation_title") } /// Uploaded by internal static var screenMediaDetailsUploadedBy: String { return L10n.tr("Localizable", "screen_media_details_uploaded_by") } /// Uploaded on diff --git a/ElementX/Sources/Mocks/MediaProviderMock.swift b/ElementX/Sources/Mocks/MediaProviderMock.swift index 375ab2a5d..fe9984f8b 100644 --- a/ElementX/Sources/Mocks/MediaProviderMock.swift +++ b/ElementX/Sources/Mocks/MediaProviderMock.swift @@ -61,7 +61,13 @@ extension MediaProviderMock { return .success(data) } - loadFileFromSourceFilenameReturnValue = .failure(.failedRetrievingFile) + loadFileFromSourceFilenameClosure = { _, _ in + guard let url = Bundle.main.url(forResource: "preview_image", withExtension: "jpg") else { + return .failure(.failedRetrievingFile) + } + + return .success(.unmanaged(url: url)) + } loadImageRetryingOnReconnectionSizeClosure = { _, _ in Task { diff --git a/ElementX/Sources/Other/Avatars.swift b/ElementX/Sources/Other/Avatars.swift index 96fc58aaa..e97b3dae6 100644 --- a/ElementX/Sources/Other/Avatars.swift +++ b/ElementX/Sources/Other/Avatars.swift @@ -75,6 +75,7 @@ enum UserAvatarSizeOnScreen { case knockingUsersBannerStack case knockingUserBanner case knockingUserList + case mediaPreviewDetails var value: CGFloat { switch self { @@ -110,6 +111,8 @@ enum UserAvatarSizeOnScreen { return 32 case .knockingUserList: return 52 + case .mediaPreviewDetails: + return 32 } } } diff --git a/ElementX/Sources/Other/Extensions/Date.swift b/ElementX/Sources/Other/Extensions/Date.swift index 31f85c216..14ef23bdf 100644 --- a/ElementX/Sources/Other/Extensions/Date.swift +++ b/ElementX/Sources/Other/Extensions/Date.swift @@ -40,6 +40,6 @@ extension Date { /// A fixed date used for mocks, previews etc. static var mock: Date { - Calendar.current.startOfDay(for: .now).addingTimeInterval((9 * 60 * 60) + (41 * 60)) // 9:41 am + DateComponents(calendar: .current, year: 2007, month: 1, day: 9, hour: 9, minute: 41).date ?? .now } } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewController.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewController.swift new file mode 100644 index 000000000..e78f15321 --- /dev/null +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewController.swift @@ -0,0 +1,149 @@ +// +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import Combine +import Compound +import QuickLook +import SwiftUI + +class TimelineMediaPreviewController: QLPreviewController, QLPreviewControllerDataSource { + private let viewModel: TimelineMediaPreviewViewModel + + private var cancellables: Set = [] + + private let headerHostingController: UIHostingController + private let captionHostingController: UIHostingController + private let detailsHostingController: UIHostingController + + private var navigationBar: UINavigationBar? { view.subviews.first?.subviews.first { $0 is UINavigationBar } as? UINavigationBar } + private var toolbar: UIToolbar? { view.subviews.first?.subviews.last { $0 is UIToolbar } as? UIToolbar } + private var captionView: UIView { captionHostingController.view } + + init(viewModel: TimelineMediaPreviewViewModel) { + self.viewModel = viewModel + + headerHostingController = UIHostingController(rootView: HeaderView(context: viewModel.context)) + headerHostingController.view.backgroundColor = .clear + captionHostingController = UIHostingController(rootView: CaptionView(context: viewModel.context)) + captionHostingController.view.backgroundColor = .clear + detailsHostingController = UIHostingController(rootView: TimelineMediaPreviewDetailsView(context: viewModel.context)) + detailsHostingController.view.backgroundColor = .compound.bgCanvasDefault + + // let materialView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial)) + // captionHostingController.view.insertMatchedSubview(materialView, at: 0) + + super.init(nibName: nil, bundle: nil) + + view.addSubview(captionView) + + // Observation of currentPreviewItem doesn't work, so use the index instead. + publisher(for: \.currentPreviewItemIndex) + .sink { [weak self] _ in + guard let self, let currentPreviewItem = currentPreviewItem as? TimelineMediaPreviewItem else { return } + Task { await self.viewModel.updateCurrentItem(currentPreviewItem) } + } + .store(in: &cancellables) + + viewModel.actions + .sink { [weak self] action in + switch action { + case .loadedMediaFile: + self?.refreshCurrentPreviewItem() + case .viewInTimeline: + self?.dismiss(animated: true) // Dismiss the details sheet. + // Errrr, hmmmmm, do something else here. + } + } + .store(in: &cancellables) + + dataSource = self + } + + @available(*, unavailable) required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + + overrideUserInterfaceStyle = .dark + + if let toolbar { + captionView.isHidden = toolbar.alpha == 0 + + if captionView.constraints.isEmpty { + captionView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + captionView.bottomAnchor.constraint(equalTo: toolbar.topAnchor), + captionView.leadingAnchor.constraint(equalTo: toolbar.leadingAnchor), + captionView.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor) + ]) + } + } + + navigationBar?.topItem?.titleView = headerHostingController.view + + if navigationBar?.topItem?.rightBarButtonItems?.count == 1 { + navigationBar?.topItem?.rightBarButtonItems?.append(UIBarButtonItem(image: UIImage(systemSymbol: .infoCircle), style: .plain, target: self, action: #selector(presentMediaDetails))) + } + } + + // MARK: QLPreviewControllerDataSource + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + viewModel.state.previewItems.count + } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + viewModel.state.previewItems[index] + } + + // MARK: Private + + @objc func presentMediaDetails() { + detailsHostingController.overrideUserInterfaceStyle = .dark + detailsHostingController.sheetPresentationController?.detents = [.medium()] + detailsHostingController.sheetPresentationController?.prefersGrabberVisible = true + + present(detailsHostingController, animated: true) + } +} + +// MARK: - Subviews + +private struct HeaderView: View { + @ObservedObject var context: TimelineMediaPreviewViewModel.Context + + var body: some View { + VStack(spacing: 0) { + Text(context.viewState.currentItem?.sender.displayName ?? context.viewState.currentItem?.sender.id ?? L10n.commonLoading) + .font(.compound.bodySMSemibold) + .foregroundStyle(.compound.textPrimary) + Text(context.viewState.currentItem?.timestamp.formatted(date: .abbreviated, time: .omitted) ?? "") + .font(.compound.bodyXS) + .foregroundStyle(.compound.textPrimary) + .textCase(.uppercase) + } + } +} + +private struct CaptionView: View { + @ObservedObject var context: TimelineMediaPreviewViewModel.Context + + var body: some View { + if let caption = context.viewState.currentItem?.caption { + Text(caption) + .font(.compound.bodyLG) + .foregroundStyle(.compound.textPrimary) + .lineLimit(5) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .padding(16) + .background(.ultraThinMaterial) + } + } +} diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift new file mode 100644 index 000000000..24ccd0198 --- /dev/null +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift @@ -0,0 +1,162 @@ +// +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import QuickLook + +enum TimelineMediaPreviewViewModelAction { + case loadedMediaFile + case viewInTimeline +} + +struct TimelineMediaPreviewViewState: BindableState { + var previewItems: [TimelineMediaPreviewItem] + var currentItem: TimelineMediaPreviewItem? +} + +/// Wraps a media file and title to be previewed with QuickLook. +class TimelineMediaPreviewItem: NSObject, QLPreviewItem { + private let timelineItem: EventBasedMessageTimelineItemProtocol + var fileHandle: MediaFileHandleProxy? + + init(timelineItem: EventBasedMessageTimelineItemProtocol) { + self.timelineItem = timelineItem + } + + var id: TimelineItemIdentifier { timelineItem.id } + + // MARK: QLPreviewItem + + var previewItemURL: URL? { + fileHandle?.url + } + + var previewItemTitle: String? { + switch timelineItem { + case let audioItem as AudioRoomTimelineItem: + audioItem.content.filename + case let fileItem as FileRoomTimelineItem: + fileItem.content.filename + case let imageItem as ImageRoomTimelineItem: + imageItem.content.filename + case let videoItem as VideoRoomTimelineItem: + videoItem.content.filename + default: + nil + } + } + + // MARK: Event details + + var sender: TimelineItemSender { + timelineItem.sender + } + + var timestamp: Date { + timelineItem.timestamp + } + + // MARK: Media details + + var mediaSource: MediaSourceProxy? { + switch timelineItem { + case let audioItem as AudioRoomTimelineItem: + audioItem.content.source + case let fileItem as FileRoomTimelineItem: + fileItem.content.source + case let imageItem as ImageRoomTimelineItem: + imageItem.content.imageInfo.source + case let videoItem as VideoRoomTimelineItem: + videoItem.content.videoInfo.source + default: + nil + } + } + + var thumbnailMediaSource: MediaSourceProxy? { + switch timelineItem { + case let fileItem as FileRoomTimelineItem: + fileItem.content.thumbnailSource + case let imageItem as ImageRoomTimelineItem: + imageItem.content.thumbnailInfo?.source + case let videoItem as VideoRoomTimelineItem: + videoItem.content.thumbnailInfo?.source + default: + nil + } + } + + var filename: String? { + switch timelineItem { + case let audioItem as AudioRoomTimelineItem: + audioItem.content.filename + case let fileItem as FileRoomTimelineItem: + fileItem.content.filename + case let imageItem as ImageRoomTimelineItem: + imageItem.content.filename + case let videoItem as VideoRoomTimelineItem: + videoItem.content.filename + default: + nil + } + } + + var fileSize: Double? { + previewItemURL.flatMap { try? FileManager.default.sizeForItem(at: $0) } ?? expectedFileSize + } + + private var expectedFileSize: Double? { + let fileSize: UInt? = switch timelineItem { + case let audioItem as AudioRoomTimelineItem: + audioItem.content.fileSize + case let fileItem as FileRoomTimelineItem: + fileItem.content.fileSize + case let imageItem as ImageRoomTimelineItem: + imageItem.content.imageInfo.fileSize + case let videoItem as VideoRoomTimelineItem: + videoItem.content.videoInfo.fileSize + default: + nil + } + + return fileSize.map(Double.init) + } + + var caption: String? { + timelineItem.mediaCaption + } + + var contentType: String? { + switch timelineItem { + case let audioItem as AudioRoomTimelineItem: + audioItem.content.contentType?.localizedDescription + case let fileItem as FileRoomTimelineItem: + fileItem.content.contentType?.localizedDescription + case let imageItem as ImageRoomTimelineItem: + imageItem.content.contentType?.localizedDescription + case let videoItem as VideoRoomTimelineItem: + videoItem.content.contentType?.localizedDescription + default: + nil + } + } + + var blurhash: String? { + switch timelineItem { + case let imageItem as ImageRoomTimelineItem: + imageItem.content.blurhash + case let videoItem as VideoRoomTimelineItem: + videoItem.content.blurhash + default: + nil + } + } +} + +enum TimelineMediaPreviewViewAction { + case viewInTimeline + case redact +} diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift new file mode 100644 index 000000000..6edf0d7d7 --- /dev/null +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift @@ -0,0 +1,75 @@ +// +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import Combine +import Foundation + +typealias TimelineMediaPreviewViewModelType = StateStoreViewModel + +class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { + private let mediaProvider: MediaProviderProtocol + private let userIndicatorController: UserIndicatorControllerProtocol + + private let actionsSubject: PassthroughSubject = .init() + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(previewItems: [EventBasedMessageTimelineItemProtocol], mediaProvider: MediaProviderProtocol, userIndicatorController: UserIndicatorControllerProtocol) { + self.mediaProvider = mediaProvider + + // We might not want to inject this, instead creating a new instance with a custom position and colour scheme 🤔 + self.userIndicatorController = userIndicatorController + + super.init(initialViewState: TimelineMediaPreviewViewState(previewItems: previewItems.map(TimelineMediaPreviewItem.init)), mediaProvider: mediaProvider) + } + + override func process(viewAction: TimelineMediaPreviewViewAction) { + switch viewAction { + case .viewInTimeline: + actionsSubject.send(.viewInTimeline) + case .redact: + break // Do it here?? + } + } + + func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async { + state.currentItem = previewItem + + if previewItem.fileHandle == nil, let source = previewItem.mediaSource { + showDownloadingIndicator(itemID: previewItem.id) + defer { hideDownloadingIndicator(itemID: previewItem.id) } + + switch await mediaProvider.loadFileFromSource(source) { + case .success(let handle): + previewItem.fileHandle = handle + actionsSubject.send(.loadedMediaFile) + case .failure(let error): + MXLog.error("Failed loading media: \(error)") + #warning("Show the error!") + } + } + } + + private func showDownloadingIndicator(itemID: TimelineItemIdentifier) { + let indicatorID = makeDownloadIndicatorID(itemID: itemID) + userIndicatorController.submitIndicator(UserIndicator(id: indicatorID, + type: .toast(progress: .indeterminate), + title: L10n.commonDownloading, + persistent: true), + delay: .seconds(0.1)) // Don't show the indicator when the SDK loads the file from the store. + } + + private func hideDownloadingIndicator(itemID: TimelineItemIdentifier) { + let indicatorID = makeDownloadIndicatorID(itemID: itemID) + userIndicatorController.retractIndicatorWithId(indicatorID) + } + + private func makeDownloadIndicatorID(itemID: TimelineItemIdentifier) -> String { + "\(TimelineMediaPreviewViewModel.self)-Download-\(itemID.uniqueID.id)" + } +} diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaQuickLook.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaQuickLook.swift new file mode 100644 index 000000000..4a186ec75 --- /dev/null +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaQuickLook.swift @@ -0,0 +1,155 @@ +// +// Copyright 2022-2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import Combine +import QuickLook +import SwiftUI + +extension View { + /// Preview a media file using a QuickLook Preview Controller. The preview is interactive with + /// the dismiss gesture working as expected if it was presented from UIKit. + func timelineMediaQuickLook(viewModel: Binding) -> some View { + modifier(TimelineMediaQuickLookModifier(viewModel: viewModel)) + } +} + +private struct TimelineMediaQuickLookModifier: ViewModifier { + @Binding var viewModel: TimelineMediaPreviewViewModel? + + @State private var dismissalPublisher = PassthroughSubject() + + func body(content: Content) -> some View { + content.background { + if let viewModel { + EmbeddedQuickLookPresenter(viewModel: viewModel, dismissalPublisher: dismissalPublisher) { + self.viewModel = nil + } + } else { + // Work around QLPreviewController dismissal issues, see below. + let _ = dismissalPublisher.send(()) + } + } + } +} + +/// When this view is put in the background of a SwiftUI view hierarchy, +/// it will present a QLPreviewController on top of the entire app. +private struct EmbeddedQuickLookPresenter: UIViewControllerRepresentable { + let viewModel: TimelineMediaPreviewViewModel + let dismissalPublisher: PassthroughSubject + let onDismiss: () -> Void + + func makeUIViewController(context: Context) -> PresentingController { + PresentingController(viewModel: viewModel, dismissalPublisher: dismissalPublisher, onDismiss: onDismiss) + } + + func updateUIViewController(_ uiViewController: PresentingController, context: Context) { } + + /// A view controller that hosts the QuickLook preview. + /// + /// This wrapper somehow allows the preview controller to do presentation/dismissal + /// animations and interactions which don't work if you represent it directly to SwiftUI 🤷‍♂️ + class PresentingController: UIViewController, QLPreviewControllerDelegate { + private let previewController: QLPreviewController + private let sourceView = UIView() + + private var hasPresented = false + private let onDismiss: () -> Void + private var dismissalObserver: AnyCancellable? + + init(viewModel: TimelineMediaPreviewViewModel, + dismissalPublisher: PassthroughSubject, + onDismiss: @escaping () -> Void) { + previewController = TimelineMediaPreviewController(viewModel: viewModel) + self.onDismiss = onDismiss + + super.init(nibName: nil, bundle: nil) + + // The QLPreviewController will not automatically dismiss itself when the underlying view is removed + // (e.g. switching rooms from a notification) and it continues to hold on to the whole hierarcy. + // Manually tell it to dismiss itself here. + dismissalObserver = dismissalPublisher.sink { [weak self] _ in + // Dispatching on main.async with weak self we avoid doing an extra dismiss if the view is presented on top of another modal + DispatchQueue.main.async { [weak self] in + self?.dismiss(animated: true) + } + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + view.backgroundColor = .clear + view.addSubview(sourceView) + + sourceView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + sourceView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + sourceView.centerYAnchor.constraint(equalTo: view.bottomAnchor), + sourceView.widthAnchor.constraint(equalToConstant: 200), + sourceView.heightAnchor.constraint(equalToConstant: 200) + ]) + } + + // Don't use viewWillAppear due to the following warning: + // Presenting view controller from detached view controller is not supported, + // and may result in incorrect safe area insets and a corrupt root presentation. Make sure is in + // the view controller hierarchy before presenting from it. Will become a hard exception in a future release. + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + guard !hasPresented else { return } + + previewController.delegate = self + present(previewController, animated: true) + hasPresented = true + } + + // MARK: QLPreviewControllerDelegate + + func previewController(_ controller: QLPreviewController, transitionViewFor item: any QLPreviewItem) -> UIView? { + sourceView + } + + func previewControllerDidDismiss(_ controller: QLPreviewController) { + onDismiss() + } + } +} + +// MARK: - Previews + +struct TimelineMediaQuickLook_Previews: PreviewProvider { + static let viewModel = makeViewModel() + + static var previews: some View { + EmbeddedQuickLookPresenter(viewModel: viewModel, dismissalPublisher: .init()) { } + } + + static func makeViewModel() -> TimelineMediaPreviewViewModel { + let previewItem = FileRoomTimelineItem(id: .randomEvent, + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + isThreaded: false, + sender: .init(id: "", displayName: "Sally Sanderson"), + content: .init(filename: "Important document.pdf", + caption: "A caption goes right here.", + source: try? .init(url: .mockMXCFile, mimeType: nil), + fileSize: 3 * 1024 * 1024, + thumbnailSource: nil, + contentType: .pdf)) + + return TimelineMediaPreviewViewModel(previewItems: [previewItem], + mediaProvider: MediaProviderMock(configuration: .init()), + userIndicatorController: UserIndicatorControllerMock()) + } +} diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift new file mode 100644 index 000000000..09e714545 --- /dev/null +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift @@ -0,0 +1,167 @@ +// +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import Compound +import SwiftUI + +struct TimelineMediaPreviewDetailsView: View { + @ObservedObject var context: TimelineMediaPreviewViewModel.Context + + @State private var isPresentingRedactConfirmation = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + details + actions + } + .frame(maxWidth: .infinity) + } + .padding(.top, 19) // For the drag indicator + .sheet(isPresented: $isPresentingRedactConfirmation) { + TimelineMediaPreviewRedactConfirmationView(context: context) + } + } + + private var details: some View { + VStack(alignment: .leading, spacing: 24) { + DetailsRow(title: L10n.screenMediaDetailsUploadedBy) { + HStack(spacing: 8) { + if let sender = context.viewState.currentItem?.sender { + LoadableAvatarImage(url: sender.avatarURL, + name: sender.displayName, + contentID: sender.id, + avatarSize: .user(on: .mediaPreviewDetails), + mediaProvider: context.mediaProvider) + + VStack(alignment: .leading, spacing: 0) { + if let displayName = sender.displayName { + Text(displayName) + .font(.compound.bodyMDSemibold) + .foregroundStyle(.compound.decorativeColor(for: sender.id).text) + } + + Text(sender.id) + .font(.compound.bodySM) + .foregroundStyle(.compound.textSecondary) + } + } else { + Text(L10n.commonLoading) + .font(.compound.bodyMD) + .foregroundStyle(.compound.textPrimary) + } + } + } + + DetailsRow(title: L10n.screenMediaDetailsUploadedOn) { + Text(context.viewState.currentItem?.timestamp.formatted(date: .abbreviated, time: .shortened) ?? "") + .font(.compound.bodyMD) + .foregroundStyle(.compound.textPrimary) + } + + DetailsRow(title: L10n.screenMediaDetailsFilename) { + Text(context.viewState.currentItem?.filename ?? "") + .font(.compound.bodyMD) + .foregroundStyle(.compound.textPrimary) + } + + if let contentType = context.viewState.currentItem?.contentType { + DetailsRow(title: L10n.screenMediaDetailsFileFormat) { + Group { + if let fileSize = context.viewState.currentItem?.fileSize { + Text(contentType) + Text(" – ") + Text(UInt(fileSize).formatted(.byteCount(style: .file))) + } else { + Text(contentType) + } + } + .font(.compound.bodyMD) + .foregroundStyle(.compound.textPrimary) + } + } + } + .padding(.top, 24) + .padding(.bottom, 32) + .padding(.horizontal, 16) + } + + private var actions: some View { + VStack(spacing: 0) { + Divider() + .background(Color.compound.bgSubtlePrimary) + + Button { context.send(viewAction: .viewInTimeline) } label: { + Label(L10n.actionViewInTimeline, icon: \.visibilityOn) + } + .buttonStyle(.menuSheet) + + Divider() + .background(Color.compound.bgSubtlePrimary) + + Button(role: .destructive) { isPresentingRedactConfirmation = true } label: { + Label(L10n.actionRemove, icon: \.delete) + } + .buttonStyle(.menuSheet) + } + } + + private struct DetailsRow: View { + let title: String + let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.compound.bodyXS) + .foregroundStyle(.compound.textSecondary) + .textCase(.uppercase) + + content() + } + } + } +} + +// MARK: - Previews + +import UniformTypeIdentifiers + +struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePreview { + static let viewModel = makeViewModel(contentType: .jpeg) + static let unknownTypeViewModel = makeViewModel() + + static var previews: some View { + TimelineMediaPreviewDetailsView(context: viewModel.context) + .previewDisplayName("Image") + TimelineMediaPreviewDetailsView(context: unknownTypeViewModel.context) + .previewDisplayName("Unknown type") + } + + static func makeViewModel(contentType: UTType? = nil) -> TimelineMediaPreviewViewModel { + let previewItems = [ + ImageRoomTimelineItem(id: .randomEvent, + timestamp: .mock, + isOutgoing: false, + isEditable: true, + canBeRepliedTo: true, + isThreaded: false, + sender: .init(id: "@alice:matrix.org", + displayName: "Alice", + avatarURL: .mockMXCUserAvatar), + content: .init(filename: "Amazing Image.jpeg", + imageInfo: .mockImage, + thumbnailInfo: .mockThumbnail, + contentType: contentType)) + ] + + let viewModel = TimelineMediaPreviewViewModel(previewItems: previewItems, + mediaProvider: MediaProviderMock(configuration: .init()), + userIndicatorController: UserIndicatorControllerMock()) + viewModel.state.currentItem = viewModel.state.previewItems.first + + return viewModel + } +} diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift new file mode 100644 index 000000000..1e000285e --- /dev/null +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift @@ -0,0 +1,150 @@ +// +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import Compound +import SwiftUI + +struct TimelineMediaPreviewRedactConfirmationView: View { + @Environment(\.dismiss) private var dismiss + + @ObservedObject var context: TimelineMediaPreviewViewModel.Context + + var body: some View { + ScrollView { + VStack(spacing: 0) { + header + preview + buttons + } + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + .padding(.top, 19) // For the drag indicator + .presentationBackground(.compound.bgCanvasDefault) + .preferredColorScheme(.dark) + } + + private var header: some View { + VStack(spacing: 16) { + BigIcon(icon: \.delete, style: .alertSolid) + + VStack(spacing: 8) { + Text(L10n.screenMediaDetailsRedactConfirmationTitle) + .font(.compound.headingMDBold) + .foregroundStyle(.compound.textPrimary) + .multilineTextAlignment(.center) + + Text(L10n.screenMediaDetailsRedactConfirmationMessage) + .font(.compound.bodyMD) + .foregroundStyle(.compound.textSecondary) + .multilineTextAlignment(.center) + } + } + .padding(.top, 24) + .padding(.bottom, 32) + .padding(.horizontal, 24) + } + + @ViewBuilder + private var preview: some View { + if let currentItem = context.viewState.currentItem { + HStack(spacing: 12) { + if let mediaSource = currentItem.thumbnailMediaSource { + Color.clear + .scaledFrame(size: 40) + .background { + LoadableImage(mediaSource: mediaSource, + mediaType: .timelineItem(uniqueID: currentItem.id.uniqueID.id), + blurhash: currentItem.blurhash, + mediaProvider: context.mediaProvider) { + Color.compound.bgSubtleSecondary + } + .aspectRatio(contentMode: .fill) + } + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + VStack(alignment: .leading, spacing: 4) { + Text(currentItem.filename ?? "") + .font(.compound.bodyMD) + .foregroundStyle(.compound.textPrimary) + + if let contentType = currentItem.contentType { + Group { + if let fileSize = currentItem.fileSize { + Text(contentType) + Text(" – ") + Text(UInt(fileSize).formatted(.byteCount(style: .file))) + } else { + Text(contentType) + } + } + .font(.compound.bodySM) + .foregroundStyle(.compound.textSecondary) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.bottom, 40) + } + } + + private var buttons: some View { + VStack(spacing: 16) { + Button(L10n.actionRemove, role: .destructive) { + context.send(viewAction: .redact) + } + .buttonStyle(.compound(.primary)) + + Button { + dismiss() + } label: { + Text(L10n.actionCancel) + .padding(.vertical, 14) + } + .buttonStyle(.compound(.plain)) + } + .padding(.bottom, 16) + .padding(.horizontal, 16) + } +} + +// MARK: - Previews + +import UniformTypeIdentifiers + +struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, TestablePreview { + static let viewModel = makeViewModel(contentType: .jpeg) + + static var previews: some View { + TimelineMediaPreviewRedactConfirmationView(context: viewModel.context) + } + + static func makeViewModel(contentType: UTType? = nil) -> TimelineMediaPreviewViewModel { + let previewItems = [ + ImageRoomTimelineItem(id: .randomEvent, + timestamp: .mock, + isOutgoing: false, + isEditable: true, + canBeRepliedTo: true, + isThreaded: false, + sender: .init(id: "@alice:matrix.org", + displayName: "Alice", + avatarURL: .mockMXCUserAvatar), + content: .init(filename: "Amazing Image.jpeg", + imageInfo: .mockImage, + thumbnailInfo: .mockThumbnail, + contentType: contentType)) + ] + + let viewModel = TimelineMediaPreviewViewModel(previewItems: previewItems, + mediaProvider: MediaProviderMock(configuration: .init()), + userIndicatorController: UserIndicatorControllerMock()) + viewModel.state.currentItem = viewModel.state.previewItems.first + + return viewModel + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 8596be01a..595718188 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -96,6 +96,7 @@ struct RoomScreen: View { .environmentObject(timelineContext) } .interactiveQuickLook(item: $timelineContext.mediaPreviewItem) + .timelineMediaQuickLook(viewModel: $timelineContext.mediaPreviewViewModel) .track(screen: .Room) .onDrop(of: ["public.item", "public.file-url"], isTargeted: $dragOver) { providers -> Bool in guard let provider = providers.first, diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index 168c6bcc3..2f1cf70fb 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -127,6 +127,8 @@ struct TimelineViewStateBindings { /// A media item that will be previewed with QuickLook. var mediaPreviewItem: MediaPreviewItem? + var mediaPreviewViewModel: TimelineMediaPreviewViewModel? + var alertInfo: AlertInfo? var debugInfo: TimelineItemDebugInfo? diff --git a/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift b/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift index 7d122c806..4dc27d611 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift @@ -221,8 +221,9 @@ struct VideoInfoProxy: Hashable { private(set) var size: CGSize? private(set) var aspectRatio: CGFloat? private(set) var mimeType: String? + private(set) var fileSize: UInt? - init(source: MediaSource, duration: TimeInterval, width: UInt64?, height: UInt64?, mimeType: String?) { + init(source: MediaSource, duration: TimeInterval, width: UInt64?, height: UInt64?, mimeType: String?, fileSize: UInt?) { self.source = MediaSourceProxy(source: source, mimeType: mimeType) self.duration = duration @@ -230,16 +231,18 @@ struct VideoInfoProxy: Hashable { size = mediaInfo.size aspectRatio = mediaInfo.aspectRatio self.mimeType = mediaInfo.mimeType + self.fileSize = fileSize } // MARK: - Mocks - private init(source: MediaSourceProxy, duration: TimeInterval, size: CGSize?, aspectRatio: CGFloat?, mimeType: String?) { + private init(source: MediaSourceProxy, duration: TimeInterval, size: CGSize?, aspectRatio: CGFloat?, mimeType: String?, fileSize: UInt?) { self.source = source self.duration = duration self.size = size self.aspectRatio = aspectRatio self.mimeType = mimeType + self.fileSize = fileSize } static var mockVideo: VideoInfoProxy { @@ -251,7 +254,8 @@ struct VideoInfoProxy: Hashable { duration: 100, size: .init(width: 1920, height: 1080), aspectRatio: 1.78, - mimeType: nil) + mimeType: nil, + fileSize: 45_167_000) } } @@ -260,35 +264,38 @@ struct ImageInfoProxy: Hashable { private(set) var size: CGSize? private(set) var aspectRatio: CGFloat? private(set) var mimeType: String? + private(set) var fileSize: UInt? - init?(source: MediaSource?, width: UInt64?, height: UInt64?, mimeType: String?) { + init?(source: MediaSource?, width: UInt64?, height: UInt64?, mimeType: String?, fileSize: UInt?) { guard let source else { return nil } - self.init(source: .init(source: source, mimeType: mimeType), width: width, height: height, mimeType: mimeType) + self.init(source: .init(source: source, mimeType: mimeType), width: width, height: height, mimeType: mimeType, fileSize: fileSize) } - init(source: MediaSource, width: UInt64?, height: UInt64?, mimeType: String?) { - self.init(source: .init(source: source, mimeType: mimeType), width: width, height: height, mimeType: mimeType) + init(source: MediaSource, width: UInt64?, height: UInt64?, mimeType: String?, fileSize: UInt?) { + self.init(source: .init(source: source, mimeType: mimeType), width: width, height: height, mimeType: mimeType, fileSize: fileSize) } - init(source: MediaSourceProxy, width: UInt64?, height: UInt64?, mimeType: String?) { + init(source: MediaSourceProxy, width: UInt64?, height: UInt64?, mimeType: String?, fileSize: UInt?) { self.source = source let mediaInfo = MediaInfoProxy(width: width, height: height, mimeType: mimeType) size = mediaInfo.size aspectRatio = mediaInfo.aspectRatio self.mimeType = mediaInfo.mimeType + self.fileSize = fileSize } // MARK: - Mocks - private init(source: MediaSourceProxy, size: CGSize?, aspectRatio: CGFloat?) { + private init(source: MediaSourceProxy, size: CGSize?, aspectRatio: CGFloat?, fileSize: UInt?) { self.source = source self.size = size self.aspectRatio = aspectRatio mimeType = source.mimeType + self.fileSize = fileSize } static var mockImage: ImageInfoProxy { @@ -296,7 +303,7 @@ struct ImageInfoProxy: Hashable { fatalError("Invalid mock media source URL") } - return .init(source: mediaSource, size: .init(width: 2730, height: 2048), aspectRatio: 4 / 3) + return .init(source: mediaSource, size: .init(width: 2730, height: 2048), aspectRatio: 4 / 3, fileSize: 717_000) } static var mockThumbnail: ImageInfoProxy { @@ -304,7 +311,7 @@ struct ImageInfoProxy: Hashable { fatalError("Invalid mock media source URL") } - return .init(source: mediaSource, size: .init(width: 800, height: 600), aspectRatio: 4 / 3) + return .init(source: mediaSource, size: .init(width: 800, height: 600), aspectRatio: 4 / 3, fileSize: 84000) } static var mockVideoThumbnail: ImageInfoProxy { @@ -312,11 +319,11 @@ struct ImageInfoProxy: Hashable { fatalError("Invalid mock media source URL") } - return .init(source: mediaSource, size: .init(width: 800, height: 450), aspectRatio: 16 / 9) + return .init(source: mediaSource, size: .init(width: 800, height: 450), aspectRatio: 16 / 9, fileSize: 98000) } } -struct MediaInfoProxy: Hashable { +private struct MediaInfoProxy: Hashable { private(set) var size: CGSize? private(set) var mimeType: String? private(set) var aspectRatio: CGFloat? diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 58e785c68..90d656b53 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -116,7 +116,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { _ info: MatrixRustSDK.ImageInfo, _ mediaSource: MediaSource, _ isOutgoing: Bool) -> RoomTimelineItemProtocol { - let imageInfo = ImageInfoProxy(source: mediaSource, width: info.width, height: info.height, mimeType: info.mimetype) + let imageInfo = ImageInfoProxy(source: mediaSource, width: info.width, height: info.height, mimeType: info.mimetype, fileSize: info.size.map(UInt.init)) return StickerRoomTimelineItem(id: eventItemProxy.id, body: body, @@ -518,12 +518,14 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { let thumbnailInfo = ImageInfoProxy(source: messageContent.info?.thumbnailSource, width: messageContent.info?.thumbnailInfo?.width, height: messageContent.info?.thumbnailInfo?.height, - mimeType: messageContent.info?.thumbnailInfo?.mimetype) + mimeType: messageContent.info?.thumbnailInfo?.mimetype, + fileSize: messageContent.info?.size.map(UInt.init)) let imageInfo = ImageInfoProxy(source: messageContent.source, width: messageContent.info?.width, height: messageContent.info?.height, - mimeType: messageContent.info?.mimetype) + mimeType: messageContent.info?.mimetype, + fileSize: messageContent.info?.size.map(UInt.init)) return .init(filename: messageContent.filename, caption: messageContent.caption, @@ -542,13 +544,15 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { let thumbnailInfo = ImageInfoProxy(source: messageContent.info?.thumbnailSource, width: messageContent.info?.thumbnailInfo?.width, height: messageContent.info?.thumbnailInfo?.height, - mimeType: messageContent.info?.thumbnailInfo?.mimetype) + mimeType: messageContent.info?.thumbnailInfo?.mimetype, + fileSize: messageContent.info?.size.map(UInt.init)) let videoInfo = VideoInfoProxy(source: messageContent.source, duration: messageContent.info?.duration ?? 0, width: messageContent.info?.width, height: messageContent.info?.height, - mimeType: messageContent.info?.mimetype) + mimeType: messageContent.info?.mimetype, + fileSize: messageContent.info?.size.map(UInt.init)) return .init(filename: messageContent.filename, caption: messageContent.caption, diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index a543332db..b7c803082 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -911,6 +911,18 @@ extension PreviewTests { } } + func test_timelineMediaPreviewDetailsView() { + for preview in TimelineMediaPreviewDetailsView_Previews._allPreviews { + assertSnapshots(matching: preview) + } + } + + func test_timelineMediaPreviewRedactConfirmationView() { + for preview in TimelineMediaPreviewRedactConfirmationView_Previews._allPreviews { + assertSnapshots(matching: preview) + } + } + func test_timelineReactionView() { for preview in TimelineReactionView_Previews._allPreviews { assertSnapshots(matching: preview) diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-en-GB.Image.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-en-GB.Image.png new file mode 100644 index 000000000..4b39f40b1 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-en-GB.Image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8645fdbed1a4f638d60adba24276b286e226da662f11c47bddfa2b9b68bed366 +size 124470 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-en-GB.Unknown-type.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-en-GB.Unknown-type.png new file mode 100644 index 000000000..a9a9e838b --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-en-GB.Unknown-type.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98a68098a2b71bc2fe0d5a09397e9146134f90933f29d8632ea64bccabdb9398 +size 115786 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-pseudo.Image.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-pseudo.Image.png new file mode 100644 index 000000000..f0b0f8733 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-pseudo.Image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5aa25d8c1b9da3725d4e52cf678a1b9b102c7ecfd1398f592cec16cafeae72ba +size 124094 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-pseudo.Unknown-type.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-pseudo.Unknown-type.png new file mode 100644 index 000000000..19b05c085 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPad-pseudo.Unknown-type.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95120569dffed5d1fe5be2022dfa5b90c31bb81c67faf7610274142d57e2aee2 +size 114855 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-en-GB.Image.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-en-GB.Image.png new file mode 100644 index 000000000..196a6edc3 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-en-GB.Image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7f26f557f9c2698e31764fa51e33208862f95727881617a818352e646c10383 +size 78566 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-en-GB.Unknown-type.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-en-GB.Unknown-type.png new file mode 100644 index 000000000..9e5ec47aa --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-en-GB.Unknown-type.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a3840a624858eb29852450b253f672d4911fb8b9cf43424f43198e65df3fa30d +size 70977 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-pseudo.Image.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-pseudo.Image.png new file mode 100644 index 000000000..9753fb5e3 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-pseudo.Image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80a4ca380d19314f9fabc222ce47aa2cb99e06df2a659703432cbd5e76385f13 +size 76284 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-pseudo.Unknown-type.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-pseudo.Unknown-type.png new file mode 100644 index 000000000..ac809f280 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewDetailsView-iPhone-16-pseudo.Unknown-type.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d268bbbaf1996132ff6ed23ba92afcec1b5e6307aec34b17e40edb549754c996 +size 68547 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPad-en-GB.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPad-en-GB.1.png new file mode 100644 index 000000000..032179491 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPad-en-GB.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5c3a13b9e1975255bb09e0db46b67ea70fe6d88d5ce846a0d8e76b67cbee59f2 +size 118791 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPad-pseudo.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPad-pseudo.1.png new file mode 100644 index 000000000..e45821f9f --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPad-pseudo.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:953d61772a749a0aeca48c1ffaf5769ef9930b030c0804bfc7c39c9a279b5a0d +size 127235 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPhone-16-en-GB.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPhone-16-en-GB.1.png new file mode 100644 index 000000000..27e1fcfa1 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPhone-16-en-GB.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf416680c94639c9f1f3002b210c8d8871ef2268acb2f8f89b8fb14c530b4969 +size 73718 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPhone-16-pseudo.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPhone-16-pseudo.1.png new file mode 100644 index 000000000..b2dca03b7 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineMediaPreviewRedactConfirmationView-iPhone-16-pseudo.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:596cb5285a11299aa7cdbf2b55a44dfd45909d7c5ba95d90c34bc30929049a95 +size 86526 diff --git a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift new file mode 100644 index 000000000..f8dd1d153 --- /dev/null +++ b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift @@ -0,0 +1,56 @@ +// +// Copyright 2022-2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +@testable import ElementX + +import Combine +import MatrixRustSDK +import XCTest + +@MainActor +class TimelineMediaPreviewViewModelTests: XCTestCase { + var viewModel: TimelineMediaPreviewViewModel! + var context: TimelineMediaPreviewViewModel.Context { viewModel.context } + var mediaProvider: MediaProviderMock! + + func testLoadingItem() async throws { + // Given a fresh view model. + setupViewModel() + XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled) + XCTAssertNil(context.viewState.currentItem) + + // When setting the current item. + await viewModel.updateCurrentItem(context.viewState.previewItems[0]) + + // Then the view model should load the item and update its view state. + XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled) + XCTAssertEqual(context.viewState.currentItem, context.viewState.previewItems[0]) + } + + // MARK: - Helpers + + private func setupViewModel() { + let previewItems = [ + ImageRoomTimelineItem(id: .randomEvent, + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + isThreaded: false, + sender: .init(id: "", displayName: "Sally Sanderson"), + content: .init(filename: "Amazing image.jpeg", + caption: "A caption goes right here.", + imageInfo: .mockImage, + thumbnailInfo: .mockThumbnail)) + ] + + mediaProvider = MediaProviderMock(configuration: .init()) + viewModel = TimelineMediaPreviewViewModel(previewItems: previewItems, + mediaProvider: mediaProvider, + userIndicatorController: UserIndicatorControllerMock()) + } +} diff --git a/UnitTests/Sources/VoiceMessageMediaManagerTests.swift b/UnitTests/Sources/VoiceMessageMediaManagerTests.swift index b9dba4287..92f4b0844 100644 --- a/UnitTests/Sources/VoiceMessageMediaManagerTests.swift +++ b/UnitTests/Sources/VoiceMessageMediaManagerTests.swift @@ -22,6 +22,8 @@ class VoiceMessageMediaManagerTests: XCTestCase { override func setUp() async throws { voiceMessageCache = VoiceMessageCacheMock() mediaProvider = MediaProviderMock(configuration: .init()) + mediaProvider.loadFileFromSourceFilenameClosure = nil + mediaProvider.loadFileFromSourceFilenameReturnValue = .failure(.failedRetrievingFile) voiceMessageMediaManager = VoiceMessageMediaManager(mediaProvider: mediaProvider, voiceMessageCache: voiceMessageCache) }