Merge branch 'feat/report-a-message' into 'main'

Report a message, not just a story

See merge request paulhitt/flamenet-messenger-android!74
This commit was merged in pull request #101.
This commit is contained in:
Paul Hitt
2026-09-05 18:35:11 +00:00
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).
*