Skip to content

feat(reporting): let people report a user, a group, or a message - #1522

Merged
bmc08gt merged 14 commits into
code/cashfrom
feat/reporting-surfaces
Sep 22, 2026
Merged

bmc08gt merged 14 commits into
code/cashfrom
feat/reporting-surfaces

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Builds on #1519, which merged the shared ReportReason vocabulary. This adds the three surfaces that use it.

What lands

  • User profile — a Report row above Block.
  • Group profile — a Report row above Leave Chat.
  • Message selection bar — a Report case on MessageCapability, for anything another participant sent.

All three open AppRoute.Messaging.Report: a top-level flow route with two steps, pick a reason and — for Something Else only — describe it. The reason list is a radio group carrying a description per reason, and its button reads "Next" for Something Else and "Submit Report" for the rest, so the second step is only ever reached when it has something to collect. ReportDescription.build from :libs:reporting does the formatting, so Android and iOS send byte-identical descriptions.

Why it is a route and not a sheet

It started as a wrap-content sheet inside the chat flow, and picking Something Else had to grow that sheet to fit a text field. That ANR'd the app. A wrap-content sheet takes its drag anchors from the content's reported height and then reads them again while placing that content, so changing the content's size while the sheet is open spins the main thread. SizeTransform and BoxWithConstraints were both tried and neither helps; the rule worth carrying forward is that nothing inside a wrap-content sheet may be sized from a measurement or animate its height.

So report moved out of the chat flow into its own route with its own ReportViewModel, which also gets its state out of ChatViewModel. Height now comes from the container, and the details step has room without anything resizing.

FullscreenSheet is the only part of this that touches shared navigation. Sheets rest at 92.5%, and the 7.5% left showing is what reads as "this is a layer over something". A route you work through rather than glance at loses room to that sliver without gaining the signal, so this one takes the full height, keeps the display's own square top corners, and insets its content past the status bar.

The capability rule

Anything a participant sent can be reported; anything the server wrote, or that no longer exists, cannot. Two consequences worth reviewing:

  • Your own messages are excluded. Reporting one is not a thing anyone does, and a row that is always present is a row people stop reading.
  • Report has no time window, unlike edit and delete. Those windows exist because the server enforces them; nothing in the contract limits how old a reportable message may be.

Report is ordered last in the selection bar, so on a narrow screen it is the first to collapse into the overflow rather than pushing out Reply or Delete.

Confirming the report

Submitting used to close the flow in the same frame it called submit, which left the confirmation nowhere to land: a success prompt appeared only once the sheet was gone, on whatever screen was underneath, and a failure said nothing at all because the flow — and the half-filled report — had already been dismissed.

The flow now stays up until the report is acknowledged, and the Submit button carries the send. ReportViewModel exposes LoadingSuccessState: the button spins while the report is in flight, then holds its checkmark for SuccessHoldDuration — the same beat the username-claim, name-entry and photo-selection screens wait — before the confirmation comes up. The checkmark is drawn on the frame after the state is set, so raising the prompt immediately would put a scrim over one nobody had seen.

Dismissing that prompt is what exits the flow. The success state is held rather than reset while the prompt is up, so the button reads as done instead of offering to file the same report again; CodeButton disables itself while isSuccess, and the double-submit guard reads !isIdle to cover the press after success as well as the one mid-flight. A failure goes back to idle and raises an error prompt, leaving the flow where it is with the reason still picked, so the retry is one press rather than the whole flow again.

For the prompt to be seen at all, NavMessagingEntryDecorator had to stop refusing to host bars for every sheet entry. A sheet that covers the screen now hosts its own bars, exactly as a route does; a partial sheet still defers to the route beneath it, where a bar would be clipped to the sheet or hidden behind it. The predicate is extracted as hostsBars and covered by BarHostingTest.

What running it on a phone turned up

