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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user