Repository navigation
feat(chat): mute a chat - #1506
Merged
Merged
Conversation
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.
bmc08gt
force-pushed
the
feat/chat-mute
branch
from
September 19, 2026 21:43
b6d2635 to
ef8859e
Compare
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.
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
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.
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.
The service layer has had
muteChat/unmuteChatand theMuteState/ViewerStatemodels since thecontract 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;
isMutedAtandrememberIsMutedanswer against now on every read, and thecomposable waits the remaining time out and re-answers itself. That is why every consumer here takes
ViewerStaterather than a boolean.Viewer state converges by version. Stream delivery is not ordered, so
updateViewerStateIfNewerdrops an update carrying a lower
versionthan the row already holds. Without it a late-arrivingolder frame un-mutes a chat the user just muted.
What's in each commit
ChatMetadataEntity, database v35 with anAutoMigration and an exported
35.json, the version-gated DAO write, and both stream delegatesfeeding it.
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.
MuteChatItem<T>isgeneric over the action because the two screens name their own; muting is one definition rather
than two that can drift.
An audible chat emits no layout node, so neither row spends spacing on it.
Push suppression, and what it still needs
planPushHandlingomitsPostNotificationwhen the payload says the chat is muted, which drops thealert, 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
onMessageReceivedis already the data-message entry point in everyapp state.
Scope
Tip DMs and groups only.
ChatStep.Profilealso opens a group member's profile, where a mute rowwould 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
AppRouterroutes nothing for their pushes, but their feeds are stillsynced, 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.