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
+23
View File
@@ -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.
@@ -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<String?>(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") }
},
)
}
@@ -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
@@ -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
@@ -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 })
}
}
@@ -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") }