Skip to content

fix(chat): settle the chats list in one update after launch - #1665

Merged
bmc08gt merged 4 commits into
code/cashfrom
fix/chat-list-launch-sync
Oct 2, 2026
Merged

bmc08gt merged 4 commits into
code/cashfrom
fix/chat-list-launch-sync

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

On cold launch the chats list painted from Room and then kept changing: rows reordered, previews lagged, and unread dots turned into counts a moment later. The list should be current the first time it settles, with only new events changing it after that, and launch must not get slower.

Why it churned

  • onStart called syncFeeds() while the login sync was still running, which cancelled it (syncJob?.cancel()) and started over.
  • Feed sync wrote metadata, then members one transaction per chat, then previews. The list observer combines chat_metadata and chat_members, so each of those transactions rebuilt the list.
  • Per-chat catch-up (loadMessages, performDeltaSync) made three separate metadata writes and set last_activity from the newest message even when nothing was new, so rows moved one at a time after Synced.
  • The observer didn't watch chat_messages, so a reorder could land before its preview, and the preview stayed stale until an unrelated write.
  • Read stamps were fetched after the list showed, so counts popped in.

What changes

  • onStart, reconnect and the heartbeat join the in-flight sync instead of restarting it. A push-triggered refreshFeed joins it and queues one trailing re-run, coalesced across pushes, so a message that landed mid-sync isn't missed.
  • reconcileFeeds fetches the DM and group feeds together, then ChatFeedWriter writes metadata, members and previews for both in one Room transaction. The wait for both is capped at 2s; a slower feed is written when it arrives.
  • Read stamps are fetched inside the reconcile, up to 4 at a time with a 1.5s cap, and applied with the rebuild. Unread can't be computed locally because the device may not store the message the read pointer names.
  • ChatMetadataDao.applyCatchUp is one forward-only update shared by catch-up, stream events and pushed messages. last_message_id and last_activity move only for a strictly newer message, so a catch-up with nothing new changes no rows.
  • The feed upsert is newer-wins too (MAX on last_activity, strictly newer last_message_id), so an older feed response can't roll back a row a stream event just advanced. iOS found the same race with a test.
  • The observer also watches observeLatestVisibleChanges(), de-duplicated, so the list rebuilds when a chat's newest visible message changes, including edits and deletes.
  • Catch-up runs the open chat first, then newest first. Opening a chat already shows stored rows straight from Room with no network gate.

Tests: FeedReconcileTest (one write for DM and group, onStart shares the sync, one push or five pushes during a sync produce one trailing fetch), ChatMetadataCatchUpTest and ChatFeedUpsertClampTest (Robolectric with real Room: equal or older data leaves the row unchanged), plus updated messaging and feed tests.

Every chat_messages write now re-runs a newest-per-chat query, and its cost on a large database hasn't been measured.

iOS counterpart: code-payments/code-ios-app#947.

Share the in-flight feed sync with onStart and push refreshes, write DM and
group feeds (rows, members, previews) in one transaction, make catch-up a
single forward-only metadata write, rebuild the list when a chat's newest
message changes, and stage read stamps into the reconcile with bounded
concurrency. Catch-up now runs the open chat first.
A feed response in flight while a stream event advanced a row overwrote last_activity and last_message_id with older server values, rolling the row back. The feed upsert now takes MAX on last_activity and replaces last_message_id only when strictly newer. Other feed-owned fields are unchanged.
A push refresh that lands mid-sync joined that sync, which may have fetched before the change. It now also queues a single trailing run, coalesced across pushes. onStart, reconnect and heartbeat still only share the in-flight sync.
The first-frame draw passed no sender profiles, so a cold launch drew
group previews without the sender prefix until the user_profiles read
landed. Names are already persisted there; expose the last read
synchronously and use it for that draw.

Adds tests that a stored name is in the next session's first emission
and that re-storing an unchanged name does not re-emit.
@bmc08gt
bmc08gt merged commit ad5f9e9 into code/cash Oct 2, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant