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). *