Repository navigation
feat(chat): mark chats unread or read from the chat list - #1200
Merged
Merged
Conversation
bmc08gt
marked this pull request as ready for review
November 3, 2026 07:45
bmc08gt
force-pushed
the
feat/chat-mark-unread
branch
2 times, most recently
from
November 3, 2026 19:22
5a88231 to
5803a96
Compare
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
force-pushed
the
feat/chat-mark-unread
branch
from
November 3, 2026 23:40
825d19f to
9349bfa
Compare
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.
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'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.mdin code-payments/flipcash-client-orchestrator (#94). Design nodes: A113176:58, A213176:68, B113176:74, E113186:96, E213186:113.What changed
ChatMarkedUnreadStore(LocalChatMarkedUnreadStore). It lives besideChatArchiveStoreand uses the same owner-scoped JSON file outside theFlipcashStorethat 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.chatListUnread(for:)and the row both read the flag, sounreadChatListCount(the tab badge) and the row agree. A marked chat with no unread messages draws the shippedhasUnread = true, unreadCount = nilstate; once a count is known the count wins. A pending join request doesn't count as unread for the action label.message.badge/message, in Mute's tonal style: the glyph baked inColor.unreadIndicatoron the new flatunreadIndicatorSecondary(24, 36, 49, the blue at 10% overbackgroundMain, derived the waywarningSecondaryis fromwarning). A full swipe fires it.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..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, newisReadOnlyPreviewmode): 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 useConversationScreen, so it never callsmarkSeenand looking doesn't advance READ. A chat with no last message gets the menu without a preview.+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.Imagewhose 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 opensMuteChatSheetonce, or offers Unmute when every selected chat is muted.MuteChatSheetnow takesconversationIDs: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-spmrelease and pin bump. The call sites are ready:markUnread/markRead/performtake asource: ChatReadSource(.swipe,.menu,.selection), andreadAllChats()returns the count of chats it advanced.Tests
FlipcashTests/Chat/ChatMarkUnreadTests.swiftcovers all 18 rows of the spec's test-case table. "No RPC" and "advances READ" are asserted against theConversationMessagingfake, and case 17 hosts the transcript-backedChatPreviewin 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,ChatMarkUnreadTestsandConversationControllerTests(121) and the chat view suites (ChatReadReportingTests,ChatViewControllerTestsand 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:
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.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
#182431next 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
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: