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
@@ -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