mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 08:15:02 +00:00
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:
+8
-4
@@ -57,7 +57,7 @@ fun RoomListInterface.loadingStateFlow(): Flow<RoomListLoadingState> =
|
||||
internal fun RoomListInterface.entriesFlow(
|
||||
pageSize: Int,
|
||||
initialFilterKind: RoomListEntriesDynamicFilterKind,
|
||||
onControllerCreated: (RoomListDynamicEntriesController) -> Unit,
|
||||
onControllerCreated: suspend (RoomListDynamicEntriesController) -> Unit,
|
||||
): Flow<List<RoomListEntriesUpdate>> =
|
||||
callbackFlow {
|
||||
val listener = object : RoomListEntriesListener {
|
||||
@@ -70,9 +70,13 @@ internal fun RoomListInterface.entriesFlow(
|
||||
listener = listener,
|
||||
)
|
||||
val controller = result.controller()
|
||||
controller.setFilter(initialFilterKind)
|
||||
onControllerCreated(controller)
|
||||
awaitClose {
|
||||
// Use try/finally rather than awaitClose's block, so that the resources are released even if
|
||||
// onControllerCreated is cancelled or throws before awaitClose is reached
|
||||
try {
|
||||
controller.setFilter(initialFilterKind)
|
||||
onControllerCreated(controller)
|
||||
awaitClose()
|
||||
} finally {
|
||||
result.entriesStream().cancelAndDestroy()
|
||||
controller.destroy()
|
||||
result.destroy()
|
||||
|
||||
+9
-10
@@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
|
||||
import org.matrix.rustcomponents.sdk.RoomListLoadingState
|
||||
import org.matrix.rustcomponents.sdk.RoomListService
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
@@ -49,7 +48,13 @@ internal class RoomListFactory(
|
||||
val summariesFlow = MutableSharedFlow<List<RoomSummary>>(replay = 1, extraBufferCapacity = 1)
|
||||
val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryFactory, analyticsService)
|
||||
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")
|
||||
|
||||
@@ -60,7 +65,7 @@ internal class RoomListFactory(
|
||||
pageSize = pageSize,
|
||||
initialFilterKind = RoomListFilterMapper.toRustFilter(initialFilter),
|
||||
onControllerCreated = { controller ->
|
||||
dynamicController = controller
|
||||
dynamicRoomList.onControllerCreated(controller, initialFilter)
|
||||
}
|
||||
).onEach { update ->
|
||||
if (!firstRoomsTransaction.isFinished()) {
|
||||
@@ -80,13 +85,7 @@ internal class RoomListFactory(
|
||||
}.invokeOnCompletion {
|
||||
innerRoomList?.destroy()
|
||||
}
|
||||
return RustDynamicRoomList(
|
||||
summaries = summariesFlow,
|
||||
loadingState = loadingStateFlow,
|
||||
processor = processor,
|
||||
pageSize = pageSize,
|
||||
dynamicController = { dynamicController }
|
||||
)
|
||||
return dynamicRoomList
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
-11
@@ -24,37 +24,58 @@ internal class RustDynamicRoomList(
|
||||
override val loadingState: MutableStateFlow<RoomList.LoadingState>,
|
||||
private val processor: RoomSummaryListProcessor,
|
||||
override val pageSize: Int,
|
||||
private val dynamicController: () -> RoomListDynamicEntriesController?,
|
||||
initialFilter: RoomListFilter,
|
||||
private val addPagesCount: Int = DEFAULT_ADD_PAGES_COUNT
|
||||
) : DynamicRoomList {
|
||||
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() {
|
||||
processor.rebuildRoomSummaries()
|
||||
}
|
||||
|
||||
override suspend fun updateFilter(filter: RoomListFilter) {
|
||||
mutex.withLock {
|
||||
dynamicController()?.let { controller ->
|
||||
// Reset pagination when filter changes
|
||||
controller.resetToOnePage()
|
||||
val rustFilter = RoomListFilterMapper.toRustFilter(filter)
|
||||
controller.setFilter(rustFilter)
|
||||
// Then preload some pages
|
||||
controller.addPages(addPagesCount)
|
||||
}
|
||||
currentFilter = filter
|
||||
dynamicController?.applyFilter(filter)
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
mutex.withLock {
|
||||
dynamicController()?.addPages(addPagesCount)
|
||||
dynamicController?.addPages(addPagesCount)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun reset() {
|
||||
mutex.withLock {
|
||||
dynamicController()?.resetToOnePage()
|
||||
dynamicController?.resetToOnePage()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
@@ -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
|
||||
}
|
||||
+67
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
-1
@@ -13,7 +13,6 @@ import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
|
||||
|
||||
sealed interface RoomSelectEvent {
|
||||
data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent
|
||||
data object ToggleSearchActive : RoomSelectEvent
|
||||
data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent
|
||||
data object ToggleAccountListExpanded : RoomSelectEvent
|
||||
data class SelectAccount(val sessionId: SessionId) : RoomSelectEvent
|
||||
|
||||
+2
-7
@@ -8,7 +8,6 @@
|
||||
|
||||
package io.element.android.libraries.roomselect.impl
|
||||
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -63,7 +62,6 @@ class RoomSelectPresenter(
|
||||
override fun present(): RoomSelectState {
|
||||
var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) }
|
||||
val queryState = rememberTextFieldState()
|
||||
var isSearchActive by remember { mutableStateOf(false) }
|
||||
var isAccountListExpanded by remember { mutableStateOf(false) }
|
||||
var selectedSessionId by remember { mutableStateOf(initialSessionId) }
|
||||
val sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = emptyList())
|
||||
@@ -136,7 +134,7 @@ class RoomSelectPresenter(
|
||||
derivedStateOf {
|
||||
when {
|
||||
roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList())
|
||||
isSearchActive -> SearchBarResultState.NoResultsFound
|
||||
queryState.text.isNotBlank() -> SearchBarResultState.NoResultsFound
|
||||
else -> SearchBarResultState.Initial
|
||||
}
|
||||
}
|
||||
@@ -152,13 +150,11 @@ class RoomSelectPresenter(
|
||||
selectedRooms.adding(event.room)
|
||||
}
|
||||
}
|
||||
RoomSelectEvent.ToggleSearchActive -> isSearchActive = !isSearchActive
|
||||
RoomSelectEvent.ToggleAccountListExpanded -> isAccountListExpanded = !isAccountListExpanded
|
||||
is RoomSelectEvent.SelectAccount -> {
|
||||
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()
|
||||
queryState.clearText()
|
||||
isAccountListExpanded = false
|
||||
}
|
||||
is RoomSelectEvent.UpdateVisibleRange -> coroutineScope.launch {
|
||||
@@ -172,7 +168,6 @@ class RoomSelectPresenter(
|
||||
maxNumberOfRooms = maxNumberOfRooms,
|
||||
resultState = searchResults,
|
||||
searchQuery = queryState,
|
||||
isSearchActive = isSearchActive,
|
||||
selectedRooms = selectedRooms,
|
||||
selectedAccount = selectedAccount,
|
||||
hasRoomListError = hasRoomListError,
|
||||
|
||||
-1
@@ -20,7 +20,6 @@ data class RoomSelectState(
|
||||
val maxNumberOfRooms: Int,
|
||||
val resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>>,
|
||||
val searchQuery: TextFieldState,
|
||||
val isSearchActive: Boolean,
|
||||
val selectedRooms: ImmutableList<SelectRoomInfo>,
|
||||
val selectedAccount: MatrixUser,
|
||||
/**
|
||||
|
||||
+1
-5
@@ -26,17 +26,15 @@ open class RoomSelectStatePreviewParam : PreviewParameterProvider<RoomSelectStat
|
||||
override val values: Sequence<RoomSelectState>
|
||||
get() = sequenceOf(
|
||||
aRoomSelectState(),
|
||||
aRoomSelectState(searchQuery = "Test", isSearchActive = true),
|
||||
aRoomSelectState(searchQuery = "Test", resultState = SearchBarResultState.NoResultsFound),
|
||||
aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())),
|
||||
aRoomSelectState(
|
||||
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
|
||||
searchQuery = "Test",
|
||||
isSearchActive = true,
|
||||
),
|
||||
aRoomSelectState(
|
||||
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
|
||||
searchQuery = "Test",
|
||||
isSearchActive = true,
|
||||
selectedRooms = aRoomSelectRoomList().subList(0, 1),
|
||||
),
|
||||
aRoomSelectState(
|
||||
@@ -76,7 +74,6 @@ internal fun aRoomSelectState(
|
||||
maxNumberOfRooms: Int = 10,
|
||||
resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial,
|
||||
searchQuery: String = "",
|
||||
isSearchActive: Boolean = false,
|
||||
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
|
||||
selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")),
|
||||
hasRoomListError: Boolean = false,
|
||||
@@ -88,7 +85,6 @@ internal fun aRoomSelectState(
|
||||
maxNumberOfRooms = maxNumberOfRooms,
|
||||
resultState = resultState,
|
||||
searchQuery = TextFieldState(initialText = searchQuery),
|
||||
isSearchActive = isSearchActive,
|
||||
selectedRooms = selectedRooms,
|
||||
selectedAccount = selectedAccount,
|
||||
hasRoomListError = hasRoomListError,
|
||||
|
||||
+98
-69
@@ -10,6 +10,7 @@ package io.element.android.libraries.roomselect.impl
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
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.asPaddingValues
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -46,9 +52,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
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.tooling.preview.PreviewParameter
|
||||
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.ListSectionHeader
|
||||
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.SearchField
|
||||
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.TopAppBar
|
||||
@@ -123,10 +131,8 @@ fun RoomSelectView(
|
||||
}
|
||||
|
||||
var canHandleBack by remember { mutableStateOf(true) }
|
||||
fun onBackButton(state: RoomSelectState) {
|
||||
if (state.isSearchActive) {
|
||||
state.eventSink(RoomSelectEvent.ToggleSearchActive)
|
||||
} else if (canHandleBack) {
|
||||
fun onBackButton() {
|
||||
if (canHandleBack) {
|
||||
canHandleBack = false
|
||||
onDismiss()
|
||||
}
|
||||
@@ -134,16 +140,34 @@ fun RoomSelectView(
|
||||
|
||||
BackHandler(
|
||||
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
|
||||
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.
|
||||
// Use a new state when the selected account changes, so that the section is fully displayed again.
|
||||
val accountSwitchScrollBehavior = key(state.selectedAccount.userId) {
|
||||
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 ->
|
||||
state.eventSink(RoomSelectEvent.UpdateVisibleRange(visibleRange))
|
||||
}
|
||||
@@ -159,7 +183,7 @@ fun RoomSelectView(
|
||||
navigationIcon = {
|
||||
BackButton(
|
||||
enabled = canHandleBack,
|
||||
onClick = { onBackButton(state) }
|
||||
onClick = ::onBackButton,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
@@ -179,89 +203,60 @@ fun RoomSelectView(
|
||||
.consumeWindowInsets(paddingValues)
|
||||
.then(
|
||||
// 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)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
) {
|
||||
SearchBar(
|
||||
SearchField(
|
||||
state = state.searchQuery,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
WindowInsets.safeDrawing
|
||||
.only(WindowInsetsSides.Horizontal)
|
||||
.asPaddingValues()
|
||||
),
|
||||
placeHolderTitle = stringResource(CommonStrings.action_search),
|
||||
queryState = state.searchQuery,
|
||||
active = state.isSearchActive,
|
||||
onActiveChange = { state.eventSink(RoomSelectEvent.ToggleSearchActive) },
|
||||
resultState = state.resultState,
|
||||
showBackButton = false,
|
||||
) { summaries ->
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
contentPadding = lazyColumnContentPadding,
|
||||
) {
|
||||
if (summaries.isNotEmpty()) {
|
||||
item {
|
||||
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,
|
||||
)
|
||||
}
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = stringResource(CommonStrings.action_search),
|
||||
)
|
||||
if (state.showAccountSwitch) {
|
||||
// Keep this space outside the collapsing section, so that the section does not touch the search field when it is collapsing
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) {
|
||||
AccountSwitchSection(
|
||||
selectedAccount = state.selectedAccount,
|
||||
otherAccounts = state.otherAccounts,
|
||||
isExpanded = state.isAccountListExpanded,
|
||||
onToggleExpand = { state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) },
|
||||
onSelectAccount = { state.eventSink(RoomSelectEvent.SelectAccount(it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.isSearchActive) {
|
||||
if (state.showAccountSwitch) {
|
||||
// Keep this space outside the collapsing section, so that the section does not touch the search bar when it is collapsing
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) {
|
||||
AccountSwitchSection(
|
||||
selectedAccount = state.selectedAccount,
|
||||
otherAccounts = state.otherAccounts,
|
||||
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) {
|
||||
SelectedRoomsHelper(
|
||||
selectedRooms = state.selectedRooms,
|
||||
// showVerticalSpace only if there is no other accounts
|
||||
showVerticalSpace = !state.showAccountSwitch,
|
||||
)
|
||||
when {
|
||||
state.hasRoomListError -> RoomListError()
|
||||
state.resultState is SearchBarResultState.NoResultsFound -> NoResultsView(searchQuery = searchQuery)
|
||||
state.resultState is SearchBarResultState.Results -> {
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
contentPadding = lazyColumnContentPadding,
|
||||
) {
|
||||
if (state.resultState.results.isNotEmpty()) {
|
||||
item {
|
||||
val headerText = if (searchQuery.isBlank()) {
|
||||
stringResource(CommonStrings.common_header_rooms)
|
||||
} else {
|
||||
stringResource(CommonStrings.common_results_for, searchQuery)
|
||||
}
|
||||
ListSectionHeader(
|
||||
title = stringResource(CommonStrings.common_header_rooms),
|
||||
title = headerText,
|
||||
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
|
||||
private fun RoomListError() {
|
||||
IconTitleSubtitleMolecule(
|
||||
|
||||
+27
-15
@@ -59,7 +59,6 @@ class RoomSelectPresenterTest {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedRooms).isEmpty()
|
||||
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java)
|
||||
assertThat(initialState.isSearchActive).isFalse()
|
||||
assertThat(initialState.maxNumberOfRooms).isEqualTo(10)
|
||||
assertThat(initialState.canSelectMoreRooms).isTrue()
|
||||
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
|
||||
fun `present - forward mode with several accounts does not show the account switch`() = runTest {
|
||||
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
|
||||
fun `present - update query`() = runTest {
|
||||
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
|
||||
val result = (awaitItem().resultState as SearchBarResultState.Results).results
|
||||
assertThat(result).isEqualTo(listOf(expectedRoomInfo))
|
||||
initialState.eventSink(RoomSelectEvent.ToggleSearchActive)
|
||||
skipItems(1)
|
||||
initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained")
|
||||
assertThat(
|
||||
roomList.currentFilter.value
|
||||
|
||||
+11
@@ -155,6 +155,17 @@ class RoomSelectViewTest : RobolectricTest() {
|
||||
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
|
||||
fun `an error is displayed instead of the rooms if they cannot be loaded`() = runAndroidComposeUiTest<ComponentActivity> {
|
||||
setRoomSelectView(
|
||||
|
||||
@@ -335,6 +335,7 @@ Reason: %1$s."</string>
|
||||
<string name="common_mute">"Mute"</string>
|
||||
<string name="common_name">"Name"</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_room_name">"No room 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_report_a_problem">"Report a problem"</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_role">"Role"</string>
|
||||
<string name="common_room">"Room"</string>
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user