monal-rebuild/README.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

283 lines
16 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.

# 0ff chat
A small XMPP web client in the spirit of Monal, for `chat.0ff.space`.
Svelte 5 + TypeScript + xmpp.js, built with Vite. Plan and decisions:
[PLAN.md](PLAN.md).
Mobile UX investigation and device test checklist: [UX-AUDIT.md](UX-AUDIT.md).
Browser findings, comparison evidence and improvement batches:
[UX-AUDIT-REPORT.md](UX-AUDIT-REPORT.md).
Prioritized change packages, dependencies and acceptance criteria:
[UX implementation plan](ux-implementation-plan.md).
Implemented packages 0–3, candidate/live evidence and remaining device checks:
[UX implementation results](UX-IMPLEMENTATION-RESULTS.md).
Packages 4–6, candidate/live evidence and remaining device checks:
[UX state and group results](UX-STATE-RESULTS.md).
Package 7 and iPhone screen edges, evidence and remaining device checks:
[UX phone results](UX-PHONE-RESULTS.md).
Package 8 protocol decision and reply checks:
[UX reply results](UX-REPLY-RESULTS.md).
Package 9 format decision, encrypted-attachment checks and the device half
still open: [UX media results](UX-MEDIA-RESULTS.md).
Swipe to reply, and the measured cause of the duplicated iPhone
notifications: [UX swipe results](UX-SWIPE-RESULTS.md).
Emoji reactions (XEP-0444), what was built and what was checked:
[UX reactions results](UX-REACTIONS-RESULTS.md); the survey behind them and
the two decisions they waited on: [reactions plan](REACTIONS-PLAN.md).
`ux-audit-runner.cjs` explores groups and navigation;
`ux-pwa-direct-runner.cjs` covers direct chats and simulated PWA lifecycle
with private persistent browser profiles; `ux-state-runner.cjs` covers
packages 4-8, `ux-media-runner.cjs` the encrypted attachments of package
9, `ux-swipe-runner.cjs` the swipe-to-reply gesture and
`ux-reactions-runner.cjs` emoji reactions. Commands and simulation limits are documented in the reports. All
runners use dedicated audit accounts and keep profiles, credentials and
reports outside Git. `ux-media-runner.cjs` wants a fresh profile directory:
its round trips count what arrives, and a stale profile already holds an
earlier run's attachments.
Candidate regression checks (packages 0–3): build once, start
`npm run preview -- --host 127.0.0.1`, then run
`UX_BASE_URL=http://127.0.0.1:4173 node ux-pwa-direct-runner.cjs chromium --candidate`.
Run WebKit afterward with the runtime environment documented in the audit
report. One worker, no parallel build. `--new-device` deliberately adds a B
profile for the device-addition regression; ordinary runs reuse A/B profiles
under `/tmp/chat-ux-runner/candidate-profiles/<engine>` (`UX_PROFILE_DIR`
overrides this). Credentials, profiles and reports stay outside Git.
`UX_CHECKS=BUILD,D09-FAILED-DISPLAY` selects focused checks. A deliberately
added B device is removed afterward using the reusable B profile; ordinary
profiles stay available. `UX_RETIRE_DEVICE_IDS` removes only explicitly
listed temporary test IDs. The runner also records viewport and fixture setup.
Candidate reports record revision, dirty state, origin, engine and bundle
hash; failed expectations return a nonzero exit status. Candidate mode never
uses the historical cache-deletion intervention. Historical audit results
remain in the report; HTTPS/CSP/push and actual iPhone checks are separate.
Packages 4–6: `node ux-state-runner.cjs chromium`, then WebKit with the same
runtime settings. The default origin is the built preview at 127.0.0.1:4173;
set `UX_BASE_URL`/`--origin=` for live checks and `UX_CHECKS`/`--checks=` for a
comma-separated subset. WebKit defaults to the audited blunix runtime paths;
environment variables override them. Its Linux offline-emulation limitation
is recorded separately from the test with shell online and XMPP unavailable.
The runner uses stable audit profiles, locally seeds pagination history,
faults/holds device queries for send races, exercises a closed-profile offline
restart and credential rejection, and creates a disposable audit group for
owner/admin/member permissions, absent members and person actions. Temporary
admin affiliation is confined to that group and restored; the group is
destroyed and its bookmarks removed afterward. Logout tests delete only the
audit account's local chat store. Credentials and captures stay outside Git.
Emoji reactions: `node ux-reactions-runner.cjs chromium`, then `webkit` with
the same runtime defaults. Same `UX_BASE_URL`/`--origin=` and
`UX_CHECKS`/`--checks=` conventions. Besides the browser it opens two raw
XMPP clients — the second audit account, which supplies incoming fixtures
and, as the recipient, lets the outgoing stanzas be read off the wire
including deliberately hostile payloads; and a second resource of the first
account, used only to query MAM. That resource publishes no OMEMO bundle, so
it adds no device to the account's device list. A disposable audit room is
created for the groupchat and room-archive checks and destroyed afterward.
Under Chromium it also drives a real touch double-tap through CDP, which is
the gesture that gives a reaction; under WebKit the double-click stands in.
```bash
npm install
npm run dev # http://localhost:5173, websocket proxied to chat.0ff.space
npm run check # svelte-check / TypeScript
npm run build # static bundle in dist/
```
`XMPP_UPSTREAM=https://other.host npm run dev` points the dev proxy at a
different server. In production the per-host settings live in `config.js`
next to `index.html` (see `public/config.js`), generated by Nix.
## Develop on blunix
`npm run dev -- --host 100.64.0.3` serves on the tailnet; open
`http://100.64.0.3:5173` from a tailnet device (iPhone). Over plain HTTP
iOS gives no microphone, camera, service worker or push — test those on
`chat.0ff.space` after a release.
Release: commit and push here, then on blunix in `~/blunix`
`nix flake update chat-client` (its own commit), push, `sudo nixos-rebuild
switch --flake .#blunix`, reload on the phone. Way back: previous
generation.
## Nix
`package.nix` builds the same bundle with `buildNpmPackage`; a host passes
its settings as `configJs`:
```nix
pkgs.callPackage "${src}/package.nix" {
configJs = ''window.config = { name: "0ff chat", websocket: "/xmpp-websocket", domain: "0ff.space" };'';
}
```
`pushService` in `config.js` names the server's web push relay component
(Prosody `mod_push2` plus `mod_webpush_relay`, PLAN.md milestone 8);
without it the app doesn't subscribe to push.
After changing dependencies, update `npmDepsHash`: set it to
`lib.fakeHash`, build, and copy the hash from the error.
## License
0BSD for first-party source. The distributed `dist/` bundle, once it
includes OMEMO encryption, additionally carries GPLv3 obligations due to
`libomemo.js` — see [LICENSE](LICENSE) for details.
## OMEMO device freshness
Device lists accept only validated legacy PEP payloads from the direct bare
publisher. Lists expire after five minutes, refresh after reconnect, and
coalesce concurrent requests per account/peer/client. Without a confirmed
roster subscription, the first send of a conversation or a send burst after
ten seconds idle refreshes again, even if the UI warmed the cache. Own-device
lists follow the same burst fallback. Identity, trust and ratchets remain.
A malformed/unavailable fetch preserves the cache and fails sending; a peer
previously known to support OMEMO cannot silently become a plaintext recipient
when its list becomes empty. Reconnect and new messages retry normally.
## Displayed-marker policy (package 2)
Local attention and remote display reports are separate. Visiting a decrypt
error clears the local badge, but the error itself never sends a displayed
marker. Select the newest visible, readable, markable incoming direct
message. A later readable message can advance the cumulative marker past an
unreadable gap or pre-device backlog; the stored error stays unchanged.
Markers never move backward. External markers mean the remote client reported
“Displayed”, not proof of human reading or successful decryption. Existing
stored `read` values remain compatible and render with that new label.
This follows the cumulative reporting semantics of
[XEP-0333](https://xmpp.org/extensions/xep-0333.html). The error's expandable
help explains older-device history and asking the sender to reconnect/send
again, without promising recovery of old ciphertext.
## Media capability
Session owns the media decision for each conversation. Direct encrypted
files/voice are unavailable with a visible composer explanation; selection,
paste, drop and programmatic sends all check before requesting upload slots
or sending bytes. Recording checks before microphone permission. Unknown
capability and offline state fail closed. The upload path checks again after
discovery and after a slot response, and respects cancellation while checking
or waiting for a slot. Joined group file/image uploads remain supported.
This communicates the current limitation; encrypted attachments remain
package 9 of the implementation plan.
## Local drafts and reading position
Drafts are stored per account/conversation in IndexedDB, including multiline
text, and previewed in the chat list. Input writes are enqueued before view
navigation; tabs notify each other. A successful send clears only the revision
it sent, preserving subsequent typing or another tab's edit. Failed sends keep
the draft. Logout deletes the account's local drafts with its history.
Reading position stores the first visible message and its offset, or the
latest-message position. Returning loads the required local page; incoming
messages, pagination and media sizing preserve the visible anchor. The latest
messages control returns to the bottom. This state is local to this browser.
## Remembered sessions offline
An opted-in FAST session opens its account's saved chats and drafts before
network authentication. A last-used username alone never opens local history.
Offline/reconnecting state and Retry are visible in every view; sending and
server actions wait for an online session. Initial network failures keep the
token and retry with backoff; restored connectivity and app activation wake
the retry. Existing stream resumption is retained and concurrent reconnects
are coalesced. Retry, restored connectivity and app activation bypass the
automatic reconnect delay instead of starting another backoff timer.
An early socket close releases the reconnect guard even if the transport's
connect promise remains pending, so later attempts can open a new socket.
`RETRY-LATENCY` in the state runner measures the first socket attempt after
two failures and requires it within one second of clicking Retry.
Tabs use distinct XMPP resources while retaining the stable
browser device identity, so they do not displace each other. Rejected/expired
credentials return to login with the reason; logout clears
local history and drafts. The service worker refreshes the offline page only
after caching its referenced bundles, including after a client-only update
whose worker script stayed unchanged. Downloads and cache writes run in the
background within the fetch handler lifetime; the online page and bundles
are returned immediately instead of waiting for offline-cache maintenance.
Push behavior and the existing cache namespaces stay intact.
## Group information
Tap the group's avatar, name or count to open group information; message
nicknames open the same person's actions. Returning preserves the draft and
visible message. Present participants retain their nickname, role, affiliation
and any account identity disclosed by the room, including nickname changes
and reconnect. Presence is not an active-use indicator.
The member/admin/owner lists are requested separately under existing room
permissions. Denied or failed lists are labelled incomplete, not empty.
Disclosed membership identities are deduplicated and people not listed as
present are labelled separately. Room-private messaging uses the occupant
address; direct-chat/contact actions require a disclosed account address.
Invite/leave retain their existing server policy. No room privacy or server
permission changes are part of this UI. Protocol: [XEP-0045](https://xmpp.org/extensions/xep-0045.html).
## Phone controls and screen edges
Buttons have at least 44 × 44 CSS-pixel hit areas with compact icons and
visible keyboard focus. More actions opens with Enter/Space or Up/Down;
arrows and Home/End select items, Escape returns to the trigger, and Tab or
an outside pointer dismisses the menu. Video call is in that menu to keep
the phone header usable. Fingerprints use a native modal dialog: background
controls are inert, focus stays inside, and closing/Escape returns to the
lock button. New-chat/invite fields take focus explicitly and Escape returns
to their trigger. Person actions move focus to their heading and return it
to the selected person when closed.
The installed iOS app requests content behind the status bar using
`apple-mobile-web-app-capable=yes`, `black-translucent` and the existing
`viewport-fit=cover`. The HTML canvas and browser theme color match the header
surface for light/dark/system themes. Backgrounds extend to screen edges;
controls use all four safe-area insets, and the connection banner consumes
the top inset once. Rotation is allowed. See
[Apple's status-bar metadata](https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html)
and [WebKit's screen-edge guidance](https://webkit.org/blog/7929/designing-websites-for-iphone-x/).
`node ux-state-runner.cjs chromium --checks=BUILD,PHONE-CONTROLS,GROUP-INFO`
checks touch areas, modal/menu focus, themes and 320/402/740/1280px layouts;
repeat with WebKit. Safe-area values and large text are injected for layout
checks. Linux browsers cannot verify actual iOS status-bar appearance,
VoiceOver or the phone keyboard. After rollout, reload and fully reopen the
installed app, then check the area around the camera/battery on the phone.
## Quoted replies
Each readable message with a stable ID has an explicit Reply action. The
composer preview can be cancelled (button or Escape); selecting/cancelling
retains the typed text. Text and quote are saved together in the existing
per-account/chat draft, and a send clears only its captured revision.
Attachments are disabled while composing a text reply. Tapping a received
quote loads and focuses the original from this conversation's saved history;
missing history is explained without guessing a different message.
Older saved group messages without a captured room stanza ID cannot expose
Reply; new reflections/archive copies add that field without a schema migration.
Plaintext replies use XEP-0461 plus a bounded text quote and XEP-0428 Unicode
codepoint fallback range. Groups require the room-issued XEP-0359 stanza ID,
not the sender ID or MAM cursor. Legacy OMEMO encrypts the complete quote
and answer as ordinary text, preserving Monal's text decoder; the outer body
remains the fixed encryption placeholder. Only reference ID/address and the
fallback range are outside encryption (this exposes reply linkage/quote
length, not quote text). These outer fields are sender-provided navigation
hints, not authenticated statements. Parsing defers quote separation until
decryption, validates bounds and never removes arbitrary decrypted text.
An encrypted original cannot be quoted through an unencrypted send.
Monal's text-quote path displays a useful quote once; its own text-only
replies remain readable without inferring an original ID.
See [XEP-0461](https://xmpp.org/extensions/xep-0461.html),
[XEP-0428](https://xmpp.org/extensions/xep-0428.html) and the source/version
evidence in the reply results. The operator confirmed the actual encrypted
PWA → Monal 6.4.21 quote/answer display; two web profiles alone do not certify
native interoperability. Monal's text-only replies retain their text fallback.
`node ux-state-runner.cjs chromium --checks=BUILD,QUOTED-REPLIES` checks the
reply flow with the existing A/B profiles and a disposable owned room;
repeat sequentially with WebKit. It seeds/removes only its own local history
fixtures, destroys the room and removes its bookmarks. Credentials, wire
observations and reports stay private. Combine the phone, reading-position,
draft-send and offline checks as needed; live HTTPS uses `--origin=`.