Room select: update empty search UI and behavior.

This commit is contained in:
Benoit Marty
2026-10-08 17:29:44 +02:00
parent 59bc387c7d
commit 0c6da39acb
4 changed files with 90 additions and 15 deletions
@@ -8,7 +8,6 @@
package io.element.android.libraries.roomselect.impl package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -154,9 +153,8 @@ class RoomSelectPresenter(
RoomSelectEvent.ToggleAccountListExpanded -> isAccountListExpanded = !isAccountListExpanded RoomSelectEvent.ToggleAccountListExpanded -> isAccountListExpanded = !isAccountListExpanded
is RoomSelectEvent.SelectAccount -> { is RoomSelectEvent.SelectAccount -> {
selectedSessionId = event.sessionId selectedSessionId = event.sessionId
// The selected rooms and the search query are specific to the session // The selected rooms are specific to the session. The search query is kept, and applied to the rooms of the new session.
selectedRooms = persistentListOf() selectedRooms = persistentListOf()
queryState.clearText()
isAccountListExpanded = false isAccountListExpanded = false
} }
is RoomSelectEvent.UpdateVisibleRange -> coroutineScope.launch { is RoomSelectEvent.UpdateVisibleRange -> coroutineScope.launch {
@@ -10,12 +10,14 @@ package io.element.android.libraries.roomselect.impl
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -24,18 +26,23 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -46,6 +53,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
@@ -136,13 +144,27 @@ fun RoomSelectView(
onBack = ::onBackButton, onBack = ::onBackButton,
) )
// Use a new list state when the selected account changes, so that the list of rooms is scrolled to the top val searchQuery = state.searchQuery.text.toString()
val lazyListState = key(state.selectedAccount.userId) { rememberLazyListState() } // Use a new list state when the selected account or the search query changes, so that the list of rooms is scrolled to the top
val lazyListState = key(state.selectedAccount.userId, searchQuery) { rememberLazyListState() }
// Hide the account switch section when the room list is scrolled up, and show it again as soon as it is scrolled down. // Hide the account switch section when the room list is scrolled up, and show it again as soon as it is scrolled down.
// Use a new state when the selected account changes, so that the section is fully displayed again. // Use a new state when the selected account changes, so that the section is fully displayed again.
val accountSwitchScrollBehavior = key(state.selectedAccount.userId) { val accountSwitchScrollBehavior = key(state.selectedAccount.userId) {
TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
} }
// Collapse the account switch section when the user starts searching. It can be displayed again by scrolling down.
val isSearching = searchQuery.isNotEmpty()
LaunchedEffect(isSearching) {
if (isSearching) {
val topAppBarState = accountSwitchScrollBehavior.state
animate(
initialValue = topAppBarState.heightOffset,
targetValue = topAppBarState.heightOffsetLimit,
) { value, _ ->
topAppBarState.heightOffset = value
}
}
}
OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(RoomSelectEvent.UpdateVisibleRange(visibleRange)) state.eventSink(RoomSelectEvent.UpdateVisibleRange(visibleRange))
} }
@@ -217,7 +239,7 @@ fun RoomSelectView(
) )
when { when {
state.hasRoomListError -> RoomListError() state.hasRoomListError -> RoomListError()
state.resultState is SearchBarResultState.NoResultsFound -> NoResultsFound() state.resultState is SearchBarResultState.NoResultsFound -> NoResultsView(searchQuery = searchQuery)
state.resultState is SearchBarResultState.Results -> { state.resultState is SearchBarResultState.Results -> {
LazyColumn( LazyColumn(
state = lazyListState, state = lazyListState,
@@ -225,8 +247,13 @@ fun RoomSelectView(
) { ) {
if (state.resultState.results.isNotEmpty()) { if (state.resultState.results.isNotEmpty()) {
item { item {
val headerText = if (searchQuery.isEmpty()) {
stringResource(CommonStrings.common_header_rooms)
} else {
stringResource(CommonStrings.common_results_for, searchQuery)
}
ListSectionHeader( ListSectionHeader(
title = stringResource(CommonStrings.common_header_rooms), title = headerText,
hasDivider = false, hasDivider = false,
) )
} }
@@ -250,15 +277,36 @@ fun RoomSelectView(
} }
@Composable @Composable
private fun NoResultsFound() { private fun NoResultsView(
searchQuery: String,
) {
Column(
modifier = Modifier
.fillMaxSize()
// Scrollable, so that the account switch section can be displayed again by scrolling down
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = spacedBy(8.dp),
) {
Text( Text(
text = stringResource(CommonStrings.common_no_results), text = stringResource(CommonStrings.common_no_results),
textAlign = TextAlign.Center,
color = ElementTheme.colors.textSecondary,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .semantics {
.padding(horizontal = 16.dp, vertical = 80.dp), heading()
},
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
) )
Text(
text = stringResource(CommonStrings.common_no_results_for, searchQuery),
modifier = Modifier.widthIn(max = 300.dp),
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
} }
@Composable @Composable
@@ -263,6 +263,33 @@ class RoomSelectPresenterTest {
} }
} }
@Test
fun `present - select another account keeps the search query and applies it to the rooms of this account`() = runTest {
val roomList2 = FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary(roomId = A_ROOM_ID_2))))
val roomListServices = mapOf(
A_SESSION_ID to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary()))) }),
A_SESSION_ID_2 to FakeRoomListService(createRoomListLambda = { roomList2 }),
)
val presenter = createRoomSelectPresenter(
mode = RoomSelectMode.Share,
sessionStore = aSessionStoreWithTwoAccounts(),
matrixClientProvider = FakeMatrixClientProvider { sessionId ->
Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListServices.getValue(sessionId)))
},
)
presenter.test {
val state = awaitLastSequentialItem()
state.searchQuery.setTextAndPlaceCursorAtEnd("query")
state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2))
val finalState = consumeItemsUntilPredicate {
it.selectedAccount.userId == A_SESSION_ID_2 && it.resultState is SearchBarResultState.Results
}.last()
assertThat(finalState.searchQuery.text.toString()).isEqualTo("query")
assertThat(finalState.showAccountSwitch).isTrue()
assertThat(roomList2.currentFilter.value).isEqualTo(RoomListFilter.NormalizedMatchRoomName("query"))
}
}
@Test @Test
fun `present - forward mode with several accounts does not show the account switch`() = runTest { fun `present - forward mode with several accounts does not show the account switch`() = runTest {
val presenter = createRoomSelectPresenter( val presenter = createRoomSelectPresenter(
@@ -335,6 +335,7 @@ Reason: %1$s."</string>
<string name="common_mute">"Mute"</string> <string name="common_mute">"Mute"</string>
<string name="common_name">"Name"</string> <string name="common_name">"Name"</string>
<string name="common_name_and_id">"%1$s (%2$s)"</string> <string name="common_name_and_id">"%1$s (%2$s)"</string>
<string name="common_no_results_for">"There are no results for “%1$s”. Try a new search term."</string>
<string name="common_no_results">"No results"</string> <string name="common_no_results">"No results"</string>
<string name="common_no_room_name">"No room name"</string> <string name="common_no_room_name">"No room name"</string>
<string name="common_no_space_name">"No space name"</string> <string name="common_no_space_name">"No space name"</string>
@@ -381,6 +382,7 @@ Reason: %1$s."</string>
<string name="common_replying_to">"Replying to %1$s"</string> <string name="common_replying_to">"Replying to %1$s"</string>
<string name="common_report_a_problem">"Report a problem"</string> <string name="common_report_a_problem">"Report a problem"</string>
<string name="common_report_submitted">"Report submitted"</string> <string name="common_report_submitted">"Report submitted"</string>
<string name="common_results_for">"Results for “%1$s”"</string>
<string name="common_rich_text_editor">"Rich text editor"</string> <string name="common_rich_text_editor">"Rich text editor"</string>
<string name="common_role">"Role"</string> <string name="common_role">"Role"</string>
<string name="common_room">"Room"</string> <string name="common_room">"Room"</string>