diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index eac82ea..215cc86 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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"), + ) + } } } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 6c5ec3c..a27a625 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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, ) } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 94ecf9d..7620c9d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -41,6 +41,11 @@ class Session( * filed as one of their own. */ val reactions: Map> = 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> = 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> = + 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 diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/PrivateReactionSendTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/PrivateReactionSendTest.kt new file mode 100644 index 0000000..c27a090 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/PrivateReactionSendTest.kt @@ -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") + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index fa51cbb..06cd045 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -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>? = null, mentions: List = 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, ) } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PrivateReactionTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PrivateReactionTest.kt new file mode 100644 index 0000000..96d6f9c --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/PrivateReactionTest.kt @@ -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) + } +}