Six fixes, one commit each. None of these were visible from the code; all of them were obvious within a minute of using the flow on a 400dp device.

  • The button showed neither spinner nor checkmark. CodeButton takes its content colour and its clickability from enabled, not from isLoading or isSuccess. Left enabled, a Filled button keeps its white background, while the spinner draws in the content colour and the checkmark in a hardcoded white — both white on white — and the button went on accepting presses right through the send. Both steps now pair enabled with progress.isIdle, which is what every other confirm button in the app does.
  • The spinner was still invisible, because the send beats a frame. A report is a single unary call, so the button crossfaded from its label straight to a checkmark and the send read as never having happened. MinimumLoadingDuration puts a floor under the spinner rather than a delay after it: it starts with the spinner and runs alongside the send, so a send that already outlasted it is not held back. Result.onSuccessWithDelay looks like the helper for this, but it starts its clock after the work returns, so it always waits the full duration — latency on a slow call rather than a floor under a fast one.
  • The sheet disappeared in a frame instead of animating down, on every exit. Report's onExit popped the entry outright. It now hands sheetNavigator.pendingSheetDismiss an empty lambda: the scene animates the sheet out and pops the entry itself once that finishes, so onExit must not also pop, or the flow leaves with one entry more than it opened.
  • Reporting from the selection bar left the message highlighted. Every other action on that bar dispatches an event whose reducer clears the selection; report only pushed a route, and because it leaves the chat entirely, nothing on the way back cleared it either. Added Event.ReportRequested.
  • On a 400dp phone, copy and report collapsed into the overflow with most of the bar empty. The inline budget is a fraction of the slot the actions lay out in, and that slot is the bar less the app bar's 5dp either side. 0.35 of 390dp is 136.5dp against the 140dp that three 40dp buttons and two 10dp gaps need, so capacity came out 2 — one icon plus the menu. Raised to 0.5, which clears three buttons down to a 320dp screen, and pulled out as inlineActionCapacity so widths can be checked without laying a bar out.
  • The conversation title washed a ripple across the whole bar. Its click target is the entire title row, and unboundedClickable centres an unbounded ripple on it. Swapped to noRippleClickable, which gained an optional role (defaulting to null, so no existing caller changes) so dropping the indication did not also drop Role.Button.

Each of these has a test that was confirmed failing before its fix.

Notes

  • FlowNavigator.back() already exits the flow when it is called at the root — its onRootReached fires before it returns false. The KDoc said the host "should" exit, which invites if (!back()) exitCanceled(), and that exits twice. A host that pops on exit then pops one entry more than it opened, which is how leaving the first report step used to land you on the chat list. Only the doc changed; no other caller used that idiom.
  • Leaving the first step is a close on the trailing edge, not a back arrow. AppBarWithTitle already places a dismiss there when the ambient FlowDismissStyle is Close, but FlowHost cannot work that out here: it decides from the outer stack depth, and report is pushed over the chat rather than opened as the sheet's first entry. The step declares the style itself, in the screen rather than the flow host, so it holds wherever the step is rendered — including under test.
  • A testing trap found on the way: action_back is present in the semantics tree of every centre-titled app bar, because TopAppBarBase composes a phantom UpNavigation to reserve width, plus a second copy of the real leading icon as a measurement probe. Only one copy is ever placed, so ReportFlowTest matches on layoutInfo.isPlaced rather than on the tag alone. Asserting a tag's absence would otherwise be meaningless.
  • ReportFlowTest reaches the details field by its set-text action rather than a test tag: this TextInput merges its semantics into a wrapper and does not expose the field's own modifier.
  • The two selection-bar tests run at a wider qualifier than the rest of the file, so the question they ask — is Report offered at all — does not depend on how many icons happen to fit.

@bmc08gt bmc08gt self-assigned this Sep 21, 2026
@github-actions github-actions Bot added the type: feature New functionality label Sep 21, 2026
Three entry points, one sheet. A report row sits on the user profile above
Block and on the group profile above Leave Chat, and Report joins the message
selection bar for anything another participant sent. Each opens ReportSheet,
which lists the six shared reasons and, for Something Else, asks for a
description before it will submit.

The capability rule is that anything a participant sent can be reported and
anything the server wrote, or that no longer exists, cannot. Your own messages
are excluded: reporting one is not a thing anyone does, and a row that is
always present is a row people stop reading. Report carries no time window,
unlike edit and delete, because nothing in the contract limits how old a
reportable message may be.

Report is ordered last in the selection bar, so it is the first to collapse
into the overflow when the bar runs out of room.
@bmc08gt
bmc08gt force-pushed the feat/reporting-surfaces branch from 8ec376c to 065fa1b Compare September 21, 2026 22:06
…write

