Add member directory tab to home navigation (#3)

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

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

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

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

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

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

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

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

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

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

* Update screenshots

* Revert "Update screenshots"

This reverts commit b99e0d643f.

* Reapply "Update screenshots"

This reverts commit 85bceb6d67.

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

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

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

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

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

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

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
2026-08-06 13:10:01 +02:00
committed by Sedat Qaja
parent 21444b45e7
commit 1cf01cdc62
37 changed files with 1651 additions and 14 deletions
+1
View File
@@ -49,6 +49,7 @@ dependencies {
implementation(projects.libraries.push.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.features.announcement.api)
implementation(projects.features.healthchat.api)
implementation(projects.features.invite.api)
implementation(projects.features.leaveroom.api)
implementation(projects.features.rageshake.api)
@@ -12,6 +12,7 @@ import android.app.Activity
import android.os.Parcelable
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -22,6 +23,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.LocalNodeTargetVisibility
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.node
import com.bumble.appyx.core.plugin.Plugin
@@ -32,6 +34,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
import io.element.android.features.healthchat.api.directory.MemberDirectoryRenderer
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.home.impl.components.RoomListMenuAction
import io.element.android.features.home.impl.model.RoomListRoomSummary
@@ -86,6 +89,8 @@ class HomeFlowNode(
private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
private val leaveRoomRenderer: LeaveRoomRenderer,
// HealthChat fork: renders the member directory tab.
private val memberDirectoryRenderer: MemberDirectoryRenderer,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : BaseFlowNode<HomeFlowNode.NavTarget>(
backstack = BackStack(
@@ -98,6 +103,10 @@ class HomeFlowNode(
private val callback: HomeEntryPoint.Callback = callback()
private val stateFlow = launchMolecule { presenter.present() }
// HealthChat fork: whether this node is the visible target within the parent backstack.
// False as soon as another screen (e.g. a member profile) starts being pushed on top.
private val isVisibleNavTarget = mutableStateOf(true)
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
@@ -246,13 +255,37 @@ class HomeFlowNode(
onSelectNewOwners = ::navigateToSelectNewOwnersWhenLeavingRoom,
modifier = Modifier
)
}
},
memberDirectoryView = { contentPadding, lazyListState ->
memberDirectoryRenderer.Render(
state = state.memberDirectoryState,
contentPadding = contentPadding,
lazyListState = lazyListState,
onOpenDm = { navigateToRoom(roomId = it, eventId = null) },
onMemberClick = { callback.navigateToUserProfile(it) },
onMyProfileClick = { callback.navigateToUserProfile(matrixClient.sessionId) },
// Hidden until the native editor and invite screens exist.
onEditProfileClick = null,
onInviteClick = null,
modifier = Modifier,
)
},
// Both levels: Home within the parent backstack (captured in View) and the
// root element within Home's own backstack.
isBackHandlingEnabled = isVisibleNavTarget.value && LocalNodeTargetVisibility.current,
)
}
}
@Composable
override fun View(modifier: Modifier) {
// HealthChat fork: capture the target visibility at this level, where the
// CompositionLocal still refers to Home within the PARENT backstack (inside
// rootNode it is re-provided for Home's own backstack).
val isVisibleTarget = LocalNodeTargetVisibility.current
SideEffect {
isVisibleNavTarget.value = isVisibleTarget
}
BackstackView()
}
@@ -11,6 +11,7 @@ package io.element.android.features.home.impl
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.healthchat.api.HealthChatStrings
enum class HomeNavigationBarItem(
@StringRes
@@ -21,6 +22,11 @@ enum class HomeNavigationBarItem(
),
Spaces(
labelRes = R.string.screen_home_tab_spaces
),
// HealthChat fork: member directory tab. Keep as LAST entry so persisted ordinals stay stable.
Members(
labelRes = HealthChatStrings.healthchat_screen_home_tab_members
);
@Composable
@@ -29,6 +35,7 @@ enum class HomeNavigationBarItem(
) = when (this) {
Chats -> if (isSelected) CompoundIcons.ChatSolid() else CompoundIcons.Chat()
Spaces -> if (isSelected) CompoundIcons.SpaceSolid() else CompoundIcons.Space()
Members -> if (isSelected) CompoundIcons.UserProfileSolid() else CompoundIcons.UserProfile()
}
companion object {
@@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
@@ -49,6 +50,8 @@ class HomePresenter(
private val indicatorService: IndicatorService,
private val roomListPresenter: Presenter<RoomListState>,
private val homeSpacesPresenter: Presenter<HomeSpacesState>,
// HealthChat fork: member directory tab presenter.
private val memberDirectoryPresenter: Presenter<MemberDirectoryState>,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val sessionStore: SessionStore,
private val announcementService: AnnouncementService,
@@ -100,6 +103,7 @@ class HomePresenter(
}
val roomListState = roomListPresenter.present()
val homeSpacesState = homeSpacesPresenter.present()
val memberDirectoryState = memberDirectoryPresenter.present()
var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) }
val currentHomeNavigationBarItem by remember {
derivedStateOf {
@@ -136,6 +140,7 @@ class HomePresenter(
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState,
homeSpacesState = homeSpacesState,
memberDirectoryState = memberDirectoryState,
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
@@ -8,6 +8,7 @@
package io.element.android.features.home.impl
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spaces.HomeSpacesState
@@ -26,6 +27,8 @@ data class HomeState(
val currentHomeNavigationBarItem: HomeNavigationBarItem,
val roomListState: RoomListState,
val homeSpacesState: HomeSpacesState,
// HealthChat fork: state of the member directory tab.
val memberDirectoryState: MemberDirectoryState,
val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean,
val showMultiAccountAnnouncement: Boolean,
@@ -9,6 +9,8 @@
package io.element.android.features.home.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.features.healthchat.api.directory.aMemberDirectoryState
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.roomlist.RoomListStatePreviewParam
import io.element.android.features.home.impl.roomlist.aRoomListState
@@ -50,6 +52,9 @@ open class HomeStatePreviewParam : PreviewParameterProvider<HomeState> {
),
) + aHomeState(
showMultiAccountAnnouncement = true,
) + aHomeState(
// HealthChat fork: appended LAST so upstream snapshot indices stay stable.
currentHomeNavigationBarItem = HomeNavigationBarItem.Members,
)
}
@@ -62,6 +67,7 @@ internal fun aHomeState(
currentHomeNavigationBarItem: HomeNavigationBarItem = HomeNavigationBarItem.Chats,
roomListState: RoomListState = aRoomListState(),
homeSpacesState: HomeSpacesState = aHomeSpacesState(),
memberDirectoryState: MemberDirectoryState = aMemberDirectoryState(),
canReportBug: Boolean = true,
showMultiAccountAnnouncement: Boolean = false,
totalUnreadCount: Int = 0,
@@ -77,5 +83,6 @@ internal fun aHomeState(
roomListState = roomListState,
homeSpacesState = homeSpacesState,
totalUnreadCount = totalUnreadCount,
memberDirectoryState = memberDirectoryState,
eventSink = eventSink,
)
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.FabPosition
import androidx.compose.material3.TopAppBarDefaults
@@ -90,6 +91,11 @@ fun HomeView(
onDeclineInviteAndBlockUser: (roomSummary: RoomListRoomSummary) -> Unit,
acceptDeclineInviteView: @Composable () -> Unit,
modifier: Modifier = Modifier,
// HealthChat fork: false while another screen is pushed on top of Home, so the
// tab-switching BackHandler cannot swallow back presses during the transition.
isBackHandlingEnabled: Boolean = true,
// HealthChat fork: content of the member directory tab, rendered by features/healthchat.
memberDirectoryView: @Composable (PaddingValues, LazyListState) -> Unit = { _, _ -> },
leaveRoomView: @Composable () -> Unit,
) {
val state: RoomListState = homeState.roomListState
@@ -133,6 +139,8 @@ fun HomeView(
onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() },
onMenuActionClick = onMenuActionClick,
onSwitchToAccount = onSwitchToAccount,
memberDirectoryView = memberDirectoryView,
isBackHandlingEnabled = isBackHandlingEnabled,
)
if (state.globalSearchState.isEnabled) {
@@ -171,7 +179,9 @@ private fun HomeScaffold(
onCreateSpaceClick: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
onSwitchToAccount: (SessionId) -> Unit,
isBackHandlingEnabled: Boolean,
modifier: Modifier = Modifier,
memberDirectoryView: @Composable (PaddingValues, LazyListState) -> Unit,
) {
fun onRoomClick(room: RoomListRoomSummary) {
onRoomClick(room.roomId)
@@ -182,7 +192,8 @@ private fun HomeScaffold(
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
val roomListState: RoomListState = state.roomListState
BackHandler(enabled = state.isBackHandlerEnabled) {
// HealthChat fork: also gated on isBackHandlingEnabled (see HomeView docs).
BackHandler(enabled = state.isBackHandlerEnabled && isBackHandlingEnabled) {
if (state.currentHomeNavigationBarItem != HomeNavigationBarItem.Chats) {
state.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats))
} else {
@@ -195,6 +206,7 @@ private fun HomeScaffold(
val roomsLazyListState = rememberLazyListState()
val spacesLazyListState = rememberLazyListState()
val membersLazyListState = rememberLazyListState()
Scaffold(
modifier = modifier,
@@ -242,6 +254,7 @@ private fun HomeScaffold(
val lazyListStateTarget = when (item) {
HomeNavigationBarItem.Chats -> roomsLazyListState
HomeNavigationBarItem.Spaces -> spacesLazyListState
HomeNavigationBarItem.Members -> membersLazyListState
}
coroutineScope.launch {
if (lazyListStateTarget.firstVisibleItemIndex > 10) {
@@ -255,15 +268,15 @@ private fun HomeScaffold(
state.eventSink(HomeEvent.SelectHomeNavigationBarItem(item))
}
},
floatingActionButton = {
when (state.currentHomeNavigationBarItem) {
HomeNavigationBarItem.Chats -> {
HomeFloatingActionButton(onStartChatClick, CommonStrings.action_create_room)
}
HomeNavigationBarItem.Spaces -> {
HomeFloatingActionButton(onCreateSpaceClick, CommonStrings.action_create_space)
}
floatingActionButton = when (state.currentHomeNavigationBarItem) {
HomeNavigationBarItem.Chats -> {
{ HomeFloatingActionButton(onStartChatClick, CommonStrings.action_create_room) }
}
HomeNavigationBarItem.Spaces -> {
{ HomeFloatingActionButton(onCreateSpaceClick, CommonStrings.action_create_space) }
}
// HealthChat fork: the member directory has no create action.
HomeNavigationBarItem.Members -> null
},
totalUnreadCount = state.totalUnreadCount,
)
@@ -325,6 +338,17 @@ private fun HomeScaffold(
onExploreClick = {},
)
}
// HealthChat fork: member directory tab content.
HomeNavigationBarItem.Members -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(outerPadding)
.consumeWindowInsets(outerPadding),
) {
memberDirectoryView(lazyColumnContentPadding + contentPadding, membersLazyListState)
}
}
}
}
},
@@ -133,6 +133,8 @@ fun HomeTopBar(
}
}
HomeNavigationBarItem.Spaces -> null
// HealthChat fork: member directory tab title.
HomeNavigationBarItem.Members -> stringResource(selectedNavigationItem.labelRes)
}
displayTitle?.let {
val style = when (spaceFiltersState) {
@@ -15,6 +15,7 @@ import io.element.android.features.invite.test.declineandblock.FakeDeclineInvite
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.services.analytics.test.FakeAnalyticsService
@@ -41,6 +42,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
declineInviteAndBlockUserEntryPoint = FakeDeclineInviteAndBlockEntryPoint(),
changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() },
leaveRoomRenderer = { _, _, _ -> lambdaError() },
memberDirectoryRenderer = { _, _, _, _, _, _, _, _, _ -> lambdaError() },
sessionCoroutineScope = backgroundScope,
)
}
@@ -55,6 +57,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
override fun navigateToBugReport() = lambdaError()
override fun navigateToAddAccount() = lambdaError()
override fun navigateToSession(sessionId: SessionId) = lambdaError()
override fun navigateToUserProfile(userId: UserId) = lambdaError()
}
val result = entryPoint.createNode(
parentNode = parentNode,
@@ -14,6 +14,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.announcement.test.FakeAnnouncementService
import io.element.android.features.healthchat.api.directory.aMemberDirectoryState
import io.element.android.features.home.impl.roomlist.aRoomListState
import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.features.home.impl.spaces.aHomeSpacesState
@@ -322,6 +323,21 @@ class HomePresenterTest {
assertThat(awaitItem().totalUnreadCount).isEqualTo(5)
}
}
@Test
fun `present - NavigationBar change to Members`() = runTest {
val presenter = createHomePresenter(
sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _, _ -> },
),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Members))
val finalState = awaitItem()
assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Members)
}
}
}
internal fun TestScope.createHomePresenter(
@@ -342,6 +358,7 @@ internal fun TestScope.createHomePresenter(
indicatorService = indicatorService,
roomListPresenter = { aRoomListState() },
homeSpacesPresenter = homeSpacesPresenter,
memberDirectoryPresenter = { aMemberDirectoryState() },
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
sessionStore = sessionStore,
announcementService = announcementService,
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.home.impl
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
class HomeViewBackHandlingTest : RobolectricTest() {
@Test
fun `back press on the members tab switches to the chats tab`() = runAndroidComposeUiTest<ComponentActivity> {
val eventsRecorder = EventsRecorder<HomeEvent>()
setContent {
HomeView(
homeState = aHomeState(
currentHomeNavigationBarItem = HomeNavigationBarItem.Members,
eventSink = eventsRecorder,
),
onRoomClick = {},
onSettingsClick = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
onCreateSpaceClick = {},
onRoomSettingsClick = {},
onMenuActionClick = {},
onReportRoomClick = {},
onDeclineInviteAndBlockUser = {},
acceptDeclineInviteView = {},
leaveRoomView = {},
)
}
pressBackKey()
eventsRecorder.assertSingle(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats))
}
@Test
fun `back press is not intercepted while home is not the visible nav target`() = runAndroidComposeUiTest<ComponentActivity> {
// Regression test: while another screen (e.g. a member profile) is being pushed on
// top of Home, the tab BackHandler must not swallow the back press by invisibly
// switching tabs — the back press has to reach the parent backstack pop instead.
val eventsRecorder = EventsRecorder<HomeEvent>(expectEvents = false)
setContent {
HomeView(
homeState = aHomeState(
currentHomeNavigationBarItem = HomeNavigationBarItem.Members,
eventSink = eventsRecorder,
),
onRoomClick = {},
onSettingsClick = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
onCreateSpaceClick = {},
onRoomSettingsClick = {},
onMenuActionClick = {},
onReportRoomClick = {},
onDeclineInviteAndBlockUser = {},
acceptDeclineInviteView = {},
leaveRoomView = {},
isBackHandlingEnabled = false,
)
}
pressBackKey()
}
}