Skip to content

fix: mark a thread read only once its conversation loads on screen - #603

Merged
Tryanks merged 1 commit into
mainfrom
fix/read-on-loaded
Oct 6, 2026
Merged

Tryanks merged 1 commit into
mainfrom
fix/read-on-loaded

Conversation

@Tryanks

@Tryanks Tryanks commented Oct 6, 2026

Copy link
Copy Markdown
Owner

Summary

A thread was marked read when the host received a subscription to it. On a bad network a remote client's subscription can arrive long after the user opened a thread, saw a blank loading screen and went back. A phone also keeps the thread selected on the thread list, so its conversation still loads there. Either way the thread became read although nothing reached the screen.

Now the client says when a thread is read, and only once its conversation has loaded and is on screen.

 host: Subscribe(SessionEvents)
   select_session
-    mark_visited(now)
 host: persist_meta (thread subscribed)
-  advance last_visited to updated_at
+host: MarkSessionRead { session_id, through }
+  last_visited = max(last_visited, through)

 client store: apply_domain_event
+  acknowledge_read
+    if conversation loaded && conversation on screen
+       && index updated_at > last acknowledged
+      dispatch MarkSessionRead { through: updated_at }

 shell: sync_nav
+  conversation on screen = compact ? page is Thread : route is Chat
  • through is the updated_at the client actually showed, so a delayed acknowledgement never covers updates that came after it and never rewinds a newer one.
  • Updates that land while the thread is shown keep it read, because the client acknowledges each newer updated_at. That replaces the host's own advance for subscribed threads.
  • MarkSessionRead is a new command. It is noted under "Unreleased" above PROTOCOL_VERSION and the number is not bumped (principle 9).

Evidence

Each new test fails with its part of the fix removed and passes with it.

  • Host: only_a_loaded_conversation_marks_a_thread_read. It delivers a subscribe and an unsubscribe the way a reconnecting client replays them.
    Before: fails with a late subscription is not a read.
    After: passes. It also checks that a delayed older acknowledgement does not rewind, and that a later update makes the thread unread again.
  • Client store: a_thread_is_reported_read_once_its_conversation_loads.
    Before: without the acknowledgement, the expected MarkSessionRead("shown", 100) never appears.
    After: passes. Nothing is sent for a view left before it loaded, or while it loaded off screen. One acknowledgement is sent when it is shown, and another when its updated_at advances.
  • Phone shell: a_conversation_that_loads_behind_the_thread_list_is_not_read. It restores a paired phone on a thread page, goes back before the baseline arrives, then delivers it.
    Before: with on-screen tracking forced on, it sends ["thread-a"] while the list is showing.
    After: it sends nothing until the thread page is shown again.

Changed tests:

  • updates_on_the_viewed_thread_do_not_mark_it_unread is replaced by the host test above. Its contract that a viewed thread stays read through updates now belongs to the client store test, since the client acknowledges them.
  • index_and_visit_changes_cross_the_wire_one_thread_at_a_time now changes a visit with MarkSessionRead instead of the removed mark_visited. Its contract, that only the changed visit crosses the wire, is unchanged.

Checks run locally on macOS:

  • cargo fmt --all --check
  • cargo clippy --workspace --all-targets --locked -- -D warnings
  • cargo nextest run --workspace --locked
  • cargo check -p tcode-web --target wasm32-unknown-unknown and cargo check -p tcode-ios --target aarch64-apple-ios-sim, both with -D warnings

Android, Windows, Linux and cargo machete are left to CI. Nothing was tried on a real phone over a degraded network.

Merge Danger

Door: two-way

The stored last_visited format is unchanged, and reverting restores the old trigger.

Blast Radius: unread-dots

Unread dots on every client may now lag until a conversation has actually loaded on screen. A client built before this change never sends MarkSessionRead, so against a new host its threads never become read. Clients and hosts come from one tree between releases.

A subscription reaching the host marked the thread read, so a remote
client whose subscription arrived late, or a phone whose conversation
loaded behind the thread list, made a thread read that never reached
the screen. The client now sends MarkSessionRead with the updated_at it
showed, once the conversation has loaded and is on screen.
@Tryanks
Tryanks merged commit 2566d6e into main Oct 6, 2026
7 checks passed
@Tryanks
Tryanks deleted the fix/read-on-loaded branch October 6, 2026 11:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant