Skip to content

feat(chat): mute a chat - #1506

Merged
bmc08gt merged 13 commits into
code/cashfrom
feat/chat-mute
Sep 20, 2026
Merged

bmc08gt merged 13 commits into
code/cashfrom
feat/chat-mute

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

The service layer has had muteChat/unmuteChat and the MuteState/ViewerState models since the
contract move, with nothing calling them. This wires muting up end to end: it persists, it has an
entry point on both profiles that can reach it, and it keeps a muted chat's messages from ringing.

Two rules the client owns

A timed mute lapses with no server signal. Nothing arrives when a deadline passes, so a stored
muted boolean would go stale in place and stay wrong until the next unrelated write. The deadline is
what gets stored; isMutedAt and rememberIsMuted answer against now on every read, and the
composable waits the remaining time out and re-answers itself. That is why every consumer here takes
ViewerState rather than a boolean.

Viewer state converges by version. Stream delivery is not ordered, so updateViewerStateIfNewer
drops an update carrying a lower version than the row already holds. Without it a late-arriving
older frame un-mutes a chat the user just muted.

What's in each commit

  • persist the viewer's mute — mute columns on ChatMetadataEntity, database v35 with an
    AutoMigration and an exported 35.json, the version-gated DAO write, and both stream delegates
    feeding it.
  • mute a group from its profile — the duration picker (1 Hour / 8 Hours / 1 Week / Always) and
    the mute row on the group profile, which turns into an Unmute row stating the deadline while the
    mute is running. Unmute is its own RPC rather than a zero duration.
  • silence pushes for a muted chat — see below.
  • mute a tip DM from its profile — the same row, hosted by the DM profile. MuteChatItem<T> is
    generic over the action because the two screens name their own; muting is one definition rather
    than two that can drift.
  • show a bell wherever a muted chat is named — the chats list row and the transcript title bar.
    An audible chat emits no layout node, so neither row spends spacing on it.

Push suppression, and what it still needs

planPushHandling omits PostNotification when the payload says the chat is muted, which drops the
alert, the group summary and the reply actions together while the sync half of the plan is untouched
— the message still stores and still appears in the feed. Unread counts come from
FeedProjection.unreadCount, so muting cannot zero them.

It reads the payload's flag rather than local mute state on purpose: the flag is what the server
decided at send time, and local state can be a version behind at exactly the moment a push lands.
The server does not set that flag yet, so this half is inert until it does. The send-side spec is
written and the backend change is being handled outside this repo; Android needs no further client
change when it lands, because onMessageReceived is already the data-message entry point in every
app state.

Scope

Tip DMs and groups only. ChatStep.Profile also opens a group member's profile, where a mute row
would silence the whole group from a screen naming one person — so the row is hosted by the two
screens that mean the chat, not by the route.

Contact DMs have no profile screen to host it. They are half-retired already: the Chats tab lists
TIP_DM and GROUP only and AppRouter routes nothing for their pushes, but their feeds are still
synced, so one can still be opened with nowhere to mute it. That gap predates this branch.

iOS shipped the same scope in code-payments/code-ios-app#806 — same durations, same wording, same
deadline readout, by a different route through its own shared row.

@bmc08gt bmc08gt self-assigned this Sep 19, 2026
@github-actions github-actions Bot added type: feature New functionality area: network gRPC, connectivity, API, exchange rates area: notifications Push notifications, in-app messaging and removed type: feature New functionality labels Sep 19, 2026
flipcash2 added per-viewer mute state, but chat_metadata had no column
for it, so EventStreamDelegate acknowledged ViewerStateChanged and
dropped it.

Adds three columns behind database version 35 (auto-migrated):
mute_until_epoch_ms, mute_forever, and viewer_state_version. A timed
mute is stored as its deadline rather than as a muted flag, because it
lapses with no server signal — only the deadline compared against the
clock on read stays correct. MuteState.isActiveAt is that comparison,
and is how anything should ask whether a chat is muted.

Writes go through updateViewerStateIfNewer, gated on a strictly higher
version. Stream delivery is unordered, so a mute and the unmute after
it can land either way round and the version is what decides. The gate
also makes the round trip a no-op: toMetadata carries the stored
version, so metadata rebuilt from a row and pushed back through upsert
cannot re-apply what is already there.

Leaving a chat clears the mute and resets the version to zero, both on
a successful leave and on a self MemberLeft from another device. The
server drops the mute when you leave and sends nothing to say so, so a
retained one would be waiting on the other side of a rejoin; resetting
the version means the rejoin's first viewer state still applies.
The mute RPCs were wired and the viewer state stored, but nothing asked for
either. This adds the entry point, beside Leave Chat on the group profile.

Muting takes a shape, so the row opens a sheet: 1 Hour, 8 Hours, 1 Week,
Always. Nothing else, because the contract admits exactly two mute shapes —
until a deadline, or forever — and every option here is one of the two.
Unmuting is its own RPC rather than a zero-length mute, so it is its own row,
shown in the mute row's place while a mute is actually in force.

The set and the wording match what iOS shipped, taken from WhatsApp's sheet
plus Telegram's one-hour option. Options name the state they produce ("1 Week")
rather than the act, so the muted row reuses the same words in its readout:
"Muted until 5:56 PM" — a time today, a weekday this week, a date beyond that.
Without the deadline a timed mute reads exactly like a permanent one.

Whether the chat is muted is asked per composition rather than stored:
a timed mute lapses with nothing sent to say so, and rememberIsMuted waits out
the remaining time so the row flips back to Mute while the screen is open. The
sheet mints its deadline on the tap, not on open, so a sheet left sitting still
means what it says.

The mute call is not optimistic, unlike the leave. The stored viewer state is
versioned and the version is the server's to mint; writing a guess would either
lose to the echo of the call itself or shut a genuinely newer update out. The
response goes through the same version gate the stream writes go through, so
either arrival order lands on the same row.
The server sets `muted` on a chat push and sends the push anyway, deliberately,
so the client can still store the message. Nothing read the flag, so a muted
chat notified exactly as an audible one did.

`planPushHandling` now drops `PostNotification` when the payload says the chat
was muted at send time, and leaves the sync half untouched: the message is
still applied or fetched and the feed still refreshes. Everything user-visible
hangs off that one action, so the reply and mark-as-read actions, the
conversation styling and the group summary go with it — a suppressed push
leaves no summary behind to resurface through.

The flag is read off the payload rather than off local viewer state. A timed
mute lapses with nothing sent to say so, so the two can disagree; what the
server decided when it sent is the answer that matters.

Unread counts are unaffected: `unreadCount` is computed from stored metadata in
`FeedProjection`, not from anything posted.
Mute was reachable only from a group's profile, though nothing about it is
group-shaped: the server holds viewer state on every chat, and a tip DM is the
other conversation a user gets pushed about.

The row is the same row on both, so it is one generic definition rather than two
that can drift; the profiles differ only in what they do with the tap. For the
same reason viewer state moves off ChatSubject.Group onto ChatViewModel.State —
it describes the chat, not who the chat is with — and the observation feeding it
drops the GROUP filter. The DM profile now names its own actions so one MenuList
can carry both a block, which is the screen's own business, and a mute, which is
the conversation's.

Tip DMs only: ChatStep.Profile is also how a group member's profile opens, and a
mute row there would silence the whole group from a screen naming one person.
Contact DMs have no profile screen. iOS ships the same limit.
Muting had no trace outside the profile that set it. A muted chat looked
like any other in the list and in its own transcript, so the only way to
tell why it had gone quiet was to open the profile and read the row.

MutedIndicator draws a bell at caption size in the secondary text colour,
on the chats list row and in the transcript's title bar. It takes the
viewer state rather than a muted boolean: a timed mute lapses with nothing
sent to say so, so a caller that resolved the answer when it built the row
would hand over a value that goes stale in place. rememberIsMuted moves to
chat-ui from messenger so both surfaces share the one implementation that
waits the deadline out.

An audible chat emits no layout node at all, so neither Row spends its
spacing on it and the list row keeps its full preview width.

