Skip to content

🔄️ Improve conversation list performance - #6498

Open
AndyScherzinger wants to merge 8 commits into
feat/noid/msgUnreadMarkerfrom
feat/noid/convoListUpdate
Open

🔄️ Improve conversation list performance#6498
AndyScherzinger wants to merge 8 commits into
feat/noid/msgUnreadMarkerfrom
feat/noid/convoListUpdate

Conversation

@AndyScherzinger

@AndyScherzinger AndyScherzinger commented Aug 14, 2026

Copy link
Copy Markdown
Member

For the user interface, rely on the database, make action where useful optimistic with self-healing in place based on the regular server-fetches. Sagefuard the changes via actions when competing with server fetches being in-flight, likely impacting the conversation list data.

🏁 Checklist

  • ⛑️ Tests (unit and/or integration) are included or not needed
  • 🔖 Capability is checked or not needed
  • 🔙 Backport requests are created or not needed: /backport to stable-xx.x
  • 📅 Milestone is set
  • 🌸 PR title is meaningful (if it should be in the changelog: is it meaningful to users?)

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI

@AndyScherzinger AndyScherzinger added this to the 25.0.0 milestone Aug 14, 2026
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/msgUnreadMarker branch from a137536 to 8c9e228 Compare August 14, 2026 15:47
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/convoListUpdate branch 2 times, most recently from f71c91f to afcaf62 Compare August 14, 2026 17:08
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/msgUnreadMarker branch from 8c9e228 to 4f75c5e Compare August 14, 2026 17:22
The room list was fed by manual emissions: getRooms emitted a database
snapshot and a post-sync snapshot, and nothing else. Any change to the
conversations table in between stayed invisible until the next full
fetch cycle.

roomListFlow now observes the conversations table for the selected
account, making the database the single source of truth: every write
reaches the UI reactively, whoever made it. getRooms shrinks to
selecting the account and triggering the background sync, which stays
in place unchanged as the authority and self-healing safeguard
(deletions, statuses, drift correction). The sync applies deletions and
upserts in one transaction, so observers see a single consistent update
per sync instead of intermediate states.

Unlike the MutableSharedFlow it replaces, the database-backed flow can
throw, and getRoomsStateFlow collects it eagerly in the viewModelScope
- an uncaught exception there would crash the app, so it is caught and
surfaced as GetRoomsErrorState. updateConversationLocallyAndEmit lost
its manual emission and with it any difference to updateConversation,
so it is removed and all call sites point at updateConversation.

Assisted-by: Claude Code:claude-fable-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
…h-ups

Background message catch-ups (room list prefetch, push notifications)
cached the messages but never touched the conversation entry, so the
conversation list kept showing the stale last message and unread count
until the next full room list sync answered - even though the fresh
data was already on the device.

After a room-level catch-up that persisted messages, the room's
conversation entry is now updated with the newest persisted message
(skipping system messages that never become a conversation's preview),
its activity timestamp and a locally derived unread count. The count
mirrors the server's calculation (spreed's ChatManager.getUnreadCount
counts the comment and object_shared verbs only), is only derived when
the latest chat block reaches back to the last read message, and
excludes the user's own messages since the server advances the author's
read marker on every post - which the locally cached marker may lag
behind.

Concurrency with the room list sync is handled without locking: the
write is a single guarded UPDATE that only applies while the derived
state is newer than the stored one, so the sync - which remains the
authority and self-healing safeguard - can never be overwritten with
older data and no read-modify-write window exists. The derivation runs
inside the existing per-room catch-up mutex.

Assisted-by: Claude Code:claude-fable-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Marking a chat as read (or unread) only sent the marker to the server
in a fire-and-forget call: the local conversation entry was never
updated, so the conversation list showed a stale unread badge until the
next room list sync answered - and a failed call was only logged,
silently losing the read state.

The read state is now written into the local conversation entry
immediately (with the unread count recounted from the cached messages
the way the server counts), so the list reflects it the moment the user
leaves the chat. Sending the marker moves to a ReadMarkerSyncWorker:
network-constrained, exponential backoff, at most three attempts, and
unique per room with REPLACE so the newest marker always wins and a
retry can never ship a stale marker backwards.

