Refactor room list filtering to use Rust SDK

This commit is contained in:
ganfra 2026-01-28 15:50:34 +01:00
parent 3d75b92ec3
commit 0824a3ab8b
33 changed files with 358 additions and 481 deletions

View file

@ -24,9 +24,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -55,6 +53,7 @@ import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -215,17 +214,8 @@ private fun RoomsViewList(
lazyListState: LazyListState, lazyListState: LazyListState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val visibleRange by remember { OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
derivedStateOf { eventSink(RoomListEvent.UpdateVisibleRange(visibleRange))
val layoutInfo = lazyListState.layoutInfo
val firstItemIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0
val size = layoutInfo.visibleItemsInfo.size
firstItemIndex until firstItemIndex + size
}
}
val updatedEventSink by rememberUpdatedState(newValue = eventSink)
LaunchedEffect(visibleRange) {
updatedEventSink(RoomListEvent.UpdateVisibleRange(visibleRange))
} }
LazyColumn( LazyColumn(
state = lazyListState, state = lazyListState,
@ -237,7 +227,7 @@ private fun RoomsViewList(
item { item {
SetUpRecoveryKeyBanner( SetUpRecoveryKeyBanner(
onContinueClick = onSetUpRecoveryClick, onContinueClick = onSetUpRecoveryClick,
onDismissClick = { updatedEventSink(RoomListEvent.DismissBanner) }, onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
) )
} }
} }
@ -245,7 +235,7 @@ private fun RoomsViewList(
item { item {
ConfirmRecoveryKeyBanner( ConfirmRecoveryKeyBanner(
onContinueClick = onConfirmRecoveryKeyClick, onContinueClick = onConfirmRecoveryKeyClick,
onDismissClick = { updatedEventSink(RoomListEvent.DismissBanner) }, onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
) )
} }
} }
@ -260,7 +250,7 @@ private fun RoomsViewList(
} else if (state.showNewNotificationSoundBanner) { } else if (state.showNewNotificationSoundBanner) {
item { item {
NewNotificationSoundBanner( NewNotificationSoundBanner(
onDismissClick = { updatedEventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) },
) )
} }
} }

View file

