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>
283 lines
16 KiB
Markdown
283 lines
16 KiB
Markdown
# 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=`.
|