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
@@ -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).