Merge branch 'feat/private-reactions' into 'main'

Private reactions: react to one person inside a group

Closes #24

See merge request paulhitt/flamenet-messenger-android!126
This commit was merged in pull request #153.
This commit is contained in:
Paul Hitt
2026-09-12 01:08:43 +00:00
6 changed files with 233 additions and 23 deletions
@@ -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)
}
}