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
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.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
@@ -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.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.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(
@@ -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
AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection),
.nestedScroll(nestedScrollConnection)
.testTag(TestTags.timeline),
state = lazyListState,
reverseLayout = useReverseLayout,
contentPadding = PaddingValues(vertical = 8.dp),
@@ -175,6 +194,11 @@ fun TimelineView(
onClearFocusRequestState = ::clearFocusRequestState
)
TimelinePrefetchingHelper(
lazyListState = lazyListState,
prefetch = ::prefetchMoreItems
)
TimelineScrollHelper(
hasAnyEvent = state.hasAnyEvent,
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
private fun BoxScope.TimelineScrollHelper(
hasAnyEvent: Boolean,
@@ -228,7 +292,7 @@ private fun BoxScope.TimelineScrollHelper(
coroutineScope.launch {
if (lazyListState.firstVisibleItemIndex > 10) {
lazyListState.scrollToItem(0)
} else {
} else if (lazyListState.firstVisibleItemIndex != 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.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))
}
}

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.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<MessagesEvents>(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<MessagesEvents>()
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<ReactionSummaryEvents>()
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<CustomReactionEvents>()
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<CustomReactionEvents>()
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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
onCreatePollClick = onCreatePollClick,
onJoinCallClick = onJoinCallClick,
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.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,37 @@ 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<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(

View File

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