Skip to content

fix(chat): preview the last message with content after a delete - #1396

Merged
bmc08gt merged 1 commit into
code/cashfrom
fix/chat-deleted-message-preview
Sep 3, 2026
Merged

fix(chat): preview the last message with content after a delete#1396
bmc08gt merged 1 commit into
code/cashfrom
fix/chat-deleted-message-preview

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Deleting the newest message in a DM left the Chats row reading "Message deleted" with the unread splat still beside it. iOS falls back to the newest message that still has content, and the splat clears with it. This brings Android to that behaviour.

How it works

Both symptoms come from one field. The feed's ChatMetadata.lastMessage is what the row previews and what its unread check compares against the READ pointer, so FeedSyncDelegate.buildFeedFromDb reading getLatestVisible instead of getLatest fixes both at once.

Tombstones are filtered on a new chat_messages.is_deleted column rather than matching the serialized discriminator in content_json. There is precedent for the LIKE approach in hasEverTipped, but here a text message quoting "type":"deleted" would be misread as a tombstone. The mapper writes the flag from the domain content, so only rows cached before the column existed need repair — DB 31 -> 32 adds the column and backfills them, and that is the one place the LIKE survives, where a false positive is bounded and self-heals on the next server sync.

markAsRead needed a companion change. It previously preferred the feed's lastMessage?.messageId, which no longer identifies the newest row; left alone it would have parked the READ pointer below the deleted message and left the chat unread forever. It now prefers messageDataSource.getLatestMessageId, tombstones included. This is the same split iOS draws between latestMessage (filters kind != 2) and newestMessage / newestMessageID.

ChatSummaryMapping.formatPreview returns null for a tombstone, which is now only reachable when every message in a chat is deleted. The label_chat_preview_deletedMessage string is deleted with its last caller; the in-transcript bubble copy (label_messageDeleted*) is untouched.

Tests

Nine new tests across the three touched layers:

  • FeedDeletedMessageTest covers the two user-visible behaviours, the case that must not change (a fallback message that was itself never read keeps its splat), and the all-deleted chat.
  • ChatMessageDaoTest covers the getLatest / getLatestVisible split and runs the migration backfill SQL against real serialized rows, pinning the matched string to what the converters actually write.
  • One ChatEntityMapperTest case pins is_deleted to the message content.

Note

The feed still updates on server confirmation rather than optimistically — PendingMutation reaches the transcript only, not the Chats list. That matches iOS, so it is left as is; your own delete refreshes the open conversation immediately and the list follows when the event lands.

Deleting the newest message in a DM left the Chats row reading "Message
deleted" with the unread splat still beside it. iOS falls back to the newest
message that still has content and clears the splat with it.

Both symptoms come from one field: the feed's `ChatMetadata.lastMessage` is
what the row previews and what its unread check compares against the READ
pointer. `FeedSyncDelegate.buildFeedFromDb` now reads `getLatestVisible`
instead of `getLatest`, so the fallback message drives both.

Tombstones are filtered on a new `chat_messages.is_deleted` column rather than
matching the serialized discriminator in `content_json` — a text message
quoting that string would be misread as deleted. The mapper writes the flag
from the domain content; DB 31 -> 32 adds the column and backfills rows cached
before it existed, where the `LIKE` match is the only evidence available and a
false positive self-heals on the next sync.

`markAsRead` has to keep anchoring on the newest stored id, tombstones
included, so it now prefers `getLatestMessageId` over the feed's
`lastMessage`. Reading the feed's value would park the pointer below the
deleted message and leave the chat unread forever. This is the split iOS draws
between `latestMessage` and `newestMessageID`.

The feed still updates on server confirmation rather than optimistically:
`PendingMutation` reaches the transcript only, as on iOS.
@bmc08gt bmc08gt self-assigned this Sep 3, 2026
@github-actions github-actions Bot added the type: fix Bug fix label Sep 3, 2026
@bmc08gt
bmc08gt merged commit 57ab8bd into code/cash Sep 3, 2026
3 checks passed
bmc08gt added a commit to code-payments/code-ios-app that referenced this pull request Sep 3, 2026
Deleting the newest message left the conversation list blank until the chat was
opened and closed again. Server metadata reports the newest message whatever its
state, so the delete arrives as a tombstone `last_message`, and both seats for it
— the feed load and a metadata refresh — wrote it to the row verbatim. Nothing
draws for a tombstone, so the row went blank with an unread splat beside it.

The store now refuses a tombstone as a preview. It keeps the visible message the
row already carries, unless that is the message the tombstone replaces, and the
feed load fills the gap from the newest visible message in the database — the
path that already repaired the row on open, now run where the blank was seated.

That is the fallback code-payments/code-android-app#1396 brought Android to; iOS
only did it on the paths that already went through `refreshFeedPreview`.

The two on-demand hydrates persist the server's copy rather than the store's, so
the database still gets the tombstone row the repair reads to tell that the
newest message was deleted.
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