The server remains the authority, with one narrowly scoped exception: a
server response computed before a concurrently sent marker reached the
server would revert the entry to unread until the next sync. Markers
are therefore tracked as pending until a sync confirms their delivery,
and while the server's read state is provably behind a pending marker
it is kept out of the merge - for the room list sync and the
single-room refresh alike. Once the server has caught up (or moved past
the marker, e.g. read further on another device) the server state
applies unchanged, so marking as unread from another device keeps
working. When sending ultimately fails the worker releases the pending
marker and the next sync restores the server state, so the
inconsistency window stays bounded by the retry backoff or one sync
cycle.

Assisted-by: Claude Code:claude-fable-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/convoListUpdate branch from afcaf62 to 8c6fbe0 Compare August 14, 2026 17:34
Covers, against a real in-memory database, that a background catch-up
updates the conversation entry from the fetched messages (preview,
activity, derived unread count with system messages excluded the way
the server excludes them), that the guarded update never regresses a
newer stored entry, that the local read state write-through resets and
recounts the unread badge (own messages excluded), that neither a room
list sync nor a single-room refresh can revert the read state while its
marker is pending - with the server's authority restored once it
confirms the marker, including a lower read state from marking unread
on another device - and that the reactive room list flow delivers plain
database writes without any fetch call.

Assisted-by: Claude Code:claude-fable-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
ChatMessageSyncer had grown into owning message fetching, chat blocks,
catch-up coalescing, HTTP sync anchors and - since the conversation
list freshness work - conversation entry updates, the local read state
and the pending read markers. Move the conversation-list concerns into
a dedicated ConversationListUpdater: reflecting catch-ups in the
conversation entries, the optimistic read state write-through, and the
pending markers with their stale-server-response guard.

Behavior is unchanged; the syncer delegates the catch-up reflection,
and the chat repository, the read marker worker and the conversations
repository now use the updater directly instead of going through the
syncer.

Assisted-by: Claude Code:claude-fable-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Returning from a chat to the conversation list made the list visibly
jump shortly after rendering. The saved scroll position was re-armed on
every resume and restored on the next room list emission - which used
to be an immediate local snapshot, making the restore an invisible
no-op. With the list derived reactively from the database, the state
current at resume never re-emits, so the first post-resume emission is
the server sync response seconds later: scrollToItem then re-anchored
an already rendered (and possibly slightly changed) list under the
user's eyes.

Restore the position only once per activity lifetime: after a
recreation the LazyListState is genuinely lost and the first emission -
which the reactive flow now delivers immediately on subscription -
restores it, while a plain resume keeps the retained state's position
naturally, without any late scrollToItem.

Assisted-by: Claude Code:claude-fable-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
The conversation list actions - mark as read/unread (menu and swipe),
add to or remove from favorites, and tag assignment - write the local
conversation entry optimistically and revert on server failure, but had
no protection against the same race the read marker had: a room list
sync (or single-room refresh) whose response was computed before the
action reached the server reverts the entry to the old state until the
next sync, flipping badges and jumping favorite-sorted rows.

Generalize the pending read marker mechanism in ConversationListUpdater
to these actions: each optimistic write registers a pending change, and
while the server response provably doesn't reflect it yet, the local
state is kept in the merge. Once the server confirms the change it is
released and the server state applies unchanged - so changes made on
other devices keep flowing - and a failed action releases its pending
change when reverting, restoring the server's authority. Mark-as-read
reuses the pending read marker itself; mark-as-unread, favorites and
tags get their own pending state with value-based confirmation.

Assisted-by: Claude Code:claude-fable-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
…other list actions

Archiving from the conversation list was the last list action without an
optimistic write: it awaited the server call and a full room list sync
before the row moved, and a concurrent sync whose response was computed
before the archive reached the server could still flip the row back
until the next refresh.

Move the action into ConversationsListViewModel following the favorite
pattern: the archived flag is written locally first (the reactive list
moves the row instantly, no extra sync round-trip), the server call is
retried once and reverted on failure, and the change is registered as a
pending archived flag in ConversationListUpdater so stale server
responses cannot revert it before the server confirms.

Assisted-by: Claude Code:claude-fable-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/convoListUpdate branch from bfcd87c to 1374082 Compare August 14, 2026 18:33
@AndyScherzinger AndyScherzinger changed the title 🔄️ Auto-update conversation list 🔄️ Improve conversation list performance Aug 14, 2026
@AndyScherzinger AndyScherzinger added 3. to review Waiting for reviews and removed 2. developing Work in progress labels Aug 14, 2026
@AndyScherzinger
AndyScherzinger requested a review from mahibi August 14, 2026 19:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant