diff --git a/PARITY.md b/PARITY.md index 3835f58..fe4ac5d 100644 --- a/PARITY.md +++ b/PARITY.md @@ -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. diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 2f03c0b..a059ccb 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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. * diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 0ec7fb3..f363cf1 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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 diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 923923e..c3950f6 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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 diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index 4305e28..f627842 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -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") diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StoryReplyTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StoryReplyTest.kt new file mode 100644 index 0000000..c1f0345 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/StoryReplyTest.kt @@ -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) + } +}