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>
16 KiB
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.
Mobile UX investigation and device test checklist: UX-AUDIT.md.
Browser findings, comparison evidence and improvement batches:
UX-AUDIT-REPORT.md.
Prioritized change packages, dependencies and acceptance criteria:
UX implementation plan.
Implemented packages 0–3, candidate/live evidence and remaining device checks:
UX implementation results.
Packages 4–6, candidate/live evidence and remaining device checks:
UX state and group results.
Package 7 and iPhone screen edges, evidence and remaining device checks:
UX phone results.
Package 8 protocol decision and reply checks:
UX reply results.
Package 9 format decision, encrypted-attachment checks and the device half
still open: UX media results.
Swipe to reply, and the measured cause of the duplicated iPhone
notifications: UX swipe results.
Emoji reactions (XEP-0444), what was built and what was checked:
UX reactions results; the survey behind them and
the two decisions they waited on: reactions plan.
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.
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:
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 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. 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.
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
and WebKit's screen-edge guidance.
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, XEP-0428 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=.