Repository navigation
feat(chat): mute a chat, and keep a muted chat quiet - #806
Merged
Merged
Conversation
The contract sync to dd5e92db added MuteChat/UnmuteChat and the muted flag on the push payload; the RPCs were wrapped but nothing called them and nothing read the flag. This adds the UI and both notification paths. Entry point is the group chat profile, between the invite row and Leave Group. Tapping it opens a sheet offering 1 hour, 8 hours, 1 week, or Always — WhatsApp's set plus Telegram's 1 hour. Once muted, the row flips to "Unmute Notifications" and reads "Muted until 5:56 PM" on the right; unmuting is that same row, since UnmuteChat is its own RPC with nothing to ask. DMs have no settings screen to hang this on, so it is group-only. A timed mute lapses with no server signal, so nothing stores a computed muted boolean: the until-timestamp is kept and compared against now on read, and the profile screen re-reads `now` exactly at the expiry so the row flips back on its own. Mute is cleared server-side on leave and its version restarts, so leaving drops the cached viewer state entirely — keeping the old version would make a rejoined chat's first update look stale and be swallowed. Both notification paths read the payload flag rather than local state, which is what the extension has to do anyway: - Foreground: `willPresent` returns empty presentation options for a muted chat. Everything above that point still runs, so the message is still stored and still counted as unread. - Background: the service extension cannot drop a notification — whatever it returns is displayed — so `.passive` with no sound is the floor. A muted chat's message still joins the notification list. Unread counts are deliberately untouched. They come from the stored message and the read watermark, never from the notification, so muting silences the banner without zeroing the badge. Viewer state is now cached in the conversations table as JSON, the same way `rules` is, storing the whole struct so the version survives a cold start and a reload cannot beat a fresher stream update. Without it a muted chat renders unmuted between the cache load and the feed refresh; Android is persisting it too. Schema version bumped accordingly. Also fixes two switches the scaffold commit left non-exhaustive (`persist(event:)` and `hydrateIfUnknown`), and restores the exact signatures `ConversationFetching`/`ConversationMessaging` require — a defaulted `viewMode` does not witness a requirement that omits it, so the protocol conformance had stopped compiling.
Muting was reachable only from a group's profile screen. A DM has no such screen — a tip DM's title opens the counterpart's user profile, which carried only Block — so the chat type most likely to be noisy had no way to be silenced. Move the control out of ChatProfileScreen into ChatMuteRow and host it on both screens. The row owns the picker presentation, the unmute call and the countdown that lapses a timed mute, so the two surfaces can't drift; they differ only in the chevron they draw, which each screen already styles its own way. UserProfileScreen resolves the chat through tipDM(withUserID:), which matches the roster rather than going through counterpart(excluding:) — that falls back to the first member and would answer a malformed single-member chat with the viewer themselves. Hidden chats are excluded, since a blocked counterpart's DM is off the feed and muting a chat the user can't see isn't a control worth offering. The lookup re-runs on every pass, so a DM a tip creates while the screen is open brings its mute row with it.
#804 bumped FlipcashAPI's exact pin to 0.10.0 but left the workspace Package.resolved on 0.9.0, so every clean resolve rewrites the file and shows up as a dirty tree. Commit the resolution the pin already requires.
The mute row overflowed in the state that needed it most. Muted, it drew "Unmute Notifications" at 20pt next to "Muted until 5:56 PM" at 14pt, a 45pt icon slot and a chevron — the two longest strings always appeared together, because the wording that grew was the muted one. The label also swapped under the tap that changed it, which read as a missed tap. The row is now always "Mute Notifications" and always opens the picker. What it used to report moves under the chat title beside the member count, as ChatMuteStatusLabel — shared for the same reason the row is, since a DM and a group are the same mute. The sheet gains "Never", which leads the list once there is a mute to clear and is left off when there isn't; it calls UnmuteChat rather than muting for zero seconds, and is worded as a duration only because every label there names the resulting state rather than the action. With no state left to draw, the row keeps one piece of state instead of three.
The line was absent while a chat was audible, so the rows below it jumped up the moment a mute was cleared or lapsed — the whole screen appeared to re-lay itself out rather than one fact going away. It now always renders, falling back to a placeholder it never shows, and carries its visibility on opacity with a 0.25s ease. Nothing moves, and the line is hidden from accessibility while it is invisible.
Under the chat title sit the chat's own facts — its name, its size, when someone joined. The mute is the viewer's setting, and given the same secondary styling it read as a fourth fact about the chat; on a counterpart's profile it was the fourth such line in a row. chip(.standard) is the existing treatment for a non-interactive affordance in this flow, and its fill gives the line its own ground without adding a colour.
Arriving is the event worth seeing — the user has just chosen it — so the chip scales up from 0.85 with a little overshoot. Going away is either their unmute or a deadline passing quietly, and neither wants attention drawn to it, so it leaves on a plain ease. The animation is read against the new state, which is what lets one value drive two curves.
The mute is the one thing on a profile screen that stops being true on its own, and white on the row fill gave it the same weight as the chat's name and size. Amber separates it, and the tint keeps it under the title — at full strength on the row fill it out-shouted the title instead. ChipStyle becomes a struct of statics so `tinted(_:on:)` can name its colours. The enum had no references outside the file and `chip()` had one call site, so a case would have cost a new name and two switch arms for a single use. The style now carries the shape as well: a status chip draws as a capsule rather than at `Metrics.buttonRadius`, which is the corner every button on these screens uses and invites a tap the chip has nothing to do with. `warningSecondary` is amber at a tenth of its strength over `backgroundMain`, baked flat the way the sentiment pair secondaries are.
Three things visible on device: The chip drew its text outside the capsule for the length of the spring. Geometry interpolates and glyphs don't, so the fill grew from the old string's width while the new string was already drawn in full. The chip now takes its width at once inside the spring, which still carries the scale and the fade. Fading out, the label changed what it said on the way: the text is nil the instant a mute is cleared, so the placeholder it fell back to replaced the deadline mid-fade. It now falls back to what it last said, which reads as the chip going quiet rather than correcting itself. "Never" appeared on the sheet while the sheet was closing. The store converges before the dismissal finishes, so a list read live gained its first row on the way out. `apply` pins the row's presence; left idle the list is still live, so a mute lapsing under an open sheet drops it. Also drops the trailing period from the sheet's caption, matching the copy elsewhere.
The chip slid sideways into place on every text change. Frame-by-frame, its glyph box travelled 63px right-to-left over the spring while its width barely moved: the string had snapped to full size, but the frame was still interpolating from the old string's width, and the content sits at that frame's leading edge. Suppressing the glyph animation only swapped one symptom for the other — before it the text spilled out of the capsule, after it the whole chip slid. Anything that keeps one view and changes its string interpolates the width. Key the chip's identity on the string instead, so a change is an insert and a remove with a scale-and-fade transition. There is no width to interpolate, and the scale is anchored at the centre. A hidden placeholder holds the line's height, and the outgoing copy keeps its own wording as it fades, which drops the `lastText` cache that was doing that by hand.
The amber chip only exists on the chat's own profile, so a muted chat was indistinguishable from an audible one everywhere the user actually looks — the chats list, the transcript's title bar, the card at the head of the transcript. `MuteBell` draws `bell.slash.fill` at caption size in `textSecondary`, the weight the chevron and the timestamp beside it already carry. It takes the mute rather than a muted flag and keeps its own clock, for the same reason the status label does: a timed mute lapses with nothing sent from the server, so a bell handed a boolean stays lit until something unrelated redraws its host. Both head cards are configured from `Codable` structs in FlipcashCore, so the mute travels on the card. On the chat list row it sits at the trailing end of the subtitle line, past a spacer — it belongs to the chat, not to the message the preview quotes. On the head cards it goes before the chevron, so the disclosure indicator stays last. The row and the title button both collapse to a single accessibility element, which discards the bell's own label; both now append "muted" to the container label from a plain read.
The bell stays on the two surfaces that list or title a chat: the chats row and the transcript's title bar. The head card sits directly under that title bar, so it was repeating the bell a few points below it. The `mute` field comes off `ChatProfileCard` and `ChatGroupCard` with it. It existed only because the cells are hosted from those structs, and `ChatProfileCard` rides in the byte-compared `ConversationLoadCoordinator.Inputs`, so a mute change was re-mapping the transcript to draw a glyph the title bar already carried.
The bell sat behind `Spacer(minLength: 12)` in an `HStack(spacing: 6)`, and both are unconditional: an audible chat drew no bell but still paid 18pt off the end of its message preview. Rendered at a 200pt width, the subtitle's ink stopped at 174pt where it now reaches 197pt. The gap moves inside `MuteBell`, behind the same condition as the glyph, so it appears and disappears with it. `leadingGap` defaults to zero, which is what the transcript title bar wants — its stack spacing is already right and its title is not competing with the bell for width.
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.
Muting a chat end to end: the duration picker, where a mute is reported, and the notification behaviour that follows from it. The core layer already carried
muteChat/unmuteChatand version-convergent viewer state, so this is the UI and the suppression.The control
ChatMuteRowis one definition hosted by both profile screens — the group's (ChatProfileScreen) and the counterpart's (UserProfileScreen) — so group mute and DM mute can't drift. The row is fixed text that opens the picker and nothing else; the current state is drawn byChatMuteStatusLabelunder the chat's title, where the rest of the chat's facts already sit. The hosts differ only in the chevron they draw, which each screen already styles its own way.UserProfileScreenresolves the chat throughConversationController.tipDM(withUserID:), which matches the roster rather than going throughcounterpart(excluding:)— that falls back to the first member and would answer a malformed single-member chat with the viewer themselves. Hidden chats are excluded, since a blocked counterpart's DM is off the feed. The lookup re-runs on every pass, so a DM a tip creates while the screen is open brings its mute row with it.Durations
1 Hour / 8 Hours / 1 Week / Always, named for the resulting state rather than the action, because the same labels double as the readout ("Muted until 5:56 PM"). The contract admits only two mute shapes — until an instant, or forever — so every timed option is that shape with a different offset.
Unmuting is its own RPC rather than a mute of zero length, so it is a "Never" row on the same sheet: first in the list, and present only while the chat is muted.
pinnedOffersUnmuteholds that row through the dismissal — the store converges before the sheet finishes animating out, so without it the list gained a row on its way off screen.A timed mute lapses with no server signal, so the client owns the countdown: nothing stores or renders a computed muted boolean.
isMuted(at:)compares the stored until-instant against now on read, and every view that reports a mute holds the state itself and re-reads its own clock when the instant passes.Leaving a chat drops the local viewer state outright, version included. The server clears mute on leave and resets the version, so a retained version would swallow the rejoin's first update and show a stale mute.
Where a mute shows
Two weights, because the two surfaces are answering different questions.
On the chat's own profile the mute is the subject, so
ChatMuteStatusLabeldraws it as an amber chip under the title.ChipStyle.tinted(_:on:)puts the colour in the fill as well as the text — at full strength on the row fill a tinted label out-shouts the title above it — and draws aCapsule, becauseMetrics.buttonRadiusis the corner every button on that screen uses and a rounded rect invites a tap the chip has nothing to do with.Color.warningSecondaryiswarningat 10% overbackgroundMain, baked flat, so it is only correct on that ground.The chip is re-identified on its text (
.id(text)) with a scale-and-fade transition rather than animated in place..animation(nil, value:)freezes the glyphs but not the frame, which left full-size text sitting at the leading edge of a capsule still growing out of the previous string's width — the chip visibly slid sideways on every change. Making each change an insert and a remove leaves no geometry to interpolate; a hidden placeholder in theZStackholds the line's height.Everywhere the chat is merely named — the chats list row and the transcript's title bar —
MuteBellstates the same fact as abell.slash.fillglyph carrying the chevron's weight rather than the chip's. In the list it sits at the end of the subtitle line against the row's trailing edge: the mute belongs to the chat, not to the message the preview quotes.The head card above the transcript carried the bell for a commit and no longer does: it sits directly under the title bar that already draws one. That keeps
muteoffChatProfileCardandChatGroupCardas well, which is worth more than the glyph was — those structs configureUIHostingConfigurationcells, andChatProfileCardrides in the byte-comparedConversationLoadCoordinator.Inputs, so carrying the mute there re-mapped the transcript on every mute change.The list row and the transcript's title button each collapse to a single accessibility element, which discards the bell's own "Muted" label — those two container labels append ", muted" themselves.
Suppression
Both the foreground path (
NotificationPresentation) and the service extension readmutedoff the push payload, never off local viewer state — the extension has no access to the app's store, and deciding from the payload keeps the two paths agreeing by construction. Android reads the same flag.Known limit:
UNNotificationServiceExtensioncannot discard a push. Whatever the handler returns is displayed, and not calling it shows the original afterserviceExtensionTimeWillExpire..passiveplus no sound is the floor, so a muted chat still lands silently in the notification list.removeDeliveredNotificationscan't clear the push being handled, andfilterCriteriaonly feeds Focus filters. The real fix is server-side — a data-only send to muted viewers (apns-push-type: background,apns-priority: 5) — which needs a background-push handler on our side. That spec is agreed with Android and not yet filed; this PR is the client half that works today.Also here
A
chore(deps)commit: #804 bumpedFlipcashAPI's exact pin to 0.10.0 but left the workspacePackage.resolvedon 0.9.0, somainrewrites that file on every clean resolve and shows up as a dirty tree. This commits the resolution the pin already requires.