@ -9,33 +9,48 @@
package io.element.android.features.home.impl.datasource package io.element.android.features.home.impl.datasource
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.features.home.impl.model.RoomListRoomSummary import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.libraries.androidutils.diff.DiffCacheUpdater import io.element.android.libraries.androidutils.diff.DiffCacheUpdater
import io.element.android.libraries.androidutils.diff.MutableListDiffCache import io.element.android.libraries.androidutils.diff.MutableListDiffCache
import io.element.android.libraries.androidutils.system.DateTimeObserver import io.element.android.libraries.androidutils.system.DateTimeObserver
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
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.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.roomlist.updateVisibleRange
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.lang.IllegalStateException
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
private const val PAGE_SIZE = 20
private const val EXTENDED_VISIBILITY_RANGE_SIZE = 40
private const val SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS = 300L
private const val PAGINATION_THRESHOLD = 3 * PAGE_SIZE
@Inject @Inject
@SingleIn(SessionScope::class)
class RoomListDataSource( class RoomListDataSource(
private val roomListService: RoomListService, private val roomListService: RoomListService,
private val roomListRoomSummaryFactory: RoomListRoomSummaryFactory, private val roomListRoomSummaryFactory: RoomListRoomSummaryFactory,
@ -51,7 +66,12 @@ class RoomListDataSource(
observeDateTimeChanges() observeDateTimeChanges()
} }
private val _allRooms = MutableSharedFlow<ImmutableList<RoomListRoomSummary>>(replay = 1) private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE,
source = RoomList.Source.All,
coroutineScope = sessionCoroutineScope
)
private val _roomSummariesFlow = MutableSharedFlow<ImmutableList<RoomListRoomSummary>>(replay = 1)
private val lock = Mutex() private val lock = Mutex()
private val diffCache = MutableListDiffCache<RoomListRoomSummary>() private val diffCache = MutableListDiffCache<RoomListRoomSummary>()
@ -59,22 +79,49 @@ class RoomListDataSource(
old?.roomId == new?.roomId old?.roomId == new?.roomId
} }
val allRooms: Flow<ImmutableList<RoomListRoomSummary>> = _allRooms val roomSummariesFlow: Flow<ImmutableList<RoomListRoomSummary>> = _roomSummariesFlow
val loadingState = roomListService.allRooms.loadingState val loadingState = roomList.loadingState
fun launchIn(coroutineScope: CoroutineScope) { fun launchIn(coroutineScope: CoroutineScope) {
roomListService roomList
.allRooms .summaries
.filteredSummaries
.onEach { roomSummaries -> .onEach { roomSummaries ->
replaceWith(roomSummaries) replaceWith(roomSummaries)
} }
.launchIn(coroutineScope) .launchIn(coroutineScope)
} }
suspend fun subscribeToVisibleRooms(roomIds: List<RoomId>) { suspend fun updateFilter(filter: RoomListFilter) {
roomListService.subscribeToVisibleRooms(roomIds) roomList.updateFilter(filter)
}
suspend fun updateVisibleRange(visibleRange: IntRange) = coroutineScope {
launch {
roomList.updateVisibleRange(visibleRange, PAGINATION_THRESHOLD)
}
launch {
subscribeToVisibleRoomsIfNeeded(visibleRange)
}
}
private var currentSubscribeToVisibleRoomsJob: Job? = null
private fun CoroutineScope.subscribeToVisibleRoomsIfNeeded(range: IntRange) {
currentSubscribeToVisibleRoomsJob?.cancel()
currentSubscribeToVisibleRoomsJob = launch {
// Debounce the subscription to avoid subscribing to too many rooms
delay(SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS)
if (range.isEmpty()) return@launch
val currentRoomList = roomSummariesFlow.first()
// Use extended range to 'prefetch' the next rooms info
val midExtendedRangeSize = EXTENDED_VISIBILITY_RANGE_SIZE / 2
val extendedRange = range.first until range.last + midExtendedRangeSize
val roomIds = extendedRange.mapNotNull { index ->
currentRoomList.getOrNull(index)?.roomId
}
roomListService.subscribeToVisibleRooms(roomIds)
}
} }
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
@ -82,7 +129,7 @@ class RoomListDataSource(
notificationSettingsService.notificationSettingsChangeFlow notificationSettingsService.notificationSettingsChangeFlow
.debounce(0.5.seconds) .debounce(0.5.seconds)
.onEach { .onEach {
roomListService.allRooms.rebuildSummaries() roomList.rebuildSummaries()
} }
.launchIn(sessionCoroutineScope) .launchIn(sessionCoroutineScope)
} }
@ -108,6 +155,7 @@ class RoomListDataSource(
private suspend fun buildAndEmitAllRooms(roomSummaries: List<RoomSummary>, useCache: Boolean = true) { private suspend fun buildAndEmitAllRooms(roomSummaries: List<RoomSummary>, useCache: Boolean = true) {
// Used to detect duplicates in the room list summaries - see comment below // Used to detect duplicates in the room list summaries - see comment below
data class CacheResult(val index: Int, val fromCache: Boolean) data class CacheResult(val index: Int, val fromCache: Boolean)
val cachingResults = mutableMapOf<RoomId, MutableList<CacheResult>>() val cachingResults = mutableMapOf<RoomId, MutableList<CacheResult>>()
val roomListRoomSummaries = diffCache.indices().mapNotNull { index -> val roomListRoomSummaries = diffCache.indices().mapNotNull { index ->
@ -144,14 +192,14 @@ class RoomListDataSource(
analyticsService.trackError( analyticsService.trackError(
IllegalStateException( IllegalStateException(
"Found duplicates in room summaries after a local UI update: $duplicates. " + "Found duplicates in room summaries after a local UI update: $duplicates. " +
"This could be a race condition/caching issue of some kind" "This could be a race condition/caching issue of some kind"
) )
) )
// Remove duplicates before emitting the new values // Remove duplicates before emitting the new values
_allRooms.emit(roomListRoomSummaries.distinctBy { it.roomId }.toImmutableList()) _roomSummariesFlow.emit(roomListRoomSummaries.distinctBy { it.roomId }.toImmutableList())
} else { } else {
_allRooms.emit(roomListRoomSummaries.toImmutableList()) _roomSummariesFlow.emit(roomListRoomSummaries.toImmutableList())
} }
} }
@ -163,7 +211,7 @@ class RoomListDataSource(
private suspend fun rebuildAllRoomSummaries() { private suspend fun rebuildAllRoomSummaries() {
lock.withLock { lock.withLock {
roomListService.allRooms.filteredSummaries.replayCache.firstOrNull()?.let { roomSummaries -> roomList.summaries.replayCache.firstOrNull()?.let { roomSummaries ->
buildAndEmitAllRooms(roomSummaries, useCache = false) buildAndEmitAllRooms(roomSummaries, useCache = false)
} }
} }

View file

@ -12,16 +12,19 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.home.impl.datasource.RoomListDataSource
import io.element.android.features.home.impl.filters.selection.FilterSelectionStrategy import io.element.android.features.home.impl.filters.selection.FilterSelectionStrategy
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlin.time.Duration.Companion.milliseconds
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
@Inject @Inject
class RoomListFiltersPresenter( class RoomListFiltersPresenter(
private val roomListService: RoomListService, private val roomListDataSource: RoomListDataSource,
private val filterSelectionStrategy: FilterSelectionStrategy, private val filterSelectionStrategy: FilterSelectionStrategy,
) : Presenter<RoomListFiltersState> { ) : Presenter<RoomListFiltersState> {
private val initialFilters = filterSelectionStrategy.filterSelectionStates.value.toImmutableList() private val initialFilters = filterSelectionStrategy.filterSelectionStates.value.toImmutableList()
@ -56,9 +59,9 @@ class RoomListFiltersPresenter(
} }
} }
} }
.collect { filters -> .collectLatest { filters ->
val result = MatrixRoomListFilter.All(filters) val result = MatrixRoomListFilter.All(filters)
roomListService.allRooms.updateFilter(result) roomListDataSource.updateFilter(result)
} }
} }

