monal-rebuild/UX-AUDIT-REPORT.md

27 KiB
Raw Permalink Blame History

Mobile UX audit results — 2026-10-07

The extended direct-chat audit makes new-device message reliability the first priority: a cached OMEMO device list can omit a newly added browser, leaving new messages unreadable while the sender sees a read tick. Encrypted direct attachments are also offered but cannot be sent. Group information, drafts and reading-position preservation remain frequent-task gaps. Core text delivery works with a current device list; the successful initial file checks were group uploads, not encrypted direct attachments.

This is an agent-led exploratory audit and documentation-based comparison, not a user study or a completed real-iPhone PWA certification. No application fix or production deployment was performed.

Environment and evidence

  • User's main device: iPhone 17 Pro, iOS 26.6, installed PWA.
  • Automated engines: Chromium 152.0.7977.82 and Linux Playwright WebKit 26.5. The latter is not installed Safari/iOS 26.6. Touch-enabled contexts used a 402 × 874 viewport; WebKit used Playwright's iPhone 13 user-agent.
  • Additional viewport checks: 320 × 700, 874 × 402 and 1280 × 800. They exercise responsive layout, not actual desktop/mobile OS integration.
  • Target: https://chat.0ff.space, bundle assets/index-DLDjjWaX.js, SHA-256 f153a8723ae24c14db115f725c24bd7cab3a0355695d4430efef2ffc19978f23. Both engines fetched that same bundle. Its bytes match the active nginx root /nix/store/lqny4y6afnm1mvq234995x53xcd8lwnw-0ff-chat-0.1.0. The package source's src/ and public/ match the local checkout at 4eb726340a6448fba81fc91ec10bbeee65863d6b. The deployed Git revision itself was not independently established; the bundle hash identifies it.
  • Dedicated accounts A/B and test room are named in UX-AUDIT.md. All sent messages and uploads were synthetic, within those accounts.
  • Chromium full report: /tmp/chat-ux-runner/results/chromium-1791380418245/report.json.
  • Corrected Chromium reconnect report: /tmp/chat-ux-runner/results/chromium-1791380751412/report.json.
  • WebKit full report: /tmp/chat-ux-runner/results/webkit-1791380800982/report.json.
  • Extended Chromium direct/PWA report: /tmp/chat-ux-runner/results/direct-pwa-chromium-1791385881194/report.json.
  • Extended WebKit direct/PWA report: /tmp/chat-ux-runner/results/direct-pwa-webkit-1791386884704/report.json.
  • Extended runs use the same bundle/hash above and private persistent A profiles. Standalone media/navigator indicators are injected; this is not an actual Home Screen installation. Real browser storage, workers and the live app are exercised. Chromium-only freeze/push/voice tests are labeled separately below. Profile data includes authentication/key material; keep the entire runtime directory private and outside Git.
  • Each full-report directory contains screenshots of group headers, message actions, four group layouts, settings, attachments and the fingerprint dialog. Runtime evidence is outside Git and subject to /tmp cleanup; the findings below preserve the observations in the repository.
  • Protocol evidence: /tmp/chat-ux-runner/room-info.json, queries made as both owner and joined ordinary member of the dedicated test room.

Prioritized findings

P1 — new direct-chat devices are missed; read ticks can mislead (D00)

Reproduce in both engines: open A's conversation with B, caching B's existing devices. Then log B into a new browser profile and send a new message from A. A's cached list omits B's current device ID. B displays "Could not decrypt this message." for the new live message, while A can show read. This is distinct from the expected undecryptable backlog on a device that did not exist when old messages were sent.

Diagnostic evidence: each report compares the cached public device IDs with B's current ID. Deleting only the B device-list entry in A's private test profile and reloading allows new messages to decrypt. Subsequent successful delivery/read/link checks explicitly depend on that test intervention; it is not a production fix or normal user workflow. cachedDeviceList returns an IndexedDB entry indefinitely, without a freshness check; markDisplayed can mark an undecryptable incoming item as displayed. These source observations support the reproduced behavior.

Proposal: refresh device lists when peers add/remove devices, with a bounded freshness policy/fallback and clear key/device status. Decide what a read marker means when the content cannot be decrypted; avoid implying successful reading. Never fall back to plaintext to hide failures.

Acceptance: add a device after an existing conversation is open, send in both directions without clearing storage, and verify the new device can read messages; remove a device and verify encryption targets update. Undecryptable content must have accurate status and a useful recovery path.

