feat(healthchat): add member directory tab to home navigation

Third bottom-nav tab backed by the HealthChat API: searchable member
cards with native start-chat, approval-pending banner, retry on
failure and navigation into the native profile screens.
This commit is contained in:
2026-07-30 21:13:53 +02:00
parent ce180ef25b
commit d37b3a2359
84 changed files with 1662 additions and 64 deletions
@@ -373,6 +373,11 @@ class LoggedInFlowNode(
override fun navigateToBugReport() {
callback.navigateToBugReport()
}
// HealthChat fork: open a user profile from the member directory tab.
override fun navigateToUserProfile(userId: UserId) {
backstack.push(NavTarget.UserProfile(userId))
}
}
homeEntryPoint.createNode(
parentNode = this,
@@ -8,6 +8,8 @@
package io.element.android.features.healthchat.api
import io.element.android.libraries.matrix.api.user.MatrixUser
/**
* Display name fallback chain, mirroring the web client:
* displayName → "title firstName lastName" → businessEmail → [fallback].
@@ -21,3 +23,13 @@ fun HealthChatMember.bestDisplayName(fallback: String): String {
businessEmail?.takeIf { it.isNotBlank() }?.let { return it }
return fallback
}
/**
* The Matrix representation of a directory member, e.g. to start a DM through the app's
* regular flows.
*/
fun HealthChatMember.toMatrixUser() = MatrixUser(
userId = userId,
displayName = displayName,
avatarUrl = avatarUrl,
)
@@ -18,7 +18,7 @@ import io.element.android.libraries.matrix.api.core.UserId
* Injected into the upstream profile screens as a slot so the fork's only upstream diff is
* one defaulted parameter plus the injection (same pattern as `InvitePeopleRenderer`).
*/
interface HealthChatProfileSectionRenderer {
fun interface HealthChatProfileSectionRenderer {
@Composable
fun Render(
userId: UserId,
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.api.directory
import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface MemberDirectoryEvents {
/**
* Fired by the view on first composition. The tab presenter runs from app start (it is
* composed by the Home presenter), so the first fetch is deferred until the tab is shown.
*/
data object LoadIfNeeded : MemberDirectoryEvents
/** Header refresh and "try again" — one load path. */
data object Refresh : MemberDirectoryEvents
data class StartDm(val matrixUser: MatrixUser) : MemberDirectoryEvents
data object ClearStartDmState : MemberDirectoryEvents
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.api.directory
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
/**
* Renders the member directory tab content. Injected into the Home screen as a slot so the
* fork's upstream diff stays minimal.
*
* Nullable callbacks hide their affordance entirely (e.g. profile editing and inviting only
* become available once the corresponding screens exist).
*/
fun interface MemberDirectoryRenderer {
@Composable
fun Render(
state: MemberDirectoryState,
contentPadding: PaddingValues,
lazyListState: LazyListState,
onOpenDm: (RoomId) -> Unit,
onMemberClick: ((UserId) -> Unit)?,
onMyProfileClick: (() -> Unit)?,
onEditProfileClick: (() -> Unit)?,
onInviteClick: (() -> Unit)?,
modifier: Modifier,
)
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.api.directory
import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.RoomId
data class MemberDirectoryState(
val searchQuery: TextFieldState,
val directory: AsyncData<HealthChatMemberDirectory>,
val startDmActionState: AsyncAction<RoomId>,
val eventSink: (MemberDirectoryEvents) -> Unit,
) {
/** Unknown-while-loading counts as approved so no banner flashes during the first load. */
val isApproved: Boolean
get() = directory.dataOrNull()?.isApproved != false
/** The verbatim German server explanation, only when the account is not approved. */
val pendingMessage: String?
get() = directory.dataOrNull()?.takeIf { !it.isApproved }?.pendingMessage
}
@@ -0,0 +1,99 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.api.directory
import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.toImmutableList
/**
* Sample state builders, shared by previews inside the healthchat feature and by the Home
* screen's previews (same pattern as `aDirectLogoutState` in `features/logout/api`).
*/
fun aMemberDirectoryState(
searchQuery: TextFieldState = TextFieldState(),
directory: AsyncData<HealthChatMemberDirectory> = AsyncData.Success(aMemberDirectory()),
startDmActionState: AsyncAction<RoomId> = AsyncAction.Uninitialized,
eventSink: (MemberDirectoryEvents) -> Unit = {},
) = MemberDirectoryState(
searchQuery = searchQuery,
directory = directory,
startDmActionState = startDmActionState,
eventSink = eventSink,
)
fun aMemberDirectory(
members: List<HealthChatMember> = aMemberList(),
totalCount: Int = members.size,
searchQuery: String = "",
isApproved: Boolean = true,
pendingMessage: String? = null,
) = HealthChatMemberDirectory(
members = members.toImmutableList(),
totalCount = totalCount,
searchQuery = searchQuery,
isApproved = isApproved,
pendingMessage = pendingMessage,
)
fun aMemberList(): List<HealthChatMember> = listOf(
aDirectoryMember(),
aDirectoryMember(
userId = UserId("@luca.bianchi:healthchat.ch"),
displayName = "Dr. med. Luca Bianchi",
specialty = "Kardiologie",
practiceName = "Herzzentrum Bellevue",
businessCity = "Bern",
businessEmail = null,
businessTel = null,
),
aDirectoryMember(
userId = UserId("@nora.keller:healthchat.ch"),
displayName = null,
specialty = null,
practiceName = null,
businessCity = null,
businessEmail = "nora.keller@example.ch",
businessTel = null,
),
)
fun aDirectoryMember(
userId: UserId = UserId("@erika.muster:healthchat.ch"),
displayName: String? = "Dr. med. Erika Muster",
specialty: String? = "Allgemeinmedizin",
practiceName: String? = "Praxis Muster",
businessCity: String? = "Zürich",
businessEmail: String? = "praxis@muster.ch",
businessTel: String? = "+41 44 123 45 67",
isApproved: Boolean = true,
) = HealthChatMember(
userId = userId,
displayName = displayName,
avatarUrl = null,
title = null,
firstName = null,
lastName = null,
specialty = specialty,
practiceName = practiceName,
businessCity = businessCity,
furtherInfo = null,
businessEmail = businessEmail,
businessTel = businessTel,
street = null,
houseNumber = null,
postalCode = null,
website = null,
isApproved = isApproved,
)
@@ -20,4 +20,21 @@
<string name="healthchat_field_further_info">Weitere Infos / Qualifikationen</string>
<string name="healthchat_profile_error_loading">Die HealthChat-Profildaten konnten nicht geladen werden.</string>
<string name="healthchat_profile_not_approved_hint">Profildetails sind sichtbar, sobald Ihr Konto freigegeben ist.</string>
<string name="healthchat_screen_home_tab_members">Mitglieder</string>
<plurals name="healthchat_screen_member_directory_member_count">
<item quantity="one">%1$d Mitglied</item>
<item quantity="other">%1$d Mitglieder</item>
</plurals>
<string name="healthchat_screen_member_directory_my_profile">Mein Profil</string>
<string name="healthchat_screen_member_directory_search_placeholder">Suche nach Name, Praxis, Ort oder Fachgebiet</string>
<string name="healthchat_screen_member_directory_start_chat">Chat starten</string>
<string name="healthchat_screen_member_directory_error_starting_chat">Fehler beim Erstellen des Chats</string>
<string name="healthchat_screen_member_directory_empty">Es sind noch keine Mitglieder vorhanden.</string>
<string name="healthchat_screen_member_directory_empty_search">Keine Mitglieder entsprechen Ihrer Suche.</string>
<string name="healthchat_screen_member_directory_unknown_name">Unbekannt</string>
<string name="healthchat_screen_member_directory_pending_title">Konto wird geprüft</string>
<string name="healthchat_screen_member_directory_pending_message">Ihr Konto wird derzeit geprüft. Nach der Freigabe können Sie alle Mitglieder sehen und durchsuchen.</string>
<string name="healthchat_a11y_refresh">Aktualisieren</string>
<string name="healthchat_a11y_edit_profile">Profil bearbeiten</string>
<string name="healthchat_a11y_invite_member">Neues Mitglied einladen</string>
</resources>
@@ -20,4 +20,22 @@
<string name="healthchat_field_further_info">Informations complémentaires / qualifications</string>
<string name="healthchat_profile_error_loading">Les détails du profil HealthChat n’ont pas pu être chargés.</string>
<string name="healthchat_profile_not_approved_hint">Les détails du profil seront visibles dès que votre compte aura été approuvé.</string>
<string name="healthchat_screen_home_tab_members">Membres</string>
<plurals name="healthchat_screen_member_directory_member_count">
<item quantity="one">%1$d membre</item>
<item quantity="many">%1$d membres</item>
<item quantity="other">%1$d membres</item>
</plurals>
<string name="healthchat_screen_member_directory_my_profile">Mon profil</string>
<string name="healthchat_screen_member_directory_search_placeholder">Rechercher par nom, cabinet, ville ou spécialité</string>
<string name="healthchat_screen_member_directory_start_chat">Démarrer une discussion</string>
<string name="healthchat_screen_member_directory_error_starting_chat">Impossible de démarrer la discussion</string>
<string name="healthchat_screen_member_directory_empty">Il n’y a pas encore de membres.</string>
<string name="healthchat_screen_member_directory_empty_search">Aucun membre ne correspond à votre recherche.</string>
<string name="healthchat_screen_member_directory_unknown_name">Inconnu</string>
<string name="healthchat_screen_member_directory_pending_title">Compte en cours de vérification</string>
<string name="healthchat_screen_member_directory_pending_message">Votre compte est en cours de vérification. Une fois approuvé, vous pourrez voir et rechercher tous les membres.</string>
<string name="healthchat_a11y_refresh">Actualiser</string>
<string name="healthchat_a11y_edit_profile">Modifier le profil</string>
<string name="healthchat_a11y_invite_member">Inviter un nouveau membre</string>
</resources>
@@ -20,4 +20,22 @@
<string name="healthchat_field_further_info">Ulteriori informazioni / qualifiche</string>
<string name="healthchat_profile_error_loading">Impossibile caricare i dettagli del profilo HealthChat.</string>
<string name="healthchat_profile_not_approved_hint">I dettagli del profilo saranno visibili non appena il tuo account sarà stato approvato.</string>
<string name="healthchat_screen_home_tab_members">Membri</string>
<plurals name="healthchat_screen_member_directory_member_count">
<item quantity="one">%1$d membro</item>
<item quantity="many">%1$d membri</item>
<item quantity="other">%1$d membri</item>
</plurals>
<string name="healthchat_screen_member_directory_my_profile">Il mio profilo</string>
<string name="healthchat_screen_member_directory_search_placeholder">Cerca per nome, studio, città o specializzazione</string>
<string name="healthchat_screen_member_directory_start_chat">Avvia chat</string>
<string name="healthchat_screen_member_directory_error_starting_chat">Impossibile avviare la chat</string>
<string name="healthchat_screen_member_directory_empty">Non ci sono ancora membri.</string>
<string name="healthchat_screen_member_directory_empty_search">Nessun membro corrisponde alla tua ricerca.</string>
<string name="healthchat_screen_member_directory_unknown_name">Sconosciuto</string>
<string name="healthchat_screen_member_directory_pending_title">Account in fase di verifica</string>
<string name="healthchat_screen_member_directory_pending_message">Il tuo account è attualmente in fase di verifica. Una volta approvato potrai vedere e cercare tutti i membri.</string>
<string name="healthchat_a11y_refresh">Aggiorna</string>
<string name="healthchat_a11y_edit_profile">Modifica profilo</string>
<string name="healthchat_a11y_invite_member">Invita un nuovo membro</string>
</resources>
@@ -21,4 +21,21 @@
<string name="healthchat_field_further_info">Further info / qualifications</string>
<string name="healthchat_profile_error_loading">The HealthChat profile details couldn’t be loaded.</string>
<string name="healthchat_profile_not_approved_hint">Profile details become visible once your account has been approved.</string>
<string name="healthchat_screen_home_tab_members">Members</string>
<plurals name="healthchat_screen_member_directory_member_count">
<item quantity="one">%1$d member</item>
<item quantity="other">%1$d members</item>
</plurals>
<string name="healthchat_screen_member_directory_my_profile">My profile</string>
<string name="healthchat_screen_member_directory_search_placeholder">Search by name, practice, city or specialty</string>
<string name="healthchat_screen_member_directory_start_chat">Start chat</string>
<string name="healthchat_screen_member_directory_error_starting_chat">Failed to start the chat</string>
<string name="healthchat_screen_member_directory_empty">There are no members yet.</string>
<string name="healthchat_screen_member_directory_empty_search">No members match your search.</string>
<string name="healthchat_screen_member_directory_unknown_name">Unknown</string>
<string name="healthchat_screen_member_directory_pending_title">Account under review</string>
<string name="healthchat_screen_member_directory_pending_message">Your account is currently under review. Once it has been approved you can see and search all members.</string>
<string name="healthchat_a11y_refresh">Refresh</string>
<string name="healthchat_a11y_edit_profile">Edit profile</string>
<string name="healthchat_a11y_invite_member">Invite a new member</string>
</resources>
@@ -44,6 +44,7 @@ setupDependencyInjection()
dependencies {
api(projects.features.healthchat.api)
implementation(libs.coroutines.core)
implementation(projects.features.startchat.api)
implementation(platform(libs.network.okhttp.bom))
implementation(libs.network.okhttp.okhttp)
implementation(libs.network.okhttp.logging)
@@ -56,6 +57,7 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.network)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.uiStrings)
@@ -63,6 +65,7 @@ dependencies {
testCommonDependencies(libs, true)
testImplementation(libs.network.mockwebserver)
testImplementation(projects.features.healthchat.test)
testImplementation(projects.features.startchat.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.api)
testImplementation(projects.libraries.preferences.test)
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.HealthChatStrings
/**
* User-facing message for a HealthChat failure: the verbatim German server message where one
* exists, localized fallbacks for local/unknown failures.
*/
@Composable
fun Throwable.healthChatUserMessage(): String {
return when (this) {
is HealthChatApiException.Network -> stringResource(HealthChatStrings.healthchat_error_network)
is HealthChatApiException.Unauthorized -> serverMessage
is HealthChatApiException.NotApproved -> serverMessage
is HealthChatApiException.NotFound -> serverMessage
is HealthChatApiException.Http -> serverMessage
else -> null
} ?: stringResource(HealthChatStrings.healthchat_error_unknown)
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.directory
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.healthchat.api.directory.MemberDirectoryRenderer
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@ContributesBinding(SessionScope::class)
class DefaultMemberDirectoryRenderer : MemberDirectoryRenderer {
@Composable
override fun Render(
state: MemberDirectoryState,
contentPadding: PaddingValues,
lazyListState: LazyListState,
onOpenDm: (RoomId) -> Unit,
onMemberClick: ((UserId) -> Unit)?,
onMyProfileClick: (() -> Unit)?,
onEditProfileClick: (() -> Unit)?,
onInviteClick: (() -> Unit)?,
modifier: Modifier,
) {
MemberDirectoryView(
state = state,
contentPadding = contentPadding,
lazyListState = lazyListState,
onOpenDm = onOpenDm,
onMemberClick = onMemberClick,
onMyProfileClick = onMyProfileClick,
onEditProfileClick = onEditProfileClick,
onInviteClick = onInviteClick,
modifier = modifier,
)
}
}
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.directory
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.healthchat.api.HealthChatDirectoryService
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
import io.element.android.features.healthchat.api.directory.MemberDirectoryEvents
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.features.startchat.api.StartDMAction
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
@Inject
class MemberDirectoryPresenter(
private val directoryService: HealthChatDirectoryService,
private val startDMAction: StartDMAction,
) : Presenter<MemberDirectoryState> {
@Composable
override fun present(): MemberDirectoryState {
val coroutineScope = rememberCoroutineScope()
val searchQuery = rememberTextFieldState()
// This presenter is composed from app start (by the Home presenter), so the first
// fetch is deferred until the tab is actually shown (LoadIfNeeded from the view).
var hasBeenRequested by rememberSaveable { mutableStateOf(false) }
var loadTrigger by rememberSaveable { mutableIntStateOf(0) }
var directory by remember { mutableStateOf<AsyncData<HealthChatMemberDirectory>>(AsyncData.Uninitialized) }
val startDmActionState = remember { mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized) }
LaunchedEffect(hasBeenRequested, searchQuery.text, loadTrigger) {
if (!hasBeenRequested) return@LaunchedEffect
if (searchQuery.text.isNotEmpty()) {
// Effect restart on typing = debounce.
delay(300.milliseconds)
}
directory = AsyncData.Loading(directory.dataOrNull())
directoryService.searchMembers(searchQuery.text.toString()).fold(
onSuccess = { directory = AsyncData.Success(it) },
// No stale fallback: a failed (re)load always shows the error state.
onFailure = { directory = AsyncData.Failure(it) },
)
}
fun handleEvents(event: MemberDirectoryEvents) {
when (event) {
MemberDirectoryEvents.LoadIfNeeded -> {
hasBeenRequested = true
}
MemberDirectoryEvents.Refresh -> {
loadTrigger++
}
is MemberDirectoryEvents.StartDm -> coroutineScope.launch {
startDMAction.execute(
matrixUser = event.matrixUser,
createIfDmDoesNotExist = startDmActionState.value is AsyncAction.Confirming,
actionState = startDmActionState,
)
}
MemberDirectoryEvents.ClearStartDmState -> {
startDmActionState.value = AsyncAction.Uninitialized
}
}
}
return MemberDirectoryState(
searchQuery = searchQuery,
directory = directory,
startDmActionState = startDmActionState.value,
eventSink = ::handleEvents,
)
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.directory
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.features.healthchat.api.directory.aMemberDirectory
import io.element.android.features.healthchat.api.directory.aMemberDirectoryState
import io.element.android.libraries.architecture.AsyncData
open class MemberDirectoryStateProvider : PreviewParameterProvider<MemberDirectoryState> {
override val values: Sequence<MemberDirectoryState>
get() = sequenceOf(
aMemberDirectoryState(),
aMemberDirectoryState(directory = AsyncData.Uninitialized),
aMemberDirectoryState(directory = AsyncData.Loading(aMemberDirectory())),
aMemberDirectoryState(
directory = AsyncData.Failure(
HealthChatApiException.Http(500, "Fehler beim Laden der Benutzer")
),
),
aMemberDirectoryState(
directory = AsyncData.Success(
aMemberDirectory(
members = emptyList(),
isApproved = false,
pendingMessage = "Ihr Konto wird derzeit geprüft. Nach der Freigabe können Sie alle Mitglieder sehen und durchsuchen.",
)
),
),
aMemberDirectoryState(
directory = AsyncData.Success(aMemberDirectory(members = emptyList())),
),
)
}
@@ -0,0 +1,234 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.directory
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.healthchat.api.HealthChatStrings
import io.element.android.features.healthchat.api.directory.MemberDirectoryEvents
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.features.healthchat.api.toMatrixUser
import io.element.android.features.healthchat.impl.directory.components.MemberCard
import io.element.android.features.healthchat.impl.directory.components.MemberDirectoryErrorContent
import io.element.android.features.healthchat.impl.directory.components.MemberDirectoryHeader
import io.element.android.features.healthchat.impl.directory.components.MemberDirectoryPendingBanner
import io.element.android.features.healthchat.impl.healthChatUserMessage
import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
import io.element.android.libraries.designsystem.theme.components.SearchField
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun MemberDirectoryView(
state: MemberDirectoryState,
contentPadding: PaddingValues,
lazyListState: LazyListState,
onOpenDm: (RoomId) -> Unit,
onMemberClick: ((UserId) -> Unit)?,
onMyProfileClick: (() -> Unit)?,
onEditProfileClick: (() -> Unit)?,
onInviteClick: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
LaunchedEffect(Unit) {
state.eventSink(MemberDirectoryEvents.LoadIfNeeded)
}
Box(modifier = modifier.fillMaxSize()) {
when (val directory = state.directory) {
AsyncData.Uninitialized -> FullScreenLoading()
is AsyncData.Loading -> if (directory.prevData == null) {
FullScreenLoading()
} else {
MemberDirectoryContent(
state = state,
contentPadding = contentPadding,
lazyListState = lazyListState,
isRefreshing = true,
onMemberClick = onMemberClick,
onMyProfileClick = onMyProfileClick,
onEditProfileClick = onEditProfileClick,
onInviteClick = onInviteClick,
)
}
is AsyncData.Failure -> MemberDirectoryErrorContent(
message = directory.error.healthChatUserMessage(),
onRetry = { state.eventSink(MemberDirectoryEvents.Refresh) },
)
is AsyncData.Success -> MemberDirectoryContent(
state = state,
contentPadding = contentPadding,
lazyListState = lazyListState,
isRefreshing = false,
onMemberClick = onMemberClick,
onMyProfileClick = onMyProfileClick,
onEditProfileClick = onEditProfileClick,
onInviteClick = onInviteClick,
)
}
AsyncActionView(
async = state.startDmActionState,
progressDialog = {
AsyncActionViewDefaults.ProgressDialog(
progressText = stringResource(CommonStrings.common_starting_chat),
)
},
onSuccess = onOpenDm,
errorMessage = { stringResource(HealthChatStrings.healthchat_screen_member_directory_error_starting_chat) },
onRetry = null,
onErrorDismiss = { state.eventSink(MemberDirectoryEvents.ClearStartDmState) },
confirmationDialog = { data ->
if (data is ConfirmingStartDmWithMatrixUser) {
CreateDmConfirmationBottomSheet(
matrixUser = data.matrixUser,
isUserIdentityUnknown = data.isUserIdentityUnknown,
onSendInvite = {
state.eventSink(MemberDirectoryEvents.StartDm(data.matrixUser))
},
onDismiss = {
state.eventSink(MemberDirectoryEvents.ClearStartDmState)
},
)
}
},
)
}
}
@Composable
private fun MemberDirectoryContent(
state: MemberDirectoryState,
contentPadding: PaddingValues,
lazyListState: LazyListState,
isRefreshing: Boolean,
onMemberClick: ((UserId) -> Unit)?,
onMyProfileClick: (() -> Unit)?,
onEditProfileClick: (() -> Unit)?,
onInviteClick: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val directory = state.directory.dataOrNull() ?: return
Column(modifier = modifier.fillMaxSize()) {
if (isRefreshing) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
LazyColumn(
state = lazyListState,
contentPadding = contentPadding,
modifier = Modifier.fillMaxSize(),
) {
item(key = "header") {
MemberDirectoryHeader(
memberCount = directory.totalCount,
onMyProfileClick = onMyProfileClick,
onEditProfileClick = onEditProfileClick,
onInviteClick = if (state.isApproved) onInviteClick else null,
onRefreshClick = { state.eventSink(MemberDirectoryEvents.Refresh) },
)
}
if (!state.isApproved) {
item(key = "pending") {
MemberDirectoryPendingBanner(
message = state.pendingMessage
?: stringResource(HealthChatStrings.healthchat_screen_member_directory_pending_message),
)
}
} else {
item(key = "search") {
SearchField(
state = state.searchQuery,
placeholder = stringResource(HealthChatStrings.healthchat_screen_member_directory_search_placeholder),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
if (directory.members.isEmpty()) {
item(key = "empty") {
val message = if (state.searchQuery.text.isNotEmpty()) {
stringResource(HealthChatStrings.healthchat_screen_member_directory_empty_search)
} else {
stringResource(HealthChatStrings.healthchat_screen_member_directory_empty)
}
Text(
text = message,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
)
}
}
items(directory.members, key = { it.userId.value }) { member ->
MemberCard(
member = member,
onClick = onMemberClick?.let { callback -> { callback(member.userId) } },
onStartChatClick = { state.eventSink(MemberDirectoryEvents.StartDm(member.toMatrixUser())) },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
}
}
}
@Composable
private fun FullScreenLoading(
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
@PreviewsDayNight
@Composable
internal fun MemberDirectoryViewPreview(
@PreviewParameter(MemberDirectoryStateProvider::class) state: MemberDirectoryState
) = ElementPreview {
MemberDirectoryView(
state = state,
contentPadding = PaddingValues(),
lazyListState = rememberLazyListState(),
onOpenDm = {},
onMemberClick = {},
onMyProfileClick = {},
onEditProfileClick = null,
onInviteClick = null,
)
}
@@ -0,0 +1,146 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.directory.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.features.healthchat.api.HealthChatStrings
import io.element.android.features.healthchat.api.bestDisplayName
import io.element.android.features.healthchat.api.directory.aDirectoryMember
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun MemberCard(
member: HealthChatMember,
onClick: (() -> Unit)?,
onStartChatClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.let { base -> onClick?.let { base.clickable(onClick = it) } ?: base },
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(12.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
val displayName = member.bestDisplayName(
fallback = stringResource(HealthChatStrings.healthchat_screen_member_directory_unknown_name),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Avatar(
avatarData = AvatarData(
id = member.userId.value,
name = displayName,
url = member.avatarUrl,
size = AvatarSize.UserPreference,
),
avatarType = AvatarType.User,
)
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(
text = displayName,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
member.specialty?.let {
Text(
text = it,
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textActionAccent,
)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
member.practiceName?.let { MemberCardDetailRow(icon = CompoundIcons.Company(), text = it) }
member.businessCity?.let { MemberCardDetailRow(icon = CompoundIcons.LocationPin(), text = it) }
member.businessEmail?.let { MemberCardDetailRow(icon = CompoundIcons.Email(), text = it) }
member.businessTel?.let { MemberCardDetailRow(icon = CompoundIcons.VoiceCall(), text = it) }
Text(
text = member.userId.value,
style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textSecondary,
)
Spacer(modifier = Modifier.height(4.dp))
OutlinedButton(
text = stringResource(HealthChatStrings.healthchat_screen_member_directory_start_chat),
onClick = onStartChatClick,
size = ButtonSize.Small,
)
}
}
}
@Composable
private fun MemberCardDetailRow(
icon: ImageVector,
text: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
modifier = Modifier.size(16.dp),
)
Text(
text = text,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
modifier = Modifier.padding(start = 8.dp),
)
}
}
@PreviewsDayNight
@Composable
internal fun MemberCardPreview() = ElementPreview {
MemberCard(
member = aDirectoryMember(),
onClick = {},
onStartChatClick = {},
)
}
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.directory.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun MemberDirectoryErrorContent(
message: String,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
) {
BigIcon(style = BigIcon.Style.AlertSolid)
Text(
text = message,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Button(
text = stringResource(CommonStrings.action_retry),
onClick = onRetry,
)
}
}
@PreviewsDayNight
@Composable
internal fun MemberDirectoryErrorContentPreview() = ElementPreview {
MemberDirectoryErrorContent(
message = "Fehler beim Laden der Benutzer",
onRetry = {},
)
}
@@ -0,0 +1,97 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.directory.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.healthchat.api.HealthChatStrings
import io.element.android.features.healthchat.api.R as HealthChatR
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
@Composable
fun MemberDirectoryHeader(
memberCount: Int,
onMyProfileClick: (() -> Unit)?,
onEditProfileClick: (() -> Unit)?,
onInviteClick: (() -> Unit)?,
onRefreshClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = pluralStringResource(
HealthChatR.plurals.healthchat_screen_member_directory_member_count,
memberCount,
memberCount,
),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textSecondary,
modifier = Modifier.weight(1f),
)
onMyProfileClick?.let {
TextButton(
text = stringResource(HealthChatStrings.healthchat_screen_member_directory_my_profile),
onClick = it,
)
}
onEditProfileClick?.let {
IconButton(onClick = it) {
Icon(
imageVector = CompoundIcons.Edit(),
contentDescription = stringResource(HealthChatStrings.healthchat_a11y_edit_profile),
)
}
}
onInviteClick?.let {
IconButton(onClick = it) {
Icon(
imageVector = CompoundIcons.UserAdd(),
contentDescription = stringResource(HealthChatStrings.healthchat_a11y_invite_member),
)
}
}
IconButton(onClick = onRefreshClick) {
Icon(
imageVector = CompoundIcons.Restart(),
contentDescription = stringResource(HealthChatStrings.healthchat_a11y_refresh),
)
}
}
}
@PreviewsDayNight
@Composable
internal fun MemberDirectoryHeaderPreview() = ElementPreview {
MemberDirectoryHeader(
memberCount = 42,
onMyProfileClick = {},
onEditProfileClick = null,
onInviteClick = null,
onRefreshClick = {},
)
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.directory.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.features.healthchat.api.HealthChatStrings
import io.element.android.libraries.designsystem.components.Announcement
import io.element.android.libraries.designsystem.components.AnnouncementType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@Composable
fun MemberDirectoryPendingBanner(
message: String,
modifier: Modifier = Modifier,
) {
Announcement(
title = stringResource(HealthChatStrings.healthchat_screen_member_directory_pending_title),
description = message,
type = AnnouncementType.Informative(),
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
@PreviewsDayNight
@Composable
internal fun MemberDirectoryPendingBannerPreview() = ElementPreview {
MemberDirectoryPendingBanner(
message = "Ihr Konto wird derzeit geprüft. Nach der Freigabe können Sie alle Mitglieder sehen und durchsuchen.",
)
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.directory.di
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.features.healthchat.impl.directory.MemberDirectoryPresenter
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.SessionScope
@BindingContainer
@ContributesTo(SessionScope::class)
interface MemberDirectoryModule {
@Binds
fun bindMemberDirectoryPresenter(presenter: MemberDirectoryPresenter): Presenter<MemberDirectoryState>
}
@@ -0,0 +1,184 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.directory
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.MutableState
import com.google.common.truth.Truth.assertThat
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.HealthChatDirectoryService
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
import io.element.android.features.healthchat.api.directory.MemberDirectoryEvents
import io.element.android.features.healthchat.test.FakeHealthChatDirectoryService
import io.element.android.features.healthchat.test.aHealthChatMember
import io.element.android.features.invitepeople.test.FakeStartDMAction
import io.element.android.features.startchat.api.StartDMAction
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import java.io.IOException
class MemberDirectoryPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - nothing is fetched before LoadIfNeeded`() = runTest {
val searchLambda = lambdaRecorder<String, Result<HealthChatMemberDirectory>> {
Result.success(aDirectory())
}
val presenter = createPresenter(
directoryService = FakeHealthChatDirectoryService(searchMembersLambda = { searchLambda(it) }),
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.directory).isEqualTo(AsyncData.Uninitialized)
searchLambda.assertions().isNeverCalled()
initialState.eventSink(MemberDirectoryEvents.LoadIfNeeded)
val loadedState = consumeItemsUntilPredicate { it.directory is AsyncData.Success }.last()
assertThat(loadedState.directory.dataOrNull()).isEqualTo(aDirectory())
searchLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - typing a search query triggers a debounced server side search`() = runTest {
val queries = mutableListOf<String>()
val presenter = createPresenter(
directoryService = FakeHealthChatDirectoryService(
searchMembersLambda = { query ->
queries.add(query)
Result.success(aDirectory(searchQuery = query))
},
),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(MemberDirectoryEvents.LoadIfNeeded)
val loadedState = consumeItemsUntilPredicate { it.directory is AsyncData.Success }.last()
// Typing restarts the effect: intermediate values must be debounced away.
loadedState.searchQuery.setTextAndPlaceCursorAtEnd("M")
loadedState.searchQuery.setTextAndPlaceCursorAtEnd("Mü")
loadedState.searchQuery.setTextAndPlaceCursorAtEnd("Müller")
consumeItemsUntilPredicate { it.directory.dataOrNull()?.searchQuery == "Müller" }
assertThat(queries).containsExactly("", "Müller").inOrder()
}
}
@Test
fun `present - a failure is exposed and Refresh recovers`() = runTest {
var callCount = 0
val presenter = createPresenter(
directoryService = FakeHealthChatDirectoryService(
searchMembersLambda = {
callCount++
if (callCount == 1) {
Result.failure(HealthChatApiException.Network(IOException("no network")))
} else {
Result.success(aDirectory())
}
},
),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(MemberDirectoryEvents.LoadIfNeeded)
val failedState = consumeItemsUntilPredicate { it.directory is AsyncData.Failure }.last()
// No stale fallback.
assertThat((failedState.directory as AsyncData.Failure).prevData).isNull()
failedState.eventSink(MemberDirectoryEvents.Refresh)
val loadedState = consumeItemsUntilPredicate { it.directory is AsyncData.Success }.last()
assertThat(loadedState.directory.dataOrNull()).isEqualTo(aDirectory())
}
}
@Test
fun `present - an unapproved account exposes the pending message and disables search`() = runTest {
val presenter = createPresenter(
directoryService = FakeHealthChatDirectoryService(
searchMembersLambda = {
Result.success(
aDirectory(
members = persistentListOf(),
isApproved = false,
pendingMessage = "Ihr Konto wird derzeit geprüft.",
)
)
},
),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(MemberDirectoryEvents.LoadIfNeeded)
val state = consumeItemsUntilPredicate { it.directory is AsyncData.Success }.last()
assertThat(state.isApproved).isFalse()
assertThat(state.pendingMessage).isEqualTo("Ihr Konto wird derzeit geprüft.")
}
}
@Test
fun `present - StartDm delegates to the StartDMAction with the given user`() = runTest {
val executeLambda = lambdaRecorder { matrixUser: MatrixUser, createIfDmDoesNotExist: Boolean, actionState: MutableState<AsyncAction<RoomId>> ->
assertThat(matrixUser.userId).isEqualTo(A_USER_ID)
assertThat(createIfDmDoesNotExist).isFalse()
actionState.value = AsyncAction.Success(A_ROOM_ID)
}
val presenter = createPresenter(
directoryService = FakeHealthChatDirectoryService(searchMembersLambda = { Result.success(aDirectory()) }),
startDMAction = FakeStartDMAction(executeResult = { user, create, actionState ->
executeLambda(user, create, actionState)
}),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(MemberDirectoryEvents.StartDm(MatrixUser(A_USER_ID)))
val state = consumeItemsUntilPredicate { it.startDmActionState is AsyncAction.Success }.last()
assertThat((state.startDmActionState as AsyncAction.Success).data).isEqualTo(A_ROOM_ID)
executeLambda.assertions().isCalledOnce()
state.eventSink(MemberDirectoryEvents.ClearStartDmState)
val clearedState = consumeItemsUntilPredicate { it.startDmActionState is AsyncAction.Uninitialized }.last()
assertThat(clearedState.startDmActionState).isEqualTo(AsyncAction.Uninitialized)
}
}
private fun aDirectory(
members: List<HealthChatMember> = listOf(aHealthChatMember()),
searchQuery: String = "",
isApproved: Boolean = true,
pendingMessage: String? = null,
) = HealthChatMemberDirectory(
members = persistentListOf(*members.toTypedArray()),
totalCount = members.size,
searchQuery = searchQuery,
isApproved = isApproved,
pendingMessage = pendingMessage,
)
private fun createPresenter(
directoryService: HealthChatDirectoryService = FakeHealthChatDirectoryService(),
startDMAction: StartDMAction = FakeStartDMAction(),
): MemberDirectoryPresenter {
return MemberDirectoryPresenter(
directoryService = directoryService,
startDMAction = startDMAction,
)
}
}
@@ -0,0 +1,159 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.healthchat.impl.directory
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.directory.MemberDirectoryEvents
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.features.healthchat.api.directory.aDirectoryMember
import io.element.android.features.healthchat.api.directory.aMemberDirectory
import io.element.android.features.healthchat.api.directory.aMemberDirectoryState
import io.element.android.features.healthchat.api.toMatrixUser
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
class MemberDirectoryViewTest : RobolectricTest() {
@Test
fun `on composition LoadIfNeeded is emitted`() = runAndroidComposeUiTest<ComponentActivity> {
val eventsRecorder = EventsRecorder<MemberDirectoryEvents>()
setMemberDirectoryView(
state = aMemberDirectoryState(
directory = AsyncData.Uninitialized,
eventSink = eventsRecorder,
),
)
eventsRecorder.assertSingle(MemberDirectoryEvents.LoadIfNeeded)
}
@Test
fun `clicking retry on the error state emits Refresh`() = runAndroidComposeUiTest<ComponentActivity> {
val eventsRecorder = EventsRecorder<MemberDirectoryEvents>()
setMemberDirectoryView(
state = aMemberDirectoryState(
directory = AsyncData.Failure(HealthChatApiException.Http(500, "Fehler beim Laden der Benutzer")),
eventSink = eventsRecorder,
),
)
// The verbatim German server message is shown.
onNodeWithText("Fehler beim Laden der Benutzer").assertExists()
clickOn(CommonStrings.action_retry)
eventsRecorder.assertList(
listOf(
MemberDirectoryEvents.LoadIfNeeded,
MemberDirectoryEvents.Refresh,
)
)
}
@Test
fun `clicking start chat emits StartDm with the member as MatrixUser`() = runAndroidComposeUiTest<ComponentActivity> {
val member = aDirectoryMember()
val eventsRecorder = EventsRecorder<MemberDirectoryEvents>()
setMemberDirectoryView(
state = aMemberDirectoryState(
directory = AsyncData.Success(aMemberDirectory(members = listOf(member))),
eventSink = eventsRecorder,
),
)
val activity = requireNotNull(activity)
onNodeWithText(activity.getString(io.element.android.features.healthchat.api.R.string.healthchat_screen_member_directory_start_chat))
.performClick()
eventsRecorder.assertList(
listOf(
MemberDirectoryEvents.LoadIfNeeded,
MemberDirectoryEvents.StartDm(member.toMatrixUser()),
)
)
}
@Test
fun `clicking a member card opens the profile`() = runAndroidComposeUiTest<ComponentActivity> {
val member = aDirectoryMember()
val eventsRecorder = EventsRecorder<MemberDirectoryEvents>(expectEvents = true)
ensureCalledOnceWithParam(member.userId) { callback ->
setMemberDirectoryView(
state = aMemberDirectoryState(
directory = AsyncData.Success(aMemberDirectory(members = listOf(member))),
eventSink = eventsRecorder,
),
onMemberClick = callback,
)
onNodeWithText("Dr. med. Erika Muster").performClick()
}
}
@Test
fun `an unapproved account shows the pending banner and no search field`() = runAndroidComposeUiTest<ComponentActivity> {
val eventsRecorder = EventsRecorder<MemberDirectoryEvents>(expectEvents = true)
setMemberDirectoryView(
state = aMemberDirectoryState(
directory = AsyncData.Success(
aMemberDirectory(
members = emptyList(),
isApproved = false,
pendingMessage = "Ihr Konto wird derzeit geprüft.",
)
),
eventSink = eventsRecorder,
),
)
onNodeWithText("Ihr Konto wird derzeit geprüft.").assertExists()
val activity = requireNotNull(activity)
onNodeWithText(activity.getString(io.element.android.features.healthchat.api.R.string.healthchat_screen_member_directory_search_placeholder))
.assertDoesNotExist()
}
@Test
fun `hidden affordances are not rendered when their callbacks are null`() = runAndroidComposeUiTest<ComponentActivity> {
val eventsRecorder = EventsRecorder<MemberDirectoryEvents>(expectEvents = true)
setMemberDirectoryView(
state = aMemberDirectoryState(eventSink = eventsRecorder),
onMyProfileClick = null,
)
val activity = requireNotNull(activity)
onNodeWithText(activity.getString(io.element.android.features.healthchat.api.R.string.healthchat_screen_member_directory_my_profile))
.assertDoesNotExist()
}
}
@OptIn(ExperimentalTestApi::class)
private fun androidx.compose.ui.test.AndroidComposeUiTest<ComponentActivity>.setMemberDirectoryView(
state: MemberDirectoryState,
onMemberClick: ((UserId) -> Unit)? = EnsureNeverCalledWithParam(),
onMyProfileClick: (() -> Unit)? = {},
) {
setContent {
MemberDirectoryView(
state = state,
contentPadding = PaddingValues(),
lazyListState = rememberLazyListState(),
onOpenDm = {},
onMemberClick = onMemberClick,
onMyProfileClick = onMyProfileClick,
onEditProfileClick = null,
onInviteClick = null,
)
}
}
@@ -13,6 +13,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom
interface HomeEntryPoint : FeatureEntryPoint {
@@ -31,5 +32,8 @@ interface HomeEntryPoint : FeatureEntryPoint {
fun navigateToEnterRecoveryKey()
fun navigateToRoomSettings(roomId: RoomId)
fun navigateToBugReport()
// HealthChat fork: open a user profile from the member directory tab.
fun navigateToUserProfile(userId: UserId)
}
}
+1
View File
@@ -48,6 +48,7 @@ dependencies {
implementation(projects.libraries.push.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.features.announcement.api)
implementation(projects.features.healthchat.api)
implementation(projects.features.invite.api)
implementation(projects.features.networkmonitor.api)
implementation(projects.features.logout.api)
@@ -32,6 +32,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
import io.element.android.features.healthchat.api.directory.MemberDirectoryRenderer
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.home.impl.components.RoomListMenuAction
import io.element.android.features.home.impl.model.RoomListRoomSummary
@@ -87,6 +88,8 @@ class HomeFlowNode(
private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
private val leaveRoomRenderer: LeaveRoomRenderer,
// HealthChat fork: renders the member directory tab.
private val memberDirectoryRenderer: MemberDirectoryRenderer,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : BaseFlowNode<HomeFlowNode.NavTarget>(
backstack = BackStack(
@@ -244,7 +247,21 @@ class HomeFlowNode(
onSelectNewOwners = ::navigateToSelectNewOwnersWhenLeavingRoom,
modifier = Modifier
)
}
},
memberDirectoryView = { contentPadding, lazyListState ->
memberDirectoryRenderer.Render(
state = state.memberDirectoryState,
contentPadding = contentPadding,
lazyListState = lazyListState,
onOpenDm = ::navigateToRoom,
onMemberClick = { callback.navigateToUserProfile(it) },
onMyProfileClick = { callback.navigateToUserProfile(matrixClient.sessionId) },
// Hidden until the native editor and invite screens exist.
onEditProfileClick = null,
onInviteClick = null,
modifier = Modifier,
)
},
)
directLogoutView.Render(state.directLogoutState)
}
@@ -11,6 +11,7 @@ package io.element.android.features.home.impl
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.healthchat.api.HealthChatStrings
enum class HomeNavigationBarItem(
@StringRes
@@ -21,6 +22,11 @@ enum class HomeNavigationBarItem(
),
Spaces(
labelRes = R.string.screen_home_tab_spaces
),
// HealthChat fork: member directory tab. Keep as LAST entry so persisted ordinals stay stable.
Members(
labelRes = HealthChatStrings.healthchat_screen_home_tab_members
);
@Composable
@@ -29,6 +35,7 @@ enum class HomeNavigationBarItem(
) = when (this) {
Chats -> if (isSelected) CompoundIcons.ChatSolid() else CompoundIcons.Chat()
Spaces -> if (isSelected) CompoundIcons.SpaceSolid() else CompoundIcons.Space()
Members -> if (isSelected) CompoundIcons.UserProfileSolid() else CompoundIcons.UserProfile()
}
companion object {
@@ -19,6 +19,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.features.logout.api.direct.DirectLogoutState
@@ -42,6 +43,8 @@ class HomePresenter(
private val indicatorService: IndicatorService,
private val roomListPresenter: Presenter<RoomListState>,
private val homeSpacesPresenter: Presenter<HomeSpacesState>,
// HealthChat fork: member directory tab presenter.
private val memberDirectoryPresenter: Presenter<MemberDirectoryState>,
private val logoutPresenter: Presenter<DirectLogoutState>,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val sessionStore: SessionStore,
@@ -63,6 +66,7 @@ class HomePresenter(
val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
val roomListState = roomListPresenter.present()
val homeSpacesState = homeSpacesPresenter.present()
val memberDirectoryState = memberDirectoryPresenter.present()
var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) }
val currentHomeNavigationBarItem by remember {
derivedStateOf {
@@ -96,6 +100,7 @@ class HomePresenter(
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState,
homeSpacesState = homeSpacesState,
memberDirectoryState = memberDirectoryState,
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
directLogoutState = directLogoutState,
@@ -8,6 +8,7 @@
package io.element.android.features.home.impl
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spaces.HomeSpacesState
@@ -27,6 +28,8 @@ data class HomeState(
val currentHomeNavigationBarItem: HomeNavigationBarItem,
val roomListState: RoomListState,
val homeSpacesState: HomeSpacesState,
// HealthChat fork: state of the member directory tab.
val memberDirectoryState: MemberDirectoryState,
val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean,
val directLogoutState: DirectLogoutState,
@@ -9,6 +9,8 @@
package io.element.android.features.home.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.features.healthchat.api.directory.aMemberDirectoryState
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.roomlist.RoomListStateProvider
import io.element.android.features.home.impl.roomlist.aRoomListState
@@ -50,6 +52,9 @@ open class HomeStateProvider : PreviewParameterProvider<HomeState> {
homeSpacesState = aHomeSpacesState(
spaceRooms = emptyList(),
),
// HealthChat fork: appended LAST so upstream snapshot indices stay stable.
) + aHomeState(
currentHomeNavigationBarItem = HomeNavigationBarItem.Members,
)
}
@@ -62,6 +67,7 @@ internal fun aHomeState(
currentHomeNavigationBarItem: HomeNavigationBarItem = HomeNavigationBarItem.Chats,
roomListState: RoomListState = aRoomListState(),
homeSpacesState: HomeSpacesState = aHomeSpacesState(),
memberDirectoryState: MemberDirectoryState = aMemberDirectoryState(),
canReportBug: Boolean = true,
directLogoutState: DirectLogoutState = aDirectLogoutState(),
eventSink: (HomeEvent) -> Unit = {}
@@ -75,5 +81,6 @@ internal fun aHomeState(
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState,
homeSpacesState = homeSpacesState,
memberDirectoryState = memberDirectoryState,
eventSink = eventSink,
)
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -87,6 +88,8 @@ fun HomeView(
acceptDeclineInviteView: @Composable () -> Unit,
modifier: Modifier = Modifier,
leaveRoomView: @Composable () -> Unit,
// HealthChat fork: content of the member directory tab, rendered by features/healthchat.
memberDirectoryView: @Composable (PaddingValues, LazyListState) -> Unit = { _, _ -> },
) {
val state: RoomListState = homeState.roomListState
val coroutineScope = rememberCoroutineScope()
@@ -121,6 +124,7 @@ fun HomeView(
onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() },
onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() },
onMenuActionClick = onMenuActionClick,
memberDirectoryView = memberDirectoryView,
)
// This overlaid view will only be visible when state.displaySearchResults is true
RoomListSearchView(
@@ -147,6 +151,7 @@ private fun HomeScaffold(
onStartChatClick: () -> Unit,
onCreateSpaceClick: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
memberDirectoryView: @Composable (PaddingValues, LazyListState) -> Unit,
modifier: Modifier = Modifier,
) {
fun onRoomClick(room: RoomListRoomSummary) {
@@ -172,6 +177,7 @@ private fun HomeScaffold(
val hazeState = rememberHazeState()
val roomsLazyListState = rememberLazyListState()
val spacesLazyListState = rememberLazyListState()
val membersLazyListState = rememberLazyListState()
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -208,6 +214,7 @@ private fun HomeScaffold(
val lazyListStateTarget = when (item) {
HomeNavigationBarItem.Chats -> roomsLazyListState
HomeNavigationBarItem.Spaces -> spacesLazyListState
HomeNavigationBarItem.Members -> membersLazyListState
}
coroutineScope.launch {
if (lazyListStateTarget.firstVisibleItemIndex > 10) {
@@ -221,15 +228,15 @@ private fun HomeScaffold(
state.eventSink(HomeEvent.SelectHomeNavigationBarItem(item))
}
},
floatingActionButton = {
when (state.currentHomeNavigationBarItem) {
HomeNavigationBarItem.Chats -> {
HomeFloatingActionButton(onStartChatClick, CommonStrings.action_create_room)
}
HomeNavigationBarItem.Spaces -> {
HomeFloatingActionButton(onCreateSpaceClick, CommonStrings.action_create_space)
}
floatingActionButton = when (state.currentHomeNavigationBarItem) {
HomeNavigationBarItem.Chats -> {
{ HomeFloatingActionButton(onStartChatClick, CommonStrings.action_create_room) }
}
HomeNavigationBarItem.Spaces -> {
{ HomeFloatingActionButton(onCreateSpaceClick, CommonStrings.action_create_space) }
}
// HealthChat fork: the member directory has no create action.
HomeNavigationBarItem.Members -> null
},
)
},
@@ -285,6 +292,18 @@ private fun HomeScaffold(
onExploreClick = {},
)
}
// HealthChat fork: member directory tab content.
HomeNavigationBarItem.Members -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
.hazeSource(state = hazeState),
) {
memberDirectoryView(contentPadding, membersLazyListState)
}
}
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -116,6 +116,8 @@ fun HomeTopBar(
}
}
HomeNavigationBarItem.Spaces -> stringResource(selectedNavigationItem.labelRes)
// HealthChat fork: member directory tab title.
HomeNavigationBarItem.Members -> stringResource(selectedNavigationItem.labelRes)
}
Text(
modifier = Modifier.semantics {
@@ -12,6 +12,7 @@ import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.services.analytics.test.FakeAnalyticsService
@@ -39,6 +40,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
declineInviteAndBlockUserEntryPoint = { _, _, _ -> lambdaError() },
changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() },
leaveRoomRenderer = { _, _, _ -> lambdaError() },
memberDirectoryRenderer = { _, _, _, _, _, _, _, _, _ -> lambdaError() },
sessionCoroutineScope = backgroundScope,
)
}
@@ -51,6 +53,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
override fun navigateToEnterRecoveryKey() = lambdaError()
override fun navigateToRoomSettings(roomId: RoomId) = lambdaError()
override fun navigateToBugReport() = lambdaError()
override fun navigateToUserProfile(userId: UserId) = lambdaError()
}
val result = entryPoint.createNode(
parentNode = parentNode,
@@ -9,6 +9,7 @@
package io.element.android.features.home.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.healthchat.api.directory.aMemberDirectoryState
import io.element.android.features.home.impl.roomlist.aRoomListState
import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.features.home.impl.spaces.aHomeSpacesState
@@ -145,6 +146,21 @@ class HomePresenterTest {
assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces)
}
}
@Test
fun `present - NavigationBar change to Members`() = runTest {
val presenter = createHomePresenter(
sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Members))
val finalState = awaitItem()
assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Members)
}
}
}
internal fun createHomePresenter(
@@ -162,6 +178,7 @@ internal fun createHomePresenter(
indicatorService = indicatorService,
roomListPresenter = { aRoomListState() },
homeSpacesPresenter = homeSpacesPresenter,
memberDirectoryPresenter = { aMemberDirectoryState() },
logoutPresenter = { aDirectLogoutState() },
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
sessionStore = sessionStore,