Report a story to whoever runs the server (§6.25)

The reporting path, taken by hand — the original commit also rewrote 318
lines of ChatScreens against a story feed that no longer looks like that.
The UI control comes next; this is the part underneath it.

🔴 **A report is the one thing in this app that leaves readable.** The
relay cannot open a sealed story, so a report about one is only
actionable if the reporter hands over what they saw. That is the point
and the cost, and it means the consent has to name the consequence —
`reportStory` does not ask, and must never be reached from a control
that does not.

⚠️ It sends only what it is passed, and never reaches into the thread
for surrounding context. §6.25 forbids including anything the reporter
did not choose to include.

⚠️ The reasons are enumerated to match the relay. A reason the relay
rejects is a report somebody believed they had filed.

The message afterwards says what will and will not happen: whoever runs
the server sees it, and you are not told what they do. Promising an
outcome we cannot observe would be worse than saying nothing.
This commit is contained in:
Paul Hitt
2026-09-03 19:20:37 -04:00
parent 32e2f6b5df
commit a0bc8e68d2
4 changed files with 154 additions and 0 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") }