P1 — encrypted direct media is offered but rejected (D06/D08)

Both engines: select a synthetic PNG and text file in the direct composer. Each fails with "Encrypted file attachments aren't supported yet." Chromium's synthetic microphone also supports recording and discard, but sending a recording fails with the same message. Voice recording was not repeated in WebKit or with real iPhone permissions/hardware.

This is a known missing capability with misleading availability. Source sendFile uploads the blob before send rejects its attachment URL for an OMEMO-capable peer. Do not describe the successful group-file audit as evidence that encrypted direct media works.

Proposal: communicate unsupported media before file selection/recording and before uploading. Full encrypted attachment support needs a compatible protocol/client design; never remove the rejection by sending plaintext. Acceptance: supported flows send/play back; unsupported flows explain the limitation without recording/uploading data that cannot be sent.

P1 — offline cold start hides local history and needs retry (P07)

In the extended Chromium run, close the browser, reopen its persistent profile with networking offline, and navigate to the direct chat. The cached shell returns HTTP 200, but the app displays the login form instead of locally stored messages. After connectivity is restored, the form remains for the ten-second observation window. Reload restores the saved session and chat without entering a password.

Source App.svelte mounts Main only with a session JID and non-offline status, and attempts saved-token resume once on mount. The successful in-session reconnect check is a different case from cold startup.

Proposal: show locally stored conversations in a clearly offline state, keep sending unavailable, and retry the stored session on restored connectivity or through an explicit Retry action. Acceptance: offline startup exposes saved history, explains connectivity, retains drafts and recovers without requiring an unexplained browser reload.

P1 — group information and people actions are missing (G01/G03)

Reproduce: open a group and tap its name/count. Nothing opens. Tap the nickname above an incoming group message: no person/contact action opens. Both engines reproduce these results. Chat.svelte renders the group title as a static div and sender nickname as a span; the group menu only contains Invite and Leave.

Impact: a user cannot answer "Who is in this group?" or find a person from the conversation through familiar navigation. This is a feature gap.

Proposed behavior: make the name/avatar/count an accessible button opening group info. Show group name, description, people and available actions. Selecting a person opens supported actions such as a room-private chat; show contact actions only when the real identity is available and permitted. Keep nickname, account identity and membership roles distinct.

Acceptance: one tap reaches group info; participants are individually selectable; back restores the chat, draft and reading position; owner, admin and ordinary-member views are tested independently.

P1 — unsubmitted text is lost on navigation (N02)

Reproduce: type a draft, use Back to chats, reopen the same room. The composer is empty in both engines. The component owns draft in memory and is remounted during navigation.

Impact: switching conversations can discard text without notice. The extended direct-chat checks reproduce the loss through both the chat list and settings. An unsent direct draft is also lost after a persistent browser restart, even though automatic login and stored messages return.

Proposed behavior: store a draft per account/conversation and restore it when opening that conversation. Clear it only after successful sending or explicit deletion. Show a draft indicator in the chat list. Cross-device draft synchronization is a separate extension, not required for this fix.

Acceptance: switching chat, settings and browser back preserves text and multiline content; chats never share drafts; failed sends retain text. Decide and document reload persistence and logout deletion explicitly.

P1 — reopening a chat jumps away from the reading position (N01)

Reproduce: scroll up in a room with synthetic history, return to the list, reopen it. Both engines jump to the bottom. Chromium's measured remaining scroll distance changes from 2,014 px to 0; WebKit's from 4,441 px to 0. The component's first load always calls scrollToBottom.

Proposed behavior: retain a per-conversation message anchor and offset; preserve it across navigation. Offer an explicit jump-to-latest control when reading older messages and new messages arrive.

Acceptance: return to the same visible message after switching views; incoming messages, older-history loading and image resizing do not unexpectedly move the reader. First-time opening/unread positioning needs a deliberate policy rather than applying restoration to every situation.

P1 candidate — no quoted reply action (M02)

Browser observation: tapping and invoking a context-menu gesture on an incoming message exposes no application reply action. Source inspection finds no quoted-reply/swipe-reply implementation or message action menu. Native iOS long press and native text selection were not exercised.

Impact: group replies cannot visibly identify which earlier message they refer to. This is a feature gap, not a failed delivery mechanism.

Proposal: a discoverable Reply action and optional swipe gesture, quoted composer preview with cancel, and a tap on the sent quote to reach the original message. First establish compatible XMPP representation/fallback before implementing cross-client behavior.

