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

Refactor room list filtering to use Rust SDK
This commit is contained in:
ganfra
2026-02-02 10:58:21 +01:00
committed by GitHub
43 changed files with 648 additions and 613 deletions

View File

@@ -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.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom 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.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.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.ui.room.LoadingRoomState import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory
@@ -54,7 +55,8 @@ class LoadingBaseRoomStateFlowFactoryTest {
@Test @Test
fun `flow should emit Loading and then Loaded when there is a room in cache after SS is loaded`() = runTest { 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 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 matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService)
val flowFactory = LoadingRoomStateFlowFactory(matrixClient) val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory flowFactory
@@ -62,21 +64,22 @@ class LoadingBaseRoomStateFlowFactoryTest {
.test { .test {
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading) assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
matrixClient.givenGetRoomResult(A_ROOM_ID, room) 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)) assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room))
} }
} }
@Test @Test
fun `flow should emit Loading and then Error when there is no room in cache after SS is loaded`() = runTest { 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 matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService)
val flowFactory = LoadingRoomStateFlowFactory(matrixClient) val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory flowFactory
.create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = null) .create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = null)
.test { .test {
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading) assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) roomList.loadingState.emit(RoomList.LoadingState.Loaded(1))
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Error) assertThat(awaitItem()).isEqualTo(LoadingRoomState.Error)
} }
} }

View File

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

View File

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

View File

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

View File

