Merge pull request #4399 from element-hq/feat/add-timeline-prefetching-mechanism
Add timeline item prefetching
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -97,6 +97,11 @@ object TestTags {
|
||||
*/
|
||||
val floatingActionButton = TestTag("floating-action-button")
|
||||
|
||||
/**
|
||||
* Timeline.
|
||||
*/
|
||||
val timeline = TestTag("timeline")
|
||||
|
||||
/**
|
||||
* Timeline item.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user