Compare commits

...

17 Commits

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:15:48 -04:00
jared 23f715857c docs: mark P4-8 (search cache) + session-atomicity as shipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:09:50 -04:00
jared f589182709 docs: deep-audit wave dispositions in LOTUS_BUGS
CI / Build & Quality Checks (push) Successful in 10m57s
CI / Trigger Desktop Build (push) Successful in 7s
Dep triage recorded (zero shipped exposure; SDK now 41.7.0 stable; dompurify
removed); Needs Verification rows for the audit-wave fixes (scheduled-cancel,
emoji lazy-load, SW precache, desktop CSP smoke).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:19:50 -04:00
jared ef573376ac chore(deps): matrix-js-sdk 41.6.0-rc.0 → 41.7.0 stable
Off the release candidate onto stable: pulls matrix-sdk-crypto-wasm 18.3.1 (a
security update) + MSC4140 delayed-event auth fixes. Thread/receipt API
signatures spot-checked unchanged (sendEvent threadId overloads, sendReceipt
unthreaded arg). Gates green: tsc/build/658 tests. E2EE runtime behavior needs
the usual live smoke (send/receive in an encrypted room, call keys).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:19:21 -04:00
jared 34d9272790 feat(call): denoise asset smoke check at ML-tier call start
HEAD-checks the copied denoise worklet/wasm/model assets for the selected model
and console.warns a single line listing anything missing — a silent asset skew
between the EC fork's expectations and vite's copied files would otherwise
disable noise suppression with no signal. Fire-and-forget; never blocks call
setup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:19:16 -04:00
jared 96f7187031 perf(audit): emojibase lazy-split, SW precache, Prism subset, lazy images
- emojibase (~965 KB) is now fully lazy: plugins/emoji.ts loads compact data +
  shortcode maps via a memoized dynamic import (rejections reset the memo so a
  mid-deploy chunk 404 can retry); reaction labels degrade to the raw glyph
  until loaded. Consumers get FRESH array references on load (the module arrays
  populate in place — same-ref state updates would skip re-render and leave
  emoji search empty; reviewer-caught). Verified out of the eager graph.
- Service worker precaches hashed assets (workbox precacheAndRoute, 82 entries
  ~10.8 MB incl. the crypto wasm): repeat visits stop re-downloading the app.
  index.html is NOT precached — navigations stay network-first so deploys are
  picked up immediately; the media-auth fetch handler is untouched.
- ReactPrism: curated 21-language set — chunk 574 KB → 71 KB.
- Timeline inline images get loading="lazy".
- Removed dead dompurify (+types); sanitize-html is the real sanitizer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:19:16 -04:00
jared 664dcd4cd8 fix(audit): correctness wave — ghost sends, Escape coordination, panel exclusion
- ScheduledMessagesTray: cancel prunes local state ONLY on confirmed server
  cancel; failures keep the item + show an inline error (was: a failed cancel
  looked cancelled but still sent at the scheduled time).
- Escape semantics: the composer consumes Escape (preventDefault+stopPropagation)
  iff autocomplete is open or a reply draft is set; the thread panel and Room's
  markAsRead act only on unconsumed Escape, and markAsRead defers entirely while
  a thread panel is open (listener order made it fire before the panel closed).
- Room: thread panel / media gallery are mutually exclusive (most-recently-
  opened wins); on mobile at most one right panel renders (thread > gallery >
  members) instead of stacked fullscreen overlays.
- RemindMeDialog: busy-disabled presets (no more double-click duplicates),
  try/catch with inline error, close only on success.
- ThreadTimeline: "Jump to Latest" floating chip when scrolled up (RoomTimeline
  idiom).

From the 4-auditor deep-audit wave; reviewer-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:18:51 -04:00
jared 7f960b026b fix(build): complete the threadSummary rename — remove the old casing
CI / Build & Quality Checks (push) Successful in 10m44s
CI / Trigger Desktop Build (push) Successful in 7s
The deletions from the git-mv in 992d2b83 were unstaged by a concurrent
worktree operation before commit, so the pushed tree contained BOTH
threadSummary.ts and threadSummaryData.ts (and the Windows case-collision
persisted). This commit removes the stale originals; caseCollision.test.ts
would have failed CI on the incomplete state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 23:44:59 -04:00
jared 992d2b83b3 fix(build): rename threadSummary.ts — case-collision broke the Windows release
CI / Build & Quality Checks (push) Failing after 5m22s
CI / Trigger Desktop Build (push) Has been skipped
threadSummary.ts (pure helpers) and ThreadSummary.tsx (chip component) lived in
the same directory differing only by case. On the case-insensitive Windows
release runner, RoomTimeline's extensionless import of ./thread/ThreadSummary
resolved .ts BEFORE .tsx and matched the helper module → rolldown
MISSING_EXPORT "ThreadSummary" — invisible on every Linux/macOS build (and the
cause of the earlier masked pdf.worker failure). Helper module renamed to
threadSummaryData.ts (+ test), 3 importers updated.

Prevention: new caseCollision.test.ts walks src/ and fails on any same-directory
names differing only by case (extensionless compare, so Foo.tsx vs foo.ts is
caught) — verified it fails on the pre-rename tree. Runs in the hard CI gate.

