Skip to content

feat(chats): mark chats unread, Read All and select mode on the chat list - #1938

Merged
bmc08gt merged 16 commits into
code/cashfrom
feat/chat-mark-unread
Nov 3, 2026
Merged

bmc08gt merged 16 commits into
code/cashfrom
feat/chat-mark-unread

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

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.md in code-payments/flipcash-client-orchestrator (#94). Figma frames: Android A1 13201:90, A2 13176:113, C1 13176:95, F1 13201:108.

What changed

The flag. No contract change. Mark as Unread sets a flag stored on this device only, in a new chat_marked_unread table (DB 51 → 52, AutoMigration). ChatMarkedUnreadStore / RealChatMarkedUnreadStore sit beside ChatArchiveStore and follow the same clear rules. Opening a chat clears the flag, through RealChatCoordinator.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, and clearCache clears it with the archive set. Mark as Read on a chat with unread messages calls the existing markAsRead, the same call opening the chat makes.

One filter point. FeedSyncDelegate folds the marked set into ChatState the way it does the archived set, and ChatSummary.isUnread (unread messages or marked) is the one predicate observeUnreadChatListCount() and the Unread chip use. A marked chat with no unread messages maps to unread = null, which the shipped UnreadBadge(count = null) already draws. When a count is known, the count wins.

Rules in commonMain. ChatReadRules.kt holds 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. ChatReadActions executes 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 in commonMain; system back, the Read All confirm sheet and the contextual bar are in the androidMain wrappers):

  • Leading swipe on main-list and archived-list rows, one action. It is tonal like the trailing Mute circle: the glyph in the unread blue on a new flat indicatorSecondary ground. A full swipe fires it with the gesture haptic. Requests rows get none.
  • Main-list trailing swipe: Mute inside, Archive outermost, so a full swipe archives (with Undo) and Mute is a tap on its revealed pill; it still resets the row under the mute sheet. The archived list has only Unarchive.
  • Long press enters select mode. The app bar becomes a contextual bar: close, "N Selected", read toggle, Mute / Unmute, Archive (Unarchive on the archived list). Rows get a leading circle, the tab bar stays, and swipes are off. The circle slides in and out only as select mode starts and ends. Toggling a row crossfades its circle in place, from an outline in textSecondary to 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.
  • "…" button opposite +, with an M3 DropdownMenu: 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.
  • Bulk archive shows one toast, "Chat archived" or "%d chats archived", whose Undo restores all of them.
  • Bulk mute opens the shipped MuteChatSheet once through a new AppRoute.Messaging.MuteChats route and applies the duration to every selected chat.
  • Icons: ic_mark_chat_unread.xml and ic_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 derives warningSecondary from warning. Flipcash sets #172431 (23, 36, 49); Flipcash2ColorSpecTest pins it to that derivation. Other schemes get the same derivation from their own colors. Android had no warningSecondary token: the Mute circle is warning.copy(alpha = 0.15f).

Shared components touched, additively only:

  • SwipeActionRow gets leadingActions: 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; SwipeActionRowTest covers a partial reveal followed by a full swipe.
  • SwipeAction takes an optional label. 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 in textSecondary, 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.
  • ChatListRow gets an optional onLongClick, null by default.

Analytics. Chat Marked Unread and Chat Marked Read (Chat Type, Source), Chats Read All (Count), and the ChatReadSource list (Swipe, Menu, Selection), added through events.toml with EVENTS.md regenerated. The Swift typealias is in SharedCoreKit/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 in shared/chat, ChatListSelectionTest and ChatSwipeRowTest in features/chat-list, ChatMarkedUnreadDaoTest, and SwipeActionRowTest.

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.

  1. Partial leading swipe on a read chat reveals the hand-drawn icon. A full swipe marks it: the row returns home with the dot and the tab badge goes 7 → 8.
  2. Long press enters select mode with "1 Selected" and three actions, and system back exits.
  3. With two read chats selected, the read toggle reads Mark as Unread and marks both, then Mark as Read clears both. Mute opens the sheet once and mutes both. Selecting them again offers Unmute, which unmutes both. Archive shows "2 chats archived", and Undo brings both back. On the archived list the same bar offers Unarchive.
  4. "…" → Select Chats enters with "0 Selected" and the actions disabled. "…" → Read All shows the confirmation. Cancel changes nothing, and Mark as Read clears every unread and marked main-list chat in one pass.
  5. After a cold relaunch, chats marked in an earlier run still show the dot.
  6. Select circle and swipe style, from a fresh cold launch (60 fps frame steps). Entering select mode slides the circles in over about 8 frames, and leaving slides them out. Tapping a row only crossfades its circle: the avatar stays on a fixed reference line across every toggle frame. The revealed leading action samples (23, 36, 49) behind the blue glyph.
  7. Swipe labels, cold launch: a partial leading swipe shows "Unread" under the tonal blue pill, and a partial trailing swipe shows "Mute" and "Archive" under their pills, Archive outermost, and a full trailing swipe archives the chat. The Members row was checked in 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, as ChatTopBar'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).

Frame Matches Differs, and why
A1 13201:90 Circle at the leading edge, same size as Archive / Mute, row content shifted right Frame's icon is an SF Symbol placeholder; the build uses the hand-drawn drawable. Frame draws a solid blue circle; the build is tonal (blue glyph on indicatorSecondary), matching Mute, per a later design decision
A2 13176:113 Shipped no-count dot, blue timestamp —
C1 13176:95 Close, count, three actions, leading circles, selected-row tint, tab bar stays Frame shows a bare "2"; the spec's "2 Selected" wins. Frame draws bare icons; the build uses the app's circular icon buttons, as + and the in-chat selection bar do
F1 13201:108 "…" opposite + in the same style, menu under it Frame's items are sentence case; the spec's Title Case wins. The menu uses brandLight and extraLarge, as ChatTopBar's menu does, not the frame's grey and corner radius

Notes

  • Marks follow the archive set's lifetime: 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.
  • The catalogue has no archive or unarchive event, so bulk archive sends none. Bulk mute reuses Chat Muted per 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.

@bmc08gt bmc08gt self-assigned this Nov 2, 2026
@github-actions github-actions Bot added area: ui Compose UI, theme, components, resources type: feature New functionality labels Nov 2, 2026
@bmc08gt
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
bmc08gt force-pushed the feat/chat-mark-unread branch from 242e7a6 to 0379e90 Compare November 3, 2026 16:56
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
bmc08gt force-pushed the feat/chat-mark-unread branch from 0379e90 to 8b9e61d Compare November 3, 2026 17:11
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.
@bmc08gt
bmc08gt merged commit a3aa747 into code/cash Nov 3, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: ui Compose UI, theme, components, resources type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant