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:
@@ -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