Skip to content

feat(chat): mute a chat, and keep a muted chat quiet - #806

Merged
bmc08gt merged 13 commits into
mainfrom
feat/chat-mute
Sep 19, 2026
Merged

bmc08gt merged 13 commits into
mainfrom
feat/chat-mute

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

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/unmuteChat and version-convergent viewer state, so this is the UI and the suppression.

The control

ChatMuteRow is 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 by ChatMuteStatusLabel under 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.

UserProfileScreen resolves the chat through ConversationController.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. 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. pinnedOffersUnmute holds 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 ChatMuteStatusLabel draws 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 a Capsule, because Metrics.buttonRadius is the corner every button on that screen uses and a rounded rect invites a tap the chip has nothing to do with. Color.warningSecondary is warning at 10% over backgroundMain, 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 the ZStack holds the line's height.

Everywhere the chat is merely named — the chats list row and the transcript's title bar — MuteBell states the same fact as a bell.slash.fill glyph 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 mute off ChatProfileCard and ChatGroupCard as well, which is worth more than the glyph was — those structs configure UIHostingConfiguration cells, and ChatProfileCard rides in the byte-compared ConversationLoadCoordinator.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 read muted off 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: UNNotificationServiceExtension cannot discard a push. Whatever the handler returns is displayed, and not calling it shows the original after serviceExtensionTimeWillExpire. .passive plus no sound is the floor, so a muted chat still lands silently in the notification list. removeDeliveredNotifications can't clear the push being handled, and filterCriteria only 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 bumped FlipcashAPI's exact pin to 0.10.0 but left the workspace Package.resolved on 0.9.0, so main rewrites that file on every clean resolve and shows up as a dirty tree. This commits the resolution the pin already requires.

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.
@bmc08gt bmc08gt self-assigned this Sep 19, 2026
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.
@bmc08gt
bmc08gt merged commit d9e1e2e into main Sep 19, 2026
1 check passed
@bmc08gt
bmc08gt deleted the feat/chat-mute branch October 5, 2026 15:15
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