diff --git a/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingBaseRoomStateFlowFactoryTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingBaseRoomStateFlowFactoryTest.kt index 14128ac33a..7a02591237 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingBaseRoomStateFlowFactoryTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingBaseRoomStateFlowFactoryTest.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.ui.room.LoadingRoomState import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory @@ -54,7 +55,8 @@ class LoadingBaseRoomStateFlowFactoryTest { @Test fun `flow should emit Loading and then Loaded when there is a room in cache after SS is loaded`() = runTest { val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = A_SESSION_ID, roomId = A_ROOM_ID)) - val roomListService = FakeRoomListService() + val roomList = FakeDynamicRoomList() + val roomListService = FakeRoomListService(allRooms = roomList) val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService) val flowFactory = LoadingRoomStateFlowFactory(matrixClient) flowFactory @@ -62,21 +64,22 @@ class LoadingBaseRoomStateFlowFactoryTest { .test { assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading) matrixClient.givenGetRoomResult(A_ROOM_ID, room) - roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) + roomList.loadingState.emit(RoomList.LoadingState.Loaded(1)) assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room)) } } @Test fun `flow should emit Loading and then Error when there is no room in cache after SS is loaded`() = runTest { - val roomListService = FakeRoomListService() + val roomList = FakeDynamicRoomList() + val roomListService = FakeRoomListService(allRooms = roomList) val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService) val flowFactory = LoadingRoomStateFlowFactory(matrixClient) flowFactory .create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = null) .test { assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading) - roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) + roomList.loadingState.emit(RoomList.LoadingState.Loaded(1)) assertThat(awaitItem()).isEqualTo(LoadingRoomState.Error) } } 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..f3628fce9d 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 @@ -23,11 +23,7 @@ import androidx.compose.foundation.lazy.LazyListState 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 import androidx.compose.ui.res.stringResource @@ -55,6 +51,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 +212,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 +225,7 @@ private fun RoomsViewList( item { SetUpRecoveryKeyBanner( onContinueClick = onSetUpRecoveryClick, - onDismissClick = { updatedEventSink(RoomListEvent.DismissBanner) }, + onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, ) } } @@ -245,7 +233,7 @@ private fun RoomsViewList( item { ConfirmRecoveryKeyBanner( onContinueClick = onConfirmRecoveryKeyClick, - onDismissClick = { updatedEventSink(RoomListEvent.DismissBanner) }, + onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, ) } } @@ -260,7 +248,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..3c808045fd 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,17 @@ 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.map 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 +57,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..830a84ee4d 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 { @@ -322,23 +317,4 @@ class RoomListPresenter( room.clearEventCacheStorage() } } - - 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..03fcb5520f 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 { 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..1ce5061356 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 @@ -16,9 +16,11 @@ import io.element.android.libraries.dateformatter.test.FakeDateFormatter 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.room.aRoomSummary +import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test @@ -27,9 +29,13 @@ import java.time.Instant class RoomListDataSourceTest { @Test fun `when DateTimeObserver gets a date change, the room summaries are refreshed`() = runTest { - val roomListService = FakeRoomListService().apply { + val roomList = FakeDynamicRoomList().apply { + summaries.emit(listOf(aRoomSummary())) + } + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ).apply { postState(RoomListService.State.Running) - postAllRooms(listOf(aRoomSummary())) } val dateTimeObserver = FakeDateTimeObserver() var dateFormatterResult = "Today" @@ -42,7 +48,7 @@ class RoomListDataSourceTest { dateTimeObserver = dateTimeObserver, ) - roomListDataSource.allRooms.test { + roomListDataSource.roomSummariesFlow.test { // Observe room list items changes roomListDataSource.launchIn(backgroundScope) // Get the initial room list @@ -61,9 +67,11 @@ class RoomListDataSourceTest { @Test fun `when DateTimeObserver gets a time zone change, the room summaries are refreshed`() = runTest { - val roomListService = FakeRoomListService().apply { + val roomList = FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary()))) + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ).apply { postState(RoomListService.State.Running) - postAllRooms(listOf(aRoomSummary())) } val dateTimeObserver = FakeDateTimeObserver() var dateFormatterResult = "Today" @@ -75,7 +83,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..c30e279b2e 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,15 +9,28 @@ 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 class RoomListFiltersPresenterTest { @Test @@ -39,13 +52,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 +69,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 +82,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 +97,7 @@ class RoomListFiltersPresenterTest { assertThat(state.hasAnyFilterSelected).isTrue() state.eventSink.invoke(RoomListFiltersEvent.ClearSelectedFilters) } + advanceUntilIdle() awaitLastSequentialItem().let { state -> assertThat(state.hasAnyFilterSelected).isFalse() } @@ -100,11 +110,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/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt index dd2702bd77..9ee95cc811 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -55,6 +55,7 @@ import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService @@ -77,6 +78,7 @@ import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy @@ -91,7 +93,10 @@ class RoomListPresenterTest { @Test fun `present - load 1 room with success`() = runTest { - val roomListService = FakeRoomListService() + val roomList = FakeDynamicRoomList() + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ) val matrixClient = FakeMatrixClient( roomListService = roomListService ) @@ -102,8 +107,8 @@ class RoomListPresenterTest { presenter.test { val initialState = consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Skeleton }.last() assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Skeleton::class.java) - roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) - roomListService.postAllRooms( + roomList.loadingState.emit(RoomList.LoadingState.Loaded(1)) + roomList.summaries.emit( listOf( aRoomSummary( numUnreadMentions = 1, @@ -128,9 +133,12 @@ class RoomListPresenterTest { @Test fun `present - handle DismissRequestVerificationPrompt`() = runTest { - val roomListService = FakeRoomListService().apply { - postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) - } + val roomList = FakeDynamicRoomList( + loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1)) + ) + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ) val encryptionService = FakeEncryptionService().apply { emitRecoveryState(RecoveryState.INCOMPLETE) } @@ -154,9 +162,12 @@ class RoomListPresenterTest { val encryptionService = FakeEncryptionService().apply { recoveryStateStateFlow.emit(RecoveryState.DISABLED) } - val roomListService = FakeRoomListService().apply { - postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) - } + val roomList = FakeDynamicRoomList( + loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1)) + ) + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ) val matrixClient = FakeMatrixClient( roomListService = roomListService, encryptionService = encryptionService, @@ -344,9 +355,13 @@ class RoomListPresenterTest { fun `present - change in notification settings updates the summary for decorations`() = runTest { val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY val notificationSettingsService = FakeNotificationSettingsService() - val roomListService = FakeRoomListService() - roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) - roomListService.postAllRooms(listOf(aRoomSummary(userDefinedNotificationMode = userDefinedMode))) + val roomList = FakeDynamicRoomList( + summaries = MutableStateFlow(listOf(aRoomSummary(userDefinedNotificationMode = userDefinedMode))), + loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1)) + ) + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ) val matrixClient = FakeMatrixClient( roomListService = roomListService, notificationSettingsService = notificationSettingsService @@ -397,8 +412,12 @@ class RoomListPresenterTest { @Test fun `present - when room service returns no room, then contentState is Empty`() = runTest { - val roomListService = FakeRoomListService() - roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(0)) + val roomList = FakeDynamicRoomList( + loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(0)) + ) + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ) val matrixClient = FakeMatrixClient( roomListService = roomListService, ) @@ -479,16 +498,21 @@ class RoomListPresenterTest { val acceptDeclinePresenter = Presenter { anAcceptDeclineInviteState(eventSink = eventSinkRecorder) } - val roomListService = FakeRoomListService() - val matrixClient = FakeMatrixClient( - roomListService = roomListService, - ) + val roomSummary = aRoomSummary( currentUserMembership = CurrentUserMembership.INVITED, inviter = aRoomMember(), ) - roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) - roomListService.postAllRooms(listOf(roomSummary)) + val roomList = FakeDynamicRoomList( + summaries = MutableStateFlow(listOf(roomSummary)), + loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1)) + ) + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ) + val matrixClient = FakeMatrixClient( + roomListService = roomListService, + ) val presenter = createRoomListPresenter( client = matrixClient, acceptDeclineInvitePresenter = acceptDeclinePresenter @@ -519,15 +543,20 @@ class RoomListPresenterTest { @Test fun `present - UpdateVisibleRange will cancel the previous subscription if called too soon`() = runTest { val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List -> } - val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda) - val matrixClient = FakeMatrixClient( - roomListService = roomListService, - ) val roomSummary = aRoomSummary( currentUserMembership = CurrentUserMembership.INVITED ) - roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) - roomListService.postAllRooms(listOf(roomSummary)) + val roomList = FakeDynamicRoomList( + summaries = MutableStateFlow(listOf(roomSummary)), + loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1)) + ) + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList }, + subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda, + ) + val matrixClient = FakeMatrixClient( + roomListService = roomListService, + ) val presenter = createRoomListPresenter( client = matrixClient, ) @@ -548,15 +577,20 @@ class RoomListPresenterTest { @Test fun `present - UpdateVisibleRange subscribes to rooms in visible range`() = runTest { val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List -> } - val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda) - val matrixClient = FakeMatrixClient( - roomListService = roomListService, - ) val roomSummary = aRoomSummary( currentUserMembership = CurrentUserMembership.INVITED ) - roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) - roomListService.postAllRooms(listOf(roomSummary)) + val roomList = FakeDynamicRoomList( + summaries = MutableStateFlow(listOf(roomSummary)), + loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1)) + ) + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList }, + subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda, + ) + val matrixClient = FakeMatrixClient( + roomListService = roomListService, + ) val presenter = createRoomListPresenter( client = matrixClient, ) @@ -579,15 +613,20 @@ class RoomListPresenterTest { @Test fun `present - notification sound banner`() = runTest { val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List -> } - val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda) - val matrixClient = FakeMatrixClient( - roomListService = roomListService, - ) val roomSummary = aRoomSummary( currentUserMembership = CurrentUserMembership.INVITED ) - roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) - roomListService.postAllRooms(listOf(roomSummary)) + val roomList = FakeDynamicRoomList( + summaries = MutableStateFlow(listOf(roomSummary)), + loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1)) + ) + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList }, + subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda, + ) + val matrixClient = FakeMatrixClient( + roomListService = roomListService, + ) val onAnnouncementDismissedResult = lambdaRecorder { } val announcementService = FakeAnnouncementService( onAnnouncementDismissedResult = onAnnouncementDismissedResult, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt index c135d62e49..04a12f8591 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt @@ -15,7 +15,10 @@ import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventForma import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.CoroutineScope @@ -56,12 +59,15 @@ class RoomListSearchPresenterTest { @Test fun `present - query search changes`() = runTest { - val roomListService = FakeRoomListService() + val roomList = FakeDynamicRoomList() + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ) val presenter = createRoomListSearchPresenter(roomListService) presenter.test { awaitItem().let { state -> assertThat( - roomListService.allRooms.currentFilter.value + roomList.currentFilter.value ).isEqualTo( RoomListFilter.None ) @@ -70,7 +76,7 @@ class RoomListSearchPresenterTest { awaitItem().let { state -> assertThat(state.query.text).isEqualTo("Search") assertThat( - roomListService.allRooms.currentFilter.value + roomList.currentFilter.value ).isEqualTo( RoomListFilter.NormalizedMatchRoomName("Search") ) @@ -79,7 +85,7 @@ class RoomListSearchPresenterTest { awaitItem().let { state -> assertThat(state.query.text.toString()).isEmpty() assertThat( - roomListService.allRooms.currentFilter.value + roomList.currentFilter.value ).isEqualTo( RoomListFilter.None ) @@ -89,24 +95,51 @@ class RoomListSearchPresenterTest { @Test fun `present - room list changes`() = runTest { - val roomListService = FakeRoomListService() + val roomList = FakeDynamicRoomList() + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ) val presenter = createRoomListSearchPresenter(roomListService) presenter.test { awaitItem().let { state -> assertThat(state.results).isEmpty() } - roomListService.postAllRooms( + roomList.summaries.emit( listOf(aRoomSummary()) ) awaitItem().let { state -> assertThat(state.results).hasSize(1) } - roomListService.postAllRooms(emptyList()) + roomList.summaries.emit(emptyList()) awaitItem().let { state -> assertThat(state.results).isEmpty() } } } + + @Test + fun `present - UpdateVisibleRange triggers pagination when near end`() = runTest { + val loadMoreLambda = lambdaRecorder { } + val roomList = FakeDynamicRoomList(loadMoreLambda = loadMoreLambda) + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ) + val presenter = createRoomListSearchPresenter(roomListService) + presenter.test { + val initialState = awaitItem() + // Post some rooms to simulate loaded content + val rooms = (1..10).map { aRoomSummary() } + roomList.summaries.emit(rooms) + skipItems(1) + + // UpdateVisibleRange near end should trigger loadMore + initialState.eventSink(RoomListSearchEvent.UpdateVisibleRange(IntRange(0, 9))) + // Give time for the coroutine to complete + testScheduler.advanceUntilIdle() + + assert(loadMoreLambda).isCalledOnce() + } + } } fun TestScope.createRoomListSearchPresenter( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultRoomAliasSuggestionsDataSourceTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultRoomAliasSuggestionsDataSourceTest.kt index 5cd3607701..0c101a7440 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultRoomAliasSuggestionsDataSourceTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultRoomAliasSuggestionsDataSourceTest.kt @@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.messagecomposer.suggestions.Roo import io.element.android.libraries.matrix.test.A_ROOM_ALIAS import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import kotlinx.coroutines.test.runTest import org.junit.Test @@ -22,7 +23,10 @@ import org.junit.Test class DefaultRoomAliasSuggestionsDataSourceTest { @Test fun `getAllRoomAliasSuggestions must emit a list of room alias suggestions`() = runTest { - val roomListService = FakeRoomListService() + val roomList = FakeDynamicRoomList() + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ) val sut = DefaultRoomAliasSuggestionsDataSource( roomListService ) @@ -31,7 +35,7 @@ class DefaultRoomAliasSuggestionsDataSourceTest { ) sut.getAllRoomAliasSuggestions().test { assertThat(awaitItem()).isEmpty() - roomListService.postAllRooms( + roomList.summaries.emit( listOf( aRoomSummary(roomId = A_ROOM_ID_2, canonicalAlias = null), aRoomSummaryWithAnAlias, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTest.kt index 153c265560..e03b65f0a9 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTest.kt @@ -17,10 +17,12 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.test +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Test @@ -53,10 +55,14 @@ class EditDefaultNotificationSettingsPresenterTest { initialRoomModeIsDefault = false, getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID)) }, ) - val roomListService = FakeRoomListService() + val roomList = FakeDynamicRoomList( + summaries = MutableStateFlow(listOf(aRoomSummary(userDefinedNotificationMode = RoomNotificationMode.ALL_MESSAGES))) + ) + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ) val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService) presenter.test { - roomListService.postAllRooms(listOf(aRoomSummary(userDefinedNotificationMode = RoomNotificationMode.ALL_MESSAGES))) val loadedState = consumeItemsUntilPredicate { state -> state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.ALL_MESSAGES } }.last() @@ -71,10 +77,8 @@ class EditDefaultNotificationSettingsPresenterTest { initialRoomModeIsDefault = false, getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID, A_ROOM_ID_2)) }, ) - val roomListService = FakeRoomListService() - val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService) - presenter.test { - roomListService.postAllRooms( + val roomList = FakeDynamicRoomList( + summaries = MutableStateFlow( listOf( aRoomSummary( roomId = A_ROOM_ID, @@ -86,8 +90,14 @@ class EditDefaultNotificationSettingsPresenterTest { name = "A", userDefinedNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, ), - ), + ) ) + ) + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ) + val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService) + presenter.test { val loadedState = consumeItemsUntilPredicate { state -> state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY } }.last() @@ -103,10 +113,8 @@ class EditDefaultNotificationSettingsPresenterTest { initialRoomModeIsDefault = false, getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID, A_ROOM_ID_2)) }, ) - val roomListService = FakeRoomListService() - val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService) - presenter.test { - roomListService.postAllRooms( + val roomList = FakeDynamicRoomList( + summaries = MutableStateFlow( listOf( aRoomSummary( roomId = A_ROOM_ID, @@ -118,8 +126,14 @@ class EditDefaultNotificationSettingsPresenterTest { name = null, userDefinedNotificationMode = RoomNotificationMode.MUTE, ), - ), + ) ) + ) + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ) + val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService) + presenter.test { val loadedState = consumeItemsUntilPredicate { state -> state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.MUTE } }.last() 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/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenterTest.kt index 381206c3ec..cdfd4e548c 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenterTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenterTest.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList import io.element.android.libraries.matrix.test.spaces.FakeSpaceService @@ -116,12 +117,15 @@ class AddRoomToSpacePresenterTest { @Test fun `present - searchResults shows Results when rooms available`() = runTest { - val roomListService = FakeRoomListService() + val roomList = FakeDynamicRoomList() + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ) val presenter = createAddRoomToSpacePresenter(roomListService = roomListService) presenter.test { awaitItem() // Initial state // Post rooms to the service - roomListService.postAllRooms( + roomList.summaries.emit( listOf( aRoomSummary( roomId = A_ROOM_ID, @@ -296,6 +300,29 @@ class AddRoomToSpacePresenterTest { } } + @Test + fun `present - UpdateSearchVisibleRange triggers pagination when near end`() = runTest { + val loadMoreLambda = lambdaRecorder { } + val roomList = FakeDynamicRoomList(loadMoreLambda = loadMoreLambda) + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ) + val presenter = createAddRoomToSpacePresenter(roomListService = roomListService) + presenter.test { + val state = awaitItem() + // Post rooms to simulate loaded content + roomList.summaries.emit(listOf(aRoomSummary())) + advanceUntilIdle() + skipItems(1) + + // UpdateSearchVisibleRange should trigger loadMore + state.eventSink(AddRoomToSpaceEvent.UpdateSearchVisibleRange(IntRange(0, 9))) + advanceUntilIdle() + + assert(loadMoreLambda).isCalledOnce() + } + } + @Test fun `present - Dismiss after partial success calls reset`() = runTest { val resetResult = lambdaRecorder> { Result.success(Unit) } diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceViewTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceViewTest.kt index 16710ae0e4..d75fecd05a 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceViewTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceViewTest.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EventsRecorder @@ -99,6 +100,21 @@ class AddRoomToSpaceViewTest { ) } } + + @Config(qualifiers = "h1024dp") + @Test + fun `displaying search results sends UpdateSearchVisibleRange event`() { + val eventsRecorder = EventsRecorder() + val rooms = aSelectRoomInfoList() + rule.setAddRoomToSpaceView( + anAddRoomToSpaceState( + isSearchActive = true, + searchResults = SearchBarResultState.Results(rooms), + eventSink = eventsRecorder, + ), + ) + eventsRecorder.assertTrue(0) { it is AddRoomToSpaceEvent.UpdateSearchVisibleRange } + } } private fun AndroidComposeTestRule.setAddRoomToSpaceView( diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt index d13777842d..fe563138ad 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt @@ -8,7 +8,6 @@ package io.element.android.libraries.core.extensions -import java.text.Normalizer import java.util.Locale fun Boolean.toOnOff() = if (this) "ON" else "OFF" @@ -86,11 +85,6 @@ fun String.safeCapitalize(): String { } } -fun String.withoutAccents(): String { - return Normalizer.normalize(this, Normalizer.Form.NFD) - .replace("\\p{Mn}+".toRegex(), "") -} - private const val RTL_OVERRIDE_CHAR = '\u202E' private const val LTR_OVERRIDE_CHAR = '\u202D' diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/OnVisibleRangeChangeEffect.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/OnVisibleRangeChangeEffect.kt new file mode 100644 index 0000000000..01a3be1711 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/OnVisibleRangeChangeEffect.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +@Composable +fun OnVisibleRangeChangeEffect(lazyListState: LazyListState, onChange: (IntRange) -> Unit) { + val onChangeUpdated by rememberUpdatedState(onChange) + LaunchedEffect(lazyListState) { + snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo } + .map { visibleItemsInfo -> + val firstItemIndex = visibleItemsInfo.firstOrNull()?.index ?: 0 + val size = visibleItemsInfo.size + firstItemIndex until firstItemIndex + size + } + .distinctUntilChanged() + .collectLatest { visibleRange -> + onChangeUpdated(visibleRange) + } + } +} 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..952cee6f22 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,25 +8,14 @@ package io.element.android.libraries.matrix.api.roomlist -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach - /** * RoomList with dynamic filtering and loading. * This is useful for large lists of rooms. * It lets load rooms on demand and filter them. */ interface DynamicRoomList : RoomList { - val currentFilter: StateFlow - val loadedPages: StateFlow val pageSize: Int - val filteredSummaries: SharedFlow> - /** * Load more rooms into the list if possible. */ @@ -44,28 +33,13 @@ interface DynamicRoomList : RoomList { suspend fun updateFilter(filter: RoomListFilter) } -/** - * Offers a way to load all the rooms incrementally. - * It will load more room until all are loaded. - * If total number of rooms increase, it will load more pages if needed. - * The number of rooms is independent of the filter. - */ -fun DynamicRoomList.loadAllIncrementally(coroutineScope: CoroutineScope) { - combine( - loadedPages, - loadingState, - ) { loadedPages, loadingState -> - loadedPages to loadingState +suspend fun DynamicRoomList.updateVisibleRange( + visibleRange: IntRange, + paginationThreshold: Int = pageSize * 3 +) { + val loadedCount = summaries.replayCache.firstOrNull().orEmpty().count() + val threshold = loadedCount - paginationThreshold + if (visibleRange.last >= threshold) { + loadMore() } - .onEach { (loadedPages, loadingState) -> - when (loadingState) { - is RoomList.LoadingState.Loaded -> { - if (pageSize * loadedPages < loadingState.numberOfRooms) { - loadMore() - } - } - RoomList.LoadingState.NotLoaded -> Unit - } - } - .launchIn(coroutineScope) } 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..5fd5e0c75d 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 @@ -18,9 +18,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind import org.matrix.rustcomponents.sdk.RoomListEntriesListener import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate @@ -57,8 +56,8 @@ fun RoomListInterface.loadingStateFlow(): Flow = internal fun RoomListInterface.entriesFlow( pageSize: Int, - roomListDynamicEvents: Flow, - initialFilterKind: RoomListEntriesDynamicFilterKind + initialFilterKind: RoomListEntriesDynamicFilterKind, + onControllerCreated: (RoomListDynamicEntriesController) -> Unit, ): Flow> = callbackFlow { val listener = object : RoomListEntriesListener { @@ -73,19 +72,7 @@ internal fun RoomListInterface.entriesFlow( ) val controller = result.controller() controller.setFilter(initialFilterKind) - roomListDynamicEvents.onEach { controllerEvents -> - when (controllerEvents) { - is RoomListDynamicEvents.SetFilter -> { - controller.setFilter(controllerEvents.filter) - } - is RoomListDynamicEvents.LoadMore -> { - controller.addOnePage() - } - is RoomListDynamicEvents.Reset -> { - controller.resetToOnePage() - } - } - }.launchIn(this) + onControllerCreated(controller) awaitClose { result.entriesStream().cancelAndDestroy() controller.destroy() 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..1d90b07104 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,61 +76,20 @@ internal class RoomListFactory( loadingStateFlow.value = it } .launchIn(this) - - combine( - currentFilter, - summariesFlow - ) { filter, summaries -> - summaries.filter(filter) - }.onEach { - filteredSummariesFlow.emit(it) - }.launchIn(this) } }.invokeOnCompletion { innerRoomList?.destroy() } return RustDynamicRoomList( summaries = summariesFlow, - filteredSummaries = filteredSummariesFlow, loadingState = loadingStateFlow, - currentFilter = currentFilter, - loadedPages = loadedPages, - dynamicEvents = dynamicEvents, processor = processor, pageSize = pageSize, + dynamicController = { dynamicController } ) } } -private class RustDynamicRoomList( - override val summaries: MutableSharedFlow>, - 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) { is RoomListLoadingState.Loaded -> RoomList.LoadingState.Loaded(maximumNumberOfRooms?.toInt() ?: 0) 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..fdee790b19 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterMapper.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomlist + +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.All +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Any +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Category +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.DeduplicateVersions +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Favourite +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Invite +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NonLeft +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NonSpace +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.None +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NormalizedMatchRoomName +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Space +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Unread +import org.matrix.rustcomponents.sdk.RoomListFilterCategory + +/** + * Mapper for converting RoomListFilter to Rust SDK filter kinds. + */ +internal object RoomListFilterMapper { + /** + * Base rust filters to always apply across all room lists. + * These filters ensure we show: + * - Non-space, non-left rooms (regular rooms user is part of) + * - OR space invites (pending space invitations) + * - With version deduplication enabled + */ + private val RUST_BASE_FILTERS = listOf( + Any( + listOf( + All(listOf(NonSpace, NonLeft)), + All(listOf(Space, Invite)), + ) + ), + DeduplicateVersions + ) + + /** + * Converts a RoomListFilter to a Rust SDK RoomListEntriesDynamicFilterKind. + * Applies base filters along with the provided filter. + */ + fun toRustFilter(filter: RoomListFilter): RoomListEntriesDynamicFilterKind { + return All(RUST_BASE_FILTERS + mapFilter(filter)) + } + + /** + * Maps a RoomListFilter to its Rust SDK equivalent. + * This replaces the previous RoomListFilter.into() extension function. + */ + private fun mapFilter(filter: RoomListFilter): RoomListEntriesDynamicFilterKind { + return when (filter) { + is RoomListFilter.All -> All(filters = filter.filters.map { mapFilter(it) }) + is RoomListFilter.Any -> Any(filters = filter.filters.map { mapFilter(it) }) + RoomListFilter.None -> None + RoomListFilter.Category.Group -> Category(RoomListFilterCategory.GROUP) + RoomListFilter.Category.People -> Category(RoomListFilterCategory.PEOPLE) + RoomListFilter.Category.Space -> Space + RoomListFilter.Favorite -> Favourite + RoomListFilter.Unread -> Unread + is RoomListFilter.NormalizedMatchRoomName -> NormalizedMatchRoomName( + pattern = filter.pattern + ) + RoomListFilter.Invite -> Invite + } + } +} 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..0d7b2394b3 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomList.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomlist + +import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList +import io.element.android.libraries.matrix.api.roomlist.RoomList +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController + +private const val DEFAULT_ADD_PAGES_COUNT = 3 + +internal class RustDynamicRoomList( + override val summaries: MutableSharedFlow>, + 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/SimplePagedRoomList.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeDynamicRoomList.kt similarity index 52% rename from libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/SimplePagedRoomList.kt rename to libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeDynamicRoomList.kt index a63212c005..ffb75a535d 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/FakeDynamicRoomList.kt @@ -13,34 +13,30 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomSummary import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.getAndUpdate -data class SimplePagedRoomList( - override val summaries: MutableStateFlow>, - override val loadingState: StateFlow, - override val currentFilter: MutableStateFlow +class FakeDynamicRoomList( + override val summaries: MutableStateFlow> = MutableStateFlow(emptyList()), + override val loadingState: MutableStateFlow = MutableStateFlow(RoomList.LoadingState.NotLoaded), + override val pageSize: Int = Int.MAX_VALUE, + val currentFilter: MutableStateFlow = MutableStateFlow(RoomListFilter.None), + private val loadMoreLambda: () -> Unit = {}, + private val resetLambda: () -> Unit = {}, + private val updateFilterLambda: (RoomListFilter) -> Unit = { filter -> currentFilter.value = filter }, + private val rebuildSummariesLambda: () -> Unit = {}, ) : DynamicRoomList { - override val pageSize: Int = Int.MAX_VALUE - override val loadedPages = MutableStateFlow(1) - - override val filteredSummaries: SharedFlow> = summaries - override suspend fun loadMore() { - // No-op - loadedPages.getAndUpdate { it + 1 } + loadMoreLambda() } override suspend fun reset() { - loadedPages.emit(1) + resetLambda() } override suspend fun updateFilter(filter: RoomListFilter) { - currentFilter.emit(filter) + updateFilterLambda(filter) } override suspend fun rebuildSummaries() { - // No-op + rebuildSummariesLambda() } } 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..cdac4cd16a 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 @@ -11,29 +11,19 @@ package io.element.android.libraries.matrix.test.roomlist import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList import io.element.android.libraries.matrix.api.roomlist.RoomList -import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListService -import io.element.android.libraries.matrix.api.roomlist.RoomSummary import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class FakeRoomListService( - var subscribeToVisibleRoomsLambda: (List) -> Unit = {}, + private val subscribeToVisibleRoomsLambda: (List) -> Unit = {}, + private val createRoomListLambda: (pageSize: Int) -> DynamicRoomList = { pageSize -> FakeDynamicRoomList(pageSize = pageSize) }, + override val allRooms: RoomList = createRoomListLambda(Int.MAX_VALUE), ) : RoomListService { - private val allRoomSummariesFlow = MutableStateFlow>(emptyList()) - private val allRoomsLoadingStateFlow = MutableStateFlow(RoomList.LoadingState.NotLoaded) private val roomListStateFlow = MutableStateFlow(RoomListService.State.Idle) private val syncIndicatorStateFlow = MutableStateFlow(RoomListService.SyncIndicator.Hide) - suspend fun postAllRooms(roomSummaries: List) { - allRoomSummariesFlow.emit(roomSummaries) - } - - suspend fun postAllRoomsLoadingState(loadingState: RoomList.LoadingState) { - allRoomsLoadingStateFlow.emit(loadingState) - } - suspend fun postState(state: RoomListService.State) { roomListStateFlow.emit(state) } @@ -44,25 +34,14 @@ class FakeRoomListService( override fun createRoomList( pageSize: Int, - initialFilter: RoomListFilter, source: RoomList.Source, coroutineScope: CoroutineScope, - ): DynamicRoomList { - return when (source) { - RoomList.Source.All -> allRooms - } - } + ) = createRoomListLambda(pageSize) override suspend fun subscribeToVisibleRooms(roomIds: List) { subscribeToVisibleRoomsLambda(roomIds) } - override val allRooms = SimplePagedRoomList( - allRoomSummariesFlow, - allRoomsLoadingStateFlow, - MutableStateFlow(RoomListFilter.all()) - ) - override val state: StateFlow = roomListStateFlow override val syncIndicator: StateFlow = syncIndicatorStateFlow diff --git a/libraries/roomselect/impl/build.gradle.kts b/libraries/roomselect/impl/build.gradle.kts index 1a5ae81928..07443bbf9d 100644 --- a/libraries/roomselect/impl/build.gradle.kts +++ b/libraries/roomselect/impl/build.gradle.kts @@ -16,6 +16,12 @@ plugins { android { namespace = "io.element.android.libraries.roomselect.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } setupDependencyInjection() @@ -30,6 +36,6 @@ dependencies { implementation(projects.libraries.uiStrings) api(projects.libraries.roomselect.api) - testCommonDependencies(libs) + testCommonDependencies(libs, includeTestComposeView = true) testImplementation(projects.libraries.matrix.test) } 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..a3512c419c 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/RoomSelectStateProvider.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt index 6cd2bc6921..b0d18659b6 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt @@ -43,22 +43,23 @@ open class RoomSelectStateProvider : PreviewParameterProvider { ) } -private fun aRoomSelectState( +internal fun aRoomSelectState( mode: RoomSelectMode = RoomSelectMode.Forward, resultState: SearchBarResultState> = SearchBarResultState.Initial(), searchQuery: String = "", isSearchActive: Boolean = false, selectedRooms: ImmutableList = persistentListOf(), + eventSink: (RoomSelectEvents) -> Unit = {}, ) = RoomSelectState( mode = mode, resultState = resultState, searchQuery = TextFieldState(initialText = searchQuery), isSearchActive = isSearchActive, selectedRooms = selectedRooms, - eventSink = {} + eventSink = eventSink, ) -private fun aRoomSelectRoomList() = persistentListOf( +internal fun aRoomSelectRoomList() = persistentListOf( aSelectRoomInfo( roomId = RoomId("!room1:domain"), name = "Room with name", 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( diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt index bd8ef59482..67c1b52231 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt @@ -17,13 +17,17 @@ import io.element.android.libraries.designsystem.theme.components.SearchBarResul import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo import io.element.android.libraries.roomselect.api.RoomSelectMode import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -63,9 +67,12 @@ class RoomSelectPresenterTest { @Test fun `present - update query`() = runTest { val roomSummary = aRoomSummary() - val roomListService = FakeRoomListService().apply { - postAllRooms(listOf(roomSummary)) - } + val roomList = FakeDynamicRoomList( + summaries = MutableStateFlow(listOf(roomSummary)) + ) + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ) val presenter = createRoomSelectPresenter( roomListService = roomListService ) @@ -81,12 +88,12 @@ class RoomSelectPresenterTest { skipItems(1) initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained") assertThat( - roomListService.allRooms.currentFilter.value + roomList.currentFilter.value ).isEqualTo( RoomListFilter.NormalizedMatchRoomName("string not contained") ) assertThat(awaitItem().searchQuery.text.toString()).isEqualTo("string not contained") - roomListService.postAllRooms( + roomList.summaries.emit( emptyList() ) assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResultsFound::class.java) @@ -96,9 +103,12 @@ class RoomSelectPresenterTest { @Test fun `present - select and remove a room`() = runTest { val roomSummary = aRoomSummary() - val roomListService = FakeRoomListService().apply { - postAllRooms(listOf(roomSummary)) - } + val roomList = FakeDynamicRoomList( + summaries = MutableStateFlow(listOf(roomSummary)) + ) + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ) val presenter = createRoomSelectPresenter( roomListService = roomListService, ) @@ -114,6 +124,35 @@ class RoomSelectPresenterTest { cancel() } } + + @Test + fun `present - UpdateVisibleRange triggers pagination when near end`() = runTest { + val loadMoreLambda = lambdaRecorder { } + val roomList = FakeDynamicRoomList( + summaries = MutableStateFlow(listOf()), + loadMoreLambda = loadMoreLambda, + ) + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList } + ) + val presenter = createRoomSelectPresenter(roomListService = roomListService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // Post some rooms to simulate loaded content + val rooms = (1..10).map { aRoomSummary() } + roomList.summaries.emit(rooms) + skipItems(1) + + // UpdateVisibleRange near end should trigger loadMore + initialState.eventSink(RoomSelectEvents.UpdateVisibleRange(IntRange(0, 9))) + // Give time for the coroutine to complete + testScheduler.advanceUntilIdle() + + assert(loadMoreLambda).isCalledOnce() + } + } } internal fun TestScope.createRoomSelectPresenter(