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>
347 lines
18 KiB
Markdown
347 lines
18 KiB
Markdown
# 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 `<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.
|