Keep a focused timeline focused until the event has been rendered

Opening a room from a notification asks the timeline to focus the notified
event. When that event is outside the loaded live window the controller
creates a detached timeline for it, and a detached timeline always carries a
forward loading indicator as its last chronological item. The items list is
reversed for display, so that indicator is the bottom-most row and is composed
on first layout, which unconditionally emits a forward pagination request
nobody asked for. Because a notified event sits close to the live end, that one
page reaches the end, and the controller responds by closing the detached
timeline. The live window replaces the item list while it is still anchored at
the bottom, so the user lands on the newest message and the focus request is
discarded.

Defer automatic forward pagination while a focus request is still pending. Once
the focused event has been rendered the viewport has moved off the indicator
row, so only a real scroll back to the live end switches to the live timeline,
which is what the controller's existing behaviour is for. Backward pagination
and the explicit jump-to-live action are untouched.
This commit is contained in:
hayaksi1
2026-08-17 16:20:43 +03:00
parent 12791406ca
commit 11e3cb9b19
2 changed files with 106 additions and 3 deletions
@@ -166,9 +166,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)
@@ -537,6 +542,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
@@ -1059,6 +1059,69 @@ 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 presenter = createTimelinePresenter(
room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda),
)
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 presenter = createTimelinePresenter(
room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda),
)
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 presenter = createTimelinePresenter(
room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda),
)
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 - focus on known event retrieves the event from cache`() = runTest {
val timelineItemIndexer = TimelineItemIndexer()
@@ -1552,6 +1615,31 @@ class TimelinePresenterTest {
return awaitItem()
}
private fun aRoomWithDetachedTimeline(
paginateLambda: (Timeline.PaginationDirection) -> Result<Boolean>,
): FakeJoinedRoom {
val detachedTimeline = FakeTimeline(
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(eventId = AN_EVENT_ID),
)
)
)
).apply {
this.paginateLambda = paginateLambda
}
return FakeJoinedRoom(
liveTimeline = FakeTimeline(timelineItems = flowOf(emptyList())),
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
threadRootIdForEventResult = { _ -> Result.success(null) },
),
)
}
private fun roomPermissions(
canRedactOther: Boolean = false,
canRedactOwn: Boolean = true,