From 98bb5720d1b548ed76f371c75309bcfe8d7c17d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 12 Mar 2025 16:19:36 +0100 Subject: [PATCH 1/5] Add timeline prefetching This should trigger when getting close the start of the loaded timeline, making scrolling back smoother, specially when combined with the persistent event cache. --- .../features/messages/impl/timeline/TimelineView.kt | 13 +++++++++++++ .../timeline/components/TimelineItemVirtualRow.kt | 2 ++ 2 files changed, 15 insertions(+) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 115039a284..d11d655ec5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -70,8 +70,10 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.launch +import timber.log.Timber @Composable fun TimelineView( @@ -175,6 +177,17 @@ fun TimelineView( onClearFocusRequestState = ::clearFocusRequestState ) + val isCloseToStartOfLoadedTimeline by remember { derivedStateOf { + lazyListState.firstVisibleItemIndex + lazyListState.layoutInfo.visibleItemsInfo.size >= lazyListState.layoutInfo.totalItemsCount - 10 + } } + LaunchedEffect(isCloseToStartOfLoadedTimeline) { + // Only back paginate when we're close to the start of the loaded timeline items and the user is actively scrolling + if (lazyListState.isScrollInProgress && isCloseToStartOfLoadedTimeline) { + Timber.d("Prefetching pagination with ${lazyListState.layoutInfo.totalItemsCount} items") + state.eventSink(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)) + } + } + TimelineScrollHelper( hasAnyEvent = state.hasAnyEvent, lazyListState = lazyListState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt index aa4a006249..2507fb9f01 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt @@ -28,6 +28,7 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel import io.element.android.features.messages.impl.typing.TypingNotificationView +import timber.log.Timber @Composable fun TimelineItemVirtualRow( @@ -45,6 +46,7 @@ fun TimelineItemVirtualRow( TimelineLoadingMoreIndicator(virtual.model.direction) val latestEventSink by rememberUpdatedState(eventSink) LaunchedEffect(virtual.model.timestamp) { + Timber.d("Pagination triggered by load more indicator") latestEventSink(TimelineEvents.LoadMore(virtual.model.direction)) } } From 955a599d5cab038e873a01704034c524f22a5ca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 12 Mar 2025 16:48:43 +0100 Subject: [PATCH 2/5] Fix lint issues --- .../android/features/messages/impl/timeline/TimelineView.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index d11d655ec5..bcf6365d6b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -177,9 +177,11 @@ fun TimelineView( onClearFocusRequestState = ::clearFocusRequestState ) - val isCloseToStartOfLoadedTimeline by remember { derivedStateOf { + val isCloseToStartOfLoadedTimeline by remember { + derivedStateOf { lazyListState.firstVisibleItemIndex + lazyListState.layoutInfo.visibleItemsInfo.size >= lazyListState.layoutInfo.totalItemsCount - 10 - } } + } + } LaunchedEffect(isCloseToStartOfLoadedTimeline) { // Only back paginate when we're close to the start of the loaded timeline items and the user is actively scrolling if (lazyListState.isScrollInProgress && isCloseToStartOfLoadedTimeline) { From 97b20d102f8730396c31cbece4c62f39650bea0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 17 Mar 2025 16:30:43 +0100 Subject: [PATCH 3/5] Try improved version using `snapshotFlow`, which seems more performant, as well as an extended threshold (-40 items) --- .../messages/impl/timeline/TimelineView.kt | 53 +++++++++++++++---- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index bcf6365d6b..8172617d86 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -36,6 +36,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate @@ -72,6 +73,10 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import timber.log.Timber @@ -177,17 +182,8 @@ fun TimelineView( onClearFocusRequestState = ::clearFocusRequestState ) - val isCloseToStartOfLoadedTimeline by remember { - derivedStateOf { - lazyListState.firstVisibleItemIndex + lazyListState.layoutInfo.visibleItemsInfo.size >= lazyListState.layoutInfo.totalItemsCount - 10 - } - } - LaunchedEffect(isCloseToStartOfLoadedTimeline) { - // Only back paginate when we're close to the start of the loaded timeline items and the user is actively scrolling - if (lazyListState.isScrollInProgress && isCloseToStartOfLoadedTimeline) { - Timber.d("Prefetching pagination with ${lazyListState.layoutInfo.totalItemsCount} items") - state.eventSink(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)) - } + TimelinePrefetchingHelper(lazyListState = lazyListState) { + state.eventSink(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)) } TimelineScrollHelper( @@ -218,6 +214,41 @@ private fun MessageShieldDialog(state: TimelineState) { ) } +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +private fun TimelinePrefetchingHelper( + lazyListState: LazyListState, + prefetch: () -> Unit, +) { + // We're using snapshot flows for these because using `LaunchedEffect` with `derivedState` doesn't seem to be responsive enough + val firstVisibleItemIndexFlow = snapshotFlow { + lazyListState.firstVisibleItemIndex + } + val layoutInfoFlow = snapshotFlow { + lazyListState.layoutInfo + } + val isScrollingFlow = snapshotFlow { + lazyListState.isScrollInProgress + } + + LaunchedEffect(Unit) { + val isCloseToStartOfLoadedTimelineFlow = combine(layoutInfoFlow, firstVisibleItemIndexFlow) { layoutInfo, firstVisibleItemIndex -> + firstVisibleItemIndex + layoutInfo.visibleItemsInfo.size >= layoutInfo.totalItemsCount - 40 + } + + combine(isCloseToStartOfLoadedTimelineFlow, isScrollingFlow) { needsPrefetch, isScrolling -> + needsPrefetch && isScrolling + } + .distinctUntilChanged() + .collectLatest { needsPrefetch -> + if (needsPrefetch) { + Timber.d("Prefetching pagination with ${lazyListState.layoutInfo.totalItemsCount} items") + prefetch() + } + } + } +} + @Composable private fun BoxScope.TimelineScrollHelper( hasAnyEvent: Boolean, From f95a959ed18f1f4ada4ad8e7138e50dcc82d3596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 17 Mar 2025 18:22:03 +0100 Subject: [PATCH 4/5] Fix tests and lint issues --- .../messages/impl/timeline/TimelineView.kt | 18 +++++++--- .../messages/impl/MessagesViewTest.kt | 27 ++++++++++++-- .../impl/timeline/TimelineViewTest.kt | 35 +++++++++++++++++-- .../android/libraries/testtags/TestTags.kt | 5 +++ 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 8172617d86..afd0834ad8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -72,6 +72,8 @@ import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collectLatest @@ -143,7 +145,8 @@ fun TimelineView( LazyColumn( modifier = Modifier .fillMaxSize() - .nestedScroll(nestedScrollConnection), + .nestedScroll(nestedScrollConnection) + .testTag(TestTags.timeline), state = lazyListState, reverseLayout = useReverseLayout, contentPadding = PaddingValues(vertical = 8.dp), @@ -220,6 +223,8 @@ private fun TimelinePrefetchingHelper( lazyListState: LazyListState, prefetch: () -> Unit, ) { + val latestPrefetch by rememberUpdatedState(prefetch) + // We're using snapshot flows for these because using `LaunchedEffect` with `derivedState` doesn't seem to be responsive enough val firstVisibleItemIndexFlow = snapshotFlow { lazyListState.firstVisibleItemIndex @@ -231,19 +236,22 @@ private fun TimelinePrefetchingHelper( lazyListState.isScrollInProgress } - LaunchedEffect(Unit) { + LaunchedEffect(latestPrefetch) { val isCloseToStartOfLoadedTimelineFlow = combine(layoutInfoFlow, firstVisibleItemIndexFlow) { layoutInfo, firstVisibleItemIndex -> firstVisibleItemIndex + layoutInfo.visibleItemsInfo.size >= layoutInfo.totalItemsCount - 40 } - combine(isCloseToStartOfLoadedTimelineFlow, isScrollingFlow) { needsPrefetch, isScrolling -> + combine( + isCloseToStartOfLoadedTimelineFlow, + isScrollingFlow, + ) { needsPrefetch, isScrolling -> needsPrefetch && isScrolling } .distinctUntilChanged() .collectLatest { needsPrefetch -> if (needsPrefetch) { Timber.d("Prefetching pagination with ${lazyListState.layoutInfo.totalItemsCount} items") - prefetch() + latestPrefetch() } } } @@ -274,7 +282,7 @@ private fun BoxScope.TimelineScrollHelper( coroutineScope.launch { if (lazyListState.firstVisibleItemIndex > 10) { lazyListState.scrollToItem(0) - } else { + } else if (lazyListState.firstVisibleItemIndex != 0) { lazyListState.animateScrollToItem(0) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index a7c76d73e2..9f46637948 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -41,6 +41,7 @@ import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMess import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.aTimelineItemList import io.element.android.features.messages.impl.timeline.aTimelineItemReadReceipts import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo import io.element.android.features.messages.impl.timeline.aTimelineState @@ -50,6 +51,7 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.testtags.TestTags @@ -126,6 +128,9 @@ class MessagesViewTest { fun `clicking on an Event invoke expected callback`() { val eventsRecorder = EventsRecorder(expectEvents = false) val state = aMessagesState( + timelineState = aTimelineState( + timelineItems = aTimelineItemList(aTimelineItemTextContent()), + ), eventSink = eventsRecorder ) val timelineItem = state.timelineState.timelineItems.first() @@ -182,6 +187,9 @@ class MessagesViewTest { canSendReaction = userHasPermissionToSendReaction, canPinUnpin = userCanPinEvent, ), + timelineState = aTimelineState( + timelineItems = aTimelineItemList(aTimelineItemTextContent()), + ), ) val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event rule.setMessagesView( @@ -349,7 +357,10 @@ class MessagesViewTest { fun `clicking on a reaction emits the expected Event`() { val eventsRecorder = EventsRecorder() val state = aMessagesState( - eventSink = eventsRecorder + timelineState = aTimelineState( + timelineItems = aTimelineItemList(aTimelineItemTextContent()), + ), + eventSink = eventsRecorder, ) val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event rule.setMessagesView( @@ -363,6 +374,9 @@ class MessagesViewTest { fun `long clicking on a reaction emits the expected Event`() { val eventsRecorder = EventsRecorder() val state = aMessagesState( + timelineState = aTimelineState( + timelineItems = aTimelineItemList(aTimelineItemTextContent()), + ), reactionSummaryState = aReactionSummaryState( target = null, eventSink = eventsRecorder, @@ -380,6 +394,9 @@ class MessagesViewTest { fun `clicking on more reaction emits the expected Event`() { val eventsRecorder = EventsRecorder() val state = aMessagesState( + timelineState = aTimelineState( + timelineItems = aTimelineItemList(aTimelineItemTextContent()), + ), customReactionState = aCustomReactionState( eventSink = eventsRecorder, ), @@ -396,7 +413,11 @@ class MessagesViewTest { @Test fun `clicking on more reaction from action list emits the expected Event`() { val eventsRecorder = EventsRecorder() - val state = aMessagesState() + val state = aMessagesState( + timelineState = aTimelineState( + timelineItems = aTimelineItemList(aTimelineItemTextContent()), + ), + ) val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event val stateWithActionListState = state.copy( actionListState = anActionListState( @@ -538,7 +559,7 @@ private fun AndroidComposeTestRule.setMessa onCreatePollClick = onCreatePollClick, onJoinCallClick = onJoinCallClick, onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick, - knockRequestsBannerView = {} + knockRequestsBannerView = {}, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt index 61e742f872..0c434aef15 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -11,14 +11,18 @@ import androidx.activity.ComponentActivity import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToIndex import androidx.test.ext.junit.runners.AndroidJUnit4 import io.element.android.features.messages.impl.timeline.components.aCriticalShield import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.Timeline @@ -31,6 +35,7 @@ import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.setSafeContent import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule @@ -114,8 +119,6 @@ class TimelineViewTest { rule.onNodeWithContentDescription(contentDescription).performClick() eventsRecorder.assertList( listOf( - TimelineEvents.OnScrollFinished(0), - TimelineEvents.OnScrollFinished(0), TimelineEvents.OnScrollFinished(0), TimelineEvents.ShowShieldDialog(MessageShield.UnverifiedIdentity(true)), ) @@ -135,6 +138,34 @@ class TimelineViewTest { rule.clickOn(CommonStrings.action_ok) eventsRecorder.assertSingle(TimelineEvents.HideShieldDialog) } + + @Test + fun `scrolling near to the start of the loaded items triggers a pre-fetch`() { + val eventsRecorder = EventsRecorder() + val items = List(20) { + aTimelineItemEvent( + eventId = EventId("\$event_$it"), + content = aTimelineItemUnknownContent(), + ) + }.toPersistentList() + + rule.setTimelineView( + state = aTimelineState( + timelineItems = items, + eventSink = eventsRecorder, + focusedEventIndex = -1, + isLive = false, + ), + ) + + rule.onNodeWithTag("timeline").performScrollToIndex(10) + eventsRecorder.assertList( + listOf( + TimelineEvents.OnScrollFinished(firstIndex = 0), + TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS), + ) + ) + } } private fun AndroidComposeTestRule.setTimelineView( diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt index ddc720ed40..8ee03dd5ad 100644 --- a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -97,6 +97,11 @@ object TestTags { */ val floatingActionButton = TestTag("floating-action-button") + /** + * Timeline. + */ + val timeline = TestTag("timeline") + /** * Timeline item. */ From 784f00feb8907592727c81657e3f8ebac6f8d5d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 19 Mar 2025 11:41:34 +0100 Subject: [PATCH 5/5] Use `snapshotFlow` inside a `LaunchedEffect`, improve pre-fetching strategy --- .../messages/impl/timeline/TimelineView.kt | 44 ++++++++++++------- .../impl/timeline/TimelineViewTest.kt | 7 ++- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index afd0834ad8..b34af8f5a3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -76,11 +76,16 @@ import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.transform import kotlinx.coroutines.launch import timber.log.Timber +import kotlin.time.Duration.Companion.milliseconds @Composable fun TimelineView( @@ -139,6 +144,10 @@ fun TimelineView( ) } + fun prefetchMoreItems() { + state.eventSink(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)) + } + // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms AnimatedVisibility(visible = true, enter = fadeIn()) { Box(modifier) { @@ -185,9 +194,10 @@ fun TimelineView( onClearFocusRequestState = ::clearFocusRequestState ) - TimelinePrefetchingHelper(lazyListState = lazyListState) { - state.eventSink(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)) - } + TimelinePrefetchingHelper( + lazyListState = lazyListState, + prefetch = ::prefetchMoreItems + ) TimelineScrollHelper( hasAnyEvent = state.hasAnyEvent, @@ -217,7 +227,7 @@ private fun MessageShieldDialog(state: TimelineState) { ) } -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable private fun TimelinePrefetchingHelper( lazyListState: LazyListState, @@ -225,25 +235,25 @@ private fun TimelinePrefetchingHelper( ) { val latestPrefetch by rememberUpdatedState(prefetch) - // We're using snapshot flows for these because using `LaunchedEffect` with `derivedState` doesn't seem to be responsive enough - val firstVisibleItemIndexFlow = snapshotFlow { - lazyListState.firstVisibleItemIndex - } - val layoutInfoFlow = snapshotFlow { - lazyListState.layoutInfo - } - val isScrollingFlow = snapshotFlow { - lazyListState.isScrollInProgress - } + LaunchedEffect(Unit) { + // We're using snapshot flows for these because using `LaunchedEffect` with `derivedState` doesn't seem to be responsive enough + val firstVisibleItemIndexFlow = snapshotFlow { lazyListState.firstVisibleItemIndex } + val layoutInfoFlow = snapshotFlow { lazyListState.layoutInfo } + val isScrollingFlow = snapshotFlow { lazyListState.isScrollInProgress } + // This value changes too frequently, so we debounce it to avoid unnecessary prefetching. It's the equivalent of a conditional 'throttleLatest' + .conflate() + .transform { isScrolling -> + emit(isScrolling) + if (isScrolling) delay(100.milliseconds) + } - LaunchedEffect(latestPrefetch) { val isCloseToStartOfLoadedTimelineFlow = combine(layoutInfoFlow, firstVisibleItemIndexFlow) { layoutInfo, firstVisibleItemIndex -> firstVisibleItemIndex + layoutInfo.visibleItemsInfo.size >= layoutInfo.totalItemsCount - 40 } combine( - isCloseToStartOfLoadedTimelineFlow, - isScrollingFlow, + isCloseToStartOfLoadedTimelineFlow.distinctUntilChanged(), + isScrollingFlow.distinctUntilChanged(), ) { needsPrefetch, isScrolling -> needsPrefetch && isScrolling } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt index 0c434aef15..fba34f7fab 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -142,7 +142,7 @@ class TimelineViewTest { @Test fun `scrolling near to the start of the loaded items triggers a pre-fetch`() { val eventsRecorder = EventsRecorder() - val items = List(20) { + val items = List(200) { aTimelineItemEvent( eventId = EventId("\$event_$it"), content = aTimelineItemUnknownContent(), @@ -158,7 +158,10 @@ class TimelineViewTest { ), ) - rule.onNodeWithTag("timeline").performScrollToIndex(10) + rule.onNodeWithTag("timeline").performScrollToIndex(180) + + rule.mainClock.advanceTimeBy(1000) + eventsRecorder.assertList( listOf( TimelineEvents.OnScrollFinished(firstIndex = 0),