React to a story with one tap

Android half of ios#52, spec §6.22.

🔴 Decoded before §6.9's reaction path, which requires `about`. 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. Tested both ways,
including that an ordinary thread reaction still decodes as before.

⚠️ Its own disclosure and its own stored key. A reaction reveals that you
watched exactly as a reply does; what differs is the deliberation. A
reply is typed. A reaction is one tap on a story already filling the
screen, so an account told about replies has been warned about the
considered case and not the careless one.

⚠️ The row does not share a gesture with anything that navigates: an
acknowledgement delivered by a mis-tap is a disclosure the person did not
make.

Branched from feat/story-replies, which carries the `sre` plumbing this
reuses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 17:52:32 -04:00
co-authored by Claude Opus 4.8
parent 52a793ce5c
commit 8b719032c2
6 changed files with 210 additions and 8 deletions
+17 -8
View File
@@ -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 | | 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 | | 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 | | 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 | | Reactions | 🟡 codec done, no UI |
| Search inside messages | ✅ scanned, never indexed — no plaintext index at rest | | 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 | | 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 | | Photos | ✅ send via the system picker, render inline |
| Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send | | 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. untimed conversation start reporting.
⚠️ **Last-notice times are in memory on both.** Persisting them would store who ⚠️ **Last-notice times are in memory on both.** Persisting them would store who
screenshotted whom, on disk, to enforce a five-second window. 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) ## Text and link stories (§6.21, ios#32)
A story may carry no attachment; the caption is then the whole story. Both A story may carry no attachment; the caption is then the whole story. Both
platforms encode, decode, store, render and post one. 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. ⚠️ 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, Client-enforced expiry is the whole mechanism rather than a backstop to one,
and both composers say so instead of implying more. 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) ## Saving your own story (ios#34)
Both platforms can write a story you posted yourself out to the device gallery Both platforms can write a story you posted yourself out to the device gallery
before it expires. Local only; no protocol change. 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. 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 ⚠️ 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. 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) ## Story replies (§6.20, ios#30)
A reply to a story is an ordinary direct message to its author carrying 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.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. ⚠️ **The disclosure comes before the first reply**, on both platforms.
Replying tells the author you watched, which stories otherwise never reveal. Replying tells the author you watched, which stories otherwise never reveal.
Neither platform sends anything automatic when a story is merely opened. 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) ## Private story archive (ios#54)
Your own stories, kept past their expiry. Local only; no protocol change. Your own stories, kept past their expiry. Local only; no protocol change.
🔴 **This keeps something the interface told everybody would disappear**, so 🔴 **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 ⚠️ Android injects the setting into `Session` as a lambda rather than reading
preferences in the engine layer, which has no Android dependency. An absent preferences in the engine layer, which has no Android dependency. An absent
value reads as OFF on both platforms. value reads as OFF on both platforms.
||||||| parent of f67edeb (Post several pictures as one story sequence)
## Multi-item stories (§6.23, ios#53) ## Multi-item stories (§6.23, ios#53)
Several pictures as one sequence. Each item is an ordinary story envelope Several pictures as one sequence. Each item is an ordinary story envelope
carrying a `set` that names the sequence, its position and its size. 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 ⚠️ **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 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. 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.
@@ -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<String?>(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 // §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. // comment on the story. Absent on your own stories: nobody to send to.
if (!story.mine) { 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" 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. * Add and remove people after a group exists.
* *
@@ -2059,6 +2059,22 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
update { it.copy(storyArchive = emptyList()) } 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. */ /** Change who may see this account's stories. */
fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch { fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch {
@@ -1132,6 +1132,20 @@ class Session(
/** Empty the archive (ios#54). */ /** Empty the archive (ios#54). */
fun clearStoryArchive() = vault.saveStoryArchive(emptyList()) 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 * 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 * us (audit H1). Cached for a short window: a poll pass can carry many
@@ -515,6 +515,20 @@ data class Content(
* message rather than an absence: the other device has already rendered * message rather than an absence: the other device has already rendered
* the first one, and there is no other way to reach it. * 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 = fun reaction(emoji: String, about: Reference, groupId: String? = null): String =
assemble("react", caption = emoji, groupId = groupId, about = about) 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. // A reaction is not a message and must never become a bubble.
// Checked before everything else so a field-ordering change cannot // Checked before everything else so a field-ordering change cannot
// let one fall through and render as an empty line in the thread. // 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) { if (kind == Kind.REACT && about != null) {
return Content("", kind = kind, raw = json, groupId = groupId, return Content("", kind = kind, raw = json, groupId = groupId,
messageId = messageId, about = about, reaction = caption) messageId = messageId, about = about, reaction = caption)
@@ -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"))
}
}