# Emoji reactions (XEP-0444) — design notes, 2026-10-08 The survey, then the two decisions it waited on, both now settled — §4 by the operator, §5 from Monal's source. Implementation is done; §6 and §7 say what was built and what was run, and `UX-REACTIONS-RESULTS.md` carries the evidence. The operator's framing: Monal probably cannot do reactions, and 0ff chat should have them anyway. That is a legitimate position — it just changes what "done" means, which §4 is about. ## 1. What exists today, verified - **No reaction code anywhere.** `grep -niE "emoji|reaction|0444"` over `src/`, `test/` and over blunix's `capabilities/` and `docs/` returns nothing. - **Nobody has ever sent one to this server.** `urn:xmpp:reactions` does not appear anywhere under `/var/lib/prosody/`. So no client in use here — Monal included — has put a reaction on the wire, which matches the operator's guess without confirming it (an unused feature and an absent feature look the same from the archive). - **Emoji as text already work**, and need nothing: they are ordinary Unicode, 10 archive files already contain codepoints from the U+1F300–1FAFF block. There is no emoji picker in the composer; on a phone the system keyboard covers it, on desktop it does not. - **The client advertises its features** in `src/xmpp/caps.ts` (`BASE_FEATURES`); reactions would add one entry there. - **The reply plumbing already solved the hard addressing question.** `src/xmpp/replies.ts`'s `replyId()` returns the room-issued `stanzaId` for `groupchat` and `originId ?? id` otherwise — which is exactly XEP-0444's rule. Reuse it; do not write a second one. ## 2. The wire format, from the XEP Read from on 2026-10-08: ```xml 👋 ``` - **Full state, not a delta.** Each `` replaces everything that sender had on that message. Removing one means resending the set without it; removing all means an empty ``. The UI must therefore always send the sender's complete set, and the store must key reactions by (message, sender) and overwrite. - **Which id:** the `id` attribute, preferably XEP-0359 ``, in 1:1; for `type='groupchat'` the stanza's own `id` **must not** be used — the room-issued `` is required. - **`` hint** (XEP-0334) SHOULD be added unless the message reacted to carried ``. This matters here, see §3. - **Disco feature:** `urn:xmpp:reactions:0`. - **Content:** a `` SHOULD be codepoints that display as a single emoji (UTS #51). The XEP says nothing about encryption and nothing about MAM. ## 3. The server side: almost nothing, and why A reaction is an ordinary ``, so Prosody needs no module and no new configuration. One thing was checked rather than assumed: `mod_mam`'s `should_store` (`mod_mam.lua:322` in the deployed prosody 13.0.6) archives on a ``, a ``, `urn:xmpp:eme:0`, receipts, chat markers — **and has no branch for `urn:xmpp:reactions:0`**. A reaction has no body, so it would not be archived and would vanish on reload. But the `` hint hits the branch above those checks and returns true. **So: send the hint and the archive works; forget the hint and reactions silently fail to persist.** That is the single server-facing fact of this whole feature. Not checked yet: whether MUC reactions pass through `muc.0ff.space` untouched, and whether the room archive stores them. `mod_muc_mam` has its own storage rules. ## 4. The real decision: OMEMO This is what makes the feature more than an afternoon, and it has to be settled before any code. **The client has no envelope for arbitrary XML.** It implements legacy OMEMO (`eu.siacs.conversations.axolotl`): `session.svelte.ts:233` encrypts `content.body` — a string — and `buildEncryptedMessage` (`omemo-wire.ts:185`) wraps exactly that. There is no XEP-0420 Stanza Content Encryption here, so a `` element cannot be put inside the ciphertext the way the `aesgcm://` URL was. Three ways out, with what each costs: - **(a) Send reactions in cleartext**, next to the encrypted body. Cheap and interoperable. It leaks, per reaction, that a reaction happened, to which message, and which emoji — in a chat whose text is encrypted. For 👍 on a message whose content is unknown to the server, that is a small leak; it is still a visible exception to "this chat is encrypted", and the client has so far refused every such exception (a plaintext `oob` to a capable peer is rejected outright, `session.svelte.ts:218`). - **(b) Implement XEP-0420 SCE** and encrypt the whole payload. The honest answer protocol-wise, and it would also clean up quoted replies, whose metadata is likewise outside the ciphertext today. It is a substantial piece of work and changes the OMEMO wire format this client and Monal agreed on — verified interop would have to be redone. - **(c) A private convention** (a reaction encoded in an encrypted body). Interoperates with nothing and pollutes the archive. Rejected before it is proposed. **Decided 2026-10-08, by the operator: (a), stated plainly in the UI.** A reaction in an encrypted chat goes out in cleartext, and the UI says so where the reaction is given rather than burying it — what leaks is which emoji landed on which message, never the message text. This is a named exception to the client's fail-closed rule, which stays in force for everything carrying content: a plaintext `oob` to a capable peer is still rejected outright (`session.svelte.ts:218`), and an encrypted message still refuses to be quoted into an unencrypted reply. (b) stays on the backlog as its own project, with quoted replies' metadata folded in; it is what retires the exception. ## 5. Monal ignores them silently — checked, not assumed Monal **Stable 6.4.21** (release source `9be038e9`, the same checkout that settled the `aesgcm://` format; see `OMEMO-PLAN.md`) was read on 2026-10-08. The question was whether a reaction would reach the operator's phone as an empty bubble or, worse, as a notification per thumbs-up. - **No XEP-0444 at all.** `urn:xmpp:reactions` appears nowhere in the tree; the string `reaction` appears in no `.m`, `.h`, `.swift` or `.rs` file; `monal.doap` does not list the XEP. - **A bodiless reaction is dropped on the floor.** In `MLMessageProcessor.m` the whole store/notify/contact-create branch is gated on `[messageNode check:@"body#"] || decrypted` (`:560`), with `addActiveBuddies` inside it (`:712`). Without a body the stanza falls through the else-if chain — `:728` is the outbound case — and the separate receipt, chat-marker and chatstate blocks match nothing in it. No bubble, no entry in the active-chats list, no notification. - **No push.** `mod_push2.lua:184` `is_important` ends at `has_body` (`:165`) for a `chat` message. A cleartext reaction has no body, no `` and no Jingle element, so it is not important and the `urn:xmpp:push2:match:important` filter drops it. So reactions are invisible on the phone and cost nothing there. Two consequences worth holding on to: the feature is 0ff chat ↔ 0ff chat only until Monal grows support, and the no-push result depends on reactions staying unencrypted — the moment they ride inside `` (§4(b)) they become important at `mod_push2.lua:170` and start pushing. Still worth confirming once in the journal rather than trusting the read. ## 6. Client work, in the order it happened All of steps 1-7 below are done, and `UX-REACTIONS-RESULTS.md` records what was built and what was run. Two differences from the plan as written: the store keys by the reacted-to message's stable id rather than by the local message key, so a reaction arriving before its message can wait for it; and the affordance is not what step 5 imagined. It was built as a picker — six quick reactions plus a field — and the operator replaced it the same day with a **double-tap that gives one fixed reaction**, and had the bubble's "Reply" and "React" buttons removed with it. See `UX-REACTIONS-RESULTS.md`, "The bubble as the operator wanted it". 1. **Decide §4 and §5.** Both settled on 2026-10-08: cleartext with a visible notice, and Monal ignores a bodiless reaction entirely. 2. **Wire layer**, `src/xmpp/reactions.ts` alongside `replies.ts`: build and parse ``, reuse `replyId()` for addressing, validate that each `` is a single grapheme and cap the count per sender. Never trust the id or the emoji from the wire. 3. **Store**, `src/store/db.ts`: reactions keyed by (message key, sender), full-state overwrite, delete on empty. They arrive both live and from MAM, so the same dedupe discipline as messages applies — a reaction can also arrive *before* the message it points at (MAM paging), which the store has to tolerate rather than drop. 4. **Receive and render**, `src/ui/Chat.svelte`: a compact row under the bubble, grouped by emoji with a count, the viewer's own highlighted. 5. **Send**: superseded by the operator on 2026-10-08. A **double-tap or double-click** on a bubble gives one fixed reaction (a heart); tapping a chip joins or leaves a reaction somebody already gave. There is no picker and no bubble button, so there is also **no keyboard or mouse route to quoting** any more — the swipe is touch-only. That is a deliberate trade the operator made for a quieter bubble; it is recorded here rather than argued, and `SWIPE-IS-THE-ONLY-QUOTE-ROUTE` in `ux-swipe-runner.cjs` fails if the buttons come back unnoticed. 6. **Advertise** `urn:xmpp:reactions:0` in `caps.ts`. 7. **Rooms** last: the `stanza-id` addressing, and the `mod_muc_mam` question from §3. ## 7. What "verified" has to mean here The bar the previous packages set: candidate checks in a runner against the deployed bundle, then the device half by the operator. Every line below now has a named check or a named reason it was not run; see [UX-REACTIONS-RESULTS.md](UX-REACTIONS-RESULTS.md) for the results. - A reaction goes out with the right id for 1:1 and for a room, carries ``, and survives a reload — read back from the server archive, not only from local state. **Done:** `REACT-GOES-OUT`, `REACT-ROOM-STANZA-ID`, `REACT-IN-ARCHIVE`, `REACT-ROOM-ARCHIVE`, `REACT-SURVIVES-RELOAD`. The `mod_muc_mam` question from §3 is answered with a read of the room's own archive: it keeps them. - Full-state semantics: two reactions, then one removed, leaves exactly one, for both the sender's and the receiver's view. **Done:** `REACT-FULL-STATE`, `REACT-RECEIVE-AND-WITHDRAW`. - A reaction to a message that is not in the local store does not create a ghost, and does not crash the list. **Done:** `REACT-NO-GHOST`. - A reaction with twenty emoji, or with arbitrary text instead of an emoji, is refused or clamped — not rendered. **Done:** `REACT-CLAMPED`, and the parser cases in `test/reactions.test.ts`. - The push journal shows no push for a reaction. **Partly:** zero pushes over the run window, but the audit sessions hold no push registration, so the weight is on the `mod_push2.lua` read in §5. A real pushing device is a device check. - On the phone: what Monal actually does with one. **Not run:** its source says it is dropped silently (§5); nobody has watched the phone. ## 8. Smaller thing, separately An **emoji picker in the composer** is unrelated to all of the above — pure client UI, no protocol, no OMEMO question. It is worth little on iOS (the system keyboard has one) and noticeably more on desktop. If the point is "it would be nice to have emoji", that is a much cheaper piece of work than §4–§7 and could come first.