Repository navigation
feat(reporting): let people report a user, a group, or a message - #1522
Merged
Merged
Conversation
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
force-pushed
the
feat/reporting-surfaces
branch
from
September 21, 2026 22:06
8ec376c to
065fa1b
Compare
…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.
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.
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.
Builds on #1519, which merged the shared
ReportReasonvocabulary. This adds the three surfaces that use it.What lands
Reportcase onMessageCapability, 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.buildfrom:libs:reportingdoes 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.
SizeTransformandBoxWithConstraintswere 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 ofChatViewModel. Height now comes from the container, and the details step has room without anything resizing.FullscreenSheetis 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:
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.
ReportViewModelexposesLoadingSuccessState: the button spins while the report is in flight, then holds its checkmark forSuccessHoldDuration— 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;
CodeButtondisables itself whileisSuccess, and the double-submit guard reads!isIdleto 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,
NavMessagingEntryDecoratorhad 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 ashostsBarsand covered byBarHostingTest.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.
CodeButtontakes its content colour and its clickability fromenabled, not fromisLoadingorisSuccess. 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 pairenabledwithprogress.isIdle, which is what every other confirm button in the app does.MinimumLoadingDurationputs 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.onSuccessWithDelaylooks 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.onExitpopped the entry outright. It now handssheetNavigator.pendingSheetDismissan empty lambda: the scene animates the sheet out and pops the entry itself once that finishes, soonExitmust not also pop, or the flow leaves with one entry more than it opened.Event.ReportRequested.inlineActionCapacityso widths can be checked without laying a bar out.unboundedClickablecentres an unbounded ripple on it. Swapped tonoRippleClickable, which gained an optionalrole(defaulting to null, so no existing caller changes) so dropping the indication did not also dropRole.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 — itsonRootReachedfires before it returnsfalse. The KDoc said the host "should" exit, which invitesif (!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.AppBarWithTitlealready places a dismiss there when the ambientFlowDismissStyleisClose, butFlowHostcannot 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.action_backis present in the semantics tree of every centre-titled app bar, becauseTopAppBarBasecomposes a phantomUpNavigationto reserve width, plus a second copy of the real leading icon as a measurement probe. Only one copy is ever placed, soReportFlowTestmatches onlayoutInfo.isPlacedrather than on the tag alone. Asserting a tag's absence would otherwise be meaningless.ReportFlowTestreaches the details field by its set-text action rather than a test tag: thisTextInputmerges its semantics into a wrapper and does not expose the field's own modifier.