feat(healthchat): show default contacts while approval is pending

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.
This commit is contained in:
Sedat Qaja
2026-10-09 14:23:39 +02:00
parent 8124a72516
commit b70e77f7c5
8 changed files with 121 additions and 26 deletions
@@ -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";
@@ -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";
@@ -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";
@@ -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
@@ -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
@@ -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()
@@ -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:
@@ -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 {