Merge branch 'feat/report-or-block-from-a-story' into 'main'
Report or block from a story See merge request paulhitt/flamenet-messenger-android!28
This commit was merged in pull request #55.
This commit is contained in:
@@ -742,3 +742,26 @@ refused — the story is legitimately theirs; only the claim about where it was
|
||||
posted is unverified.
|
||||
⚠️ `exp` is the ordinary story lifetime on both. Group membership does not
|
||||
extend it.
|
||||
## Report or block from a story (§6.25, ios#56)
|
||||
Blocking existed in a conversation and nowhere near a story — the surface a
|
||||
stranger is most likely to reach somebody through. Reporting did not exist at
|
||||
all, on either platform or in the relay.
|
||||
🔴 **A report is the one thing either app sends that the server can read.**
|
||||
Everything else is sealed, and the relay cannot open a story, so a report about
|
||||
one is only actionable if the reporter hands over what they saw. Both platforms
|
||||
state that before sending, in those words: what leaves the device, who can read
|
||||
it, and that it cannot be undone. Declining to include a copy is offered on
|
||||
both, and says plainly there may then be nothing to act on.
|
||||
⚠️ **Both send only what they were passed.** Neither reaches into the thread for
|
||||
surrounding context — §6.25 forbids including anything the reporter did not
|
||||
choose to include, and a helpful "we also attached the conversation" would be
|
||||
exactly the wrong kind of help.
|
||||
⚠️ **Blocking and reporting are offered together and never merged**, on both.
|
||||
Blocking is the reporter's own decision and takes effect now; a report is a
|
||||
request to somebody else and nothing may come of it. A combined control would
|
||||
make one of the two invisible.
|
||||
⚠️ **The reasons are enumerated to match the relay**, with a test on each
|
||||
platform asserting the lists are equal — a reason the relay rejects is a report
|
||||
somebody believed they had filed. CSAM is its own reason on both rather than
|
||||
folded into abuse: it carries a different obligation, and an operator cannot
|
||||
triage what nobody can name.
|
||||
|
||||
@@ -2545,6 +2545,8 @@ fun ColumnScope.StoryStrip(
|
||||
/** One story, full width, with what it says about its own lifetime. */
|
||||
@Composable
|
||||
private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Story) {
|
||||
// ios#56 — reporting, and the consent that has to precede it.
|
||||
var reporting by remember(story.id) { mutableStateOf(false) }
|
||||
val remaining = remember(story.id) {
|
||||
val seconds = story.expiresAt - System.currentTimeMillis() / 1000.0
|
||||
when {
|
||||
@@ -2560,6 +2562,14 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor
|
||||
// §6.21 — a text story has no blob; the caption IS the story.
|
||||
story.ref?.let { AttachedImage(it, model) }
|
||||
if (story.caption.isNotBlank()) Text(story.caption)
|
||||
if (reporting) {
|
||||
StoryReportDialog(
|
||||
model = model,
|
||||
story = story,
|
||||
onDismiss = { reporting = false },
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -2574,205 +2584,22 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor
|
||||
onClick = { model.saveStory(story) }, tag = "save-story")
|
||||
Spacer(Modifier.size(Space.sm))
|
||||
}
|
||||
// ios#56 — stories are the surface a stranger is most likely to
|
||||
// reach somebody through, and this was the one place with no
|
||||
// way to act. Absent on your own.
|
||||
if (!story.mine) {
|
||||
KitChip(text = "Report", selected = false,
|
||||
onClick = { reporting = true }, tag = "report-story")
|
||||
Spacer(Modifier.size(Space.sm))
|
||||
KitChip(text = "Block", selected = false,
|
||||
onClick = { model.setBlocked(story.authorId, true) },
|
||||
tag = "block-from-story")
|
||||
Spacer(Modifier.size(Space.sm))
|
||||
}
|
||||
KitChip(text = "Close", selected = false,
|
||||
onClick = model::closeStory, tag = "close-story")
|
||||
}
|
||||
}
|
||||
|
||||
// §6.20 — a reply is an ordinary direct message to the author, not a
|
||||
// comment on the story. Absent on your own stories: nobody to send to.
|
||||
if (!story.mine) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember {
|
||||
context.getSharedPreferences("flamenet-settings", android.content.Context.MODE_PRIVATE)
|
||||
}
|
||||
var draft by remember(story.id) { mutableStateOf("") }
|
||||
var asking by remember(story.id) { mutableStateOf(false) }
|
||||
|
||||
fun send() {
|
||||
model.replyToStory(story, draft)
|
||||
draft = ""
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = Space.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
placeholder = { Text("Reply privately…") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f).testTag("story-reply-field"),
|
||||
)
|
||||
Spacer(Modifier.width(Space.sm))
|
||||
KitChip(
|
||||
text = "Send",
|
||||
selected = false,
|
||||
// ⚠️ The disclosure comes BEFORE the first reply this
|
||||
// account ever sends, not after. Replying tells the author
|
||||
// you watched — which stories otherwise never reveal — so
|
||||
// it has to be a decision rather than a side effect.
|
||||
onClick = {
|
||||
if (draft.isBlank()) return@KitChip
|
||||
if (prefs.getBoolean(STORY_REPLY_DISCLOSURE_SEEN, false)) send()
|
||||
else asking = true
|
||||
},
|
||||
tag = "story-reply-send",
|
||||
)
|
||||
}
|
||||
|
||||
if (asking) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { asking = false },
|
||||
title = { Text("Replying tells them you watched") },
|
||||
text = {
|
||||
Text(
|
||||
"Stories have no viewer list, so they cannot see who opened this "
|
||||
+ "one. Sending a reply tells them you did. Your reply goes to "
|
||||
+ "your conversation with them, not onto the story."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(onClick = {
|
||||
prefs.edit().putBoolean(STORY_REPLY_DISCLOSURE_SEEN, true).apply()
|
||||
asking = false
|
||||
send()
|
||||
}) { Text("Send reply") }
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = { asking = false }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// §6.22 — one tap. Its own row, ABOVE the reply field: a reaction
|
||||
// delivered by a mis-tap is a disclosure the person did not make, so
|
||||
// it does not share a gesture with anything that navigates.
|
||||
if (!story.mine) {
|
||||
val ctx = LocalContext.current
|
||||
val reactPrefs = remember {
|
||||
ctx.getSharedPreferences("flamenet-settings", android.content.Context.MODE_PRIVATE)
|
||||
}
|
||||
var pending by remember(story.id) { mutableStateOf<String?>(null) }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = Space.sm),
|
||||
horizontalArrangement = Arrangement.spacedBy(Space.md),
|
||||
) {
|
||||
listOf("\u2764\uFE0F", "\uD83D\uDE02", "\uD83D\uDE2E",
|
||||
"\uD83D\uDE22", "\uD83D\uDC4F", "\uD83D\uDD25").forEach { emoji ->
|
||||
Text(
|
||||
emoji,
|
||||
fontSize = 24.sp,
|
||||
modifier = Modifier
|
||||
.testTag("story-react-$emoji")
|
||||
.clickable {
|
||||
if (reactPrefs.getBoolean(STORY_REACT_DISCLOSURE_SEEN, false)) {
|
||||
model.reactToStory(story, emoji)
|
||||
} else {
|
||||
pending = emoji
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
pending?.let { emoji ->
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { pending = null },
|
||||
title = { Text("Reacting tells them you watched") },
|
||||
text = {
|
||||
Text(
|
||||
"Stories have no viewer list, so they cannot see who opened "
|
||||
+ "this one. Sending a reaction tells them you did — the "
|
||||
+ "same as a reply, from one tap."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(onClick = {
|
||||
reactPrefs.edit()
|
||||
.putBoolean(STORY_REACT_DISCLOSURE_SEEN, true).apply()
|
||||
model.reactToStory(story, emoji)
|
||||
pending = null
|
||||
}) { Text("Send it") }
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = { pending = null }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// §6.20 — a reply is an ordinary direct message to the author, not a
|
||||
// comment on the story. Absent on your own stories: nobody to send to.
|
||||
if (!story.mine) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember {
|
||||
context.getSharedPreferences("flamenet-settings", android.content.Context.MODE_PRIVATE)
|
||||
}
|
||||
var draft by remember(story.id) { mutableStateOf("") }
|
||||
var asking by remember(story.id) { mutableStateOf(false) }
|
||||
|
||||
fun send() {
|
||||
model.replyToStory(story, draft)
|
||||
draft = ""
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = Space.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
placeholder = { Text("Reply privately…") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f).testTag("story-reply-field"),
|
||||
)
|
||||
Spacer(Modifier.width(Space.sm))
|
||||
KitChip(
|
||||
text = "Send",
|
||||
selected = false,
|
||||
// ⚠️ The disclosure comes BEFORE the first reply this
|
||||
// account ever sends, not after. Replying tells the author
|
||||
// you watched — which stories otherwise never reveal — so
|
||||
// it has to be a decision rather than a side effect.
|
||||
onClick = {
|
||||
if (draft.isBlank()) return@KitChip
|
||||
if (prefs.getBoolean(STORY_REPLY_DISCLOSURE_SEEN, false)) send()
|
||||
else asking = true
|
||||
},
|
||||
tag = "story-reply-send",
|
||||
)
|
||||
}
|
||||
|
||||
if (asking) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { asking = false },
|
||||
title = { Text("Replying tells them you watched") },
|
||||
text = {
|
||||
Text(
|
||||
"Stories have no viewer list, so they cannot see who opened this "
|
||||
+ "one. Sending a reply tells them you did. Your reply goes to "
|
||||
+ "your conversation with them, not onto the story."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(onClick = {
|
||||
prefs.edit().putBoolean(STORY_REPLY_DISCLOSURE_SEEN, true).apply()
|
||||
asking = false
|
||||
send()
|
||||
}) { Text("Send reply") }
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = { asking = false }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3787,3 +3614,104 @@ fun ColumnScope.MailboxesSection(
|
||||
tag = "confirm-add-mailbox",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reporting a story to whoever runs the relay (spec §6.25).
|
||||
*
|
||||
* 🔴 **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 story, so a report about one is only actionable 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.
|
||||
*/
|
||||
@Composable
|
||||
private fun StoryReportDialog(
|
||||
model: MessengerViewModel,
|
||||
story: Vault.Story,
|
||||
onDismiss: () -> 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 story") },
|
||||
text = {
|
||||
Column {
|
||||
Text("What is wrong", fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
MessengerViewModel.ReportReason.entries.forEach { option ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { reason = option }
|
||||
.padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
androidx.compose.material3.RadioButton(
|
||||
selected = reason == option,
|
||||
onClick = { reason = option },
|
||||
)
|
||||
Text(option.label)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(Space.sm))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
androidx.compose.material3.Checkbox(
|
||||
checked = includeContent,
|
||||
onCheckedChange = { includeContent = it },
|
||||
modifier = Modifier.testTag("report-include-content"),
|
||||
)
|
||||
Text("Include a copy of what they posted")
|
||||
}
|
||||
// 🔴 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, not about what is happening.
|
||||
Text(
|
||||
if (includeContent) {
|
||||
"Whoever runs this server cannot read your messages or stories. " +
|
||||
"Sending this gives them a readable copy of this one, and of " +
|
||||
"who posted it. That cannot be undone."
|
||||
} else {
|
||||
"Without a copy, whoever runs this server sees only that you " +
|
||||
"reported this account and why. They cannot look at the story " +
|
||||
"themselves, so there may be nothing for them to act on."
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.size(Space.sm))
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = note,
|
||||
onValueChange = { note = it },
|
||||
placeholder = { Text("Anything you want to add") },
|
||||
modifier = Modifier.fillMaxWidth().testTag("report-note"),
|
||||
)
|
||||
Spacer(Modifier.size(Space.sm))
|
||||
// ⚠️ Says which one does something. A report is a request to a
|
||||
// person who may not act; blocking is the reporter's own
|
||||
// decision and takes effect now.
|
||||
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 = {
|
||||
model.reportStory(story, reason, includeContent, note)
|
||||
onDismiss()
|
||||
},
|
||||
modifier = Modifier.testTag("report-send"),
|
||||
) { Text("Send report") }
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2076,6 +2076,55 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The reasons a report may give (§6.25).
|
||||
*
|
||||
* ⚠️ Enumerated, matching the relay. A reason the relay rejects is a
|
||||
* report somebody believed they had filed.
|
||||
*/
|
||||
enum class ReportReason(val wire: String, val label: String) {
|
||||
ABUSE("abuse", "Abusive content"),
|
||||
HARASSMENT("harassment", "Harassment"),
|
||||
SPAM("spam", "Spam"),
|
||||
IMPERSONATION("impersonation", "Pretending to be someone"),
|
||||
CSAM("csam", "Child sexual abuse material"),
|
||||
OTHER("other", "Something else"),
|
||||
}
|
||||
|
||||
/**
|
||||
* File a report about a story (§6.25).
|
||||
*
|
||||
* 🔴 **`content` leaves the device readable.** That is the point and the
|
||||
* cost: the relay cannot open a sealed story, so a report about one is
|
||||
* only actionable if the reporter hands over what they saw. The caller
|
||||
* must have obtained consent naming that consequence — this function does
|
||||
* not ask, and must never be reached from a control that does not.
|
||||
*
|
||||
* ⚠️ Sends only what it is passed. It does not reach into the thread for
|
||||
* surrounding context: §6.25 forbids including anything the reporter did
|
||||
* not choose to include.
|
||||
*/
|
||||
fun reportStory(
|
||||
story: Vault.Story,
|
||||
reason: ReportReason,
|
||||
includeContent: Boolean,
|
||||
note: String,
|
||||
) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val done = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
current.reportStory(story, 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 {
|
||||
"That report could not be sent."
|
||||
}
|
||||
update { it.copy(error = message) }
|
||||
}
|
||||
|
||||
/** Change who may see this account's stories. */
|
||||
fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
|
||||
@@ -1211,6 +1211,23 @@ class Session(
|
||||
return posted.reached
|
||||
}
|
||||
|
||||
/**
|
||||
* File a report about a story (§6.25).
|
||||
*
|
||||
* 🔴 Sends readable content, deliberately and only on the caller's
|
||||
* instruction. See [MessengerViewModel.reportStory].
|
||||
*/
|
||||
fun reportStory(story: Vault.Story, reason: String,
|
||||
includeContent: Boolean, note: String) {
|
||||
relay.submitReport(
|
||||
about = story.authorId,
|
||||
kind = "story",
|
||||
reason = reason,
|
||||
content = if (includeContent) story.caption else "",
|
||||
note = note,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Members of a group we are in, or null if the relay does not list it for
|
||||
* us (audit H1). Cached for a short window: a poll pass can carry many
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Reporting a story (spec §6.25, ios#56).
|
||||
*
|
||||
* 🔴 **A report is the one thing this app sends that the server can read.**
|
||||
* Everything else is sealed. The relay cannot open a story, so a report about
|
||||
* one is only actionable if the reporter hands over what they saw — which makes
|
||||
* the consent, and the exact set of things that travel, the feature.
|
||||
*/
|
||||
class StoryReportTest {
|
||||
|
||||
/**
|
||||
* ⚠️ The wire values must match what the relay accepts. A reason the relay
|
||||
* rejects is a report somebody believed they had filed.
|
||||
*/
|
||||
@Test
|
||||
fun `every reason matches the relays enumeration`() {
|
||||
val accepted = setOf("abuse", "harassment", "spam",
|
||||
"impersonation", "csam", "other")
|
||||
assertEquals(accepted,
|
||||
MessengerViewModel.ReportReason.entries.map { it.wire }.toSet())
|
||||
}
|
||||
|
||||
/** A raw wire value in a picker is the wire format leaking into the UI. */
|
||||
@Test
|
||||
fun `every reason has words a person can choose between`() {
|
||||
MessengerViewModel.ReportReason.entries.forEach {
|
||||
assertTrue(it.label.isNotEmpty(), it.wire)
|
||||
assertNotEquals(it.wire, it.label)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ CSAM must be reportable as itself rather than folded into abuse: it
|
||||
* carries a different obligation, and an operator cannot triage what
|
||||
* nobody can name.
|
||||
*/
|
||||
@Test
|
||||
fun `csam is its own reason`() {
|
||||
assertTrue(MessengerViewModel.ReportReason.entries
|
||||
.any { it == MessengerViewModel.ReportReason.CSAM })
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Without one, the picker forces a person to mis-categorise in order to
|
||||
* report at all.
|
||||
*/
|
||||
@Test
|
||||
fun `there is a reason for anything else`() {
|
||||
assertTrue(MessengerViewModel.ReportReason.entries
|
||||
.any { it == MessengerViewModel.ReportReason.OTHER })
|
||||
}
|
||||
}
|
||||
@@ -339,6 +339,33 @@ class RelayClient(
|
||||
* inside the ratchet; putting it anywhere the relay can read defeats the
|
||||
* point of encrypting the blob.
|
||||
*/
|
||||
/**
|
||||
* `POST /e2e/moderation/report` — file a report (§6.25).
|
||||
*
|
||||
* 🔴 **The one call that deliberately sends readable content to the
|
||||
* server.** Everything else this client sends is sealed. A relay that
|
||||
* cannot open a reported story cannot act on an accusation about it, so
|
||||
* the reporter's own copy travels with the report — and the caller is
|
||||
* required to have obtained consent naming that, before reaching here.
|
||||
*/
|
||||
fun submitReport(
|
||||
about: Long,
|
||||
kind: String,
|
||||
reason: String,
|
||||
content: String,
|
||||
note: String,
|
||||
) {
|
||||
val body = JSONObject()
|
||||
.put("about", about)
|
||||
.put("kind", kind)
|
||||
.put("reason", reason)
|
||||
.put("content", content)
|
||||
.put("note", note)
|
||||
.toString()
|
||||
request("POST", url("/e2e/moderation/report"), "/e2e/moderation/report",
|
||||
body.toRequestBody(JSON)).use { it.close() }
|
||||
}
|
||||
|
||||
fun uploadAttachment(blob: ByteArray): String =
|
||||
request("POST", url("/e2e/attachments"), "/e2e/attachments", blob.toRequestBody(OCTETS))
|
||||
.use { json(it).getString("id") }
|
||||
|
||||
Reference in New Issue
Block a user