Skip to content

fix(chat-list): mark read moves READ to the highest stored message id - #1955

Merged
bmc08gt merged 1 commit into
code/cashfrom
fix/chat-list-mark-read
Nov 4, 2026
Merged

bmc08gt merged 1 commit into
code/cashfrom
fix/chat-list-mark-read

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator

Mark as Read from the chat list (swipe, select mode, Read All) left the unread badge up: a badge of 4 dropped to 3 instead of clearing. Opening the chat cleared it.

Cause

MessagingDelegate.markAsRead took its target from ChatMessageDataSource.getLatestMessageId, which read ChatMessageDao.getLatest:

SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex ORDER BY timestamp_epoch_ms DESC LIMIT 1

The badge (FeedProjection.unreadCount) compares the READ pointer by id against the newest visible message, which getLatestVisible orders by timestamp_epoch_ms DESC, message_id DESC. When two messages share a millisecond and the older row was inserted later, getLatest returns the older one. That ordering is normal: the feed write stores a chat's newest message first, and the history load fills in older ones afterwards. READ then lands below the newest message and the count drops instead of clearing.

The store is not behind the server here. The badge reads the same local table (FeedSyncDelegate.buildFeedFromDb and toSummary both use getLatestVisible), and the feed write persists each chat's preview row before the transcript loads. So the metadata's last_message_id would not move the pointer any further than the highest stored id does. The two queries just order the rows differently.

Opening the chat works because ChatViewModel advances to the id of a message on screen.

Fix

New ChatMessageDao.getNewestMessageId: MAX(message_id) over positive ids, tombstones included. That is the order the badge compares in. getLatestMessageId, whose only caller is markAsRead, now uses it. Leaving out negative ids also means an unsent placeholder can't be picked anymore. A placeholder's timestamp comes from the device clock, so it could win the old timestamp sort, and the server would then get a negative READ. That case doesn't produce this bug, since a pending send is the user's own message and the badge already reads 0, but the server shouldn't get a negative READ either way.

getLatest keeps its other callers (hasMessages and the receive buzz) unchanged.

Test

MarkReadAnchorTest (persistence sources, Robolectric, real database):

  • same-millisecond pair, older row inserted later: anchor was 4, now 5
  • older tombstone with a later timestamp: anchor was 2, now 3
  • unsent placeholder with the latest timestamp: anchor was -3000, now 2
  • newest row is a tombstone: still the anchor (passed before and after)

Mark as Read from the chat list left the unread badge up, e.g. 4 dropped
to 3. markAsRead took its target from ChatMessageDao.getLatest, which
orders by timestamp only. The badge compares the READ pointer by id
against the newest visible message, ordered by timestamp then id. When
two messages share a millisecond and the older row is the later insert
(the feed writes a chat's newest message before the history load fills
in older ones), getLatest returns the older one and READ lands short.

Both sides read the same local table, so the store is not behind the
badge; the two queries disagree on order. Opening the chat works
because it advances to the id of a message on screen.

The list path now uses MAX(message_id) over positive ids, tombstones
included. Excluding negative ids also stops an unsent placeholder from
being picked, which used to send the server a negative READ.
@bmc08gt bmc08gt self-assigned this Nov 4, 2026
@github-actions github-actions Bot added the type: fix Bug fix label Nov 4, 2026
@bmc08gt
bmc08gt marked this pull request as ready for review November 4, 2026 14:51
@bmc08gt
bmc08gt merged commit d2d214d into code/cash Nov 4, 2026
5 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