Report or block from a story
Android half of ios#56, spec §6.25. Needs the relay endpoint from flamenet-relay!24. 🔴 A report is the one thing this 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. The dialog states that before anything is sent: what leaves the device, who can read it, and that it cannot be undone. Declining to include a copy is offered, and says plainly there may then be nothing to act on. ⚠️ `reportStory` 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. ⚠️ Blocking and reporting sit side by side and are never merged. Blocking is this person's own decision and takes effect now; a report is a request to somebody else. The copy says which is which, because a combined control would make one of the two invisible. The reasons are enumerated to match the relay, and a test asserts the two lists are equal — a reason the relay rejects is a report somebody believed they had filed. CSAM is its own reason rather than folded into abuse: it carries a different obligation, and an operator cannot triage what nobody can name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ae7c234bf2
commit
6fe463d578
@@ -742,3 +742,26 @@ refused — the story is legitimately theirs; only the claim about where it was
|
|||||||
posted is unverified.
|
posted is unverified.
|
||||||
⚠️ `exp` is the ordinary story lifetime on both. Group membership does not
|
⚠️ `exp` is the ordinary story lifetime on both. Group membership does not
|
||||||
extend it.
|
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. */
|
/** One story, full width, with what it says about its own lifetime. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Story) {
|
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 remaining = remember(story.id) {
|
||||||
val seconds = story.expiresAt - System.currentTimeMillis() / 1000.0
|
val seconds = story.expiresAt - System.currentTimeMillis() / 1000.0
|
||||||
when {
|
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.
|
// §6.21 — a text story has no blob; the caption IS the story.
|
||||||
story.ref?.let { AttachedImage(it, model) }
|
story.ref?.let { AttachedImage(it, model) }
|
||||||
if (story.caption.isNotBlank()) Text(story.caption)
|
if (story.caption.isNotBlank()) Text(story.caption)
|
||||||
|
if (reporting) {
|
||||||
|
StoryReportDialog(
|
||||||
|
model = model,
|
||||||
|
story = story,
|
||||||
|
onDismiss = { reporting = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -2574,205 +2584,22 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor
|
|||||||
onClick = { model.saveStory(story) }, tag = "save-story")
|
onClick = { model.saveStory(story) }, tag = "save-story")
|
||||||
Spacer(Modifier.size(Space.sm))
|
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,
|
KitChip(text = "Close", selected = false,
|
||||||
onClick = model::closeStory, tag = "close-story")
|
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",
|
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. */
|
/** Change who may see this account's stories. */
|
||||||
fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch {
|
fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch {
|
||||||
val current = session ?: return@launch
|
val current = session ?: return@launch
|
||||||
|
|||||||
@@ -1211,6 +1211,23 @@ class Session(
|
|||||||
return posted.reached
|
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
|
* 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
|
* 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
|
* inside the ratchet; putting it anywhere the relay can read defeats the
|
||||||
* point of encrypting the blob.
|
* 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 =
|
fun uploadAttachment(blob: ByteArray): String =
|
||||||
request("POST", url("/e2e/attachments"), "/e2e/attachments", blob.toRequestBody(OCTETS))
|
request("POST", url("/e2e/attachments"), "/e2e/attachments", blob.toRequestBody(OCTETS))
|
||||||
.use { json(it).getString("id") }
|
.use { json(it).getString("id") }
|
||||||
|
|||||||
Reference in New Issue
Block a user