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

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.