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>
220 lines
12 KiB
Markdown
220 lines
12 KiB
Markdown
# 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 <https://xmpp.org/extensions/xep-0444.html> on 2026-10-08:
|
||
|
||
```xml
|
||
<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 `id` attribute, preferably XEP-0359 `<origin-id>`, in
|
||
1:1; for `type='groupchat'` the stanza's own `id` **must 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 `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
|
||
`<encrypted>` 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 `<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".
|
||
|
||
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 `<reactions>`, reuse `replyId()` 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.
|
||
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
|
||
`<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`. 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.
|