Add member directory tab to home navigation (#3)

* 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.

* fix(healthchat): stop tab back handler swallowing back presses

While a screen is pushed on top of Home, the still-composed tab
BackHandler intercepted back presses during the transition and
invisibly switched to the Chats tab. Gate it on the node's target
visibility in both the parent and Home's own backstack.

* fix(healthchat): satisfy detekt, ktlint and konsist rules

Import alias order, composable parameter order in HomeView, and konsist presenter helper shape.

* ci: pass the real repository name to the screenshot recorder

recordScreenshots.yml set GITHUB_REPOSITORY from `secrets.GITHUB_REPOSITORY`.
No such secret exists on this fork, so the expression expands to an empty
string and overrides the built-in environment variable of the same name.
recordScreenshots.sh checks it up front and exits 1 with "No repo specified",
so the workflow could never record anything here.

Use the `github.repository` context instead, which is always populated.

Claude-Session: https://claude.ai/code/session_01MQbp1EPM4TSXq8P8Pyq44F

* Update screenshots

* Revert "Update screenshots"

This reverts commit b99e0d643f.

* Reapply "Update screenshots"

This reverts commit 85bceb6d67.

* test(screenshots): stop a LayoutLib teardown crash failing the shard tests

Paparazzi 2.0.0-alpha05 / LayoutLib 16.2.1 races on HandlerThread startup:
HandlerThread_Delegate.run calls Process.setThreadPriority, which throws
NoSuchMethodError for Thread.setPosixNicenessInternal and kills the
"android.bg" thread. If the test thread reaches getLooper() after that, it
gets null, BackgroundThread.ensureThreadReady() NPEs, and the static
initialiser of LatencyTracker fails. The JVM then marks that class erroneous
for the lifetime of the fork, so every later preview showing the soft
keyboard dies with NoClassDefFoundError.

LayoutLibErrorFilterStatement already swallowed the bare NoSuchMethodError,
but PaparazziLogger re-throws collected background errors from teardown()
wrapped in a MultipleFailuresException when there is more than one, and that
wrapper slipped through. A recent run reported 17 failures with zero image
comparisons failing and zero delta images written.

Unwrap the wrapper by reflection and swallow it only when every collected
cause matches the known-bug list; anything unrecognised, and anything that
cannot be decomposed, is rethrown.

Also close a hole this filter had from the start. Paparazzi runs the test as
`try { body } finally { teardown() }`, so a teardown error discards whatever
the body threw — including a genuine snapshot mismatch. renderFailureCapture
is registered inside the Paparazzi rule to record the body's exception so it
wins over any teardown error, and the filter refuses to swallow anything if
that rule is not registered. Two HorizontalFloatingToolbar previews have been
mismatching silently behind this, and may now surface as real failures.

Claude-Session: https://claude.ai/code/session_01MQbp1EPM4TSXq8P8Pyq44F

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
2026-08-06 13:10:01 +02:00
committed by Sedat Qaja
parent 21444b45e7
commit 1cf01cdc62
37 changed files with 1651 additions and 14 deletions
+11 -3
View File
@@ -20,8 +20,12 @@ env:
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
# make two builds likelier to coexist; the real fix is sizing the containers to the
# host. See the "Report runner capacity" step.
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g -Dsonar.gradle.skipCompile=true
CI_GRADLE_ARG_PROPERTIES: --no-configuration-cache
# Kept character-identical to tests.yml so the record path and the verify path cannot drift
# apart again. These only affect the Gradle daemon — Paparazzi renders in forked test workers
# configured entirely by build.gradle.kts — but keeping them aligned removes a whole class of
# "did record and verify run the same way?" questions.
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options=-Xmx2g -XX:+UseG1GC
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache --max-workers=4
jobs:
record:
@@ -82,5 +86,9 @@ jobs:
run: ./.github/workflows/scripts/recordScreenshots.sh
env:
GITHUB_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN || secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ secrets.GITHUB_REPOSITORY }}
# Must be the `github.repository` context, not a secret. There is no
# GITHUB_REPOSITORY secret on this fork, so `secrets.GITHUB_REPOSITORY` expands to ""
# and overrides the built-in env var of the same name — recordScreenshots.sh then
# exits 1 with "No repo specified" before Gradle ever runs.
GITHUB_REPOSITORY: ${{ github.repository }}
GRADLE_ARGS: ${{ env.CI_GRADLE_ARG_PROPERTIES }}
@@ -362,6 +362,11 @@ class LoggedInFlowNode(
override fun navigateToSession(sessionId: SessionId) {
callback.navigateToSession(sessionId)
}
// 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.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
import io.element.android.features.healthchat.api.R as HealthChatR
@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,182 @@
/*
* 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(
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,
)
}
}
@@ -15,6 +15,7 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom
interface HomeEntryPoint : FeatureEntryPoint {
@@ -35,5 +36,8 @@ interface HomeEntryPoint : FeatureEntryPoint {
fun navigateToBugReport()
fun navigateToAddAccount()
fun navigateToSession(sessionId: SessionId)
// HealthChat fork: open a user profile from the member directory tab.
fun navigateToUserProfile(userId: UserId)
}
}
+1
View File
@@ -49,6 +49,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.leaveroom.api)
implementation(projects.features.rageshake.api)
@@ -12,6 +12,7 @@ import android.app.Activity
import android.os.Parcelable
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -22,6 +23,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.LocalNodeTargetVisibility
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.node
import com.bumble.appyx.core.plugin.Plugin
@@ -32,6 +34,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
@@ -86,6 +89,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(
@@ -98,6 +103,10 @@ class HomeFlowNode(
private val callback: HomeEntryPoint.Callback = callback()
private val stateFlow = launchMolecule { presenter.present() }
// HealthChat fork: whether this node is the visible target within the parent backstack.
// False as soon as another screen (e.g. a member profile) starts being pushed on top.
private val isVisibleNavTarget = mutableStateOf(true)
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
@@ -246,13 +255,37 @@ class HomeFlowNode(
onSelectNewOwners = ::navigateToSelectNewOwnersWhenLeavingRoom,
modifier = Modifier
)
}
},
memberDirectoryView = { contentPadding, lazyListState ->
memberDirectoryRenderer.Render(
state = state.memberDirectoryState,
contentPadding = contentPadding,
lazyListState = lazyListState,
onOpenDm = { navigateToRoom(roomId = it, eventId = null) },
onMemberClick = { callback.navigateToUserProfile(it) },
onMyProfileClick = { callback.navigateToUserProfile(matrixClient.sessionId) },
// Hidden until the native editor and invite screens exist.
onEditProfileClick = null,
onInviteClick = null,
modifier = Modifier,
)
},
// Both levels: Home within the parent backstack (captured in View) and the
// root element within Home's own backstack.
isBackHandlingEnabled = isVisibleNavTarget.value && LocalNodeTargetVisibility.current,
)
}
}
@Composable
override fun View(modifier: Modifier) {
// HealthChat fork: capture the target visibility at this level, where the
// CompositionLocal still refers to Home within the PARENT backstack (inside
// rootNode it is re-provided for Home's own backstack).
val isVisibleTarget = LocalNodeTargetVisibility.current
SideEffect {
isVisibleNavTarget.value = isVisibleTarget
}
BackstackView()
}
@@ -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 {
@@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
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.rageshake.api.RageshakeFeatureAvailability
@@ -49,6 +50,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 rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val sessionStore: SessionStore,
private val announcementService: AnnouncementService,
@@ -100,6 +103,7 @@ class HomePresenter(
}
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 {
@@ -136,6 +140,7 @@ class HomePresenter(
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState,
homeSpacesState = homeSpacesState,
memberDirectoryState = memberDirectoryState,
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
@@ -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
@@ -26,6 +27,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 showMultiAccountAnnouncement: Boolean,
@@ -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.RoomListStatePreviewParam
import io.element.android.features.home.impl.roomlist.aRoomListState
@@ -50,6 +52,9 @@ open class HomeStatePreviewParam : PreviewParameterProvider<HomeState> {
),
) + aHomeState(
showMultiAccountAnnouncement = true,
) + aHomeState(
// HealthChat fork: appended LAST so upstream snapshot indices stay stable.
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,
showMultiAccountAnnouncement: Boolean = false,
totalUnreadCount: Int = 0,
@@ -77,5 +83,6 @@ internal fun aHomeState(
roomListState = roomListState,
homeSpacesState = homeSpacesState,
totalUnreadCount = totalUnreadCount,
memberDirectoryState = memberDirectoryState,
eventSink = eventSink,
)
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.FabPosition
import androidx.compose.material3.TopAppBarDefaults
@@ -90,6 +91,11 @@ fun HomeView(
onDeclineInviteAndBlockUser: (roomSummary: RoomListRoomSummary) -> Unit,
acceptDeclineInviteView: @Composable () -> Unit,
modifier: Modifier = Modifier,
// HealthChat fork: false while another screen is pushed on top of Home, so the
// tab-switching BackHandler cannot swallow back presses during the transition.
isBackHandlingEnabled: Boolean = true,
// HealthChat fork: content of the member directory tab, rendered by features/healthchat.
memberDirectoryView: @Composable (PaddingValues, LazyListState) -> Unit = { _, _ -> },
leaveRoomView: @Composable () -> Unit,
) {
val state: RoomListState = homeState.roomListState
@@ -133,6 +139,8 @@ fun HomeView(
onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() },
onMenuActionClick = onMenuActionClick,
onSwitchToAccount = onSwitchToAccount,
memberDirectoryView = memberDirectoryView,
isBackHandlingEnabled = isBackHandlingEnabled,
)
if (state.globalSearchState.isEnabled) {
@@ -171,7 +179,9 @@ private fun HomeScaffold(
onCreateSpaceClick: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
onSwitchToAccount: (SessionId) -> Unit,
isBackHandlingEnabled: Boolean,
modifier: Modifier = Modifier,
memberDirectoryView: @Composable (PaddingValues, LazyListState) -> Unit,
) {
fun onRoomClick(room: RoomListRoomSummary) {
onRoomClick(room.roomId)
@@ -182,7 +192,8 @@ private fun HomeScaffold(
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
val roomListState: RoomListState = state.roomListState
BackHandler(enabled = state.isBackHandlerEnabled) {
// HealthChat fork: also gated on isBackHandlingEnabled (see HomeView docs).
BackHandler(enabled = state.isBackHandlerEnabled && isBackHandlingEnabled) {
if (state.currentHomeNavigationBarItem != HomeNavigationBarItem.Chats) {
state.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats))
} else {
@@ -195,6 +206,7 @@ private fun HomeScaffold(
val roomsLazyListState = rememberLazyListState()
val spacesLazyListState = rememberLazyListState()
val membersLazyListState = rememberLazyListState()
Scaffold(
modifier = modifier,
@@ -242,6 +254,7 @@ private fun HomeScaffold(
val lazyListStateTarget = when (item) {
HomeNavigationBarItem.Chats -> roomsLazyListState
HomeNavigationBarItem.Spaces -> spacesLazyListState
HomeNavigationBarItem.Members -> membersLazyListState
}
coroutineScope.launch {
if (lazyListStateTarget.firstVisibleItemIndex > 10) {
@@ -255,15 +268,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
},
totalUnreadCount = state.totalUnreadCount,
)
@@ -325,6 +338,17 @@ private fun HomeScaffold(
onExploreClick = {},
)
}
// HealthChat fork: member directory tab content.
HomeNavigationBarItem.Members -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(outerPadding)
.consumeWindowInsets(outerPadding),
) {
memberDirectoryView(lazyColumnContentPadding + contentPadding, membersLazyListState)
}
}
}
}
},
@@ -133,6 +133,8 @@ fun HomeTopBar(
}
}
HomeNavigationBarItem.Spaces -> null
// HealthChat fork: member directory tab title.
HomeNavigationBarItem.Members -> stringResource(selectedNavigationItem.labelRes)
}
displayTitle?.let {
val style = when (spaceFiltersState) {
@@ -15,6 +15,7 @@ import io.element.android.features.invite.test.declineandblock.FakeDeclineInvite
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
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
@@ -41,6 +42,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
declineInviteAndBlockUserEntryPoint = FakeDeclineInviteAndBlockEntryPoint(),
changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() },
leaveRoomRenderer = { _, _, _ -> lambdaError() },
memberDirectoryRenderer = { _, _, _, _, _, _, _, _, _ -> lambdaError() },
sessionCoroutineScope = backgroundScope,
)
}
@@ -55,6 +57,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
override fun navigateToBugReport() = lambdaError()
override fun navigateToAddAccount() = lambdaError()
override fun navigateToSession(sessionId: SessionId) = lambdaError()
override fun navigateToUserProfile(userId: UserId) = lambdaError()
}
val result = entryPoint.createNode(
parentNode = parentNode,
@@ -14,6 +14,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.announcement.test.FakeAnnouncementService
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
@@ -322,6 +323,21 @@ class HomePresenterTest {
assertThat(awaitItem().totalUnreadCount).isEqualTo(5)
}
}
@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 TestScope.createHomePresenter(
@@ -342,6 +358,7 @@ internal fun TestScope.createHomePresenter(
indicatorService = indicatorService,
roomListPresenter = { aRoomListState() },
homeSpacesPresenter = homeSpacesPresenter,
memberDirectoryPresenter = { aMemberDirectoryState() },
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
sessionStore = sessionStore,
announcementService = announcementService,
@@ -0,0 +1,78 @@
/*
* 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.home.impl
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
class HomeViewBackHandlingTest : RobolectricTest() {
@Test
fun `back press on the members tab switches to the chats tab`() = runAndroidComposeUiTest<ComponentActivity> {
val eventsRecorder = EventsRecorder<HomeEvent>()
setContent {
HomeView(
homeState = aHomeState(
currentHomeNavigationBarItem = HomeNavigationBarItem.Members,
eventSink = eventsRecorder,
),
onRoomClick = {},
onSettingsClick = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
onCreateSpaceClick = {},
onRoomSettingsClick = {},
onMenuActionClick = {},
onReportRoomClick = {},
onDeclineInviteAndBlockUser = {},
acceptDeclineInviteView = {},
leaveRoomView = {},
)
}
pressBackKey()
eventsRecorder.assertSingle(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats))
}
@Test
fun `back press is not intercepted while home is not the visible nav target`() = runAndroidComposeUiTest<ComponentActivity> {
// Regression test: while another screen (e.g. a member profile) is being pushed on
// top of Home, the tab BackHandler must not swallow the back press by invisibly
// switching tabs — the back press has to reach the parent backstack pop instead.
val eventsRecorder = EventsRecorder<HomeEvent>(expectEvents = false)
setContent {
HomeView(
homeState = aHomeState(
currentHomeNavigationBarItem = HomeNavigationBarItem.Members,
eventSink = eventsRecorder,
),
onRoomClick = {},
onSettingsClick = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
onCreateSpaceClick = {},
onRoomSettingsClick = {},
onMenuActionClick = {},
onReportRoomClick = {},
onDeclineInviteAndBlockUser = {},
acceptDeclineInviteView = {},
leaveRoomView = {},
isBackHandlingEnabled = false,
)
}
pressBackKey()
}
}