Compare commits

...

10 Commits

Author SHA1 Message Date
jared 4d7a05c0f1 fix(a11y): review-wave fixes (P3-4)
CI / Build & Quality Checks (push) Successful in 11m3s
CI / Trigger Desktop Build (push) Successful in 22s
- `?` shortcut now stopImmediatePropagation so RoomView's type-to-focus handler
  doesn't steal focus into the composer behind the dialog (and swallow Escape) —
  CONFIRMED review finding.
- Typing live region stays mounted (empty when idle) so the FIRST "X is typing"
  is reliably announced (a status region added with its text isn't always read).
- Removed a stray empty `{}` JSX expression in MediaGallery (leftover from an
  auto-fix).

Reviewer verified the rest: collapsed-message labels, focus-return
classification (4 dialogs fixed, popouts correctly left), and all aria fixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:57:32 -04:00
jared b5e7bcc0b8 chore: prettier-normalize page/style.css.ts (pre-existing debt)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:50:32 -04:00
jared bca371ad38 feat(a11y): label the moderation reason input (P3-4)
Missed from the form-labels commit — aria-label on the shared kick/ban/invite
reason input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:46:35 -04:00
jared 899a14c119 docs: P3-4 accessibility — features section, TODO/BUGS, LOTUS_TESTING §P
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:45:22 -04:00
jared 6728a1274d chore(a11y): enforce a curated jsx-a11y lint gate in CI (P3-4)
Enables ARIA-correctness rules (aria-props/proptypes/role/unsupported-elements,
role-has/supports-aria-props, no-redundant-roles, anchor/heading-has-content)
+ label-has-associated-control as errors — a regression gate for accessible
names + valid ARIA. control-has-associated-label deliberately NOT enabled (the
repo's <Text as="label" htmlFor> component pattern defeats its static analysis);
the real gaps it surfaced were fixed directly. Also disable max-classes-per-file
for test files (mock classes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:45:22 -04:00
jared 21dda93d1b feat(a11y): focus return, typing announcement, shortcuts help (P3-4)
- Focus returns to the trigger when closing 4 genuine dialogs (room-topic
  viewer, reaction viewer, header topic, Search) — 20 inline popouts/menus
  correctly left as-is (returning focus to a hover target would be wrong).
- Typing indicator announced via a visually-hidden role="status" region;
  the visual text is aria-hidden to avoid double announcement.
- New keyboard-shortcuts help dialog (press ?, ignored while typing),
  mounted in ClientNonUIFeatures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:45:22 -04:00
jared 4380041014 feat(a11y): label form controls + overlays (P3-4)
Accessible names for ~15 controls that lacked them: invite/join/create-room/
account-data/image-pack/private-note/power-level inputs (visible <label htmlFor>
where a label exists, else aria-label); the two range sliders (night-light
intensity, noise-gate threshold); the soundboard file input; media <video>
elements; and the Media Gallery (region) + Search (dialog) overlays. Hidden
notification/preview <audio> marked aria-hidden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:45:21 -04:00
jared 8729ccfcf5 feat(a11y): message semantics for screen readers (P3-4)
- Each message is role="article"; collapsed messages (consecutive from one
  sender) now carry an aria-label with sender + time — previously a screen
  reader heard only the body with no attribution (the biggest a11y gap).
  Pure messageAriaLabel() reuses the existing time utils (+3 tests).
- Editing a message announces "Editing message from <sender>" (ariaLabel
  threaded MessageEditor → CustomEditor; the main composer is unaffected).
- System emoji get role="img" + aria-label from the shortcode; custom
  emoticons always have an accessible name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:45:21 -04:00
jared 8ab1ec254b docs(testing): add July batch — threads, per-thread notifs, math, search cache, session, audit wave, desktop CSP (§O)
Fills the gap where LOTUS_BUGS referenced test IDs (P3-8/P4-1/P4-4/P4-8/N97a/
AW-1..4) with no matching procedures in the testing guide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:15:48 -04:00
jared 23f715857c docs: mark P4-8 (search cache) + session-atomicity as shipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:09:50 -04:00
38 changed files with 748 additions and 187 deletions
+3 -2
View File
@@ -16,7 +16,7 @@ step-by-step checks in [`LOTUS_TESTING.md`](./LOTUS_TESTING.md).
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
| ID | Item | File / area | Test |
| :--- | :------------------------------------------------------------------------------------- | :--------------------------------------------------- | :-------------------------------------------------------------------------------- |
| :--- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------- |
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
@@ -41,6 +41,7 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
| AW-2 | Emoji lazy-load (search/autocomplete/recents fill in; board opens fast) | `plugins/emoji.ts` + consumers | first emoji-board open of a session: grid+search populate; reactions still label |
| AW-3 | SW precache (repeat-visit near-instant; deploys still picked up immediately) | `sw.ts`, `vite.config.js` | load app twice (2nd = cached assets); deploy → reload picks new version |
| AW-4 | Desktop CSP tighten + Escape/panel fixes + thread Jump to Latest | `tauri.conf.json`, Room/ThreadPanel | desktop: boots, avatars/media load, VT323 font renders, location maps embed, calls connect, deep links work |
| P3-4 | Accessibility compliance pass (collapsed-msg SR sender, form/overlay labels, typing announce, focus-return, `?` help, jsx-a11y CI gate) | `message/*`, `RoomViewTyping`, `features/shortcuts/*`, `eslint.config.mjs` | LOTUS_TESTING §P — axe-core + VoiceOver/NVDA on the golden path |
**Verified working in live testing (2026-06):** A2, B1B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
@@ -139,7 +140,7 @@ retry … AbortError: Restart delayed event timed out before the HS responded`,
### Security & Privacy
- **N97 — Access token stored in plaintext `localStorage`** (`state/sessions.ts`), vulnerable to XSS; device ID likewise. Architectural — needs a token-protection / session-storage redesign.
- **Session writes are non-atomic and not cross-tab synced** (`state/sessions.ts`) — risks inconsistent state / races across tabs.
- ~~**Session writes are non-atomic and not cross-tab synced**~~ — **done (2026-07):** atomic single-key `cinny_session_v1` blob (legacy-key migration + dual-write) + `subscribeSessionChanges`/`useSessionSync` cross-tab reload. (The plaintext-token concern in N97 above is the remaining, separate architectural item.)
- **Persisted PII without encryption:** user status message + expiry (`settings/account/Profile.tsx`), unsent composer drafts (`room/RoomInput.tsx`). Leak risk on shared devices.
### PWA / Offline / Notifications
+17 -12
View File
@@ -18,7 +18,7 @@
From the matrix infra README (`/root/code/matrix/README.md`):
| Thing | Value |
|-------|-------|
| ------------------------ | ------------------------------------------------------------- |
| Synapse host | LXC **151**, `10.10.10.29` (Synapse 1.155.0) |
| Synapse log | `/var/log/matrix-synapse/homeserver.log` |
| Synapse config | `/etc/matrix-synapse/homeserver.yaml` (+ `conf.d/`) |
@@ -86,6 +86,7 @@ use it as the primary client artifact and DevTools as the raw backup.
/var/log/matrix-synapse/homeserver.log | grep "<user_id>"
```
- **Synapse SQL (LXC 109) — what the server thinks it holds:**
```sql
-- Current OTK inventory for the device (compare key_id set against the
-- request body the client keeps retrying).
@@ -106,8 +107,10 @@ use it as the primary client artifact and DevTools as the raw backup.
FROM e2e_fallback_keys_json
WHERE user_id = '@user:matrix.lotusguild.org' AND device_id = '<DEVICE_ID>';
```
> Table names are Synapse 1.155 (`e2e_one_time_keys_json`,
> `e2e_fallback_keys_json`). If a name is absent, list with `\dt e2e*` in psql.
- **Confirms:** if the offending `key_id` (from the 400) is **present** in
`e2e_one_time_keys_json` with a **different** stored value than the client's
request body → OTK state has diverged (rust-crypto store vs Synapse). That is
@@ -132,6 +135,7 @@ use it as the primary client artifact and DevTools as the raw backup.
/var/log/matrix-synapse/homeserver.log | grep -E "<user_id>|<participant_id>"
```
- **Synapse SQL (LXC 109) — undelivered / queued to-device events:**
```sql
-- Backlog of to-device messages queued for the affected device. A growing
-- count here = the HS has the media-key events but the device isn't draining
@@ -145,6 +149,7 @@ use it as the primary client artifact and DevTools as the raw backup.
SELECT device_id, display_name, last_seen, ts
FROM devices WHERE user_id = '@user:matrix.lotusguild.org';
```
- **Confirms:** to-device events present but undecryptable (client shows the
`io.element.call.encryption_keys` "unexpected encrypted" warning) ⇒ there is
**no valid Olm session** to decrypt them — the expected downstream of KE-1.
@@ -265,7 +270,7 @@ Q4. KE-4: do delayed-event timeouts coincide with Synapse p99 latency spikes
> "fix" before the planning session** — they are listed so evidence collection
> can be paired with a recovery plan. Confirm the root condition (Q1/Q2) first.
1. **Per-device logout + re-login of the affected device** *(lowest blast radius)*
1. **Per-device logout + re-login of the affected device** _(lowest blast radius)_
- **What:** log the one glitching device out and back in. Forces a fresh
device id, fresh device keys, and a clean OTK batch — sidesteps a diverged
OTK store without touching other sessions.
@@ -275,7 +280,7 @@ Q4. KE-4: do delayed-event timeouts coincide with Synapse p99 latency spikes
- **Confirms/uses:** if KE-1 stops after this, OTK-store divergence (Q1) was
the cause.
2. **Client crypto-store reset (`clearLoginData` path)** *(medium)*
2. **Client crypto-store reset (`clearLoginData` path)** _(medium)_
- **What:** `clearLoginData()` in `src/client/initMatrix.ts` (coordinator's
file — do not edit) **deletes ALL IndexedDB databases** (incl.
`web-sync-store` and the rust-crypto store `crypto-store`), **unregisters
@@ -294,16 +299,16 @@ Q4. KE-4: do delayed-event timeouts coincide with Synapse p99 latency spikes
- **Use when:** the rust-crypto store itself is corrupt/diverged and option 1
didn't clear it.
3. **SDK pin change off the RC** *(medium — codebase change, needs rebuild)*
3. **SDK pin change off the RC** _(medium — codebase change, needs rebuild)_
- **Current pin:** `package.json` → `"matrix-js-sdk": "41.6.0-rc.0"` (a
release candidate).
- **Finding (npm / GitHub changelog, checked 2026-07):** stable **`41.6.0`**
was released **2026-05-26**. Its only changelog line is *"Throw sane error
on completeLoginOnNewDevice IdP rejection"* — **no OTK / keys-upload / Olm /
was released **2026-05-26**. Its only changelog line is _"Throw sane error
on completeLoginOnNewDevice IdP rejection"_ — **no OTK / keys-upload / Olm /
to-device fix** relative to the RC. Later stable lines exist
(`41.7.0`, `41.8.0`; `41.7.0-rc.3` / `41.9.0-rc.0` seen as pre-releases).
Nearby crypto-relevant entries: `41.5.0` *"Enable encrypted history sharing
by default"*; `41.4.0` key-backup handling. **No changelog entry directly
Nearby crypto-relevant entries: `41.5.0` _"Enable encrypted history sharing
by default"_; `41.4.0` key-backup handling. **No changelog entry directly
addresses the KE-1 OTK-conflict symptom** in the immediate range — so
moving RC→`41.6.0` stable is a low-risk hygiene step but is **not expected
to fix KE-1 by itself**. Before pinning, re-read the CHANGELOG for any
@@ -312,7 +317,7 @@ Q4. KE-4: do delayed-event timeouts coincide with Synapse p99 latency spikes
rust-crypto IndexedDB schema — a downgrade triggers the `IDB_VERSION_CONFLICT`
path in `initMatrix.ts`.
4. **Synapse-side OTK row surgery** *(LAST RESORT — highest danger)*
4. **Synapse-side OTK row surgery** _(LAST RESORT — highest danger)_
- **What:** deleting/rewriting rows in `e2e_one_time_keys_json` (and/or
`e2e_fallback_keys_json`, `device_inbox`) for the affected device to force
the client to re-upload a clean batch.
@@ -324,7 +329,7 @@ Q4. KE-4: do delayed-event timeouts coincide with Synapse p99 latency spikes
- **Guardrails if ever done (planning session + HS owner only):** full
`pg_dump` of `synapse` first; do it during **zero active calls**; delete only
the exact diverged `key_id` for the exact `device_id`; `systemctl restart
matrix-synapse` to flush caches; then log the device out/in (option 1) so it
matrix-synapse` to flush caches; then log the device out/in (option 1) so it
republishes. **Never** run this speculatively.
---
@@ -337,7 +342,7 @@ Do these **in order**. Aim to have client + server capturing the **same call**.
`tail -F /var/log/matrix-synapse/homeserver.log | tee /tmp/lotus-call-$(date +%s).log`.
(Optionally raise the `synapse.rest.client.keys` / `handlers.e2e_keys` /
`handlers.devicemessage` loggers to DEBUG per §0 and `systemctl reload
matrix-synapse` — remember to revert after.)
matrix-synapse` — remember to revert after.)
2. **Prep client:** open Lotus Chat → Settings → Developer Tools → **enable
Developer Tools** so the **Crypto Diagnostics** card is visible; note its
entry count starts at (or reset by reload to) 0.
@@ -373,7 +378,7 @@ Do these **in order**. Aim to have client + server capturing the **same call**.
with pass-through wrappers (originals always called) that ring-buffer (max
**200**) any line matching the KE signatures. No network, no timers.
- `getCryptoDiagEntries()` — snapshot copy of the buffer (`{ ts, level, ke,
signature, message }`, most-recent-last).
signature, message }`, most-recent-last).
- `buildCryptoDiagReport(mx)` — JSON string: SDK version, device id, user id,
sync state, `cryptoReady` (`mx.getCrypto()` presence), per-KE counts, and the
entry buffer. No tokens/PII beyond those ids; captured log lines are retained
+12
View File
@@ -1179,6 +1179,18 @@ Three one-tap presets at the top of **Settings → Notifications** that apply a
---
## Accessibility (P3-4)
WCAG 2.1 AA hardening of the golden path (find room → read → reply → send) for keyboard and screen-reader users.
- **Timeline for screen readers:** each message is `role="article"`; **collapsed messages announce their sender + time** (they drop the visible header, so AT would otherwise hear the body with no attribution). The timeline is a `role="log"` `aria-live="polite"` region so new messages are announced; emoji/emoticons carry text labels.
- **Live status:** typing indicators announce via a `role="status"` region; editing a message announces "Editing message from <sender>".
- **Forms & overlays:** all inputs have associated labels (visible `<label htmlFor>` or `aria-label`); the Media Gallery and Search overlays are named.
- **Focus management:** skip-to-content link + `nav`/`main` landmarks; genuine dialogs return focus to their trigger on close (inline popouts intentionally keep focus in context).
- **Keyboard-shortcuts help:** press <kbd>?</kbd> for a dialog of the existing shortcuts (Escape, type-to-focus composer, Enter/Shift+Enter send, message actions).
- **Regression gate:** a curated `eslint-plugin-jsx-a11y` rule set (ARIA correctness + label association) runs in CI. Files: `components/message/*`, `features/room/RoomViewTyping.tsx`, `features/shortcuts/*`, `utils/a11y.ts`, `eslint.config.mjs`.
- _Known limitation:_ list virtualization keeps far-scrolled history out of the a11y tree (perf trade-off); newly-arriving messages are announced.
## Infrastructure
### Authenticated Media
+96 -6
View File
@@ -1,6 +1,6 @@
# Lotus Chat — Manual Testing Guide
**Generated:** June 2026
**Generated:** June 2026 · **Updated:** July 2026 (added §O — threads, per-thread notifications, math, search cache, session hardening, audit wave, desktop CSP)
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
@@ -573,10 +573,100 @@ Log into **matrix.lotusguild.org** (password) and **matrix.org**.
---
## O. July 2026 batch — threads, notifications, math, search cache, audit wave
Everything landed after the OIDC work. These mirror the checklists in `LOTUS_TODO.md` (§P3-8, §P4-1) and the Needs-Verification rows in `LOTUS_BUGS.md` (P3-8/P4-1/P4-4/P4-8/N97a/AW-1…4). **⚠️ Threads change the main timeline** — thread replies no longer render inline; that's intended (see O1).
### O1. Thread Panel (P3-8) — 👥 2 people help for live replies
1. Hover a message → **Reply in Thread** (message menu). The right-side **thread panel** opens with that message as the root.
2. Send text, an emoji, and a file upload into the thread; have the second person reply too.
3. Reply to a reply _inside_ the panel.
**Expected:** the panel shows the root at top + an "N replies" divider + the reply timeline (own composer at the bottom). Your sends appear immediately (pending → confirmed). A reply-to-a-reply is a proper thread reply. In the **main** timeline the replies do **not** appear inline — the root message instead shows a **"N replies · time"** chip. Clicking the chip (or a reply's thread indicator) opens the panel. **×** or **Escape** closes it; on mobile the panel is fullscreen. Scrolled up in a long thread → a **Jump to Latest** chip appears. Reload the page → the root/reply split persists; in an **encrypted** room the thread replies decrypt (not "Unable to decrypt").
### O2. Per-thread notifications (P4-1, Slack-style) — 👥 2 people
1. Have the second person reply in a thread **you have posted in** → expect a notification + sound.
2. Have them reply in a thread **you have never touched** and don't @mention you → expect **silence** (only the chip's unread badge updates).
3. Have them **@mention** you in any thread → expect a notification regardless of participation.
4. Open the panel's **bell menu** (header) → set the thread to **Mute** → expect no notifications, the chip's unread badge gone (bell-mute glyph shown), and the room's **sidebar badge drops** by that thread's count. Try **All** (every reply notifies) and **Mentions only** (only @mentions).
5. On a **second device**, confirm the same per-thread modes are set (they sync via account data).
6. Room-level **Mute** (room context menu) still silences everything, including thread overrides.
**Known caveat:** Mentions-only can under-notify in E2EE rooms (the decision runs before decryption). Muted-thread badge subtraction is Lotus-only.
### O3. Math / LaTeX (P4-4)
Send each and confirm rendering: `$x^2 + y^2$` (inline), `$$\int_0^1 f(x)\,dx$$` (block, centered), `$5 and $10 for lunch` (**stays plain text** — currency guard), and a code block containing `$x$` (**stays literal** inside the code block). **Expected:** the first two render as math (KaTeX); the last two are untouched. First math of the session may show the raw `$…$` for a beat while the KaTeX chunk lazy-loads, then renders.
### O4. Encrypted search cache (P4-8) — opt-in
In an **encrypted** room's message search, enable **"Persist search index on this device"** (Encrypted Rooms panel). Search, then **reload** and search the same term. **Expected:** coverage survives the reload (results without re-paginating everything). **Clear cached index** empties it. **Log out** → the cache is wiped (privacy). Toggling the setting OFF does **not** wipe (only Clear/logout do).
### O5. Session hardening (N97a) — cross-tab
1. Log in on a build that predates the change, then load this build → you stay logged in (legacy keys migrate to the `cinny_session_v1` blob; check DevTools → Application → Local Storage).
2. Open the app in **two tabs**; **log out** in tab A → tab B reloads to the auth screen within a moment. Log in again in one tab → the other reloads too.
### O6. Audit-wave correctness fixes (AW-1)
- **Scheduled-message cancel:** schedule a message, then cancel it **with the network cut** (DevTools offline) → the item **stays** with an inline error (it does **not** silently disappear and still send). Restore network, retry → cancels cleanly.
- **Escape coordination:** in a thread panel, open the mention autocomplete or set a reply draft, press **Escape** → it dismisses the autocomplete/reply **without** closing the panel. A bare Escape (nothing to dismiss) still marks the room read / closes the panel as before.
- **Panel exclusivity:** on mobile, opening a thread while the media gallery (or members drawer) is open shows only **one** right panel (thread wins), not stacked fullscreen overlays.
- **Emoji board (AW-2):** the **first** time you open the emoji board / autocomplete in a session, the grid **and search** populate with unicode emoji (they don't stay empty). Reactions still show a label.
### O7. Desktop (Tauri) — CSP tighten + native stack (AW-4) — 🖥️ desktop build only
The webview CSP was tightened and the full native module set now compiles. Smoke-test the desktop build:
1. App **boots**, avatars + media thumbnails load, the **VT323** terminal font renders (Lotus Terminal theme), a **location message** embeds its OpenStreetMap map, **calls** connect (EC iframe), **deep links** (`matrix:` / clicking a room link) navigate.
2. **Native features:** minimize to tray (notifications still arrive), a message notification is a **rich toast** (click opens the room; reply box sends), the taskbar **Jump List** lists recent rooms, in a call the taskbar thumbnail shows **Mute/Deafen/End**, Windows **Focus Assist** silences Lotus.
3. **Console** (desktop devtools) shows **no CSP violations** during normal use. If something visual/media is blocked, that's the CSP to loosen — note exactly what and where.
### O8. E2EE / call-key cluster (KE-1→4) — 👥 2 people, during a real call
We shipped the diagnostics kit + a **Crypto Diagnostics** card (**Settings → Developer Tools**). During your next call that glitches (audio cutouts, "Unable to decrypt"), open it and **Download report**, and note whether the symptoms even still occur now that we're on **matrix-js-sdk 41.7.0** (crypto-wasm 18.3.1). Send me the report + `LOTUS_E2EE_INVESTIGATION.md` is the runbook.
---
## P. Accessibility (P3-4) — needs a browser + a screen reader
The compliance fixes are gate-verified in code; these confirm the runtime a11y behavior only a human + AT can check. Tools: browser DevTools "axe" extension / Lighthouse a11y, plus **VoiceOver** (macOS ⌘F5) or **NVDA** (Windows).
### P1. Keyboard-only golden path (no mouse)
Tab from page load: **skip-to-content** link appears first (Enter jumps to the timeline). Tab reaches the room list (rooms are focusable, active room announced), open a room (Enter), type a character → focus lands in the composer, send with Enter (or Shift+Enter per your `enterForNewline` setting). No keyboard trap; visible focus ring throughout.
### P2. `?` shortcuts dialog
Press **?** (Shift+/) with focus NOT in a text field → the keyboard-shortcuts dialog opens, is focus-trapped, Escape closes it and focus returns to where you were. Pressing `?` while typing in the composer/search inserts a literal `?` (does NOT open the dialog).
### P3. Screen-reader: reading messages
With VoiceOver/NVDA on, arrow through the timeline: each message is announced as an article with **sender name + time** — critically, this includes **collapsed messages** (consecutive messages from the same person), which previously announced only the body with no sender. Reactions, "edited", replies, and delivery status are announced with labels.
### P4. Screen-reader: live announcements
- **New message** arrives while you're reading → announced (polite).
- **Someone starts typing** → "X is typing" announced once (not spammed per keystroke).
- **Editing a message** → the edit box announces "Editing message from X".
### P5. Focus return from dialogs
Open then close (Escape or ×): the **room topic viewer**, a **reaction viewer** (click a reaction count), and **Search** → focus returns to the button/element you opened them from (not lost to `<body>`). Inline popouts (emoji picker, autocomplete, hover menus) intentionally keep focus in context — that's expected, not a bug.
### P6. axe / Lighthouse scan
Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view, Settings, and the login screen. Expect **no critical/serious** "missing accessible name" or "ARIA" violations on the golden path. Report any that appear (note: far-scrolled timeline history being virtualized out is a known, accepted limitation — not a finding).
---
## Priority if you're short on time
1. **A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce.
2. **B1B3** (polls on a default theme) — the confirmed visual bug.
3. **D** (EC 0.20.1 control sweep) — guards against the upstream merge breaking calls.
4. **A7** false-positive check (normal joins don't show the error overlay).
5. Everything else.
1. **O1 + O2** (threads + per-thread notifications) — the largest new surface; the main-timeline change is user-visible.
2. **O7** (desktop CSP smoke) — CI can't catch CSP breakage; a wrong directive silently breaks media/fonts/maps.
3. **O5** (session cross-tab) + **O6** (scheduled-cancel ghost-send) — auth-critical + a real data-loss-class fix.
4. **A4** (in-call banner) + **A3** (ringtone) — newest call logic, hardest to reproduce.
5. **D** (EC control sweep) — guards against the fork breaking calls.
6. Everything else.
+17 -8
View File
@@ -141,7 +141,12 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
## Priority 3 — Higher complexity / lower daily frequency
### [ ] P3-4 · Accessibility Improvements (WCAG 2.1 AA)
### [~] P3-4 · Accessibility Improvements (WCAG 2.1 AA) — COMPLIANCE PASS DONE (2026-07), ⚠️ AWAITING LIVE AXE/SR AUDIT
**Shipped (compliance + shortcuts-help tier):** messages `role="article"` + collapsed-message sender/time announced to AT (the biggest gap — collapsed rows had no sender for a screen reader); ~10 unlabeled form inputs + Media Gallery / Search overlays named; emoji/emoticon aria-labels; typing indicator now announced via a `role="status"` live region; editing a message announces "Editing message from X"; focus now returns to the trigger on close of 4 genuine dialogs (RoomIntro/Reactions/RoomViewHeader-topic/Search — inline popouts correctly left); a `?` keyboard-shortcuts help dialog; and a **jsx-a11y lint gate** (curated ARIA-correctness + label rules, enforced in CI) to prevent regressions. Already-good before this pass: skip link + landmarks, timeline `role="log"`/`aria-live`, ~99% icon-button labels, labeled editor.
**DEFERRED (documented):** virtualization keeps scrolled-away history out of the a11y tree (architectural; the live-region announces newly-arriving messages) — not re-architected to avoid perf regression; roving-tabindex + command palette + section-jump shortcuts (user-deferred); the live axe-core / VoiceOver+NVDA audit → LOTUS_TESTING §P.
_Original scope (for reference):_
**What:** Comprehensive audit and fix pass targeting the critical user paths:
@@ -167,13 +172,14 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
Built per the design below (4-agent build + 2-agent review). Gates green (tsc/eslint/build/tests). **Release note: threaded replies no longer render inline in the main timeline — roots show a "N replies" chip that opens the panel.**
**Manual QA checklist (post-deploy):**
1. Reply in Thread (message menu) → panel opens; send text/upload/emoji into it (appears pending → confirmed)
2. Reply to a reply inside the panel → event carries `m.thread` + `m.in_reply_to` with `is_falling_back:false`
3. Main timeline: root + chip only (replies absent); chip count/time updates live; unread badge appears for others' thread replies and clears after viewing the panel
4. Room badge clears via normal markAsRead even with unread threads (unthreaded receipt)
5. Reload: partitioning persists; encrypted-room threads decrypt (back-pagination too)
6. Escape / × closes; mobile = fullscreen panel; switching rooms and back restores the open thread
**What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies.
**What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies.
Features:
@@ -209,23 +215,23 @@ Features:
**What:** Replace the manual "load more" button with an automated, virtualized infinite scroll for search results.
**Status:** Done in a prior session — `MessageSearch.tsx` already uses `useVirtualizer` (~line 336) over the result groups AND auto-fetches the `nextToken` page when the last virtual item scrolls into view (~line 469) via `useInfiniteQuery`. Nothing left to build.
### [ ] P4-8 · Encrypted Message Search Indexing & Caching
### [~] P4-8 · Encrypted Message Search Indexing & Caching — IMPLEMENTED (2026-07), opt-in
**What:** Implement a persistent local cache for search results, optimized for encrypted rooms.
**Approach:** Use `IndexedDB` to store search metadata (event IDs, timestamps) to prevent redundant server-side decryption/fetching.
**Shipped:** `src/app/utils/searchCache.ts` — raw-IndexedDB per-room index (`lotus-search-cache`) of decrypted search rows + coverage markers, merged into local search (in-memory-wins dedupe). **Opt-in, default OFF** (stores plaintext at rest) with a privacy note, Clear button, and logout wipe. Awaiting live QA (LOTUS_BUGS AW / P4-8 row).
### [~] P4-1 · Thread Notification Mode Per-Thread — IMPLEMENTED (2026-07), ⚠️ AWAITING LIVE QA
**Shipped (Slack-style):** default = **Participating** (notified only for threads you've posted in or where you're @mentioned); per-thread override **All / Mentions-only / Mute** via the bell menu in the thread panel header; modes sync across devices (`io.lotus.thread_notifications` account data, pruned on write). Mute also suppresses the chip badge and subtracts the thread from the room's sidebar badge (client-side). Also fixed the underlying path: thread replies are notified via exactly one handler (room-level `ThreadEvent.NewReply`), with the main-timeline notifier + unread binder thread-guarded, and live badge refresh on `RoomEvent.UnreadNotifications`.
**Manual QA checklist (post-deploy):**
1. Friend replies in a thread YOU posted in → notification + sound; in a thread you never touched → silent (chip badge only)
2. @mention in any thread → notified regardless of participation
3. Set a thread to Mute → no notifications, chip badge gone (bell-mute glyph), room sidebar badge drops by that thread's count
4. Set to All → every reply notifies; Mentions-only → only @mentions
5. Second device shows the same per-thread modes (account-data sync)
6. Room-level Mute still silences everything incl. thread overrides
**Known caveats:** Mentions-only can under-notify in E2EE rooms (decision runs pre-decryption — same class as the existing notifier); muted-thread badge subtraction is Lotus-only (other clients still count them).
**Known caveats:** Mentions-only can under-notify in E2EE rooms (decision runs pre-decryption — same class as the existing notifier); muted-thread badge subtraction is Lotus-only (other clients still count them).
---
@@ -418,10 +424,11 @@ Features:
**Decision:** Deferred (reviewed 2026-07). Multi-sprint, and it touches the auth/crypto/storage core — not worth the risk/effort for a niche need right now. Kept here with a concrete spec so it's actionable later.
**Future-work spec (why it's big):** the app is currently **single-session**.
- Session lives in `src/app/state/sessions.ts` under fixed localStorage keys — `cinny_access_token`, `cinny_device_id`, `cinny_user_id`, `cinny_hs_base_url`, plus the OIDC keys (`cinny_refresh_token`, `cinny_expires_at`, `cinny_oidc_*`).
- Persistence lives in `src/client/initMatrix.ts`: two fixed IndexedDB stores — `web-sync-store` (`IndexedDBStore`) and `crypto-store` (`IndexedDBCryptoStore`) — feeding one `createClient(...)`.
True per-context isolation would require: (1) namespace every localStorage key per context (`ctx:<id>:cinny_*`); (2) per-context IndexedDB dbNames for **both** the sync store and the crypto store; (3) a context registry + switcher UI (create/rename/delete/switch); (4) full client teardown + re-init on switch (`initMatrix` currently assumes one global client); (5) per-context settings + notification/quiet-hours state; (6) careful crypto-store isolation so device keys never bleed across contexts. **Smaller intermediate step** if demand appears: plain multi-account (fast account switch) *without* the hard isolation boundary — much less risky, reuses most of the login flow.
True per-context isolation would require: (1) namespace every localStorage key per context (`ctx:<id>:cinny_*`); (2) per-context IndexedDB dbNames for **both** the sync store and the crypto store; (3) a context registry + switcher UI (create/rename/delete/switch); (4) full client teardown + re-init on switch (`initMatrix` currently assumes one global client); (5) per-context settings + notification/quiet-hours state; (6) careful crypto-store isolation so device keys never bleed across contexts. **Smaller intermediate step** if demand appears: plain multi-account (fast account switch) _without_ the hard isolation boundary — much less risky, reuses most of the login flow.
**Priority:** Extreme Low (Multi-sprint/Architectural).
### [DROPPED] P5-52 · Desktop — Room-Level Sync Governor (Performance Control)
@@ -511,7 +518,7 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
**Decisions (each backed by SDK evidence in node_modules/matrix-js-sdk):**
| Question | Decision |
|---|---|
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Thread rendering | **New lean `ThreadTimeline`** reusing `Message`, `useVirtualPaginator`, and RoomTimeline's exported timeline helpers (lines 156-227). Do NOT refactor 2214-line RoomTimeline (its ~35 hooks are hardwired to the room live timeline). |
| threadSupport | **Enable `threadSupport: true`** in `initMatrix.ts` (~line 39). ⚠️ Thread replies then LEAVE the main timeline (`room.js eventShouldLiveIn``shouldLiveInRoom:false`), retroactively on reload — MUST ship the "N replies" summary chip in the same release. Roots stay in both timelines. |
| State | `roomIdToActiveThreadIdAtomFamily` (per-room, mirrors `roomIdToReplyDraftAtomFamily`) in new `state/room/thread.ts` + `getThreadDraftKey(roomId, threadRootId)` = `` `${roomId}::${threadRootId}` `` |
@@ -520,10 +527,12 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
| Mobile | Pure CSS like `MembersDrawer.css.ts`: fixed width toRem(360) desktop, `position:fixed; inset:0` under 750px. |
**Critical side-effect fixes (one-liners, land FIRST):**
1. `initMatrix.ts` → `threadSupport: true`.
2. `utils/notifications.ts:24` → `sendReadReceipt(latestEvent, type, /*unthreaded*/ true)` — otherwise markAsRead becomes `main`-scoped and room badges stick permanently unread (room unread total includes thread counts).
**Known SDK traps (verified):**
- **Local echo gap:** chronological pending ordering means the thread timelineSet never receives pending events (`canContain` rejects; `room.getPendingEvents()` THROWS in this mode) — ThreadTimeline must render its own pending strip via `RoomEvent.LocalEchoUpdated` filtering on `threadRootId`, deduped against `thread.findEventById`.
- **Bootstrap:** `room.getThread(id) ?? room.createThread(id, room.findEventById(id), [], false)` — the SDK auto-fetches via `/relations` and inserts the root at top; gate rendering on `thread.initialEventsFetched`; decrypt with `decryptAllTimelineEvent` after init + each pagination.
- **Deep links:** `getEventTimeline(mainSet, threadEventId)` returns undefined for thread events — redirect jump-to-event to the panel (best-effort v1).
+4 -4
View File
@@ -5,11 +5,11 @@ experimental_features:
msc3861:
enabled: true
issuer: http://localhost:8090/
client_id: "0000000000000000000SYNAPSE"
client_id: '0000000000000000000SYNAPSE'
client_auth_method: client_secret_basic
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET"
admin_token: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN"
account_management_url: "http://localhost:8090/account"
client_secret: 'REPLACE_WITH_A_SHARED_CLIENT_SECRET'
admin_token: 'REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN'
account_management_url: 'http://localhost:8090/account'
# With msc3861 enabled, Synapse disables its own password/SSO login and advertises
# `m.authentication` in /.well-known/matrix/client — which is exactly what the
+28 -1
View File
@@ -25,7 +25,7 @@ export default [
tsPlugin.configs['flat/eslint-recommended'],
...tsPlugin.configs['flat/recommended'],
reactPlugin.configs.flat.recommended,
reactHooksPlugin.configs.flat['recommended'],
reactHooksPlugin.configs.flat.recommended,
// Register jsx-a11y plugin (rules selectively enabled below)
{ plugins: { 'jsx-a11y': jsxA11yPlugin } },
// airbnb-base via FlatCompat (JS/import rules; no React plugin, no getFilename issue)
@@ -115,6 +115,26 @@ export default [
'jsx-a11y/media-has-caption': 'off',
'jsx-a11y/no-noninteractive-element-interactions': 'off',
'jsx-a11y/alt-text': 'off',
// A11y regression gate (P3-4). A CURATED set — correctness rules that catch
// real WCAG gaps (missing accessible names, malformed ARIA) without
// flooding on the pre-existing clickable-div patterns. The heavier
// interaction rules (no-static-element-interactions,
// click-events-have-key-events) are a separate cleanup and stay OFF.
'jsx-a11y/aria-props': 'error',
'jsx-a11y/aria-proptypes': 'error',
'jsx-a11y/aria-role': ['error', { ignoreNonDOM: true }],
'jsx-a11y/aria-unsupported-elements': 'error',
'jsx-a11y/role-has-required-aria-props': 'error',
'jsx-a11y/role-supports-aria-props': 'error',
'jsx-a11y/no-redundant-roles': 'error',
'jsx-a11y/anchor-has-content': 'error',
'jsx-a11y/heading-has-content': 'error',
'jsx-a11y/label-has-associated-control': ['error', { assert: 'either', depth: 5 }],
// NOT enabled: control-has-associated-label. This repo labels most inputs
// with folds `<Text as="label" htmlFor>` — a component the rule's static
// analysis can't see as a <label>, producing false positives on correctly
// labeled controls. The genuinely-unlabeled controls it surfaced (sliders,
// file input, media players, notes) were fixed directly with aria-label.
},
},
{
@@ -123,4 +143,11 @@ export default [
'no-undef': 'off',
},
},
{
// Test files commonly define several small mock/fake classes.
files: ['**/*.test.ts', '**/*.test.tsx'],
rules: {
'max-classes-per-file': 'off',
},
},
];
+1
View File
@@ -213,6 +213,7 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
<Text size="L400">Account Data</Text>
<Input
variant="SurfaceVariant"
aria-label="Account data type"
size="400"
radii="300"
readOnly
+6 -1
View File
@@ -282,7 +282,12 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
>
{previewUrl && (
<>
<audio ref={previewAudioRef} src={previewUrl} onEnded={() => setPreviewPlaying(false)} />
<audio
ref={previewAudioRef}
src={previewUrl}
onEnded={() => setPreviewPlaying(false)}
aria-hidden="true"
/>
<IconButton
onClick={() => {
const audio = previewAudioRef.current;
@@ -78,11 +78,14 @@ export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
return (
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Address (Optional)</Text>
<Text as="label" htmlFor="create-room-alias" size="L400">
Address (Optional)
</Text>
<Text size="T200" priority="300">
Pick an unique address to make it discoverable.
</Text>
<Input
id="create-room-alias"
ref={aliasInputRef}
onChange={handleAliasChange}
before={
+4 -1
View File
@@ -66,6 +66,8 @@ type CustomEditorProps = {
maxHeight?: string;
editor: Editor;
placeholder?: string;
/** Explicit accessible name for the textbox; falls back to the placeholder. */
ariaLabel?: string;
onKeyDown?: KeyboardEventHandler;
onKeyUp?: KeyboardEventHandler;
onChange?: EditorChangeHandler;
@@ -82,6 +84,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
maxHeight = '50vh',
editor,
placeholder,
ariaLabel,
onKeyDown,
onKeyUp,
onChange,
@@ -139,7 +142,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
data-editable-name={editableName}
className={css.EditorTextarea}
placeholder={placeholder}
aria-label={placeholder ?? 'Message input'}
aria-label={ariaLabel ?? placeholder ?? 'Message input'}
aria-multiline="true"
renderPlaceholder={renderPlaceholder}
renderElement={renderElement}
@@ -200,12 +200,24 @@ export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfil
</Box>
</Box>
<Box direction="Inherit" gap="100">
<Text size="L400">Name</Text>
<Input name="nameInput" defaultValue={meta.name} variant="Secondary" radii="300" required />
<Text as="label" htmlFor="image-pack-name" size="L400">
Name
</Text>
<Input
id="image-pack-name"
name="nameInput"
defaultValue={meta.name}
variant="Secondary"
radii="300"
required
/>
</Box>
<Box direction="Inherit" gap="100">
<Text size="L400">Attribution</Text>
<Text as="label" htmlFor="image-pack-attribution" size="L400">
Attribution
</Text>
<TextArea
id="image-pack-attribution"
name="attributionTextArea"
defaultValue={meta.attribution}
variant="Secondary"
@@ -261,9 +261,12 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
gap="400"
>
<Box direction="Column" gap="100">
<Text size="L400">User ID</Text>
<Text as="label" htmlFor="invite-user-id" size="L400">
User ID
</Text>
<div>
<Input
id="invite-user-id"
size="500"
ref={inputRef}
onChange={handleSearchChange}
@@ -334,8 +337,11 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
</div>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Reason (Optional)</Text>
<Text as="label" htmlFor="invite-reason" size="L400">
Reason (Optional)
</Text>
<TextArea
id="invite-reason"
size="500"
name="reasonInput"
variant="Background"
@@ -108,8 +108,11 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
</Text>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Address</Text>
<Text as="label" htmlFor="join-address" size="L400">
Address
</Text>
<Input
id="join-address"
size="500"
autoFocus
name="addressInput"
-1
View File
@@ -117,7 +117,6 @@ export const PageHeroSection = style([
},
]);
export const PageContentCenter = style([
DefaultReset,
{
@@ -95,7 +95,6 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true,
onDeactivate: () => setViewTopic(false),
escapeDeactivates: stopPropagation,
@@ -278,6 +278,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
<Box direction="Column" gap="300">
<input
ref={fileInputRef}
aria-label="Upload soundboard clip"
type="file"
accept={SOUNDBOARD_ACCEPT}
multiple
@@ -56,6 +56,7 @@ function PreviewVideo({ fileItem }: PreviewVideoProps) {
return (
<video
aria-label="Video attachment preview"
style={{
objectFit: 'contain',
width: '100%',
@@ -260,6 +260,7 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
<Input
ref={reasonInputRef}
placeholder="Reason"
aria-label="Moderation reason"
size="300"
variant="Background"
radii="300"
@@ -253,6 +253,7 @@ function UserPrivateNotes({ userId }: { userId: string }) {
)}
</Box>
<textarea
aria-label="Private note about this user"
value={draft}
onChange={handleChange}
maxLength={USER_NOTE_MAX_LENGTH}
@@ -147,6 +147,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
<Text size="L400">Name</Text>
<Input
name="nameInput"
aria-label="Power level name"
defaultValue={tag?.name}
placeholder="Bot"
size="300"
@@ -160,6 +161,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
<Input
defaultValue={power}
name="powerInput"
aria-label="Power level value"
size="300"
variant={typeof power === 'number' ? 'SurfaceVariant' : 'Secondary'}
radii="300"
+4 -3
View File
@@ -186,8 +186,8 @@ function LightboxMedia({
)}
{media.status === 'ok' &&
(item.msgtype === MsgType.Video ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
aria-label="Video attachment"
src={media.url}
controls
autoPlay
@@ -261,7 +261,6 @@ function Lightbox({
escapeDeactivates: false,
}}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
role="dialog"
aria-modal
@@ -640,13 +639,15 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
className={classNames(css.MediaGalleryDrawer, ContainerColor({ variant: 'Background' }))}
shrink="No"
direction="Column"
role="region"
aria-labelledby="media-gallery-title"
>
{/* Header */}
<Header variant="Background" size="600" className={css.MediaGalleryHeader}>
<Box grow="Yes" alignItems="Center" gap="200">
<Icon size="200" src={Icons.Photo} />
<Box grow="Yes">
<Text size="H4" truncate>
<Text id="media-gallery-title" size="H4" truncate>
Media Gallery
</Text>
</Box>
-2
View File
@@ -142,7 +142,6 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
placeholder="Ask a question…"
value={question}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuestion(e.target.value)}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
</Box>
@@ -151,7 +150,6 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
<Box direction="Column" gap="200">
<Text size="L400">Options</Text>
{options.map((opt, index) => (
// eslint-disable-next-line react/no-array-index-key
<Box key={index} alignItems="Center" gap="200">
<Input
style={{ flex: 1 }}
-1
View File
@@ -583,7 +583,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true,
onDeactivate: () => setViewTopic(false),
escapeDeactivates: stopPropagation,
@@ -25,3 +25,16 @@ export const RoomViewTyping = style([
export const TypingText = style({
flexGrow: 1,
});
// Visually hidden but available to assistive technology.
export const SrOnly = style({
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
});
+23 -4
View File
@@ -33,8 +33,21 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
[typingMembers, myUserId, room],
);
if (typingNames.length === 0) {
return null;
// A single, non-truncated string for assistive technology to announce.
// Computed even when empty so the live region can stay mounted (below) —
// a `role="status"` region added to the DOM together with its first text
// is not reliably announced by some screen readers.
let typingAnnouncement = '';
if (typingNames.length === 1) {
typingAnnouncement = `${typingNames[0]} is typing`;
} else if (typingNames.length === 2) {
typingAnnouncement = `${typingNames[0]} and ${typingNames[1]} are typing`;
} else if (typingNames.length === 3) {
typingAnnouncement = `${typingNames[0]}, ${typingNames[1]} and ${typingNames[2]} are typing`;
} else {
typingAnnouncement = `${typingNames[0]}, ${typingNames[1]}, ${typingNames[2]} and ${
typingNames.length - 3
} others are typing`;
}
const handleDropAll = () => {
@@ -50,7 +63,12 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
};
return (
<div style={{ position: 'relative' }} aria-live="polite" aria-atomic="false">
<div style={{ position: 'relative' }}>
{/* Persistently mounted so the FIRST "X is typing" is announced. */}
<span className={css.SrOnly} role="status" aria-live="polite" aria-atomic="true">
{typingAnnouncement}
</span>
{typingNames.length > 0 && (
<Box
className={classNames(css.RoomViewTyping, className)}
alignItems="Center"
@@ -59,7 +77,7 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
ref={ref}
>
<TypingIndicator />
<Text className={css.TypingText} size="T300" truncate>
<Text className={css.TypingText} size="T300" truncate aria-hidden>
{typingNames.length === 1 && (
<>
<b>{typingNames[0]}</b>
@@ -127,6 +145,7 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
<Icon size="50" src={Icons.Cross} />
</IconButton>
</Box>
)}
</div>
);
},
@@ -56,6 +56,7 @@ import {
getMemberDisplayName,
} from '../../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { messageAriaLabel } from '../../../utils/a11y';
import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
@@ -972,6 +973,10 @@ export const Message = React.memo(
[MsgAppearClass]: playAppear,
[MentionHighlightPulse]: playMentionPulse,
})}
role="article"
aria-label={
collapse ? messageAriaLabel(senderDisplayName, mEvent.getTs(), hour24Clock) : undefined
}
tabIndex={0}
space={messageSpacing}
collapse={collapse}
@@ -51,7 +51,13 @@ import { UseStateProvider } from '../../../components/UseStateProvider';
import { EmojiBoard } from '../../../components/emoji-board';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room';
import {
getEditedEvent,
getMemberDisplayName,
getMentionContent,
trimReplyFromFormattedBody,
} from '../../../utils/room';
import { getMxIdLocalPart } from '../../../utils/matrix';
import { mobileOrTablet } from '../../../utils/user-agent';
import { useComposingCheck } from '../../../hooks/useComposingCheck';
@@ -66,6 +72,12 @@ export const MessageEditor = as<'div', MessageEditorProps>(
({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
const mx = useMatrixClient();
const editor = useEditor();
// Accessible name for the edit textbox so screen readers announce which
// message is being edited (a11y, P3-4).
const editSenderId = mEvent.getSender();
const editSenderName = editSenderId
? (getMemberDisplayName(room, editSenderId) ?? getMxIdLocalPart(editSenderId) ?? editSenderId)
: '';
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
@@ -259,6 +271,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
<CustomEditor
editor={editor}
placeholder="Edit message..."
ariaLabel={editSenderId ? `Editing message from ${editSenderName}` : 'Edit message'}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
bottom={
@@ -106,7 +106,6 @@ export const Reactions = as<'div', ReactionsProps>(
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setViewer(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
+8 -2
View File
@@ -247,7 +247,6 @@ export function Search({ requestClose }: SearchProps) {
<FocusTrap
focusTrapOptions={{
initialFocus: () => inputRef.current,
returnFocusOnDeactivate: false,
allowOutsideClick: true,
clickOutsideDeactivates: true,
onDeactivate: requestClose,
@@ -257,7 +256,13 @@ export function Search({ requestClose }: SearchProps) {
},
}}
>
<Modal size="400" style={{ maxHeight: toRem(400), borderRadius: config.radii.R500 }}>
<Modal
size="400"
role="dialog"
aria-modal="true"
aria-label="Search"
style={{ maxHeight: toRem(400), borderRadius: config.radii.R500 }}
>
<Box
shrink="No"
style={{ padding: config.space.S400, paddingBottom: 0 }}
@@ -270,6 +275,7 @@ export function Search({ requestClose }: SearchProps) {
radii="400"
outlined
placeholder="Search"
aria-label="Search rooms"
before={<Icon size="200" src={Icons.Search} />}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
@@ -531,6 +531,7 @@ function Appearance() {
Intensity: {nightLightOpacity}%
</Text>
<input
aria-label="Night light intensity"
type="range"
min={5}
max={80}
@@ -1663,6 +1664,7 @@ function Calls() {
<Text size="T200">{callDenoiseGateThreshold} dB</Text>
</Box>
<input
aria-label="Noise gate threshold"
type="range"
min="-100"
max="0"
@@ -0,0 +1,49 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const ShortcutList = style([
DefaultReset,
{
margin: 0,
},
]);
export const ShortcutRow = style({
padding: `${config.space.S100} 0`,
});
export const ShortcutTerm = style([
DefaultReset,
{
flexGrow: 1,
},
]);
export const ShortcutKeys = style([
DefaultReset,
{
display: 'flex',
alignItems: 'center',
gap: config.space.S100,
flexShrink: 0,
},
]);
export const Kbd = style([
DefaultReset,
{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: toRem(20),
padding: `0 ${config.space.S200}`,
height: toRem(24),
fontFamily: 'inherit',
fontSize: toRem(12),
lineHeight: toRem(24),
color: color.SurfaceVariant.OnContainer,
backgroundColor: color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
},
]);
@@ -0,0 +1,208 @@
import React, { useCallback } from 'react';
import FocusTrap from 'focus-trap-react';
import { atom, useAtom, useSetAtom } from 'jotai';
import {
Box,
Dialog,
Header,
Icon,
IconButton,
Icons,
Line,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Text,
config,
} from 'folds';
import { stopPropagation } from '../../utils/keyboard';
import { editableActiveElement } from '../../utils/dom';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useModalStyle } from '../../hooks/useModalStyle';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { isMacOS } from '../../utils/user-agent';
import { KeySymbol } from '../../utils/key-symbol';
import * as css from './KeyboardShortcutsDialog.css';
/** Global open-state for the keyboard shortcuts help dialog. */
export const keyboardShortcutsDialogAtom = atom<boolean>(false);
/** Read/control the keyboard shortcuts dialog open-state. */
export function useKeyboardShortcutsDialog() {
const [open, setOpen] = useAtom(keyboardShortcutsDialogAtom);
const openDialog = useCallback(() => setOpen(true), [setOpen]);
const closeDialog = useCallback(() => setOpen(false), [setOpen]);
return { open, openDialog, closeDialog };
}
/**
* Registers the global `Shift + /` (`?`) shortcut that opens the keyboard
* shortcuts help dialog. Ignored while the user is typing into an input,
* textarea or contenteditable so it never steals a literal `?` character.
*
* Mount once in the client shell (e.g. `ClientNonUIFeatures`).
*/
export function useKeyboardShortcutsTrigger() {
const setOpen = useSetAtom(keyboardShortcutsDialogAtom);
useKeyDown(
window,
useCallback(
(evt: KeyboardEvent) => {
// Never intercept `?` while the user is typing into a field/editor.
if (editableActiveElement()) return;
// `?` is produced by Shift + `/` on the common layouts.
if (evt.key === '?') {
evt.preventDefault();
// Stop RoomView's window-level "type any char → focus composer"
// handler from also firing — otherwise focus lands in the composer
// behind the dialog and Escape gets swallowed by the contenteditable.
evt.stopImmediatePropagation();
setOpen(true);
}
},
[setOpen],
),
);
}
type ShortcutRow = {
description: string;
keys: string[];
};
type ShortcutSection = {
title: string;
rows: ShortcutRow[];
};
function ShortcutKeys({ keys }: { keys: string[] }) {
return (
<Box as="dd" className={css.ShortcutKeys}>
{keys.map((key, index) => (
<kbd key={`${key}-${index}`} className={css.Kbd}>
{key}
</kbd>
))}
</Box>
);
}
/**
* Accessible keyboard shortcuts help dialog. Renders (as a modal overlay) only
* while `keyboardShortcutsDialogAtom` is `true`. Open it with the `?` shortcut
* (see `useKeyboardShortcutsTrigger`) or via `useKeyboardShortcutsDialog`.
*/
export function KeyboardShortcutsDialog() {
const { open, closeDialog } = useKeyboardShortcutsDialog();
const modalStyle = useModalStyle(480);
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
if (!open) return null;
const sections: ShortcutSection[] = [
{
title: 'General',
rows: [
{ description: 'Show keyboard shortcuts', keys: ['?'] },
{ description: 'Close open panel, otherwise mark room as read', keys: [KeySymbol.Escape] },
],
},
{
title: 'Composer',
rows: [
{ description: 'Focus the message composer', keys: ['Any character'] },
{
description: 'Send message',
keys: enterForNewline ? [modKey, 'Enter'] : ['Enter'],
},
{
description: 'Insert a new line',
keys: enterForNewline ? ['Enter'] : [KeySymbol.Shift, 'Enter'],
},
{ description: 'Send message (always)', keys: [modKey, 'Enter'] },
],
},
{
title: 'Messages',
rows: [
{ description: 'Reveal message actions (react, reply, more)', keys: ['Hover / focus'] },
],
},
];
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: closeDialog,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog
variant="Surface"
aria-labelledby="keyboard-shortcuts-dialog-title"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4" id="keyboard-shortcuts-dialog-title">
Keyboard Shortcuts
</Text>
</Box>
<IconButton size="300" onClick={closeDialog} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Scroll size="300" hideTrack visibility="Hover">
<Box style={{ padding: config.space.S400 }} direction="Column" gap="500">
{sections.map((section, sectionIndex) => (
<Box key={section.title} direction="Column" gap="300">
{sectionIndex > 0 && <Line variant="Surface" size="300" />}
<Text size="L400" priority="400">
{section.title}
</Text>
<Box as="dl" className={css.ShortcutList} direction="Column">
{section.rows.map((row) => (
<Box
key={row.description}
className={css.ShortcutRow}
direction="Row"
alignItems="Center"
gap="200"
>
<Text as="dt" className={css.ShortcutTerm} size="T300">
{row.description}
</Text>
<ShortcutKeys keys={row.keys} />
</Box>
))}
</Box>
</Box>
))}
<Text size="T200" priority="300">
{enterForNewline
? 'Enter inserts a new line while “Enter for newline” is enabled in Settings.'
: 'Enter sends the message. Enable “Enter for newline” in Settings to swap this.'}
</Text>
</Box>
</Scroll>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
+1
View File
@@ -0,0 +1 @@
export * from './KeyboardShortcutsDialog';
+11 -2
View File
@@ -42,6 +42,7 @@ import { toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders';
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
import { useRoomsListener } from '../../hooks/useRoomsListener';
import { threadNotificationsAtom } from '../../state/threadNotifications';
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
@@ -213,7 +214,7 @@ function InviteNotifications() {
]);
return (
<audio ref={audioRef} style={{ display: 'none' }}>
<audio ref={audioRef} style={{ display: 'none' }} aria-hidden="true">
<source src={soundSrc ?? InviteSound} type="audio/ogg" />
</audio>
);
@@ -496,7 +497,7 @@ function MessageNotifications() {
useRoomsListener(mx, ThreadEvent.NewReply, handleNewReply);
return (
<audio ref={audioRef} style={{ display: 'none' }}>
<audio ref={audioRef} style={{ display: 'none' }} aria-hidden="true">
<source src={soundSrc ?? NotificationSound} type="audio/ogg" />
</audio>
);
@@ -642,6 +643,13 @@ function LotusDenoiseFeature() {
return null;
}
// Registers the global `?` shortcut (ignored while typing) and renders the
// keyboard-shortcuts help dialog. Headless — the dialog self-gates on its atom.
function KeyboardShortcutsFeature() {
useKeyboardShortcutsTrigger();
return <KeyboardShortcutsDialog />;
}
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
return (
<>
@@ -656,6 +664,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<TauriDesktopFeatures />
<LotusDenoiseFeature />
<DeepLinkNavigator />
<KeyboardShortcutsFeature />
{children}
</>
);
+22 -6
View File
@@ -229,13 +229,21 @@ export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] =>
findAndReplace(
text,
EMOJI_REG_G,
(match, pushIndex) => (
(match, pushIndex) => {
const shortcode = getShortcodeFor(getHexcodeForEmoji(match[0]));
return (
<span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}>
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(match[0]))}>
<span
className={css.Emoticon()}
title={shortcode}
aria-label={shortcode || undefined}
role={shortcode ? 'img' : undefined}
>
{match[0]}
</span>
</span>
),
);
},
(txt) => txt,
);
@@ -574,10 +582,20 @@ export const getReactCustomHtmlParser = (
);
}
if (htmlSrc && 'data-mx-emoticon' in props) {
const emoticonAlt =
(typeof props.alt === 'string' && props.alt) ||
(typeof props.title === 'string' && props.title) ||
'emoji';
return (
<span className={css.EmoticonBase}>
<span className={css.Emoticon()}>
<img {...props} className={css.EmoticonImg} src={htmlSrc} loading="lazy" />
<img
{...props}
alt={emoticonAlt}
className={css.EmoticonImg}
src={htmlSrc}
loading="lazy"
/>
</span>
</span>
);
@@ -611,7 +629,6 @@ export const getReactCustomHtmlParser = (
<>
{segments.map((segment, index) => {
if (segment.type === 'text') {
// eslint-disable-next-line react/no-array-index-key
return (
<React.Fragment key={index}>{renderTextChunk(segment.value)}</React.Fragment>
);
@@ -619,7 +636,6 @@ export const getReactCustomHtmlParser = (
const raw =
segment.type === 'block' ? `$$${segment.value}$$` : `$${segment.value}$`;
return (
// eslint-disable-next-line react/no-array-index-key
<React.Fragment key={index}>
{renderMath(segment.value, segment.type === 'block', raw, raw)}
</React.Fragment>
+28
View File
@@ -0,0 +1,28 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import dayjs from 'dayjs';
import { messageAriaLabel } from './a11y';
import { timeDayMonthYear, timeHourMinute } from './time';
test('messageAriaLabel composes sender, date and time (24h)', () => {
const ts = dayjs('2026-07-01T14:30:00').valueOf();
assert.equal(
messageAriaLabel('Alice', ts, true),
`Alice, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, true)}`,
);
});
test('messageAriaLabel honours the 12-hour clock preference', () => {
const ts = dayjs('2026-07-01T14:30:00').valueOf();
assert.equal(
messageAriaLabel('Bob', ts, false),
`Bob, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, false)}`,
);
});
test('messageAriaLabel keeps the sender name verbatim as plain text', () => {
const ts = dayjs('2026-07-01T09:05:00').valueOf();
const label = messageAriaLabel('@user:example.org', ts, true);
assert.ok(label.startsWith('@user:example.org, '));
assert.ok(!label.includes('<'));
});
+14
View File
@@ -0,0 +1,14 @@
import { timeDayMonthYear, timeHourMinute } from './time';
/**
* Builds a plain-text accessible label for a message row, used when the
* visible sender/timestamp header is collapsed and therefore hidden from
* assistive technology.
*
* @param sender - Sender display name (already resolved to a human string).
* @param ts - Message origin timestamp in milliseconds.
* @param hour24Clock - Whether to format the time using a 24-hour clock.
* @returns A label such as `Alice, 1 July 2026 14:30`.
*/
export const messageAriaLabel = (sender: string, ts: number, hour24Clock: boolean): string =>
`${sender}, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`;