Tapping "Something Else" hung the app. The reason list and the details field
were swapped with an AnimatedContent, and this sheet sizes itself to its
content: it writes the height it measured into its own state, and that write
recomputes the drag detents that are read again while the content is placed.
Animating the height therefore re-enters measurement on every pass, and the
loop runs off the layout pass rather than the frame clock, so it starves the
clock that would have finished the animation. The main thread spun at 100%
until Android offered to close the app; the ANR trace names the cycle
(updateContentHeight -> invalidateDetents -> updateAnchors). Swapping on a
plain `if` changes the measured height once, and the sheet animates its own
offset between detents, so the two steps still read as one surface resizing.

On top of that, the details step now earns the trip:

- A leading back arrow returns to the reason list and keeps what was typed.
  The system back gesture unwinds the same steps in the same order.
- The field opens three lines tall, grows with what is typed, and scrolls
  past eight.
- An expand control, always visible, takes it to the height of the sheet.
  That height is a fraction of the screen rather than the space left over
  after layout, because measuring for it is what deadlocked above.
- Content padding, so the text no longer touches the border.

Six tests cover the step machinery: the arrow appears only in the second
step, returning preserves the draft, and back collapses an expanded field
before it leaves the step.
Sheets rest at 92.5% of the container, and the 7.5% left showing is what tells
you a sheet is a layer over something. A route people work through rather than
glance at gains nothing from that sliver and loses the room it costs.

FullscreenSheet takes the height back. The sheet rests at full container
height, keeps the display's own square top corners instead of carving two
notches out of them, and insets its content past the status bar the way a
full-screen route's app bar does. Declare it alongside Sheet, as with
HalfSheet.
back() pops the current step, and at the root it calls navigateBack() on a
navigator whose onRootReached exits the flow. By the time it returns false the
host has already been told.

The KDoc said the flow "should be exited by the host", which reads as a
request and invites `if (!back()) exitCanceled()`. That exits twice, costing a
host that pops on exit one entry more than it opened.
Report was a wrap-content sheet inside the chat flow, so its state lived in
ChatViewModel and its height came from whatever the content measured. Picking
"Something Else" then had to grow that sheet to fit a text field, and resizing
a wrap-content sheet spins the main thread: it takes its drag anchors from the
content's reported height, then reads them again while placing that content.
It ANR'd.

Report is now AppRoute.Messaging.Report, a top-level FlowRoute presented as a
FullscreenSheet with its own ReportViewModel, stepped as ReasonSelection then
Details. Height comes from the container, so the details step has room without
anything resizing.

The reason list is a radio group carrying a description per reason. Its button
reads "Next" for Something Else and "Submit Report" for the rest, so the
details step is only reached when it has something to collect. Leaving the
first step is a close rather than a back arrow, and it lands you on the profile
or chat you opened it from.
@github-actions github-actions Bot added the area: ui Compose UI, theme, components, resources label Sep 22, 2026
The first step's dismiss was forced into the leading slot with leadingDismiss,
which exists for a screen that is a modal dismiss rather than a flow root —
currency-info is its one other caller. Report's first step is a flow root, and
the app puts that dismiss on the trailing edge.

Declaring the step's own FlowDismissStyle.Close is what AppBarWithTitle already
reads to place it there. FlowHost cannot work it out here: it decides from the
outer stack depth, and report is pushed over the chat rather than opened as the
sheet's first entry. The declaration sits in the screen rather than the flow
host so that it holds wherever the step is rendered, including under test.

The new case pins the edge, which the existing ones could not: they assert
which glyph shows, and both glyphs sit in the same slot.
Submitting closed the flow in the same frame it called `submit`, so the
confirmation had nowhere to land. Two things followed from that: a success
prompt raised from a sheet only appeared once the sheet was gone, on whatever
screen happened to be underneath, and a failure said nothing at all — the flow
was already dismissed, taking the half-filled report with it.

The flow now stays up until the report is acknowledged. `ReportViewModel`
exposes `confirmed`, emitted when the success prompt is dismissed, and each step
exits on it; a failure raises an error prompt and leaves the flow where it is,
with the reason still picked, so the retry is one press. `submitting` drives the
button's loading state for the wait that closing used to hide, and guards
against a second press filing the same report twice.

`showSuccess` replaces `showMessage` so the prompt carries an OK to dismiss —
without one there is nothing to wait for.

