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:
Paul Hitt
2026-09-02 17:50:17 -04:00
co-authored by Claude Opus 4.8
parent b25481c262
commit 52a793ce5c
6 changed files with 77 additions and 4 deletions
+1
View File
@@ -710,3 +710,4 @@ would kill the whole set.
⚠️ **Grouping renders what arrived.** `n` is the sender's claim and any item can
fail to arrive, so a set is never held back or shown as incomplete. Stories with
no set are keyed so two of them cannot collide into one group.
||||||| parent of f3db054d (Reply to a story, as a sealed direct message)
@@ -2647,6 +2647,75 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor
)
}
}
// §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") }
},
)
}
}
}
}
@@ -2059,6 +2059,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
update { it.copy(storyArchive = emptyList()) }
}
/** Change who may see this account's stories. */
fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch {
val current = session ?: return@launch
@@ -338,6 +338,7 @@ class Session(
/** 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.
*
@@ -429,6 +429,7 @@ data class Content(
forwarded: Boolean = false,
lifetimeSeconds: Int? = null,
/** The story this answers (§6.20). */
storyReplyId: String? = null,
): String =
@@ -585,11 +586,8 @@ data class Content(
forwarded: Boolean = false,
mentions: List<Long> = emptyList(),
lifetimeSeconds: Int? = null,
background: Int? = null,
storyReplyId: String? = null,
storySet: StorySet? = null,
): String {
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
@@ -188,7 +188,10 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
* as "expires at the epoch" — which would kill the whole set.
*/
fun setExpiry(items: List<Story>): Double =
items.mapNotNull { it.ref.expires.takeIf { e -> e > 0 } }.minOrNull() ?: 0.0
// ⚠️ `ref?`: a text story (§6.21) has no blob and so no expiry of
// its own. It contributes nothing rather than a zero, which
// `minOrNull` would otherwise pick and expire the whole set.
items.mapNotNull { it.ref?.expires?.takeIf { e -> e > 0 } }.minOrNull() ?: 0.0
/**
* Group one author's stories into sets, in position order (§6.23).