RoomList : rework how search is done to prepare for later filtering

This commit is contained in:
ganfra
2024-02-16 19:22:06 +01:00
parent b3b580089c
commit 85a0ef3677
25 changed files with 512 additions and 196 deletions

View File

@@ -20,7 +20,6 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.matrix.api.core.RoomId
sealed interface RoomListEvents {
data class UpdateFilter(val newFilter: String) : RoomListEvents
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
data object DismissRequestVerificationPrompt : RoomListEvents
data object DismissRecoveryKeyPrompt : RoomListEvents

View File

@@ -37,6 +37,8 @@ import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
import io.element.android.features.roomlist.impl.search.RoomListSearchEvents
import io.element.android.features.roomlist.impl.search.RoomListSearchPresenter
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@@ -76,6 +78,7 @@ class RoomListPresenter @Inject constructor(
private val encryptionService: EncryptionService,
private val featureFlagService: FeatureFlagService,
private val indicatorService: IndicatorService,
private val searchPresenter: RoomListSearchPresenter,
private val migrationScreenPresenter: MigrationScreenPresenter,
private val sessionPreferencesStore: SessionPreferencesStore,
) : Presenter<RoomListState> {
@@ -89,9 +92,8 @@ class RoomListPresenter @Inject constructor(
val roomList by produceState(initialValue = AsyncData.Loading()) {
roomListDataSource.allRooms.collect { value = AsyncData.Success(it) }
}
val filteredRoomList by roomListDataSource.filteredRooms.collectAsState()
val filter by roomListDataSource.filter.collectAsState()
val networkConnectionStatus by networkMonitor.connectivity.collectAsState()
val searchState = searchPresenter.present()
LaunchedEffect(Unit) {
roomListDataSource.launchIn(this)
@@ -122,21 +124,14 @@ class RoomListPresenter @Inject constructor(
// Avatar indicator
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
var displaySearchResults by rememberSaveable { mutableStateOf(false) }
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
fun handleEvents(event: RoomListEvents) {
when (event) {
is RoomListEvents.UpdateFilter -> roomListDataSource.updateFilter(event.newFilter)
is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range)
RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true
RoomListEvents.DismissRecoveryKeyPrompt -> recoveryKeyPromptDismissed = true
RoomListEvents.ToggleSearchResults -> {
if (displaySearchResults) {
roomListDataSource.updateFilter("")
}
displaySearchResults = !displaySearchResults
}
RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
is RoomListEvents.ShowContextMenu -> {
coroutineScope.showContextMenu(event, contextMenu)
}
@@ -175,16 +170,14 @@ class RoomListPresenter @Inject constructor(
matrixUser = matrixUser.value,
showAvatarIndicator = showAvatarIndicator,
roomList = roomList,
filter = filter,
filteredRoomList = filteredRoomList,
displayVerificationPrompt = displayVerificationPrompt,
displayRecoveryKeyPrompt = displayRecoveryKeyPrompt,
snackbarMessage = snackbarMessage,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
invitesState = inviteStateDataSource.inviteState(),
displaySearchResults = displaySearchResults,
contextMenu = contextMenu.value,
leaveRoomState = leaveRoomState,
searchState = searchState,
displayMigrationStatus = isMigrating,
eventSink = ::handleEvents
)

View File

@@ -19,6 +19,7 @@ package io.element.android.features.roomlist.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId
@@ -30,16 +31,14 @@ data class RoomListState(
val matrixUser: MatrixUser?,
val showAvatarIndicator: Boolean,
val roomList: AsyncData<ImmutableList<RoomListRoomSummary>>,
val filter: String?,
val filteredRoomList: ImmutableList<RoomListRoomSummary>,
val displayVerificationPrompt: Boolean,
val displayRecoveryKeyPrompt: Boolean,
val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?,
val invitesState: InvitesState,
val displaySearchResults: Boolean,
val contextMenu: ContextMenu,
val leaveRoomState: LeaveRoomState,
val searchState: RoomListSearchState,
val displayMigrationStatus: Boolean,
val eventSink: (RoomListEvents) -> Unit,
) {

View File

@@ -21,6 +21,7 @@ import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary
import io.element.android.features.roomlist.impl.search.aRoomListSearchState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -41,14 +42,13 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState().copy(hasNetworkConnection = false),
aRoomListState().copy(invitesState = InvitesState.SeenInvites),
aRoomListState().copy(invitesState = InvitesState.NewInvites),
aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()),
aRoomListState().copy(displaySearchResults = true),
aRoomListState().copy(contextMenu = aContextMenuShown(roomName = "A nice room name")),
aRoomListState().copy(contextMenu = aContextMenuShown(isFavorite = true)),
aRoomListState().copy(displayRecoveryKeyPrompt = true),
aRoomListState().copy(roomList = AsyncData.Success(persistentListOf())),
aRoomListState().copy(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())),
aRoomListState().copy(matrixUser = null, displayMigrationStatus = true),
aRoomListState().copy(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
)
}
@@ -56,16 +56,14 @@ internal fun aRoomListState() = RoomListState(
matrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"),
showAvatarIndicator = false,
roomList = AsyncData.Success(aRoomListRoomSummaryList()),
filter = "filter",
filteredRoomList = aRoomListRoomSummaryList(),
hasNetworkConnection = true,
snackbarMessage = null,
displayVerificationPrompt = false,
displayRecoveryKeyPrompt = false,
invitesState = InvitesState.NoInvites,
displaySearchResults = false,
contextMenu = RoomListState.ContextMenu.Hidden,
leaveRoomState = aLeaveRoomState(),
searchState = aRoomListSearchState(),
displayMigrationStatus = false,
eventSink = {}
)

View File

@@ -57,7 +57,7 @@ import io.element.android.features.roomlist.impl.components.RoomListTopBar
import io.element.android.features.roomlist.impl.components.RoomSummaryRow
import io.element.android.features.roomlist.impl.migration.MigrationScreenView
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchResultView
import io.element.android.features.roomlist.impl.search.RoomListSearchView
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -118,8 +118,8 @@ fun RoomListView(
onMenuActionClicked = onMenuActionClicked,
)
// This overlaid view will only be visible when state.displaySearchResults is true
RoomListSearchResultView(
state = state,
RoomListSearchView(
state = state.searchState,
onRoomClicked = onRoomClicked,
onRoomLongClicked = { onRoomLongClicked(it) },
modifier = Modifier
@@ -207,8 +207,7 @@ private fun RoomListContent(
RoomListTopBar(
matrixUser = state.matrixUser,
showAvatarIndicator = state.showAvatarIndicator,
areSearchResultsDisplayed = state.displaySearchResults,
onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
areSearchResultsDisplayed = state.searchState.isSearchActive,
onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) },
onMenuActionClicked = onMenuActionClicked,
onOpenSettings = onOpenSettings,

View File

@@ -16,7 +16,6 @@
package io.element.android.features.roomlist.impl.components
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
@@ -87,7 +86,6 @@ fun RoomListTopBar(
matrixUser: MatrixUser?,
showAvatarIndicator: Boolean,
areSearchResultsDisplayed: Boolean,
onFilterChanged: (String) -> Unit,
onToggleSearch: () -> Unit,
onMenuActionClicked: (RoomListMenuAction) -> Unit,
onOpenSettings: () -> Unit,
@@ -95,15 +93,6 @@ fun RoomListTopBar(
displayMenuItems: Boolean,
modifier: Modifier = Modifier,
) {
fun closeFilter() {
onFilterChanged("")
}
BackHandler(enabled = areSearchResultsDisplayed) {
closeFilter()
onToggleSearch()
}
DefaultRoomListTopBar(
matrixUser = matrixUser,
showAvatarIndicator = showAvatarIndicator,

View File

@@ -25,15 +25,11 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -54,9 +50,7 @@ class RoomListDataSource @Inject constructor(
observeNotificationSettings()
}
private val _filter = MutableStateFlow("")
private val _allRooms = MutableSharedFlow<ImmutableList<RoomListRoomSummary>>(replay = 1)
private val _filteredRooms = MutableStateFlow<ImmutableList<RoomListRoomSummary>>(persistentListOf())
private val lock = Mutex()
private val diffCache = MutableListDiffCache<RoomListRoomSummary>()
@@ -72,29 +66,9 @@ class RoomListDataSource @Inject constructor(
replaceWith(roomSummaries)
}
.launchIn(coroutineScope)
combine(
_filter,
_allRooms
) { filterValue, allRoomsValue ->
when {
filterValue.isEmpty() -> emptyList()
else -> allRoomsValue.filter { it.name.contains(filterValue, ignoreCase = true) }
}.toImmutableList()
}
.onEach {
_filteredRooms.value = it
}
.launchIn(coroutineScope)
}
fun updateFilter(filterValue: String) {
_filter.value = filterValue
}
val filter: StateFlow<String> = _filter
val allRooms: SharedFlow<ImmutableList<RoomListRoomSummary>> = _allRooms
val filteredRooms: StateFlow<ImmutableList<RoomListRoomSummary>> = _filteredRooms
@OptIn(FlowPreview::class)
private fun observeNotificationSettings() {

View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.search
sealed interface RoomListSearchEvents {
data object ToggleSearchVisibility : RoomListSearchEvents
data class QueryChanged(val query: String) : RoomListSearchEvents
data object ClearQuery : RoomListSearchEvents
}

View File

@@ -0,0 +1,120 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.search
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
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.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import javax.inject.Inject
private const val PAGE_SIZE = 50
class RoomListSearchPresenter @Inject constructor(
private val roomListService: RoomListService,
private val roomSummaryFactory: RoomListRoomSummaryFactory,
private val coroutineDispatchers: CoroutineDispatchers,
) : Presenter<RoomListSearchState> {
@Composable
override fun present(): RoomListSearchState {
var isSearchActive by rememberSaveable {
mutableStateOf(false)
}
var searchQuery by rememberSaveable {
mutableStateOf("")
}
val coroutineScope = rememberCoroutineScope()
val roomList = remember {
roomListService.createRoomList(
coroutineScope = coroutineScope,
pageSize = PAGE_SIZE,
initialFilter = RoomListFilter.all(RoomListFilter.None),
source = RoomList.Source.All,
)
}
LaunchedEffect(Unit) {
roomList.loadAllIncrementally(this)
}
LaunchedEffect(key1 = searchQuery) {
val filter = if (searchQuery.isBlank()) {
RoomListFilter.all(RoomListFilter.None)
} else {
RoomListFilter.all(RoomListFilter.NonLeft, RoomListFilter.NormalizedMatchRoomName(searchQuery))
}
roomList.updateFilter(filter)
}
fun handleEvents(event: RoomListSearchEvents) {
when (event) {
RoomListSearchEvents.ClearQuery -> {
searchQuery = ""
}
is RoomListSearchEvents.QueryChanged -> {
searchQuery = event.query
}
RoomListSearchEvents.ToggleSearchVisibility -> {
isSearchActive = !isSearchActive
searchQuery = ""
}
}
}
val searchResults by roomList
.rememberMappedSummaries()
.collectAsState(initial = persistentListOf())
return RoomListSearchState(
isSearchActive = isSearchActive,
query = searchQuery,
results = searchResults,
eventSink = ::handleEvents
)
}
@Composable
private fun RoomList.rememberMappedSummaries() = remember {
summaries
.map { roomSummaries ->
roomSummaries
.filterIsInstance<RoomSummary.Filled>()
.map(roomSummaryFactory::create)
.toPersistentList()
}
.flowOn(coroutineDispatchers.computation)
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.search
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import kotlinx.collections.immutable.ImmutableList
data class RoomListSearchState(
val isSearchActive: Boolean,
val query: String,
val results: ImmutableList<RoomListRoomSummary>,
val eventSink: (RoomListSearchEvents) -> Unit
)

View File

@@ -0,0 +1,46 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.search
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomlist.impl.aRoomListRoomSummaryList
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
class RoomListSearchStateProvider : PreviewParameterProvider<RoomListSearchState> {
override val values: Sequence<RoomListSearchState>
get() = sequenceOf(
aRoomListSearchState(),
aRoomListSearchState(
isSearchActive = true,
query = "Test",
results = aRoomListRoomSummaryList()
),
)
}
fun aRoomListSearchState(
isSearchActive: Boolean = false,
query: String = "",
results: ImmutableList<RoomListRoomSummary> = persistentListOf(),
) = RoomListSearchState(
isSearchActive = isSearchActive,
query = query,
results = results,
eventSink = { },
)

View File

@@ -16,6 +16,7 @@
package io.element.android.features.roomlist.impl.search
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -25,32 +26,23 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomlist.impl.RoomListEvents
import io.element.android.features.roomlist.impl.RoomListState
import io.element.android.features.roomlist.impl.aRoomListState
import io.element.android.features.roomlist.impl.components.RoomSummaryRow
import io.element.android.features.roomlist.impl.contentType
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
@@ -68,26 +60,30 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun RoomListSearchResultView(
state: RoomListState,
internal fun RoomListSearchView(
state: RoomListSearchState,
onRoomClicked: (RoomId) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(enabled = state.isSearchActive) {
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
}
AnimatedVisibility(
visible = state.displaySearchResults,
visible = state.isSearchActive,
enter = fadeIn(),
exit = fadeOut(),
) {
Column(
modifier = modifier
.applyIf(state.displaySearchResults, ifTrue = {
.applyIf(state.isSearchActive, ifTrue = {
// Disable input interaction to underlying views
pointerInput(Unit) {}
})
) {
if (state.displaySearchResults) {
RoomListSearchResultContent(
if (state.isSearchActive) {
RoomListSearchContent(
state = state,
onRoomClicked = onRoomClicked,
onRoomLongClicked = onRoomLongClicked,
@@ -99,15 +95,15 @@ internal fun RoomListSearchResultView(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoomListSearchResultContent(
state: RoomListState,
private fun RoomListSearchContent(
state: RoomListSearchState,
onRoomClicked: (RoomId) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
) {
val borderColor = MaterialTheme.colorScheme.tertiary
val strokeWidth = 1.dp
fun onBackButtonPressed() {
state.eventSink(RoomListEvents.ToggleSearchResults)
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
}
fun onRoomClicked(room: RoomListRoomSummary) {
@@ -126,7 +122,7 @@ private fun RoomListSearchResultContent(
},
navigationIcon = { BackButton(onClick = ::onBackButtonPressed) },
title = {
val filter = state.filter.orEmpty()
val filter = state.query
val focusRequester = FocusRequester()
TextField(
modifier = Modifier
@@ -134,7 +130,7 @@ private fun RoomListSearchResultContent(
.focusRequester(focusRequester),
value = filter,
singleLine = true,
onValueChange = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
onValueChange = { state.eventSink(RoomListSearchEvents.QueryChanged(it)) },
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
@@ -147,7 +143,7 @@ private fun RoomListSearchResultContent(
trailingIcon = {
if (filter.isNotEmpty()) {
IconButton(onClick = {
state.eventSink(RoomListEvents.UpdateFilter(""))
state.eventSink(RoomListSearchEvents.ClearQuery)
}) {
Icon(
imageVector = CompoundIcons.Close(),
@@ -158,8 +154,8 @@ private fun RoomListSearchResultContent(
}
)
LaunchedEffect(state.displaySearchResults) {
if (state.displaySearchResults) {
LaunchedEffect(state.isSearchActive) {
if (state.isSearchActive) {
focusRequester.requestFocus()
}
}
@@ -168,39 +164,16 @@ private fun RoomListSearchResultContent(
)
}
) { padding ->
val lazyListState = rememberLazyListState()
val visibleRange by remember {
derivedStateOf {
val layoutInfo = lazyListState.layoutInfo
val firstItemIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0
val size = layoutInfo.visibleItemsInfo.size
firstItemIndex until firstItemIndex + size
}
}
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override suspend fun onPostFling(
consumed: Velocity,
available: Velocity
): Velocity {
state.eventSink(RoomListEvents.UpdateVisibleRange(visibleRange))
return super.onPostFling(consumed, available)
}
}
}
Column(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
) {
LazyColumn(
modifier = Modifier
.weight(1f)
.nestedScroll(nestedScrollConnection),
state = lazyListState,
modifier = Modifier.weight(1f),
) {
items(
items = state.filteredRoomList,
items = state.results,
contentType = { room -> room.contentType() },
) { room ->
RoomSummaryRow(
@@ -216,9 +189,9 @@ private fun RoomListSearchResultContent(
@PreviewsDayNight
@Composable
internal fun RoomListSearchResultContentPreview() = ElementPreview {
RoomListSearchResultContent(
state = aRoomListState(),
internal fun RoomListSearchResultContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview {
RoomListSearchContent(
state = state,
onRoomClicked = {},
onRoomLongClicked = {}
)

View File

@@ -33,6 +33,8 @@ import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryF
import io.element.android.features.roomlist.impl.migration.InMemoryMigrationScreenStore
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
import io.element.android.features.roomlist.impl.model.createRoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchPresenter
import io.element.android.features.roomlist.impl.search.createRoomListSearchPresenter
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
@@ -54,7 +56,6 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
@@ -145,24 +146,6 @@ class RoomListPresenterTests {
}
}
@Test
fun `present - should filter room with success`() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val withUserState = awaitItem()
assertThat(withUserState.filter).isEqualTo("")
withUserState.eventSink.invoke(RoomListEvents.UpdateFilter("t"))
val withFilterState = awaitItem()
assertThat(withFilterState.filter).isEqualTo("t")
cancelAndIgnoreRemainingEvents()
scope.cancel()
}
}
@Test
fun `present - load 1 room with success`() = runTest {
val roomListService = FakeRoomListService()
@@ -196,51 +179,7 @@ class RoomListPresenterTests {
numberOfUnreadMessages = 2,
)
)
scope.cancel()
}
}
@Test
fun `present - load 1 room with success and filter rooms`() = runTest {
val roomListService = FakeRoomListService()
val matrixClient = FakeMatrixClient(
roomListService = roomListService
)
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
roomListService.postAllRooms(
listOf(
aRoomSummaryFilled(
numUnreadMentions = 1,
numUnreadMessages = 2,
)
)
)
skipItems(3)
val loadedState = awaitItem()
// Test filtering with result
assertThat(loadedState.roomList.dataOrNull().orEmpty().size).isEqualTo(1)
loadedState.eventSink.invoke(RoomListEvents.UpdateFilter(A_ROOM_NAME.substring(0, 3)))
skipItems(1)
val withFilteredRoomState = awaitItem()
assertThat(withFilteredRoomState.filteredRoomList.size).isEqualTo(1)
assertThat(withFilteredRoomState.filter).isEqualTo(A_ROOM_NAME.substring(0, 3))
assertThat(withFilteredRoomState.filteredRoomList.size).isEqualTo(1)
assertThat(withFilteredRoomState.filteredRoomList.first()).isEqualTo(
createRoomListRoomSummary(
numberOfUnreadMentions = 1,
numberOfUnreadMessages = 2,
)
)
// Test filtering without result
withFilteredRoomState.eventSink.invoke(RoomListEvents.UpdateFilter("tada"))
skipItems(1)
val withNotFilteredRoomState = awaitItem()
assertThat(withNotFilteredRoomState.filter).isEqualTo("tada")
assertThat(withNotFilteredRoomState.filteredRoomList).isEmpty()
cancelAndIgnoreRemainingEvents()
scope.cancel()
}
}
@@ -572,7 +511,8 @@ class RoomListPresenterTests {
migrationScreenPresenter: MigrationScreenPresenter = MigrationScreenPresenter(
matrixClient = client,
migrationScreenStore = InMemoryMigrationScreenStore(),
)
),
searchPresenter: RoomListSearchPresenter = createRoomListSearchPresenter(roomListService = client.roomListService)
) = RoomListPresenter(
client = client,
sessionVerificationService = sessionVerificationService,
@@ -598,6 +538,7 @@ class RoomListPresenterTests {
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)),
),
migrationScreenPresenter = migrationScreenPresenter,
searchPresenter = searchPresenter,
sessionPreferencesStore = sessionPreferencesStore,
)
}

View File

@@ -0,0 +1,151 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.search
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RoomListSearchPresenterTests {
@Test
fun `present - initial state`() = runTest {
val presenter = createRoomListSearchPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse()
assertThat(state.query).isEmpty()
assertThat(state.results).isEmpty()
}
}
}
@Test
fun `present - toggle search visibility`() = runTest {
val presenter = createRoomListSearchPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse()
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
}
awaitItem().let { state ->
assertThat(state.isSearchActive).isTrue()
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
}
awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse()
}
}
}
@Test
fun `present - query search changes`() = runTest {
val roomListService = FakeRoomListService()
val presenter = createRoomListSearchPresenter(roomListService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().let { state ->
assertThat(
roomListService.allRooms.currentFilter.value
).isEqualTo(
RoomListFilter.all(
RoomListFilter.None,
)
)
state.eventSink(RoomListSearchEvents.QueryChanged("Search"))
}
awaitItem().let { state ->
assertThat(state.query).isEqualTo("Search")
assertThat(
roomListService.allRooms.currentFilter.value
).isEqualTo(
RoomListFilter.all(
RoomListFilter.NonLeft,
RoomListFilter.NormalizedMatchRoomName("Search")
)
)
state.eventSink(RoomListSearchEvents.ClearQuery)
}
awaitItem().let { state ->
assertThat(state.query).isEmpty()
assertThat(
roomListService.allRooms.currentFilter.value
).isEqualTo(
RoomListFilter.all(
RoomListFilter.None,
)
)
}
}
}
@Test
fun `present - room list changes`() = runTest {
val roomListService = FakeRoomListService()
val presenter = createRoomListSearchPresenter(roomListService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().let { state ->
assertThat(state.results).isEmpty()
}
roomListService.postAllRooms(
listOf(
RoomSummary.Empty("1"),
aRoomSummaryFilled()
)
)
awaitItem().let { state ->
assertThat(state.results).hasSize(1)
}
roomListService.postAllRooms(emptyList())
awaitItem().let { state ->
assertThat(state.results).isEmpty()
}
}
}
}
fun TestScope.createRoomListSearchPresenter(
roomListService: RoomListService = FakeRoomListService(),
): RoomListSearchPresenter {
return RoomListSearchPresenter(
roomListService = roomListService,
roomSummaryFactory = RoomListRoomSummaryFactory(
lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(),
roomLastMessageFormatter = FakeRoomLastMessageFormatter(),
),
coroutineDispatchers = testCoroutineDispatchers(),
)
}

View File

@@ -33,6 +33,11 @@ interface RoomList {
data class Loaded(val numberOfRooms: Int) : LoadingState
}
enum class Source {
All,
Invites,
}
/**
* The list of room summaries as a flow.
*/

View File

@@ -18,38 +18,68 @@ package io.element.android.libraries.matrix.api.roomlist
sealed interface RoomListFilter {
companion object {
/**
* Create a filter that matches all the given filters.
*/
fun all(vararg filters: RoomListFilter): RoomListFilter {
return All(filters.toList())
}
/**
* Create a filter that matches any of the given filters.
*/
fun any(vararg filters: RoomListFilter): RoomListFilter {
return Any(filters.toList())
}
}
/**
* A filter that matches all the given filters.
*/
data class All(
val filters: List<RoomListFilter>
) : RoomListFilter
/**
* A filter that matches any of the given filters.
*/
data class Any(
val filters: List<RoomListFilter>
) : RoomListFilter
/**
* A filter that matches rooms that are not left.
*/
data object NonLeft : RoomListFilter
/**
* A filter that matches rooms that are unread.
*/
data object Unread : RoomListFilter
/**
* A filter that matches either Group or People rooms.
*/
sealed interface Category : RoomListFilter {
data object Group : Category
data object People : Category
}
/**
* A filter that matches no room.
*/
data object None : RoomListFilter
/**
* A filter that matches rooms with a name using a normalized match.
*/
data class NormalizedMatchRoomName(
val pattern: String
) : RoomListFilter
/**
* A filter that matches rooms with a name using a fuzzy match.
*/
data class FuzzyMatchRoomName(
val pattern: String
) : RoomListFilter

View File

@@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.api.roomlist
import androidx.compose.runtime.Immutable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
/**
@@ -39,6 +40,20 @@ interface RoomListService {
data object Hide : SyncIndicator
}
/**
* Creates a room list that can be used to load more rooms and filter them dynamically.
* @param coroutineScope the scope to use for the room list. When the scope will be closed, the room list will be closed too.
* @param pageSize the number of rooms to load at once.
* @param initialFilter the initial filter to apply to the rooms.
* @param source the source of the rooms, either all rooms or invites.
*/
fun createRoomList(
coroutineScope: CoroutineScope,
pageSize: Int,
initialFilter: RoomListFilter,
source: RoomList.Source,
): DynamicRoomList
/**
* returns a [DynamicRoomList] object of all rooms we want to display.
* This will exclude some rooms like the invites, or spaces.

View File

@@ -199,8 +199,8 @@ class RustMatrixClient(
sessionCoroutineScope = sessionCoroutineScope,
roomListFactory = RoomListFactory(
innerRoomListService = innerRoomListService,
coroutineScope = sessionCoroutineScope,
dispatcher = sessionDispatcher,
defaultCoroutineScope = sessionCoroutineScope,
defaultCoroutineContext = sessionDispatcher,
),
)

View File

@@ -20,7 +20,6 @@ import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList
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 kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -30,13 +29,15 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.matrix.rustcomponents.sdk.RoomListLoadingState
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import org.matrix.rustcomponents.sdk.RoomList as InnerRoomList
import org.matrix.rustcomponents.sdk.RoomListService as InnerRoomListService
internal class RoomListFactory(
private val innerRoomListService: InnerRoomListService,
private val coroutineScope: CoroutineScope,
private val dispatcher: CoroutineDispatcher,
private val defaultCoroutineScope: CoroutineScope,
private val defaultCoroutineContext: CoroutineContext = EmptyCoroutineContext,
private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
) {
/**
@@ -44,18 +45,21 @@ internal class RoomListFactory(
*/
fun createRoomList(
pageSize: Int,
coroutineScope: CoroutineScope = defaultCoroutineScope,
coroutineContext: CoroutineContext = defaultCoroutineContext,
initialFilter: RoomListFilter = RoomListFilter.all(),
innerProvider: suspend () -> InnerRoomList
): DynamicRoomList {
val loadingStateFlow: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded)
val summariesFlow = MutableStateFlow<List<RoomSummary>>(emptyList())
val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, dispatcher, roomSummaryDetailsFactory)
val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryDetailsFactory)
// Makes sure we don't miss any events
val dynamicEvents = MutableSharedFlow<RoomListDynamicEvents>(replay = 100)
val currentFilter = MutableStateFlow(initialFilter)
val loadedPages = MutableStateFlow(1)
var innerRoomList: InnerRoomList? = null
coroutineScope.launch(dispatcher) {
coroutineScope.launch(coroutineContext) {
innerRoomList = innerProvider()
innerRoomList?.let { innerRoomList ->
innerRoomList.entriesFlow(

View File

@@ -17,7 +17,6 @@
package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -28,11 +27,12 @@ import org.matrix.rustcomponents.sdk.RoomListServiceInterface
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import java.util.UUID
import kotlin.coroutines.CoroutineContext
class RoomSummaryListProcessor(
private val roomSummaries: MutableStateFlow<List<RoomSummary>>,
private val roomListService: RoomListServiceInterface,
private val dispatcher: CoroutineDispatcher,
private val coroutineContext: CoroutineContext,
private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
) {
private val roomSummariesByIdentifier = HashMap<String, RoomSummary>()
@@ -130,7 +130,7 @@ class RoomSummaryListProcessor(
return builtRoomSummary
}
private suspend fun updateRoomSummaries(block: suspend MutableList<RoomSummary>.() -> Unit) = withContext(dispatcher) {
private suspend fun updateRoomSummaries(block: suspend MutableList<RoomSummary>.() -> Unit) = withContext(coroutineContext) {
mutex.withLock {
val mutableRoomSummaries = roomSummaries.value.toMutableList()
block(mutableRoomSummaries)

View File

@@ -42,8 +42,26 @@ private const val DEFAULT_PAGE_SIZE = 20
internal class RustRoomListService(
private val innerRoomListService: InnerRustRoomListService,
private val sessionCoroutineScope: CoroutineScope,
roomListFactory: RoomListFactory,
private val roomListFactory: RoomListFactory,
) : RoomListService {
override fun createRoomList(
coroutineScope: CoroutineScope,
pageSize: Int,
initialFilter: RoomListFilter,
source: RoomList.Source
): DynamicRoomList {
return roomListFactory.createRoomList(
pageSize = pageSize,
initialFilter = initialFilter,
coroutineScope = coroutineScope,
) {
when (source) {
RoomList.Source.All -> innerRoomListService.allRooms()
RoomList.Source.Invites -> innerRoomListService.invites()
}
}
}
override val allRooms: DynamicRoomList = roomListFactory.createRoomList(
pageSize = DEFAULT_PAGE_SIZE,
initialFilter = RoomListFilter.all(RoomListFilter.NonLeft),

View File

@@ -158,7 +158,7 @@ class RoomSummaryListProcessorTests {
private fun TestScope.createProcessor() = RoomSummaryListProcessor(
summaries,
fakeRoomListService,
dispatcher = StandardTestDispatcher(testScheduler),
coroutineContext = StandardTestDispatcher(testScheduler),
roomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
)

View File

@@ -53,6 +53,10 @@ fun aRoomSummaryFilled(
)
)
fun aRoomSummaryFilled(
details: RoomSummaryDetails = aRoomSummaryDetails(),
) = RoomSummary.Filled(details)
fun aRoomSummaryDetails(
roomId: RoomId = A_ROOM_ID,
name: String = A_ROOM_NAME,

View File

@@ -21,6 +21,7 @@ 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.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -59,13 +60,20 @@ class FakeRoomListService : RoomListService {
var latestSlidingSyncRange: IntRange? = null
private set
override val allRooms: DynamicRoomList = SimplePagedRoomList(
override fun createRoomList(coroutineScope: CoroutineScope, pageSize: Int, initialFilter: RoomListFilter, source: RoomList.Source): DynamicRoomList {
return when (source) {
RoomList.Source.All -> allRooms
RoomList.Source.Invites -> invites
}
}
override val allRooms = SimplePagedRoomList(
allRoomSummariesFlow,
allRoomsLoadingStateFlow,
MutableStateFlow(RoomListFilter.all())
)
override val invites: RoomList = SimplePagedRoomList(
override val invites = SimplePagedRoomList(
inviteRoomSummariesFlow,
inviteRoomsLoadingStateFlow,
MutableStateFlow(RoomListFilter.all())

View File

@@ -25,7 +25,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate
data class SimplePagedRoomList(
override val summaries: StateFlow<List<RoomSummary>>,
override val summaries: MutableStateFlow<List<RoomSummary>>,
override val loadingState: StateFlow<RoomList.LoadingState>,
override val currentFilter: MutableStateFlow<RoomListFilter>
) : DynamicRoomList {