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:
co-authored by
Claude Opus 4.8
parent
52a793ce5c
commit
8b719032c2
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user