Merge pull request #2451 from element-hq/feature/fga/room_list_filter_improvements

[Improvements] Room list filters
This commit is contained in:
Benoit Marty 2024-02-27 23:13:28 +01:00 committed by GitHub
commit f9c1d2ce97
15 changed files with 302 additions and 133 deletions

View file

@ -30,8 +30,6 @@ import io.element.android.features.lockscreen.impl.pin.model.assertText
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -54,13 +52,14 @@ class PinUnlockPresenterTest {
assertThat(state.signOutAction).isInstanceOf(AsyncData.Uninitialized::class.java) assertThat(state.signOutAction).isInstanceOf(AsyncData.Uninitialized::class.java)
assertThat(state.remainingAttempts).isInstanceOf(AsyncData.Uninitialized::class.java) assertThat(state.remainingAttempts).isInstanceOf(AsyncData.Uninitialized::class.java)
} }
consumeItemsUntilPredicate { awaitItem().also { state ->
it.pinEntry is AsyncData.Success && it.remainingAttempts is AsyncData.Success assertThat(state.pinEntry).isInstanceOf(AsyncData.Success::class.java)
}.last().also { state -> assertThat(state.remainingAttempts).isInstanceOf(AsyncData.Success::class.java)
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1'))) state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1')))
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('2'))) state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('2')))
} }
awaitLastSequentialItem().also { state -> skipItems(1)
awaitItem().also { state ->
state.pinEntry.assertText(halfCompletePin) state.pinEntry.assertText(halfCompletePin)
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3'))) state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Back)) state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Back))
@ -68,7 +67,8 @@ class PinUnlockPresenterTest {
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3'))) state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('5'))) state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('5')))
} }
awaitLastSequentialItem().also { state -> skipItems(4)
awaitItem().also { state ->
state.pinEntry.assertText(completePin) state.pinEntry.assertText(completePin)
assertThat(state.isUnlocked).isTrue() assertThat(state.isUnlocked).isTrue()
} }
@ -81,9 +81,11 @@ class PinUnlockPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = consumeItemsUntilPredicate { skipItems(1)
it.pinEntry is AsyncData.Success && it.remainingAttempts is AsyncData.Success val initialState = awaitItem().also { state ->
}.last() assertThat(state.pinEntry).isInstanceOf(AsyncData.Success::class.java)
assertThat(state.remainingAttempts).isInstanceOf(AsyncData.Success::class.java)
}
val numberOfAttempts = initialState.remainingAttempts.dataOrNull() ?: 0 val numberOfAttempts = initialState.remainingAttempts.dataOrNull() ?: 0
repeat(numberOfAttempts) { repeat(numberOfAttempts) {
initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1'))) initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1')))
@ -91,7 +93,8 @@ class PinUnlockPresenterTest {
initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3'))) initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('4'))) initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('4')))
} }
awaitLastSequentialItem().also { state -> skipItems(4 * numberOfAttempts + 2)
awaitItem().also { state ->
assertThat(state.remainingAttempts.dataOrNull()).isEqualTo(0) assertThat(state.remainingAttempts.dataOrNull()).isEqualTo(0)
assertThat(state.showSignOutPrompt).isTrue() assertThat(state.showSignOutPrompt).isTrue()
assertThat(state.isSignOutPromptCancellable).isFalse() assertThat(state.isSignOutPromptCancellable).isFalse()
@ -105,26 +108,28 @@ class PinUnlockPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
consumeItemsUntilPredicate { skipItems(1)
it.pinEntry is AsyncData.Success && it.remainingAttempts is AsyncData.Success awaitItem().also { state ->
}.last().also { state -> assertThat(state.pinEntry).isInstanceOf(AsyncData.Success::class.java)
assertThat(state.remainingAttempts).isInstanceOf(AsyncData.Success::class.java)
state.eventSink(PinUnlockEvents.OnForgetPin) state.eventSink(PinUnlockEvents.OnForgetPin)
} }
awaitLastSequentialItem().also { state -> awaitItem().also { state ->
assertThat(state.showSignOutPrompt).isTrue() assertThat(state.showSignOutPrompt).isTrue()
assertThat(state.isSignOutPromptCancellable).isTrue() assertThat(state.isSignOutPromptCancellable).isTrue()
state.eventSink(PinUnlockEvents.ClearSignOutPrompt) state.eventSink(PinUnlockEvents.ClearSignOutPrompt)
} }
awaitLastSequentialItem().also { state -> awaitItem().also { state ->
assertThat(state.showSignOutPrompt).isFalse() assertThat(state.showSignOutPrompt).isFalse()
state.eventSink(PinUnlockEvents.OnForgetPin) state.eventSink(PinUnlockEvents.OnForgetPin)
} }
awaitLastSequentialItem().also { state -> awaitItem().also { state ->
assertThat(state.showSignOutPrompt).isTrue() assertThat(state.showSignOutPrompt).isTrue()
state.eventSink(PinUnlockEvents.SignOut) state.eventSink(PinUnlockEvents.SignOut)
} }
consumeItemsUntilPredicate { state -> skipItems(2)
state.signOutAction is AsyncData.Success awaitItem().also { state ->
assertThat(state.signOutAction).isInstanceOf(AsyncData.Success::class.java)
} }
} }
} }

