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
@@ -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"))
}
}