Private reactions: react to one person inside a group
React to a message in a group and only its author sees it. A quiet note without turning it into an event for everybody else. The reaction is sent to the author and to nobody else, which is the whole guarantee: asking the other members' apps not to display it would be a request, and a request is not privacy. The envelope carries a flag so the author's app can label what it got, not to ask anyone to hide anything. Private reactions are kept apart from the public tally on both devices that hold them, and drawn with a label, so one is never mistaken for something the group watched arrive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
43baac80b7
commit
8037a299b5
@@ -1809,6 +1809,22 @@ fun ColumnScope.ChatScreen(
|
||||
// The reactions are IN the menu, as its first row, rather
|
||||
// than behind a "React" item that opens a second one.
|
||||
if (line.id != null) {
|
||||
// Off every time the menu opens: a reaction meant for
|
||||
// one person should be chosen on purpose, not inherited
|
||||
// from the last time.
|
||||
var privately by remember(menuOpen) { mutableStateOf(false) }
|
||||
if (chat.group != null && !line.mine) {
|
||||
Text(
|
||||
if (privately) "Just to them, on" else "Just to them",
|
||||
fontSize = 12.sp,
|
||||
color = if (privately) Palette.Accent else Palette.SecondaryText,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp)
|
||||
.clickable { privately = !privately }
|
||||
.testTag("react-private-toggle"),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(Space.xs),
|
||||
modifier =
|
||||
@@ -1817,7 +1833,12 @@ fun ColumnScope.ChatScreen(
|
||||
.testTag("react-row"),
|
||||
) {
|
||||
listOf("❤️", "😂", "😮", "😢", "🔥").forEach { emoji ->
|
||||
val already = line.reactions[emoji]?.contains(myUserId) == true
|
||||
val already =
|
||||
if (privately) {
|
||||
line.privateReactions[emoji]?.contains(myUserId) == true
|
||||
} else {
|
||||
line.reactions[emoji]?.contains(myUserId) == true
|
||||
}
|
||||
Text(
|
||||
emoji,
|
||||
fontSize = 22.sp,
|
||||
@@ -1833,7 +1854,12 @@ fun ColumnScope.ChatScreen(
|
||||
).clickable {
|
||||
// Tapping the one already sent
|
||||
// takes it back.
|
||||
model.reactToMessage(if (already) "" else emoji, line, chat)
|
||||
model.reactToMessage(
|
||||
if (already) "" else emoji,
|
||||
line,
|
||||
chat,
|
||||
privately = privately,
|
||||
)
|
||||
menuOpen = false
|
||||
}.padding(4.dp)
|
||||
.testTag("react-$emoji"),
|
||||
@@ -2480,7 +2506,7 @@ private fun Bubble(
|
||||
line.link?.let { LinkPreviewCard(it, mine = line.mine) }
|
||||
}
|
||||
// Under the bubble, not inside it.
|
||||
if (line.reactions.isNotEmpty()) {
|
||||
if (line.reactions.isNotEmpty() || line.privateReactions.isNotEmpty()) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(Space.xs),
|
||||
modifier =
|
||||
@@ -2500,6 +2526,21 @@ private fun Bubble(
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
// Labelled, because a reaction only two people can see
|
||||
// should not be mistaken for one the group watched arrive.
|
||||
line.privateReactions.entries.sortedBy { it.key }.forEach { (emoji, who) ->
|
||||
Text(
|
||||
if (who.size == 1) "$emoji private" else "$emoji ${who.size} private",
|
||||
fontSize = 13.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.testTag("private-reactions"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2981,6 +2981,7 @@ class MessengerViewModel(
|
||||
emoji: String,
|
||||
line: Session.Line,
|
||||
chat: Phase.Chat,
|
||||
privately: Boolean = false,
|
||||
) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val mid = line.id ?: return@launch
|
||||
@@ -3001,6 +3002,7 @@ class MessengerViewModel(
|
||||
author,
|
||||
peerUserId = chat.peerId,
|
||||
group = chat.group,
|
||||
privateToAuthor = privately,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,11 @@ class Session(
|
||||
* filed as one of their own.
|
||||
*/
|
||||
val reactions: Map<String, Set<Long>> = emptyMap(),
|
||||
/**
|
||||
* Reactions sent to the author alone. Present only on the two devices
|
||||
* entitled to see them: the author's and the sender's.
|
||||
*/
|
||||
val privateReactions: Map<String, Set<Long>> = emptyMap(),
|
||||
/**
|
||||
* Who wrote it, a user id, 0 when unrecorded.
|
||||
*/
|
||||
@@ -94,18 +99,8 @@ class Session(
|
||||
mine = m.optBoolean("mine"),
|
||||
at = m.optLong("at"),
|
||||
id = m.optString("mid").takeIf { it.isNotBlank() },
|
||||
reactions =
|
||||
m
|
||||
.optJSONObject("rx")
|
||||
?.let { stored ->
|
||||
stored
|
||||
.keys()
|
||||
.asSequence()
|
||||
.associateWith { emoji ->
|
||||
val who = stored.optJSONArray(emoji) ?: org.json.JSONArray()
|
||||
(0 until who.length()).map { i -> who.getLong(i) }.toSet()
|
||||
}.filterValues { it.isNotEmpty() }
|
||||
}.orEmpty(),
|
||||
reactions = reactionsUnder(m, "rx"),
|
||||
privateReactions = reactionsUnder(m, "rxp"),
|
||||
author = m.optLong("author"),
|
||||
attachment =
|
||||
m.optJSONObject("att")?.let { att ->
|
||||
@@ -680,22 +675,44 @@ class Session(
|
||||
* `text` is the ratchet PLAINTEXT, not necessarily prose.
|
||||
*/
|
||||
|
||||
/** The reactions stored under one key of a history row. */
|
||||
private fun reactionsUnder(
|
||||
row: JSONObject,
|
||||
key: String,
|
||||
): Map<String, Set<Long>> =
|
||||
row
|
||||
.optJSONObject(key)
|
||||
?.let { stored ->
|
||||
stored
|
||||
.keys()
|
||||
.asSequence()
|
||||
.associateWith { emoji ->
|
||||
val who = stored.optJSONArray(emoji) ?: org.json.JSONArray()
|
||||
(0 until who.length()).map { i -> who.getLong(i) }.toSet()
|
||||
}.filterValues { it.isNotEmpty() }
|
||||
}.orEmpty()
|
||||
|
||||
/**
|
||||
* Fold a reaction onto the message it is about.
|
||||
*
|
||||
* A private one is kept apart from the public tally, so the author can see
|
||||
* it was meant for them alone and it never reads as something the group saw.
|
||||
*/
|
||||
fun applyReaction(
|
||||
threadId: String,
|
||||
messageId: String,
|
||||
author: Long,
|
||||
emoji: String,
|
||||
private: Boolean = false,
|
||||
): Boolean {
|
||||
val key = if (private) "rxp" else "rx"
|
||||
val stored = vault.history(threadId)
|
||||
var hit = false
|
||||
for (i in 0 until stored.length()) {
|
||||
val row = stored.getJSONObject(i)
|
||||
if (row.optString("mid") != messageId) continue
|
||||
hit = true
|
||||
val existing = row.optJSONObject("rx") ?: JSONObject()
|
||||
val existing = row.optJSONObject(key) ?: JSONObject()
|
||||
val rebuilt = JSONObject()
|
||||
for (key in existing.keys()) {
|
||||
val who = existing.optJSONArray(key) ?: org.json.JSONArray()
|
||||
@@ -710,7 +727,7 @@ class Session(
|
||||
who.put(author)
|
||||
rebuilt.put(emoji, who)
|
||||
}
|
||||
if (rebuilt.length() > 0) row.put("rx", rebuilt) else row.remove("rx")
|
||||
if (rebuilt.length() > 0) row.put(key, rebuilt) else row.remove(key)
|
||||
break
|
||||
}
|
||||
if (hit) vault.replaceHistory(threadId, stored)
|
||||
@@ -773,21 +790,33 @@ class Session(
|
||||
author: Long,
|
||||
peerUserId: Long?,
|
||||
group: RelayClient.Group?,
|
||||
privateToAuthor: Boolean = false,
|
||||
): Boolean {
|
||||
// Private only means something inside a group, and reacting privately
|
||||
// to your own message would be a note to yourself.
|
||||
val private = privateToAuthor && group != null && author != userId
|
||||
val previous =
|
||||
history(threadId)
|
||||
.firstOrNull { line -> line.id == messageId }
|
||||
?.reactions
|
||||
?.let { line -> if (private) line.privateReactions else line.reactions }
|
||||
?.entries
|
||||
?.firstOrNull { entry -> userId in entry.value }
|
||||
?.key
|
||||
.orEmpty()
|
||||
if (!applyReaction(threadId, messageId, userId, emoji)) return false
|
||||
if (!applyReaction(threadId, messageId, userId, emoji, private = private)) return false
|
||||
val reference = Content.Reference(messageId, author, "")
|
||||
val body = Content.reaction(emoji, reference, groupId = group?.id)
|
||||
val sent = sendToThread(body, peerUserId, group)
|
||||
val body = Content.reaction(emoji, reference, groupId = group?.id, privateToAuthor = private)
|
||||
// The whole guarantee: a private reaction goes to the author and to
|
||||
// nobody else. Asking the other members' apps not to show it would be a
|
||||
// request, and a request is not privacy.
|
||||
val sent =
|
||||
if (private) {
|
||||
runCatching { conversations.send(author, body).reachedSomeone }.getOrDefault(false)
|
||||
} else {
|
||||
sendToThread(body, peerUserId, group)
|
||||
}
|
||||
if (!sent) {
|
||||
applyReaction(threadId, messageId, userId, previous)
|
||||
applyReaction(threadId, messageId, userId, previous, private = private)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -1259,6 +1288,7 @@ class Session(
|
||||
about.messageId,
|
||||
message.fromUser,
|
||||
message.content.reaction.orEmpty(),
|
||||
private = message.content.reactionIsPrivate,
|
||||
)
|
||||
) {
|
||||
touched += thread
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* A private reaction is private because of who it is sent to, not because of
|
||||
* what it asks other clients to do.
|
||||
*
|
||||
* `Session` needs a vault and a relay, so this reads the source the way
|
||||
* `SecondDeviceCallTest` does.
|
||||
*/
|
||||
class PrivateReactionSendTest {
|
||||
private fun read(vararg paths: String): String = paths.map(::File).first { it.exists() }.readText()
|
||||
|
||||
private val session: String by lazy {
|
||||
read(
|
||||
"src/main/kotlin/io/flamenet/messenger/app/Session.kt",
|
||||
"app/src/main/kotlin/io/flamenet/messenger/app/Session.kt",
|
||||
)
|
||||
}
|
||||
|
||||
private fun between(
|
||||
from: String,
|
||||
to: String,
|
||||
): String {
|
||||
val start = session.indexOf(from)
|
||||
assertTrue(start >= 0, "no $from in Session.kt")
|
||||
val end = session.indexOf(to, start + from.length)
|
||||
assertTrue(end > start, "no $to after $from")
|
||||
return session.substring(start, end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a private reaction is sent to the author and to nobody else`() {
|
||||
val react = between("fun react(", "private fun sendToThread(")
|
||||
assertTrue(
|
||||
react.contains("conversations.send(author, body)"),
|
||||
"the private branch must address the author directly",
|
||||
)
|
||||
assertTrue(
|
||||
react.contains("sendToThread(body, peerUserId, group)"),
|
||||
"a public reaction still goes to the whole thread",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `private only applies inside a group, and never to your own message`() {
|
||||
val react = between("fun react(", "private fun sendToThread(")
|
||||
assertTrue(react.contains("group != null && author != userId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `private reactions are kept out of the public tally`() {
|
||||
val apply = between("fun applyReaction(", "fun applyEdit(")
|
||||
assertTrue(apply.contains("""if (private) "rxp" else "rx""""))
|
||||
assertTrue(session.contains("""privateReactions = reactionsUnder(m, "rxp")"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an arriving reaction keeps the flag it came with`() {
|
||||
assertTrue(session.contains("private = message.content.reactionIsPrivate"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the chat offers the choice, and labels what only two people can see`() {
|
||||
val chat =
|
||||
read(
|
||||
"src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt",
|
||||
"app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt",
|
||||
)
|
||||
assertTrue(chat.contains("react-private-toggle"), "no way to choose a private reaction")
|
||||
assertTrue(chat.contains("privately = privately"))
|
||||
assertTrue(chat.contains("line.privateReactions.entries"), "private reactions are not drawn")
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,12 @@ data class Content(
|
||||
val about: Reference? = null,
|
||||
/** Set iff this is a reaction: the emoji, or empty to take one back. */
|
||||
val reaction: String? = null,
|
||||
/**
|
||||
* Set iff this reaction went to the author of the message alone. Nobody
|
||||
* else in the group was sent it, which is what makes it private: the flag
|
||||
* is here so the author's app can label it, not to ask anybody to hide it.
|
||||
*/
|
||||
val reactionIsPrivate: Boolean = false,
|
||||
val call: CallSignal? = null,
|
||||
/** Set iff this is a sticker: which pack, and which one in it. */
|
||||
val sticker: Sticker? = null,
|
||||
@@ -453,7 +459,15 @@ data class Content(
|
||||
emoji: String,
|
||||
about: Reference,
|
||||
groupId: String? = null,
|
||||
): String = assemble("react", caption = emoji, groupId = groupId, about = about)
|
||||
privateToAuthor: Boolean = false,
|
||||
): String =
|
||||
assemble(
|
||||
"react",
|
||||
caption = emoji,
|
||||
groupId = groupId,
|
||||
about = about,
|
||||
privateToAuthor = privateToAuthor,
|
||||
)
|
||||
|
||||
/**
|
||||
* Hand-ordered like the rest: the receive side's marker check matches
|
||||
@@ -524,6 +538,7 @@ data class Content(
|
||||
label: Pair<Int, List<Int>>? = null,
|
||||
mentions: List<Int> = emptyList(),
|
||||
aboutAStory: Boolean = false,
|
||||
privateToAuthor: Boolean = false,
|
||||
forwarded: Boolean = false,
|
||||
lifetimeSeconds: Int? = null,
|
||||
storySet: StorySet? = null,
|
||||
@@ -554,6 +569,7 @@ data class Content(
|
||||
parts += "\"men\":${mentions.sorted().joinToString(",", "[", "]")}"
|
||||
}
|
||||
if (aboutAStory) parts += "\"st\":1"
|
||||
if (privateToAuthor) parts += "\"priv\":1"
|
||||
if (label != null) {
|
||||
parts += "\"cls\":${label.first}"
|
||||
parts += "\"cmp\":${label.second.sorted().joinToString(",", "[", "]")}"
|
||||
@@ -649,6 +665,7 @@ data class Content(
|
||||
val caption = json.optString("caption")
|
||||
// Additive keys.
|
||||
val aboutAStory = json.optInt("st", 0) != 0 || json.optBoolean("st", false)
|
||||
val privateReaction = json.optInt("priv", 0) != 0 || json.optBoolean("priv", false)
|
||||
val storySet = json.optJSONObject("set")?.let(StorySet::fromJson)
|
||||
val mentions =
|
||||
json.optJSONArray("men")?.let { array ->
|
||||
@@ -665,6 +682,7 @@ data class Content(
|
||||
messageId = messageId,
|
||||
about = about,
|
||||
reaction = caption,
|
||||
reactionIsPrivate = privateReaction,
|
||||
reactsToStory = aboutAStory,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* A reaction meant for the author alone.
|
||||
*
|
||||
* The flag is on the envelope so the author's app can label what it received.
|
||||
* It is not what makes the reaction private: nobody else is sent one.
|
||||
*/
|
||||
class PrivateReactionTest {
|
||||
private val about = Content.Reference("m-1", 9, "")
|
||||
|
||||
@Test
|
||||
fun `an ordinary reaction carries no flag`() {
|
||||
val wire = Content.reaction("+1", about, groupId = "g-1")
|
||||
assertFalse(wire.contains("priv"), "a public reaction must not be marked private")
|
||||
assertFalse(Content.parse(wire).reactionIsPrivate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a private reaction is marked, and reads back as one`() {
|
||||
val wire = Content.reaction("+1", about, groupId = "g-1", privateToAuthor = true)
|
||||
assertTrue(wire.contains("\"priv\":1"))
|
||||
val read = Content.parse(wire)
|
||||
assertTrue(read.reactionIsPrivate)
|
||||
assertEquals("+1", read.reaction)
|
||||
assertEquals("g-1", read.groupId)
|
||||
assertEquals("m-1", read.about?.messageId)
|
||||
}
|
||||
|
||||
/** An older build sends no flag at all, and that must read as public. */
|
||||
@Test
|
||||
fun `an envelope without the key is public`() {
|
||||
val read = Content.parse("""{"fnc":1,"kind":"react","re":{"mid":"m-1","author":9,"preview":""},"caption":"+1"}""")
|
||||
assertEquals("+1", read.reaction)
|
||||
assertFalse(read.reactionIsPrivate)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user