P2 — menu and modal keyboard handling (A02)

Both engines: Escape does not close More actions; opening the fingerprint dialog leaves focus outside it; Tab can reach controls behind the dialog; Escape does not close it either.

Proposal: move focus into the modal, keep it there while open, close with Escape and return focus to the trigger. Implement the appropriate menu focus/navigation behavior. This also matters to assistive input users.

P2 candidate — touch areas below the preferred phone size (A01)

Measured in the group view: More actions 34 × 34 CSS px; back, attachment and record 36 × 36. They exceed the 24 px WCAG 2.2 AA size threshold by size alone; these measurements do not justify claiming an AA violation. The stronger target-size criterion uses 44 × 44 CSS px. The proposal is to aim for that larger target for frequent phone actions without enlarging every icon. W3C minimum, W3C enhanced.

Real-phone comfort and spacing still require validation. No horizontal page overflow was measured in the tested group/settings layouts.

P2 candidates — discovery, search and per-chat controls

Source/UI inventory shows New chat as a raw account/room-address input, without a contact picker or guided group creation; no conversation/history search; no per-chat mute option in the examined menus/settings. Assess frequency with the operator before implementing these extensions.

Suggested order: contact picker and a clear Join/Create group distinction, then per-chat mute and local history search. Avoid a full messenger-feature catalog before fixing navigation and group information.

Membership constraint: a frontend list needs correct semantics

The current participants number comes from a set of occupants observed through MUC presence, not a full membership query. In this audit the count stayed at 2 even after one browser left; earlier hibernated sessions can retain the same account's nickname. It is unsuitable as proof that all members are online or that there are only two members.

Live XMPP queries after joining the test room showed:

Requester Member list Admin list Owner list
Owner A B returned as member Empty A returned as owner
Ordinary member B forbidden forbidden forbidden

Therefore implement available occupant information first, and query all membership affiliations only when allowed. A member-only query would omit owners/admins. If the operator wants every ordinary member to see absent members too, review the room's anonymity/list permissions as an explicit product/privacy decision; do not silently expose identities or pretend a permission error means an empty group. XEP-0045 makes member-list access dependent on room configuration. XEP-0045 member lists.

Comparator research

Checked official sources on 2026-10-07. No comparator app was logged into; installed app versions and hands-on gesture behavior are not verified. These are documented patterns supporting proposals, not visual parity claims about current releases.

Task Officially documented pattern Implication for 0ff chat
Inspect group Signal mobile opens group settings from the group name; members and admin labels are available there. Signal Make header an entry to group info and people, with roles.
Preserve unfinished text Telegram documents peer-specific drafts and cross-device synchronization. Telegram At least preserve local per-chat drafts and show their existence.
Reply to a message Telegram documents swipe-to-reply and navigating from a quote to its original. Telegram Add quoted replies and a return path to the original message.
Reply or contact a group sender Signal documents swipe-to-reply and avatar → Message for a private conversation. Signal Give message/person actions discoverable entry points.
Manage interruptions on iOS Signal exposes timed per-chat muting through the chat header. Signal Consider per-chat mute alongside global notification controls.
WhatsApp group/reply workflows Official articles exist for adding/removing group members and replying to messages; search-indexed group instructions mention opening group info via the subject. Members, Reply Useful secondary references; full article bodies did not render in the research fetch, so exact iPhone steps remain unverified.

Successful checks and limits

  • Both accounts logged in; group messages arrived; synthetic text-file uploads appeared for the recipient; Leave room followed by rejoining worked in both engines. Invitations were already verified in the initial trial, not independently repeated for new membership in this round.
  • Direct messages arrived with the OMEMO lock visible in both engines. This is a UI flow check, not a new cryptographic or Monal interop audit. A newly created WebKit profile showed the expected undecryptable backlog placeholder for messages sent before that device existed.
  • The corrected interruption test injects a WebSocket close and blocks reconnect attempts in the test transport, with browser HTTP offline emulation too. Draft survives, Send is disabled, reconnect restores the composer in Chromium and WebKit. The first Chromium offline-only attempt did not terminate the existing WebSocket and is recorded as test setup failure, not an application bug.
  • Dark settings rendered correctly; no page JavaScript errors were caught during the successful full runs. Large system text/VoiceOver, real mobile keyboard and OS-level media permissions remain untested.
  • Browser sessions were sequential. Minimum sampled available RAM: 1,638 MiB in Chromium, 1,748 MiB in WebKit. WebKit one-minute load reached 3.73 on two CPUs; swap also varied slightly. Keep one worker and avoid simultaneous builds. Long-run or production-latency guarantees were not established. After tests nginx, Prosody, LLDAP and Traefik were active, chat returned HTTPS 200, and no audit browser processes remained.

