Give every message an id, and reactions something to land on
Reactions, edits and pins have all sat in the codec with no way to reach them, and the reason was the same in each case: they say "this one", and an Android message had no id to point at. 1:1 text went out as the raw body — cheaper on the wire, and it cost all three features. Every message now carries one. The sent copy, the copy written into our own transcript, and the copy queued for retry all share it: three constructions that disagreed about the id would be three different messages, and anything said about one would land on nothing. Inbound messages keep the SENDER's id rather than one we invent, because that is what they will use when they refer back to it. Old lines keep no id and must not be given one on read. Two devices would invent different ids for the same message, and a reaction would land on nothing — or on the wrong thing. Those messages simply cannot be reacted to, and the emoji row is absent on them rather than inert. Reacting is one long-press and one tap: the five emoji are the menu's first row, not behind a "React" item that opens a second menu. One emoji per person, so tapping another replaces it and tapping the same one takes it back. Applied locally before the send, because waiting on a round trip to see your own tap register makes it feel broken — and a failed send restores what was there before, not "nothing", which would be wrong for somebody replacing or removing one. Reactions render under the bubble, not inside it. They are what other people said about the message, not part of what was written. 648 unit tests green across app, engine and client; 8 new ones pin the id and the one-emoji-per-person rule. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2179615263
commit
26b4f20a5f
@@ -1767,6 +1767,44 @@ fun ColumnScope.ChatScreen(
|
||||
picture = authorId?.let { avatars[it] },
|
||||
)
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
// 🔴 The reactions are IN the menu, as its first row,
|
||||
// rather than behind a "React" item that opens a second
|
||||
// one. A reaction is a one-tap gesture; making it two taps
|
||||
// to reach a picker is how the feature stops being used.
|
||||
//
|
||||
// ⚠️ Only for a message that has an id. Anything sent
|
||||
// before ids existed has nothing to point at, so the row
|
||||
// is absent rather than present and inert.
|
||||
if (line.id != null) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(Space.xs),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
.testTag("react-row"),
|
||||
) {
|
||||
listOf("❤️", "😂", "😮", "😢", "🔥").forEach { emoji ->
|
||||
val already = line.reactions[emoji]?.contains(myUserId) == true
|
||||
Text(
|
||||
emoji,
|
||||
fontSize = 22.sp,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(
|
||||
if (already) Palette.Accent.copy(alpha = 0.15f)
|
||||
else Color.Transparent
|
||||
)
|
||||
.clickable {
|
||||
// Tapping the one already sent takes it back.
|
||||
model.reactToMessage(
|
||||
if (already) "" else emoji, line, chat)
|
||||
menuOpen = false
|
||||
}
|
||||
.padding(4.dp)
|
||||
.testTag("react-$emoji"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (line.text.isNotBlank()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy") },
|
||||
@@ -2224,6 +2262,29 @@ private fun Bubble(
|
||||
}
|
||||
line.link?.let { LinkPreviewCard(it, mine = line.mine) }
|
||||
}
|
||||
// ⚠️ Under the bubble, not inside it. A reaction is something other
|
||||
// people said ABOUT this message; drawing it inside the bubble makes
|
||||
// it look like part of what was written.
|
||||
if (line.reactions.isNotEmpty()) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(Space.xs),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = Kit.bubblePadding, vertical = 2.dp)
|
||||
.testTag("reactions"),
|
||||
) {
|
||||
line.reactions.entries.sortedBy { it.key }.forEach { (emoji, who) ->
|
||||
Text(
|
||||
if (who.size == 1) emoji else "$emoji ${who.size}",
|
||||
fontSize = 13.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1993,6 +1993,33 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React to a message, or take it back with an empty [emoji].
|
||||
*
|
||||
* ⚠️ The transcript is rewritten by [Session.react] before the send, so
|
||||
* the tap registers immediately and a failed send puts back what was
|
||||
* there. All this has to do is re-read the thread afterwards.
|
||||
*/
|
||||
fun reactToMessage(emoji: String, line: Session.Line, chat: Phase.Chat) =
|
||||
viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val mid = line.id ?: return@launch
|
||||
// Whose message it is, which is what the other side needs to find
|
||||
// it: our own id for something we sent, theirs for something they did.
|
||||
val author = if (line.mine) current.userId
|
||||
else line.author.takeIf { it != 0L } ?: chat.peerId ?: 0L
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
current.react(emoji, chat.threadId, mid, author,
|
||||
peerUserId = chat.peerId, group = chat.group)
|
||||
}
|
||||
}
|
||||
update {
|
||||
it.copy(openChat = it.openChat?.copy(
|
||||
lines = current.history(chat.threadId)))
|
||||
}
|
||||
}
|
||||
|
||||
/** Counts per emoji, for the author's own view of their story. */
|
||||
fun storyTally(storyKey: String): Map<String, Int> =
|
||||
session?.reactionsFor(storyKey).orEmpty().values
|
||||
|
||||
@@ -40,6 +40,27 @@ class Session(
|
||||
val text: String,
|
||||
val mine: Boolean,
|
||||
val at: Long,
|
||||
/**
|
||||
* What identifies this message to the other side.
|
||||
*
|
||||
* 🔴 **Null for anything stored before ids existed, and that is the
|
||||
* whole compatibility story.** A reaction, an edit and a pin all say
|
||||
* "this one" by id; a line with none simply cannot be the subject of
|
||||
* any of them. Making one up on read would be worse — two devices
|
||||
* would invent different ids for the same message and a reaction would
|
||||
* land on nothing.
|
||||
*/
|
||||
val id: String? = null,
|
||||
/**
|
||||
* Emoji to the people who sent it, folded onto the message rather than
|
||||
* filed as one of their own.
|
||||
*
|
||||
* ⚠️ One emoji per person, so reacting again replaces rather than
|
||||
* accumulates — which is why this is keyed by emoji and holds a set of
|
||||
* authors, and why applying one removes that author from every other
|
||||
* entry first.
|
||||
*/
|
||||
val reactions: Map<String, Set<Long>> = emptyMap(),
|
||||
/**
|
||||
* Who wrote it — a user id, 0 when unrecorded. Groups need it: every
|
||||
* received bubble in a group was captioned and pictured as the GROUP
|
||||
@@ -94,6 +115,13 @@ class Session(
|
||||
text = m.optString("text"),
|
||||
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(),
|
||||
author = m.optLong("author"),
|
||||
attachment = m.optJSONObject("att")?.let { att ->
|
||||
Attachment.Ref.fromJson(m.optString("kind", Attachment.IMAGE), att, 0.0)
|
||||
@@ -121,9 +149,12 @@ class Session(
|
||||
at: Long = System.currentTimeMillis(),
|
||||
delivery: Delivery? = null,
|
||||
link: Content.LinkPreview? = null,
|
||||
/** What the other side will call this message when it refers back. */
|
||||
messageId: String? = null,
|
||||
) {
|
||||
vault.append(threadId, JSONObject()
|
||||
.put("author", author)
|
||||
.also { if (messageId != null) it.put("mid", messageId) }
|
||||
.put("text", text).put("mine", mine).put("at", at)
|
||||
.also { if (delivery != null) it.put("delivery", delivery.name) }
|
||||
.also {
|
||||
@@ -293,17 +324,27 @@ class Session(
|
||||
*/
|
||||
link: Content.LinkPreview? = null,
|
||||
): Conversations.Sent {
|
||||
// With no preview this is the raw body, byte for byte, exactly as
|
||||
// before — `Content.text` only builds an envelope when it has to.
|
||||
val plaintext = Content.text(text, link = link)
|
||||
// 🔴 **Every message gets an id now, so this is always an envelope.**
|
||||
// It used to be the raw body byte for byte, which is cheaper on the
|
||||
// wire and cost us reactions, edits and pins: all three say "this
|
||||
// one", and there was nothing to point at. iOS has always wrapped, and
|
||||
// both engines read a bare body as text, so an older peer is
|
||||
// unaffected — see `isUnknownControl`.
|
||||
val mid = java.util.UUID.randomUUID().toString()
|
||||
val plaintext = Content.text(text, messageId = mid, link = link)
|
||||
val sent = conversations.send(peerUserId, plaintext)
|
||||
val at = System.currentTimeMillis()
|
||||
record(Vault.threadForUser(peerUserId), text, mine = true, at = at,
|
||||
link = link,
|
||||
link = link, messageId = mid,
|
||||
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
|
||||
// 🔴 Queued, not abandoned. A friend who is simply signed out should
|
||||
// not cost the sender a reminder to try again later.
|
||||
if (!sent.reachedSomeone) enqueue(peerUserId, text, at)
|
||||
//
|
||||
// ⚠️ The ENVELOPE is queued, not the bare text: a retry has to arrive
|
||||
// carrying the same id, or the copy that eventually lands is a
|
||||
// different message from the one already in this transcript and
|
||||
// nothing said about it reaches anywhere.
|
||||
if (!sent.reachedSomeone) enqueue(peerUserId, plaintext, at)
|
||||
return sent
|
||||
}
|
||||
|
||||
@@ -491,6 +532,85 @@ class Session(
|
||||
* which transmits its argument as the plaintext verbatim — so both kinds
|
||||
* take the same path out.
|
||||
*/
|
||||
/**
|
||||
* Fold a reaction onto the message it is about.
|
||||
*
|
||||
* 🔴 **One emoji per person.** Reacting again replaces; reacting with the
|
||||
* same one, or with an empty string, takes it back. So the author is
|
||||
* removed from every emoji first and then added to at most one — an
|
||||
* accumulate-only version would let one tap each on five emoji stand
|
||||
* simultaneously, which is not what anybody means by reacting.
|
||||
*
|
||||
* ⚠️ Returns false when the message is not in this thread, which is the
|
||||
* ordinary case for a reaction to something sent before ids existed, or
|
||||
* to a message this device never received. Silently doing nothing is
|
||||
* right; inventing a bubble for it would not be.
|
||||
*/
|
||||
fun applyReaction(threadId: String, messageId: String, author: Long, emoji: String): Boolean {
|
||||
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 rebuilt = JSONObject()
|
||||
for (key in existing.keys()) {
|
||||
val who = existing.optJSONArray(key) ?: org.json.JSONArray()
|
||||
val kept = org.json.JSONArray()
|
||||
for (j in 0 until who.length()) {
|
||||
if (who.getLong(j) != author) kept.put(who.getLong(j))
|
||||
}
|
||||
if (kept.length() > 0) rebuilt.put(key, kept)
|
||||
}
|
||||
if (emoji.isNotBlank()) {
|
||||
val who = rebuilt.optJSONArray(emoji) ?: org.json.JSONArray()
|
||||
who.put(author)
|
||||
rebuilt.put(emoji, who)
|
||||
}
|
||||
if (rebuilt.length() > 0) row.put("rx", rebuilt) else row.remove("rx")
|
||||
break
|
||||
}
|
||||
if (hit) vault.replaceHistory(threadId, stored)
|
||||
return hit
|
||||
}
|
||||
|
||||
/**
|
||||
* React to somebody's message, or take it back with an empty [emoji].
|
||||
*
|
||||
* ⚠️ **Applied here first, then sent.** A reaction is a one-tap gesture,
|
||||
* and waiting on a round trip to see your own tap register makes it feel
|
||||
* broken on a slow connection. A send that fails restores what was there
|
||||
* before rather than leaving a reaction that exists only on this phone —
|
||||
* and restoring "nothing" would be wrong for somebody who was replacing
|
||||
* one emoji or removing one, so the previous value is captured first.
|
||||
*/
|
||||
fun react(emoji: String, threadId: String, messageId: String, author: Long,
|
||||
peerUserId: Long?, group: RelayClient.Group?): Boolean {
|
||||
val previous = history(threadId).firstOrNull { line -> line.id == messageId }
|
||||
?.reactions?.entries?.firstOrNull { entry -> userId in entry.value }?.key.orEmpty()
|
||||
if (!applyReaction(threadId, messageId, userId, emoji)) return false
|
||||
val reference = Content.Reference(messageId, author, "")
|
||||
val body = Content.reaction(emoji, reference, groupId = group?.id)
|
||||
val sent = runCatching {
|
||||
when {
|
||||
// ⚠️ Pairwise, like every group message: the relay does no
|
||||
// fan-out, so a group reaction is one send per member.
|
||||
group != null -> group.memberIds
|
||||
.filter { it != userId }
|
||||
.map { conversations.send(it, body).reachedSomeone }
|
||||
.any { it }
|
||||
peerUserId != null -> conversations.send(peerUserId, body).reachedSomeone
|
||||
else -> false
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
if (!sent) {
|
||||
applyReaction(threadId, messageId, userId, previous)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun enqueue(peerUserId: Long, text: String, at: Long,
|
||||
thread: String? = null) {
|
||||
val pending = vault.outbox()
|
||||
@@ -648,9 +768,11 @@ class Session(
|
||||
|
||||
fun sendGroup(group: RelayClient.Group, text: String,
|
||||
mentions: List<Int> = emptyList()): Conversations.Sent {
|
||||
val sent = conversations.sendToGroup(group, text, userId, mentions)
|
||||
val mid = java.util.UUID.randomUUID().toString()
|
||||
val sent = conversations.sendToGroup(group, text, userId, mentions, messageId = mid)
|
||||
val at = System.currentTimeMillis()
|
||||
record(Vault.threadForGroup(group.id), text, mine = true, author = userId, at = at,
|
||||
messageId = mid,
|
||||
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
|
||||
// 🔴 **The group half of the outbox promise.** Members with no
|
||||
// registered device are filed in the failure map as "user:<id>" by the
|
||||
@@ -658,7 +780,8 @@ class Session(
|
||||
// the group thread to restate. A member whose devices took it is not
|
||||
// queued — resending to them would duplicate the message.
|
||||
val body = io.flamenet.e2e.Content.groupText(
|
||||
text, group.id, groupName = vault.groupName(group.id), mentions = mentions)
|
||||
text, group.id, groupName = vault.groupName(group.id), mentions = mentions,
|
||||
messageId = mid)
|
||||
sent.failed.keys.filter { it.startsWith("user:") }
|
||||
.mapNotNull { it.removePrefix("user:").toLongOrNull() }
|
||||
.forEach { member ->
|
||||
@@ -749,6 +872,28 @@ class Session(
|
||||
if (story.isActive(now)) incomingStories += story
|
||||
continue
|
||||
}
|
||||
// A reaction to a MESSAGE. Folded onto the message it is about,
|
||||
// never filed as one of its own — a thread where every thumbs-up
|
||||
// is a bubble is a thread nobody can read.
|
||||
//
|
||||
// 🔴 Checked before the story branch, which is the narrower case:
|
||||
// a story reaction carries the same `reaction` field and is told
|
||||
// apart only by `reactsToStory`.
|
||||
if (message.content.reaction != null && !message.content.reactsToStory) {
|
||||
val about = message.content.about
|
||||
if (about != null) {
|
||||
val gid = if (message.fromAddress != null) null
|
||||
else message.content.groupId
|
||||
val thread = gid?.let(Vault::threadForGroup)
|
||||
?: message.fromAddress?.let(Vault::threadForAddress)
|
||||
?: Vault.threadForUser(message.fromUser)
|
||||
if (applyReaction(thread, about.messageId, message.fromUser,
|
||||
message.content.reaction.orEmpty())) {
|
||||
touched += thread
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
// A reaction to one of OUR stories. Folded onto the story rather
|
||||
// than filed as a message — and never notified, for the same
|
||||
// reason a thumbs-up on a message is not.
|
||||
@@ -830,7 +975,12 @@ class Session(
|
||||
record(thread, message.content.text, mine = false,
|
||||
attachment = message.content.attachment,
|
||||
sticker = message.content.sticker?.let { Sticker(it.pack, it.id) },
|
||||
author = message.fromUser)
|
||||
author = message.fromUser,
|
||||
// ⚠️ THEIR id, not one we make up. It is what they will use
|
||||
// when they edit this message, and what we use when we react
|
||||
// to it — an id invented here would match nothing on their
|
||||
// side.
|
||||
messageId = message.content.messageId)
|
||||
touched += thread
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import io.flamenet.client.MessengerApi
|
||||
import io.flamenet.client.RelayClient
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Reactions, and the message id everything else will hang off.
|
||||
*
|
||||
* 🔴 **The id is the point.** A reaction, an edit and a pin all say "this
|
||||
* one", and until every message carried an identifier there was nothing to
|
||||
* point at — which is why all three sat in the codec with no way to reach
|
||||
* them. The rules pinned here are the ones that decide whether a reaction
|
||||
* lands on the right message, on the wrong one, or on nothing.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class MessageReactionTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
private class NoKeyLog : okhttp3.mockwebserver.QueueDispatcher() {
|
||||
override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse =
|
||||
if ("/e2e/log/" in request.path.orEmpty()) MockResponse().setResponseCode(404)
|
||||
else super.dispatch(request)
|
||||
}
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.dispatcher = NoKeyLog(); it.start() }
|
||||
}
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = mutableMapOf<String, ByteArray>()
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey =
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private fun session(): Session {
|
||||
val identity = DeviceIdentity.create()
|
||||
val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) }
|
||||
val base = server.url("/").toString()
|
||||
return Session(
|
||||
handle = "alice",
|
||||
userId = 1,
|
||||
identity = identity,
|
||||
vault = vault,
|
||||
relay = RelayClient(base, "alice", "app pass word"),
|
||||
api = MessengerApi(base, "alice", "app pass word"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
private val peer = 99L
|
||||
private val thread get() = Vault.threadForUser(peer)
|
||||
|
||||
// MARK: - The id
|
||||
|
||||
/**
|
||||
* 🔴 A sent message carries an id, and the copy in our own transcript
|
||||
* carries the SAME one. Two ids for one message means anything the other
|
||||
* side says about it arrives about a message we do not have.
|
||||
*/
|
||||
@Test
|
||||
fun `a sent message and its local copy share one id`() {
|
||||
val session = session()
|
||||
server.enqueue(json("""{"devices":[]}"""))
|
||||
|
||||
session.send(peer, "shall we say seven")
|
||||
|
||||
val line = session.history(thread).single()
|
||||
assertNotNull(line.id, "a sent message has no id, so nothing can refer to it")
|
||||
assertTrue(line.id!!.isNotBlank())
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ The queued retry has to be the SAME message, id included. Queuing the
|
||||
* bare text meant the copy that eventually landed was a different message
|
||||
* from the one already in this transcript.
|
||||
*/
|
||||
@Test
|
||||
fun `the queued retry carries the same id as the local copy`() {
|
||||
val session = session()
|
||||
server.enqueue(json("""{"devices":[]}"""))
|
||||
|
||||
session.send(peer, "shall we say seven")
|
||||
|
||||
val mid = session.history(thread).single().id
|
||||
assertNotNull(mid)
|
||||
val queued = session.vault.outbox().getJSONObject(0).getString("text")
|
||||
assertTrue(queued.contains(mid!!),
|
||||
"the queued copy does not carry the id the transcript recorded")
|
||||
}
|
||||
|
||||
// MARK: - Applying one
|
||||
|
||||
@Test
|
||||
fun `a reaction folds onto the message it names`() {
|
||||
val session = session()
|
||||
server.enqueue(json("""{"devices":[]}"""))
|
||||
session.send(peer, "shall we say seven")
|
||||
val mid = session.history(thread).single().id!!
|
||||
|
||||
assertTrue(session.applyReaction(thread, mid, peer, "❤️"))
|
||||
|
||||
val line = session.history(thread).single()
|
||||
assertEquals(mapOf("❤️" to setOf(peer)), line.reactions)
|
||||
// 🔴 And it stays one message. A reaction filed as a message of its
|
||||
// own is how a thread becomes unreadable.
|
||||
assertEquals(1, session.history(thread).size)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 One emoji per person. Reacting again replaces rather than
|
||||
* accumulates — otherwise one tap each on five emoji all stand at once,
|
||||
* which is not what anybody means by reacting.
|
||||
*/
|
||||
@Test
|
||||
fun `reacting again replaces the previous one`() {
|
||||
val session = session()
|
||||
server.enqueue(json("""{"devices":[]}"""))
|
||||
session.send(peer, "shall we say seven")
|
||||
val mid = session.history(thread).single().id!!
|
||||
|
||||
session.applyReaction(thread, mid, peer, "❤️")
|
||||
session.applyReaction(thread, mid, peer, "😂")
|
||||
|
||||
assertEquals(mapOf("😂" to setOf(peer)), session.history(thread).single().reactions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty emoji takes the reaction back`() {
|
||||
val session = session()
|
||||
server.enqueue(json("""{"devices":[]}"""))
|
||||
session.send(peer, "shall we say seven")
|
||||
val mid = session.history(thread).single().id!!
|
||||
|
||||
session.applyReaction(thread, mid, peer, "❤️")
|
||||
session.applyReaction(thread, mid, peer, "")
|
||||
|
||||
assertEquals(emptyMap(), session.history(thread).single().reactions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two people can react with the same emoji`() {
|
||||
val session = session()
|
||||
server.enqueue(json("""{"devices":[]}"""))
|
||||
session.send(peer, "shall we say seven")
|
||||
val mid = session.history(thread).single().id!!
|
||||
|
||||
session.applyReaction(thread, mid, peer, "❤️")
|
||||
session.applyReaction(thread, mid, 77L, "❤️")
|
||||
|
||||
assertEquals(setOf(peer, 77L), session.history(thread).single().reactions["❤️"])
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A reaction to a message this device does not have does nothing, and
|
||||
* says so. Inventing a bubble for it would put a message in the thread
|
||||
* that nobody wrote.
|
||||
*/
|
||||
@Test
|
||||
fun `a reaction to a message we do not have is ignored, not invented`() {
|
||||
val session = session()
|
||||
server.enqueue(json("""{"devices":[]}"""))
|
||||
session.send(peer, "shall we say seven")
|
||||
|
||||
assertFalse(session.applyReaction(thread, "not-a-message-here", peer, "❤️"))
|
||||
assertEquals(1, session.history(thread).size)
|
||||
assertEquals(emptyMap(), session.history(thread).single().reactions)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 A line stored before ids existed has none, and must not be given one
|
||||
* on read. Two devices would invent different ids for the same message,
|
||||
* and a reaction would land on nothing — or worse, on the wrong thing.
|
||||
*/
|
||||
@Test
|
||||
fun `a message stored before ids existed keeps no id`() {
|
||||
val session = session()
|
||||
session.record(thread, "from the old days", mine = false, author = peer)
|
||||
|
||||
assertNull(session.history(thread).single().id)
|
||||
}
|
||||
}
|
||||
@@ -213,11 +213,19 @@ class Conversations(
|
||||
* locally by the caller.
|
||||
*/
|
||||
fun sendToGroup(group: RelayClient.Group, text: String, myUserId: Long,
|
||||
mentions: List<Int> = emptyList()): Sent {
|
||||
mentions: List<Int> = emptyList(),
|
||||
/**
|
||||
* 🔴 Minted by the caller, not here, because the caller
|
||||
* also writes the sender's own copy and queues the retries.
|
||||
* Three constructions of the same message that disagreed
|
||||
* about its id would be three different messages, and
|
||||
* anything said about one of them would land on nothing.
|
||||
*/
|
||||
messageId: String? = null): Sent {
|
||||
val delivered = mutableListOf<String>()
|
||||
val failed = LinkedHashMap<String, String>()
|
||||
val body = Content.groupText(text, group.id, groupName = vault.groupName(group.id),
|
||||
mentions = mentions)
|
||||
mentions = mentions, messageId = messageId)
|
||||
|
||||
for (member in group.memberIds) {
|
||||
if (member == myUserId) continue
|
||||
|
||||
Reference in New Issue
Block a user