# 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/` (`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=`.