The bell carries its own contentDescription and needs no help from its
container: a Compose merging node concatenates a child's description into
its own rather than dropping it, so the row reads its name, its timestamp
and then "Muted". MutedIndicatorTest asserts that through a clickable Row
rather than taking it on trust.
@github-actions github-actions Bot added the type: feature New functionality label Sep 19, 2026
The profile row toggled between "Mute Notifications" and "Unmute
Notifications", so the words under the finger became the opposite of what
had just been tapped — which reads as a missed tap — and the deadline it
drew alongside left the label nothing to work with.

The row now reads the same either way and always opens the picker. The
picker gains a "Never" row while there is a mute to clear, and what the
mute currently is moves to an amber chip under the profile title, where
it sits apart from the chat's own facts and can say "Muted until Sunday
2:04 AM" in full. The chip holds its line height while absent so the rows
below don't jump when a timed mute lapses.

The picker's rows become cards with icons — a clock for the ones that
name a length, a bell-slash for "Always", a bell for "Never" — matching
the invite sheet next door and iOS' sheet of the same name
(code-ios-app#806). ChoiceRow gains a Painter overload so a Material
vector can be passed where there is no drawable id.

"Never" is pinned on the way out: viewer state converges before the sheet
has finished animating away, so read live the list would lose its first
row while the user is still watching it.
@github-actions github-actions Bot added the area: ui Compose UI, theme, components, resources label Sep 19, 2026
Block sat first, so the routine reversible setting was under the one that
ends the conversation. Mute now leads and block closes the list, which is
also the order the group's profile already puts leaving in.
At 20.dp the bell read larger than iOS' in both places it appears — over
the 14sp row subtitle it trails and the 16sp title bar name it follows.
15.dp sits with that text instead of above it.

Matching iOS by box size alone would not have closed the gap: a Material
glyph fills its box, where the SF Symbol on the other side carries its own
padding.
Both profile screens ran their header straight into the first menu row:
MenuList emits its header slot and its items back to back with nothing
between them, and neither header supplied a bottom padding, so the mute
chip sat against Mute Notifications and the screen read as one block.

Pad the header 40dp at the bottom, which is what iOS spends here — its
16pt stack spacing plus the row block's 24pt top inset. The row's own
25dp inset already matches iOS's.
10dp put the name as close to the picture as the handle and join date are
to each other, so the four read as one undifferentiated stack. 15dp
separates the picture from the identity block that follows it, and is the
nearest step on the 5-grid to iOS' 16pt.
Matches iOS, and the 103 of 109 `subtitle_` strings that already end
without one.
The indicator in the chats list and the transcript title bar drew
Material's outlined NotificationsOff, where iOS' MuteBell draws
bell.slash.fill. Switched to the filled glyph so the two agree.

The mute row and the profile chip keep the outline, matching iOS'
ChatMuteRow and ChatMuteStatusLabel.
planPushHandling is handed an already-decoded NotificationPayload, so
its suppression tests say nothing about how muted gets there. A mapping
that dropped push.v1.ChatMetadata.muted would leave every one of them
green and every muted chat audible.

Covers asPayload: set, clear, absent, and a payload with no chat
metadata. The absent case pins the behaviour the rollout depends on —
the field is a bare bool, so a server that does not set it yet reads as
not muted and leaves notifications alone.
@bmc08gt
bmc08gt merged commit 2e067ed into code/cash Sep 20, 2026
3 checks passed
bmc08gt added a commit that referenced this pull request Sep 20, 2026
Renumbers the chat_draft migration. #1506 landed the chat_metadata viewer
state as 34 -> 35 while this branch was open, so chat_draft becomes
35 -> 36 and its exported schema moves to 36.json; 35.json is now that
PR's, unchanged. Two AutoMigrations claiming the same version is not a
merge conflict Room would have caught at runtime -- it would have taken
whichever entry it read first and dropped the other table's DDL.

Also adds draftStore to the RealChatCoordinator built by that PR's
ViewerStateStreamTest.
@bmc08gt
bmc08gt deleted the feat/chat-mute branch September 23, 2026 16:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: network gRPC, connectivity, API, exchange rates area: notifications Push notifications, in-app messaging 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