View file

@ -83,7 +83,7 @@ class RoomListFiltersPresenter @Inject constructor(
RoomListFilter.Unread -> MatrixRoomListFilter.Unread RoomListFilter.Unread -> MatrixRoomListFilter.Unread
RoomListFilter.Favourites -> MatrixRoomListFilter.Favorite RoomListFilter.Favourites -> MatrixRoomListFilter.Favorite
} }
}.plus(MatrixRoomListFilter.NonLeft) }
) )
roomListService.allRooms.updateFilter(allRoomsFilter) roomListService.allRooms.updateFilter(allRoomsFilter)
} }

View file

@ -103,10 +103,8 @@ private fun LazyListScope.roomListFilters(
) { ) {
items( items(
items = filters, items = filters,
key = { it.ordinal },
) { filter -> ) { filter ->
RoomListFilterView( RoomListFilterView(
modifier = Modifier.animateItemPlacement(),
roomListFilter = filter, roomListFilter = filter,
selected = selected, selected = selected,
onClick = onClick, onClick = onClick,

View file

@ -0,0 +1,73 @@
/*
* 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.datasource.RoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
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.PersistentList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import javax.inject.Inject
private const val PAGE_SIZE = 30
class RoomListSearchDataSource @Inject constructor(
roomListService: RoomListService,
coroutineDispatchers: CoroutineDispatchers,
private val roomSummaryFactory: RoomListRoomSummaryFactory,
) {
private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE,
initialFilter = RoomListFilter.None,
source = RoomList.Source.All,
)
val roomSummaries: Flow<PersistentList<RoomListRoomSummary>> = roomList.summaries
.map { roomSummaries ->
roomSummaries
.filterIsInstance<RoomSummary.Filled>()
.map(roomSummaryFactory::create)
.toPersistentList()
}
.flowOn(coroutineDispatchers.computation)
suspend fun setIsActive(isActive: Boolean) = coroutineScope {
if (isActive) {
roomList.loadAllIncrementally(this)
} else {
roomList.reset()
}
}
suspend fun setSearchQuery(searchQuery: String) = coroutineScope {
val filter = if (searchQuery.isBlank()) {
RoomListFilter.None
} else {
RoomListFilter.NormalizedMatchRoomName(searchQuery)
}
roomList.updateFilter(filter)
}
}

View file

@ -21,30 +21,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.saveable.rememberSaveable
import androidx.compose.runtime.setValue 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.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.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
private const val PAGE_SIZE = 50
class RoomListSearchPresenter @Inject constructor( class RoomListSearchPresenter @Inject constructor(
private val roomListService: RoomListService, private val dataSource: RoomListSearchDataSource,
private val roomSummaryFactory: RoomListRoomSummaryFactory,
private val coroutineDispatchers: CoroutineDispatchers,
) : Presenter<RoomListSearchState> { ) : Presenter<RoomListSearchState> {
@Composable @Composable
override fun present(): RoomListSearchState { override fun present(): RoomListSearchState {
@ -54,27 +38,13 @@ class RoomListSearchPresenter @Inject constructor(
var searchQuery by rememberSaveable { var searchQuery by rememberSaveable {
mutableStateOf("") mutableStateOf("")
} }
val coroutineScope = rememberCoroutineScope()
val roomList = remember { LaunchedEffect(isSearchActive) {
roomListService.createRoomList( dataSource.setIsActive(isSearchActive)
coroutineScope = coroutineScope,
pageSize = PAGE_SIZE,
initialFilter = RoomListFilter.all(RoomListFilter.None),
source = RoomList.Source.All,
)
} }
LaunchedEffect(Unit) { LaunchedEffect(searchQuery) {
roomList.loadAllIncrementally(this) dataSource.setSearchQuery(searchQuery)
}
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) { fun handleEvents(event: RoomListSearchEvents) {
@ -92,9 +62,7 @@ class RoomListSearchPresenter @Inject constructor(
} }
} }
val searchResults by roomList val searchResults by dataSource.roomSummaries.collectAsState(initial = persistentListOf())
.rememberMappedSummaries()
.collectAsState(initial = persistentListOf())
return RoomListSearchState( return RoomListSearchState(
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
@ -103,16 +71,4 @@ class RoomListSearchPresenter @Inject constructor(
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
@Composable
private fun RoomList.rememberMappedSummaries() = remember {
summaries
.map { roomSummaries ->
roomSummaries
.filterIsInstance<RoomSummary.Filled>()
.map(roomSummaryFactory::create)
.toPersistentList()
}
.flowOn(coroutineDispatchers.computation)
}
} }

View file

@ -69,7 +69,6 @@ class RoomListFiltersPresenterTests {
) )
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
assertThat(roomListCurrentFilter.filters).containsExactly( assertThat(roomListCurrentFilter.filters).containsExactly(
MatrixRoomListFilter.NonLeft,
MatrixRoomListFilter.Category.Group, MatrixRoomListFilter.Category.Group,
) )
@ -86,9 +85,7 @@ class RoomListFiltersPresenterTests {
RoomListFilter.Favourites, RoomListFilter.Favourites,
) )
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
assertThat(roomListCurrentFilter.filters).containsExactly( assertThat(roomListCurrentFilter.filters).isEmpty()
MatrixRoomListFilter.NonLeft,
)
} }
} }
} }

View file

@ -79,9 +79,7 @@ class RoomListSearchPresenterTests {
assertThat( assertThat(
roomListService.allRooms.currentFilter.value roomListService.allRooms.currentFilter.value
).isEqualTo( ).isEqualTo(
RoomListFilter.all( RoomListFilter.None
RoomListFilter.None,
)
) )
state.eventSink(RoomListSearchEvents.QueryChanged("Search")) state.eventSink(RoomListSearchEvents.QueryChanged("Search"))
} }
@ -90,10 +88,7 @@ class RoomListSearchPresenterTests {
assertThat( assertThat(
roomListService.allRooms.currentFilter.value roomListService.allRooms.currentFilter.value
).isEqualTo( ).isEqualTo(
RoomListFilter.all( RoomListFilter.NormalizedMatchRoomName("Search")
RoomListFilter.NonLeft,
RoomListFilter.NormalizedMatchRoomName("Search")
)
) )
state.eventSink(RoomListSearchEvents.ClearQuery) state.eventSink(RoomListSearchEvents.ClearQuery)
} }
@ -102,9 +97,7 @@ class RoomListSearchPresenterTests {
assertThat( assertThat(
roomListService.allRooms.currentFilter.value roomListService.allRooms.currentFilter.value
).isEqualTo( ).isEqualTo(
RoomListFilter.all( RoomListFilter.None
RoomListFilter.None,
)
) )
} }
} }
@ -141,11 +134,13 @@ fun TestScope.createRoomListSearchPresenter(
roomListService: RoomListService = FakeRoomListService(), roomListService: RoomListService = FakeRoomListService(),
): RoomListSearchPresenter { ): RoomListSearchPresenter {
return RoomListSearchPresenter( return RoomListSearchPresenter(
roomListService = roomListService, dataSource = RoomListSearchDataSource(
roomSummaryFactory = RoomListRoomSummaryFactory( roomListService = roomListService,
lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(), roomSummaryFactory = RoomListRoomSummaryFactory(
roomLastMessageFormatter = FakeRoomLastMessageFormatter(), lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(),
roomLastMessageFormatter = FakeRoomLastMessageFormatter(),
), ),
coroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers = testCoroutineDispatchers(),
)
) )
} }

View file

@ -47,11 +47,6 @@ sealed interface RoomListFilter {
val filters: List<RoomListFilter> val filters: List<RoomListFilter>
) : RoomListFilter ) : RoomListFilter
/**
* A filter that matches rooms that are not left.
*/
data object NonLeft : RoomListFilter
/** /**
* A filter that matches rooms that are unread. * A filter that matches rooms that are unread.
*/ */
@ -81,11 +76,4 @@ sealed interface RoomListFilter {
data class NormalizedMatchRoomName( data class NormalizedMatchRoomName(
val pattern: String val pattern: String
) : RoomListFilter ) : RoomListFilter
/**
* A filter that matches rooms with a name using a fuzzy match.
*/
data class FuzzyMatchRoomName(
val pattern: String
) : RoomListFilter
} }

View file

@ -17,7 +17,6 @@
package io.element.android.libraries.matrix.api.roomlist package io.element.android.libraries.matrix.api.roomlist
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
/** /**
@ -42,13 +41,11 @@ interface RoomListService {
/** /**
* Creates a room list that can be used to load more rooms and filter them dynamically. * 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 pageSize the number of rooms to load at once.
* @param initialFilter the initial filter to apply to the rooms. * @param initialFilter the initial filter to apply to the rooms.
* @param source the source of the rooms, either all rooms or invites. * @param source the source of the rooms, either all rooms or invites.
*/ */
fun createRoomList( fun createRoomList(
coroutineScope: CoroutineScope,
pageSize: Int, pageSize: Int,
initialFilter: RoomListFilter, initialFilter: RoomListFilter,
source: RoomList.Source, source: RoomList.Source,

View file

@ -23,11 +23,13 @@ import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.launchIn 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.RoomListEntriesDynamicFilterKind
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
@ -50,6 +52,7 @@ internal class RoomListFactory(
innerProvider: suspend () -> InnerRoomList innerProvider: suspend () -> InnerRoomList
): DynamicRoomList { ): DynamicRoomList {
val loadingStateFlow: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded) val loadingStateFlow: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded)
val filteredSummariesFlow = MutableSharedFlow<List<RoomSummary>>(replay = 1, extraBufferCapacity = 1)
val summariesFlow = MutableSharedFlow<List<RoomSummary>>(replay = 1, extraBufferCapacity = 1) val summariesFlow = MutableSharedFlow<List<RoomSummary>>(replay = 1, extraBufferCapacity = 1)
val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryDetailsFactory) val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryDetailsFactory)
// Makes sure we don't miss any events // Makes sure we don't miss any events
@ -63,8 +66,8 @@ internal class RoomListFactory(
innerRoomList?.let { innerRoomList -> innerRoomList?.let { innerRoomList ->
innerRoomList.entriesFlow( innerRoomList.entriesFlow(
pageSize = pageSize, pageSize = pageSize,
initialFilterKind = initialFilter.toRustFilter(), roomListDynamicEvents = dynamicEvents,
roomListDynamicEvents = dynamicEvents initialFilterKind = RoomListEntriesDynamicFilterKind.NonLeft
).onEach { update -> ).onEach { update ->
processor.postUpdate(update) processor.postUpdate(update)
}.launchIn(this) }.launchIn(this)
@ -75,12 +78,21 @@ internal class RoomListFactory(
loadingStateFlow.value = it loadingStateFlow.value = it
} }
.launchIn(this) .launchIn(this)
combine(
currentFilter,
summariesFlow
) { filter, summaries ->
summaries.filter(filter)
}.onEach {
filteredSummariesFlow.emit(it)
}.launchIn(this)
} }
}.invokeOnCompletion { }.invokeOnCompletion {
innerRoomList?.destroy() innerRoomList?.destroy()
} }
return RustDynamicRoomList( return RustDynamicRoomList(
summaries = summariesFlow, summaries = filteredSummariesFlow,
loadingState = loadingStateFlow, loadingState = loadingStateFlow,
currentFilter = currentFilter, currentFilter = currentFilter,
loadedPages = loadedPages, loadedPages = loadedPages,
@ -106,8 +118,6 @@ private class RustDynamicRoomList(
override suspend fun updateFilter(filter: RoomListFilter) { override suspend fun updateFilter(filter: RoomListFilter) {
currentFilter.emit(filter) currentFilter.emit(filter)
val filterEvent = RoomListDynamicEvents.SetFilter(filter.toRustFilter())
dynamicEvents.emit(filterEvent)
} }
override suspend fun loadMore() { override suspend fun loadMore() {

View file

@ -17,20 +17,41 @@
package io.element.android.libraries.matrix.impl.roomlist package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import org.matrix.rustcomponents.sdk.RoomListFilterCategory
fun RoomListFilter.toRustFilter(): RoomListEntriesDynamicFilterKind { val RoomListFilter.predicate
return when (this) { get() = when (this) {
is RoomListFilter.All -> RoomListEntriesDynamicFilterKind.All(filters.map { it.toRustFilter() }) is RoomListFilter.All -> { _: RoomSummary -> true }
is RoomListFilter.Any -> RoomListEntriesDynamicFilterKind.Any(filters.map { it.toRustFilter() }) is RoomListFilter.Any -> { _: RoomSummary -> true }
RoomListFilter.Category.Group -> RoomListEntriesDynamicFilterKind.Category(RoomListFilterCategory.GROUP) RoomListFilter.None -> { _: RoomSummary -> false }
RoomListFilter.Category.People -> RoomListEntriesDynamicFilterKind.Category(RoomListFilterCategory.PEOPLE) RoomListFilter.Category.Group -> { roomSummary: RoomSummary ->
is RoomListFilter.FuzzyMatchRoomName -> RoomListEntriesDynamicFilterKind.FuzzyMatchRoomName(pattern) roomSummary is RoomSummary.Filled && !roomSummary.details.isDirect
RoomListFilter.NonLeft -> RoomListEntriesDynamicFilterKind.NonLeft }
RoomListFilter.None -> RoomListEntriesDynamicFilterKind.None RoomListFilter.Category.People -> { roomSummary: RoomSummary ->
is RoomListFilter.NormalizedMatchRoomName -> RoomListEntriesDynamicFilterKind.NormalizedMatchRoomName(pattern) roomSummary is RoomSummary.Filled && roomSummary.details.isDirect
RoomListFilter.Unread -> RoomListEntriesDynamicFilterKind.Unread }
RoomListFilter.Favorite -> RoomListEntriesDynamicFilterKind.Favourite RoomListFilter.Favorite -> { roomSummary: RoomSummary ->
roomSummary is RoomSummary.Filled && roomSummary.details.isFavorite
}
RoomListFilter.Unread -> { roomSummary: RoomSummary ->
roomSummary is RoomSummary.Filled &&
(roomSummary.details.numUnreadNotifications > 0 || roomSummary.details.isMarkedUnread)
}
is RoomListFilter.NormalizedMatchRoomName -> { roomSummary: RoomSummary ->
roomSummary is RoomSummary.Filled && roomSummary.details.name.contains(pattern, ignoreCase = true)
}
}
fun List<RoomSummary>.filter(filter: RoomListFilter): List<RoomSummary> {
return when (filter) {
is RoomListFilter.All -> {
val predicates = filter.filters.map { it.predicate }
filter { roomSummary -> predicates.all { it(roomSummary) } }
}
is RoomListFilter.Any -> {
val predicates = filter.filters.map { it.predicate }
filter { roomSummary -> predicates.any { it(roomSummary) } }
}
else -> filter(filter.predicate)
} }
} }

View file

@ -47,7 +47,6 @@ internal class RustRoomListService(
private val roomListFactory: RoomListFactory, private val roomListFactory: RoomListFactory,
) : RoomListService { ) : RoomListService {
override fun createRoomList( override fun createRoomList(
coroutineScope: CoroutineScope,
pageSize: Int, pageSize: Int,
initialFilter: RoomListFilter, initialFilter: RoomListFilter,
source: RoomList.Source source: RoomList.Source
@ -55,7 +54,6 @@ internal class RustRoomListService(
return roomListFactory.createRoomList( return roomListFactory.createRoomList(
pageSize = pageSize, pageSize = pageSize,
initialFilter = initialFilter, initialFilter = initialFilter,
coroutineScope = coroutineScope,
coroutineContext = sessionDispatcher, coroutineContext = sessionDispatcher,
) { ) {
when (source) { when (source) {
@ -68,7 +66,6 @@ internal class RustRoomListService(
override val allRooms: DynamicRoomList = roomListFactory.createRoomList( override val allRooms: DynamicRoomList = roomListFactory.createRoomList(
pageSize = DEFAULT_PAGE_SIZE, pageSize = DEFAULT_PAGE_SIZE,
coroutineContext = sessionDispatcher, coroutineContext = sessionDispatcher,
initialFilter = RoomListFilter.all(RoomListFilter.NonLeft),
) { ) {
innerRoomListService.allRooms() innerRoomListService.allRooms()
} }

View file

@ -0,0 +1,126 @@
/*
* 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.libraries.matrix.impl.roomlist
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RoomListFilterTests {
private val regularRoom = aRoomSummaryFilled(
aRoomSummaryDetails(
isDirect = false
)
)
private val directRoom = aRoomSummaryFilled(
aRoomSummaryDetails(
isDirect = true
)
)
private val favoriteRoom = aRoomSummaryFilled(
aRoomSummaryDetails(
isFavorite = true
)
)
private val markedAsUnreadRoom = aRoomSummaryFilled(
aRoomSummaryDetails(
isMarkedUnread = true
)
)
private val unreadNotificationRoom = aRoomSummaryFilled(
aRoomSummaryDetails(
numUnreadNotifications = 1
)
)
private val roomToSearch = aRoomSummaryFilled(
aRoomSummaryDetails(
name = "Room to search"
)
)
private val roomSummaries = listOf(
regularRoom,
directRoom,
favoriteRoom,
markedAsUnreadRoom,
unreadNotificationRoom,
roomToSearch
)
@Test
fun `Room list filter all empty`() = runTest {
val filter = RoomListFilter.all()
assertThat(roomSummaries.filter(filter)).isEqualTo(roomSummaries)
}
@Test
fun `Room list filter none`() = runTest {
val filter = RoomListFilter.None
assertThat(roomSummaries.filter(filter)).isEmpty()
}
@Test
fun `Room list filter people`() = runTest {
val filter = RoomListFilter.Category.People
assertThat(roomSummaries.filter(filter)).containsExactly(directRoom)
}
@Test
fun `Room list filter group`() = runTest {
val filter = RoomListFilter.Category.Group
assertThat(roomSummaries.filter(filter)).containsExactly(regularRoom, favoriteRoom, markedAsUnreadRoom, unreadNotificationRoom, roomToSearch)
}
@Test
fun `Room list filter favorite`() = runTest {
val filter = RoomListFilter.Favorite
assertThat(roomSummaries.filter(filter)).containsExactly(favoriteRoom)
}
@Test
fun `Room list filter unread`() = runTest {
val filter = RoomListFilter.Unread
assertThat(roomSummaries.filter(filter)).containsExactly(markedAsUnreadRoom, unreadNotificationRoom)
}
@Test
fun `Room list filter normalized match room name`() = runTest {
val filter = RoomListFilter.NormalizedMatchRoomName("search")
assertThat(roomSummaries.filter(filter)).containsExactly(roomToSearch)
}
@Test
fun `Room list filter all with one match`() = runTest {
val filter = RoomListFilter.all(
RoomListFilter.Category.Group,
RoomListFilter.Favorite
)
assertThat(roomSummaries.filter(filter)).containsExactly(favoriteRoom)
}
@Test
fun `Room list filter all with no match`() = runTest {
val filter = RoomListFilter.all(
RoomListFilter.Category.People,
RoomListFilter.Favorite
)
assertThat(roomSummaries.filter(filter)).isEmpty()
}
}

View file

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

View file

@ -31,6 +31,7 @@ import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryF
import io.element.android.features.roomlist.impl.filters.RoomListFiltersPresenter import io.element.android.features.roomlist.impl.filters.RoomListFiltersPresenter
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
import io.element.android.features.roomlist.impl.migration.SharedPrefsMigrationScreenStore import io.element.android.features.roomlist.impl.migration.SharedPrefsMigrationScreenStore
import io.element.android.features.roomlist.impl.search.RoomListSearchDataSource
import io.element.android.features.roomlist.impl.search.RoomListSearchPresenter import io.element.android.features.roomlist.impl.search.RoomListSearchPresenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.dateformatter.impl.DateFormatters import io.element.android.libraries.dateformatter.impl.DateFormatters
@ -112,9 +113,11 @@ class RoomListScreen(
migrationScreenStore = SharedPrefsMigrationScreenStore(context.getSharedPreferences("migration", Context.MODE_PRIVATE)) migrationScreenStore = SharedPrefsMigrationScreenStore(context.getSharedPreferences("migration", Context.MODE_PRIVATE))
), ),
searchPresenter = RoomListSearchPresenter( searchPresenter = RoomListSearchPresenter(
roomListService = matrixClient.roomListService, RoomListSearchDataSource(
roomSummaryFactory = roomListRoomSummaryFactory, roomListService = matrixClient.roomListService,
coroutineDispatchers = coroutineDispatchers, roomSummaryFactory = roomListRoomSummaryFactory,
coroutineDispatchers = coroutineDispatchers,
)
), ),
sessionPreferencesStore = DefaultSessionPreferencesStore( sessionPreferencesStore = DefaultSessionPreferencesStore(
context = context, context = context,