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