From 52a793ce5cf3119bf7c3e489e3ed8fbb3779ca7f Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Wed, 2 Sep 2026 09:40:29 -0400 Subject: [PATCH] Reply to a story, as a sealed direct message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PARITY.md | 1 + .../io/flamenet/messenger/app/ChatScreens.kt | 69 +++++++++++++++++++ .../messenger/app/MessengerViewModel.kt | 1 + .../io/flamenet/messenger/app/Session.kt | 1 + .../main/kotlin/io/flamenet/e2e/Content.kt | 4 +- .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 5 +- 6 files changed, 77 insertions(+), 4 deletions(-) diff --git a/PARITY.md b/PARITY.md index 090f0f8..3257865 100644 --- a/PARITY.md +++ b/PARITY.md @@ -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) 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 65a2d4e..966aad3 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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") } + }, + ) + } + } } } 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 50433f2..e2dd0ae 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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 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 add41b5..133a5e4 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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. * diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index 4ddd609..f0977c7 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -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 = emptyList(), lifetimeSeconds: Int? = null, - background: Int? = null, - storyReplyId: String? = null, - storySet: StorySet? = null, ): String { val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}") diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 551d58d..c4058e8 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -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): 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).