View file

@ -57,8 +57,6 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -68,9 +66,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private const val EXTENDED_RANGE_SIZE = 40
private const val SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS = 300L
@Inject @Inject
class RoomListPresenter( class RoomListPresenter(
private val client: MatrixClient, private val client: MatrixClient,
@ -119,7 +114,7 @@ class RoomListPresenter(
fun handleEvent(event: RoomListEvent) { fun handleEvent(event: RoomListEvent) {
when (event) { when (event) {
is RoomListEvent.UpdateVisibleRange -> coroutineScope.launch { is RoomListEvent.UpdateVisibleRange -> coroutineScope.launch {
updateVisibleRange(event.range) roomListDataSource.updateVisibleRange(event.range)
} }
RoomListEvent.DismissRequestVerificationPrompt -> securityBannerDismissed = true RoomListEvent.DismissRequestVerificationPrompt -> securityBannerDismissed = true
RoomListEvent.DismissBanner -> securityBannerDismissed = true RoomListEvent.DismissBanner -> securityBannerDismissed = true
@ -217,7 +212,7 @@ class RoomListPresenter(
showNewNotificationSoundBanner: Boolean, showNewNotificationSoundBanner: Boolean,
): RoomListContentState { ): RoomListContentState {
val roomSummaries by produceState(initialValue = AsyncData.Loading()) { val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
roomListDataSource.allRooms.collect { value = AsyncData.Success(it) } roomListDataSource.roomSummariesFlow.collect { value = AsyncData.Success(it) }
} }
val loadingState by roomListDataSource.loadingState.collectAsState() val loadingState by roomListDataSource.loadingState.collectAsState()
val showEmpty by remember { val showEmpty by remember {
@ -323,22 +318,5 @@ class RoomListPresenter(
} }
} }
private var currentUpdateVisibleRangeJob: Job? = null
private fun CoroutineScope.updateVisibleRange(range: IntRange) {
currentUpdateVisibleRangeJob?.cancel()
currentUpdateVisibleRangeJob = launch {
// Debounce the subscription to avoid subscribing to too many rooms
delay(SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS)
if (range.isEmpty()) return@launch
val currentRoomList = roomListDataSource.allRooms.first()
// Use extended range to 'prefetch' the next rooms info
val midExtendedRangeSize = EXTENDED_RANGE_SIZE / 2
val extendedRange = range.first until range.last + midExtendedRangeSize
val roomIds = extendedRange.mapNotNull { index ->
currentRoomList.getOrNull(index)?.roomId
}
roomListDataSource.subscribeToVisibleRooms(roomIds)
}
}
} }

View file

@ -17,7 +17,7 @@ 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.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.loadAllIncrementally import io.element.android.libraries.matrix.api.roomlist.updateVisibleRange
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -42,12 +42,11 @@ class RoomListSearchDataSource(
private val roomList = roomListService.createRoomList( private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE, pageSize = PAGE_SIZE,
initialFilter = RoomListFilter.None,
source = RoomList.Source.All, source = RoomList.Source.All,
coroutineScope = coroutineScope coroutineScope = coroutineScope
) )
val roomSummaries: Flow<ImmutableList<RoomListRoomSummary>> = roomList.filteredSummaries val roomSummaries: Flow<ImmutableList<RoomListRoomSummary>> = roomList.summaries
.map { roomSummaries -> .map { roomSummaries ->
roomSummaries roomSummaries
.map(roomSummaryFactory::create) .map(roomSummaryFactory::create)
@ -55,12 +54,8 @@ class RoomListSearchDataSource(
} }
.flowOn(coroutineDispatchers.computation) .flowOn(coroutineDispatchers.computation)
suspend fun setIsActive(isActive: Boolean) = coroutineScope { suspend fun updateVisibleRange(visibleRange: IntRange) {
if (isActive) { roomList.updateVisibleRange(visibleRange)
roomList.loadAllIncrementally(this)
} else {
roomList.reset()
}
} }
suspend fun setSearchQuery(searchQuery: String) = coroutineScope { suspend fun setSearchQuery(searchQuery: String) = coroutineScope {
@ -71,4 +66,5 @@ class RoomListSearchDataSource(
} }
roomList.updateFilter(filter) roomList.updateFilter(filter)
} }
} }

View file

@ -11,4 +11,5 @@ package io.element.android.features.home.impl.search
sealed interface RoomListSearchEvent { sealed interface RoomListSearchEvent {
data object ToggleSearchVisibility : RoomListSearchEvent data object ToggleSearchVisibility : RoomListSearchEvent
data object ClearQuery : RoomListSearchEvent data object ClearQuery : RoomListSearchEvent
data class UpdateVisibleRange(val range: IntRange) : RoomListSearchEvent
} }

View file

@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
@Inject @Inject
class RoomListSearchPresenter( class RoomListSearchPresenter(
@ -37,10 +38,6 @@ class RoomListSearchPresenter(
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val dataSource = remember { dataSourceFactory.create(coroutineScope) } val dataSource = remember { dataSourceFactory.create(coroutineScope) }
LaunchedEffect(isSearchActive) {
dataSource.setIsActive(isSearchActive)
}
LaunchedEffect(searchQuery.text) { LaunchedEffect(searchQuery.text) {
dataSource.setSearchQuery(searchQuery.text.toString()) dataSource.setSearchQuery(searchQuery.text.toString())
} }
@ -54,6 +51,9 @@ class RoomListSearchPresenter(
isSearchActive = !isSearchActive isSearchActive = !isSearchActive
searchQuery.clearText() searchQuery.clearText()
} }
is RoomListSearchEvent.UpdateVisibleRange -> coroutineScope.launch {
dataSource.updateVisibleRange(visibleRange = event.range)
}
} }
} }