WebKit originally failed creating its WPE EGL display; pointing it at Nix Mesa software-rendering libraries fixed the run without a NixOS switch. The runner and runtime packages are documented below.

Implementation batches

The detailed follow-up is UX implementation plan: ordered packages, source areas, compatibility decisions, regression checks and release boundaries. The plan is proposed; no package is implemented yet.

  1. Direct-message reliability: fresh device lists and truthful status when decrypting fails; explain unsupported encrypted media before selection/upload, then plan compatible encrypted attachments.
  2. Keep user state: local per-conversation drafts and reading anchors; cached-history access and session retry on offline startup. Regression: navigating away and back retains both; new messages and attachments do not pull a reader away from older content.
  3. Group info: accessible header entry, available people/roles, person actions, invite/leave and explicit full-list permission handling. Test owner/member and an absent member, not only two active owners.
  4. Phone interaction: quoted replies, larger touch targets, menu/modal focus and dismissal; validate on the actual iPhone during implementation.
  5. Daily-use extensions: contact picker, search and per-chat mute after operator prioritization and checking protocol/client compatibility.

Extended direct-chat and PWA simulation results

The operator requested simulating PWA use and expanding direct chats. These checks were actually run; their level of simulation is explicit:

Check Chromium Linux WebKit What the result establishes
Contact request / roster Request accepted in first extended run; restored later Existing contact restored Test accounts can become contacts; enabled call controls appeared. No audio/video call was placed.
Direct text, receipts, unread Delivered → read; unread row/title cleared on opening Same Healthy path after the documented device-cache intervention.
Typing / multiline / links Passed Passed Typing starts/ends, touch Enter inserts a newline, Unicode/link text arrives and links use a new tab with appropriate rel attributes.
Newly added device Unreadable new live message; sender read tick Same Reproducible stale-device-list failure, distinct from old backlog.
Direct drafts / reading position Drafts lost; reading distance 3,006 → 0 px Drafts lost; extended position probe inconclusive (0 → 0) Direct position gap confirmed in Chromium; earlier group-position checks passed reproduction in both engines.
Direct image/file/voice Image/file rejected; synthetic voice recording/discard works, send rejected Image/file rejected; voice not run Missing encrypted attachment capability.
Standalone indicators Injected, shell controlled, assets cached Injected, shell controlled; page cache inventory empty App renders with the simulated indicators. This does not install a PWA. WebKit cache result needs engine/device validation.
Keyboard-size viewport Composer reachable at 402 × 500 and 874 × 300 Same Resizing the layout viewport works; native keyboard overlays and safe-area values are not reproduced.
Background-like freeze/resume Draft retained; one incoming message after brief freeze Not run Chromium lifecycle freeze works in this short scenario, not iOS process eviction or long background suspension.
Worker push / notification click Notification created; route opens correct chat; focus denied for untrusted click Not run Real worker executes injected push and click handlers. It does not prove OS delivery, OS focus or opening a closed iPhone PWA.
Worker update check Passed, unchanged worker Same Update API works; no changed production version or old/new release migration was tested.
Persistent-profile restart Saved login, browser/OMEMO device IDs and message retained; draft lost Same Real browser shutdown/relaunch preserves those stored states.
Offline cold start Cached shell, history hidden, no automatic recovery in 10 seconds; reload restores session Internal navigation error Chromium application gap above; WebKit result is an execution/environment limitation, not a proven Safari bug.

Chromium push uses CDP ServiceWorker.deliverPushMessage. The click is an untrusted NotificationEvent dispatched to the real worker with its waitUntil completion captured. Posting the chat route succeeds; focus() rejects with "Not allowed to focus a window." That expected synthetic-click restriction is not claimed as an application defect. Playwright persistent contexts preserve storage across actual browser restarts. Chromium ServiceWorker protocol, Playwright persistent contexts.

The first extended Chromium attempt used arrival assumptions that became invalid because of the new-device failure, and later aborted before the restart checks. A second run established the explicit cache intervention, but its offline test treated failed automatic recovery as a timeout. The final report linked above records offline state, the ten-second recovery window and successful reload separately. Those earlier execution failures are retained as evidence, not converted into successful checks.

