Compare commits
10 Commits
f589182709
...
4d7a05c0f1
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d7a05c0f1 | |||
| b5e7bcc0b8 | |||
| bca371ad38 | |||
| 899a14c119 | |||
| 6728a1274d | |||
| 21dda93d1b | |||
| 4380041014 | |||
| 8729ccfcf5 | |||
| 8ab1ec254b | |||
| 23f715857c |
+28
-27
@@ -15,32 +15,33 @@ 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.
|
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
|
||||||
|
|
||||||
| ID | Item | File / area | Test |
|
| ID | Item | File / area | Test |
|
||||||
| :--- | :------------------------------------------------------------------------------------- | :--------------------------------------------------- | :-------------------------------------------------------------------------------- |
|
| :--- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------- |
|
||||||
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
|
| #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 |
|
| #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 |
|
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
|
||||||
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
|
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
|
||||||
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
|
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
|
||||||
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
|
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
|
||||||
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
|
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
|
||||||
| #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 |
|
| #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 |
|
||||||
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
|
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
|
||||||
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
|
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
|
||||||
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
|
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
|
||||||
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
|
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
|
||||||
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
|
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
|
||||||
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
|
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
|
||||||
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
|
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
|
||||||
| P3-8 | Thread Panel (side drawer, chips, threaded receipts, thread composer) | `features/room/thread/*`, `RoomTimeline/RoomInput` | 6-step checklist in LOTUS_TODO §P3-8 |
|
| P3-8 | Thread Panel (side drawer, chips, threaded receipts, thread composer) | `features/room/thread/*`, `RoomTimeline/RoomInput` | 6-step checklist in LOTUS_TODO §P3-8 |
|
||||||
| P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk) | `utils/mathParse.ts`, `components/math/` | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text) |
|
| P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk) | `utils/mathParse.ts`, `components/math/` | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text) |
|
||||||
| P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes |
|
| P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes |
|
||||||
| N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth |
|
| N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth |
|
||||||
| P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math) | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread` | 6-step checklist in LOTUS_TODO §P4-1 |
|
| P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math) | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread` | 6-step checklist in LOTUS_TODO §P4-1 |
|
||||||
| AW-1 | Scheduled-message cancel no longer ghost-sends (error row on failure) | `ScheduledMessagesTray.tsx` | schedule → cancel with network cut → item stays + error; retry works |
|
| AW-1 | Scheduled-message cancel no longer ghost-sends (error row on failure) | `ScheduledMessagesTray.tsx` | schedule → cancel with network cut → item stays + error; retry works |
|
||||||
| 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-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-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 |
|
| 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, B1–B4, 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.
|
**Verified working in live testing (2026-06):** A2, B1–B4, 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
|
### 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.
|
- **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.
|
- **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
|
### PWA / Offline / Notifications
|
||||||
|
|||||||
+29
-24
@@ -17,17 +17,17 @@
|
|||||||
|
|
||||||
From the matrix infra README (`/root/code/matrix/README.md`):
|
From the matrix infra README (`/root/code/matrix/README.md`):
|
||||||
|
|
||||||
| Thing | Value |
|
| Thing | Value |
|
||||||
|-------|-------|
|
| ------------------------ | ------------------------------------------------------------- |
|
||||||
| Synapse host | LXC **151**, `10.10.10.29` (Synapse 1.155.0) |
|
| Synapse host | LXC **151**, `10.10.10.29` (Synapse 1.155.0) |
|
||||||
| Synapse log | `/var/log/matrix-synapse/homeserver.log` |
|
| Synapse log | `/var/log/matrix-synapse/homeserver.log` |
|
||||||
| Synapse config | `/etc/matrix-synapse/homeserver.yaml` (+ `conf.d/`) |
|
| Synapse config | `/etc/matrix-synapse/homeserver.yaml` (+ `conf.d/`) |
|
||||||
| Synapse HTTP | `10.10.10.29:8008` |
|
| Synapse HTTP | `10.10.10.29:8008` |
|
||||||
| PostgreSQL host | LXC **109**, `10.10.10.44` (PG 17.9), db `synapse` |
|
| PostgreSQL host | LXC **109**, `10.10.10.44` (PG 17.9), db `synapse` |
|
||||||
| synapse-admin UI | `http://10.10.10.29:8080` |
|
| synapse-admin UI | `http://10.10.10.29:8080` |
|
||||||
| LiveKit / lk-jwt / guard | LXC 151: LiveKit `:7880/:7881`, guard `:8070`, lk-jwt `:8071` |
|
| LiveKit / lk-jwt / guard | LXC 151: LiveKit `:7880/:7881`, guard `:8070`, lk-jwt `:8071` |
|
||||||
| SSH path to Synapse | `ssh root@10.10.10.4` then `pct enter 151` |
|
| SSH path to Synapse | `ssh root@10.10.10.4` then `pct enter 151` |
|
||||||
| SSH path to PG | `ssh root@10.10.10.4` then `pct enter 109` |
|
| SSH path to PG | `ssh root@10.10.10.4` then `pct enter 109` |
|
||||||
|
|
||||||
**Getting a psql shell** (run on LXC 109, or from 151 over the network):
|
**Getting a psql shell** (run on LXC 109, or from 151 over the network):
|
||||||
|
|
||||||
@@ -50,10 +50,10 @@ temporarily raise it in `/etc/matrix-synapse/conf.d/log.yaml` (or the
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
loggers:
|
loggers:
|
||||||
synapse.rest.client.keys: { level: DEBUG }
|
synapse.rest.client.keys: { level: DEBUG }
|
||||||
synapse.handlers.e2e_keys: { level: DEBUG }
|
synapse.handlers.e2e_keys: { level: DEBUG }
|
||||||
synapse.storage.databases.main.end_to_end_keys: { level: DEBUG }
|
synapse.storage.databases.main.end_to_end_keys: { level: DEBUG }
|
||||||
synapse.handlers.devicemessage: { level: DEBUG } # to-device
|
synapse.handlers.devicemessage: { level: DEBUG } # to-device
|
||||||
```
|
```
|
||||||
|
|
||||||
Then `systemctl reload matrix-synapse` (reload re-reads log config without a
|
Then `systemctl reload matrix-synapse` (reload re-reads log config without a
|
||||||
@@ -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>"
|
/var/log/matrix-synapse/homeserver.log | grep "<user_id>"
|
||||||
```
|
```
|
||||||
- **Synapse SQL (LXC 109) — what the server thinks it holds:**
|
- **Synapse SQL (LXC 109) — what the server thinks it holds:**
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- Current OTK inventory for the device (compare key_id set against the
|
-- Current OTK inventory for the device (compare key_id set against the
|
||||||
-- request body the client keeps retrying).
|
-- 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
|
FROM e2e_fallback_keys_json
|
||||||
WHERE user_id = '@user:matrix.lotusguild.org' AND device_id = '<DEVICE_ID>';
|
WHERE user_id = '@user:matrix.lotusguild.org' AND device_id = '<DEVICE_ID>';
|
||||||
```
|
```
|
||||||
|
|
||||||
> Table names are Synapse 1.155 (`e2e_one_time_keys_json`,
|
> 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.
|
> `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
|
- **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
|
`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
|
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>"
|
/var/log/matrix-synapse/homeserver.log | grep -E "<user_id>|<participant_id>"
|
||||||
```
|
```
|
||||||
- **Synapse SQL (LXC 109) — undelivered / queued to-device events:**
|
- **Synapse SQL (LXC 109) — undelivered / queued to-device events:**
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- Backlog of to-device messages queued for the affected device. A growing
|
-- 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
|
-- 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
|
SELECT device_id, display_name, last_seen, ts
|
||||||
FROM devices WHERE user_id = '@user:matrix.lotusguild.org';
|
FROM devices WHERE user_id = '@user:matrix.lotusguild.org';
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Confirms:** to-device events present but undecryptable (client shows the
|
- **Confirms:** to-device events present but undecryptable (client shows the
|
||||||
`io.element.call.encryption_keys` "unexpected encrypted" warning) ⇒ there is
|
`io.element.call.encryption_keys` "unexpected encrypted" warning) ⇒ there is
|
||||||
**no valid Olm session** to decrypt them — the expected downstream of KE-1.
|
**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
|
> "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.
|
> 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
|
- **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
|
device id, fresh device keys, and a clean OTK batch — sidesteps a diverged
|
||||||
OTK store without touching other sessions.
|
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
|
- **Confirms/uses:** if KE-1 stops after this, OTK-store divergence (Q1) was
|
||||||
the cause.
|
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
|
- **What:** `clearLoginData()` in `src/client/initMatrix.ts` (coordinator's
|
||||||
file — do not edit) **deletes ALL IndexedDB databases** (incl.
|
file — do not edit) **deletes ALL IndexedDB databases** (incl.
|
||||||
`web-sync-store` and the rust-crypto store `crypto-store`), **unregisters
|
`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
|
- **Use when:** the rust-crypto store itself is corrupt/diverged and option 1
|
||||||
didn't clear it.
|
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
|
- **Current pin:** `package.json` → `"matrix-js-sdk": "41.6.0-rc.0"` (a
|
||||||
release candidate).
|
release candidate).
|
||||||
- **Finding (npm / GitHub changelog, checked 2026-07):** stable **`41.6.0`**
|
- **Finding (npm / GitHub changelog, checked 2026-07):** stable **`41.6.0`**
|
||||||
was released **2026-05-26**. Its only changelog line is *"Throw sane error
|
was released **2026-05-26**. Its only changelog line is _"Throw sane error
|
||||||
on completeLoginOnNewDevice IdP rejection"* — **no OTK / keys-upload / Olm /
|
on completeLoginOnNewDevice IdP rejection"_ — **no OTK / keys-upload / Olm /
|
||||||
to-device fix** relative to the RC. Later stable lines exist
|
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).
|
(`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
|
Nearby crypto-relevant entries: `41.5.0` _"Enable encrypted history sharing
|
||||||
by default"*; `41.4.0` key-backup handling. **No changelog entry directly
|
by default"_; `41.4.0` key-backup handling. **No changelog entry directly
|
||||||
addresses the KE-1 OTK-conflict symptom** in the immediate range — so
|
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
|
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
|
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`
|
rust-crypto IndexedDB schema — a downgrade triggers the `IDB_VERSION_CONFLICT`
|
||||||
path in `initMatrix.ts`.
|
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
|
- **What:** deleting/rewriting rows in `e2e_one_time_keys_json` (and/or
|
||||||
`e2e_fallback_keys_json`, `device_inbox`) for the affected device to force
|
`e2e_fallback_keys_json`, `device_inbox`) for the affected device to force
|
||||||
the client to re-upload a clean batch.
|
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
|
- **Guardrails if ever done (planning session + HS owner only):** full
|
||||||
`pg_dump` of `synapse` first; do it during **zero active calls**; delete only
|
`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
|
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.
|
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`.
|
`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` /
|
(Optionally raise the `synapse.rest.client.keys` / `handlers.e2e_keys` /
|
||||||
`handlers.devicemessage` loggers to DEBUG per §0 and `systemctl reload
|
`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
|
2. **Prep client:** open Lotus Chat → Settings → Developer Tools → **enable
|
||||||
Developer Tools** so the **Crypto Diagnostics** card is visible; note its
|
Developer Tools** so the **Crypto Diagnostics** card is visible; note its
|
||||||
entry count starts at (or reset by reload to) 0.
|
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
|
with pass-through wrappers (originals always called) that ring-buffer (max
|
||||||
**200**) any line matching the KE signatures. No network, no timers.
|
**200**) any line matching the KE signatures. No network, no timers.
|
||||||
- `getCryptoDiagEntries()` — snapshot copy of the buffer (`{ ts, level, ke,
|
- `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,
|
- `buildCryptoDiagReport(mx)` — JSON string: SDK version, device id, user id,
|
||||||
sync state, `cryptoReady` (`mx.getCrypto()` presence), per-KE counts, and the
|
sync state, `cryptoReady` (`mx.getCrypto()` presence), per-KE counts, and the
|
||||||
entry buffer. No tokens/PII beyond those ids; captured log lines are retained
|
entry buffer. No tokens/PII beyond those ids; captured log lines are retained
|
||||||
|
|||||||
@@ -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
|
## Infrastructure
|
||||||
|
|
||||||
### Authenticated Media
|
### Authenticated Media
|
||||||
|
|||||||
+96
-6
@@ -1,6 +1,6 @@
|
|||||||
# Lotus Chat — Manual Testing Guide
|
# 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.
|
**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.
|
> **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
|
## Priority if you're short on time
|
||||||
|
|
||||||
1. **A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce.
|
1. **O1 + O2** (threads + per-thread notifications) — the largest new surface; the main-timeline change is user-visible.
|
||||||
2. **B1–B3** (polls on a default theme) — the confirmed visual bug.
|
2. **O7** (desktop CSP smoke) — CI can't catch CSP breakage; a wrong directive silently breaks media/fonts/maps.
|
||||||
3. **D** (EC 0.20.1 control sweep) — guards against the upstream merge breaking calls.
|
3. **O5** (session cross-tab) + **O6** (scheduled-cancel ghost-send) — auth-critical + a real data-loss-class fix.
|
||||||
4. **A7** false-positive check (normal joins don't show the error overlay).
|
4. **A4** (in-call banner) + **A3** (ringtone) — newest call logic, hardest to reproduce.
|
||||||
5. Everything else.
|
5. **D** (EC control sweep) — guards against the fork breaking calls.
|
||||||
|
6. Everything else.
|
||||||
|
|||||||
+24
-15
@@ -141,7 +141,12 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
|
|||||||
|
|
||||||
## Priority 3 — Higher complexity / lower daily frequency
|
## 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:
|
**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.**
|
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):**
|
**Manual QA checklist (post-deploy):**
|
||||||
|
|
||||||
1. Reply in Thread (message menu) → panel opens; send text/upload/emoji into it (appears pending → confirmed)
|
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`
|
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
|
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)
|
4. Room badge clears via normal markAsRead even with unread threads (unthreaded receipt)
|
||||||
5. Reload: partitioning persists; encrypted-room threads decrypt (back-pagination too)
|
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
|
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:
|
Features:
|
||||||
|
|
||||||
@@ -209,23 +215,23 @@ Features:
|
|||||||
**What:** Replace the manual "load more" button with an automated, virtualized infinite scroll for search results.
|
**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.
|
**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.
|
**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).
|
||||||
**Approach:** Use `IndexedDB` to store search metadata (event IDs, timestamps) to prevent redundant server-side decryption/fetching.
|
|
||||||
|
|
||||||
### [~] P4-1 · Thread Notification Mode Per-Thread — IMPLEMENTED (2026-07), ⚠️ AWAITING LIVE QA
|
### [~] 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`.
|
**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):**
|
**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)
|
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
|
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
|
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
|
4. Set to All → every reply notifies; Mentions-only → only @mentions
|
||||||
5. Second device shows the same per-thread modes (account-data sync)
|
5. Second device shows the same per-thread modes (account-data sync)
|
||||||
6. Room-level Mute still silences everything incl. thread overrides
|
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.
|
**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**.
|
**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_*`).
|
- 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(...)`.
|
- 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).
|
**Priority:** Extreme Low (Multi-sprint/Architectural).
|
||||||
|
|
||||||
### [DROPPED] P5-52 · Desktop — Room-Level Sync Governor (Performance Control)
|
### [DROPPED] P5-52 · Desktop — Room-Level Sync Governor (Performance Control)
|
||||||
@@ -510,20 +517,22 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
|
|||||||
|
|
||||||
**Decisions (each backed by SDK evidence in node_modules/matrix-js-sdk):**
|
**Decisions (each backed by SDK evidence in node_modules/matrix-js-sdk):**
|
||||||
|
|
||||||
| Question | Decision |
|
| 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). |
|
| 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. |
|
| 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}` `` |
|
| State | `roomIdToActiveThreadIdAtomFamily` (per-room, mirrors `roomIdToReplyDraftAtomFamily`) in new `state/room/thread.ts` + `getThreadDraftKey(roomId, threadRootId)` = `` `${roomId}::${threadRootId}` `` |
|
||||||
| Composer | **Reuse RoomInput**: add optional `threadRootId` prop; scope its 3 atom-family lookups by draftKey (isolates thread drafts from the main composer); pass `threadRootId ?? null` at all 7 `mx.sendMessage/sendEvent` call sites — the SDK's `addThreadRelationIfNeeded` then emits spec-correct `m.thread` relations incl. reply-in-thread. Separate `useEditor()` instance in the panel. Hide schedule + commands in thread mode v1. |
|
| Composer | **Reuse RoomInput**: add optional `threadRootId` prop; scope its 3 atom-family lookups by draftKey (isolates thread drafts from the main composer); pass `threadRootId ?? null` at all 7 `mx.sendMessage/sendEvent` call sites — the SDK's `addThreadRelationIfNeeded` then emits spec-correct `m.thread` relations incl. reply-in-thread. Separate `useEditor()` instance in the panel. Hide schedule + commands in thread mode v1. |
|
||||||
| Unreads | v1 = unread badge on the summary chip (`room.getThreadUnreadNotificationCount` — counts already synced independent of threadSupport) + `markThreadAsRead` threaded receipt when panel open at bottom. |
|
| Unreads | v1 = unread badge on the summary chip (`room.getThreadUnreadNotificationCount` — counts already synced independent of threadSupport) + `markThreadAsRead` threaded receipt when panel open at bottom. |
|
||||||
| Mobile | Pure CSS like `MembersDrawer.css.ts`: fixed width toRem(360) desktop, `position:fixed; inset:0` under 750px. |
|
| 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):**
|
**Critical side-effect fixes (one-liners, land FIRST):**
|
||||||
|
|
||||||
1. `initMatrix.ts` → `threadSupport: true`.
|
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).
|
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):**
|
**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`.
|
- **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.
|
- **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).
|
- **Deep links:** `getEventTimeline(mainSet, threadEventId)` returns undefined for thread events — redirect jump-to-event to the panel (best-effort v1).
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ experimental_features:
|
|||||||
msc3861:
|
msc3861:
|
||||||
enabled: true
|
enabled: true
|
||||||
issuer: http://localhost:8090/
|
issuer: http://localhost:8090/
|
||||||
client_id: "0000000000000000000SYNAPSE"
|
client_id: '0000000000000000000SYNAPSE'
|
||||||
client_auth_method: client_secret_basic
|
client_auth_method: client_secret_basic
|
||||||
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET"
|
client_secret: 'REPLACE_WITH_A_SHARED_CLIENT_SECRET'
|
||||||
admin_token: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN"
|
admin_token: 'REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN'
|
||||||
account_management_url: "http://localhost:8090/account"
|
account_management_url: 'http://localhost:8090/account'
|
||||||
|
|
||||||
# With msc3861 enabled, Synapse disables its own password/SSO login and advertises
|
# With msc3861 enabled, Synapse disables its own password/SSO login and advertises
|
||||||
# `m.authentication` in /.well-known/matrix/client — which is exactly what the
|
# `m.authentication` in /.well-known/matrix/client — which is exactly what the
|
||||||
|
|||||||
+28
-1
@@ -25,7 +25,7 @@ export default [
|
|||||||
tsPlugin.configs['flat/eslint-recommended'],
|
tsPlugin.configs['flat/eslint-recommended'],
|
||||||
...tsPlugin.configs['flat/recommended'],
|
...tsPlugin.configs['flat/recommended'],
|
||||||
reactPlugin.configs.flat.recommended,
|
reactPlugin.configs.flat.recommended,
|
||||||
reactHooksPlugin.configs.flat['recommended'],
|
reactHooksPlugin.configs.flat.recommended,
|
||||||
// Register jsx-a11y plugin (rules selectively enabled below)
|
// Register jsx-a11y plugin (rules selectively enabled below)
|
||||||
{ plugins: { 'jsx-a11y': jsxA11yPlugin } },
|
{ plugins: { 'jsx-a11y': jsxA11yPlugin } },
|
||||||
// airbnb-base via FlatCompat (JS/import rules; no React plugin, no getFilename issue)
|
// 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/media-has-caption': 'off',
|
||||||
'jsx-a11y/no-noninteractive-element-interactions': 'off',
|
'jsx-a11y/no-noninteractive-element-interactions': 'off',
|
||||||
'jsx-a11y/alt-text': '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',
|
'no-undef': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Test files commonly define several small mock/fake classes.
|
||||||
|
files: ['**/*.test.ts', '**/*.test.tsx'],
|
||||||
|
rules: {
|
||||||
|
'max-classes-per-file': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
|
|||||||
<Text size="L400">Account Data</Text>
|
<Text size="L400">Account Data</Text>
|
||||||
<Input
|
<Input
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
aria-label="Account data type"
|
||||||
size="400"
|
size="400"
|
||||||
radii="300"
|
radii="300"
|
||||||
readOnly
|
readOnly
|
||||||
|
|||||||
@@ -282,7 +282,12 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
>
|
>
|
||||||
{previewUrl && (
|
{previewUrl && (
|
||||||
<>
|
<>
|
||||||
<audio ref={previewAudioRef} src={previewUrl} onEnded={() => setPreviewPlaying(false)} />
|
<audio
|
||||||
|
ref={previewAudioRef}
|
||||||
|
src={previewUrl}
|
||||||
|
onEnded={() => setPreviewPlaying(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const audio = previewAudioRef.current;
|
const audio = previewAudioRef.current;
|
||||||
|
|||||||
@@ -78,11 +78,14 @@ export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box shrink="No" direction="Column" gap="100">
|
<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">
|
<Text size="T200" priority="300">
|
||||||
Pick an unique address to make it discoverable.
|
Pick an unique address to make it discoverable.
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
|
id="create-room-alias"
|
||||||
ref={aliasInputRef}
|
ref={aliasInputRef}
|
||||||
onChange={handleAliasChange}
|
onChange={handleAliasChange}
|
||||||
before={
|
before={
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ type CustomEditorProps = {
|
|||||||
maxHeight?: string;
|
maxHeight?: string;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/** Explicit accessible name for the textbox; falls back to the placeholder. */
|
||||||
|
ariaLabel?: string;
|
||||||
onKeyDown?: KeyboardEventHandler;
|
onKeyDown?: KeyboardEventHandler;
|
||||||
onKeyUp?: KeyboardEventHandler;
|
onKeyUp?: KeyboardEventHandler;
|
||||||
onChange?: EditorChangeHandler;
|
onChange?: EditorChangeHandler;
|
||||||
@@ -82,6 +84,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
maxHeight = '50vh',
|
maxHeight = '50vh',
|
||||||
editor,
|
editor,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
ariaLabel,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
onKeyUp,
|
onKeyUp,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -139,7 +142,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
data-editable-name={editableName}
|
data-editable-name={editableName}
|
||||||
className={css.EditorTextarea}
|
className={css.EditorTextarea}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
aria-label={placeholder ?? 'Message input'}
|
aria-label={ariaLabel ?? placeholder ?? 'Message input'}
|
||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
renderPlaceholder={renderPlaceholder}
|
renderPlaceholder={renderPlaceholder}
|
||||||
renderElement={renderElement}
|
renderElement={renderElement}
|
||||||
|
|||||||
@@ -200,12 +200,24 @@ export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfil
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Inherit" gap="100">
|
<Box direction="Inherit" gap="100">
|
||||||
<Text size="L400">Name</Text>
|
<Text as="label" htmlFor="image-pack-name" size="L400">
|
||||||
<Input name="nameInput" defaultValue={meta.name} variant="Secondary" radii="300" required />
|
Name
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
id="image-pack-name"
|
||||||
|
name="nameInput"
|
||||||
|
defaultValue={meta.name}
|
||||||
|
variant="Secondary"
|
||||||
|
radii="300"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Inherit" gap="100">
|
<Box direction="Inherit" gap="100">
|
||||||
<Text size="L400">Attribution</Text>
|
<Text as="label" htmlFor="image-pack-attribution" size="L400">
|
||||||
|
Attribution
|
||||||
|
</Text>
|
||||||
<TextArea
|
<TextArea
|
||||||
|
id="image-pack-attribution"
|
||||||
name="attributionTextArea"
|
name="attributionTextArea"
|
||||||
defaultValue={meta.attribution}
|
defaultValue={meta.attribution}
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
|
|||||||
@@ -261,9 +261,12 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">User ID</Text>
|
<Text as="label" htmlFor="invite-user-id" size="L400">
|
||||||
|
User ID
|
||||||
|
</Text>
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
|
id="invite-user-id"
|
||||||
size="500"
|
size="500"
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
@@ -334,8 +337,11 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Reason (Optional)</Text>
|
<Text as="label" htmlFor="invite-reason" size="L400">
|
||||||
|
Reason (Optional)
|
||||||
|
</Text>
|
||||||
<TextArea
|
<TextArea
|
||||||
|
id="invite-reason"
|
||||||
size="500"
|
size="500"
|
||||||
name="reasonInput"
|
name="reasonInput"
|
||||||
variant="Background"
|
variant="Background"
|
||||||
|
|||||||
@@ -108,8 +108,11 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Address</Text>
|
<Text as="label" htmlFor="join-address" size="L400">
|
||||||
|
Address
|
||||||
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
|
id="join-address"
|
||||||
size="500"
|
size="500"
|
||||||
autoFocus
|
autoFocus
|
||||||
name="addressInput"
|
name="addressInput"
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ export const PageHeroSection = style([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
||||||
export const PageContentCenter = style([
|
export const PageContentCenter = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
|||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
returnFocusOnDeactivate: false,
|
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
onDeactivate: () => setViewTopic(false),
|
onDeactivate: () => setViewTopic(false),
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
aria-label="Upload soundboard clip"
|
||||||
type="file"
|
type="file"
|
||||||
accept={SOUNDBOARD_ACCEPT}
|
accept={SOUNDBOARD_ACCEPT}
|
||||||
multiple
|
multiple
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function PreviewVideo({ fileItem }: PreviewVideoProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<video
|
<video
|
||||||
|
aria-label="Video attachment preview"
|
||||||
style={{
|
style={{
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
|
|||||||
<Input
|
<Input
|
||||||
ref={reasonInputRef}
|
ref={reasonInputRef}
|
||||||
placeholder="Reason"
|
placeholder="Reason"
|
||||||
|
aria-label="Moderation reason"
|
||||||
size="300"
|
size="300"
|
||||||
variant="Background"
|
variant="Background"
|
||||||
radii="300"
|
radii="300"
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ function UserPrivateNotes({ userId }: { userId: string }) {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<textarea
|
<textarea
|
||||||
|
aria-label="Private note about this user"
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
maxLength={USER_NOTE_MAX_LENGTH}
|
maxLength={USER_NOTE_MAX_LENGTH}
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
|
|||||||
<Text size="L400">Name</Text>
|
<Text size="L400">Name</Text>
|
||||||
<Input
|
<Input
|
||||||
name="nameInput"
|
name="nameInput"
|
||||||
|
aria-label="Power level name"
|
||||||
defaultValue={tag?.name}
|
defaultValue={tag?.name}
|
||||||
placeholder="Bot"
|
placeholder="Bot"
|
||||||
size="300"
|
size="300"
|
||||||
@@ -160,6 +161,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
|
|||||||
<Input
|
<Input
|
||||||
defaultValue={power}
|
defaultValue={power}
|
||||||
name="powerInput"
|
name="powerInput"
|
||||||
|
aria-label="Power level value"
|
||||||
size="300"
|
size="300"
|
||||||
variant={typeof power === 'number' ? 'SurfaceVariant' : 'Secondary'}
|
variant={typeof power === 'number' ? 'SurfaceVariant' : 'Secondary'}
|
||||||
radii="300"
|
radii="300"
|
||||||
|
|||||||
@@ -186,8 +186,8 @@ function LightboxMedia({
|
|||||||
)}
|
)}
|
||||||
{media.status === 'ok' &&
|
{media.status === 'ok' &&
|
||||||
(item.msgtype === MsgType.Video ? (
|
(item.msgtype === MsgType.Video ? (
|
||||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
|
||||||
<video
|
<video
|
||||||
|
aria-label="Video attachment"
|
||||||
src={media.url}
|
src={media.url}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
@@ -261,7 +261,6 @@ function Lightbox({
|
|||||||
escapeDeactivates: false,
|
escapeDeactivates: false,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal
|
aria-modal
|
||||||
@@ -640,13 +639,15 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
className={classNames(css.MediaGalleryDrawer, ContainerColor({ variant: 'Background' }))}
|
className={classNames(css.MediaGalleryDrawer, ContainerColor({ variant: 'Background' }))}
|
||||||
shrink="No"
|
shrink="No"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
|
role="region"
|
||||||
|
aria-labelledby="media-gallery-title"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Header variant="Background" size="600" className={css.MediaGalleryHeader}>
|
<Header variant="Background" size="600" className={css.MediaGalleryHeader}>
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
<Icon size="200" src={Icons.Photo} />
|
<Icon size="200" src={Icons.Photo} />
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4" truncate>
|
<Text id="media-gallery-title" size="H4" truncate>
|
||||||
Media Gallery
|
Media Gallery
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -142,7 +142,6 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
|||||||
placeholder="Ask a question…"
|
placeholder="Ask a question…"
|
||||||
value={question}
|
value={question}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuestion(e.target.value)}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuestion(e.target.value)}
|
||||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -151,7 +150,6 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
|||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Text size="L400">Options</Text>
|
<Text size="L400">Options</Text>
|
||||||
{options.map((opt, index) => (
|
{options.map((opt, index) => (
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
|
||||||
<Box key={index} alignItems="Center" gap="200">
|
<Box key={index} alignItems="Center" gap="200">
|
||||||
<Input
|
<Input
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
|
|||||||
@@ -583,7 +583,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
returnFocusOnDeactivate: false,
|
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
onDeactivate: () => setViewTopic(false),
|
onDeactivate: () => setViewTopic(false),
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
|
|||||||
@@ -25,3 +25,16 @@ export const RoomViewTyping = style([
|
|||||||
export const TypingText = style({
|
export const TypingText = style({
|
||||||
flexGrow: 1,
|
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,
|
||||||
|
});
|
||||||
|
|||||||
@@ -33,8 +33,21 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
|||||||
[typingMembers, myUserId, room],
|
[typingMembers, myUserId, room],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (typingNames.length === 0) {
|
// A single, non-truncated string for assistive technology to announce.
|
||||||
return null;
|
// 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 = () => {
|
const handleDropAll = () => {
|
||||||
@@ -50,83 +63,89 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }} aria-live="polite" aria-atomic="false">
|
<div style={{ position: 'relative' }}>
|
||||||
<Box
|
{/* Persistently mounted so the FIRST "X is typing" is announced. */}
|
||||||
className={classNames(css.RoomViewTyping, className)}
|
<span className={css.SrOnly} role="status" aria-live="polite" aria-atomic="true">
|
||||||
alignItems="Center"
|
{typingAnnouncement}
|
||||||
gap="400"
|
</span>
|
||||||
{...props}
|
{typingNames.length > 0 && (
|
||||||
ref={ref}
|
<Box
|
||||||
>
|
className={classNames(css.RoomViewTyping, className)}
|
||||||
<TypingIndicator />
|
alignItems="Center"
|
||||||
<Text className={css.TypingText} size="T300" truncate>
|
gap="400"
|
||||||
{typingNames.length === 1 && (
|
{...props}
|
||||||
<>
|
ref={ref}
|
||||||
<b>{typingNames[0]}</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{' is typing...'}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{typingNames.length === 2 && (
|
|
||||||
<>
|
|
||||||
<b>{typingNames[0]}</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{' and '}
|
|
||||||
</Text>
|
|
||||||
<b>{typingNames[1]}</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{' are typing...'}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{typingNames.length === 3 && (
|
|
||||||
<>
|
|
||||||
<b>{typingNames[0]}</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{', '}
|
|
||||||
</Text>
|
|
||||||
<b>{typingNames[1]}</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{' and '}
|
|
||||||
</Text>
|
|
||||||
<b>{typingNames[2]}</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{' are typing...'}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{typingNames.length > 3 && (
|
|
||||||
<>
|
|
||||||
<b>{typingNames[0]}</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{', '}
|
|
||||||
</Text>
|
|
||||||
<b>{typingNames[1]}</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{', '}
|
|
||||||
</Text>
|
|
||||||
<b>{typingNames[2]}</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{' and '}
|
|
||||||
</Text>
|
|
||||||
<b>{typingNames.length - 3} others</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{' are typing...'}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<IconButton
|
|
||||||
title="Drop Typing Status"
|
|
||||||
aria-label="Drop typing status"
|
|
||||||
size="300"
|
|
||||||
radii="Pill"
|
|
||||||
onClick={handleDropAll}
|
|
||||||
>
|
>
|
||||||
<Icon size="50" src={Icons.Cross} />
|
<TypingIndicator />
|
||||||
</IconButton>
|
<Text className={css.TypingText} size="T300" truncate aria-hidden>
|
||||||
</Box>
|
{typingNames.length === 1 && (
|
||||||
|
<>
|
||||||
|
<b>{typingNames[0]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' is typing...'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{typingNames.length === 2 && (
|
||||||
|
<>
|
||||||
|
<b>{typingNames[0]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' and '}
|
||||||
|
</Text>
|
||||||
|
<b>{typingNames[1]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' are typing...'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{typingNames.length === 3 && (
|
||||||
|
<>
|
||||||
|
<b>{typingNames[0]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{', '}
|
||||||
|
</Text>
|
||||||
|
<b>{typingNames[1]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' and '}
|
||||||
|
</Text>
|
||||||
|
<b>{typingNames[2]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' are typing...'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{typingNames.length > 3 && (
|
||||||
|
<>
|
||||||
|
<b>{typingNames[0]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{', '}
|
||||||
|
</Text>
|
||||||
|
<b>{typingNames[1]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{', '}
|
||||||
|
</Text>
|
||||||
|
<b>{typingNames[2]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' and '}
|
||||||
|
</Text>
|
||||||
|
<b>{typingNames.length - 3} others</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' are typing...'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<IconButton
|
||||||
|
title="Drop Typing Status"
|
||||||
|
aria-label="Drop typing status"
|
||||||
|
size="300"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={handleDropAll}
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
getMemberDisplayName,
|
getMemberDisplayName,
|
||||||
} from '../../../utils/room';
|
} from '../../../utils/room';
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
|
import { messageAriaLabel } from '../../../utils/a11y';
|
||||||
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||||
@@ -972,6 +973,10 @@ export const Message = React.memo(
|
|||||||
[MsgAppearClass]: playAppear,
|
[MsgAppearClass]: playAppear,
|
||||||
[MentionHighlightPulse]: playMentionPulse,
|
[MentionHighlightPulse]: playMentionPulse,
|
||||||
})}
|
})}
|
||||||
|
role="article"
|
||||||
|
aria-label={
|
||||||
|
collapse ? messageAriaLabel(senderDisplayName, mEvent.getTs(), hour24Clock) : undefined
|
||||||
|
}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
space={messageSpacing}
|
space={messageSpacing}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
|
|||||||
@@ -51,7 +51,13 @@ import { UseStateProvider } from '../../../components/UseStateProvider';
|
|||||||
import { EmojiBoard } from '../../../components/emoji-board';
|
import { EmojiBoard } from '../../../components/emoji-board';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
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 { mobileOrTablet } from '../../../utils/user-agent';
|
||||||
import { useComposingCheck } from '../../../hooks/useComposingCheck';
|
import { useComposingCheck } from '../../../hooks/useComposingCheck';
|
||||||
|
|
||||||
@@ -66,6 +72,12 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||||||
({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
|
({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const editor = useEditor();
|
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 [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||||
@@ -259,6 +271,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||||||
<CustomEditor
|
<CustomEditor
|
||||||
editor={editor}
|
editor={editor}
|
||||||
placeholder="Edit message..."
|
placeholder="Edit message..."
|
||||||
|
ariaLabel={editSenderId ? `Editing message from ${editSenderName}` : 'Edit message'}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onKeyUp={handleKeyUp}
|
onKeyUp={handleKeyUp}
|
||||||
bottom={
|
bottom={
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ export const Reactions = as<'div', ReactionsProps>(
|
|||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
returnFocusOnDeactivate: false,
|
|
||||||
onDeactivate: () => setViewer(false),
|
onDeactivate: () => setViewer(false),
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
|
|||||||
@@ -247,7 +247,6 @@ export function Search({ requestClose }: SearchProps) {
|
|||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: () => inputRef.current,
|
initialFocus: () => inputRef.current,
|
||||||
returnFocusOnDeactivate: false,
|
|
||||||
allowOutsideClick: true,
|
allowOutsideClick: true,
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
onDeactivate: requestClose,
|
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
|
<Box
|
||||||
shrink="No"
|
shrink="No"
|
||||||
style={{ padding: config.space.S400, paddingBottom: 0 }}
|
style={{ padding: config.space.S400, paddingBottom: 0 }}
|
||||||
@@ -270,6 +275,7 @@ export function Search({ requestClose }: SearchProps) {
|
|||||||
radii="400"
|
radii="400"
|
||||||
outlined
|
outlined
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
|
aria-label="Search rooms"
|
||||||
before={<Icon size="200" src={Icons.Search} />}
|
before={<Icon size="200" src={Icons.Search} />}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
|
|||||||
@@ -531,6 +531,7 @@ function Appearance() {
|
|||||||
Intensity: {nightLightOpacity}%
|
Intensity: {nightLightOpacity}%
|
||||||
</Text>
|
</Text>
|
||||||
<input
|
<input
|
||||||
|
aria-label="Night light intensity"
|
||||||
type="range"
|
type="range"
|
||||||
min={5}
|
min={5}
|
||||||
max={80}
|
max={80}
|
||||||
@@ -1663,6 +1664,7 @@ function Calls() {
|
|||||||
<Text size="T200">{callDenoiseGateThreshold} dB</Text>
|
<Text size="T200">{callDenoiseGateThreshold} dB</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<input
|
<input
|
||||||
|
aria-label="Noise gate threshold"
|
||||||
type="range"
|
type="range"
|
||||||
min="-100"
|
min="-100"
|
||||||
max="0"
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './KeyboardShortcutsDialog';
|
||||||
@@ -42,6 +42,7 @@ import { toastQueueAtom } from '../../state/toast';
|
|||||||
import { useReminders } from '../../hooks/useReminders';
|
import { useReminders } from '../../hooks/useReminders';
|
||||||
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
|
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
|
||||||
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
||||||
|
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
|
||||||
import { useRoomsListener } from '../../hooks/useRoomsListener';
|
import { useRoomsListener } from '../../hooks/useRoomsListener';
|
||||||
import { threadNotificationsAtom } from '../../state/threadNotifications';
|
import { threadNotificationsAtom } from '../../state/threadNotifications';
|
||||||
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||||
@@ -213,7 +214,7 @@ function InviteNotifications() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<audio ref={audioRef} style={{ display: 'none' }}>
|
<audio ref={audioRef} style={{ display: 'none' }} aria-hidden="true">
|
||||||
<source src={soundSrc ?? InviteSound} type="audio/ogg" />
|
<source src={soundSrc ?? InviteSound} type="audio/ogg" />
|
||||||
</audio>
|
</audio>
|
||||||
);
|
);
|
||||||
@@ -496,7 +497,7 @@ function MessageNotifications() {
|
|||||||
useRoomsListener(mx, ThreadEvent.NewReply, handleNewReply);
|
useRoomsListener(mx, ThreadEvent.NewReply, handleNewReply);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<audio ref={audioRef} style={{ display: 'none' }}>
|
<audio ref={audioRef} style={{ display: 'none' }} aria-hidden="true">
|
||||||
<source src={soundSrc ?? NotificationSound} type="audio/ogg" />
|
<source src={soundSrc ?? NotificationSound} type="audio/ogg" />
|
||||||
</audio>
|
</audio>
|
||||||
);
|
);
|
||||||
@@ -642,6 +643,13 @@ function LotusDenoiseFeature() {
|
|||||||
return null;
|
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) {
|
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -656,6 +664,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<TauriDesktopFeatures />
|
<TauriDesktopFeatures />
|
||||||
<LotusDenoiseFeature />
|
<LotusDenoiseFeature />
|
||||||
<DeepLinkNavigator />
|
<DeepLinkNavigator />
|
||||||
|
<KeyboardShortcutsFeature />
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -229,13 +229,21 @@ export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] =>
|
|||||||
findAndReplace(
|
findAndReplace(
|
||||||
text,
|
text,
|
||||||
EMOJI_REG_G,
|
EMOJI_REG_G,
|
||||||
(match, pushIndex) => (
|
(match, pushIndex) => {
|
||||||
<span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}>
|
const shortcode = getShortcodeFor(getHexcodeForEmoji(match[0]));
|
||||||
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(match[0]))}>
|
return (
|
||||||
{match[0]}
|
<span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}>
|
||||||
|
<span
|
||||||
|
className={css.Emoticon()}
|
||||||
|
title={shortcode}
|
||||||
|
aria-label={shortcode || undefined}
|
||||||
|
role={shortcode ? 'img' : undefined}
|
||||||
|
>
|
||||||
|
{match[0]}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
);
|
||||||
),
|
},
|
||||||
(txt) => txt,
|
(txt) => txt,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -574,10 +582,20 @@ export const getReactCustomHtmlParser = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (htmlSrc && 'data-mx-emoticon' in props) {
|
if (htmlSrc && 'data-mx-emoticon' in props) {
|
||||||
|
const emoticonAlt =
|
||||||
|
(typeof props.alt === 'string' && props.alt) ||
|
||||||
|
(typeof props.title === 'string' && props.title) ||
|
||||||
|
'emoji';
|
||||||
return (
|
return (
|
||||||
<span className={css.EmoticonBase}>
|
<span className={css.EmoticonBase}>
|
||||||
<span className={css.Emoticon()}>
|
<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>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -611,7 +629,6 @@ export const getReactCustomHtmlParser = (
|
|||||||
<>
|
<>
|
||||||
{segments.map((segment, index) => {
|
{segments.map((segment, index) => {
|
||||||
if (segment.type === 'text') {
|
if (segment.type === 'text') {
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={index}>{renderTextChunk(segment.value)}</React.Fragment>
|
<React.Fragment key={index}>{renderTextChunk(segment.value)}</React.Fragment>
|
||||||
);
|
);
|
||||||
@@ -619,7 +636,6 @@ export const getReactCustomHtmlParser = (
|
|||||||
const raw =
|
const raw =
|
||||||
segment.type === 'block' ? `$$${segment.value}$$` : `$${segment.value}$`;
|
segment.type === 'block' ? `$$${segment.value}$$` : `$${segment.value}$`;
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
|
||||||
<React.Fragment key={index}>
|
<React.Fragment key={index}>
|
||||||
{renderMath(segment.value, segment.type === 'block', raw, raw)}
|
{renderMath(segment.value, segment.type === 'block', raw, raw)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|||||||
@@ -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('<'));
|
||||||
|
});
|
||||||
@@ -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)}`;
|
||||||
Reference in New Issue
Block a user