Three things the operator asked for after using the first version.
The emoji were not displayed. The app's font stack named no emoji family
at all, so a reaction's glyph depended on whatever the first matching
family happened to cover — with a partial, monochrome emoji font 👍 comes
out an empty box. The emoji families are now named before the generic,
which also covers emoji in ordinary message text. This could not be
verified here: the host has no emoji font installed, so nothing it renders
says anything about a device that has one.
A double-tap or double-click on a bubble now gives one fixed reaction and
a second withdraws it; joining somebody else's is a tap on their chip.
`touch-action: manipulation` keeps iOS from also zooming on that tap.
Both bubble buttons are gone, "Reply" and "React", and the picker with
them. What that costs is stated rather than buried: quoting is now
reachable only by the swipe, which is touch-only by design, so a mouse or
keyboard cannot quote a message. SWIPE-IS-THE-ONLY-QUOTE-ROUTE records it
so it stays a decision.
The cleartext notice moved out of the picker to a status line, once per
chat per app start rather than on every tap, and it keys on the chat being
encrypted rather than on the one message — which is what the lock in the
header says and what the reader is weighing.
Checks: zero svelte-check errors, 93 tests, build, ux-reactions-runner.cjs
13/13 and ux-swipe-runner.cjs 11/11, both with zero page errors. The
reactions runner now drives a real touch double-tap through CDP, and its
clamp check fails on a silent under-render as well as an over-render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
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"oversrc/,test/and over blunix'scapabilities/anddocs/returns nothing. - Nobody has ever sent one to this server.
urn:xmpp:reactionsdoes 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'sreplyId()returns the room-issuedstanzaIdforgroupchatandoriginId ?? idotherwise — which is exactly XEP-0444's rule. Reuse it; do not write a second one.
2. The wire format, from the XEP
Read from https://xmpp.org/extensions/xep-0444.html on 2026-10-08:
<message to='…' type='chat'>
<reactions id='744f6e18-a57a-11e9-a656-4889e7820c76' xmlns='urn:xmpp:reactions:0'>
<reaction>👋</reaction>
</reactions>
<store xmlns='urn:xmpp:hints'/>
</message>
- Full state, not a delta. Each
<reactions>replaces everything that sender had on that message. Removing one means resending the set without it; removing all means an empty<reactions id='…'/>. 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
idattribute, preferably XEP-0359<origin-id>, in 1:1; fortype='groupchat'the stanza's ownidmust not be used — the room-issued<stanza-id>is required. <store/>hint (XEP-0334) SHOULD be added unless the message reacted to carried<no-store/>. This matters here, see §3.- Disco feature:
urn:xmpp:reactions:0. - Content: a
<reaction>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 <message>, 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 <body>, a <subject>, 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 <store/> 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 <reactions> 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
oobto 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:reactionsappears nowhere in the tree; the stringreactionappears in no.m,.h,.swiftor.rsfile;monal.doapdoes not list the XEP. - A bodiless reaction is dropped on the floor. In
MLMessageProcessor.mthe whole store/notify/contact-create branch is gated on[messageNode check:@"body#"] || decrypted(:560), withaddActiveBuddiesinside it (:712). Without a body the stanza falls through the else-if chain —:728is 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:184is_importantends athas_body(:165) for achatmessage. A cleartext reaction has no body, no<encrypted>and no Jingle element, so it is not important and theurn:xmpp:push2:match:importantfilter 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 <encrypted> (§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".
- Decide §4 and §5. Both settled on 2026-10-08: cleartext with a visible notice, and Monal ignores a bodiless reaction entirely.
- Wire layer,
src/xmpp/reactions.tsalongsidereplies.ts: build and parse<reactions>, reusereplyId()for addressing, validate that each<reaction>is a single grapheme and cap the count per sender. Never trust the id or the emoji from the wire. - 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. - Receive and render,
src/ui/Chat.svelte: a compact row under the bubble, grouped by emoji with a count, the viewer's own highlighted. - 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-ROUTEinux-swipe-runner.cjsfails if the buttons come back unnoticed. - Advertise
urn:xmpp:reactions:0incaps.ts. - Rooms last: the
stanza-idaddressing, and themod_muc_mamquestion 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 for the results.
- A reaction goes out with the right id for 1:1 and for a room, carries
<store/>, 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. Themod_muc_mamquestion 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 intest/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.luaread 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.