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:
Paul Hitt
2026-09-03 15:19:49 -04:00
co-authored by Claude Opus 4.8
parent 2179615263
commit 26b4f20a5f
5 changed files with 466 additions and 10 deletions
@@ -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