From 0824a3ab8b458ccc5f6c878caf76e51eb2771e29 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 28 Jan 2026 15:50:34 +0100 Subject: [PATCH] Refactor room list filtering to use Rust SDK --- .../impl/components/RoomListContentView.kt | 22 +-- .../impl/datasource/RoomListDataSource.kt | 76 +++++++-- .../impl/filters/RoomListFiltersPresenter.kt | 11 +- .../home/impl/roomlist/RoomListPresenter.kt | 26 +-- .../impl/search/RoomListSearchDataSource.kt | 14 +- .../home/impl/search/RoomListSearchEvent.kt | 1 + .../impl/search/RoomListSearchPresenter.kt | 8 +- .../home/impl/search/RoomListSearchView.kt | 7 + .../impl/datasource/RoomListDataSourceTest.kt | 4 +- .../filters/RoomListFiltersPresenterTest.kt | 43 ++++- .../space/impl/addroom/AddRoomToSpaceEvent.kt | 1 + .../impl/addroom/AddRoomToSpacePresenter.kt | 6 +- .../addroom/AddRoomToSpaceSearchDataSource.kt | 14 +- .../space/impl/addroom/AddRoomToSpaceView.kt | 6 + .../designsystem/utils/LazyListState.kt | 23 +++ .../matrix/api/roomlist/DynamicRoomList.kt | 42 +---- .../libraries/matrix/api/roomlist/RoomList.kt | 1 + .../matrix/api/roomlist/RoomListFilter.kt | 6 +- .../matrix/api/roomlist/RoomListService.kt | 6 +- .../impl/roomlist/RoomListDynamicEvents.kt | 17 -- .../impl/roomlist/RoomListExtensions.kt | 21 +-- .../matrix/impl/roomlist/RoomListFactory.kt | 66 +------- .../matrix/impl/roomlist/RoomListFilter.kt | 72 -------- .../impl/roomlist/RoomListFilterMapper.kt | 76 +++++++++ .../impl/roomlist/RustDynamicRoomList.kt | 63 +++++++ .../impl/roomlist/RustRoomListService.kt | 14 +- .../impl/roomlist/RoomListFilterTest.kt | 156 ------------------ .../test/roomlist/FakeRoomListService.kt | 1 - .../test/roomlist/SimplePagedRoomList.kt | 7 +- .../roomselect/impl/RoomSelectEvents.kt | 1 + .../roomselect/impl/RoomSelectPresenter.kt | 4 + .../impl/RoomSelectSearchDataSource.kt | 13 +- .../roomselect/impl/RoomSelectView.kt | 11 +- 33 files changed, 358 insertions(+), 481 deletions(-) delete mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListDynamicEvents.kt delete mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilter.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterMapper.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomList.kt delete mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterTest.kt diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt index 98874ff773..c02efa02a1 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt @@ -24,9 +24,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment 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.IconSource 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 kotlinx.collections.immutable.ImmutableList @@ -215,17 +214,8 @@ private fun RoomsViewList( lazyListState: LazyListState, modifier: Modifier = Modifier, ) { - val visibleRange by remember { - derivedStateOf { - val layoutInfo = lazyListState.layoutInfo - val firstItemIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0 - val size = layoutInfo.visibleItemsInfo.size - firstItemIndex until firstItemIndex + size - } - } - val updatedEventSink by rememberUpdatedState(newValue = eventSink) - LaunchedEffect(visibleRange) { - updatedEventSink(RoomListEvent.UpdateVisibleRange(visibleRange)) + OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> + eventSink(RoomListEvent.UpdateVisibleRange(visibleRange)) } LazyColumn( state = lazyListState, @@ -237,7 +227,7 @@ private fun RoomsViewList( item { SetUpRecoveryKeyBanner( onContinueClick = onSetUpRecoveryClick, - onDismissClick = { updatedEventSink(RoomListEvent.DismissBanner) }, + onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, ) } } @@ -245,7 +235,7 @@ private fun RoomsViewList( item { ConfirmRecoveryKeyBanner( onContinueClick = onConfirmRecoveryKeyClick, - onDismissClick = { updatedEventSink(RoomListEvent.DismissBanner) }, + onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, ) } } @@ -260,7 +250,7 @@ private fun RoomsViewList( } else if (state.showNewNotificationSoundBanner) { item { NewNotificationSoundBanner( - onDismissClick = { updatedEventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, + onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, ) } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt index c17923fb04..3ff4339bb2 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt @@ -9,33 +9,48 @@ package io.element.android.features.home.impl.datasource import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import io.element.android.features.home.impl.model.RoomListRoomSummary import io.element.android.libraries.androidutils.diff.DiffCacheUpdater import io.element.android.libraries.androidutils.diff.MutableListDiffCache import io.element.android.libraries.androidutils.system.DateTimeObserver 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.matrix.api.core.RoomId 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.RoomSummary +import io.element.android.libraries.matrix.api.roomlist.updateVisibleRange import io.element.android.services.analytics.api.AnalyticsService import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope 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.MutableSharedFlow import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import java.lang.IllegalStateException 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 +@SingleIn(SessionScope::class) class RoomListDataSource( private val roomListService: RoomListService, private val roomListRoomSummaryFactory: RoomListRoomSummaryFactory, @@ -51,7 +66,12 @@ class RoomListDataSource( observeDateTimeChanges() } - private val _allRooms = MutableSharedFlow>(replay = 1) + private val roomList = roomListService.createRoomList( + pageSize = PAGE_SIZE, + source = RoomList.Source.All, + coroutineScope = sessionCoroutineScope + ) + private val _roomSummariesFlow = MutableSharedFlow>(replay = 1) private val lock = Mutex() private val diffCache = MutableListDiffCache() @@ -59,22 +79,49 @@ class RoomListDataSource( old?.roomId == new?.roomId } - val allRooms: Flow> = _allRooms + val roomSummariesFlow: Flow> = _roomSummariesFlow - val loadingState = roomListService.allRooms.loadingState + val loadingState = roomList.loadingState fun launchIn(coroutineScope: CoroutineScope) { - roomListService - .allRooms - .filteredSummaries + roomList + .summaries .onEach { roomSummaries -> replaceWith(roomSummaries) } .launchIn(coroutineScope) } - suspend fun subscribeToVisibleRooms(roomIds: List) { - roomListService.subscribeToVisibleRooms(roomIds) + suspend fun updateFilter(filter: RoomListFilter) { + 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) @@ -82,7 +129,7 @@ class RoomListDataSource( notificationSettingsService.notificationSettingsChangeFlow .debounce(0.5.seconds) .onEach { - roomListService.allRooms.rebuildSummaries() + roomList.rebuildSummaries() } .launchIn(sessionCoroutineScope) } @@ -108,6 +155,7 @@ class RoomListDataSource( private suspend fun buildAndEmitAllRooms(roomSummaries: List, useCache: Boolean = true) { // Used to detect duplicates in the room list summaries - see comment below data class CacheResult(val index: Int, val fromCache: Boolean) + val cachingResults = mutableMapOf>() val roomListRoomSummaries = diffCache.indices().mapNotNull { index -> @@ -144,14 +192,14 @@ class RoomListDataSource( analyticsService.trackError( IllegalStateException( "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 - _allRooms.emit(roomListRoomSummaries.distinctBy { it.roomId }.toImmutableList()) + _roomSummariesFlow.emit(roomListRoomSummaries.distinctBy { it.roomId }.toImmutableList()) } else { - _allRooms.emit(roomListRoomSummaries.toImmutableList()) + _roomSummariesFlow.emit(roomListRoomSummaries.toImmutableList()) } } @@ -163,7 +211,7 @@ class RoomListDataSource( private suspend fun rebuildAllRoomSummaries() { lock.withLock { - roomListService.allRooms.filteredSummaries.replayCache.firstOrNull()?.let { roomSummaries -> + roomList.summaries.replayCache.firstOrNull()?.let { roomSummaries -> buildAndEmitAllRooms(roomSummaries, useCache = false) } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenter.kt index bda6bee6bd..1b2b4d8b53 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenter.kt @@ -12,16 +12,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState 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.libraries.architecture.Presenter -import io.element.android.libraries.matrix.api.roomlist.RoomListService import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.map +import kotlin.time.Duration.Companion.milliseconds import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter @Inject class RoomListFiltersPresenter( - private val roomListService: RoomListService, + private val roomListDataSource: RoomListDataSource, private val filterSelectionStrategy: FilterSelectionStrategy, ) : Presenter { private val initialFilters = filterSelectionStrategy.filterSelectionStates.value.toImmutableList() @@ -56,9 +59,9 @@ class RoomListFiltersPresenter( } } } - .collect { filters -> + .collectLatest { filters -> val result = MatrixRoomListFilter.All(filters) - roomListService.allRooms.updateFilter(result) + roomListDataSource.updateFilter(result) } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index 1c11390a86..60630ee63f 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -57,8 +57,6 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first @@ -68,9 +66,6 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch -private const val EXTENDED_RANGE_SIZE = 40 -private const val SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS = 300L - @Inject class RoomListPresenter( private val client: MatrixClient, @@ -119,7 +114,7 @@ class RoomListPresenter( fun handleEvent(event: RoomListEvent) { when (event) { is RoomListEvent.UpdateVisibleRange -> coroutineScope.launch { - updateVisibleRange(event.range) + roomListDataSource.updateVisibleRange(event.range) } RoomListEvent.DismissRequestVerificationPrompt -> securityBannerDismissed = true RoomListEvent.DismissBanner -> securityBannerDismissed = true @@ -217,7 +212,7 @@ class RoomListPresenter( showNewNotificationSoundBanner: Boolean, ): RoomListContentState { 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 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) - } - } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt index 8496a425d2..bace8b9874 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt @@ -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.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 kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope @@ -42,12 +42,11 @@ class RoomListSearchDataSource( private val roomList = roomListService.createRoomList( pageSize = PAGE_SIZE, - initialFilter = RoomListFilter.None, source = RoomList.Source.All, coroutineScope = coroutineScope ) - val roomSummaries: Flow> = roomList.filteredSummaries + val roomSummaries: Flow> = roomList.summaries .map { roomSummaries -> roomSummaries .map(roomSummaryFactory::create) @@ -55,12 +54,8 @@ class RoomListSearchDataSource( } .flowOn(coroutineDispatchers.computation) - suspend fun setIsActive(isActive: Boolean) = coroutineScope { - if (isActive) { - roomList.loadAllIncrementally(this) - } else { - roomList.reset() - } + suspend fun updateVisibleRange(visibleRange: IntRange) { + roomList.updateVisibleRange(visibleRange) } suspend fun setSearchQuery(searchQuery: String) = coroutineScope { @@ -71,4 +66,5 @@ class RoomListSearchDataSource( } roomList.updateFilter(filter) } + } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvent.kt index 25fb7896bc..38ffea8d25 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvent.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvent.kt @@ -11,4 +11,5 @@ package io.element.android.features.home.impl.search sealed interface RoomListSearchEvent { data object ToggleSearchVisibility : RoomListSearchEvent data object ClearQuery : RoomListSearchEvent + data class UpdateVisibleRange(val range: IntRange) : RoomListSearchEvent } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt index f42dd2a6d8..763fbb23d0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject import io.element.android.libraries.architecture.Presenter import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.launch @Inject class RoomListSearchPresenter( @@ -37,10 +38,6 @@ class RoomListSearchPresenter( val coroutineScope = rememberCoroutineScope() val dataSource = remember { dataSourceFactory.create(coroutineScope) } - LaunchedEffect(isSearchActive) { - dataSource.setIsActive(isSearchActive) - } - LaunchedEffect(searchQuery.text) { dataSource.setSearchQuery(searchQuery.text.toString()) } @@ -54,6 +51,9 @@ class RoomListSearchPresenter( isSearchActive = !isSearchActive searchQuery.clearText() } + is RoomListSearchEvent.UpdateVisibleRange -> coroutineScope.launch { + dataSource.updateVisibleRange(visibleRange = event.range) + } } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt index d0b41c73ae..942e924820 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.material3.ExperimentalMaterial3Api 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.Scaffold 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.ui.strings.CommonStrings @@ -154,7 +156,12 @@ private fun RoomListSearchContent( .padding(padding) .consumeWindowInsets(padding) ) { + val lazyListState = rememberLazyListState() + OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> + state.eventSink(RoomListSearchEvent.UpdateVisibleRange(visibleRange)) + } LazyColumn( + state = lazyListState, modifier = Modifier.weight(1f), ) { items( diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt index aaecd90c56..f76743e591 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt @@ -42,7 +42,7 @@ class RoomListDataSourceTest { dateTimeObserver = dateTimeObserver, ) - roomListDataSource.allRooms.test { + roomListDataSource.roomSummariesFlow.test { // Observe room list items changes roomListDataSource.launchIn(backgroundScope) // Get the initial room list @@ -75,7 +75,7 @@ class RoomListDataSourceTest { ), dateTimeObserver = dateTimeObserver, ) - roomListDataSource.allRooms.test { + roomListDataSource.roomSummariesFlow.test { // Observe room list items changes roomListDataSource.launchIn(backgroundScope) // Get the initial room list diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt index ef8ca425b6..63e0814cd0 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt @@ -9,12 +9,26 @@ package io.element.android.features.home.impl.filters 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.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.test.notificationsettings.FakeNotificationSettingsService 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.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 org.junit.Test import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter @@ -39,13 +53,13 @@ class RoomListFiltersPresenterTest { } @Test + @OptIn(ExperimentalCoroutinesApi::class) fun `present - toggle rooms filter`() = runTest { val roomListService = FakeRoomListService() val presenter = createRoomListFiltersPresenter(roomListService) presenter.test { awaitItem().eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms)) awaitLastSequentialItem().let { state -> - assertThat(state.hasAnyFilterSelected).isTrue() assertThat(state.filterSelectionStates).containsExactly( filterSelectionState(RoomListFilter.Rooms, true), @@ -56,12 +70,9 @@ class RoomListFiltersPresenterTest { assertThat(state.selectedFilters()).containsExactly( 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)) } + advanceUntilIdle() awaitLastSequentialItem().let { state -> assertThat(state.hasAnyFilterSelected).isFalse() assertThat(state.filterSelectionStates).containsExactly( @@ -72,13 +83,12 @@ class RoomListFiltersPresenterTest { filterSelectionState(RoomListFilter.Invites, false), ).inOrder() assertThat(state.selectedFilters()).isEmpty() - val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All - assertThat(roomListCurrentFilter.filters).isEmpty() } } } @Test + @OptIn(ExperimentalCoroutinesApi::class) fun `present - clear filters event`() = runTest { val roomListService = FakeRoomListService() val presenter = createRoomListFiltersPresenter(roomListService) @@ -88,6 +98,7 @@ class RoomListFiltersPresenterTest { assertThat(state.hasAnyFilterSelected).isTrue() state.eventSink.invoke(RoomListFiltersEvent.ClearSelectedFilters) } + advanceUntilIdle() awaitLastSequentialItem().let { state -> assertThat(state.hasAnyFilterSelected).isFalse() } @@ -100,11 +111,25 @@ private fun filterSelectionState(filter: RoomListFilter, selected: Boolean) = Fi isSelected = selected, ) -private fun createRoomListFiltersPresenter( +private fun TestScope.createRoomListFiltersPresenter( roomListService: RoomListService = FakeRoomListService(), + notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(), + dateFormatter: DateFormatter = FakeDateFormatter(), + roomLatestEventFormatter: RoomLatestEventFormatter = FakeRoomLatestEventFormatter(), ): 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(), ) } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceEvent.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceEvent.kt index b052bbc37e..1d8d157bc8 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceEvent.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceEvent.kt @@ -15,4 +15,5 @@ sealed interface AddRoomToSpaceEvent { data object Save : AddRoomToSpaceEvent data object ResetSaveAction : AddRoomToSpaceEvent data object Dismiss : AddRoomToSpaceEvent + data class UpdateSearchVisibleRange(val range: IntRange) : AddRoomToSpaceEvent } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenter.kt index 9393018cdd..e4f58fdff2 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenter.kt @@ -58,9 +58,6 @@ class AddRoomToSpacePresenter( LaunchedEffect(searchQuery.text) { dataSource.setSearchQuery(searchQuery.text.toString()) } - LaunchedEffect(isSearchActive) { - dataSource.setIsActive(isSearchActive) - } val suggestions by dataSource.suggestions.collectAsState(initial = persistentListOf()) @@ -111,6 +108,9 @@ class AddRoomToSpacePresenter( coroutineScope.launch { spaceRoomList.reset() } } } + is AddRoomToSpaceEvent.UpdateSearchVisibleRange -> coroutineScope.launch { + dataSource.updateVisibleRange(event.range) + } } } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceSearchDataSource.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceSearchDataSource.kt index 24c2095ff9..10c70d6f7d 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceSearchDataSource.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceSearchDataSource.kt @@ -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.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.api.spaces.SpaceRoomList import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine @@ -58,7 +57,6 @@ class AddRoomToSpaceSearchDataSource( private val roomList = roomListService.createRoomList( pageSize = PAGE_SIZE, - initialFilter = RoomListFilter.all(), source = RoomList.Source.All, coroutineScope = coroutineScope, ) @@ -87,7 +85,7 @@ class AddRoomToSpaceSearchDataSource( } val roomInfoList: Flow> = combine( - roomList.filteredSummaries, + roomList.summaries, spaceChildrenFlow, addedRoomIdsFlow, ) { roomSummaries, childIds, addedIds -> @@ -109,12 +107,8 @@ class AddRoomToSpaceSearchDataSource( .toImmutableList() }.flowOn(coroutineDispatchers.computation) - suspend fun setIsActive(isActive: Boolean) = coroutineScope { - if (isActive) { - roomList.loadAllIncrementally(this) - } else { - roomList.reset() - } + suspend fun updateVisibleRange(visibleRange: IntRange) { + roomList.updateVisibleRange(visibleRange) } suspend fun setSearchQuery(searchQuery: String) { diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt index df40a0145f..c64efa8074 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt @@ -19,6 +19,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.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.TextButton 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.model.SelectRoomInfo import io.element.android.libraries.matrix.ui.model.getAvatarData @@ -121,6 +123,10 @@ fun AddRoomToSpaceView( } }, ) { rooms -> + val lazyListState = rememberLazyListState() + OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> + state.eventSink(AddRoomToSpaceEvent.UpdateSearchVisibleRange(visibleRange)) + } LazyColumn { items(rooms, key = { it.roomId }) { roomInfo -> RoomListItem( diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LazyListState.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LazyListState.kt index f252c69b8f..be6888642b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LazyListState.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LazyListState.kt @@ -12,11 +12,17 @@ import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.lazy.LazyListLayoutInfo import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState 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. @@ -73,3 +79,20 @@ suspend fun LazyListState.animateScrollToItemCenter(index: Int) { 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) + } + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/DynamicRoomList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/DynamicRoomList.kt index acba1e3cf6..af2c083d05 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/DynamicRoomList.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/DynamicRoomList.kt @@ -8,12 +8,7 @@ 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 +import timber.log.Timber /** * RoomList with dynamic filtering and loading. @@ -21,12 +16,8 @@ import kotlinx.coroutines.flow.onEach * It lets load rooms on demand and filter them. */ interface DynamicRoomList : RoomList { - val currentFilter: StateFlow - val loadedPages: StateFlow val pageSize: Int - val filteredSummaries: SharedFlow> - /** * Load more rooms into the list if possible. */ @@ -44,28 +35,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) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomList.kt index a0d092596a..2b343bd625 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomList.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomList.kt @@ -21,6 +21,7 @@ import kotlin.time.Duration * Can be retrieved from [RoomListService] methods. */ interface RoomList { + /** * The loading state of the room list. */ diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt index 33d233d589..3c6e35d339 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt @@ -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 } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt index c0cf7d57da..8acfcccdd8 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt @@ -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) /** - * 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. diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListDynamicEvents.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListDynamicEvents.kt deleted file mode 100644 index 48d97cc684..0000000000 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListDynamicEvents.kt +++ /dev/null @@ -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 -} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt index ae241e9c02..4905471e98 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt @@ -21,9 +21,10 @@ 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.RoomListEntriesDynamicFilterKind +import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController import org.matrix.rustcomponents.sdk.RoomListEntriesListener import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind import org.matrix.rustcomponents.sdk.RoomListInterface import org.matrix.rustcomponents.sdk.RoomListLoadingState import org.matrix.rustcomponents.sdk.RoomListLoadingStateListener @@ -57,8 +58,8 @@ fun RoomListInterface.loadingStateFlow(): Flow = internal fun RoomListInterface.entriesFlow( pageSize: Int, - roomListDynamicEvents: Flow, - initialFilterKind: RoomListEntriesDynamicFilterKind + initialFilterKind: RoomListEntriesDynamicFilterKind, + onControllerCreated: (RoomListDynamicEntriesController) -> Unit, ): Flow> = callbackFlow { val listener = object : RoomListEntriesListener { @@ -73,19 +74,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() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt index 19bd3b7573..0b92e3acad 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt @@ -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 = MutableStateFlow(RoomList.LoadingState.NotLoaded) - val filteredSummariesFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1) val summariesFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1) val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryFactory, analyticsService) - // Makes sure we don't miss any events - val dynamicEvents = MutableSharedFlow(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,60 +76,21 @@ 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>, - override val filteredSummaries: SharedFlow>, - override val loadingState: MutableStateFlow, - override val currentFilter: MutableStateFlow, - override val loadedPages: MutableStateFlow, - private val dynamicEvents: MutableSharedFlow, - 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) { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilter.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilter.kt deleted file mode 100644 index ed4d5735e0..0000000000 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilter.kt +++ /dev/null @@ -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.filter(filter: RoomListFilter): List { - 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) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterMapper.kt new file mode 100644 index 0000000000..e807501451 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterMapper.kt @@ -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 + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomList.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomList.kt new file mode 100644 index 0000000000..241d33086d --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomList.kt @@ -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>, + override val loadingState: MutableStateFlow, + 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() } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt index 69bddf328e..70a2ac9f93 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt @@ -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 = innerRoomListService.syncIndicator() .map { it.toSyncIndicator() } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterTest.kt deleted file mode 100644 index 9568de33e4..0000000000 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterTest.kt +++ /dev/null @@ -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() - } -} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt index 96a72d1278..dc2faae91a 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt @@ -44,7 +44,6 @@ class FakeRoomListService( override fun createRoomList( pageSize: Int, - initialFilter: RoomListFilter, source: RoomList.Source, coroutineScope: CoroutineScope, ): DynamicRoomList { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/SimplePagedRoomList.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/SimplePagedRoomList.kt index a63212c005..dacd835591 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/SimplePagedRoomList.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/SimplePagedRoomList.kt @@ -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.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>, override val loadingState: StateFlow, - override val currentFilter: MutableStateFlow + private val currentFilter: MutableStateFlow ) : DynamicRoomList { override val pageSize: Int = Int.MAX_VALUE - override val loadedPages = MutableStateFlow(1) - - override val filteredSummaries: SharedFlow> = summaries + private val loadedPages = MutableStateFlow(1) override suspend fun loadMore() { // No-op diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvents.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvents.kt index 0dcfb51079..e6dbe63177 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvents.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvents.kt @@ -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 } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt index d38d9bde2e..b7547b8a45 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt @@ -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) + } } } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt index 3c5cc124c0..c6e6bc88fc 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt @@ -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> = roomList.filteredSummaries + val roomInfoList: Flow> = 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() diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt index 19be54d6c4..69f091ddc4 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -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(