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:
@@ -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).
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user