monal-rebuild/REACTIONS-PLAN.md
pijukka fa4ae24012 feat(chat): react by double-tapping, and name the emoji fonts
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>
2026-10-08 20:27:04 +02:00

220 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.