No page JavaScript errors were reported in the final extended runs. Minimum sampled available memory was 1,970 MiB in Chromium and 1,781 MiB in WebKit. All runs were sequential, with a single browser process tree and isolated account contexts. This remains a short-run resource result. Maximum sampled one-minute load was 2.13 in Chromium and 2.36 in WebKit. After the extended runs, nginx/Prosody/LLDAP/Traefik were active, chat HTTPS returned 200 in 0.094 seconds, and the host process inventory contained no remaining audit browser processes. This is an end-of-run health check, not continuous production-latency monitoring. Credentials, caches and keys are in the private runtime profiles, not Git.

Remaining device-only validation is narrower: actual iOS keyboard/visual viewport and safe areas, Home Screen installation, OS background/eviction, real push delivery and trusted notification opening, media permissions and hardware, large text/VoiceOver. The simulation results above already cover the app behavior that could be tested autonomously on blunix.

Real iPhone PWA result sheet — pending

Use the existing installed PWA on iPhone 17 Pro / iOS 26.6. Do not clear its data or log out just to run the audit. Record pass/fail and short notes:

ID Device task What to observe Result
PWA01 Open a chat, type harmless unsent multiline text, open/close keyboard Composer/latest message reachable; no hidden buttons or jump Pending
PWA02 Navigate between chat/list with a harmless draft and while reading old messages Confirm known draft/reading-position gaps on real PWA Pending
PWA03 Switch apps and lock/unlock, then reopen chat Session and composer recover predictably Pending
PWA04 Toggle connectivity, then restore it Clear reconnect state; draft kept; no duplicate sending Pending
PWA05 Receive a message from an arranged tester while PWA is backgrounded/closed; open notification Correct chat, unread count and badge Pending; requires arranged sender
PWA06 Photo/file selection and voice recording, using arranged test chat Permission prompt, cancel/send and playback work Pending; requires test data
PWA07 Large text, VoiceOver, safe areas and orientation Readable/reachable controls; no clipping; focus behavior Pending

Notification delivery here cannot be certified by Linux WebKit. iOS web push applies to Home Screen web apps; actual permission, background and notification-open behavior needs the device. Apple/WebKit.

Re-running the audit

ux-audit-runner.cjs records gaps and measurements without treating an observed UX gap as a script failure; execution failures are explicit. It uses the existing dedicated test room and requires its accounts to have membership. Passwords remain in the mode-0600 runtime accounts file.

npm install --prefix /tmp/chat-ux-runner --no-audit --no-fund playwright
node ux-audit-runner.cjs chromium
node ux-audit-runner.cjs chromium --reconnect-only
node ux-pwa-direct-runner.cjs chromium

npm install --prefix /tmp/chat-ux-webkit --no-audit --no-fund playwright@1.61.1
env UX_PLAYWRIGHT_MODULE=/tmp/chat-ux-webkit/node_modules/playwright \
  UX_WEBKIT_PATH=/nix/store/kvbchbp55jd09wh2h1bv3xx9cz26k6vg-playwright-webkit/pw_run.sh \
  LIBGL_ALWAYS_SOFTWARE=1 EGL_PLATFORM=surfaceless \
  LIBGL_DRIVERS_PATH=/nix/store/ppphyhp48gzdji3q9q945gmamy4rrj3f-mesa-26.2.2/lib/dri \
  __EGL_VENDOR_LIBRARY_FILENAMES=/nix/store/ppphyhp48gzdji3q9q945gmamy4rrj3f-mesa-26.2.2/share/glvnd/egl_vendor.d/50_mesa.json \
  node ux-audit-runner.cjs webkit

For extended WebKit checks, use the same environment command with node ux-pwa-direct-runner.cjs webkit instead. That runner creates a private profile in its per-run results directory; do not export/commit that profile. It changes only the synthetic accounts' contact relationship and sends synthetic messages/uploads. Its cache invalidation is restricted to the private A profile and is always recorded as TEST-CACHE-REFRESH.

Nix browser/Mesa paths refer to the packages fetched for this audit; rebuild the corresponding package attributes if they were garbage-collected. The app package and its package-lock were not changed for browser tooling.

Implementation follow-up — 2026-10-07

Packages 0–3 are implemented and released. See implementation results for candidate/deployment evidence, the corrected WebKit scroll setup and native validation limits. The findings above describe the original audited build and remain historical evidence.