Reply to a story, as a sealed direct message
Android half of ios#30, spec §6.20. A reply is an ordinary direct message to the author carrying `sre.sid`, landing in the existing conversation. Stories gain no comment thread and no viewer list. The reference carries the id and nothing else. A story's author had the story by definition, so the only way they no longer hold it is that their own timer removed it — sending a copy back would resurrect exactly that, at a moment they did not choose. 🔴 `Content.text` returns the raw body when it has nothing to wrap, so `storyReplyId` had to join that condition. Left out, a reply with no message id and no quote would have gone out as plain text and arrived with the reference silently dropped — on exactly the path a story reply normally takes. There is a test for it. The disclosure is shown before the first reply this account sends, names what is revealed, and says where the reply goes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
03c7855a88
commit
819fab3161
@@ -2514,9 +2514,87 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor
|
||||
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") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account has been told that replying to a story reveals they
|
||||
* watched it (§6.20).
|
||||
*
|
||||
* ⚠️ Shown before the FIRST reply, not after. Stories keep no viewer list, so
|
||||
* a reply is the only thing that discloses a view.
|
||||
*/
|
||||
private const val STORY_REPLY_DISCLOSURE_SEEN = "stories.replyDisclosureSeen"
|
||||
|
||||
/**
|
||||
* Add and remove people after a group exists.
|
||||
*
|
||||
|
||||
@@ -1995,6 +1995,28 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply to a story, as an ordinary direct message to its author (§6.20).
|
||||
*
|
||||
* ⚠️ Replying tells the author you watched, which stories otherwise never
|
||||
* reveal — so the caller must have disclosed that before the first reply.
|
||||
*
|
||||
* 🔴 Carries the story's id and nothing else. The author had the story by
|
||||
* definition; the only way they no longer hold it is that their own timer
|
||||
* removed it, and sending a copy back would resurrect it at a moment they
|
||||
* did not choose.
|
||||
*/
|
||||
fun replyToStory(story: Vault.Story, body: String) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val trimmed = body.trim()
|
||||
if (trimmed.isEmpty() || story.mine) return@launch
|
||||
withContext(Dispatchers.IO) {
|
||||
// `story.id`, not `ref.id`: a text story (§6.21) has no blob and so
|
||||
// no blob id, and a reply has to be able to name one.
|
||||
runCatching { current.send(story.authorId, trimmed, storyReplyId = story.id) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Change who may see this account's stories. */
|
||||
fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
|
||||
@@ -337,11 +337,23 @@ class Session(
|
||||
link: Content.LinkPreview? = null,
|
||||
/** A timer for this one message, in seconds (§6.17); 0 for none. */
|
||||
lifetimeSeconds: Int = 0,
|
||||
|
||||
/**
|
||||
* The story this answers (§6.20), by id alone.
|
||||
*
|
||||
* 🔴 Never a copy of the story. The author had it by definition, so
|
||||
* the only way they no longer hold it is that their own timer removed
|
||||
* it — sending a copy back would resurrect it at a moment they did not
|
||||
* choose.
|
||||
*/
|
||||
storyReplyId: String? = null,
|
||||
): Conversations.Sent {
|
||||
// With no preview and no timer this is the raw body, byte for byte,
|
||||
// exactly as before — `Content.text` only builds an envelope when it
|
||||
// has to.
|
||||
val plaintext = Content.text(text, link = link, lifetimeSeconds = lifetimeSeconds)
|
||||
// With no preview, no timer and no story reference this is the raw
|
||||
// body, byte for byte, exactly as before — `Content.text` only builds
|
||||
// an envelope when it has to.
|
||||
val plaintext = Content.text(text, link = link,
|
||||
lifetimeSeconds = lifetimeSeconds,
|
||||
storyReplyId = storyReplyId)
|
||||
val sent = conversations.send(peerUserId, plaintext)
|
||||
val at = System.currentTimeMillis()
|
||||
// 🔴 The sender's own copy expires too. A timer that only cleared the
|
||||
|
||||
Reference in New Issue
Block a user