From c349f74ce80860256bcb2eb40cfcef469f81583c Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Thu, 16 Apr 2026 16:19:29 +0200 Subject: [PATCH] Fix loading initial items of non-live timelines (#6598) This was done automatically by the SDK in the past by returning a `Reset` timeline update, but this behaviour changed and now we have to do it. --- .../messages/impl/timeline/TimelineView.kt | 10 ++++-- .../messages/impl/MessagesViewTest.kt | 9 +++++ .../impl/timeline/TimelineViewTest.kt | 33 +++++++++++++++++-- 3 files changed, 48 insertions(+), 4 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 0137b1736d..0c5bb28890 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 @@ -84,6 +84,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.transform import kotlinx.coroutines.launch import timber.log.Timber @@ -262,11 +263,16 @@ private fun TimelinePrefetchingHelper( firstVisibleItemIndex + layoutInfo.visibleItemsInfo.size >= layoutInfo.totalItemsCount - 40 } + // If we have no timeline items, we need to back paginate to load some messages. This usually happens on all timelines except for live ones. + // This automatic pagination was previously done by the SDK, and we received a `Reset` update, but now we need to do it ourselves. + val isEmptyTimelineFlow = layoutInfoFlow.map { it.totalItemsCount == 0 } + combine( isCloseToStartOfLoadedTimelineFlow.distinctUntilChanged(), isScrollingFlow.distinctUntilChanged(), - ) { needsPrefetch, isScrolling -> - needsPrefetch && isScrolling + isEmptyTimelineFlow, + ) { needsPrefetch, isScrolling, isEmptyAndNeedsBackPagination -> + isEmptyAndNeedsBackPagination || needsPrefetch && isScrolling } .distinctUntilChanged() .collectLatest { needsPrefetch -> 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 ff4bc37fa3..62b9eac68d 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 @@ -522,6 +522,9 @@ class MessagesViewTest { rule.setMessagesView( state = stateWithActionListState, ) + // Clear initial 'LoadMore' event emitted when setting the state + eventsRecorder.clear() + val verifiedUserSendFailure = rule.activity.getString(CommonStrings.screen_timeline_item_menu_send_failure_changed_identity, "Alice") rule.onNodeWithText(verifiedUserSendFailure).performClick() // Give time for the close animation to complete @@ -585,6 +588,9 @@ class MessagesViewTest { ), ) rule.setMessagesView(state = state) + // Clear initial 'LoadMore' event emitted when setting the state + eventsRecorder.clear() + rule.onNodeWithText("This is a pinned message").performClick() eventsRecorder.assertSingle(TimelineEvent.FocusOnEvent(AN_EVENT_ID, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)) } @@ -601,6 +607,9 @@ class MessagesViewTest { timelineState = aTimelineState(eventSink = eventsRecorder) ) rule.setMessagesView(state = state) + // Clear initial 'LoadMore' event emitted when setting the state + eventsRecorder.clear() + val text = rule.activity.getString(R.string.screen_room_timeline_tombstoned_room_action) // The bottomsheet subcompose seems to make the node to appear twice rule.onAllNodesWithText(text).onFirst().performClick() 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 3a97fbd9dc..c05625e2d3 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 @@ -67,24 +67,31 @@ class TimelineViewTest { @Test fun `reaching the end of the timeline does not send a LoadMore event`() { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder() rule.setTimelineView( state = aTimelineState( + timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), eventSink = eventsRecorder, ), ) + eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0)) } @Test fun `scroll to bottom on live timeline does not emit the Event`() { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder() rule.setTimelineView( state = aTimelineState( + timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), isLive = true, eventSink = eventsRecorder, ), forceJumpToBottomVisibility = true, ) + + eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0)) + eventsRecorder.clear() + val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom) rule.onNodeWithContentDescription(contentDescription).performClick() } @@ -94,15 +101,33 @@ class TimelineViewTest { val eventsRecorder = EventsRecorder() rule.setTimelineView( state = aTimelineState( + timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), isLive = false, eventSink = eventsRecorder, ), ) + + eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0)) + eventsRecorder.clear() + val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom) rule.onNodeWithContentDescription(contentDescription).performClick() eventsRecorder.assertSingle(TimelineEvent.JumpToLive) } + @Test + fun `an empty timeline triggers a prefetch`() { + val eventsRecorder = EventsRecorder() + rule.setTimelineView( + state = aTimelineState( + timelineItems = persistentListOf(), + eventSink = eventsRecorder, + ), + ) + + eventsRecorder.assertSingle(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS)) + } + @Test fun `show shield dialog`() { val eventsRecorder = EventsRecorder() @@ -133,11 +158,15 @@ class TimelineViewTest { val eventsRecorder = EventsRecorder() rule.setTimelineView( state = aTimelineState( + timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), isLive = false, eventSink = eventsRecorder, messageShield = aCriticalShield(), ), ) + eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0)) + eventsRecorder.clear() + rule.clickOn(CommonStrings.action_ok) eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog) }