Skip to content

feat(ui): jump to first unread message + unread divider #459

Description

@sanity

Problem

User request via Matrix (2026-07-23):

Consider adding a button to jump to the last message before you opened River. Otherwise I have to figure out where to resume reading.

River already tracks the data this needs. The gap is purely UI.

What exists:

  • RoomData.last_read_message_id (ui/src/room_data.rs:53), a per-room marker persisted to delegate storage, advanced when the user opens the room, when a message arrives while viewing, and on tab hide.
  • It already drives count_unread_in_room_data (ui/src/components/app/document_title.rs:172), the document-title badge, the room-list badge (ui/src/components/room_list.rs:213), and the mobile hamburger badge.

What is missing:

So the marker is maintained and counted, then ignored when deciding where to put the reader.

Proposal

  1. Render an unread divider ("New messages") at the first message after last_read_message_id.
  2. On room switch, if unread > 0, scroll to the divider instead of the bottom.
  3. Keep the existing scroll-to-latest button (conversation.rs:2238-2268) for returning to the present.

This is the Slack / Discord / Element pattern, and it is a concrete implementation of the first bullet of #70.

Performance constraints

PR #458 just fixed mobile jank caused by an O(history) use_memo re-running over the entire message list on every ROOMS mutation. Do not reintroduce that shape.

  • Freeze the divider boundary at room-open, in a signal. Do not derive it from a memo over ROOMS. Two independent reasons: recomputing on every mutation re-does O(history) work on every sync tick, reaction and inbound message; and a live boundary would slide away as you read, when the divider should stay put (Slack and Discord both freeze it).
  • The boundary scan is O(<=100), since max_recent_messages defaults to 100 (common/src/room_state/configuration.rs:179), and it duplicates work count_unread_in_room_data already performs. Worth sharing rather than adding a second pass.
  • The divider is one extra DOM node and must not invalidate the per-message HTML cache added in perf(ui): memoize per-message HTML to fix mobile chat jank #458.

Edge cases

  • Pruned marker. apply_delta drains the oldest messages beyond max_recent_messages (common/src/room_state/message.rs:229), so the marked message can be evicted from the ring buffer. count_unread_in_room_data already falls back to treating everything as unread (document_title.rs:176-184, pinned by pruned_marker_falls_back_to_all_other_authored at 692-704). Open question: should the divider then sit at the top of the buffer, or be suppressed entirely? Suppressing is probably less confusing, since "everything here is new" is rarely true and the top of a 100-message buffer is an arbitrary place to anchor.
  • Zero unread. No divider, keep the current scroll-to-bottom.
  • Direct messages track last-seen separately and in memory only (DM_LAST_SEEN, ui/src/components/direct_messages.rs:42, seeded once per session), so DMs are out of scope here unless we want parity in the same pass.

Blocked on a decision in #446

mark_all_rooms_as_read() fires on every visible to hidden visibilitychange (document_title.rs:430-480), so on mobile an app-switch marks every room read, including rooms that were never on screen. A jump-to-unread button that points at a wiped marker is worse than no button at all, and #446 may well be the underlying cause of the original report.

#446 already asks for a product call between three options. Option 1 there ("only mark the current room read on hide") is what this feature requires. @sanity, that is the call #446 has been waiting on.

Recommend resolving #446 as part of this work rather than shipping the button on top of a marker that gets erased.

Not in scope

The second bullet of #70 (append inbound messages to the bottom regardless of timestamp) is a separate display-ordering question and is not proposed here.

Related: #70, #446, #402, #458

[AI-assisted - Claude]

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions