Report or block from a story

Android half of ios#56, spec §6.25. Needs the relay endpoint from
flamenet-relay!24.

🔴 A report is the one thing this app sends that the server can read.
Everything else is sealed, and the relay cannot open a story, so a report
about one is only actionable if the reporter hands over what they saw.
The dialog states that before anything is sent: what leaves the device,
who can read it, and that it cannot be undone. Declining to include a
copy is offered, and says plainly there may then be nothing to act on.

⚠️ `reportStory` sends only what it is passed. It does not reach into the
thread for surrounding context — §6.25 forbids including anything the
reporter did not choose to include.

⚠️ Blocking and reporting sit side by side and are never merged. Blocking
is this person's own decision and takes effect now; a report is a request
to somebody else. The copy says which is which, because a combined
control would make one of the two invisible.

The reasons are enumerated to match the relay, and a test asserts the two
lists are equal — a reason the relay rejects is a report somebody
believed they had filed. CSAM is its own reason rather than folded into
abuse: it carries a different obligation, and an operator cannot triage
what nobody can name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 17:55:29 -04:00
co-authored by Claude Opus 4.8
parent ae7c234bf2
commit 6fe463d578
6 changed files with 298 additions and 195 deletions
@@ -339,6 +339,33 @@ class RelayClient(
* inside the ratchet; putting it anywhere the relay can read defeats the
* point of encrypting the blob.
*/
/**
* `POST /e2e/moderation/report` — file a report (§6.25).
*
* 🔴 **The one call that deliberately sends readable content to the
* server.** Everything else this client sends is sealed. A relay that
* cannot open a reported story cannot act on an accusation about it, so
* the reporter's own copy travels with the report — and the caller is
* required to have obtained consent naming that, before reaching here.
*/
fun submitReport(
about: Long,
kind: String,
reason: String,
content: String,
note: String,
) {
val body = JSONObject()
.put("about", about)
.put("kind", kind)
.put("reason", reason)
.put("content", content)
.put("note", note)
.toString()
request("POST", url("/e2e/moderation/report"), "/e2e/moderation/report",
body.toRequestBody(JSON)).use { it.close() }
}
fun uploadAttachment(blob: ByteArray): String =
request("POST", url("/e2e/attachments"), "/e2e/attachments", blob.toRequestBody(OCTETS))
.use { json(it).getString("id") }