fix(chat): preview the last message with content after a delete - #1396
Merged
Conversation
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.lastMessageis what the row previews and what its unread check compares against the READ pointer, soFeedSyncDelegate.buildFeedFromDbreadinggetLatestVisibleinstead ofgetLatestfixes both at once.Tombstones are filtered on a new
chat_messages.is_deletedcolumn rather than matching the serialized discriminator incontent_json. There is precedent for theLIKEapproach inhasEverTipped, 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 theLIKEsurvives, where a false positive is bounded and self-heals on the next server sync.markAsReadneeded a companion change. It previously preferred the feed'slastMessage?.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 prefersmessageDataSource.getLatestMessageId, tombstones included. This is the same split iOS draws betweenlatestMessage(filterskind != 2) andnewestMessage/newestMessageID.ChatSummaryMapping.formatPreviewreturns null for a tombstone, which is now only reachable when every message in a chat is deleted. Thelabel_chat_preview_deletedMessagestring is deleted with its last caller; the in-transcript bubble copy (label_messageDeleted*) is untouched.Tests
Nine new tests across the three touched layers:
FeedDeletedMessageTestcovers 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.ChatMessageDaoTestcovers thegetLatest/getLatestVisiblesplit and runs the migration backfill SQL against real serialized rows, pinning the matched string to what the converters actually write.ChatEntityMapperTestcase pinsis_deletedto the message content.Note
The feed still updates on server confirmation rather than optimistically —
PendingMutationreaches 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.