Skip to content

feat(chat): mark chats unread or read from the chat list - #1200

Merged
bmc08gt merged 27 commits into
mainfrom
feat/chat-mark-unread
Nov 4, 2026
Merged

bmc08gt merged 27 commits into
mainfrom
feat/chat-mark-unread

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Once a chat is opened there's no way to put it back to unread, and clearing a backlog means opening chats one at a time. This adds Mark as Unread and Mark as Read to the chat list: a leading swipe and a long-press menu 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). Design nodes: A1 13176:58, A2 13176:68, B1 13176:74, E1 13186:96, E2 13186:113.

What changed

  • The flag. Mark as Unread sets a device-only flag in ChatMarkedUnreadStore (LocalChatMarkedUnreadStore). It lives beside ChatArchiveStore and uses the same owner-scoped JSON file outside the FlipcashStore that a version bump deletes. The flag is cleared by Mark as Read, Read All, opening the chat (ConversationScreen's opening task, so search and push opens count too), and leaving a group. Marks are stored per account and survive logout: signing back in to the same account shows them again, and another account sees none. It survives archive, unarchive and new messages. Nothing is sent to the server.
  • One unread rule. chatListUnread(for:) and the row both read the flag, so unreadChatListCount (the tab badge) and the row agree. A marked chat with no unread messages draws the shipped hasUnread = true, unreadCount = nil state; once a count is known the count wins. A pending join request doesn't count as unread for the action label.
  • Mark as Read advances READ to the latest message, the same call opening the chat makes.
  • Leading swipe on main and archived rows: one action, message.badge / message, in Mute's tonal style: the glyph baked in Color.unreadIndicator on the new flat unreadIndicatorSecondary (24, 36, 49, the blue at 10% over backgroundMain, derived the way warningSecondary is from warning). A full swipe fires it.
  • Swipe titles. Every swipe action on the chat list, the archived list and the Members moderation actions now has a title under it, through a shared SwipeActionLabel: Unread / Read on the leading action (the context menu keeps "Mark as Unread" / "Mark as Read", and so does VoiceOver), Mute / Unmute, Archive / Unarchive, and on the Members moderation actions Restrict, Lift Restriction and Remove (the row is already in the group's member list; the member sheet and VoiceOver keep "Remove from Group"). A titled swipe action draws as a tonal pill, not a circle, because that is how iOS lays out a swipe action with a title; a longer title makes a wider pill. Android draws its titled actions as the same pill. Full swipe is unchanged.
  • Long press: .contextMenu(menuItems:preview:) with Mark as Unread/Read, Mute/Unmute and Archive (Unarchive on the archived list). The preview, ChatPreview, hosts the chat's own transcript (ChatScreenRepresentable, new isReadOnlyPreview mode): the same cells, background, date separators, sender names and reactions, opened at the newest message, with no input bar, no touches, read reporting off and every action a no-op. It doesn't use ConversationScreen, so it never calls markSeen and looking doesn't advance READ. A chat with no last message gets the menu without a preview.
  • "…" menu opposite + on the main list: Select Chats, and Read All (disabled when nothing in the main list is unread). Read All confirms first, then advances every unread main-list chat (muted included) and clears every marked main-list chat. Marked archived chats keep their flag.
  • Select mode: Done and "%d Selected" in the nav bar, a selection circle on each row (one Image whose symbol swaps with a replace effect, so a tap changes it in place; only entering and leaving select mode slides it), chevrons hidden, swipes and long press off. The tab bar gives way to three glass capsules: the read toggle (Read All / Read / Unread), Archive and Mute. Bulk archive shows one toast, "Chat archived" or "%d chats archived", whose Undo restores all of them. Bulk mute opens MuteChatSheet once, or offers Unmute when every selected chat is muted.
  • MuteChatSheet now takes conversationIDs: so one sheet can apply a duration to several chats. Its layout is unchanged.

Analytics

Not wired. The three spec events (Chat Marked Unread, Chat Marked Read, Chats Read All) reach iOS only through a flipcash-shared-core-spm release and pin bump. The call sites are ready: markUnread / markRead / perform take a source: ChatReadSource (.swipe, .menu, .selection), and readAllChats() returns the count of chats it advanced.

Tests

FlipcashTests/Chat/ChatMarkUnreadTests.swift covers all 18 rows of the spec's test-case table. "No RPC" and "advances READ" are asserted against the ConversationMessaging fake, and case 17 hosts the transcript-backed ChatPreview in a window on an unread chat, checks it built the chat's messages, and checks READ didn't move and no read call was sent. Case 8 checks marks are per account: owner Y sees none of X's, and a fresh store for X sees X's mark again. Locally, ChatMarkUnreadTests and ConversationControllerTests (121) and the chat view suites (ChatReadReportingTests, ChatViewControllerTests and seven more, 54) pass.

Device run

iPhone 17 simulator, test account, cold launch straight to Chats, existing chats only. Steps 1 to 4 were screen-recorded and stepped through frame by frame:

  1. Full leading swipe on a read chat: the action grows across the row, the row gets the dot and blue time, and the tab badge goes up by one.
  2. Long press on that unread chat: the preview shows the last messages with sender names, and dismissing it leaves the chat unread. Mark as Read from the menu then clears it and the badge. Rerun on the transcript-backed preview (b1d0ecd1c): long press on Moony Nigeria (31 unread) lifts the transcript at its newest message. Next to the opened chat it shows the same quotes, avatars, sender names and reaction pill, with no input bar. Dismissing it left the chat at 31 unread, including after a cold relaunch that reloads the feed from the server.
  3. "…" → Select Chats: picking a marked chat plus a read one shows Read; swapping to two read chats shows Unread. Archive gives one "2 chats archived" toast, and Undo brings both back.
  4. "…" → Read All → confirm: every main-list chat reads and the badge clears. A chat marked and then archived beforehand still shows its dot in Archived.
  5. Cold relaunch: the archived chat's mark persists.

After the design pass (select circle, tonal swipe), a second recorded run on the same simulator: a partial leading swipe beside a partial trailing swipe on the same row shows the Unread circle rendered #182431 next to Mute's #30271D. In select mode, 49 frames at 30 fps across a row tap show the circle filling to blue and drawing its check without leaving its column.

The test account was left as it started: nothing marked, nothing archived.

Where this differs from the frames

  • The context menu and the "…" pull-down are system menus, so their icons sit leading (iOS 26) rather than trailing as drawn.
  • The select-mode title uses the app's nav-bar title style, which is larger than E2's title.
  • The capsules use the app's existing glass style, not the frame's blur and tint values.

Parity with Android

Android: code-payments/code-android-app#1938. Both follow the same rules for what counts as unread, what clears a mark, Read All's scope and count, the bulk read and mute toggles, archive Undo, marks surviving logout, and the copy. Where they differ, the difference is intended:

  • Analytics go out on Android only. iOS waits on a shared-core release.
  • Deselecting the last chat leaves iOS in select mode at "0 Selected", and Android exits. Only Done leaves on iOS, per the spec.
  • Select mode covers only the main list on iOS. Android also offers it in Archived, with Unarchive in the bar.
  • iOS shows Read All as a capsule when nothing is selected. Android has it only in the "…" menu.
  • The tonal secondary is 24,36,49 on iOS and 23,36,49 on Android. Leaving it as is.
  • Long press opens a preview on iOS and select mode on Android.
  • Swipe labels match: both draw a titled action as a tonal pill with Unread / Read, Mute / Unmute, Archive / Unarchive, and Restrict / Lift Restriction / Remove on Members.
  • Members actions now share one order: Remove at the outer edge, Restrict or Lift Restriction inside it (457492a moved Remove outward to match Android). Restrict now uses a crossed-out mic everywhere it appears on both apps (1cd512a here), replacing the clock on the swipe and the muted speaker elsewhere.

@bmc08gt bmc08gt self-assigned this Nov 2, 2026
@bmc08gt
bmc08gt marked this pull request as ready for review November 3, 2026 07:45
@bmc08gt
bmc08gt force-pushed the feat/chat-mark-unread branch 2 times, most recently from 5a88231 to 5803a96 Compare November 3, 2026 19:22
Adds a leading swipe and a long-press menu on chat rows, a "…" menu with
Select Chats and Read All, and a select mode with bulk read, archive and
mute. Mark as Unread is a device-only flag in ChatMarkedUnreadStore, kept
beside ChatArchiveStore; Mark as Read advances READ as opening the chat
does. The long-press preview is a read-only view that never calls
markSeen.
…newest message in view

The preview passed no known-author snapshot, so group senders the roster
couldn't name got no label; it now takes the list's snapshot and drops the
"Name:" / "You:" prefix inside bubbles. An overflowing transcript was
centred in the fixed frame, clipping the newest message; a bottom-anchored
scroll view keeps it in view.

Select mode: the selected circle uses Color.unreadIndicator with a white
check (frame E2), and only a disabled capsule's text dims, not its glass.
…te's tonal style

The select circle was two Image views in an if/else, so each toggle took the
row's slide-in transition and the circle slid. It is now one Image whose symbol
switches between circle and checkmark.circle.fill with a symbol replace
effect; the slide stays for entering and leaving select mode.

The leading Unread/Read swipe now matches Mute: the glyph baked in
unreadIndicator on a new flat unreadIndicatorSecondary (24, 36, 49), the blue
at 10% over backgroundMain, derived the way warningSecondary is from warning.
Marks are stored per account in flipcash-<owner>-marked-unread.json, so
logout no longer clears them: signing back in to the same account shows
them again, and another account reads its own file and sees none. Drops
ConversationController.prepareForLogout() and the store's clearAll(),
which had no other caller.
The preview drew its own bubbles, so it lost date separators, quotes,
reactions and the chat background. It now hosts ChatScreenRepresentable
in a read-only mode: no input bar or jump button, no touches, opened at
the newest message, read reporting off and every action a no-op.
ConversationScreen still isn't used, so showing the preview never moves
READ (test 17 now hosts this path).
The long-press Unmute and the select-mode Unmute capsule called unmute with
try?, so neither sent chatUnmuted and a failure showed nothing. Both now go
through unmuteReporting, which tracks per chat and shows the sheet's error
dialog. Android's ChatRowActions.unmute already tracks both outcomes.
Each chat-list and Members swipe action draws its title under it via
.labelStyle(.titleAndIcon): Unread/Read, Mute/Unmute, Archive/Unarchive,
Restrict, Lift Restriction, Remove from Group. iOS lays a titled swipe
action out as a tonal pill rather than a circle. VoiceOver labels and
full-swipe behaviour are unchanged.
The row already sits in the group's member list, so the swipe title drops "from Group". The member sheet and VoiceOver keep "Remove from Group".
Android already places Remove outermost; iOS had Restrict there.
The profile menu and the Restricted tag already use speaker.slash, as
Android does; the clock read as a timed mute.
Restrict stops a member posting; a muted speaker read as "I can't hear
them", closer to Mute. Covers the Members swipe, profile menu, Restricted
tag, toast, Forever option and the message action.
Animating the select slot's width let the title and subtitle each
re-truncate and cross-fade on their own while the circle slid in, so
two copies of the subtitle drew at once mid-slide.

RecipientRowSelectReveal is an animatable Layout: SwiftUI interpolates
its progress and lays the row out again every frame, so the circle,
avatar, title, subtitle, and trailing content move as one block, as
Android's TipChatRow does.
contextMenu evaluates its preview closure on every row update, and
every row updates when the "..." menu opens or select mode starts.
The closure built a ConversationLoadCoordinator, which read and mapped
each listed chat's messages on the main thread. A main-thread sample
of the first menu open after launch spent 359 ms there, and select
entry spent 57 ms.

ChatRowPreview builds the coordinator in its body, which runs only
when the preview is presented. Busy main-thread time on the first
menu open drops from 705 ms to 34 ms, and on select entry from 253 ms
to 75 ms.
Select mode's circle now sits at its final position and fades in while
the avatar, title, subtitle and trailing content slide right to make
room; leaving select mode runs it backwards. Before, the circle slid in
from off the leading edge with the content.

The same animated progress still lays the content out every frame, so
it moves as one block. The circle's opacity animates in the same
transaction and curve. Measured per frame on a simulator recording, the
circle's leading edge stays at x 66 px through entry and exit while
its stroke brightness rises and falls; before, it moved from 27 to 66.

Until the content clears it (about the first two-thirds of the slide),
the avatar passes over the fading circle, which is drawn behind it.
A chat row's long press only dimmed the list, so the rows stayed
readable behind the preview and menu. A long-pressed message in a chat
frosts the screen instead.

The row preview now presents the chat's own MessageBackdrop over the
window's root view when it appears and fades it out when it goes, so
the material and the 0.2 s fade are the chat's. MessageBackdrop is made
public for this.

SwiftUI's contextMenu still draws its own dim above the frost, with no
way to turn it off, so the list reads darker than a chat: a fixed patch
measures rgb ~23 against ~30-37 in the chat (12-13 before, dim only).
The frost from ee700e2 appeared in a single frame on the chat list,
while a long-pressed message's frost fades in over about 0.2 s. The row
preview appears inside UIKit's performWithoutAnimation as the menu
presents, so MessageBackdrop's fade ran with animations off.

ChatListBackdrop now starts the frost on the next turn of the main
queue, and skips it if the menu was dismissed in between. In a 60 fps
recording the list's frost now ramps over frames 351-362, alongside the
preview lifting, and fades out over about 0.23 s as the menu leaves.
A photo in the long-press preview stayed an empty gray box, such as the
chart card above a Moony buy. ChatPreview never passed the transcript a
way to mint or decrypt photo URLs, so it fell back to the default
minter, which returns nil. The gap dates from b600540, when the
preview started using the chat's transcript.

The row preview now passes the same blob URL minter and decrypter that
ConversationScreen uses, built from the session's flipClient and the
conversation controller. The chart card renders in the preview on the
simulator.
@bmc08gt
bmc08gt force-pushed the feat/chat-mark-unread branch from 825d19f to 9349bfa Compare November 3, 2026 23:40
Mark as Read used the plain `message` outline, which reads as "open chat"
next to Mark as Unread's `message.badge`. Android now draws a bubble with a
centred check (code-android-app #1956). SF Symbols ships that shape as
`checkmark.message`, built on the same outline, so it matches the weight of
Mark as Unread, Mute and Archive without a custom symbol. The swipe action,
long-press menu and selection capsule all read it from `systemImage`.
…capsules

Entering select mode hid the tab bar, and its scroll edge blur went with
it. The capsules sat in a plain `safeAreaInset`, which has no blur, so the
list showed through sharp until the capsules' own glass faded in, and the
capsules first drew above the still-visible tab bar before dropping into
place.

Put the capsules in a bottom `scrollEdgeBar` so the edge blur stays behind
them, and enter and leave select mode through one `withAnimation` spring so
the tab bar and capsules cross-fade together. The capsules fade and scale in
from the bottom edge; with Reduce Motion they only fade.
The system tab bar's animated hide drops its icons at once and leaves its
empty glass on screen for about four frames before the capsules arrive.

Hide it with animations disabled instead, in an update of its own: sharing
an update with the animated `begin()` left the bar on screen for the whole
of select mode. The capsules' edge bar is inserted in that same update with
the capsules at zero opacity, so its blur takes over from the tab bar's in
the same frame, and they fade in on the next turn. Leaving select mode is
unchanged.
… blur

The capsules sat in a bottom `scrollEdgeBar`, so a scroll edge blur band
ran behind them and blurred the bottom of the list for the whole of
select mode.

Put them in a plain `safeAreaInset` instead. Each capsule keeps its own
glass, the list behind them stays sharp, and the inset still lets the
last row scroll clear of them. The tab bar still goes in one frame and
the capsules fade in after it; with Reduce Motion they only fade.
Hiding the system tab bar also removed its 83pt bottom inset. The list's
soft bottom scroll edge effect is drawn over that inset, so the blur went
in the frame the bar hid and came back at full strength in one frame on
the way out.

On iOS 26 the bar now stays in place and only fades: alpha to 0, with
touches and accessibility off. The inset and the edge effect never change.
The capsules sit over the faded bar in its pill's 62pt slot and cross-fade
with it in one animation. In a replica of the tab view and list, the
bottom band outside the pills measures within 1 per channel of the tab
bar state, against 2 to 23 when the bar was hidden.

The `.tips` sheet, the pre-iOS 26 pill (which still hides) and a bar on a
side edge keep the capsules in their own bottom inset.
@bmc08gt
bmc08gt merged commit 5a5bb45 into main Nov 4, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant