Skip to content

feat(chat): report a message, a person, or a group - #821

Merged
bmc08gt merged 5 commits into
mainfrom
feat/reporting-surfaces
Sep 22, 2026
Merged

bmc08gt merged 5 commits into
mainfrom
feat/reporting-surfaces

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Three places to file a report, all opening the same two-step flow and sending through
FlipClient.report: a message's context menu, a person's profile, and a group's profile. The
group row sits outside the isMember gate, because leaving a group is not a reason to lose the
ability to report what happened in it.

Picking a reason is not the same as filing one

The first step is a radio group. Tapping a row selects it, and a second press on the button files.
That costs one tap, and it buys back a class of mistake that has no undo. The labels sit close
together, Harassment or Bullying directly above Sexual Content, and a report cannot be unsent.

Something Else is the only reason that needs a second screen, so the button reads Next there
and Submit Report everywhere else. The details step is only reached when it has something to
collect, and going back to the reasons keeps both what was typed and what was picked.

Each reason carries a line of scope under its label. Six words on their own leave people guessing
which bucket their situation belongs in, and a guess lands in the wrong one.

The bytes on the wire come from shared Kotlin

ReportDescription.build(reason:details:) assembles description: the reason's token on the first
line, the person's own words from the second line on, details trimmed, dropped entirely when blank,
and truncated at maxDetailsLength rather than refused. iOS never formats that string itself, so
the same choice produces the same bytes here and on Android. Three tests in ReportFlowTests read
the shared builder directly and fail if that stops being true, including the blank case, where the
result is the bare token with no trailing newline for a server-side reader to trip over.

Only the labels belong to this app. ReportReason ships displayOrder and nothing else, and the
presentation mapping is asserted total against it, so a seventh reason added to the shared enum
fails a test here rather than rendering as a blank row. That test exists because a Kotlin enum
bridges to Swift as a class, so the compiler cannot check a switch over it for exhaustiveness.

The local shared-core override

docs/shared-core-local-development.md has been documenting FLIPCASH_SHARED_CORE_LOCAL as
swapping the pinned flipcash-shared-core-spm for a path dependency into a code-android-app
checkout. This repository never had it. All three manifests that consume SharedCoreKit,
FlipcashCore, FlipcashUI and CrossPlatformVectors, carried an unconditional
.upToNextMinor(from: "0.7.0"), and they gain the branch together, because SPM resolves one
version for the whole graph and a half-converted graph fails with a message about conflicting
requirements that never names the cause.

Two things the doc did not prepare me for, both now true:

  • A path dependency takes its identity from the directory basename, so shared core resolves as
    spm locally and flipcash-shared-core-spm remotely. Product references have to switch with the
    dependency, not just the dependency itself.
  • The app target's own XCRemoteSwiftPackageReference had to go, or the two identities collide:
    multiple similar targets 'SharedCore', 'SharedCoreKit' appear in package 'spm' and 'flipcash-shared-core-spm'. It existed for one call, KikCode.svg in TipCardExport, which now
    goes through a CodeView.svg in FlipcashUI. The doc describes this removal as already done. It
    was not, and the collision only appears on the app scheme, which is why it sat unnoticed.

Do not add SharedCoreKit to an Xcode target's package dependencies. It re-creates the second
identity and breaks the override for everyone, and it presents as a package-resolution bug rather
than the project-file change it is. The app target reaches the vocabulary through a new
@_exported in FlipcashCore instead, mirroring how it already reaches Logging.

One correction to the doc worth making separately: switching the variable on or off leaves a stale
precompiled module, and the next app-scheme build fails with SharedCore.h has been modified since the module file was built. Wiping DerivedData for the project clears it. "Switching back needs no
clean" holds for resolution, not for the app scheme.

This does not build against the pinned 0.7.0

