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
@@ -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
// 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.
*
@@ -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 {
@@ -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