Skip to content

Commit

Permalink
Subscribe to RoomListItems in the visible range (#3169)
Browse files Browse the repository at this point in the history
* Subscribe to `RoomListItems` in the visible range

This ensures the room list items always have updated info.
  • Loading branch information
jmartinesp authored Jul 11, 2024
1 parent 0be7058 commit 5944f11
Show file tree
Hide file tree
Showing 16 changed files with 204 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.matrix.api.core.RoomId

sealed interface RoomListEvents {
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
data object DismissRequestVerificationPrompt : RoomListEvents
data object DismissRecoveryKeyPrompt : RoomListEvents
data object ToggleSearchResults : RoomListEvents
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ import io.element.android.services.analyticsproviders.api.trackers.captureIntera
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
Expand All @@ -78,6 +80,8 @@ import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
import javax.inject.Inject

private const val EXTENDED_RANGE_SIZE = 40

class RoomListPresenter @Inject constructor(
private val client: MatrixClient,
private val networkMonitor: NetworkMonitor,
Expand Down Expand Up @@ -122,6 +126,9 @@ class RoomListPresenter @Inject constructor(

fun handleEvents(event: RoomListEvents) {
when (event) {
is RoomListEvents.UpdateVisibleRange -> coroutineScope.launch {
updateVisibleRange(event.range)
}
RoomListEvents.DismissRequestVerificationPrompt -> securityBannerDismissed = true
RoomListEvents.DismissRecoveryKeyPrompt -> securityBannerDismissed = true
RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
Expand Down Expand Up @@ -283,6 +290,22 @@ class RoomListPresenter @Inject constructor(
}
}
}

private var currentUpdateVisibleRangeJob: Job? = null
private fun CoroutineScope.updateVisibleRange(range: IntRange) {
currentUpdateVisibleRangeJob?.cancel()
currentUpdateVisibleRangeJob = launch(SupervisorJob()) {
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)
}
}
}

@VisibleForTesting
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
Expand Down Expand Up @@ -165,6 +170,18 @@ private fun RoomsViewList(
modifier: Modifier = Modifier,
) {
val lazyListState = rememberLazyListState()
val visibleRange by remember {
derivedStateOf {
val layoutInfo = lazyListState.layoutInfo
val firstItemIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0
val size = layoutInfo.visibleItemsInfo.size
firstItemIndex until firstItemIndex + size
}
}
val updatedEventSink by rememberUpdatedState(newValue = eventSink)
LaunchedEffect(visibleRange) {
updatedEventSink(RoomListEvents.UpdateVisibleRange(visibleRange))
}
LazyColumn(
state = lazyListState,
modifier = modifier,
Expand All @@ -177,7 +194,7 @@ private fun RoomsViewList(
item {
ConfirmRecoveryKeyBanner(
onContinueClick = onConfirmRecoveryKeyClick,
onDismissClick = { eventSink(RoomListEvents.DismissRecoveryKeyPrompt) }
onDismissClick = { updatedEventSink(RoomListEvents.DismissRecoveryKeyPrompt) }
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.androidutils.diff.DiffCacheUpdater
import io.element.android.libraries.androidutils.diff.MutableListDiffCache
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
Expand Down Expand Up @@ -57,6 +58,10 @@ class RoomListDataSource @Inject constructor(
old?.roomId == new?.roomId
}

val allRooms: Flow<ImmutableList<RoomListRoomSummary>> = _allRooms

val loadingState = roomListService.allRooms.loadingState

fun launchIn(coroutineScope: CoroutineScope) {
roomListService
.allRooms
Expand All @@ -67,9 +72,9 @@ class RoomListDataSource @Inject constructor(
.launchIn(coroutineScope)
}

val allRooms: Flow<ImmutableList<RoomListRoomSummary>> = _allRooms

val loadingState = roomListService.allRooms.loadingState
suspend fun subscribeToVisibleRooms(roomIds: List<RoomId>) {
roomListService.subscribeToVisibleRooms(roomIds)
}

@OptIn(FlowPreview::class)
private fun observeNotificationSettings() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.fullscreenintent.test.FakeFullScreenIntentPermissionsPresenter
import io.element.android.libraries.indicator.impl.DefaultIndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
Expand Down Expand Up @@ -584,6 +585,37 @@ class RoomListPresenterTest {
}
}

@Test
fun `present - UpdateVisibleRange subscribes to rooms in visible range`() = runTest {
val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> }
val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda)
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED
)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
roomListService.postAllRooms(listOf(roomSummary))
val presenter = createRoomListPresenter(
coroutineScope = scope,
client = matrixClient,
)
presenter.test {
val state = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}.last()

state.eventSink(RoomListEvents.UpdateVisibleRange(IntRange(0, 10)))
subscribeToVisibleRoomsLambda.assertions().isCalledOnce()

// If called again, it will cancel the current one, which should not result in a test failure
state.eventSink(RoomListEvents.UpdateVisibleRange(IntRange(0, 11)))
subscribeToVisibleRoomsLambda.assertions().isCalledExactly(2)
}
}

private fun TestScope.createRoomListPresenter(
client: MatrixClient = FakeMatrixClient(),
networkMonitor: NetworkMonitor = FakeNetworkMonitor(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@ import org.junit.runner.RunWith
class RoomListViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()

@Test
fun `displaying the view automatically sends a couple of UpdateVisibleRangeEvents`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
rule.setRoomListView(
state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
eventSink = eventsRecorder,
)
)

eventsRecorder.assertList(
listOf(
RoomListEvents.UpdateVisibleRange(IntRange.EMPTY),
RoomListEvents.UpdateVisibleRange(0 until 2),
)
)
}

@Test
fun `clicking on close recovery key banner emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
Expand All @@ -53,14 +71,18 @@ class RoomListViewTest {
eventSink = eventsRecorder,
)
)

// Remove automatic initial events
eventsRecorder.clear()

val close = rule.activity.getString(CommonStrings.action_close)
rule.onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(RoomListEvents.DismissRecoveryKeyPrompt)
}

@Test
fun `clicking on continue recovery key banner invokes the expected callback`() {
val eventsRecorder = EventsRecorder<RoomListEvents>(expectEvents = false)
val eventsRecorder = EventsRecorder<RoomListEvents>()
ensureCalledOnce { callback ->
rule.setRoomListView(
state = aRoomListState(
Expand All @@ -69,7 +91,13 @@ class RoomListViewTest {
),
onConfirmRecoveryKeyClick = callback,
)

// Remove automatic initial events
eventsRecorder.clear()

rule.clickOn(CommonStrings.action_continue)

eventsRecorder.assertEmpty()
}
}

Expand All @@ -90,7 +118,7 @@ class RoomListViewTest {

@Test
fun `clicking on a room invokes the expected callback`() {
val eventsRecorder = EventsRecorder<RoomListEvents>(expectEvents = false)
val eventsRecorder = EventsRecorder<RoomListEvents>()
val state = aRoomListState(
eventSink = eventsRecorder,
)
Expand All @@ -102,8 +130,14 @@ class RoomListViewTest {
state = state,
onRoomClick = callback,
)

// Remove automatic initial events
eventsRecorder.clear()

rule.onNodeWithText(room0.lastMessage!!.toString()).performClick()
}

eventsRecorder.assertEmpty()
}

@Test
Expand All @@ -118,6 +152,9 @@ class RoomListViewTest {
rule.setRoomListView(
state = state,
)
// Remove automatic initial events
eventsRecorder.clear()

rule.onNodeWithText(room0.lastMessage!!.toString()).performTouchInput { longClick() }
eventsRecorder.assertSingle(RoomListEvents.ShowContextMenu(room0))
}
Expand All @@ -135,8 +172,13 @@ class RoomListViewTest {
state = state,
onRoomSettingsClick = callback,
)

// Remove automatic initial events
eventsRecorder.clear()

rule.clickOn(CommonStrings.common_settings)
}

eventsRecorder.assertSingle(RoomListEvents.HideContextMenu)
}

Expand All @@ -150,6 +192,10 @@ class RoomListViewTest {
it.displayType == RoomSummaryDisplayType.INVITE
}
rule.setRoomListView(state = state)

// Remove automatic initial events
eventsRecorder.clear()

rule.clickOn(CommonStrings.action_accept)
rule.clickOn(CommonStrings.action_decline)
eventsRecorder.assertList(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.api.roomlist

import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterIsInstance
Expand Down Expand Up @@ -53,6 +54,12 @@ interface RoomListService {
source: RoomList.Source,
): DynamicRoomList

/**
* Subscribes to sync requests for the visible rooms.
* @param roomIds the list of visible room ids to subscribe to.
*/
suspend fun subscribeToVisibleRooms(roomIds: List<RoomId>)

/**
* Returns a [DynamicRoomList] object of all rooms we want to display.
* If you want to get a filtered room list, consider using [createRoomList].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import io.element.android.libraries.matrix.impl.notificationsettings.RustNotific
import io.element.android.libraries.matrix.impl.oidc.toRustAction
import io.element.android.libraries.matrix.impl.pushers.RustPushersService
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber
import io.element.android.libraries.matrix.impl.room.RustRoomFactory
import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewMapper
import io.element.android.libraries.matrix.impl.roomdirectory.RustRoomDirectoryService
Expand Down Expand Up @@ -213,6 +214,8 @@ class RustMatrixClient(
}
}

private val roomSyncSubscriber: RoomSyncSubscriber = RoomSyncSubscriber(innerRoomListService, dispatchers)

override val roomListService: RoomListService = RustRoomListService(
innerRoomListService = innerRoomListService,
sessionCoroutineScope = sessionCoroutineScope,
Expand All @@ -221,6 +224,7 @@ class RustMatrixClient(
innerRoomListService = innerRoomListService,
sessionCoroutineScope = sessionCoroutineScope,
),
roomSyncSubscriber = roomSyncSubscriber,
)

private val verificationService = RustSessionVerificationService(
Expand All @@ -238,6 +242,7 @@ class RustMatrixClient(
dispatchers = dispatchers,
systemClock = clock,
roomContentForwarder = RoomContentForwarder(innerRoomListService),
roomSyncSubscriber = roomSyncSubscriber,
isKeyBackupEnabled = { client.encryption().use { it.backupState() == BackupState.ENABLED } },
getSessionData = { sessionStore.getSession(sessionId.value)!! },
)
Expand Down
Loading

0 comments on commit 5944f11

Please sign in to comment.