Merge pull request #4399 from element-hq/feat/add-timeline-prefetching-mechanism

Add timeline item prefetching
This commit is contained in:
ganfra 2025-03-20 11:11:57 +01:00 committed by GitHub
commit f85abe27fc
5 changed files with 133 additions and 7 deletions

View file

@ -36,6 +36,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
@ -70,8 +71,21 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter 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.EventId
import io.element.android.libraries.matrix.api.core.UserId 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 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 kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun TimelineView( fun TimelineView(
@ -130,13 +144,18 @@ 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 // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
AnimatedVisibility(visible = true, enter = fadeIn()) { AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) { Box(modifier) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.nestedScroll(nestedScrollConnection), .nestedScroll(nestedScrollConnection)
.testTag(TestTags.timeline),
state = lazyListState, state = lazyListState,
reverseLayout = useReverseLayout, reverseLayout = useReverseLayout,
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
@ -175,6 +194,11 @@ fun TimelineView(
onClearFocusRequestState = ::clearFocusRequestState onClearFocusRequestState = ::clearFocusRequestState
) )
TimelinePrefetchingHelper(
lazyListState = lazyListState,
prefetch = ::prefetchMoreItems
)
TimelineScrollHelper( TimelineScrollHelper(
hasAnyEvent = state.hasAnyEvent, hasAnyEvent = state.hasAnyEvent,
lazyListState = lazyListState, lazyListState = lazyListState,
@ -203,6 +227,46 @@ private fun MessageShieldDialog(state: TimelineState) {
) )
} }
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
private fun TimelinePrefetchingHelper(
lazyListState: LazyListState,
prefetch: () -> Unit,
) {
val latestPrefetch by rememberUpdatedState(prefetch)
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)
}
val isCloseToStartOfLoadedTimelineFlow = combine(layoutInfoFlow, firstVisibleItemIndexFlow) { layoutInfo, firstVisibleItemIndex ->
firstVisibleItemIndex + layoutInfo.visibleItemsInfo.size >= layoutInfo.totalItemsCount - 40
}
combine(
isCloseToStartOfLoadedTimelineFlow.distinctUntilChanged(),
isScrollingFlow.distinctUntilChanged(),
) { needsPrefetch, isScrolling ->
needsPrefetch && isScrolling
}
.distinctUntilChanged()
.collectLatest { needsPrefetch ->
if (needsPrefetch) {
Timber.d("Prefetching pagination with ${lazyListState.layoutInfo.totalItemsCount} items")
latestPrefetch()
}
}
}
}
@Composable @Composable
private fun BoxScope.TimelineScrollHelper( private fun BoxScope.TimelineScrollHelper(
hasAnyEvent: Boolean, hasAnyEvent: Boolean,
@ -228,7 +292,7 @@ private fun BoxScope.TimelineScrollHelper(
coroutineScope.launch { coroutineScope.launch {
if (lazyListState.firstVisibleItemIndex > 10) { if (lazyListState.firstVisibleItemIndex > 10) {
lazyListState.scrollToItem(0) lazyListState.scrollToItem(0)
} else { } else if (lazyListState.firstVisibleItemIndex != 0) {
lazyListState.animateScrollToItem(0) lazyListState.animateScrollToItem(0)
} }
} }

View file

@ -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.TimelineItemRoomBeginningModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
import io.element.android.features.messages.impl.typing.TypingNotificationView import io.element.android.features.messages.impl.typing.TypingNotificationView
import timber.log.Timber
@Composable @Composable
fun TimelineItemVirtualRow( fun TimelineItemVirtualRow(
@ -45,6 +46,7 @@ fun TimelineItemVirtualRow(
TimelineLoadingMoreIndicator(virtual.model.direction) TimelineLoadingMoreIndicator(virtual.model.direction)
val latestEventSink by rememberUpdatedState(eventSink) val latestEventSink by rememberUpdatedState(eventSink)
LaunchedEffect(virtual.model.timestamp) { LaunchedEffect(virtual.model.timestamp) {
Timber.d("Pagination triggered by load more indicator")
latestEventSink(TimelineEvents.LoadMore(virtual.model.direction)) latestEventSink(TimelineEvents.LoadMore(virtual.model.direction))
} }
} }

View file

@ -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.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.TimelineEvents
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent 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.aTimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineState 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.aReadReceiptData
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents 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.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.api.core.UserId
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
@ -126,6 +128,9 @@ class MessagesViewTest {
fun `clicking on an Event invoke expected callback`() { fun `clicking on an Event invoke expected callback`() {
val eventsRecorder = EventsRecorder<MessagesEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<MessagesEvents>(expectEvents = false)
val state = aMessagesState( val state = aMessagesState(
timelineState = aTimelineState(
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
),
eventSink = eventsRecorder eventSink = eventsRecorder
) )
val timelineItem = state.timelineState.timelineItems.first() val timelineItem = state.timelineState.timelineItems.first()
@ -182,6 +187,9 @@ class MessagesViewTest {
canSendReaction = userHasPermissionToSendReaction, canSendReaction = userHasPermissionToSendReaction,
canPinUnpin = userCanPinEvent, canPinUnpin = userCanPinEvent,
), ),
timelineState = aTimelineState(
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
),
) )
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView( rule.setMessagesView(
@ -349,7 +357,10 @@ class MessagesViewTest {
fun `clicking on a reaction emits the expected Event`() { fun `clicking on a reaction emits the expected Event`() {
val eventsRecorder = EventsRecorder<MessagesEvents>() val eventsRecorder = EventsRecorder<MessagesEvents>()
val state = aMessagesState( val state = aMessagesState(
eventSink = eventsRecorder timelineState = aTimelineState(
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
),
eventSink = eventsRecorder,
) )
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView( rule.setMessagesView(
@ -363,6 +374,9 @@ class MessagesViewTest {
fun `long clicking on a reaction emits the expected Event`() { fun `long clicking on a reaction emits the expected Event`() {
val eventsRecorder = EventsRecorder<ReactionSummaryEvents>() val eventsRecorder = EventsRecorder<ReactionSummaryEvents>()
val state = aMessagesState( val state = aMessagesState(
timelineState = aTimelineState(
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
),
reactionSummaryState = aReactionSummaryState( reactionSummaryState = aReactionSummaryState(
target = null, target = null,
eventSink = eventsRecorder, eventSink = eventsRecorder,
@ -380,6 +394,9 @@ class MessagesViewTest {
fun `clicking on more reaction emits the expected Event`() { fun `clicking on more reaction emits the expected Event`() {
val eventsRecorder = EventsRecorder<CustomReactionEvents>() val eventsRecorder = EventsRecorder<CustomReactionEvents>()
val state = aMessagesState( val state = aMessagesState(
timelineState = aTimelineState(
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
),
customReactionState = aCustomReactionState( customReactionState = aCustomReactionState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
@ -396,7 +413,11 @@ class MessagesViewTest {
@Test @Test
fun `clicking on more reaction from action list emits the expected Event`() { fun `clicking on more reaction from action list emits the expected Event`() {
val eventsRecorder = EventsRecorder<CustomReactionEvents>() val eventsRecorder = EventsRecorder<CustomReactionEvents>()
val state = aMessagesState() val state = aMessagesState(
timelineState = aTimelineState(
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
),
)
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
val stateWithActionListState = state.copy( val stateWithActionListState = state.copy(
actionListState = anActionListState( actionListState = anActionListState(
@ -538,7 +559,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
onCreatePollClick = onCreatePollClick, onCreatePollClick = onCreatePollClick,
onJoinCallClick = onJoinCallClick, onJoinCallClick = onJoinCallClick,
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick, onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
knockRequestsBannerView = {} knockRequestsBannerView = {},
) )
} }
} }

View file

@ -11,14 +11,18 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToIndex
import androidx.test.ext.junit.runners.AndroidJUnit4 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.components.aCriticalShield
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.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.model.virtual.TimelineItemLoadingIndicatorModel
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState 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.UniqueId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.Timeline 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.clickOn
import io.element.android.tests.testutils.setSafeContent import io.element.android.tests.testutils.setSafeContent
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
@ -114,8 +119,6 @@ class TimelineViewTest {
rule.onNodeWithContentDescription(contentDescription).performClick() rule.onNodeWithContentDescription(contentDescription).performClick()
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
TimelineEvents.OnScrollFinished(0),
TimelineEvents.OnScrollFinished(0),
TimelineEvents.OnScrollFinished(0), TimelineEvents.OnScrollFinished(0),
TimelineEvents.ShowShieldDialog(MessageShield.UnverifiedIdentity(true)), TimelineEvents.ShowShieldDialog(MessageShield.UnverifiedIdentity(true)),
) )
@ -135,6 +138,37 @@ class TimelineViewTest {
rule.clickOn(CommonStrings.action_ok) rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(TimelineEvents.HideShieldDialog) eventsRecorder.assertSingle(TimelineEvents.HideShieldDialog)
} }
@Test
fun `scrolling near to the start of the loaded items triggers a pre-fetch`() {
val eventsRecorder = EventsRecorder<TimelineEvents>()
val items = List<TimelineItem>(200) {
aTimelineItemEvent(
eventId = EventId("\$event_$it"),
content = aTimelineItemUnknownContent(),
)
}.toPersistentList()
rule.setTimelineView(
state = aTimelineState(
timelineItems = items,
eventSink = eventsRecorder,
focusedEventIndex = -1,
isLive = false,
),
)
rule.onNodeWithTag("timeline").performScrollToIndex(180)
rule.mainClock.advanceTimeBy(1000)
eventsRecorder.assertList(
listOf(
TimelineEvents.OnScrollFinished(firstIndex = 0),
TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS),
)
)
}
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimelineView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimelineView(

View file

@ -97,6 +97,11 @@ object TestTags {
*/ */
val floatingActionButton = TestTag("floating-action-button") val floatingActionButton = TestTag("floating-action-button")
/**
* Timeline.
*/
val timeline = TestTag("timeline")
/** /**
* Timeline item. * Timeline item.
*/ */