Report a message, not just a story (android#20)

**The relay has always accepted `kind: "message"`. Nothing ever sent one.**
Reporting existed on stories only — so the surface where unsolicited
contact, spam and harassment actually arrive had no way to tell anybody.
The person being messaged by a stranger had Block, which protects them
and tells whoever runs the server nothing, so the account moves on to the
next person.

🔴 **Android had `reportStory` wired all the way to the relay and no
screen anywhere calling it.** The plumbing was complete and unreachable.
This is the first report UI on this platform, and the sheet is not
story-specific, so stories can use it too.

🔴 **The consent is the feature; the form is scaffolding.** Everything
else this app sends is sealed and a report is not: the relay cannot open
a message, so a report is actionable only if the reporter hands over what
they saw. §6.25 requires the client to say so, and it says so in these
words — what leaves the device, who can read it, and that it cannot be
taken back. Turning the copy off states what that costs rather than
quietly weakening the report.

⚠️ Block is offered beside it and never merged into it. A block takes
effect immediately and is the reporter's own decision; a report is a
request to somebody who may do nothing, and the sheet says which is
which.

⚠️ One message travels, never the thread around it — §6.25 forbids
including anything the reporter did not choose to.

⚠️ Report is absent, not disabled, on your own messages, and on the
stranger banner it sits below both buttons rather than beside them. Most
first messages are somebody you gave your address to; leading with Report
would frame every one of them as a threat.

⚠️ A relay may decline reports entirely (`reports_not_accepted`, 501).
That is a property of the server, not a mistake the reporter made, and
the message does not read like one.
This commit is contained in:
Paul Hitt
2026-09-05 14:28:38 -04:00
parent 1594ce84f6
commit 9bba9d2555
3 changed files with 239 additions and 1 deletions
@@ -7,6 +7,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
@@ -1606,6 +1607,7 @@ private fun ColumnScope.StrangerBanner(
model: MessengerViewModel,
peerId: Long,
buddies: List<io.flamenet.client.MessengerApi.Buddy>,
onReport: () -> Unit,
) {
val row = buddies.firstOrNull { it.userId == peerId } ?: return
if (row.accepted) return
@@ -1651,6 +1653,21 @@ private fun ColumnScope.StrangerBanner(
tag = "block-stranger",
)
}
// 🔴 **The case android#20 is really about.** A Report on each
// message satisfies the rule; this is where it helps. Somebody who
// has just been written to by a stranger is looking at exactly this
// banner, and their only options were to keep them or block them —
// and blocking tells whoever runs the server nothing, so the
// account moves on to the next person.
//
// ⚠️ Below the row and quieter than both, deliberately. Most first
// messages are somebody you gave your address to; giving Report
// equal weight would frame every one of them as a threat, which is
// wrong far more often than it is right.
androidx.compose.material3.TextButton(
onClick = onReport,
modifier = Modifier.testTag("report-stranger"),
) { Text("Report to whoever runs this server", fontSize = 12.sp) }
}
}
}
@@ -1771,10 +1788,17 @@ fun ColumnScope.ChatScreen(
}
}
/** The message being reported to whoever runs the relay (android#20). */
var reporting by remember(chat.threadId) { mutableStateOf<Session.Line?>(null) }
// 🔴 **Above everything else in the thread**, because it is a statement
// about who you are talking to and it has to be read before the messages
// are, not after scrolling past them.
if (chat.peerId != null) StrangerBanner(model, chat.peerId, buddies)
if (chat.peerId != null) {
StrangerBanner(model, chat.peerId, buddies) {
reporting = chat.lines.lastOrNull { !it.mine }
}
}
chat.group?.let { group -> GroupMembers(model, group, buddies) }
@@ -1845,6 +1869,24 @@ fun ColumnScope.ChatScreen(
)
}
reporting?.let { line ->
ReportSheet(
authorName = chat.title,
preview = line.text,
onDismiss = { reporting = null },
onSend = { reason, includeContent, note ->
chat.peerId?.let { peer ->
model.reportMessage(line.text, peer, reason, includeContent, note)
}
reporting = null
},
onBlock = {
chat.peerId?.let { model.setBlocked(it, true) }
reporting = null
},
)
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"),
@@ -1952,6 +1994,20 @@ fun ColumnScope.ChatScreen(
modifier = Modifier.testTag("forward-message"),
)
}
// 🔴 **Only on messages somebody else sent (android#20).**
// This is the surface unsolicited contact, spam and
// harassment actually arrive on, and until now the person
// receiving it had Block and nothing else — which protects
// them and tells whoever runs the server nothing, so the
// account simply moves on to the next person. Reporting
// your own message is not something anybody means to do.
if (!line.mine && line.text.isNotBlank()) {
DropdownMenuItem(
text = { Text("Report") },
onClick = { reporting = line; menuOpen = false },
modifier = Modifier.testTag("report-message"),
)
}
DropdownMenuItem(
// 🔴 **Said "Unsend" on your own messages and did
// nothing of the kind (android#18).** `deleteMessage`
@@ -4015,6 +4071,129 @@ private fun EditMessageSheet(
* message's author to somebody who was never in that conversation, which is
* the one thing §6.16 exists to prevent.
*/
/**
* Reporting a message to whoever runs the relay (§6.25, android#20).
*
* 🔴 **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 message, so a report about one is actionable only 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.
*
* ⚠️ Blocking is offered beside it and never merged into it. A block is the
* reporter's own decision and takes effect immediately; a report is a request
* to somebody who may do nothing.
*/
@Composable
private fun ReportSheet(
authorName: String,
preview: String,
onDismiss: () -> Unit,
onSend: (MessengerViewModel.ReportReason, Boolean, String) -> Unit,
onBlock: () -> 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 message") },
text = {
Column(
modifier = Modifier
.testTag("report-sheet")
.verticalScroll(rememberScrollState()),
) {
Text("What is wrong", fontSize = 12.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.xs))
MessengerViewModel.ReportReason.entries.forEach { option ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { reason = option }
.testTag("report-reason-${option.wire}"),
) {
androidx.compose.material3.RadioButton(
selected = reason == option,
onClick = { reason = option },
)
Text(option.label, fontSize = 14.sp)
}
}
Spacer(Modifier.height(Space.sm))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().testTag("report-include"),
) {
androidx.compose.material3.Switch(
checked = includeContent,
onCheckedChange = { includeContent = it },
)
Spacer(Modifier.width(Space.sm))
Text("Include a copy of the message", fontSize = 14.sp)
}
// 🔴 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 rather than about what is happening.
Text(
if (includeContent) {
"Whoever runs this server cannot read your messages. Sending " +
"this gives them a readable copy of this one, and of who " +
"sent it. That cannot be undone."
} else {
"Without a copy, whoever runs this server sees only that you " +
"reported this account and why. They cannot read the " +
"message themselves, so there may be nothing for them to " +
"act on."
},
fontSize = 12.sp, color = Palette.SecondaryText,
)
if (includeContent && preview.isNotBlank()) {
Spacer(Modifier.height(Space.xs))
Text(preview, fontSize = 12.sp, color = Palette.SecondaryText,
maxLines = 3)
}
Spacer(Modifier.height(Space.sm))
androidx.compose.material3.OutlinedTextField(
value = note,
onValueChange = { note = it },
label = { Text("Anything you want to add (optional)") },
modifier = Modifier.fillMaxWidth().testTag("report-note"),
)
Spacer(Modifier.height(Space.sm))
androidx.compose.material3.TextButton(
onClick = onBlock,
modifier = Modifier.testTag("report-block"),
) { Text("Block $authorName") }
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 = { onSend(reason, includeContent, note) },
modifier = Modifier.testTag("report-send"),
) { Text("Send report") }
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@Composable
private fun ForwardSheet(
line: Session.Line,
@@ -2946,6 +2946,38 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
update { it.copy(error = message) }
}
/**
* File a report about a message (§6.25, android#20).
*
* ⚠️ Same consent requirement as `reportStory`: the sheet that reaches
* this must have said, in words, that a readable copy leaves the device
* and cannot be taken back.
*/
fun reportMessage(
text: String,
senderId: Long,
reason: ReportReason,
includeContent: Boolean,
note: String,
) = viewModelScope.launch {
val current = session ?: return@launch
val done = withContext(Dispatchers.IO) {
runCatching {
current.reportMessage(text, senderId, 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 {
// ⚠️ A relay may decline reports entirely (reports_not_accepted,
// 501). That is a property of the server, not a mistake the
// reporter made, and the message should not read like one.
"That report could not be sent. This server may not take reports."
}
update { it.copy(error = message) }
}
/** Counts per emoji, for the author's own view of their story. */
fun storyTally(storyKey: String): Map<String, Int> =
session?.reactionsFor(storyKey).orEmpty().values
@@ -1540,6 +1540,33 @@ class Session(
)
}
/**
* File a report about a MESSAGE (§6.25, android#20 / ios#12).
*
* 🔴 **`kind = "message"` has always been accepted and nothing ever sent
* one.** Reporting existed on stories only, so the surface where
* unsolicited contact, spam and harassment actually arrive had no way to
* tell anybody. The person being messaged by a stranger had Block, which
* protects them and tells the operator nothing — so the account moves on
* to the next person.
*
* ⚠️ Same bargain as `reportStory`, and the caller must have obtained
* consent naming it: the relay cannot open a sealed message, so a report
* is actionable only if the reporter hands over what they saw.
*
* ⚠️ The ONE message, never the thread around it.
*/
fun reportMessage(text: String, senderId: Long, reason: String,
includeContent: Boolean, note: String) {
relay.submitReport(
about = senderId,
kind = "message",
reason = reason,
content = if (includeContent) text else "",
note = note,
)
}
/**
* Told after every 1:1 send whether it was sealed (ios#16).
*