Skip to content

feat(chat): swipe a chat list row to open the mute sheet - #1556

Merged
bmc08gt merged 5 commits into
code/cashfrom
feat/chat-list-swipe-mute
Sep 24, 2026
Merged

bmc08gt merged 5 commits into
code/cashfrom
feat/chat-list-swipe-mute

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator

A trailing swipe on a chat list row opens the mute sheet the DM and group profiles already open. It never mutes directly: a mute needs a duration, and unmuting stays the sheet's "Never" row. This matches the iOS change, where the swipe also opens the sheet rather than toggling. The icon is a crossed-out bell on an audible chat and a plain bell on a muted one, on the amber-at-15% treatment of the profile's unverified badge rather than the delete red. There are no analytics, since neither platform has mute events yet.

Why a new sheet route

The sheet was reached only through ChatStep.MuteChat. That step carries no arguments and reads the chat id and viewer state from the chat flow's ChatViewModel, which the chat list doesn't have. Opening it from the list meant either pulling the chat flow into :features:tipping or giving the sheet its own way to know which chat it's for.

The sheet is now a top-level route, AppRoute.Messaging.MuteChat(chatId), opened the way Report is. MuteChatScreen(chatId) hosts the unchanged MuteChatSheet, backed by a MuteChatViewModel that observes that chat's viewer state and calls ChatCoordinator.mute/unmute.

Both profile screens move to the new route, and ChatStep.MuteChat, its flow entry, and ChatViewModel's MuteChat/UnmuteChat events are removed. Keeping the step would have left two paths into one sheet, each with its own mute call and error handling. With one route, MuteChatSheet keeps a single caller.

The sheet closes on the tap, and its view model is scoped to the sheet's entry, so the mute request runs NonCancellable. Otherwise closing the sheet would cancel the call whose result is written to the store and shown as the row's muted indicator.

Other changes this needed

  • NavBillOverlayEntryDecorator wrapped every root entry, sheets included, in a fillMaxSize box, which stretched a WrapContentSheet to full height. The old flow step ran under a navigator without this decorator, so nothing hit it until now. The box no longer fills for wrap-content sheets, which also applies to the invite-contact sheet.
  • SwipeActionRow, shared with the user profile and region selection:
    • A resetOnDismiss row now animates back to closed after its action. Before, the rejected dismiss settled on Revealed and the row stayed open under the sheet.
    • A second full swipe on the same row never fired, because the trigger flag was cleared only when an effect finished that the drag's own settle could interrupt. The flag is now re-armed when a drag starts.
    • A new, optional SwipeRevealGroup keeps one row open at a time. The chat list uses it; the other callers don't pass one and behave as before.
  • The chat list items are keyed by chat id, so swipe state stays with its chat when activity reorders the list. The action is also exposed as an accessibility custom action.
  • The muted bell moves from the end of the preview line to right after the chat name. The name is one line and ellipsizes before it pushes the bell or timestamp off the row.

A trailing swipe on a chat list row now opens the same mute sheet the DM
and group profiles open. It never mutes on its own, because a mute needs
a duration, and unmuting stays the sheet's "Never" row. The icon is a
crossed-out bell on an audible chat and a plain bell on a muted one.

The sheet was the ChatStep.MuteChat flow step. The step carried nothing
and read the chat id and viewer state off the chat flow's ChatViewModel,
which the chat list does not have. It is now a top-level
AppRoute.Messaging.MuteChat(chatId) sheet, opened the way Report is,
backed by a MuteChatViewModel that observes that chat's viewer state and
calls ChatCoordinator.mute/unmute. Both profiles push the new route, and
the step, its flow entry and ChatViewModel's MuteChat/UnmuteChat events
are removed, so MuteChatSheet still has one caller.

The sheet closes on the tap and its view model is scoped to the sheet's
entry, so the request runs NonCancellable. Otherwise closing the sheet
would cancel the call whose answer is written to the store and shown as
the row's muted indicator.

The list items are keyed by chat id, so a row's swipe state stays with
its chat when new activity reorders the list. The row also exposes the
action as an accessibility custom action, since a screen reader user
cannot swipe.
NavBillOverlayEntryDecorator wraps every root entry in a fillMaxSize Box
to draw the bill overlay. Sheets get the Box too, so a sheet marked
WrapContentSheet was stretched to the full expanded height. The mute
sheet showed it once it moved to a root route; the flow-level step it
replaced ran under a navigator without this decorator.

The Box now fills only when the entry is not a wrap-content sheet.
A full swipe on a resetOnDismiss action is rejected, and the drag then
settles on the nearest allowed anchor, Revealed, so the row stayed open
under whatever the action opened. The row now animates back to Settled
after the callback.

The flag that fires the callback was cleared when that effect finished,
but the drag's own settle can interrupt it, leaving the flag set. The
next full swipe then wrote the same value, nothing relaunched, and the
action never fired again. The flag is now re-armed when a drag starts,
which still fires once per gesture: the settle confirms Dismissed on
every frame it heads there.

SwipeRevealGroup is new and optional: rows given the same group keep at
most one open, closing the other as soon as a drag starts on another.
The mute action uses the profile's unverified badge treatment, warning at
15% behind a warning-tinted icon. The list shares a SwipeRevealGroup so
swiping one row closes another left open.

The muted bell moves from the end of the preview line to right after the
chat name. The name takes one line and only the width it needs, so a
long name ellipsizes before it pushes the bell or the timestamp off.
@bmc08gt bmc08gt self-assigned this Sep 24, 2026
@github-actions github-actions Bot added type: feature New functionality area: ui Compose UI, theme, components, resources and removed type: feature New functionality labels Sep 24, 2026
The chip's label went null the moment the mute cleared, while the chip
was still fading out, so the text vanished and a bare bell faded on its
own. The chip now keeps its last label through the exit.

rememberIsMuted also held the previous answer for a frame after an
unmute: produceState re-answers after composition, so the unmute drew
one frame of "muted" with no deadline. It now reads the state in the
same pass and only waits on the lapse of a timed mute.
@github-actions github-actions Bot added the type: feature New functionality label Sep 24, 2026
@bmc08gt
bmc08gt merged commit 18bcb11 into code/cash Sep 24, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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