Compare commits
17 Commits
a9505ca5b2
...
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d7a05c0f1 | |||
| b5e7bcc0b8 | |||
| bca371ad38 | |||
| 899a14c119 | |||
| 6728a1274d | |||
| 21dda93d1b | |||
| 4380041014 | |||
| 8729ccfcf5 | |||
| 8ab1ec254b | |||
| 23f715857c | |||
| f589182709 | |||
| ef573376ac | |||
| 34d9272790 | |||
| 96f7187031 | |||
| 664dcd4cd8 | |||
| 7f960b026b | |||
| 992d2b83b3 |
+29
-24
@@ -15,28 +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.
|
||||
|
||||
| ID | Item | File / area | Test |
|
||||
| :--- | :------------------------------------------------------------------------------------- | :--------------------------------------------------- | :-------------------------------------------------------------------------------- |
|
||||
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
|
||||
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
|
||||
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
|
||||
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
|
||||
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
|
||||
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
|
||||
| #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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| ID | Item | File / area | Test |
|
||||
| :--- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------- |
|
||||
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
|
||||
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
|
||||
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
|
||||
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
|
||||
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
|
||||
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
|
||||
| #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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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-3 | SW precache (repeat-visit near-instant; deploys still picked up immediately) | `sw.ts`, `vite.config.js` | load app twice (2nd = cached assets); deploy → reload picks new version |
|
||||
| AW-4 | Desktop CSP tighten + Escape/panel fixes + thread Jump to Latest | `tauri.conf.json`, Room/ThreadPanel | desktop: boots, avatars/media load, VT323 font renders, location maps embed, calls connect, deep links work |
|
||||
| P3-4 | Accessibility compliance pass (collapsed-msg SR sender, form/overlay labels, typing announce, focus-return, `?` help, jsx-a11y CI gate) | `message/*`, `RoomViewTyping`, `features/shortcuts/*`, `eslint.config.mjs` | LOTUS_TESTING §P — axe-core + VoiceOver/NVDA on the golden path |
|
||||
|
||||
**Verified working in live testing (2026-06):** A2, 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.
|
||||
|
||||
@@ -135,7 +140,7 @@ retry … AbortError: Restart delayed event timed out before the HS responded`,
|
||||
### Security & Privacy
|
||||
|
||||
- **N97 — Access token stored in plaintext `localStorage`** (`state/sessions.ts`), vulnerable to XSS; device ID likewise. Architectural — needs a token-protection / session-storage redesign.
|
||||
- **Session writes are non-atomic and not cross-tab synced** (`state/sessions.ts`) — risks inconsistent state / races across tabs.
|
||||
- ~~**Session writes are non-atomic and not cross-tab synced**~~ — **done (2026-07):** atomic single-key `cinny_session_v1` blob (legacy-key migration + dual-write) + `subscribeSessionChanges`/`useSessionSync` cross-tab reload. (The plaintext-token concern in N97 above is the remaining, separate architectural item.)
|
||||
- **Persisted PII without encryption:** user status message + expiry (`settings/account/Profile.tsx`), unsent composer drafts (`room/RoomInput.tsx`). Leak risk on shared devices.
|
||||
|
||||
### PWA / Offline / Notifications
|
||||
@@ -146,7 +151,7 @@ retry … AbortError: Restart delayed event timed out before the HS responded`,
|
||||
|
||||
### Dependencies & Build
|
||||
|
||||
- **`matrix-js-sdk` pinned to a Release Candidate** (`41.6.0-rc.0`); `@atlaskit` and build tools (`vite`, `typescript`, `eslint`) on unstable/experimental pins — review for stable versions; RC SDK is a tree-shaking/bundle-size risk.
|
||||
- ~~**`matrix-js-sdk` pinned to a Release Candidate**~~ — **done (2026-07):** moved to `41.7.0` stable (crypto-wasm 18.3.1 security bump). Deep-audit dep triage: all 16 npm advisories are dev-only/unreachable/dead-dep — zero shipped exposure; dead `dompurify` removed. `@atlaskit`/build-tool pins remain review-worthy but low priority.
|
||||
- **Build-time overhead:** `lotusDenoise` does heavy sequential `fs` work in `closeBundle`; `viteStaticCopy` config is complex with redundant renames — could be streamlined.
|
||||
|
||||
### Code Hygiene / DevEx
|
||||
|
||||
+29
-24
@@ -17,17 +17,17 @@
|
||||
|
||||
From the matrix infra README (`/root/code/matrix/README.md`):
|
||||
|
||||
| Thing | Value |
|
||||
|-------|-------|
|
||||
| Synapse host | LXC **151**, `10.10.10.29` (Synapse 1.155.0) |
|
||||
| Synapse log | `/var/log/matrix-synapse/homeserver.log` |
|
||||
| Synapse config | `/etc/matrix-synapse/homeserver.yaml` (+ `conf.d/`) |
|
||||
| Synapse HTTP | `10.10.10.29:8008` |
|
||||
| PostgreSQL host | LXC **109**, `10.10.10.44` (PG 17.9), db `synapse` |
|
||||
| synapse-admin UI | `http://10.10.10.29:8080` |
|
||||
| Thing | Value |
|
||||
| ------------------------ | ------------------------------------------------------------- |
|
||||
| Synapse host | LXC **151**, `10.10.10.29` (Synapse 1.155.0) |
|
||||
| Synapse log | `/var/log/matrix-synapse/homeserver.log` |
|
||||
| Synapse config | `/etc/matrix-synapse/homeserver.yaml` (+ `conf.d/`) |
|
||||
| Synapse HTTP | `10.10.10.29:8008` |
|
||||
| PostgreSQL host | LXC **109**, `10.10.10.44` (PG 17.9), db `synapse` |
|
||||
| synapse-admin UI | `http://10.10.10.29:8080` |
|
||||
| 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 PG | `ssh root@10.10.10.4` then `pct enter 109` |
|
||||
| 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` |
|
||||
|
||||
**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
|
||||
loggers:
|
||||
synapse.rest.client.keys: { level: DEBUG }
|
||||
synapse.handlers.e2e_keys: { level: DEBUG }
|
||||
synapse.rest.client.keys: { level: DEBUG }
|
||||
synapse.handlers.e2e_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
|
||||
@@ -86,6 +86,7 @@ use it as the primary client artifact and DevTools as the raw backup.
|
||||
/var/log/matrix-synapse/homeserver.log | grep "<user_id>"
|
||||
```
|
||||
- **Synapse SQL (LXC 109) — what the server thinks it holds:**
|
||||
|
||||
```sql
|
||||
-- Current OTK inventory for the device (compare key_id set against the
|
||||
-- request body the client keeps retrying).
|
||||
@@ -106,8 +107,10 @@ use it as the primary client artifact and DevTools as the raw backup.
|
||||
FROM e2e_fallback_keys_json
|
||||
WHERE user_id = '@user:matrix.lotusguild.org' AND device_id = '<DEVICE_ID>';
|
||||
```
|
||||
|
||||
> Table names are Synapse 1.155 (`e2e_one_time_keys_json`,
|
||||
> `e2e_fallback_keys_json`). If a name is absent, list with `\dt e2e*` in psql.
|
||||
|
||||
- **Confirms:** if the offending `key_id` (from the 400) is **present** in
|
||||
`e2e_one_time_keys_json` with a **different** stored value than the client's
|
||||
request body → OTK state has diverged (rust-crypto store vs Synapse). That is
|
||||
@@ -132,6 +135,7 @@ use it as the primary client artifact and DevTools as the raw backup.
|
||||
/var/log/matrix-synapse/homeserver.log | grep -E "<user_id>|<participant_id>"
|
||||
```
|
||||
- **Synapse SQL (LXC 109) — undelivered / queued to-device events:**
|
||||
|
||||
```sql
|
||||
-- Backlog of to-device messages queued for the affected device. A growing
|
||||
-- count here = the HS has the media-key events but the device isn't draining
|
||||
@@ -145,6 +149,7 @@ use it as the primary client artifact and DevTools as the raw backup.
|
||||
SELECT device_id, display_name, last_seen, ts
|
||||
FROM devices WHERE user_id = '@user:matrix.lotusguild.org';
|
||||
```
|
||||
|
||||
- **Confirms:** to-device events present but undecryptable (client shows the
|
||||
`io.element.call.encryption_keys` "unexpected encrypted" warning) ⇒ there is
|
||||
**no valid Olm session** to decrypt them — the expected downstream of KE-1.
|
||||
@@ -265,7 +270,7 @@ Q4. KE-4: do delayed-event timeouts coincide with Synapse p99 latency spikes
|
||||
> "fix" before the planning session** — they are listed so evidence collection
|
||||
> can be paired with a recovery plan. Confirm the root condition (Q1/Q2) first.
|
||||
|
||||
1. **Per-device logout + re-login of the affected device** *(lowest blast radius)*
|
||||
1. **Per-device logout + re-login of the affected device** _(lowest blast radius)_
|
||||
- **What:** log the one glitching device out and back in. Forces a fresh
|
||||
device id, fresh device keys, and a clean OTK batch — sidesteps a diverged
|
||||
OTK store without touching other sessions.
|
||||
@@ -275,7 +280,7 @@ Q4. KE-4: do delayed-event timeouts coincide with Synapse p99 latency spikes
|
||||
- **Confirms/uses:** if KE-1 stops after this, OTK-store divergence (Q1) was
|
||||
the cause.
|
||||
|
||||
2. **Client crypto-store reset (`clearLoginData` path)** *(medium)*
|
||||
2. **Client crypto-store reset (`clearLoginData` path)** _(medium)_
|
||||
- **What:** `clearLoginData()` in `src/client/initMatrix.ts` (coordinator's
|
||||
file — do not edit) **deletes ALL IndexedDB databases** (incl.
|
||||
`web-sync-store` and the rust-crypto store `crypto-store`), **unregisters
|
||||
@@ -294,16 +299,16 @@ Q4. KE-4: do delayed-event timeouts coincide with Synapse p99 latency spikes
|
||||
- **Use when:** the rust-crypto store itself is corrupt/diverged and option 1
|
||||
didn't clear it.
|
||||
|
||||
3. **SDK pin change off the RC** *(medium — codebase change, needs rebuild)*
|
||||
3. **SDK pin change off the RC** _(medium — codebase change, needs rebuild)_
|
||||
- **Current pin:** `package.json` → `"matrix-js-sdk": "41.6.0-rc.0"` (a
|
||||
release candidate).
|
||||
- **Finding (npm / GitHub changelog, checked 2026-07):** stable **`41.6.0`**
|
||||
was released **2026-05-26**. Its only changelog line is *"Throw sane error
|
||||
on completeLoginOnNewDevice IdP rejection"* — **no OTK / keys-upload / Olm /
|
||||
was released **2026-05-26**. Its only changelog line is _"Throw sane error
|
||||
on completeLoginOnNewDevice IdP rejection"_ — **no OTK / keys-upload / Olm /
|
||||
to-device fix** relative to the RC. Later stable lines exist
|
||||
(`41.7.0`, `41.8.0`; `41.7.0-rc.3` / `41.9.0-rc.0` seen as pre-releases).
|
||||
Nearby crypto-relevant entries: `41.5.0` *"Enable encrypted history sharing
|
||||
by default"*; `41.4.0` key-backup handling. **No changelog entry directly
|
||||
Nearby crypto-relevant entries: `41.5.0` _"Enable encrypted history sharing
|
||||
by default"_; `41.4.0` key-backup handling. **No changelog entry directly
|
||||
addresses the KE-1 OTK-conflict symptom** in the immediate range — so
|
||||
moving RC→`41.6.0` stable is a low-risk hygiene step but is **not expected
|
||||
to fix KE-1 by itself**. Before pinning, re-read the CHANGELOG for any
|
||||
@@ -312,7 +317,7 @@ Q4. KE-4: do delayed-event timeouts coincide with Synapse p99 latency spikes
|
||||
rust-crypto IndexedDB schema — a downgrade triggers the `IDB_VERSION_CONFLICT`
|
||||
path in `initMatrix.ts`.
|
||||
|
||||
4. **Synapse-side OTK row surgery** *(LAST RESORT — highest danger)*
|
||||
4. **Synapse-side OTK row surgery** _(LAST RESORT — highest danger)_
|
||||
- **What:** deleting/rewriting rows in `e2e_one_time_keys_json` (and/or
|
||||
`e2e_fallback_keys_json`, `device_inbox`) for the affected device to force
|
||||
the client to re-upload a clean batch.
|
||||
@@ -324,7 +329,7 @@ Q4. KE-4: do delayed-event timeouts coincide with Synapse p99 latency spikes
|
||||
- **Guardrails if ever done (planning session + HS owner only):** full
|
||||
`pg_dump` of `synapse` first; do it during **zero active calls**; delete only
|
||||
the exact diverged `key_id` for the exact `device_id`; `systemctl restart
|
||||
matrix-synapse` to flush caches; then log the device out/in (option 1) so it
|
||||
matrix-synapse` to flush caches; then log the device out/in (option 1) so it
|
||||
republishes. **Never** run this speculatively.
|
||||
|
||||
---
|
||||
@@ -337,7 +342,7 @@ Do these **in order**. Aim to have client + server capturing the **same call**.
|
||||
`tail -F /var/log/matrix-synapse/homeserver.log | tee /tmp/lotus-call-$(date +%s).log`.
|
||||
(Optionally raise the `synapse.rest.client.keys` / `handlers.e2e_keys` /
|
||||
`handlers.devicemessage` loggers to DEBUG per §0 and `systemctl reload
|
||||
matrix-synapse` — remember to revert after.)
|
||||
matrix-synapse` — remember to revert after.)
|
||||
2. **Prep client:** open Lotus Chat → Settings → Developer Tools → **enable
|
||||
Developer Tools** so the **Crypto Diagnostics** card is visible; note its
|
||||
entry count starts at (or reset by reload to) 0.
|
||||
@@ -373,7 +378,7 @@ Do these **in order**. Aim to have client + server capturing the **same call**.
|
||||
with pass-through wrappers (originals always called) that ring-buffer (max
|
||||
**200**) any line matching the KE signatures. No network, no timers.
|
||||
- `getCryptoDiagEntries()` — snapshot copy of the buffer (`{ ts, level, ke,
|
||||
signature, message }`, most-recent-last).
|
||||
signature, message }`, most-recent-last).
|
||||
- `buildCryptoDiagReport(mx)` — JSON string: SDK version, device id, user id,
|
||||
sync state, `cryptoReady` (`mx.getCrypto()` presence), per-KE counts, and the
|
||||
entry buffer. No tokens/PII beyond those ids; captured log lines are retained
|
||||
|
||||
@@ -1179,6 +1179,18 @@ Three one-tap presets at the top of **Settings → Notifications** that apply a
|
||||
|
||||
---
|
||||
|
||||
## Accessibility (P3-4)
|
||||
|
||||
WCAG 2.1 AA hardening of the golden path (find room → read → reply → send) for keyboard and screen-reader users.
|
||||
|
||||
- **Timeline for screen readers:** each message is `role="article"`; **collapsed messages announce their sender + time** (they drop the visible header, so AT would otherwise hear the body with no attribution). The timeline is a `role="log"` `aria-live="polite"` region so new messages are announced; emoji/emoticons carry text labels.
|
||||
- **Live status:** typing indicators announce via a `role="status"` region; editing a message announces "Editing message from <sender>".
|
||||
- **Forms & overlays:** all inputs have associated labels (visible `<label htmlFor>` or `aria-label`); the Media Gallery and Search overlays are named.
|
||||
- **Focus management:** skip-to-content link + `nav`/`main` landmarks; genuine dialogs return focus to their trigger on close (inline popouts intentionally keep focus in context).
|
||||
- **Keyboard-shortcuts help:** press <kbd>?</kbd> for a dialog of the existing shortcuts (Escape, type-to-focus composer, Enter/Shift+Enter send, message actions).
|
||||
- **Regression gate:** a curated `eslint-plugin-jsx-a11y` rule set (ARIA correctness + label association) runs in CI. Files: `components/message/*`, `features/room/RoomViewTyping.tsx`, `features/shortcuts/*`, `utils/a11y.ts`, `eslint.config.mjs`.
|
||||
- _Known limitation:_ list virtualization keeps far-scrolled history out of the a11y tree (perf trade-off); newly-arriving messages are announced.
|
||||
|
||||
## Infrastructure
|
||||
|
||||
### Authenticated Media
|
||||
|
||||
+96
-6
@@ -1,6 +1,6 @@
|
||||
# Lotus Chat — Manual Testing Guide
|
||||
|
||||
**Generated:** June 2026
|
||||
**Generated:** June 2026 · **Updated:** July 2026 (added §O — threads, per-thread notifications, math, search cache, session hardening, audit wave, desktop CSP)
|
||||
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
|
||||
|
||||
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
|
||||
@@ -573,10 +573,100 @@ Log into **matrix.lotusguild.org** (password) and **matrix.org**.
|
||||
|
||||
---
|
||||
|
||||
## O. July 2026 batch — threads, notifications, math, search cache, audit wave
|
||||
|
||||
Everything landed after the OIDC work. These mirror the checklists in `LOTUS_TODO.md` (§P3-8, §P4-1) and the Needs-Verification rows in `LOTUS_BUGS.md` (P3-8/P4-1/P4-4/P4-8/N97a/AW-1…4). **⚠️ Threads change the main timeline** — thread replies no longer render inline; that's intended (see O1).
|
||||
|
||||
### O1. Thread Panel (P3-8) — 👥 2 people help for live replies
|
||||
|
||||
1. Hover a message → **Reply in Thread** (message menu). The right-side **thread panel** opens with that message as the root.
|
||||
2. Send text, an emoji, and a file upload into the thread; have the second person reply too.
|
||||
3. Reply to a reply _inside_ the panel.
|
||||
|
||||
**Expected:** the panel shows the root at top + an "N replies" divider + the reply timeline (own composer at the bottom). Your sends appear immediately (pending → confirmed). A reply-to-a-reply is a proper thread reply. In the **main** timeline the replies do **not** appear inline — the root message instead shows a **"N replies · time"** chip. Clicking the chip (or a reply's thread indicator) opens the panel. **×** or **Escape** closes it; on mobile the panel is fullscreen. Scrolled up in a long thread → a **Jump to Latest** chip appears. Reload the page → the root/reply split persists; in an **encrypted** room the thread replies decrypt (not "Unable to decrypt").
|
||||
|
||||
### O2. Per-thread notifications (P4-1, Slack-style) — 👥 2 people
|
||||
|
||||
1. Have the second person reply in a thread **you have posted in** → expect a notification + sound.
|
||||
2. Have them reply in a thread **you have never touched** and don't @mention you → expect **silence** (only the chip's unread badge updates).
|
||||
3. Have them **@mention** you in any thread → expect a notification regardless of participation.
|
||||
4. Open the panel's **bell menu** (header) → set the thread to **Mute** → expect no notifications, the chip's unread badge gone (bell-mute glyph shown), and the room's **sidebar badge drops** by that thread's count. Try **All** (every reply notifies) and **Mentions only** (only @mentions).
|
||||
5. On a **second device**, confirm the same per-thread modes are set (they sync via account data).
|
||||
6. Room-level **Mute** (room context menu) still silences everything, including thread overrides.
|
||||
|
||||
**Known caveat:** Mentions-only can under-notify in E2EE rooms (the decision runs before decryption). Muted-thread badge subtraction is Lotus-only.
|
||||
|
||||
### O3. Math / LaTeX (P4-4)
|
||||
|
||||
Send each and confirm rendering: `$x^2 + y^2$` (inline), `$$\int_0^1 f(x)\,dx$$` (block, centered), `$5 and $10 for lunch` (**stays plain text** — currency guard), and a code block containing `$x$` (**stays literal** inside the code block). **Expected:** the first two render as math (KaTeX); the last two are untouched. First math of the session may show the raw `$…$` for a beat while the KaTeX chunk lazy-loads, then renders.
|
||||
|
||||
### O4. Encrypted search cache (P4-8) — opt-in
|
||||
|
||||
In an **encrypted** room's message search, enable **"Persist search index on this device"** (Encrypted Rooms panel). Search, then **reload** and search the same term. **Expected:** coverage survives the reload (results without re-paginating everything). **Clear cached index** empties it. **Log out** → the cache is wiped (privacy). Toggling the setting OFF does **not** wipe (only Clear/logout do).
|
||||
|
||||
### O5. Session hardening (N97a) — cross-tab
|
||||
|
||||
1. Log in on a build that predates the change, then load this build → you stay logged in (legacy keys migrate to the `cinny_session_v1` blob; check DevTools → Application → Local Storage).
|
||||
2. Open the app in **two tabs**; **log out** in tab A → tab B reloads to the auth screen within a moment. Log in again in one tab → the other reloads too.
|
||||
|
||||
### O6. Audit-wave correctness fixes (AW-1)
|
||||
|
||||
- **Scheduled-message cancel:** schedule a message, then cancel it **with the network cut** (DevTools offline) → the item **stays** with an inline error (it does **not** silently disappear and still send). Restore network, retry → cancels cleanly.
|
||||
- **Escape coordination:** in a thread panel, open the mention autocomplete or set a reply draft, press **Escape** → it dismisses the autocomplete/reply **without** closing the panel. A bare Escape (nothing to dismiss) still marks the room read / closes the panel as before.
|
||||
- **Panel exclusivity:** on mobile, opening a thread while the media gallery (or members drawer) is open shows only **one** right panel (thread wins), not stacked fullscreen overlays.
|
||||
- **Emoji board (AW-2):** the **first** time you open the emoji board / autocomplete in a session, the grid **and search** populate with unicode emoji (they don't stay empty). Reactions still show a label.
|
||||
|
||||
### O7. Desktop (Tauri) — CSP tighten + native stack (AW-4) — 🖥️ desktop build only
|
||||
|
||||
The webview CSP was tightened and the full native module set now compiles. Smoke-test the desktop build:
|
||||
|
||||
1. App **boots**, avatars + media thumbnails load, the **VT323** terminal font renders (Lotus Terminal theme), a **location message** embeds its OpenStreetMap map, **calls** connect (EC iframe), **deep links** (`matrix:` / clicking a room link) navigate.
|
||||
2. **Native features:** minimize to tray (notifications still arrive), a message notification is a **rich toast** (click opens the room; reply box sends), the taskbar **Jump List** lists recent rooms, in a call the taskbar thumbnail shows **Mute/Deafen/End**, Windows **Focus Assist** silences Lotus.
|
||||
3. **Console** (desktop devtools) shows **no CSP violations** during normal use. If something visual/media is blocked, that's the CSP to loosen — note exactly what and where.
|
||||
|
||||
### O8. E2EE / call-key cluster (KE-1→4) — 👥 2 people, during a real call
|
||||
|
||||
We shipped the diagnostics kit + a **Crypto Diagnostics** card (**Settings → Developer Tools**). During your next call that glitches (audio cutouts, "Unable to decrypt"), open it and **Download report**, and note whether the symptoms even still occur now that we're on **matrix-js-sdk 41.7.0** (crypto-wasm 18.3.1). Send me the report + `LOTUS_E2EE_INVESTIGATION.md` is the runbook.
|
||||
|
||||
---
|
||||
|
||||
## P. Accessibility (P3-4) — needs a browser + a screen reader
|
||||
|
||||
The compliance fixes are gate-verified in code; these confirm the runtime a11y behavior only a human + AT can check. Tools: browser DevTools "axe" extension / Lighthouse a11y, plus **VoiceOver** (macOS ⌘F5) or **NVDA** (Windows).
|
||||
|
||||
### P1. Keyboard-only golden path (no mouse)
|
||||
|
||||
Tab from page load: **skip-to-content** link appears first (Enter jumps to the timeline). Tab reaches the room list (rooms are focusable, active room announced), open a room (Enter), type a character → focus lands in the composer, send with Enter (or Shift+Enter per your `enterForNewline` setting). No keyboard trap; visible focus ring throughout.
|
||||
|
||||
### P2. `?` shortcuts dialog
|
||||
|
||||
Press **?** (Shift+/) with focus NOT in a text field → the keyboard-shortcuts dialog opens, is focus-trapped, Escape closes it and focus returns to where you were. Pressing `?` while typing in the composer/search inserts a literal `?` (does NOT open the dialog).
|
||||
|
||||
### P3. Screen-reader: reading messages
|
||||
|
||||
With VoiceOver/NVDA on, arrow through the timeline: each message is announced as an article with **sender name + time** — critically, this includes **collapsed messages** (consecutive messages from the same person), which previously announced only the body with no sender. Reactions, "edited", replies, and delivery status are announced with labels.
|
||||
|
||||
### P4. Screen-reader: live announcements
|
||||
|
||||
- **New message** arrives while you're reading → announced (polite).
|
||||
- **Someone starts typing** → "X is typing" announced once (not spammed per keystroke).
|
||||
- **Editing a message** → the edit box announces "Editing message from X".
|
||||
|
||||
### P5. Focus return from dialogs
|
||||
|
||||
Open then close (Escape or ×): the **room topic viewer**, a **reaction viewer** (click a reaction count), and **Search** → focus returns to the button/element you opened them from (not lost to `<body>`). Inline popouts (emoji picker, autocomplete, hover menus) intentionally keep focus in context — that's expected, not a bug.
|
||||
|
||||
### P6. axe / Lighthouse scan
|
||||
|
||||
Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view, Settings, and the login screen. Expect **no critical/serious** "missing accessible name" or "ARIA" violations on the golden path. Report any that appear (note: far-scrolled timeline history being virtualized out is a known, accepted limitation — not a finding).
|
||||
|
||||
---
|
||||
|
||||
## Priority if you're short on time
|
||||
|
||||
1. **A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce.
|
||||
2. **B1–B3** (polls on a default theme) — the confirmed visual bug.
|
||||
3. **D** (EC 0.20.1 control sweep) — guards against the upstream merge breaking calls.
|
||||
4. **A7** false-positive check (normal joins don't show the error overlay).
|
||||
5. Everything else.
|
||||
1. **O1 + O2** (threads + per-thread notifications) — the largest new surface; the main-timeline change is user-visible.
|
||||
2. **O7** (desktop CSP smoke) — CI can't catch CSP breakage; a wrong directive silently breaks media/fonts/maps.
|
||||
3. **O5** (session cross-tab) + **O6** (scheduled-cancel ghost-send) — auth-critical + a real data-loss-class fix.
|
||||
4. **A4** (in-call banner) + **A3** (ringtone) — newest call logic, hardest to reproduce.
|
||||
5. **D** (EC control sweep) — guards against the fork breaking calls.
|
||||
6. Everything else.
|
||||
|
||||
+24
-15
@@ -141,7 +141,12 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
|
||||
|
||||
## Priority 3 — Higher complexity / lower daily frequency
|
||||
|
||||
### [ ] P3-4 · Accessibility Improvements (WCAG 2.1 AA)
|
||||
### [~] P3-4 · Accessibility Improvements (WCAG 2.1 AA) — COMPLIANCE PASS DONE (2026-07), ⚠️ AWAITING LIVE AXE/SR AUDIT
|
||||
|
||||
**Shipped (compliance + shortcuts-help tier):** messages `role="article"` + collapsed-message sender/time announced to AT (the biggest gap — collapsed rows had no sender for a screen reader); ~10 unlabeled form inputs + Media Gallery / Search overlays named; emoji/emoticon aria-labels; typing indicator now announced via a `role="status"` live region; editing a message announces "Editing message from X"; focus now returns to the trigger on close of 4 genuine dialogs (RoomIntro/Reactions/RoomViewHeader-topic/Search — inline popouts correctly left); a `?` keyboard-shortcuts help dialog; and a **jsx-a11y lint gate** (curated ARIA-correctness + label rules, enforced in CI) to prevent regressions. Already-good before this pass: skip link + landmarks, timeline `role="log"`/`aria-live`, ~99% icon-button labels, labeled editor.
|
||||
**DEFERRED (documented):** virtualization keeps scrolled-away history out of the a11y tree (architectural; the live-region announces newly-arriving messages) — not re-architected to avoid perf regression; roving-tabindex + command palette + section-jump shortcuts (user-deferred); the live axe-core / VoiceOver+NVDA audit → LOTUS_TESTING §P.
|
||||
|
||||
_Original scope (for reference):_
|
||||
|
||||
**What:** Comprehensive audit and fix pass targeting the critical user paths:
|
||||
|
||||
@@ -167,13 +172,14 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
|
||||
Built per the design below (4-agent build + 2-agent review). Gates green (tsc/eslint/build/tests). **Release note: threaded replies no longer render inline in the main timeline — roots show a "N replies" chip that opens the panel.**
|
||||
|
||||
**Manual QA checklist (post-deploy):**
|
||||
|
||||
1. Reply in Thread (message menu) → panel opens; send text/upload/emoji into it (appears pending → confirmed)
|
||||
2. Reply to a reply inside the panel → event carries `m.thread` + `m.in_reply_to` with `is_falling_back:false`
|
||||
3. Main timeline: root + chip only (replies absent); chip count/time updates live; unread badge appears for others' thread replies and clears after viewing the panel
|
||||
4. Room badge clears via normal markAsRead even with unread threads (unthreaded receipt)
|
||||
5. Reload: partitioning persists; encrypted-room threads decrypt (back-pagination too)
|
||||
6. Escape / × closes; mobile = fullscreen panel; switching rooms and back restores the open thread
|
||||
**What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies.
|
||||
**What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies.
|
||||
|
||||
Features:
|
||||
|
||||
@@ -209,23 +215,23 @@ Features:
|
||||
**What:** Replace the manual "load more" button with an automated, virtualized infinite scroll for search results.
|
||||
**Status:** Done in a prior session — `MessageSearch.tsx` already uses `useVirtualizer` (~line 336) over the result groups AND auto-fetches the `nextToken` page when the last virtual item scrolls into view (~line 469) via `useInfiniteQuery`. Nothing left to build.
|
||||
|
||||
### [ ] P4-8 · Encrypted Message Search Indexing & Caching
|
||||
### [~] P4-8 · Encrypted Message Search Indexing & Caching — IMPLEMENTED (2026-07), opt-in
|
||||
|
||||
**What:** Implement a persistent local cache for search results, optimized for encrypted rooms.
|
||||
**Approach:** Use `IndexedDB` to store search metadata (event IDs, timestamps) to prevent redundant server-side decryption/fetching.
|
||||
**Shipped:** `src/app/utils/searchCache.ts` — raw-IndexedDB per-room index (`lotus-search-cache`) of decrypted search rows + coverage markers, merged into local search (in-memory-wins dedupe). **Opt-in, default OFF** (stores plaintext at rest) with a privacy note, Clear button, and logout wipe. Awaiting live QA (LOTUS_BUGS AW / P4-8 row).
|
||||
|
||||
### [~] P4-1 · Thread Notification Mode Per-Thread — IMPLEMENTED (2026-07), ⚠️ AWAITING LIVE QA
|
||||
|
||||
**Shipped (Slack-style):** default = **Participating** (notified only for threads you've posted in or where you're @mentioned); per-thread override **All / Mentions-only / Mute** via the bell menu in the thread panel header; modes sync across devices (`io.lotus.thread_notifications` account data, pruned on write). Mute also suppresses the chip badge and subtracts the thread from the room's sidebar badge (client-side). Also fixed the underlying path: thread replies are notified via exactly one handler (room-level `ThreadEvent.NewReply`), with the main-timeline notifier + unread binder thread-guarded, and live badge refresh on `RoomEvent.UnreadNotifications`.
|
||||
|
||||
**Manual QA checklist (post-deploy):**
|
||||
|
||||
1. Friend replies in a thread YOU posted in → notification + sound; in a thread you never touched → silent (chip badge only)
|
||||
2. @mention in any thread → notified regardless of participation
|
||||
3. Set a thread to Mute → no notifications, chip badge gone (bell-mute glyph), room sidebar badge drops by that thread's count
|
||||
4. Set to All → every reply notifies; Mentions-only → only @mentions
|
||||
5. Second device shows the same per-thread modes (account-data sync)
|
||||
6. Room-level Mute still silences everything incl. thread overrides
|
||||
**Known caveats:** Mentions-only can under-notify in E2EE rooms (decision runs pre-decryption — same class as the existing notifier); muted-thread badge subtraction is Lotus-only (other clients still count them).
|
||||
**Known caveats:** Mentions-only can under-notify in E2EE rooms (decision runs pre-decryption — same class as the existing notifier); muted-thread badge subtraction is Lotus-only (other clients still count them).
|
||||
|
||||
---
|
||||
|
||||
@@ -418,10 +424,11 @@ Features:
|
||||
**Decision:** Deferred (reviewed 2026-07). Multi-sprint, and it touches the auth/crypto/storage core — not worth the risk/effort for a niche need right now. Kept here with a concrete spec so it's actionable later.
|
||||
|
||||
**Future-work spec (why it's big):** the app is currently **single-session**.
|
||||
|
||||
- Session lives in `src/app/state/sessions.ts` under fixed localStorage keys — `cinny_access_token`, `cinny_device_id`, `cinny_user_id`, `cinny_hs_base_url`, plus the OIDC keys (`cinny_refresh_token`, `cinny_expires_at`, `cinny_oidc_*`).
|
||||
- Persistence lives in `src/client/initMatrix.ts`: two fixed IndexedDB stores — `web-sync-store` (`IndexedDBStore`) and `crypto-store` (`IndexedDBCryptoStore`) — feeding one `createClient(...)`.
|
||||
|
||||
True per-context isolation would require: (1) namespace every localStorage key per context (`ctx:<id>:cinny_*`); (2) per-context IndexedDB dbNames for **both** the sync store and the crypto store; (3) a context registry + switcher UI (create/rename/delete/switch); (4) full client teardown + re-init on switch (`initMatrix` currently assumes one global client); (5) per-context settings + notification/quiet-hours state; (6) careful crypto-store isolation so device keys never bleed across contexts. **Smaller intermediate step** if demand appears: plain multi-account (fast account switch) *without* the hard isolation boundary — much less risky, reuses most of the login flow.
|
||||
True per-context isolation would require: (1) namespace every localStorage key per context (`ctx:<id>:cinny_*`); (2) per-context IndexedDB dbNames for **both** the sync store and the crypto store; (3) a context registry + switcher UI (create/rename/delete/switch); (4) full client teardown + re-init on switch (`initMatrix` currently assumes one global client); (5) per-context settings + notification/quiet-hours state; (6) careful crypto-store isolation so device keys never bleed across contexts. **Smaller intermediate step** if demand appears: plain multi-account (fast account switch) _without_ the hard isolation boundary — much less risky, reuses most of the login flow.
|
||||
**Priority:** Extreme Low (Multi-sprint/Architectural).
|
||||
|
||||
### [DROPPED] P5-52 · Desktop — Room-Level Sync Governor (Performance Control)
|
||||
@@ -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):**
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Thread rendering | **New lean `ThreadTimeline`** reusing `Message`, `useVirtualPaginator`, and RoomTimeline's exported timeline helpers (lines 156-227). Do NOT refactor 2214-line RoomTimeline (its ~35 hooks are hardwired to the room live timeline). |
|
||||
| threadSupport | **Enable `threadSupport: true`** in `initMatrix.ts` (~line 39). ⚠️ Thread replies then LEAVE the main timeline (`room.js eventShouldLiveIn` → `shouldLiveInRoom:false`), retroactively on reload — MUST ship the "N replies" summary chip in the same release. Roots stay in both timelines. |
|
||||
| State | `roomIdToActiveThreadIdAtomFamily` (per-room, mirrors `roomIdToReplyDraftAtomFamily`) in new `state/room/thread.ts` + `getThreadDraftKey(roomId, threadRootId)` = `` `${roomId}::${threadRootId}` `` |
|
||||
| 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. |
|
||||
| Mobile | Pure CSS like `MembersDrawer.css.ts`: fixed width toRem(360) desktop, `position:fixed; inset:0` under 750px. |
|
||||
| Question | Decision |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Thread rendering | **New lean `ThreadTimeline`** reusing `Message`, `useVirtualPaginator`, and RoomTimeline's exported timeline helpers (lines 156-227). Do NOT refactor 2214-line RoomTimeline (its ~35 hooks are hardwired to the room live timeline). |
|
||||
| threadSupport | **Enable `threadSupport: true`** in `initMatrix.ts` (~line 39). ⚠️ Thread replies then LEAVE the main timeline (`room.js eventShouldLiveIn` → `shouldLiveInRoom:false`), retroactively on reload — MUST ship the "N replies" summary chip in the same release. Roots stay in both timelines. |
|
||||
| State | `roomIdToActiveThreadIdAtomFamily` (per-room, mirrors `roomIdToReplyDraftAtomFamily`) in new `state/room/thread.ts` + `getThreadDraftKey(roomId, threadRootId)` = `` `${roomId}::${threadRootId}` `` |
|
||||
| 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. |
|
||||
| Mobile | Pure CSS like `MembersDrawer.css.ts`: fixed width toRem(360) desktop, `position:fixed; inset:0` under 750px. |
|
||||
|
||||
**Critical side-effect fixes (one-liners, land FIRST):**
|
||||
|
||||
1. `initMatrix.ts` → `threadSupport: true`.
|
||||
2. `utils/notifications.ts:24` → `sendReadReceipt(latestEvent, type, /*unthreaded*/ true)` — otherwise markAsRead becomes `main`-scoped and room badges stick permanently unread (room unread total includes thread counts).
|
||||
|
||||
**Known SDK traps (verified):**
|
||||
|
||||
- **Local echo gap:** chronological pending ordering means the thread timelineSet never receives pending events (`canContain` rejects; `room.getPendingEvents()` THROWS in this mode) — ThreadTimeline must render its own pending strip via `RoomEvent.LocalEchoUpdated` filtering on `threadRootId`, deduped against `thread.findEventById`.
|
||||
- **Bootstrap:** `room.getThread(id) ?? room.createThread(id, room.findEventById(id), [], false)` — the SDK auto-fetches via `/relations` and inserts the root at top; gate rendering on `thread.initialEventsFetched`; decrypt with `decryptAllTimelineEvent` after init + each pagination.
|
||||
- **Deep links:** `getEventTimeline(mainSet, threadEventId)` returns undefined for thread events — redirect jump-to-event to the panel (best-effort v1).
|
||||
|
||||
@@ -5,11 +5,11 @@ experimental_features:
|
||||
msc3861:
|
||||
enabled: true
|
||||
issuer: http://localhost:8090/
|
||||
client_id: "0000000000000000000SYNAPSE"
|
||||
client_id: '0000000000000000000SYNAPSE'
|
||||
client_auth_method: client_secret_basic
|
||||
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET"
|
||||
admin_token: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN"
|
||||
account_management_url: "http://localhost:8090/account"
|
||||
client_secret: 'REPLACE_WITH_A_SHARED_CLIENT_SECRET'
|
||||
admin_token: 'REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN'
|
||||
account_management_url: 'http://localhost:8090/account'
|
||||
|
||||
# With msc3861 enabled, Synapse disables its own password/SSO login and advertises
|
||||
# `m.authentication` in /.well-known/matrix/client — which is exactly what the
|
||||
|
||||
+28
-1
@@ -25,7 +25,7 @@ export default [
|
||||
tsPlugin.configs['flat/eslint-recommended'],
|
||||
...tsPlugin.configs['flat/recommended'],
|
||||
reactPlugin.configs.flat.recommended,
|
||||
reactHooksPlugin.configs.flat['recommended'],
|
||||
reactHooksPlugin.configs.flat.recommended,
|
||||
// Register jsx-a11y plugin (rules selectively enabled below)
|
||||
{ plugins: { 'jsx-a11y': jsxA11yPlugin } },
|
||||
// airbnb-base via FlatCompat (JS/import rules; no React plugin, no getFilename issue)
|
||||
@@ -115,6 +115,26 @@ export default [
|
||||
'jsx-a11y/media-has-caption': 'off',
|
||||
'jsx-a11y/no-noninteractive-element-interactions': 'off',
|
||||
'jsx-a11y/alt-text': 'off',
|
||||
// A11y regression gate (P3-4). A CURATED set — correctness rules that catch
|
||||
// real WCAG gaps (missing accessible names, malformed ARIA) without
|
||||
// flooding on the pre-existing clickable-div patterns. The heavier
|
||||
// interaction rules (no-static-element-interactions,
|
||||
// click-events-have-key-events) are a separate cleanup and stay OFF.
|
||||
'jsx-a11y/aria-props': 'error',
|
||||
'jsx-a11y/aria-proptypes': 'error',
|
||||
'jsx-a11y/aria-role': ['error', { ignoreNonDOM: true }],
|
||||
'jsx-a11y/aria-unsupported-elements': 'error',
|
||||
'jsx-a11y/role-has-required-aria-props': 'error',
|
||||
'jsx-a11y/role-supports-aria-props': 'error',
|
||||
'jsx-a11y/no-redundant-roles': 'error',
|
||||
'jsx-a11y/anchor-has-content': 'error',
|
||||
'jsx-a11y/heading-has-content': 'error',
|
||||
'jsx-a11y/label-has-associated-control': ['error', { assert: 'either', depth: 5 }],
|
||||
// NOT enabled: control-has-associated-label. This repo labels most inputs
|
||||
// with folds `<Text as="label" htmlFor>` — a component the rule's static
|
||||
// analysis can't see as a <label>, producing false positives on correctly
|
||||
// labeled controls. The genuinely-unlabeled controls it surfaced (sliders,
|
||||
// file input, media players, notes) were fixed directly with aria-label.
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -123,4 +143,11 @@ export default [
|
||||
'no-undef': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Test files commonly define several small mock/fake classes.
|
||||
files: ['**/*.test.ts', '**/*.test.tsx'],
|
||||
rules: {
|
||||
'max-classes-per-file': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
Generated
+20
-40
@@ -24,7 +24,6 @@
|
||||
"@tanstack/react-query": "5.100.13",
|
||||
"@tanstack/react-query-devtools": "5.100.13",
|
||||
"@tanstack/react-virtual": "3.13.25",
|
||||
"@types/dompurify": "3.2.0",
|
||||
"@workadventure/noise-suppression": "0.0.4",
|
||||
"await-to-js": "3.0.0",
|
||||
"badwords-list": "2.0.1-4",
|
||||
@@ -36,7 +35,6 @@
|
||||
"dayjs": "1.11.20",
|
||||
"deepfilternet3-noise-filter": "1.2.1",
|
||||
"domhandler": "6.0.1",
|
||||
"dompurify": "3.4.5",
|
||||
"emojibase": "17.0.0",
|
||||
"emojibase-data": "17.0.0",
|
||||
"file-saver": "2.0.5",
|
||||
@@ -54,7 +52,7 @@
|
||||
"katex": "0.16.11",
|
||||
"linkify-react": "4.3.3",
|
||||
"linkifyjs": "4.3.3",
|
||||
"matrix-js-sdk": "41.6.0-rc.0",
|
||||
"matrix-js-sdk": "41.7.0",
|
||||
"matrix-widget-api": "1.17.0",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "5.7.284",
|
||||
@@ -75,7 +73,8 @@
|
||||
"slate-history": "0.113.1",
|
||||
"slate-react": "0.124.2",
|
||||
"styled-components": "6.4.2",
|
||||
"ua-parser-js": "2.0.10"
|
||||
"ua-parser-js": "2.0.10",
|
||||
"workbox-precaching": "7.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
|
||||
@@ -2697,9 +2696,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
"version": "18.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.0.tgz",
|
||||
"integrity": "sha512-9a4feyt8QLysARu7PHKaRWT+wcCd+IYH074LXp9QK5WqfN4zUXueRhiSSMNT18Bm+8q3sBR/4zxDxOSDR0M8Kg==",
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.1.tgz",
|
||||
"integrity": "sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
@@ -3920,16 +3919,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
|
||||
"integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
|
||||
"deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dompurify": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -4051,7 +4040,7 @@
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"devOptional": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/ua-parser-js": {
|
||||
"version": "0.7.39",
|
||||
@@ -5550,12 +5539,16 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/conventional-commit-types": {
|
||||
@@ -6196,15 +6189,6 @@
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
|
||||
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
@@ -9971,16 +9955,16 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/matrix-js-sdk": {
|
||||
"version": "41.6.0-rc.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.6.0-rc.0.tgz",
|
||||
"integrity": "sha512-FcTQyR+Nfh0ASEogYcX393hxGr1936Esg53Z+0f9O4SBsAxl1ZSkLXY3JfLZRLX9dNe38VVwQDQE6QuwnwV7Zw==",
|
||||
"version": "41.7.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.7.0.tgz",
|
||||
"integrity": "sha512-MP0xNv/VVRbshq00TE6EVo77IIXsQk0KjiVtgKV0t9j/V77a6Klt00QrrO0XykkTUsNC0+mQeBMxnx75rZO86Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^18.2.0",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^18.3.1",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^6.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
"content-type": "^2.0.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
@@ -13228,7 +13212,6 @@
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz",
|
||||
"integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/workbox-expiration": {
|
||||
@@ -13269,7 +13252,6 @@
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz",
|
||||
"integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"workbox-core": "7.4.1",
|
||||
@@ -13306,7 +13288,6 @@
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz",
|
||||
"integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"workbox-core": "7.4.1"
|
||||
@@ -13316,7 +13297,6 @@
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz",
|
||||
"integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"workbox-core": "7.4.1"
|
||||
|
||||
+3
-4
@@ -49,7 +49,6 @@
|
||||
"@tanstack/react-query": "5.100.13",
|
||||
"@tanstack/react-query-devtools": "5.100.13",
|
||||
"@tanstack/react-virtual": "3.13.25",
|
||||
"@types/dompurify": "3.2.0",
|
||||
"@workadventure/noise-suppression": "0.0.4",
|
||||
"await-to-js": "3.0.0",
|
||||
"badwords-list": "2.0.1-4",
|
||||
@@ -61,7 +60,6 @@
|
||||
"dayjs": "1.11.20",
|
||||
"deepfilternet3-noise-filter": "1.2.1",
|
||||
"domhandler": "6.0.1",
|
||||
"dompurify": "3.4.5",
|
||||
"emojibase": "17.0.0",
|
||||
"emojibase-data": "17.0.0",
|
||||
"file-saver": "2.0.5",
|
||||
@@ -79,7 +77,7 @@
|
||||
"katex": "0.16.11",
|
||||
"linkify-react": "4.3.3",
|
||||
"linkifyjs": "4.3.3",
|
||||
"matrix-js-sdk": "41.6.0-rc.0",
|
||||
"matrix-js-sdk": "41.7.0",
|
||||
"matrix-widget-api": "1.17.0",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "5.7.284",
|
||||
@@ -100,7 +98,8 @@
|
||||
"slate-history": "0.113.1",
|
||||
"slate-react": "0.124.2",
|
||||
"styled-components": "6.4.2",
|
||||
"ua-parser-js": "2.0.10"
|
||||
"ua-parser-js": "2.0.10",
|
||||
"workbox-precaching": "7.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
|
||||
|
||||
@@ -213,6 +213,7 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
|
||||
<Text size="L400">Account Data</Text>
|
||||
<Input
|
||||
variant="SurfaceVariant"
|
||||
aria-label="Account data type"
|
||||
size="400"
|
||||
radii="300"
|
||||
readOnly
|
||||
|
||||
@@ -282,7 +282,12 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
>
|
||||
{previewUrl && (
|
||||
<>
|
||||
<audio ref={previewAudioRef} src={previewUrl} onEnded={() => setPreviewPlaying(false)} />
|
||||
<audio
|
||||
ref={previewAudioRef}
|
||||
src={previewUrl}
|
||||
onEnded={() => setPreviewPlaying(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
const audio = previewAudioRef.current;
|
||||
|
||||
@@ -78,11 +78,14 @@ export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
|
||||
|
||||
return (
|
||||
<Box shrink="No" direction="Column" gap="100">
|
||||
<Text size="L400">Address (Optional)</Text>
|
||||
<Text as="label" htmlFor="create-room-alias" size="L400">
|
||||
Address (Optional)
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
Pick an unique address to make it discoverable.
|
||||
</Text>
|
||||
<Input
|
||||
id="create-room-alias"
|
||||
ref={aliasInputRef}
|
||||
onChange={handleAliasChange}
|
||||
before={
|
||||
|
||||
@@ -66,6 +66,8 @@ type CustomEditorProps = {
|
||||
maxHeight?: string;
|
||||
editor: Editor;
|
||||
placeholder?: string;
|
||||
/** Explicit accessible name for the textbox; falls back to the placeholder. */
|
||||
ariaLabel?: string;
|
||||
onKeyDown?: KeyboardEventHandler;
|
||||
onKeyUp?: KeyboardEventHandler;
|
||||
onChange?: EditorChangeHandler;
|
||||
@@ -82,6 +84,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
maxHeight = '50vh',
|
||||
editor,
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
onChange,
|
||||
@@ -139,7 +142,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
data-editable-name={editableName}
|
||||
className={css.EditorTextarea}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder ?? 'Message input'}
|
||||
aria-label={ariaLabel ?? placeholder ?? 'Message input'}
|
||||
aria-multiline="true"
|
||||
renderPlaceholder={renderPlaceholder}
|
||||
renderElement={renderElement}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo } from 'react';
|
||||
import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { Editor } from 'slate';
|
||||
import { Box, MenuItem, Text, toRem } from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
@@ -11,7 +11,7 @@ import { onTabPress } from '../../../utils/keyboard';
|
||||
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
|
||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
|
||||
import { IEmoji, emojis } from '../../../plugins/emoji';
|
||||
import { IEmoji, emojis, loadEmojiData } from '../../../plugins/emoji';
|
||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
@@ -47,13 +47,32 @@ export function EmoticonAutocomplete({
|
||||
const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
|
||||
const recentEmoji = useRecentEmoji(mx, 20);
|
||||
|
||||
// Lazily load emojibase data (see plugins/emoji `loadEmojiData`). Until it
|
||||
// resolves, `emojis` is empty and autocomplete matches only custom-emoji
|
||||
// packs; the unicode emoji list fills in once loaded.
|
||||
const [loadedEmojis, setLoadedEmojis] = useState<IEmoji[]>(() => emojis);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
loadEmojiData()
|
||||
// Fresh array reference: loadEmojiData populates the module-level array
|
||||
// IN PLACE, so state set to the same ref would bail out of re-rendering
|
||||
// and the search list would never gain the unicode emojis.
|
||||
.then((loaded) => {
|
||||
if (alive) setLoadedEmojis(loaded.emojis.slice());
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const searchList = useMemo(() => {
|
||||
const list: Array<EmoticonSearchItem> = [];
|
||||
return list.concat(
|
||||
imagePacks.flatMap((pack) => pack.getImages(ImageUsage.Emoticon)),
|
||||
emojis,
|
||||
loadedEmojis,
|
||||
);
|
||||
}, [imagePacks]);
|
||||
}, [imagePacks, loadedEmojis]);
|
||||
|
||||
const [result, search, resetSearch] = useAsyncSearch(
|
||||
searchList,
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Box, config, Icons, Scroll } from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
@@ -15,7 +16,7 @@ import { isKeyHotkey } from 'is-hotkey';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { IEmoji, emojiGroups, emojis } from '../../plugins/emoji';
|
||||
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
|
||||
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
||||
@@ -56,6 +57,33 @@ import { VirtualTile } from '../virtualizer';
|
||||
const RECENT_GROUP_ID = 'recent_group';
|
||||
const SEARCH_GROUP_ID = 'search_group';
|
||||
|
||||
/**
|
||||
* Lazily pull in the emojibase data (see plugins/emoji `loadEmojiData`). The
|
||||
* `emojis`/`emojiGroups` arrays are populated in place once the promise
|
||||
* resolves; we wrap them in a fresh object on load so React re-renders and the
|
||||
* board fills in. Before that, both are empty and the board shows only custom
|
||||
* image packs / recents (which is fleeting — the load starts on mount).
|
||||
*/
|
||||
const useEmojiData = (): EmojiData => {
|
||||
const [data, setData] = useState<EmojiData>(() => ({ emojis, emojiGroups }));
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
loadEmojiData()
|
||||
// Fresh array references (not just a fresh wrapper): downstream memos
|
||||
// depend on the arrays themselves, which are populated IN PLACE — same
|
||||
// refs would skip recompute and leave emoji search empty until remount.
|
||||
.then((loaded) => {
|
||||
if (alive)
|
||||
setData({ emojis: loaded.emojis.slice(), emojiGroups: loaded.emojiGroups.slice() });
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
return data;
|
||||
};
|
||||
|
||||
type EmojiGroupItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -75,6 +103,7 @@ const useGroups = (
|
||||
|
||||
const recentEmojis = useRecentEmoji(mx, 21);
|
||||
const labels = useEmojiGroupLabels();
|
||||
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
|
||||
|
||||
const emojiGroupItems = useMemo(() => {
|
||||
const g: EmojiGroupItem[] = [];
|
||||
@@ -99,7 +128,7 @@ const useGroups = (
|
||||
});
|
||||
});
|
||||
|
||||
emojiGroups.forEach((group) => {
|
||||
loadedEmojiGroups.forEach((group) => {
|
||||
g.push({
|
||||
id: group.id,
|
||||
name: labels[group.id],
|
||||
@@ -108,7 +137,7 @@ const useGroups = (
|
||||
});
|
||||
|
||||
return g;
|
||||
}, [mx, recentEmojis, labels, imagePacks, tab]);
|
||||
}, [mx, recentEmojis, labels, imagePacks, tab, loadedEmojiGroups]);
|
||||
|
||||
const stickerGroupItems = useMemo(() => {
|
||||
const g: StickerGroupItem[] = [];
|
||||
@@ -177,6 +206,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
||||
const usage = ImageUsage.Emoticon;
|
||||
const labels = useEmojiGroupLabels();
|
||||
const icons = useEmojiGroupIcons();
|
||||
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
|
||||
|
||||
const packLabels = useMemo(() => {
|
||||
const map = new Map<string, string | undefined>();
|
||||
@@ -234,7 +264,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
||||
}}
|
||||
>
|
||||
<SidebarDivider />
|
||||
{emojiGroups.map((group) => (
|
||||
{loadedEmojiGroups.map((group) => (
|
||||
<GroupIcon
|
||||
key={group.id}
|
||||
active={activeGroupId === group.id}
|
||||
@@ -409,13 +439,14 @@ export function EmojiBoard({
|
||||
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
|
||||
const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
|
||||
const renderItem = useItemRenderer(tab);
|
||||
const { emojis: loadedEmojis } = useEmojiData();
|
||||
|
||||
const searchList = useMemo(() => {
|
||||
let list: Array<PackImageReader | IEmoji> = [];
|
||||
list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage)));
|
||||
if (emojiTab) list = list.concat(emojis);
|
||||
if (emojiTab) list = list.concat(loadedEmojis);
|
||||
return list;
|
||||
}, [emojiTab, usage, imagePacks]);
|
||||
}, [emojiTab, usage, imagePacks, loadedEmojis]);
|
||||
|
||||
const [result, search, resetSearch] = useAsyncSearch(
|
||||
searchList,
|
||||
|
||||
@@ -200,12 +200,24 @@ export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfil
|
||||
</Box>
|
||||
</Box>
|
||||
<Box direction="Inherit" gap="100">
|
||||
<Text size="L400">Name</Text>
|
||||
<Input name="nameInput" defaultValue={meta.name} variant="Secondary" radii="300" required />
|
||||
<Text as="label" htmlFor="image-pack-name" size="L400">
|
||||
Name
|
||||
</Text>
|
||||
<Input
|
||||
id="image-pack-name"
|
||||
name="nameInput"
|
||||
defaultValue={meta.name}
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
required
|
||||
/>
|
||||
</Box>
|
||||
<Box direction="Inherit" gap="100">
|
||||
<Text size="L400">Attribution</Text>
|
||||
<Text as="label" htmlFor="image-pack-attribution" size="L400">
|
||||
Attribution
|
||||
</Text>
|
||||
<TextArea
|
||||
id="image-pack-attribution"
|
||||
name="attributionTextArea"
|
||||
defaultValue={meta.attribution}
|
||||
variant="Secondary"
|
||||
|
||||
@@ -261,9 +261,12 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
||||
gap="400"
|
||||
>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">User ID</Text>
|
||||
<Text as="label" htmlFor="invite-user-id" size="L400">
|
||||
User ID
|
||||
</Text>
|
||||
<div>
|
||||
<Input
|
||||
id="invite-user-id"
|
||||
size="500"
|
||||
ref={inputRef}
|
||||
onChange={handleSearchChange}
|
||||
@@ -334,8 +337,11 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
||||
</div>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Reason (Optional)</Text>
|
||||
<Text as="label" htmlFor="invite-reason" size="L400">
|
||||
Reason (Optional)
|
||||
</Text>
|
||||
<TextArea
|
||||
id="invite-reason"
|
||||
size="500"
|
||||
name="reasonInput"
|
||||
variant="Background"
|
||||
|
||||
@@ -108,8 +108,11 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
|
||||
</Text>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Address</Text>
|
||||
<Text as="label" htmlFor="join-address" size="L400">
|
||||
Address
|
||||
</Text>
|
||||
<Input
|
||||
id="join-address"
|
||||
size="500"
|
||||
autoFocus
|
||||
name="addressInput"
|
||||
|
||||
@@ -117,7 +117,6 @@ export const PageHeroSection = style([
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
export const PageContentCenter = style([
|
||||
DefaultReset,
|
||||
{
|
||||
|
||||
@@ -95,7 +95,6 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
clickOutsideDeactivates: true,
|
||||
onDeactivate: () => setViewTopic(false),
|
||||
escapeDeactivates: stopPropagation,
|
||||
|
||||
@@ -278,6 +278,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
||||
<Box direction="Column" gap="300">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
aria-label="Upload soundboard clip"
|
||||
type="file"
|
||||
accept={SOUNDBOARD_ACCEPT}
|
||||
multiple
|
||||
|
||||
@@ -56,6 +56,7 @@ function PreviewVideo({ fileItem }: PreviewVideoProps) {
|
||||
|
||||
return (
|
||||
<video
|
||||
aria-label="Video attachment preview"
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
width: '100%',
|
||||
|
||||
@@ -260,6 +260,7 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
|
||||
<Input
|
||||
ref={reasonInputRef}
|
||||
placeholder="Reason"
|
||||
aria-label="Moderation reason"
|
||||
size="300"
|
||||
variant="Background"
|
||||
radii="300"
|
||||
|
||||
@@ -253,6 +253,7 @@ function UserPrivateNotes({ userId }: { userId: string }) {
|
||||
)}
|
||||
</Box>
|
||||
<textarea
|
||||
aria-label="Private note about this user"
|
||||
value={draft}
|
||||
onChange={handleChange}
|
||||
maxLength={USER_NOTE_MAX_LENGTH}
|
||||
|
||||
@@ -147,6 +147,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
|
||||
<Text size="L400">Name</Text>
|
||||
<Input
|
||||
name="nameInput"
|
||||
aria-label="Power level name"
|
||||
defaultValue={tag?.name}
|
||||
placeholder="Bot"
|
||||
size="300"
|
||||
@@ -160,6 +161,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
|
||||
<Input
|
||||
defaultValue={power}
|
||||
name="powerInput"
|
||||
aria-label="Power level value"
|
||||
size="300"
|
||||
variant={typeof power === 'number' ? 'SurfaceVariant' : 'Secondary'}
|
||||
radii="300"
|
||||
|
||||
@@ -186,8 +186,8 @@ function LightboxMedia({
|
||||
)}
|
||||
{media.status === 'ok' &&
|
||||
(item.msgtype === MsgType.Video ? (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video
|
||||
aria-label="Video attachment"
|
||||
src={media.url}
|
||||
controls
|
||||
autoPlay
|
||||
@@ -261,7 +261,6 @@ function Lightbox({
|
||||
escapeDeactivates: false,
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal
|
||||
@@ -640,13 +639,15 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
className={classNames(css.MediaGalleryDrawer, ContainerColor({ variant: 'Background' }))}
|
||||
shrink="No"
|
||||
direction="Column"
|
||||
role="region"
|
||||
aria-labelledby="media-gallery-title"
|
||||
>
|
||||
{/* Header */}
|
||||
<Header variant="Background" size="600" className={css.MediaGalleryHeader}>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Icon size="200" src={Icons.Photo} />
|
||||
<Box grow="Yes">
|
||||
<Text size="H4" truncate>
|
||||
<Text id="media-gallery-title" size="H4" truncate>
|
||||
Media Gallery
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -142,7 +142,6 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||
placeholder="Ask a question…"
|
||||
value={question}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuestion(e.target.value)}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
/>
|
||||
</Box>
|
||||
@@ -151,7 +150,6 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="L400">Options</Text>
|
||||
{options.map((opt, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Box key={index} alignItems="Center" gap="200">
|
||||
<Input
|
||||
style={{ flex: 1 }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { Box, Line } from 'folds';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
@@ -49,15 +49,46 @@ export function Room() {
|
||||
useCallback(
|
||||
(evt) => {
|
||||
if (isKeyHotkey('escape', evt)) {
|
||||
// Skip when a composer already consumed Escape (it preventDefaults).
|
||||
if (evt.defaultPrevented) return;
|
||||
// Skip while a thread panel is open: listener registration order
|
||||
// means this can run BEFORE the panel's own Escape handler, and the
|
||||
// user's intent there is "close the panel", not "mark room read".
|
||||
if (activeThreadId) return;
|
||||
markAsRead(mx, room.roomId, hideActivity);
|
||||
}
|
||||
},
|
||||
[mx, room.roomId, hideActivity],
|
||||
[mx, room.roomId, hideActivity, activeThreadId],
|
||||
),
|
||||
);
|
||||
|
||||
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
|
||||
|
||||
// Thread panel and media gallery are mutually exclusive on every screen size:
|
||||
// opening one closes the other. Detect the just-opened transition so whichever
|
||||
// was opened most recently wins.
|
||||
const prevThreadRef = useRef(activeThreadId);
|
||||
const prevGalleryRef = useRef(galleryOpen);
|
||||
useEffect(() => {
|
||||
const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current;
|
||||
const galleryJustOpened = galleryOpen && !prevGalleryRef.current;
|
||||
if (threadJustOpened && galleryOpen) {
|
||||
setGalleryOpen(false);
|
||||
} else if (galleryJustOpened && activeThreadId) {
|
||||
setActiveThreadId(null);
|
||||
}
|
||||
prevThreadRef.current = activeThreadId;
|
||||
prevGalleryRef.current = galleryOpen;
|
||||
}, [activeThreadId, galleryOpen, setGalleryOpen, setActiveThreadId]);
|
||||
|
||||
// On non-desktop screens at most one right-side panel may show, priority
|
||||
// thread > gallery > members. On desktop thread + members may coexist while
|
||||
// thread + gallery stay mutually exclusive (via the effect above).
|
||||
const isDesktop = screenSize === ScreenSize.Desktop;
|
||||
const showThreadPanel = !callView && Boolean(activeThreadId);
|
||||
const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId);
|
||||
const showMembers = !callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen));
|
||||
|
||||
return (
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
<Box grow="Yes">
|
||||
@@ -86,7 +117,7 @@ export function Room() {
|
||||
<CallChatView />
|
||||
</>
|
||||
)}
|
||||
{!callView && galleryOpen && (
|
||||
{showGallery && (
|
||||
<>
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
@@ -94,7 +125,7 @@ export function Room() {
|
||||
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
|
||||
</>
|
||||
)}
|
||||
{!callView && activeThreadId && (
|
||||
{showThreadPanel && activeThreadId && (
|
||||
<>
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
@@ -107,7 +138,7 @@ export function Room() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!callView && isDrawer && (
|
||||
{showMembers && (
|
||||
<>
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
|
||||
@@ -679,15 +679,23 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
submit();
|
||||
}
|
||||
if (isKeyHotkey('escape', evt)) {
|
||||
evt.preventDefault();
|
||||
// Only consume Escape (and stop it bubbling to the thread panel / room
|
||||
// window handlers) when the composer actually has something to dismiss.
|
||||
// If we did nothing, let Escape propagate so those handlers can run.
|
||||
if (autocompleteQuery) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
setAutocompleteQuery(undefined);
|
||||
return;
|
||||
}
|
||||
setReplyDraft(undefined);
|
||||
if (replyDraft) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
setReplyDraft(undefined);
|
||||
}
|
||||
}
|
||||
},
|
||||
[submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing],
|
||||
[submit, replyDraft, setReplyDraft, enterForNewline, autocompleteQuery, isComposing],
|
||||
);
|
||||
|
||||
const handleKeyUp: KeyboardEventHandler = useCallback(
|
||||
|
||||
@@ -583,7 +583,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
clickOutsideDeactivates: true,
|
||||
onDeactivate: () => setViewTopic(false),
|
||||
escapeDeactivates: stopPropagation,
|
||||
|
||||
@@ -25,3 +25,16 @@ export const RoomViewTyping = style([
|
||||
export const TypingText = style({
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
// Visually hidden but available to assistive technology.
|
||||
export const SrOnly = style({
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
});
|
||||
|
||||
@@ -33,8 +33,21 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
||||
[typingMembers, myUserId, room],
|
||||
);
|
||||
|
||||
if (typingNames.length === 0) {
|
||||
return null;
|
||||
// A single, non-truncated string for assistive technology to announce.
|
||||
// Computed even when empty so the live region can stay mounted (below) —
|
||||
// a `role="status"` region added to the DOM together with its first text
|
||||
// is not reliably announced by some screen readers.
|
||||
let typingAnnouncement = '';
|
||||
if (typingNames.length === 1) {
|
||||
typingAnnouncement = `${typingNames[0]} is typing`;
|
||||
} else if (typingNames.length === 2) {
|
||||
typingAnnouncement = `${typingNames[0]} and ${typingNames[1]} are typing`;
|
||||
} else if (typingNames.length === 3) {
|
||||
typingAnnouncement = `${typingNames[0]}, ${typingNames[1]} and ${typingNames[2]} are typing`;
|
||||
} else {
|
||||
typingAnnouncement = `${typingNames[0]}, ${typingNames[1]}, ${typingNames[2]} and ${
|
||||
typingNames.length - 3
|
||||
} others are typing`;
|
||||
}
|
||||
|
||||
const handleDropAll = () => {
|
||||
@@ -50,83 +63,89 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }} aria-live="polite" aria-atomic="false">
|
||||
<Box
|
||||
className={classNames(css.RoomViewTyping, className)}
|
||||
alignItems="Center"
|
||||
gap="400"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<TypingIndicator />
|
||||
<Text className={css.TypingText} size="T300" truncate>
|
||||
{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}
|
||||
<div style={{ position: 'relative' }}>
|
||||
{/* Persistently mounted so the FIRST "X is typing" is announced. */}
|
||||
<span className={css.SrOnly} role="status" aria-live="polite" aria-atomic="true">
|
||||
{typingAnnouncement}
|
||||
</span>
|
||||
{typingNames.length > 0 && (
|
||||
<Box
|
||||
className={classNames(css.RoomViewTyping, className)}
|
||||
alignItems="Center"
|
||||
gap="400"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Icon size="50" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<TypingIndicator />
|
||||
<Text className={css.TypingText} size="T300" truncate aria-hidden>
|
||||
{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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
const [scheduledMessages, setScheduledMessages] = useAtom(scheduledMessagesAtom);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
|
||||
const [cancelErrors, setCancelErrors] = useState<Set<string>>(new Set());
|
||||
|
||||
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
|
||||
|
||||
@@ -68,12 +69,17 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
async (msg: ScheduledMessage) => {
|
||||
if (cancelling.has(msg.delayId)) return;
|
||||
setCancelling((prev) => new Set(prev).add(msg.delayId));
|
||||
setCancelErrors((prev) => {
|
||||
if (!prev.has(msg.delayId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(msg.delayId);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await cancelScheduledMessage(mx, msg.delayId);
|
||||
} catch {
|
||||
// If cancellation fails on the server, still remove locally
|
||||
// since the user intends to remove it
|
||||
} finally {
|
||||
// Only prune local state once the server confirms cancellation. If we
|
||||
// removed it optimistically the still-live delayed event would fire and
|
||||
// the "cancelled" message would send anyway.
|
||||
setScheduledMessages((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(roomId) ?? [];
|
||||
@@ -85,6 +91,11 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// Keep the item (still cancellable) and surface an inline error; the
|
||||
// delayed event is still scheduled on the server.
|
||||
setCancelErrors((prev) => new Set(prev).add(msg.delayId));
|
||||
} finally {
|
||||
setCancelling((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(msg.delayId);
|
||||
@@ -131,41 +142,52 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
{messages.map((msg) => (
|
||||
<Box
|
||||
key={msg.delayId}
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
direction="Column"
|
||||
style={{
|
||||
padding: `${config.space.S100} ${config.space.S300}`,
|
||||
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="T200"
|
||||
priority="400"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)'}
|
||||
</Text>
|
||||
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{formatSendAt(msg.sendAt)}
|
||||
</Text>
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
aria-label="Cancel scheduled message"
|
||||
disabled={cancelling.has(msg.delayId)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancel(msg);
|
||||
}}
|
||||
>
|
||||
<Icon src={Icons.Cross} size="50" />
|
||||
</IconButton>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Text
|
||||
size="T200"
|
||||
priority="400"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{typeof msg.content.body === 'string'
|
||||
? (msg.content.body as string)
|
||||
: '(message)'}
|
||||
</Text>
|
||||
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{formatSendAt(msg.sendAt)}
|
||||
</Text>
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
aria-label="Cancel scheduled message"
|
||||
disabled={cancelling.has(msg.delayId)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancel(msg);
|
||||
}}
|
||||
>
|
||||
<Icon src={Icons.Cross} size="50" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{cancelErrors.has(msg.delayId) && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
||||
>
|
||||
Could not cancel this message. Try again.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
getMemberDisplayName,
|
||||
} from '../../../utils/room';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { messageAriaLabel } from '../../../utils/a11y';
|
||||
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||
@@ -972,6 +973,10 @@ export const Message = React.memo(
|
||||
[MsgAppearClass]: playAppear,
|
||||
[MentionHighlightPulse]: playMentionPulse,
|
||||
})}
|
||||
role="article"
|
||||
aria-label={
|
||||
collapse ? messageAriaLabel(senderDisplayName, mEvent.getTs(), hour24Clock) : undefined
|
||||
}
|
||||
tabIndex={0}
|
||||
space={messageSpacing}
|
||||
collapse={collapse}
|
||||
|
||||
@@ -51,7 +51,13 @@ import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||
import { EmojiBoard } from '../../../components/emoji-board';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||
import {
|
||||
getEditedEvent,
|
||||
getMemberDisplayName,
|
||||
getMentionContent,
|
||||
trimReplyFromFormattedBody,
|
||||
} from '../../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../../utils/matrix';
|
||||
import { mobileOrTablet } from '../../../utils/user-agent';
|
||||
import { useComposingCheck } from '../../../hooks/useComposingCheck';
|
||||
|
||||
@@ -66,6 +72,12 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const editor = useEditor();
|
||||
// Accessible name for the edit textbox so screen readers announce which
|
||||
// message is being edited (a11y, P3-4).
|
||||
const editSenderId = mEvent.getSender();
|
||||
const editSenderName = editSenderId
|
||||
? (getMemberDisplayName(room, editSenderId) ?? getMxIdLocalPart(editSenderId) ?? editSenderId)
|
||||
: '';
|
||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
@@ -259,6 +271,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
<CustomEditor
|
||||
editor={editor}
|
||||
placeholder="Edit message..."
|
||||
ariaLabel={editSenderId ? `Editing message from ${editSenderName}` : 'Edit message'}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
bottom={
|
||||
|
||||
@@ -106,7 +106,6 @@ export const Reactions = as<'div', ReactionsProps>(
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setViewer(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
color,
|
||||
config,
|
||||
Dialog,
|
||||
Header,
|
||||
@@ -43,15 +44,25 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
const modalStyle = useModalStyle(320);
|
||||
const { addReminder } = useReminders();
|
||||
const presets = useMemo(() => getPresets(), []);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handlePick = async (ms: number) => {
|
||||
await addReminder({
|
||||
roomId,
|
||||
eventId,
|
||||
timestamp: Date.now() + ms,
|
||||
message: previewText || 'Reminder',
|
||||
});
|
||||
onClose();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await addReminder({
|
||||
roomId,
|
||||
eventId,
|
||||
timestamp: Date.now() + ms,
|
||||
message: previewText || 'Reminder',
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
setBusy(false);
|
||||
setError('Could not set reminder. Try again.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -108,6 +119,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
disabled={busy}
|
||||
onClick={() => handlePick(p.ms)}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
@@ -115,6 +127,14 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
{error && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
||||
>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
|
||||
@@ -123,6 +123,10 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
||||
useCallback(
|
||||
(evt) => {
|
||||
if (isKeyHotkey('escape', evt)) {
|
||||
// The composer preventDefaults Escape when it consumes it (dismissing
|
||||
// autocomplete / clearing a reply draft). Don't close the panel in
|
||||
// that case — only when Escape wasn't already handled.
|
||||
if (evt.defaultPrevented) return;
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
requestClose();
|
||||
|
||||
@@ -11,6 +11,15 @@ export const ThreadTimelineContent = style({
|
||||
padding: `${config.space.S400} 0`,
|
||||
});
|
||||
|
||||
export const ThreadTimelineFloat = style({
|
||||
position: 'absolute',
|
||||
bottom: config.space.S400,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1,
|
||||
minWidth: 'max-content',
|
||||
});
|
||||
|
||||
export const ThreadCentered = style({
|
||||
height: '100%',
|
||||
padding: config.space.S700,
|
||||
|
||||
@@ -29,7 +29,7 @@ import { Editor } from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import to from 'await-to-js';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { Badge, Box, Line, Scroll, Spinner, Text, color, config } from 'folds';
|
||||
import { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, config } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix';
|
||||
@@ -459,6 +459,14 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
}
|
||||
}, [scrollToBottomCount]);
|
||||
|
||||
const handleJumpToBottom = useCallback(() => {
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = true;
|
||||
// Flip atBottom so the layout effect re-runs (count re-read) and live
|
||||
// events resume sticking to the bottom.
|
||||
setAtBottom(true);
|
||||
}, []);
|
||||
|
||||
// Scroll in-place editor into view.
|
||||
useEffect(() => {
|
||||
if (editId) {
|
||||
@@ -949,6 +957,19 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
<span ref={atBottomAnchorRef} />
|
||||
</Box>
|
||||
</Scroll>
|
||||
{!atBottom && (
|
||||
<Box className={css.ThreadTimelineFloat} justifyContent="Center" alignItems="Center">
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
outlined
|
||||
before={<Icon size="50" src={Icons.ArrowBottom} />}
|
||||
onClick={handleJumpToBottom}
|
||||
>
|
||||
<Text size="L400">Jump to Latest</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
)}
|
||||
{editHistoryEvent && (
|
||||
<EditHistoryModal
|
||||
room={room}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventStatus, MatrixEvent, RelationType } from 'matrix-js-sdk';
|
||||
import { getThreadSummary, isPendingThreadReply } from './threadSummary';
|
||||
import { getThreadSummary, isPendingThreadReply } from './threadSummaryData';
|
||||
|
||||
// getThreadSummary reads either the live Thread (preferred) or the
|
||||
// server-aggregated `m.thread` bundle. We stub only the members it touches and
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ThreadEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
import { getLinkedTimelines } from '../RoomTimeline';
|
||||
import { isPendingThreadReply } from './threadSummary';
|
||||
import { isPendingThreadReply } from './threadSummaryData';
|
||||
|
||||
/**
|
||||
* Resolve (or bootstrap) the live {@link Thread} for a root event.
|
||||
|
||||
@@ -247,7 +247,6 @@ export function Search({ requestClose }: SearchProps) {
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: () => inputRef.current,
|
||||
returnFocusOnDeactivate: false,
|
||||
allowOutsideClick: true,
|
||||
clickOutsideDeactivates: true,
|
||||
onDeactivate: requestClose,
|
||||
@@ -257,7 +256,13 @@ export function Search({ requestClose }: SearchProps) {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Modal size="400" style={{ maxHeight: toRem(400), borderRadius: config.radii.R500 }}>
|
||||
<Modal
|
||||
size="400"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search"
|
||||
style={{ maxHeight: toRem(400), borderRadius: config.radii.R500 }}
|
||||
>
|
||||
<Box
|
||||
shrink="No"
|
||||
style={{ padding: config.space.S400, paddingBottom: 0 }}
|
||||
@@ -270,6 +275,7 @@ export function Search({ requestClose }: SearchProps) {
|
||||
radii="400"
|
||||
outlined
|
||||
placeholder="Search"
|
||||
aria-label="Search rooms"
|
||||
before={<Icon size="200" src={Icons.Search} />}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
|
||||
@@ -531,6 +531,7 @@ function Appearance() {
|
||||
Intensity: {nightLightOpacity}%
|
||||
</Text>
|
||||
<input
|
||||
aria-label="Night light intensity"
|
||||
type="range"
|
||||
min={5}
|
||||
max={80}
|
||||
@@ -1663,6 +1664,7 @@ function Calls() {
|
||||
<Text size="T200">{callDenoiseGateThreshold} dB</Text>
|
||||
</Box>
|
||||
<input
|
||||
aria-label="Noise gate threshold"
|
||||
type="range"
|
||||
min="-100"
|
||||
max="0"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
|
||||
export const ShortcutList = style([
|
||||
DefaultReset,
|
||||
{
|
||||
margin: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
export const ShortcutRow = style({
|
||||
padding: `${config.space.S100} 0`,
|
||||
});
|
||||
|
||||
export const ShortcutTerm = style([
|
||||
DefaultReset,
|
||||
{
|
||||
flexGrow: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
export const ShortcutKeys = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S100,
|
||||
flexShrink: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
export const Kbd = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: toRem(20),
|
||||
padding: `0 ${config.space.S200}`,
|
||||
height: toRem(24),
|
||||
fontFamily: 'inherit',
|
||||
fontSize: toRem(12),
|
||||
lineHeight: toRem(24),
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderRadius: config.radii.R300,
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,208 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { atom, useAtom, useSetAtom } from 'jotai';
|
||||
import {
|
||||
Box,
|
||||
Dialog,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Line,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Scroll,
|
||||
Text,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { editableActiveElement } from '../../utils/dom';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { isMacOS } from '../../utils/user-agent';
|
||||
import { KeySymbol } from '../../utils/key-symbol';
|
||||
import * as css from './KeyboardShortcutsDialog.css';
|
||||
|
||||
/** Global open-state for the keyboard shortcuts help dialog. */
|
||||
export const keyboardShortcutsDialogAtom = atom<boolean>(false);
|
||||
|
||||
/** Read/control the keyboard shortcuts dialog open-state. */
|
||||
export function useKeyboardShortcutsDialog() {
|
||||
const [open, setOpen] = useAtom(keyboardShortcutsDialogAtom);
|
||||
const openDialog = useCallback(() => setOpen(true), [setOpen]);
|
||||
const closeDialog = useCallback(() => setOpen(false), [setOpen]);
|
||||
return { open, openDialog, closeDialog };
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the global `Shift + /` (`?`) shortcut that opens the keyboard
|
||||
* shortcuts help dialog. Ignored while the user is typing into an input,
|
||||
* textarea or contenteditable so it never steals a literal `?` character.
|
||||
*
|
||||
* Mount once in the client shell (e.g. `ClientNonUIFeatures`).
|
||||
*/
|
||||
export function useKeyboardShortcutsTrigger() {
|
||||
const setOpen = useSetAtom(keyboardShortcutsDialogAtom);
|
||||
useKeyDown(
|
||||
window,
|
||||
useCallback(
|
||||
(evt: KeyboardEvent) => {
|
||||
// Never intercept `?` while the user is typing into a field/editor.
|
||||
if (editableActiveElement()) return;
|
||||
// `?` is produced by Shift + `/` on the common layouts.
|
||||
if (evt.key === '?') {
|
||||
evt.preventDefault();
|
||||
// Stop RoomView's window-level "type any char → focus composer"
|
||||
// handler from also firing — otherwise focus lands in the composer
|
||||
// behind the dialog and Escape gets swallowed by the contenteditable.
|
||||
evt.stopImmediatePropagation();
|
||||
setOpen(true);
|
||||
}
|
||||
},
|
||||
[setOpen],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
type ShortcutRow = {
|
||||
description: string;
|
||||
keys: string[];
|
||||
};
|
||||
|
||||
type ShortcutSection = {
|
||||
title: string;
|
||||
rows: ShortcutRow[];
|
||||
};
|
||||
|
||||
function ShortcutKeys({ keys }: { keys: string[] }) {
|
||||
return (
|
||||
<Box as="dd" className={css.ShortcutKeys}>
|
||||
{keys.map((key, index) => (
|
||||
<kbd key={`${key}-${index}`} className={css.Kbd}>
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessible keyboard shortcuts help dialog. Renders (as a modal overlay) only
|
||||
* while `keyboardShortcutsDialogAtom` is `true`. Open it with the `?` shortcut
|
||||
* (see `useKeyboardShortcutsTrigger`) or via `useKeyboardShortcutsDialog`.
|
||||
*/
|
||||
export function KeyboardShortcutsDialog() {
|
||||
const { open, closeDialog } = useKeyboardShortcutsDialog();
|
||||
const modalStyle = useModalStyle(480);
|
||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
|
||||
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const sections: ShortcutSection[] = [
|
||||
{
|
||||
title: 'General',
|
||||
rows: [
|
||||
{ description: 'Show keyboard shortcuts', keys: ['?'] },
|
||||
{ description: 'Close open panel, otherwise mark room as read', keys: [KeySymbol.Escape] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Composer',
|
||||
rows: [
|
||||
{ description: 'Focus the message composer', keys: ['Any character'] },
|
||||
{
|
||||
description: 'Send message',
|
||||
keys: enterForNewline ? [modKey, 'Enter'] : ['Enter'],
|
||||
},
|
||||
{
|
||||
description: 'Insert a new line',
|
||||
keys: enterForNewline ? ['Enter'] : [KeySymbol.Shift, 'Enter'],
|
||||
},
|
||||
{ description: 'Send message (always)', keys: [modKey, 'Enter'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Messages',
|
||||
rows: [
|
||||
{ description: 'Reveal message actions (react, reply, more)', keys: ['Hover / focus'] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: closeDialog,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog
|
||||
variant="Surface"
|
||||
aria-labelledby="keyboard-shortcuts-dialog-title"
|
||||
style={modalStyle}
|
||||
>
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text as="h2" size="H4" id="keyboard-shortcuts-dialog-title">
|
||||
Keyboard Shortcuts
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={closeDialog} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Scroll size="300" hideTrack visibility="Hover">
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="500">
|
||||
{sections.map((section, sectionIndex) => (
|
||||
<Box key={section.title} direction="Column" gap="300">
|
||||
{sectionIndex > 0 && <Line variant="Surface" size="300" />}
|
||||
<Text size="L400" priority="400">
|
||||
{section.title}
|
||||
</Text>
|
||||
<Box as="dl" className={css.ShortcutList} direction="Column">
|
||||
{section.rows.map((row) => (
|
||||
<Box
|
||||
key={row.description}
|
||||
className={css.ShortcutRow}
|
||||
direction="Row"
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
>
|
||||
<Text as="dt" className={css.ShortcutTerm} size="T300">
|
||||
{row.description}
|
||||
</Text>
|
||||
<ShortcutKeys keys={row.keys} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
<Text size="T200" priority="300">
|
||||
{enterForNewline
|
||||
? 'Enter inserts a new line while “Enter for newline” is enabled in Settings.'
|
||||
: 'Enter sends the message. Enable “Enter for newline” in Settings to swap this.'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './KeyboardShortcutsDialog';
|
||||
@@ -2,11 +2,26 @@ import { useEffect, useState } from 'react';
|
||||
import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { getRecentEmojis } from '../plugins/recent-emoji';
|
||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||
import { IEmoji } from '../plugins/emoji';
|
||||
import { IEmoji, loadEmojiData } from '../plugins/emoji';
|
||||
|
||||
export const useRecentEmoji = (mx: MatrixClient, limit?: number): IEmoji[] => {
|
||||
const [recentEmoji, setRecentEmoji] = useState(() => getRecentEmojis(mx, limit));
|
||||
|
||||
// Recent emojis are resolved against the (now lazily loaded) emojibase data
|
||||
// via getRecentEmojis. Recompute once loadEmojiData has populated it so the
|
||||
// recent list fills in on first open.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
loadEmojiData()
|
||||
.then(() => {
|
||||
if (alive) setRecentEmoji(getRecentEmojis(mx, limit));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [mx, limit]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAccountData = (event: MatrixEvent) => {
|
||||
if (event.getType() !== AccountDataEvent.ElementRecentEmoji) return;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
RoomEventHandlerMap,
|
||||
ThreadEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
import { getThreadSummary, ThreadSummaryData } from '../features/room/thread/threadSummary';
|
||||
import { getThreadSummary, ThreadSummaryData } from '../features/room/thread/threadSummaryData';
|
||||
import { threadNotificationsAtom } from '../state/threadNotifications';
|
||||
import { getThreadNotificationMode, ThreadNotificationMode } from '../utils/threadNotifications';
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import { toastQueueAtom } from '../../state/toast';
|
||||
import { useReminders } from '../../hooks/useReminders';
|
||||
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
|
||||
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
||||
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
|
||||
import { useRoomsListener } from '../../hooks/useRoomsListener';
|
||||
import { threadNotificationsAtom } from '../../state/threadNotifications';
|
||||
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||
@@ -213,7 +214,7 @@ function InviteNotifications() {
|
||||
]);
|
||||
|
||||
return (
|
||||
<audio ref={audioRef} style={{ display: 'none' }}>
|
||||
<audio ref={audioRef} style={{ display: 'none' }} aria-hidden="true">
|
||||
<source src={soundSrc ?? InviteSound} type="audio/ogg" />
|
||||
</audio>
|
||||
);
|
||||
@@ -496,7 +497,7 @@ function MessageNotifications() {
|
||||
useRoomsListener(mx, ThreadEvent.NewReply, handleNewReply);
|
||||
|
||||
return (
|
||||
<audio ref={audioRef} style={{ display: 'none' }}>
|
||||
<audio ref={audioRef} style={{ display: 'none' }} aria-hidden="true">
|
||||
<source src={soundSrc ?? NotificationSound} type="audio/ogg" />
|
||||
</audio>
|
||||
);
|
||||
@@ -642,6 +643,13 @@ function LotusDenoiseFeature() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Registers the global `?` shortcut (ignored while typing) and renders the
|
||||
// keyboard-shortcuts help dialog. Headless — the dialog self-gates on its atom.
|
||||
function KeyboardShortcutsFeature() {
|
||||
useKeyboardShortcutsTrigger();
|
||||
return <KeyboardShortcutsDialog />;
|
||||
}
|
||||
|
||||
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -656,6 +664,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
<TauriDesktopFeatures />
|
||||
<LotusDenoiseFeature />
|
||||
<DeepLinkNavigator />
|
||||
<KeyboardShortcutsFeature />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from './types';
|
||||
import { CallControl } from './CallControl';
|
||||
import { CallControlState } from './CallControlState';
|
||||
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
||||
|
||||
// Maximum time to wait for the embedded Element Call iframe to progress from
|
||||
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
||||
@@ -205,6 +206,12 @@ export class CallEmbed {
|
||||
params.append('lotusModel', denoiseModel);
|
||||
params.append('lotusGate', denoiseGate.toString());
|
||||
params.append('lotusGateThreshold', denoiseGateThreshold.toString());
|
||||
|
||||
// [lotus] Fire-and-forget: confirm the fork's ML-denoise assets are
|
||||
// actually served under public/element-call/denoise/ (they're copied by
|
||||
// vite.config.js at build time). Warns once if the copy step regressed;
|
||||
// never blocks call start.
|
||||
verifyDenoiseAssets(denoiseModel).catch(() => undefined);
|
||||
}
|
||||
|
||||
if (CallEmbed.startingCall(intent)) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { trimTrailingSlash } from '../../utils/common';
|
||||
|
||||
// Denoise assets copied into public/element-call/denoise/ by vite.config.js's
|
||||
// lotusDenoise() plugin. The filenames here MUST match what that plugin writes
|
||||
// (and what the fork's TrackProcessor fetches at runtime). Grouped per model so
|
||||
// the smoke-check only probes what the active call will actually load.
|
||||
const DENOISE_ASSETS: Record<string, readonly string[]> = {
|
||||
rnnoise: ['rnnoiseWorklet.js', 'rnnoise.wasm', 'rnnoise_simd.wasm'],
|
||||
speex: ['speexWorklet.js', 'speex.wasm'],
|
||||
dtln: ['workadventure/audio-worklet.js'],
|
||||
deepfilternet: [
|
||||
'deepfilternet/index.esm.js',
|
||||
'deepfilternet/v2/pkg/df_bg.wasm',
|
||||
'deepfilternet/v2/models/DeepFilterNet3_onnx.tar.gz',
|
||||
],
|
||||
};
|
||||
|
||||
// The noise-gate worklet is a shared asset the build ships for every model
|
||||
// (loaded when the gate is enabled), so probe it regardless of the model.
|
||||
const SHARED_ASSETS: readonly string[] = ['noiseGateWorklet.js'];
|
||||
|
||||
/**
|
||||
* Fire-and-forget smoke-check for the ML-denoise asset contract.
|
||||
*
|
||||
* The fork's in-source denoiser (lotusDenoiseSource) loads its worklet/wasm/ESM
|
||||
* from `public/element-call/denoise/` at runtime; if the build's asset copy
|
||||
* step regressed, those fetches 404 and denoise silently degrades to a raw mic.
|
||||
* This HEAD-fetches the critical assets for the selected model and emits a
|
||||
* single console.warn listing any that are missing. No UI, no throw — purely a
|
||||
* developer/operator breadcrumb.
|
||||
*
|
||||
* @param model the selected denoise model (defaults to rnnoise)
|
||||
* @returns true if every probed asset responded OK, false otherwise
|
||||
*/
|
||||
export async function verifyDenoiseAssets(model = 'rnnoise'): Promise<boolean> {
|
||||
const base = new URL(
|
||||
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/denoise/`,
|
||||
window.location.origin,
|
||||
);
|
||||
const names = [...(DENOISE_ASSETS[model] ?? DENOISE_ASSETS.rnnoise), ...SHARED_ASSETS];
|
||||
|
||||
const results = await Promise.all(
|
||||
names.map(async (name): Promise<string | null> => {
|
||||
try {
|
||||
const res = await fetch(new URL(name, base).href, { method: 'HEAD' });
|
||||
return res.ok ? null : name;
|
||||
} catch {
|
||||
return name;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const missing = results.filter((n): n is string => n !== null);
|
||||
if (missing.length > 0) {
|
||||
console.warn(
|
||||
`[lotus-denoise] ML denoise assets missing under ${base.href} (model="${model}"): ${missing.join(
|
||||
', ',
|
||||
)} — the in-source denoiser will fall back to a raw mic. Check vite.config.js lotusDenoise().`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
+110
-63
@@ -1,7 +1,4 @@
|
||||
import { CompactEmoji, fromUnicodeToHexcode } from 'emojibase';
|
||||
import emojisData from 'emojibase-data/en/compact.json';
|
||||
import joypixels from 'emojibase-data/en/shortcodes/joypixels.json';
|
||||
import emojibase from 'emojibase-data/en/shortcodes/emojibase.json';
|
||||
import type { CompactEmoji } from 'emojibase';
|
||||
|
||||
export type IEmoji = CompactEmoji & {
|
||||
shortcode: string;
|
||||
@@ -24,57 +21,76 @@ export type IEmojiGroup = {
|
||||
emojis: IEmoji[];
|
||||
};
|
||||
|
||||
export const getShortcodesFor = (hexcode: string): string[] | string | undefined =>
|
||||
joypixels[hexcode] || emojibase[hexcode];
|
||||
export type EmojiData = {
|
||||
emojis: IEmoji[];
|
||||
emojiGroups: IEmojiGroup[];
|
||||
};
|
||||
|
||||
type ShortcodeMap = Record<string, string | string[]>;
|
||||
|
||||
/**
|
||||
* PERF (lazy emojibase split): the heavy `emojibase-data` JSON (compact emoji
|
||||
* data + the joypixels/emojibase shortcode maps, ~965 KB combined) used to be
|
||||
* imported statically at module top-level. Because reaction/message rendering
|
||||
* (`Reaction`, `scaleSystemEmoji`) import this module eagerly, that dragged the
|
||||
* whole `emojibase` chunk into the initial (eager) bundle graph.
|
||||
*
|
||||
* It is now loaded on demand via `loadEmojiData()` (a memoized dynamic import).
|
||||
* Only lazy emoji surfaces (EmojiBoard, EmoticonAutocomplete, recent-emoji)
|
||||
* trigger the load. Anything that renders eagerly (reaction/emoji tooltips and
|
||||
* aria-labels via `getShortcodeFor`) gracefully degrades to `undefined` until
|
||||
* the data has been loaded — the visible emoji glyph itself never depended on
|
||||
* this data, so on-screen UX is unchanged; the shortcode label simply resolves
|
||||
* once emoji data is loaded. `getHexcodeForEmoji` is inlined below so it stays
|
||||
* synchronous WITHOUT pulling the `emojibase` runtime into the eager graph.
|
||||
*/
|
||||
|
||||
// Inlined from emojibase's `fromUnicodeToHexcode` so this synchronous helper
|
||||
// does not import the `emojibase` package (and thus the emojibase chunk) into
|
||||
// the eager graph. Kept byte-for-byte behaviourally identical.
|
||||
const SEQUENCE_REMOVAL_PATTERN = /200D|FE0E|FE0F/g;
|
||||
|
||||
export const getHexcodeForEmoji = (unicode: string, strip = true): string => {
|
||||
const hexcode: string[] = [];
|
||||
[...unicode].forEach((codepoint) => {
|
||||
let hex = codepoint.codePointAt(0)?.toString(16).toUpperCase() ?? '';
|
||||
while (hex.length < 4) {
|
||||
hex = `0${hex}`;
|
||||
}
|
||||
if (!strip || !hex.match(SEQUENCE_REMOVAL_PATTERN)) {
|
||||
hexcode.push(hex);
|
||||
}
|
||||
});
|
||||
return hexcode.join('-');
|
||||
};
|
||||
|
||||
// Populated by loadEmojiData(); `undefined` until the data has been loaded.
|
||||
let joypixelsShortcodes: ShortcodeMap | undefined;
|
||||
let emojibaseShortcodes: ShortcodeMap | undefined;
|
||||
|
||||
export const getShortcodesFor = (hexcode: string): string[] | string | undefined => {
|
||||
if (!joypixelsShortcodes || !emojibaseShortcodes) return undefined;
|
||||
return joypixelsShortcodes[hexcode] || emojibaseShortcodes[hexcode];
|
||||
};
|
||||
|
||||
export const getShortcodeFor = (hexcode: string): string | undefined => {
|
||||
const shortcode = joypixels[hexcode] || emojibase[hexcode];
|
||||
const shortcode = getShortcodesFor(hexcode);
|
||||
return Array.isArray(shortcode) ? shortcode[0] : shortcode;
|
||||
};
|
||||
|
||||
export const getHexcodeForEmoji = fromUnicodeToHexcode;
|
||||
|
||||
// Shared, stable array references. They start empty and are populated in place
|
||||
// the first time loadEmojiData() resolves (mirroring the previous eager module
|
||||
// side-effect). React consumers await loadEmojiData() and re-render to observe
|
||||
// the populated data; non-React consumers (recent-emoji) read them after load.
|
||||
export const emojiGroups: IEmojiGroup[] = [
|
||||
{
|
||||
id: EmojiGroupId.People,
|
||||
order: 0,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Nature,
|
||||
order: 1,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Food,
|
||||
order: 2,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Activity,
|
||||
order: 3,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Travel,
|
||||
order: 4,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Object,
|
||||
order: 5,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Symbol,
|
||||
order: 6,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Flag,
|
||||
order: 7,
|
||||
emojis: [],
|
||||
},
|
||||
{ id: EmojiGroupId.People, order: 0, emojis: [] },
|
||||
{ id: EmojiGroupId.Nature, order: 1, emojis: [] },
|
||||
{ id: EmojiGroupId.Food, order: 2, emojis: [] },
|
||||
{ id: EmojiGroupId.Activity, order: 3, emojis: [] },
|
||||
{ id: EmojiGroupId.Travel, order: 4, emojis: [] },
|
||||
{ id: EmojiGroupId.Object, order: 5, emojis: [] },
|
||||
{ id: EmojiGroupId.Symbol, order: 6, emojis: [] },
|
||||
{ id: EmojiGroupId.Flag, order: 7, emojis: [] },
|
||||
];
|
||||
|
||||
export const emojis: IEmoji[] = [];
|
||||
@@ -95,20 +111,51 @@ function getGroupIndex(emoji: IEmoji): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
emojisData.forEach((emoji) => {
|
||||
const myShortCodes = getShortcodesFor(emoji.hexcode);
|
||||
if (!myShortCodes) return;
|
||||
if (Array.isArray(myShortCodes) && myShortCodes.length === 0) return;
|
||||
let emojiDataPromise: Promise<EmojiData> | undefined;
|
||||
|
||||
const em: IEmoji = {
|
||||
...emoji,
|
||||
shortcode: Array.isArray(myShortCodes) ? myShortCodes[0] : myShortCodes,
|
||||
shortcodes: Array.isArray(myShortCodes) ? myShortCodes : emoji.shortcodes,
|
||||
};
|
||||
/**
|
||||
* Lazily load emojibase data (dynamic import → the `emojibase` chunk). Memoized:
|
||||
* the JSON is fetched/parsed and `emojis`/`emojiGroups` are built exactly once.
|
||||
*/
|
||||
export const loadEmojiData = (): Promise<EmojiData> => {
|
||||
if (!emojiDataPromise) {
|
||||
emojiDataPromise = (async (): Promise<EmojiData> => {
|
||||
const [emojisModule, joypixelsModule, emojibaseModule] = await Promise.all([
|
||||
import('emojibase-data/en/compact.json'),
|
||||
import('emojibase-data/en/shortcodes/joypixels.json'),
|
||||
import('emojibase-data/en/shortcodes/emojibase.json'),
|
||||
]);
|
||||
|
||||
const groupIndex = getGroupIndex(em);
|
||||
if (groupIndex !== undefined) {
|
||||
addEmojiToGroup(groupIndex, em);
|
||||
emojis.push(em);
|
||||
joypixelsShortcodes = joypixelsModule.default as ShortcodeMap;
|
||||
emojibaseShortcodes = emojibaseModule.default as ShortcodeMap;
|
||||
|
||||
const emojisData = emojisModule.default as unknown as CompactEmoji[];
|
||||
emojisData.forEach((emoji) => {
|
||||
const myShortCodes = getShortcodesFor(emoji.hexcode);
|
||||
if (!myShortCodes) return;
|
||||
if (Array.isArray(myShortCodes) && myShortCodes.length === 0) return;
|
||||
|
||||
const em: IEmoji = {
|
||||
...emoji,
|
||||
shortcode: Array.isArray(myShortCodes) ? myShortCodes[0] : myShortCodes,
|
||||
shortcodes: Array.isArray(myShortCodes) ? myShortCodes : emoji.shortcodes,
|
||||
};
|
||||
|
||||
const groupIndex = getGroupIndex(em);
|
||||
if (groupIndex !== undefined) {
|
||||
addEmojiToGroup(groupIndex, em);
|
||||
emojis.push(em);
|
||||
}
|
||||
});
|
||||
|
||||
return { emojis, emojiGroups };
|
||||
})();
|
||||
// Don't cache a rejection: a transient chunk-load failure (e.g. mid-deploy
|
||||
// 404) would otherwise permanently disable emoji data until a full reload.
|
||||
emojiDataPromise = emojiDataPromise.catch((err) => {
|
||||
emojiDataPromise = undefined;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
});
|
||||
return emojiDataPromise;
|
||||
};
|
||||
|
||||
@@ -229,13 +229,21 @@ export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] =>
|
||||
findAndReplace(
|
||||
text,
|
||||
EMOJI_REG_G,
|
||||
(match, pushIndex) => (
|
||||
<span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}>
|
||||
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(match[0]))}>
|
||||
{match[0]}
|
||||
(match, pushIndex) => {
|
||||
const shortcode = getShortcodeFor(getHexcodeForEmoji(match[0]));
|
||||
return (
|
||||
<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>
|
||||
),
|
||||
);
|
||||
},
|
||||
(txt) => txt,
|
||||
);
|
||||
|
||||
@@ -574,15 +582,25 @@ export const getReactCustomHtmlParser = (
|
||||
);
|
||||
}
|
||||
if (htmlSrc && 'data-mx-emoticon' in props) {
|
||||
const emoticonAlt =
|
||||
(typeof props.alt === 'string' && props.alt) ||
|
||||
(typeof props.title === 'string' && props.title) ||
|
||||
'emoji';
|
||||
return (
|
||||
<span className={css.EmoticonBase}>
|
||||
<span className={css.Emoticon()}>
|
||||
<img {...props} className={css.EmoticonImg} src={htmlSrc} />
|
||||
<img
|
||||
{...props}
|
||||
alt={emoticonAlt}
|
||||
className={css.EmoticonImg}
|
||||
src={htmlSrc}
|
||||
loading="lazy"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (htmlSrc) return <img {...props} className={css.Img} src={htmlSrc} />;
|
||||
if (htmlSrc) return <img {...props} className={css.Img} src={htmlSrc} loading="lazy" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,7 +629,6 @@ export const getReactCustomHtmlParser = (
|
||||
<>
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === 'text') {
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
return (
|
||||
<React.Fragment key={index}>{renderTextChunk(segment.value)}</React.Fragment>
|
||||
);
|
||||
@@ -619,7 +636,6 @@ export const getReactCustomHtmlParser = (
|
||||
const raw =
|
||||
segment.type === 'block' ? `$$${segment.value}$$` : `$${segment.value}$`;
|
||||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<React.Fragment key={index}>
|
||||
{renderMath(segment.value, segment.type === 'block', raw, raw)}
|
||||
</React.Fragment>
|
||||
|
||||
@@ -2,307 +2,33 @@ import React, { MutableRefObject, ReactNode, useEffect, useRef } from 'react';
|
||||
|
||||
import Prism from 'prismjs';
|
||||
|
||||
import 'prismjs/components/prism-abap.js';
|
||||
import 'prismjs/components/prism-abnf.js';
|
||||
import 'prismjs/components/prism-actionscript.js';
|
||||
import 'prismjs/components/prism-ada.js';
|
||||
import 'prismjs/components/prism-agda.js';
|
||||
import 'prismjs/components/prism-al.js';
|
||||
import 'prismjs/components/prism-antlr4.js';
|
||||
import 'prismjs/components/prism-apacheconf.js';
|
||||
import 'prismjs/components/prism-apex.js';
|
||||
import 'prismjs/components/prism-apl.js';
|
||||
import 'prismjs/components/prism-applescript.js';
|
||||
import 'prismjs/components/prism-aql.js';
|
||||
import 'prismjs/components/prism-arff.js';
|
||||
import 'prismjs/components/prism-armasm.js';
|
||||
import 'prismjs/components/prism-arturo.js';
|
||||
import 'prismjs/components/prism-asciidoc.js';
|
||||
import 'prismjs/components/prism-asm6502.js';
|
||||
import 'prismjs/components/prism-asmatmel.js';
|
||||
import 'prismjs/components/prism-aspnet.js';
|
||||
import 'prismjs/components/prism-autohotkey.js';
|
||||
import 'prismjs/components/prism-autoit.js';
|
||||
import 'prismjs/components/prism-avisynth.js';
|
||||
import 'prismjs/components/prism-avro-idl.js';
|
||||
import 'prismjs/components/prism-awk.js';
|
||||
import 'prismjs/components/prism-bash.js';
|
||||
import 'prismjs/components/prism-basic.js';
|
||||
import 'prismjs/components/prism-batch.js';
|
||||
import 'prismjs/components/prism-bbcode.js';
|
||||
import 'prismjs/components/prism-bbj.js';
|
||||
import 'prismjs/components/prism-bicep.js';
|
||||
import 'prismjs/components/prism-birb.js';
|
||||
import 'prismjs/components/prism-bnf.js';
|
||||
import 'prismjs/components/prism-bqn.js';
|
||||
import 'prismjs/components/prism-brainfuck.js';
|
||||
import 'prismjs/components/prism-brightscript.js';
|
||||
import 'prismjs/components/prism-bro.js';
|
||||
import 'prismjs/components/prism-bsl.js';
|
||||
import 'prismjs/components/prism-c.js';
|
||||
import 'prismjs/components/prism-cfscript.js';
|
||||
import 'prismjs/components/prism-cil.js';
|
||||
import 'prismjs/components/prism-cilkc.js';
|
||||
import 'prismjs/components/prism-cilkcpp.js';
|
||||
// PERF: Prism used to import every bundled language (~574 KB lazy chunk). We now
|
||||
// ship a curated subset covering the languages actually seen in chat. Imports
|
||||
// MUST stay in dependency order (Prism component files assume their base grammar
|
||||
// is already registered): base grammars (markup/css/clike/javascript) first,
|
||||
// then languages that extend them (e.g. c→cpp, javascript→typescript,
|
||||
// markup+javascript→jsx, jsx+typescript→tsx, markup→markdown).
|
||||
import 'prismjs/components/prism-markup.js'; // markup / html / xml / svg
|
||||
import 'prismjs/components/prism-css.js';
|
||||
import 'prismjs/components/prism-clike.js';
|
||||
import 'prismjs/components/prism-clojure.js';
|
||||
import 'prismjs/components/prism-cmake.js';
|
||||
import 'prismjs/components/prism-cobol.js';
|
||||
import 'prismjs/components/prism-coffeescript.js';
|
||||
import 'prismjs/components/prism-concurnas.js';
|
||||
import 'prismjs/components/prism-cooklang.js';
|
||||
import 'prismjs/components/prism-coq.js';
|
||||
import 'prismjs/components/prism-javascript.js'; // js
|
||||
import 'prismjs/components/prism-json.js';
|
||||
import 'prismjs/components/prism-yaml.js';
|
||||
import 'prismjs/components/prism-bash.js'; // bash / shell / sh
|
||||
import 'prismjs/components/prism-python.js';
|
||||
import 'prismjs/components/prism-rust.js';
|
||||
import 'prismjs/components/prism-go.js';
|
||||
import 'prismjs/components/prism-java.js';
|
||||
import 'prismjs/components/prism-c.js';
|
||||
import 'prismjs/components/prism-cpp.js';
|
||||
import 'prismjs/components/prism-csharp.js';
|
||||
import 'prismjs/components/prism-cshtml.js';
|
||||
import 'prismjs/components/prism-csp.js';
|
||||
import 'prismjs/components/prism-css-extras.js';
|
||||
import 'prismjs/components/prism-css.js';
|
||||
import 'prismjs/components/prism-csv.js';
|
||||
import 'prismjs/components/prism-cue.js';
|
||||
import 'prismjs/components/prism-cypher.js';
|
||||
import 'prismjs/components/prism-d.js';
|
||||
import 'prismjs/components/prism-dart.js';
|
||||
import 'prismjs/components/prism-dataweave.js';
|
||||
import 'prismjs/components/prism-dax.js';
|
||||
import 'prismjs/components/prism-dhall.js';
|
||||
import 'prismjs/components/prism-diff.js';
|
||||
import 'prismjs/components/prism-dns-zone-file.js';
|
||||
import 'prismjs/components/prism-docker.js';
|
||||
import 'prismjs/components/prism-dot.js';
|
||||
import 'prismjs/components/prism-ebnf.js';
|
||||
import 'prismjs/components/prism-editorconfig.js';
|
||||
import 'prismjs/components/prism-eiffel.js';
|
||||
import 'prismjs/components/prism-ejs.js';
|
||||
import 'prismjs/components/prism-elixir.js';
|
||||
import 'prismjs/components/prism-elm.js';
|
||||
import 'prismjs/components/prism-erb.js';
|
||||
import 'prismjs/components/prism-erlang.js';
|
||||
import 'prismjs/components/prism-etlua.js';
|
||||
import 'prismjs/components/prism-excel-formula.js';
|
||||
import 'prismjs/components/prism-factor.js';
|
||||
import 'prismjs/components/prism-false.js';
|
||||
import 'prismjs/components/prism-firestore-security-rules.js';
|
||||
import 'prismjs/components/prism-flow.js';
|
||||
import 'prismjs/components/prism-fortran.js';
|
||||
import 'prismjs/components/prism-fsharp.js';
|
||||
import 'prismjs/components/prism-ftl.js';
|
||||
import 'prismjs/components/prism-gap.js';
|
||||
import 'prismjs/components/prism-gcode.js';
|
||||
import 'prismjs/components/prism-gdscript.js';
|
||||
import 'prismjs/components/prism-gedcom.js';
|
||||
import 'prismjs/components/prism-gettext.js';
|
||||
import 'prismjs/components/prism-gherkin.js';
|
||||
import 'prismjs/components/prism-git.js';
|
||||
import 'prismjs/components/prism-glsl.js';
|
||||
import 'prismjs/components/prism-gml.js';
|
||||
import 'prismjs/components/prism-gn.js';
|
||||
import 'prismjs/components/prism-go-module.js';
|
||||
import 'prismjs/components/prism-go.js';
|
||||
import 'prismjs/components/prism-gradle.js';
|
||||
import 'prismjs/components/prism-graphql.js';
|
||||
import 'prismjs/components/prism-groovy.js';
|
||||
import 'prismjs/components/prism-haml.js';
|
||||
import 'prismjs/components/prism-handlebars.js';
|
||||
import 'prismjs/components/prism-haskell.js';
|
||||
import 'prismjs/components/prism-haxe.js';
|
||||
import 'prismjs/components/prism-hcl.js';
|
||||
import 'prismjs/components/prism-hlsl.js';
|
||||
import 'prismjs/components/prism-hoon.js';
|
||||
import 'prismjs/components/prism-hpkp.js';
|
||||
import 'prismjs/components/prism-hsts.js';
|
||||
import 'prismjs/components/prism-http.js';
|
||||
import 'prismjs/components/prism-ichigojam.js';
|
||||
import 'prismjs/components/prism-icon.js';
|
||||
import 'prismjs/components/prism-icu-message-format.js';
|
||||
import 'prismjs/components/prism-idris.js';
|
||||
import 'prismjs/components/prism-iecst.js';
|
||||
import 'prismjs/components/prism-ignore.js';
|
||||
import 'prismjs/components/prism-inform7.js';
|
||||
import 'prismjs/components/prism-ini.js';
|
||||
import 'prismjs/components/prism-io.js';
|
||||
import 'prismjs/components/prism-j.js';
|
||||
import 'prismjs/components/prism-java.js';
|
||||
import 'prismjs/components/prism-javadoclike.js';
|
||||
import 'prismjs/components/prism-javascript.js';
|
||||
import 'prismjs/components/prism-javastacktrace.js';
|
||||
import 'prismjs/components/prism-jexl.js';
|
||||
import 'prismjs/components/prism-jolie.js';
|
||||
import 'prismjs/components/prism-jq.js';
|
||||
import 'prismjs/components/prism-js-extras.js';
|
||||
import 'prismjs/components/prism-js-templates.js';
|
||||
import 'prismjs/components/prism-json.js';
|
||||
import 'prismjs/components/prism-json5.js';
|
||||
import 'prismjs/components/prism-jsonp.js';
|
||||
import 'prismjs/components/prism-jsstacktrace.js';
|
||||
import 'prismjs/components/prism-jsx.js';
|
||||
import 'prismjs/components/prism-julia.js';
|
||||
import 'prismjs/components/prism-keepalived.js';
|
||||
import 'prismjs/components/prism-keyman.js';
|
||||
import 'prismjs/components/prism-kotlin.js';
|
||||
import 'prismjs/components/prism-kumir.js';
|
||||
import 'prismjs/components/prism-kusto.js';
|
||||
import 'prismjs/components/prism-latex.js';
|
||||
import 'prismjs/components/prism-latte.js';
|
||||
import 'prismjs/components/prism-less.js';
|
||||
import 'prismjs/components/prism-lilypond.js';
|
||||
import 'prismjs/components/prism-linker-script.js';
|
||||
import 'prismjs/components/prism-liquid.js';
|
||||
import 'prismjs/components/prism-lisp.js';
|
||||
import 'prismjs/components/prism-livescript.js';
|
||||
import 'prismjs/components/prism-llvm.js';
|
||||
import 'prismjs/components/prism-log.js';
|
||||
import 'prismjs/components/prism-lolcode.js';
|
||||
import 'prismjs/components/prism-lua.js';
|
||||
import 'prismjs/components/prism-magma.js';
|
||||
import 'prismjs/components/prism-makefile.js';
|
||||
import 'prismjs/components/prism-markdown.js';
|
||||
import 'prismjs/components/prism-markup-templating.js';
|
||||
import 'prismjs/components/prism-markup.js';
|
||||
import 'prismjs/components/prism-mata.js';
|
||||
import 'prismjs/components/prism-matlab.js';
|
||||
import 'prismjs/components/prism-maxscript.js';
|
||||
import 'prismjs/components/prism-mel.js';
|
||||
import 'prismjs/components/prism-mermaid.js';
|
||||
import 'prismjs/components/prism-metafont.js';
|
||||
import 'prismjs/components/prism-mizar.js';
|
||||
import 'prismjs/components/prism-mongodb.js';
|
||||
import 'prismjs/components/prism-monkey.js';
|
||||
import 'prismjs/components/prism-moonscript.js';
|
||||
import 'prismjs/components/prism-n1ql.js';
|
||||
import 'prismjs/components/prism-n4js.js';
|
||||
import 'prismjs/components/prism-nand2tetris-hdl.js';
|
||||
import 'prismjs/components/prism-naniscript.js';
|
||||
import 'prismjs/components/prism-nasm.js';
|
||||
import 'prismjs/components/prism-neon.js';
|
||||
import 'prismjs/components/prism-nevod.js';
|
||||
import 'prismjs/components/prism-nginx.js';
|
||||
import 'prismjs/components/prism-nim.js';
|
||||
import 'prismjs/components/prism-nix.js';
|
||||
import 'prismjs/components/prism-nsis.js';
|
||||
import 'prismjs/components/prism-objectivec.js';
|
||||
import 'prismjs/components/prism-ocaml.js';
|
||||
import 'prismjs/components/prism-odin.js';
|
||||
import 'prismjs/components/prism-opencl.js';
|
||||
import 'prismjs/components/prism-openqasm.js';
|
||||
import 'prismjs/components/prism-oz.js';
|
||||
import 'prismjs/components/prism-parigp.js';
|
||||
import 'prismjs/components/prism-parser.js';
|
||||
import 'prismjs/components/prism-pascal.js';
|
||||
import 'prismjs/components/prism-pascaligo.js';
|
||||
import 'prismjs/components/prism-pcaxis.js';
|
||||
import 'prismjs/components/prism-peoplecode.js';
|
||||
import 'prismjs/components/prism-perl.js';
|
||||
import 'prismjs/components/prism-php-extras.js';
|
||||
import 'prismjs/components/prism-php.js';
|
||||
import 'prismjs/components/prism-phpdoc.js';
|
||||
import 'prismjs/components/prism-plant-uml.js';
|
||||
import 'prismjs/components/prism-powerquery.js';
|
||||
import 'prismjs/components/prism-powershell.js';
|
||||
import 'prismjs/components/prism-processing.js';
|
||||
import 'prismjs/components/prism-prolog.js';
|
||||
import 'prismjs/components/prism-promql.js';
|
||||
import 'prismjs/components/prism-properties.js';
|
||||
import 'prismjs/components/prism-protobuf.js';
|
||||
import 'prismjs/components/prism-psl.js';
|
||||
import 'prismjs/components/prism-pug.js';
|
||||
import 'prismjs/components/prism-puppet.js';
|
||||
import 'prismjs/components/prism-pure.js';
|
||||
import 'prismjs/components/prism-purebasic.js';
|
||||
import 'prismjs/components/prism-purescript.js';
|
||||
import 'prismjs/components/prism-python.js';
|
||||
import 'prismjs/components/prism-q.js';
|
||||
import 'prismjs/components/prism-qml.js';
|
||||
import 'prismjs/components/prism-qore.js';
|
||||
import 'prismjs/components/prism-qsharp.js';
|
||||
import 'prismjs/components/prism-r.js';
|
||||
import 'prismjs/components/prism-reason.js';
|
||||
import 'prismjs/components/prism-regex.js';
|
||||
import 'prismjs/components/prism-rego.js';
|
||||
import 'prismjs/components/prism-renpy.js';
|
||||
import 'prismjs/components/prism-rescript.js';
|
||||
import 'prismjs/components/prism-rest.js';
|
||||
import 'prismjs/components/prism-rip.js';
|
||||
import 'prismjs/components/prism-roboconf.js';
|
||||
import 'prismjs/components/prism-robotframework.js';
|
||||
import 'prismjs/components/prism-ruby.js';
|
||||
import 'prismjs/components/prism-rust.js';
|
||||
import 'prismjs/components/prism-sas.js';
|
||||
import 'prismjs/components/prism-sass.js';
|
||||
import 'prismjs/components/prism-scala.js';
|
||||
import 'prismjs/components/prism-scheme.js';
|
||||
import 'prismjs/components/prism-scss.js';
|
||||
import 'prismjs/components/prism-shell-session.js';
|
||||
import 'prismjs/components/prism-smali.js';
|
||||
import 'prismjs/components/prism-smalltalk.js';
|
||||
import 'prismjs/components/prism-smarty.js';
|
||||
import 'prismjs/components/prism-sml.js';
|
||||
import 'prismjs/components/prism-solidity.js';
|
||||
import 'prismjs/components/prism-solution-file.js';
|
||||
import 'prismjs/components/prism-soy.js';
|
||||
import 'prismjs/components/prism-splunk-spl.js';
|
||||
import 'prismjs/components/prism-sqf.js';
|
||||
import 'prismjs/components/prism-sql.js';
|
||||
import 'prismjs/components/prism-squirrel.js';
|
||||
import 'prismjs/components/prism-stan.js';
|
||||
import 'prismjs/components/prism-stata.js';
|
||||
import 'prismjs/components/prism-stylus.js';
|
||||
import 'prismjs/components/prism-supercollider.js';
|
||||
import 'prismjs/components/prism-swift.js';
|
||||
import 'prismjs/components/prism-systemd.js';
|
||||
import 'prismjs/components/prism-t4-templating.js';
|
||||
import 'prismjs/components/prism-t4-vb.js';
|
||||
import 'prismjs/components/prism-tap.js';
|
||||
import 'prismjs/components/prism-tcl.js';
|
||||
import 'prismjs/components/prism-textile.js';
|
||||
import 'prismjs/components/prism-toml.js';
|
||||
import 'prismjs/components/prism-tremor.js';
|
||||
import 'prismjs/components/prism-diff.js';
|
||||
import 'prismjs/components/prism-docker.js';
|
||||
import 'prismjs/components/prism-markdown.js';
|
||||
import 'prismjs/components/prism-typescript.js'; // ts
|
||||
import 'prismjs/components/prism-jsx.js';
|
||||
import 'prismjs/components/prism-tsx.js';
|
||||
import 'prismjs/components/prism-tt2.js';
|
||||
import 'prismjs/components/prism-turtle.js';
|
||||
import 'prismjs/components/prism-twig.js';
|
||||
import 'prismjs/components/prism-typescript.js';
|
||||
import 'prismjs/components/prism-typoscript.js';
|
||||
import 'prismjs/components/prism-unrealscript.js';
|
||||
import 'prismjs/components/prism-uorazor.js';
|
||||
import 'prismjs/components/prism-uri.js';
|
||||
import 'prismjs/components/prism-v.js';
|
||||
import 'prismjs/components/prism-vala.js';
|
||||
import 'prismjs/components/prism-vbnet.js';
|
||||
import 'prismjs/components/prism-velocity.js';
|
||||
import 'prismjs/components/prism-verilog.js';
|
||||
import 'prismjs/components/prism-vhdl.js';
|
||||
import 'prismjs/components/prism-vim.js';
|
||||
import 'prismjs/components/prism-visual-basic.js';
|
||||
import 'prismjs/components/prism-warpscript.js';
|
||||
import 'prismjs/components/prism-wasm.js';
|
||||
import 'prismjs/components/prism-web-idl.js';
|
||||
import 'prismjs/components/prism-wgsl.js';
|
||||
import 'prismjs/components/prism-wiki.js';
|
||||
import 'prismjs/components/prism-wolfram.js';
|
||||
import 'prismjs/components/prism-wren.js';
|
||||
import 'prismjs/components/prism-xeora.js';
|
||||
import 'prismjs/components/prism-xml-doc.js';
|
||||
import 'prismjs/components/prism-xojo.js';
|
||||
import 'prismjs/components/prism-xquery.js';
|
||||
import 'prismjs/components/prism-yaml.js';
|
||||
import 'prismjs/components/prism-yang.js';
|
||||
import 'prismjs/components/prism-zig.js';
|
||||
import 'prismjs/components/prism-arduino.js';
|
||||
|
||||
// Broken:
|
||||
//
|
||||
// import 'prismjs/components/prism-bison.js';
|
||||
// import 'prismjs/components/prism-chaiscript.js';
|
||||
// import 'prismjs/components/prism-core.js';
|
||||
// import 'prismjs/components/prism-crystal.js';
|
||||
// import 'prismjs/components/prism-django.js';
|
||||
// import 'prismjs/components/prism-javadoc.js';
|
||||
// import 'prismjs/components/prism-jsdoc.js';
|
||||
// import 'prismjs/components/prism-plsql.js';
|
||||
// import 'prismjs/components/prism-racket.js';
|
||||
// import 'prismjs/components/prism-sparql.js';
|
||||
// import 'prismjs/components/prism-t4-cs.js';
|
||||
|
||||
import './ReactPrism.css';
|
||||
// using classNames .prism-dark .prism-light from ReactPrism.css
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { addRecentEmoji, getRecentEmojis, IRecentEmojiContent } from './recent-emoji';
|
||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||
import { emojis } from './emoji';
|
||||
import { emojis, loadEmojiData } from './emoji';
|
||||
|
||||
// A Map-backed MatrixClient stub supporting get/setAccountData.
|
||||
const createMx = () => {
|
||||
@@ -25,6 +25,9 @@ const createMx = () => {
|
||||
const getStored = (store: Map<string, unknown>): IRecentEmojiContent['recent_emoji'] =>
|
||||
(store.get(AccountDataEvent.ElementRecentEmoji) as IRecentEmojiContent | undefined)?.recent_emoji;
|
||||
|
||||
// Emoji data is now loaded lazily; populate `emojis` before the round trips.
|
||||
await loadEmojiData();
|
||||
|
||||
// Pick two real unicode emojis to drive add->get round trips.
|
||||
const u1 = emojis[0].unicode;
|
||||
const u2 = emojis[1].unicode;
|
||||
|
||||
@@ -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)}`;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test } from 'node:test';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Guard against same-directory filenames that differ only by case (e.g.
|
||||
* `threadSummary.ts` vs `ThreadSummary.tsx`). On case-insensitive filesystems
|
||||
* (the Windows release runner) an extensionless import of one can resolve to
|
||||
* the OTHER file — rolldown tries `.ts` before `.tsx` — producing
|
||||
* MISSING_EXPORT failures that never reproduce on the Linux/macOS machines the
|
||||
* project is developed and web-deployed on. This broke the desktop release
|
||||
* build twice before being diagnosed; this test makes the collision a local,
|
||||
* immediate failure instead.
|
||||
*/
|
||||
const findCaseCollisions = (dir: string, collisions: string[]): void => {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
const seen = new Map<string, string>();
|
||||
entries.forEach((entry) => {
|
||||
// Compare basenames without extension: `Foo.tsx` collides with `foo.ts`
|
||||
// because module resolution is extensionless.
|
||||
const stem = entry.isDirectory() ? entry.name : entry.name.replace(/\.[^.]+$/, '');
|
||||
const key = stem.toLowerCase();
|
||||
const existing = seen.get(key);
|
||||
if (existing !== undefined && existing !== stem) {
|
||||
collisions.push(`${dir}: "${existing}" vs "${stem}"`);
|
||||
}
|
||||
if (existing === undefined) seen.set(key, stem);
|
||||
if (entry.isDirectory()) {
|
||||
findCaseCollisions(join(dir, entry.name), collisions);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
test('no same-directory filenames differing only by case under src/', () => {
|
||||
const collisions: string[] = [];
|
||||
findCaseCollisions('src', collisions);
|
||||
assert.deepEqual(
|
||||
collisions,
|
||||
[],
|
||||
`Case-colliding names break Windows builds:\n${collisions.join('\n')}`,
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,39 @@
|
||||
/// <reference lib="WebWorker" />
|
||||
|
||||
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
||||
|
||||
export type {};
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
// Replaced at build time by vite-plugin-pwa (injectManifest) with the list of
|
||||
// hashed build assets to precache. See vite.config.js VitePWA injectManifest.
|
||||
__WB_MANIFEST: Array<string | PrecacheEntry>;
|
||||
};
|
||||
|
||||
/**
|
||||
* PRECACHE (workbox-precaching). `self.__WB_MANIFEST` is replaced at build time
|
||||
* by vite-plugin-pwa with the list of hashed build assets
|
||||
* (assets/**\/*.{js,css,wasm}; see vite.config.js injectManifest.globPatterns).
|
||||
*
|
||||
* DEPLOY-SAFETY INVARIANTS (do not break):
|
||||
* 1. index.html / navigations are NEVER precached or precache-routed. The
|
||||
* manifest globs only `assets/**` (content-hashed), so index.html (served
|
||||
* from the app root) is absent from it and navigation requests fall through
|
||||
* to the network — a new deploy is picked up immediately, no stale SPA
|
||||
* shell. We deliberately do NOT register a navigation route /
|
||||
* createHandlerBoundToURL fallback.
|
||||
* 2. precacheAndRoute only matches its own manifest URLs (same-origin hashed
|
||||
* assets). It never matches the media-auth paths handled by the fetch
|
||||
* listener below — those are cross-origin homeserver URLs absent from the
|
||||
* manifest — so the existing media fetch behaviour is fully preserved. It
|
||||
* is registered before that listener; for a media request the precache
|
||||
* route finds no match and does not call respondWith, so the media handler
|
||||
* still runs.
|
||||
* 3. Assets are content-hashed, so a changed asset ships under a new filename;
|
||||
* PrecacheController drops entries no longer in the current manifest on
|
||||
* activate, so the precache self-updates each deploy without unbounded
|
||||
* growth.
|
||||
*/
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
|
||||
type SessionInfo = {
|
||||
accessToken: string;
|
||||
|
||||
+13
-1
@@ -261,7 +261,19 @@ export default defineConfig({
|
||||
injectRegister: false,
|
||||
manifest: false,
|
||||
injectManifest: {
|
||||
injectionPoint: undefined,
|
||||
// PRECACHE (P5): emit `self.__WB_MANIFEST` into src/sw.ts so it can
|
||||
// precacheAndRoute the hashed build assets. index.html is deliberately
|
||||
// EXCLUDED from the manifest (globs only `assets/**`) so navigations
|
||||
// stay network-first and a new deploy is picked up immediately — see
|
||||
// the deploy-safety invariants documented in src/sw.ts.
|
||||
injectionPoint: 'self.__WB_MANIFEST',
|
||||
globPatterns: ['assets/**/*.{js,css,wasm}'],
|
||||
// Assets are content-hashed, so the filename is the cache key — don't
|
||||
// append a revision cache-busting param.
|
||||
dontCacheBustURLsMatching: /assets\//,
|
||||
// Raised above the 2 MB default so the ~5.5 MB matrix-sdk crypto wasm
|
||||
// (hash-busted and hot on every session) is precached deliberately.
|
||||
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,
|
||||
// codeSplitting: false is not yet supported by vite-plugin-pwa 1.3.0;
|
||||
// the inlineDynamicImports deprecation warning from Vite is from pwa internal build
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user