From 6fe463d57873aff3c3e018e90093f0f55e2d2d8e Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Wed, 2 Sep 2026 10:50:43 -0400 Subject: [PATCH] Report or block from a story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PARITY.md | 23 ++ .../io/flamenet/messenger/app/ChatScreens.kt | 318 +++++++----------- .../messenger/app/MessengerViewModel.kt | 49 +++ .../io/flamenet/messenger/app/Session.kt | 17 + .../flamenet/messenger/app/StoryReportTest.kt | 59 ++++ .../kotlin/io/flamenet/client/RelayClient.kt | 27 ++ 6 files changed, 298 insertions(+), 195 deletions(-) create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/StoryReportTest.kt diff --git a/PARITY.md b/PARITY.md index aaa5538..6d810b7 100644 --- a/PARITY.md +++ b/PARITY.md @@ -742,3 +742,26 @@ refused — the story is legitimately theirs; only the claim about where it was posted is unverified. ⚠️ `exp` is the ordinary story lifetime on both. Group membership does not extend it. +## Report or block from a story (§6.25, ios#56) +Blocking existed in a conversation and nowhere near a story — the surface a +stranger is most likely to reach somebody through. Reporting did not exist at +all, on either platform or in the relay. +🔴 **A report is the one thing either 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. Both platforms +state that before sending, in those words: what leaves the device, who can read +it, and that it cannot be undone. Declining to include a copy is offered on +both, and says plainly there may then be nothing to act on. +⚠️ **Both send only what they were passed.** Neither reaches into the thread for +surrounding context — §6.25 forbids including anything the reporter did not +choose to include, and a helpful "we also attached the conversation" would be +exactly the wrong kind of help. +⚠️ **Blocking and reporting are offered together and never merged**, on both. +Blocking is the reporter's own decision and takes effect now; a report is a +request to somebody else and nothing may come of it. A combined control would +make one of the two invisible. +⚠️ **The reasons are enumerated to match the relay**, with a test on each +platform asserting the lists are equal — a reason the relay rejects is a report +somebody believed they had filed. CSAM is its own reason on both rather than +folded into abuse: it carries a different obligation, and an operator cannot +triage what nobody can name. diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 05b0ff2..9f2c2b1 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -2545,6 +2545,8 @@ fun ColumnScope.StoryStrip( /** One story, full width, with what it says about its own lifetime. */ @Composable private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Story) { + // ios#56 — reporting, and the consent that has to precede it. + var reporting by remember(story.id) { mutableStateOf(false) } val remaining = remember(story.id) { val seconds = story.expiresAt - System.currentTimeMillis() / 1000.0 when { @@ -2560,6 +2562,14 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor // §6.21 — a text story has no blob; the caption IS the story. story.ref?.let { AttachedImage(it, model) } if (story.caption.isNotBlank()) Text(story.caption) + if (reporting) { + StoryReportDialog( + model = model, + story = story, + onDismiss = { reporting = false }, + ) + } + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -2574,205 +2584,22 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor onClick = { model.saveStory(story) }, tag = "save-story") Spacer(Modifier.size(Space.sm)) } + // ios#56 — stories are the surface a stranger is most likely to + // reach somebody through, and this was the one place with no + // way to act. Absent on your own. + if (!story.mine) { + KitChip(text = "Report", selected = false, + onClick = { reporting = true }, tag = "report-story") + Spacer(Modifier.size(Space.sm)) + KitChip(text = "Block", selected = false, + onClick = { model.setBlocked(story.authorId, true) }, + tag = "block-from-story") + Spacer(Modifier.size(Space.sm)) + } KitChip(text = "Close", selected = false, onClick = model::closeStory, tag = "close-story") } } - - // §6.20 — a reply is an ordinary direct message to the author, not a - // comment on the story. Absent on your own stories: nobody to send to. - if (!story.mine) { - val context = LocalContext.current - val prefs = remember { - context.getSharedPreferences("flamenet-settings", android.content.Context.MODE_PRIVATE) - } - var draft by remember(story.id) { mutableStateOf("") } - var asking by remember(story.id) { mutableStateOf(false) } - - fun send() { - model.replyToStory(story, draft) - draft = "" - } - - Row( - modifier = Modifier.fillMaxWidth().padding(top = Space.sm), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = draft, - onValueChange = { draft = it }, - placeholder = { Text("Reply privately…") }, - singleLine = true, - modifier = Modifier.weight(1f).testTag("story-reply-field"), - ) - Spacer(Modifier.width(Space.sm)) - KitChip( - text = "Send", - selected = false, - // ⚠️ The disclosure comes BEFORE the first reply this - // account ever sends, not after. Replying tells the author - // you watched — which stories otherwise never reveal — so - // it has to be a decision rather than a side effect. - onClick = { - if (draft.isBlank()) return@KitChip - if (prefs.getBoolean(STORY_REPLY_DISCLOSURE_SEEN, false)) send() - else asking = true - }, - tag = "story-reply-send", - ) - } - - if (asking) { - androidx.compose.material3.AlertDialog( - onDismissRequest = { asking = false }, - title = { Text("Replying tells them you watched") }, - text = { - Text( - "Stories have no viewer list, so they cannot see who opened this " - + "one. Sending a reply tells them you did. Your reply goes to " - + "your conversation with them, not onto the story." - ) - }, - confirmButton = { - androidx.compose.material3.TextButton(onClick = { - prefs.edit().putBoolean(STORY_REPLY_DISCLOSURE_SEEN, true).apply() - asking = false - send() - }) { Text("Send reply") } - }, - dismissButton = { - androidx.compose.material3.TextButton( - onClick = { asking = false }) { Text("Cancel") } - }, - ) - } - } - - // §6.22 — one tap. Its own row, ABOVE the reply field: a reaction - // delivered by a mis-tap is a disclosure the person did not make, so - // it does not share a gesture with anything that navigates. - if (!story.mine) { - val ctx = LocalContext.current - val reactPrefs = remember { - ctx.getSharedPreferences("flamenet-settings", android.content.Context.MODE_PRIVATE) - } - var pending by remember(story.id) { mutableStateOf(null) } - Row( - modifier = Modifier.fillMaxWidth().padding(top = Space.sm), - horizontalArrangement = Arrangement.spacedBy(Space.md), - ) { - listOf("\u2764\uFE0F", "\uD83D\uDE02", "\uD83D\uDE2E", - "\uD83D\uDE22", "\uD83D\uDC4F", "\uD83D\uDD25").forEach { emoji -> - Text( - emoji, - fontSize = 24.sp, - modifier = Modifier - .testTag("story-react-$emoji") - .clickable { - if (reactPrefs.getBoolean(STORY_REACT_DISCLOSURE_SEEN, false)) { - model.reactToStory(story, emoji) - } else { - pending = emoji - } - }, - ) - } - } - pending?.let { emoji -> - androidx.compose.material3.AlertDialog( - onDismissRequest = { pending = null }, - title = { Text("Reacting tells them you watched") }, - text = { - Text( - "Stories have no viewer list, so they cannot see who opened " - + "this one. Sending a reaction tells them you did — the " - + "same as a reply, from one tap." - ) - }, - confirmButton = { - androidx.compose.material3.TextButton(onClick = { - reactPrefs.edit() - .putBoolean(STORY_REACT_DISCLOSURE_SEEN, true).apply() - model.reactToStory(story, emoji) - pending = null - }) { Text("Send it") } - }, - dismissButton = { - androidx.compose.material3.TextButton( - onClick = { pending = null }) { Text("Cancel") } - }, - ) - } - } - - // §6.20 — a reply is an ordinary direct message to the author, not a - // comment on the story. Absent on your own stories: nobody to send to. - if (!story.mine) { - val context = LocalContext.current - val prefs = remember { - context.getSharedPreferences("flamenet-settings", android.content.Context.MODE_PRIVATE) - } - var draft by remember(story.id) { mutableStateOf("") } - var asking by remember(story.id) { mutableStateOf(false) } - - fun send() { - model.replyToStory(story, draft) - draft = "" - } - - Row( - modifier = Modifier.fillMaxWidth().padding(top = Space.sm), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = draft, - onValueChange = { draft = it }, - placeholder = { Text("Reply privately…") }, - singleLine = true, - modifier = Modifier.weight(1f).testTag("story-reply-field"), - ) - Spacer(Modifier.width(Space.sm)) - KitChip( - text = "Send", - selected = false, - // ⚠️ The disclosure comes BEFORE the first reply this - // account ever sends, not after. Replying tells the author - // you watched — which stories otherwise never reveal — so - // it has to be a decision rather than a side effect. - onClick = { - if (draft.isBlank()) return@KitChip - if (prefs.getBoolean(STORY_REPLY_DISCLOSURE_SEEN, false)) send() - else asking = true - }, - tag = "story-reply-send", - ) - } - - if (asking) { - androidx.compose.material3.AlertDialog( - onDismissRequest = { asking = false }, - title = { Text("Replying tells them you watched") }, - text = { - Text( - "Stories have no viewer list, so they cannot see who opened this " - + "one. Sending a reply tells them you did. Your reply goes to " - + "your conversation with them, not onto the story." - ) - }, - confirmButton = { - androidx.compose.material3.TextButton(onClick = { - prefs.edit().putBoolean(STORY_REPLY_DISCLOSURE_SEEN, true).apply() - asking = false - send() - }) { Text("Send reply") } - }, - dismissButton = { - androidx.compose.material3.TextButton( - onClick = { asking = false }) { Text("Cancel") } - }, - ) - } - } } } @@ -3787,3 +3614,104 @@ fun ColumnScope.MailboxesSection( tag = "confirm-add-mailbox", ) } + +/** + * Reporting a story to whoever runs the relay (spec §6.25). + * + * 🔴 **The consent is the feature; the form is scaffolding around it.** + * Everything else this app sends is sealed, and a report is not: the relay + * cannot open a story, so a report about one is only actionable if the reporter + * hands over what they saw. That is a reasonable bargain and an invisible one, + * so it is stated here, in these words, before anything is sent — not in a + * policy document and not after the fact. + */ +@Composable +private fun StoryReportDialog( + model: MessengerViewModel, + story: Vault.Story, + onDismiss: () -> Unit, +) { + var reason by remember { mutableStateOf(MessengerViewModel.ReportReason.ABUSE) } + var includeContent by remember { mutableStateOf(true) } + var note by remember { mutableStateOf("") } + + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Report story") }, + text = { + Column { + Text("What is wrong", fontSize = 12.sp, color = Palette.SecondaryText) + MessengerViewModel.ReportReason.entries.forEach { option -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { reason = option } + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + androidx.compose.material3.RadioButton( + selected = reason == option, + onClick = { reason = option }, + ) + Text(option.label) + } + } + Spacer(Modifier.size(Space.sm)) + Row(verticalAlignment = Alignment.CenterVertically) { + androidx.compose.material3.Checkbox( + checked = includeContent, + onCheckedChange = { includeContent = it }, + modifier = Modifier.testTag("report-include-content"), + ) + Text("Include a copy of what they posted") + } + // 🔴 The disclosure, in the words §6.25 requires: what leaves + // the device, who can read it, and that it cannot be taken + // back. "Your report will be reviewed" would be a sentence + // about them, not about what is happening. + Text( + if (includeContent) { + "Whoever runs this server cannot read your messages or stories. " + + "Sending this gives them a readable copy of this one, and of " + + "who posted it. That cannot be undone." + } else { + "Without a copy, whoever runs this server sees only that you " + + "reported this account and why. They cannot look at the story " + + "themselves, so there may be nothing for them to act on." + }, + fontSize = 12.sp, + color = Palette.SecondaryText, + ) + Spacer(Modifier.size(Space.sm)) + androidx.compose.material3.OutlinedTextField( + value = note, + onValueChange = { note = it }, + placeholder = { Text("Anything you want to add") }, + modifier = Modifier.fillMaxWidth().testTag("report-note"), + ) + Spacer(Modifier.size(Space.sm)) + // ⚠️ Says which one does something. A report is a request to a + // person who may not act; blocking is the reporter's own + // decision and takes effect now. + Text( + "Blocking takes effect immediately and is your own decision. A report " + + "is a request to whoever runs this server, and nothing may come of it.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) + } + }, + confirmButton = { + androidx.compose.material3.TextButton( + onClick = { + model.reportStory(story, reason, includeContent, note) + onDismiss() + }, + modifier = Modifier.testTag("report-send"), + ) { Text("Send report") } + }, + dismissButton = { + androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index f1b769d..5fe6142 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -2076,6 +2076,55 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } } + /** + * The reasons a report may give (§6.25). + * + * ⚠️ Enumerated, matching the relay. A reason the relay rejects is a + * report somebody believed they had filed. + */ + enum class ReportReason(val wire: String, val label: String) { + ABUSE("abuse", "Abusive content"), + HARASSMENT("harassment", "Harassment"), + SPAM("spam", "Spam"), + IMPERSONATION("impersonation", "Pretending to be someone"), + CSAM("csam", "Child sexual abuse material"), + OTHER("other", "Something else"), + } + + /** + * File a report about a story (§6.25). + * + * 🔴 **`content` leaves the device readable.** That is the point and the + * cost: the relay cannot open a sealed story, so a report about one is + * only actionable if the reporter hands over what they saw. The caller + * must have obtained consent naming that consequence — this function does + * not ask, and must never be reached from a control that does not. + * + * ⚠️ 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. + */ + fun reportStory( + story: Vault.Story, + reason: ReportReason, + includeContent: Boolean, + note: String, + ) = viewModelScope.launch { + val current = session ?: return@launch + val done = withContext(Dispatchers.IO) { + runCatching { + current.reportStory(story, reason.wire, includeContent, note) + } + } + val message = if (done.isSuccess) { + "Report sent. Whoever runs this server will see it; you will not be " + + "told what they do." + } else { + "That report could not be sent." + } + update { it.copy(error = message) } + } + /** Change who may see this account's stories. */ fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch { val current = session ?: return@launch diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 5e08460..2f79c44 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -1211,6 +1211,23 @@ class Session( return posted.reached } + /** + * File a report about a story (§6.25). + * + * 🔴 Sends readable content, deliberately and only on the caller's + * instruction. See [MessengerViewModel.reportStory]. + */ + fun reportStory(story: Vault.Story, reason: String, + includeContent: Boolean, note: String) { + relay.submitReport( + about = story.authorId, + kind = "story", + reason = reason, + content = if (includeContent) story.caption else "", + note = note, + ) + } + /** * Members of a group we are in, or null if the relay does not list it for * us (audit H1). Cached for a short window: a poll pass can carry many diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/StoryReportTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/StoryReportTest.kt new file mode 100644 index 0000000..6d187fb --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/StoryReportTest.kt @@ -0,0 +1,59 @@ +package io.flamenet.messenger.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Reporting a story (spec §6.25, ios#56). + * + * 🔴 **A report is the one thing this app sends that the server can read.** + * Everything else is sealed. The relay cannot open a story, so a report about + * one is only actionable if the reporter hands over what they saw — which makes + * the consent, and the exact set of things that travel, the feature. + */ +class StoryReportTest { + + /** + * ⚠️ The wire values must match what the relay accepts. A reason the relay + * rejects is a report somebody believed they had filed. + */ + @Test + fun `every reason matches the relays enumeration`() { + val accepted = setOf("abuse", "harassment", "spam", + "impersonation", "csam", "other") + assertEquals(accepted, + MessengerViewModel.ReportReason.entries.map { it.wire }.toSet()) + } + + /** A raw wire value in a picker is the wire format leaking into the UI. */ + @Test + fun `every reason has words a person can choose between`() { + MessengerViewModel.ReportReason.entries.forEach { + assertTrue(it.label.isNotEmpty(), it.wire) + assertNotEquals(it.wire, it.label) + } + } + + /** + * ⚠️ CSAM must be reportable as itself rather than folded into abuse: it + * carries a different obligation, and an operator cannot triage what + * nobody can name. + */ + @Test + fun `csam is its own reason`() { + assertTrue(MessengerViewModel.ReportReason.entries + .any { it == MessengerViewModel.ReportReason.CSAM }) + } + + /** + * ⚠️ Without one, the picker forces a person to mis-categorise in order to + * report at all. + */ + @Test + fun `there is a reason for anything else`() { + assertTrue(MessengerViewModel.ReportReason.entries + .any { it == MessengerViewModel.ReportReason.OTHER }) + } +} diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index f427e51..b85db39 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -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") }