For the prompt to be seen at all, `NavMessagingEntryDecorator` had to stop
refusing to host bars for every sheet entry. A sheet that covers the screen now
hosts its own, as a route does; a partial sheet still defers to the route
beneath it, where a bar would otherwise be clipped to the sheet or hidden behind
it. That predicate is extracted as `hostsBars` and covered by `BarHostingTest`.
The button only showed the wait; the checkmark every other confirm button in
the app draws was skipped, and the confirmation came up the instant the send
returned.

`ReportViewModel` now exposes `LoadingSuccessState` rather than a bare
`submitting` flag, and holds `success` for `SuccessHoldDuration` before raising
the confirmation — the same beat the username-claim, name-entry and
photo-selection screens wait. The checkmark is drawn on the frame after the
state is set, so raising the prompt immediately would put a scrim over one
nobody had seen.

The success state is held rather than reset once the prompt is up. The flow is
still on screen behind it, and `CodeButton` disables itself while `isSuccess`,
so the button reads as done instead of offering to file the same report again.
A failure goes back to idle, because the retry needs a pressable button; the
double-submit guard now reads `!isIdle`, which covers the press after success
as well as the one mid-flight.
CodeButton takes its content colour, and its clickability, from `enabled`
rather than from `isLoading`/`isSuccess`. Left enabled, a Filled button keeps
its white background: the spinner is drawn in the content colour and the
checkmark in a hardcoded white, so both came out white on white and the button
read as blank for the whole send. It also went on taking presses.

Pair `enabled` with `progress.isIdle` on both steps, which is what every other
confirm button in the app does.
Every other action on the bar dispatches an event whose reducer drops the
selection. Report only pushed a route, and because it leaves the chat for a
flow of its own, nothing on the way back dismissed the bar either — the
reported message was still sitting highlighted when the flow closed over it.

Give it the event the others have. It carries no subject: the bar's caller has
already captured the message to build the route with, so all this does is
dismiss.
On a 400dp phone another participant's message put copy and report in the
overflow while most of the bar stood empty. The budget is a fraction of the
slot the actions lay out in, and that slot is the bar less the app bar's 5dp
either side — so the sum was 0.35 of 390dp, or 136.5dp, against the 140dp that
three 40dp buttons and their two 10dp gaps need. Three and a half short.

Raise the fraction to 0.5, which clears three buttons down to a 320dp screen.
It is a ceiling rather than a width: the row is laid out to its contents, so a
bar leaving the budget unspent is no wider for it.

Pull the arithmetic out as `inlineActionCapacity` so the widths that decide it
can be tested without laying a bar out. `MessageActionCapacityTest` pins the
device widths; `ChatSelectionBarTest` pins the rendering at 400dp, which the
class already defaults to.
The click target is the whole title row, and `unboundedClickable` centres an
unbounded ripple on it — so every tap washed a circle across the width of the
bar on the way to the profile.

`noRippleClickable` had no `role`, and swapping to it as it stood would have
taken `Role.Button` off the row along with the indication. Give it an optional
one, defaulting to null so no existing caller's semantics change, and pass
`Role.Button` here.
A report is one unary call and usually answers inside a frame. The button
crossfaded from its label straight to a checkmark, so the send read as
never having happened.

MinimumLoadingDuration puts a floor under the spinner, starting when the
spinner does and running alongside the send, so a call that already
outlasted it is not held back. It pairs with SuccessHoldDuration at the
other end.

Result.onSuccessWithDelay looked like the helper for this, but it starts
its clock after the work has returned, so it always waits the full
duration — latency on a slow call rather than a floor under a fast one.
Leaving the flow popped the entry outright, so the sheet vanished in a
frame on every exit — close, back out of the root, and dismissing the
confirmation alike.

Hand the sheet navigator a dismiss instead. The scene animates the sheet
down and pops the entry once that finishes, so onExit must not pop as
well; that pair of pops is what used to land on the chat list.

FlowHost has an animated exit already but gates it on isSheetRoot, which
is backStack.size <= 1, and report opens over a chat. A sheet is no less
a sheet for having something underneath, so that gate is wrong for every
flow opened this way. Fixing it there changes exit behaviour for ten
flows, three of which pop in onExit and would then pop twice, so it is
left alone here.
@bmc08gt
bmc08gt merged commit b176a16 into code/cash Sep 22, 2026
3 checks passed
@bmc08gt
bmc08gt deleted the feat/reporting-surfaces 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: 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