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.
This commit is contained in:
Jorge Martin Espinosa 2026-04-16 16:19:29 +02:00 committed by GitHub
parent a3b973a57d
commit 5ed8b3893e
3 changed files with 48 additions and 4 deletions

View file

@ -84,6 +84,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@ -262,11 +263,16 @@ private fun TimelinePrefetchingHelper(
firstVisibleItemIndex + layoutInfo.visibleItemsInfo.size >= layoutInfo.totalItemsCount - 40 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( combine(
isCloseToStartOfLoadedTimelineFlow.distinctUntilChanged(), isCloseToStartOfLoadedTimelineFlow.distinctUntilChanged(),
isScrollingFlow.distinctUntilChanged(), isScrollingFlow.distinctUntilChanged(),
) { needsPrefetch, isScrolling -> isEmptyTimelineFlow,
needsPrefetch && isScrolling ) { needsPrefetch, isScrolling, isEmptyAndNeedsBackPagination ->
isEmptyAndNeedsBackPagination || needsPrefetch && isScrolling
} }
.distinctUntilChanged() .distinctUntilChanged()
.collectLatest { needsPrefetch -> .collectLatest { needsPrefetch ->

View file

@ -522,6 +522,9 @@ class MessagesViewTest {
rule.setMessagesView( rule.setMessagesView(
state = stateWithActionListState, 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") val verifiedUserSendFailure = rule.activity.getString(CommonStrings.screen_timeline_item_menu_send_failure_changed_identity, "Alice")
rule.onNodeWithText(verifiedUserSendFailure).performClick() rule.onNodeWithText(verifiedUserSendFailure).performClick()
// Give time for the close animation to complete // Give time for the close animation to complete
@ -585,6 +588,9 @@ class MessagesViewTest {
), ),
) )
rule.setMessagesView(state = state) rule.setMessagesView(state = state)
// Clear initial 'LoadMore' event emitted when setting the state
eventsRecorder.clear()
rule.onNodeWithText("This is a pinned message").performClick() 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)) 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) timelineState = aTimelineState(eventSink = eventsRecorder)
) )
rule.setMessagesView(state = state) 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) val text = rule.activity.getString(R.string.screen_room_timeline_tombstoned_room_action)
// The bottomsheet subcompose seems to make the node to appear twice // The bottomsheet subcompose seems to make the node to appear twice
rule.onAllNodesWithText(text).onFirst().performClick() rule.onAllNodesWithText(text).onFirst().performClick()

View file

@ -67,24 +67,31 @@ class TimelineViewTest {
@Test @Test
fun `reaching the end of the timeline does not send a LoadMore event`() { fun `reaching the end of the timeline does not send a LoadMore event`() {
val eventsRecorder = EventsRecorder<TimelineEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<TimelineEvent>()
rule.setTimelineView( rule.setTimelineView(
state = aTimelineState( state = aTimelineState(
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
} }
@Test @Test
fun `scroll to bottom on live timeline does not emit the Event`() { fun `scroll to bottom on live timeline does not emit the Event`() {
val eventsRecorder = EventsRecorder<TimelineEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<TimelineEvent>()
rule.setTimelineView( rule.setTimelineView(
state = aTimelineState( state = aTimelineState(
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
isLive = true, isLive = true,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
forceJumpToBottomVisibility = true, forceJumpToBottomVisibility = true,
) )
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
eventsRecorder.clear()
val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom) val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom)
rule.onNodeWithContentDescription(contentDescription).performClick() rule.onNodeWithContentDescription(contentDescription).performClick()
} }
@ -94,15 +101,33 @@ class TimelineViewTest {
val eventsRecorder = EventsRecorder<TimelineEvent>() val eventsRecorder = EventsRecorder<TimelineEvent>()
rule.setTimelineView( rule.setTimelineView(
state = aTimelineState( state = aTimelineState(
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
isLive = false, isLive = false,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
eventsRecorder.clear()
val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom) val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom)
rule.onNodeWithContentDescription(contentDescription).performClick() rule.onNodeWithContentDescription(contentDescription).performClick()
eventsRecorder.assertSingle(TimelineEvent.JumpToLive) eventsRecorder.assertSingle(TimelineEvent.JumpToLive)
} }
@Test
fun `an empty timeline triggers a prefetch`() {
val eventsRecorder = EventsRecorder<TimelineEvent>()
rule.setTimelineView(
state = aTimelineState(
timelineItems = persistentListOf(),
eventSink = eventsRecorder,
),
)
eventsRecorder.assertSingle(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS))
}
@Test @Test
fun `show shield dialog`() { fun `show shield dialog`() {
val eventsRecorder = EventsRecorder<TimelineEvent>() val eventsRecorder = EventsRecorder<TimelineEvent>()
@ -133,11 +158,15 @@ class TimelineViewTest {
val eventsRecorder = EventsRecorder<TimelineEvent>() val eventsRecorder = EventsRecorder<TimelineEvent>()
rule.setTimelineView( rule.setTimelineView(
state = aTimelineState( state = aTimelineState(
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
isLive = false, isLive = false,
eventSink = eventsRecorder, eventSink = eventsRecorder,
messageShield = aCriticalShield(), messageShield = aCriticalShield(),
), ),
) )
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
eventsRecorder.clear()
rule.clickOn(CommonStrings.action_ok) rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog) eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog)
} }