Skip to content

Commit 811ba95

Browse files
authored
Merge pull request #4399 from element-hq/feat/add-timeline-prefetching-mechanism
Add timeline item prefetching
2 parents 6ad969e + badca69 commit 811ba95

File tree

5 files changed

+133
-7
lines changed

5 files changed

+133
-7
lines changed

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import androidx.compose.runtime.remember
3636
import androidx.compose.runtime.rememberCoroutineScope
3737
import androidx.compose.runtime.rememberUpdatedState
3838
import androidx.compose.runtime.setValue
39+
import androidx.compose.runtime.snapshotFlow
3940
import androidx.compose.ui.Alignment
4041
import androidx.compose.ui.Modifier
4142
import androidx.compose.ui.draw.rotate
@@ -70,8 +71,21 @@ import io.element.android.libraries.designsystem.theme.components.Icon
7071
import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter
7172
import io.element.android.libraries.matrix.api.core.EventId
7273
import io.element.android.libraries.matrix.api.core.UserId
74+
import io.element.android.libraries.matrix.api.timeline.Timeline
75+
import io.element.android.libraries.testtags.TestTags
76+
import io.element.android.libraries.testtags.testTag
7377
import io.element.android.libraries.ui.strings.CommonStrings
78+
import kotlinx.coroutines.ExperimentalCoroutinesApi
79+
import kotlinx.coroutines.FlowPreview
80+
import kotlinx.coroutines.delay
81+
import kotlinx.coroutines.flow.collectLatest
82+
import kotlinx.coroutines.flow.combine
83+
import kotlinx.coroutines.flow.conflate
84+
import kotlinx.coroutines.flow.distinctUntilChanged
85+
import kotlinx.coroutines.flow.transform
7486
import kotlinx.coroutines.launch
87+
import timber.log.Timber
88+
import kotlin.time.Duration.Companion.milliseconds
7589

7690
@Composable
7791
fun TimelineView(
@@ -130,13 +144,18 @@ fun TimelineView(
130144
)
131145
}
132146