@@ -57,8 +57,6 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -68,9 +66,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private const val EXTENDED_RANGE_SIZE = 40
private const val SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS = 300L
@Inject @Inject
class RoomListPresenter( class RoomListPresenter(
private val client: MatrixClient, private val client: MatrixClient,
@@ -119,7 +114,7 @@ class RoomListPresenter(
fun handleEvent(event: RoomListEvent) { fun handleEvent(event: RoomListEvent) {
when (event) { when (event) {
is RoomListEvent.UpdateVisibleRange -> coroutineScope.launch { is RoomListEvent.UpdateVisibleRange -> coroutineScope.launch {
updateVisibleRange(event.range) roomListDataSource.updateVisibleRange(event.range)
} }
RoomListEvent.DismissRequestVerificationPrompt -> securityBannerDismissed = true RoomListEvent.DismissRequestVerificationPrompt -> securityBannerDismissed = true
RoomListEvent.DismissBanner -> securityBannerDismissed = true RoomListEvent.DismissBanner -> securityBannerDismissed = true
@@ -217,7 +212,7 @@ class RoomListPresenter(
showNewNotificationSoundBanner: Boolean, showNewNotificationSoundBanner: Boolean,
): RoomListContentState { ): RoomListContentState {
val roomSummaries by produceState(initialValue = AsyncData.Loading()) { val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
roomListDataSource.allRooms.collect { value = AsyncData.Success(it) } roomListDataSource.roomSummariesFlow.collect { value = AsyncData.Success(it) }
} }
val loadingState by roomListDataSource.loadingState.collectAsState() val loadingState by roomListDataSource.loadingState.collectAsState()
val showEmpty by remember { val showEmpty by remember {
@@ -322,23 +317,4 @@ class RoomListPresenter(
room.clearEventCacheStorage() 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)
}
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService 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.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.roomlist.FakeRoomListService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@@ -27,9 +29,13 @@ import java.time.Instant
class RoomListDataSourceTest { class RoomListDataSourceTest {
@Test @Test
fun `when DateTimeObserver gets a date change, the room summaries are refreshed`() = runTest { 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) postState(RoomListService.State.Running)
postAllRooms(listOf(aRoomSummary()))
} }
val dateTimeObserver = FakeDateTimeObserver() val dateTimeObserver = FakeDateTimeObserver()
var dateFormatterResult = "Today" var dateFormatterResult = "Today"
@@ -42,7 +48,7 @@ class RoomListDataSourceTest {
dateTimeObserver = dateTimeObserver, dateTimeObserver = dateTimeObserver,
) )
roomListDataSource.allRooms.test { roomListDataSource.roomSummariesFlow.test {
// Observe room list items changes // Observe room list items changes
roomListDataSource.launchIn(backgroundScope) roomListDataSource.launchIn(backgroundScope)
// Get the initial room list // Get the initial room list
@@ -61,9 +67,11 @@ class RoomListDataSourceTest {
@Test @Test
fun `when DateTimeObserver gets a time zone change, the room summaries are refreshed`() = runTest { 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) postState(RoomListService.State.Running)
postAllRooms(listOf(aRoomSummary()))
} }
val dateTimeObserver = FakeDateTimeObserver() val dateTimeObserver = FakeDateTimeObserver()
var dateFormatterResult = "Today" var dateFormatterResult = "Today"
@@ -75,7 +83,7 @@ class RoomListDataSourceTest {
), ),
dateTimeObserver = dateTimeObserver, dateTimeObserver = dateTimeObserver,
) )
roomListDataSource.allRooms.test { roomListDataSource.roomSummariesFlow.test {
// Observe room list items changes // Observe room list items changes
roomListDataSource.launchIn(backgroundScope) roomListDataSource.launchIn(backgroundScope)
// Get the initial room list // Get the initial room list

View File

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

View File

@@ -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.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember 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.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.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService 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.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceTimeBy
@@ -91,7 +93,10 @@ class RoomListPresenterTest {
@Test @Test
fun `present - load 1 room with success`() = runTest { fun `present - load 1 room with success`() = runTest {
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList()
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService roomListService = roomListService
) )
@@ -102,8 +107,8 @@ class RoomListPresenterTest {
presenter.test { presenter.test {
val initialState = consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Skeleton }.last() val initialState = consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Skeleton }.last()
assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Skeleton::class.java) assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Skeleton::class.java)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) roomList.loadingState.emit(RoomList.LoadingState.Loaded(1))
roomListService.postAllRooms( roomList.summaries.emit(
listOf( listOf(
aRoomSummary( aRoomSummary(
numUnreadMentions = 1, numUnreadMentions = 1,
@@ -128,9 +133,12 @@ class RoomListPresenterTest {
@Test @Test
fun `present - handle DismissRequestVerificationPrompt`() = runTest { fun `present - handle DismissRequestVerificationPrompt`() = runTest {
val roomListService = FakeRoomListService().apply { val roomList = FakeDynamicRoomList(
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
} )
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val encryptionService = FakeEncryptionService().apply { val encryptionService = FakeEncryptionService().apply {
emitRecoveryState(RecoveryState.INCOMPLETE) emitRecoveryState(RecoveryState.INCOMPLETE)
} }
@@ -154,9 +162,12 @@ class RoomListPresenterTest {
val encryptionService = FakeEncryptionService().apply { val encryptionService = FakeEncryptionService().apply {
recoveryStateStateFlow.emit(RecoveryState.DISABLED) recoveryStateStateFlow.emit(RecoveryState.DISABLED)
} }
val roomListService = FakeRoomListService().apply { val roomList = FakeDynamicRoomList(
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
} )
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService, roomListService = roomListService,
encryptionService = encryptionService, encryptionService = encryptionService,
@@ -344,9 +355,13 @@ class RoomListPresenterTest {
fun `present - change in notification settings updates the summary for decorations`() = runTest { fun `present - change in notification settings updates the summary for decorations`() = runTest {
val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
val notificationSettingsService = FakeNotificationSettingsService() val notificationSettingsService = FakeNotificationSettingsService()
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList(
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) summaries = MutableStateFlow(listOf(aRoomSummary(userDefinedNotificationMode = userDefinedMode))),
roomListService.postAllRooms(listOf(aRoomSummary(userDefinedNotificationMode = userDefinedMode))) loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService, roomListService = roomListService,
notificationSettingsService = notificationSettingsService notificationSettingsService = notificationSettingsService
@@ -397,8 +412,12 @@ class RoomListPresenterTest {
@Test @Test
fun `present - when room service returns no room, then contentState is Empty`() = runTest { fun `present - when room service returns no room, then contentState is Empty`() = runTest {
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList(
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(0)) loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(0))
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService, roomListService = roomListService,
) )
@@ -479,16 +498,21 @@ class RoomListPresenterTest {
val acceptDeclinePresenter = Presenter { val acceptDeclinePresenter = Presenter {
anAcceptDeclineInviteState(eventSink = eventSinkRecorder) anAcceptDeclineInviteState(eventSink = eventSinkRecorder)
} }
val roomListService = FakeRoomListService()
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val roomSummary = aRoomSummary( val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED, currentUserMembership = CurrentUserMembership.INVITED,
inviter = aRoomMember(), inviter = aRoomMember(),
) )
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) val roomList = FakeDynamicRoomList(
roomListService.postAllRooms(listOf(roomSummary)) summaries = MutableStateFlow(listOf(roomSummary)),
loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
client = matrixClient, client = matrixClient,
acceptDeclineInvitePresenter = acceptDeclinePresenter acceptDeclineInvitePresenter = acceptDeclinePresenter
@@ -519,15 +543,20 @@ class RoomListPresenterTest {
@Test @Test
fun `present - UpdateVisibleRange will cancel the previous subscription if called too soon`() = runTest { fun `present - UpdateVisibleRange will cancel the previous subscription if called too soon`() = runTest {
val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> } val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> }
val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda)
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val roomSummary = aRoomSummary( val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED currentUserMembership = CurrentUserMembership.INVITED
) )
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) val roomList = FakeDynamicRoomList(
roomListService.postAllRooms(listOf(roomSummary)) 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( val presenter = createRoomListPresenter(
client = matrixClient, client = matrixClient,
) )
@@ -548,15 +577,20 @@ class RoomListPresenterTest {
@Test @Test
fun `present - UpdateVisibleRange subscribes to rooms in visible range`() = runTest { fun `present - UpdateVisibleRange subscribes to rooms in visible range`() = runTest {
val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> } val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> }
val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda)
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val roomSummary = aRoomSummary( val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED currentUserMembership = CurrentUserMembership.INVITED
) )
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) val roomList = FakeDynamicRoomList(
roomListService.postAllRooms(listOf(roomSummary)) 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( val presenter = createRoomListPresenter(
client = matrixClient, client = matrixClient,
) )
@@ -579,15 +613,20 @@ class RoomListPresenterTest {
@Test @Test
fun `present - notification sound banner`() = runTest { fun `present - notification sound banner`() = runTest {
val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> } val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> }
val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda)
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val roomSummary = aRoomSummary( val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED currentUserMembership = CurrentUserMembership.INVITED
) )
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) val roomList = FakeDynamicRoomList(
roomListService.postAllRooms(listOf(roomSummary)) 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<Announcement, Unit> { } val onAnnouncementDismissedResult = lambdaRecorder<Announcement, Unit> { }
val announcementService = FakeAnnouncementService( val announcementService = FakeAnnouncementService(
onAnnouncementDismissedResult = onAnnouncementDismissedResult, onAnnouncementDismissedResult = onAnnouncementDismissedResult,

View File

@@ -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.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.room.aRoomSummary 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.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.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -56,12 +59,15 @@ class RoomListSearchPresenterTest {
@Test @Test
fun `present - query search changes`() = runTest { fun `present - query search changes`() = runTest {
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList()
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createRoomListSearchPresenter(roomListService) val presenter = createRoomListSearchPresenter(roomListService)
presenter.test { presenter.test {
awaitItem().let { state -> awaitItem().let { state ->
assertThat( assertThat(
roomListService.allRooms.currentFilter.value roomList.currentFilter.value
).isEqualTo( ).isEqualTo(
RoomListFilter.None RoomListFilter.None
) )
@@ -70,7 +76,7 @@ class RoomListSearchPresenterTest {
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.query.text).isEqualTo("Search") assertThat(state.query.text).isEqualTo("Search")
assertThat( assertThat(
roomListService.allRooms.currentFilter.value roomList.currentFilter.value
).isEqualTo( ).isEqualTo(
RoomListFilter.NormalizedMatchRoomName("Search") RoomListFilter.NormalizedMatchRoomName("Search")
) )
@@ -79,7 +85,7 @@ class RoomListSearchPresenterTest {
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.query.text.toString()).isEmpty() assertThat(state.query.text.toString()).isEmpty()
assertThat( assertThat(
roomListService.allRooms.currentFilter.value roomList.currentFilter.value
).isEqualTo( ).isEqualTo(
RoomListFilter.None RoomListFilter.None
) )
@@ -89,24 +95,51 @@ class RoomListSearchPresenterTest {
@Test @Test
fun `present - room list changes`() = runTest { fun `present - room list changes`() = runTest {
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList()
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createRoomListSearchPresenter(roomListService) val presenter = createRoomListSearchPresenter(roomListService)
presenter.test { presenter.test {
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.results).isEmpty() assertThat(state.results).isEmpty()
} }
roomListService.postAllRooms( roomList.summaries.emit(
listOf(aRoomSummary()) listOf(aRoomSummary())
) )
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.results).hasSize(1) assertThat(state.results).hasSize(1)
} }
roomListService.postAllRooms(emptyList()) roomList.summaries.emit(emptyList())
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.results).isEmpty() assertThat(state.results).isEmpty()
} }
} }
} }
@Test
fun `present - UpdateVisibleRange triggers pagination when near end`() = runTest {
val loadMoreLambda = lambdaRecorder<Unit> { }
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( fun TestScope.createRoomListSearchPresenter(

View File

@@ -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_ALIAS
import io.element.android.libraries.matrix.test.A_ROOM_ID_2 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.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.roomlist.FakeRoomListService
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@@ -22,7 +23,10 @@ import org.junit.Test
class DefaultRoomAliasSuggestionsDataSourceTest { class DefaultRoomAliasSuggestionsDataSourceTest {
@Test @Test
fun `getAllRoomAliasSuggestions must emit a list of room alias suggestions`() = runTest { 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( val sut = DefaultRoomAliasSuggestionsDataSource(
roomListService roomListService
) )
@@ -31,7 +35,7 @@ class DefaultRoomAliasSuggestionsDataSourceTest {
) )
sut.getAllRoomAliasSuggestions().test { sut.getAllRoomAliasSuggestions().test {
assertThat(awaitItem()).isEmpty() assertThat(awaitItem()).isEmpty()
roomListService.postAllRooms( roomList.summaries.emit(
listOf( listOf(
aRoomSummary(roomId = A_ROOM_ID_2, canonicalAlias = null), aRoomSummary(roomId = A_ROOM_ID_2, canonicalAlias = null),
aRoomSummaryWithAnAlias, aRoomSummaryWithAnAlias,

View File

@@ -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.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService 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.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.roomlist.FakeRoomListService
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@@ -53,10 +55,14 @@ class EditDefaultNotificationSettingsPresenterTest {
initialRoomModeIsDefault = false, initialRoomModeIsDefault = false,
getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID)) }, 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) val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService)
presenter.test { presenter.test {
roomListService.postAllRooms(listOf(aRoomSummary(userDefinedNotificationMode = RoomNotificationMode.ALL_MESSAGES)))
val loadedState = consumeItemsUntilPredicate { state -> val loadedState = consumeItemsUntilPredicate { state ->
state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.ALL_MESSAGES } state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.ALL_MESSAGES }
}.last() }.last()
@@ -71,10 +77,8 @@ class EditDefaultNotificationSettingsPresenterTest {
initialRoomModeIsDefault = false, initialRoomModeIsDefault = false,
getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID, A_ROOM_ID_2)) }, getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID, A_ROOM_ID_2)) },
) )
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList(
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService) summaries = MutableStateFlow(
presenter.test {
roomListService.postAllRooms(
listOf( listOf(
aRoomSummary( aRoomSummary(
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@@ -86,8 +90,14 @@ class EditDefaultNotificationSettingsPresenterTest {
name = "A", name = "A",
userDefinedNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, userDefinedNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
), ),
), )
) )
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService)
presenter.test {
val loadedState = consumeItemsUntilPredicate { state -> val loadedState = consumeItemsUntilPredicate { state ->
state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY } state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY }
}.last() }.last()
@@ -103,10 +113,8 @@ class EditDefaultNotificationSettingsPresenterTest {
initialRoomModeIsDefault = false, initialRoomModeIsDefault = false,
getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID, A_ROOM_ID_2)) }, getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID, A_ROOM_ID_2)) },
) )
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList(
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService) summaries = MutableStateFlow(
presenter.test {
roomListService.postAllRooms(
listOf( listOf(
aRoomSummary( aRoomSummary(
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@@ -118,8 +126,14 @@ class EditDefaultNotificationSettingsPresenterTest {
name = null, name = null,
userDefinedNotificationMode = RoomNotificationMode.MUTE, userDefinedNotificationMode = RoomNotificationMode.MUTE,
), ),
), )
) )
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService)
presenter.test {
val loadedState = consumeItemsUntilPredicate { state -> val loadedState = consumeItemsUntilPredicate { state ->
state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.MUTE } state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.MUTE }
}.last() }.last()

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.aRoomSummary 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.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
@@ -116,12 +117,15 @@ class AddRoomToSpacePresenterTest {
@Test @Test
fun `present - searchResults shows Results when rooms available`() = runTest { 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) val presenter = createAddRoomToSpacePresenter(roomListService = roomListService)
presenter.test { presenter.test {
awaitItem() // Initial state awaitItem() // Initial state
// Post rooms to the service // Post rooms to the service
roomListService.postAllRooms( roomList.summaries.emit(
listOf( listOf(
aRoomSummary( aRoomSummary(
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@@ -296,6 +300,29 @@ class AddRoomToSpacePresenterTest {
} }
} }
@Test
fun `present - UpdateSearchVisibleRange triggers pagination when near end`() = runTest {
val loadMoreLambda = lambdaRecorder<Unit> { }
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 @Test
fun `present - Dismiss after partial success calls reset`() = runTest { fun `present - Dismiss after partial success calls reset`() = runTest {
val resetResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) } val resetResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }

View File

@@ -14,6 +14,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction 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.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder 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<AddRoomToSpaceEvent>()
val rooms = aSelectRoomInfoList()
rule.setAddRoomToSpaceView(
anAddRoomToSpaceState(
isSearchActive = true,
searchResults = SearchBarResultState.Results(rooms),
eventSink = eventsRecorder,
),
)
eventsRecorder.assertTrue(0) { it is AddRoomToSpaceEvent.UpdateSearchVisibleRange }
}
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAddRoomToSpaceView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAddRoomToSpaceView(

View File

@@ -8,7 +8,6 @@
package io.element.android.libraries.core.extensions package io.element.android.libraries.core.extensions
import java.text.Normalizer
import java.util.Locale import java.util.Locale
fun Boolean.toOnOff() = if (this) "ON" else "OFF" 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 RTL_OVERRIDE_CHAR = '\u202E'
private const val LTR_OVERRIDE_CHAR = '\u202D' private const val LTR_OVERRIDE_CHAR = '\u202D'

View File

@@ -0,0 +1,35 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.utils
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
@Composable
fun OnVisibleRangeChangeEffect(lazyListState: LazyListState, onChange: (IntRange) -> Unit) {
val onChangeUpdated by rememberUpdatedState(onChange)
LaunchedEffect(lazyListState) {
snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo }
.map { visibleItemsInfo ->
val firstItemIndex = visibleItemsInfo.firstOrNull()?.index ?: 0
val size = visibleItemsInfo.size
firstItemIndex until firstItemIndex + size
}
.distinctUntilChanged()
.collectLatest { visibleRange ->
onChangeUpdated(visibleRange)
}
}
}

View File

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

View File

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

View File

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

View File

@@ -1,17 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.roomlist
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
internal sealed interface RoomListDynamicEvents {
data object Reset : RoomListDynamicEvents
data object LoadMore : RoomListDynamicEvents
data class SetFilter(val filter: RoomListEntriesDynamicFilterKind) : RoomListDynamicEvents
}

View File

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

View File

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

View File

@@ -1,72 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.core.extensions.withoutAccents
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
val RoomListFilter.predicate
get() = when (this) {
is RoomListFilter.All -> { roomSummary -> NonSpacePredicate(roomSummary) || IsInvitedPredicate(roomSummary) }
is RoomListFilter.Any -> { roomSummary -> NonSpacePredicate(roomSummary) || IsInvitedPredicate(roomSummary) }
RoomListFilter.None -> { _ -> false }
RoomListFilter.Category.Group -> { roomSummary: RoomSummary ->
!roomSummary.info.isDm && NonInvitedPredicate(roomSummary) && NonSpacePredicate(roomSummary)
}
RoomListFilter.Category.People -> { roomSummary: RoomSummary ->
roomSummary.info.isDm && NonInvitedPredicate(roomSummary) && NonSpacePredicate(roomSummary)
}
RoomListFilter.Category.Space -> IsSpacePredicate
RoomListFilter.Favorite -> { roomSummary: RoomSummary ->
roomSummary.info.isFavorite && NonInvitedPredicate(roomSummary) && NonSpacePredicate(roomSummary)
}
RoomListFilter.Unread -> { roomSummary: RoomSummary ->
NonInvitedPredicate(roomSummary) &&
NonSpacePredicate(roomSummary) &&
(roomSummary.info.numUnreadNotifications > 0 || roomSummary.info.isMarkedUnread)
}
is RoomListFilter.NormalizedMatchRoomName -> { roomSummary: RoomSummary ->
roomSummary.info.name?.withoutAccents().orEmpty().contains(normalizedPattern, ignoreCase = true) &&
(NonSpacePredicate(roomSummary) || IsInvitedPredicate(roomSummary))
}
RoomListFilter.Invite -> IsInvitedPredicate
}
fun List<RoomSummary>.filter(filter: RoomListFilter): List<RoomSummary> {
return when (filter) {
is RoomListFilter.All -> {
val predicates = if (filter.filters.isNotEmpty()) {
filter.filters.map { it.predicate }
} else {
listOf(filter.predicate)
}
filter { roomSummary -> predicates.all { it(roomSummary) } }
}
is RoomListFilter.Any -> {
val predicates = if (filter.filters.isNotEmpty()) {
filter.filters.map { it.predicate }
} else {
listOf(filter.predicate)
}
filter { roomSummary -> predicates.any { it(roomSummary) } }
}
else -> filter(filter.predicate)
}
}
private val IsSpacePredicate = { roomSummary: RoomSummary -> roomSummary.info.isSpace }
private val NonSpacePredicate = { roomSummary: RoomSummary -> !IsSpacePredicate(roomSummary) }
private val IsInvitedPredicate = { roomSummary: RoomSummary -> roomSummary.info.currentUserMembership == CurrentUserMembership.INVITED }
private val NonInvitedPredicate = { roomSummary: RoomSummary -> !IsInvitedPredicate(roomSummary) }

View File

@@ -0,0 +1,75 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.All
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Any
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Category
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.DeduplicateVersions
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Favourite
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Invite
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NonLeft
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NonSpace
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.None
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NormalizedMatchRoomName
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Space
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Unread
import org.matrix.rustcomponents.sdk.RoomListFilterCategory
/**
* Mapper for converting RoomListFilter to Rust SDK filter kinds.
*/
internal object RoomListFilterMapper {
/**
* Base rust filters to always apply across all room lists.
* These filters ensure we show:
* - Non-space, non-left rooms (regular rooms user is part of)
* - OR space invites (pending space invitations)
* - With version deduplication enabled
*/
private val RUST_BASE_FILTERS = listOf(
Any(
listOf(
All(listOf(NonSpace, NonLeft)),
All(listOf(Space, Invite)),
)
),
DeduplicateVersions
)
/**
* Converts a RoomListFilter to a Rust SDK RoomListEntriesDynamicFilterKind.
* Applies base filters along with the provided filter.
*/
fun toRustFilter(filter: RoomListFilter): RoomListEntriesDynamicFilterKind {
return All(RUST_BASE_FILTERS + mapFilter(filter))
}
/**
* Maps a RoomListFilter to its Rust SDK equivalent.
* This replaces the previous RoomListFilter.into() extension function.
*/
private fun mapFilter(filter: RoomListFilter): RoomListEntriesDynamicFilterKind {
return when (filter) {
is RoomListFilter.All -> All(filters = filter.filters.map { mapFilter(it) })
is RoomListFilter.Any -> Any(filters = filter.filters.map { mapFilter(it) })
RoomListFilter.None -> None
RoomListFilter.Category.Group -> Category(RoomListFilterCategory.GROUP)
RoomListFilter.Category.People -> Category(RoomListFilterCategory.PEOPLE)
RoomListFilter.Category.Space -> Space
RoomListFilter.Favorite -> Favourite
RoomListFilter.Unread -> Unread
is RoomListFilter.NormalizedMatchRoomName -> NormalizedMatchRoomName(
pattern = filter.pattern
)
RoomListFilter.Invite -> Invite
}
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
private const val DEFAULT_ADD_PAGES_COUNT = 3
internal class RustDynamicRoomList(
override val summaries: MutableSharedFlow<List<RoomSummary>>,
override val loadingState: MutableStateFlow<RoomList.LoadingState>,
private val processor: RoomSummaryListProcessor,
override val pageSize: Int,
private val dynamicController: () -> RoomListDynamicEntriesController?,
private val addPagesCount: Int = DEFAULT_ADD_PAGES_COUNT
) : DynamicRoomList {
private val mutex = Mutex()
override suspend fun rebuildSummaries() {
processor.rebuildRoomSummaries()
}
override suspend fun updateFilter(filter: RoomListFilter) {
mutex.withLock {
dynamicController()?.let { controller ->
// Reset pagination when filter changes
controller.resetToOnePage()
val rustFilter = RoomListFilterMapper.toRustFilter(filter)
controller.setFilter(rustFilter)
// Then preload some pages
controller.addPages(addPagesCount)
}
}
}
override suspend fun loadMore() {
mutex.withLock {
dynamicController()?.addPages(addPagesCount)
}
}
override suspend fun reset() {
mutex.withLock {
dynamicController()?.resetToOnePage()
}
}
private fun RoomListDynamicEntriesController.addPages(pageCount: Int) = repeat(pageCount) { addOnePage() }
}

View File

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

View File

@@ -1,156 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.roomlist
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.test.room.aRoomSummary
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RoomListFilterTest {
private val regularRoom = aRoomSummary(
isDirect = false,
)
private val dmRoom = aRoomSummary(
isDirect = true,
activeMembersCount = 2
)
private val favoriteRoom = aRoomSummary(
isFavorite = true
)
private val markedAsUnreadRoom = aRoomSummary(
isMarkedUnread = true
)
private val unreadNotificationRoom = aRoomSummary(
numUnreadNotifications = 1
)
private val roomToSearch = aRoomSummary(
name = "Room to search"
)
private val roomWithAccent = aRoomSummary(
name = "Frédéric"
)
private val invitedRoom = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED
)
private val space = aRoomSummary(
isSpace = true
)
private val invitedSpace = aRoomSummary(
isSpace = true,
currentUserMembership = CurrentUserMembership.INVITED
)
private val roomSummaries = listOf(
regularRoom,
dmRoom,
favoriteRoom,
markedAsUnreadRoom,
unreadNotificationRoom,
roomToSearch,
roomWithAccent,
invitedRoom,
space,
invitedSpace,
)
@Test
fun `Room list filter all empty`() = runTest {
val filter = RoomListFilter.all()
assertThat(roomSummaries.filter(filter)).isEqualTo(roomSummaries - space)
}
@Test
fun `Room list filter none`() = runTest {
val filter = RoomListFilter.None
assertThat(roomSummaries.filter(filter)).isEmpty()
}
@Test
fun `Room list filter people`() = runTest {
val filter = RoomListFilter.Category.People
assertThat(roomSummaries.filter(filter)).containsExactly(dmRoom)
}
@Test
fun `Room list filter group`() = runTest {
val filter = RoomListFilter.Category.Group
assertThat(roomSummaries.filter(filter)).containsExactly(
regularRoom,
favoriteRoom,
markedAsUnreadRoom,
unreadNotificationRoom,
roomToSearch,
roomWithAccent,
)
}
@Test
fun `Room list filter space`() = runTest {
val filter = RoomListFilter.Category.Space
assertThat(roomSummaries.filter(filter)).containsExactly(space, invitedSpace)
}
@Test
fun `Room list filter favorite`() = runTest {
val filter = RoomListFilter.Favorite
assertThat(roomSummaries.filter(filter)).containsExactly(favoriteRoom)
}
@Test
fun `Room list filter unread`() = runTest {
val filter = RoomListFilter.Unread
assertThat(roomSummaries.filter(filter)).containsExactly(markedAsUnreadRoom, unreadNotificationRoom)
}
@Test
fun `Room list filter invites`() = runTest {
val filter = RoomListFilter.Invite
assertThat(roomSummaries.filter(filter)).containsExactly(invitedRoom, invitedSpace)
}
@Test
fun `Room list filter normalized match room name`() = runTest {
val filter = RoomListFilter.NormalizedMatchRoomName("search")
assertThat(roomSummaries.filter(filter)).containsExactly(roomToSearch)
}
@Test
fun `Room list filter normalized match room name with accent`() = runTest {
val filter = RoomListFilter.NormalizedMatchRoomName("Fred")
assertThat(roomSummaries.filter(filter)).containsExactly(roomWithAccent)
}
@Test
fun `Room list filter normalized match room name with accent when searching with accent`() = runTest {
val filter = RoomListFilter.NormalizedMatchRoomName("Fréd")
assertThat(roomSummaries.filter(filter)).containsExactly(roomWithAccent)
}
@Test
fun `Room list filter all with one match`() = runTest {
val filter = RoomListFilter.all(
RoomListFilter.Category.Group,
RoomListFilter.Favorite
)
assertThat(roomSummaries.filter(filter)).containsExactly(favoriteRoom)
}
@Test
fun `Room list filter all with no match`() = runTest {
val filter = RoomListFilter.all(
RoomListFilter.Category.People,
RoomListFilter.Favorite
)
assertThat(roomSummaries.filter(filter)).isEmpty()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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