monal-rebuild/UX-REACTIONS-RESULTS.md
pijukka 2b671e050f chat(media): say Connecting while connecting
Opening a chat before the socket is up showed "Connect to check media
support." in the composer — asking for something the app is already
doing. The rest of the UI calls that state "Connecting…".

Also withdraws the claim that the composer drops an attachment silently
while capability is being established. It does not: measured against the
live site, a file set 508 ms after the chat opened uploaded and sent. The
claim came from the check that was really failing on row counting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 21:34:48 +02:00

18 KiB

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; 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 <reactions> 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 <encrypted> 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 <store/> 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 <store/> 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 <store/> 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.
  • <no-store/>. XEP-0444 says to omit the hint when the reacted-to message carried <no-store/>. 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.