Merge pull request #7463 from hayaksi1/fix/4460-focused-timeline-pagination

Keep a focused timeline focused until the event has been rendered
This commit is contained in:
Benoit Marty
2026-08-21 16:00:41 +02:00
committed by GitHub
2 changed files with 212 additions and 3 deletions
@@ -169,9 +169,14 @@ class TimelinePresenter(
fun handleEvent(event: TimelineEvent) {
when (event) {
is TimelineEvent.LoadMore -> {
if (event.direction == Timeline.PaginationDirection.FORWARDS && timelineMode is Timeline.Mode.Thread) {
// Do not paginate forwards in thread mode, as it's not supported
return
if (event.direction == Timeline.PaginationDirection.FORWARDS) {
if (timelineMode is Timeline.Mode.Thread) {
// Do not paginate forwards in thread mode, as it's not supported
return
}
if (focusRequestState.value.isPending()) {
return
}
}
localScope.launch {
timelineController.paginate(direction = event.direction)
@@ -582,6 +587,16 @@ private fun FocusRequestState.onFocusEventRender(): FocusRequestState {
}
}
private fun FocusRequestState.isPending(): Boolean {
return when (this) {
is FocusRequestState.Requested,
is FocusRequestState.Loading -> true
is FocusRequestState.Success -> !rendered
FocusRequestState.None,
is FocusRequestState.Failure -> false
}
}
// Workaround for not having the server names available, get possible server names from the user ids of the room members
private fun calculateServerNamesForRoom(room: JoinedRoom): List<String> {
// If we have no room members, return right ahead
@@ -1063,6 +1063,169 @@ class TimelinePresenterTest {
}
}
@Test
fun `present - a forward pagination while the focused event is not rendered yet is ignored`() = runTest {
val paginateLambda = lambdaRecorder(ensureNeverCalled = true) { _: Timeline.PaginationDirection ->
Result.success(true)
}
val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
val presenter = createTimelinePresenter(
room = room,
timeline = room.liveTimeline,
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
val focusedState = consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }.last()
assertThat(focusedState.isLive).isFalse()
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
advanceUntilIdle()
assert(paginateLambda).isNeverCalled()
assertThat(expectMostRecentItem().isLive).isFalse()
}
}
@Test
fun `present - a forward pagination is performed once the focused event has been rendered`() = runTest {
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
Result.success(true)
}
val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
val presenter = createTimelinePresenter(
room = room,
timeline = room.liveTimeline,
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }
initialState.eventSink.invoke(TimelineEvent.OnFocusEventRender)
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
advanceUntilIdle()
assert(paginateLambda)
.isCalledOnce()
.with(value(Timeline.PaginationDirection.FORWARDS))
assertThat(expectMostRecentItem().isLive).isTrue()
}
}
@Test
fun `present - a backward pagination while the focused event is not rendered yet is performed`() = runTest {
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
Result.success(false)
}
val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
val presenter = createTimelinePresenter(
room = room,
timeline = room.liveTimeline,
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS))
advanceUntilIdle()
assert(paginateLambda)
.isCalledOnce()
.with(value(Timeline.PaginationDirection.BACKWARDS))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - a forward pagination while the focus request is debounced is ignored`() = runTest {
val paginateLambda = lambdaRecorder(ensureNeverCalled = true) { _: Timeline.PaginationDirection ->
Result.success(true)
}
val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
val presenter = createTimelinePresenter(
room = room,
timeline = room.liveTimeline,
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID, debounce = 1.seconds))
// The focus request has started and is now waiting for the debounce to elapse
consumeItemsUntilPredicate { it.focusRequestState is FocusRequestState.Requested }
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
advanceUntilIdle()
assert(paginateLambda).isNeverCalled()
assertThat(expectMostRecentItem().isLive).isFalse()
}
}
@Test
fun `present - a forward pagination while the focused timeline is loading is ignored`() = runTest {
val paginateLambda = lambdaRecorder(ensureNeverCalled = true) { _: Timeline.PaginationDirection ->
Result.success(true)
}
val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
val presenter = createTimelinePresenter(
room = room,
timeline = room.liveTimeline,
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
// The focused timeline is being created, the focus request has not resolved yet
consumeItemsUntilPredicate { it.focusRequestState is FocusRequestState.Loading }
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
advanceUntilIdle()
assert(paginateLambda).isNeverCalled()
assertThat(expectMostRecentItem().isLive).isFalse()
}
}
@Test
fun `present - a forward pagination after a failed focus request is performed`() = runTest {
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
Result.success(false)
}
val room = aRoomWithDetachedTimeline(
paginateLambda = paginateLambda,
createTimelineResult = { Result.failure(RuntimeException("An error")) },
)
val presenter = createTimelinePresenter(
room = room,
timeline = room.liveTimeline,
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
consumeItemsUntilPredicate { it.focusRequestState is FocusRequestState.Failure }
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
advanceUntilIdle()
assert(paginateLambda)
.isCalledOnce()
.with(value(Timeline.PaginationDirection.FORWARDS))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - a forward pagination in a thread timeline is ignored`() = runTest {
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
Result.success(true)
}
val timeline = FakeTimeline(mode = Timeline.Mode.Thread(A_THREAD_ID)).apply {
this.paginateLambda = paginateLambda
}
val presenter = createTimelinePresenter(timeline = timeline)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
advanceUntilIdle()
assert(paginateLambda).isNeverCalled()
// A backward pagination is still performed in a thread timeline
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS))
advanceUntilIdle()
assert(paginateLambda)
.isCalledOnce()
.with(value(Timeline.PaginationDirection.BACKWARDS))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - focus on known event retrieves the event from cache`() = runTest {
val timelineItemIndexer = TimelineItemIndexer()
@@ -1662,6 +1825,37 @@ class TimelinePresenterTest {
return awaitItem()
}
private fun aRoomWithDetachedTimeline(
paginateLambda: (Timeline.PaginationDirection) -> Result<Boolean>,
createTimelineResult: () -> Result<Timeline> = { Result.success(aDetachedTimeline(paginateLambda)) },
): FakeJoinedRoom {
return FakeJoinedRoom(
liveTimeline = FakeTimeline(timelineItems = flowOf(emptyList())).apply {
this.paginateLambda = paginateLambda
},
createTimelineResult = { createTimelineResult() },
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
threadRootIdForEventResult = { _ -> Result.success(null) },
),
)
}
private fun aDetachedTimeline(
paginateLambda: (Timeline.PaginationDirection) -> Result<Boolean>,
) = FakeTimeline(
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(eventId = AN_EVENT_ID),
)
)
)
).apply {
this.paginateLambda = paginateLambda
}
private fun roomPermissions(
canRedactOther: Boolean = false,
canRedactOwn: Boolean = true,