From 9bba9d255524ded1ba68786a2f3959ecb1bc83cb Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sat, 5 Sep 2026 14:28:38 -0400 Subject: [PATCH] Report a message, not just a story (android#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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. --- .../io/flamenet/messenger/app/ChatScreens.kt | 181 +++++++++++++++++- .../messenger/app/MessengerViewModel.kt | 32 ++++ .../io/flamenet/messenger/app/Session.kt | 27 +++ 3 files changed, 239 insertions(+), 1 deletion(-) 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 ea1e3eb..90853e2 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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, + 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(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, 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 2f50b58..0efdbc7 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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 = session?.reactionsFor(storyKey).orEmpty().values 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 c97d364..e3e4906 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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). *