Merge pull request #7883 from element-hq/feature/bma/roomSelectSearchField

Use a search field to filter the rooms in the room select screen
This commit is contained in:
Benoit Marty
2026-10-09 15:09:50 +02:00
committed by GitHub
33 changed files with 322 additions and 163 deletions
@@ -57,7 +57,7 @@ fun RoomListInterface.loadingStateFlow(): Flow<RoomListLoadingState> =
internal fun RoomListInterface.entriesFlow( internal fun RoomListInterface.entriesFlow(
pageSize: Int, pageSize: Int,
initialFilterKind: RoomListEntriesDynamicFilterKind, initialFilterKind: RoomListEntriesDynamicFilterKind,
onControllerCreated: (RoomListDynamicEntriesController) -> Unit, onControllerCreated: suspend (RoomListDynamicEntriesController) -> Unit,
): Flow<List<RoomListEntriesUpdate>> = ): Flow<List<RoomListEntriesUpdate>> =
callbackFlow { callbackFlow {
val listener = object : RoomListEntriesListener { val listener = object : RoomListEntriesListener {
@@ -70,9 +70,13 @@ internal fun RoomListInterface.entriesFlow(
listener = listener, listener = listener,
) )
val controller = result.controller() val controller = result.controller()
controller.setFilter(initialFilterKind) // Use try/finally rather than awaitClose's block, so that the resources are released even if
onControllerCreated(controller) // onControllerCreated is cancelled or throws before awaitClose is reached
awaitClose { try {
controller.setFilter(initialFilterKind)
onControllerCreated(controller)
awaitClose()
} finally {
result.entriesStream().cancelAndDestroy() result.entriesStream().cancelAndDestroy()
controller.destroy() controller.destroy()
result.destroy() result.destroy()
@@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
import org.matrix.rustcomponents.sdk.RoomListLoadingState import org.matrix.rustcomponents.sdk.RoomListLoadingState
import org.matrix.rustcomponents.sdk.RoomListService import org.matrix.rustcomponents.sdk.RoomListService
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
@@ -49,7 +48,13 @@ internal class RoomListFactory(
val summariesFlow = MutableSharedFlow<List<RoomSummary>>(replay = 1, extraBufferCapacity = 1) val summariesFlow = MutableSharedFlow<List<RoomSummary>>(replay = 1, extraBufferCapacity = 1)
val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryFactory, analyticsService) val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryFactory, analyticsService)
var innerRoomList: InnerRoomList? = null var innerRoomList: InnerRoomList? = null
var dynamicController: RoomListDynamicEntriesController? = null val dynamicRoomList = RustDynamicRoomList(
summaries = summariesFlow,
loadingState = loadingStateFlow,
processor = processor,
pageSize = pageSize,
initialFilter = initialFilter,
)
val firstRoomsTransaction = analyticsService.startTransaction("Load first set of rooms", "innerRoomList.entriesFlow") val firstRoomsTransaction = analyticsService.startTransaction("Load first set of rooms", "innerRoomList.entriesFlow")
@@ -60,7 +65,7 @@ internal class RoomListFactory(
pageSize = pageSize, pageSize = pageSize,
initialFilterKind = RoomListFilterMapper.toRustFilter(initialFilter), initialFilterKind = RoomListFilterMapper.toRustFilter(initialFilter),
onControllerCreated = { controller -> onControllerCreated = { controller ->
dynamicController = controller dynamicRoomList.onControllerCreated(controller, initialFilter)
} }
).onEach { update -> ).onEach { update ->
if (!firstRoomsTransaction.isFinished()) { if (!firstRoomsTransaction.isFinished()) {
@@ -80,13 +85,7 @@ internal class RoomListFactory(
}.invokeOnCompletion { }.invokeOnCompletion {
innerRoomList?.destroy() innerRoomList?.destroy()
} }
return RustDynamicRoomList( return dynamicRoomList
summaries = summariesFlow,
loadingState = loadingStateFlow,
processor = processor,
pageSize = pageSize,
dynamicController = { dynamicController }
)
} }
} }
@@ -24,37 +24,58 @@ internal class RustDynamicRoomList(
override val loadingState: MutableStateFlow<RoomList.LoadingState>, override val loadingState: MutableStateFlow<RoomList.LoadingState>,
private val processor: RoomSummaryListProcessor, private val processor: RoomSummaryListProcessor,
override val pageSize: Int, override val pageSize: Int,
private val dynamicController: () -> RoomListDynamicEntriesController?, initialFilter: RoomListFilter,
private val addPagesCount: Int = DEFAULT_ADD_PAGES_COUNT private val addPagesCount: Int = DEFAULT_ADD_PAGES_COUNT
) : DynamicRoomList { ) : DynamicRoomList {
private val mutex = Mutex() private val mutex = Mutex()
// The controller is created asynchronously, once the entries of the room list are observed
private var dynamicController: RoomListDynamicEntriesController? = null
// The last requested filter, so that it can be applied when the controller is created
private var currentFilter: RoomListFilter = initialFilter
/**
* To be called when the controller is created. The controller already has the initial filter set, so apply the filter
* only if it has been updated in the meantime.
*/
suspend fun onControllerCreated(controller: RoomListDynamicEntriesController, initialFilter: RoomListFilter) {
mutex.withLock {
dynamicController = controller
if (currentFilter != initialFilter) {
controller.applyFilter(currentFilter)
}
}
}
override suspend fun rebuildSummaries() { override suspend fun rebuildSummaries() {
processor.rebuildRoomSummaries() processor.rebuildRoomSummaries()
} }
override suspend fun updateFilter(filter: RoomListFilter) { override suspend fun updateFilter(filter: RoomListFilter) {
mutex.withLock { mutex.withLock {
dynamicController()?.let { controller -> currentFilter = filter
// Reset pagination when filter changes dynamicController?.applyFilter(filter)
controller.resetToOnePage()
val rustFilter = RoomListFilterMapper.toRustFilter(filter)
controller.setFilter(rustFilter)
// Then preload some pages
controller.addPages(addPagesCount)
}
} }
} }
private fun RoomListDynamicEntriesController.applyFilter(filter: RoomListFilter) {
// Reset pagination when filter changes
resetToOnePage()
setFilter(RoomListFilterMapper.toRustFilter(filter))
// Then preload some pages
addPages(addPagesCount)
}
override suspend fun loadMore() { override suspend fun loadMore() {
mutex.withLock { mutex.withLock {
dynamicController()?.addPages(addPagesCount) dynamicController?.addPages(addPagesCount)
} }
} }
override suspend fun reset() { override suspend fun reset() {
mutex.withLock { mutex.withLock {
dynamicController()?.resetToOnePage() dynamicController?.resetToOnePage()
} }
} }
@@ -0,0 +1,25 @@
/*
* 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.libraries.matrix.impl.fixtures.fakes
import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
class FakeFfiRoomListDynamicEntriesController : RoomListDynamicEntriesController(NoHandle) {
val filters = mutableListOf<RoomListEntriesDynamicFilterKind>()
override fun setFilter(kind: RoomListEntriesDynamicFilterKind): Boolean {
filters.add(kind)
return true
}
override fun resetToOnePage() = Unit
override fun addOnePage() = Unit
}
@@ -0,0 +1,67 @@
/*
* 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.libraries.matrix.impl.roomlist
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListDynamicEntriesController
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListService
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RustDynamicRoomListTest {
@Test
fun `updateFilter before the controller is created applies the filter when it is created`() = runTest {
val sut = createRustDynamicRoomList()
sut.updateFilter(RoomListFilter.NormalizedMatchRoomName("query"))
val controller = FakeFfiRoomListDynamicEntriesController()
sut.onControllerCreated(controller, initialFilter = RoomListFilter.all())
assertThat(controller.filters).containsExactly(RoomListFilterMapper.toRustFilter(RoomListFilter.NormalizedMatchRoomName("query")))
}
@Test
fun `when the filter is not updated, the controller filter is not changed when it is created`() = runTest {
val sut = createRustDynamicRoomList()
val controller = FakeFfiRoomListDynamicEntriesController()
sut.onControllerCreated(controller, initialFilter = RoomListFilter.all())
assertThat(controller.filters).isEmpty()
}
@Test
fun `updateFilter after the controller is created applies the filter`() = runTest {
val sut = createRustDynamicRoomList()
val controller = FakeFfiRoomListDynamicEntriesController()
sut.onControllerCreated(controller, initialFilter = RoomListFilter.all())
sut.updateFilter(RoomListFilter.NormalizedMatchRoomName("query"))
assertThat(controller.filters).containsExactly(RoomListFilterMapper.toRustFilter(RoomListFilter.NormalizedMatchRoomName("query")))
}
private fun TestScope.createRustDynamicRoomList(): RustDynamicRoomList {
val summaries = MutableSharedFlow<List<RoomSummary>>(replay = 1)
return RustDynamicRoomList(
summaries = summaries,
loadingState = MutableStateFlow(RoomList.LoadingState.NotLoaded),
processor = RoomSummaryListProcessor(
summaries,
FakeFfiRoomListService(),
coroutineContext = StandardTestDispatcher(testScheduler),
roomSummaryFactory = RoomSummaryFactory(),
analyticsService = FakeAnalyticsService(),
),
pageSize = 10,
initialFilter = RoomListFilter.all(),
)
}
}
@@ -13,7 +13,6 @@ import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
sealed interface RoomSelectEvent { sealed interface RoomSelectEvent {
data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent
data object ToggleSearchActive : RoomSelectEvent
data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent
data object ToggleAccountListExpanded : RoomSelectEvent data object ToggleAccountListExpanded : RoomSelectEvent
data class SelectAccount(val sessionId: SessionId) : RoomSelectEvent data class SelectAccount(val sessionId: SessionId) : RoomSelectEvent
@@ -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
@@ -63,7 +62,6 @@ class RoomSelectPresenter(
override fun present(): RoomSelectState { override fun present(): RoomSelectState {
var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) } var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) }
val queryState = rememberTextFieldState() val queryState = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) }
var isAccountListExpanded by remember { mutableStateOf(false) } var isAccountListExpanded by remember { mutableStateOf(false) }
var selectedSessionId by remember { mutableStateOf(initialSessionId) } var selectedSessionId by remember { mutableStateOf(initialSessionId) }
val sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = emptyList()) val sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = emptyList())
@@ -136,7 +134,7 @@ class RoomSelectPresenter(
derivedStateOf { derivedStateOf {
when { when {
roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList()) roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList())
isSearchActive -> SearchBarResultState.NoResultsFound queryState.text.isNotBlank() -> SearchBarResultState.NoResultsFound
else -> SearchBarResultState.Initial else -> SearchBarResultState.Initial
} }
} }
@@ -152,13 +150,11 @@ class RoomSelectPresenter(
selectedRooms.adding(event.room) selectedRooms.adding(event.room)
} }
} }
RoomSelectEvent.ToggleSearchActive -> isSearchActive = !isSearchActive
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 {
@@ -172,7 +168,6 @@ class RoomSelectPresenter(
maxNumberOfRooms = maxNumberOfRooms, maxNumberOfRooms = maxNumberOfRooms,
resultState = searchResults, resultState = searchResults,
searchQuery = queryState, searchQuery = queryState,
isSearchActive = isSearchActive,
selectedRooms = selectedRooms, selectedRooms = selectedRooms,
selectedAccount = selectedAccount, selectedAccount = selectedAccount,
hasRoomListError = hasRoomListError, hasRoomListError = hasRoomListError,
@@ -20,7 +20,6 @@ data class RoomSelectState(
val maxNumberOfRooms: Int, val maxNumberOfRooms: Int,
val resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>>, val resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>>,
val searchQuery: TextFieldState, val searchQuery: TextFieldState,
val isSearchActive: Boolean,
val selectedRooms: ImmutableList<SelectRoomInfo>, val selectedRooms: ImmutableList<SelectRoomInfo>,
val selectedAccount: MatrixUser, val selectedAccount: MatrixUser,
/** /**
@@ -26,17 +26,15 @@ open class RoomSelectStatePreviewParam : PreviewParameterProvider<RoomSelectStat
override val values: Sequence<RoomSelectState> override val values: Sequence<RoomSelectState>
get() = sequenceOf( get() = sequenceOf(
aRoomSelectState(), aRoomSelectState(),
aRoomSelectState(searchQuery = "Test", isSearchActive = true), aRoomSelectState(searchQuery = "Test", resultState = SearchBarResultState.NoResultsFound),
aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())), aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())),
aRoomSelectState( aRoomSelectState(
resultState = SearchBarResultState.Results(aRoomSelectRoomList()), resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
searchQuery = "Test", searchQuery = "Test",
isSearchActive = true,
), ),
aRoomSelectState( aRoomSelectState(
resultState = SearchBarResultState.Results(aRoomSelectRoomList()), resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
searchQuery = "Test", searchQuery = "Test",
isSearchActive = true,
selectedRooms = aRoomSelectRoomList().subList(0, 1), selectedRooms = aRoomSelectRoomList().subList(0, 1),
), ),
aRoomSelectState( aRoomSelectState(
@@ -76,7 +74,6 @@ internal fun aRoomSelectState(
maxNumberOfRooms: Int = 10, maxNumberOfRooms: Int = 10,
resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial, resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial,
searchQuery: String = "", searchQuery: String = "",
isSearchActive: Boolean = false,
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(), selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")), selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")),
hasRoomListError: Boolean = false, hasRoomListError: Boolean = false,
@@ -88,7 +85,6 @@ internal fun aRoomSelectState(
maxNumberOfRooms = maxNumberOfRooms, maxNumberOfRooms = maxNumberOfRooms,
resultState = resultState, resultState = resultState,
searchQuery = TextFieldState(initialText = searchQuery), searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive,
selectedRooms = selectedRooms, selectedRooms = selectedRooms,
selectedAccount = selectedAccount, selectedAccount = selectedAccount,
hasRoomListError = hasRoomListError, hasRoomListError = hasRoomListError,
@@ -10,6 +10,7 @@ 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
@@ -24,18 +25,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,9 +52,11 @@ 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
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -70,8 +78,8 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.SearchField
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
@@ -123,10 +131,8 @@ fun RoomSelectView(
} }
var canHandleBack by remember { mutableStateOf(true) } var canHandleBack by remember { mutableStateOf(true) }
fun onBackButton(state: RoomSelectState) { fun onBackButton() {
if (state.isSearchActive) { if (canHandleBack) {
state.eventSink(RoomSelectEvent.ToggleSearchActive)
} else if (canHandleBack) {
canHandleBack = false canHandleBack = false
onDismiss() onDismiss()
} }
@@ -134,16 +140,34 @@ fun RoomSelectView(
BackHandler( BackHandler(
enabled = canHandleBack, enabled = canHandleBack,
onBack = { onBackButton(state) } onBack = ::onBackButton,
) )
val searchQuery = state.searchQuery.text.toString()
// Use a new list state when the selected account changes, so that the list of rooms is scrolled to the top // Use a new list state when the selected account changes, so that the list of rooms is scrolled to the top
val lazyListState = key(state.selectedAccount.userId) { rememberLazyListState() } val lazyListState = key(state.selectedAccount.userId) { rememberLazyListState() }
// Scroll the list of rooms to the top when the search query changes
LaunchedEffect(lazyListState, searchQuery) {
lazyListState.scrollToItem(0)
}
// 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.isNotBlank()
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))
} }
@@ -159,7 +183,7 @@ fun RoomSelectView(
navigationIcon = { navigationIcon = {
BackButton( BackButton(
enabled = canHandleBack, enabled = canHandleBack,
onClick = { onBackButton(state) } onClick = ::onBackButton,
) )
}, },
actions = { actions = {
@@ -179,89 +203,60 @@ fun RoomSelectView(
.consumeWindowInsets(paddingValues) .consumeWindowInsets(paddingValues)
.then( .then(
// Only when the account switch section is displayed, else its unknown height would consume all the scroll events // Only when the account switch section is displayed, else its unknown height would consume all the scroll events
if (state.showAccountSwitch && !state.isSearchActive) { if (state.showAccountSwitch) {
Modifier.nestedScroll(accountSwitchScrollBehavior.nestedScrollConnection) Modifier.nestedScroll(accountSwitchScrollBehavior.nestedScrollConnection)
} else { } else {
Modifier Modifier
} }
) )
) { ) {
SearchBar( SearchField(
state = state.searchQuery,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding( .padding(
WindowInsets.safeDrawing WindowInsets.safeDrawing
.only(WindowInsetsSides.Horizontal) .only(WindowInsetsSides.Horizontal)
.asPaddingValues() .asPaddingValues()
), )
placeHolderTitle = stringResource(CommonStrings.action_search), .padding(horizontal = 16.dp, vertical = 8.dp),
queryState = state.searchQuery, placeholder = stringResource(CommonStrings.action_search),
active = state.isSearchActive, )
onActiveChange = { state.eventSink(RoomSelectEvent.ToggleSearchActive) }, if (state.showAccountSwitch) {
resultState = state.resultState, // Keep this space outside the collapsing section, so that the section does not touch the search field when it is collapsing
showBackButton = false, Spacer(modifier = Modifier.height(8.dp))
) { summaries -> TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) {
LazyColumn( AccountSwitchSection(
state = lazyListState, selectedAccount = state.selectedAccount,
contentPadding = lazyColumnContentPadding, otherAccounts = state.otherAccounts,
) { isExpanded = state.isAccountListExpanded,
if (summaries.isNotEmpty()) { onToggleExpand = { state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) },
item { onSelectAccount = { state.eventSink(RoomSelectEvent.SelectAccount(it)) },
ListSectionHeader( )
title = stringResource(CommonStrings.common_header_rooms),
hasDivider = false,
)
}
}
item {
SelectedRoomsHelper(
selectedRooms = state.selectedRooms,
showVerticalSpace = false,
)
}
items(summaries, key = { it.roomId.value }) { roomSummary ->
RoomSummaryView(
roomSummary,
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
onSelection = { roomSummary ->
state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary))
},
canBeSelected = state.canSelectMoreRooms,
)
}
} }
} }
SelectedRoomsHelper(
if (!state.isSearchActive) { selectedRooms = state.selectedRooms,
if (state.showAccountSwitch) { // showVerticalSpace only if there is no other accounts
// Keep this space outside the collapsing section, so that the section does not touch the search bar when it is collapsing showVerticalSpace = !state.showAccountSwitch,
Spacer(modifier = Modifier.height(8.dp)) )
TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) { when {
AccountSwitchSection( state.hasRoomListError -> RoomListError()
selectedAccount = state.selectedAccount, state.resultState is SearchBarResultState.NoResultsFound -> NoResultsView(searchQuery = searchQuery)
otherAccounts = state.otherAccounts, state.resultState is SearchBarResultState.Results -> {
isExpanded = state.isAccountListExpanded,
onToggleExpand = { state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) },
onSelectAccount = { state.eventSink(RoomSelectEvent.SelectAccount(it)) },
)
}
}
SelectedRoomsHelper(
selectedRooms = state.selectedRooms,
// showVerticalSpace only if there is no other accounts
showVerticalSpace = !state.showAccountSwitch,
)
if (state.hasRoomListError) {
RoomListError()
} else if (state.resultState is SearchBarResultState.Results) {
LazyColumn( LazyColumn(
state = lazyListState, state = lazyListState,
contentPadding = lazyColumnContentPadding, contentPadding = lazyColumnContentPadding,
) { ) {
if (state.resultState.results.isNotEmpty()) { if (state.resultState.results.isNotEmpty()) {
item { item {
val headerText = if (searchQuery.isBlank()) {
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,
) )
} }
@@ -278,11 +273,45 @@ fun RoomSelectView(
} }
} }
} }
else -> Unit
} }
} }
} }
} }
@Composable
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 = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(CommonStrings.common_no_results),
modifier = Modifier
.semantics {
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
private fun RoomListError() { private fun RoomListError() {
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
@@ -59,7 +59,6 @@ class RoomSelectPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.selectedRooms).isEmpty() assertThat(initialState.selectedRooms).isEmpty()
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java) assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.maxNumberOfRooms).isEqualTo(10) assertThat(initialState.maxNumberOfRooms).isEqualTo(10)
assertThat(initialState.canSelectMoreRooms).isTrue() assertThat(initialState.canSelectMoreRooms).isTrue()
assertThat(initialState.selectedAccount).isEqualTo(MatrixUser(A_SESSION_ID)) assertThat(initialState.selectedAccount).isEqualTo(MatrixUser(A_SESSION_ID))
@@ -264,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(
@@ -284,18 +310,6 @@ class RoomSelectPresenterTest {
), ),
) )
@Test
fun `present - toggle search active`() = runTest {
val presenter = createRoomSelectPresenter()
presenter.test {
val initialState = awaitItem()
initialState.eventSink(RoomSelectEvent.ToggleSearchActive)
assertThat(awaitItem().isSearchActive).isTrue()
initialState.eventSink(RoomSelectEvent.ToggleSearchActive)
assertThat(awaitItem().isSearchActive).isFalse()
}
}
@Test @Test
fun `present - update query`() = runTest { fun `present - update query`() = runTest {
val roomSummary = aRoomSummary() val roomSummary = aRoomSummary()
@@ -314,8 +328,6 @@ class RoomSelectPresenterTest {
// Do not compare the lambda because they will be different. So copy the lambda from expectedRoomSummary to result // Do not compare the lambda because they will be different. So copy the lambda from expectedRoomSummary to result
val result = (awaitItem().resultState as SearchBarResultState.Results).results val result = (awaitItem().resultState as SearchBarResultState.Results).results
assertThat(result).isEqualTo(listOf(expectedRoomInfo)) assertThat(result).isEqualTo(listOf(expectedRoomInfo))
initialState.eventSink(RoomSelectEvent.ToggleSearchActive)
skipItems(1)
initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained") initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained")
assertThat( assertThat(
roomList.currentFilter.value roomList.currentFilter.value
@@ -155,6 +155,17 @@ class RoomSelectViewTest : RobolectricTest() {
onNodeWithText("Room 0").assertIsNotDisplayed() onNodeWithText("Room 0").assertIsNotDisplayed()
} }
@Test
fun `a message is displayed when no room matches the search query`() = runAndroidComposeUiTest {
setRoomSelectView(
aRoomSelectState(
searchQuery = "Test",
resultState = SearchBarResultState.NoResultsFound,
),
)
onNodeWithText(activity!!.getString(CommonStrings.common_no_results)).assertIsDisplayed()
}
@Test @Test
fun `an error is displayed instead of the rooms if they cannot be loaded`() = runAndroidComposeUiTest<ComponentActivity> { fun `an error is displayed instead of the rooms if they cannot be loaded`() = runAndroidComposeUiTest<ComponentActivity> {
setRoomSelectView( setRoomSelectView(
@@ -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>