Gate out-of-window jump-to-unread on genuinely unread displayable content

The jump-to-unread FAB could surface an OutOfWindow target when the
fully-read marker event was either loaded-but-not-displayed (state/filtered
events) or when there was nothing displayable to jump to. Add two guards to
the recompute:

- Require roomInfo.numUnreadMessages > 0, so the FAB only appears when there
  is genuinely unread "interesting" content (never state/hidden events).
- Add Timeline.isEventLoaded to distinguish "in the loaded window but not
  rendered" from "genuinely outside the window", falling back to the SDK only
  after the cheap display-index check (isKnown) misses.

The RustTimeline implementation reads the replay cache first and releases the
EventTimelineItem native handle via use {} when probing the SDK. Adds presenter
tests for the loaded-but-not-displayed and no-unread-messages branches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jenna Vassar
2026-06-09 18:05:22 -07:00
parent ea94996db7
commit 5a2977134b
5 changed files with 128 additions and 11 deletions
@@ -216,6 +216,13 @@ interface Timeline : AutoCloseable {
suspend fun loadReplyDetails(eventId: EventId): InReplyTo
/**
* Returns true if [eventId] is currently loaded in this timeline's window, even if the display
* layer does not render it (e.g. filtered or state events). This distinguishes "in the window
* but not displayed" from "genuinely outside the loaded window".
*/
suspend fun isEventLoaded(eventId: EventId): Boolean
/**
* Adds a new pinned event by sending an updated `m.room.pinned_events`
* event containing the new event id.
@@ -637,6 +637,24 @@ class RustTimeline(
}
}
override suspend fun isEventLoaded(eventId: EventId): Boolean = withContext(dispatcher) {
val isInLoadedItems = _timelineItems.replayCache.firstOrNull().orEmpty().any { timelineItem ->
timelineItem is MatrixTimelineItem.Event && timelineItem.eventId == eventId
}
if (isInLoadedItems) {
// Displayed events are caught above. The SDK-level list still holds events the display
// layer later drops, so this avoids the FFI call for in-window-but-not-rendered events.
true
} else {
// getEventTimelineItemByEventId throws when the event isn't in the loaded window.
// EventTimelineItem is a Disposable wrapping a native handle, so release it once we've
// confirmed presence — otherwise the handle lingers until the GC cleaner runs.
runCatchingExceptions {
inner.getEventTimelineItemByEventId(eventId.value).use { /* presence is all we need */ }
}.isSuccess
}
}
override suspend fun pinEvent(eventId: EventId): Result<Boolean> = withContext(dispatcher) {
runCatchingExceptions {
inner.pinEvent(eventId = eventId.value)
@@ -436,6 +436,9 @@ class FakeTimeline(
override suspend fun loadReplyDetails(eventId: EventId) = loadReplyDetailsLambda(eventId)
var isEventLoadedLambda: (eventId: EventId) -> Boolean = { false }
override suspend fun isEventLoaded(eventId: EventId): Boolean = isEventLoadedLambda(eventId)
var pinEventLambda: (eventId: EventId) -> Result<Boolean> = { lambdaError() }
override suspend fun pinEvent(eventId: EventId): Result<Boolean> {
return pinEventLambda(eventId)