View file

@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -47,6 +48,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar 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.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -154,7 +156,12 @@ private fun RoomListSearchContent(
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
) { ) {
val lazyListState = rememberLazyListState()
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(RoomListSearchEvent.UpdateVisibleRange(visibleRange))
}
LazyColumn( LazyColumn(
state = lazyListState,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { ) {
items( items(

View file

@ -42,7 +42,7 @@ class RoomListDataSourceTest {
dateTimeObserver = dateTimeObserver, dateTimeObserver = dateTimeObserver,
) )
roomListDataSource.allRooms.test { roomListDataSource.roomSummariesFlow.test {
// Observe room list items changes // Observe room list items changes
roomListDataSource.launchIn(backgroundScope) roomListDataSource.launchIn(backgroundScope)
// Get the initial room list // Get the initial room list
@ -75,7 +75,7 @@ class RoomListDataSourceTest {
), ),
dateTimeObserver = dateTimeObserver, dateTimeObserver = dateTimeObserver,
) )
roomListDataSource.allRooms.test { roomListDataSource.roomSummariesFlow.test {
// Observe room list items changes // Observe room list items changes
roomListDataSource.launchIn(backgroundScope) roomListDataSource.launchIn(backgroundScope)
// Get the initial room list // Get the initial room list

View file

@ -9,12 +9,26 @@
package io.element.android.features.home.impl.filters package io.element.android.features.home.impl.filters
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.impl.FakeDateTimeObserver
import io.element.android.features.home.impl.datasource.RoomListDataSource
import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory
import io.element.android.features.home.impl.filters.selection.DefaultFilterSelectionStrategy import io.element.android.features.home.impl.filters.selection.DefaultFilterSelectionStrategy
import io.element.android.features.home.impl.filters.selection.FilterSelectionState import io.element.android.features.home.impl.filters.selection.FilterSelectionState
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
@ -39,13 +53,13 @@ class RoomListFiltersPresenterTest {
} }
@Test @Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - toggle rooms filter`() = runTest { fun `present - toggle rooms filter`() = runTest {
val roomListService = FakeRoomListService() val roomListService = FakeRoomListService()
val presenter = createRoomListFiltersPresenter(roomListService) val presenter = createRoomListFiltersPresenter(roomListService)
presenter.test { presenter.test {
awaitItem().eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms)) awaitItem().eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
awaitLastSequentialItem().let { state -> awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isTrue() assertThat(state.hasAnyFilterSelected).isTrue()
assertThat(state.filterSelectionStates).containsExactly( assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.Rooms, true), filterSelectionState(RoomListFilter.Rooms, true),
@ -56,12 +70,9 @@ class RoomListFiltersPresenterTest {
assertThat(state.selectedFilters()).containsExactly( assertThat(state.selectedFilters()).containsExactly(
RoomListFilter.Rooms, RoomListFilter.Rooms,
) )
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
assertThat(roomListCurrentFilter.filters).containsExactly(
MatrixRoomListFilter.Category.Group,
)
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms)) state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
} }
advanceUntilIdle()
awaitLastSequentialItem().let { state -> awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isFalse() assertThat(state.hasAnyFilterSelected).isFalse()
assertThat(state.filterSelectionStates).containsExactly( assertThat(state.filterSelectionStates).containsExactly(
@ -72,13 +83,12 @@ class RoomListFiltersPresenterTest {
filterSelectionState(RoomListFilter.Invites, false), filterSelectionState(RoomListFilter.Invites, false),
).inOrder() ).inOrder()
assertThat(state.selectedFilters()).isEmpty() assertThat(state.selectedFilters()).isEmpty()
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
assertThat(roomListCurrentFilter.filters).isEmpty()
} }
} }
} }
@Test @Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - clear filters event`() = runTest { fun `present - clear filters event`() = runTest {
val roomListService = FakeRoomListService() val roomListService = FakeRoomListService()
val presenter = createRoomListFiltersPresenter(roomListService) val presenter = createRoomListFiltersPresenter(roomListService)
@ -88,6 +98,7 @@ class RoomListFiltersPresenterTest {
assertThat(state.hasAnyFilterSelected).isTrue() assertThat(state.hasAnyFilterSelected).isTrue()
state.eventSink.invoke(RoomListFiltersEvent.ClearSelectedFilters) state.eventSink.invoke(RoomListFiltersEvent.ClearSelectedFilters)
} }
advanceUntilIdle()
awaitLastSequentialItem().let { state -> awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isFalse() assertThat(state.hasAnyFilterSelected).isFalse()
} }
@ -100,11 +111,25 @@ private fun filterSelectionState(filter: RoomListFilter, selected: Boolean) = Fi
isSelected = selected, isSelected = selected,
) )
private fun createRoomListFiltersPresenter( private fun TestScope.createRoomListFiltersPresenter(
roomListService: RoomListService = FakeRoomListService(), roomListService: RoomListService = FakeRoomListService(),
notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
dateFormatter: DateFormatter = FakeDateFormatter(),
roomLatestEventFormatter: RoomLatestEventFormatter = FakeRoomLatestEventFormatter(),
): RoomListFiltersPresenter { ): RoomListFiltersPresenter {
return RoomListFiltersPresenter( return RoomListFiltersPresenter(
roomListService = roomListService, roomListDataSource = RoomListDataSource(
roomListService = roomListService,
roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
dateFormatter = dateFormatter,
roomLatestEventFormatter = roomLatestEventFormatter,
),
coroutineDispatchers = testCoroutineDispatchers(),
notificationSettingsService = notificationSettingsService,
sessionCoroutineScope = backgroundScope,
dateTimeObserver = FakeDateTimeObserver(),
analyticsService = FakeAnalyticsService(),
),
filterSelectionStrategy = DefaultFilterSelectionStrategy(), filterSelectionStrategy = DefaultFilterSelectionStrategy(),
) )
} }

View file

@ -15,4 +15,5 @@ sealed interface AddRoomToSpaceEvent {
data object Save : AddRoomToSpaceEvent data object Save : AddRoomToSpaceEvent
data object ResetSaveAction : AddRoomToSpaceEvent data object ResetSaveAction : AddRoomToSpaceEvent
data object Dismiss : AddRoomToSpaceEvent data object Dismiss : AddRoomToSpaceEvent
data class UpdateSearchVisibleRange(val range: IntRange) : AddRoomToSpaceEvent
} }

