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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user