diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index 2e518daba..8768829f9 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -195,6 +195,10 @@ extension AccessibilityTests { try await performAccessibilityAudit(named: "HealthChatExtendedProfileSection_Previews") } + func testHealthChatMemberDirectoryScreen() async throws { + try await performAccessibilityAudit(named: "HealthChatMemberDirectoryScreen_Previews") + } + func testHighlightedTimelineItemModifier() async throws { try await performAccessibilityAudit(named: "HighlightedTimelineItemModifier_Previews") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 686ebb631..c556145a4 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ 0033481EE363E4914295F188 /* LocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C070FD43DC6BF4E50217965A /* LocalizationTests.swift */; }; 00C3023B6DF55024D8876B76 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3D8BEEFCA07BEA43F4F4BF77 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 012D9DDCDE6278E4E0CDCC0F /* LinkNewDeviceFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94EE2C5F0A06F146BBE3A1B1 /* LinkNewDeviceFlowCoordinator.swift */; }; + 014BDC76374F9B3EB424288F /* HealthChatMemberDirectoryScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76CA927002A2327B4DA97B38 /* HealthChatMemberDirectoryScreenViewModelProtocol.swift */; }; 01681E8B20AD6F0D237F2DC1 /* IdentityConfirmedScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C6624240FFD32B7F0834229 /* IdentityConfirmedScreenViewModel.swift */; }; 0180C44B997EDA8D21F883AC /* RoomNotificationSettingsCustomSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B746EFA112532A7B701FB914 /* RoomNotificationSettingsCustomSectionView.swift */; }; 01B63F1A04A276B39AC17014 /* CallInviteRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9A3D3CFA199FA7897364547 /* CallInviteRoomTimelineItem.swift */; }; @@ -113,6 +114,7 @@ 101C8BFCAE35F43F37A6D8ED /* NotificationToneManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18678D0A4177D8F0A255BC36 /* NotificationToneManagerMock.swift */; }; 108D3C0707A90B0F848CDBB9 /* ResolveVerifiedUserSendFailureScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60011EF0086E49DBD78E16E5 /* ResolveVerifiedUserSendFailureScreenModels.swift */; }; 109AEB7D33C4497727AFB87F /* TimelineInteractionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BA894BC09972DC45E497D37 /* TimelineInteractionHandler.swift */; }; + 10C8D33023FE3628B58DA459 /* HealthChatMemberDirectoryScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E9B8347C558A340B819FB75 /* HealthChatMemberDirectoryScreenModels.swift */; }; 10D60D287025B71F4743A425 /* RoomDirectorySearchProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 471BB7276C97AF60B3A5463B /* RoomDirectorySearchProxy.swift */; }; 112C6F1C493B3F26AB22716A /* LinkMetadataProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6885D1340AA9C76063E26868 /* LinkMetadataProviderProtocol.swift */; }; 1146E9EDCF8344F7D6E0D553 /* MockCoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0376C429FAB1687C3D905F3E /* MockCoder.swift */; }; @@ -251,6 +253,7 @@ 2748E5574A1031DD05E54FDA /* KnockRequestCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44B71F6D9062E8EB8929BB97 /* KnockRequestCell.swift */; }; 274CE3C986841D15FD530BF5 /* ShimmerModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97CE98208321C4D66E363612 /* ShimmerModifier.swift */; }; 275EDE8849A2AC1D9309ED7C /* TemplateScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43456E73F8A2D52B69B9FB9 /* TemplateScreenViewModel.swift */; }; + 2768E28BC1A92DCCF4A82BEF /* HealthChatMemberListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF6767EB24B1685E55C0C032 /* HealthChatMemberListRow.swift */; }; 277FD4394EAFF323DA34997E /* HorizontalHighlightGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4F0AB250EFA7B71FB2BDB2 /* HorizontalHighlightGradient.swift */; }; 2797C9D9BA642370F1C85D78 /* Untranslated.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = F75DF9500D69A3AAF8339E69 /* Untranslated.stringsdict */; }; 27F015B0D5436633B5B3C8C3 /* SecureBackupRecoveryKeyScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7061BE2C0BF427C38AEDEF5E /* SecureBackupRecoveryKeyScreenViewModel.swift */; }; @@ -296,6 +299,7 @@ 2D38D39B1789B91AE69F477F /* PhotoLibraryManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD955A0380C287C418F1A74D /* PhotoLibraryManagerMock.swift */; }; 2D45A04699BB6BA3B3A0CB9A /* TracingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A95C9B8299A36A6495DECA6 /* TracingHook.swift */; }; 2D76463CE5A9238B5BB5F393 /* TimelineItemKeyForwarder.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F137B69DCB59353E37B80 /* TimelineItemKeyForwarder.swift */; }; + 2D8E9CAAF9CFA13861A2C736 /* HomeTabsHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3604CC1640C001F228EAF8AD /* HomeTabsHook.swift */; }; 2DA27D78560D5F79B917E163 /* AudioConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E44E35AA87F49503E7B3BF6E /* AudioConverter.swift */; }; 2DD9D0FE7CB5CFC80D071451 /* AppLockScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3E67E09FE5A35D73818C39 /* AppLockScreenModels.swift */; }; 2E073ABFC6062D1DF727E2BC /* preview_avatar_room.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 690915157A874D1F21ADE70C /* preview_avatar_room.jpg */; }; @@ -364,10 +368,12 @@ 38DE3B3FF9EB8661FD3844CC /* CurrentValuePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA393404F96DFB505B86E913 /* CurrentValuePublisherTests.swift */; }; 38E0F9A4CB5237F039A2EEE6 /* RoomHistorySharingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722013E20EEED73553EC6F8D /* RoomHistorySharingState.swift */; }; 38E8F5CC90DC0D716B799DE1 /* RoomThreadListServiceProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7396A6491DEC64FCB66F60A1 /* RoomThreadListServiceProxyMock.swift */; }; + 392853E45693680A8B1FF224 /* HealthChatMemberDirectoryScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C28C98D9068738E8F6D61851 /* HealthChatMemberDirectoryScreenCoordinator.swift */; }; 3982C505960006B341CFD0C6 /* UserDetailsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27D0EA07BD545CC9F234DB8D /* UserDetailsEditScreenModels.swift */; }; 3982E60F9C126437D5E488A3 /* PillContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31A6314FDC51DA25712D9A81 /* PillContextTests.swift */; }; 39A987B3E41B976D1DF944C6 /* CallScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A63A59BFDDC494B1C20119 /* CallScreenViewModel.swift */; }; 39DFC4B9EB6A8757210BDEC6 /* RoomSelectionScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DD2A058F3566FEEBA1D11B3 /* RoomSelectionScreenViewModelProtocol.swift */; }; + 3A053CFE1CF7129E4C5E05BE /* HealthChatMemberDirectoryScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B706B8482FB67D59FF7B6F /* HealthChatMemberDirectoryScreen.swift */; }; 3A08584ECDD4A4541DBF21F8 /* EmojiLoaderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201305507D7DFD16E544563A /* EmojiLoaderProtocol.swift */; }; 3A0D1A201E2CD209A04CE89B /* SearchScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD63A653BCA2C8112654B19 /* SearchScreenModels.swift */; }; 3A164187907DA43B7858F9EC /* CompletionSuggestionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5EA0312A6262484AA393AC9 /* CompletionSuggestionServiceTests.swift */; }; @@ -594,6 +600,7 @@ 5FA1DCE55973862632961D7C /* PhotoLibraryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7495E1119753B06FF2C2279 /* PhotoLibraryManager.swift */; }; 5FCD8AFA364206EE32B909A3 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = B050A6B233D95807A09289E7 /* Settings.bundle */; }; 601AB75BD52B0B4276CEB84A /* SessionVerificationScreenStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 161CD412E75F4086F422AE39 /* SessionVerificationScreenStateMachine.swift */; }; + 60317E05BFDD46BD99C2871B /* HealthChatMemberDirectoryScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DF449911E6776EE3DDE2A34 /* HealthChatMemberDirectoryScreenViewModelTests.swift */; }; 60ACDF0237CED82912786A33 /* test_apple_image.heic in Resources */ = {isa = PBXBuildFile; fileRef = 9C5639C7D679F094E7AB6B04 /* test_apple_image.heic */; }; 60ED66E63A169E47489348A8 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = A20EA00CCB9DBE0FFB17DD09 /* Collections */; }; 611BEE29B8B622204E1E6B04 /* SecurityAndPrivacyScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97F2F6B6E56055EF173A2DD3 /* SecurityAndPrivacyScreenCoordinator.swift */; }; @@ -728,6 +735,7 @@ 726AA74DF4E5EFCEBD78CE3F /* RoomMembersFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A70B03471F6027C90EE868C /* RoomMembersFlowCoordinator.swift */; }; 72D2298DE695A6797CDA1A2A /* SpaceScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B223FA339BF53085328DEE /* SpaceScreenViewModelTests.swift */; }; 733E2B19AB1FDA3B93293A28 /* AppLockSetupPINScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F275432954C8C6B1B7D966 /* AppLockSetupPINScreen.swift */; }; + 73783FE13BDB8E0A9576DABE /* HealthChatMemberDirectoryScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 720F2364E071232D81AE98A4 /* HealthChatMemberDirectoryScreenViewModel.swift */; }; 738288EAEE235CAC0893AB9E /* ThreadTimelineScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C9ACDD96F36510C1FC0836B /* ThreadTimelineScreenViewModel.swift */; }; 73D4437A6A410FA2B94CFE0F /* ManualClock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32435409710BF1A54C3BBD53 /* ManualClock.swift */; }; 73F33E9776B7A50B65A031D2 /* AppLockSettingsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BA67B3E4EF9D29D14A78CE /* AppLockSettingsScreenViewModelTests.swift */; }; @@ -999,6 +1007,7 @@ 9A0326D2375075871D2AB537 /* ResolveVerifiedUserSendFailureScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 574CB70E82D7EAEA538E4135 /* ResolveVerifiedUserSendFailureScreenViewModel.swift */; }; 9A3B0CDF097E3838FB1B9595 /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E89E530A8E92EC44301CA1 /* Bundle.swift */; }; 9A8E6FCD86B89970EC72EFD8 /* BugReportServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F7FC9580CABF797A2E6213A /* BugReportServiceMock.swift */; }; + 9A94A0FED05B52019B9CCD5B /* HealthChatHomeTabsHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDD488380158986A75D6640 /* HealthChatHomeTabsHook.swift */; }; 9AC47275B8E1EB0976BA7A80 /* MapTilerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1F2AAA3F0F2B72D2FFE4D0 /* MapTilerConfiguration.swift */; }; 9AC5F8142413862A9E3A2D98 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 9C73F37731C9FDED1BB24C1C /* Collections */; }; 9AF73FA23C86B45801EE2949 /* ClientFactoryHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3561843E139EA7CDAED0933 /* ClientFactoryHook.swift */; }; @@ -1538,6 +1547,7 @@ F16EED85E30B83878FBC6629 /* JoinedMembersBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F320003F490B11F808ECC5E9 /* JoinedMembersBadgeView.swift */; }; F18CA61A58C77C84F551B8E7 /* GeneratedMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57916A1578D8043BB0795441 /* GeneratedMocks.swift */; }; F1C68F64FC8A66B6B9510BF7 /* ManageRoomMemberSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B30CD19ED6243FEDFBA8400 /* ManageRoomMemberSheetView.swift */; }; + F24F8DFA140C94C9A815FE8C /* HealthChatMembersFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 905305593D89BA4CE831A8FA /* HealthChatMembersFlowCoordinator.swift */; }; F255083E18CDBFDF7E640FB1 /* Avatars.swift in Sources */ = {isa = PBXBuildFile; fileRef = C142248014E08E885E323E56 /* Avatars.swift */; }; F2D5C0E1351DA7BD16867629 /* TimelineStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD4823EAB4B4E8BAB4F6B8C /* TimelineStyle.swift */; }; F2E580C0FBFBEFFE9D69893B /* RoomPreviewProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 739077686814E4EA339B1C83 /* RoomPreviewProxyProtocol.swift */; }; @@ -2099,6 +2109,7 @@ 35A057BA9BE0F079784CD061 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 35A5AB4E2E5CA98F99BFC552 /* GalleryRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryRoomTimelineItemContent.swift; sourceTree = ""; }; 35AFCF4C05DEED04E3DB1A16 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; + 3604CC1640C001F228EAF8AD /* HomeTabsHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTabsHook.swift; sourceTree = ""; }; 3674212CAB6C5143DD437AC4 /* RoomDetailsScreenHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsScreenHook.swift; sourceTree = ""; }; 369236171C351AB8C70BB161 /* preview_video.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = preview_video.jpg; sourceTree = ""; }; 36DA824791172B9821EACBED /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -2283,6 +2294,7 @@ 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 = ""; }; 54AD70D6E03D2031AE1B5A52 /* TimelineReactionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReactionsView.swift; sourceTree = ""; }; + 54B706B8482FB67D59FF7B6F /* HealthChatMemberDirectoryScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatMemberDirectoryScreen.swift; sourceTree = ""; }; 54C4E7B46099462F12000C91 /* DeveloperOptionsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenViewModelProtocol.swift; sourceTree = ""; }; 5557DDA438841AF5DC003D0B /* SpaceListScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceListScreenViewModelTests.swift; sourceTree = ""; }; 558AA5B376CE8C3982A9557F /* AnalyticsServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsServiceProtocol.swift; sourceTree = ""; }; @@ -2443,6 +2455,7 @@ 71BC7CA1BC1041E93077BBA1 /* HomeScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenModels.swift; sourceTree = ""; }; 71D52BAA5BADB06E5E8C295D /* Assets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = ""; }; 71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileScreenViewModelTests.swift; sourceTree = ""; }; + 720F2364E071232D81AE98A4 /* HealthChatMemberDirectoryScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatMemberDirectoryScreenViewModel.swift; sourceTree = ""; }; 722013E20EEED73553EC6F8D /* RoomHistorySharingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomHistorySharingState.swift; sourceTree = ""; }; 72614BFF35B8394C6E13F55A /* TimelineItemStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemStatusView.swift; sourceTree = ""; }; 726E901DF76393E335FD7E8E /* ManageAuthorizedSpacesScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageAuthorizedSpacesScreenViewModel.swift; sourceTree = ""; }; @@ -2473,6 +2486,7 @@ 75B3CE05643C7791D46AC54B /* LeaveSpaceHandleProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveSpaceHandleProxy.swift; sourceTree = ""; }; 76310030C831D4610A705603 /* URLComponentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLComponentsTests.swift; sourceTree = ""; }; 76A46ABD27628CB5FC402541 /* Backports.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backports.swift; sourceTree = ""; }; + 76CA927002A2327B4DA97B38 /* HealthChatMemberDirectoryScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatMemberDirectoryScreenViewModelProtocol.swift; sourceTree = ""; }; 7720ACAC6155AB7F9C70B546 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = nb; path = nb.lproj/Localizable.stringsdict; sourceTree = ""; }; 7773CBFDBD458E0B7E270507 /* PillView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillView.swift; sourceTree = ""; }; 77AB44FFCF7DA5300D943169 /* SpaceAddRoomsScreenSelectedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceAddRoomsScreenSelectedItem.swift; sourceTree = ""; }; @@ -2517,7 +2531,9 @@ 7DC017C3CB6B0F7C63F460F2 /* SecureBackupLogoutConfirmationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupLogoutConfirmationScreenViewModel.swift; sourceTree = ""; }; 7DDBF99755A9008CF8C8499E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 7DDF49CEBC0DFC59C308335F /* RoomMemberDetailsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberDetailsScreenViewModelProtocol.swift; sourceTree = ""; }; + 7DF449911E6776EE3DDE2A34 /* HealthChatMemberDirectoryScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatMemberDirectoryScreenViewModelTests.swift; sourceTree = ""; }; 7E8562F4D7DE073BC32902AB /* EncryptionResetScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetScreenViewModelProtocol.swift; sourceTree = ""; }; + 7E9B8347C558A340B819FB75 /* HealthChatMemberDirectoryScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatMemberDirectoryScreenModels.swift; sourceTree = ""; }; 7EA2AFF6EB59FE25234D29F3 /* ElementCallServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementCallServiceTests.swift; sourceTree = ""; }; 7EB58E4E8D6D634C246AD5C2 /* RoomInviterLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomInviterLabel.swift; sourceTree = ""; }; 7EECE8B331CD169790EF284F /* BugReportScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreenViewModelTests.swift; sourceTree = ""; }; @@ -2630,6 +2646,7 @@ 8FB89DC7F9A4A91020037001 /* AuthenticationStartScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationStartScreenViewModelTests.swift; sourceTree = ""; }; 8FC598338E7CF41107293AB5 /* RageshakeConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RageshakeConfiguration.swift; sourceTree = ""; }; 8FC803282F9268D49F4ABF14 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; + 905305593D89BA4CE831A8FA /* HealthChatMembersFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatMembersFlowCoordinator.swift; sourceTree = ""; }; 906451FB8CF27C628152BF7A /* EditRoomAddressScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditRoomAddressScreenViewModelTests.swift; sourceTree = ""; }; 90791B9C739C716A40E1B230 /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; 907FA4DE17DEA1A3738EFB83 /* AudioRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorder.swift; sourceTree = ""; }; @@ -2712,6 +2729,7 @@ 9E8F4D7D61B80EBD5CB92F8A /* KnockedRoomProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockedRoomProxyMock.swift; sourceTree = ""; }; 9EB9BA2F30EB8C33226D8FF1 /* UserSessionStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionStoreMock.swift; sourceTree = ""; }; 9ECF11669EF253E98AA2977A /* CompletionSuggestionServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSuggestionServiceProtocol.swift; sourceTree = ""; }; + 9EDD488380158986A75D6640 /* HealthChatHomeTabsHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatHomeTabsHook.swift; sourceTree = ""; }; 9F1DF3FFFE5ED2B8133F43A7 /* MessageComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposer.swift; sourceTree = ""; }; 9F3E4640982CE98C6ADD7896 /* SearchServiceProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchServiceProxyProtocol.swift; sourceTree = ""; }; 9F40FB0A43DAECEC27C73722 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/SAS.strings; sourceTree = ""; }; @@ -2939,6 +2957,7 @@ C1FA515B3B0D61EF1E907D2D /* BadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeView.swift; sourceTree = ""; }; C258C9C815272911A5B132C3 /* FormattedBodyText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattedBodyText.swift; sourceTree = ""; }; C2886615BEBAE33A0AA4D5F8 /* RoomScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenModels.swift; sourceTree = ""; }; + C28C98D9068738E8F6D61851 /* HealthChatMemberDirectoryScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatMemberDirectoryScreenCoordinator.swift; sourceTree = ""; }; C2B8AA3199BAAD066E36CB07 /* RoomListSDKMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListSDKMock.swift; sourceTree = ""; }; C2E9B841EE4878283ECDB554 /* InviteUsersScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreen.swift; sourceTree = ""; }; C30F45308428A4D9FFDB2FB8 /* BannedRoomProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannedRoomProxy.swift; sourceTree = ""; }; @@ -3109,6 +3128,7 @@ DF05DA24F71B455E8EFEBC3B /* SessionVerificationViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationViewModelTests.swift; sourceTree = ""; }; DF17EA323AD0205A6AB621AA /* Snapshotting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Snapshotting.swift; sourceTree = ""; }; DF3D25B3EDB283B5807EADCF /* ReadMarkerRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadMarkerRoomTimelineItem.swift; sourceTree = ""; }; + DF6767EB24B1685E55C0C032 /* HealthChatMemberListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatMemberListRow.swift; sourceTree = ""; }; DFFB0E7C6D8E190AFA0176DC /* uz */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = uz; path = uz.lproj/Localizable.stringsdict; sourceTree = ""; }; E06AAD6D9D3F5833E7A5A2F9 /* RoomListFilterModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListFilterModels.swift; sourceTree = ""; }; E09E2B1AB937F2D01815495F /* HealthChatCompoundHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatCompoundHook.swift; sourceTree = ""; }; @@ -4013,6 +4033,7 @@ 8B89D6C760E8CAE29CA28FB1 /* CompoundHook.swift */, 0F60FDB3AEFC60830BD289FE /* DeveloperOptionsScreenHook.swift */, AC2AAA62D6EE2520CE2036F0 /* ExtendedProfileSectionHook.swift */, + 3604CC1640C001F228EAF8AD /* HomeTabsHook.swift */, 04CAC77224E949D8E758E2E3 /* OAuthPresenterHook.swift */, 7AE97AD18893E2D8120F559D /* RecoveryKeyScreenHook.swift */, D5D186A6DB8FAC5C9D0E4D61 /* RemoteSettingsHook.swift */, @@ -4891,6 +4912,7 @@ F2B94F1B0B5D9D42B15AA6E8 /* ChatsTabFlowCoordinatorStateMachine.swift */, A07B011547B201A836C03052 /* EncryptionResetFlowCoordinator.swift */, ECB836DD8BE31931F51B8AC9 /* EncryptionSettingsFlowCoordinator.swift */, + 905305593D89BA4CE831A8FA /* HealthChatMembersFlowCoordinator.swift */, 94EE2C5F0A06F146BBE3A1B1 /* LinkNewDeviceFlowCoordinator.swift */, 2178B951602AA921A5FD9DC8 /* MediaEventsTimelineFlowCoordinator.swift */, C3285BD95B564CA2A948E511 /* OnboardingFlowCoordinator.swift */, @@ -5145,6 +5167,15 @@ path = TimelineItemContent; sourceTree = ""; }; + 6CA4B285768092EC32841E1B /* View */ = { + isa = PBXGroup; + children = ( + 54B706B8482FB67D59FF7B6F /* HealthChatMemberDirectoryScreen.swift */, + DF6767EB24B1685E55C0C032 /* HealthChatMemberListRow.swift */, + ); + path = View; + sourceTree = ""; + }; 6D7503E64A458DD09E65A3F7 /* View */ = { isa = PBXGroup; children = ( @@ -5632,6 +5663,7 @@ F89310978E1570B02FF4E4A3 /* HealthChatExtendedProfileLoader.swift */, 687D3C6697703342512AF6AF /* HealthChatExtendedProfileSection.swift */, AD3C983BFE38789B9C5EA7A5 /* HealthChatExtendedProfileSectionHook.swift */, + 9EDD488380158986A75D6640 /* HealthChatHomeTabsHook.swift */, 715B60D68D20808E328DA1DA /* HealthChatSettings.swift */, ); path = HealthChat; @@ -5777,6 +5809,7 @@ CB71F86BB7A3AC9B32845B5B /* HealthChatAPIModelsTests.swift */, 087194751572DF7CA65D1CCB /* HealthChatAPIServiceTests.swift */, BB7ABDCA2059497899F226DF /* HealthChatExtendedProfileLoaderTests.swift */, + 7DF449911E6776EE3DDE2A34 /* HealthChatMemberDirectoryScreenViewModelTests.swift */, 11E3983406311A8BBE37A19A /* HealthChatOpenIDTokenProviderTests.swift */, ); path = HealthChat; @@ -5805,6 +5838,18 @@ path = EncryptionReset; sourceTree = ""; }; + 87188EFD40C301F153B6572E /* HealthChatMemberDirectoryScreen */ = { + isa = PBXGroup; + children = ( + C28C98D9068738E8F6D61851 /* HealthChatMemberDirectoryScreenCoordinator.swift */, + 7E9B8347C558A340B819FB75 /* HealthChatMemberDirectoryScreenModels.swift */, + 720F2364E071232D81AE98A4 /* HealthChatMemberDirectoryScreenViewModel.swift */, + 76CA927002A2327B4DA97B38 /* HealthChatMemberDirectoryScreenViewModelProtocol.swift */, + 6CA4B285768092EC32841E1B /* View */, + ); + path = HealthChatMemberDirectoryScreen; + sourceTree = ""; + }; 87E2774157D9C4894BCFF3F8 /* MediaPickerScreen */ = { isa = PBXGroup; children = ( @@ -7116,6 +7161,7 @@ F5A65D1D3B83593598DC278D /* EmojiPickerScreen */, 8656AFF06650360A5D0695FF /* EncryptionReset */, 448435400B561C40E514BE1C /* FilePreviewScreen */, + 87188EFD40C301F153B6572E /* HealthChatMemberDirectoryScreen */, B53CA9BECD3F97805E1432D0 /* HomeScreen */, F12966DF3DA87FEF21348D60 /* InviteUsersScreen */, FFD7C58CA6A7D6BBC2F584B5 /* JoinRoomScreen */, @@ -8408,6 +8454,7 @@ C40957497CAFADB846784A2A /* HealthChatAPIModelsTests.swift in Sources */, F995036BBA39691CF7AD1F08 /* HealthChatAPIServiceTests.swift in Sources */, 99DE2B618703A58BC3303B92 /* HealthChatExtendedProfileLoaderTests.swift in Sources */, + 60317E05BFDD46BD99C2871B /* HealthChatMemberDirectoryScreenViewModelTests.swift in Sources */, BC38C35A60F164A7AE3743E4 /* HealthChatOpenIDTokenProviderTests.swift in Sources */, 6817EAD73DC1FFD8B943B5B9 /* HomeScreenRoomTests.swift in Sources */, F6F49E37272AD7397CD29A01 /* HomeScreenViewModelTests.swift in Sources */, @@ -8956,6 +9003,14 @@ AEDBB75C62E53A9DAA32DFE1 /* HealthChatExtendedProfileLoader.swift in Sources */, 630CC29D639086CB1F88C74E /* HealthChatExtendedProfileSection.swift in Sources */, FAA41D6957FC5BA357092D6D /* HealthChatExtendedProfileSectionHook.swift in Sources */, + 9A94A0FED05B52019B9CCD5B /* HealthChatHomeTabsHook.swift in Sources */, + 3A053CFE1CF7129E4C5E05BE /* HealthChatMemberDirectoryScreen.swift in Sources */, + 392853E45693680A8B1FF224 /* HealthChatMemberDirectoryScreenCoordinator.swift in Sources */, + 10C8D33023FE3628B58DA459 /* HealthChatMemberDirectoryScreenModels.swift in Sources */, + 73783FE13BDB8E0A9576DABE /* HealthChatMemberDirectoryScreenViewModel.swift in Sources */, + 014BDC76374F9B3EB424288F /* HealthChatMemberDirectoryScreenViewModelProtocol.swift in Sources */, + 2768E28BC1A92DCCF4A82BEF /* HealthChatMemberListRow.swift in Sources */, + F24F8DFA140C94C9A815FE8C /* HealthChatMembersFlowCoordinator.swift in Sources */, 4B5E2E8FBE54B73F79E607BC /* HealthChatOpenIDTokenProvider.swift in Sources */, 1E822B072359DD37EEA8CE78 /* HealthChatSettings.swift in Sources */, 0C1E537A49ABB386F7554D4A /* HighlightedTimelineItemModifier.swift in Sources */, @@ -8972,6 +9027,7 @@ A10D6CCDE2010C09EEA1A593 /* HomeScreenRoomList.swift in Sources */, DE4F8C4E0F1DB4832F09DE97 /* HomeScreenViewModel.swift in Sources */, 56F0A22972A3BB519DA2261C /* HomeScreenViewModelProtocol.swift in Sources */, + 2D8E9CAAF9CFA13861A2C736 /* HomeTabsHook.swift in Sources */, C0A845AAFEB7E1BEAE04306E /* HomeserverCapabilitiesProxy.swift in Sources */, 97550ECE8A1D28FEF74FBACC /* HomeserverCapabilitiesProxyProtocol.swift in Sources */, 96AA8FAE9F2B3149DEA8496B /* HomeserverCredentials.swift in Sources */, diff --git a/ElementX/Resources/Localizations/de.lproj/HealthChat.strings b/ElementX/Resources/Localizations/de.lproj/HealthChat.strings index ef7fe10bc..1852f5379 100644 --- a/ElementX/Resources/Localizations/de.lproj/HealthChat.strings +++ b/ElementX/Resources/Localizations/de.lproj/HealthChat.strings @@ -7,3 +7,6 @@ "common_practice_name" = "Praxisname / Firmenname"; "common_specialty" = "Fachgebiet"; "common_website" = "Webseite"; +"screen_member_directory_pending_approval" = "Ihr Konto wird derzeit geprüft. Sie erhalten Zugriff auf das Mitgliederverzeichnis, sobald Ihr Konto freigegeben wurde."; +"screen_member_directory_search_placeholder" = "Name, Praxis, Ort oder Fachgebiet"; +"screen_member_directory_title" = "Mitglieder"; diff --git a/ElementX/Resources/Localizations/en.lproj/HealthChat.strings b/ElementX/Resources/Localizations/en.lproj/HealthChat.strings index d7d5e23c9..9ca42e318 100644 --- a/ElementX/Resources/Localizations/en.lproj/HealthChat.strings +++ b/ElementX/Resources/Localizations/en.lproj/HealthChat.strings @@ -7,3 +7,6 @@ "common_practice_name" = "Practice name"; "common_specialty" = "Speciality"; "common_website" = "Website"; +"screen_member_directory_pending_approval" = "Your account is being reviewed. You’ll get access to the member directory once it has been approved."; +"screen_member_directory_search_placeholder" = "Name, practice, city or speciality"; +"screen_member_directory_title" = "Members"; diff --git a/ElementX/Resources/Localizations/fr.lproj/HealthChat.strings b/ElementX/Resources/Localizations/fr.lproj/HealthChat.strings index 385bc26a1..ac1b46558 100644 --- a/ElementX/Resources/Localizations/fr.lproj/HealthChat.strings +++ b/ElementX/Resources/Localizations/fr.lproj/HealthChat.strings @@ -7,3 +7,6 @@ "common_practice_name" = "Nom de l’entreprise"; "common_specialty" = "Spécialisation"; "common_website" = "Site web"; +"screen_member_directory_pending_approval" = "Votre compte est en cours de vérification. Vous aurez accès à l’annuaire des membres dès qu’il aura été approuvé."; +"screen_member_directory_search_placeholder" = "Nom, cabinet, ville ou spécialisation"; +"screen_member_directory_title" = "Membres"; diff --git a/ElementX/Sources/AppHooks/AppHooks.swift b/ElementX/Sources/AppHooks/AppHooks.swift index 4bcbdf9af..b196883cd 100644 --- a/ElementX/Sources/AppHooks/AppHooks.swift +++ b/ElementX/Sources/AppHooks/AppHooks.swift @@ -46,6 +46,9 @@ final nonisolated class AppHooks: AppHooksProtocol { @AppHook(default: DefaultExtendedProfileSectionHook()) var extendedProfileSectionHook: ExtendedProfileSectionHookProtocol + + @AppHook(default: DefaultHomeTabsHook()) + var homeTabsHook: HomeTabsHookProtocol @AppHook(default: DefaultDeveloperOptionsScreenHook()) var developerOptionsScreenHook: DeveloperOptionsScreenHookProtocol diff --git a/ElementX/Sources/AppHooks/HealthChat/HealthChatAppHooks.swift b/ElementX/Sources/AppHooks/HealthChat/HealthChatAppHooks.swift index e697db398..167e71133 100644 --- a/ElementX/Sources/AppHooks/HealthChat/HealthChatAppHooks.swift +++ b/ElementX/Sources/AppHooks/HealthChat/HealthChatAppHooks.swift @@ -17,6 +17,7 @@ extension AppHooks { registerAppSettingsHook(HealthChatAppSettingsHook()) registerCompoundHook(HealthChatCompoundHook()) registerExtendedProfileSectionHook(HealthChatExtendedProfileSectionHook()) + registerHomeTabsHook(HealthChatHomeTabsHook()) #endif } } diff --git a/ElementX/Sources/AppHooks/HealthChat/HealthChatHomeTabsHook.swift b/ElementX/Sources/AppHooks/HealthChat/HealthChatHomeTabsHook.swift new file mode 100644 index 000000000..7d4fdb3e3 --- /dev/null +++ b/ElementX/Sources/AppHooks/HealthChat/HealthChatHomeTabsHook.swift @@ -0,0 +1,37 @@ +// +// Copyright 2026 Novaloop AG +// +// 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 + +#if IS_MAIN_APP +// Conforming to the nonisolated hook protocol would infer nonisolated, but the +// flow it retains is main-actor state — the hook's requirement is @MainActor anyway. +@MainActor +final class HealthChatHomeTabsHook: HomeTabsHookProtocol { + private var membersFlowCoordinator: HealthChatMembersFlowCoordinator? + + // Constructed during hook registration, before the main actor is reachable. + nonisolated init() { } + + func makeExtraTab(flowParameters: CommonFlowParameters) + -> (tab: NavigationTabCoordinator.Tab, actions: AnyPublisher)? { + let flowCoordinator = HealthChatMembersFlowCoordinator(flowParameters: flowParameters) + membersFlowCoordinator = flowCoordinator + flowCoordinator.start() + + let details = NavigationTabCoordinator + .TabDetails(tag: .members, + title: HealthChatL10n.screenMemberDirectoryTitle, + icon: \.userProfile, + selectedIcon: \.userProfileSolid) + + return (tab: .init(coordinator: flowCoordinator.navigationStackCoordinator, details: details), + actions: flowCoordinator.actionsPublisher) + } +} +#endif diff --git a/ElementX/Sources/AppHooks/Hooks/HomeTabsHook.swift b/ElementX/Sources/AppHooks/Hooks/HomeTabsHook.swift new file mode 100644 index 000000000..65f85242c --- /dev/null +++ b/ElementX/Sources/AppHooks/Hooks/HomeTabsHook.swift @@ -0,0 +1,29 @@ +// +// Copyright 2026 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 + +/// Actions an extra tab can ask the session flow to perform on its behalf. +enum ExtraHomeTabAction { + case presentRoom(roomID: String) + case presentCall(roomID: String, isVoiceCall: Bool) +} + +nonisolated protocol HomeTabsHookProtocol: Sendable { + /// An extra tab appended after the standard tabs, or nil for none. + /// The hook keeps ownership of the tab's flow. + @MainActor func makeExtraTab(flowParameters: CommonFlowParameters) + -> (tab: NavigationTabCoordinator.Tab, actions: AnyPublisher)? +} + +struct DefaultHomeTabsHook: HomeTabsHookProtocol { + func makeExtraTab(flowParameters: CommonFlowParameters) + -> (tab: NavigationTabCoordinator.Tab, actions: AnyPublisher)? { + nil + } +} diff --git a/ElementX/Sources/FlowCoordinators/HealthChatMembersFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/HealthChatMembersFlowCoordinator.swift new file mode 100644 index 000000000..925570ac0 --- /dev/null +++ b/ElementX/Sources/FlowCoordinators/HealthChatMembersFlowCoordinator.swift @@ -0,0 +1,125 @@ +// +// Copyright 2026 Novaloop AG +// +// 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 SwiftState +import SwiftUI + +/// The flow behind the HealthChat "Members" tab: the member directory with +/// pushed member profiles. Chats and calls are handed off to the session flow. +final class HealthChatMembersFlowCoordinator { + private let flowParameters: CommonFlowParameters + + let navigationStackCoordinator: NavigationStackCoordinator + + private var cancellables = Set() + + enum State: StateType { + /// The state machine hasn't started. + case initial + /// The member directory at the root of the tab. + case memberDirectory + /// A member's profile pushed on top of the directory. + case memberProfile + } + + enum Event: EventType { + /// The flow is being started. + case start + /// Request presentation of a member's profile. + case showMemberProfile + /// The member profile has been dismissed. + case dismissedMemberProfile + } + + private let stateMachine: StateMachine + + private let actionsSubject: PassthroughSubject = .init() + var actionsPublisher: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(flowParameters: CommonFlowParameters) { + self.flowParameters = flowParameters + + navigationStackCoordinator = NavigationStackCoordinator() + stateMachine = StateMachine(state: .initial) + + configureStateMachine() + } + + func start() { + stateMachine.tryEvent(.start) + } + + // MARK: - Private + + private func configureStateMachine() { + stateMachine.addRoutes(event: .start, transitions: [.initial => .memberDirectory]) { [weak self] _ in + self?.presentMemberDirectory() + } + + stateMachine.addRoutes(event: .showMemberProfile, transitions: [.memberDirectory => .memberProfile]) + + stateMachine.addRoutes(event: .dismissedMemberProfile, transitions: [.memberProfile => .memberDirectory]) + + stateMachine.addErrorHandler { context in + fatalError("Unexpected transition from `\(context.fromState)` to `\(context.toState)` for event `\(String(describing: context.event))`.") + } + } + + private func presentMemberDirectory() { + let userSession = flowParameters.userSession + let apiService = HealthChatAPIService(tokenProvider: HealthChatOpenIDTokenProvider(clientProxy: userSession.clientProxy)) + let coordinator = HealthChatMemberDirectoryScreenCoordinator(parameters: .init(apiService: apiService, + clientProxy: userSession.clientProxy, + mediaProvider: userSession.mediaProvider, + userIndicatorController: flowParameters.userIndicatorController)) + + coordinator.actionsPublisher.sink { [weak self] action in + guard let self else { return } + switch action { + case .selectMember(let userID): + presentMemberProfile(userID: userID) + case .presentRoom(let roomID): + actionsSubject.send(.presentRoom(roomID: roomID)) + } + } + .store(in: &cancellables) + + navigationStackCoordinator.setRootCoordinator(coordinator) + } + + private func presentMemberProfile(userID: String) { + let parameters = UserProfileScreenCoordinatorParameters(userID: userID, + isPresentedModally: false, + userSession: flowParameters.userSession, + userIndicatorController: flowParameters.userIndicatorController, + analytics: flowParameters.analytics, + appSettings: flowParameters.appSettings, + appHooks: flowParameters.appHooks) + let coordinator = UserProfileScreenCoordinator(parameters: parameters) + + coordinator.actionsPublisher.sink { [weak self] action in + guard let self else { return } + switch action { + case .openDirectChat(let roomID): + actionsSubject.send(.presentRoom(roomID: roomID)) + case .startCall(let roomProxy, let isVoiceCall): + actionsSubject.send(.presentCall(roomID: roomProxy.id, isVoiceCall: isVoiceCall)) + case .dismiss: + navigationStackCoordinator.pop() + } + } + .store(in: &cancellables) + + stateMachine.tryEvent(.showMemberProfile) + navigationStackCoordinator.push(coordinator) { [weak self] in + self?.stateMachine.tryEvent(.dismissedMemberProfile) + } + } +} diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index a8a392b5b..b84894c94 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -21,7 +21,7 @@ enum UserSessionFlowCoordinatorAction { } class UserSessionFlowCoordinator: FlowCoordinatorProtocol { - enum HomeTab: Hashable { case chats, spaces, search } + enum HomeTab: Hashable { case chats, spaces, search, members } private let navigationRootCoordinator: NavigationRootCoordinator private let navigationTabCoordinator: NavigationTabCoordinator @@ -45,6 +45,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { private let searchTabNavigationStackCoordinator: NavigationStackCoordinator? private let searchTabDetails: NavigationTabCoordinator.TabDetails? + private let extraTabActions: AnyPublisher? + private var settingsFlowCoordinator: SettingsFlowCoordinator? enum State: StateType { @@ -128,6 +130,14 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { if let searchTabNavigationStackCoordinator, let searchTabDetails { tabs.append(.init(coordinator: searchTabNavigationStackCoordinator, details: searchTabDetails)) } + + if let extraTab = flowParameters.appHooks.homeTabsHook.makeExtraTab(flowParameters: flowParameters) { + tabs.append(extraTab.tab) + extraTabActions = extraTab.actions + } else { + extraTabActions = nil + } + navigationTabCoordinator.setTabs(tabs) stateMachine = flowParameters.stateMachineFactory.makeUserSessionFlowStateMachine(state: .initial) @@ -227,6 +237,17 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } private func setupObservers() { + extraTabActions? + .sink { [weak self] action in + switch action { + case .presentRoom(let roomID): + self?.handleAppRoute(.room(roomID: roomID, via: []), animated: true) + case .presentCall(let roomID, let isVoiceCall): + self?.handleAppRoute(.call(roomID: roomID, isVoiceCall: isVoiceCall), animated: true) + } + } + .store(in: &cancellables) + chatsTabFlowCoordinator.actionsPublisher .sink { [weak self] action in guard let self else { return } diff --git a/ElementX/Sources/Generated/Strings+HealthChat.swift b/ElementX/Sources/Generated/Strings+HealthChat.swift index 1f944c41f..21e337bcf 100644 --- a/ElementX/Sources/Generated/Strings+HealthChat.swift +++ b/ElementX/Sources/Generated/Strings+HealthChat.swift @@ -26,6 +26,12 @@ internal nonisolated enum HealthChatL10n { internal static var commonSpecialty: String { return HealthChatL10n.tr("HealthChat", "common_specialty") } /// Website internal static var commonWebsite: String { return HealthChatL10n.tr("HealthChat", "common_website") } + /// Your account is being reviewed. You’ll get access to the member directory once it has been approved. + internal static var screenMemberDirectoryPendingApproval: String { return HealthChatL10n.tr("HealthChat", "screen_member_directory_pending_approval") } + /// Name, practice, city or speciality + internal static var screenMemberDirectorySearchPlaceholder: String { return HealthChatL10n.tr("HealthChat", "screen_member_directory_search_placeholder") } + /// Members + internal static var screenMemberDirectoryTitle: String { return HealthChatL10n.tr("HealthChat", "screen_member_directory_title") } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index c9a419d88..820ca145a 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -56,6 +56,7 @@ enum TestablePreviewsDictionary { "FullscreenDialog_Previews" : FullscreenDialog_Previews.self, "GalleryRoomTimelineView_Previews" : GalleryRoomTimelineView_Previews.self, "HealthChatExtendedProfileSection_Previews" : HealthChatExtendedProfileSection_Previews.self, + "HealthChatMemberDirectoryScreen_Previews" : HealthChatMemberDirectoryScreen_Previews.self, "HighlightedTimelineItemModifier_Previews" : HighlightedTimelineItemModifier_Previews.self, "HomeScreenEmptyStateView_Previews" : HomeScreenEmptyStateView_Previews.self, "HomeScreenInviteCell_Previews" : HomeScreenInviteCell_Previews.self, diff --git a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenCoordinator.swift b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenCoordinator.swift new file mode 100644 index 000000000..3a014d6d2 --- /dev/null +++ b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenCoordinator.swift @@ -0,0 +1,56 @@ +// +// Copyright 2026 Novaloop AG +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import SwiftUI + +struct HealthChatMemberDirectoryScreenCoordinatorParameters { + let apiService: HealthChatAPIServiceProtocol + let clientProxy: ClientProxyProtocol + let mediaProvider: MediaProviderProtocol + let userIndicatorController: UserIndicatorControllerProtocol +} + +enum HealthChatMemberDirectoryScreenCoordinatorAction { + case selectMember(userID: String) + case presentRoom(roomID: String) +} + +final class HealthChatMemberDirectoryScreenCoordinator: CoordinatorProtocol { + private let viewModel: HealthChatMemberDirectoryScreenViewModelProtocol + + private var cancellables = Set() + + private let actionsSubject: PassthroughSubject = .init() + var actionsPublisher: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(parameters: HealthChatMemberDirectoryScreenCoordinatorParameters) { + viewModel = HealthChatMemberDirectoryScreenViewModel(apiService: parameters.apiService, + clientProxy: parameters.clientProxy, + mediaProvider: parameters.mediaProvider, + userIndicatorController: parameters.userIndicatorController) + } + + func start() { + viewModel.actionsPublisher.sink { [weak self] action in + guard let self else { return } + switch action { + case .selectMember(let userID): + actionsSubject.send(.selectMember(userID: userID)) + case .presentRoom(let roomID): + actionsSubject.send(.presentRoom(roomID: roomID)) + } + } + .store(in: &cancellables) + } + + func toPresentable() -> AnyView { + AnyView(HealthChatMemberDirectoryScreen(context: viewModel.context)) + } +} diff --git a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenModels.swift b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenModels.swift new file mode 100644 index 000000000..83459672d --- /dev/null +++ b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenModels.swift @@ -0,0 +1,41 @@ +// +// Copyright 2026 Novaloop AG +// +// 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 HealthChatMemberDirectoryScreenViewModelAction { + case selectMember(userID: String) + case presentRoom(roomID: String) +} + +nonisolated enum HealthChatMemberDirectoryScreenContent: Equatable { + case loading + case members([HealthChatMember]) + case pendingApproval(message: String) + case failure +} + +struct HealthChatMemberDirectoryScreenViewState: BindableState { + var content: HealthChatMemberDirectoryScreenContent = .loading + + var bindings = HealthChatMemberDirectoryScreenViewStateBindings() +} + +struct HealthChatMemberDirectoryScreenViewStateBindings { + var searchQuery = "" + var alertInfo: AlertInfo? +} + +enum HealthChatMemberDirectoryScreenAlertType: Hashable { + case failedStartingChat +} + +enum HealthChatMemberDirectoryScreenViewAction { + case retry + case selectMember(HealthChatMember) + case startChat(HealthChatMember) +} diff --git a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenViewModel.swift b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenViewModel.swift new file mode 100644 index 000000000..9727cf9f7 --- /dev/null +++ b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenViewModel.swift @@ -0,0 +1,132 @@ +// +// Copyright 2026 Novaloop AG +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import AsyncAlgorithms +import Combine +import SwiftUI + +typealias HealthChatMemberDirectoryScreenViewModelType = StateStoreViewModelV2 + +class HealthChatMemberDirectoryScreenViewModel: HealthChatMemberDirectoryScreenViewModelType, HealthChatMemberDirectoryScreenViewModelProtocol { + private let apiService: HealthChatAPIServiceProtocol + private let clientProxy: ClientProxyProtocol + private let userIndicatorController: UserIndicatorControllerProtocol + + private var searchQueryObservationTask: Task? + // periphery:ignore - auto cancels when reassigned + @CancellableTask private var fetchTask: Task? + + private let actionsSubject: PassthroughSubject = .init() + var actionsPublisher: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(apiService: HealthChatAPIServiceProtocol, + clientProxy: ClientProxyProtocol, + mediaProvider: MediaProviderProtocol, + userIndicatorController: UserIndicatorControllerProtocol) { + self.apiService = apiService + self.clientProxy = clientProxy + self.userIndicatorController = userIndicatorController + + super.init(initialViewState: HealthChatMemberDirectoryScreenViewState(), mediaProvider: mediaProvider) + + fetchMembers() + + // The stream replays the current query, which the initial fetch above already covers. + let searchQueryStream = context.observe(\.viewState.bindings.searchQuery) + .debounce(for: .milliseconds(300)) + .removeDuplicates() + .dropFirst() + searchQueryObservationTask = Task { [weak self] in + for await _ in searchQueryStream { + self?.fetchMembers() + } + } + } + + isolated deinit { + searchQueryObservationTask?.cancel() + } + + // MARK: - Public + + override func process(viewAction: HealthChatMemberDirectoryScreenViewAction) { + switch viewAction { + case .retry: + state.content = .loading + fetchMembers() + case .selectMember(let member): + actionsSubject.send(.selectMember(userID: member.userID)) + case .startChat(let member): + Task { await startChat(with: member) } + } + } + + // MARK: - Private + + private func fetchMembers() { + fetchTask = Task { [weak self] in + guard let self else { return } + + let searchQuery = state.bindings.searchQuery + let result = await apiService.members(searchQuery: searchQuery.isEmpty ? nil : searchQuery) + + guard !Task.isCancelled else { return } + + switch result { + case .success(let response) where !response.isApproved: + state.content = .pendingApproval(message: response.pendingMessage ?? HealthChatL10n.screenMemberDirectoryPendingApproval) + case .success(let response): + state.content = .members(response.users) + case .failure: + state.content = .failure + } + } + } + + private func startChat(with member: HealthChatMember) async { + switch clientProxy.directRoomForUserID(member.userID) { + case .success(let roomID?): + actionsSubject.send(.presentRoom(roomID: roomID)) + case .success(nil): + showLoadingIndicator() + defer { hideLoadingIndicator() } + + switch await clientProxy.createDirectRoom(with: member.userID, expectedRoomName: member.displayName) { + case .success(let roomID): + actionsSubject.send(.presentRoom(roomID: roomID)) + case .failure: + state.bindings.alertInfo = .init(id: .failedStartingChat, + title: L10n.commonError, + message: L10n.screenStartChatErrorStartingChat) + } + case .failure: + state.bindings.alertInfo = .init(id: .failedStartingChat, + title: L10n.commonError, + message: L10n.screenStartChatErrorStartingChat) + } + } + + // MARK: User Indicators + + private var loadingIndicatorIdentifier: String { + "\(Self.self)-Loading" + } + + private func showLoadingIndicator() { + userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, + type: .modal(progress: .indeterminate, interactiveDismissDisabled: false, allowsInteraction: false), + title: L10n.commonLoading, + persistent: true), + delay: .milliseconds(100)) + } + + private func hideLoadingIndicator() { + userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier) + } +} diff --git a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenViewModelProtocol.swift b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenViewModelProtocol.swift new file mode 100644 index 000000000..cba3035b2 --- /dev/null +++ b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenViewModelProtocol.swift @@ -0,0 +1,14 @@ +// +// Copyright 2026 Novaloop AG +// +// 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 HealthChatMemberDirectoryScreenViewModelProtocol { + var actionsPublisher: AnyPublisher { get } + var context: HealthChatMemberDirectoryScreenViewModelType.Context { get } +} diff --git a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/View/HealthChatMemberDirectoryScreen.swift b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/View/HealthChatMemberDirectoryScreen.swift new file mode 100644 index 000000000..f85d79628 --- /dev/null +++ b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/View/HealthChatMemberDirectoryScreen.swift @@ -0,0 +1,165 @@ +// +// Copyright 2026 Novaloop AG +// +// 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 HealthChatMemberDirectoryScreen: View { + @Bindable var context: HealthChatMemberDirectoryScreenViewModel.Context + + var body: some View { + content + .navigationTitle(HealthChatL10n.screenMemberDirectoryTitle) + .navigationBarTitleDisplayMode(.inline) + .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) + .searchController(query: $context.searchQuery, + placeholder: HealthChatL10n.screenMemberDirectorySearchPlaceholder) + .alert(item: $context.alertInfo) + } + + // MARK: - Private + + @ViewBuilder + private var content: some View { + switch context.viewState.content { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .pendingApproval(let message): + statusView(icon: \.time, message: message) + case .failure: + failureView + case .members(let members) where members.isEmpty: + statusView(icon: \.search, message: L10n.commonNoResults) + case .members(let members): + memberList(members) + } + } + + private func memberList(_ members: [HealthChatMember]) -> some View { + Form { + Section { + ForEach(members) { member in + HealthChatMemberListRow(member: member, mediaProvider: context.mediaProvider) { + context.send(viewAction: .selectMember(member)) + } startChatAction: { + context.send(viewAction: .startChat(member)) + } + } + } + } + .compoundList() + } + + private var failureView: some View { + VStack(spacing: 16) { + statusContent(icon: \.error, message: L10n.commonSomethingWentWrong) + + Button(L10n.actionRetry) { + context.send(viewAction: .retry) + } + .buttonStyle(.compound(.secondary, size: .medium)) + .fixedSize() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(16) + } + + private func statusView(icon: KeyPath, message: String) -> some View { + statusContent(icon: icon, message: message) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(16) + } + + private func statusContent(icon: KeyPath, message: String) -> some View { + VStack(spacing: 16) { + BigIcon(icon: icon) + + Text(message) + .font(.compound.bodyLG) + .foregroundColor(.compound.textSecondary) + .multilineTextAlignment(.center) + } + } +} + +// MARK: - Previews + +struct HealthChatMemberDirectoryScreen_Previews: PreviewProvider, TestablePreview { + static let loadedViewModel = makeViewModel(configuration: .members) + static let pendingViewModel = makeViewModel(configuration: .pendingApproval) + static let failureViewModel = makeViewModel(configuration: .failure) + + static var previews: some View { + ElementNavigationStack { + HealthChatMemberDirectoryScreen(context: loadedViewModel.context) + } + .snapshotPreferences(expect: loadedViewModel.context.observe(\.viewState.content).map { $0 != .loading }) + .previewDisplayName("Members") + + ElementNavigationStack { + HealthChatMemberDirectoryScreen(context: pendingViewModel.context) + } + .snapshotPreferences(expect: pendingViewModel.context.observe(\.viewState.content).map { $0 != .loading }) + .previewDisplayName("Pending approval") + + ElementNavigationStack { + HealthChatMemberDirectoryScreen(context: failureViewModel.context) + } + .snapshotPreferences(expect: failureViewModel.context.observe(\.viewState.content).map { $0 != .loading }) + .previewDisplayName("Failure") + } + + enum PreviewConfiguration { + case members + case pendingApproval + case failure + } + + static func makeViewModel(configuration: PreviewConfiguration) -> HealthChatMemberDirectoryScreenViewModel { + let apiService = HealthChatAPIServiceMock() + + switch configuration { + case .members: + let members = try? JSONDecoder().decode([HealthChatMember].self, from: Data(""" + [ + { + "userId": "@alice:healthchat.ch", + "displayName": "Dr. Alice Alpha", + "specialty": "Physiotherapie", + "practiceName": "Praxis Alpha", + "businessCity": "Zürich" + }, + { + "userId": "@bob:healthchat.ch", + "displayName": "Bob Beta", + "specialty": "Ergotherapie", + "businessCity": "Basel" + }, + { + "userId": "@carol:healthchat.ch", + "displayName": "Dr. Carol Gamma" + } + ] + """.utf8)) + apiService.membersSearchQueryReturnValue = .success(.init(users: members ?? [], + isApproved: true, + pendingMessage: nil)) + case .pendingApproval: + apiService.membersSearchQueryReturnValue = .success(.init(users: [], + isApproved: false, + pendingMessage: "Ihr Konto wird derzeit geprüft. Sie erhalten Zugriff auf das Mitgliederverzeichnis, sobald Ihr Konto freigegeben wurde.")) + case .failure: + apiService.membersSearchQueryReturnValue = .failure(.networkFailure) + } + + return HealthChatMemberDirectoryScreenViewModel(apiService: apiService, + clientProxy: ClientProxyMock(.init()), + mediaProvider: MediaProviderMock(.init()), + userIndicatorController: UserIndicatorControllerMock()) + } +} diff --git a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/View/HealthChatMemberListRow.swift b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/View/HealthChatMemberListRow.swift new file mode 100644 index 000000000..2dad3374d --- /dev/null +++ b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/View/HealthChatMemberListRow.swift @@ -0,0 +1,65 @@ +// +// Copyright 2026 Novaloop AG +// +// 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 HealthChatMemberListRow: View { + let member: HealthChatMember + let mediaProvider: MediaProviderProtocol? + let selectAction: () -> Void + let startChatAction: () -> Void + + private var subtitle: String { + [member.specialty, member.practiceName, member.businessCity] + .compactMap { $0 } + .joined(separator: " · ") + } + + var body: some View { + ListRow(kind: .custom { + HStack(spacing: 12) { + Button(action: selectAction) { + HStack(spacing: 12) { + LoadableAvatarImage(url: member.avatarURL, + name: member.displayName, + contentID: member.userID, + avatarSize: .user(on: .startChat), + mediaProvider: mediaProvider) + + VStack(alignment: .leading, spacing: 2) { + Text(member.displayName ?? member.userID) + .font(.compound.bodyLGSemibold) + .foregroundColor(.compound.textPrimary) + .lineLimit(1) + + if !subtitle.isEmpty { + Text(subtitle) + .font(.compound.bodySM) + .foregroundColor(.compound.textSecondary) + .lineLimit(2) + } + } + + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Button(action: startChatAction) { + CompoundIcon(\.chat) + .foregroundColor(.compound.iconAccentTertiary) + } + .buttonStyle(.borderless) + .accessibilityLabel(L10n.actionStartChat) + } + .padding(.horizontal, ListRowPadding.horizontal) + .padding(.vertical, 8) + }) + } +} diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index 33dcddf0b..bd5622e1b 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -340,6 +340,13 @@ extension PreviewTests { } } + @Test + func healthChatMemberDirectoryScreen() async throws { + for (index, preview) in HealthChatMemberDirectoryScreen_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + @Test func highlightedTimelineItemModifier() async throws { for (index, preview) in HighlightedTimelineItemModifier_Previews._allPreviews.enumerated() { diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Failure-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Failure-iPad-en-GB.png new file mode 100644 index 000000000..ebb5c59d2 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Failure-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1330bdf08030ca860724fd5c2f65033004dd42c066fc8617ff8d00954bad83c8 +size 79809 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Failure-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Failure-iPad-pseudo.png new file mode 100644 index 000000000..7f8b6081e --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Failure-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca1253d8529e3c7ca8541d8e9b4533680496e97fed4d0783a48998cb01137821 +size 81699 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Failure-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Failure-iPhone-en-GB.png new file mode 100644 index 000000000..de717e431 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Failure-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:91247aee8564f70018e5ef3fe17966fc2111d1c1a1f910e8ea382657320adf43 +size 39454 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Failure-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Failure-iPhone-pseudo.png new file mode 100644 index 000000000..844119cc4 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Failure-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:736ba40f231684e1a886bc5aac2ca14002f0d73329c25bc3786f6733c09e310c +size 42377 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPad-en-GB.png new file mode 100644 index 000000000..759fc0854 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:835f7263895535cfec1d671cedeb84751be94545c1ca3670e55ee8d1e09b2bf7 +size 115087 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPad-pseudo.png new file mode 100644 index 000000000..3bb95e882 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d45d4bce9720d2953fc778eb008fd5a126a45cf01ecf9eeb98ad2ef203733ed +size 117358 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPhone-en-GB.png new file mode 100644 index 000000000..a6c74aa90 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f5f4c69f3edb9af10ce7286084d969b67d949c5d3499d3ef49eb1b82caa19a7 +size 68274 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPhone-pseudo.png new file mode 100644 index 000000000..ea8b41287 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a52d8441c8052e6b633dbf0aba62d55912ab177008764b06d65c99420a002085 +size 69205 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Pending-approval-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Pending-approval-iPad-en-GB.png new file mode 100644 index 000000000..f729f8680 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Pending-approval-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de8ee6d4ea64a8bbec86e011b8d6ff507ed1f0c8664e5f2b50c2e25f8e089525 +size 85969 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Pending-approval-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Pending-approval-iPad-pseudo.png new file mode 100644 index 000000000..f729f8680 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Pending-approval-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de8ee6d4ea64a8bbec86e011b8d6ff507ed1f0c8664e5f2b50c2e25f8e089525 +size 85969 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Pending-approval-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Pending-approval-iPhone-en-GB.png new file mode 100644 index 000000000..ac1b9d1ce --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Pending-approval-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:67522686b52a4479ea55eb1a202e516236ce40c7277112ca77e3c86d9ba565a6 +size 50652 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Pending-approval-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Pending-approval-iPhone-pseudo.png new file mode 100644 index 000000000..ac1b9d1ce --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Pending-approval-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:67522686b52a4479ea55eb1a202e516236ce40c7277112ca77e3c86d9ba565a6 +size 50652 diff --git a/UnitTests/Sources/HealthChat/HealthChatMemberDirectoryScreenViewModelTests.swift b/UnitTests/Sources/HealthChat/HealthChatMemberDirectoryScreenViewModelTests.swift new file mode 100644 index 000000000..7d78ccb0c --- /dev/null +++ b/UnitTests/Sources/HealthChat/HealthChatMemberDirectoryScreenViewModelTests.swift @@ -0,0 +1,145 @@ +// +// Copyright 2026 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. +// + +@testable import ElementX +import Foundation +import Testing + +final class HealthChatMemberDirectoryScreenViewModelTests { + private let apiService = HealthChatAPIServiceMock() + private let clientProxy = ClientProxyMock(.init()) + + @Test + func loadsMembersOnStart() async throws { + apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@alice:healthchat.ch"])) + + let viewModel = makeViewModel() + + let deferred = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in + guard case .members(let members) = content else { return false } + return members.map(\.userID) == ["@alice:healthchat.ch"] + } + try await deferred.fulfill() + } + + @Test + func showsPendingApprovalMessage() async throws { + apiService.membersSearchQueryReturnValue = .success(.init(users: [], + isApproved: false, + pendingMessage: "Ihr Konto wird geprüft")) + + let viewModel = makeViewModel() + + let deferred = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in + content == .pendingApproval(message: "Ihr Konto wird geprüft") + } + try await deferred.fulfill() + } + + @Test + func showsFailureAndRecoversOnRetry() async throws { + apiService.membersSearchQueryReturnValue = .failure(.networkFailure) + + let viewModel = makeViewModel() + + let deferredFailure = deferFulfillment(viewModel.context.observe(\.viewState.content)) { $0 == .failure } + try await deferredFailure.fulfill() + + apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@alice:healthchat.ch"])) + + let deferredMembers = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in + guard case .members = content else { return false } + return true + } + viewModel.context.send(viewAction: .retry) + try await deferredMembers.fulfill() + } + + @Test + func searchQueryTriggersServerSearch() async throws { + let allMembers = try makeResponse(userIDs: ["@alice:healthchat.ch"]) + let searchResults = try makeResponse(userIDs: ["@zoe:healthchat.ch"]) + apiService.membersSearchQueryClosure = { searchQuery in + searchQuery == "Zürich" ? .success(searchResults) : .success(allMembers) + } + + let viewModel = makeViewModel() + + let deferred = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in + guard case .members(let members) = content else { return false } + return members.map(\.userID) == ["@zoe:healthchat.ch"] + } + viewModel.context.searchQuery = "Zürich" + try await deferred.fulfill() + + #expect(apiService.membersSearchQueryReceivedInvocations.last == "Zürich") + } + + @Test + func startChatWithExistingDMPresentsRoom() async throws { + apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@alice:healthchat.ch"])) + clientProxy.directRoomForUserIDReturnValue = .success("existingRoom") + + let viewModel = makeViewModel() + + let deferred = deferFulfillment(viewModel.actionsPublisher) { action in + guard case .presentRoom(let roomID) = action else { return false } + return roomID == "existingRoom" + } + try viewModel.context.send(viewAction: .startChat(makeMember(userID: "@alice:healthchat.ch"))) + try await deferred.fulfill() + } + + @Test + func startChatWithoutDMCreatesOne() async throws { + apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@alice:healthchat.ch"])) + clientProxy.directRoomForUserIDReturnValue = .success(nil) + clientProxy.createDirectRoomWithExpectedRoomNameReturnValue = .success("newRoom") + + let viewModel = makeViewModel() + + let deferred = deferFulfillment(viewModel.actionsPublisher) { action in + guard case .presentRoom(let roomID) = action else { return false } + return roomID == "newRoom" + } + try viewModel.context.send(viewAction: .startChat(makeMember(userID: "@alice:healthchat.ch"))) + try await deferred.fulfill() + + #expect(clientProxy.createDirectRoomWithExpectedRoomNameReceivedArguments?.userID == "@alice:healthchat.ch") + } + + @Test + func selectMemberEmitsAction() async throws { + apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@alice:healthchat.ch"])) + + let viewModel = makeViewModel() + + let deferred = deferFulfillment(viewModel.actionsPublisher) { action in + guard case .selectMember(let userID) = action else { return false } + return userID == "@alice:healthchat.ch" + } + try viewModel.context.send(viewAction: .selectMember(makeMember(userID: "@alice:healthchat.ch"))) + try await deferred.fulfill() + } + + // MARK: - Helpers + + private func makeViewModel() -> HealthChatMemberDirectoryScreenViewModel { + HealthChatMemberDirectoryScreenViewModel(apiService: apiService, + clientProxy: clientProxy, + mediaProvider: MediaProviderMock(.init()), + userIndicatorController: UserIndicatorControllerMock()) + } + + private func makeResponse(userIDs: [String]) throws -> HealthChatMembersResponse { + try .init(users: userIDs.map(makeMember(userID:)), isApproved: true, pendingMessage: nil) + } + + private func makeMember(userID: String) throws -> HealthChatMember { + try JSONDecoder().decode(HealthChatMember.self, from: Data(#"{ "userId": "\#(userID)" }"#.utf8)) + } +}