View file

@ -58,9 +58,6 @@ class AddRoomToSpacePresenter(
LaunchedEffect(searchQuery.text) { LaunchedEffect(searchQuery.text) {
dataSource.setSearchQuery(searchQuery.text.toString()) dataSource.setSearchQuery(searchQuery.text.toString())
} }
LaunchedEffect(isSearchActive) {
dataSource.setIsActive(isSearchActive)
}
val suggestions by dataSource.suggestions.collectAsState(initial = persistentListOf()) val suggestions by dataSource.suggestions.collectAsState(initial = persistentListOf())
@ -111,6 +108,9 @@ class AddRoomToSpacePresenter(
coroutineScope.launch { spaceRoomList.reset() } coroutineScope.launch { spaceRoomList.reset() }
} }
} }
is AddRoomToSpaceEvent.UpdateSearchVisibleRange -> coroutineScope.launch {
dataSource.updateVisibleRange(event.range)
}
} }
} }

View file

@ -20,14 +20,13 @@ import io.element.android.libraries.matrix.api.room.recent.getRecentlyVisitedRoo
import io.element.android.libraries.matrix.api.roomlist.RoomList 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.loadAllIncrementally import io.element.android.libraries.matrix.api.roomlist.updateVisibleRange
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -58,7 +57,6 @@ class AddRoomToSpaceSearchDataSource(
private val roomList = roomListService.createRoomList( private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE, pageSize = PAGE_SIZE,
initialFilter = RoomListFilter.all(),
source = RoomList.Source.All, source = RoomList.Source.All,
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
) )
@ -87,7 +85,7 @@ class AddRoomToSpaceSearchDataSource(
} }
val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = combine( val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = combine(
roomList.filteredSummaries, roomList.summaries,
spaceChildrenFlow, spaceChildrenFlow,
addedRoomIdsFlow, addedRoomIdsFlow,
) { roomSummaries, childIds, addedIds -> ) { roomSummaries, childIds, addedIds ->
@ -109,12 +107,8 @@ class AddRoomToSpaceSearchDataSource(
.toImmutableList() .toImmutableList()
}.flowOn(coroutineDispatchers.computation) }.flowOn(coroutineDispatchers.computation)
suspend fun setIsActive(isActive: Boolean) = coroutineScope { suspend fun updateVisibleRange(visibleRange: IntRange) {
if (isActive) { roomList.updateVisibleRange(visibleRange)
roomList.loadAllIncrementally(this)
} else {
roomList.reset()
}
} }
suspend fun setSearchQuery(searchQuery: String) { suspend fun setSearchQuery(searchQuery: String) {

View file

@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -43,6 +44,7 @@ import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect
import io.element.android.libraries.matrix.ui.components.SelectedRoom import io.element.android.libraries.matrix.ui.components.SelectedRoom
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
@ -121,6 +123,10 @@ fun AddRoomToSpaceView(
} }
}, },
) { rooms -> ) { rooms ->
val lazyListState = rememberLazyListState()
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(AddRoomToSpaceEvent.UpdateSearchVisibleRange(visibleRange))
}
LazyColumn { LazyColumn {
items(rooms, key = { it.roomId }) { roomInfo -> items(rooms, key = { it.roomId }) { roomInfo ->
RoomListItem( RoomListItem(

View file

@ -12,11 +12,17 @@ import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.lazy.LazyListLayoutInfo import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/** /**
* Returns whether the lazy list is currently scrolling up. * Returns whether the lazy list is currently scrolling up.
@ -73,3 +79,20 @@ suspend fun LazyListState.animateScrollToItemCenter(index: Int) {
animateScrollToItem(index, offset) animateScrollToItem(index, offset)
} }
} }
@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,12 +8,7 @@
package io.element.android.libraries.matrix.api.roomlist package io.element.android.libraries.matrix.api.roomlist
import kotlinx.coroutines.CoroutineScope import timber.log.Timber
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. * RoomList with dynamic filtering and loading.
@ -21,12 +16,8 @@ import kotlinx.coroutines.flow.onEach
* It lets load rooms on demand and filter them. * It lets load rooms on demand and filter them.
*/ */
interface DynamicRoomList : RoomList { interface DynamicRoomList : RoomList {
val currentFilter: StateFlow<RoomListFilter>
val loadedPages: StateFlow<Int>
val pageSize: Int val pageSize: Int
val filteredSummaries: SharedFlow<List<RoomSummary>>
/** /**
* Load more rooms into the list if possible. * Load more rooms into the list if possible.
*/ */
@ -44,28 +35,13 @@ interface DynamicRoomList : RoomList {
suspend fun updateFilter(filter: RoomListFilter) suspend fun updateFilter(filter: RoomListFilter)
} }
/** suspend fun DynamicRoomList.updateVisibleRange(
* Offers a way to load all the rooms incrementally. visibleRange: IntRange,
* It will load more room until all are loaded. paginationThreshold: Int = pageSize * 3
* If total number of rooms increase, it will load more pages if needed. ) {
* The number of rooms is independent of the filter. val loadedCount = summaries.replayCache.firstOrNull().orEmpty().count()
*/ val threshold = loadedCount - paginationThreshold
fun DynamicRoomList.loadAllIncrementally(coroutineScope: CoroutineScope) { if (visibleRange.last >= threshold) {
combine( loadMore()
loadedPages,
loadingState,
) { loadedPages, loadingState ->
loadedPages to loadingState
} }
.onEach { (loadedPages, loadingState) ->
when (loadingState) {
is RoomList.LoadingState.Loaded -> {
if (pageSize * loadedPages < loadingState.numberOfRooms) {
loadMore()
}
}
RoomList.LoadingState.NotLoaded -> Unit
}
}
.launchIn(coroutineScope)
} }

View file

@ -21,6 +21,7 @@ import kotlin.time.Duration
* Can be retrieved from [RoomListService] methods. * Can be retrieved from [RoomListService] methods.
*/ */
interface RoomList { interface RoomList {
/** /**
* The loading state of the room list. * The loading state of the room list.
*/ */

View file

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

View file

@ -38,13 +38,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 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 source the source of the rooms, either all rooms or invites. * @param source the source of the rooms, either all rooms or invites.
* @param coroutineScope the coroutine scope to use for the room list operations. * @param coroutineScope the coroutine scope to use for the room list operations.
*/ */
fun createRoomList( fun createRoomList(
pageSize: Int, pageSize: Int,
initialFilter: RoomListFilter,
source: RoomList.Source, source: RoomList.Source,
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
): DynamicRoomList ): DynamicRoomList
@ -56,10 +54,10 @@ interface RoomListService {
suspend fun subscribeToVisibleRooms(roomIds: List<RoomId>) 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]. * If you want to get a filtered room list, consider using [createRoomList].
*/ */
val allRooms: DynamicRoomList val allRooms: RoomList
/** /**
* The sync indicator as a flow. * 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

@ -21,9 +21,10 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
import org.matrix.rustcomponents.sdk.RoomListEntriesListener import org.matrix.rustcomponents.sdk.RoomListEntriesListener
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
import org.matrix.rustcomponents.sdk.RoomListInterface import org.matrix.rustcomponents.sdk.RoomListInterface
import org.matrix.rustcomponents.sdk.RoomListLoadingState import org.matrix.rustcomponents.sdk.RoomListLoadingState
import org.matrix.rustcomponents.sdk.RoomListLoadingStateListener import org.matrix.rustcomponents.sdk.RoomListLoadingStateListener
@ -57,8 +58,8 @@ fun RoomListInterface.loadingStateFlow(): Flow<RoomListLoadingState> =
internal fun RoomListInterface.entriesFlow( internal fun RoomListInterface.entriesFlow(
pageSize: Int, pageSize: Int,
roomListDynamicEvents: Flow<RoomListDynamicEvents>, initialFilterKind: RoomListEntriesDynamicFilterKind,
initialFilterKind: RoomListEntriesDynamicFilterKind onControllerCreated: (RoomListDynamicEntriesController) -> Unit,
): Flow<List<RoomListEntriesUpdate>> = ): Flow<List<RoomListEntriesUpdate>> =
callbackFlow { callbackFlow {
val listener = object : RoomListEntriesListener { val listener = object : RoomListEntriesListener {
@ -73,19 +74,7 @@ internal fun RoomListInterface.entriesFlow(
) )
val controller = result.controller() val controller = result.controller()
controller.setFilter(initialFilterKind) controller.setFilter(initialFilterKind)
roomListDynamicEvents.onEach { controllerEvents -> onControllerCreated(controller)
when (controllerEvents) {
is RoomListDynamicEvents.SetFilter -> {
controller.setFilter(controllerEvents.filter)
}
is RoomListDynamicEvents.LoadMore -> {
controller.addOnePage()
}
is RoomListDynamicEvents.Reset -> {
controller.resetToOnePage()
}
}
}.launchIn(this)
awaitClose { awaitClose {
result.entriesStream().cancelAndDestroy() result.entriesStream().cancelAndDestroy()
controller.destroy() 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.DynamicRoomList
import io.element.android.libraries.matrix.api.roomlist.RoomList 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.RoomListFilter.Companion.all
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService 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.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.SharedFlow
import kotlinx.coroutines.flow.combine
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.RoomListDynamicEntriesController
import org.matrix.rustcomponents.sdk.RoomListLoadingState import org.matrix.rustcomponents.sdk.RoomListLoadingState
import org.matrix.rustcomponents.sdk.RoomListService import org.matrix.rustcomponents.sdk.RoomListService
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import org.matrix.rustcomponents.sdk.RoomList as InnerRoomList import org.matrix.rustcomponents.sdk.RoomList as InnerRoomList
private val ROOM_LIST_RUST_FILTERS = listOf(
RoomListEntriesDynamicFilterKind.NonLeft,
RoomListEntriesDynamicFilterKind.DeduplicateVersions
)
internal class RoomListFactory( internal class RoomListFactory(
private val innerRoomListService: RoomListService, private val innerRoomListService: RoomListService,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
@ -49,18 +42,14 @@ internal class RoomListFactory(
pageSize: Int, pageSize: Int,
coroutineContext: CoroutineContext, coroutineContext: CoroutineContext,
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
initialFilter: RoomListFilter = RoomListFilter.all(), initialFilter: RoomListFilter = all(),
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, roomSummaryFactory, analyticsService) 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 innerRoomList: InnerRoomList? = null
var dynamicController: RoomListDynamicEntriesController? = null
val firstRoomsTransaction = analyticsService.startTransaction("Load first set of rooms", "innerRoomList.entriesFlow") val firstRoomsTransaction = analyticsService.startTransaction("Load first set of rooms", "innerRoomList.entriesFlow")
@ -69,8 +58,10 @@ internal class RoomListFactory(
innerRoomList.let { innerRoomList -> innerRoomList.let { innerRoomList ->
innerRoomList.entriesFlow( innerRoomList.entriesFlow(
pageSize = pageSize, pageSize = pageSize,
roomListDynamicEvents = dynamicEvents, initialFilterKind = RoomListFilterMapper.toRustFilter(initialFilter),
initialFilterKind = RoomListEntriesDynamicFilterKind.All(ROOM_LIST_RUST_FILTERS), onControllerCreated = { controller ->
dynamicController = controller
}
).onEach { update -> ).onEach { update ->
if (!firstRoomsTransaction.isFinished()) { if (!firstRoomsTransaction.isFinished()) {
analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.FirstRoomsDisplayed) analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.FirstRoomsDisplayed)
@ -85,60 +76,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 = summariesFlow,
filteredSummaries = filteredSummariesFlow,
loadingState = loadingStateFlow, loadingState = loadingStateFlow,
currentFilter = currentFilter,
loadedPages = loadedPages,
dynamicEvents = dynamicEvents,
processor = processor, processor = processor,
pageSize = pageSize, 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 { private fun RoomListLoadingState.toLoadingState(): RoomList.LoadingState {
return when (this) { return when (this) {

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

@ -44,7 +44,6 @@ class FakeRoomListService(
override fun createRoomList( override fun createRoomList(
pageSize: Int, pageSize: Int,
initialFilter: RoomListFilter,
source: RoomList.Source, source: RoomList.Source,
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
): DynamicRoomList { ): DynamicRoomList {

View file

@ -13,19 +13,16 @@ 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.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.getAndUpdate
data class SimplePagedRoomList( data class SimplePagedRoomList(
override val summaries: MutableStateFlow<List<RoomSummary>>, override val summaries: MutableStateFlow<List<RoomSummary>>,
override val loadingState: StateFlow<RoomList.LoadingState>, override val loadingState: StateFlow<RoomList.LoadingState>,
override val currentFilter: MutableStateFlow<RoomListFilter> private val currentFilter: MutableStateFlow<RoomListFilter>
) : DynamicRoomList { ) : DynamicRoomList {
override val pageSize: Int = Int.MAX_VALUE override val pageSize: Int = Int.MAX_VALUE
override val loadedPages = MutableStateFlow(1) private val loadedPages = MutableStateFlow(1)
override val filteredSummaries: SharedFlow<List<RoomSummary>> = summaries
override suspend fun loadMore() { override suspend fun loadMore() {
// No-op // No-op

View file

@ -16,4 +16,5 @@ sealed interface RoomSelectEvents {
// TODO remove to restore multi-selection // TODO remove to restore multi-selection
data object RemoveSelectedRoom : RoomSelectEvents data object RemoveSelectedRoom : RoomSelectEvents
data object ToggleSearchActive : 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.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@AssistedInject @AssistedInject
class RoomSelectPresenter( class RoomSelectPresenter(
@ -80,6 +81,9 @@ class RoomSelectPresenter(
} }
RoomSelectEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf() RoomSelectEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf()
RoomSelectEvents.ToggleSearchActive -> isSearchActive = !isSearchActive 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.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.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.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -46,14 +46,11 @@ class RoomSelectSearchDataSource(
private val roomList = roomListService.createRoomList( private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE, pageSize = PAGE_SIZE,
initialFilter = RoomListFilter.all(),
source = RoomList.Source.All, source = RoomList.Source.All,
coroutineScope = coroutineScope coroutineScope = coroutineScope
).apply { )
loadAllIncrementally(coroutineScope)
}
val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = roomList.filteredSummaries val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = roomList.summaries
.map { roomSummaries -> .map { roomSummaries ->
roomSummaries roomSummaries
.filter { it.info.currentUserMembership == CurrentUserMembership.JOINED } .filter { it.info.currentUserMembership == CurrentUserMembership.JOINED }
@ -63,6 +60,10 @@ class RoomSelectSearchDataSource(
} }
.flowOn(coroutineDispatchers.computation) .flowOn(coroutineDispatchers.computation)
suspend fun updateVisibleRange(visibleRange: IntRange) {
roomList.updateVisibleRange(visibleRange)
}
suspend fun setSearchQuery(searchQuery: String) = coroutineScope { suspend fun setSearchQuery(searchQuery: String) = coroutineScope {
val filter = if (searchQuery.isBlank()) { val filter = if (searchQuery.isBlank()) {
RoomListFilter.all() RoomListFilter.all()

View file

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