From b70e77f7c554f99c9be25d5e26c41cc4ab98c81e Mon Sep 17 00:00:00 2001 From: Sedat Qaja Date: Fri, 9 Oct 2026 14:23:39 +0200 Subject: [PATCH] feat(healthchat): show default contacts while approval is pending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unapproved account only gets the server's default contacts, which are its way to reach support — list them instead of discarding them, and hide the search field that cannot return anything. --- .../Localizations/de.lproj/HealthChat.strings | 2 + .../Localizations/en.lproj/HealthChat.strings | 2 + .../Localizations/fr.lproj/HealthChat.strings | 2 + .../Generated/Strings+HealthChat.swift | 4 + ...ealthChatMemberDirectoryScreenModels.swift | 6 +- ...thChatMemberDirectoryScreenViewModel.swift | 5 +- .../HealthChatMemberDirectoryScreen.swift | 75 ++++++++++++++++--- ...tMemberDirectoryScreenViewModelTests.swift | 51 ++++++++++--- 8 files changed, 121 insertions(+), 26 deletions(-) diff --git a/ElementX/Resources/Localizations/de.lproj/HealthChat.strings b/ElementX/Resources/Localizations/de.lproj/HealthChat.strings index 76e3c124a..48704078d 100644 --- a/ElementX/Resources/Localizations/de.lproj/HealthChat.strings +++ b/ElementX/Resources/Localizations/de.lproj/HealthChat.strings @@ -1,4 +1,5 @@ // HealthChat fork strings. Not managed by Localazy — edit all three languages by hand. +"action_refresh" = "Aktualisieren"; "common_academic_title" = "Titel"; "common_address" = "Adresse"; "common_business_email" = "E-Mail (geschäftlich)"; @@ -19,6 +20,7 @@ "screen_invite_member_success" = "Einladung wurde versendet"; "screen_invite_member_title" = "Mitglied einladen"; "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_pending_title" = "Konto wird geprüft"; "screen_member_directory_search_placeholder" = "Name, Praxis, Ort oder Fachgebiet"; "screen_member_directory_title" = "Mitglieder"; "screen_profile_edit_section_address" = "Praxisadresse"; diff --git a/ElementX/Resources/Localizations/en.lproj/HealthChat.strings b/ElementX/Resources/Localizations/en.lproj/HealthChat.strings index 90c11a769..84ddcee3e 100644 --- a/ElementX/Resources/Localizations/en.lproj/HealthChat.strings +++ b/ElementX/Resources/Localizations/en.lproj/HealthChat.strings @@ -1,4 +1,5 @@ // HealthChat fork strings. Not managed by Localazy — edit all three languages by hand. +"action_refresh" = "Refresh"; "common_academic_title" = "Title"; "common_address" = "Address"; "common_business_email" = "Business email"; @@ -19,6 +20,7 @@ "screen_invite_member_success" = "Invitation sent"; "screen_invite_member_title" = "Invite a member"; "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_pending_title" = "Account under review"; "screen_member_directory_search_placeholder" = "Name, practice, city or speciality"; "screen_member_directory_title" = "Members"; "screen_profile_edit_section_address" = "Practice address"; diff --git a/ElementX/Resources/Localizations/fr.lproj/HealthChat.strings b/ElementX/Resources/Localizations/fr.lproj/HealthChat.strings index 7028bb30b..3542be620 100644 --- a/ElementX/Resources/Localizations/fr.lproj/HealthChat.strings +++ b/ElementX/Resources/Localizations/fr.lproj/HealthChat.strings @@ -1,4 +1,5 @@ // HealthChat fork strings. Not managed by Localazy — edit all three languages by hand. +"action_refresh" = "Actualiser"; "common_academic_title" = "Titre"; "common_address" = "Adresse"; "common_business_email" = "Email (professionnel)"; @@ -19,6 +20,7 @@ "screen_invite_member_success" = "Invitation envoyée"; "screen_invite_member_title" = "Inviter un membre"; "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_pending_title" = "Compte en cours de vérification"; "screen_member_directory_search_placeholder" = "Nom, cabinet, ville ou spécialisation"; "screen_member_directory_title" = "Membres"; "screen_profile_edit_section_address" = "Adresse du cabinet"; diff --git a/ElementX/Sources/Generated/Strings+HealthChat.swift b/ElementX/Sources/Generated/Strings+HealthChat.swift index 3e528425a..367cbe15b 100644 --- a/ElementX/Sources/Generated/Strings+HealthChat.swift +++ b/ElementX/Sources/Generated/Strings+HealthChat.swift @@ -10,6 +10,8 @@ import Foundation // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces internal nonisolated enum HealthChatL10n { + /// Refresh + internal static var actionRefresh: String { return HealthChatL10n.tr("HealthChat", "action_refresh") } /// Title internal static var commonAcademicTitle: String { return HealthChatL10n.tr("HealthChat", "common_academic_title") } /// Address @@ -50,6 +52,8 @@ internal nonisolated enum HealthChatL10n { internal static var screenInviteMemberTitle: String { return HealthChatL10n.tr("HealthChat", "screen_invite_member_title") } /// 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") } + /// Account under review + internal static var screenMemberDirectoryPendingTitle: String { return HealthChatL10n.tr("HealthChat", "screen_member_directory_pending_title") } /// Name, practice, city or speciality internal static var screenMemberDirectorySearchPlaceholder: String { return HealthChatL10n.tr("HealthChat", "screen_member_directory_search_placeholder") } /// Members diff --git a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenModels.swift b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenModels.swift index a16dedcea..321e47442 100644 --- a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenModels.swift +++ b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenModels.swift @@ -17,14 +17,15 @@ enum HealthChatMemberDirectoryScreenViewModelAction { nonisolated enum HealthChatMemberDirectoryScreenContent: Equatable { case loading case members([HealthChatMember]) - case pendingApproval(message: String) + /// Not approved yet: the server's explanation alongside the default contacts it still allows. + case pendingApproval(message: String, members: [HealthChatMember]) case failure } struct HealthChatMemberDirectoryScreenViewState: BindableState { var content: HealthChatMemberDirectoryScreenContent = .loading - /// Inviting others is only available to approved accounts. + /// Searching and inviting others are only available to approved accounts. var isApproved = false /// The hint shown while the own profile is missing required fields. @@ -44,6 +45,7 @@ enum HealthChatMemberDirectoryScreenAlertType: Hashable { enum HealthChatMemberDirectoryScreenViewAction { case retry + case refresh case selectMember(HealthChatMember) case startChat(HealthChatMember) case editProfile diff --git a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenViewModel.swift b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenViewModel.swift index 9207b4f8a..bcd94a48a 100644 --- a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenViewModel.swift +++ b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenViewModel.swift @@ -60,6 +60,8 @@ class HealthChatMemberDirectoryScreenViewModel: HealthChatMemberDirectoryScreenV case .retry: state.content = .loading fetchMembers() + case .refresh: + fetchMembers() case .selectMember(let member): actionsSubject.send(.selectMember(userID: member.userID)) case .startChat(let member): @@ -86,7 +88,8 @@ class HealthChatMemberDirectoryScreenViewModel: HealthChatMemberDirectoryScreenV case .success(let response) where !response.isApproved: state.isApproved = false state.completenessMessage = nil - state.content = .pendingApproval(message: response.pendingMessage ?? HealthChatL10n.screenMemberDirectoryPendingApproval) + state.content = .pendingApproval(message: response.pendingMessage ?? HealthChatL10n.screenMemberDirectoryPendingApproval, + members: response.users) case .success(let response): state.isApproved = true await updateCompletenessMessage() diff --git a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/View/HealthChatMemberDirectoryScreen.swift b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/View/HealthChatMemberDirectoryScreen.swift index cc1372a48..5b2d3e2e0 100644 --- a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/View/HealthChatMemberDirectoryScreen.swift +++ b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/View/HealthChatMemberDirectoryScreen.swift @@ -12,12 +12,21 @@ struct HealthChatMemberDirectoryScreen: View { @Bindable var context: HealthChatMemberDirectoryScreenViewModel.Context var body: some View { + // Searching is approved-only, and the search controller can't be applied conditionally. + if context.viewState.isApproved { + screen + .searchController(query: $context.searchQuery, + placeholder: HealthChatL10n.screenMemberDirectorySearchPlaceholder) + } else { + screen + } + } + + private var screen: some View { content .navigationTitle(HealthChatL10n.screenMemberDirectoryTitle) .navigationBarTitleDisplayMode(.inline) .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) - .searchController(query: $context.searchQuery, - placeholder: HealthChatL10n.screenMemberDirectorySearchPlaceholder) .toolbar { toolbar } .alert(item: $context.alertInfo) } @@ -53,8 +62,8 @@ struct HealthChatMemberDirectoryScreen: View { case .loading: ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) - case .pendingApproval(let message): - statusView(icon: \.time, message: message) + case .pendingApproval(let message, let members): + pendingApprovalList(message: message, members: members) case .failure: failureView case .members(let members) where members.isEmpty: @@ -75,17 +84,44 @@ struct HealthChatMemberDirectoryScreen: View { } } + memberSection(members) + } + .compoundList() + } + + /// The server allows an unapproved account the default contacts only, so they are listed + /// below the hint rather than withheld — they are the way to reach support. + private func pendingApprovalList(message: String, 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)) - } + ListRow(label: .default(title: HealthChatL10n.screenMemberDirectoryPendingTitle, + description: message, + icon: \.time), + kind: .label) + + // Without the search field there is no other way to pick up an approval + // that arrives while the app is running. + ListRow(label: .default(title: HealthChatL10n.actionRefresh, icon: \.restart), + kind: .button { + context.send(viewAction: .refresh) + }) + } + + memberSection(members) + } + .compoundList() + } + + private func memberSection(_ members: [HealthChatMember]) -> some View { + 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 { @@ -202,7 +238,22 @@ struct HealthChatMemberDirectoryScreen_Previews: PreviewProvider, TestablePrevie apiService.profileCompletenessReturnValue = try? .success(JSONDecoder().decode(HealthChatProfileCompleteness.self, from: Data(#"{ "isComplete": false, "message": "Profil unvollständig – Fehlende Angaben: Fachgebiet, Ort" }"#.utf8))) case .pendingApproval: - apiService.membersSearchQueryReturnValue = .success(.init(users: [], + // The server hands an unapproved account its default contacts, nothing else. + let defaultContacts = try? JSONDecoder().decode([HealthChatMember].self, from: Data(""" + [ + { + "userId": "@natascha.katumba:healthchat.ch", + "displayName": "Natascha Katumba", + "practiceName": "HealthChat Support" + }, + { + "userId": "@sophia.cagna:healthchat.ch", + "displayName": "Sophia Cagna", + "practiceName": "HealthChat Support" + } + ] + """.utf8)) + apiService.membersSearchQueryReturnValue = .success(.init(users: defaultContacts ?? [], isApproved: false, pendingMessage: "Ihr Konto wird derzeit geprüft. Sie erhalten Zugriff auf das Mitgliederverzeichnis, sobald Ihr Konto freigegeben wurde.")) case .failure: diff --git a/UnitTests/Sources/HealthChat/HealthChatMemberDirectoryScreenViewModelTests.swift b/UnitTests/Sources/HealthChat/HealthChatMemberDirectoryScreenViewModelTests.swift index 65f840250..ef2a27eae 100644 --- a/UnitTests/Sources/HealthChat/HealthChatMemberDirectoryScreenViewModelTests.swift +++ b/UnitTests/Sources/HealthChat/HealthChatMemberDirectoryScreenViewModelTests.swift @@ -31,19 +31,46 @@ final class HealthChatMemberDirectoryScreenViewModelTests { } @Test - func showsPendingApprovalMessage() async throws { - apiService.membersSearchQueryReturnValue = .success(.init(users: [], - isApproved: false, - pendingMessage: "Ihr Konto wird geprüft")) + func showsPendingApprovalMessageWithTheDefaultContacts() async throws { + apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@natascha.katumba:healthchat.ch"], + 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") + guard case .pendingApproval(let message, let members) = content else { return false } + return message == "Ihr Konto wird geprüft" && members.map(\.userID) == ["@natascha.katumba:healthchat.ch"] } try await deferred.fulfill() } + @Test + func refreshPicksUpAnApprovalWithoutClearingTheList() async throws { + apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@natascha.katumba:healthchat.ch"], + isApproved: false, + pendingMessage: "Ihr Konto wird geprüft")) + + let viewModel = makeViewModel() + + let deferredPending = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in + guard case .pendingApproval = content else { return false } + return true + } + try await deferredPending.fulfill() + + apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@alice:healthchat.ch"])) + + let deferredMembers = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in + guard case .members(let members) = content else { return false } + return members.map(\.userID) == ["@alice:healthchat.ch"] + } + viewModel.context.send(viewAction: .refresh) + try await deferredMembers.fulfill() + + #expect(viewModel.context.viewState.isApproved) + } + @Test func showsFailureAndRecoversOnRetry() async throws { apiService.membersSearchQueryReturnValue = .failure(.networkFailure) @@ -160,10 +187,10 @@ final class HealthChatMemberDirectoryScreenViewModelTests { } @Test - func pendingApprovalDisablesInviting() async throws { - apiService.membersSearchQueryReturnValue = .success(.init(users: [], - isApproved: false, - pendingMessage: "Ihr Konto wird geprüft")) + func pendingApprovalDisablesSearchingAndInviting() async throws { + apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: [], + isApproved: false, + pendingMessage: "Ihr Konto wird geprüft")) let viewModel = makeViewModel() @@ -220,8 +247,10 @@ final class HealthChatMemberDirectoryScreenViewModelTests { userIndicatorController: UserIndicatorControllerMock()) } - private func makeResponse(userIDs: [String]) throws -> HealthChatMembersResponse { - try .init(users: userIDs.map(makeMember(userID:)), isApproved: true, pendingMessage: nil) + private func makeResponse(userIDs: [String], + isApproved: Bool = true, + pendingMessage: String? = nil) throws -> HealthChatMembersResponse { + try .init(users: userIDs.map(makeMember(userID:)), isApproved: isApproved, pendingMessage: pendingMessage) } private func makeMember(userID: String) throws -> HealthChatMember {