Reply to a story, as a sealed direct message #48

Merged
paulhitt merged 1 commits from feat/story-replies into main 2026-09-02 21:47:01 +00:00
6 changed files with 250 additions and 12 deletions
+16
View File
@@ -655,3 +655,19 @@ much larger ask than the feature.
terminated on first save rather than warned. Android has no equivalent trap.
⚠️ Both messages say the saved copy has left the disappearing model, and where
it went. "Saved" alone would let somebody assume the expiry still covered it.
||||||| parent of f3db054d (Reply to a story, as a sealed direct message)
## Story replies (§6.20, ios#30)
A reply to a story is an ordinary direct message to its author carrying
`sre.sid`. Stories gain no comment thread and no viewer list.
🔴 **`sre` carries the id and nothing else** — no caption, no thumbnail, no
blob. This diverges from §6.9's `re` on purpose: a quoted message may be one
the recipient never had, but a story's 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.
🔴 **`Content.text` returns raw bytes when it has nothing to carry**, so
`storyReplyId` had to join that condition. Left out, a reply with no message id
and no quote would go out as plain text and arrive with the reference silently
dropped — on exactly the path a story reply normally takes.
⚠️ **The disclosure comes before the first reply**, on both platforms.
Replying tells the author you watched, which stories otherwise never reveal.
Neither platform sends anything automatic when a story is merely opened.
@@ -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
@@ -102,6 +102,18 @@ data class Content(
* rather than refusing the story.
*/
val storyBackground: Int? = null,
/**
* The story this message answers (§6.20), by id alone.
*
* 🔴 **No copy of the story travels with it** — no caption, no thumbnail,
* no blob. This is where §6.20 diverges from §6.9's `re`, and the reason
* is expiry: the 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 —
* the trap §6.19 documents for pins, through a different door.
*/
val storyReplyId: String? = null,
) {
/**
@@ -392,18 +404,23 @@ data class Content(
link: LinkPreview? = null,
forwarded: Boolean = false,
lifetimeSeconds: Int? = null,
/** The story this answers (§6.20). */
storyReplyId: String? = null,
): String =
// ⚠️ Both of these belong in this condition, not just in the
// envelope. A plain message with nothing else to carry goes out as
// raw bytes with no envelope at all — so a forward that was not
// ⚠️ Every one of these belongs in this condition, not just in
// the envelope. A plain message with nothing else to carry goes out
// as raw bytes with no envelope at all — so a forward that was not
// counted here would lose its flag and arrive looking like the
// sender's own words, and a timed message would lose its timer and
// live for ever.
// sender's own words, a timed message would lose its timer and live
// for ever, and a story reply would arrive as an ordinary message
// with the reference silently dropped.
if (messageId == null && about == null && link == null && !forwarded
&& (lifetimeSeconds ?: 0) <= 0) body
&& (lifetimeSeconds ?: 0) <= 0 && storyReplyId == null) body
else assemble("text", caption = body, messageId = messageId, about = about,
link = link, forwarded = forwarded,
lifetimeSeconds = lifetimeSeconds)
lifetimeSeconds = lifetimeSeconds,
storyReplyId = storyReplyId)
/**
* A story that is only its caption (§6.21).
@@ -534,6 +551,8 @@ data class Content(
lifetimeSeconds: Int? = null,
background: Int? = null,
storyReplyId: String? = null,
): String {
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
// ⚠️ `1`, not `true` — §6.10's shape for every flag on this
@@ -551,6 +570,8 @@ data class Content(
}
messageId?.let { parts += "\"mid\":${quote(it)}" }
about?.let { parts += "\"re\":${it.toJson()}" }
// §6.20 — the story being answered. An id and nothing else.
storyReplyId?.let { parts += "\"sre\":{\"sid\":${quote(it)}}" }
caption?.let { parts += "\"caption\":${quote(it)}" }
expires?.let { parts += "\"exp\":$it" }
groupId?.let { parts += "\"gid\":${quote(it)}" }
@@ -689,6 +710,9 @@ data class Content(
val groupId = json.optString("gid").takeIf { it.isNotEmpty() }
val messageId = json.optString("mid").takeIf { it.isNotEmpty() }
val about = json.optJSONObject("re")?.let(Reference::fromJson)
// §6.20 — id only. Anything else in `sre` is ignored by design.
val storyReplyId = json.optJSONObject("sre")
?.optString("sid")?.takeIf { it.isNotEmpty() }
val caption = json.optString("caption")
// A reaction is not a message and must never become a bubble.
@@ -771,7 +795,8 @@ data class Content(
if (kind == Kind.TEXT) {
val link = json.optJSONObject("lnk")?.let { LinkPreview.fromJson(it) }
return Content(caption, kind = kind, raw = json, groupId = groupId, link = link,
messageId = messageId, about = about, mentions = mentions(json))
messageId = messageId, about = about, mentions = mentions(json),
storyReplyId = storyReplyId)
}
val ref = json.optJSONObject("att")
@@ -0,0 +1,85 @@
package io.flamenet.e2e
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Story replies as sealed direct messages (spec §6.20, ios#30).
*
* A story keeps no comment thread and no viewer list. A reply is an ordinary
* direct message to the author carrying the story's id — and nothing else.
*/
class StoryReplyTest {
@Test
fun `a reply carries the story id`() {
val wire = Content.text("where is this?", messageId = "m1", storyReplyId = "story-7")
val content = Content.parse(wire)
assertEquals("story-7", content.storyReplyId)
assertEquals("where is this?", content.text)
}
/**
* 🔴 **The whole point of the section.** A reply that carried the caption
* or a thumbnail would put an expired story back on the author's screen —
* the failure §6.19 documents for pins, arriving by a different door.
*/
@Test
fun `the story reference carries nothing but the id`() {
val wire = Content.text("nice", messageId = "m1", storyReplyId = "story-7")
val sre = JSONObject(wire).getJSONObject("sre")
assertEquals(setOf("sid"), sre.keys().asSequence().toSet())
}
/**
* 🔴 **The raw-bytes shortcut is the trap.** `text()` returns the body
* unwrapped when there is nothing to carry. Without `storyReplyId` in that
* condition, a reply with no message id and no quote goes out as plain
* text and arrives with the reference silently dropped — on exactly the
* path a story reply normally takes.
*/
@Test
fun `a reply with nothing else attached is still an envelope`() {
val wire = Content.text("nice", storyReplyId = "story-7")
assertTrue(wire.startsWith("{\"fnc\":1"), wire)
assertEquals("story-7", Content.parse(wire).storyReplyId)
}
@Test
fun `an ordinary message carries no story reference`() {
val wire = Content.text("hello", messageId = "m1")
assertTrue(!wire.contains("sre"), wire)
assertNull(Content.parse(wire).storyReplyId)
}
/** ⚠️ `fnc` must stay first: the receive side recognises it by prefix. */
@Test
fun `the marker stays first`() {
assertTrue(Content.text("hi", storyReplyId = "s1").startsWith("{\"fnc\":1"))
}
/** A newer peer may add fields to `sre`; the message must still read. */
@Test
fun `an unknown story reference shape does not break the message`() {
val wire = """{"fnc":1,"kind":"text","caption":"hi","sre":{"sid":"s1","extra":9}}"""
val content = Content.parse(wire)
assertEquals("hi", content.text)
assertEquals("s1", content.storyReplyId)
}
/** An `sre` with no usable id is absent, not an empty string. */
@Test
fun `an empty story id reads as no reference`() {
val wire = """{"fnc":1,"kind":"text","caption":"hi","sre":{"sid":""}}"""
assertNull(Content.parse(wire).storyReplyId)
}
/** A story reply is a renderable message, not control traffic. */
@Test
fun `a story reply is renderable`() {
assertTrue(Content.parse(Content.text("hi", storyReplyId = "s1")).isRenderable)
}
}