Add member directory tab to home navigation (#3)

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

Third bottom-nav tab backed by the HealthChat API: searchable member
cards with native start-chat, approval-pending banner, retry on
failure and navigation into the native profile screens.

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

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

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

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

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

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

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

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

* Update screenshots

* Revert "Update screenshots"

This reverts commit b99e0d643f.

* Reapply "Update screenshots"

This reverts commit 85bceb6d67.

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

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

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

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

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

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

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
2026-08-06 13:10:01 +02:00
committed by GitHub
parent 0c78553641
commit edb3520c56
123 changed files with 1968 additions and 138 deletions
@@ -13,6 +13,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom
interface HomeEntryPoint : FeatureEntryPoint {
@@ -31,5 +32,8 @@ interface HomeEntryPoint : FeatureEntryPoint {
fun navigateToEnterRecoveryKey()
fun navigateToRoomSettings(roomId: RoomId)
fun navigateToBugReport()
// HealthChat fork: open a user profile from the member directory tab.
fun navigateToUserProfile(userId: UserId)
}
}
+1
View File
@@ -48,6 +48,7 @@ dependencies {
implementation(projects.libraries.push.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.features.announcement.api)
implementation(projects.features.healthchat.api)
implementation(projects.features.invite.api)
implementation(projects.features.networkmonitor.api)
implementation(projects.features.logout.api)
@@ -12,6 +12,7 @@ import android.app.Activity
import android.os.Parcelable
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -22,6 +23,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.LocalNodeTargetVisibility
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.node
import com.bumble.appyx.core.plugin.Plugin
@@ -32,6 +34,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
import io.element.android.features.healthchat.api.directory.MemberDirectoryRenderer
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.home.impl.components.RoomListMenuAction
import io.element.android.features.home.impl.model.RoomListRoomSummary
@@ -87,6 +90,8 @@ class HomeFlowNode(
private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
private val leaveRoomRenderer: LeaveRoomRenderer,
// HealthChat fork: renders the member directory tab.
private val memberDirectoryRenderer: MemberDirectoryRenderer,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : BaseFlowNode<HomeFlowNode.NavTarget>(
backstack = BackStack(
@@ -99,6 +104,10 @@ class HomeFlowNode(
private val callback: HomeEntryPoint.Callback = callback()
private val stateFlow = launchMolecule { presenter.present() }
// HealthChat fork: whether this node is the visible target within the parent backstack.
// False as soon as another screen (e.g. a member profile) starts being pushed on top.
private val isVisibleNavTarget = mutableStateOf(true)
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
@@ -244,7 +253,24 @@ class HomeFlowNode(
onSelectNewOwners = ::navigateToSelectNewOwnersWhenLeavingRoom,
modifier = Modifier
)
}
},
memberDirectoryView = { contentPadding, lazyListState ->
memberDirectoryRenderer.Render(
state = state.memberDirectoryState,
contentPadding = contentPadding,
lazyListState = lazyListState,
onOpenDm = ::navigateToRoom,
onMemberClick = { callback.navigateToUserProfile(it) },
onMyProfileClick = { callback.navigateToUserProfile(matrixClient.sessionId) },
// Hidden until the native editor and invite screens exist.
onEditProfileClick = null,
onInviteClick = null,
modifier = Modifier,
)
},
// Both levels: Home within the parent backstack (captured in View) and the
// root element within Home's own backstack.
isBackHandlingEnabled = isVisibleNavTarget.value && LocalNodeTargetVisibility.current,
)
directLogoutView.Render(state.directLogoutState)
}
@@ -252,6 +278,13 @@ class HomeFlowNode(
@Composable
override fun View(modifier: Modifier) {
// HealthChat fork: capture the target visibility at this level, where the
// CompositionLocal still refers to Home within the PARENT backstack (inside
// rootNode it is re-provided for Home's own backstack).
val isVisibleTarget = LocalNodeTargetVisibility.current
SideEffect {
isVisibleNavTarget.value = isVisibleTarget
}
BackstackView()
}
@@ -11,6 +11,7 @@ package io.element.android.features.home.impl
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.healthchat.api.HealthChatStrings
enum class HomeNavigationBarItem(
@StringRes
@@ -21,6 +22,11 @@ enum class HomeNavigationBarItem(
),
Spaces(
labelRes = R.string.screen_home_tab_spaces
),
// HealthChat fork: member directory tab. Keep as LAST entry so persisted ordinals stay stable.
Members(
labelRes = HealthChatStrings.healthchat_screen_home_tab_members
);
@Composable
@@ -29,6 +35,7 @@ enum class HomeNavigationBarItem(
) = when (this) {
Chats -> if (isSelected) CompoundIcons.ChatSolid() else CompoundIcons.Chat()
Spaces -> if (isSelected) CompoundIcons.SpaceSolid() else CompoundIcons.Space()
Members -> if (isSelected) CompoundIcons.UserProfileSolid() else CompoundIcons.UserProfile()
}
companion object {
@@ -19,6 +19,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.features.logout.api.direct.DirectLogoutState
@@ -42,6 +43,8 @@ class HomePresenter(
private val indicatorService: IndicatorService,
private val roomListPresenter: Presenter<RoomListState>,
private val homeSpacesPresenter: Presenter<HomeSpacesState>,
// HealthChat fork: member directory tab presenter.
private val memberDirectoryPresenter: Presenter<MemberDirectoryState>,
private val logoutPresenter: Presenter<DirectLogoutState>,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val sessionStore: SessionStore,
@@ -63,6 +66,7 @@ class HomePresenter(
val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
val roomListState = roomListPresenter.present()
val homeSpacesState = homeSpacesPresenter.present()
val memberDirectoryState = memberDirectoryPresenter.present()
var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) }
val currentHomeNavigationBarItem by remember {
derivedStateOf {
@@ -96,6 +100,7 @@ class HomePresenter(
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState,
homeSpacesState = homeSpacesState,
memberDirectoryState = memberDirectoryState,
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
directLogoutState = directLogoutState,
@@ -8,6 +8,7 @@
package io.element.android.features.home.impl
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spaces.HomeSpacesState
@@ -27,6 +28,8 @@ data class HomeState(
val currentHomeNavigationBarItem: HomeNavigationBarItem,
val roomListState: RoomListState,
val homeSpacesState: HomeSpacesState,
// HealthChat fork: state of the member directory tab.
val memberDirectoryState: MemberDirectoryState,
val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean,
val directLogoutState: DirectLogoutState,
@@ -9,6 +9,8 @@
package io.element.android.features.home.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.features.healthchat.api.directory.aMemberDirectoryState
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.roomlist.RoomListStateProvider
import io.element.android.features.home.impl.roomlist.aRoomListState
@@ -50,6 +52,9 @@ open class HomeStateProvider : PreviewParameterProvider<HomeState> {
homeSpacesState = aHomeSpacesState(
spaceRooms = emptyList(),
),
// HealthChat fork: appended LAST so upstream snapshot indices stay stable.
) + aHomeState(
currentHomeNavigationBarItem = HomeNavigationBarItem.Members,
)
}
@@ -62,6 +67,7 @@ internal fun aHomeState(
currentHomeNavigationBarItem: HomeNavigationBarItem = HomeNavigationBarItem.Chats,
roomListState: RoomListState = aRoomListState(),
homeSpacesState: HomeSpacesState = aHomeSpacesState(),
memberDirectoryState: MemberDirectoryState = aMemberDirectoryState(),
canReportBug: Boolean = true,
directLogoutState: DirectLogoutState = aDirectLogoutState(),
eventSink: (HomeEvent) -> Unit = {}
@@ -75,5 +81,6 @@ internal fun aHomeState(
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState,
homeSpacesState = homeSpacesState,
memberDirectoryState = memberDirectoryState,
eventSink = eventSink,
)
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -86,6 +87,11 @@ fun HomeView(
onDeclineInviteAndBlockUser: (roomSummary: RoomListRoomSummary) -> Unit,
acceptDeclineInviteView: @Composable () -> Unit,
modifier: Modifier = Modifier,
// HealthChat fork: false while another screen is pushed on top of Home, so the
// tab-switching BackHandler cannot swallow back presses during the transition.
isBackHandlingEnabled: Boolean = true,
// HealthChat fork: content of the member directory tab, rendered by features/healthchat.
memberDirectoryView: @Composable (PaddingValues, LazyListState) -> Unit = { _, _ -> },
leaveRoomView: @Composable () -> Unit,
) {
val state: RoomListState = homeState.roomListState
@@ -121,6 +127,8 @@ fun HomeView(
onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() },
onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() },
onMenuActionClick = onMenuActionClick,
memberDirectoryView = memberDirectoryView,
isBackHandlingEnabled = isBackHandlingEnabled,
)
// This overlaid view will only be visible when state.displaySearchResults is true
RoomListSearchView(
@@ -147,7 +155,9 @@ private fun HomeScaffold(
onStartChatClick: () -> Unit,
onCreateSpaceClick: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
isBackHandlingEnabled: Boolean,
modifier: Modifier = Modifier,
memberDirectoryView: @Composable (PaddingValues, LazyListState) -> Unit,
) {
fun onRoomClick(room: RoomListRoomSummary) {
onRoomClick(room.roomId)
@@ -158,7 +168,8 @@ private fun HomeScaffold(
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
val roomListState: RoomListState = state.roomListState
BackHandler(enabled = state.isBackHandlerEnabled) {
// HealthChat fork: also gated on isBackHandlingEnabled (see HomeView docs).
BackHandler(enabled = state.isBackHandlerEnabled && isBackHandlingEnabled) {
if (state.currentHomeNavigationBarItem != HomeNavigationBarItem.Chats) {
state.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats))
} else {
@@ -172,6 +183,7 @@ private fun HomeScaffold(
val hazeState = rememberHazeState()
val roomsLazyListState = rememberLazyListState()
val spacesLazyListState = rememberLazyListState()
val membersLazyListState = rememberLazyListState()
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -208,6 +220,7 @@ private fun HomeScaffold(
val lazyListStateTarget = when (item) {
HomeNavigationBarItem.Chats -> roomsLazyListState
HomeNavigationBarItem.Spaces -> spacesLazyListState
HomeNavigationBarItem.Members -> membersLazyListState
}
coroutineScope.launch {
if (lazyListStateTarget.firstVisibleItemIndex > 10) {
@@ -221,15 +234,15 @@ private fun HomeScaffold(
state.eventSink(HomeEvent.SelectHomeNavigationBarItem(item))
}
},
floatingActionButton = {
when (state.currentHomeNavigationBarItem) {
HomeNavigationBarItem.Chats -> {
HomeFloatingActionButton(onStartChatClick, CommonStrings.action_create_room)
}
HomeNavigationBarItem.Spaces -> {
HomeFloatingActionButton(onCreateSpaceClick, CommonStrings.action_create_space)
}
floatingActionButton = when (state.currentHomeNavigationBarItem) {
HomeNavigationBarItem.Chats -> {
{ HomeFloatingActionButton(onStartChatClick, CommonStrings.action_create_room) }
}
HomeNavigationBarItem.Spaces -> {
{ HomeFloatingActionButton(onCreateSpaceClick, CommonStrings.action_create_space) }
}
// HealthChat fork: the member directory has no create action.
HomeNavigationBarItem.Members -> null
},
)
},
@@ -285,6 +298,18 @@ private fun HomeScaffold(
onExploreClick = {},
)
}
// HealthChat fork: member directory tab content.
HomeNavigationBarItem.Members -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
.hazeSource(state = hazeState),
) {
memberDirectoryView(contentPadding, membersLazyListState)
}
}
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -116,6 +116,8 @@ fun HomeTopBar(
}
}
HomeNavigationBarItem.Spaces -> stringResource(selectedNavigationItem.labelRes)
// HealthChat fork: member directory tab title.
HomeNavigationBarItem.Members -> stringResource(selectedNavigationItem.labelRes)
}
Text(
modifier = Modifier.semantics {
@@ -12,6 +12,7 @@ import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.services.analytics.test.FakeAnalyticsService
@@ -39,6 +40,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
declineInviteAndBlockUserEntryPoint = { _, _, _ -> lambdaError() },
changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() },
leaveRoomRenderer = { _, _, _ -> lambdaError() },
memberDirectoryRenderer = { _, _, _, _, _, _, _, _, _ -> lambdaError() },
sessionCoroutineScope = backgroundScope,
)
}
@@ -51,6 +53,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
override fun navigateToEnterRecoveryKey() = lambdaError()
override fun navigateToRoomSettings(roomId: RoomId) = lambdaError()
override fun navigateToBugReport() = lambdaError()
override fun navigateToUserProfile(userId: UserId) = lambdaError()
}
val result = entryPoint.createNode(
parentNode = parentNode,
@@ -9,6 +9,7 @@
package io.element.android.features.home.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.healthchat.api.directory.aMemberDirectoryState
import io.element.android.features.home.impl.roomlist.aRoomListState
import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.features.home.impl.spaces.aHomeSpacesState
@@ -145,6 +146,21 @@ class HomePresenterTest {
assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces)
}
}
@Test
fun `present - NavigationBar change to Members`() = runTest {
val presenter = createHomePresenter(
sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Members))
val finalState = awaitItem()
assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Members)
}
}
}
internal fun createHomePresenter(
@@ -162,6 +178,7 @@ internal fun createHomePresenter(
indicatorService = indicatorService,
roomListPresenter = { aRoomListState() },
homeSpacesPresenter = homeSpacesPresenter,
memberDirectoryPresenter = { aMemberDirectoryState() },
logoutPresenter = { aDirectLogoutState() },
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
sessionStore = sessionStore,
@@ -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()
}
}