mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 08:15:02 +00:00
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 commitb99e0d643f. * Reapply "Update screenshots" This reverts commit85bceb6d67. * 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:
@@ -20,8 +20,12 @@ env:
|
|||||||
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
|
# 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
|
# make two builds likelier to coexist; the real fix is sizing the containers to the
|
||||||
# host. See the "Report runner capacity" step.
|
# 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
|
# Kept character-identical to tests.yml so the record path and the verify path cannot drift
|
||||||
CI_GRADLE_ARG_PROPERTIES: --no-configuration-cache
|
# 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:
|
jobs:
|
||||||
record:
|
record:
|
||||||
@@ -82,5 +86,9 @@ jobs:
|
|||||||
run: ./.github/workflows/scripts/recordScreenshots.sh
|
run: ./.github/workflows/scripts/recordScreenshots.sh
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN || secrets.GITHUB_TOKEN }}
|
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 }}
|
GRADLE_ARGS: ${{ env.CI_GRADLE_ARG_PROPERTIES }}
|
||||||
|
|||||||
@@ -362,6 +362,11 @@ class LoggedInFlowNode(
|
|||||||
override fun navigateToSession(sessionId: SessionId) {
|
override fun navigateToSession(sessionId: SessionId) {
|
||||||
callback.navigateToSession(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(
|
homeEntryPoint.createNode(
|
||||||
parentNode = this,
|
parentNode = this,
|
||||||
|
|||||||
+12
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
package io.element.android.features.healthchat.api
|
package io.element.android.features.healthchat.api
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display name fallback chain, mirroring the web client:
|
* Display name fallback chain, mirroring the web client:
|
||||||
* displayName → "title firstName lastName" → businessEmail → [fallback].
|
* displayName → "title firstName lastName" → businessEmail → [fallback].
|
||||||
@@ -21,3 +23,13 @@ fun HealthChatMember.bestDisplayName(fallback: String): String {
|
|||||||
businessEmail?.takeIf { it.isNotBlank() }?.let { return it }
|
businessEmail?.takeIf { it.isNotBlank() }?.let { return it }
|
||||||
return fallback
|
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,
|
||||||
|
)
|
||||||
|
|||||||
+1
-1
@@ -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
|
* 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`).
|
* one defaulted parameter plus the injection (same pattern as `InvitePeopleRenderer`).
|
||||||
*/
|
*/
|
||||||
interface HealthChatProfileSectionRenderer {
|
fun interface HealthChatProfileSectionRenderer {
|
||||||
@Composable
|
@Composable
|
||||||
fun Render(
|
fun Render(
|
||||||
userId: UserId,
|
userId: UserId,
|
||||||
|
|||||||
+26
@@ -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
|
||||||
|
}
|
||||||
+38
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+30
@@ -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
|
||||||
|
}
|
||||||
+99
@@ -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_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_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_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>
|
</resources>
|
||||||
|
|||||||
@@ -20,4 +20,22 @@
|
|||||||
<string name="healthchat_field_further_info">Informations complémentaires / qualifications</string>
|
<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_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_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>
|
</resources>
|
||||||
|
|||||||
@@ -20,4 +20,22 @@
|
|||||||
<string name="healthchat_field_further_info">Ulteriori informazioni / qualifiche</string>
|
<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_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_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>
|
</resources>
|
||||||
|
|||||||
@@ -21,4 +21,21 @@
|
|||||||
<string name="healthchat_field_further_info">Further info / qualifications</string>
|
<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_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_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>
|
</resources>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ setupDependencyInjection()
|
|||||||
dependencies {
|
dependencies {
|
||||||
api(projects.features.healthchat.api)
|
api(projects.features.healthchat.api)
|
||||||
implementation(libs.coroutines.core)
|
implementation(libs.coroutines.core)
|
||||||
|
implementation(projects.features.startchat.api)
|
||||||
implementation(platform(libs.network.okhttp.bom))
|
implementation(platform(libs.network.okhttp.bom))
|
||||||
implementation(libs.network.okhttp.okhttp)
|
implementation(libs.network.okhttp.okhttp)
|
||||||
implementation(libs.network.okhttp.logging)
|
implementation(libs.network.okhttp.logging)
|
||||||
@@ -56,6 +57,7 @@ dependencies {
|
|||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.libraries.network)
|
implementation(projects.libraries.network)
|
||||||
implementation(projects.libraries.sessionStorage.api)
|
implementation(projects.libraries.sessionStorage.api)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
@@ -63,6 +65,7 @@ dependencies {
|
|||||||
testCommonDependencies(libs, true)
|
testCommonDependencies(libs, true)
|
||||||
testImplementation(libs.network.mockwebserver)
|
testImplementation(libs.network.mockwebserver)
|
||||||
testImplementation(projects.features.healthchat.test)
|
testImplementation(projects.features.healthchat.test)
|
||||||
|
testImplementation(projects.features.startchat.test)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.preferences.api)
|
testImplementation(projects.libraries.preferences.api)
|
||||||
testImplementation(projects.libraries.preferences.test)
|
testImplementation(projects.libraries.preferences.test)
|
||||||
|
|||||||
+30
@@ -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)
|
||||||
|
}
|
||||||
+48
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -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())),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
+234
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+146
@@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
+63
@@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
+97
@@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
+44
@@ -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.",
|
||||||
|
)
|
||||||
|
}
|
||||||
+24
@@ -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>
|
||||||
|
}
|
||||||
+182
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+159
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -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.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
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.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.api.room.JoinedRoom
|
||||||
|
|
||||||
interface HomeEntryPoint : FeatureEntryPoint {
|
interface HomeEntryPoint : FeatureEntryPoint {
|
||||||
@@ -35,5 +36,8 @@ interface HomeEntryPoint : FeatureEntryPoint {
|
|||||||
fun navigateToBugReport()
|
fun navigateToBugReport()
|
||||||
fun navigateToAddAccount()
|
fun navigateToAddAccount()
|
||||||
fun navigateToSession(sessionId: SessionId)
|
fun navigateToSession(sessionId: SessionId)
|
||||||
|
|
||||||
|
// HealthChat fork: open a user profile from the member directory tab.
|
||||||
|
fun navigateToUserProfile(userId: UserId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ dependencies {
|
|||||||
implementation(projects.libraries.push.api)
|
implementation(projects.libraries.push.api)
|
||||||
implementation(projects.libraries.sessionStorage.api)
|
implementation(projects.libraries.sessionStorage.api)
|
||||||
implementation(projects.features.announcement.api)
|
implementation(projects.features.announcement.api)
|
||||||
|
implementation(projects.features.healthchat.api)
|
||||||
implementation(projects.features.invite.api)
|
implementation(projects.features.invite.api)
|
||||||
implementation(projects.features.leaveroom.api)
|
implementation(projects.features.leaveroom.api)
|
||||||
implementation(projects.features.rageshake.api)
|
implementation(projects.features.rageshake.api)
|
||||||
|
|||||||
+34
-1
@@ -12,6 +12,7 @@ import android.app.Activity
|
|||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.activity.compose.LocalActivity
|
import androidx.activity.compose.LocalActivity
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -22,6 +23,7 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.coroutineScope
|
import androidx.lifecycle.coroutineScope
|
||||||
import com.bumble.appyx.core.lifecycle.subscribe
|
import com.bumble.appyx.core.lifecycle.subscribe
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
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.node.node
|
import com.bumble.appyx.core.node.node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
@@ -32,6 +34,7 @@ import dev.zacsweers.metro.Assisted
|
|||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.AssistedInject
|
||||||
import im.vector.app.features.analytics.plan.MobileScreen
|
import im.vector.app.features.analytics.plan.MobileScreen
|
||||||
import io.element.android.annotations.ContributesNode
|
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.api.HomeEntryPoint
|
||||||
import io.element.android.features.home.impl.components.RoomListMenuAction
|
import io.element.android.features.home.impl.components.RoomListMenuAction
|
||||||
import io.element.android.features.home.impl.model.RoomListRoomSummary
|
import io.element.android.features.home.impl.model.RoomListRoomSummary
|
||||||
@@ -86,6 +89,8 @@ class HomeFlowNode(
|
|||||||
private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint,
|
private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint,
|
||||||
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
|
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
|
||||||
private val leaveRoomRenderer: LeaveRoomRenderer,
|
private val leaveRoomRenderer: LeaveRoomRenderer,
|
||||||
|
// HealthChat fork: renders the member directory tab.
|
||||||
|
private val memberDirectoryRenderer: MemberDirectoryRenderer,
|
||||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||||
) : BaseFlowNode<HomeFlowNode.NavTarget>(
|
) : BaseFlowNode<HomeFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
@@ -98,6 +103,10 @@ class HomeFlowNode(
|
|||||||
private val callback: HomeEntryPoint.Callback = callback()
|
private val callback: HomeEntryPoint.Callback = callback()
|
||||||
private val stateFlow = launchMolecule { presenter.present() }
|
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() {
|
override fun onBuilt() {
|
||||||
super.onBuilt()
|
super.onBuilt()
|
||||||
lifecycle.subscribe(
|
lifecycle.subscribe(
|
||||||
@@ -246,13 +255,37 @@ class HomeFlowNode(
|
|||||||
onSelectNewOwners = ::navigateToSelectNewOwnersWhenLeavingRoom,
|
onSelectNewOwners = ::navigateToSelectNewOwnersWhenLeavingRoom,
|
||||||
modifier = Modifier
|
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
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
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()
|
BackstackView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
@@ -11,6 +11,7 @@ package io.element.android.features.home.impl
|
|||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.features.healthchat.api.HealthChatStrings
|
||||||
|
|
||||||
enum class HomeNavigationBarItem(
|
enum class HomeNavigationBarItem(
|
||||||
@StringRes
|
@StringRes
|
||||||
@@ -21,6 +22,11 @@ enum class HomeNavigationBarItem(
|
|||||||
),
|
),
|
||||||
Spaces(
|
Spaces(
|
||||||
labelRes = R.string.screen_home_tab_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
|
@Composable
|
||||||
@@ -29,6 +35,7 @@ enum class HomeNavigationBarItem(
|
|||||||
) = when (this) {
|
) = when (this) {
|
||||||
Chats -> if (isSelected) CompoundIcons.ChatSolid() else CompoundIcons.Chat()
|
Chats -> if (isSelected) CompoundIcons.ChatSolid() else CompoundIcons.Chat()
|
||||||
Spaces -> if (isSelected) CompoundIcons.SpaceSolid() else CompoundIcons.Space()
|
Spaces -> if (isSelected) CompoundIcons.SpaceSolid() else CompoundIcons.Space()
|
||||||
|
Members -> if (isSelected) CompoundIcons.UserProfileSolid() else CompoundIcons.UserProfile()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
+5
@@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import io.element.android.features.announcement.api.Announcement
|
import io.element.android.features.announcement.api.Announcement
|
||||||
import io.element.android.features.announcement.api.AnnouncementService
|
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.roomlist.RoomListState
|
||||||
import io.element.android.features.home.impl.spaces.HomeSpacesState
|
import io.element.android.features.home.impl.spaces.HomeSpacesState
|
||||||
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
||||||
@@ -49,6 +50,8 @@ class HomePresenter(
|
|||||||
private val indicatorService: IndicatorService,
|
private val indicatorService: IndicatorService,
|
||||||
private val roomListPresenter: Presenter<RoomListState>,
|
private val roomListPresenter: Presenter<RoomListState>,
|
||||||
private val homeSpacesPresenter: Presenter<HomeSpacesState>,
|
private val homeSpacesPresenter: Presenter<HomeSpacesState>,
|
||||||
|
// HealthChat fork: member directory tab presenter.
|
||||||
|
private val memberDirectoryPresenter: Presenter<MemberDirectoryState>,
|
||||||
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
||||||
private val sessionStore: SessionStore,
|
private val sessionStore: SessionStore,
|
||||||
private val announcementService: AnnouncementService,
|
private val announcementService: AnnouncementService,
|
||||||
@@ -100,6 +103,7 @@ class HomePresenter(
|
|||||||
}
|
}
|
||||||
val roomListState = roomListPresenter.present()
|
val roomListState = roomListPresenter.present()
|
||||||
val homeSpacesState = homeSpacesPresenter.present()
|
val homeSpacesState = homeSpacesPresenter.present()
|
||||||
|
val memberDirectoryState = memberDirectoryPresenter.present()
|
||||||
var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) }
|
var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) }
|
||||||
val currentHomeNavigationBarItem by remember {
|
val currentHomeNavigationBarItem by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
@@ -136,6 +140,7 @@ class HomePresenter(
|
|||||||
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
|
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
|
||||||
roomListState = roomListState,
|
roomListState = roomListState,
|
||||||
homeSpacesState = homeSpacesState,
|
homeSpacesState = homeSpacesState,
|
||||||
|
memberDirectoryState = memberDirectoryState,
|
||||||
snackbarMessage = snackbarMessage,
|
snackbarMessage = snackbarMessage,
|
||||||
canReportBug = canReportBug,
|
canReportBug = canReportBug,
|
||||||
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
|
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
package io.element.android.features.home.impl
|
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.roomlist.RoomListState
|
||||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
|
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
|
||||||
import io.element.android.features.home.impl.spaces.HomeSpacesState
|
import io.element.android.features.home.impl.spaces.HomeSpacesState
|
||||||
@@ -26,6 +27,8 @@ data class HomeState(
|
|||||||
val currentHomeNavigationBarItem: HomeNavigationBarItem,
|
val currentHomeNavigationBarItem: HomeNavigationBarItem,
|
||||||
val roomListState: RoomListState,
|
val roomListState: RoomListState,
|
||||||
val homeSpacesState: HomeSpacesState,
|
val homeSpacesState: HomeSpacesState,
|
||||||
|
// HealthChat fork: state of the member directory tab.
|
||||||
|
val memberDirectoryState: MemberDirectoryState,
|
||||||
val snackbarMessage: SnackbarMessage?,
|
val snackbarMessage: SnackbarMessage?,
|
||||||
val canReportBug: Boolean,
|
val canReportBug: Boolean,
|
||||||
val showMultiAccountAnnouncement: Boolean,
|
val showMultiAccountAnnouncement: Boolean,
|
||||||
|
|||||||
+7
@@ -9,6 +9,8 @@
|
|||||||
package io.element.android.features.home.impl
|
package io.element.android.features.home.impl
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
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.RoomListState
|
||||||
import io.element.android.features.home.impl.roomlist.RoomListStatePreviewParam
|
import io.element.android.features.home.impl.roomlist.RoomListStatePreviewParam
|
||||||
import io.element.android.features.home.impl.roomlist.aRoomListState
|
import io.element.android.features.home.impl.roomlist.aRoomListState
|
||||||
@@ -50,6 +52,9 @@ open class HomeStatePreviewParam : PreviewParameterProvider<HomeState> {
|
|||||||
),
|
),
|
||||||
) + aHomeState(
|
) + aHomeState(
|
||||||
showMultiAccountAnnouncement = true,
|
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,
|
currentHomeNavigationBarItem: HomeNavigationBarItem = HomeNavigationBarItem.Chats,
|
||||||
roomListState: RoomListState = aRoomListState(),
|
roomListState: RoomListState = aRoomListState(),
|
||||||
homeSpacesState: HomeSpacesState = aHomeSpacesState(),
|
homeSpacesState: HomeSpacesState = aHomeSpacesState(),
|
||||||
|
memberDirectoryState: MemberDirectoryState = aMemberDirectoryState(),
|
||||||
canReportBug: Boolean = true,
|
canReportBug: Boolean = true,
|
||||||
showMultiAccountAnnouncement: Boolean = false,
|
showMultiAccountAnnouncement: Boolean = false,
|
||||||
totalUnreadCount: Int = 0,
|
totalUnreadCount: Int = 0,
|
||||||
@@ -77,5 +83,6 @@ internal fun aHomeState(
|
|||||||
roomListState = roomListState,
|
roomListState = roomListState,
|
||||||
homeSpacesState = homeSpacesState,
|
homeSpacesState = homeSpacesState,
|
||||||
totalUnreadCount = totalUnreadCount,
|
totalUnreadCount = totalUnreadCount,
|
||||||
|
memberDirectoryState = memberDirectoryState,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|||||||
+33
-9
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.navigationBars
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.plus
|
import androidx.compose.foundation.layout.plus
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.FabPosition
|
import androidx.compose.material3.FabPosition
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
@@ -90,6 +91,11 @@ fun HomeView(
|
|||||||
onDeclineInviteAndBlockUser: (roomSummary: RoomListRoomSummary) -> Unit,
|
onDeclineInviteAndBlockUser: (roomSummary: RoomListRoomSummary) -> Unit,
|
||||||
acceptDeclineInviteView: @Composable () -> Unit,
|
acceptDeclineInviteView: @Composable () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
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,
|
leaveRoomView: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val state: RoomListState = homeState.roomListState
|
val state: RoomListState = homeState.roomListState
|
||||||
@@ -133,6 +139,8 @@ fun HomeView(
|
|||||||
onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() },
|
onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() },
|
||||||
onMenuActionClick = onMenuActionClick,
|
onMenuActionClick = onMenuActionClick,
|
||||||
onSwitchToAccount = onSwitchToAccount,
|
onSwitchToAccount = onSwitchToAccount,
|
||||||
|
memberDirectoryView = memberDirectoryView,
|
||||||
|
isBackHandlingEnabled = isBackHandlingEnabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (state.globalSearchState.isEnabled) {
|
if (state.globalSearchState.isEnabled) {
|
||||||
@@ -171,7 +179,9 @@ private fun HomeScaffold(
|
|||||||
onCreateSpaceClick: () -> Unit,
|
onCreateSpaceClick: () -> Unit,
|
||||||
onMenuActionClick: (RoomListMenuAction) -> Unit,
|
onMenuActionClick: (RoomListMenuAction) -> Unit,
|
||||||
onSwitchToAccount: (SessionId) -> Unit,
|
onSwitchToAccount: (SessionId) -> Unit,
|
||||||
|
isBackHandlingEnabled: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
memberDirectoryView: @Composable (PaddingValues, LazyListState) -> Unit,
|
||||||
) {
|
) {
|
||||||
fun onRoomClick(room: RoomListRoomSummary) {
|
fun onRoomClick(room: RoomListRoomSummary) {
|
||||||
onRoomClick(room.roomId)
|
onRoomClick(room.roomId)
|
||||||
@@ -182,7 +192,8 @@ private fun HomeScaffold(
|
|||||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||||
val roomListState: RoomListState = state.roomListState
|
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) {
|
if (state.currentHomeNavigationBarItem != HomeNavigationBarItem.Chats) {
|
||||||
state.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats))
|
state.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats))
|
||||||
} else {
|
} else {
|
||||||
@@ -195,6 +206,7 @@ private fun HomeScaffold(
|
|||||||
|
|
||||||
val roomsLazyListState = rememberLazyListState()
|
val roomsLazyListState = rememberLazyListState()
|
||||||
val spacesLazyListState = rememberLazyListState()
|
val spacesLazyListState = rememberLazyListState()
|
||||||
|
val membersLazyListState = rememberLazyListState()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -242,6 +254,7 @@ private fun HomeScaffold(
|
|||||||
val lazyListStateTarget = when (item) {
|
val lazyListStateTarget = when (item) {
|
||||||
HomeNavigationBarItem.Chats -> roomsLazyListState
|
HomeNavigationBarItem.Chats -> roomsLazyListState
|
||||||
HomeNavigationBarItem.Spaces -> spacesLazyListState
|
HomeNavigationBarItem.Spaces -> spacesLazyListState
|
||||||
|
HomeNavigationBarItem.Members -> membersLazyListState
|
||||||
}
|
}
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
if (lazyListStateTarget.firstVisibleItemIndex > 10) {
|
if (lazyListStateTarget.firstVisibleItemIndex > 10) {
|
||||||
@@ -255,15 +268,15 @@ private fun HomeScaffold(
|
|||||||
state.eventSink(HomeEvent.SelectHomeNavigationBarItem(item))
|
state.eventSink(HomeEvent.SelectHomeNavigationBarItem(item))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = when (state.currentHomeNavigationBarItem) {
|
||||||
when (state.currentHomeNavigationBarItem) {
|
HomeNavigationBarItem.Chats -> {
|
||||||
HomeNavigationBarItem.Chats -> {
|
{ HomeFloatingActionButton(onStartChatClick, CommonStrings.action_create_room) }
|
||||||
HomeFloatingActionButton(onStartChatClick, CommonStrings.action_create_room)
|
|
||||||
}
|
|
||||||
HomeNavigationBarItem.Spaces -> {
|
|
||||||
HomeFloatingActionButton(onCreateSpaceClick, CommonStrings.action_create_space)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
HomeNavigationBarItem.Spaces -> {
|
||||||
|
{ HomeFloatingActionButton(onCreateSpaceClick, CommonStrings.action_create_space) }
|
||||||
|
}
|
||||||
|
// HealthChat fork: the member directory has no create action.
|
||||||
|
HomeNavigationBarItem.Members -> null
|
||||||
},
|
},
|
||||||
totalUnreadCount = state.totalUnreadCount,
|
totalUnreadCount = state.totalUnreadCount,
|
||||||
)
|
)
|
||||||
@@ -325,6 +338,17 @@ private fun HomeScaffold(
|
|||||||
onExploreClick = {},
|
onExploreClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// HealthChat fork: member directory tab content.
|
||||||
|
HomeNavigationBarItem.Members -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(outerPadding)
|
||||||
|
.consumeWindowInsets(outerPadding),
|
||||||
|
) {
|
||||||
|
memberDirectoryView(lazyColumnContentPadding + contentPadding, membersLazyListState)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+2
@@ -133,6 +133,8 @@ fun HomeTopBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
HomeNavigationBarItem.Spaces -> null
|
HomeNavigationBarItem.Spaces -> null
|
||||||
|
// HealthChat fork: member directory tab title.
|
||||||
|
HomeNavigationBarItem.Members -> stringResource(selectedNavigationItem.labelRes)
|
||||||
}
|
}
|
||||||
displayTitle?.let {
|
displayTitle?.let {
|
||||||
val style = when (spaceFiltersState) {
|
val style = when (spaceFiltersState) {
|
||||||
|
|||||||
+3
@@ -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.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
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.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.api.room.JoinedRoom
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
@@ -41,6 +42,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
|
|||||||
declineInviteAndBlockUserEntryPoint = FakeDeclineInviteAndBlockEntryPoint(),
|
declineInviteAndBlockUserEntryPoint = FakeDeclineInviteAndBlockEntryPoint(),
|
||||||
changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() },
|
changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() },
|
||||||
leaveRoomRenderer = { _, _, _ -> lambdaError() },
|
leaveRoomRenderer = { _, _, _ -> lambdaError() },
|
||||||
|
memberDirectoryRenderer = { _, _, _, _, _, _, _, _, _ -> lambdaError() },
|
||||||
sessionCoroutineScope = backgroundScope,
|
sessionCoroutineScope = backgroundScope,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -55,6 +57,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
|
|||||||
override fun navigateToBugReport() = lambdaError()
|
override fun navigateToBugReport() = lambdaError()
|
||||||
override fun navigateToAddAccount() = lambdaError()
|
override fun navigateToAddAccount() = lambdaError()
|
||||||
override fun navigateToSession(sessionId: SessionId) = lambdaError()
|
override fun navigateToSession(sessionId: SessionId) = lambdaError()
|
||||||
|
override fun navigateToUserProfile(userId: UserId) = lambdaError()
|
||||||
}
|
}
|
||||||
val result = entryPoint.createNode(
|
val result = entryPoint.createNode(
|
||||||
parentNode = parentNode,
|
parentNode = parentNode,
|
||||||
|
|||||||
+17
@@ -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.Announcement
|
||||||
import io.element.android.features.announcement.api.AnnouncementService
|
import io.element.android.features.announcement.api.AnnouncementService
|
||||||
import io.element.android.features.announcement.test.FakeAnnouncementService
|
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.roomlist.aRoomListState
|
||||||
import io.element.android.features.home.impl.spaces.HomeSpacesState
|
import io.element.android.features.home.impl.spaces.HomeSpacesState
|
||||||
import io.element.android.features.home.impl.spaces.aHomeSpacesState
|
import io.element.android.features.home.impl.spaces.aHomeSpacesState
|
||||||
@@ -322,6 +323,21 @@ class HomePresenterTest {
|
|||||||
assertThat(awaitItem().totalUnreadCount).isEqualTo(5)
|
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(
|
internal fun TestScope.createHomePresenter(
|
||||||
@@ -342,6 +358,7 @@ internal fun TestScope.createHomePresenter(
|
|||||||
indicatorService = indicatorService,
|
indicatorService = indicatorService,
|
||||||
roomListPresenter = { aRoomListState() },
|
roomListPresenter = { aRoomListState() },
|
||||||
homeSpacesPresenter = homeSpacesPresenter,
|
homeSpacesPresenter = homeSpacesPresenter,
|
||||||
|
memberDirectoryPresenter = { aMemberDirectoryState() },
|
||||||
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
||||||
sessionStore = sessionStore,
|
sessionStore = sessionStore,
|
||||||
announcementService = announcementService,
|
announcementService = announcementService,
|
||||||
|
|||||||
+78
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user