147+
fun prefetchMoreItems() {
148+
state.eventSink(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS))
149+
}
150+
133151
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
134152
AnimatedVisibility(visible = true, enter = fadeIn()) {
135153
Box(modifier) {
136154
LazyColumn(
137155
modifier = Modifier
138156
.fillMaxSize()
139-
.nestedScroll(nestedScrollConnection),
157+
.nestedScroll(nestedScrollConnection)
158+
.testTag(TestTags.timeline),
140159
state = lazyListState,
141160
reverseLayout = useReverseLayout,
142161
contentPadding = PaddingValues(vertical = 8.dp),
@@ -175,6 +194,11 @@ fun TimelineView(
175194
onClearFocusRequestState = ::clearFocusRequestState
176195
)
177196

197+
TimelinePrefetchingHelper(
198+
lazyListState = lazyListState,
199+
prefetch = ::prefetchMoreItems
200+
)
201+
178202
TimelineScrollHelper(
179203
hasAnyEvent = state.hasAnyEvent,
180204
lazyListState = lazyListState,
@@ -203,6 +227,46 @@ private fun MessageShieldDialog(state: TimelineState) {
203227
)
204228
}
205229

230+
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
231+
@Composable
232+
private fun TimelinePrefetchingHelper(
233+
lazyListState: LazyListState,
234+
prefetch: () -> Unit,
235+
) {
236+
val latestPrefetch by rememberUpdatedState(prefetch)
237+
238+
LaunchedEffect(Unit) {
239+
// We're using snapshot flows for these because using `LaunchedEffect` with `derivedState` doesn't seem to be responsive enough
240+
val firstVisibleItemIndexFlow = snapshotFlow { lazyListState.firstVisibleItemIndex }
241+
val layoutInfoFlow = snapshotFlow { lazyListState.layoutInfo }
242+
val isScrollingFlow = snapshotFlow { lazyListState.isScrollInProgress }
243+
// This value changes too frequently, so we debounce it to avoid unnecessary prefetching. It's the equivalent of a conditional 'throttleLatest'
244+
.conflate()
245+
.transform { isScrolling ->
246+
emit(isScrolling)
247+
if (isScrolling) delay(100.milliseconds)
248+
}
249+
250+
val isCloseToStartOfLoadedTimelineFlow = combine(layoutInfoFlow, firstVisibleItemIndexFlow) { layoutInfo, firstVisibleItemIndex ->
251+
firstVisibleItemIndex + layoutInfo.visibleItemsInfo.size >= layoutInfo.totalItemsCount - 40
252+
}
253+
254+
combine(
255+
isCloseToStartOfLoadedTimelineFlow.distinctUntilChanged(),
256+
isScrollingFlow.distinctUntilChanged(),
257+
) { needsPrefetch, isScrolling ->
258+
needsPrefetch && isScrolling
259+
}
260+
.distinctUntilChanged()
261+
.collectLatest { needsPrefetch ->
262+
if (needsPrefetch) {
263+
Timber.d("Prefetching pagination with ${lazyListState.layoutInfo.totalItemsCount} items")
264+
latestPrefetch()
265+
}
266+
}
267+
}
268+
}
269+
206270
@Composable
207271
private fun BoxScope.TimelineScrollHelper(
208272
hasAnyEvent: Boolean,
@@ -228,7 +292,7 @@ private fun BoxScope.TimelineScrollHelper(
228292
coroutineScope.launch {
229293
if (lazyListState.firstVisibleItemIndex > 10) {
230294
lazyListState.scrollToItem(0)
231-
} else {
295+
} else if (lazyListState.firstVisibleItemIndex != 0) {
232296
lazyListState.animateScrollToItem(0)
233297
}
234298
}

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline
2828
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel
2929
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
3030
import io.element.android.features.messages.impl.typing.TypingNotificationView
31+
import timber.log.Timber
3132

3233
@Composable
3334
fun TimelineItemVirtualRow(
@@ -45,6 +46,7 @@ fun TimelineItemVirtualRow(
4546
TimelineLoadingMoreIndicator(virtual.model.direction)
4647
val latestEventSink by rememberUpdatedState(eventSink)
4748
LaunchedEffect(virtual.model.timestamp) {
49+
Timber.d("Pagination triggered by load more indicator")
4850
latestEventSink(TimelineEvents.LoadMore(virtual.model.direction))
4951
}
5052
}

features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMess
4141
import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS
4242
import io.element.android.features.messages.impl.timeline.TimelineEvents
4343
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
44+
import io.element.android.features.messages.impl.timeline.aTimelineItemList
4445
import io.element.android.features.messages.impl.timeline.aTimelineItemReadReceipts
4546
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
4647
import io.element.android.features.messages.impl.timeline.aTimelineState
@@ -50,6 +51,7 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
5051
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
5152
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
5253
import io.element.android.features.messages.impl.timeline.model.TimelineItem
54+
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
5355
import io.element.android.libraries.matrix.api.core.UserId
5456
import io.element.android.libraries.matrix.test.AN_EVENT_ID
5557
import io.element.android.libraries.testtags.TestTags
@@ -126,6 +128,9 @@ class MessagesViewTest {
126128
fun `clicking on an Event invoke expected callback`() {
127129
val eventsRecorder = EventsRecorder<MessagesEvents>(expectEvents = false)
128130
val state = aMessagesState(
131+
timelineState = aTimelineState(
132+
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
133+
),
129134
eventSink = eventsRecorder
130135
)
131136
val timelineItem = state.timelineState.timelineItems.first()
@@ -182,6 +187,9 @@ class MessagesViewTest {
182187
canSendReaction = userHasPermissionToSendReaction,
183188
canPinUnpin = userCanPinEvent,
184189
),
190+
timelineState = aTimelineState(
191+
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
192+
),
185193
)
186194
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
187195
rule.setMessagesView(
@@ -349,7 +357,10 @@ class MessagesViewTest {
349357
fun `clicking on a reaction emits the expected Event`() {
350358
val eventsRecorder = EventsRecorder<MessagesEvents>()
351359
val state = aMessagesState(
352-
eventSink = eventsRecorder
360+
timelineState = aTimelineState(
361+
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
362+
),
363+
eventSink = eventsRecorder,
353364
)
354365
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
355366
rule.setMessagesView(
@@ -363,6 +374,9 @@ class MessagesViewTest {
363374
fun `long clicking on a reaction emits the expected Event`() {
364375
val eventsRecorder = EventsRecorder<ReactionSummaryEvents>()
365376
val state = aMessagesState(
377+
timelineState = aTimelineState(
378+
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
379+
),
366380
reactionSummaryState = aReactionSummaryState(
367381
target = null,
368382
eventSink = eventsRecorder,
@@ -380,6 +394,9 @@ class MessagesViewTest {
380394
fun `clicking on more reaction emits the expected Event`() {
381395
val eventsRecorder = EventsRecorder<CustomReactionEvents>()
382396
val state = aMessagesState(
397+
timelineState = aTimelineState(
398+
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
399+
),
383400
customReactionState = aCustomReactionState(
384401
eventSink = eventsRecorder,
385402
),
@@ -396,7 +413,11 @@ class MessagesViewTest {
396413
@Test
397414
fun `clicking on more reaction from action list emits the expected Event`() {
398415
val eventsRecorder = EventsRecorder<CustomReactionEvents>()
399-
val state = aMessagesState()
416+
val state = aMessagesState(
417+
timelineState = aTimelineState(
418+
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
419+
),
420+
)
400421
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
401422
val stateWithActionListState = state.copy(
402423
actionListState = anActionListState(
@@ -538,7 +559,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
538559
onCreatePollClick = onCreatePollClick,
539560
onJoinCallClick = onJoinCallClick,
540561
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
541-
knockRequestsBannerView = {}
562+
knockRequestsBannerView = {},
542563
)
543564
}
544565
}

features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,18 @@ import androidx.activity.ComponentActivity
1111
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
1212
import androidx.compose.ui.test.junit4.createAndroidComposeRule
1313
import androidx.compose.ui.test.onNodeWithContentDescription
14+
import androidx.compose.ui.test.onNodeWithTag
1415
import androidx.compose.ui.test.performClick
16+
import androidx.compose.ui.test.performScrollToIndex
1517
import androidx.test.ext.junit.runners.AndroidJUnit4
1618
import io.element.android.features.messages.impl.timeline.components.aCriticalShield
1719
import io.element.android.features.messages.impl.timeline.model.TimelineItem
1820
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
21+
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemUnknownContent
1922
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
2023
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
2124
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
25+
import io.element.android.libraries.matrix.api.core.EventId
2226
import io.element.android.libraries.matrix.api.core.UniqueId
2327
import io.element.android.libraries.matrix.api.core.UserId
2428
import io.element.android.libraries.matrix.api.timeline.Timeline
@@ -31,6 +35,7 @@ import io.element.android.tests.testutils.EventsRecorder
3135
import io.element.android.tests.testutils.clickOn
3236
import io.element.android.tests.testutils.setSafeContent
3337
import kotlinx.collections.immutable.persistentListOf
38+
import kotlinx.collections.immutable.toPersistentList
3439
import org.junit.Rule
3540
import org.junit.Test
3641
import org.junit.rules.TestRule
@@ -114,8 +119,6 @@ class TimelineViewTest {
114119
rule.onNodeWithContentDescription(contentDescription).performClick()
115120
eventsRecorder.assertList(
116121
listOf(
117-
TimelineEvents.OnScrollFinished(0),
118-
TimelineEvents.OnScrollFinished(0),
119122
TimelineEvents.OnScrollFinished(0),
120123
TimelineEvents.ShowShieldDialog(MessageShield.UnverifiedIdentity(true)),
121124
)
@@ -135,6 +138,37 @@ class TimelineViewTest {
135138
rule.clickOn(CommonStrings.action_ok)
136139
eventsRecorder.assertSingle(TimelineEvents.HideShieldDialog)
137140
}
141+
142+
@Test
143+
fun `scrolling near to the start of the loaded items triggers a pre-fetch`() {
144+
val eventsRecorder = EventsRecorder<TimelineEvents>()
145+
val items = List<TimelineItem>(200) {
146+
aTimelineItemEvent(
147+
eventId = EventId("\$event_$it"),
148+
content = aTimelineItemUnknownContent(),
149+
)
150+
}.toPersistentList()
151+
152+
rule.setTimelineView(
153+
state = aTimelineState(
154+
timelineItems = items,
155+
eventSink = eventsRecorder,
156+
focusedEventIndex = -1,
157+
isLive = false,
158+
),
159+
)
160+
161+
rule.onNodeWithTag("timeline").performScrollToIndex(180)
162+
163+
rule.mainClock.advanceTimeBy(1000)
164+
165+
eventsRecorder.assertList(
166+
listOf(
167+
TimelineEvents.OnScrollFinished(firstIndex = 0),
168+
TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS),
169+
)
170+
)
171+
}
138172
}
139173

140174
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimelineView(

libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ object TestTags {
9797
*/
9898
val floatingActionButton = TestTag("floating-action-button")
9999

100+
/**
101+
* Timeline.
102+
*/
103+
val timeline = TestTag("timeline")
104+
100105
/**
101106
* Timeline item.
102107
*/

0 commit comments

Comments
 (0)