Gates: tsc clean, eslint/prettier clean, build OK, 658/659 tests (1 IDB skip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 23:43:20 -04:00
62 changed files with 1313 additions and 656 deletions
+29 -24
View File
@@ -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. Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
| ID | Item | File / area | Test | | ID | Item | File / area | Test |
| :--- | :------------------------------------------------------------------------------------- | :--------------------------------------------------- | :-------------------------------------------------------------------------------- | | :--- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------- |
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 | | #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 | | #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 | | #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 | | #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 | | #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 | | #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 | | #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
| #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 | | #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 |
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 | | N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 | | N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 | | N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 | | EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room | | N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 | | Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I | | a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
| P3-8 | Thread Panel (side drawer, chips, threaded receipts, thread composer) | `features/room/thread/*`, `RoomTimeline/RoomInput` | 6-step checklist in LOTUS_TODO §P3-8 | | P3-8 | Thread Panel (side drawer, chips, threaded receipts, thread composer) | `features/room/thread/*`, `RoomTimeline/RoomInput` | 6-step checklist in LOTUS_TODO §P3-8 |
| P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk) | `utils/mathParse.ts`, `components/math/` | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text) | | P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk) | `utils/mathParse.ts`, `components/math/` | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text) |
| P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes | | P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes |
| N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth | | N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth |
| P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math) | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread` | 6-step checklist in LOTUS_TODO §P4-1 | | P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math) | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread` | 6-step checklist in LOTUS_TODO §P4-1 |
| AW-1 | Scheduled-message cancel no longer ghost-sends (error row on failure) | `ScheduledMessagesTray.tsx` | schedule → cancel with network cut → item stays + error; retry works |
| AW-2 | Emoji lazy-load (search/autocomplete/recents fill in; board opens fast) | `plugins/emoji.ts` + consumers | first emoji-board open of a session: grid+search populate; reactions still label |
| AW-3 | SW precache (repeat-visit near-instant; deploys still picked up immediately) | `sw.ts`, `vite.config.js` | load app twice (2nd = cached assets); deploy → reload picks new version |
| AW-4 | Desktop CSP tighten + Escape/panel fixes + thread Jump to Latest | `tauri.conf.json`, Room/ThreadPanel | desktop: boots, avatars/media load, VT323 font renders, location maps embed, calls connect, deep links work |
| P3-4 | Accessibility compliance pass (collapsed-msg SR sender, form/overlay labels, typing announce, focus-return, `?` help, jsx-a11y CI gate) | `message/*`, `RoomViewTyping`, `features/shortcuts/*`, `eslint.config.mjs` | LOTUS_TESTING §P — axe-core + VoiceOver/NVDA on the golden path |
**Verified working in live testing (2026-06):** A2, B1B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression. **Verified working in live testing (2026-06):** A2, B1B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
@@ -135,7 +140,7 @@ retry … AbortError: Restart delayed event timed out before the HS responded`,
### Security & Privacy ### Security & Privacy
- **N97 — Access token stored in plaintext `localStorage`** (`state/sessions.ts`), vulnerable to XSS; device ID likewise. Architectural — needs a token-protection / session-storage redesign. - **N97 — Access token stored in plaintext `localStorage`** (`state/sessions.ts`), vulnerable to XSS; device ID likewise. Architectural — needs a token-protection / session-storage redesign.
- **Session writes are non-atomic and not cross-tab synced** (`state/sessions.ts`) — risks inconsistent state / races across tabs. - ~~**Session writes are non-atomic and not cross-tab synced**~~ — **done (2026-07):** atomic single-key `cinny_session_v1` blob (legacy-key migration + dual-write) + `subscribeSessionChanges`/`useSessionSync` cross-tab reload. (The plaintext-token concern in N97 above is the remaining, separate architectural item.)
- **Persisted PII without encryption:** user status message + expiry (`settings/account/Profile.tsx`), unsent composer drafts (`room/RoomInput.tsx`). Leak risk on shared devices. - **Persisted PII without encryption:** user status message + expiry (`settings/account/Profile.tsx`), unsent composer drafts (`room/RoomInput.tsx`). Leak risk on shared devices.
### PWA / Offline / Notifications ### PWA / Offline / Notifications
@@ -146,7 +151,7 @@ retry … AbortError: Restart delayed event timed out before the HS responded`,
### Dependencies & Build ### 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. - **Build-time overhead:** `lotusDenoise` does heavy sequential `fs` work in `closeBundle`; `viteStaticCopy` config is complex with redundant renames — could be streamlined.
### Code Hygiene / DevEx ### Code Hygiene / DevEx
+29 -24
View File
@@ -17,17 +17,17 @@
From the matrix infra README (`/root/code/matrix/README.md`): From the matrix infra README (`/root/code/matrix/README.md`):
| Thing | Value | | Thing | Value |
|-------|-------| | ------------------------ | ------------------------------------------------------------- |
| Synapse host | LXC **151**, `10.10.10.29` (Synapse 1.155.0) | | Synapse host | LXC **151**, `10.10.10.29` (Synapse 1.155.0) |
| Synapse log | `/var/log/matrix-synapse/homeserver.log` | | Synapse log | `/var/log/matrix-synapse/homeserver.log` |
| Synapse config | `/etc/matrix-synapse/homeserver.yaml` (+ `conf.d/`) | | Synapse config | `/etc/matrix-synapse/homeserver.yaml` (+ `conf.d/`) |
| Synapse HTTP | `10.10.10.29:8008` | | Synapse HTTP | `10.10.10.29:8008` |
| PostgreSQL host | LXC **109**, `10.10.10.44` (PG 17.9), db `synapse` | | PostgreSQL host | LXC **109**, `10.10.10.44` (PG 17.9), db `synapse` |
| synapse-admin UI | `http://10.10.10.29:8080` | | synapse-admin UI | `http://10.10.10.29:8080` |
| LiveKit / lk-jwt / guard | LXC 151: LiveKit `:7880/:7881`, guard `:8070`, lk-jwt `:8071` | | LiveKit / lk-jwt / guard | LXC 151: LiveKit `:7880/:7881`, guard `:8070`, lk-jwt `:8071` |
| SSH path to Synapse | `ssh root@10.10.10.4` then `pct enter 151` | | SSH path to Synapse | `ssh root@10.10.10.4` then `pct enter 151` |
| SSH path to PG | `ssh root@10.10.10.4` then `pct enter 109` | | SSH path to PG | `ssh root@10.10.10.4` then `pct enter 109` |
**Getting a psql shell** (run on LXC 109, or from 151 over the network): **Getting a psql shell** (run on LXC 109, or from 151 over the network):
@@ -50,10 +50,10 @@ temporarily raise it in `/etc/matrix-synapse/conf.d/log.yaml` (or the
```yaml ```yaml
loggers: loggers:
synapse.rest.client.keys: { level: DEBUG } synapse.rest.client.keys: { level: DEBUG }
synapse.handlers.e2e_keys: { level: DEBUG } synapse.handlers.e2e_keys: { level: DEBUG }
synapse.storage.databases.main.end_to_end_keys: { level: DEBUG } synapse.storage.databases.main.end_to_end_keys: { level: DEBUG }
synapse.handlers.devicemessage: { level: DEBUG } # to-device synapse.handlers.devicemessage: { level: DEBUG } # to-device
``` ```
Then `systemctl reload matrix-synapse` (reload re-reads log config without a Then `systemctl reload matrix-synapse` (reload re-reads log config without a
@@ -86,6 +86,7 @@ use it as the primary client artifact and DevTools as the raw backup.
/var/log/matrix-synapse/homeserver.log | grep "<user_id>" /var/log/matrix-synapse/homeserver.log | grep "<user_id>"
``` ```
- **Synapse SQL (LXC 109) — what the server thinks it holds:** - **Synapse SQL (LXC 109) — what the server thinks it holds:**
```sql ```sql
-- Current OTK inventory for the device (compare key_id set against the -- Current OTK inventory for the device (compare key_id set against the
-- request body the client keeps retrying). -- request body the client keeps retrying).
@@ -106,8 +107,10 @@ use it as the primary client artifact and DevTools as the raw backup.
FROM e2e_fallback_keys_json FROM e2e_fallback_keys_json
WHERE user_id = '@user:matrix.lotusguild.org' AND device_id = '<DEVICE_ID>'; WHERE user_id = '@user:matrix.lotusguild.org' AND device_id = '<DEVICE_ID>';
``` ```
> Table names are Synapse 1.155 (`e2e_one_time_keys_json`, > Table names are Synapse 1.155 (`e2e_one_time_keys_json`,
> `e2e_fallback_keys_json`). If a name is absent, list with `\dt e2e*` in psql. > `e2e_fallback_keys_json`). If a name is absent, list with `\dt e2e*` in psql.
- **Confirms:** if the offending `key_id` (from the 400) is **present** in - **Confirms:** if the offending `key_id` (from the 400) is **present** in
`e2e_one_time_keys_json` with a **different** stored value than the client's `e2e_one_time_keys_json` with a **different** stored value than the client's
request body → OTK state has diverged (rust-crypto store vs Synapse). That is request body → OTK state has diverged (rust-crypto store vs Synapse). That is
@@ -132,6 +135,7 @@ use it as the primary client artifact and DevTools as the raw backup.
/var/log/matrix-synapse/homeserver.log | grep -E "<user_id>|<participant_id>" /var/log/matrix-synapse/homeserver.log | grep -E "<user_id>|<participant_id>"
``` ```
- **Synapse SQL (LXC 109) — undelivered / queued to-device events:** - **Synapse SQL (LXC 109) — undelivered / queued to-device events:**
```sql ```sql
-- Backlog of to-device messages queued for the affected device. A growing -- Backlog of to-device messages queued for the affected device. A growing
-- count here = the HS has the media-key events but the device isn't draining -- count here = the HS has the media-key events but the device isn't draining
@@ -145,6 +149,7 @@ use it as the primary client artifact and DevTools as the raw backup.
SELECT device_id, display_name, last_seen, ts SELECT device_id, display_name, last_seen, ts
FROM devices WHERE user_id = '@user:matrix.lotusguild.org'; FROM devices WHERE user_id = '@user:matrix.lotusguild.org';
``` ```
- **Confirms:** to-device events present but undecryptable (client shows the - **Confirms:** to-device events present but undecryptable (client shows the
`io.element.call.encryption_keys` "unexpected encrypted" warning) ⇒ there is `io.element.call.encryption_keys` "unexpected encrypted" warning) ⇒ there is
**no valid Olm session** to decrypt them — the expected downstream of KE-1. **no valid Olm session** to decrypt them — the expected downstream of KE-1.
@@ -265,7 +270,7 @@ Q4. KE-4: do delayed-event timeouts coincide with Synapse p99 latency spikes
> "fix" before the planning session** — they are listed so evidence collection > "fix" before the planning session** — they are listed so evidence collection
> can be paired with a recovery plan. Confirm the root condition (Q1/Q2) first. > can be paired with a recovery plan. Confirm the root condition (Q1/Q2) first.
1. **Per-device logout + re-login of the affected device** *(lowest blast radius)* 1. **Per-device logout + re-login of the affected device** _(lowest blast radius)_
- **What:** log the one glitching device out and back in. Forces a fresh - **What:** log the one glitching device out and back in. Forces a fresh
device id, fresh device keys, and a clean OTK batch — sidesteps a diverged device id, fresh device keys, and a clean OTK batch — sidesteps a diverged
OTK store without touching other sessions. OTK store without touching other sessions.
@@ -275,7 +280,7 @@ Q4. KE-4: do delayed-event timeouts coincide with Synapse p99 latency spikes
- **Confirms/uses:** if KE-1 stops after this, OTK-store divergence (Q1) was - **Confirms/uses:** if KE-1 stops after this, OTK-store divergence (Q1) was
the cause. the cause.
2. **Client crypto-store reset (`clearLoginData` path)** *(medium)* 2. **Client crypto-store reset (`clearLoginData` path)** _(medium)_
- **What:** `clearLoginData()` in `src/client/initMatrix.ts` (coordinator's - **What:** `clearLoginData()` in `src/client/initMatrix.ts` (coordinator's
file — do not edit) **deletes ALL IndexedDB databases** (incl. file — do not edit) **deletes ALL IndexedDB databases** (incl.
`web-sync-store` and the rust-crypto store `crypto-store`), **unregisters `web-sync-store` and the rust-crypto store `crypto-store`), **unregisters
@@ -294,16 +299,16 @@ Q4. KE-4: do delayed-event timeouts coincide with Synapse p99 latency spikes
- **Use when:** the rust-crypto store itself is corrupt/diverged and option 1 - **Use when:** the rust-crypto store itself is corrupt/diverged and option 1
didn't clear it. didn't clear it.
3. **SDK pin change off the RC** *(medium — codebase change, needs rebuild)* 3. **SDK pin change off the RC** _(medium — codebase change, needs rebuild)_
- **Current pin:** `package.json` → `"matrix-js-sdk": "41.6.0-rc.0"` (a - **Current pin:** `package.json` → `"matrix-js-sdk": "41.6.0-rc.0"` (a
release candidate). release candidate).
- **Finding (npm / GitHub changelog, checked 2026-07):** stable **`41.6.0`** - **Finding (npm / GitHub changelog, checked 2026-07):** stable **`41.6.0`**
was released **2026-05-26**. Its only changelog line is *"Throw sane error was released **2026-05-26**. Its only changelog line is _"Throw sane error
on completeLoginOnNewDevice IdP rejection"* — **no OTK / keys-upload / Olm / on completeLoginOnNewDevice IdP rejection"_ — **no OTK / keys-upload / Olm /
to-device fix** relative to the RC. Later stable lines exist to-device fix** relative to the RC. Later stable lines exist
(`41.7.0`, `41.8.0`; `41.7.0-rc.3` / `41.9.0-rc.0` seen as pre-releases). (`41.7.0`, `41.8.0`; `41.7.0-rc.3` / `41.9.0-rc.0` seen as pre-releases).
Nearby crypto-relevant entries: `41.5.0` *"Enable encrypted history sharing Nearby crypto-relevant entries: `41.5.0` _"Enable encrypted history sharing
by default"*; `41.4.0` key-backup handling. **No changelog entry directly by default"_; `41.4.0` key-backup handling. **No changelog entry directly
addresses the KE-1 OTK-conflict symptom** in the immediate range — so addresses the KE-1 OTK-conflict symptom** in the immediate range — so
moving RC→`41.6.0` stable is a low-risk hygiene step but is **not expected moving RC→`41.6.0` stable is a low-risk hygiene step but is **not expected
to fix KE-1 by itself**. Before pinning, re-read the CHANGELOG for any to fix KE-1 by itself**. Before pinning, re-read the CHANGELOG for any
@@ -312,7 +317,7 @@ Q4. KE-4: do delayed-event timeouts coincide with Synapse p99 latency spikes
rust-crypto IndexedDB schema — a downgrade triggers the `IDB_VERSION_CONFLICT` rust-crypto IndexedDB schema — a downgrade triggers the `IDB_VERSION_CONFLICT`
path in `initMatrix.ts`. path in `initMatrix.ts`.
4. **Synapse-side OTK row surgery** *(LAST RESORT — highest danger)* 4. **Synapse-side OTK row surgery** _(LAST RESORT — highest danger)_
- **What:** deleting/rewriting rows in `e2e_one_time_keys_json` (and/or - **What:** deleting/rewriting rows in `e2e_one_time_keys_json` (and/or
`e2e_fallback_keys_json`, `device_inbox`) for the affected device to force `e2e_fallback_keys_json`, `device_inbox`) for the affected device to force
the client to re-upload a clean batch. the client to re-upload a clean batch.
@@ -324,7 +329,7 @@ Q4. KE-4: do delayed-event timeouts coincide with Synapse p99 latency spikes
- **Guardrails if ever done (planning session + HS owner only):** full - **Guardrails if ever done (planning session + HS owner only):** full
`pg_dump` of `synapse` first; do it during **zero active calls**; delete only `pg_dump` of `synapse` first; do it during **zero active calls**; delete only
the exact diverged `key_id` for the exact `device_id`; `systemctl restart the exact diverged `key_id` for the exact `device_id`; `systemctl restart
matrix-synapse` to flush caches; then log the device out/in (option 1) so it matrix-synapse` to flush caches; then log the device out/in (option 1) so it
republishes. **Never** run this speculatively. republishes. **Never** run this speculatively.
--- ---
@@ -337,7 +342,7 @@ Do these **in order**. Aim to have client + server capturing the **same call**.
`tail -F /var/log/matrix-synapse/homeserver.log | tee /tmp/lotus-call-$(date +%s).log`. `tail -F /var/log/matrix-synapse/homeserver.log | tee /tmp/lotus-call-$(date +%s).log`.
(Optionally raise the `synapse.rest.client.keys` / `handlers.e2e_keys` / (Optionally raise the `synapse.rest.client.keys` / `handlers.e2e_keys` /
`handlers.devicemessage` loggers to DEBUG per §0 and `systemctl reload `handlers.devicemessage` loggers to DEBUG per §0 and `systemctl reload
matrix-synapse` — remember to revert after.) matrix-synapse` — remember to revert after.)
2. **Prep client:** open Lotus Chat → Settings → Developer Tools → **enable 2. **Prep client:** open Lotus Chat → Settings → Developer Tools → **enable
Developer Tools** so the **Crypto Diagnostics** card is visible; note its Developer Tools** so the **Crypto Diagnostics** card is visible; note its
entry count starts at (or reset by reload to) 0. entry count starts at (or reset by reload to) 0.
@@ -373,7 +378,7 @@ Do these **in order**. Aim to have client + server capturing the **same call**.
with pass-through wrappers (originals always called) that ring-buffer (max with pass-through wrappers (originals always called) that ring-buffer (max
**200**) any line matching the KE signatures. No network, no timers. **200**) any line matching the KE signatures. No network, no timers.
- `getCryptoDiagEntries()` — snapshot copy of the buffer (`{ ts, level, ke, - `getCryptoDiagEntries()` — snapshot copy of the buffer (`{ ts, level, ke,
signature, message }`, most-recent-last). signature, message }`, most-recent-last).
- `buildCryptoDiagReport(mx)` — JSON string: SDK version, device id, user id, - `buildCryptoDiagReport(mx)` — JSON string: SDK version, device id, user id,
sync state, `cryptoReady` (`mx.getCrypto()` presence), per-KE counts, and the sync state, `cryptoReady` (`mx.getCrypto()` presence), per-KE counts, and the
entry buffer. No tokens/PII beyond those ids; captured log lines are retained entry buffer. No tokens/PII beyond those ids; captured log lines are retained
+12
View File
@@ -1179,6 +1179,18 @@ Three one-tap presets at the top of **Settings → Notifications** that apply a
--- ---
## Accessibility (P3-4)
WCAG 2.1 AA hardening of the golden path (find room → read → reply → send) for keyboard and screen-reader users.
- **Timeline for screen readers:** each message is `role="article"`; **collapsed messages announce their sender + time** (they drop the visible header, so AT would otherwise hear the body with no attribution). The timeline is a `role="log"` `aria-live="polite"` region so new messages are announced; emoji/emoticons carry text labels.
- **Live status:** typing indicators announce via a `role="status"` region; editing a message announces "Editing message from <sender>".
- **Forms & overlays:** all inputs have associated labels (visible `<label htmlFor>` or `aria-label`); the Media Gallery and Search overlays are named.
- **Focus management:** skip-to-content link + `nav`/`main` landmarks; genuine dialogs return focus to their trigger on close (inline popouts intentionally keep focus in context).
- **Keyboard-shortcuts help:** press <kbd>?</kbd> for a dialog of the existing shortcuts (Escape, type-to-focus composer, Enter/Shift+Enter send, message actions).
- **Regression gate:** a curated `eslint-plugin-jsx-a11y` rule set (ARIA correctness + label association) runs in CI. Files: `components/message/*`, `features/room/RoomViewTyping.tsx`, `features/shortcuts/*`, `utils/a11y.ts`, `eslint.config.mjs`.
- _Known limitation:_ list virtualization keeps far-scrolled history out of the a11y tree (perf trade-off); newly-arriving messages are announced.
## Infrastructure ## Infrastructure
### Authenticated Media ### Authenticated Media
+96 -6
View File
@@ -1,6 +1,6 @@
# Lotus Chat — Manual Testing Guide # Lotus Chat — Manual Testing Guide
**Generated:** June 2026 **Generated:** June 2026 · **Updated:** July 2026 (added §O — threads, per-thread notifications, math, search cache, session hardening, audit wave, desktop CSP)
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first. **Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual. > **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
@@ -573,10 +573,100 @@ Log into **matrix.lotusguild.org** (password) and **matrix.org**.
--- ---
## O. July 2026 batch — threads, notifications, math, search cache, audit wave
Everything landed after the OIDC work. These mirror the checklists in `LOTUS_TODO.md` (§P3-8, §P4-1) and the Needs-Verification rows in `LOTUS_BUGS.md` (P3-8/P4-1/P4-4/P4-8/N97a/AW-1…4). **⚠️ Threads change the main timeline** — thread replies no longer render inline; that's intended (see O1).
### O1. Thread Panel (P3-8) — 👥 2 people help for live replies
1. Hover a message → **Reply in Thread** (message menu). The right-side **thread panel** opens with that message as the root.
2. Send text, an emoji, and a file upload into the thread; have the second person reply too.
3. Reply to a reply _inside_ the panel.
**Expected:** the panel shows the root at top + an "N replies" divider + the reply timeline (own composer at the bottom). Your sends appear immediately (pending → confirmed). A reply-to-a-reply is a proper thread reply. In the **main** timeline the replies do **not** appear inline — the root message instead shows a **"N replies · time"** chip. Clicking the chip (or a reply's thread indicator) opens the panel. **×** or **Escape** closes it; on mobile the panel is fullscreen. Scrolled up in a long thread → a **Jump to Latest** chip appears. Reload the page → the root/reply split persists; in an **encrypted** room the thread replies decrypt (not "Unable to decrypt").
### O2. Per-thread notifications (P4-1, Slack-style) — 👥 2 people
1. Have the second person reply in a thread **you have posted in** → expect a notification + sound.
2. Have them reply in a thread **you have never touched** and don't @mention you → expect **silence** (only the chip's unread badge updates).
3. Have them **@mention** you in any thread → expect a notification regardless of participation.
4. Open the panel's **bell menu** (header) → set the thread to **Mute** → expect no notifications, the chip's unread badge gone (bell-mute glyph shown), and the room's **sidebar badge drops** by that thread's count. Try **All** (every reply notifies) and **Mentions only** (only @mentions).
5. On a **second device**, confirm the same per-thread modes are set (they sync via account data).
6. Room-level **Mute** (room context menu) still silences everything, including thread overrides.
**Known caveat:** Mentions-only can under-notify in E2EE rooms (the decision runs before decryption). Muted-thread badge subtraction is Lotus-only.
### O3. Math / LaTeX (P4-4)
Send each and confirm rendering: `$x^2 + y^2$` (inline), `$$\int_0^1 f(x)\,dx$$` (block, centered), `$5 and $10 for lunch` (**stays plain text** — currency guard), and a code block containing `$x$` (**stays literal** inside the code block). **Expected:** the first two render as math (KaTeX); the last two are untouched. First math of the session may show the raw `$…$` for a beat while the KaTeX chunk lazy-loads, then renders.
### O4. Encrypted search cache (P4-8) — opt-in
In an **encrypted** room's message search, enable **"Persist search index on this device"** (Encrypted Rooms panel). Search, then **reload** and search the same term. **Expected:** coverage survives the reload (results without re-paginating everything). **Clear cached index** empties it. **Log out** → the cache is wiped (privacy). Toggling the setting OFF does **not** wipe (only Clear/logout do).
### O5. Session hardening (N97a) — cross-tab
1. Log in on a build that predates the change, then load this build → you stay logged in (legacy keys migrate to the `cinny_session_v1` blob; check DevTools → Application → Local Storage).
2. Open the app in **two tabs**; **log out** in tab A → tab B reloads to the auth screen within a moment. Log in again in one tab → the other reloads too.
### O6. Audit-wave correctness fixes (AW-1)
- **Scheduled-message cancel:** schedule a message, then cancel it **with the network cut** (DevTools offline) → the item **stays** with an inline error (it does **not** silently disappear and still send). Restore network, retry → cancels cleanly.
- **Escape coordination:** in a thread panel, open the mention autocomplete or set a reply draft, press **Escape** → it dismisses the autocomplete/reply **without** closing the panel. A bare Escape (nothing to dismiss) still marks the room read / closes the panel as before.
- **Panel exclusivity:** on mobile, opening a thread while the media gallery (or members drawer) is open shows only **one** right panel (thread wins), not stacked fullscreen overlays.
- **Emoji board (AW-2):** the **first** time you open the emoji board / autocomplete in a session, the grid **and search** populate with unicode emoji (they don't stay empty). Reactions still show a label.
### O7. Desktop (Tauri) — CSP tighten + native stack (AW-4) — 🖥️ desktop build only
The webview CSP was tightened and the full native module set now compiles. Smoke-test the desktop build:
1. App **boots**, avatars + media thumbnails load, the **VT323** terminal font renders (Lotus Terminal theme), a **location message** embeds its OpenStreetMap map, **calls** connect (EC iframe), **deep links** (`matrix:` / clicking a room link) navigate.
2. **Native features:** minimize to tray (notifications still arrive), a message notification is a **rich toast** (click opens the room; reply box sends), the taskbar **Jump List** lists recent rooms, in a call the taskbar thumbnail shows **Mute/Deafen/End**, Windows **Focus Assist** silences Lotus.
3. **Console** (desktop devtools) shows **no CSP violations** during normal use. If something visual/media is blocked, that's the CSP to loosen — note exactly what and where.
### O8. E2EE / call-key cluster (KE-1→4) — 👥 2 people, during a real call
We shipped the diagnostics kit + a **Crypto Diagnostics** card (**Settings → Developer Tools**). During your next call that glitches (audio cutouts, "Unable to decrypt"), open it and **Download report**, and note whether the symptoms even still occur now that we're on **matrix-js-sdk 41.7.0** (crypto-wasm 18.3.1). Send me the report + `LOTUS_E2EE_INVESTIGATION.md` is the runbook.
---
## P. Accessibility (P3-4) — needs a browser + a screen reader
The compliance fixes are gate-verified in code; these confirm the runtime a11y behavior only a human + AT can check. Tools: browser DevTools "axe" extension / Lighthouse a11y, plus **VoiceOver** (macOS ⌘F5) or **NVDA** (Windows).
### P1. Keyboard-only golden path (no mouse)
Tab from page load: **skip-to-content** link appears first (Enter jumps to the timeline). Tab reaches the room list (rooms are focusable, active room announced), open a room (Enter), type a character → focus lands in the composer, send with Enter (or Shift+Enter per your `enterForNewline` setting). No keyboard trap; visible focus ring throughout.
### P2. `?` shortcuts dialog
Press **?** (Shift+/) with focus NOT in a text field → the keyboard-shortcuts dialog opens, is focus-trapped, Escape closes it and focus returns to where you were. Pressing `?` while typing in the composer/search inserts a literal `?` (does NOT open the dialog).
### P3. Screen-reader: reading messages
With VoiceOver/NVDA on, arrow through the timeline: each message is announced as an article with **sender name + time** — critically, this includes **collapsed messages** (consecutive messages from the same person), which previously announced only the body with no sender. Reactions, "edited", replies, and delivery status are announced with labels.
### P4. Screen-reader: live announcements
- **New message** arrives while you're reading → announced (polite).
- **Someone starts typing** → "X is typing" announced once (not spammed per keystroke).
- **Editing a message** → the edit box announces "Editing message from X".
### P5. Focus return from dialogs
Open then close (Escape or ×): the **room topic viewer**, a **reaction viewer** (click a reaction count), and **Search** → focus returns to the button/element you opened them from (not lost to `<body>`). Inline popouts (emoji picker, autocomplete, hover menus) intentionally keep focus in context — that's expected, not a bug.
### P6. axe / Lighthouse scan
Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view, Settings, and the login screen. Expect **no critical/serious** "missing accessible name" or "ARIA" violations on the golden path. Report any that appear (note: far-scrolled timeline history being virtualized out is a known, accepted limitation — not a finding).
---
## Priority if you're short on time ## Priority if you're short on time
1. **A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce. 1. **O1 + O2** (threads + per-thread notifications) — the largest new surface; the main-timeline change is user-visible.
2. **B1B3** (polls on a default theme) — the confirmed visual bug. 2. **O7** (desktop CSP smoke) — CI can't catch CSP breakage; a wrong directive silently breaks media/fonts/maps.
3. **D** (EC 0.20.1 control sweep) — guards against the upstream merge breaking calls. 3. **O5** (session cross-tab) + **O6** (scheduled-cancel ghost-send) — auth-critical + a real data-loss-class fix.
4. **A7** false-positive check (normal joins don't show the error overlay). 4. **A4** (in-call banner) + **A3** (ringtone) — newest call logic, hardest to reproduce.
5. Everything else. 5. **D** (EC control sweep) — guards against the fork breaking calls.
6. Everything else.
+24 -15
View File
@@ -141,7 +141,12 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
## Priority 3 — Higher complexity / lower daily frequency ## Priority 3 — Higher complexity / lower daily frequency
### [ ] P3-4 · Accessibility Improvements (WCAG 2.1 AA) ### [~] P3-4 · Accessibility Improvements (WCAG 2.1 AA) — COMPLIANCE PASS DONE (2026-07), ⚠️ AWAITING LIVE AXE/SR AUDIT
**Shipped (compliance + shortcuts-help tier):** messages `role="article"` + collapsed-message sender/time announced to AT (the biggest gap — collapsed rows had no sender for a screen reader); ~10 unlabeled form inputs + Media Gallery / Search overlays named; emoji/emoticon aria-labels; typing indicator now announced via a `role="status"` live region; editing a message announces "Editing message from X"; focus now returns to the trigger on close of 4 genuine dialogs (RoomIntro/Reactions/RoomViewHeader-topic/Search — inline popouts correctly left); a `?` keyboard-shortcuts help dialog; and a **jsx-a11y lint gate** (curated ARIA-correctness + label rules, enforced in CI) to prevent regressions. Already-good before this pass: skip link + landmarks, timeline `role="log"`/`aria-live`, ~99% icon-button labels, labeled editor.
**DEFERRED (documented):** virtualization keeps scrolled-away history out of the a11y tree (architectural; the live-region announces newly-arriving messages) — not re-architected to avoid perf regression; roving-tabindex + command palette + section-jump shortcuts (user-deferred); the live axe-core / VoiceOver+NVDA audit → LOTUS_TESTING §P.
_Original scope (for reference):_
**What:** Comprehensive audit and fix pass targeting the critical user paths: **What:** Comprehensive audit and fix pass targeting the critical user paths:
@@ -167,13 +172,14 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
Built per the design below (4-agent build + 2-agent review). Gates green (tsc/eslint/build/tests). **Release note: threaded replies no longer render inline in the main timeline — roots show a "N replies" chip that opens the panel.** Built per the design below (4-agent build + 2-agent review). Gates green (tsc/eslint/build/tests). **Release note: threaded replies no longer render inline in the main timeline — roots show a "N replies" chip that opens the panel.**
**Manual QA checklist (post-deploy):** **Manual QA checklist (post-deploy):**
1. Reply in Thread (message menu) → panel opens; send text/upload/emoji into it (appears pending → confirmed) 1. Reply in Thread (message menu) → panel opens; send text/upload/emoji into it (appears pending → confirmed)
2. Reply to a reply inside the panel → event carries `m.thread` + `m.in_reply_to` with `is_falling_back:false` 2. Reply to a reply inside the panel → event carries `m.thread` + `m.in_reply_to` with `is_falling_back:false`
3. Main timeline: root + chip only (replies absent); chip count/time updates live; unread badge appears for others' thread replies and clears after viewing the panel 3. Main timeline: root + chip only (replies absent); chip count/time updates live; unread badge appears for others' thread replies and clears after viewing the panel
4. Room badge clears via normal markAsRead even with unread threads (unthreaded receipt) 4. Room badge clears via normal markAsRead even with unread threads (unthreaded receipt)
5. Reload: partitioning persists; encrypted-room threads decrypt (back-pagination too) 5. Reload: partitioning persists; encrypted-room threads decrypt (back-pagination too)
6. Escape / × closes; mobile = fullscreen panel; switching rooms and back restores the open thread 6. Escape / × closes; mobile = fullscreen panel; switching rooms and back restores the open thread
**What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies. **What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies.
Features: Features:
@@ -209,23 +215,23 @@ Features:
**What:** Replace the manual "load more" button with an automated, virtualized infinite scroll for search results. **What:** Replace the manual "load more" button with an automated, virtualized infinite scroll for search results.
**Status:** Done in a prior session — `MessageSearch.tsx` already uses `useVirtualizer` (~line 336) over the result groups AND auto-fetches the `nextToken` page when the last virtual item scrolls into view (~line 469) via `useInfiniteQuery`. Nothing left to build. **Status:** Done in a prior session — `MessageSearch.tsx` already uses `useVirtualizer` (~line 336) over the result groups AND auto-fetches the `nextToken` page when the last virtual item scrolls into view (~line 469) via `useInfiniteQuery`. Nothing left to build.
### [ ] P4-8 · Encrypted Message Search Indexing & Caching ### [~] P4-8 · Encrypted Message Search Indexing & Caching — IMPLEMENTED (2026-07), opt-in
**What:** Implement a persistent local cache for search results, optimized for encrypted rooms. **Shipped:** `src/app/utils/searchCache.ts` — raw-IndexedDB per-room index (`lotus-search-cache`) of decrypted search rows + coverage markers, merged into local search (in-memory-wins dedupe). **Opt-in, default OFF** (stores plaintext at rest) with a privacy note, Clear button, and logout wipe. Awaiting live QA (LOTUS_BUGS AW / P4-8 row).
**Approach:** Use `IndexedDB` to store search metadata (event IDs, timestamps) to prevent redundant server-side decryption/fetching.
### [~] P4-1 · Thread Notification Mode Per-Thread — IMPLEMENTED (2026-07), ⚠️ AWAITING LIVE QA ### [~] P4-1 · Thread Notification Mode Per-Thread — IMPLEMENTED (2026-07), ⚠️ AWAITING LIVE QA
**Shipped (Slack-style):** default = **Participating** (notified only for threads you've posted in or where you're @mentioned); per-thread override **All / Mentions-only / Mute** via the bell menu in the thread panel header; modes sync across devices (`io.lotus.thread_notifications` account data, pruned on write). Mute also suppresses the chip badge and subtracts the thread from the room's sidebar badge (client-side). Also fixed the underlying path: thread replies are notified via exactly one handler (room-level `ThreadEvent.NewReply`), with the main-timeline notifier + unread binder thread-guarded, and live badge refresh on `RoomEvent.UnreadNotifications`. **Shipped (Slack-style):** default = **Participating** (notified only for threads you've posted in or where you're @mentioned); per-thread override **All / Mentions-only / Mute** via the bell menu in the thread panel header; modes sync across devices (`io.lotus.thread_notifications` account data, pruned on write). Mute also suppresses the chip badge and subtracts the thread from the room's sidebar badge (client-side). Also fixed the underlying path: thread replies are notified via exactly one handler (room-level `ThreadEvent.NewReply`), with the main-timeline notifier + unread binder thread-guarded, and live badge refresh on `RoomEvent.UnreadNotifications`.
**Manual QA checklist (post-deploy):** **Manual QA checklist (post-deploy):**
1. Friend replies in a thread YOU posted in → notification + sound; in a thread you never touched → silent (chip badge only) 1. Friend replies in a thread YOU posted in → notification + sound; in a thread you never touched → silent (chip badge only)
2. @mention in any thread → notified regardless of participation 2. @mention in any thread → notified regardless of participation
3. Set a thread to Mute → no notifications, chip badge gone (bell-mute glyph), room sidebar badge drops by that thread's count 3. Set a thread to Mute → no notifications, chip badge gone (bell-mute glyph), room sidebar badge drops by that thread's count
4. Set to All → every reply notifies; Mentions-only → only @mentions 4. Set to All → every reply notifies; Mentions-only → only @mentions
5. Second device shows the same per-thread modes (account-data sync) 5. Second device shows the same per-thread modes (account-data sync)
6. Room-level Mute still silences everything incl. thread overrides 6. Room-level Mute still silences everything incl. thread overrides
**Known caveats:** Mentions-only can under-notify in E2EE rooms (decision runs pre-decryption — same class as the existing notifier); muted-thread badge subtraction is Lotus-only (other clients still count them). **Known caveats:** Mentions-only can under-notify in E2EE rooms (decision runs pre-decryption — same class as the existing notifier); muted-thread badge subtraction is Lotus-only (other clients still count them).
--- ---
@@ -418,10 +424,11 @@ Features:
**Decision:** Deferred (reviewed 2026-07). Multi-sprint, and it touches the auth/crypto/storage core — not worth the risk/effort for a niche need right now. Kept here with a concrete spec so it's actionable later. **Decision:** Deferred (reviewed 2026-07). Multi-sprint, and it touches the auth/crypto/storage core — not worth the risk/effort for a niche need right now. Kept here with a concrete spec so it's actionable later.
**Future-work spec (why it's big):** the app is currently **single-session**. **Future-work spec (why it's big):** the app is currently **single-session**.
- Session lives in `src/app/state/sessions.ts` under fixed localStorage keys — `cinny_access_token`, `cinny_device_id`, `cinny_user_id`, `cinny_hs_base_url`, plus the OIDC keys (`cinny_refresh_token`, `cinny_expires_at`, `cinny_oidc_*`). - Session lives in `src/app/state/sessions.ts` under fixed localStorage keys — `cinny_access_token`, `cinny_device_id`, `cinny_user_id`, `cinny_hs_base_url`, plus the OIDC keys (`cinny_refresh_token`, `cinny_expires_at`, `cinny_oidc_*`).
- Persistence lives in `src/client/initMatrix.ts`: two fixed IndexedDB stores — `web-sync-store` (`IndexedDBStore`) and `crypto-store` (`IndexedDBCryptoStore`) — feeding one `createClient(...)`. - Persistence lives in `src/client/initMatrix.ts`: two fixed IndexedDB stores — `web-sync-store` (`IndexedDBStore`) and `crypto-store` (`IndexedDBCryptoStore`) — feeding one `createClient(...)`.
True per-context isolation would require: (1) namespace every localStorage key per context (`ctx:<id>:cinny_*`); (2) per-context IndexedDB dbNames for **both** the sync store and the crypto store; (3) a context registry + switcher UI (create/rename/delete/switch); (4) full client teardown + re-init on switch (`initMatrix` currently assumes one global client); (5) per-context settings + notification/quiet-hours state; (6) careful crypto-store isolation so device keys never bleed across contexts. **Smaller intermediate step** if demand appears: plain multi-account (fast account switch) *without* the hard isolation boundary — much less risky, reuses most of the login flow. True per-context isolation would require: (1) namespace every localStorage key per context (`ctx:<id>:cinny_*`); (2) per-context IndexedDB dbNames for **both** the sync store and the crypto store; (3) a context registry + switcher UI (create/rename/delete/switch); (4) full client teardown + re-init on switch (`initMatrix` currently assumes one global client); (5) per-context settings + notification/quiet-hours state; (6) careful crypto-store isolation so device keys never bleed across contexts. **Smaller intermediate step** if demand appears: plain multi-account (fast account switch) _without_ the hard isolation boundary — much less risky, reuses most of the login flow.
**Priority:** Extreme Low (Multi-sprint/Architectural). **Priority:** Extreme Low (Multi-sprint/Architectural).
### [DROPPED] P5-52 · Desktop — Room-Level Sync Governor (Performance Control) ### [DROPPED] P5-52 · Desktop — Room-Level Sync Governor (Performance Control)
@@ -510,20 +517,22 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
**Decisions (each backed by SDK evidence in node_modules/matrix-js-sdk):** **Decisions (each backed by SDK evidence in node_modules/matrix-js-sdk):**
| Question | Decision | | Question | Decision |
|---|---| | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Thread rendering | **New lean `ThreadTimeline`** reusing `Message`, `useVirtualPaginator`, and RoomTimeline's exported timeline helpers (lines 156-227). Do NOT refactor 2214-line RoomTimeline (its ~35 hooks are hardwired to the room live timeline). | | Thread rendering | **New lean `ThreadTimeline`** reusing `Message`, `useVirtualPaginator`, and RoomTimeline's exported timeline helpers (lines 156-227). Do NOT refactor 2214-line RoomTimeline (its ~35 hooks are hardwired to the room live timeline). |
| threadSupport | **Enable `threadSupport: true`** in `initMatrix.ts` (~line 39). ⚠️ Thread replies then LEAVE the main timeline (`room.js eventShouldLiveIn``shouldLiveInRoom:false`), retroactively on reload — MUST ship the "N replies" summary chip in the same release. Roots stay in both timelines. | | threadSupport | **Enable `threadSupport: true`** in `initMatrix.ts` (~line 39). ⚠️ Thread replies then LEAVE the main timeline (`room.js eventShouldLiveIn``shouldLiveInRoom:false`), retroactively on reload — MUST ship the "N replies" summary chip in the same release. Roots stay in both timelines. |
| State | `roomIdToActiveThreadIdAtomFamily` (per-room, mirrors `roomIdToReplyDraftAtomFamily`) in new `state/room/thread.ts` + `getThreadDraftKey(roomId, threadRootId)` = `` `${roomId}::${threadRootId}` `` | | State | `roomIdToActiveThreadIdAtomFamily` (per-room, mirrors `roomIdToReplyDraftAtomFamily`) in new `state/room/thread.ts` + `getThreadDraftKey(roomId, threadRootId)` = `` `${roomId}::${threadRootId}` `` |
| Composer | **Reuse RoomInput**: add optional `threadRootId` prop; scope its 3 atom-family lookups by draftKey (isolates thread drafts from the main composer); pass `threadRootId ?? null` at all 7 `mx.sendMessage/sendEvent` call sites — the SDK's `addThreadRelationIfNeeded` then emits spec-correct `m.thread` relations incl. reply-in-thread. Separate `useEditor()` instance in the panel. Hide schedule + commands in thread mode v1. | | Composer | **Reuse RoomInput**: add optional `threadRootId` prop; scope its 3 atom-family lookups by draftKey (isolates thread drafts from the main composer); pass `threadRootId ?? null` at all 7 `mx.sendMessage/sendEvent` call sites — the SDK's `addThreadRelationIfNeeded` then emits spec-correct `m.thread` relations incl. reply-in-thread. Separate `useEditor()` instance in the panel. Hide schedule + commands in thread mode v1. |
| Unreads | v1 = unread badge on the summary chip (`room.getThreadUnreadNotificationCount` — counts already synced independent of threadSupport) + `markThreadAsRead` threaded receipt when panel open at bottom. | | Unreads | v1 = unread badge on the summary chip (`room.getThreadUnreadNotificationCount` — counts already synced independent of threadSupport) + `markThreadAsRead` threaded receipt when panel open at bottom. |
| Mobile | Pure CSS like `MembersDrawer.css.ts`: fixed width toRem(360) desktop, `position:fixed; inset:0` under 750px. | | Mobile | Pure CSS like `MembersDrawer.css.ts`: fixed width toRem(360) desktop, `position:fixed; inset:0` under 750px. |
**Critical side-effect fixes (one-liners, land FIRST):** **Critical side-effect fixes (one-liners, land FIRST):**
1. `initMatrix.ts` → `threadSupport: true`. 1. `initMatrix.ts` → `threadSupport: true`.
2. `utils/notifications.ts:24` → `sendReadReceipt(latestEvent, type, /*unthreaded*/ true)` — otherwise markAsRead becomes `main`-scoped and room badges stick permanently unread (room unread total includes thread counts). 2. `utils/notifications.ts:24` → `sendReadReceipt(latestEvent, type, /*unthreaded*/ true)` — otherwise markAsRead becomes `main`-scoped and room badges stick permanently unread (room unread total includes thread counts).
**Known SDK traps (verified):** **Known SDK traps (verified):**
- **Local echo gap:** chronological pending ordering means the thread timelineSet never receives pending events (`canContain` rejects; `room.getPendingEvents()` THROWS in this mode) — ThreadTimeline must render its own pending strip via `RoomEvent.LocalEchoUpdated` filtering on `threadRootId`, deduped against `thread.findEventById`. - **Local echo gap:** chronological pending ordering means the thread timelineSet never receives pending events (`canContain` rejects; `room.getPendingEvents()` THROWS in this mode) — ThreadTimeline must render its own pending strip via `RoomEvent.LocalEchoUpdated` filtering on `threadRootId`, deduped against `thread.findEventById`.
- **Bootstrap:** `room.getThread(id) ?? room.createThread(id, room.findEventById(id), [], false)` — the SDK auto-fetches via `/relations` and inserts the root at top; gate rendering on `thread.initialEventsFetched`; decrypt with `decryptAllTimelineEvent` after init + each pagination. - **Bootstrap:** `room.getThread(id) ?? room.createThread(id, room.findEventById(id), [], false)` — the SDK auto-fetches via `/relations` and inserts the root at top; gate rendering on `thread.initialEventsFetched`; decrypt with `decryptAllTimelineEvent` after init + each pagination.
- **Deep links:** `getEventTimeline(mainSet, threadEventId)` returns undefined for thread events — redirect jump-to-event to the panel (best-effort v1). - **Deep links:** `getEventTimeline(mainSet, threadEventId)` returns undefined for thread events — redirect jump-to-event to the panel (best-effort v1).
+4 -4
View File
@@ -5,11 +5,11 @@ experimental_features:
msc3861: msc3861:
enabled: true enabled: true
issuer: http://localhost:8090/ issuer: http://localhost:8090/
client_id: "0000000000000000000SYNAPSE" client_id: '0000000000000000000SYNAPSE'
client_auth_method: client_secret_basic client_auth_method: client_secret_basic
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET" client_secret: 'REPLACE_WITH_A_SHARED_CLIENT_SECRET'
admin_token: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN" admin_token: 'REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN'
account_management_url: "http://localhost:8090/account" account_management_url: 'http://localhost:8090/account'
# With msc3861 enabled, Synapse disables its own password/SSO login and advertises # With msc3861 enabled, Synapse disables its own password/SSO login and advertises
# `m.authentication` in /.well-known/matrix/client — which is exactly what the # `m.authentication` in /.well-known/matrix/client — which is exactly what the
+28 -1
View File
@@ -25,7 +25,7 @@ export default [
tsPlugin.configs['flat/eslint-recommended'], tsPlugin.configs['flat/eslint-recommended'],
...tsPlugin.configs['flat/recommended'], ...tsPlugin.configs['flat/recommended'],
reactPlugin.configs.flat.recommended, reactPlugin.configs.flat.recommended,
reactHooksPlugin.configs.flat['recommended'], reactHooksPlugin.configs.flat.recommended,
// Register jsx-a11y plugin (rules selectively enabled below) // Register jsx-a11y plugin (rules selectively enabled below)
{ plugins: { 'jsx-a11y': jsxA11yPlugin } }, { plugins: { 'jsx-a11y': jsxA11yPlugin } },
// airbnb-base via FlatCompat (JS/import rules; no React plugin, no getFilename issue) // airbnb-base via FlatCompat (JS/import rules; no React plugin, no getFilename issue)
@@ -115,6 +115,26 @@ export default [
'jsx-a11y/media-has-caption': 'off', 'jsx-a11y/media-has-caption': 'off',
'jsx-a11y/no-noninteractive-element-interactions': 'off', 'jsx-a11y/no-noninteractive-element-interactions': 'off',
'jsx-a11y/alt-text': 'off', 'jsx-a11y/alt-text': 'off',
// A11y regression gate (P3-4). A CURATED set — correctness rules that catch
// real WCAG gaps (missing accessible names, malformed ARIA) without
// flooding on the pre-existing clickable-div patterns. The heavier
// interaction rules (no-static-element-interactions,
// click-events-have-key-events) are a separate cleanup and stay OFF.
'jsx-a11y/aria-props': 'error',
'jsx-a11y/aria-proptypes': 'error',
'jsx-a11y/aria-role': ['error', { ignoreNonDOM: true }],
'jsx-a11y/aria-unsupported-elements': 'error',
'jsx-a11y/role-has-required-aria-props': 'error',
'jsx-a11y/role-supports-aria-props': 'error',
'jsx-a11y/no-redundant-roles': 'error',
'jsx-a11y/anchor-has-content': 'error',
'jsx-a11y/heading-has-content': 'error',
'jsx-a11y/label-has-associated-control': ['error', { assert: 'either', depth: 5 }],
// NOT enabled: control-has-associated-label. This repo labels most inputs
// with folds `<Text as="label" htmlFor>` — a component the rule's static
// analysis can't see as a <label>, producing false positives on correctly
// labeled controls. The genuinely-unlabeled controls it surfaced (sliders,
// file input, media players, notes) were fixed directly with aria-label.
}, },
}, },
{ {
@@ -123,4 +143,11 @@ export default [
'no-undef': 'off', 'no-undef': 'off',
}, },
}, },
{
// Test files commonly define several small mock/fake classes.
files: ['**/*.test.ts', '**/*.test.tsx'],
rules: {
'max-classes-per-file': 'off',
},
},
]; ];
+20 -40
View File
@@ -24,7 +24,6 @@
"@tanstack/react-query": "5.100.13", "@tanstack/react-query": "5.100.13",
"@tanstack/react-query-devtools": "5.100.13", "@tanstack/react-query-devtools": "5.100.13",
"@tanstack/react-virtual": "3.13.25", "@tanstack/react-virtual": "3.13.25",
"@types/dompurify": "3.2.0",
"@workadventure/noise-suppression": "0.0.4", "@workadventure/noise-suppression": "0.0.4",
"await-to-js": "3.0.0", "await-to-js": "3.0.0",
"badwords-list": "2.0.1-4", "badwords-list": "2.0.1-4",
@@ -36,7 +35,6 @@
"dayjs": "1.11.20", "dayjs": "1.11.20",
"deepfilternet3-noise-filter": "1.2.1", "deepfilternet3-noise-filter": "1.2.1",
"domhandler": "6.0.1", "domhandler": "6.0.1",
"dompurify": "3.4.5",
"emojibase": "17.0.0", "emojibase": "17.0.0",
"emojibase-data": "17.0.0", "emojibase-data": "17.0.0",
"file-saver": "2.0.5", "file-saver": "2.0.5",
@@ -54,7 +52,7 @@
"katex": "0.16.11", "katex": "0.16.11",
"linkify-react": "4.3.3", "linkify-react": "4.3.3",
"linkifyjs": "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", "matrix-widget-api": "1.17.0",
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "5.7.284", "pdfjs-dist": "5.7.284",
@@ -75,7 +73,8 @@
"slate-history": "0.113.1", "slate-history": "0.113.1",
"slate-react": "0.124.2", "slate-react": "0.124.2",
"styled-components": "6.4.2", "styled-components": "6.4.2",
"ua-parser-js": "2.0.10" "ua-parser-js": "2.0.10",
"workbox-precaching": "7.4.1"
}, },
"devDependencies": { "devDependencies": {
"@lotusguild/element-call-embedded": "0.20.1-lotus.1", "@lotusguild/element-call-embedded": "0.20.1-lotus.1",
@@ -2697,9 +2696,9 @@
"dev": true "dev": true
}, },
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": { "node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
"version": "18.3.0", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.0.tgz", "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.1.tgz",
"integrity": "sha512-9a4feyt8QLysARu7PHKaRWT+wcCd+IYH074LXp9QK5WqfN4zUXueRhiSSMNT18Bm+8q3sBR/4zxDxOSDR0M8Kg==", "integrity": "sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">= 18" "node": ">= 18"
@@ -3920,16 +3919,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -4051,7 +4040,7 @@
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true "dev": true
}, },
"node_modules/@types/ua-parser-js": { "node_modules/@types/ua-parser-js": {
"version": "0.7.39", "version": "0.7.39",
@@ -5550,12 +5539,16 @@
"dev": true "dev": true
}, },
"node_modules/content-type": { "node_modules/content-type": {
"version": "1.0.5", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/conventional-commit-types": { "node_modules/conventional-commit-types": {
@@ -6196,15 +6189,6 @@
"node": ">=20.19.0" "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": { "node_modules/domutils": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
@@ -9971,16 +9955,16 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/matrix-js-sdk": { "node_modules/matrix-js-sdk": {
"version": "41.6.0-rc.0", "version": "41.7.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.6.0-rc.0.tgz", "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.7.0.tgz",
"integrity": "sha512-FcTQyR+Nfh0ASEogYcX393hxGr1936Esg53Z+0f9O4SBsAxl1ZSkLXY3JfLZRLX9dNe38VVwQDQE6QuwnwV7Zw==", "integrity": "sha512-MP0xNv/VVRbshq00TE6EVo77IIXsQk0KjiVtgKV0t9j/V77a6Klt00QrrO0XykkTUsNC0+mQeBMxnx75rZO86Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@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", "another-json": "^0.2.0",
"bs58": "^6.0.0", "bs58": "^6.0.0",
"content-type": "^1.0.4", "content-type": "^2.0.0",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"loglevel": "^1.9.2", "loglevel": "^1.9.2",
"matrix-events-sdk": "0.0.1", "matrix-events-sdk": "0.0.1",
@@ -13228,7 +13212,6 @@
"version": "7.4.1", "version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz", "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz",
"integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==", "integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/workbox-expiration": { "node_modules/workbox-expiration": {
@@ -13269,7 +13252,6 @@
"version": "7.4.1", "version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz", "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz",
"integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==", "integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"workbox-core": "7.4.1", "workbox-core": "7.4.1",
@@ -13306,7 +13288,6 @@
"version": "7.4.1", "version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz", "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz",
"integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==", "integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"workbox-core": "7.4.1" "workbox-core": "7.4.1"
@@ -13316,7 +13297,6 @@
"version": "7.4.1", "version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz", "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz",
"integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==", "integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"workbox-core": "7.4.1" "workbox-core": "7.4.1"
+3 -4
View File
@@ -49,7 +49,6 @@
"@tanstack/react-query": "5.100.13", "@tanstack/react-query": "5.100.13",
"@tanstack/react-query-devtools": "5.100.13", "@tanstack/react-query-devtools": "5.100.13",
"@tanstack/react-virtual": "3.13.25", "@tanstack/react-virtual": "3.13.25",
"@types/dompurify": "3.2.0",
"@workadventure/noise-suppression": "0.0.4", "@workadventure/noise-suppression": "0.0.4",
"await-to-js": "3.0.0", "await-to-js": "3.0.0",
"badwords-list": "2.0.1-4", "badwords-list": "2.0.1-4",
@@ -61,7 +60,6 @@
"dayjs": "1.11.20", "dayjs": "1.11.20",
"deepfilternet3-noise-filter": "1.2.1", "deepfilternet3-noise-filter": "1.2.1",
"domhandler": "6.0.1", "domhandler": "6.0.1",
"dompurify": "3.4.5",
"emojibase": "17.0.0", "emojibase": "17.0.0",
"emojibase-data": "17.0.0", "emojibase-data": "17.0.0",
"file-saver": "2.0.5", "file-saver": "2.0.5",
@@ -79,7 +77,7 @@
"katex": "0.16.11", "katex": "0.16.11",
"linkify-react": "4.3.3", "linkify-react": "4.3.3",
"linkifyjs": "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", "matrix-widget-api": "1.17.0",
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "5.7.284", "pdfjs-dist": "5.7.284",
@@ -100,7 +98,8 @@
"slate-history": "0.113.1", "slate-history": "0.113.1",
"slate-react": "0.124.2", "slate-react": "0.124.2",
"styled-components": "6.4.2", "styled-components": "6.4.2",
"ua-parser-js": "2.0.10" "ua-parser-js": "2.0.10",
"workbox-precaching": "7.4.1"
}, },
"devDependencies": { "devDependencies": {
"@lotusguild/element-call-embedded": "0.20.1-lotus.1", "@lotusguild/element-call-embedded": "0.20.1-lotus.1",
+1
View File
@@ -213,6 +213,7 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
<Text size="L400">Account Data</Text> <Text size="L400">Account Data</Text>
<Input <Input
variant="SurfaceVariant" variant="SurfaceVariant"
aria-label="Account data type"
size="400" size="400"
radii="300" radii="300"
readOnly readOnly
+6 -1
View File
@@ -282,7 +282,12 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
> >
{previewUrl && ( {previewUrl && (
<> <>
<audio ref={previewAudioRef} src={previewUrl} onEnded={() => setPreviewPlaying(false)} /> <audio
ref={previewAudioRef}
src={previewUrl}
onEnded={() => setPreviewPlaying(false)}
aria-hidden="true"
/>
<IconButton <IconButton
onClick={() => { onClick={() => {
const audio = previewAudioRef.current; const audio = previewAudioRef.current;
@@ -78,11 +78,14 @@ export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
return ( return (
<Box shrink="No" direction="Column" gap="100"> <Box shrink="No" direction="Column" gap="100">
<Text size="L400">Address (Optional)</Text> <Text as="label" htmlFor="create-room-alias" size="L400">
Address (Optional)
</Text>
<Text size="T200" priority="300"> <Text size="T200" priority="300">
Pick an unique address to make it discoverable. Pick an unique address to make it discoverable.
</Text> </Text>
<Input <Input
id="create-room-alias"
ref={aliasInputRef} ref={aliasInputRef}
onChange={handleAliasChange} onChange={handleAliasChange}
before={ before={
+4 -1
View File
@@ -66,6 +66,8 @@ type CustomEditorProps = {
maxHeight?: string; maxHeight?: string;
editor: Editor; editor: Editor;
placeholder?: string; placeholder?: string;
/** Explicit accessible name for the textbox; falls back to the placeholder. */
ariaLabel?: string;
onKeyDown?: KeyboardEventHandler; onKeyDown?: KeyboardEventHandler;
onKeyUp?: KeyboardEventHandler; onKeyUp?: KeyboardEventHandler;
onChange?: EditorChangeHandler; onChange?: EditorChangeHandler;
@@ -82,6 +84,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
maxHeight = '50vh', maxHeight = '50vh',
editor, editor,
placeholder, placeholder,
ariaLabel,
onKeyDown, onKeyDown,
onKeyUp, onKeyUp,
onChange, onChange,
@@ -139,7 +142,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
data-editable-name={editableName} data-editable-name={editableName}
className={css.EditorTextarea} className={css.EditorTextarea}
placeholder={placeholder} placeholder={placeholder}
aria-label={placeholder ?? 'Message input'} aria-label={ariaLabel ?? placeholder ?? 'Message input'}
aria-multiline="true" aria-multiline="true"
renderPlaceholder={renderPlaceholder} renderPlaceholder={renderPlaceholder}
renderElement={renderElement} renderElement={renderElement}
@@ -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 { Editor } from 'slate';
import { Box, MenuItem, Text, toRem } from 'folds'; import { Box, MenuItem, Text, toRem } from 'folds';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
@@ -11,7 +11,7 @@ import { onTabPress } from '../../../utils/keyboard';
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils'; import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { useRelevantImagePacks } from '../../../hooks/useImagePacks'; import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
import { IEmoji, emojis } from '../../../plugins/emoji'; import { IEmoji, emojis, loadEmojiData } from '../../../plugins/emoji';
import { useKeyDown } from '../../../hooks/useKeyDown'; import { useKeyDown } from '../../../hooks/useKeyDown';
import { mxcUrlToHttp } from '../../../utils/matrix'; import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
@@ -47,13 +47,32 @@ export function EmoticonAutocomplete({
const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms); const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
const recentEmoji = useRecentEmoji(mx, 20); 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 searchList = useMemo(() => {
const list: Array<EmoticonSearchItem> = []; const list: Array<EmoticonSearchItem> = [];
return list.concat( return list.concat(
imagePacks.flatMap((pack) => pack.getImages(ImageUsage.Emoticon)), imagePacks.flatMap((pack) => pack.getImages(ImageUsage.Emoticon)),
emojis, loadedEmojis,
); );
}, [imagePacks]); }, [imagePacks, loadedEmojis]);
const [result, search, resetSearch] = useAsyncSearch( const [result, search, resetSearch] = useAsyncSearch(
searchList, searchList,
+37 -6
View File
@@ -8,6 +8,7 @@ import React, {
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState,
} from 'react'; } from 'react';
import { Box, config, Icons, Scroll } from 'folds'; import { Box, config, Icons, Scroll } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
@@ -15,7 +16,7 @@ import { isKeyHotkey } from 'is-hotkey';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai'; import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual'; 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 { useEmojiGroupLabels } from './useEmojiGroupLabels';
import { useEmojiGroupIcons } from './useEmojiGroupIcons'; import { useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard'; import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
@@ -56,6 +57,33 @@ import { VirtualTile } from '../virtualizer';
const RECENT_GROUP_ID = 'recent_group'; const RECENT_GROUP_ID = 'recent_group';
const SEARCH_GROUP_ID = 'search_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 = { type EmojiGroupItem = {
id: string; id: string;
name: string; name: string;
@@ -75,6 +103,7 @@ const useGroups = (
const recentEmojis = useRecentEmoji(mx, 21); const recentEmojis = useRecentEmoji(mx, 21);
const labels = useEmojiGroupLabels(); const labels = useEmojiGroupLabels();
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
const emojiGroupItems = useMemo(() => { const emojiGroupItems = useMemo(() => {
const g: EmojiGroupItem[] = []; const g: EmojiGroupItem[] = [];
@@ -99,7 +128,7 @@ const useGroups = (
}); });
}); });
emojiGroups.forEach((group) => { loadedEmojiGroups.forEach((group) => {
g.push({ g.push({
id: group.id, id: group.id,
name: labels[group.id], name: labels[group.id],
@@ -108,7 +137,7 @@ const useGroups = (
}); });
return g; return g;
}, [mx, recentEmojis, labels, imagePacks, tab]); }, [mx, recentEmojis, labels, imagePacks, tab, loadedEmojiGroups]);
const stickerGroupItems = useMemo(() => { const stickerGroupItems = useMemo(() => {
const g: StickerGroupItem[] = []; const g: StickerGroupItem[] = [];
@@ -177,6 +206,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
const usage = ImageUsage.Emoticon; const usage = ImageUsage.Emoticon;
const labels = useEmojiGroupLabels(); const labels = useEmojiGroupLabels();
const icons = useEmojiGroupIcons(); const icons = useEmojiGroupIcons();
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
const packLabels = useMemo(() => { const packLabels = useMemo(() => {
const map = new Map<string, string | undefined>(); const map = new Map<string, string | undefined>();
@@ -234,7 +264,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
}} }}
> >
<SidebarDivider /> <SidebarDivider />
{emojiGroups.map((group) => ( {loadedEmojiGroups.map((group) => (
<GroupIcon <GroupIcon
key={group.id} key={group.id}
active={activeGroupId === group.id} active={activeGroupId === group.id}
@@ -409,13 +439,14 @@ export function EmojiBoard({
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks); const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
const groups = emojiTab ? emojiGroupItems : stickerGroupItems; const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
const renderItem = useItemRenderer(tab); const renderItem = useItemRenderer(tab);
const { emojis: loadedEmojis } = useEmojiData();
const searchList = useMemo(() => { const searchList = useMemo(() => {
let list: Array<PackImageReader | IEmoji> = []; let list: Array<PackImageReader | IEmoji> = [];
list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage))); list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage)));
if (emojiTab) list = list.concat(emojis); if (emojiTab) list = list.concat(loadedEmojis);
return list; return list;
}, [emojiTab, usage, imagePacks]); }, [emojiTab, usage, imagePacks, loadedEmojis]);
const [result, search, resetSearch] = useAsyncSearch( const [result, search, resetSearch] = useAsyncSearch(
searchList, searchList,
@@ -200,12 +200,24 @@ export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfil
</Box> </Box>
</Box> </Box>
<Box direction="Inherit" gap="100"> <Box direction="Inherit" gap="100">
<Text size="L400">Name</Text> <Text as="label" htmlFor="image-pack-name" size="L400">
<Input name="nameInput" defaultValue={meta.name} variant="Secondary" radii="300" required /> Name
</Text>
<Input
id="image-pack-name"
name="nameInput"
defaultValue={meta.name}
variant="Secondary"
radii="300"
required
/>
</Box> </Box>
<Box direction="Inherit" gap="100"> <Box direction="Inherit" gap="100">
<Text size="L400">Attribution</Text> <Text as="label" htmlFor="image-pack-attribution" size="L400">
Attribution
</Text>
<TextArea <TextArea
id="image-pack-attribution"
name="attributionTextArea" name="attributionTextArea"
defaultValue={meta.attribution} defaultValue={meta.attribution}
variant="Secondary" variant="Secondary"
@@ -261,9 +261,12 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
gap="400" gap="400"
> >
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">User ID</Text> <Text as="label" htmlFor="invite-user-id" size="L400">
User ID
</Text>
<div> <div>
<Input <Input
id="invite-user-id"
size="500" size="500"
ref={inputRef} ref={inputRef}
onChange={handleSearchChange} onChange={handleSearchChange}
@@ -334,8 +337,11 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
</div> </div>
</Box> </Box>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Reason (Optional)</Text> <Text as="label" htmlFor="invite-reason" size="L400">
Reason (Optional)
</Text>
<TextArea <TextArea
id="invite-reason"
size="500" size="500"
name="reasonInput" name="reasonInput"
variant="Background" variant="Background"
@@ -108,8 +108,11 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
</Text> </Text>
</Box> </Box>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Address</Text> <Text as="label" htmlFor="join-address" size="L400">
Address
</Text>
<Input <Input
id="join-address"
size="500" size="500"
autoFocus autoFocus
name="addressInput" name="addressInput"
-1
View File
@@ -117,7 +117,6 @@ export const PageHeroSection = style([
}, },
]); ]);
export const PageContentCenter = style([ export const PageContentCenter = style([
DefaultReset, DefaultReset,
{ {
@@ -95,7 +95,6 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
onDeactivate: () => setViewTopic(false), onDeactivate: () => setViewTopic(false),
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
@@ -278,6 +278,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
<Box direction="Column" gap="300"> <Box direction="Column" gap="300">
<input <input
ref={fileInputRef} ref={fileInputRef}
aria-label="Upload soundboard clip"
type="file" type="file"
accept={SOUNDBOARD_ACCEPT} accept={SOUNDBOARD_ACCEPT}
multiple multiple
@@ -56,6 +56,7 @@ function PreviewVideo({ fileItem }: PreviewVideoProps) {
return ( return (
<video <video
aria-label="Video attachment preview"
style={{ style={{
objectFit: 'contain', objectFit: 'contain',
width: '100%', width: '100%',
@@ -260,6 +260,7 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
<Input <Input
ref={reasonInputRef} ref={reasonInputRef}
placeholder="Reason" placeholder="Reason"
aria-label="Moderation reason"
size="300" size="300"
variant="Background" variant="Background"
radii="300" radii="300"
@@ -253,6 +253,7 @@ function UserPrivateNotes({ userId }: { userId: string }) {
)} )}
</Box> </Box>
<textarea <textarea
aria-label="Private note about this user"
value={draft} value={draft}
onChange={handleChange} onChange={handleChange}
maxLength={USER_NOTE_MAX_LENGTH} maxLength={USER_NOTE_MAX_LENGTH}
@@ -147,6 +147,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
<Text size="L400">Name</Text> <Text size="L400">Name</Text>
<Input <Input
name="nameInput" name="nameInput"
aria-label="Power level name"
defaultValue={tag?.name} defaultValue={tag?.name}
placeholder="Bot" placeholder="Bot"
size="300" size="300"
@@ -160,6 +161,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
<Input <Input
defaultValue={power} defaultValue={power}
name="powerInput" name="powerInput"
aria-label="Power level value"
size="300" size="300"
variant={typeof power === 'number' ? 'SurfaceVariant' : 'Secondary'} variant={typeof power === 'number' ? 'SurfaceVariant' : 'Secondary'}
radii="300" radii="300"
+4 -3
View File
@@ -186,8 +186,8 @@ function LightboxMedia({
)} )}
{media.status === 'ok' && {media.status === 'ok' &&
(item.msgtype === MsgType.Video ? ( (item.msgtype === MsgType.Video ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video <video
aria-label="Video attachment"
src={media.url} src={media.url}
controls controls
autoPlay autoPlay
@@ -261,7 +261,6 @@ function Lightbox({
escapeDeactivates: false, escapeDeactivates: false,
}} }}
> >
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div <div
role="dialog" role="dialog"
aria-modal aria-modal
@@ -640,13 +639,15 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
className={classNames(css.MediaGalleryDrawer, ContainerColor({ variant: 'Background' }))} className={classNames(css.MediaGalleryDrawer, ContainerColor({ variant: 'Background' }))}
shrink="No" shrink="No"
direction="Column" direction="Column"
role="region"
aria-labelledby="media-gallery-title"
> >
{/* Header */} {/* Header */}
<Header variant="Background" size="600" className={css.MediaGalleryHeader}> <Header variant="Background" size="600" className={css.MediaGalleryHeader}>
<Box grow="Yes" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
<Icon size="200" src={Icons.Photo} /> <Icon size="200" src={Icons.Photo} />
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4" truncate> <Text id="media-gallery-title" size="H4" truncate>
Media Gallery Media Gallery
</Text> </Text>
</Box> </Box>
-2
View File
@@ -142,7 +142,6 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
placeholder="Ask a question…" placeholder="Ask a question…"
value={question} value={question}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuestion(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuestion(e.target.value)}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus autoFocus
/> />
</Box> </Box>
@@ -151,7 +150,6 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
<Box direction="Column" gap="200"> <Box direction="Column" gap="200">
<Text size="L400">Options</Text> <Text size="L400">Options</Text>
{options.map((opt, index) => ( {options.map((opt, index) => (
// eslint-disable-next-line react/no-array-index-key
<Box key={index} alignItems="Center" gap="200"> <Box key={index} alignItems="Center" gap="200">
<Input <Input
style={{ flex: 1 }} style={{ flex: 1 }}
+36 -5
View File
@@ -1,4 +1,4 @@
import React, { useCallback } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { Box, Line } from 'folds'; import { Box, Line } from 'folds';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
@@ -49,15 +49,46 @@ export function Room() {
useCallback( useCallback(
(evt) => { (evt) => {
if (isKeyHotkey('escape', 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); 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; 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 ( return (
<PowerLevelsContextProvider value={powerLevels}> <PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes"> <Box grow="Yes">
@@ -86,7 +117,7 @@ export function Room() {
<CallChatView /> <CallChatView />
</> </>
)} )}
{!callView && galleryOpen && ( {showGallery && (
<> <>
{screenSize === ScreenSize.Desktop && ( {screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" /> <Line variant="Background" direction="Vertical" size="300" />
@@ -94,7 +125,7 @@ export function Room() {
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} /> <MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
</> </>
)} )}
{!callView && activeThreadId && ( {showThreadPanel && activeThreadId && (
<> <>
{screenSize === ScreenSize.Desktop && ( {screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" /> <Line variant="Background" direction="Vertical" size="300" />
@@ -107,7 +138,7 @@ export function Room() {
/> />
</> </>
)} )}
{!callView && isDrawer && ( {showMembers && (
<> <>
{screenSize === ScreenSize.Desktop && ( {screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" /> <Line variant="Background" direction="Vertical" size="300" />
+11 -3
View File
@@ -679,15 +679,23 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
submit(); submit();
} }
if (isKeyHotkey('escape', evt)) { 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) { if (autocompleteQuery) {
evt.preventDefault();
evt.stopPropagation();
setAutocompleteQuery(undefined); setAutocompleteQuery(undefined);
return; 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( const handleKeyUp: KeyboardEventHandler = useCallback(
-1
View File
@@ -583,7 +583,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
onDeactivate: () => setViewTopic(false), onDeactivate: () => setViewTopic(false),
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
@@ -25,3 +25,16 @@ export const RoomViewTyping = style([
export const TypingText = style({ export const TypingText = style({
flexGrow: 1, flexGrow: 1,
}); });
// Visually hidden but available to assistive technology.
export const SrOnly = style({
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
});
+97 -78
View File
@@ -33,8 +33,21 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
[typingMembers, myUserId, room], [typingMembers, myUserId, room],
); );
if (typingNames.length === 0) { // A single, non-truncated string for assistive technology to announce.
return null; // Computed even when empty so the live region can stay mounted (below) —
// a `role="status"` region added to the DOM together with its first text
// is not reliably announced by some screen readers.
let typingAnnouncement = '';
if (typingNames.length === 1) {
typingAnnouncement = `${typingNames[0]} is typing`;
} else if (typingNames.length === 2) {
typingAnnouncement = `${typingNames[0]} and ${typingNames[1]} are typing`;
} else if (typingNames.length === 3) {
typingAnnouncement = `${typingNames[0]}, ${typingNames[1]} and ${typingNames[2]} are typing`;
} else {
typingAnnouncement = `${typingNames[0]}, ${typingNames[1]}, ${typingNames[2]} and ${
typingNames.length - 3
} others are typing`;
} }
const handleDropAll = () => { const handleDropAll = () => {
@@ -50,83 +63,89 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
}; };
return ( return (
<div style={{ position: 'relative' }} aria-live="polite" aria-atomic="false"> <div style={{ position: 'relative' }}>
<Box {/* Persistently mounted so the FIRST "X is typing" is announced. */}
className={classNames(css.RoomViewTyping, className)} <span className={css.SrOnly} role="status" aria-live="polite" aria-atomic="true">
alignItems="Center" {typingAnnouncement}
gap="400" </span>
{...props} {typingNames.length > 0 && (
ref={ref} <Box
> className={classNames(css.RoomViewTyping, className)}
<TypingIndicator /> alignItems="Center"
<Text className={css.TypingText} size="T300" truncate> gap="400"
{typingNames.length === 1 && ( {...props}
<> ref={ref}
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' is typing...'}
</Text>
</>
)}
{typingNames.length === 2 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
{typingNames.length === 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
{typingNames.length > 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames.length - 3} others</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
</Text>
<IconButton
title="Drop Typing Status"
aria-label="Drop typing status"
size="300"
radii="Pill"
onClick={handleDropAll}
> >
<Icon size="50" src={Icons.Cross} /> <TypingIndicator />
</IconButton> <Text className={css.TypingText} size="T300" truncate aria-hidden>
</Box> {typingNames.length === 1 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' is typing...'}
</Text>
</>
)}
{typingNames.length === 2 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
{typingNames.length === 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
{typingNames.length > 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames.length - 3} others</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
</Text>
<IconButton
title="Drop Typing Status"
aria-label="Drop typing status"
size="300"
radii="Pill"
onClick={handleDropAll}
>
<Icon size="50" src={Icons.Cross} />
</IconButton>
</Box>
)}
</div> </div>
); );
}, },
+56 -34
View File
@@ -33,6 +33,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
const [scheduledMessages, setScheduledMessages] = useAtom(scheduledMessagesAtom); const [scheduledMessages, setScheduledMessages] = useAtom(scheduledMessagesAtom);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [cancelling, setCancelling] = useState<Set<string>>(new Set()); const [cancelling, setCancelling] = useState<Set<string>>(new Set());
const [cancelErrors, setCancelErrors] = useState<Set<string>>(new Set());
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]); const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
@@ -68,12 +69,17 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
async (msg: ScheduledMessage) => { async (msg: ScheduledMessage) => {
if (cancelling.has(msg.delayId)) return; if (cancelling.has(msg.delayId)) return;
setCancelling((prev) => new Set(prev).add(msg.delayId)); 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 { try {
await cancelScheduledMessage(mx, msg.delayId); await cancelScheduledMessage(mx, msg.delayId);
} catch { // Only prune local state once the server confirms cancellation. If we
// If cancellation fails on the server, still remove locally // removed it optimistically the still-live delayed event would fire and
// since the user intends to remove it // the "cancelled" message would send anyway.
} finally {
setScheduledMessages((prev) => { setScheduledMessages((prev) => {
const next = new Map(prev); const next = new Map(prev);
const current = next.get(roomId) ?? []; const current = next.get(roomId) ?? [];
@@ -85,6 +91,11 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
} }
return next; 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) => { setCancelling((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.delete(msg.delayId); next.delete(msg.delayId);
@@ -131,41 +142,52 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
{messages.map((msg) => ( {messages.map((msg) => (
<Box <Box
key={msg.delayId} key={msg.delayId}
alignItems="Center" direction="Column"
gap="200"
style={{ style={{
padding: `${config.space.S100} ${config.space.S300}`, padding: `${config.space.S100} ${config.space.S300}`,
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
}} }}
> >
<Text <Box alignItems="Center" gap="200">
size="T200" <Text
priority="400" size="T200"
style={{ priority="400"
flex: 1, style={{
overflow: 'hidden', flex: 1,
textOverflow: 'ellipsis', overflow: 'hidden',
whiteSpace: 'nowrap', textOverflow: 'ellipsis',
}} whiteSpace: 'nowrap',
> }}
{typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)'} >
</Text> {typeof msg.content.body === 'string'
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}> ? (msg.content.body as string)
{formatSendAt(msg.sendAt)} : '(message)'}
</Text> </Text>
<IconButton <Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
size="300" {formatSendAt(msg.sendAt)}
radii="300" </Text>
variant="SurfaceVariant" <IconButton
aria-label="Cancel scheduled message" size="300"
disabled={cancelling.has(msg.delayId)} radii="300"
onClick={(e) => { variant="SurfaceVariant"
e.stopPropagation(); aria-label="Cancel scheduled message"
handleCancel(msg); disabled={cancelling.has(msg.delayId)}
}} onClick={(e) => {
> e.stopPropagation();
<Icon src={Icons.Cross} size="50" /> handleCancel(msg);
</IconButton> }}
>
<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>
))} ))}
</Box> </Box>
@@ -56,6 +56,7 @@ import {
getMemberDisplayName, getMemberDisplayName,
} from '../../../utils/room'; } from '../../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { messageAriaLabel } from '../../../utils/a11y';
import { MessageLayout, MessageSpacing } from '../../../state/settings'; import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
@@ -972,6 +973,10 @@ export const Message = React.memo(
[MsgAppearClass]: playAppear, [MsgAppearClass]: playAppear,
[MentionHighlightPulse]: playMentionPulse, [MentionHighlightPulse]: playMentionPulse,
})} })}
role="article"
aria-label={
collapse ? messageAriaLabel(senderDisplayName, mEvent.getTs(), hour24Clock) : undefined
}
tabIndex={0} tabIndex={0}
space={messageSpacing} space={messageSpacing}
collapse={collapse} collapse={collapse}
@@ -51,7 +51,13 @@ import { UseStateProvider } from '../../../components/UseStateProvider';
import { EmojiBoard } from '../../../components/emoji-board'; import { EmojiBoard } from '../../../components/emoji-board';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room'; import {
getEditedEvent,
getMemberDisplayName,
getMentionContent,
trimReplyFromFormattedBody,
} from '../../../utils/room';
import { getMxIdLocalPart } from '../../../utils/matrix';
import { mobileOrTablet } from '../../../utils/user-agent'; import { mobileOrTablet } from '../../../utils/user-agent';
import { useComposingCheck } from '../../../hooks/useComposingCheck'; import { useComposingCheck } from '../../../hooks/useComposingCheck';
@@ -66,6 +72,12 @@ export const MessageEditor = as<'div', MessageEditorProps>(
({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => { ({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const editor = useEditor(); const editor = useEditor();
// Accessible name for the edit textbox so screen readers announce which
// message is being edited (a11y, P3-4).
const editSenderId = mEvent.getSender();
const editSenderName = editSenderId
? (getMemberDisplayName(room, editSenderId) ?? getMxIdLocalPart(editSenderId) ?? editSenderId)
: '';
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
@@ -259,6 +271,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
<CustomEditor <CustomEditor
editor={editor} editor={editor}
placeholder="Edit message..." placeholder="Edit message..."
ariaLabel={editSenderId ? `Editing message from ${editSenderName}` : 'Edit message'}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp} onKeyUp={handleKeyUp}
bottom={ bottom={
@@ -106,7 +106,6 @@ export const Reactions = as<'div', ReactionsProps>(
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setViewer(false), onDeactivate: () => setViewer(false),
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
@@ -1,8 +1,9 @@
import React, { useMemo } from 'react'; import React, { useMemo, useState } from 'react';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { import {
Box, Box,
Button, Button,
color,
config, config,
Dialog, Dialog,
Header, Header,
@@ -43,15 +44,25 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
const modalStyle = useModalStyle(320); const modalStyle = useModalStyle(320);
const { addReminder } = useReminders(); const { addReminder } = useReminders();
const presets = useMemo(() => getPresets(), []); const presets = useMemo(() => getPresets(), []);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const handlePick = async (ms: number) => { const handlePick = async (ms: number) => {
await addReminder({ if (busy) return;
roomId, setBusy(true);
eventId, setError(null);
timestamp: Date.now() + ms, try {
message: previewText || 'Reminder', await addReminder({
}); roomId,
onClose(); eventId,
timestamp: Date.now() + ms,
message: previewText || 'Reminder',
});
onClose();
} catch {
setBusy(false);
setError('Could not set reminder. Try again.');
}
}; };
return ( return (
@@ -108,6 +119,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
variant="Secondary" variant="Secondary"
fill="Soft" fill="Soft"
radii="300" radii="300"
disabled={busy}
onClick={() => handlePick(p.ms)} onClick={() => handlePick(p.ms)}
> >
<Text size="B300" truncate> <Text size="B300" truncate>
@@ -115,6 +127,14 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
</Text> </Text>
</Button> </Button>
))} ))}
{error && (
<Text
size="T200"
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
>
{error}
</Text>
)}
</Box> </Box>
</Dialog> </Dialog>
</FocusTrap> </FocusTrap>
@@ -123,6 +123,10 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
useCallback( useCallback(
(evt) => { (evt) => {
if (isKeyHotkey('escape', 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.preventDefault();
evt.stopPropagation(); evt.stopPropagation();
requestClose(); requestClose();
@@ -11,6 +11,15 @@ export const ThreadTimelineContent = style({
padding: `${config.space.S400} 0`, 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({ export const ThreadCentered = style({
height: '100%', height: '100%',
padding: config.space.S700, padding: config.space.S700,
@@ -29,7 +29,7 @@ import { Editor } from 'slate';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import to from 'await-to-js'; import to from 'await-to-js';
import { useAtomValue, useSetAtom } from 'jotai'; 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 classNames from 'classnames';
import { Opts as LinkifyOpts } from 'linkifyjs'; import { Opts as LinkifyOpts } from 'linkifyjs';
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix'; import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix';
@@ -459,6 +459,14 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
} }
}, [scrollToBottomCount]); }, [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. // Scroll in-place editor into view.
useEffect(() => { useEffect(() => {
if (editId) { if (editId) {
@@ -949,6 +957,19 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
<span ref={atBottomAnchorRef} /> <span ref={atBottomAnchorRef} />
</Box> </Box>
</Scroll> </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 && ( {editHistoryEvent && (
<EditHistoryModal <EditHistoryModal
room={room} room={room}
@@ -1,7 +1,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { EventStatus, MatrixEvent, RelationType } from 'matrix-js-sdk'; 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 // getThreadSummary reads either the live Thread (preferred) or the
// server-aggregated `m.thread` bundle. We stub only the members it touches and // server-aggregated `m.thread` bundle. We stub only the members it touches and
+1 -1
View File
@@ -12,7 +12,7 @@ import {
ThreadEvent, ThreadEvent,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { getLinkedTimelines } from '../RoomTimeline'; import { getLinkedTimelines } from '../RoomTimeline';
import { isPendingThreadReply } from './threadSummary'; import { isPendingThreadReply } from './threadSummaryData';
/** /**
* Resolve (or bootstrap) the live {@link Thread} for a root event. * Resolve (or bootstrap) the live {@link Thread} for a root event.
+8 -2
View File
@@ -247,7 +247,6 @@ export function Search({ requestClose }: SearchProps) {
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: () => inputRef.current, initialFocus: () => inputRef.current,
returnFocusOnDeactivate: false,
allowOutsideClick: true, allowOutsideClick: true,
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
onDeactivate: requestClose, onDeactivate: requestClose,
@@ -257,7 +256,13 @@ export function Search({ requestClose }: SearchProps) {
}, },
}} }}
> >
<Modal size="400" style={{ maxHeight: toRem(400), borderRadius: config.radii.R500 }}> <Modal
size="400"
role="dialog"
aria-modal="true"
aria-label="Search"
style={{ maxHeight: toRem(400), borderRadius: config.radii.R500 }}
>
<Box <Box
shrink="No" shrink="No"
style={{ padding: config.space.S400, paddingBottom: 0 }} style={{ padding: config.space.S400, paddingBottom: 0 }}
@@ -270,6 +275,7 @@ export function Search({ requestClose }: SearchProps) {
radii="400" radii="400"
outlined outlined
placeholder="Search" placeholder="Search"
aria-label="Search rooms"
before={<Icon size="200" src={Icons.Search} />} before={<Icon size="200" src={Icons.Search} />}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
@@ -531,6 +531,7 @@ function Appearance() {
Intensity: {nightLightOpacity}% Intensity: {nightLightOpacity}%
</Text> </Text>
<input <input
aria-label="Night light intensity"
type="range" type="range"
min={5} min={5}
max={80} max={80}
@@ -1663,6 +1664,7 @@ function Calls() {
<Text size="T200">{callDenoiseGateThreshold} dB</Text> <Text size="T200">{callDenoiseGateThreshold} dB</Text>
</Box> </Box>
<input <input
aria-label="Noise gate threshold"
type="range" type="range"
min="-100" min="-100"
max="0" max="0"
@@ -0,0 +1,49 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const ShortcutList = style([
DefaultReset,
{
margin: 0,
},
]);
export const ShortcutRow = style({
padding: `${config.space.S100} 0`,
});
export const ShortcutTerm = style([
DefaultReset,
{
flexGrow: 1,
},
]);
export const ShortcutKeys = style([
DefaultReset,
{
display: 'flex',
alignItems: 'center',
gap: config.space.S100,
flexShrink: 0,
},
]);
export const Kbd = style([
DefaultReset,
{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: toRem(20),
padding: `0 ${config.space.S200}`,
height: toRem(24),
fontFamily: 'inherit',
fontSize: toRem(12),
lineHeight: toRem(24),
color: color.SurfaceVariant.OnContainer,
backgroundColor: color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
},
]);
@@ -0,0 +1,208 @@
import React, { useCallback } from 'react';
import FocusTrap from 'focus-trap-react';
import { atom, useAtom, useSetAtom } from 'jotai';
import {
Box,
Dialog,
Header,
Icon,
IconButton,
Icons,
Line,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Text,
config,
} from 'folds';
import { stopPropagation } from '../../utils/keyboard';
import { editableActiveElement } from '../../utils/dom';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useModalStyle } from '../../hooks/useModalStyle';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { isMacOS } from '../../utils/user-agent';
import { KeySymbol } from '../../utils/key-symbol';
import * as css from './KeyboardShortcutsDialog.css';
/** Global open-state for the keyboard shortcuts help dialog. */
export const keyboardShortcutsDialogAtom = atom<boolean>(false);
/** Read/control the keyboard shortcuts dialog open-state. */
export function useKeyboardShortcutsDialog() {
const [open, setOpen] = useAtom(keyboardShortcutsDialogAtom);
const openDialog = useCallback(() => setOpen(true), [setOpen]);
const closeDialog = useCallback(() => setOpen(false), [setOpen]);
return { open, openDialog, closeDialog };
}
/**
* Registers the global `Shift + /` (`?`) shortcut that opens the keyboard
* shortcuts help dialog. Ignored while the user is typing into an input,
* textarea or contenteditable so it never steals a literal `?` character.
*
* Mount once in the client shell (e.g. `ClientNonUIFeatures`).
*/
export function useKeyboardShortcutsTrigger() {
const setOpen = useSetAtom(keyboardShortcutsDialogAtom);
useKeyDown(
window,
useCallback(
(evt: KeyboardEvent) => {
// Never intercept `?` while the user is typing into a field/editor.
if (editableActiveElement()) return;
// `?` is produced by Shift + `/` on the common layouts.
if (evt.key === '?') {
evt.preventDefault();
// Stop RoomView's window-level "type any char → focus composer"
// handler from also firing — otherwise focus lands in the composer
// behind the dialog and Escape gets swallowed by the contenteditable.
evt.stopImmediatePropagation();
setOpen(true);
}
},
[setOpen],
),
);
}
type ShortcutRow = {
description: string;
keys: string[];
};
type ShortcutSection = {
title: string;
rows: ShortcutRow[];
};
function ShortcutKeys({ keys }: { keys: string[] }) {
return (
<Box as="dd" className={css.ShortcutKeys}>
{keys.map((key, index) => (
<kbd key={`${key}-${index}`} className={css.Kbd}>
{key}
</kbd>
))}
</Box>
);
}
/**
* Accessible keyboard shortcuts help dialog. Renders (as a modal overlay) only
* while `keyboardShortcutsDialogAtom` is `true`. Open it with the `?` shortcut
* (see `useKeyboardShortcutsTrigger`) or via `useKeyboardShortcutsDialog`.
*/
export function KeyboardShortcutsDialog() {
const { open, closeDialog } = useKeyboardShortcutsDialog();
const modalStyle = useModalStyle(480);
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
if (!open) return null;
const sections: ShortcutSection[] = [
{
title: 'General',
rows: [
{ description: 'Show keyboard shortcuts', keys: ['?'] },
{ description: 'Close open panel, otherwise mark room as read', keys: [KeySymbol.Escape] },
],
},
{
title: 'Composer',
rows: [
{ description: 'Focus the message composer', keys: ['Any character'] },
{
description: 'Send message',
keys: enterForNewline ? [modKey, 'Enter'] : ['Enter'],
},
{
description: 'Insert a new line',
keys: enterForNewline ? ['Enter'] : [KeySymbol.Shift, 'Enter'],
},
{ description: 'Send message (always)', keys: [modKey, 'Enter'] },
],
},
{
title: 'Messages',
rows: [
{ description: 'Reveal message actions (react, reply, more)', keys: ['Hover / focus'] },
],
},
];
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: closeDialog,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog
variant="Surface"
aria-labelledby="keyboard-shortcuts-dialog-title"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4" id="keyboard-shortcuts-dialog-title">
Keyboard Shortcuts
</Text>
</Box>
<IconButton size="300" onClick={closeDialog} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Scroll size="300" hideTrack visibility="Hover">
<Box style={{ padding: config.space.S400 }} direction="Column" gap="500">
{sections.map((section, sectionIndex) => (
<Box key={section.title} direction="Column" gap="300">
{sectionIndex > 0 && <Line variant="Surface" size="300" />}
<Text size="L400" priority="400">
{section.title}
</Text>
<Box as="dl" className={css.ShortcutList} direction="Column">
{section.rows.map((row) => (
<Box
key={row.description}
className={css.ShortcutRow}
direction="Row"
alignItems="Center"
gap="200"
>
<Text as="dt" className={css.ShortcutTerm} size="T300">
{row.description}
</Text>
<ShortcutKeys keys={row.keys} />
</Box>
))}
</Box>
</Box>
))}
<Text size="T200" priority="300">
{enterForNewline
? 'Enter inserts a new line while “Enter for newline” is enabled in Settings.'
: 'Enter sends the message. Enable “Enter for newline” in Settings to swap this.'}
</Text>
</Box>
</Scroll>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
+1
View File
@@ -0,0 +1 @@
export * from './KeyboardShortcutsDialog';
+16 -1
View File
@@ -2,11 +2,26 @@ import { useEffect, useState } from 'react';
import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk'; import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { getRecentEmojis } from '../plugins/recent-emoji'; import { getRecentEmojis } from '../plugins/recent-emoji';
import { AccountDataEvent } from '../../types/matrix/accountData'; 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[] => { export const useRecentEmoji = (mx: MatrixClient, limit?: number): IEmoji[] => {
const [recentEmoji, setRecentEmoji] = useState(() => getRecentEmojis(mx, limit)); 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(() => { useEffect(() => {
const handleAccountData = (event: MatrixEvent) => { const handleAccountData = (event: MatrixEvent) => {
if (event.getType() !== AccountDataEvent.ElementRecentEmoji) return; if (event.getType() !== AccountDataEvent.ElementRecentEmoji) return;
+1 -1
View File
@@ -8,7 +8,7 @@ import {
RoomEventHandlerMap, RoomEventHandlerMap,
ThreadEvent, ThreadEvent,
} from 'matrix-js-sdk'; } 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 { threadNotificationsAtom } from '../state/threadNotifications';
import { getThreadNotificationMode, ThreadNotificationMode } from '../utils/threadNotifications'; import { getThreadNotificationMode, ThreadNotificationMode } from '../utils/threadNotifications';
+11 -2
View File
@@ -42,6 +42,7 @@ import { toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders'; import { useReminders } from '../../hooks/useReminders';
import { useTauriUpdater } from '../../hooks/useTauriUpdater'; import { useTauriUpdater } from '../../hooks/useTauriUpdater';
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures'; import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
import { useRoomsListener } from '../../hooks/useRoomsListener'; import { useRoomsListener } from '../../hooks/useRoomsListener';
import { threadNotificationsAtom } from '../../state/threadNotifications'; import { threadNotificationsAtom } from '../../state/threadNotifications';
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread'; import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
@@ -213,7 +214,7 @@ function InviteNotifications() {
]); ]);
return ( return (
<audio ref={audioRef} style={{ display: 'none' }}> <audio ref={audioRef} style={{ display: 'none' }} aria-hidden="true">
<source src={soundSrc ?? InviteSound} type="audio/ogg" /> <source src={soundSrc ?? InviteSound} type="audio/ogg" />
</audio> </audio>
); );
@@ -496,7 +497,7 @@ function MessageNotifications() {
useRoomsListener(mx, ThreadEvent.NewReply, handleNewReply); useRoomsListener(mx, ThreadEvent.NewReply, handleNewReply);
return ( return (
<audio ref={audioRef} style={{ display: 'none' }}> <audio ref={audioRef} style={{ display: 'none' }} aria-hidden="true">
<source src={soundSrc ?? NotificationSound} type="audio/ogg" /> <source src={soundSrc ?? NotificationSound} type="audio/ogg" />
</audio> </audio>
); );
@@ -642,6 +643,13 @@ function LotusDenoiseFeature() {
return null; return null;
} }
// Registers the global `?` shortcut (ignored while typing) and renders the
// keyboard-shortcuts help dialog. Headless — the dialog self-gates on its atom.
function KeyboardShortcutsFeature() {
useKeyboardShortcutsTrigger();
return <KeyboardShortcutsDialog />;
}
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
return ( return (
<> <>
@@ -656,6 +664,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<TauriDesktopFeatures /> <TauriDesktopFeatures />
<LotusDenoiseFeature /> <LotusDenoiseFeature />
<DeepLinkNavigator /> <DeepLinkNavigator />
<KeyboardShortcutsFeature />
{children} {children}
</> </>
); );
+7
View File
@@ -27,6 +27,7 @@ import {
} from './types'; } from './types';
import { CallControl } from './CallControl'; import { CallControl } from './CallControl';
import { CallControlState } from './CallControlState'; import { CallControlState } from './CallControlState';
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
// Maximum time to wait for the embedded Element Call iframe to progress from // 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 // 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('lotusModel', denoiseModel);
params.append('lotusGate', denoiseGate.toString()); params.append('lotusGate', denoiseGate.toString());
params.append('lotusGateThreshold', denoiseGateThreshold.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)) { if (CallEmbed.startingCall(intent)) {
+63
View File
@@ -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
View File
@@ -1,7 +1,4 @@
import { CompactEmoji, fromUnicodeToHexcode } from 'emojibase'; import type { CompactEmoji } 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';
export type IEmoji = CompactEmoji & { export type IEmoji = CompactEmoji & {
shortcode: string; shortcode: string;
@@ -24,57 +21,76 @@ export type IEmojiGroup = {
emojis: IEmoji[]; emojis: IEmoji[];
}; };
export const getShortcodesFor = (hexcode: string): string[] | string | undefined => export type EmojiData = {
joypixels[hexcode] || emojibase[hexcode]; 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 => { export const getShortcodeFor = (hexcode: string): string | undefined => {
const shortcode = joypixels[hexcode] || emojibase[hexcode]; const shortcode = getShortcodesFor(hexcode);
return Array.isArray(shortcode) ? shortcode[0] : shortcode; 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[] = [ export const emojiGroups: IEmojiGroup[] = [
{ { id: EmojiGroupId.People, order: 0, emojis: [] },
id: EmojiGroupId.People, { id: EmojiGroupId.Nature, order: 1, emojis: [] },
order: 0, { id: EmojiGroupId.Food, order: 2, emojis: [] },
emojis: [], { id: EmojiGroupId.Activity, order: 3, emojis: [] },
}, { id: EmojiGroupId.Travel, order: 4, emojis: [] },
{ { id: EmojiGroupId.Object, order: 5, emojis: [] },
id: EmojiGroupId.Nature, { id: EmojiGroupId.Symbol, order: 6, emojis: [] },
order: 1, { id: EmojiGroupId.Flag, order: 7, emojis: [] },
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[] = []; export const emojis: IEmoji[] = [];
@@ -95,20 +111,51 @@ function getGroupIndex(emoji: IEmoji): number | undefined {
return undefined; return undefined;
} }
emojisData.forEach((emoji) => { let emojiDataPromise: Promise<EmojiData> | undefined;
const myShortCodes = getShortcodesFor(emoji.hexcode);
if (!myShortCodes) return;
if (Array.isArray(myShortCodes) && myShortCodes.length === 0) return;
const em: IEmoji = { /**
...emoji, * Lazily load emojibase data (dynamic import the `emojibase` chunk). Memoized:
shortcode: Array.isArray(myShortCodes) ? myShortCodes[0] : myShortCodes, * the JSON is fetched/parsed and `emojis`/`emojiGroups` are built exactly once.
shortcodes: Array.isArray(myShortCodes) ? myShortCodes : emoji.shortcodes, */
}; 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); joypixelsShortcodes = joypixelsModule.default as ShortcodeMap;
if (groupIndex !== undefined) { emojibaseShortcodes = emojibaseModule.default as ShortcodeMap;
addEmojiToGroup(groupIndex, em);
emojis.push(em); 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;
};
+26 -10
View File
@@ -229,13 +229,21 @@ export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] =>
findAndReplace( findAndReplace(
text, text,
EMOJI_REG_G, EMOJI_REG_G,
(match, pushIndex) => ( (match, pushIndex) => {
<span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}> const shortcode = getShortcodeFor(getHexcodeForEmoji(match[0]));
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(match[0]))}> return (
{match[0]} <span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}>
<span
className={css.Emoticon()}
title={shortcode}
aria-label={shortcode || undefined}
role={shortcode ? 'img' : undefined}
>
{match[0]}
</span>
</span> </span>
</span> );
), },
(txt) => txt, (txt) => txt,
); );
@@ -574,15 +582,25 @@ export const getReactCustomHtmlParser = (
); );
} }
if (htmlSrc && 'data-mx-emoticon' in props) { if (htmlSrc && 'data-mx-emoticon' in props) {
const emoticonAlt =
(typeof props.alt === 'string' && props.alt) ||
(typeof props.title === 'string' && props.title) ||
'emoji';
return ( return (
<span className={css.EmoticonBase}> <span className={css.EmoticonBase}>
<span className={css.Emoticon()}> <span className={css.Emoticon()}>
<img {...props} className={css.EmoticonImg} src={htmlSrc} /> <img
{...props}
alt={emoticonAlt}
className={css.EmoticonImg}
src={htmlSrc}
loading="lazy"
/>
</span> </span>
</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) => { {segments.map((segment, index) => {
if (segment.type === 'text') { if (segment.type === 'text') {
// eslint-disable-next-line react/no-array-index-key
return ( return (
<React.Fragment key={index}>{renderTextChunk(segment.value)}</React.Fragment> <React.Fragment key={index}>{renderTextChunk(segment.value)}</React.Fragment>
); );
@@ -619,7 +636,6 @@ export const getReactCustomHtmlParser = (
const raw = const raw =
segment.type === 'block' ? `$$${segment.value}$$` : `$${segment.value}$`; segment.type === 'block' ? `$$${segment.value}$$` : `$${segment.value}$`;
return ( return (
// eslint-disable-next-line react/no-array-index-key
<React.Fragment key={index}> <React.Fragment key={index}>
{renderMath(segment.value, segment.type === 'block', raw, raw)} {renderMath(segment.value, segment.type === 'block', raw, raw)}
</React.Fragment> </React.Fragment>
+22 -296
View File
@@ -2,307 +2,33 @@ import React, { MutableRefObject, ReactNode, useEffect, useRef } from 'react';
import Prism from 'prismjs'; import Prism from 'prismjs';
import 'prismjs/components/prism-abap.js'; // PERF: Prism used to import every bundled language (~574 KB lazy chunk). We now
import 'prismjs/components/prism-abnf.js'; // ship a curated subset covering the languages actually seen in chat. Imports
import 'prismjs/components/prism-actionscript.js'; // MUST stay in dependency order (Prism component files assume their base grammar
import 'prismjs/components/prism-ada.js'; // is already registered): base grammars (markup/css/clike/javascript) first,
import 'prismjs/components/prism-agda.js'; // then languages that extend them (e.g. c→cpp, javascript→typescript,
import 'prismjs/components/prism-al.js'; // markup+javascript→jsx, jsx+typescript→tsx, markup→markdown).
import 'prismjs/components/prism-antlr4.js'; import 'prismjs/components/prism-markup.js'; // markup / html / xml / svg
import 'prismjs/components/prism-apacheconf.js'; import 'prismjs/components/prism-css.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';
import 'prismjs/components/prism-clike.js'; import 'prismjs/components/prism-clike.js';
import 'prismjs/components/prism-clojure.js'; import 'prismjs/components/prism-javascript.js'; // js
import 'prismjs/components/prism-cmake.js'; import 'prismjs/components/prism-json.js';
import 'prismjs/components/prism-cobol.js'; import 'prismjs/components/prism-yaml.js';
import 'prismjs/components/prism-coffeescript.js'; import 'prismjs/components/prism-bash.js'; // bash / shell / sh
import 'prismjs/components/prism-concurnas.js'; import 'prismjs/components/prism-python.js';
import 'prismjs/components/prism-cooklang.js'; import 'prismjs/components/prism-rust.js';
import 'prismjs/components/prism-coq.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-cpp.js';
import 'prismjs/components/prism-csharp.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-sql.js';
import 'prismjs/components/prism-squirrel.js'; import 'prismjs/components/prism-diff.js';
import 'prismjs/components/prism-stan.js'; import 'prismjs/components/prism-docker.js';
import 'prismjs/components/prism-stata.js'; import 'prismjs/components/prism-markdown.js';
import 'prismjs/components/prism-stylus.js'; import 'prismjs/components/prism-typescript.js'; // ts
import 'prismjs/components/prism-supercollider.js'; import 'prismjs/components/prism-jsx.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-tsx.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'; import './ReactPrism.css';
// using classNames .prism-dark .prism-light from ReactPrism.css // using classNames .prism-dark .prism-light from ReactPrism.css
+4 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk'; import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { addRecentEmoji, getRecentEmojis, IRecentEmojiContent } from './recent-emoji'; import { addRecentEmoji, getRecentEmojis, IRecentEmojiContent } from './recent-emoji';
import { AccountDataEvent } from '../../types/matrix/accountData'; import { AccountDataEvent } from '../../types/matrix/accountData';
import { emojis } from './emoji'; import { emojis, loadEmojiData } from './emoji';
// A Map-backed MatrixClient stub supporting get/setAccountData. // A Map-backed MatrixClient stub supporting get/setAccountData.
const createMx = () => { const createMx = () => {
@@ -25,6 +25,9 @@ const createMx = () => {
const getStored = (store: Map<string, unknown>): IRecentEmojiContent['recent_emoji'] => const getStored = (store: Map<string, unknown>): IRecentEmojiContent['recent_emoji'] =>
(store.get(AccountDataEvent.ElementRecentEmoji) as IRecentEmojiContent | undefined)?.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. // Pick two real unicode emojis to drive add->get round trips.
const u1 = emojis[0].unicode; const u1 = emojis[0].unicode;
const u2 = emojis[1].unicode; const u2 = emojis[1].unicode;
+28
View File
@@ -0,0 +1,28 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import dayjs from 'dayjs';
import { messageAriaLabel } from './a11y';
import { timeDayMonthYear, timeHourMinute } from './time';
test('messageAriaLabel composes sender, date and time (24h)', () => {
const ts = dayjs('2026-07-01T14:30:00').valueOf();
assert.equal(
messageAriaLabel('Alice', ts, true),
`Alice, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, true)}`,
);
});
test('messageAriaLabel honours the 12-hour clock preference', () => {
const ts = dayjs('2026-07-01T14:30:00').valueOf();
assert.equal(
messageAriaLabel('Bob', ts, false),
`Bob, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, false)}`,
);
});
test('messageAriaLabel keeps the sender name verbatim as plain text', () => {
const ts = dayjs('2026-07-01T09:05:00').valueOf();
const label = messageAriaLabel('@user:example.org', ts, true);
assert.ok(label.startsWith('@user:example.org, '));
assert.ok(!label.includes('<'));
});
+14
View File
@@ -0,0 +1,14 @@
import { timeDayMonthYear, timeHourMinute } from './time';
/**
* Builds a plain-text accessible label for a message row, used when the
* visible sender/timestamp header is collapsed and therefore hidden from
* assistive technology.
*
* @param sender - Sender display name (already resolved to a human string).
* @param ts - Message origin timestamp in milliseconds.
* @param hour24Clock - Whether to format the time using a 24-hour clock.
* @returns A label such as `Alice, 1 July 2026 14:30`.
*/
export const messageAriaLabel = (sender: string, ts: number, hour24Clock: boolean): string =>
`${sender}, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`;
+43
View File
@@ -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')}`,
);
});
+33 -1
View File
@@ -1,7 +1,39 @@
/// <reference lib="WebWorker" /> /// <reference lib="WebWorker" />
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
export type {}; 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 = { type SessionInfo = {
accessToken: string; accessToken: string;
+13 -1
View File
@@ -261,7 +261,19 @@ export default defineConfig({
injectRegister: false, injectRegister: false,
manifest: false, manifest: false,
injectManifest: { 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; // codeSplitting: false is not yet supported by vite-plugin-pwa 1.3.0;
// the inlineDynamicImports deprecation warning from Vite is from pwa internal build // the inlineDynamicImports deprecation warning from Vite is from pwa internal build
}, },