Merge pull request #6117 from element-hq/feature/fga/room_list_filter_rust

Refactor room list filtering to use Rust SDK
This commit is contained in:
ganfra 2026-02-02 10:58:21 +01:00 committed by GitHub
commit 440eacc892
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 648 additions and 613 deletions

View file

@ -8,7 +8,6 @@
package io.element.android.libraries.core.extensions
import java.text.Normalizer
import java.util.Locale
fun Boolean.toOnOff() = if (this) "ON" else "OFF"
@ -86,11 +85,6 @@ fun String.safeCapitalize(): String {
}
}
fun String.withoutAccents(): String {
return Normalizer.normalize(this, Normalizer.Form.NFD)
.replace("\\p{Mn}+".toRegex(), "")
}
private const val RTL_OVERRIDE_CHAR = '\u202E'
private const val LTR_OVERRIDE_CHAR = '\u202D'

View file

@ -0,0 +1,35 @@
/*
* 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.designsystem.utils
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
@Composable
fun OnVisibleRangeChangeEffect(lazyListState: LazyListState, onChange: (IntRange) -> Unit) {
val onChangeUpdated by rememberUpdatedState(onChange)
LaunchedEffect(lazyListState) {
snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo }
.map { visibleItemsInfo ->
val firstItemIndex = visibleItemsInfo.firstOrNull()?.index ?: 0
val size = visibleItemsInfo.size
firstItemIndex until firstItemIndex + size
}
.distinctUntilChanged()
.collectLatest { visibleRange ->
onChangeUpdated(visibleRange)
}
}
}

View file

@ -8,25 +8,14 @@
package io.element.android.libraries.matrix.api.roomlist
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
/**
* RoomList with dynamic filtering and loading.
* This is useful for large lists of rooms.
* It lets load rooms on demand and filter them.
*/
interface DynamicRoomList : RoomList {
val currentFilter: StateFlow<RoomListFilter>
val loadedPages: StateFlow<Int>
val pageSize: Int
val filteredSummaries: SharedFlow<List<RoomSummary>>
/**
* Load more rooms into the list if possible.
*/
@ -44,28 +33,13 @@ interface DynamicRoomList : RoomList {
suspend fun updateFilter(filter: RoomListFilter)
}
/**
* Offers a way to load all the rooms incrementally.
* It will load more room until all are loaded.
* If total number of rooms increase, it will load more pages if needed.
* The number of rooms is independent of the filter.
*/
fun DynamicRoomList.loadAllIncrementally(coroutineScope: CoroutineScope) {
combine(
loadedPages,
loadingState,
) { loadedPages, loadingState ->
loadedPages to loadingState
suspend fun DynamicRoomList.updateVisibleRange(
visibleRange: IntRange,
paginationThreshold: Int = pageSize * 3
) {
val loadedCount = summaries.replayCache.firstOrNull().orEmpty().count()
val threshold = loadedCount - paginationThreshold
if (visibleRange.last >= threshold) {
loadMore()
}
.onEach { (loadedPages, loadingState) ->
when (loadingState) {
is RoomList.LoadingState.Loaded -> {
if (pageSize * loadedPages < loadingState.numberOfRooms) {
loadMore()
}
}
RoomList.LoadingState.NotLoaded -> Unit
}
}
.launchIn(coroutineScope)
}

View file

@ -8,8 +8,6 @@
package io.element.android.libraries.matrix.api.roomlist
import io.element.android.libraries.core.extensions.withoutAccents
sealed interface RoomListFilter {
companion object {
/**
@ -77,7 +75,5 @@ sealed interface RoomListFilter {
*/
data class NormalizedMatchRoomName(
val pattern: String
) : RoomListFilter {
val normalizedPattern: String = pattern.withoutAccents()
}
) : RoomListFilter
}

View file

@ -38,13 +38,11 @@ interface RoomListService {
/**
* Creates a room list that can be used to load more rooms and filter them dynamically.
* @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.
* @param coroutineScope the coroutine scope to use for the room list operations.
*/
fun createRoomList(
pageSize: Int,
initialFilter: RoomListFilter,
source: RoomList.Source,
coroutineScope: CoroutineScope,
): DynamicRoomList
@ -56,10 +54,10 @@ interface RoomListService {
suspend fun subscribeToVisibleRooms(roomIds: List<RoomId>)
/**
* Returns a [DynamicRoomList] object of all rooms we want to display.
* Returns a [RoomList] object with all rooms locally known.
* If you want to get a filtered room list, consider using [createRoomList].
*/
val allRooms: DynamicRoomList
val allRooms: RoomList
/**
* The sync indicator as a flow.

View file

@ -1,17 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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 org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
internal sealed interface RoomListDynamicEvents {
data object Reset : RoomListDynamicEvents
data object LoadMore : RoomListDynamicEvents
data class SetFilter(val filter: RoomListEntriesDynamicFilterKind) : RoomListDynamicEvents
}

View file

@ -18,9 +18,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
import org.matrix.rustcomponents.sdk.RoomListEntriesListener
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
@ -57,8 +56,8 @@ fun RoomListInterface.loadingStateFlow(): Flow<RoomListLoadingState> =
internal fun RoomListInterface.entriesFlow(
pageSize: Int,
roomListDynamicEvents: Flow<RoomListDynamicEvents>,
initialFilterKind: RoomListEntriesDynamicFilterKind
initialFilterKind: RoomListEntriesDynamicFilterKind,
onControllerCreated: (RoomListDynamicEntriesController) -> Unit,
): Flow<List<RoomListEntriesUpdate>> =
callbackFlow {
val listener = object : RoomListEntriesListener {
@ -73,19 +72,7 @@ internal fun RoomListInterface.entriesFlow(
)
val controller = result.controller()
controller.setFilter(initialFilterKind)
roomListDynamicEvents.onEach { controllerEvents ->
when (controllerEvents) {
is RoomListDynamicEvents.SetFilter -> {
controller.setFilter(controllerEvents.filter)
}
is RoomListDynamicEvents.LoadMore -> {
controller.addOnePage()
}
is RoomListDynamicEvents.Reset -> {
controller.resetToOnePage()
}
}
}.launchIn(this)
onControllerCreated(controller)
awaitClose {
result.entriesStream().cancelAndDestroy()
controller.destroy()

View file

@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl.roomlist
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.RoomListFilter.Companion.all
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService
@ -18,24 +19,16 @@ import io.element.android.services.analytics.api.finishLongRunningTransaction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
import org.matrix.rustcomponents.sdk.RoomListLoadingState
import org.matrix.rustcomponents.sdk.RoomListService
import kotlin.coroutines.CoroutineContext
import org.matrix.rustcomponents.sdk.RoomList as InnerRoomList
private val ROOM_LIST_RUST_FILTERS = listOf(
RoomListEntriesDynamicFilterKind.NonLeft,
RoomListEntriesDynamicFilterKind.DeduplicateVersions
)
internal class RoomListFactory(
private val innerRoomListService: RoomListService,
private val analyticsService: AnalyticsService,
@ -49,18 +42,14 @@ internal class RoomListFactory(
pageSize: Int,
coroutineContext: CoroutineContext,
coroutineScope: CoroutineScope,
initialFilter: RoomListFilter = RoomListFilter.all(),
initialFilter: RoomListFilter = all(),
innerProvider: suspend () -> InnerRoomList
): DynamicRoomList {
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 processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryFactory, analyticsService)
// 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
var dynamicController: RoomListDynamicEntriesController? = null
val firstRoomsTransaction = analyticsService.startTransaction("Load first set of rooms", "innerRoomList.entriesFlow")
@ -69,8 +58,10 @@ internal class RoomListFactory(
innerRoomList.let { innerRoomList ->
innerRoomList.entriesFlow(
pageSize = pageSize,
roomListDynamicEvents = dynamicEvents,
initialFilterKind = RoomListEntriesDynamicFilterKind.All(ROOM_LIST_RUST_FILTERS),
initialFilterKind = RoomListFilterMapper.toRustFilter(initialFilter),
onControllerCreated = { controller ->
dynamicController = controller
}
).onEach { update ->
if (!firstRoomsTransaction.isFinished()) {
analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.FirstRoomsDisplayed)
@ -85,61 +76,20 @@ internal class RoomListFactory(
loadingStateFlow.value = it
}
.launchIn(this)
combine(
currentFilter,
summariesFlow
) { filter, summaries ->
summaries.filter(filter)
}.onEach {
filteredSummariesFlow.emit(it)
}.launchIn(this)
}
}.invokeOnCompletion {
innerRoomList?.destroy()
}
return RustDynamicRoomList(
summaries = summariesFlow,
filteredSummaries = filteredSummariesFlow,
loadingState = loadingStateFlow,
currentFilter = currentFilter,
loadedPages = loadedPages,
dynamicEvents = dynamicEvents,
processor = processor,
pageSize = pageSize,
dynamicController = { dynamicController }
)
}
}
private class RustDynamicRoomList(
override val summaries: MutableSharedFlow<List<RoomSummary>>,
override val filteredSummaries: SharedFlow<List<RoomSummary>>,
override val loadingState: MutableStateFlow<RoomList.LoadingState>,
override val currentFilter: MutableStateFlow<RoomListFilter>,
override val loadedPages: MutableStateFlow<Int>,
private val dynamicEvents: MutableSharedFlow<RoomListDynamicEvents>,
private val processor: RoomSummaryListProcessor,
override val pageSize: Int,
) : DynamicRoomList {
override suspend fun rebuildSummaries() {
processor.rebuildRoomSummaries()
}
override suspend fun updateFilter(filter: RoomListFilter) {
currentFilter.emit(filter)
}
override suspend fun loadMore() {
dynamicEvents.emit(RoomListDynamicEvents.LoadMore)
loadedPages.getAndUpdate { it + 1 }
}
override suspend fun reset() {
dynamicEvents.emit(RoomListDynamicEvents.Reset)
loadedPages.emit(1)
}
}
private fun RoomListLoadingState.toLoadingState(): RoomList.LoadingState {
return when (this) {
is RoomListLoadingState.Loaded -> RoomList.LoadingState.Loaded(maximumNumberOfRooms?.toInt() ?: 0)

View file

@ -1,72 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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 io.element.android.libraries.core.extensions.withoutAccents
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
val RoomListFilter.predicate
get() = when (this) {
is RoomListFilter.All -> { roomSummary -> NonSpacePredicate(roomSummary) || IsInvitedPredicate(roomSummary) }
is RoomListFilter.Any -> { roomSummary -> NonSpacePredicate(roomSummary) || IsInvitedPredicate(roomSummary) }
RoomListFilter.None -> { _ -> false }
RoomListFilter.Category.Group -> { roomSummary: RoomSummary ->
!roomSummary.info.isDm && NonInvitedPredicate(roomSummary) && NonSpacePredicate(roomSummary)
}
RoomListFilter.Category.People -> { roomSummary: RoomSummary ->
roomSummary.info.isDm && NonInvitedPredicate(roomSummary) && NonSpacePredicate(roomSummary)
}
RoomListFilter.Category.Space -> IsSpacePredicate
RoomListFilter.Favorite -> { roomSummary: RoomSummary ->
roomSummary.info.isFavorite && NonInvitedPredicate(roomSummary) && NonSpacePredicate(roomSummary)
}
RoomListFilter.Unread -> { roomSummary: RoomSummary ->
NonInvitedPredicate(roomSummary) &&
NonSpacePredicate(roomSummary) &&
(roomSummary.info.numUnreadNotifications > 0 || roomSummary.info.isMarkedUnread)
}
is RoomListFilter.NormalizedMatchRoomName -> { roomSummary: RoomSummary ->
roomSummary.info.name?.withoutAccents().orEmpty().contains(normalizedPattern, ignoreCase = true) &&
(NonSpacePredicate(roomSummary) || IsInvitedPredicate(roomSummary))
}
RoomListFilter.Invite -> IsInvitedPredicate
}
fun List<RoomSummary>.filter(filter: RoomListFilter): List<RoomSummary> {
return when (filter) {
is RoomListFilter.All -> {
val predicates = if (filter.filters.isNotEmpty()) {
filter.filters.map { it.predicate }
} else {
listOf(filter.predicate)
}
filter { roomSummary -> predicates.all { it(roomSummary) } }
}
is RoomListFilter.Any -> {
val predicates = if (filter.filters.isNotEmpty()) {
filter.filters.map { it.predicate }
} else {
listOf(filter.predicate)
}
filter { roomSummary -> predicates.any { it(roomSummary) } }
}
else -> filter(filter.predicate)
}
}
private val IsSpacePredicate = { roomSummary: RoomSummary -> roomSummary.info.isSpace }
private val NonSpacePredicate = { roomSummary: RoomSummary -> !IsSpacePredicate(roomSummary) }
private val IsInvitedPredicate = { roomSummary: RoomSummary -> roomSummary.info.currentUserMembership == CurrentUserMembership.INVITED }
private val NonInvitedPredicate = { roomSummary: RoomSummary -> !IsInvitedPredicate(roomSummary) }

View file

@ -0,0 +1,75 @@
/*
* 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 io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.All
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Any
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Category
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.DeduplicateVersions
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Favourite
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Invite
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NonLeft
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NonSpace
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.None
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NormalizedMatchRoomName
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Space
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Unread
import org.matrix.rustcomponents.sdk.RoomListFilterCategory
/**
* Mapper for converting RoomListFilter to Rust SDK filter kinds.
*/
internal object RoomListFilterMapper {
/**
* Base rust filters to always apply across all room lists.
* These filters ensure we show:
* - Non-space, non-left rooms (regular rooms user is part of)
* - OR space invites (pending space invitations)
* - With version deduplication enabled
*/
private val RUST_BASE_FILTERS = listOf(
Any(
listOf(
All(listOf(NonSpace, NonLeft)),
All(listOf(Space, Invite)),
)
),
DeduplicateVersions
)
/**
* Converts a RoomListFilter to a Rust SDK RoomListEntriesDynamicFilterKind.
* Applies base filters along with the provided filter.
*/
fun toRustFilter(filter: RoomListFilter): RoomListEntriesDynamicFilterKind {
return All(RUST_BASE_FILTERS + mapFilter(filter))
}
/**
* Maps a RoomListFilter to its Rust SDK equivalent.
* This replaces the previous RoomListFilter.into() extension function.
*/
private fun mapFilter(filter: RoomListFilter): RoomListEntriesDynamicFilterKind {
return when (filter) {
is RoomListFilter.All -> All(filters = filter.filters.map { mapFilter(it) })
is RoomListFilter.Any -> Any(filters = filter.filters.map { mapFilter(it) })
RoomListFilter.None -> None
RoomListFilter.Category.Group -> Category(RoomListFilterCategory.GROUP)
RoomListFilter.Category.People -> Category(RoomListFilterCategory.PEOPLE)
RoomListFilter.Category.Space -> Space
RoomListFilter.Favorite -> Favourite
RoomListFilter.Unread -> Unread
is RoomListFilter.NormalizedMatchRoomName -> NormalizedMatchRoomName(
pattern = filter.pattern
)
RoomListFilter.Invite -> Invite
}
}
}

View file

@ -0,0 +1,62 @@
/*
* 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 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.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
private const val DEFAULT_ADD_PAGES_COUNT = 3
internal class RustDynamicRoomList(
override val summaries: MutableSharedFlow<List<RoomSummary>>,
override val loadingState: MutableStateFlow<RoomList.LoadingState>,
private val processor: RoomSummaryListProcessor,
override val pageSize: Int,
private val dynamicController: () -> RoomListDynamicEntriesController?,
private val addPagesCount: Int = DEFAULT_ADD_PAGES_COUNT
) : DynamicRoomList {
private val mutex = Mutex()
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)
}
}
}
override suspend fun loadMore() {
mutex.withLock {
dynamicController()?.addPages(addPagesCount)
}
}
override suspend fun reset() {
mutex.withLock {
dynamicController()?.resetToOnePage()
}
}
private fun RoomListDynamicEntriesController.addPages(pageCount: Int) = repeat(pageCount) { addOnePage() }
}

View file

@ -11,9 +11,7 @@ package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.core.RoomId
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.RoomListService
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@ -28,8 +26,6 @@ import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator
import timber.log.Timber
import org.matrix.rustcomponents.sdk.RoomListService as InnerRustRoomListService
private const val DEFAULT_PAGE_SIZE = 20
internal class RustRoomListService(
private val innerRoomListService: InnerRustRoomListService,
private val sessionDispatcher: CoroutineDispatcher,
@ -39,13 +35,11 @@ internal class RustRoomListService(
) : RoomListService {
override fun createRoomList(
pageSize: Int,
initialFilter: RoomListFilter,
source: RoomList.Source,
coroutineScope: CoroutineScope,
): DynamicRoomList {
return roomListFactory.createRoomList(
pageSize = pageSize,
initialFilter = initialFilter,
coroutineContext = sessionDispatcher,
coroutineScope = coroutineScope,
) {
@ -59,18 +53,14 @@ internal class RustRoomListService(
roomSyncSubscriber.batchSubscribe(roomIds)
}
override val allRooms: DynamicRoomList = roomListFactory.createRoomList(
pageSize = DEFAULT_PAGE_SIZE,
override val allRooms: RoomList = roomListFactory.createRoomList(
pageSize = Int.MAX_VALUE,
coroutineContext = sessionDispatcher,
coroutineScope = sessionCoroutineScope,
) {
innerRoomListService.allRooms()
}
init {
allRooms.loadAllIncrementally(sessionCoroutineScope)
}
override val syncIndicator: StateFlow<RoomListService.SyncIndicator> =
innerRoomListService.syncIndicator()
.map { it.toSyncIndicator() }

View file

@ -1,156 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.test.room.aRoomSummary
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RoomListFilterTest {
private val regularRoom = aRoomSummary(
isDirect = false,
)
private val dmRoom = aRoomSummary(
isDirect = true,
activeMembersCount = 2
)
private val favoriteRoom = aRoomSummary(
isFavorite = true
)
private val markedAsUnreadRoom = aRoomSummary(
isMarkedUnread = true
)
private val unreadNotificationRoom = aRoomSummary(
numUnreadNotifications = 1
)
private val roomToSearch = aRoomSummary(
name = "Room to search"
)
private val roomWithAccent = aRoomSummary(
name = "Frédéric"
)
private val invitedRoom = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED
)
private val space = aRoomSummary(
isSpace = true
)
private val invitedSpace = aRoomSummary(
isSpace = true,
currentUserMembership = CurrentUserMembership.INVITED
)
private val roomSummaries = listOf(
regularRoom,
dmRoom,
favoriteRoom,
markedAsUnreadRoom,
unreadNotificationRoom,
roomToSearch,
roomWithAccent,
invitedRoom,
space,
invitedSpace,
)
@Test
fun `Room list filter all empty`() = runTest {
val filter = RoomListFilter.all()
assertThat(roomSummaries.filter(filter)).isEqualTo(roomSummaries - space)
}
@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(dmRoom)
}
@Test
fun `Room list filter group`() = runTest {
val filter = RoomListFilter.Category.Group
assertThat(roomSummaries.filter(filter)).containsExactly(
regularRoom,
favoriteRoom,
markedAsUnreadRoom,
unreadNotificationRoom,
roomToSearch,
roomWithAccent,
)
}
@Test
fun `Room list filter space`() = runTest {
val filter = RoomListFilter.Category.Space
assertThat(roomSummaries.filter(filter)).containsExactly(space, invitedSpace)
}
@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 invites`() = runTest {
val filter = RoomListFilter.Invite
assertThat(roomSummaries.filter(filter)).containsExactly(invitedRoom, invitedSpace)
}
@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 normalized match room name with accent`() = runTest {
val filter = RoomListFilter.NormalizedMatchRoomName("Fred")
assertThat(roomSummaries.filter(filter)).containsExactly(roomWithAccent)
}
@Test
fun `Room list filter normalized match room name with accent when searching with accent`() = runTest {
val filter = RoomListFilter.NormalizedMatchRoomName("Fréd")
assertThat(roomSummaries.filter(filter)).containsExactly(roomWithAccent)
}
@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

@ -13,34 +13,30 @@ 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.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate
data class SimplePagedRoomList(
override val summaries: MutableStateFlow<List<RoomSummary>>,
override val loadingState: StateFlow<RoomList.LoadingState>,
override val currentFilter: MutableStateFlow<RoomListFilter>
class FakeDynamicRoomList(
override val summaries: MutableStateFlow<List<RoomSummary>> = MutableStateFlow(emptyList()),
override val loadingState: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded),
override val pageSize: Int = Int.MAX_VALUE,
val currentFilter: MutableStateFlow<RoomListFilter> = MutableStateFlow(RoomListFilter.None),
private val loadMoreLambda: () -> Unit = {},
private val resetLambda: () -> Unit = {},
private val updateFilterLambda: (RoomListFilter) -> Unit = { filter -> currentFilter.value = filter },
private val rebuildSummariesLambda: () -> Unit = {},
) : DynamicRoomList {
override val pageSize: Int = Int.MAX_VALUE
override val loadedPages = MutableStateFlow(1)
override val filteredSummaries: SharedFlow<List<RoomSummary>> = summaries
override suspend fun loadMore() {
// No-op
loadedPages.getAndUpdate { it + 1 }
loadMoreLambda()
}
override suspend fun reset() {
loadedPages.emit(1)
resetLambda()
}
override suspend fun updateFilter(filter: RoomListFilter) {
currentFilter.emit(filter)
updateFilterLambda(filter)
}
override suspend fun rebuildSummaries() {
// No-op
rebuildSummariesLambda()
}
}

View file

@ -11,29 +11,19 @@ package io.element.android.libraries.matrix.test.roomlist
import io.element.android.libraries.matrix.api.core.RoomId
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.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class FakeRoomListService(
var subscribeToVisibleRoomsLambda: (List<RoomId>) -> Unit = {},
private val subscribeToVisibleRoomsLambda: (List<RoomId>) -> Unit = {},
private val createRoomListLambda: (pageSize: Int) -> DynamicRoomList = { pageSize -> FakeDynamicRoomList(pageSize = pageSize) },
override val allRooms: RoomList = createRoomListLambda(Int.MAX_VALUE),
) : RoomListService {
private val allRoomSummariesFlow = MutableStateFlow<List<RoomSummary>>(emptyList())
private val allRoomsLoadingStateFlow = MutableStateFlow<RoomList.LoadingState>(RoomList.LoadingState.NotLoaded)
private val roomListStateFlow = MutableStateFlow<RoomListService.State>(RoomListService.State.Idle)
private val syncIndicatorStateFlow = MutableStateFlow<RoomListService.SyncIndicator>(RoomListService.SyncIndicator.Hide)
suspend fun postAllRooms(roomSummaries: List<RoomSummary>) {
allRoomSummariesFlow.emit(roomSummaries)
}
suspend fun postAllRoomsLoadingState(loadingState: RoomList.LoadingState) {
allRoomsLoadingStateFlow.emit(loadingState)
}
suspend fun postState(state: RoomListService.State) {
roomListStateFlow.emit(state)
}
@ -44,25 +34,14 @@ class FakeRoomListService(
override fun createRoomList(
pageSize: Int,
initialFilter: RoomListFilter,
source: RoomList.Source,
coroutineScope: CoroutineScope,
): DynamicRoomList {
return when (source) {
RoomList.Source.All -> allRooms
}
}
) = createRoomListLambda(pageSize)
override suspend fun subscribeToVisibleRooms(roomIds: List<RoomId>) {
subscribeToVisibleRoomsLambda(roomIds)
}
override val allRooms = SimplePagedRoomList(
allRoomSummariesFlow,
allRoomsLoadingStateFlow,
MutableStateFlow(RoomListFilter.all())
)
override val state: StateFlow<RoomListService.State> = roomListStateFlow
override val syncIndicator: StateFlow<RoomListService.SyncIndicator> = syncIndicatorStateFlow

View file

@ -16,6 +16,12 @@ plugins {
android {
namespace = "io.element.android.libraries.roomselect.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupDependencyInjection()
@ -30,6 +36,6 @@ dependencies {
implementation(projects.libraries.uiStrings)
api(projects.libraries.roomselect.api)
testCommonDependencies(libs)
testCommonDependencies(libs, includeTestComposeView = true)
testImplementation(projects.libraries.matrix.test)
}

View file

@ -16,4 +16,5 @@ sealed interface RoomSelectEvents {
// TODO remove to restore multi-selection
data object RemoveSelectedRoom : RoomSelectEvents
data object ToggleSearchActive : RoomSelectEvents
data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvents
}

View file

@ -29,6 +29,7 @@ import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@AssistedInject
class RoomSelectPresenter(
@ -80,6 +81,9 @@ class RoomSelectPresenter(
}
RoomSelectEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf()
RoomSelectEvents.ToggleSearchActive -> isSearchActive = !isSearchActive
is RoomSelectEvents.UpdateVisibleRange -> coroutineScope.launch {
dataSource.updateVisibleRange(event.range)
}
}
}

View file

@ -16,7 +16,7 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
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.loadAllIncrementally
import io.element.android.libraries.matrix.api.roomlist.updateVisibleRange
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import kotlinx.collections.immutable.ImmutableList
@ -46,14 +46,11 @@ class RoomSelectSearchDataSource(
private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE,
initialFilter = RoomListFilter.all(),
source = RoomList.Source.All,
coroutineScope = coroutineScope
).apply {
loadAllIncrementally(coroutineScope)
}
)
val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = roomList.filteredSummaries
val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = roomList.summaries
.map { roomSummaries ->
roomSummaries
.filter { it.info.currentUserMembership == CurrentUserMembership.JOINED }
@ -63,6 +60,10 @@ class RoomSelectSearchDataSource(
}
.flowOn(coroutineDispatchers.computation)
suspend fun updateVisibleRange(visibleRange: IntRange) {
roomList.updateVisibleRange(visibleRange)
}
suspend fun setSearchQuery(searchQuery: String) = coroutineScope {
val filter = if (searchQuery.isBlank()) {
RoomListFilter.all()

View file

@ -43,22 +43,23 @@ open class RoomSelectStateProvider : PreviewParameterProvider<RoomSelectState> {
)
}
private fun aRoomSelectState(
internal fun aRoomSelectState(
mode: RoomSelectMode = RoomSelectMode.Forward,
resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial(),
searchQuery: String = "",
isSearchActive: Boolean = false,
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
eventSink: (RoomSelectEvents) -> Unit = {},
) = RoomSelectState(
mode = mode,
resultState = resultState,
searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
eventSink = {}
eventSink = eventSink,
)
private fun aRoomSelectRoomList() = persistentListOf(
internal fun aRoomSelectRoomList() = persistentListOf(
aSelectRoomInfo(
roomId = RoomId("!room1:domain"),
name = "Room with name",

View file

@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.padding
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.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -51,6 +52,7 @@ import io.element.android.libraries.designsystem.theme.components.SearchBarResul
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
import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.SelectedRoom
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
@ -100,6 +102,11 @@ fun RoomSelectView(
onBack = { onBackButton(state) }
)
val lazyListState = rememberLazyListState()
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(RoomSelectEvents.UpdateVisibleRange(visibleRange))
}
Scaffold(
modifier = modifier,
topBar = {
@ -138,7 +145,7 @@ fun RoomSelectView(
resultState = state.resultState,
showBackButton = false,
) { summaries ->
LazyColumn {
LazyColumn(state = lazyListState) {
item {
SelectedRoomsHelper(
// TODO state.isForwarding
@ -170,7 +177,7 @@ fun RoomSelectView(
Spacer(modifier = Modifier.height(20.dp))
if (state.resultState is SearchBarResultState.Results) {
LazyColumn {
LazyColumn(state = lazyListState) {
items(state.resultState.results, key = { it.roomId.value }) { roomSummary ->
Column {
RoomSummaryView(

View file

@ -17,13 +17,17 @@ import io.element.android.libraries.designsystem.theme.components.SearchBarResul
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@ -63,9 +67,12 @@ class RoomSelectPresenterTest {
@Test
fun `present - update query`() = runTest {
val roomSummary = aRoomSummary()
val roomListService = FakeRoomListService().apply {
postAllRooms(listOf(roomSummary))
}
val roomList = FakeDynamicRoomList(
summaries = MutableStateFlow(listOf(roomSummary))
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createRoomSelectPresenter(
roomListService = roomListService
)
@ -81,12 +88,12 @@ class RoomSelectPresenterTest {
skipItems(1)
initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained")
assertThat(
roomListService.allRooms.currentFilter.value
roomList.currentFilter.value
).isEqualTo(
RoomListFilter.NormalizedMatchRoomName("string not contained")
)
assertThat(awaitItem().searchQuery.text.toString()).isEqualTo("string not contained")
roomListService.postAllRooms(
roomList.summaries.emit(
emptyList()
)
assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResultsFound::class.java)
@ -96,9 +103,12 @@ class RoomSelectPresenterTest {
@Test
fun `present - select and remove a room`() = runTest {
val roomSummary = aRoomSummary()
val roomListService = FakeRoomListService().apply {
postAllRooms(listOf(roomSummary))
}
val roomList = FakeDynamicRoomList(
summaries = MutableStateFlow(listOf(roomSummary))
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createRoomSelectPresenter(
roomListService = roomListService,
)
@ -114,6 +124,35 @@ class RoomSelectPresenterTest {
cancel()
}
}
@Test
fun `present - UpdateVisibleRange triggers pagination when near end`() = runTest {
val loadMoreLambda = lambdaRecorder<Unit> { }
val roomList = FakeDynamicRoomList(
summaries = MutableStateFlow(listOf()),
loadMoreLambda = loadMoreLambda,
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createRoomSelectPresenter(roomListService = roomListService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Post some rooms to simulate loaded content
val rooms = (1..10).map { aRoomSummary() }
roomList.summaries.emit(rooms)
skipItems(1)
// UpdateVisibleRange near end should trigger loadMore
initialState.eventSink(RoomSelectEvents.UpdateVisibleRange(IntRange(0, 9)))
// Give time for the coroutine to complete
testScheduler.advanceUntilIdle()
assert(loadMoreLambda).isCalledOnce()
}
}
}
internal fun TestScope.createRoomSelectPresenter(