ReportReason and ReportDescription landed in code-payments/code-android-app#1519, but no tag
carries them yet. flipcash-shared-core-spm still tops out at 0.7.0, which has no report
symbols. Until 0.8.0 publishes, building this needs FLIPCASH_SHARED_CORE_LOCAL pointed at a
code-android-app checkout, and CI has no such checkout. Bumping the pin afterwards is one line in
each of the three manifests.

The pin deliberately stays at 0.7.0 rather than moving to an unpublished tag, so that 9f4766e
remains buildable on its own.

One open question

The success copy, Report Sent and Thanks. We'll take a look, is Android's placeholder carried
over word for word, so that one moderation answer fixes both platforms instead of two different
ones. Nobody has said what actually happens to a report once it is filed, and an OK button makes
that a firmer promise than a transient toast would. This needs someone who can speak for
moderation.

The orchestrator's `docs/shared-core-local-development.md` documents
`FLIPCASH_SHARED_CORE_LOCAL` as swapping the pinned `flipcash-shared-core-spm`
for a path dependency into a `code-android-app` checkout, so a `:kmp:shared-core`
change can be tried from an iOS build without publishing a version. It was never
implemented here: `git grep` found nothing, and all three manifests that consume
`SharedCoreKit` carried an unconditional `.upToNextMinor(from: "0.7.0")`.

All three gain the branch together — SPM resolves one version for the whole
graph, so a half-converted graph fails to resolve with a message about
conflicting requirements that does not name the cause. The shape follows
`FlipcashAPI/Package.swift`'s `FLIPCASH_PROTO_LOCAL` rather than inventing a
second one.

A path dependency takes its identity from the directory basename, so under the
override shared core resolves as `spm` rather than `flipcash-shared-core-spm`.
Product references switch with it, and the app target's own
`XCRemoteSwiftPackageReference` had to go: holding both identities in one graph
fails with `multiple similar targets 'SharedCore', 'SharedCoreKit' appear in
package 'spm' and 'flipcash-shared-core-spm'`. That reference existed for one
call, `KikCode.svg` in `TipCardExport`, which now goes through a `CodeView.svg`
declared in FlipcashUI beside the `KikCode.figure` wrapper it already had. The
orchestrator doc describes this removal as done; it was not.

Both branches build the `Flipcash` scheme for an iPhone 17 Pro simulator:
`ProcessXCFramework` runs against the local XCFramework with the variable set,
and `SharedCore` resolves from GitHub at `0.7.0` with it unset. The pin stays at
`0.7.0` — bumping it to an unpublished `0.8.0` here would make a standalone
infrastructure commit that nobody can build without the override.
`MessageCapability` gains `.report`, resolved by one rule held in step with
Android's `resolveCapabilities`: anything a participant sent can be reported;
anything that no longer exists, or that you sent, cannot. So another person's
text and their cash both carry it, your own messages and tombstones do not.

Reporting has no window. The edit and delete windows exist because the server
enforces them, and nothing in the contract caps how old a reportable message
may be — so `MessagePolicy.window(for:)` returns nil for it. That is load-
bearing rather than tidy: `nextExpiry` walks every resolved capability and
skips the ones with no window, so a windowed `.report` would arm a timer on
every incoming message in the transcript. A test pins it.

It is also not destructive. Nothing is destroyed and nothing is irreversible
from the reporter's side; the red row stays delete's.

The menu row is held back. `orderedActions` still stops at `.delete`, because
the reason sheet needs a `ReportReason` vocabulary that `SharedCoreKit` does
not ship until 0.8.0 — a tag that does not exist, and which the Android task
producing it has not started. Offering a row that opens nothing is worse than
offering none, so the plumbing around it lands now and the row follows with
the sheet: one line here and the `.report` arm of `handleMessageAction`, both
commented where a reader will look.

The exhaustive switches the new case broke are all resolved rather than
defaulted, which is what they are for. Two were not in the plan: a mutation
alert cannot arise from reporting, since it files straight from its own sheet,
and the keyboard comes down for it as it does for delete, because a sheet is
about to cover the transcript.

