diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml index f9f1e5137a..48a9f62cec 100644 --- a/.github/workflows/recordScreenshots.yml +++ b/.github/workflows/recordScreenshots.yml @@ -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 }} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 27d727afa9..63ce2be6e8 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -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, diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMemberDisplay.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMemberDisplay.kt index df1c894e13..02814fe4bd 100644 --- a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMemberDisplay.kt +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMemberDisplay.kt @@ -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, +) diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatProfileSectionRenderer.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatProfileSectionRenderer.kt index 971c459af4..ceef8b0c66 100644 --- a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatProfileSectionRenderer.kt +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatProfileSectionRenderer.kt @@ -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, diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryEvents.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryEvents.kt new file mode 100644 index 0000000000..0025442928 --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryEvents.kt @@ -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 +} diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryRenderer.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryRenderer.kt new file mode 100644 index 0000000000..3cbcc3293d --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryRenderer.kt @@ -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, + ) +} diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryState.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryState.kt new file mode 100644 index 0000000000..0cfa553669 --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryState.kt @@ -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, + val startDmActionState: AsyncAction, + 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 +} diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryStateFixtures.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryStateFixtures.kt new file mode 100644 index 0000000000..c826f0a8f5 --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryStateFixtures.kt @@ -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 = AsyncData.Success(aMemberDirectory()), + startDmActionState: AsyncAction = AsyncAction.Uninitialized, + eventSink: (MemberDirectoryEvents) -> Unit = {}, +) = MemberDirectoryState( + searchQuery = searchQuery, + directory = directory, + startDmActionState = startDmActionState, + eventSink = eventSink, +) + +fun aMemberDirectory( + members: List = 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 = 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, +) diff --git a/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml b/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml index 3e6e8159cb..ec6ffe282b 100644 --- a/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml +++ b/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml @@ -20,4 +20,21 @@ Weitere Infos / Qualifikationen Die HealthChat-Profildaten konnten nicht geladen werden. Profildetails sind sichtbar, sobald Ihr Konto freigegeben ist. + Mitglieder + + %1$d Mitglied + %1$d Mitglieder + + Mein Profil + Suche nach Name, Praxis, Ort oder Fachgebiet + Chat starten + Fehler beim Erstellen des Chats + Es sind noch keine Mitglieder vorhanden. + Keine Mitglieder entsprechen Ihrer Suche. + Unbekannt + Konto wird geprüft + Ihr Konto wird derzeit geprüft. Nach der Freigabe können Sie alle Mitglieder sehen und durchsuchen. + Aktualisieren + Profil bearbeiten + Neues Mitglied einladen diff --git a/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml b/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml index 55ea83ac9c..49df77d036 100644 --- a/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml +++ b/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml @@ -20,4 +20,22 @@ Informations complémentaires / qualifications Les détails du profil HealthChat n’ont pas pu être chargés. Les détails du profil seront visibles dès que votre compte aura été approuvé. + Membres + + %1$d membre + %1$d membres + %1$d membres + + Mon profil + Rechercher par nom, cabinet, ville ou spécialité + Démarrer une discussion + Impossible de démarrer la discussion + Il n’y a pas encore de membres. + Aucun membre ne correspond à votre recherche. + Inconnu + Compte en cours de vérification + Votre compte est en cours de vérification. Une fois approuvé, vous pourrez voir et rechercher tous les membres. + Actualiser + Modifier le profil + Inviter un nouveau membre diff --git a/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml b/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml index 31ffbf5810..20af35a02b 100644 --- a/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml +++ b/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml @@ -20,4 +20,22 @@ Ulteriori informazioni / qualifiche Impossibile caricare i dettagli del profilo HealthChat. I dettagli del profilo saranno visibili non appena il tuo account sarà stato approvato. + Membri + + %1$d membro + %1$d membri + %1$d membri + + Il mio profilo + Cerca per nome, studio, città o specializzazione + Avvia chat + Impossibile avviare la chat + Non ci sono ancora membri. + Nessun membro corrisponde alla tua ricerca. + Sconosciuto + Account in fase di verifica + Il tuo account è attualmente in fase di verifica. Una volta approvato potrai vedere e cercare tutti i membri. + Aggiorna + Modifica profilo + Invita un nuovo membro diff --git a/features/healthchat/api/src/main/res/values/strings_healthchat.xml b/features/healthchat/api/src/main/res/values/strings_healthchat.xml index 7de9716380..f17fbea476 100644 --- a/features/healthchat/api/src/main/res/values/strings_healthchat.xml +++ b/features/healthchat/api/src/main/res/values/strings_healthchat.xml @@ -21,4 +21,21 @@ Further info / qualifications The HealthChat profile details couldn’t be loaded. Profile details become visible once your account has been approved. + Members + + %1$d member + %1$d members + + My profile + Search by name, practice, city or specialty + Start chat + Failed to start the chat + There are no members yet. + No members match your search. + Unknown + Account under review + Your account is currently under review. Once it has been approved you can see and search all members. + Refresh + Edit profile + Invite a new member diff --git a/features/healthchat/impl/build.gradle.kts b/features/healthchat/impl/build.gradle.kts index cea0f71299..7042791b93 100644 --- a/features/healthchat/impl/build.gradle.kts +++ b/features/healthchat/impl/build.gradle.kts @@ -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) diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatErrorMessage.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatErrorMessage.kt new file mode 100644 index 0000000000..10a6a6980f --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatErrorMessage.kt @@ -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) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/DefaultMemberDirectoryRenderer.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/DefaultMemberDirectoryRenderer.kt new file mode 100644 index 0000000000..492401dd88 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/DefaultMemberDirectoryRenderer.kt @@ -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, + ) + } +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryPresenter.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryPresenter.kt new file mode 100644 index 0000000000..031aea6c21 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryPresenter.kt @@ -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 { + @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.Uninitialized) } + val startDmActionState = remember { mutableStateOf>(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, + ) + } +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryStateProvider.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryStateProvider.kt new file mode 100644 index 0000000000..f59e175d2d --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryStateProvider.kt @@ -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 { + override val values: Sequence + 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())), + ), + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryView.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryView.kt new file mode 100644 index 0000000000..4af5dc9b99 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryView.kt @@ -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, + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberCard.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberCard.kt new file mode 100644 index 0000000000..14bddb5298 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberCard.kt @@ -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 = {}, + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryErrorContent.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryErrorContent.kt new file mode 100644 index 0000000000..0e6e70392d --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryErrorContent.kt @@ -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 = {}, + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryHeader.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryHeader.kt new file mode 100644 index 0000000000..cf43b4d996 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryHeader.kt @@ -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 = {}, + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryPendingBanner.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryPendingBanner.kt new file mode 100644 index 0000000000..acd462da39 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryPendingBanner.kt @@ -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.", + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/di/MemberDirectoryModule.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/di/MemberDirectoryModule.kt new file mode 100644 index 0000000000..a3d004f052 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/di/MemberDirectoryModule.kt @@ -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 +} diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryPresenterTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryPresenterTest.kt new file mode 100644 index 0000000000..aae6fa932c --- /dev/null +++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryPresenterTest.kt @@ -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> { + 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() + 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> -> + 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 = 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, + ) +} diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryViewTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryViewTest.kt new file mode 100644 index 0000000000..89d88c57d3 --- /dev/null +++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryViewTest.kt @@ -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 { + val eventsRecorder = EventsRecorder() + setMemberDirectoryView( + state = aMemberDirectoryState( + directory = AsyncData.Uninitialized, + eventSink = eventsRecorder, + ), + ) + eventsRecorder.assertSingle(MemberDirectoryEvents.LoadIfNeeded) + } + + @Test + fun `clicking retry on the error state emits Refresh`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + 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 { + val member = aDirectoryMember() + val eventsRecorder = EventsRecorder() + 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 { + val member = aDirectoryMember() + val eventsRecorder = EventsRecorder(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 { + val eventsRecorder = EventsRecorder(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 { + val eventsRecorder = EventsRecorder(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.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, + ) + } +} diff --git a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt index 81c467246f..517882cec6 100644 --- a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt +++ b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt @@ -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) } } diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index 7fcc2dde32..ed3ae4bb3d 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -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) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt index fda28e0fa0..567ce4f212 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt @@ -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( 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() } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeNavigationBarItem.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeNavigationBarItem.kt index 035e953b23..8ca102eec0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeNavigationBarItem.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeNavigationBarItem.kt @@ -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 { diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index e0c7829b0f..69f9016a0b 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -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, private val homeSpacesPresenter: Presenter, + // HealthChat fork: member directory tab presenter. + private val memberDirectoryPresenter: Presenter, 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, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt index eb8e3ff2f1..204ac998cd 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt @@ -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, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt index 4a82166d3e..f073848a3d 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt @@ -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 { ), ) + 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, ) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index 6640120614..34b0b20ce7 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -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) + } + } } } }, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt index 291b9f3c00..36869e0a40 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt @@ -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) { diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt index 6e962f6fe0..70f197650f 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt @@ -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, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index 93ea9576b8..120e5138bb 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -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, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomeViewBackHandlingTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomeViewBackHandlingTest.kt new file mode 100644 index 0000000000..f4f45856f0 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomeViewBackHandlingTest.kt @@ -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 { + val eventsRecorder = EventsRecorder() + 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 { + // 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(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() + } +}