From ba810116a069d1db3a590fcc5bddaf55642d2333 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Mon, 23 Mar 2026 17:55:50 +0200 Subject: [PATCH] Add a new `RoomThreadListScreen` and hook it up to the RoomThreadListService It will automatically paginate to fill the screen and update the list as updates come in. --- .../Sources/GeneratedAccessibilityTests.swift | 4 + ElementX.xcodeproj/project.pbxproj | 36 +++++ .../xcshareddata/xcschemes/ElementX.xcscheme | 23 ++- .../RoomFlowCoordinator.swift | 13 ++ ElementX/Sources/Other/Avatars.swift | 3 +- .../TestablePreviewsDictionary.swift | 1 + .../RoomScreen/RoomScreenCoordinator.swift | 3 + .../Screens/RoomScreen/RoomScreenModels.swift | 2 + .../RoomScreen/RoomScreenViewModel.swift | 2 + .../Screens/RoomScreen/View/RoomScreen.swift | 11 ++ .../RoomThreadListScreenCoordinator.swift | 48 ++++++ .../RoomThreadListScreenModels.swift | 25 +++ .../RoomThreadListScreenViewModel.swift | 75 +++++++++ ...oomThreadListScreenViewModelProtocol.swift | 14 ++ .../View/RoomThreadListScreen.swift | 153 ++++++++++++++++++ .../Sources/GeneratedPreviewTests.swift | 8 + 16 files changed, 413 insertions(+), 8 deletions(-) create mode 100644 ElementX/Sources/Screens/RoomThreadListScreen/RoomThreadListScreenCoordinator.swift create mode 100644 ElementX/Sources/Screens/RoomThreadListScreen/RoomThreadListScreenModels.swift create mode 100644 ElementX/Sources/Screens/RoomThreadListScreen/RoomThreadListScreenViewModel.swift create mode 100644 ElementX/Sources/Screens/RoomThreadListScreen/RoomThreadListScreenViewModelProtocol.swift create mode 100644 ElementX/Sources/Screens/RoomThreadListScreen/View/RoomThreadListScreen.swift diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index 56d0cbd29..099c7f1d3 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -591,6 +591,10 @@ extension AccessibilityTests { try await performAccessibilityAudit(named: "RoomSelectionScreen_Previews") } + func testRoomThreadListScreen() async throws { + try await performAccessibilityAudit(named: "RoomThreadListScreen_Previews") + } + func testSFNumberedListView() async throws { try await performAccessibilityAudit(named: "SFNumberedListView_Previews") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index c2fc06abe..09b553240 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -515,6 +515,7 @@ 572474C7CA4B03FF0B5DF548 /* ChatsTabFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C782FCBBCC9A0CD30453C50 /* ChatsTabFlowCoordinator.swift */; }; 5732395A4F71F51F9C754C5A /* ElementCallService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33AE897D86784CCA5E4E9227 /* ElementCallService.swift */; }; 5780E444F405AA1304E1C23E /* DeveloperOptionsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E521D6C2BF8DF0DFB35146 /* DeveloperOptionsScreen.swift */; }; + 5787613A79C6D553DEA28C5F /* RoomThreadListScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F91AC45CDDF85E946391CA5C /* RoomThreadListScreenViewModel.swift */; }; 583A41A4BE76E2E9E0B97881 /* ResolveVerifiedUserSendFailureScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5AEB5907E24092D741718AF /* ResolveVerifiedUserSendFailureScreenCoordinator.swift */; }; 585DCA0487A0A6F4E59EF5CA /* AnalyticsConsentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 353024006CB726E9F9187B3A /* AnalyticsConsentState.swift */; }; 5877255D6E6ED898D402ED8D /* LocationPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408ACC0D28656F82A5EB6A7E /* LocationPickerSheet.swift */; }; @@ -683,6 +684,7 @@ 755727E0B756430DFFEC4732 /* SessionVerificationViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF05DA24F71B455E8EFEBC3B /* SessionVerificationViewModelTests.swift */; }; 756EA0D663261889EF64E6D4 /* VoiceMessageRecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E9CBF577B9711CFBB4FA40D /* VoiceMessageRecordingView.swift */; }; 7573D682F089205F7F1D96CF /* SessionDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C2067FF58B4996323EB40C /* SessionDirectories.swift */; }; + 757862045774A0F458357E19 /* RoomThreadListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544B9262E1BC6F489C03FFFA /* RoomThreadListScreen.swift */; }; 75AD7C09BD604A68E2FAA1D9 /* OIDCConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D354D4232DED9649FD0FF4 /* OIDCConfiguration.swift */; }; 75ED4B73983228BB6922CE3C /* KnockRequestsListScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A5C217DD0749EC709EED028 /* KnockRequestsListScreenViewModelProtocol.swift */; }; 761EA50B2619307AB30891B8 /* PhishingDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB07F03461023BC39C730922 /* PhishingDetector.swift */; }; @@ -692,6 +694,7 @@ 763D69741D58D2B650BC1FC9 /* CallScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F37FA1A5D55633E1942B153B /* CallScreenCoordinator.swift */; }; 7640A4B412CACF15D143CCD4 /* Strings+SAS.swift in Sources */ = {isa = PBXBuildFile; fileRef = B172057567E049007A5C4D92 /* Strings+SAS.swift */; }; 767D366C40F1311CFA333763 /* PillContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86376BEE425704AEE197CA54 /* PillContext.swift */; }; + 76B6046788017DEF088FBF87 /* RoomThreadListScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3534D37BFC074DD26F2FFE /* RoomThreadListScreenModels.swift */; }; 76C874243A8C440D6CF7B344 /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 077B01C13BBA2996272C5FB5 /* ProcessInfo.swift */; }; 7708976CEE6AFB5CFAEFBA68 /* PillTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CF1EE0AA78470C674554262 /* PillTextAttachment.swift */; }; 77574A519A4E484880053EAD /* IdentityConfirmationScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FDF541AE914059942B575B4 /* IdentityConfirmationScreenModels.swift */; }; @@ -752,6 +755,7 @@ 7FF6E1FBE6E9517FD29A1D8E /* RoomChangeRolesScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48A5C34C4E4268EF65D171EF /* RoomChangeRolesScreenModels.swift */; }; 8015842CB4DE1BE414D2CDED /* AppLockSetupBiometricsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C62E07C1164F5120727A2A8 /* AppLockSetupBiometricsScreenCoordinator.swift */; }; 804C15D8ADE0EA7A5268F58A /* OverridableAvatarImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648DD1C10E4957CB791FE0B8 /* OverridableAvatarImage.swift */; }; + 805D16A15BDF97B4EA8D3EC6 /* RoomThreadListScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A14FD296A75F5F5637EDC365 /* RoomThreadListScreenCoordinator.swift */; }; 80DEA2A4B20F9E279EAE6B2B /* UserProfile+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD01F7FC2BBAC7351948595 /* UserProfile+Mock.swift */; }; 80F6C8EFCA4564B67F0D34B0 /* DeactivateAccountScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77F75B3E9F99864048A422A /* DeactivateAccountScreenViewModelTests.swift */; }; 81CFE6FE42DF26BBCEDC7FF2 /* JoinCallButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98ABC939BC8F08CA3E967D6C /* JoinCallButton.swift */; }; @@ -1198,6 +1202,7 @@ CDAE3A37D4DF136F9D07DB61 /* RoomChangeRolesScreenSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF710CB1C31F8938EAA3A7D /* RoomChangeRolesScreenSection.swift */; }; CDCA8A559E098503DDE29477 /* AttributedStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5C6FBF97B6EED3D4FA5EFF /* AttributedStringBuilder.swift */; }; CE1694C7BB93C3311524EF28 /* Untranslated.strings in Resources */ = {isa = PBXBuildFile; fileRef = D2F7194F440375338F8E2487 /* Untranslated.strings */; }; + CE23FE7F463AD5C7D80353AA /* RoomThreadListScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 008D864B3F51B41DF483B860 /* RoomThreadListScreenViewModelProtocol.swift */; }; CE3B7FC34FB2C279AAA5EA01 /* AVMetadataMachineReadableCodeObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3339B1DDB1341E833D2555BC /* AVMetadataMachineReadableCodeObject.swift */; }; CE4B342F9DD747CF4BEDB5AB /* TestablePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43F773904F87FF5ADFE4DD1 /* TestablePreview.swift */; }; CE6F237360875D3D573FD0B2 /* RoomNotificationSettingsProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD6B522BD637845AB9570B10 /* RoomNotificationSettingsProxy.swift */; }; @@ -1578,6 +1583,7 @@ 002399C6CB875C4EBB01CBC0 /* MediaEventsTimelineScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaEventsTimelineScreen.swift; sourceTree = ""; }; 00245D40CD90FD71D6A05239 /* EmojiPickerScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreen.swift; sourceTree = ""; }; 007C16779FDCF10DA4F1A510 /* LinkNewDeviceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkNewDeviceService.swift; sourceTree = ""; }; + 008D864B3F51B41DF483B860 /* RoomThreadListScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomThreadListScreenViewModelProtocol.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 = ""; }; 011AFA4990C585D157829679 /* DeclineAndBlockScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeclineAndBlockScreenViewModel.swift; sourceTree = ""; }; @@ -1633,6 +1639,7 @@ 094F6B21835890B470DF540C /* SpaceScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceScreenCoordinator.swift; sourceTree = ""; }; 099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiProviderTests.swift; sourceTree = ""; }; 0A2074C0449B83D5858BD2D7 /* FrequentlyUsedEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrequentlyUsedEmoji.swift; sourceTree = ""; }; + 0A3534D37BFC074DD26F2FFE /* RoomThreadListScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomThreadListScreenModels.swift; sourceTree = ""; }; 0A3E77399BD262D301451BF2 /* RoomDetailsEditScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenCoordinator.swift; sourceTree = ""; }; 0A459AE4B6566B2FA99E86B2 /* TimelineItemBubbledStylerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemBubbledStylerView.swift; sourceTree = ""; }; 0A81FD0C60175FA081EB19AD /* EventTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventTimelineItem.swift; sourceTree = ""; }; @@ -2054,6 +2061,7 @@ 53F41CEAAE2BB4E74CDC2278 /* TimelineMediaPreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewViewModel.swift; sourceTree = ""; }; 53FD6D3D38F556CEAA280C58 /* test_animated_image.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = test_animated_image.gif; sourceTree = ""; }; 5445FCE0CE15E634FDC1A2E2 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; + 544B9262E1BC6F489C03FFFA /* RoomThreadListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomThreadListScreen.swift; sourceTree = ""; }; 5484457C81B325660901B161 /* AppLockSetupSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupSettingsScreen.swift; sourceTree = ""; }; 54A5E6F398C269AD52C9AE21 /* EncryptionResetPasswordScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetPasswordScreenModels.swift; sourceTree = ""; }; 54A7923F0115CF17ABC8047F /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/SAS.strings; sourceTree = ""; }; @@ -2468,6 +2476,7 @@ A1087DCC491CD4C027173DDA /* EmojiPickerScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreenViewModelTests.swift; sourceTree = ""; }; A12D3B1BCF920880CA8BBB6B /* UserIndicatorControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorControllerProtocol.swift; sourceTree = ""; }; A130A2251A15A7AACC84FD37 /* RoomPollsHistoryScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPollsHistoryScreenViewModelProtocol.swift; sourceTree = ""; }; + A14FD296A75F5F5637EDC365 /* RoomThreadListScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomThreadListScreenCoordinator.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 = ""; }; @@ -2950,6 +2959,7 @@ F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootCoordinatorTests.swift; sourceTree = ""; }; F899D02CF26EA7675EEBE74C /* UserSessionScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionScreenTests.swift; sourceTree = ""; }; F8CCF9A924521DECA44778C4 /* AppLockSetupBiometricsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupBiometricsScreen.swift; sourceTree = ""; }; + F91AC45CDDF85E946391CA5C /* RoomThreadListScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomThreadListScreenViewModel.swift; sourceTree = ""; }; F9E543072DE58E751F028998 /* TimelineProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineProxy.swift; sourceTree = ""; }; FA2397174D0DC3918A7A8A7B /* AnalyticsConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsConfiguration.swift; sourceTree = ""; }; FA3EB5B1848CF4F64E63C6B7 /* PermalinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermalinkTests.swift; sourceTree = ""; }; @@ -5060,6 +5070,14 @@ path = Media; sourceTree = ""; }; + 7AC9A0C3B3506D052CAC7743 /* View */ = { + isa = PBXGroup; + children = ( + 544B9262E1BC6F489C03FFFA /* RoomThreadListScreen.swift */, + ); + path = View; + sourceTree = ""; + }; 7AE042B6E4318E352DD3991A /* View */ = { isa = PBXGroup; children = ( @@ -5942,6 +5960,18 @@ path = Sources; sourceTree = ""; }; + B329D7B5D250717039B2814A /* RoomThreadListScreen */ = { + isa = PBXGroup; + children = ( + A14FD296A75F5F5637EDC365 /* RoomThreadListScreenCoordinator.swift */, + 0A3534D37BFC074DD26F2FFE /* RoomThreadListScreenModels.swift */, + F91AC45CDDF85E946391CA5C /* RoomThreadListScreenViewModel.swift */, + 008D864B3F51B41DF483B860 /* RoomThreadListScreenViewModelProtocol.swift */, + 7AC9A0C3B3506D052CAC7743 /* View */, + ); + path = RoomThreadListScreen; + sourceTree = ""; + }; B364E08924AD15820350CDD9 /* SettingsScreen */ = { isa = PBXGroup; children = ( @@ -6599,6 +6629,7 @@ 7B890CCD20B037760BFDF957 /* RoomRolesAndPermissionsScreen */, 679E9837ECA8D6776079D16E /* RoomScreen */, 2E42D43DB6835A58D88B2F91 /* RoomSelectionScreen */, + B329D7B5D250717039B2814A /* RoomThreadListScreen */, 2565414373E6F68005966B8E /* SecureBackup */, C59BA103987B953BA374509F /* SecurityAndPrivacyScreen */, 70B74A432C241E56A7ACE610 /* Settings */, @@ -8658,6 +8689,11 @@ 983896D611ABF52A5C37498D /* RoomSummaryProvider.swift in Sources */, B5899F18AD6C56CE08FE532B /* RoomSummaryProviderMock.swift in Sources */, AA050DF4AEE54A641BA7CA22 /* RoomSummaryProviderProtocol.swift in Sources */, + 757862045774A0F458357E19 /* RoomThreadListScreen.swift in Sources */, + 805D16A15BDF97B4EA8D3EC6 /* RoomThreadListScreenCoordinator.swift in Sources */, + 76B6046788017DEF088FBF87 /* RoomThreadListScreenModels.swift in Sources */, + 5787613A79C6D553DEA28C5F /* RoomThreadListScreenViewModel.swift in Sources */, + CE23FE7F463AD5C7D80353AA /* RoomThreadListScreenViewModelProtocol.swift in Sources */, 708FC3184CCED825F0A36273 /* RoomThreadListServiceProxy.swift in Sources */, 38E8F5CC90DC0D716B799DE1 /* RoomThreadListServiceProxyMock.swift in Sources */, 12F7172C93873638B23CD6AD /* RoomThreadListServiceProxyProtocol.swift in Sources */, diff --git a/ElementX.xcodeproj/xcshareddata/xcschemes/ElementX.xcscheme b/ElementX.xcodeproj/xcshareddata/xcschemes/ElementX.xcscheme index b50acdfed..eb510f50c 100644 --- a/ElementX.xcodeproj/xcshareddata/xcschemes/ElementX.xcscheme +++ b/ElementX.xcodeproj/xcshareddata/xcschemes/ElementX.xcscheme @@ -4,7 +4,8 @@ version = "1.7"> + buildImplicitDependencies = "YES" + runPostActionsOnFailure = "NO"> + + + + + + + + - - - - + + + + diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 7c9ca3ec0..c104922ff 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -720,6 +720,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { sendHandle: sendHandle)) case .presentKnockRequestsList: stateMachine.tryEvent(.presentKnockRequestsListScreen) + case .presentThreadList: + Task { + await self.presentThreadList(animated: true) + } case .presentThread(let threadRootEventID, let focussedEventID): stateMachine.tryEvent(.presentThread(threadRootEventID: threadRootEventID, focusEventID: focussedEventID)) case .presentRoom(let roomID, let via): @@ -733,6 +737,15 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { return coordinator } + private func presentThreadList(animated: Bool) async { + let coordinator = await RoomThreadListScreenCoordinator(parameters: .init(threadListServiceProxy: roomProxy.threadListService(), + mediaProvider: userSession.mediaProvider)) + + coordinator.actionsPublisher.sink { [weak self] _ in }.store(in: &cancellables) + + navigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in } + } + private func presentThread(threadRootEventID: String, focusEventID: String?, animated: Bool) async { showLoadingIndicator() defer { hideLoadingIndicator() } diff --git a/ElementX/Sources/Other/Avatars.swift b/ElementX/Sources/Other/Avatars.swift index 8f29188d3..1ebbefb52 100644 --- a/ElementX/Sources/Other/Avatars.swift +++ b/ElementX/Sources/Other/Avatars.swift @@ -83,6 +83,7 @@ enum UserAvatarSizeOnScreen { case mediaPreviewDetails case sendInviteConfirmation case sessionVerification + case threadList case threadSummary case map @@ -106,7 +107,7 @@ enum UserAvatarSizeOnScreen { case .roomDetails: 44 case .inviteUsers, .knockingUserList, .sessionVerification, - .settings: + .settings, .threadList: 52 case .roomChangeRoles: 56 diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index 322950746..0e4b0add5 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -155,6 +155,7 @@ enum TestablePreviewsDictionary { "RoomScreenFooterView_Previews" : RoomScreenFooterView_Previews.self, "RoomScreen_Previews" : RoomScreen_Previews.self, "RoomSelectionScreen_Previews" : RoomSelectionScreen_Previews.self, + "RoomThreadListScreen_Previews" : RoomThreadListScreen_Previews.self, "SFNumberedListView_Previews" : SFNumberedListView_Previews.self, "SecureBackupKeyBackupScreen_Previews" : SecureBackupKeyBackupScreen_Previews.self, "SecureBackupLogoutConfirmationScreen_Previews" : SecureBackupLogoutConfirmationScreen_Previews.self, diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index c17a9ebca..a04ea7e18 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -47,6 +47,7 @@ enum RoomScreenCoordinatorAction { case presentPinnedEventsTimeline case presentResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy) case presentKnockRequestsList + case presentThreadList case presentThread(threadRootEventID: String, focussedEventID: String?) case presentRoom(roomID: String, via: [String]) } @@ -187,6 +188,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.presentRoom(roomID: roomID, via: via)) case .displayMessageForwarding(let forwardingItem): actionsSubject.send(.presentMessageForwarding(forwardingItem: forwardingItem)) + case .displayThreadList: + actionsSubject.send(.presentThreadList) case .displayThread(let threadRootEventID, let focussedEventID): actionsSubject.send(.presentThread(threadRootEventID: threadRootEventID, focussedEventID: focussedEventID)) } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 9967fbcbc..375ef6f8d 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -12,6 +12,7 @@ import OrderedCollections enum RoomScreenViewModelAction: Equatable { case focusEvent(eventID: String) + case displayThreadList case displayThread(threadRootEventID: String, focussedEventID: String) case displayPinnedEventsTimeline case displayRoomDetails @@ -32,6 +33,7 @@ enum RoomScreenViewAction { case dismissKnockRequests case viewKnockRequests case displaySuccessorRoom + case displayThreadList } struct RoomScreenViewState: BindableState { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index b0aa5184e..a8040f3bc 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -119,6 +119,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol guard let successorID = roomProxy.infoPublisher.value.successor?.roomId else { return } let serverNames = roomProxy.knownServerNames(maxCount: 50) // Limit to the same number used by ClientProxy.resolveRoomAlias(_:) actionsSubject.send(.displayRoom(roomID: successorID, via: Array(serverNames))) + case .displayThreadList: + actionsSubject.send(.displayThreadList) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 8373bd6e2..3091d3153 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -179,6 +179,17 @@ struct RoomScreen: View { } } } + if #available(iOS 26, *) { + ToolbarSpacer(.fixed, placement: .primaryAction) + } + + ToolbarItem(placement: .primaryAction) { + Button { + context.send(viewAction: .displayThreadList) + } label: { + CompoundIcon(\.threads) + } + } } @ViewBuilder diff --git a/ElementX/Sources/Screens/RoomThreadListScreen/RoomThreadListScreenCoordinator.swift b/ElementX/Sources/Screens/RoomThreadListScreen/RoomThreadListScreenCoordinator.swift new file mode 100644 index 000000000..8a0f38b73 --- /dev/null +++ b/ElementX/Sources/Screens/RoomThreadListScreen/RoomThreadListScreenCoordinator.swift @@ -0,0 +1,48 @@ +// +// Copyright 2025 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +// periphery:ignore:all - this is just a roomThreadList remove this comment once generating the final file + +import Combine +import SwiftUI + +struct RoomThreadListScreenCoordinatorParameters { + let threadListServiceProxy: RoomThreadListServiceProxyProtocol + let mediaProvider: MediaProviderProtocol +} + +enum RoomThreadListScreenCoordinatorAction { } + +final class RoomThreadListScreenCoordinator: CoordinatorProtocol { + private let parameters: RoomThreadListScreenCoordinatorParameters + private let viewModel: RoomThreadListScreenViewModelProtocol + + private var cancellables = Set() + + private let actionsSubject: PassthroughSubject = .init() + var actionsPublisher: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(parameters: RoomThreadListScreenCoordinatorParameters) { + self.parameters = parameters + + viewModel = RoomThreadListScreenViewModel(threadListServiceProxy: parameters.threadListServiceProxy, + mediaProvider: parameters.mediaProvider) + } + + func start() { + viewModel.actionsPublisher.sink { [weak self] action in + MXLog.info("Coordinator: received view model action: \(action)") + } + .store(in: &cancellables) + } + + func toPresentable() -> AnyView { + AnyView(RoomThreadListScreen(context: viewModel.context)) + } +} diff --git a/ElementX/Sources/Screens/RoomThreadListScreen/RoomThreadListScreenModels.swift b/ElementX/Sources/Screens/RoomThreadListScreen/RoomThreadListScreenModels.swift new file mode 100644 index 000000000..1d0999d5b --- /dev/null +++ b/ElementX/Sources/Screens/RoomThreadListScreen/RoomThreadListScreenModels.swift @@ -0,0 +1,25 @@ +// +// Copyright 2025 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Foundation + +enum RoomThreadListScreenViewModelAction { } + +struct RoomThreadListScreenViewState: BindableState { + var items = [RoomThreadListItem]() + + var isPaginating = false + + var bindings: RoomThreadListScreenViewStateBindings +} + +struct RoomThreadListScreenViewStateBindings { } + +enum RoomThreadListScreenViewAction { + case oldestItemDidAppear + case oldestItemDidDisappear +} diff --git a/ElementX/Sources/Screens/RoomThreadListScreen/RoomThreadListScreenViewModel.swift b/ElementX/Sources/Screens/RoomThreadListScreen/RoomThreadListScreenViewModel.swift new file mode 100644 index 000000000..502bb88c0 --- /dev/null +++ b/ElementX/Sources/Screens/RoomThreadListScreen/RoomThreadListScreenViewModel.swift @@ -0,0 +1,75 @@ +// +// Copyright 2025 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import SwiftUI + +typealias RoomThreadListScreenViewModelType = StateStoreViewModelV2 + +class RoomThreadListScreenViewModel: RoomThreadListScreenViewModelType, RoomThreadListScreenViewModelProtocol { + private let threadListServiceProxy: RoomThreadListServiceProxyProtocol + + private var isOldestItemVisible = false + + private let actionsSubject: PassthroughSubject = .init() + var actionsPublisher: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(threadListServiceProxy: RoomThreadListServiceProxyProtocol, mediaProvider: MediaProviderProtocol) { + self.threadListServiceProxy = threadListServiceProxy + + super.init(initialViewState: .init(bindings: .init()), mediaProvider: mediaProvider) + + updateItems(self.threadListServiceProxy.itemsPublisher.value) + + threadListServiceProxy.itemsPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] items in + self?.updateItems(items) + } + .store(in: &cancellables) + + threadListServiceProxy.paginationStatePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] paginationState in + guard let self else { return } + state.isPaginating = paginationState == .loading + Task { await self.paginateIfNecessary(paginationState: paginationState) } + } + .store(in: &cancellables) + } + + // MARK: - Public + + override func process(viewAction: RoomThreadListScreenViewAction) { + MXLog.info("View model: received view action: \(viewAction)") + + switch viewAction { + case .oldestItemDidAppear: + isOldestItemVisible = true + + Task { + await paginateIfNecessary(paginationState: threadListServiceProxy.paginationStatePublisher.value) + } + case .oldestItemDidDisappear: + isOldestItemVisible = false + } + } + + // MARK: - Private + + private func paginateIfNecessary(paginationState: RoomThreadListPaginationState) async { + if isOldestItemVisible, case .idle(endReached: false) = paginationState { + _ = await threadListServiceProxy.paginate() + } + } + + private func updateItems(_ items: [RoomThreadListItem]) { + state.items = items + } +} diff --git a/ElementX/Sources/Screens/RoomThreadListScreen/RoomThreadListScreenViewModelProtocol.swift b/ElementX/Sources/Screens/RoomThreadListScreen/RoomThreadListScreenViewModelProtocol.swift new file mode 100644 index 000000000..876dc9d26 --- /dev/null +++ b/ElementX/Sources/Screens/RoomThreadListScreen/RoomThreadListScreenViewModelProtocol.swift @@ -0,0 +1,14 @@ +// +// Copyright 2025 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine + +@MainActor +protocol RoomThreadListScreenViewModelProtocol { + var actionsPublisher: AnyPublisher { get } + var context: RoomThreadListScreenViewModelType.Context { get } +} diff --git a/ElementX/Sources/Screens/RoomThreadListScreen/View/RoomThreadListScreen.swift b/ElementX/Sources/Screens/RoomThreadListScreen/View/RoomThreadListScreen.swift new file mode 100644 index 000000000..f3d43e68d --- /dev/null +++ b/ElementX/Sources/Screens/RoomThreadListScreen/View/RoomThreadListScreen.swift @@ -0,0 +1,153 @@ +// +// Copyright 2025 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Compound +import SwiftUI + +struct RoomThreadListScreen: View { + @Bindable var context: RoomThreadListScreenViewModel.Context + + var body: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(context.viewState.items) { item in + RoomThreadListCell(item: item, mediaProvider: context.mediaProvider) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + footer + } + } + .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) + .navigationTitle(L10n.commonThreads) + .navigationBarTitleDisplayMode(.inline) + } + + private var footer: some View { + // Needs to be wrapped in a LazyStack otherwise appearance calls don't trigger + LazyVStack(spacing: 0) { + ProgressView() + .padding() + .opacity(context.viewState.isPaginating ? 1 : 0) + + Rectangle() + .frame(height: 1) + .foregroundStyle(.compound.bgCanvasDefault) + .onAppear { + context.send(viewAction: .oldestItemDidAppear) + } + .onDisappear { + context.send(viewAction: .oldestItemDidDisappear) + } + } + } +} + +private struct RoomThreadListCell: View { + let item: RoomThreadListItem + let mediaProvider: MediaProviderProtocol? + + var body: some View { + HStack(alignment: .center, spacing: 16) { + LoadableAvatarImage(url: item.rootMessageDetails.sender.avatarURL, + name: item.rootMessageDetails.sender.displayName, + contentID: item.rootMessageDetails.sender.id, + avatarSize: .user(on: .threadList), + mediaProvider: mediaProvider) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .center, spacing: 16) { + creatorDetails + Spacer() + timestamp + } + + rootMessageDetails + latestMessageDetails + } + } + } + + private var creatorDetails: some View { + Text(item.rootMessageDetails.sender.disambiguatedDisplayName ?? item.rootMessageDetails.sender.id) + .font(.compound.bodyLGSemibold) + .foregroundColor(.compound.textPrimary) + .lineLimit(1) + } + + @ViewBuilder + private var rootMessageDetails: some View { + if let message = item.rootMessageDetails.message { + Text(message) + .font(.compound.bodyMD) + .foregroundColor(.compound.textSecondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + @ViewBuilder + private var latestMessageDetails: some View { + if let latestMessageDetails = item.latestMessageDetails { + HStack(alignment: .center, spacing: 8) { + Label { + Text("\(item.numberOfReplies)") + .font(.compound.bodySMSemibold) + .foregroundColor(.compound.textSecondary) + } icon: { + CompoundIcon(\.threads, size: .small, relativeTo: .compound.bodySMSemibold) + .foregroundColor(.compound.iconSecondary) + } + .labelStyle(.custom(spacing: 4, alignment: .center, iconLayout: .trailing)) + + LoadableAvatarImage(url: latestMessageDetails.sender.avatarURL, + name: latestMessageDetails.sender.displayName, + contentID: latestMessageDetails.sender.id, + avatarSize: .user(on: .threadSummary), + mediaProvider: mediaProvider) + .accessibilityHidden(true) + + if let message = latestMessageDetails.message { + Text(message) + .font(.compound.bodySM) + .foregroundColor(.compound.textSecondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + + private var timestamp: some View { + if let latestMessageDetails = item.latestMessageDetails { + Text(latestMessageDetails.timestamp.formattedMinimal()) + .font(.compound.bodySM) + .foregroundColor(.compound.textSecondary) + } else { + Text(item.rootMessageDetails.timestamp.formattedTime()) + .font(.compound.bodySM) + .foregroundColor(.compound.textSecondary) + } + } +} + +// MARK: - Previews + +struct RoomThreadListScreen_Previews: PreviewProvider, TestablePreview { + static let viewModel = makeViewModel() + + static var previews: some View { + RoomThreadListScreen(context: viewModel.context) + } + + static func makeViewModel() -> RoomThreadListScreenViewModel { + RoomThreadListScreenViewModel(threadListServiceProxy: RoomThreadListServiceProxyMock(.init()), + mediaProvider: MediaProviderMock(configuration: .init())) + } +} diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index 86b4b5457..976fad120 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -1179,6 +1179,14 @@ extension PreviewTests { } } + @Test + func roomThreadListScreen() async throws { + AppSettings.resetAllSettings() // Ensure this test's previews start with fresh settings. + for (index, preview) in RoomThreadListScreen_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + @Test func sFNumberedListView() async throws { AppSettings.resetAllSettings() // Ensure this test's previews start with fresh settings.