diff --git a/PARITY.md b/PARITY.md index 3257865..01c5b76 100644 --- a/PARITY.md +++ b/PARITY.md @@ -100,10 +100,8 @@ From `E2E/E2EInbox.swift`, which is the real feature list. | Group message ids and quotes (§6.9) | ✅ transcript stores both; sent and queued copies share one id | | Mentions (§6.18) | 🟡 codec done, in progress — an inbound mention parses and is bounded; composing and notifying need the UI | | Per-message timers (§6.17) | ✅ combining rule and per-message pruning; composer control in progress | -||||||| parent of fa9fdf4 (Search inside messages, across every conversation) | Reactions | 🟡 codec done, no UI | | Search inside messages | ✅ scanned, never indexed — no plaintext index at rest | -||||||| parent of 9d33e60 (Recognise a pinned message (§6.19)) | Pinned message (§6.19) | 🟡 codec done, in progress — an inbound pin is recognised and not drawn as an empty bubble | | Photos | ✅ send via the system picker, render inline | | Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send | @@ -619,7 +617,6 @@ runs BEFORE the rate-limit check on both, so a suppression window cannot make an untimed conversation start reporting. ⚠️ **Last-notice times are in memory on both.** Persisting them would store who screenshotted whom, on disk, to enforce a five-second window. -||||||| parent of 487cead (Post a story that is only words, or a link) ## Text and link stories (§6.21, ios#32) A story may carry no attachment; the caption is then the whole story. Both platforms encode, decode, store, render and post one. @@ -638,7 +635,6 @@ empty object; a reload would otherwise resurrect a blob that fails to fetch. ⚠️ A text story has no blob, so nothing dies at the server's retention purge. Client-enforced expiry is the whole mechanism rather than a backstop to one, and both composers say so instead of implying more. -||||||| parent of 91c5181 (Save a story you posted yourself, before it expires) ## Saving your own story (ios#34) Both platforms can write a story you posted yourself out to the device gallery before it expires. Local only; no protocol change. @@ -655,7 +651,6 @@ 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. @@ -671,7 +666,6 @@ 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. -||||||| parent of 4c004eb (Keep your own stories in a private archive) ## Private story archive (ios#54) Your own stories, kept past their expiry. Local only; no protocol change. 🔴 **This keeps something the interface told everybody would disappear**, so @@ -691,7 +685,6 @@ Android has a separate record and a test named for exactly that. ⚠️ Android injects the setting into `Session` as a lambda rather than reading preferences in the engine layer, which has no Android dependency. An absent value reads as OFF on both platforms. -||||||| parent of f67edeb (Post several pictures as one story sequence) ## Multi-item stories (§6.23, ios#53) Several pictures as one sequence. Each item is an ordinary story envelope carrying a `set` that names the sequence, its position and its size. @@ -710,4 +703,20 @@ 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) + +## Story reactions (§6.22, ios#52) +One tap, sealed to the author like a story reply. +🔴 **Decoded before §6.9's reaction path on both platforms.** That path requires +`re`; a story reaction carries `sre` instead. A story is not in the conversation +and has no `mid` there, so one carrying `re` would fold onto whatever message +happened to share that id — or onto nothing. Falling through would leave it +unhandled and drawn as an empty line in the thread. Both engines test it in both +directions, including that an ordinary thread reaction is untouched. +⚠️ **Its own disclosure and its own stored key** on both — `reactDisclosureSeen`, +not the reply one. A reaction discloses exactly what a reply discloses; what +differs is deliberation. A reply is typed; a reaction is one tap on something +already filling the screen, so an account told about replies has been warned +about the considered case and not the careless one. +⚠️ Neither platform puts the control on a gesture that advances or dismisses a +story. An acknowledgement delivered by a mis-tap is a disclosure the person did +not make. 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 966aad3..05b0ff2 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -2648,6 +2648,63 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor } } + // §6.22 — one tap. Its own row, ABOVE the reply field: a reaction + // delivered by a mis-tap is a disclosure the person did not make, so + // it does not share a gesture with anything that navigates. + if (!story.mine) { + val ctx = LocalContext.current + val reactPrefs = remember { + ctx.getSharedPreferences("flamenet-settings", android.content.Context.MODE_PRIVATE) + } + var pending by remember(story.id) { mutableStateOf(null) } + Row( + modifier = Modifier.fillMaxWidth().padding(top = Space.sm), + horizontalArrangement = Arrangement.spacedBy(Space.md), + ) { + listOf("\u2764\uFE0F", "\uD83D\uDE02", "\uD83D\uDE2E", + "\uD83D\uDE22", "\uD83D\uDC4F", "\uD83D\uDD25").forEach { emoji -> + Text( + emoji, + fontSize = 24.sp, + modifier = Modifier + .testTag("story-react-$emoji") + .clickable { + if (reactPrefs.getBoolean(STORY_REACT_DISCLOSURE_SEEN, false)) { + model.reactToStory(story, emoji) + } else { + pending = emoji + } + }, + ) + } + } + pending?.let { emoji -> + androidx.compose.material3.AlertDialog( + onDismissRequest = { pending = null }, + title = { Text("Reacting tells them you watched") }, + text = { + Text( + "Stories have no viewer list, so they cannot see who opened " + + "this one. Sending a reaction tells them you did — the " + + "same as a reply, from one tap." + ) + }, + confirmButton = { + androidx.compose.material3.TextButton(onClick = { + reactPrefs.edit() + .putBoolean(STORY_REACT_DISCLOSURE_SEEN, true).apply() + model.reactToStory(story, emoji) + pending = null + }) { Text("Send it") } + }, + dismissButton = { + androidx.compose.material3.TextButton( + onClick = { pending = null }) { Text("Cancel") } + }, + ) + } + } + // §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) { @@ -2728,6 +2785,18 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor */ private const val STORY_REPLY_DISCLOSURE_SEEN = "stories.replyDisclosureSeen" +/** + * Whether this account has been told that REACTING to a story reveals they + * watched it (§6.22). + * + * 🔴 **A separate key from the reply one, deliberately.** A reaction discloses + * exactly what a reply discloses; what differs is the deliberation. A reply is + * typed. A reaction is one tap on something already filling the screen — so an + * account told about replies has been warned about the considered case and not + * about the careless one. + */ +private const val STORY_REACT_DISCLOSURE_SEEN = "stories.reactDisclosureSeen" + /** * 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 e2dd0ae..f1b769d 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,22 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { update { it.copy(storyArchive = emptyList()) } } + /** + * React to a story with one tap (§6.22). + * + * 🔴 The caller must have disclosed that this tells the author you + * watched — separately from the reply disclosure, because a reply is typed + * and this is one tap on something already filling the screen. + */ + fun reactToStory(story: Vault.Story, emoji: String) = viewModelScope.launch { + val current = session ?: return@launch + if (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 reaction has to be able to name one. + runCatching { current.reactToStory(emoji, story.id, story.authorId) } + } + } /** Change who may see this account's stories. */ fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.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 133a5e4..6963235 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -1132,6 +1132,20 @@ class Session( /** Empty the archive (ios#54). */ fun clearStoryArchive() = vault.saveStoryArchive(emptyList()) + /** + * React to a story with one tap (§6.22). + * + * ⚠️ An empty emoji withdraws one already sent. Removal is a message + * rather than an absence: the author's device has drawn the first one. + * + * 🔴 Tells the author you watched, exactly as a reply does. The caller + * must have disclosed that — separately from the reply disclosure, since + * a reply is typed and this is one tap. + */ + fun reactToStory(emoji: String, storyId: String, authorId: Long) { + conversations.send(authorId, Content.storyReaction(emoji, storyId)) + } + /** * Members of a group we are in, or null if the relay does not list it for * us (audit H1). Cached for a short window: a poll pass can carry many diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index f0977c7..c01801d 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -515,6 +515,20 @@ data class Content( * message rather than an absence: the other device has already rendered * the first one, and there is no other way to reach it. */ + /** + * A one-tap reaction to a STORY (§6.22). + * + * 🔴 **No `re`.** §6.9's reaction names a message by `mid` and folds + * onto it; a story is not in the conversation and has no `mid` there, + * so a story reaction carrying `re` would fold onto whatever message + * happened to share that id — or onto nothing. + * + * ⚠️ An empty emoji withdraws one already sent: the author's device + * has drawn the first one and there is no other way to reach it. + */ + fun storyReaction(emoji: String, storyId: String): String = + assemble(Kind.REACT, caption = emoji, storyReplyId = storyId) + fun reaction(emoji: String, about: Reference, groupId: String? = null): String = assemble("react", caption = emoji, groupId = groupId, about = about) @@ -757,6 +771,15 @@ data class Content( // A reaction is not a message and must never become a bubble. // Checked before everything else so a field-ordering change cannot // let one fall through and render as an empty line in the thread. + // §6.22 — a reaction to a STORY. Checked before §6.9's path, + // which requires `about`: this one carries `sre` instead, and + // falling through would leave it unhandled and drawn as an empty + // line in the thread. + if (kind == Kind.REACT && storyReplyId != null) { + return Content("", kind = kind, raw = json, reaction = caption, + storyReplyId = storyReplyId) + } + if (kind == Kind.REACT && about != null) { return Content("", kind = kind, raw = json, groupId = groupId, messageId = messageId, about = about, reaction = caption) diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StoryReactionTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StoryReactionTest.kt new file mode 100644 index 0000000..79897cb --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/StoryReactionTest.kt @@ -0,0 +1,71 @@ +package io.flamenet.e2e + +import org.json.JSONObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** One-tap reactions to a story (spec §6.22, ios#52). */ +class StoryReactionTest { + + @Test + fun `a story reaction carries the emoji and the story`() { + val content = Content.parse(Content.storyReaction("👍", "s7")) + assertEquals("👍", content.reaction) + assertEquals("s7", content.storyReplyId) + } + + /** + * 🔴 **Why §6.22 is its own path.** §6.9's reaction names a message by + * `mid` and folds onto it. A story is not in the conversation and has no + * `mid` there, so one carrying `re` would fold onto whatever message + * happened to share that id — or onto nothing. + */ + @Test + fun `a story reaction carries no message reference`() { + val json = JSONObject(Content.storyReaction("👍", "s7")) + assertFalse(json.has("re")) + assertTrue(json.has("sre")) + } + + /** 🔴 Decoded before §6.9's path, which requires `re`. */ + @Test + fun `a story reaction is never a thread reaction`() { + val content = Content.parse( + """{"fnc":1,"kind":"react","caption":"x","sre":{"sid":"s7"}}""") + assertNull(content.about) + assertEquals("s7", content.storyReplyId) + assertTrue(content.text.isEmpty()) + } + + /** An ordinary §6.9 reaction must be untouched by this. */ + @Test + fun `a thread reaction still decodes as before`() { + val about = Content.Reference("m1", 3, "hi") + val content = Content.parse(Content.reaction("🎉", about)) + assertEquals("m1", content.about?.messageId) + assertNull(content.storyReplyId) + assertEquals("🎉", content.reaction) + } + + /** ⚠️ An empty emoji withdraws one already sent. */ + @Test + fun `an empty emoji is a withdrawal and still carries the story`() { + val content = Content.parse(Content.storyReaction("", "s7")) + assertEquals("", content.reaction) + assertEquals("s7", content.storyReplyId) + } + + /** A story reaction is not a bubble. */ + @Test + fun `a story reaction is not renderable`() { + assertFalse(Content.parse(Content.storyReaction("x", "s7")).isRenderable) + } + + @Test + fun `the marker stays first`() { + assertTrue(Content.storyReaction("x", "s").startsWith("{\"fnc\":1")) + } +}