# Emoji reactions — implemented, deployed, reshaped, 2026-10-08 XEP-0444 reactions are implemented end to end: give, withdraw, receive, group by emoji with counts, in 1:1 chats and in rooms, surviving a reload out of the server's archive. The survey and the two decisions this waited on are [REACTIONS-PLAN.md](REACTIONS-PLAN.md); this is what was built and what was actually run. ## The two decisions, as settled **OMEMO (§4), by the operator: cleartext, said plainly in the UI.** The client has no XEP-0420 envelope — legacy OMEMO encrypts the body string alone — so a `` element cannot ride inside the ciphertext. A reaction in an encrypted chat therefore goes out unencrypted, and the UI says so the first time a reaction is given in a chat, once per app start rather than on every tap: > Reactions are sent unencrypted. The server learns which emoji you put on > this message, not what the message says. That is a *named* exception. The fail-closed rule stays in force for everything carrying content: a plaintext `oob` to a capable peer is still rejected (`src/xmpp/session.svelte.ts`), and an encrypted message still refuses to be quoted into an unencrypted reply. XEP-0420 (SCE) retires the exception and stays on the backlog with quoted replies' metadata. **Monal (§5), from its source: it ignores them entirely.** Monal Stable 6.4.21 (`9be038e9`) has no XEP-0444 code at all, and its `MLMessageProcessor.m` gates the whole store/notify/contact-create branch on a body (`:560`, with `addActiveBuddies` at `:712`). A bodiless reaction falls through that chain with nothing to catch it — no bubble, no active-chats entry, no notification. `mod_push2.lua`'s `is_important` (`:184`) likewise ends at `has_body` (`:165`), so a cleartext reaction is not important and the push filter drops it. Two consequences to keep in view: reactions are 0ff chat ↔ 0ff chat only until Monal grows support, and the no-push result holds *because* they are unencrypted — inside `` they would become important at `mod_push2.lua:170` and start pushing. ## What was built - `src/xmpp/reactions.ts` — the wire layer. `replyId()` from `replies.ts` owns addressing for reactions too (origin id in 1:1, the room-issued stanza id in a room), so there is one answer to that question and not two. Full-state semantics, a single-emoji guard built on `Intl.Segmenter` plus Unicode properties, a per-sender cap of 12, and a parser that trusts nothing from the wire. - `src/store/db.ts` — a `reactions` object store (schema version 3), keyed `peer|targetId|sender` and indexed by `["peer","targetId"]`. Its own store, not a field on the message, because a reaction can arrive before the message it points at. An older copy never replaces a newer one, and the same set arriving again is not a change. - `src/sync/sync.ts` — receives reactions on the one path that already serves live stanzas, carbons and MAM. `parseMessage` discards bodiless stanzas, so reactions are parsed separately and never touch a conversation's unread count or preview: a reaction is not a message. - `src/xmpp/parse.ts` — the peer/sender/direction logic is now `address()`, shared by messages and reactions, so both land in the same conversation under the same sender identity. - `src/xmpp/session.svelte.ts` — `react(peer, key, emoji)` reads this device's current set, toggles inside it and sends the whole set. - `src/ui/Chat.svelte` — chips under the bubble grouped by emoji with a count and our own highlighted; a double-tap or double-click on a bubble gives the one default reaction and a second one withdraws it; tapping a chip joins or leaves a reaction somebody already gave. `aria-pressed` and names that say how many people and whether we are among them. `touch-action: manipulation` on the row, so the double-tap is not also the browser's zoom. - `src/app.css` — the font stack now names the emoji families. It never did, so a reaction's glyph was at the mercy of whatever the first matching family happened to cover. - `src/xmpp/caps.ts` — `urn:xmpp:reactions:0` is advertised. - `tsconfig.json` — `lib` names `ES2022.Intl` explicitly for `Intl.Segmenter`; the rest is what target ES2020 already implied. The server needed nothing. The one server-facing fact is the `` hint: `mod_mam`'s `should_store` has no reactions branch, so without the hint a bodiless reaction is never archived and vanishes on reload. It is sent, and both archive checks below read it back. ## Two bugs the checks found Worth recording, because both were in the own-reaction marking and neither would have shown up in a 1:1 chat: 1. **Own room reactions were not marked as ours.** The UI passed the bare account as "who we are", but in a room reactions are stored under the occupant JID, as messages are. `Chat.svelte` now passes `room@muc/nick` there. 2. **Then every occupant was marked as us.** `groupReactions`' bare-JID fallback — there to catch our own `account/resource` — collapses all occupants of a room onto the room's bare JID. It now only applies when the identity being compared against is itself bare. Both are covered by `test/reactions.test.ts` and by `REACT-ROOM-STANZA-ID`. ## Checks run `npm run check`: zero errors, zero warnings. `npm test`: **93 tests** passing, 8 of them the new `test/reactions.test.ts` — single-emoji validation including flags, keycaps and ZWJ sequences; addressing and the room-id requirement; full state, the archive hint and withdrawal; hostile payloads (wrong namespace, missing or 2000-character id, twenty emoji, arbitrary text, a bounced error, a bare-room-JID sender); conversation and sender resolution for incoming, own-carbon and room copies plus archive timestamps; the store's per-sender full-state overwrite, replay tolerance, reaction-before-message case and per-conversation isolation; and display grouping in both a 1:1 chat and a room. `npm run build`: clean. `ux-reactions-runner.cjs`, first against a local `npm run preview` of the production build and then against the deployed site. In both cases the websocket reaches the real server, so every reaction is a real stanza: | Engine | Origin | Result | Report | | --- | --- | --- | --- | | Chromium | built preview | 11/11, 0 page errors | `reactions-chromium-1791482088212` | | WebKit | built preview | 11/11, 0 page errors | `reactions-webkit-1791482113059` | | Chromium | live `chat.0ff.space` | 11/11, 0 page errors | `reactions-chromium-1791482783880` | | WebKit | live `chat.0ff.space` | 11/11, 0 page errors | `reactions-webkit-1791482757051` | What the eleven cover: - `REACT-GOES-OUT` — the stanza carries the target's origin id, the `` hint and no body, and the chip appears as ours. - `REACT-FULL-STATE` — adding then withdrawing each sends the complete remaining set, read off the wire; two then one leaves exactly one, ours. - `REACT-RECEIVE-AND-WITHDRAW` — a peer joining raises the count and keeps ours marked (`including you` in the accessible name); its empty set withdraws only theirs. - `REACT-IN-ARCHIVE` — the reaction is in the account's own MAM archive, read back through a second resource, with the current set as its newest copy. This is the `` hint proven rather than assumed. - `REACT-SURVIVES-RELOAD` — chip and own-marking return after a reload. - `REACT-CLAMPED` — a twenty-emoji set plus a `drop database` payload is clamped to 12 and the text dropped, with no page error. - `REACT-NO-GHOST` — a reaction to an unknown id creates no row, renders nowhere and raises nothing. - `REACT-REFUSAL-IS-VISIBLE` — a double-click on a message that has no stable id shows why instead of doing nothing, and sends nothing. - `REACT-DOUBLE-TAP-TOUCH` — with real touch through CDP, one tap does nothing, two give the default reaction and two more withdraw it. This is the gesture as a thumb makes it; the mouse path is `REACT-GOES-OUT`. Chromium only — CDP is. - `REACT-SINGLE-CLICK-DOES-NOTHING` — one click neither sends nor renders. - `REACT-NO-BUBBLE-BUTTONS` — no Reply button, no React button, no picker, so their return is noticed rather than discovered. - `REACT-CLEARTEXT-NOTICE` — the notice's presence matches whether the chat is encrypted. All four runs reported `chatEncrypted: true`, so the encrypted branch is the one that actually ran. - `REACT-ROOM-STANZA-ID` — a room reaction carries the room-issued stanza id in both directions, goes out as `groupchat`, and ours stays distinguishable from an occupant's. The check first asserts the room issued an id *different* from the sender's, so passing means something. - `REACT-ROOM-ARCHIVE` — answers the question §3 left open: `mod_muc_mam` does keep reactions, read back out of the room's own archive after a rejoin, both ours and the occupant's. ### Deployment Generation **562**, system `/nix/store/sn643z2yr1j15pag8j204spz8l1xmp0f-nixos-system-blunix-26.11.20260910.8ce4ef6`; nginx root `/nix/store/n89wvmk77ap8v5pmrhivnc84jcsfg6i8-0ff-chat-0.1.0`. The candidate, the store copy and the bundle served over HTTPS are one and the same file: `index-DN7sJK4g.js`, SHA-256 `8d24eea3ab7cb7d17b3e6bc6933013c795e5f634ed446f04e95d0ce33a6de5bb`. The service worker is unchanged from generation 561, cache headers and the strict CSP are unchanged, gzip and `Vary` intact. Ten platform services active, zero failed units; the Prosody journal holds no traceback and no Lua error over the window, only routed error stanzas from the runner's own room cleanup. Rollback is **561**. One intermittent WebKit warning was seen and is recorded rather than hidden: `ResizeObserver loop completed with undelivered notifications`, once, when thirteen chips appeared at a stroke. It comes from the list's pre-existing reading-anchor observer, whose whole job is to compensate for a content-height change; the reading position was correct in every run and the later runs did not reproduce it. The runner now records that class separately from page errors and counts errors per check, so one check's noise cannot fail the next. The Prosody journal over the run window shows **zero** push attempts (`Pushed to web.push.apple.com`: 0). Read honestly, that is weaker than it looks: the audit sessions carry no push registration at all, which the journal says in as many words. The structural argument is the `mod_push2.lua` read above; a real pushing device is a device check. After both runs, `muc.0ff.space` lists no `ux-` room: every audit room was destroyed and left. ## The bubble as the operator wanted it Within hours of the deploy the operator asked for three things, and they reshape the feature rather than patch it: 1. **The emoji were not displayed.** The app's font stack named no emoji family at all (`system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`), so a glyph was at the mercy of whatever the first matching family covered — on a host with a partial, monochrome emoji font, 👍 comes out an empty box. The emoji families are now named before the generic, which also fixes emoji in ordinary message text. **Confirmed on the device on 2026-10-09**: the heart is shown. It could not be verified here, only on a device that has an emoji font. 2. **A double-tap reacts**, with one fixed emoji (a heart). A second double-tap withdraws it. Joining somebody else's reaction is a tap on their chip. 3. **Both bubble buttons are gone**, "Reply" and "React", and the picker with them. Removing the buttons cost one thing that was not obvious until the live run caught it. The React button was only rendered when a message could actually be reacted to, so its absence was the feedback. A double-tap has no such state, and the first version of it returned **silently** when there was nothing to react to — which is exactly what happens in a room for the few hundred milliseconds between sending a message and the room reflecting it back with its own `stanza-id`. It now says "This message has no confirmed id yet. Try again in a moment." instead, and "An undecryptable message cannot be reacted to." for the other case. `REACT-REFUSAL-IS-VISIBLE` pins that down deterministically, on a message sent with no id at all, rather than racing the room. What that costs, stated rather than buried: with no buttons, quoting is reachable only by the swipe, which is touch-only by design — so **a mouse or a keyboard cannot quote a message any more**, and no emoji but the default can be given first. The operator chose a quieter bubble over those routes. `SWIPE-IS-THE-ONLY-QUOTE-ROUTE` records it so it stays a decision and not an accident. ## Not run, and deliberately left - **The phone.** Chip size, double-tap accuracy, and whether the emoji glyphs actually render there. VoiceOver. Linux WebKit is not native iOS certification. - ~~That the font fix works.~~ **Confirmed on the device, 2026-10-09:** the operator reports the heart is shown. Nothing run here could have said so — this host has no emoji font installed at all (`"Noto Color Emoji"` does not resolve, and 👍 measures 8px wide, a tofu box), so its own rendering was meaningless for the question. The missing emoji families in `--font` were the cause. - **No push on a real pushing device**, per above. - **Monal's behaviour in the hand.** Its source says a bodiless reaction is dropped silently; that was read, not watched. Worth one glance the next time a reaction goes to the operator's account. - **``.** XEP-0444 says to omit the hint when the reacted-to message carried ``. This client never sends that and does not record it on receive, so the hint is always added. Harmless here, and the place to fix it is the message store, not the reaction. - **An emoji picker in the composer** (§8) remains separate and untouched: no protocol, no OMEMO question, and worth most on desktop. ## The double tap reached the client twice — fixed, 2026-10-09 A browser synthesises a mouse `dblclick` out of a double tap. Both the touch detection in `tapEnd` and `ondblclick` therefore fired for one gesture, and the second toggle undid the first: the reaction appeared not to happen at all. Both routes now go through `gestureReact`, which treats a repeat on the same message within 900 ms as the same gesture. Caught by `REACT-DOUBLE-TAP-TOUCH` once it asserted the *number* of stanzas rather than only the last one — with two toggles the last stanza is a withdrawal, which reads as plausible on its own. ### Two runner faults found underneath it, and one wrong diagnosis Worth recording, because the first wrong turn cost real work: - **A tap point is absolute.** Adding a chip makes the bubble taller, and a bottom-anchored list then shifts every row above it — so a bounding box taken before the previous gesture points at a *different* message. `doubleTap` now takes a locator and re-measures, scrolling into view first. Before that, the second gesture was landing elsewhere and the check blamed the client. - **The dedupe window is product behaviour**, so the runner has to respect it: `reactDefault` waits past it, the way a person does between two separate reactions. - **A wrong diagnosis, reverted.** On the strength of the shifted-tap evidence, `session.react` was briefly changed to take the complete new set from the UI instead of reading it back. The reasoning was wrong — the read was never the problem — and the change swapped a fresh database read for the UI's 60 ms-debounced view, which is worse. Reverted. `ux-reactions-runner.cjs` is 14/14 under both Chromium and WebKit again, and `ux-swipe-runner.cjs` 12/12, with zero page errors. `SWIPE-QUOTES-ATTACHMENT-BY-NAME` also learned to wait for the attach button to be enabled before setting a file. **That was read wrongly at the time, and the reading is withdrawn below** ("The silent attachment drop that was not there"): the composer does not drop anything. ## Against the live site, generation 566 Both suites were run against `https://chat.0ff.space` once the switch was done: reactions **14/14 under Chromium and WebKit**, swipe **12/12**, zero page errors. The served bundle is byte for byte the candidate (`17f67c8bb74b5b0af570fb12adcc2c1f9b4e62f3a9dd25418d905da39ccf24e5`), so these runs exercised the same code the unit tests and the preview runs did. `REACT-DOUBLE-TAP-TOUCH` passes there, which is the gesture fix confirmed where it matters. Two runner faults surfaced on the way, both in `ux-swipe-runner.cjs` and neither a client defect: - **Row counting is not an existence test.** The message list renders a window of the newest messages, so once the fixture chat is longer than a page, every new row pushes the oldest out of the DOM and `.row.out` stays at its limit. `SWIPE-QUOTES-ATTACHMENT-BY-NAME` waited for "one more row" and timed out while the attachment was going out perfectly well — the Prosody log showed the slot issued and the upload completed, the stanza was on the wire, and the row was in the DOM. It now waits for a `data-message-key` that was not there before, and quotes that row. - **A pan has to start on something on screen.** `SWIPE-VERTICAL-SCROLLS` measured a named fixture that had scrolled far up the history, so the drag began outside the list and nothing moved. It now uses the last incoming row and asserts it is fully inside the list first. The lesson is the same one the shifted tap taught: a check that identifies a thing by its position or its count is measuring the viewport, not the behaviour. ## The silent attachment drop that was not there The note above claimed the composer drops an attachment silently while media capability is still being established. It does not, and the claim came from the same failing check whose real cause turned out to be row counting. Measured against the live site, attaching 508 ms after the chat opened — well inside the window in question: ``` 58 ms reason "Connect to check media support." attach disabled 508 ms file set 738 ms reason none attach enabled uploads 1 3712 ms uploads 0 (sent) ``` The file goes out. Until it can, the composer shows a reason and the button is disabled, and `sendAttachment` re-checks capability for real before anything is uploaded. Nothing is silent. One real thing did come out of the measurement: for the first ~700 ms the reason read **"Connect to check media support."** — telling the user to connect while the app is connecting. `mediaCapability` now says "Connecting…" for the `connecting` and `reconnecting` states, which is the word the rest of the UI already uses for them.