diff --git a/.swiftlint.yml b/.swiftlint.yml index d8c789ffa0..0825fb8807 100755 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -9,6 +9,7 @@ opt_in_rules: - private_action - explicit_init - shorthand_optional_binding + - trailing_closure included: - ElementX diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 0f95cf1b46..bb143c3af4 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -637,6 +637,7 @@ 847DE3A7EB9FCA2C429C6E85 /* PINTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D1D4A6D451F43A03CACD01D /* PINTextField.swift */; }; 84C631E734FD2555B39B681C /* RoomRolesAndPermissionsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48FEFF746DB341CDB18D7AAA /* RoomRolesAndPermissionsScreenViewModelTests.swift */; }; 84CAE3E96D93194DA06B9194 /* CallScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9AD6AE5FC868962F090740 /* CallScreenViewModelProtocol.swift */; }; + 84E514915DF0C168B08A3A0A /* MediaEventsTimelineFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2178B951602AA921A5FD9DC8 /* MediaEventsTimelineFlowCoordinator.swift */; }; 84EFCB95F9DA2979C8042B26 /* UITestsSignalling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F0192CE2F891141A25B49F /* UITestsSignalling.swift */; }; 8544657DEEE717ED2E22E382 /* RoomNotificationSettingsProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D1BAA90F3A073D91B4F16B /* RoomNotificationSettingsProxyMock.swift */; }; 854E82E064BA53CD0BC45600 /* LocationRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6613DE16AD26B3A74DA1F5 /* LocationRoomTimelineItemContent.swift */; }; @@ -885,6 +886,7 @@ B796A25F282C0A340D1B9C12 /* ImageRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B5EDCD05D50BA9B815C66C /* ImageRoomTimelineItemContent.swift */; }; B79E8AB83EBBDCD476D0362F /* PollFormScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622EC7898469BB1D0881CDD /* PollFormScreen.swift */; }; B7C9E07F4F9CCC8DD7156A20 /* CallScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28146817C61423CACCF942F5 /* CallScreenModels.swift */; }; + B7F58D6903F9D509EDAB9E4F /* MediaEventsTimelineScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7033218DA395B003F7AB29A2 /* MediaEventsTimelineScreenModels.swift */; }; B818580464CFB5400A3EF6AE /* TimelineModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029D5701F80A9AF7167BB4D0 /* TimelineModels.swift */; }; B855AF29D7D8FC8DAAA73D4A /* test_voice_message.m4a in Resources */ = {isa = PBXBuildFile; fileRef = DCA2D836BD10303F37FAAEED /* test_voice_message.m4a */; }; B879446FD8E65A711EF8F9F7 /* AdvancedSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63B69F9A2BC74DD40DC75C8 /* AdvancedSettingsScreenViewModel.swift */; }; @@ -915,6 +917,7 @@ BDED6DA7AD1E76018C424143 /* LegalInformationScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C34667458773B02AB5FB0B2 /* LegalInformationScreenViewModel.swift */; }; BE8E5985771DF9137C6CE89A /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 077B01C13BBA2996272C5FB5 /* ProcessInfo.swift */; }; BEA646DF302711A753F0D420 /* MapTilerStyleBuilderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 225EFCA26877E75CDFE7F48D /* MapTilerStyleBuilderProtocol.swift */; }; + BEC6DFEA506085D3027E353C /* MediaEventsTimelineScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 002399C6CB875C4EBB01CBC0 /* MediaEventsTimelineScreen.swift */; }; BFEB24336DFD5F196E6F3456 /* IntentionalMentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DF5CBAF69BDF5DF31C661E1 /* IntentionalMentions.swift */; }; C0090506A52A1991BAF4BA68 /* NotificationSettingsChatType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07579F9C29001E40715F3014 /* NotificationSettingsChatType.swift */; }; C022284E2774A5E1EF683B4D /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DF593C3F7AF4B2FBAEB05D /* FileManager.swift */; }; @@ -922,6 +925,7 @@ C08AAE7563E0722C9383F51C /* RoomMembersListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B8E176484A89BAC389D4076 /* RoomMembersListScreen.swift */; }; C0B97FFEC0083F3A36609E61 /* TimelineItemMacContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = A243A6E6207297123E60DE48 /* TimelineItemMacContextMenu.swift */; }; C11939FDC40716C4387275A4 /* NotificationSettingsEditScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8544F7058D31DBEB8DBFF0F5 /* NotificationSettingsEditScreenViewModelTests.swift */; }; + C11D4A49DC29D89CE2BB31B8 /* MediaEventsTimelineScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 976ED77B772F50C4BAD757E7 /* MediaEventsTimelineScreenViewModel.swift */; }; C13128AAA787A4C2CBE4EE82 /* MessageForwardingScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC10CCC8D68B863E20660DBC /* MessageForwardingScreenViewModelProtocol.swift */; }; C1429699A6A5BB09A25775C1 /* AudioPlayerStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89233612A8632AD7E2803620 /* AudioPlayerStateTests.swift */; }; C1910A16BDF131FECA77BE22 /* EmojiPickerScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEA38B9851CFCC4D67F5587D /* EmojiPickerScreenCoordinator.swift */; }; @@ -960,6 +964,7 @@ C8BD80891BAD688EF2C15CDB /* MediaUploadPreviewScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74DD0855F2F76D47E5555082 /* MediaUploadPreviewScreenCoordinator.swift */; }; C8C7AF33AADF88B306CD2695 /* QRCodeLoginService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4427AF4B7FB7EF3E3D424C7 /* QRCodeLoginService.swift */; }; C8E0FA0FF2CD6613264FA6B9 /* MessageForwardingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFEA446F8618DBA79A9239CC /* MessageForwardingScreen.swift */; }; + C8E1E4E06B7C7A3A8246FC9B /* MediaEventsTimelineScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8512B82404B1751D0BCC82D2 /* MediaEventsTimelineScreenCoordinator.swift */; }; C915347779B3C7FDD073A87A /* AVMetadataMachineReadableCodeObjectExtensionsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93E1FF0DFBB3768F79FDBF6D /* AVMetadataMachineReadableCodeObjectExtensionsTest.swift */; }; C969A62F3D9F14318481A33B /* KnockedRoomProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 858DA81F2ACF484B7CAD6AE4 /* KnockedRoomProxy.swift */; }; C97325EFDCCEE457432A9E82 /* MessageText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E0B4A34E69BD2132BEC521 /* MessageText.swift */; }; @@ -1203,6 +1208,7 @@ FD29471C72872F8B7580E3E1 /* KeychainControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39C0D861FC397AC34BCF089E /* KeychainControllerMock.swift */; }; FD4C21F8DA1E273DE94FCD1A /* NotificationItemProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B927CF5EF7FCCDA5EDC474B /* NotificationItemProxyProtocol.swift */; }; FD762761C5D0C30E6255C3D8 /* ServerConfirmationScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */; }; + FD9777315A5D9CDC47458AD1 /* MediaEventsTimelineScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A175D0FDEDBFA44C47FE13AE /* MediaEventsTimelineScreenViewModelProtocol.swift */; }; FDC67E8C0EDCB00ABC66C859 /* landscape_test_video.mov in Resources */ = {isa = PBXBuildFile; fileRef = 78BBDF7A05CF53B5CDC13682 /* landscape_test_video.mov */; }; FDD5B4B616D9FF4DE3E9A418 /* QRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92DB574F954CC2B40F7BE892 /* QRCodeScannerView.swift */; }; FDE47D4686BA0F86BB584633 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = CAA3B9DF998B397C9EE64E8B /* Collections */; }; @@ -1287,6 +1293,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 002399C6CB875C4EBB01CBC0 /* MediaEventsTimelineScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaEventsTimelineScreen.swift; sourceTree = ""; }; 00245D40CD90FD71D6A05239 /* EmojiPickerScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreen.swift; sourceTree = ""; }; 00AFC5F08734C2EA4EE79C59 /* IdentityConfirmationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityConfirmationScreen.swift; sourceTree = ""; }; 00E5B2CBEF8F96424F095508 /* RoomDetailsEditScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModelTests.swift; sourceTree = ""; }; @@ -1445,6 +1452,7 @@ 20E69F67D2A70ABD08CA6D54 /* NotificationPermissionsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPermissionsScreenViewModelProtocol.swift; sourceTree = ""; }; 2141693488CE5446BB391964 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; 216F0DDC98F2A2C162D09C28 /* FileRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileRoomTimelineItemContent.swift; sourceTree = ""; }; + 2178B951602AA921A5FD9DC8 /* MediaEventsTimelineFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaEventsTimelineFlowCoordinator.swift; sourceTree = ""; }; 218AB05B4E3889731959C5F1 /* EventBasedTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBasedTimelineItemProtocol.swift; sourceTree = ""; }; 21BA866267F84BF4350B0CB7 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "pt-BR"; path = "pt-BR.lproj/Localizable.stringsdict"; sourceTree = ""; }; 21DD8599815136EFF5B73F38 /* UserFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFlowTests.swift; sourceTree = ""; }; @@ -1802,6 +1810,7 @@ 6FA38E813BE14149F173F461 /* PinnedEventsBannerStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedEventsBannerStateTests.swift; sourceTree = ""; }; 6FC5015B9634698BDB8701AF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = it; path = it.lproj/Localizable.stringsdict; sourceTree = ""; }; 6FC8B21E86B137BE4E91F82A /* ElementCallServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementCallServiceProtocol.swift; sourceTree = ""; }; + 7033218DA395B003F7AB29A2 /* MediaEventsTimelineScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaEventsTimelineScreenModels.swift; sourceTree = ""; }; 7061BE2C0BF427C38AEDEF5E /* SecureBackupRecoveryKeyScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupRecoveryKeyScreenViewModel.swift; sourceTree = ""; }; 70C86696AC9521F8ED88FBEB /* MediaUploadPreviewScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadPreviewScreen.swift; sourceTree = ""; }; 713B48DBF65DE4B0DD445D66 /* ReportContentScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -1892,6 +1901,7 @@ 84A87D0471D438A233C2CF4A /* RoomMemberDetailsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberDetailsScreenViewModel.swift; sourceTree = ""; }; 84AF32E4136FD6F159D86C2C /* RoomDirectorySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDirectorySearchView.swift; sourceTree = ""; }; 84B7A28A6606D58D1E38C55A /* ExpiringTaskRunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringTaskRunnerTests.swift; sourceTree = ""; }; + 8512B82404B1751D0BCC82D2 /* MediaEventsTimelineScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaEventsTimelineScreenCoordinator.swift; sourceTree = ""; }; 85149F56BA333619900E2410 /* UserDetailsEditScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenViewModelProtocol.swift; sourceTree = ""; }; 851B95BB98649B8E773D6790 /* AppLockService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockService.swift; sourceTree = ""; }; 8544F7058D31DBEB8DBFF0F5 /* NotificationSettingsEditScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreenViewModelTests.swift; sourceTree = ""; }; @@ -1975,6 +1985,7 @@ 96CE9D6642DD487D8CC90C9C /* landscape_test_image.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = landscape_test_image.jpg; sourceTree = ""; }; 97287090CA64DAA95386ECED /* ResolveVerifiedUserSendFailureScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolveVerifiedUserSendFailureScreen.swift; sourceTree = ""; }; 974AEAF3FE0C577A6C04AD6E /* RoomPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPermissions.swift; sourceTree = ""; }; + 976ED77B772F50C4BAD757E7 /* MediaEventsTimelineScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaEventsTimelineScreenViewModel.swift; sourceTree = ""; }; 9780389F8A53E4D26E23DD03 /* LoginScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenViewModelProtocol.swift; sourceTree = ""; }; 97B2ACA28A854E41AE3AC9AD /* TimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewModel.swift; sourceTree = ""; }; 97C8E13A1FBA717B0C277ECC /* ProgressCursorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressCursorModifier.swift; sourceTree = ""; }; @@ -2021,6 +2032,7 @@ A130A2251A15A7AACC84FD37 /* RoomPollsHistoryScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPollsHistoryScreenViewModelProtocol.swift; sourceTree = ""; }; A16CD2C62CB7DB78A4238485 /* ReportContentScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenCoordinator.swift; sourceTree = ""; }; A16D0F226B1819D017531647 /* BlockedUsersScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedUsersScreenCoordinator.swift; sourceTree = ""; }; + A175D0FDEDBFA44C47FE13AE /* MediaEventsTimelineScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaEventsTimelineScreenViewModelProtocol.swift; sourceTree = ""; }; A1C22B1B5FA3A765EADB2CC9 /* SessionVerificationStateMachineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationStateMachineTests.swift; sourceTree = ""; }; A232D9156D225BD9FD1D0C43 /* PhotoLibraryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryPicker.swift; sourceTree = ""; }; A243A6E6207297123E60DE48 /* TimelineItemMacContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemMacContextMenu.swift; sourceTree = ""; }; @@ -2908,6 +2920,18 @@ path = View; sourceTree = ""; }; + 26397A1EDB867FD573821532 /* MediaEventsTimelineScreen */ = { + isa = PBXGroup; + children = ( + 8512B82404B1751D0BCC82D2 /* MediaEventsTimelineScreenCoordinator.swift */, + 7033218DA395B003F7AB29A2 /* MediaEventsTimelineScreenModels.swift */, + 976ED77B772F50C4BAD757E7 /* MediaEventsTimelineScreenViewModel.swift */, + A175D0FDEDBFA44C47FE13AE /* MediaEventsTimelineScreenViewModelProtocol.swift */, + DB180A1068D7B85489E13E3F /* View */, + ); + path = MediaEventsTimelineScreen; + sourceTree = ""; + }; 26C16326BCCCED74A85A0F48 /* View */ = { isa = PBXGroup; children = ( @@ -3704,6 +3728,7 @@ 7367B3B9A8CAF902220F31D1 /* BugReportFlowCoordinator.swift */, A07B011547B201A836C03052 /* EncryptionResetFlowCoordinator.swift */, ECB836DD8BE31931F51B8AC9 /* EncryptionSettingsFlowCoordinator.swift */, + 2178B951602AA921A5FD9DC8 /* MediaEventsTimelineFlowCoordinator.swift */, C3285BD95B564CA2A948E511 /* OnboardingFlowCoordinator.swift */, A54AAF72E821B4084B7E4298 /* PinnedEventsTimelineFlowCoordinator.swift */, 9A008E57D52B07B78DFAD1BB /* RoomFlowCoordinator.swift */, @@ -5371,6 +5396,14 @@ path = RoomChangePermissionsScreen; sourceTree = ""; }; + DB180A1068D7B85489E13E3F /* View */ = { + isa = PBXGroup; + children = ( + 002399C6CB875C4EBB01CBC0 /* MediaEventsTimelineScreen.swift */, + ); + path = View; + sourceTree = ""; + }; DD96B3F20F354494DECBC4F7 /* View */ = { isa = PBXGroup; children = ( @@ -5469,6 +5502,7 @@ BF0415BE807CA2BCFC210008 /* KnockRequestsListScreen */, 948DD12A5533BE1BC260E437 /* LocationSharing */, 73E032ADD008D63812791D97 /* LogViewerScreen */, + 26397A1EDB867FD573821532 /* MediaEventsTimelineScreen */, 87E2774157D9C4894BCFF3F8 /* MediaPickerScreen */, 23605DD08620BE6558242469 /* MediaUploadPreviewScreen */, 3348D14DBDB54E72FC67E2F3 /* MessageForwardingScreen */, @@ -6933,6 +6967,12 @@ BEA646DF302711A753F0D420 /* MapTilerStyleBuilderProtocol.swift in Sources */, 67C05C50AD734283374605E3 /* MatrixEntityRegex.swift in Sources */, 8658F5034EAD7357CE7F9AC7 /* MatrixUserShareLink.swift in Sources */, + 84E514915DF0C168B08A3A0A /* MediaEventsTimelineFlowCoordinator.swift in Sources */, + BEC6DFEA506085D3027E353C /* MediaEventsTimelineScreen.swift in Sources */, + C8E1E4E06B7C7A3A8246FC9B /* MediaEventsTimelineScreenCoordinator.swift in Sources */, + B7F58D6903F9D509EDAB9E4F /* MediaEventsTimelineScreenModels.swift in Sources */, + C11D4A49DC29D89CE2BB31B8 /* MediaEventsTimelineScreenViewModel.swift in Sources */, + FD9777315A5D9CDC47458AD1 /* MediaEventsTimelineScreenViewModelProtocol.swift in Sources */, BCC864190651B3A3CF51E4DF /* MediaFileHandleProxy.swift in Sources */, 208C19811613F9A10F8A7B75 /* MediaLoader.swift in Sources */, A2434D4DFB49A68E5CD0F53C /* MediaLoaderProtocol.swift in Sources */, diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 74f446003f..585913119b 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -1048,7 +1048,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg .actionsPublisher .filter(\.isSyncUpdate) .collect(.byTimeOrCount(DispatchQueue.main, .seconds(10), 10)) - .sink(receiveValue: { [weak self] _ in + .sink { [weak self] _ in guard let self else { return } MXLog.info("Background app refresh finished") backgroundRefreshSyncObserver?.cancel() @@ -1059,6 +1059,6 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg MXLog.info("Marking Background app refresh task as complete.") task.setTaskCompleted(success: true) } - }) + } } } diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 8da94ab933..e5c4227a42 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -48,6 +48,7 @@ final class AppSettings { case enableOnlySignedDeviceIsolationMode case knockingEnabled case createMediaCaptionsEnabled + case mediaBrowserEnabled } private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier @@ -288,6 +289,9 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.createMediaCaptionsEnabled, defaultValue: false, storageType: .userDefaults(store)) var createMediaCaptionsEnabled + @UserPreference(key: UserDefaultsKeys.mediaBrowserEnabled, defaultValue: false, storageType: .userDefaults(store)) + var mediaBrowserEnabled + #endif // MARK: - Shared diff --git a/ElementX/Sources/Application/Application.swift b/ElementX/Sources/Application/Application.swift index ad1f9823fe..fe2fb27a54 100644 --- a/ElementX/Sources/Application/Application.swift +++ b/ElementX/Sources/Application/Application.swift @@ -42,11 +42,11 @@ struct Application: App { openURLInSystemBrowser($0) } } - .onContinueUserActivity("INStartVideoCallIntent", perform: { userActivity in + .onContinueUserActivity("INStartVideoCallIntent") { userActivity in // `INStartVideoCallIntent` is to be replaced with `INStartCallIntent` // but calls from Recents still send it ¯\_(ツ)_/¯ appCoordinator.handleUserActivity(userActivity) - }) + } .task { appCoordinator.start() } diff --git a/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift new file mode 100644 index 0000000000..6965a1053d --- /dev/null +++ b/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift @@ -0,0 +1,97 @@ +// +// 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 + +enum MediaEventsTimelineFlowCoordinatorAction { + case finished +} + +class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { + private let navigationStackCoordinator: NavigationStackCoordinator + private let userSession: UserSessionProtocol + private let roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol + private let roomProxy: JoinedRoomProxyProtocol + private let userIndicatorController: UserIndicatorControllerProtocol + private let appMediator: AppMediatorProtocol + private let emojiProvider: EmojiProviderProtocol + + private let actionsSubject: PassthroughSubject = .init() + var actionsPublisher: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + private var cancellables = Set() + + init(navigationStackCoordinator: NavigationStackCoordinator, + userSession: UserSessionProtocol, + roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol, + roomProxy: JoinedRoomProxyProtocol, + userIndicatorController: UserIndicatorControllerProtocol, + appMediator: AppMediatorProtocol, + emojiProvider: EmojiProviderProtocol) { + self.navigationStackCoordinator = navigationStackCoordinator + self.userSession = userSession + self.roomTimelineControllerFactory = roomTimelineControllerFactory + self.roomProxy = roomProxy + self.userIndicatorController = userIndicatorController + self.appMediator = appMediator + self.emojiProvider = emojiProvider + } + + func start() { + Task { await presentMediaEventsTimeline() } + } + + func handleAppRoute(_ appRoute: AppRoute, animated: Bool) { + fatalError() + } + + func clearRoute(animated: Bool) { + fatalError() + } + + // MARK: - Private + + private func presentMediaEventsTimeline() async { + let timelineItemFactory = RoomTimelineItemFactory(userID: userSession.clientProxy.userID, + attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()), + stateEventStringBuilder: RoomStateEventStringBuilder(userID: userSession.clientProxy.userID)) + + guard case let .success(mediaTimelineController) = await roomTimelineControllerFactory.buildMessageFilteredRoomTimelineController(allowedMessageTypes: [.image, .video], + roomProxy: roomProxy, + timelineItemFactory: timelineItemFactory, + mediaProvider: userSession.mediaProvider) else { + MXLog.error("Failed presenting media timeline") + return + } + + guard case let .success(filesTimelineController) = await roomTimelineControllerFactory.buildMessageFilteredRoomTimelineController(allowedMessageTypes: [.file, .audio], + roomProxy: roomProxy, + timelineItemFactory: timelineItemFactory, + mediaProvider: userSession.mediaProvider) else { + MXLog.error("Failed presenting media timeline") + return + } + + let parameters = MediaEventsTimelineScreenCoordinatorParameters(roomProxy: roomProxy, + mediaTimelineController: mediaTimelineController, + filesTimelineController: filesTimelineController, + mediaProvider: userSession.mediaProvider, + mediaPlayerProvider: MediaPlayerProvider(), + voiceMessageMediaManager: userSession.voiceMessageMediaManager, + appMediator: appMediator, + emojiProvider: emojiProvider) + + let coordinator = MediaEventsTimelineScreenCoordinator(parameters: parameters) + + navigationStackCoordinator.push(coordinator) { [weak self] in + self?.actionsSubject.send(.finished) + } + } +} diff --git a/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift index fcaf4fb7dc..6ee1d0182d 100644 --- a/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift @@ -65,9 +65,9 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()), stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID)) - guard let timelineController = await roomTimelineControllerFactory.buildRoomPinnedTimelineController(roomProxy: roomProxy, - timelineItemFactory: timelineItemFactory, - mediaProvider: userSession.mediaProvider) else { + guard let timelineController = await roomTimelineControllerFactory.buildPinnedEventsRoomTimelineController(roomProxy: roomProxy, + timelineItemFactory: timelineItemFactory, + mediaProvider: userSession.mediaProvider) else { fatalError("This can never fail because we allow this view to be presented only when the timeline is fully loaded and not nil") } diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index fddb7fea16..c4caf39814 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -50,11 +50,6 @@ struct FocusEvent: Hashable { let shouldSetPin: Bool } -private enum PinnedEventsTimelineSource: Hashable { - case room - case details(isRoot: Bool) -} - private enum PresentationAction: Hashable { case eventFocus(FocusEvent) case share(ShareExtensionPayload) @@ -102,6 +97,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { // periphery:ignore - used to avoid deallocation private var pinnedEventsTimelineFlowCoordinator: PinnedEventsTimelineFlowCoordinator? // periphery:ignore - used to avoid deallocation + private var mediaEventsTimelineFlowCoordinator: MediaEventsTimelineFlowCoordinator? + // periphery:ignore - used to avoid deallocation private var childRoomFlowCoordinator: RoomFlowCoordinator? private let stateMachine: StateMachine = .init(state: .initial) @@ -149,7 +146,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { fatalError("This flow coordinator expect a route") } - // swiftlint:disable:next cyclomatic_complexity func handleAppRoute(_ appRoute: AppRoute, animated: Bool) { guard stateMachine.state != .complete else { fatalError("This flow coordinator is `finished` ☠️") @@ -369,16 +365,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { return .room case (.room, .presentPinnedEventsTimeline): - return .pinnedEventsTimeline(previousState: .room) - case (.roomDetails(let isRoot), .presentPinnedEventsTimeline): - return .pinnedEventsTimeline(previousState: .details(isRoot: isRoot)) + return .pinnedEventsTimeline(previousState: fromState) + case (.roomDetails, .presentPinnedEventsTimeline): + return .pinnedEventsTimeline(previousState: fromState) case (.pinnedEventsTimeline(let previousState), .dismissPinnedEventsTimeline): - switch previousState { - case .room: - return .room - case .details(let isRoot): - return .roomDetails(isRoot: isRoot) - } + return previousState case (.roomDetails, .presentPollsHistory): return .pollsHistory @@ -400,8 +391,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case (.resolveSendFailure, .dismissResolveSendFailure): return .room - // Child flow - case (_, .startChildFlow(let roomID, _, _)): return .presentingChild(childRoomID: roomID, previousState: fromState) case (.presentingChild(_, let previousState), .dismissChildFlow): @@ -411,6 +400,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { return .knockRequestsList(previousState: fromState) case (.knockRequestsList(let previousState), .dismissKnockRequestsListScreen): return previousState + + case (.roomDetails, .presentMediaEventsTimeline): + return .mediaEventsTimeline(previousState: fromState) + case (.mediaEventsTimeline(let previousState), .dismissMediaEventsTimeline): + return previousState default: return nil @@ -527,7 +521,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { break case (.room, .presentPinnedEventsTimeline, .pinnedEventsTimeline): - presentPinnedEventsTimeline() + startPinnedEventsTimelineFlow() case (.pinnedEventsTimeline, .dismissPinnedEventsTimeline, .room): break @@ -537,7 +531,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { break case (.roomDetails, .presentPinnedEventsTimeline, .pinnedEventsTimeline): - presentPinnedEventsTimeline() + startPinnedEventsTimelineFlow() case (.pinnedEventsTimeline, .dismissPinnedEventsTimeline, .roomDetails): break @@ -573,6 +567,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case (.knockRequestsList, .dismissKnockRequestsListScreen, .room): break + case (.roomDetails, .presentMediaEventsTimeline, .mediaEventsTimeline): + Task { await self.startMediaEventsTimelineFlow() } + case (.mediaEventsTimeline, .dismissMediaEventsTimeline, .roomDetails): + break + // Child flow case (_, .startChildFlow(let roomID, let via, let entryPoint), .presentingChild): Task { await self.startChildFlow(for: roomID, via: via, entryPoint: entryPoint) } @@ -848,6 +847,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { stateMachine.tryEvent(.presentPinnedEventsTimeline) case .presentKnockingRequestsListScreen: stateMachine.tryEvent(.presentKnockRequestsListScreen) + case .presentMediaEventsTimeline: + stateMachine.tryEvent(.presentMediaEventsTimeline) } } .store(in: &cancellables) @@ -1432,44 +1433,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { coordinator.start() } - private func presentPinnedEventsTimeline() { - let stackCoordinator = NavigationStackCoordinator() - let coordinator = PinnedEventsTimelineFlowCoordinator(navigationStackCoordinator: stackCoordinator, - userSession: userSession, - roomTimelineControllerFactory: roomTimelineControllerFactory, - roomProxy: roomProxy, - userIndicatorController: userIndicatorController, - appMediator: appMediator, - emojiProvider: emojiProvider) - - coordinator.actionsPublisher.sink { [weak self] action in - guard let self else { - return - } - - switch action { - case .finished: - navigationStackCoordinator.setSheetCoordinator(nil) - case .displayUser(let userID): - navigationStackCoordinator.setSheetCoordinator(nil) - stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID)) - case .forwardedMessageToRoom(let roomID): - navigationStackCoordinator.setSheetCoordinator(nil) - stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: [], entryPoint: .room)) - case .displayRoomScreenWithFocussedPin(let eventID): - navigationStackCoordinator.setSheetCoordinator(nil) - stateMachine.tryEvent(.presentRoom(presentationAction: .eventFocus(.init(eventID: eventID, shouldSetPin: true)))) - } - } - .store(in: &cancellables) - - pinnedEventsTimelineFlowCoordinator = coordinator - navigationStackCoordinator.setSheetCoordinator(stackCoordinator) { [weak self] in - self?.stateMachine.tryEvent(.dismissPinnedEventsTimeline) - } - coordinator.start() - } - private func presentResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy) { let coordinator = ResolveVerifiedUserSendFailureScreenCoordinator(parameters: .init(failure: failure, sendHandle: sendHandle, @@ -1490,7 +1453,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } } - // MARK: - Child Flow + // MARK: - Other flows private func startChildFlow(for roomID: String, via: [String], entryPoint: RoomFlowCoordinatorEntryPoint) async { let coordinator = await RoomFlowCoordinator(roomID: roomID, @@ -1528,6 +1491,71 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { coordinator.handleAppRoute(.share(payload), animated: true) } } + + private func startPinnedEventsTimelineFlow() { + let stackCoordinator = NavigationStackCoordinator() + + let flowCoordinator = PinnedEventsTimelineFlowCoordinator(navigationStackCoordinator: stackCoordinator, + userSession: userSession, + roomTimelineControllerFactory: roomTimelineControllerFactory, + roomProxy: roomProxy, + userIndicatorController: userIndicatorController, + appMediator: appMediator, + emojiProvider: emojiProvider) + + flowCoordinator.actionsPublisher.sink { [weak self] action in + guard let self else { + return + } + + switch action { + case .finished: + navigationStackCoordinator.setSheetCoordinator(nil) + case .displayUser(let userID): + navigationStackCoordinator.setSheetCoordinator(nil) + stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID)) + case .forwardedMessageToRoom(let roomID): + navigationStackCoordinator.setSheetCoordinator(nil) + stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: [], entryPoint: .room)) + case .displayRoomScreenWithFocussedPin(let eventID): + navigationStackCoordinator.setSheetCoordinator(nil) + stateMachine.tryEvent(.presentRoom(presentationAction: .eventFocus(.init(eventID: eventID, shouldSetPin: true)))) + } + } + .store(in: &cancellables) + + pinnedEventsTimelineFlowCoordinator = flowCoordinator + + navigationStackCoordinator.setSheetCoordinator(stackCoordinator) { [weak self] in + self?.stateMachine.tryEvent(.dismissPinnedEventsTimeline) + } + + flowCoordinator.start() + } + + private func startMediaEventsTimelineFlow() async { + let flowCoordinator = MediaEventsTimelineFlowCoordinator(navigationStackCoordinator: navigationStackCoordinator, + userSession: userSession, + roomTimelineControllerFactory: roomTimelineControllerFactory, + roomProxy: roomProxy, + userIndicatorController: userIndicatorController, + appMediator: appMediator, + emojiProvider: emojiProvider) + + flowCoordinator.actionsPublisher.sink { [weak self] action in + guard let self else { return } + + switch action { + case .finished: + stateMachine.tryEvent(.dismissMediaEventsTimeline) + } + } + .store(in: &cancellables) + + mediaEventsTimelineFlowCoordinator = flowCoordinator + + flowCoordinator.start() + } } private extension RoomFlowCoordinator { @@ -1565,9 +1593,10 @@ private extension RoomFlowCoordinator { case pollsHistory case pollsHistoryForm case rolesAndPermissions - case pinnedEventsTimeline(previousState: PinnedEventsTimelineSource) + case pinnedEventsTimeline(previousState: State) case resolveSendFailure case knockRequestsList(previousState: State) + case mediaEventsTimeline(previousState: State) /// A child flow is in progress. case presentingChild(childRoomID: String, previousState: State) @@ -1643,12 +1672,14 @@ private extension RoomFlowCoordinator { case presentResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy) case dismissResolveSendFailure - // Child room flow events case startChildFlow(roomID: String, via: [String], entryPoint: RoomFlowCoordinatorEntryPoint) case dismissChildFlow case presentKnockRequestsListScreen case dismissKnockRequestsListScreen + + case presentMediaEventsTimeline + case dismissMediaEventsTimeline } } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 576eff68fd..5176d743cf 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -6147,6 +6147,76 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol { return timelineFocusedOnEventEventIDNumberOfEventsReturnValue } } + //MARK: - messageFilteredTimeline + + var messageFilteredTimelineAllowedMessageTypesUnderlyingCallsCount = 0 + var messageFilteredTimelineAllowedMessageTypesCallsCount: Int { + get { + if Thread.isMainThread { + return messageFilteredTimelineAllowedMessageTypesUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = messageFilteredTimelineAllowedMessageTypesUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + messageFilteredTimelineAllowedMessageTypesUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + messageFilteredTimelineAllowedMessageTypesUnderlyingCallsCount = newValue + } + } + } + } + var messageFilteredTimelineAllowedMessageTypesCalled: Bool { + return messageFilteredTimelineAllowedMessageTypesCallsCount > 0 + } + var messageFilteredTimelineAllowedMessageTypesReceivedAllowedMessageTypes: [RoomMessageEventMessageType]? + var messageFilteredTimelineAllowedMessageTypesReceivedInvocations: [[RoomMessageEventMessageType]] = [] + + var messageFilteredTimelineAllowedMessageTypesUnderlyingReturnValue: Result! + var messageFilteredTimelineAllowedMessageTypesReturnValue: Result! { + get { + if Thread.isMainThread { + return messageFilteredTimelineAllowedMessageTypesUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = messageFilteredTimelineAllowedMessageTypesUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + messageFilteredTimelineAllowedMessageTypesUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + messageFilteredTimelineAllowedMessageTypesUnderlyingReturnValue = newValue + } + } + } + } + var messageFilteredTimelineAllowedMessageTypesClosure: (([RoomMessageEventMessageType]) async -> Result)? + + func messageFilteredTimeline(allowedMessageTypes: [RoomMessageEventMessageType]) async -> Result { + messageFilteredTimelineAllowedMessageTypesCallsCount += 1 + messageFilteredTimelineAllowedMessageTypesReceivedAllowedMessageTypes = allowedMessageTypes + DispatchQueue.main.async { + self.messageFilteredTimelineAllowedMessageTypesReceivedInvocations.append(allowedMessageTypes) + } + if let messageFilteredTimelineAllowedMessageTypesClosure = messageFilteredTimelineAllowedMessageTypesClosure { + return await messageFilteredTimelineAllowedMessageTypesClosure(allowedMessageTypes) + } else { + return messageFilteredTimelineAllowedMessageTypesReturnValue + } + } //MARK: - redact var redactUnderlyingCallsCount = 0 @@ -12602,17 +12672,17 @@ class RoomTimelineControllerFactoryMock: RoomTimelineControllerFactoryProtocol { return buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReturnValue } } - //MARK: - buildRoomPinnedTimelineController + //MARK: - buildPinnedEventsRoomTimelineController - var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = 0 - var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCallsCount: Int { + var buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = 0 + var buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCallsCount: Int { get { if Thread.isMainThread { - return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount + return buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount } else { var returnValue: Int? = nil DispatchQueue.main.sync { - returnValue = buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount + returnValue = buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount } return returnValue! @@ -12620,29 +12690,99 @@ class RoomTimelineControllerFactoryMock: RoomTimelineControllerFactoryProtocol { } set { if Thread.isMainThread { - buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue + buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue } else { DispatchQueue.main.sync { - buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue + buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue } } } } - var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCalled: Bool { - return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCallsCount > 0 + var buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCalled: Bool { + return buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCallsCount > 0 } - var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedArguments: (roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)? - var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations: [(roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)] = [] + var buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedArguments: (roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)? + var buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations: [(roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)] = [] - var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue: RoomTimelineControllerProtocol? - var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReturnValue: RoomTimelineControllerProtocol? { + var buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue: RoomTimelineControllerProtocol? + var buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReturnValue: RoomTimelineControllerProtocol? { get { if Thread.isMainThread { - return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue + return buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue } else { var returnValue: RoomTimelineControllerProtocol?? = nil DispatchQueue.main.sync { - returnValue = buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue + returnValue = buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue + } + } + } + } + var buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure: ((JoinedRoomProxyProtocol, RoomTimelineItemFactoryProtocol, MediaProviderProtocol) async -> RoomTimelineControllerProtocol?)? + + func buildPinnedEventsRoomTimelineController(roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) async -> RoomTimelineControllerProtocol? { + buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCallsCount += 1 + buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedArguments = (roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider) + DispatchQueue.main.async { + self.buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations.append((roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider)) + } + if let buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure = buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure { + return await buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure(roomProxy, timelineItemFactory, mediaProvider) + } else { + return buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReturnValue + } + } + //MARK: - buildMessageFilteredRoomTimelineController + + var buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = 0 + var buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderCallsCount: Int { + get { + if Thread.isMainThread { + return buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue + } + } + } + } + var buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderCalled: Bool { + return buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderCallsCount > 0 + } + var buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderReceivedArguments: (allowedMessageTypes: [RoomMessageEventMessageType], roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)? + var buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations: [(allowedMessageTypes: [RoomMessageEventMessageType], roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)] = [] + + var buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue: Result! + var buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderReturnValue: Result! { + get { + if Thread.isMainThread { + return buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue } return returnValue! @@ -12650,26 +12790,26 @@ class RoomTimelineControllerFactoryMock: RoomTimelineControllerFactoryProtocol { } set { if Thread.isMainThread { - buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue + buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue } else { DispatchQueue.main.sync { - buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue + buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue } } } } - var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure: ((JoinedRoomProxyProtocol, RoomTimelineItemFactoryProtocol, MediaProviderProtocol) async -> RoomTimelineControllerProtocol?)? + var buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderClosure: (([RoomMessageEventMessageType], JoinedRoomProxyProtocol, RoomTimelineItemFactoryProtocol, MediaProviderProtocol) async -> Result)? - func buildRoomPinnedTimelineController(roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) async -> RoomTimelineControllerProtocol? { - buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCallsCount += 1 - buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedArguments = (roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider) + func buildMessageFilteredRoomTimelineController(allowedMessageTypes: [RoomMessageEventMessageType], roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) async -> Result { + buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderCallsCount += 1 + buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderReceivedArguments = (allowedMessageTypes: allowedMessageTypes, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider) DispatchQueue.main.async { - self.buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations.append((roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider)) + self.buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations.append((allowedMessageTypes: allowedMessageTypes, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider)) } - if let buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure = buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure { - return await buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure(roomProxy, timelineItemFactory, mediaProvider) + if let buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderClosure = buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderClosure { + return await buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderClosure(allowedMessageTypes, roomProxy, timelineItemFactory, mediaProvider) } else { - return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReturnValue + return buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderReturnValue } } } diff --git a/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift index 9c76fc5c50..8b3d2ca5d3 100644 --- a/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift @@ -8248,6 +8248,71 @@ open class MediaSourceSDKMock: MatrixRustSDK.MediaSource { { } + //MARK: - toJson + + var toJsonUnderlyingCallsCount = 0 + open var toJsonCallsCount: Int { + get { + if Thread.isMainThread { + return toJsonUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = toJsonUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + toJsonUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + toJsonUnderlyingCallsCount = newValue + } + } + } + } + open var toJsonCalled: Bool { + return toJsonCallsCount > 0 + } + + var toJsonUnderlyingReturnValue: String! + open var toJsonReturnValue: String! { + get { + if Thread.isMainThread { + return toJsonUnderlyingReturnValue + } else { + var returnValue: String? = nil + DispatchQueue.main.sync { + returnValue = toJsonUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + toJsonUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + toJsonUnderlyingReturnValue = newValue + } + } + } + } + open var toJsonClosure: (() -> String)? + + open override func toJson() -> String { + toJsonCallsCount += 1 + if let toJsonClosure = toJsonClosure { + return toJsonClosure() + } else { + return toJsonReturnValue + } + } + //MARK: - url var urlUnderlyingCallsCount = 0 @@ -12813,6 +12878,81 @@ open class RoomSDKMock: MatrixRustSDK.Room { } } + //MARK: - messageFilteredTimeline + + open var messageFilteredTimelineInternalIdPrefixAllowedMessageTypesThrowableError: Error? + var messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingCallsCount = 0 + open var messageFilteredTimelineInternalIdPrefixAllowedMessageTypesCallsCount: Int { + get { + if Thread.isMainThread { + return messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingCallsCount = newValue + } + } + } + } + open var messageFilteredTimelineInternalIdPrefixAllowedMessageTypesCalled: Bool { + return messageFilteredTimelineInternalIdPrefixAllowedMessageTypesCallsCount > 0 + } + open var messageFilteredTimelineInternalIdPrefixAllowedMessageTypesReceivedArguments: (internalIdPrefix: String?, allowedMessageTypes: [RoomMessageEventMessageType])? + open var messageFilteredTimelineInternalIdPrefixAllowedMessageTypesReceivedInvocations: [(internalIdPrefix: String?, allowedMessageTypes: [RoomMessageEventMessageType])] = [] + + var messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingReturnValue: Timeline! + open var messageFilteredTimelineInternalIdPrefixAllowedMessageTypesReturnValue: Timeline! { + get { + if Thread.isMainThread { + return messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingReturnValue + } else { + var returnValue: Timeline? = nil + DispatchQueue.main.sync { + returnValue = messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingReturnValue = newValue + } + } + } + } + open var messageFilteredTimelineInternalIdPrefixAllowedMessageTypesClosure: ((String?, [RoomMessageEventMessageType]) async throws -> Timeline)? + + open override func messageFilteredTimeline(internalIdPrefix: String?, allowedMessageTypes: [RoomMessageEventMessageType]) async throws -> Timeline { + if let error = messageFilteredTimelineInternalIdPrefixAllowedMessageTypesThrowableError { + throw error + } + messageFilteredTimelineInternalIdPrefixAllowedMessageTypesCallsCount += 1 + messageFilteredTimelineInternalIdPrefixAllowedMessageTypesReceivedArguments = (internalIdPrefix: internalIdPrefix, allowedMessageTypes: allowedMessageTypes) + DispatchQueue.main.async { + self.messageFilteredTimelineInternalIdPrefixAllowedMessageTypesReceivedInvocations.append((internalIdPrefix: internalIdPrefix, allowedMessageTypes: allowedMessageTypes)) + } + if let messageFilteredTimelineInternalIdPrefixAllowedMessageTypesClosure = messageFilteredTimelineInternalIdPrefixAllowedMessageTypesClosure { + return try await messageFilteredTimelineInternalIdPrefixAllowedMessageTypesClosure(internalIdPrefix, allowedMessageTypes) + } else { + return messageFilteredTimelineInternalIdPrefixAllowedMessageTypesReturnValue + } + } + //MARK: - ownUserId var ownUserIdUnderlyingCallsCount = 0 diff --git a/ElementX/Sources/Mocks/MediaProviderMock.swift b/ElementX/Sources/Mocks/MediaProviderMock.swift index d398264f2a..375ab2a5d6 100644 --- a/ElementX/Sources/Mocks/MediaProviderMock.swift +++ b/ElementX/Sources/Mocks/MediaProviderMock.swift @@ -10,6 +10,7 @@ import SwiftUI extension MediaProviderMock { struct Configuration { } + // swiftlint:disable:next cyclomatic_complexity convenience init(configuration: Configuration) { self.init() diff --git a/ElementX/Sources/Other/VoiceMessage/VoiceMessageButton.swift b/ElementX/Sources/Other/VoiceMessage/VoiceMessageButton.swift index d0fffff0f8..b99628bd64 100644 --- a/ElementX/Sources/Other/VoiceMessage/VoiceMessageButton.swift +++ b/ElementX/Sources/Other/VoiceMessage/VoiceMessageButton.swift @@ -106,12 +106,12 @@ struct VoiceMessageButton_Previews: PreviewProvider, TestablePreview { static var previews: some View { VStack(spacing: 8) { HStack(spacing: 8) { - VoiceMessageButton(state: .paused, size: .small, action: { }) - VoiceMessageButton(state: .paused, size: .medium, action: { }) + VoiceMessageButton(state: .paused, size: .small) { } + VoiceMessageButton(state: .paused, size: .medium) { } } HStack(spacing: 8) { - VoiceMessageButton(state: .playing, size: .small, action: { }) - VoiceMessageButton(state: .playing, size: .medium, action: { }) + VoiceMessageButton(state: .playing, size: .small) { } + VoiceMessageButton(state: .playing, size: .medium) { } } } .padding() diff --git a/ElementX/Sources/Screens/BlockedUsersScreen/View/BlockedUsersScreen.swift b/ElementX/Sources/Screens/BlockedUsersScreen/View/BlockedUsersScreen.swift index 1922e1f92b..56702160b3 100644 --- a/ElementX/Sources/Screens/BlockedUsersScreen/View/BlockedUsersScreen.swift +++ b/ElementX/Sources/Screens/BlockedUsersScreen/View/BlockedUsersScreen.swift @@ -34,7 +34,7 @@ struct BlockedUsersScreen: View { ForEach(context.viewState.blockedUsers, id: \.self) { user in ListRow(label: .avatar(title: user.displayName ?? user.userID, icon: avatar(for: user)), details: .isWaiting(context.viewState.processingUserID == user.userID), - kind: .button(action: { context.send(viewAction: .unblockUser(user)) })) + kind: .button { context.send(viewAction: .unblockUser(user)) }) } } } diff --git a/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenViewModel.swift b/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenViewModel.swift index 1e750d7911..5723d8fcb2 100644 --- a/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenViewModel.swift +++ b/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenViewModel.swift @@ -34,14 +34,14 @@ class PollFormScreenViewModel: PollFormScreenViewModelType, PollFormScreenViewMo title: L10n.screenEditPollDeleteConfirmationTitle, message: L10n.screenEditPollDeleteConfirmation, primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil), - secondaryButton: .init(title: L10n.actionOk, action: { self.actionsSubject.send(.delete) })) + secondaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.delete) }) case .cancel: if state.formContentHasChanged { state.bindings.alertInfo = .init(id: .init(), title: L10n.screenCreatePollCancelConfirmationTitleIos, message: L10n.screenCreatePollCancelConfirmationContentIos, primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil), - secondaryButton: .init(title: L10n.actionOk, action: { self.actionsSubject.send(.cancel) })) + secondaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.cancel) }) } else { actionsSubject.send(.cancel) } diff --git a/ElementX/Sources/Screens/DeactivateAccountScreen/DeactivateAccountScreenViewModel.swift b/ElementX/Sources/Screens/DeactivateAccountScreen/DeactivateAccountScreenViewModel.swift index 09e5ee076e..21450bac08 100644 --- a/ElementX/Sources/Screens/DeactivateAccountScreen/DeactivateAccountScreenViewModel.swift +++ b/ElementX/Sources/Screens/DeactivateAccountScreen/DeactivateAccountScreenViewModel.swift @@ -43,9 +43,9 @@ class DeactivateAccountScreenViewModel: DeactivateAccountScreenViewModelType, De state.bindings.alertInfo = .init(id: .confirmation, title: L10n.screenDeactivateAccountTitle, message: L10n.screenDeactivateAccountConfirmationDialogContent, - primaryButton: .init(title: L10n.actionDeactivate, action: { + primaryButton: .init(title: L10n.actionDeactivate) { Task { await self.deactivateAccount() } - }), + }, secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil)) } diff --git a/ElementX/Sources/Screens/EncryptionReset/EncryptionResetScreen/EncryptionResetScreenViewModel.swift b/ElementX/Sources/Screens/EncryptionReset/EncryptionResetScreen/EncryptionResetScreenViewModel.swift index a81c2f112c..21eb50490f 100644 --- a/ElementX/Sources/Screens/EncryptionReset/EncryptionResetScreen/EncryptionResetScreenViewModel.swift +++ b/ElementX/Sources/Screens/EncryptionReset/EncryptionResetScreen/EncryptionResetScreenViewModel.swift @@ -38,10 +38,10 @@ class EncryptionResetScreenViewModel: EncryptionResetScreenViewModelType, Encryp state.bindings.alertInfo = .init(id: UUID(), title: L10n.screenResetEncryptionConfirmationAlertTitle, message: L10n.screenResetEncryptionConfirmationAlertSubtitle, - primaryButton: .init(title: L10n.screenResetEncryptionConfirmationAlertAction, role: .destructive, action: { [weak self] in + primaryButton: .init(title: L10n.screenResetEncryptionConfirmationAlertAction, role: .destructive) { [weak self] in guard let self else { return } Task { await self.startResetFlow() } - })) + }) case .cancel: actionsSubject.send(.cancel) } diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index 12313779a4..03f5d819c6 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -326,11 +326,10 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.state.bindings.alertInfo = AlertInfo(id: UUID(), title: L10n.bannerMigrateToNativeSlidingSyncForceLogoutTitle, - primaryButton: .init(title: L10n.bannerMigrateToNativeSlidingSyncAction, - action: { [weak self] in - self?.appSettings.slidingSyncDiscovery = .native - self?.actionsSubject.send(.logoutWithoutConfirmation) - })) + primaryButton: .init(title: L10n.bannerMigrateToNativeSlidingSyncAction) { [weak self] in + self?.appSettings.slidingSyncDiscovery = .native + self?.actionsSubject.send(.logoutWithoutConfirmation) + }) } } } @@ -433,7 +432,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol title: title, message: message, primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil), - secondaryButton: .init(title: L10n.actionDecline, role: .destructive, action: { Task { await self.declineInvite(roomID: room.id) } })) + secondaryButton: .init(title: L10n.actionDecline, role: .destructive) { Task { await self.declineInvite(roomID: room.id) } }) } private func declineInvite(roomID: String) async { diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift index 076f85ebd0..1b6ea1e86f 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift @@ -63,7 +63,7 @@ struct HomeScreenEmptyStateLayout: Layout { } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - let mainView = subviews.first(where: { $0.priority > 0 }) + let mainView = subviews.first { $0.priority > 0 } let topViews = subviews.filter { $0 != mainView } var y: CGFloat = bounds.minY diff --git a/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreenSelectedItem.swift b/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreenSelectedItem.swift index 5316cd9763..31c8f9b696 100644 --- a/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreenSelectedItem.swift +++ b/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreenSelectedItem.swift @@ -50,7 +50,7 @@ struct InviteUsersScreenSelectedItem_Previews: PreviewProvider, TestablePreview ScrollView(.horizontal) { HStack(spacing: 28) { ForEach(people, id: \.userID) { user in - InviteUsersScreenSelectedItem(user: user, mediaProvider: MediaProviderMock(configuration: .init()), dismissAction: { }) + InviteUsersScreenSelectedItem(user: user, mediaProvider: MediaProviderMock(configuration: .init())) { } .frame(width: 72) } } diff --git a/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift b/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift index 6312924698..724b969ced 100644 --- a/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift @@ -221,7 +221,7 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo title: L10n.screenInvitesDeclineChatTitle, message: L10n.screenInvitesDeclineChatMessage(roomName), primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil), - secondaryButton: .init(title: L10n.actionDecline, role: .destructive, action: { Task { await self.declineInvite() } })) + secondaryButton: .init(title: L10n.actionDecline, role: .destructive) { Task { await self.declineInvite() } }) } private func showCancelKnockConfirmationAlert() { @@ -229,7 +229,7 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo title: L10n.screenJoinRoomCancelKnockAlertTitle, message: L10n.screenJoinRoomCancelKnockAlertDescription, primaryButton: .init(title: L10n.actionNo, role: .cancel, action: nil), - secondaryButton: .init(title: L10n.screenJoinRoomCancelKnockAlertConfirmation, role: .destructive, action: { Task { await self.cancelKnock() } })) + secondaryButton: .init(title: L10n.screenJoinRoomCancelKnockAlertConfirmation, role: .destructive) { Task { await self.cancelKnock() } }) } private func declineInvite() async { diff --git a/ElementX/Sources/Screens/JoinRoomScreen/View/JoinRoomScreen.swift b/ElementX/Sources/Screens/JoinRoomScreen/View/JoinRoomScreen.swift index 3953ed96c6..16d3fab8ca 100644 --- a/ElementX/Sources/Screens/JoinRoomScreen/View/JoinRoomScreen.swift +++ b/ElementX/Sources/Screens/JoinRoomScreen/View/JoinRoomScreen.swift @@ -261,7 +261,7 @@ struct JoinRoomScreen_Previews: PreviewProvider, TestablePreview { topic: "“Science and technology were the only keys to opening the door to the future, and people approached science with the faith and sincerity of elementary school students.”", avatarURL: .mockMXCAvatar, memberCount: UInt(100), - isHistoryWorldReadable: false, + isHistoryWorldReadable: nil, isJoined: membership.isJoined, isInvited: membership.isInvited, isPublic: membership.isPublic, diff --git a/ElementX/Sources/Screens/KnockRequestsListScreen/View/KnockRequestCell.swift b/ElementX/Sources/Screens/KnockRequestsListScreen/View/KnockRequestCell.swift index db6962d1f1..66c1d302ff 100644 --- a/ElementX/Sources/Screens/KnockRequestsListScreen/View/KnockRequestCell.swift +++ b/ElementX/Sources/Screens/KnockRequestsListScreen/View/KnockRequestCell.swift @@ -177,19 +177,19 @@ struct KnockRequestCell_Previews: PreviewProvider, TestablePreview { static let aliceWithNoName = KnockRequestCellInfo(id: "@alice:matrix.org", displayName: nil, avatarURL: nil, timestamp: "20 Nov 2024", reason: nil) static var previews: some View { - KnockRequestCell(cellInfo: aliceWithLongReason, onAccept: { _ in }, onDecline: { _ in }, onDeclineAndBan: { _ in }) + KnockRequestCell(cellInfo: aliceWithLongReason) { _ in } onDecline: { _ in } onDeclineAndBan: { _ in } .previewDisplayName("Long reason") - KnockRequestCell(cellInfo: aliceWithShortReason, onAccept: { _ in }, onDecline: { _ in }, onDeclineAndBan: { _ in }) + KnockRequestCell(cellInfo: aliceWithShortReason) { _ in } onDecline: { _ in } onDeclineAndBan: { _ in } .previewDisplayName("Short reason") - KnockRequestCell(cellInfo: aliceWithNoReason, onAccept: { _ in }, onDecline: { _ in }, onDeclineAndBan: { _ in }) + KnockRequestCell(cellInfo: aliceWithNoReason) { _ in } onDecline: { _ in } onDeclineAndBan: { _ in } .previewDisplayName("No reason") - KnockRequestCell(cellInfo: aliceWithNoName, onAccept: { _ in }, onDecline: { _ in }, onDeclineAndBan: { _ in }) + KnockRequestCell(cellInfo: aliceWithNoName) { _ in } onDecline: { _ in } onDeclineAndBan: { _ in } .previewDisplayName("No name") - KnockRequestCell(cellInfo: aliceWithShortReason, onAccept: nil, onDecline: { _ in }, onDeclineAndBan: { _ in }) - .previewDisplayName("No Accept") - KnockRequestCell(cellInfo: aliceWithShortReason, onAccept: nil, onDecline: nil, onDeclineAndBan: { _ in }) - .previewDisplayName("No Accept and Decline") - KnockRequestCell(cellInfo: aliceWithShortReason, onAccept: { _ in }, onDecline: { _ in }, onDeclineAndBan: nil) - .previewDisplayName("No Ban") +// KnockRequestCell(cellInfo: aliceWithShortReason, onAccept: nil) onDecline: { _ in } onDeclineAndBan: { _ in } +// .previewDisplayName("No Accept") +// KnockRequestCell(cellInfo: aliceWithShortReason) onDeclineAndBan: { _ in } +// .previewDisplayName("No Accept and Decline") +// KnockRequestCell(cellInfo: aliceWithShortReason) { _ in } onDecline: { _ in }) +// .previewDisplayName("No Ban") } } diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift new file mode 100644 index 0000000000..d05fc4cd74 --- /dev/null +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift @@ -0,0 +1,68 @@ +// +// 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 SwiftUI + +struct MediaEventsTimelineScreenCoordinatorParameters { + let roomProxy: JoinedRoomProxyProtocol + let mediaTimelineController: RoomTimelineControllerProtocol + let filesTimelineController: RoomTimelineControllerProtocol + let mediaProvider: MediaProviderProtocol + let mediaPlayerProvider: MediaPlayerProviderProtocol + let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol + let appMediator: AppMediatorProtocol + let emojiProvider: EmojiProviderProtocol +} + +enum MediaEventsTimelineScreenCoordinatorAction { } + +final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol { + private let parameters: MediaEventsTimelineScreenCoordinatorParameters + private let viewModel: MediaEventsTimelineScreenViewModelProtocol + + private var cancellables = Set() + + private let actionsSubject: PassthroughSubject = .init() + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(parameters: MediaEventsTimelineScreenCoordinatorParameters) { + self.parameters = parameters + + let mediaTimelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy, + timelineController: parameters.mediaTimelineController, + mediaProvider: parameters.mediaProvider, + mediaPlayerProvider: parameters.mediaPlayerProvider, + voiceMessageMediaManager: parameters.voiceMessageMediaManager, + userIndicatorController: ServiceLocator.shared.userIndicatorController, + appMediator: parameters.appMediator, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + emojiProvider: parameters.emojiProvider) + + let filesTimelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy, + timelineController: parameters.filesTimelineController, + mediaProvider: parameters.mediaProvider, + mediaPlayerProvider: parameters.mediaPlayerProvider, + voiceMessageMediaManager: parameters.voiceMessageMediaManager, + userIndicatorController: ServiceLocator.shared.userIndicatorController, + appMediator: parameters.appMediator, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + emojiProvider: parameters.emojiProvider) + + viewModel = MediaEventsTimelineScreenViewModel(mediaTimelineViewModel: mediaTimelineViewModel, + filesTimelineViewModel: filesTimelineViewModel, + mediaProvider: parameters.mediaProvider) + } + + func toPresentable() -> AnyView { + AnyView(MediaEventsTimelineScreen(context: viewModel.context)) + } +} diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift new file mode 100644 index 0000000000..c9f761a286 --- /dev/null +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift @@ -0,0 +1,32 @@ +// +// Copyright 2022-2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import Foundation + +enum MediaEventsTimelineScreenViewModelAction { } + +enum MediaEventsTimelineScreenMode { + case media + case files +} + +struct MediaEventsTimelineScreenViewState: BindableState { + var isBackPaginating = false + var items = [RoomTimelineItemViewState]() + + var bindings: MediaEventsTimelineScreenViewStateBindings +} + +struct MediaEventsTimelineScreenViewStateBindings { + var screenMode: MediaEventsTimelineScreenMode +} + +enum MediaEventsTimelineScreenViewAction { + case changedScreenMode + case oldestItemDidAppear + case oldestItemDidDisappear +} diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift new file mode 100644 index 0000000000..62b1ab6c1f --- /dev/null +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift @@ -0,0 +1,102 @@ +// +// 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 SwiftUI + +typealias MediaEventsTimelineScreenViewModelType = StateStoreViewModel + +class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType, MediaEventsTimelineScreenViewModelProtocol { + private let mediaTimelineViewModel: TimelineViewModelProtocol + private let filesTimelineViewModel: TimelineViewModelProtocol + + private var isOldestItemVisible = false + + private var activeTimelineViewModel: TimelineViewModelProtocol { + switch state.bindings.screenMode { + case .media: + mediaTimelineViewModel + case .files: + filesTimelineViewModel + } + } + + private let actionsSubject: PassthroughSubject = .init() + var actionsPublisher: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(mediaTimelineViewModel: TimelineViewModelProtocol, + filesTimelineViewModel: TimelineViewModelProtocol, + mediaProvider: MediaProviderProtocol, + screenMode: MediaEventsTimelineScreenMode = .media) { + self.mediaTimelineViewModel = mediaTimelineViewModel + self.filesTimelineViewModel = filesTimelineViewModel + + super.init(initialViewState: .init(bindings: .init(screenMode: screenMode)), mediaProvider: mediaProvider) + + mediaTimelineViewModel.context.$viewState.sink { [weak self] timelineViewState in + guard let self, state.bindings.screenMode == .media else { + return + } + + updateWithTimelineViewState(timelineViewState) + } + .store(in: &cancellables) + + filesTimelineViewModel.context.$viewState.sink { [weak self] timelineViewState in + guard let self, state.bindings.screenMode == .files else { + return + } + + updateWithTimelineViewState(timelineViewState) + } + .store(in: &cancellables) + + updateWithTimelineViewState(activeTimelineViewModel.context.viewState) + } + + // MARK: - Public + + override func process(viewAction: MediaEventsTimelineScreenViewAction) { + MXLog.info("View model: received view action: \(viewAction)") + + switch viewAction { + case .changedScreenMode: + updateWithTimelineViewState(activeTimelineViewModel.context.viewState) + case .oldestItemDidAppear: + isOldestItemVisible = true + backPaginateIfNecessary(paginationStatus: activeTimelineViewModel.context.viewState.timelineState.paginationState.backward) + case .oldestItemDidDisappear: + isOldestItemVisible = false + } + } + + // MARK: - Private + + private func updateWithTimelineViewState(_ timelineViewState: TimelineViewState) { + state.items = timelineViewState.timelineState.itemViewStates.filter { itemViewState in + switch itemViewState.type { + case .image, .video: + state.bindings.screenMode == .media + case .audio, .file: + state.bindings.screenMode == .files + default: + false + } + }.reversed() + + state.isBackPaginating = (timelineViewState.timelineState.paginationState.backward == .paginating) + backPaginateIfNecessary(paginationStatus: timelineViewState.timelineState.paginationState.backward) + } + + private func backPaginateIfNecessary(paginationStatus: PaginationStatus) { + if paginationStatus == .idle, isOldestItemVisible { + activeTimelineViewModel.context.send(viewAction: .paginateBackwards) + } + } +} diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModelProtocol.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModelProtocol.swift new file mode 100644 index 0000000000..d6fd4f6c7a --- /dev/null +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModelProtocol.swift @@ -0,0 +1,14 @@ +// +// 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 + +@MainActor +protocol MediaEventsTimelineScreenViewModelProtocol { + var actionsPublisher: AnyPublisher { get } + var context: MediaEventsTimelineScreenViewModelType.Context { get } +} diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift new file mode 100644 index 0000000000..b5ace25424 --- /dev/null +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift @@ -0,0 +1,173 @@ +// +// Copyright 2022-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 MediaEventsTimelineScreen: View { + @ObservedObject var context: MediaEventsTimelineScreenViewModel.Context + + var body: some View { + content + .navigationBarTitleDisplayMode(.inline) + .background(.compound.bgCanvasDefault) + // Doesn't play well with the transformed scrollView + .toolbarBackground(.visible, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .principal) { + Picker("", selection: $context.screenMode) { + Text(L10n.screenMediaBrowserListModeMedia) + .padding() + .tag(MediaEventsTimelineScreenMode.media) + Text(L10n.screenMediaBrowserListModeFiles) + .padding() + .tag(MediaEventsTimelineScreenMode.files) + } + .pickerStyle(.segmented) + } + } + } + + @ViewBuilder + private var content: some View { + ScrollView { + Group { + let columns = [GridItem(.adaptive(minimum: 80, maximum: 150), spacing: 1)] + LazyVGrid(columns: columns, alignment: .center, spacing: 1) { + ForEach(context.viewState.items) { item in + Color.clear // Let the image aspect fill in place + .aspectRatio(1, contentMode: .fill) + .overlay { + viewForTimelineItem(item) + } + .clipped() + .scaleEffect(.init(width: 1, height: -1)) + } + } + + // Needs to be wrapped in a LazyStack otherwise appearance calls don't trigger + LazyVStack(spacing: 0) { + Rectangle() + .frame(height: 44) + .foregroundStyle(.compound.bgCanvasDefault) + .overlay { + if context.viewState.isBackPaginating { + ProgressView() + } + } + .onAppear { + context.send(viewAction: .oldestItemDidAppear) + } + .onDisappear { + context.send(viewAction: .oldestItemDidDisappear) + } + } + } + } + .scaleEffect(.init(width: 1, height: -1)) + .onChange(of: context.screenMode) { _, _ in + context.send(viewAction: .changedScreenMode) + } + } + + @ViewBuilder func viewForTimelineItem(_ item: RoomTimelineItemViewState) -> some View { + switch item.type { + case .image(let timelineItem): + #warning("Make this work for gifs") + LoadableImage(mediaSource: timelineItem.content.thumbnailInfo?.source ?? timelineItem.content.imageInfo.source, + mediaType: .timelineItem(uniqueID: timelineItem.id.uniqueID.id), + blurhash: timelineItem.content.blurhash, + size: timelineItem.content.thumbnailInfo?.size ?? timelineItem.content.imageInfo.size, + mediaProvider: context.mediaProvider) { + placeholder + } + .mediaItemAspectRatio(imageInfo: timelineItem.content.thumbnailInfo ?? timelineItem.content.imageInfo) + case .video(let timelineItem): + if let thumbnailSource = timelineItem.content.thumbnailInfo?.source { + LoadableImage(mediaSource: thumbnailSource, + mediaType: .timelineItem(uniqueID: timelineItem.id.uniqueID.id), + blurhash: timelineItem.content.blurhash, + size: timelineItem.content.thumbnailInfo?.size, + mediaProvider: context.mediaProvider) { imageView in + imageView + .overlay { playIcon } + } placeholder: { + placeholder + } + .mediaItemAspectRatio(imageInfo: timelineItem.content.thumbnailInfo) + } else { + playIcon + } + case .separator(let timelineItem): + Text(timelineItem.text) + default: + EmptyView() + } + } + + private var playIcon: some View { + Image(systemName: "play.circle.fill") + .resizable() + .frame(width: 50, height: 50) + .background(.ultraThinMaterial, in: Circle()) + .foregroundColor(.white) + } + + private var placeholder: some View { + Rectangle() + .foregroundColor(.compound._bgBubbleIncoming) + .opacity(0.3) + } +} + +extension View { + /// Constrains the max height of a media item in the timeline, whilst preserving its aspect ratio. + @ViewBuilder + func mediaItemAspectRatio(imageInfo: ImageInfoProxy?) -> some View { + aspectRatio(imageInfo?.aspectRatio, contentMode: .fill) + } +} + +// MARK: - Previews + +struct MediaEventsTimelineScreen_Previews: PreviewProvider, TestablePreview { + static let timelineViewModel: TimelineViewModel = { + let timelineController = MockRoomTimelineController(timelineKind: .media) + return TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")), + timelineController: timelineController, + mediaProvider: MediaProviderMock(configuration: .init()), + mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), + userIndicatorController: UserIndicatorControllerMock(), + appMediator: AppMediatorMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings)) + }() + + static let mediaViewModel = MediaEventsTimelineScreenViewModel(mediaTimelineViewModel: timelineViewModel, + filesTimelineViewModel: timelineViewModel, + mediaProvider: MediaProviderMock(configuration: .init()), + screenMode: .media) + + static let filesViewModel = MediaEventsTimelineScreenViewModel(mediaTimelineViewModel: timelineViewModel, + filesTimelineViewModel: timelineViewModel, + mediaProvider: MediaProviderMock(configuration: .init()), + screenMode: .files) + + static var previews: some View { + NavigationStack { + MediaEventsTimelineScreen(context: mediaViewModel.context) + .previewDisplayName("Media") + } + + NavigationStack { + MediaEventsTimelineScreen(context: filesViewModel.context) + .previewDisplayName("Files") + } + } +} diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift index 80dcd267cd..9d2da94731 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift @@ -69,7 +69,7 @@ struct PinnedEventsTimelineScreen: View { TimelineView() .id(timelineContext.viewState.roomID) .environmentObject(timelineContext) - .environment(\.focussedEventID, timelineContext.viewState.timelineViewState.focussedEvent?.eventID) + .environment(\.focussedEventID, timelineContext.viewState.timelineState.focussedEvent?.eventID) } } diff --git a/ElementX/Sources/Screens/RoomChangeRolesScreen/View/RoomChangeRolesScreenSelectedItem.swift b/ElementX/Sources/Screens/RoomChangeRolesScreen/View/RoomChangeRolesScreenSelectedItem.swift index 435fd33a97..b688c2612f 100644 --- a/ElementX/Sources/Screens/RoomChangeRolesScreen/View/RoomChangeRolesScreenSelectedItem.swift +++ b/ElementX/Sources/Screens/RoomChangeRolesScreen/View/RoomChangeRolesScreenSelectedItem.swift @@ -60,8 +60,7 @@ struct RoomChangeRolesScreenSelectedItem_Previews: PreviewProvider, TestablePrev HStack(spacing: 12) { ForEach(members, id: \.id) { member in RoomChangeRolesScreenSelectedItem(member: member, - mediaProvider: MediaProviderMock(configuration: .init()), - dismissAction: { }) + mediaProvider: MediaProviderMock(configuration: .init())) { } .frame(width: 72) } } diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenCoordinator.swift b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenCoordinator.swift index 43e2f1b2aa..f758a84fc4 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenCoordinator.swift @@ -29,6 +29,7 @@ enum RoomDetailsScreenCoordinatorAction { case presentRolesAndPermissionsScreen case presentCall case presentPinnedEventsTimeline + case presentMediaEventsTimeline case presentKnockingRequestsListScreen } @@ -80,6 +81,8 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.presentCall) case .displayPinnedEventsTimeline: actionsSubject.send(.presentPinnedEventsTimeline) + case .displayMediaEventsTimeline: + actionsSubject.send(.presentMediaEventsTimeline) case .displayKnockingRequests: actionsSubject.send(.presentKnockingRequestsListScreen) } diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift index cfd247cdc3..a7a74dca24 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift @@ -22,6 +22,7 @@ enum RoomDetailsScreenViewModelAction { case requestRolesAndPermissionsPresentation case startCall case displayPinnedEventsTimeline + case displayMediaEventsTimeline case displayKnockingRequests } @@ -48,13 +49,15 @@ struct RoomDetailsScreenViewState: BindableState { var notificationSettingsState: RoomDetailsNotificationSettingsState = .loading var canJoinCall = false var pinnedEventsActionState = RoomDetailsScreenPinnedEventsActionState.loading + var knockingEnabled = false var isKnockableRoom = false - var canSeeKnockingRequests: Bool { knockingEnabled && dmRecipient == nil && isKnockableRoom && (canInviteUsers || canKickUsers || canBanUsers) } + var mediaBrowserEnabled = false + var canEdit: Bool { !isDirect && (canEditRoomName || canEditRoomTopic || canEditRoomAvatar) } @@ -197,6 +200,7 @@ enum RoomDetailsScreenViewAction { case processTapRolesAndPermissions case processTapCall case processTapPinnedEvents + case processTapMediaEvents case processTapRequestsToJoin } diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift index a7a8cbd856..32f088d3ce 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift @@ -79,6 +79,10 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr .weakAssign(to: \.state.knockingEnabled, on: self) .store(in: &cancellables) + appSettings.$mediaBrowserEnabled + .weakAssign(to: \.state.mediaBrowserEnabled, on: self) + .store(in: &cancellables) + appMediator.networkMonitor.reachabilityPublisher .filter { $0 == .reachable } .receive(on: DispatchQueue.main) @@ -164,6 +168,8 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr case .processTapPinnedEvents: analyticsService.trackInteraction(name: .PinnedMessageRoomInfoButton) actionsSubject.send(.displayPinnedEventsTimeline) + case .processTapMediaEvents: + actionsSubject.send(.displayMediaEventsTimeline) case .processTapRequestsToJoin: actionsSubject.send(.displayKnockingRequests) } @@ -207,8 +213,8 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr .receive(on: DispatchQueue.main) .sink { [weak self, ownUserID = roomProxy.ownUserID] members in guard let self else { return } - let accountOwner = members.first(where: { $0.userID == ownUserID }) - let dmRecipient = members.first(where: { $0.userID != ownUserID }) + let accountOwner = members.first { $0.userID == ownUserID } + let dmRecipient = members.first { $0.userID != ownUserID } self.dmRecipient = dmRecipient self.state.dmRecipient = dmRecipient.map(RoomMemberDetails.init(withProxy:)) self.state.accountOwner = accountOwner.map(RoomMemberDetails.init(withProxy:)) diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift b/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift index 6a8613d167..bb1fb40a8f 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift @@ -164,9 +164,9 @@ struct RoomDetailsScreen: View { ListRow(label: .default(title: L10n.screenRoomDetailsPinnedEventsRowTitle, icon: \.pin), details: context.viewState.pinnedEventsActionState.isLoading ? .isWaiting(true) : .title(context.viewState.pinnedEventsActionState.count), - kind: context.viewState.pinnedEventsActionState.isLoading ? .label : .navigationLink(action: { + kind: context.viewState.pinnedEventsActionState.isLoading ? .label : .navigationLink { context.send(viewAction: .processTapPinnedEvents) - })) + }) .disabled(context.viewState.pinnedEventsActionState.isLoading) if context.viewState.canSeeKnockingRequests { @@ -184,6 +184,13 @@ struct RoomDetailsScreen: View { context.send(viewAction: .processTapPolls) }) .accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.pollsHistory) + + if context.viewState.mediaBrowserEnabled { + ListRow(label: .default(title: L10n.screenMediaBrowserTitle, icon: \.image), + kind: .navigationLink { + context.send(viewAction: .processTapMediaEvents) + }) + } } } diff --git a/ElementX/Sources/Screens/RoomMemberDetailsScreen/View/RoomMemberDetailsScreen.swift b/ElementX/Sources/Screens/RoomMemberDetailsScreen/View/RoomMemberDetailsScreen.swift index c075c5618f..490d3a7ae6 100644 --- a/ElementX/Sources/Screens/RoomMemberDetailsScreen/View/RoomMemberDetailsScreen.swift +++ b/ElementX/Sources/Screens/RoomMemberDetailsScreen/View/RoomMemberDetailsScreen.swift @@ -46,8 +46,7 @@ struct RoomMemberDetailsScreen: View { AvatarHeaderView(user: UserProfileProxy(userID: context.viewState.userID), isVerified: context.viewState.showVerifiedBadge, avatarSize: .user(on: .memberDetails), - mediaProvider: context.mediaProvider, - footer: { }) + mediaProvider: context.mediaProvider) { } } } diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift index c8e77830c2..0dda2a5271 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift @@ -586,14 +586,14 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool private func makeCreateWithTextAlertInfo(urlBinding: Binding, textBinding: Binding) -> AlertInfo { AlertInfo(id: UUID(), title: L10n.richTextEditorCreateLink, - primaryButton: AlertInfo.AlertButton(title: L10n.actionCancel, action: { + primaryButton: AlertInfo.AlertButton(title: L10n.actionCancel) { self.restoreComposerSelectedRange() - }), - secondaryButton: AlertInfo.AlertButton(title: L10n.actionSave, action: { + }, + secondaryButton: AlertInfo.AlertButton(title: L10n.actionSave) { self.restoreComposerSelectedRange() self.createLinkWithText() - }), + }, textFields: [AlertInfo.AlertTextField(placeholder: L10n.commonText, text: textBinding, autoCapitalization: .never, @@ -607,14 +607,14 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool private func makeSetUrlAlertInfo(urlBinding: Binding, isEdit: Bool) -> AlertInfo { AlertInfo(id: UUID(), title: isEdit ? L10n.richTextEditorEditLink : L10n.richTextEditorCreateLink, - primaryButton: AlertInfo.AlertButton(title: L10n.actionCancel, action: { + primaryButton: AlertInfo.AlertButton(title: L10n.actionCancel) { self.restoreComposerSelectedRange() - }), - secondaryButton: AlertInfo.AlertButton(title: L10n.actionSave, action: { + }, + secondaryButton: AlertInfo.AlertButton(title: L10n.actionSave) { self.restoreComposerSelectedRange() self.setLink() - }), + }, textFields: [AlertInfo.AlertTextField(placeholder: L10n.richTextEditorUrlPlaceholder, text: urlBinding, autoCapitalization: .never, @@ -624,16 +624,16 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool private func makeEditChoiceAlertInfo(urlBinding: Binding) -> AlertInfo { AlertInfo(id: UUID(), title: L10n.richTextEditorEditLink, - primaryButton: AlertInfo.AlertButton(title: L10n.actionRemove, role: .destructive, action: { + primaryButton: AlertInfo.AlertButton(title: L10n.actionRemove, role: .destructive) { self.restoreComposerSelectedRange() self.removeLinks() - }), - verticalButtons: [AlertInfo.AlertButton(title: L10n.actionEdit, action: { + }, + verticalButtons: [AlertInfo.AlertButton(title: L10n.actionEdit) { self.state.bindings.alertInfo = nil DispatchQueue.main.async { self.state.bindings.alertInfo = self.makeSetUrlAlertInfo(urlBinding: urlBinding, isEdit: true) } - })]) + }]) } private func restoreComposerSelectedRange() { diff --git a/ElementX/Sources/Screens/RoomScreen/View/KnockRequestsBannerView.swift b/ElementX/Sources/Screens/RoomScreen/View/KnockRequestsBannerView.swift index ae33410794..e21a07c644 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/KnockRequestsBannerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/KnockRequestsBannerView.swift @@ -102,9 +102,9 @@ private struct SingleKnockRequestBannerContent: View { Button(L10n.screenRoomSingleKnockRequestViewButtonTitle, action: onViewAll) .buttonStyle(.compound(.secondary, size: .medium)) if let onAccept { - Button(L10n.screenRoomSingleKnockRequestAcceptButtonTitle, action: { + Button(L10n.screenRoomSingleKnockRequestAcceptButtonTitle) { onAccept(request.userID) - }) + } .buttonStyle(.compound(.primary, size: .medium)) } } @@ -166,9 +166,9 @@ private struct KnockRequestsBannerDismissButton: View { CompoundIcon(\.close, size: .medium, relativeTo: .compound.bodySMSemibold) .foregroundColor(.compound.iconTertiary) } - .alignmentGuide(.top, computeValue: { _ in + .alignmentGuide(.top) { _ in 3 - }) + } } } @@ -188,15 +188,16 @@ struct KnockRequestsBannerView_Previews: PreviewProvider, TestablePreview { ] static var previews: some View { - KnockRequestsBannerView(requests: singleRequest, onDismiss: { }, onAccept: { _ in }, onViewAll: { }) + KnockRequestsBannerView(requests: singleRequest) { } onAccept: { _ in } onViewAll: { } .previewDisplayName("Single Request") + // swiftlint:disable:next trailing_closure KnockRequestsBannerView(requests: singleRequest, onDismiss: { }, onAccept: nil, onViewAll: { }) .previewDisplayName("Single Request, no accept action") - KnockRequestsBannerView(requests: singleRequestWithReason, onDismiss: { }, onAccept: { _ in }, onViewAll: { }) + KnockRequestsBannerView(requests: singleRequestWithReason) { } onAccept: { _ in } onViewAll: { } .previewDisplayName("Single Request with reason") - KnockRequestsBannerView(requests: singleRequestNoDisplayName, onDismiss: { }, onAccept: { _ in }, onViewAll: { }) + KnockRequestsBannerView(requests: singleRequestNoDisplayName) { } onAccept: { _ in } onViewAll: { } .previewDisplayName("Single Request, No Display Name") - KnockRequestsBannerView(requests: multipleRequests, onDismiss: { }, onAccept: { _ in }, onViewAll: { }) + KnockRequestsBannerView(requests: multipleRequests) { } onAccept: { _ in } onViewAll: { } .previewDisplayName("Multiple Requests") } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index c6f1792259..8596be01ac 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -113,7 +113,7 @@ struct RoomScreen: View { TimelineView() .id(timelineContext.viewState.roomID) .environmentObject(timelineContext) - .environment(\.focussedEventID, timelineContext.viewState.timelineViewState.focussedEvent?.eventID) + .environment(\.focussedEventID, timelineContext.viewState.timelineState.focussedEvent?.eventID) .overlay(alignment: .bottomTrailing) { scrollToBottomButton } @@ -183,7 +183,7 @@ struct RoomScreen: View { } private var isAtBottomAndLive: Bool { - timelineContext.isScrolledToBottom && timelineContext.viewState.timelineViewState.isLive + timelineContext.isScrolledToBottom && timelineContext.viewState.timelineState.isLive } @ViewBuilder diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index d8a496c8f0..ce43cbc9cc 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -51,6 +51,7 @@ protocol DeveloperOptionsProtocol: AnyObject { var elementCallBaseURLOverride: URL? { get set } var knockingEnabled: Bool { get set } var createMediaCaptionsEnabled: Bool { get set } + var mediaBrowserEnabled: Bool { get set } } extension AppSettings: DeveloperOptionsProtocol { } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index de6909cc5f..c4c8a6b015 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -49,7 +49,7 @@ struct DeveloperOptionsScreen: View { } } - Section("Timeline") { + Section("Room") { Toggle(isOn: $context.hideTimelineMedia) { Text("Hide image & video previews") } @@ -57,6 +57,10 @@ struct DeveloperOptionsScreen: View { Toggle(isOn: $context.createMediaCaptionsEnabled) { Text("Allow creation of media captions") } + + Toggle(isOn: $context.mediaBrowserEnabled) { + Text("Enable the media browser") + } } Section("Join rules") { diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsEditScreen/NotificationSettingsEditScreenViewModel.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsEditScreen/NotificationSettingsEditScreenViewModel.swift index 934b383798..ee46ec9088 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsEditScreen/NotificationSettingsEditScreenViewModel.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsEditScreen/NotificationSettingsEditScreenViewModel.swift @@ -123,7 +123,7 @@ class NotificationSettingsEditScreenViewModel: NotificationSettingsEditScreenVie guard !Task.isCancelled else { return } let filteredRoomsSummary = roomSummaryProvider.roomListPublisher.value.filter { summary in - roomsWithUserDefinedRules.contains(where: { summary.id == $0 }) + roomsWithUserDefinedRules.contains { summary.id == $0 } } var roomsWithUserDefinedMode: [NotificationSettingsEditScreenRoom] = [] @@ -142,7 +142,7 @@ class NotificationSettingsEditScreenViewModel: NotificationSettingsEditScreenVie } // Sort the room list - roomsWithUserDefinedMode.sort(by: { $0.name.localizedCompare($1.name) == .orderedAscending }) + roomsWithUserDefinedMode.sort { $0.name.localizedCompare($1.name) == .orderedAscending } state.roomsWithUserDefinedMode = roomsWithUserDefinedMode } diff --git a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift index 2e610569ca..0dfd0d328f 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift @@ -391,7 +391,6 @@ class TimelineInteractionHandler { // MARK: Audio Playback - // swiftlint:disable:next cyclomatic_complexity func playPauseAudio(for itemID: TimelineItemIdentifier) async { MXLog.info("Toggle play/pause audio for itemID \(itemID)") guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) else { diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index 2108e635a0..168c6bcc3c 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -92,7 +92,7 @@ struct TimelineViewState: BindableState { var showLoading = false var showReadReceipts = false var isEncryptedOneToOneRoom = false - var timelineViewState: TimelineState // check the doc before changing this + var timelineState: TimelineState // check the doc before changing this var ownUserID: String var canCurrentUserRedactOthers = false diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index 3777feed1d..358fde08d5 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -78,7 +78,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { super.init(initialViewState: TimelineViewState(isPinnedEventsTimeline: timelineController.timelineKind == .pinned, roomID: roomProxy.id, isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom, - timelineViewState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }), + timelineState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }), ownUserID: roomProxy.ownUserID, isViewSourceEnabled: appSettings.viewSourceEnabled, isCreateMediaCaptionsEnabled: appSettings.createMediaCaptionsEnabled, @@ -107,6 +107,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { return self.timelineInteractionHandler.audioPlayerState(for: itemID) } + state.timelineState.paginationState = timelineController.paginationState buildTimelineViews(timelineItems: timelineController.timelineItems) updateMembers(roomProxy.membersPublisher.value) @@ -174,7 +175,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { case .scrolledToFocussedItem: didScrollToFocussedItem() case .hasSwitchedTimeline: - Task { state.timelineViewState.isSwitchingTimelines = false } + Task { state.timelineState.isSwitchingTimelines = false } case let .hasScrolled(direction): actionsSubject.send(.hasScrolled(direction: direction)) case .setOpenURLAction(let action): @@ -215,8 +216,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { } func focusOnEvent(eventID: String) async { - if state.timelineViewState.hasLoadedItem(with: eventID) { - state.timelineViewState.focussedEvent = .init(eventID: eventID, appearance: .animated) + if state.timelineState.hasLoadedItem(with: eventID) { + state.timelineState.focussedEvent = .init(eventID: eventID, appearance: .animated) return } @@ -225,7 +226,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { switch await timelineController.focusOnEvent(eventID, timelineSize: Constants.detachedTimelineSize) { case .success: - state.timelineViewState.focussedEvent = .init(eventID: eventID, appearance: .immediate) + state.timelineState.focussedEvent = .init(eventID: eventID, appearance: .immediate) case .failure(let error): MXLog.error("Failed to focus on event \(eventID)") @@ -244,9 +245,9 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { } private func didScrollToFocussedItem() { - if var focussedEvent = state.timelineViewState.focussedEvent { + if var focussedEvent = state.timelineState.focussedEvent { focussedEvent.appearance = .hasAppeared - state.timelineViewState.focussedEvent = focussedEvent + state.timelineState.focussedEvent = focussedEvent hideFocusLoadingIndicator() } } @@ -362,16 +363,16 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { case .updatedTimelineItems(let updatedItems, let isSwitchingTimelines): buildTimelineViews(timelineItems: updatedItems, isSwitchingTimelines: isSwitchingTimelines) case .paginationState(let paginationState): - if state.timelineViewState.paginationState != paginationState { - state.timelineViewState.paginationState = paginationState + if state.timelineState.paginationState != paginationState { + state.timelineState.paginationState = paginationState } case .isLive(let isLive): - if state.timelineViewState.isLive != isLive { - state.timelineViewState.isLive = isLive + if state.timelineState.isLive != isLive { + state.timelineState.isLive = isLive // Remove the event highlight *only* when transitioning from non-live to live. - if isLive, state.timelineViewState.focussedEvent != nil { - state.timelineViewState.focussedEvent = nil + if isLive, state.timelineState.focussedEvent != nil { + state.timelineState.focussedEvent = nil } } } @@ -516,7 +517,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { break } - if state.timelineViewState.paginationState.forward == .timelineEndReached { + if state.timelineState.paginationState.forward == .timelineEndReached { focusLive() } @@ -525,8 +526,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { } private func scrollToBottom() { - if state.timelineViewState.isLive { - state.timelineViewState.scrollToBottomPublisher.send(()) + if state.timelineState.isLive { + state.timelineState.scrollToBottomPublisher.send(()) } else { focusLive() } @@ -703,14 +704,14 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { } if isSwitchingTimelines { - state.timelineViewState.isSwitchingTimelines = true + state.timelineState.isSwitchingTimelines = true } - state.timelineViewState.itemsDictionary = timelineItemsDictionary + state.timelineState.itemsDictionary = timelineItemsDictionary } private func updateViewState(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) -> RoomTimelineItemViewState { - if let timelineItemViewState = state.timelineViewState.itemsDictionary[item.id.uniqueID] { + if let timelineItemViewState = state.timelineState.itemsDictionary[item.id.uniqueID] { timelineItemViewState.groupStyle = groupStyle timelineItemViewState.type = .init(item: item) return timelineItemViewState @@ -744,7 +745,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { userIndicatorController.alertInfo = .init(id: .init(), title: L10n.screenRoomInviteAgainAlertTitle, message: L10n.screenRoomInviteAgainAlertMessage, - primaryButton: .init(title: L10n.actionInvite, action: { [weak self] in self?.inviteOtherDMUserBack() }), + primaryButton: .init(title: L10n.actionInvite) { [weak self] in self?.inviteOtherDMUserBack() }, secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil)) } @@ -830,14 +831,14 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { state.bindings.alertInfo = .init(id: type, title: L10n.dialogPermissionMicrophoneTitleIos(InfoPlistReader.main.bundleDisplayName), message: L10n.dialogPermissionMicrophoneDescriptionIos, - primaryButton: .init(title: L10n.commonSettings, action: { [weak self] in self?.appMediator.openAppSettings() }), + primaryButton: .init(title: L10n.commonSettings) { [weak self] in self?.appMediator.openAppSettings() }, secondaryButton: .init(title: L10n.actionNotNow, role: .cancel, action: nil)) case .pollEndConfirmation(let pollStartID): state.bindings.alertInfo = .init(id: type, title: L10n.actionEndPoll, message: L10n.commonPollEndConfirmation, primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil), - secondaryButton: .init(title: L10n.actionOk, action: { self.timelineInteractionHandler.endPoll(pollStartID: pollStartID) })) + secondaryButton: .init(title: L10n.actionOk) { self.timelineInteractionHandler.endPoll(pollStartID: pollStartID) }) case .sendingFailed: state.bindings.alertInfo = .init(id: type, title: L10n.commonSendingFailed, diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift index 10f2d9c924..c20faa2d6d 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift @@ -400,7 +400,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview static var mockTimeline: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { - ForEach(viewModel.state.timelineViewState.itemViewStates) { viewState in + ForEach(viewModel.state.timelineState.itemViewStates) { viewState in RoomTimelineItemView(viewState: viewState) } } diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineStyler.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineStyler.swift index fc9d90feb7..b4cad8f695 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineStyler.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineStyler.swift @@ -79,7 +79,7 @@ struct TimelineItemStyler_Previews: PreviewProvider, TestablePreview { }() static let sendingLast: TextRoomTimelineItem = { - let id = viewModel.state.timelineViewState.uniqueIDs.last ?? .init(id: UUID().uuidString) + let id = viewModel.state.timelineState.uniqueIDs.last ?? .init(id: UUID().uuidString) var result = TextRoomTimelineItem(id: .event(uniqueID: id, eventOrTransactionID: .eventId(eventId: UUID().uuidString)), timestamp: .mock, isOutgoing: true, @@ -99,7 +99,7 @@ struct TimelineItemStyler_Previews: PreviewProvider, TestablePreview { }() static let sentLast: TextRoomTimelineItem = { - let id = viewModel.state.timelineViewState.uniqueIDs.last ?? .init(id: UUID().uuidString) + let id = viewModel.state.timelineState.uniqueIDs.last ?? .init(id: UUID().uuidString) let result = TextRoomTimelineItem(id: .event(uniqueID: id, eventOrTransactionID: .eventId(eventId: UUID().uuidString)), timestamp: .mock, isOutgoing: true, diff --git a/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineItemStatusView.swift b/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineItemStatusView.swift index 1729d149d6..3a563b9778 100644 --- a/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineItemStatusView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineItemStatusView.swift @@ -14,7 +14,7 @@ struct TimelineItemStatusView: View { @EnvironmentObject private var context: TimelineViewModel.Context private var isLastOutgoingMessage: Bool { - timelineItem.isOutgoing && context.viewState.timelineViewState.uniqueIDs.last == timelineItem.id.uniqueID + timelineItem.isOutgoing && context.viewState.timelineState.uniqueIDs.last == timelineItem.id.uniqueID } var body: some View { diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineView.swift index 5315b57884..210e269d75 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineView.swift @@ -16,7 +16,7 @@ struct TimelineView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> TimelineTableViewController { let tableViewController = TimelineTableViewController(coordinator: context.coordinator, isScrolledToBottom: $viewModelContext.isScrolledToBottom, - scrollToBottomPublisher: viewModelContext.viewState.timelineViewState.scrollToBottomPublisher) + scrollToBottomPublisher: viewModelContext.viewState.timelineState.scrollToBottomPublisher) // Needs to be dispatched on main asynchronously otherwise we get a runtime warning DispatchQueue.main.async { viewModelContext.send(viewAction: .setOpenURLAction(openURL)) @@ -44,21 +44,21 @@ struct TimelineView: UIViewControllerRepresentable { /// Updates the specified table view's properties from the current view state. func update(tableViewController: TimelineTableViewController) { - if tableViewController.isSwitchingTimelines != context.viewState.timelineViewState.isSwitchingTimelines { + if tableViewController.isSwitchingTimelines != context.viewState.timelineState.isSwitchingTimelines { // Must come before timelineItemsDictionary in order to disable animations. - tableViewController.isSwitchingTimelines = context.viewState.timelineViewState.isSwitchingTimelines + tableViewController.isSwitchingTimelines = context.viewState.timelineState.isSwitchingTimelines } - if tableViewController.timelineItemsDictionary != context.viewState.timelineViewState.itemsDictionary { - tableViewController.timelineItemsDictionary = context.viewState.timelineViewState.itemsDictionary + if tableViewController.timelineItemsDictionary != context.viewState.timelineState.itemsDictionary { + tableViewController.timelineItemsDictionary = context.viewState.timelineState.itemsDictionary } - if tableViewController.paginationState != context.viewState.timelineViewState.paginationState { - tableViewController.paginationState = context.viewState.timelineViewState.paginationState + if tableViewController.paginationState != context.viewState.timelineState.paginationState { + tableViewController.paginationState = context.viewState.timelineState.paginationState } - if tableViewController.isLive != context.viewState.timelineViewState.isLive { - tableViewController.isLive = context.viewState.timelineViewState.isLive + if tableViewController.isLive != context.viewState.timelineState.isLive { + tableViewController.isLive = context.viewState.timelineState.isLive } - if tableViewController.focussedEvent != context.viewState.timelineViewState.focussedEvent { - tableViewController.focussedEvent = context.viewState.timelineViewState.focussedEvent + if tableViewController.focussedEvent != context.viewState.timelineState.focussedEvent { + tableViewController.focussedEvent = context.viewState.timelineState.focussedEvent } if tableViewController.hideTimelineMedia != context.viewState.hideTimelineMedia { tableViewController.hideTimelineMedia = context.viewState.hideTimelineMedia diff --git a/ElementX/Sources/Screens/UserProfileScreen/View/UserProfileScreen.swift b/ElementX/Sources/Screens/UserProfileScreen/View/UserProfileScreen.swift index 7e90f0fdde..228bc3a4ef 100644 --- a/ElementX/Sources/Screens/UserProfileScreen/View/UserProfileScreen.swift +++ b/ElementX/Sources/Screens/UserProfileScreen/View/UserProfileScreen.swift @@ -43,8 +43,7 @@ struct UserProfileScreen: View { AvatarHeaderView(user: UserProfileProxy(userID: context.viewState.userID), isVerified: context.viewState.showVerifiedBadge, avatarSize: .user(on: .memberDetails), - mediaProvider: context.mediaProvider, - footer: { }) + mediaProvider: context.mediaProvider) { } } } diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 41d264eda0..c541cd339d 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -506,7 +506,7 @@ class ClientProxy: ClientProxyProtocol { } if !roomSummaryProvider.statePublisher.value.isLoaded { - _ = await roomSummaryProvider.statePublisher.values.first(where: { $0.isLoaded }) + _ = await roomSummaryProvider.statePublisher.values.first { $0.isLoaded } } if shouldAwait { diff --git a/ElementX/Sources/Services/ElementCall/ElementCallService.swift b/ElementX/Sources/Services/ElementCall/ElementCallService.swift index c531012bcf..b35838e7af 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallService.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallService.swift @@ -298,11 +298,11 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe .infoPublisher .compactMap { ($0.hasRoomCall, $0.activeRoomCallParticipants) } .removeDuplicates { $0 == $1 } - .drop(while: { hasRoomCall, _ in + .drop { hasRoomCall, _ in // Filter all updates before hasRoomCall becomes `true`. Then we can correctly // detect its change to `false` to stop ringing when the caller hangs up. !hasRoomCall - }) + } .sink { [weak self] hasOngoingCall, activeRoomCallParticipants in guard let self else { return } diff --git a/ElementX/Sources/Services/NotificationSettings/NotificationSettingsProxy.swift b/ElementX/Sources/Services/NotificationSettings/NotificationSettingsProxy.swift index 0b8502ce31..8d130ebf5f 100644 --- a/ElementX/Sources/Services/NotificationSettings/NotificationSettingsProxy.swift +++ b/ElementX/Sources/Services/NotificationSettings/NotificationSettingsProxy.swift @@ -120,7 +120,7 @@ final class NotificationSettingsProxy: NotificationSettingsProxyProtocol { // as in this case no API call is made by the RustSDK and the push rules are therefore not updated. _ = await callbacks .timeout(.seconds(2.0), scheduler: DispatchQueue.main, options: nil, customError: nil) - .values.first(where: { $0 == .settingsDidChange }) + .values.first { $0 == .settingsDidChange } } @MainActor diff --git a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift index 021d5df53d..16b149e1e5 100644 --- a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift +++ b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift @@ -166,6 +166,19 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol { } } + func messageFilteredTimeline(allowedMessageTypes: [RoomMessageEventMessageType]) async -> Result { + do { + let timeline = try await TimelineProxy(timeline: room.messageFilteredTimeline(internalIdPrefix: nil, allowedMessageTypes: allowedMessageTypes), + kind: .media) + await timeline.subscribeForUpdates() + + return .success(timeline) + } catch { + MXLog.error("Failed retrieving media events timeline with error: \(error)") + return .failure(.sdkError(error)) + } + } + func redact(_ eventID: String) async -> Result { do { try await room.redact(eventId: eventID, reason: nil) diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index a19a735e79..d95f6b79a7 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -70,6 +70,8 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol { func timelineFocusedOnEvent(eventID: String, numberOfEvents: UInt16) async -> Result + func messageFilteredTimeline(allowedMessageTypes: [RoomMessageEventMessageType]) async -> Result + func redact(_ eventID: String) async -> Result func reportContent(_ eventID: String, reason: String?) async -> Result diff --git a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift index b8cf4387e2..db805e9a33 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift @@ -23,6 +23,12 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { let callbacks = PassthroughSubject() + var paginationState: PaginationState = .initial { + didSet { + callbacks.send(.paginationState(paginationState)) + } + } + var timelineItems: [RoomTimelineItemProtocol] = RoomTimelineItemFixtures.default var timelineItemsTimestamp: [TimelineItemIdentifier: Date] = [:] @@ -30,9 +36,18 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { init(timelineKind: TimelineKind = .live, listenForSignals: Bool = false) { self.timelineKind = timelineKind - callbacks.send(.paginationState(PaginationState(backward: .idle, forward: .timelineEndReached))) + paginationState = PaginationState(backward: .idle, forward: .timelineEndReached) callbacks.send(.isLive(true)) + switch timelineKind { + case .media: + timelineItems = (0..<5).reduce([]) { partialResult, _ in + partialResult + RoomTimelineItemFixtures.mediaChunk + } + default: + break + } + guard listenForSignals else { return } do { @@ -56,7 +71,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { } func paginateBackwards(requestSize: UInt16) async -> Result { - callbacks.send(.paginationState(PaginationState(backward: .paginating, forward: .timelineEndReached))) + paginationState = PaginationState(backward: .paginating, forward: .timelineEndReached) if client == nil { try? await simulateBackPagination() @@ -170,8 +185,8 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { /// Prepends the next chunk of items to the `timelineItems` array. private func simulateBackPagination() async throws { defer { - callbacks.send(.paginationState(PaginationState(backward: backPaginationResponses.isEmpty ? .timelineEndReached : .idle, - forward: .timelineEndReached))) + paginationState = PaginationState(backward: backPaginationResponses.isEmpty ? .timelineEndReached : .idle, + forward: .timelineEndReached) } guard !backPaginationResponses.isEmpty else { return } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index bdf6a5a6e1..f422c61f03 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -30,6 +30,12 @@ class RoomTimelineController: RoomTimelineControllerProtocol { } private(set) var timelineItems = [RoomTimelineItemProtocol]() + + private(set) var paginationState: PaginationState = .initial { + didSet { + callbacks.send(.paginationState(paginationState)) + } + } var roomID: String { roomProxy.id @@ -64,7 +70,8 @@ class RoomTimelineController: RoomTimelineControllerProtocol { } Task { - callbacks.send(.paginationState(PaginationState(backward: .paginating, forward: .paginating))) + paginationState = PaginationState(backward: .paginating, forward: .paginating) + switch await focusOnEvent(initialFocussedEventID, timelineSize: 100) { case .success: break @@ -368,7 +375,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol { isSwitchingTimelines = true // Inform the world that the initial items are loading from the store - callbacks.send(.paginationState(.init(backward: .paginating, forward: .paginating))) + paginationState = PaginationState(backward: .paginating, forward: .paginating) callbacks.send(.isLive(activeTimelineProvider.kind == .live)) updateTimelineItemsCancellable = activeTimelineProvider @@ -446,7 +453,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol { } callbacks.send(.updatedTimelineItems(timelineItems: newTimelineItems, isSwitchingTimelines: isNewTimeline)) - callbacks.send(.paginationState(paginationState)) + self.paginationState = paginationState } private func buildTimelineItem(for itemProxy: TimelineItemProxy) -> RoomTimelineItemProtocol? { diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift index 4e86953c25..c726c37130 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift @@ -6,6 +6,7 @@ // import Foundation +import MatrixRustSDK struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol { func buildRoomTimelineController(roomProxy: JoinedRoomProxyProtocol, @@ -20,12 +21,13 @@ struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol { appSettings: ServiceLocator.shared.settings) } - func buildRoomPinnedTimelineController(roomProxy: JoinedRoomProxyProtocol, - timelineItemFactory: RoomTimelineItemFactoryProtocol, - mediaProvider: MediaProviderProtocol) async -> RoomTimelineControllerProtocol? { + func buildPinnedEventsRoomTimelineController(roomProxy: JoinedRoomProxyProtocol, + timelineItemFactory: RoomTimelineItemFactoryProtocol, + mediaProvider: MediaProviderProtocol) async -> RoomTimelineControllerProtocol? { guard let pinnedEventsTimeline = await roomProxy.pinnedEventsTimeline else { return nil } + return RoomTimelineController(roomProxy: roomProxy, timelineProxy: pinnedEventsTimeline, initialFocussedEventID: nil, @@ -33,4 +35,21 @@ struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol { mediaProvider: mediaProvider, appSettings: ServiceLocator.shared.settings) } + + func buildMessageFilteredRoomTimelineController(allowedMessageTypes: [RoomMessageEventMessageType], + roomProxy: JoinedRoomProxyProtocol, + timelineItemFactory: RoomTimelineItemFactoryProtocol, + mediaProvider: MediaProviderProtocol) async -> Result { + switch await roomProxy.messageFilteredTimeline(allowedMessageTypes: allowedMessageTypes) { + case .success(let timelineProxy): + return .success(RoomTimelineController(roomProxy: roomProxy, + timelineProxy: timelineProxy, + initialFocussedEventID: nil, + timelineItemFactory: timelineItemFactory, + mediaProvider: mediaProvider, + appSettings: ServiceLocator.shared.settings)) + case .failure(let error): + return .failure(.roomProxyError(error)) + } + } } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactoryProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactoryProtocol.swift index 0f1093c8f5..443ac78ce0 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactoryProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactoryProtocol.swift @@ -6,6 +6,11 @@ // import Foundation +import MatrixRustSDK + +enum RoomTimelineFactoryControllerError: Error { + case roomProxyError(RoomProxyError) +} @MainActor protocol RoomTimelineControllerFactoryProtocol { @@ -13,9 +18,15 @@ protocol RoomTimelineControllerFactoryProtocol { initialFocussedEventID: String?, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) -> RoomTimelineControllerProtocol - func buildRoomPinnedTimelineController(roomProxy: JoinedRoomProxyProtocol, - timelineItemFactory: RoomTimelineItemFactoryProtocol, - mediaProvider: MediaProviderProtocol) async -> RoomTimelineControllerProtocol? + + func buildPinnedEventsRoomTimelineController(roomProxy: JoinedRoomProxyProtocol, + timelineItemFactory: RoomTimelineItemFactoryProtocol, + mediaProvider: MediaProviderProtocol) async -> RoomTimelineControllerProtocol? + + func buildMessageFilteredRoomTimelineController(allowedMessageTypes: [RoomMessageEventMessageType], + roomProxy: JoinedRoomProxyProtocol, + timelineItemFactory: RoomTimelineItemFactoryProtocol, + mediaProvider: MediaProviderProtocol) async -> Result } // sourcery: AutoMockable diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift index e32356e25a..1aa9e0a063 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift @@ -31,7 +31,12 @@ protocol RoomTimelineControllerProtocol { var roomID: String { get } var timelineKind: TimelineKind { get } + /// The currently known items, use only for setting up the intial state. var timelineItems: [RoomTimelineItemProtocol] { get } + + /// The current pagination state, use only for setting up the intial state + var paginationState: PaginationState { get } + var callbacks: PassthroughSubject { get } func processItemAppearance(_ itemID: TimelineItemIdentifier) async diff --git a/ElementX/Sources/Services/Timeline/TimelineItemContent/AggregratedReaction.swift b/ElementX/Sources/Services/Timeline/TimelineItemContent/AggregratedReaction.swift index 8baf77db5d..3903b023a5 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemContent/AggregratedReaction.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemContent/AggregratedReaction.swift @@ -42,7 +42,7 @@ extension AggregatedReaction { /// Whether to highlight the reaction, indicating that the current user sent this reaction. var isHighlighted: Bool { - senders.contains(where: { $0.id == accountOwnerID }) + senders.contains { $0.id == accountOwnerID } } /// The key to be displayed on screen. See `maxDisplayChars`. diff --git a/ElementX/Sources/Services/Timeline/TimelineItemIdentifier.swift b/ElementX/Sources/Services/Timeline/TimelineItemIdentifier.swift index a6efccb371..c65ef4ae0f 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemIdentifier.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemIdentifier.swift @@ -13,7 +13,7 @@ import MatrixRustSDK /// Its value is consistent only per timeline instance, it should **not** be used to identify an item across timeline instances. /// - eventOrTransactionID: Contains the 2 possible identifiers of an event, either it has a remote event id or /// a local transaction id, never both or none. -enum TimelineItemIdentifier: Hashable { +enum TimelineItemIdentifier: Hashable, Sendable { case event(uniqueID: TimelineUniqueId, eventOrTransactionID: EventOrTransactionId) case virtual(uniqueID: TimelineUniqueId) diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomPlaybackView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomPlaybackView.swift index 1928a55b4a..7756254709 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomPlaybackView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomPlaybackView.swift @@ -61,9 +61,10 @@ struct VoiceMessageRoomPlaybackView: View { if let url = playerState.fileURL { WaveformView(audioURL: url, configuration: .init(style: .striped(.init(color: .black, width: waveformLineWidth, spacing: waveformLinePadding)), - verticalScalingFactor: 1.0), - placeholder: { estimatedWaveformView }) - .progressMask(progress: playerState.progress) + verticalScalingFactor: 1.0)) { + estimatedWaveformView + } + .progressMask(progress: playerState.progress) } else { estimatedWaveformView } diff --git a/ElementX/Sources/Services/Timeline/TimelineProxy.swift b/ElementX/Sources/Services/Timeline/TimelineProxy.swift index 93238d41ea..6b662f8ca9 100644 --- a/ElementX/Sources/Services/Timeline/TimelineProxy.swift +++ b/ElementX/Sources/Services/Timeline/TimelineProxy.swift @@ -92,7 +92,7 @@ final class TimelineProxy: TimelineProxyProtocol { switch kind { case .live: return await paginateBackwardsOnLive(requestSize: requestSize) - case .detached: + case .detached, .media: return await focussedPaginate(.backwards, requestSize: requestSize) case .pinned: return .success(()) @@ -319,7 +319,6 @@ final class TimelineProxy: TimelineProxyProtocol { return .success(()) } - // swiftlint:disable:next function_parameter_count func sendVideo(url: URL, thumbnailURL: URL, videoInfo: VideoInfo, @@ -580,7 +579,7 @@ final class TimelineProxy: TimelineProxyProtocol { MXLog.error("Failed to subscribe to back pagination status with error: \(error)") } forwardPaginationStatusSubject.send(.timelineEndReached) - case .detached: + case .detached, .media: // Detached timelines don't support observation, set the initial state ourself. backPaginationStatusSubject.send(.idle) forwardPaginationStatusSubject.send(.idle) diff --git a/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift index d1520cbbeb..64b047b074 100644 --- a/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift @@ -13,6 +13,7 @@ enum TimelineKind { case live case detached case pinned + case media } enum TimelineProxyError: Error { diff --git a/ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorder.swift b/ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorder.swift index cd9e14f329..67a97ad59e 100644 --- a/ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorder.swift +++ b/ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorder.swift @@ -232,7 +232,7 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol { private func finalizeRecording() async -> Result { MXLog.info("finalize audio recording") - guard let url = audioRecorder.audioFileURL, audioRecorder.currentTime > 0 else { + guard audioRecorder.audioFileURL != nil, audioRecorder.currentTime > 0 else { return .failure(.previewNotAvailable) } diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index 33d08949e3..a543332db0 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -377,6 +377,12 @@ extension PreviewTests { } } + func test_mediaEventsTimelineScreen() { + for preview in MediaEventsTimelineScreen_Previews._allPreviews { + assertSnapshots(matching: preview) + } + } + func test_mediaUploadPreviewScreen() { for preview in MediaUploadPreviewScreen_Previews._allPreviews { assertSnapshots(matching: preview) diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPad-en-GB.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPad-en-GB.1.png new file mode 100644 index 0000000000..33cb092a42 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPad-en-GB.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a72db00d98af34232f15be756e5f2358920ea8b2c327949aece97755105779a +size 335736 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPad-en-GB.2.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPad-en-GB.2.png new file mode 100644 index 0000000000..f717aa8afd --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPad-en-GB.2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:da72b8fd77d8040d6d53cf4f8b02b36b1ebd4954b25c6487f7874df015f743eb +size 72305 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPad-pseudo.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPad-pseudo.1.png new file mode 100644 index 0000000000..5c40ff8c4c --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPad-pseudo.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de9881a48786a790f314fd25e1e5f76e2d91156c42d5d49337082c8023ac0de5 +size 336616 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPad-pseudo.2.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPad-pseudo.2.png new file mode 100644 index 0000000000..16a7a836a8 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPad-pseudo.2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cbdceca0d1cf597d6c1c31abb0146084fde5d183ab5acf8399badb6c1b6c7caf +size 73391 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPhone-16-en-GB.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPhone-16-en-GB.1.png new file mode 100644 index 0000000000..c91dd27350 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPhone-16-en-GB.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5e8c6353145a110c8ef00b32d6ce6edf76e093cc1aa3fdb1b62eef41af0b4e1d +size 795122 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPhone-16-en-GB.2.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPhone-16-en-GB.2.png new file mode 100644 index 0000000000..9dfbf413c9 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPhone-16-en-GB.2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e04f78541a56fcc68d4d96c4cb3c031c577fce926953710cedc515b3e0cd219 +size 31710 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPhone-16-pseudo.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPhone-16-pseudo.1.png new file mode 100644 index 0000000000..725bb4fd82 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPhone-16-pseudo.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:696f15ca06f05895a003243b041107724143bbe0815147420d50ac475952c205 +size 795870 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPhone-16-pseudo.2.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPhone-16-pseudo.2.png new file mode 100644 index 0000000000..430a12802e --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_mediaEventsTimelineScreen-iPhone-16-pseudo.2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9db638196c632227dc675d0d599d13e57d76ecf2cc7b310f94dab046316e7921 +size 32636 diff --git a/UnitTests/Sources/AttributedStringBuilderTests.swift b/UnitTests/Sources/AttributedStringBuilderTests.swift index 3c9b31bcf8..5b32fc38cf 100644 --- a/UnitTests/Sources/AttributedStringBuilderTests.swift +++ b/UnitTests/Sources/AttributedStringBuilderTests.swift @@ -79,7 +79,7 @@ class AttributedStringBuilderTests: XCTestCase { XCTAssertEqual(attributedString.runs.count, 3) - let link = attributedString.runs.first(where: { $0.link != nil })?.link + let link = attributedString.runs.first { $0.link != nil }?.link XCTAssertEqual(link?.host, "www.matrix.org") } @@ -96,7 +96,7 @@ class AttributedStringBuilderTests: XCTestCase { XCTAssertEqual(attributedString.runs.count, 3) - let link = attributedString.runs.first(where: { $0.link != nil })?.link + let link = attributedString.runs.first { $0.link != nil }?.link XCTAssertEqual(link?.host, "www.matrix.org") } @@ -113,7 +113,7 @@ class AttributedStringBuilderTests: XCTestCase { XCTAssertEqual(attributedString.runs.count, 3) - let link = attributedString.runs.first(where: { $0.link != nil })?.link + let link = attributedString.runs.first { $0.link != nil }?.link XCTAssertEqual(link?.host, "www.matrix.org") } @@ -130,7 +130,7 @@ class AttributedStringBuilderTests: XCTestCase { XCTAssertEqual(attributedString.runs.count, 3) - let link = attributedString.runs.first(where: { $0.link != nil })?.link + let link = attributedString.runs.first { $0.link != nil }?.link XCTAssertEqual(link, "https://matrix.org") } diff --git a/UnitTests/Sources/BlockedUsersScreenViewModelTests.swift b/UnitTests/Sources/BlockedUsersScreenViewModelTests.swift index 0852dede1e..1fff597533 100644 --- a/UnitTests/Sources/BlockedUsersScreenViewModelTests.swift +++ b/UnitTests/Sources/BlockedUsersScreenViewModelTests.swift @@ -20,7 +20,7 @@ class BlockedUsersScreenViewModelTests: XCTestCase { mediaProvider: MediaProviderMock(configuration: .init()), userIndicatorController: ServiceLocator.shared.userIndicatorController) - let deferred = deferFailure(viewModel.context.$viewState, timeout: 1) { $0.blockedUsers.contains(where: { $0.displayName != nil }) } + let deferred = deferFailure(viewModel.context.$viewState, timeout: 1) { $0.blockedUsers.contains { $0.displayName != nil } } try await deferred.fulfill() XCTAssertFalse(viewModel.context.viewState.blockedUsers.isEmpty) @@ -35,7 +35,7 @@ class BlockedUsersScreenViewModelTests: XCTestCase { mediaProvider: MediaProviderMock(configuration: .init()), userIndicatorController: ServiceLocator.shared.userIndicatorController) - let deferred = deferFulfillment(viewModel.context.$viewState) { $0.blockedUsers.contains(where: { $0.displayName != nil }) } + let deferred = deferFulfillment(viewModel.context.$viewState) { $0.blockedUsers.contains { $0.displayName != nil } } try await deferred.fulfill() XCTAssertFalse(viewModel.context.viewState.blockedUsers.isEmpty) diff --git a/UnitTests/Sources/ComposerToolbarViewModelTests.swift b/UnitTests/Sources/ComposerToolbarViewModelTests.swift index fbecde99de..437881a0a8 100644 --- a/UnitTests/Sources/ComposerToolbarViewModelTests.swift +++ b/UnitTests/Sources/ComposerToolbarViewModelTests.swift @@ -54,10 +54,10 @@ class ComposerToolbarViewModelTests: XCTestCase { .map(\.composerMode) .removeDuplicates() .dropFirst() - .sink(receiveValue: { composerMode in + .sink { composerMode in XCTAssertEqual(composerMode, mode) expectation.fulfill() - }) + } viewModel.process(timelineAction: .setMode(mode: mode)) diff --git a/UnitTests/Sources/JoinRoomScreenViewModelTests.swift b/UnitTests/Sources/JoinRoomScreenViewModelTests.swift index 2f8b6e0521..a14ad28e2d 100644 --- a/UnitTests/Sources/JoinRoomScreenViewModelTests.swift +++ b/UnitTests/Sources/JoinRoomScreenViewModelTests.swift @@ -84,7 +84,7 @@ class JoinRoomScreenViewModelTests: XCTestCase { topic: nil, avatarURL: nil, memberCount: 0, - isHistoryWorldReadable: false, + isHistoryWorldReadable: nil, isJoined: false, isInvited: false, isPublic: false, diff --git a/UnitTests/Sources/MessageForwardingScreenViewModelTests.swift b/UnitTests/Sources/MessageForwardingScreenViewModelTests.swift index 3076a40aa7..a61a9397ea 100644 --- a/UnitTests/Sources/MessageForwardingScreenViewModelTests.swift +++ b/UnitTests/Sources/MessageForwardingScreenViewModelTests.swift @@ -34,7 +34,7 @@ class MessageForwardingScreenViewModelTests: XCTestCase { } func testInitialState() { - XCTAssertNil(context.viewState.rooms.first(where: { $0.id == forwardingItem.roomID }), "The source room ID shouldn't be shown") + XCTAssertNil(context.viewState.rooms.first { $0.id == forwardingItem.roomID }, "The source room ID shouldn't be shown") } func testRoomSelection() { diff --git a/UnitTests/Sources/PollFormScreenViewModelTests.swift b/UnitTests/Sources/PollFormScreenViewModelTests.swift index af92477aca..2bfbdf508a 100644 --- a/UnitTests/Sources/PollFormScreenViewModelTests.swift +++ b/UnitTests/Sources/PollFormScreenViewModelTests.swift @@ -29,7 +29,7 @@ class PollFormScreenViewModelTests: XCTestCase { XCTAssertFalse(context.viewState.bindings.isUndisclosed) // Cancellation should work without confirmation - let deferred = deferFulfillment(viewModel.actions, until: { _ in true }) + let deferred = deferFulfillment(viewModel.actions) { _ in true } context.send(viewAction: .cancel) let action = try await deferred.fulfill() XCTAssertNil(context.alertInfo) @@ -45,7 +45,7 @@ class PollFormScreenViewModelTests: XCTestCase { XCTAssertFalse(context.viewState.bindings.isUndisclosed) // Cancellation should work without confirmation - let deferred = deferFulfillment(viewModel.actions, until: { _ in true }) + let deferred = deferFulfillment(viewModel.actions) { _ in true } context.send(viewAction: .cancel) let action = try await deferred.fulfill() XCTAssertNil(context.alertInfo) diff --git a/UnitTests/Sources/RoomChangeRolesScreenViewModelTests.swift b/UnitTests/Sources/RoomChangeRolesScreenViewModelTests.swift index b78bb60c88..5626a218ee 100644 --- a/UnitTests/Sources/RoomChangeRolesScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomChangeRolesScreenViewModelTests.swift @@ -161,8 +161,8 @@ class RoomChangeRolesScreenViewModelTests: XCTestCase { // Then no warning should be shown, and the call to update the users should be made straight away. XCTAssertTrue(roomProxy.updatePowerLevelsForUsersCalled) XCTAssertEqual(roomProxy.updatePowerLevelsForUsersReceivedUpdates?.count, 2) - XCTAssertEqual(roomProxy.updatePowerLevelsForUsersReceivedUpdates?.contains(where: { $0.userID == existingModerator.id && $0.powerLevel == 0 }), true) - XCTAssertEqual(roomProxy.updatePowerLevelsForUsersReceivedUpdates?.contains(where: { $0.userID == firstUser.id && $0.powerLevel == 50 }), true) + XCTAssertEqual(roomProxy.updatePowerLevelsForUsersReceivedUpdates?.contains { $0.userID == existingModerator.id && $0.powerLevel == 0 }, true) + XCTAssertEqual(roomProxy.updatePowerLevelsForUsersReceivedUpdates?.contains { $0.userID == firstUser.id && $0.powerLevel == 50 }, true) } func testSavePromotedAdministrator() async throws { @@ -189,7 +189,7 @@ class RoomChangeRolesScreenViewModelTests: XCTestCase { // Then the user should be made into an administrator. XCTAssertTrue(roomProxy.updatePowerLevelsForUsersCalled) XCTAssertEqual(roomProxy.updatePowerLevelsForUsersReceivedUpdates?.count, 1) - XCTAssertEqual(roomProxy.updatePowerLevelsForUsersReceivedUpdates?.contains(where: { $0.userID == firstUser.id && $0.powerLevel == 100 }), true) + XCTAssertEqual(roomProxy.updatePowerLevelsForUsersReceivedUpdates?.contains { $0.userID == firstUser.id && $0.powerLevel == 100 }, true) } private func setupViewModel(mode: RoomMemberDetails.Role) { diff --git a/UnitTests/Sources/RoomFlowCoordinatorTests.swift b/UnitTests/Sources/RoomFlowCoordinatorTests.swift index 4ec720826e..a96e5a5af9 100644 --- a/UnitTests/Sources/RoomFlowCoordinatorTests.swift +++ b/UnitTests/Sources/RoomFlowCoordinatorTests.swift @@ -322,7 +322,7 @@ class RoomFlowCoordinatorTests: XCTestCase { topic: nil, avatarURL: nil, memberCount: 0, - isHistoryWorldReadable: false, + isHistoryWorldReadable: nil, isJoined: false, isInvited: true, isPublic: false, diff --git a/UnitTests/Sources/RoomRolesAndPermissionsScreenViewModelTests.swift b/UnitTests/Sources/RoomRolesAndPermissionsScreenViewModelTests.swift index c37c4c63c9..3500b78f12 100644 --- a/UnitTests/Sources/RoomRolesAndPermissionsScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomRolesAndPermissionsScreenViewModelTests.swift @@ -49,7 +49,7 @@ class RoomRolesAndPermissionsScreenViewModelTests: XCTestCase { context.send(viewAction: .editOwnUserRole) XCTAssertNotNil(context.alertInfo) - context.alertInfo?.verticalButtons?.first(where: { $0.title.localizedStandardContains("moderator") })?.action?() + context.alertInfo?.verticalButtons?.first { $0.title.localizedStandardContains("moderator") }?.action?() try await Task.sleep(for: .milliseconds(100)) @@ -64,7 +64,7 @@ class RoomRolesAndPermissionsScreenViewModelTests: XCTestCase { context.send(viewAction: .editOwnUserRole) XCTAssertNotNil(context.alertInfo) - context.alertInfo?.verticalButtons?.first(where: { $0.title.localizedStandardContains("member") })?.action?() + context.alertInfo?.verticalButtons?.first { $0.title.localizedStandardContains("member") }?.action?() try await Task.sleep(for: .milliseconds(100)) diff --git a/UnitTests/Sources/TimelineViewModelTests.swift b/UnitTests/Sources/TimelineViewModelTests.swift index 1ef68a2fff..0714d035c1 100644 --- a/UnitTests/Sources/TimelineViewModelTests.swift +++ b/UnitTests/Sources/TimelineViewModelTests.swift @@ -45,9 +45,9 @@ class TimelineViewModelTests: XCTestCase { let viewModel = makeViewModel(timelineController: timelineController) // Then the messages should be grouped together. - XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.") - XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .middle, "Nothing should prevent the middle message from being grouped.") - XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.") + XCTAssertEqual(viewModel.state.timelineState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.") + XCTAssertEqual(viewModel.state.timelineState.itemViewStates[1].groupStyle, .middle, "Nothing should prevent the middle message from being grouped.") + XCTAssertEqual(viewModel.state.timelineState.itemViewStates[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.") } func testMessageGroupingMultipleSenders() { @@ -73,12 +73,12 @@ class TimelineViewModelTests: XCTestCase { let viewModel = makeViewModel(timelineController: timelineController) // Then the messages should be grouped by sender. - XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .single, "A message should not be grouped when the sender changes.") - XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .single, "A message should not be grouped when the sender changes.") - XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.") - XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[3].groupStyle, .last, "A group should be ended when the sender changes in the next message.") - XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[4].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.") - XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[5].groupStyle, .last, "A group should be ended when the sender changes in the next message.") + XCTAssertEqual(viewModel.state.timelineState.itemViewStates[0].groupStyle, .single, "A message should not be grouped when the sender changes.") + XCTAssertEqual(viewModel.state.timelineState.itemViewStates[1].groupStyle, .single, "A message should not be grouped when the sender changes.") + XCTAssertEqual(viewModel.state.timelineState.itemViewStates[2].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.") + XCTAssertEqual(viewModel.state.timelineState.itemViewStates[3].groupStyle, .last, "A group should be ended when the sender changes in the next message.") + XCTAssertEqual(viewModel.state.timelineState.itemViewStates[4].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.") + XCTAssertEqual(viewModel.state.timelineState.itemViewStates[5].groupStyle, .last, "A group should be ended when the sender changes in the next message.") } func testMessageGroupingWithLeadingReactions() { @@ -99,9 +99,9 @@ class TimelineViewModelTests: XCTestCase { let viewModel = makeViewModel(timelineController: timelineController) // Then the first message should not be grouped but the other two should. - XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .single, "When the first message has reactions it should not be grouped.") - XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .first, "A new group should be made when the preceding message has reactions.") - XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.") + XCTAssertEqual(viewModel.state.timelineState.itemViewStates[0].groupStyle, .single, "When the first message has reactions it should not be grouped.") + XCTAssertEqual(viewModel.state.timelineState.itemViewStates[1].groupStyle, .first, "A new group should be made when the preceding message has reactions.") + XCTAssertEqual(viewModel.state.timelineState.itemViewStates[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.") } func testMessageGroupingWithInnerReactions() { @@ -122,9 +122,9 @@ class TimelineViewModelTests: XCTestCase { let viewModel = makeViewModel(timelineController: timelineController) // Then the first and second messages should be grouped and the last one should not. - XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.") - XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .last, "When the message has reactions, the group should end here.") - XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .single, "The last message should not be grouped when the preceding message has reactions.") + XCTAssertEqual(viewModel.state.timelineState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.") + XCTAssertEqual(viewModel.state.timelineState.itemViewStates[1].groupStyle, .last, "When the message has reactions, the group should end here.") + XCTAssertEqual(viewModel.state.timelineState.itemViewStates[2].groupStyle, .single, "The last message should not be grouped when the preceding message has reactions.") } func testMessageGroupingWithTrailingReactions() { @@ -145,9 +145,9 @@ class TimelineViewModelTests: XCTestCase { let viewModel = makeViewModel(timelineController: timelineController) // Then the messages should be grouped together. - XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.") - XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .middle, "Nothing should prevent the second message from being grouped.") - XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .last, "Reactions on the last message should not prevent it from being grouped.") + XCTAssertEqual(viewModel.state.timelineState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.") + XCTAssertEqual(viewModel.state.timelineState.itemViewStates[1].groupStyle, .middle, "Nothing should prevent the second message from being grouped.") + XCTAssertEqual(viewModel.state.timelineState.itemViewStates[2].groupStyle, .last, "Reactions on the last message should not prevent it from being grouped.") } // MARK: - Focussing @@ -162,18 +162,18 @@ class TimelineViewModelTests: XCTestCase { let viewModel = makeViewModel(timelineController: timelineController) XCTAssertEqual(timelineController.focusOnEventCallCount, 0) - XCTAssertTrue(viewModel.context.viewState.timelineViewState.isLive) - XCTAssertNil(viewModel.context.viewState.timelineViewState.focussedEvent) + XCTAssertTrue(viewModel.context.viewState.timelineState.isLive) + XCTAssertNil(viewModel.context.viewState.timelineState.focussedEvent) // When focussing on an item that isn't loaded. - let deferred = deferFulfillment(viewModel.context.$viewState) { !$0.timelineViewState.isLive } + let deferred = deferFulfillment(viewModel.context.$viewState) { !$0.timelineState.isLive } await viewModel.focusOnEvent(eventID: "t4") try await deferred.fulfill() // Then a new timeline should be loaded and the room focussed on that event. XCTAssertEqual(timelineController.focusOnEventCallCount, 1) - XCTAssertFalse(viewModel.context.viewState.timelineViewState.isLive) - XCTAssertEqual(viewModel.context.viewState.timelineViewState.focussedEvent, .init(eventID: "t4", appearance: .immediate)) + XCTAssertFalse(viewModel.context.viewState.timelineState.isLive) + XCTAssertEqual(viewModel.context.viewState.timelineState.focussedEvent, .init(eventID: "t4", appearance: .immediate)) } func testFocusLoadedItem() async throws { @@ -186,18 +186,18 @@ class TimelineViewModelTests: XCTestCase { let viewModel = makeViewModel(timelineController: timelineController) XCTAssertEqual(timelineController.focusOnEventCallCount, 0) - XCTAssertTrue(viewModel.context.viewState.timelineViewState.isLive) - XCTAssertNil(viewModel.context.viewState.timelineViewState.focussedEvent) + XCTAssertTrue(viewModel.context.viewState.timelineState.isLive) + XCTAssertNil(viewModel.context.viewState.timelineState.focussedEvent) // When focussing on a loaded item. - let deferred = deferFailure(viewModel.context.$viewState, timeout: 1) { !$0.timelineViewState.isLive } + let deferred = deferFailure(viewModel.context.$viewState, timeout: 1) { !$0.timelineState.isLive } await viewModel.focusOnEvent(eventID: "t1") try await deferred.fulfill() // Then the timeline should remain live and the item should be focussed. XCTAssertEqual(timelineController.focusOnEventCallCount, 0) - XCTAssertTrue(viewModel.context.viewState.timelineViewState.isLive) - XCTAssertEqual(viewModel.context.viewState.timelineViewState.focussedEvent, .init(eventID: "t1", appearance: .animated)) + XCTAssertTrue(viewModel.context.viewState.timelineState.isLive) + XCTAssertEqual(viewModel.context.viewState.timelineState.focussedEvent, .init(eventID: "t1", appearance: .animated)) } func testFocusLive() async throws { @@ -210,30 +210,30 @@ class TimelineViewModelTests: XCTestCase { let viewModel = makeViewModel(timelineController: timelineController) - var deferred = deferFulfillment(viewModel.context.$viewState) { !$0.timelineViewState.isLive } + var deferred = deferFulfillment(viewModel.context.$viewState) { !$0.timelineState.isLive } await viewModel.focusOnEvent(eventID: "t4") try await deferred.fulfill() XCTAssertEqual(timelineController.focusLiveCallCount, 0) - XCTAssertFalse(viewModel.context.viewState.timelineViewState.isLive) - XCTAssertEqual(viewModel.context.viewState.timelineViewState.focussedEvent, .init(eventID: "t4", appearance: .immediate)) + XCTAssertFalse(viewModel.context.viewState.timelineState.isLive) + XCTAssertEqual(viewModel.context.viewState.timelineState.focussedEvent, .init(eventID: "t4", appearance: .immediate)) // When switching back to a live timeline. - deferred = deferFulfillment(viewModel.context.$viewState) { $0.timelineViewState.isLive } + deferred = deferFulfillment(viewModel.context.$viewState) { $0.timelineState.isLive } viewModel.context.send(viewAction: .focusLive) try await deferred.fulfill() // Then the timeline should switch back to being live and the event focus should be removed. XCTAssertEqual(timelineController.focusLiveCallCount, 1) - XCTAssertTrue(viewModel.context.viewState.timelineViewState.isLive) - XCTAssertNil(viewModel.context.viewState.timelineViewState.focussedEvent) + XCTAssertTrue(viewModel.context.viewState.timelineState.isLive) + XCTAssertNil(viewModel.context.viewState.timelineState.focussedEvent) } func testInitialFocusViewState() async throws { let timelineController = MockRoomTimelineController() let viewModel = makeViewModel(focussedEventID: "t10", timelineController: timelineController) - XCTAssertEqual(viewModel.context.viewState.timelineViewState.focussedEvent, .init(eventID: "t10", appearance: .immediate)) + XCTAssertEqual(viewModel.context.viewState.timelineState.focussedEvent, .init(eventID: "t10", appearance: .immediate)) } // MARK: - Read Receipts