`MessageCapabilityTests` asserted exact set equality throughout, so every
assertion about another person's message moved. The cash test was only ever
covering my own; it now covers both directions.
@bmc08gt bmc08gt self-assigned this Sep 21, 2026
A row on a user's profile, a row on a group's, and the context menu action that
has been resolving but going nowhere since f98a2d5. All three open the same
two-step flow and file through `FlipClient.report`.

Picking a reason selects it; the button files it. Filing is not undoable from
here and the six labels sit close together, so a mis-tap is a plausible way to
report someone for the wrong thing. A second, deliberate press costs one tap and
removes that whole class of mistake. `Something Else` is the only reason that
cannot stand on its own, so it is the only one whose button reads `Next`.

Each reason carries a line of scope under it. Six words of label leave people
guessing which bucket their situation belongs in, and a guess lands in the wrong
one.

Going back from the details step keeps both what was typed and what was picked,
so a second thought about the reason does not cost the sentence already written.

The tokens and the `description` they assemble come from shared Kotlin, never
from here, so both platforms put the same bytes on the wire for the same choice.
Only the labels are this app's. Three tests fail if iOS starts formatting the
string itself.

The app target reaches the vocabulary through a new `@_exported` in FlipcashCore,
mirroring `Logging`. It must not depend on `SharedCoreKit` directly: that gives
the package a second identity and the build fails with `multiple similar targets
'SharedCore', 'SharedCoreKit'`.

Report survives leaving a group, so the `isMember` gate in `ChatProfileScreen`
now wraps invite, mute and leave rather than the whole block.

The success copy is Android's placeholder, carried over deliberately so that one
moderation answer fixes both platforms rather than two different ones.

This does not build against the pinned `0.7.0`, which has no report symbols.
Until `flipcash-shared-core-spm` publishes `0.8.0` it needs
`FLIPCASH_SHARED_CORE_LOCAL` pointed at an Android checkout.
The flow carried Compose's arrangement, where one leading slot holds either a
close or a back arrow. Every dismissal control in this app is trailing -- eight
of them, no exceptions, including the two sheets this flow is closest to.

Splitting them across the bar also stops the two controls sharing a slot, so
back stays leading where iOS expects it, and close no longer disappears on the
details step.
The close sat beside the back arrow on both steps, so a half-written report had
two exits and the one nearer the keyboard discarded it without asking. Past the
reasons the only way out is back through them.

One control per step, chosen by a switch, so a third step has to answer the
question rather than inherit an answer.
@bmc08gt bmc08gt changed the title feat(chat): report capability, and the shared-core override the docs assumed feat(chat): report a message, a person, or a group Sep 22, 2026
@bmc08gt
bmc08gt merged commit 9f35174 into main Sep 22, 2026
1 check failed
bmc08gt added a commit that referenced this pull request Sep 22, 2026
…#827)

main does not build from its committed pins. `AllTargets` fails with
`cannot find type 'ReportReason' in scope`, and testing is cancelled
before a single test runs.

#821 added the report flow against `SharedCoreKit.ReportReason` and
developed it through the `FLIPCASH_SHARED_CORE_LOCAL` path dependency
that the same PR introduced. `Package.resolved` was correctly restored
to the pinned 0.7.0 before committing, as that manifest's comment
instructs, but the shared-core release carrying the type was never cut,
so nothing published provided it.

shared-core 0.7.1 publishes it. `.upToNextMinor(from: "0.7.0")` already
admits 0.7.1, so only the resolved pin moves and the three manifests
consuming SharedCoreKit stay untouched.

The same file also still pinned flipcash2-client-protocol at 0.10.0.
`FlipcashAPI/Package.swift` requires the contract packages `exact:`, and
#824 moved that declaration to 0.11.0 without committing the resolution
it forces, so every build since has rewritten the file locally. Commit
that too rather than leave the checked-in state unbuildable as written.
@bmc08gt
bmc08gt deleted the feat/reporting-surfaces branch October 5, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant