Repository navigation
feat(chats): mark chats unread, Read All and select mode on the chat list - #1938
Merged
Merged
Conversation
This was referenced Nov 2, 2026
bmc08gt
marked this pull request as ready for review
November 3, 2026 07:45
A local store beside ChatArchiveStore, folded into the feed in the same combine, so a marked chat counts as unread at the one filter point the tab badge and chips read. Opening a chat, leaving a group and account erasure clear the mark.
bmc08gt
force-pushed
the
feat/chat-mark-unread
branch
from
November 3, 2026 16:56
242e7a6 to
0379e90
Compare
A leading swipe on a main or archived row marks the chat unread or read. Long press, or "…" > Select Chats, enters select mode with a contextual bar (read toggle, Mute/Unmute, Archive/Unarchive). "…" > Read All confirms, then advances READ in every unread main-list chat and clears its marks. Bulk Mute opens the mute sheet once through a new MuteChats route that reuses MuteChatSheet. Bulk Archive shows one toast whose Undo restores all.
The Mark as Read/Unread swipe draws its glyph in the unread blue on a dark blue, as Mute draws amber on dark amber. The token is the indicator at a tenth over the main background, baked flat: #172431 for Flipcash, matching how iOS derives warningSecondary from warning. Schemes that don't set it get the same derivation from their own indicator and background.
The leading swipe now matches the trailing Mute circle's tonal style: the glyph in the unread blue on indicatorSecondary instead of a solid blue disc. Toggling a row only crossfades its circle between the outline and a white check on the unread blue; both fill the same 20dp (the knocked-out ic_check_circle disc drew 16.7dp of it), so nothing moves. The circle slides in and out only as select mode starts and ends. The screen drives that slide rather than each row: the row is rebuilt when select mode drops its swipe wrapper, which skipped a slide kept inside it (the enter jumped in one frame on device).
Test case 8 now signs in as account X, marks two chats, logs out, signs in as Y (no marks), then back in as X (both marks back). The database file is per account, so logout keeps the marks; clearCache still erases them.
SwipeAction takes an optional label, drawn under the circle in caption text in the secondary colour. A labelled action's slot widens to fit its title, so the reveal width follows the longest label. Unlabelled actions keep their full-height circle. Chat list and archived list rows label Mark as Read/Unread, Mute/Unmute and Archive/Unarchive with the select-mode bar's strings. Members rows use the member sheet's titles. Profile and region rows stay unlabelled.
A labelled SwipeAction now draws its tonal background as a pill the circle's height and 1.4x its width, with the label under it, as iOS draws a titled swipe action. Unlabelled actions stay circles, so the profile and region rows don't change. The chat list's leading action is labelled "Unread"/"Read"; the content description keeps "Mark as Unread"/"Mark as Read".
The row is already in the group, so the swipe label drops "from Group". The member sheet keeps "Remove from Group" and the content description is unchanged.
Restrict stops a member posting; a muted speaker read as "I can't hear them", closer to Mute. Covers the Members swipe, member sheet, profile menu, Silenced Members entry and the message action. Drops the now unused SpeakerSlash vector.
The main list's trailing swipe now draws Mute inside and Archive outermost. SwipeActionRow fires the last action on a full swipe, so a full swipe now archives (with Undo) instead of opening the mute sheet. Mute keeps resetOnDismiss so the row closes under its sheet. The archived list is unchanged; it only has Unarchive. ChatSwipeRowTest replays the device gesture: a slow partial drag leaves both actions revealed with Archive at the trailing edge, then a full swipe archives once and does not mute.
bmc08gt
force-pushed
the
feat/chat-mark-unread
branch
from
November 3, 2026 17:11
0379e90 to
8b9e61d
Compare
Past the reveal width, the actions that are not outermost fade linearly from full alpha to 0 at the point where releasing commits the full swipe (halfway from the reveal to the row's width, the drag's default positional threshold). On the chat list's trailing side Mute fades while Archive grows. Both sides use it; a side with one action is unchanged. The alpha is set in graphicsLayer from the drag offset, so dragging does not recompose, and it returns to 1 as the row springs back or a reset-on-dismiss sends it home. SwipeActionRowTest drags a two-action row slowly past the reveal and reads the inner action's pixels: opaque at the reveal, linear in the offset midway, nearly gone at the threshold, and opaque again after a release short of it. With the fade disabled the midway check reads 1.0.
Entering select mode used to slide the circle in from the leading edge with the row. The circle now sits at its final position and fades in while the row content slides right to make room; leaving is the reverse. The slot still widens with the reveal progress and clips the circle, so the content sliding off uncovers the circle instead of overlapping it. One progress value drives the slot width and the alpha, read in layout and draw only. Toggling a circle in select mode is unchanged. SelectCircleRevealTest pins it: the circle's x stays constant across progress, the avatar moves by progress times the slot, and the disc's colour matches its alpha over the row background.
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.
Once a chat is opened there is no way to put it back to unread. This adds Mark as Unread and Mark as Read to the chat list: a leading swipe and a long press for one chat, and a select mode with Read All for many.
Spec:
docs/specs/chat-mark-unread.mdin code-payments/flipcash-client-orchestrator (#94). Figma frames: Android A113201:90, A213176:113, C113176:95, F113201:108.What changed
The flag. No contract change. Mark as Unread sets a flag stored on this device only, in a new
chat_marked_unreadtable (DB 51 → 52, AutoMigration).ChatMarkedUnreadStore/RealChatMarkedUnreadStoresit besideChatArchiveStoreand follow the same clear rules. Opening a chat clears the flag, throughRealChatCoordinator.setActiveChatId, so the row, a notification and a deep link all go through the same path. Leaving a group clears it next to the archive record, andclearCacheclears it with the archive set. Mark as Read on a chat with unread messages calls the existingmarkAsRead, the same call opening the chat makes.One filter point.
FeedSyncDelegatefolds the marked set intoChatStatethe way it does the archived set, andChatSummary.isUnread(unread messages or marked) is the one predicateobserveUnreadChatListCount()and the Unread chip use. A marked chat with no unread messages maps tounread = null, which the shippedUnreadBadge(count = null)already draws. When a count is known, the count wins.Rules in commonMain.
ChatReadRules.ktholds the pure decisions: which read action a chat offers, the bulk read toggle, Mute versus Unmute for a selection, the Read All plan and its count.ChatReadActionsexecutes them. The Read All enabled state and the Read All execution read from the same plan, so they can't disagree.UI (
features/chat-list, the KMP module from #1950: the screens and select mode are incommonMain; system back, the Read All confirm sheet and the contextual bar are in theandroidMainwrappers):indicatorSecondaryground. A full swipe fires it with the gesture haptic. Requests rows get none.textSecondaryto a white check on the unread blue, and nothing else moves. Close, system back or deselecting the last row leaves select mode, and so does any action.+, with an M3DropdownMenu: Select Chats, and Read All (disabled when nothing in the main list is unread). Read All confirms through the shipped info sheet with the spec's copy, then advances READ on every unread main-list chat and clears every main-list mark. Archived marks stay.MuteChatSheetonce through a newAppRoute.Messaging.MuteChatsroute and applies the duration to every selected chat.ic_mark_chat_unread.xmlandic_mark_chat_read.xml, drawn by hand. They are not traced from SF Symbols.New token.
ColorScheme.indicatorSecondary, the indicator at a tenth over the main background, baked flat, the way iOS deriveswarningSecondaryfromwarning. Flipcash sets#172431(23, 36, 49);Flipcash2ColorSpecTestpins it to that derivation. Other schemes get the same derivation from their own colors. Android had nowarningSecondarytoken: the Mute circle iswarning.copy(alpha = 0.15f).Shared components touched, additively only:
SwipeActionRowgetsleadingActions: List<SwipeAction> = emptyList(). With no leading actions the anchors and the trailing layout are the same as before, and existing callers compile unchanged. A full leading swipe fires at the threshold and sends the row home once the drag and its settle finish;SwipeActionRowTestcovers a partial reveal followed by a full swipe.SwipeActiontakes an optionallabel. A labelled action draws its tonal background as a pill (the circle's height, 1.4× its width, fully rounded) with the label under it in caption text intextSecondary, as iOS draws a titled swipe action; a slot widens further if its label needs it. Chat and archived rows label Unread/Read (content description stays Mark as Unread/Mark as Read), Mute/Unmute and Archive/Unarchive, and Members rows label Restrict, Lift Restriction and Remove (the sheet's "Remove from Group" shortened, since the row is already in the group; the content description is unchanged). Profile and region rows pass no label and keep their circles. Tonal colours and content descriptions are unchanged.ChatListRowgets an optionalonLongClick, null by default.Analytics.
Chat Marked UnreadandChat Marked Read(Chat Type, Source),Chats Read All(Count), and theChatReadSourcelist (Swipe, Menu, Selection), added throughevents.tomlwithEVENTS.mdregenerated. The Swift typealias is inSharedCoreKit/AnalyticsEvents.swift. Selection sends one event per chat. Menu is unused on Android and is kept for parity with iOS.Tests
Spec test cases 1–16 and 18 (17 is iOS only), across
ChatReadRulesTest,ChatReadActionsTest, the feed and coordinator tests inshared/chat,ChatListSelectionTestandChatSwipeRowTestinfeatures/chat-list,ChatMarkedUnreadDaoTest, andSwipeActionRowTest.Device run
Emulator
invites_android, debug build, signed-in test account, cold launch straight onto Chat, existing chats only. Each step was screen-recorded and stepped through frame by frame.MemberModerationScreenshotTest's renders (Restrict, Lift Restriction, Remove); the test account hosts no group with other members to swipe on device.The device run caught four defects, each fixed in this branch: the row stayed off screen after a full leading swipe; the bar faded out showing "0 Selected" after an action; the menu text rendered dark on the dark surface (it now uses
textMain, asChatTopBar's menu does); and the select circle's slide-in jumped in one frame, because the row is rebuilt when select mode drops the swipe wrapper (the screen now drives the slide).13201:90indicatorSecondary), matching Mute, per a later design decision13176:11313176:95+and the in-chat selection bar do13201:108+in the same style, menu under itbrandLightandextraLarge, asChatTopBar's menu does, not the frame's grey and corner radiusNotes
clearCache(account erasure) deletes them. A logout closes the per-account database, so the marked set reads empty while signed out and comes back when the same account signs in again.Chat Mutedper chat.Parity with iOS
iOS: code-payments/code-ios-app#1200. The two differences in behaviour are intended. Deselecting the last chat exits select mode here but leaves iOS at "0 Selected", and select mode also covers Archived here, while iOS has it only on the main list. Android sends the three analytics events now; iOS will send them after a shared-core release. The tonal secondary stays at 23,36,49 here and 24,36,49 on iOS.
Swipe labels match iOS: a titled action is a tonal pill with Unread / Read, Mute / Unmute, Archive / Unarchive, and Restrict / Lift Restriction / Remove on Members. Members actions share one order on both apps: Remove at the outer edge, Restrict or Lift Restriction inside it (iOS moved to this order in code-ios-app#1200). Both use a crossed-out mic for Restrict everywhere it appears (86ad2cd here, code-ios-app#1200 on iOS), replacing the muted speaker.