Compare commits
41 Commits
ebc782b16c
..
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d7a05c0f1 | |||
| b5e7bcc0b8 | |||
| bca371ad38 | |||
| 899a14c119 | |||
| 6728a1274d | |||
| 21dda93d1b | |||
| 4380041014 | |||
| 8729ccfcf5 | |||
| 8ab1ec254b | |||
| 23f715857c | |||
| f589182709 | |||
| ef573376ac | |||
| 34d9272790 | |||
| 96f7187031 | |||
| 664dcd4cd8 | |||
| 7f960b026b | |||
| 992d2b83b3 | |||
| a9505ca5b2 | |||
| dca51a41ef | |||
| 579449acc3 | |||
| 34592d9144 | |||
| 0adce52d37 | |||
| 501d493ca4 | |||
| ffb934fce6 | |||
| 440c1fe948 | |||
| aa62df9c75 | |||
| 15ac538a4b | |||
| 39cfc23ebe | |||
| 7a8cadc6ec | |||
| 91bd360125 | |||
| 7da960ac8c | |||
| ed51c39fe7 | |||
| c1efa7b94e | |||
| e31b84c08e | |||
| 258e3ec620 | |||
| 3336abb66f | |||
| a184ee0221 | |||
| 4509a2b6d3 | |||
| 7e38baa7b6 | |||
| aab7e5ae20 | |||
| a0fcdf74da |
+22
-10
@@ -16,7 +16,7 @@ step-by-step checks in [`LOTUS_TESTING.md`](./LOTUS_TESTING.md).
|
|||||||
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
|
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 |
|
||||||
@@ -32,6 +32,16 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
|
|||||||
| 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 |
|
||||||
|
| P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk) | `utils/mathParse.ts`, `components/math/` | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text) |
|
||||||
|
| P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes |
|
||||||
|
| N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth |
|
||||||
|
| P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math) | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread` | 6-step checklist in LOTUS_TODO §P4-1 |
|
||||||
|
| AW-1 | Scheduled-message cancel no longer ghost-sends (error row on failure) | `ScheduledMessagesTray.tsx` | schedule → cancel with network cut → item stays + error; retry works |
|
||||||
|
| AW-2 | Emoji lazy-load (search/autocomplete/recents fill in; board opens fast) | `plugins/emoji.ts` + consumers | first emoji-board open of a session: grid+search populate; reactions still label |
|
||||||
|
| AW-3 | SW precache (repeat-visit near-instant; deploys still picked up immediately) | `sw.ts`, `vite.config.js` | load app twice (2nd = cached assets); deploy → reload picks new version |
|
||||||
|
| AW-4 | Desktop CSP tighten + Escape/panel fixes + thread Jump to Latest | `tauri.conf.json`, Room/ThreadPanel | desktop: boots, avatars/media load, VT323 font renders, location maps embed, calls connect, deep links work |
|
||||||
|
| P3-4 | Accessibility compliance pass (collapsed-msg SR sender, form/overlay labels, typing announce, focus-return, `?` help, jsx-a11y CI gate) | `message/*`, `RoomViewTyping`, `features/shortcuts/*`, `eslint.config.mjs` | LOTUS_TESTING §P — axe-core + VoiceOver/NVDA on the golden path |
|
||||||
|
|
||||||
**Verified working in live testing (2026-06):** A2, B1–B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
|
**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.
|
||||||
|
|
||||||
@@ -69,12 +79,14 @@ from testing:
|
|||||||
|
|
||||||
## 🔴 Open — Actionable
|
## 🔴 Open — Actionable
|
||||||
|
|
||||||
### Calls / Audio
|
|
||||||
|
|
||||||
- ~~**N127 — ML denoise shim is never injected in `vite dev`.**~~ **RESOLVED (dissolved by the A7 denoise cutover).** `vite.config.js` no longer injects a getUserMedia shim at all — the forked Element Call runs ML denoise in-source as a LiveKit `TrackProcessor` (activated by `lotusDenoiseSource=1`), so there is no build-time injection that could be missing in dev. Nothing to fix.
|
|
||||||
|
|
||||||
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED · 👤 SENIOR ENGINEER
|
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED · 👤 SENIOR ENGINEER
|
||||||
|
|
||||||
|
> 🧰 **Investigation kit ready (2026-07):** [`LOTUS_E2EE_INVESTIGATION.md`](./LOTUS_E2EE_INVESTIGATION.md)
|
||||||
|
> has the per-KE capture runbook (console signatures, synapse-side queries, the
|
||||||
|
> KE-1→KE-2 causality decision tree, ranked remediations), and the client now
|
||||||
|
> ships a **Crypto Diagnostics** capture helper (Settings) — run it during the
|
||||||
|
> next affected call and download the report before starting any fix.
|
||||||
|
|
||||||
> **Observed live in prod 2026-06-30** on `chat.lotusguild.org` during a 2-person
|
> **Observed live in prod 2026-06-30** on `chat.lotusguild.org` during a 2-person
|
||||||
> **Element Call** (E2EE enabled). These span **client rust-crypto (via
|
> **Element Call** (E2EE enabled). These span **client rust-crypto (via
|
||||||
> `matrix-js-sdk@41.6.0-rc.0`) ↔ Synapse ↔ Element Call's MatrixRTC E2EE** and are
|
> `matrix-js-sdk@41.6.0-rc.0`) ↔ Synapse ↔ Element Call's MatrixRTC E2EE** and are
|
||||||
@@ -128,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
|
||||||
@@ -139,15 +151,15 @@ 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
|
||||||
|
|
||||||
- **Automated test suite — 545 tests across 62 modules, a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Broad pure-logic coverage: utils (common, regex, sanitize/XSS, time, matrix, matrix-uia, mimeTypes, sort, accentColor, findAndReplace, AsyncSearch, ASCIILexicalTable, keyboard, room, matrix-crypto, featureCheck, syntaxHighlight, imageCompression, user-agent, callSounds), state (settings, sessions, recentSearches, upload, typingMembers, lists, room-list, toast, scheduledMessages, backupRestore, callEmbed/callPreferences, spaceRooms, …), plugins (matrix-to, call/utils, via-servers, bad-words, recent-emoji, custom-emoji, markdown block/inline/utils), OIDC (cs-api, useParsedLoginFlows, oidcState), lotus/avatarDecorations, message-search, search filters. Prevention work has caught + fixed **4 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked; `isMacOS` never matching modern Macs; `isMLDenoiseSupported` throwing `ReferenceError` instead of returning false on browsers lacking the `AudioWorkletNode` binding). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface).
|
- **Automated test suite — 561+ tests across 65+ modules, a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Broad pure-logic coverage: utils (common, regex, sanitize/XSS, time, matrix, matrix-uia, mimeTypes, sort, accentColor, findAndReplace, AsyncSearch, ASCIILexicalTable, keyboard, room, matrix-crypto, featureCheck, syntaxHighlight, imageCompression, user-agent, callSounds), state (settings, sessions, recentSearches, upload, typingMembers, lists, room-list, toast, scheduledMessages, backupRestore, callEmbed/callPreferences, spaceRooms, …), plugins (matrix-to, call/utils, via-servers, bad-words, recent-emoji, custom-emoji, markdown block/inline/utils), OIDC (cs-api, useParsedLoginFlows, oidcState), lotus/avatarDecorations, message-search, search filters. Prevention work has caught + fixed **4 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked; `isMacOS` never matching modern Macs; `isMLDenoiseSupported` throwing `ReferenceError` instead of returning false on browsers lacking the `AudioWorkletNode` binding). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface).
|
||||||
- **Extensive `as any` casts** across `src/` — gradual typing cleanup.
|
- **Extensive `as any` casts** across `src/` — gradual typing cleanup.
|
||||||
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
|
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
|
||||||
- **Hardcoded CDN URL** should move to an env var (the decoration CDN is now single-sourced in `avatarDecorations.ts`, but the literal is still in-repo).
|
- ~~**Hardcoded CDN URL** should move to an env var~~ — **done:** `avatarDecorations.ts` already honors a `VITE_DECORATION_CDN` env override (lines 14-16); the in-repo literal is only the default. Nothing left.
|
||||||
- **`patch-folds.mjs` edits `node_modules` directly** — consider `patch-package`.
|
- **`patch-folds.mjs` edits `node_modules` directly** — consider `patch-package`.
|
||||||
- **Infra docs:** `contrib/nginx` lacks security headers (HSTS/CSP) + uses rewrites over `try_files`; `contrib/caddy` has a placeholder path. CI/CD (`prod-deploy.yml`): sequential deploy, aggressive 1-min Netlify timeout, `package-manager-cache: false`.
|
- **Infra docs:** `contrib/nginx` lacks security headers (HSTS/CSP) + uses rewrites over `try_files`; `contrib/caddy` has a placeholder path. CI/CD (`prod-deploy.yml`): sequential deploy, aggressive 1-min Netlify timeout, `package-manager-cache: false`.
|
||||||
- **README:** keep the fork-sync version + logo path current. (`CONTRIBUTING.md` is intentionally left as upstream Cinny's — not a Lotus concern.)
|
- **README:** keep the fork-sync version + logo path current. (`CONTRIBUTING.md` is intentionally left as upstream Cinny's — not a Lotus concern.)
|
||||||
@@ -156,4 +168,4 @@ retry … AbortError: Restart delayed event timed out before the HS responded`,
|
|||||||
|
|
||||||
### Big Projects
|
### Big Projects
|
||||||
|
|
||||||
- **#5 — Seasonal themes & chat-background redesign.** Current backgrounds are basic CSS; goal is high-fidelity, research-backed, GPU-accelerated designs (layered `oklch`, `backdrop-filter`, `contain:paint`) with WCAG-AA overlay contrast. Treat each as its own design sprint.
|
- ~~**#5 — Seasonal themes & chat-background redesign.**~~ **DONE (2026-06/07):** 11 seasonal/holiday overlays shipped and later toned down + given a settings preview grid; all 19 chat backgrounds redesigned (Carbon + Aurora kept per user preference), one design sprint each, GPU-friendly CSS with `prefers-reduced-motion` + pause toggle. Remaining polish rides normal bug flow, not a "big project."
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
# Lotus Chat — E2EE Investigation Runbook (KE-1 → KE-4)
|
||||||
|
|
||||||
|
> **Scope:** evidence-gathering only. Do **not** apply fixes from this document
|
||||||
|
> without a cross-system planning session (client rust-crypto ↔ Synapse ↔
|
||||||
|
> Element Call MatrixRTC). Symptom source: `LOTUS_BUGS.md` §"Encryption / E2EE"
|
||||||
|
> (KE-1..KE-4), observed live 2026-06-30 on `chat.lotusguild.org` during a
|
||||||
|
> 2-person Element Call.
|
||||||
|
>
|
||||||
|
> **Client:** Lotus Cinny fork, `matrix-js-sdk@41.6.0-rc.0`, rust-crypto.
|
||||||
|
> **Server:** Synapse `1.155.0` on **LXC 151** (`10.10.10.29`), PostgreSQL 17.9
|
||||||
|
> on **LXC 109** (`10.10.10.44`). Facts below are copy-pasteable against that
|
||||||
|
> deployment (paths/IPs from `/root/code/matrix/README.md`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Deployment facts used by this runbook
|
||||||
|
|
||||||
|
From the matrix infra README (`/root/code/matrix/README.md`):
|
||||||
|
|
||||||
|
| Thing | Value |
|
||||||
|
| ------------------------ | ------------------------------------------------------------- |
|
||||||
|
| Synapse host | LXC **151**, `10.10.10.29` (Synapse 1.155.0) |
|
||||||
|
| Synapse log | `/var/log/matrix-synapse/homeserver.log` |
|
||||||
|
| Synapse config | `/etc/matrix-synapse/homeserver.yaml` (+ `conf.d/`) |
|
||||||
|
| Synapse HTTP | `10.10.10.29:8008` |
|
||||||
|
| PostgreSQL host | LXC **109**, `10.10.10.44` (PG 17.9), db `synapse` |
|
||||||
|
| synapse-admin UI | `http://10.10.10.29:8080` |
|
||||||
|
| LiveKit / lk-jwt / guard | LXC 151: LiveKit `:7880/:7881`, guard `:8070`, lk-jwt `:8071` |
|
||||||
|
| SSH path to Synapse | `ssh root@10.10.10.4` then `pct enter 151` |
|
||||||
|
| SSH path to PG | `ssh root@10.10.10.4` then `pct enter 109` |
|
||||||
|
|
||||||
|
**Getting a psql shell** (run on LXC 109, or from 151 over the network):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On LXC 109:
|
||||||
|
sudo -u postgres psql synapse
|
||||||
|
# From LXC 151 (pg_hba allows 10.10.10.29):
|
||||||
|
psql "host=10.10.10.44 user=synapse dbname=synapse"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tailing Synapse during a call** (on LXC 151):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -F /var/log/matrix-synapse/homeserver.log | tee /tmp/lotus-call-$(date +%s).log
|
||||||
|
```
|
||||||
|
|
||||||
|
Synapse E2EE/to-device logging is chatty at `INFO`; if a category is silent,
|
||||||
|
temporarily raise it in `/etc/matrix-synapse/conf.d/log.yaml` (or the
|
||||||
|
`log_config` file referenced by `homeserver.yaml`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
loggers:
|
||||||
|
synapse.rest.client.keys: { level: DEBUG }
|
||||||
|
synapse.handlers.e2e_keys: { level: DEBUG }
|
||||||
|
synapse.storage.databases.main.end_to_end_keys: { level: DEBUG }
|
||||||
|
synapse.handlers.devicemessage: { level: DEBUG } # to-device
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `systemctl reload matrix-synapse` (reload re-reads log config without a
|
||||||
|
full restart). **Revert to `INFO` after the capture** — DEBUG is very verbose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Per-KE evidence matrix
|
||||||
|
|
||||||
|
Client greps assume Chrome/Firefox DevTools console (filter box or, better,
|
||||||
|
"Preserve log" + save-as). The **Crypto Diagnostics** card (Settings →
|
||||||
|
Developer Tools) auto-captures every signature below into a downloadable JSON —
|
||||||
|
use it as the primary client artifact and DevTools as the raw backup.
|
||||||
|
|
||||||
|
### KE-1 — OTK upload conflict storm (root-cause candidate)
|
||||||
|
|
||||||
|
- **Console signature (grep):**
|
||||||
|
- `already exists`
|
||||||
|
- full: `POST /_matrix/client/v3/keys/upload … 400 M_UNKNOWN: One time key signed_curve25519:<id> already exists. Old key: {…} new key: {…}`
|
||||||
|
- **Capture client-side:**
|
||||||
|
- Timestamp (first occurrence + rate — "N/sec"), **device id**, **user id**.
|
||||||
|
- DevTools → **Network** → filter `keys/upload`: for a failing call save the
|
||||||
|
**request body** (the `one_time_keys` map — note the exact `signed_curve25519:<id>`)
|
||||||
|
and the **response body** (the `Old key` / `new key` JSON). This diff is the
|
||||||
|
smoking gun: same key-id, different value ⇒ store vs server divergence.
|
||||||
|
- Whether it self-heals or loops forever (KE-1 loops).
|
||||||
|
- **Synapse log grep (LXC 151):**
|
||||||
|
```bash
|
||||||
|
grep -E "keys/upload|One time key .* already exists|OneTimeKey" \
|
||||||
|
/var/log/matrix-synapse/homeserver.log | grep "<user_id>"
|
||||||
|
```
|
||||||
|
- **Synapse SQL (LXC 109) — what the server thinks it holds:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Current OTK inventory for the device (compare key_id set against the
|
||||||
|
-- request body the client keeps retrying).
|
||||||
|
SELECT algorithm, key_id, ts_added_ms
|
||||||
|
FROM e2e_one_time_keys_json
|
||||||
|
WHERE user_id = '@user:matrix.lotusguild.org'
|
||||||
|
AND device_id = '<DEVICE_ID>'
|
||||||
|
ORDER BY algorithm, key_id;
|
||||||
|
|
||||||
|
-- Server's advertised counts (this is what /sync tells the client it has,
|
||||||
|
-- and drives whether the client decides to upload more).
|
||||||
|
SELECT algorithm, count(*) FROM e2e_one_time_keys_json
|
||||||
|
WHERE user_id = '@user:matrix.lotusguild.org' AND device_id = '<DEVICE_ID>'
|
||||||
|
GROUP BY algorithm;
|
||||||
|
|
||||||
|
-- Fallback key state (used when OTKs are exhausted).
|
||||||
|
SELECT algorithm, key_id, used, ts_added_ms
|
||||||
|
FROM e2e_fallback_keys_json
|
||||||
|
WHERE user_id = '@user:matrix.lotusguild.org' AND device_id = '<DEVICE_ID>';
|
||||||
|
```
|
||||||
|
|
||||||
|
> Table names are Synapse 1.155 (`e2e_one_time_keys_json`,
|
||||||
|
> `e2e_fallback_keys_json`). If a name is absent, list with `\dt e2e*` in psql.
|
||||||
|
|
||||||
|
- **Confirms:** if the offending `key_id` (from the 400) is **present** in
|
||||||
|
`e2e_one_time_keys_json` with a **different** stored value than the client's
|
||||||
|
request body → OTK state has diverged (rust-crypto store vs Synapse). That is
|
||||||
|
the KE-1 root condition.
|
||||||
|
|
||||||
|
### KE-2 — EC media keys not arriving/decrypting (audio/video cutouts)
|
||||||
|
|
||||||
|
- **Console signature (grep):**
|
||||||
|
- `MissingKey`
|
||||||
|
- `missing key at index` (e.g. `MissingKey: missing key at index N for participant @user`)
|
||||||
|
- `key set not found`
|
||||||
|
- `io.element.call.encryption_keys` (rust-crypto: `WARN … Received an unexpected encrypted to-device event … event_type="io.element.call.encryption_keys"`)
|
||||||
|
- **Capture client-side:**
|
||||||
|
- Timestamp windows where a participant's audio/video cut out, and the
|
||||||
|
`@participant` + `index N` from the message.
|
||||||
|
- The `io.element.call.encryption_keys` warnings (these are the media-key
|
||||||
|
to-device events failing to decrypt) with their timestamps.
|
||||||
|
- Own device id + user id (to correlate with the sender's Olm session).
|
||||||
|
- **Synapse log grep (LXC 151) — to-device delivery of the media keys:**
|
||||||
|
```bash
|
||||||
|
grep -E "io.element.call.encryption_keys|m.room.encrypted|/sendToDevice|to_device" \
|
||||||
|
/var/log/matrix-synapse/homeserver.log | grep -E "<user_id>|<participant_id>"
|
||||||
|
```
|
||||||
|
- **Synapse SQL (LXC 109) — undelivered / queued to-device events:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Backlog of to-device messages queued for the affected device. A growing
|
||||||
|
-- count here = the HS has the media-key events but the device isn't draining
|
||||||
|
-- them via /sync (or they were sent to a stale device id).
|
||||||
|
SELECT user_id, device_id, count(*) AS pending
|
||||||
|
FROM device_inbox
|
||||||
|
WHERE user_id = '@user:matrix.lotusguild.org'
|
||||||
|
GROUP BY user_id, device_id;
|
||||||
|
|
||||||
|
-- Cross-check the device id the sender is targeting actually exists / is current.
|
||||||
|
SELECT device_id, display_name, last_seen, ts
|
||||||
|
FROM devices WHERE user_id = '@user:matrix.lotusguild.org';
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Confirms:** to-device events present but undecryptable (client shows the
|
||||||
|
`io.element.call.encryption_keys` "unexpected encrypted" warning) ⇒ there is
|
||||||
|
**no valid Olm session** to decrypt them — the expected downstream of KE-1.
|
||||||
|
|
||||||
|
### KE-3 — Timeline decryption error: missing `algorithm` field
|
||||||
|
|
||||||
|
- **Console signature (grep):**
|
||||||
|
- `DecryptionError`
|
||||||
|
- full: `Error decrypting event (… type=m.room.encrypted …): DecryptionError[msg: missing field 'algorithm' at line 1 column 138 …]`
|
||||||
|
- **Capture client-side:**
|
||||||
|
- The **event id** (`$SASBBzoqj…` was one) and the **room id**.
|
||||||
|
- Pull the raw event JSON via DevTools or the Developer Tools account-data/event
|
||||||
|
viewer, or directly:
|
||||||
|
```
|
||||||
|
GET https://matrix.lotusguild.org/_matrix/client/v3/rooms/<roomId>/event/<eventId>
|
||||||
|
```
|
||||||
|
Inspect `content` — confirm whether `algorithm` (should be
|
||||||
|
`m.megolm.v1.aes-sha2`) is truly absent vs a serialization mismatch.
|
||||||
|
- **Synapse log grep (LXC 151):**
|
||||||
|
```bash
|
||||||
|
grep -E "<eventId>" /var/log/matrix-synapse/homeserver.log
|
||||||
|
```
|
||||||
|
- **Synapse SQL (LXC 109) — the stored event content as the HS holds it:**
|
||||||
|
```sql
|
||||||
|
SELECT ej.event_id, e.type, e.sender, e.origin_server_ts,
|
||||||
|
(ej.json::json -> 'content' -> 'algorithm') AS algorithm
|
||||||
|
FROM event_json ej
|
||||||
|
JOIN events e USING (event_id)
|
||||||
|
WHERE ej.event_id = '$SASBBzoqj...';
|
||||||
|
```
|
||||||
|
- **Confirms:** if the stored `content.algorithm` is **NULL/absent** on the HS →
|
||||||
|
a malformed/legacy event was persisted (sender-side or federation). If it is
|
||||||
|
**present** on the HS but the client throws → an RC-SDK deserialization bug.
|
||||||
|
This distinction decides whether KE-3 is a data problem or a client problem.
|
||||||
|
|
||||||
|
### KE-4 — MatrixRTC delayed-event / membership timeouts
|
||||||
|
|
||||||
|
- **Console signature (grep):**
|
||||||
|
- `update_delayed_event` (`org.matrix.msc4157.update_delayed_event`)
|
||||||
|
- `delayed event` / `Restart delayed event timed out`
|
||||||
|
- full: `[MembershipManager] Network local timeout error while sending event, immediate retry … AbortError: Restart delayed event timed out before the HS responded`
|
||||||
|
- **Capture client-side:**
|
||||||
|
- Timestamps of each timeout; whether they correlate with call join/leave or
|
||||||
|
with general sync slowness.
|
||||||
|
- DevTools → Network: the `…/delayed_events…` / `update_delayed_event`
|
||||||
|
requests — their **HTTP status and latency** (timed-out vs slow-200).
|
||||||
|
- **Synapse log grep (LXC 151):**
|
||||||
|
```bash
|
||||||
|
grep -E "delayed_event|msc4140|msc4157|update_delayed" \
|
||||||
|
/var/log/matrix-synapse/homeserver.log | grep "<user_id>"
|
||||||
|
# HS responsiveness in the same window (KE-4 may be pure latency):
|
||||||
|
grep -E "Processed request|/sync" /var/log/matrix-synapse/homeserver.log | tail -50
|
||||||
|
```
|
||||||
|
- **Server-side corroboration (Grafana, `dashboard.lotusguild.org`):** Synapse
|
||||||
|
p99 response time (excl. `/sync`), event-processing lag, DB query latency for
|
||||||
|
the call window. High latency here ⇒ KE-4 is (partly) homeserver
|
||||||
|
responsiveness, not a client bug.
|
||||||
|
- **Confirms:** timeouts that line up with HS latency spikes → reliability/load;
|
||||||
|
timeouts with a healthy HS → client MembershipManager retry logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Causality hypothesis
|
||||||
|
|
||||||
|
```
|
||||||
|
KE-1 OTK upload conflict storm
|
||||||
|
(rust-crypto store ↔ Synapse OTK state DIVERGED; server rejects re-uploads)
|
||||||
|
│ no fresh OTKs can be published/claimed
|
||||||
|
▼
|
||||||
|
No new Olm (1:1) sessions can be established with this device
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
KE-2 EC media-key to-device events (io.element.call.encryption_keys)
|
||||||
|
arrive but cannot be decrypted ⇒ MissingKey at index N
|
||||||
|
⇒ friend's audio/video cuts out
|
||||||
|
```
|
||||||
|
|
||||||
|
KE-3 (missing `algorithm`) and KE-4 (delayed-event timeouts) are **likely
|
||||||
|
independent** of the KE-1→KE-2 chain: KE-3 is a decode/serialization path,
|
||||||
|
KE-4 is a MatrixRTC-vs-HS reliability path. Confirm/refute independence with the
|
||||||
|
decision tree below.
|
||||||
|
|
||||||
|
### Decision tree — which capture confirms/refutes each link
|
||||||
|
|
||||||
|
```
|
||||||
|
Q1. Does the KE-1 offending key_id from the 400 response exist in
|
||||||
|
e2e_one_time_keys_json with a DIFFERENT value than the client request body?
|
||||||
|
├─ YES → OTK divergence CONFIRMED (KE-1 root). Go to Q2.
|
||||||
|
└─ NO → Not divergence. Check: are OTK counts at 0 with fallback key `used=true`?
|
||||||
|
├─ YES → OTK exhaustion, not divergence — different remediation.
|
||||||
|
└─ NO → Suspect RC-SDK 41.6.0-rc.0 upload-loop regression (see §3).
|
||||||
|
|
||||||
|
Q2. During the same call, are io.element.call.encryption_keys to-device events
|
||||||
|
present in device_inbox / Synapse to-device logs for our device id?
|
||||||
|
├─ YES + client shows "unexpected encrypted"/MissingKey
|
||||||
|
│ → KE-1 ⇒ KE-2 LINK CONFIRMED (events delivered, no Olm session to open them).
|
||||||
|
├─ YES + client decrypts fine, but LiveKit still silent
|
||||||
|
│ → KE-2 is downstream of LiveKit/SFU, NOT KE-1. Decouple from crypto.
|
||||||
|
└─ NO (nothing queued/targeted our device)
|
||||||
|
→ media keys never sent to us: stale device id / membership (see KE-4)
|
||||||
|
→ KE-2 is a device-targeting problem, weakly linked to KE-1.
|
||||||
|
|
||||||
|
Q3. KE-3: is content.algorithm NULL in event_json on the HS?
|
||||||
|
├─ YES → malformed persisted event (sender/federation). Independent of KE-1.
|
||||||
|
└─ NO → client-side RC-SDK deserialization bug. Independent of KE-1.
|
||||||
|
|
||||||
|
Q4. KE-4: do delayed-event timeouts coincide with Synapse p99 latency spikes
|
||||||
|
(Grafana) in the same minute?
|
||||||
|
├─ YES → homeserver responsiveness/load. Independent of KE-1..KE-3.
|
||||||
|
└─ NO → client MembershipManager retry behavior. Independent.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Ranked remediation options (with blast radius)
|
||||||
|
|
||||||
|
> Ordered least-destructive → most-destructive. **Do not run any of these as a
|
||||||
|
> "fix" before the planning session** — they are listed so evidence collection
|
||||||
|
> can be paired with a recovery plan. Confirm the root condition (Q1/Q2) first.
|
||||||
|
|
||||||
|
1. **Per-device logout + re-login of the affected device** _(lowest blast radius)_
|
||||||
|
- **What:** log the one glitching device out and back in. Forces a fresh
|
||||||
|
device id, fresh device keys, and a clean OTK batch — sidesteps a diverged
|
||||||
|
OTK store without touching other sessions.
|
||||||
|
- **Blast radius:** that device only. Other sessions/devices untouched.
|
||||||
|
- **Cost:** the new device must be re-verified (cross-signing) and will need
|
||||||
|
to restore room keys from **key backup** to read old encrypted history.
|
||||||
|
- **Confirms/uses:** if KE-1 stops after this, OTK-store divergence (Q1) was
|
||||||
|
the cause.
|
||||||
|
|
||||||
|
2. **Client crypto-store reset (`clearLoginData` path)** _(medium)_
|
||||||
|
- **What:** `clearLoginData()` in `src/client/initMatrix.ts` (coordinator's
|
||||||
|
file — do not edit) **deletes ALL IndexedDB databases** (incl.
|
||||||
|
`web-sync-store` and the rust-crypto store `crypto-store`), **unregisters
|
||||||
|
service workers**, **clears all Cache Storage**, and **`localStorage.clear()`**,
|
||||||
|
then reloads. `clearCacheAndReload()` is lighter — it only calls
|
||||||
|
`mx.store.deleteAllData()` (sync cache) and does **not** wipe crypto.
|
||||||
|
- **Blast radius:** this browser profile only, but total: you are logged out,
|
||||||
|
lose all cached sync state, drafts, settings, and **the local
|
||||||
|
megolm/room-key store**.
|
||||||
|
- **⚠️ Message-history / backup implication:** wiping `crypto-store` destroys
|
||||||
|
locally-held **room keys (megolm inbound sessions)**. Any history **not
|
||||||
|
backed up to server-side Key Backup** becomes **permanently undecryptable
|
||||||
|
on this device**. Before doing this: verify Key Backup is enabled and the
|
||||||
|
recovery key / passphrase is available (Settings → Security), or the user
|
||||||
|
loses readable history. Cross-signing must be re-established too.
|
||||||
|
- **Use when:** the rust-crypto store itself is corrupt/diverged and option 1
|
||||||
|
didn't clear it.
|
||||||
|
|
||||||
|
3. **SDK pin change off the RC** _(medium — codebase change, needs rebuild)_
|
||||||
|
- **Current pin:** `package.json` → `"matrix-js-sdk": "41.6.0-rc.0"` (a
|
||||||
|
release candidate).
|
||||||
|
- **Finding (npm / GitHub changelog, checked 2026-07):** stable **`41.6.0`**
|
||||||
|
was released **2026-05-26**. Its only changelog line is _"Throw sane error
|
||||||
|
on completeLoginOnNewDevice IdP rejection"_ — **no OTK / keys-upload / Olm /
|
||||||
|
to-device fix** relative to the RC. Later stable lines exist
|
||||||
|
(`41.7.0`, `41.8.0`; `41.7.0-rc.3` / `41.9.0-rc.0` seen as pre-releases).
|
||||||
|
Nearby crypto-relevant entries: `41.5.0` _"Enable encrypted history sharing
|
||||||
|
by default"_; `41.4.0` key-backup handling. **No changelog entry directly
|
||||||
|
addresses the KE-1 OTK-conflict symptom** in the immediate range — so
|
||||||
|
moving RC→`41.6.0` stable is a low-risk hygiene step but is **not expected
|
||||||
|
to fix KE-1 by itself**. Before pinning, re-read the CHANGELOG for any
|
||||||
|
`41.7.x`/`41.8.x` OTK/one-time-key/olm entry that post-dates this note.
|
||||||
|
- **Blast radius:** all users after the next `cinny-build.sh` deploy. Test the
|
||||||
|
rust-crypto IndexedDB schema — a downgrade triggers the `IDB_VERSION_CONFLICT`
|
||||||
|
path in `initMatrix.ts`.
|
||||||
|
|
||||||
|
4. **Synapse-side OTK row surgery** _(LAST RESORT — highest danger)_
|
||||||
|
- **What:** deleting/rewriting rows in `e2e_one_time_keys_json` (and/or
|
||||||
|
`e2e_fallback_keys_json`, `device_inbox`) for the affected device to force
|
||||||
|
the client to re-upload a clean batch.
|
||||||
|
- **⚠️ Danger:** direct writes to Synapse crypto tables can **desync every
|
||||||
|
device of that user**, break Olm sessions **for everyone who has claimed one
|
||||||
|
of those keys**, and are easy to get wrong (wrong `key_id`, cache not
|
||||||
|
invalidated). Synapse caches OTK counts — a raw DELETE without a restart can
|
||||||
|
leave the advertised count wrong, **worsening** the KE-1 loop.
|
||||||
|
- **Guardrails if ever done (planning session + HS owner only):** full
|
||||||
|
`pg_dump` of `synapse` first; do it during **zero active calls**; delete only
|
||||||
|
the exact diverged `key_id` for the exact `device_id`; `systemctl restart
|
||||||
|
matrix-synapse` to flush caches; then log the device out/in (option 1) so it
|
||||||
|
republishes. **Never** run this speculatively.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. "Capture session" checklist (run during the next call)
|
||||||
|
|
||||||
|
Do these **in order**. Aim to have client + server capturing the **same call**.
|
||||||
|
|
||||||
|
1. **Prep server tail (LXC 151):** SSH in, start
|
||||||
|
`tail -F /var/log/matrix-synapse/homeserver.log | tee /tmp/lotus-call-$(date +%s).log`.
|
||||||
|
(Optionally raise the `synapse.rest.client.keys` / `handlers.e2e_keys` /
|
||||||
|
`handlers.devicemessage` loggers to DEBUG per §0 and `systemctl reload
|
||||||
|
matrix-synapse` — remember to revert after.)
|
||||||
|
2. **Prep client:** open Lotus Chat → Settings → Developer Tools → **enable
|
||||||
|
Developer Tools** so the **Crypto Diagnostics** card is visible; note its
|
||||||
|
entry count starts at (or reset by reload to) 0.
|
||||||
|
3. **Open DevTools** (F12) → Console: enable **Preserve log**; Network tab:
|
||||||
|
enable **Preserve log** + **Record**. Note your **device id** and **user id**
|
||||||
|
(Settings → Devices / Developer Tools → Copy access token page shows ids).
|
||||||
|
4. **Note wall-clock start time** (ISO/UTC) on both machines so logs align.
|
||||||
|
5. **Join the Element Call** with the second participant; reproduce the fault
|
||||||
|
(wait for the audio/video cutouts and let KE-1 storm run ~30–60s).
|
||||||
|
6. **When a fault occurs, note the wall-clock timestamp** and which symptom
|
||||||
|
(audio cut / video freeze / etc.) — this bounds the log window.
|
||||||
|
7. **Client artifacts:** in the Crypto Diagnostics card click **Download report**
|
||||||
|
(`lotus-crypto-diag-<ts>.json`); in DevTools Network, save the failing
|
||||||
|
`keys/upload` request+response (right-click → Save/Copy), and the raw HAR
|
||||||
|
(Network → Save all as HAR) for the call window.
|
||||||
|
8. **Grab KE-3 event id / KE-2 participant+index** from the console (or the
|
||||||
|
diag JSON `entries[]`) for the SQL lookups.
|
||||||
|
9. **Server artifacts:** stop the tail; run the per-KE greps and SQL from §1
|
||||||
|
against the noted device id / user id / event id, saving output alongside the
|
||||||
|
client JSON. Screenshot the Grafana Synapse latency panels for the window
|
||||||
|
(for KE-4).
|
||||||
|
10. **Bundle & label:** put client JSON + HAR + server log slice + SQL output in
|
||||||
|
one folder named with the call's UTC start time. Revert any DEBUG log config
|
||||||
|
(`systemctl reload matrix-synapse`). Hand off to the planning session — **do
|
||||||
|
not apply §3 remediations yet.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Client diagnostics helper (this kit)
|
||||||
|
|
||||||
|
- **`src/app/utils/cryptoDiagLog.ts`** — capture-only console instrumentation.
|
||||||
|
- `installCryptoDiagLog()` — idempotent; wraps `console.warn`/`console.error`
|
||||||
|
with pass-through wrappers (originals always called) that ring-buffer (max
|
||||||
|
**200**) any line matching the KE signatures. No network, no timers.
|
||||||
|
- `getCryptoDiagEntries()` — snapshot copy of the buffer (`{ ts, level, ke,
|
||||||
|
signature, message }`, most-recent-last).
|
||||||
|
- `buildCryptoDiagReport(mx)` — JSON string: SDK version, device id, user id,
|
||||||
|
sync state, `cryptoReady` (`mx.getCrypto()` presence), per-KE counts, and the
|
||||||
|
entry buffer. No tokens/PII beyond those ids; captured log lines are retained
|
||||||
|
verbatim as evidence.
|
||||||
|
- **Signatures → KE mapping:** `already exists`→KE-1; `missing key at index` /
|
||||||
|
`io.element.call.encryption_keys` / `MissingKey`→KE-2; `DecryptionError`→KE-3;
|
||||||
|
`update_delayed_event` / `delayed event`→KE-4.
|
||||||
|
- **`src/app/features/settings/developer/CryptoDiagnostics.tsx`** — a folds
|
||||||
|
`SequenceCard`/`SettingTile` card (mirrors `developer-tools/DevelopTools.tsx`)
|
||||||
|
showing the live matched-entry count (Badge) and a **Download report** button
|
||||||
|
(Blob → `lotus-crypto-diag-<ts>.json`, same download idiom as
|
||||||
|
`room-settings/ExportRoomHistory.tsx`).
|
||||||
|
|
||||||
|
### Recommended mount points (coordinator)
|
||||||
|
|
||||||
|
- **Install call:** call `installCryptoDiagLog()` **as early as possible during
|
||||||
|
boot** so it captures crypto errors from first sync — ideally at the top of
|
||||||
|
the client entry module or inside `ClientRoot` before/around `initClient`
|
||||||
|
(e.g. `src/app/pages/client/ClientRoot.tsx`). It is idempotent, side-effect
|
||||||
|
only, and needs no `mx`, so a module-scope call at app entry is safe. (Do
|
||||||
|
**not** put it in `initMatrix.ts` — that file is off-limits.)
|
||||||
|
- **Settings card:** render `<CryptoDiagnostics />` inside the Developer Tools
|
||||||
|
page — in `src/app/features/settings/developer-tools/DevelopTools.tsx`, add it
|
||||||
|
to the `Box direction="Column" gap="700"` list (guarded by the existing
|
||||||
|
`developerTools` flag), right after the "Access Token" card. It pulls `mx`
|
||||||
|
from `useMatrixClient()` itself, so it just needs to be placed in the tree.
|
||||||
+160
-15
@@ -18,14 +18,16 @@ Last updated: June 2026.
|
|||||||
9. [Per-Message Read Receipts](#per-message-read-receipts)
|
9. [Per-Message Read Receipts](#per-message-read-receipts)
|
||||||
10. [Delivery Status Indicators](#delivery-status-indicators)
|
10. [Delivery Status Indicators](#delivery-status-indicators)
|
||||||
11. [Messaging Enhancements](#messaging-enhancements)
|
11. [Messaging Enhancements](#messaging-enhancements)
|
||||||
12. [Presence](#presence)
|
12. [Threads (P3-8)](#threads-p3-8)
|
||||||
13. [UX & Composer](#ux--composer)
|
13. [Presence](#presence)
|
||||||
14. [Room Customization](#room-customization)
|
14. [UX & Composer](#ux--composer)
|
||||||
15. [Moderation](#moderation)
|
15. [Room Customization](#room-customization)
|
||||||
16. [Notifications](#notifications)
|
16. [Moderation](#moderation)
|
||||||
17. [Server Integration](#server-integration)
|
17. [Notifications](#notifications)
|
||||||
18. [Infrastructure](#infrastructure)
|
18. [Server Integration](#server-integration)
|
||||||
19. [Key Custom Files](#key-custom-files)
|
19. [Infrastructure](#infrastructure)
|
||||||
|
20. [Desktop App Features](#desktop-app-features)
|
||||||
|
21. [Key Custom Files](#key-custom-files)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -512,7 +514,7 @@ A comprehensive mic noise-suppression system in **Settings → General → Calls
|
|||||||
|
|
||||||
**Advanced Features & Test Options:**
|
**Advanced Features & Test Options:**
|
||||||
|
|
||||||
- **Multiple ML Models:** Toggle between **RNNoise** (standard hybrid) and **Speex** (legacy DSP-based) to compare artifact levels and suppression strength.
|
- **Multiple ML Models:** Four in-source models, selectable from a dropdown **ordered by quality/CPU** (best first): **DeepFilterNet 3** (48 kHz, best), **DTLN** (16 kHz), **RNNoise** (48 kHz), **Speex** (48 kHz, lightest). The **tier default is Browser-native**; when a user opts into ML the default model is **DeepFilterNet 3**.
|
||||||
- **Series Suppression (Combination):** Optional toggle to run the browser's native stationary noise filter _before_ the ML model. This allows testing the individual performance of the ML model vs the combined effectiveness at removing fan hum.
|
- **Series Suppression (Combination):** Optional toggle to run the browser's native stationary noise filter _before_ the ML model. This allows testing the individual performance of the ML model vs the combined effectiveness at removing fan hum.
|
||||||
- **Noise Gate:** Configurable hardware-style gate with a dB threshold. Hard-cuts all audio when input is below the threshold, ensuring absolute silence between sentences.
|
- **Noise Gate:** Configurable hardware-style gate with a dB threshold. Hard-cuts all audio when input is below the threshold, ensuring absolute silence between sentences.
|
||||||
- **Live Microphone Meter:** A real-time volume visualizer in the settings panel to help users accurately tune their Noise Gate threshold.
|
- **Live Microphone Meter:** A real-time volume visualizer in the settings panel to help users accurately tune their Noise Gate threshold.
|
||||||
@@ -524,17 +526,35 @@ A comprehensive mic noise-suppression system in **Settings → General → Calls
|
|||||||
**Open-Source Models (all now in-source in the EC fork):**
|
**Open-Source Models (all now in-source in the EC fork):**
|
||||||
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) | Sample rate |
|
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) | Sample rate |
|
||||||
| :--- | :--- | :--- | :--- | :--- |
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
| **RNNoise** (default) | Poor | Moderate | < 5% | 48 kHz |
|
| **DeepFilterNet 3** (ML default) | **Excellent** | **Very High** | 25-50%+ | 48 kHz |
|
||||||
| **Speex** | Poor | Low | < 5% | 48 kHz |
|
|
||||||
| **DTLN** | Good | High | 10-20% | 16 kHz |
|
| **DTLN** | Good | High | 10-20% | 16 kHz |
|
||||||
| **DeepFilterNet 3** | **Excellent** | **Very High** | 25-50%+ | 48 kHz |
|
| **RNNoise** | Poor | Moderate | < 5% | 48 kHz |
|
||||||
|
| **Speex** | Poor | Low | < 5% | 48 kHz |
|
||||||
|
|
||||||
> **Update (2026-06):** with the EC fork live, denoise runs **inside** Element
|
> **Update (2026-06):** with the EC fork live, denoise runs **inside** Element
|
||||||
> Call as a LiveKit `TrackProcessor` and **all four models ship in-source**
|
> Call as a LiveKit `TrackProcessor` and **all four models ship in-source**
|
||||||
> (DTLN at 16 kHz, the rest at 48 kHz; the processor degrades to the raw mic
|
> (DTLN at 16 kHz, the rest at 48 kHz; the processor degrades to the raw mic
|
||||||
> rather than ever going silent). The model picker selects between them. Real-call
|
> rather than ever going silent). The model picker selects between them.
|
||||||
> **audio-quality** comparison across models is still the open verification item
|
|
||||||
> (RNNoise output is known to be weak) — see `LOTUS_TESTING.md` §D2-1.
|
> **Update (2026-07) — quality, reliability & AEC/AGC:**
|
||||||
|
>
|
||||||
|
> - **Quality tuning** (addresses the "robotic/underwater" RNNoise reports):
|
||||||
|
> a **dry/wet attenuation floor** (default ~-16 dB) blends a little raw mic
|
||||||
|
> under the denoised signal so suppression can't fully collapse the noise
|
||||||
|
> floor — applied only to the low-latency flat models (RNNoise/Speex); DTLN/DFN
|
||||||
|
> would comb-filter, so they rely on their own level. The **noise gate now runs
|
||||||
|
> after the ML stage**, and **DeepFilterNet 3 level 80 → 60**. Tunable via the
|
||||||
|
> `lotusDenoiseFloor` param.
|
||||||
|
> - **AEC/AGC:** browser **echo cancellation stays ON**, but the ML tier now sets
|
||||||
|
> **auto gain control OFF** (`autoGainControl=false`) so the browser's dynamic
|
||||||
|
> gain doesn't fight the ML model. Browser/off tiers keep AGC on. (Remote
|
||||||
|
> playback stays on standard elements — no AEC-defeat vector.)
|
||||||
|
> - **Reliability:** never-silent watchdog (auto-resume a suspended context),
|
||||||
|
> `resume()` timeout (no track-lock deadlock), rejected-WASM-fetch eviction
|
||||||
|
> (transient failures recover), activation off the local participant (works
|
||||||
|
> solo), and init/build-failure leak fixes.
|
||||||
|
> - Real-call **audio-quality** A/B (model choice, floor value, AGC on/off) is the
|
||||||
|
> open by-ear validation item — see `LOTUS_TESTING.md` §D2-1.
|
||||||
|
|
||||||
### Files
|
### Files
|
||||||
|
|
||||||
@@ -671,6 +691,24 @@ Context menu → **Forward** allows forwarding a message to any room the user is
|
|||||||
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API
|
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API
|
||||||
- A chip shows the active date range with an **×** button to clear it
|
- A chip shows the active date range with an **×** button to clear it
|
||||||
|
|
||||||
|
### Encrypted Search Cache (P4-8, opt-in)
|
||||||
|
|
||||||
|
Persistent local index for encrypted-room search, so coverage survives page reloads instead of requiring re-pagination + re-decryption every session.
|
||||||
|
|
||||||
|
- Raw IndexedDB (`lotus-search-cache`): message rows keyed `[roomId, eventId]` + per-room coverage markers; merged into local search results with in-memory-wins dedupe
|
||||||
|
- **Opt-in, default OFF** (it stores decrypted text at rest): toggle + "Clear cached index" live in the search panel's Encrypted Rooms section, with the privacy note "Stores decrypted text on this device"
|
||||||
|
- Always wiped on logout; any IndexedDB error degrades to a cache-miss (never breaks search)
|
||||||
|
- Files: `src/app/utils/searchCache.ts`, `src/app/state/searchCacheEnabled.ts`, `features/message-search/useLocalMessageSearch.ts`
|
||||||
|
|
||||||
|
### Math / LaTeX Rendering (P4-4)
|
||||||
|
|
||||||
|
KaTeX-rendered math in messages, two paths:
|
||||||
|
|
||||||
|
- **Spec path (CS-API §11.5):** `<span/div data-mx-maths="…">` in `formatted_body` renders the attribute's LaTeX (block for div, inline for span); on render failure the element's child fallback content shows instead
|
||||||
|
- **Plain-text path:** `$…$` (inline) and `$$…$$` (block) with conservative rules — escape-aware (`\$`), currency-guarded (`$5 and $10` stays text), never inside `code`/`pre`
|
||||||
|
- KaTeX + its CSS load lazily on first math encountered — zero cost to the main bundle
|
||||||
|
- Files: `src/app/utils/mathParse.ts` (+14 tests), `components/math/KaTeX.tsx`, `plugins/react-custom-html-parser.tsx`
|
||||||
|
|
||||||
### Image / Video Captions
|
### Image / Video Captions
|
||||||
|
|
||||||
Images and videos can be sent with a caption. The caption and media are sent as a single event.
|
Images and videos can be sent with a caption. The caption and media are sent as a single event.
|
||||||
@@ -746,6 +784,36 @@ Generic (non-domain-specific) cards display a Google S2 favicon. Empty or unpars
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Threads (P3-8)
|
||||||
|
|
||||||
|
Full threaded-conversation support (`m.thread`, matrix-js-sdk `threadSupport`), Element-consistent.
|
||||||
|
|
||||||
|
### Thread Panel
|
||||||
|
|
||||||
|
A right-side drawer (mirrors the members drawer; fullscreen on mobile) with the thread's root message emphasized at top, an "N replies" divider, the full reply timeline (virtualized, back-paginates via `/relations`, decrypts E2EE threads), reactions/edits/redactions, and its own composer. Open it from **Reply in Thread** in the message menu, a reply's thread indicator, or a summary chip; close with **×** or Escape. Reading the panel sends threaded read receipts so per-thread unread counts clear.
|
||||||
|
|
||||||
|
### Summary Chips
|
||||||
|
|
||||||
|
Root messages in the main timeline show a **"N replies · time"** chip (server-aggregated `m.thread` bundle, or the live Thread once loaded) with an unread badge — threaded replies no longer render inline in the main timeline, so the chip is how conversations stay discoverable.
|
||||||
|
|
||||||
|
### Thread Composer
|
||||||
|
|
||||||
|
The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues **isolated per thread** (`roomId::threadRootId` keys). Replies-to-replies produce spec-correct `m.thread` + `m.in_reply_to` (`is_falling_back: false`). Scheduling and slash commands are disabled inside threads (v1).
|
||||||
|
|
||||||
|
### Notifications (Slack-style, P4-1)
|
||||||
|
|
||||||
|
By default you're notified for a thread reply only when you **participate** in that thread (you've posted in it) or the reply **@mentions** you — other threads accumulate quietly behind their chip badges. Every thread can be overridden from the bell menu in the panel header: **Default (participating) / All replies / Mentions only / Mute**. Modes sync across your devices (`io.lotus.thread_notifications` account data, auto-pruned). Muting a thread silences notifications and sounds, removes the chip's unread badge (a small bell-mute glyph shows instead), and subtracts that thread from the room's sidebar unread badge (client-side — other Matrix clients on the account still count it).
|
||||||
|
|
||||||
|
### Under the Hood
|
||||||
|
|
||||||
|
- `threadSupport: true` (startClient) partitions thread events into SDK `Thread` timelines; markAsRead sends **unthreaded** receipts so room badges keep clearing
|
||||||
|
- Thread replies are notified via exactly one path (room-level `ThreadEvent.NewReply` w/ per-thread dedupe + panel-aware focus suppression); the main timeline notifier is thread-guarded, and room badges refresh live on `RoomEvent.UnreadNotifications`
|
||||||
|
- Pending sends render via a `LocalEchoUpdated` strip (chronological local echo never enters thread timelineSets)
|
||||||
|
- Deep links to thread events redirect into the panel
|
||||||
|
- Files: `features/room/thread/*`, `state/room/thread.ts`, `hooks/useThreadSummary.ts` (+35 tests across the stack)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Presence
|
## Presence
|
||||||
|
|
||||||
### Discord-Style Presence Selector
|
### Discord-Style Presence Selector
|
||||||
@@ -1111,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
|
||||||
@@ -1141,6 +1221,71 @@ The `useAuthentication` parameter was previously mispositioned, causing unauthen
|
|||||||
|
|
||||||
The `encUrlPreview` setting defaults to `true` rather than `false`. A security advisory chip in **Settings → Privacy** explains the tradeoff (the homeserver can see which URLs are being previewed) so users can make an informed choice.
|
The `encUrlPreview` setting defaults to `true` rather than `false`. A security advisory chip in **Settings → Privacy** explains the tradeoff (the homeserver can see which URLs are being previewed) so users can make an informed choice.
|
||||||
|
|
||||||
|
### Hardened Session Storage (N97 partial, 2026-07)
|
||||||
|
|
||||||
|
The session persists as ONE atomic `cinny_session_v1` JSON write (previously ~10 separate localStorage keys written non-atomically). Reads prefer the blob with transparent migration from the legacy keys (dual-written one release for rollback). Cross-tab sync: logging out or in from one tab reloads the others so no tab runs with stale credentials. `state/sessions.ts` (22 tests), `hooks/useSessionSync.ts`.
|
||||||
|
|
||||||
|
### Crypto Diagnostics (E2EE investigation kit)
|
||||||
|
|
||||||
|
**Settings → Developer Tools → Crypto Diagnostics**: a capture-only ring buffer (max 200) hooks `console.warn/error` for E2EE failure signatures (OTK upload conflicts, missing call media keys, decryption errors, delayed-event timeouts) and downloads a JSON report — the evidence input for the KE-1→4 investigation. Companion runbook: [`LOTUS_E2EE_INVESTIGATION.md`](./LOTUS_E2EE_INVESTIGATION.md). `utils/cryptoDiagLog.ts`, `features/settings/developer/CryptoDiagnostics.tsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Desktop App Features
|
||||||
|
|
||||||
|
Native capabilities of the Lotus Chat **Tauri v2** desktop app (Windows, macOS, Linux) on top of the shared web client. Web hooks live in `src/app/hooks/useTauri*.ts` (each no-ops in the browser) and call Rust commands in `cinny-desktop/src-tauri/src/native/*`. Windows-only pieces are `#[cfg(target_os = "windows")]`, compile-verified in CI (Windows runners).
|
||||||
|
|
||||||
|
### Call Continuity — No-Sleep (P5-46)
|
||||||
|
|
||||||
|
Holds the system awake (`SetThreadExecutionState`) while a voice/video call is active; releases on end. `useTauriCallPower` ↔ `native/power.rs`.
|
||||||
|
|
||||||
|
### Windows Jump List (P5-36)
|
||||||
|
|
||||||
|
Right-click the taskbar icon → a **Recent Rooms** list of your most-active rooms; each entry opens that room via the `matrix:` deep-link. `useTauriJumpList` ↔ `native/jumplist.rs` (`ICustomDestinationList`).
|
||||||
|
|
||||||
|
### Taskbar Thumbnail Toolbar (P5-44)
|
||||||
|
|
||||||
|
Hover the taskbar preview during a call → **Mute / Deafen / End Call** buttons. `useTauriThumbbar` ↔ `native/thumbbar.rs` (`ITaskbarList3` + a window subclass for `THBN_CLICKED`).
|
||||||
|
|
||||||
|
### System Media Transport Controls — SMTC (P5-43)
|
||||||
|
|
||||||
|
Exposes call status + a mute control to the Windows volume-flyout / media overlay (WinRT `SystemMediaTransportControls`). `useTauriSmtc` ↔ `native/smtc.rs`. _Experimental — may require an active audio session to surface._
|
||||||
|
|
||||||
|
### Network Awareness (P5-49)
|
||||||
|
|
||||||
|
Detects Windows connectivity changes (`INetworkListManager`) and nudges the Matrix client to reconnect (`retryImmediately`). `useTauriNetwork` ↔ `native/network.rs`.
|
||||||
|
|
||||||
|
### Instant Background Sync (P5-42)
|
||||||
|
|
||||||
|
Keeps the `/sync` loop + notifications running full-speed while the app is closed to the tray, by disabling Chromium background throttling via WebView2 `additional_browser_args` (`lib.rs`) — no separate background process. Windows/WebView2 only; doesn't block system sleep.
|
||||||
|
|
||||||
|
### Native Rich Notifications (P5-41 / P5-35)
|
||||||
|
|
||||||
|
Windows toasts with **click-to-open-room** and **inline quick reply** (WinRT `ToastNotification`, in-process `Activated` event). Falls back to the standard toast otherwise. `useTauriToastActions` ↔ `native/toast.rs`; the desktop notification bridge routes room notifications to it.
|
||||||
|
|
||||||
|
### Focus Assist Sync (P5-56)
|
||||||
|
|
||||||
|
When Windows Focus Assist / Quiet Hours is active, Lotus suppresses its own notifications + sounds (reuses the quiet-hours gate). `useTauriFocusAssist` + `focusAssistActiveAtom` ↔ `native/focus_assist.rs` (`SHQueryUserNotificationState`).
|
||||||
|
|
||||||
|
### Custom Window Chrome (P5-47)
|
||||||
|
|
||||||
|
Opt-in (Settings → General → **Custom Window Chrome**): replaces the OS title bar with a TDS-styled titlebar (min / max / close + drag region), runtime-reversible via `set_decorations`. `features/desktop/TitleBar.tsx` + `useTauriWindowChrome` ↔ `native/chrome.rs`.
|
||||||
|
|
||||||
|
### Proactive Update Toast (P5-40)
|
||||||
|
|
||||||
|
Checks for a new desktop release every 12h and offers a one-click update. `TauriUpdateFeature` (ClientNonUIFeatures) + `useTauriUpdater`.
|
||||||
|
|
||||||
|
### Cross-platform composer niceties
|
||||||
|
|
||||||
|
- **Composer toolbar drag-reorder (P5-55)** — drag to reorder the composer buttons (Settings → General), via `@atlaskit/pragmatic-drag-and-drop`.
|
||||||
|
- **Draft-saved indicator (P5-57)** — a subtle cue in the composer when the current room has a persisted draft.
|
||||||
|
- **Recursive folder drag-drop (P5-48)** — drop a folder to upload every file inside it (all nesting levels), `utils/fileEntries.ts`.
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
- Web: `src/app/hooks/useTauri*.ts`, `src/app/components/TauriDesktopFeatures.tsx`, `src/app/features/desktop/TitleBar.tsx`, `src/app/features/room/DraftIndicator.tsx`, `src/app/utils/fileEntries.ts`, `src/app/state/{customWindowChrome,focusAssist}.ts`.
|
||||||
|
- Native (`cinny-desktop`): `src-tauri/src/native/{power,jumplist,thumbbar,smtc,network,chrome,toast,focus_assist}.rs` + `native/mod.rs` (registered in `lib.rs`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Key Custom Files
|
## Key Custom Files
|
||||||
|
|||||||
+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.
|
||||||
|
|||||||
+107
-63
@@ -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:
|
||||||
|
|
||||||
@@ -162,9 +167,18 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P3-8 · Thread Panel (full side drawer)
|
### [~] P3-8 · Thread Panel (full side drawer) — IMPLEMENTED (2026-07), ⚠️ AWAITING LIVE QA
|
||||||
|
|
||||||
**⚠️ LARGEST FEATURE — requires its own planning session before implementation.**
|
Built per the design below (4-agent build + 2-agent review). Gates green (tsc/eslint/build/tests). **Release note: threaded replies no longer render inline in the main timeline — roots show a "N replies" chip that opens the panel.**
|
||||||
|
|
||||||
|
**Manual QA checklist (post-deploy):**
|
||||||
|
|
||||||
|
1. Reply in Thread (message menu) → panel opens; send text/upload/emoji into it (appears pending → confirmed)
|
||||||
|
2. Reply to a reply inside the panel → event carries `m.thread` + `m.in_reply_to` with `is_falling_back:false`
|
||||||
|
3. Main timeline: root + chip only (replies absent); chip count/time updates live; unread badge appears for others' thread replies and clears after viewing the panel
|
||||||
|
4. Room badge clears via normal markAsRead even with unread threads (unthreaded receipt)
|
||||||
|
5. Reload: partitioning persists; encrypted-room threads decrypt (back-pagination too)
|
||||||
|
6. Escape / × closes; mobile = fullscreen panel; switching rooms and back restores the open thread
|
||||||
**What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies.
|
**What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies.
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
@@ -196,22 +210,28 @@ Features:
|
|||||||
|
|
||||||
## Priority 4 — Specialized, high complexity, or low priority
|
## Priority 4 — Specialized, high complexity, or low priority
|
||||||
|
|
||||||
### [ ] P4-7 · Virtualized Infinite Scroll for Search Results
|
### [x] P4-7 · Virtualized Infinite Scroll for Search Results — ALREADY IMPLEMENTED (found 2026-07)
|
||||||
|
|
||||||
**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.
|
||||||
**Approach:** Utilize `@tanstack/react-virtual` in `MessageSearch.tsx` to handle the `nextToken` automatically as the user scrolls.
|
**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 (MSC3771)
|
### [~] P4-1 · Thread Notification Mode Per-Thread — IMPLEMENTED (2026-07), ⚠️ AWAITING LIVE QA
|
||||||
|
|
||||||
**Spec:** MSC3771 (stable). Depends on Thread Panel (#P3-8).
|
**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`.
|
||||||
**What:** Per-thread notification toggle: "All messages" vs "Mentions only". Accessible from the thread panel header. Tracks unread counts separately per thread.
|
|
||||||
**[AUDIT REQUIRED]** — Implement after Thread Panel. Requires understanding how the SDK tracks per-thread unread counts.
|
**Manual QA checklist (post-deploy):**
|
||||||
**Complexity:** Medium (after thread panel exists).
|
|
||||||
|
1. Friend replies in a thread YOU posted in → notification + sound; in a thread you never touched → silent (chip badge only)
|
||||||
|
2. @mention in any thread → notified regardless of participation
|
||||||
|
3. Set a thread to Mute → no notifications, chip badge gone (bell-mute glyph), room sidebar badge drops by that thread's count
|
||||||
|
4. Set to All → every reply notifies; Mentions-only → only @mentions
|
||||||
|
5. Second device shows the same per-thread modes (account-data sync)
|
||||||
|
6. Room-level Mute still silences everything incl. thread overrides
|
||||||
|
**Known caveats:** Mentions-only can under-notify in E2EE rooms (decision runs pre-decryption — same class as the existing notifier); muted-thread badge subtraction is Lotus-only (other clients still count them).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -257,7 +277,7 @@ Features:
|
|||||||
- Account mgmt: `settings/account/OidcManageAccount.tsx`.
|
- Account mgmt: `settings/account/OidcManageAccount.tsx`.
|
||||||
- 13 unit tests (discovery/flow/session/cache/callback parsing). All gates green.
|
- 13 unit tests (discovery/flow/session/cache/callback parsing). All gates green.
|
||||||
**Awaiting verification (needs a real MSC3861 server — lotusguild is NOT one):** deploy + log into **mozilla.org** (requires adding mozilla to the deployed `config.json` homeserverList + its domains to the CSP `connect-src`/`img-src` — see below), OR run a local `matrix-authentication-service` + Synapse `msc3861` dev loop.
|
**Awaiting verification (needs a real MSC3861 server — lotusguild is NOT one):** deploy + log into **mozilla.org** (requires adding mozilla to the deployed `config.json` homeserverList + its domains to the CSP `connect-src`/`img-src` — see below), OR run a local `matrix-authentication-service` + Synapse `msc3861` dev loop.
|
||||||
**To enable the mozilla.org test:** add to `matrix/cinny/config.json` homeserverList `"mozilla.org"`, and to the nginx CSP `connect-src`/`img-src`: `https://mozilla.org https://mozilla.modular.im https://chat.mozilla.org https://vector.im`.
|
**Mozilla.org test enablement: ALREADY DEPLOYED (verified 2026-07)** — `matrix/cinny/config.json` homeserverList includes `mozilla.org` and the nginx CSP `connect-src` includes the mozilla/modular/vector domains (`matrix/cinny/nginx.conf:42`). **Nothing blocks the test — just pick mozilla.org on the login screen and complete an OIDC login.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -301,8 +321,12 @@ Features:
|
|||||||
|
|
||||||
**Models — all in-source in the fork:**
|
**Models — all in-source in the fork:**
|
||||||
|
|
||||||
- [x] **RNNoise** (48 kHz, default) · **Speex** (48 kHz) · **DTLN** (16 kHz) · **DeepFilterNet 3** (48 kHz) — all four wired and selectable.
|
- [x] **DeepFilterNet 3** (48 kHz, **ML default**) · **DTLN** (16 kHz) · **RNNoise** (48 kHz) · **Speex** (48 kHz) — all four wired and selectable; dropdown ordered best-quality first. Tier default is **Browser-native**.
|
||||||
- [ ] **Open verification:** real-call **audio-quality** comparison across the four models (RNNoise output is known-weak). Track under the denoise quality project, `LOTUS_TESTING.md` §D2-1 / J2.
|
- [x] **Quality tuning (2026-07):** dry/wet **attenuation floor** (~-16 dB, RNNoise/Speex only — the "robotic" fix; DTLN/DFN would comb-filter), **gate-after-ML**, **DFN level 80→60**. Floor tunable via `lotusDenoiseFloor`.
|
||||||
|
- [x] **AEC/AGC (2026-07):** echo-cancellation ON; **AGC OFF for the ML tier** (`autoGainControl=false`, threaded through EC `UrlParams`→`ConnectionFactory`) so browser AGC doesn't fight the model; playback confirmed no AEC-defeat.
|
||||||
|
- [x] **Reliability (2026-07):** never-silent watchdog, resume-timeout, WASM-cache reject-eviction, activate-off-local-participant, init/build leak fixes.
|
||||||
|
- [ ] **Open verification:** real-call by-ear **A/B** — model choice, floor value, AGC on/off (RNNoise known-weak historically). `LOTUS_TESTING.md` §D2-1 / J2.
|
||||||
|
- [ ] **GTCRN (RESEARCHED — DEFERRED):** tiny MIT 16 kHz model that beats RNNoise, but **no drop-in browser package** — needs a ~1-week from-scratch build: `onnxruntime-web` (WASM, 1 thread) in a **Web Worker** (ORT can't run in an AudioWorklet — issue #13072) behind a custom AudioWorklet ring-buffer node presenting as an `AudioNode`; model `gtcrn_simple.onnx` (~300 KB, stateful — thread `conv/tra/inter` caches per frame); we write STFT/iSTFT (n_fft 512/hop 256). Assets ~3–4 MB via the `lotusDenoise()` vite plugin. Registration checklist known (both repos, incl. the 2nd `denoisePipeline.ts` used by the DenoiseTester). **Revisit only if low-power quality is insufficient after validating the current tuning.**
|
||||||
- [ ] **Desktop-only / HW-gated (future):** FRCRN or NVIDIA Maxine (RTX/Tensor only) — impossible in-browser; would run in the Tauri Rust backend + bridge a virtual mic into the webview. Detect capability; web falls back to RNNoise.
|
- [ ] **Desktop-only / HW-gated (future):** FRCRN or NVIDIA Maxine (RTX/Tensor only) — impossible in-browser; would run in the Tauri Rust backend + bridge a virtual mic into the webview. Detect capability; web falls back to RNNoise.
|
||||||
- **Excluded:** Krisp (LiveKit Cloud only); FRCRN/Maxine on web (GPU/server-bound).
|
- **Excluded:** Krisp (LiveKit Cloud only); FRCRN/Maxine on web (GPU/server-bound).
|
||||||
|
|
||||||
@@ -330,16 +354,16 @@ Features:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P5-35 · Desktop — Notification Click Opens Room (DEFERRED)
|
### [~] P5-35 · Desktop — Notification Click Opens Room — IMPLEMENTED (Tier B, via the P5-41 WinRT toast: click → open room, reply → send); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Clicking a system tray notification navigates to the relevant room. Quick-reply from the notification toast would send the reply without opening the window.
|
**What:** Clicking a system tray notification navigates to the relevant room. Quick-reply from the notification toast would send the reply without opening the window.
|
||||||
**Status:** Deferred — `tauri-plugin-notification` has no Rust click/action callback API. Quick-reply would need a custom WinRT toast activator + COM registration, which can't be compile-tested without a Windows build environment.
|
**Status:** Deferred (Tier B). Note: the "can't compile-test without a Windows build environment" premise is **outdated** — CI now compiles Windows (Gitea self-hosted `windows` runner + GitHub `windows-latest`), and `windows`-crate/COM code already ships (e.g. `set_badge_count`, and the Tier A jump list). This still depends on P5-41 (the WinRT toast + custom activator), so it rides with that; it was not part of the Tier A wave.
|
||||||
**Note:** Tray icon and `matrix:` deep links already bring the window forward on most interactions. Revisit when tauri-plugin-notification gains click handler support upstream.
|
**Note:** Tray icon and `matrix:` deep links already bring the window forward on most interactions. Revisit when tauri-plugin-notification gains click handler support upstream.
|
||||||
**Complexity:** High (platform-specific native code required).
|
**Complexity:** High (platform-specific native code required).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P5-36 · Desktop — Windows Jump List (DEFERRED)
|
### [~] P5-36 · Desktop — Windows Jump List — IMPLEMENTED (Tier A); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Right-clicking the taskbar icon shows a jump list with recent/favorite rooms for quick navigation.
|
**What:** Right-clicking the taskbar icon shows a jump list with recent/favorite rooms for quick navigation.
|
||||||
**Status:** Deferred — implementing the Windows COM jump list API in Tauri requires iterating on C++/COM code that can only be compile-checked on Windows, making blind CI iteration impractical.
|
**Status:** Deferred — implementing the Windows COM jump list API in Tauri requires iterating on C++/COM code that can only be compile-checked on Windows, making blind CI iteration impractical.
|
||||||
@@ -348,78 +372,87 @@ Features:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P5-41 · Desktop — Native WinRT Toast Notifications
|
### [~] P5-41 · Desktop — Native WinRT Toast Notifications — IMPLEMENTED (Tier B; ToastNotification + reply input + in-process Activated; falls back to tauri-plugin-notification); native CI-compile-pending. Runtime needs a Start-menu shortcut + matching AppUserModelID to surface
|
||||||
|
|
||||||
**What:** Replace emulated notifications with native WinRT Toast notifications.
|
**What:** Replace emulated notifications with native WinRT Toast notifications.
|
||||||
**Approach:** Implement native WinRT Toast integration using `windows-rs` to enable full Action Center integration, including native Quick Reply functionality.
|
**Approach:** Implement native WinRT Toast integration using `windows-rs` to enable full Action Center integration, including native Quick Reply functionality.
|
||||||
|
|
||||||
### [ ] P5-42 · Desktop — Persistent Background Sync
|
### [~] P5-42 · Desktop — Persistent Background Sync — IMPLEMENTED (Batch 3, pragmatic keep-alive); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Maintain light connection to homeserver when WebView2 is suspended.
|
**What:** Keep receiving messages/notifications instantly while the app is closed to the tray.
|
||||||
**Approach:** Implement a headless Rust sidecar to fetch unread counts/notifications while the webview is suspended to ensure instant notification delivery.
|
**Shipped approach (80/20):** rather than a multi-sprint headless Rust sync client, disable Chromium background throttling via WebView2 `additional_browser_args` (`--disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows`, added to the existing Tauri default args) so the existing JS Matrix `/sync` loop keeps running full-speed in the tray. Windows/WebView2 only; does not block system sleep. See `cinny-desktop/src-tauri/src/lib.rs` (WebviewWindowBuilder).
|
||||||
|
**Deferred (not needed):** the full headless Rust sidecar — only revisit if WebView2 ever hard-suspends despite these flags (would require a second Matrix client with its own /sync + push-rule eval + E2EE-aware notification content).
|
||||||
|
|
||||||
### [ ] P5-43 · Desktop — System Media Transport Controls (SMTC)
|
### [~] P5-43 · Desktop — System Media Transport Controls (SMTC) — IMPLEMENTED (Tier A); CI-compile-pending; SMTC may need an active media/audio session to surface — verify on Windows
|
||||||
|
|
||||||
**What:** Integrate with Windows SMTC for volume flyout call/media control.
|
**What:** Integrate with Windows SMTC for volume flyout call/media control.
|
||||||
**Approach:** Use Windows SMTC API to expose call status, mic mute/unmute, and media controls to the Windows volume flyout/media overlay.
|
**Approach:** Use Windows SMTC API to expose call status, mic mute/unmute, and media controls to the Windows volume flyout/media overlay.
|
||||||
|
|
||||||
### [ ] P5-44 · Desktop — Taskbar Thumbnail Toolbar
|
### [~] P5-44 · Desktop — Taskbar Thumbnail Toolbar — IMPLEMENTED (Tier A); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Add persistent call controls to the taskbar preview.
|
**What:** Add persistent call controls to the taskbar preview.
|
||||||
**Approach:** Implement a COM thumbnail toolbar in the application preview window, featuring Mute/Deafen/End Call buttons.
|
**Approach:** Implement a COM thumbnail toolbar in the application preview window, featuring Mute/Deafen/End Call buttons.
|
||||||
|
|
||||||
### [ ] P5-46 · Desktop — System Power Management (Call Continuity)
|
### [~] P5-46 · Desktop — System Power Management (Call Continuity) — IMPLEMENTED (Tier A reference); web verified; native CI-compile-pending (Windows only; macOS/Linux no-op TODO)
|
||||||
|
|
||||||
**What:** Prevent system sleep/hibernate during active calls.
|
**What:** Prevent system sleep/hibernate during active calls.
|
||||||
**Approach:** Use Tauri/Rust `power-manager` or platform-specific APIs to block system power saving states while a voice/video session is active.
|
**Approach:** Use Tauri/Rust `power-manager` or platform-specific APIs to block system power saving states while a voice/video session is active.
|
||||||
|
|
||||||
### [ ] P5-47 · Desktop — TDS-Styled Native Window Chrome
|
### [~] P5-47 · Desktop — TDS-Styled Native Window Chrome — IMPLEMENTED (Tier A, OPT-IN/default-off, runtime-reversible); web verified; native CI-compile-pending
|
||||||
|
|
||||||
**What:** Replace system titlebar with custom Lotus TDS chrome.
|
**What:** Replace system titlebar with custom Lotus TDS chrome.
|
||||||
**Approach:** Configure Tauri window (`decorations: false`) and implement custom, TDS-token compliant titlebar controls (Close/Max/Min) for a cohesive UI.
|
**Approach:** Configure Tauri window (`decorations: false`) and implement custom, TDS-token compliant titlebar controls (Close/Max/Min) for a cohesive UI.
|
||||||
|
|
||||||
### [ ] P5-48 · Desktop — Native File System Drag-and-Drop Improvements
|
### [~] P5-48 · Desktop — Native File System Drag-and-Drop Improvements — IMPLEMENTED (recursive folder upload, web-verified: tsc/build/tests). SCOPED-OUT: `.lnk` shortcut resolution (webview never exposes a dropped file's OS path → native can't resolve the target) and "Send To" (installer/registry shell integration) — deferred
|
||||||
|
|
||||||
**What:** Enhance drag-and-drop support for Windows.
|
**What:** Enhance drag-and-drop support for Windows.
|
||||||
**Approach:** Improve handling for Windows file shortcuts, recursive folder uploads, and shell-integrated "Send To" context menu actions.
|
**Approach:** Improve handling for Windows file shortcuts, recursive folder uploads, and shell-integrated "Send To" context menu actions.
|
||||||
|
|
||||||
### [ ] P5-49 · Desktop — Network Awareness (NCSI Integration)
|
### [~] P5-49 · Desktop — Network Awareness (NCSI Integration) — IMPLEMENTED (Tier A; INetworkListManager poll → mx.retryImmediately); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Proactively detect Windows network connectivity changes.
|
**What:** Proactively detect Windows network connectivity changes.
|
||||||
**Approach:** Integrate with the Windows Network Connectivity Status Indicator (NCSI) API to improve offline mode transition latency and network recovery.
|
**Approach:** Integrate with the Windows Network Connectivity Status Indicator (NCSI) API to improve offline mode transition latency and network recovery.
|
||||||
|
|
||||||
### [ ] P5-50 · Desktop — Windows Hardware-Accelerated Media Pipeline
|
### [WON'T FIX] P5-50 · Desktop — Windows Hardware-Accelerated Media Pipeline
|
||||||
|
|
||||||
**What:** Replace standard browser decoding with native Windows Media Foundation.
|
**What:** Replace standard browser decoding with native Windows Media Foundation.
|
||||||
**Approach:** Leverage DirectShow/Media Foundation to offload video/audio decoding from the CPU to the GPU, significantly reducing power consumption and latency during calls.
|
**Why won't-fix (researched):** WebRTC media (the call pipeline) lives entirely inside WebView2/Chromium — you cannot inject Media Foundation/DirectShow into WebRTC's decode path from the Tauri host. Chromium already uses the platform's hardware decoders (D3D11VA/MF) where the GPU supports the codec, so there is no separate CPU pipeline to offload. Not actionable as described.
|
||||||
|
|
||||||
### [ ] P5-51 · Desktop — Federated "Identity Contexts" (Isolation Manager)
|
### [DEFERRED] P5-51 · Desktop — Federated "Identity Contexts" (Isolation Manager)
|
||||||
|
|
||||||
**What:** Compartmentalize sessions, local databases, and caches into isolated "Contexts."
|
**What:** Compartmentalize sessions, local databases, and caches into isolated "Contexts" (e.g. Work vs. Personal) with a zero-leak boundary.
|
||||||
**Approach:** Implement a zero-leak boundary for personas (e.g., Work vs. Personal) by isolating `IndexedDB`, filesystem caches, and session persistence per context.
|
**Decision:** Deferred (reviewed 2026-07). Multi-sprint, and it touches the auth/crypto/storage core — not worth the risk/effort for a niche need right now. Kept here with a concrete spec so it's actionable later.
|
||||||
|
|
||||||
|
**Future-work spec (why it's big):** the app is currently **single-session**.
|
||||||
|
|
||||||
|
- Session lives in `src/app/state/sessions.ts` under fixed localStorage keys — `cinny_access_token`, `cinny_device_id`, `cinny_user_id`, `cinny_hs_base_url`, plus the OIDC keys (`cinny_refresh_token`, `cinny_expires_at`, `cinny_oidc_*`).
|
||||||
|
- Persistence lives in `src/client/initMatrix.ts`: two fixed IndexedDB stores — `web-sync-store` (`IndexedDBStore`) and `crypto-store` (`IndexedDBCryptoStore`) — feeding one `createClient(...)`.
|
||||||
|
|
||||||
|
True per-context isolation would require: (1) namespace every localStorage key per context (`ctx:<id>:cinny_*`); (2) per-context IndexedDB dbNames for **both** the sync store and the crypto store; (3) a context registry + switcher UI (create/rename/delete/switch); (4) full client teardown + re-init on switch (`initMatrix` currently assumes one global client); (5) per-context settings + notification/quiet-hours state; (6) careful crypto-store isolation so device keys never bleed across contexts. **Smaller intermediate step** if demand appears: plain multi-account (fast account switch) _without_ the hard isolation boundary — much less risky, reuses most of the login flow.
|
||||||
**Priority:** Extreme Low (Multi-sprint/Architectural).
|
**Priority:** Extreme Low (Multi-sprint/Architectural).
|
||||||
|
|
||||||
### [~] P5-52 · Desktop — Room-Level Sync Governor (Performance Control) [STILL_CONSIDERING]
|
### [DROPPED] P5-52 · Desktop — Room-Level Sync Governor (Performance Control)
|
||||||
|
|
||||||
**What:** Granular sync tuning for individual rooms.
|
**What:** Granular per-room sync tuning (frequency, event-type filtering).
|
||||||
**Approach:** Allow per-room overrides for sync frequency and event type filtering (e.g., disable read receipts/typing in heavy rooms) to optimize performance. Implementation requires careful UX to prevent complexity fatigue.
|
**Why dropped (reviewed 2026-07):** matrix-js-sdk can't do **true** per-room sync filtering — all room events still come down the single `/sync` stream, so "disable typing/receipts in heavy rooms" can only be a **cosmetic client-side hide**, not an actual performance/bandwidth win. That, plus the UX-complexity risk flagged originally, makes it not worth building. If per-room quieting is ever wanted, add a simple "mute typing & receipts in this room" toggle to normal room settings — not a "governor."
|
||||||
|
|
||||||
### [ ] P5-53 · Desktop — Local-Only "Scripting" Plugin System (Tampermonkey-like)
|
### [DEFERRED] P5-53 · Desktop — Local-Only "Scripting" Plugin System (Tampermonkey-like)
|
||||||
|
|
||||||
**What:** A sandboxed environment for local execution of user scripts on Matrix events.
|
**What:** A sandboxed environment for local execution of user scripts on Matrix events.
|
||||||
**Approach:** Implement a WASM-based execution engine that allows users to write local-only, client-side scripts to interact with incoming Matrix events, trigger sounds/notifications, or inject custom UI elements based on event payload rules. Designed for privacy — all logic runs exclusively on the local machine.
|
**Decision:** Deferred (reviewed 2026-07). A full WASM execution engine + script registry + management UI + security model is a large surface for a very small (power-user) audience.
|
||||||
|
**Recommended lighter alternative (the ~80/20) if we ever want event automation:** a built-in **automation-rules** feature — declarative "when an incoming event matches X (room / sender / keyword / type) → notify / play sound / highlight / auto-react" rules, configured in Settings. Covers the realistic use cases (custom alerts, keyword pings) with **no arbitrary code execution**, so no sandbox/security burden. Build that instead of a scripting engine if the need arises.
|
||||||
|
|
||||||
### [ ] P5-55 · Desktop — Composer Toolbar Drag-and-Drop Reordering
|
### [~] P5-55 · Desktop — Composer Toolbar Drag-and-Drop Reordering — IMPLEMENTED (Tier A); web-verified (tsc/build/tests). Awaiting live UX check
|
||||||
|
|
||||||
**What:** Allow users to reorder toolbar icons via drag-and-drop.
|
**What:** Allow users to reorder toolbar icons via drag-and-drop.
|
||||||
**Approach:** Extend the current settings-based toolbar toggle system to include a drag-and-drop UI mode in the composer settings, allowing users to personalize their icon order.
|
**Approach:** Extend the current settings-based toolbar toggle system to include a drag-and-drop UI mode in the composer settings, allowing users to personalize their icon order.
|
||||||
|
|
||||||
### [ ] P5-56 · Desktop — Windows "Focus Assist" (DND) Sync
|
### [~] P5-56 · Desktop — Windows "Focus Assist" (DND) Sync — IMPLEMENTED (Tier B; SHQueryUserNotificationState poll → suppresses notifications+sounds via the quiet-hours gate); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Automatically toggle notification state based on Windows Focus Assist.
|
**What:** Automatically toggle notification state based on Windows Focus Assist.
|
||||||
**Approach:** Integrate with the Windows `NotificationCenter` / `Focus` state via Tauri/Rust to automatically enable/disable Lotus Chat's internal notification suppression mode when Windows Focus Assist is toggled.
|
**Approach:** Integrate with the Windows `NotificationCenter` / `Focus` state via Tauri/Rust to automatically enable/disable Lotus Chat's internal notification suppression mode when Windows Focus Assist is toggled.
|
||||||
|
|
||||||
### [ ] P5-57 · Desktop — Visual Draft Persistence Indicator
|
### [~] P5-57 · Desktop — Visual Draft Persistence Indicator — IMPLEMENTED (Tier A); web-verified. Awaiting live UX check
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -470,9 +503,9 @@ Check back after each Synapse upgrade — re-run `/matrix/client/versions` and `
|
|||||||
|
|
||||||
## Pending Audits
|
## Pending Audits
|
||||||
|
|
||||||
### [ ] Audit-3 · Profile banner image — Matrix protocol support
|
### [DEFERRED] Audit-3 · Profile banner image — Matrix protocol support — RESEARCHED (2026-07)
|
||||||
|
|
||||||
Research whether Matrix spec or MSC4133 (v1.16) defines a standard profile banner field. `uk.tcpip.msc4133.stable = true` on our server — check if a `banner_url` or similar field is defined. If no cross-client standard exists, do not implement.
|
**Finding:** [MSC4427 — Custom banners for user profiles](https://github.com/matrix-org/matrix-spec-proposals/pull/4427) defines a `banner_url` profile field on top of the MSC4133 extensible-profile system (which our server supports, `uk.tcpip.msc4133.stable = true`, and which became stable in Matrix v1.16). However MSC4427 is an **open proposal, not merged** — no cross-client standard yet, so per this item's own rule: do not implement. **Revisit when MSC4427 merges** (implementation would then be small: read/write the field via the MSC4133 profile API + render a banner in UserHero/profile popouts).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -480,26 +513,37 @@ Research whether Matrix spec or MSC4133 (v1.16) defines a standard profile banne
|
|||||||
|
|
||||||
Exhaustive, low-level implementation details for backlog items. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant).
|
Exhaustive, low-level implementation details for backlog items. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant).
|
||||||
|
|
||||||
### P3-8 · Thread Panel (Full Side Drawer)
|
### P3-8 · Thread Panel (Full Side Drawer) — 🟢 FULL DESIGN (2026-07, ready to execute)
|
||||||
|
|
||||||
**Architecture:** Mirror the `MembersDrawer` pattern but with a specialized timeline.
|
**Decisions (each backed by SDK evidence in node_modules/matrix-js-sdk):**
|
||||||
|
|
||||||
- **State (`src/app/state/room/thread.ts`):**
|
| Question | Decision |
|
||||||
```typescript
|
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
export const activeThreadIdAtom = atom<string | null>(null);
|
| 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. |
|
||||||
- **Layout (`src/app/features/room/Room.tsx`):** Insert `ThreadPanel` conditionally alongside `RoomTimeline`:
|
| State | `roomIdToActiveThreadIdAtomFamily` (per-room, mirrors `roomIdToReplyDraftAtomFamily`) in new `state/room/thread.ts` + `getThreadDraftKey(roomId, threadRootId)` = `` `${roomId}::${threadRootId}` `` |
|
||||||
```tsx
|
| 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. |
|
||||||
activeThreadId && (
|
| Mobile | Pure CSS like `MembersDrawer.css.ts`: fixed width toRem(360) desktop, `position:fixed; inset:0` under 750px. |
|
||||||
<>
|
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
**Critical side-effect fixes (one-liners, land FIRST):**
|
||||||
<ThreadPanel roomId={roomId} threadId={activeThreadId} />
|
|
||||||
</>
|
1. `initMatrix.ts` → `threadSupport: true`.
|
||||||
);
|
2. `utils/notifications.ts:24` → `sendReadReceipt(latestEvent, type, /*unthreaded*/ true)` — otherwise markAsRead becomes `main`-scoped and room badges stick permanently unread (room unread total includes thread counts).
|
||||||
}
|
|
||||||
```
|
**Known SDK traps (verified):**
|
||||||
- **Component (`src/app/features/room/thread/ThreadPanel.tsx`):** Use `room.getThread(threadId)` from the SDK. Render a `Header` with a "Close" button that sets `activeThreadIdAtom` to `null`. Reuse `RoomTimeline` but pass a filtered `EventTimelineSet`. Use `thread.timelineSet` directly for the most accurate thread view.
|
|
||||||
|
- **Local echo gap:** chronological pending ordering means the thread timelineSet never receives pending events (`canContain` rejects; `room.getPendingEvents()` THROWS in this mode) — ThreadTimeline must render its own pending strip via `RoomEvent.LocalEchoUpdated` filtering on `threadRootId`, deduped against `thread.findEventById`.
|
||||||
|
- **Bootstrap:** `room.getThread(id) ?? room.createThread(id, room.findEventById(id), [], false)` — the SDK auto-fetches via `/relations` and inserts the root at top; gate rendering on `thread.initialEventsFetched`; decrypt with `decryptAllTimelineEvent` after init + each pagination.
|
||||||
|
- **Deep links:** `getEventTimeline(mainSet, threadEventId)` returns undefined for thread events — redirect jump-to-event to the panel (best-effort v1).
|
||||||
|
- **Summary chip** must render from the server-aggregated bundle (`unsigned['m.relations']['m.thread']`) so it works before any Thread object exists.
|
||||||
|
- Room-list "latest message" preview may show the root, not the newest reply — cosmetic, accept v1.
|
||||||
|
|
||||||
|
**File inventory — new:** `state/room/thread.ts` (+test), `features/room/thread/{useThread.ts, threadSummary.ts(+test), ThreadTimeline.tsx(+css), ThreadPanel.tsx(+css), ThreadSummary.tsx, index.ts}`, `hooks/useThreadSummary.ts`. **Edited:** `initMatrix.ts` + `utils/notifications.ts` (coordinator, step 0), `RoomInput.tsx` (threadRootId prop), `RoomTimeline.tsx` (handleReplyClick startThread → open panel; ThreadSummary chips at the two Message call sites; Reply onThreadClick; deep-link redirect), `components/message/Reply.tsx`, `Room.tsx` (render panel after MediaGallery block, gated `!callView && activeThreadId`, `key={roomId+threadId}`).
|
||||||
|
|
||||||
|
**4-agent partition:** step 0 (coordinator one-liners) → A: state+SDK glue (+tests) · B: ThreadTimeline (largest; copies the `useTimelinePagination` pattern rather than exporting it) · C: RoomInput changes · D: panel shell + RoomTimeline/Reply integration — all parallel against pinned interface contracts → coordinator wires Room.tsx + gates.
|
||||||
|
|
||||||
|
**Verification:** gates (tsc/eslint/build/tests) + post-merge manual QA: open thread via chip/menu/indicator; pending→confirmed echo; `is_falling_back:false` on reply-in-thread; main timeline shows root+chip only; badge clears; reload keeps partitioning; encrypted threads decrypt. **Release note required:** threaded replies no longer render inline in the main timeline.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -631,7 +675,7 @@ See shipped implementation in LOTUS_FEATURES.md → "Noise Suppression (Advanced
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### P5-40 · Desktop — Proactive Update Notifications (Tauri)
|
### [x] P5-40 · Desktop — Proactive Update Notifications (Tauri) — DONE (already shipped: `TauriUpdateFeature` in ClientNonUIFeatures.tsx polls every 12h + fires the sticky update toast)
|
||||||
|
|
||||||
**Key Files:** `src/app/hooks/useTauriUpdater.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx`, `src/app/features/toast/LotusToastContainer.tsx`.
|
**Key Files:** `src/app/hooks/useTauriUpdater.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx`, `src/app/features/toast/LotusToastContainer.tsx`.
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
|
|||||||
|
|
||||||
### Messaging
|
### Messaging
|
||||||
|
|
||||||
|
- Threads: reply in a thread and read/write the whole conversation in a side panel — root messages show a "N replies" chip with an unread badge (threaded replies live in the panel now, not inline in the room)
|
||||||
|
- Slack-style thread notifications: by default you're only pinged for threads you're in or where you're @mentioned; set any thread to All / Mentions-only / Mute from the panel's bell menu (muted threads stop bumping badges; syncs across devices)
|
||||||
- See who has read each message, and track delivery status (sending / sent / failed)
|
- See who has read each message, and track delivery status (sending / sent / failed)
|
||||||
- Bookmark any message and revisit saved messages from the sidebar
|
- Bookmark any message and revisit saved messages from the sidebar
|
||||||
- Schedule messages to send at a specific time
|
- Schedule messages to send at a specific time
|
||||||
@@ -33,6 +35,8 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
|
|||||||
- Search for and send GIFs from a built-in GIF picker
|
- Search for and send GIFs from a built-in GIF picker
|
||||||
- Control voice message playback speed: 0.75× / 1× / 1.5× / 2×
|
- Control voice message playback speed: 0.75× / 1× / 1.5× / 2×
|
||||||
- Search messages with a date range filter
|
- Search messages with a date range filter
|
||||||
|
- Optional persistent search index for encrypted rooms (off by default — stores decrypted text on your device; clearable, wiped on logout)
|
||||||
|
- Write math with LaTeX: `$inline$` and `$$block$$` render via KaTeX (spec `data-mx-maths` supported)
|
||||||
- Room topics support rich formatting (bold, links, italics)
|
- Room topics support rich formatting (bold, links, italics)
|
||||||
- Deleted messages show a placeholder instead of disappearing
|
- Deleted messages show a placeholder instead of disappearing
|
||||||
- Code blocks highlight syntax for JS/TS, Python, and Rust
|
- Code blocks highlight syntax for JS/TS, Python, and Rust
|
||||||
@@ -139,6 +143,20 @@ When you first run the installer on Windows, you may see a popup that says **"Wi
|
|||||||
|
|
||||||
After the first install, automatic in-app updates handle all future versions — you will not see this prompt again for updates.
|
After the first install, automatic in-app updates handle all future versions — you will not see this prompt again for updates.
|
||||||
|
|
||||||
|
### Desktop-Specific Features
|
||||||
|
|
||||||
|
Beyond the web client, the desktop app adds native OS integration (Windows-focused; graceful no-ops elsewhere). See [`LOTUS_FEATURES.md`](./LOTUS_FEATURES.md#desktop-app-features) for detail.
|
||||||
|
|
||||||
|
- **Native rich notifications** — Windows toasts you can click to open the room or reply to inline, right from the toast.
|
||||||
|
- **Focus Assist sync** — Lotus silences its own notifications while Windows Focus Assist / Quiet Hours is on.
|
||||||
|
- **Windows Jump List** — right-click the taskbar icon for quick access to your most-active rooms.
|
||||||
|
- **Taskbar call controls** — Mute / Deafen / End Call buttons on the taskbar thumbnail during a call, plus call status in the volume flyout (SMTC).
|
||||||
|
- **Stays awake in calls** — the system won't sleep or dim during a voice/video call.
|
||||||
|
- **Network awareness** — reconnects promptly when Windows connectivity changes.
|
||||||
|
- **Custom window chrome** (opt-in) — a Lotus-styled title bar in place of the OS one.
|
||||||
|
- **Recursive folder drag-drop** — drop a whole folder onto the composer to upload everything inside it.
|
||||||
|
- **Automatic background updates** with a one-click update toast.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## For Developers
|
## For Developers
|
||||||
|
|||||||
@@ -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
+54
-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",
|
||||||
@@ -51,9 +49,10 @@
|
|||||||
"immer": "11.1.8",
|
"immer": "11.1.8",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
"jotai": "2.20.0",
|
"jotai": "2.20.0",
|
||||||
|
"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",
|
||||||
@@ -74,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",
|
||||||
@@ -83,6 +83,7 @@
|
|||||||
"@types/chroma-js": "3.1.2",
|
"@types/chroma-js": "3.1.2",
|
||||||
"@types/file-saver": "2.0.7",
|
"@types/file-saver": "2.0.7",
|
||||||
"@types/is-hotkey": "0.1.10",
|
"@types/is-hotkey": "0.1.10",
|
||||||
|
"@types/katex": "0.16.8",
|
||||||
"@types/node": "25.9.1",
|
"@types/node": "25.9.1",
|
||||||
"@types/prismjs": "1.26.6",
|
"@types/prismjs": "1.26.6",
|
||||||
"@types/react": "19.2.15",
|
"@types/react": "19.2.15",
|
||||||
@@ -2695,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"
|
||||||
@@ -3918,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",
|
||||||
@@ -3974,6 +3965,13 @@
|
|||||||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/katex": {
|
||||||
|
"version": "0.16.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
|
||||||
|
"integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.9.1",
|
"version": "25.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||||
@@ -4042,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",
|
||||||
@@ -5541,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": {
|
||||||
@@ -6187,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",
|
||||||
@@ -9087,6 +9080,31 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/katex": {
|
||||||
|
"version": "0.16.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz",
|
||||||
|
"integrity": "sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://opencollective.com/katex",
|
||||||
|
"https://github.com/sponsors/katex"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^8.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"katex": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/katex/node_modules/commander": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -9937,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",
|
||||||
@@ -13194,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": {
|
||||||
@@ -13235,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",
|
||||||
@@ -13272,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"
|
||||||
@@ -13282,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"
|
||||||
|
|||||||
+5
-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",
|
||||||
@@ -76,9 +74,10 @@
|
|||||||
"immer": "11.1.8",
|
"immer": "11.1.8",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
"jotai": "2.20.0",
|
"jotai": "2.20.0",
|
||||||
|
"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",
|
||||||
@@ -99,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",
|
||||||
@@ -108,6 +108,7 @@
|
|||||||
"@types/chroma-js": "3.1.2",
|
"@types/chroma-js": "3.1.2",
|
||||||
"@types/file-saver": "2.0.7",
|
"@types/file-saver": "2.0.7",
|
||||||
"@types/is-hotkey": "0.1.10",
|
"@types/is-hotkey": "0.1.10",
|
||||||
|
"@types/katex": "0.16.8",
|
||||||
"@types/node": "25.9.1",
|
"@types/node": "25.9.1",
|
||||||
"@types/prismjs": "1.26.6",
|
"@types/prismjs": "1.26.6",
|
||||||
"@types/react": "19.2.15",
|
"@types/react": "19.2.15",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useTauriCallPower } from '../hooks/useTauriCallPower';
|
||||||
|
import { useTauriJumpList } from '../hooks/useTauriJumpList';
|
||||||
|
import { useTauriThumbbar } from '../hooks/useTauriThumbbar';
|
||||||
|
import { useTauriSmtc } from '../hooks/useTauriSmtc';
|
||||||
|
import { useTauriNetwork } from '../hooks/useTauriNetwork';
|
||||||
|
import { useTauriToastActions } from '../hooks/useTauriToastActions';
|
||||||
|
import { useTauriFocusAssist } from '../hooks/useTauriFocusAssist';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts the client-scoped native desktop feature hooks (call/room aware). Each
|
||||||
|
* `useTauri*` hook no-ops in the browser (guards on `isTauri`), so this is safe
|
||||||
|
* to render unconditionally. Rendered once by `ClientNonUIFeatures`. App-level
|
||||||
|
* desktop features (window chrome) live in `App.tsx` instead, so they work
|
||||||
|
* before login.
|
||||||
|
*/
|
||||||
|
export function TauriDesktopFeatures(): null {
|
||||||
|
useTauriCallPower(); // P5-46 no-sleep during calls
|
||||||
|
useTauriJumpList(); // P5-36 Windows jump list of recent rooms
|
||||||
|
useTauriThumbbar(); // P5-44 taskbar thumbnail toolbar (mute/deafen/end)
|
||||||
|
useTauriSmtc(); // P5-43 system media transport controls
|
||||||
|
useTauriNetwork(); // P5-49 network-change awareness → sync retry
|
||||||
|
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
|
||||||
|
useTauriFocusAssist(); // P5-56 Windows Focus Assist → DND suppression atom
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Box, config, Icon, Icons, IconSrc, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
|
||||||
|
import React, { MouseEventHandler, ReactNode, useMemo, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
import { ThreadNotificationMode } from '../utils/threadNotifications';
|
||||||
|
import { useSetThreadNotificationMode } from '../hooks/useThreadNotifications';
|
||||||
|
import { AsyncStatus } from '../hooks/useAsyncCallback';
|
||||||
|
|
||||||
|
export const getThreadNotificationModeIcon = (mode?: ThreadNotificationMode): IconSrc => {
|
||||||
|
if (mode === ThreadNotificationMode.Mute) return Icons.BellMute;
|
||||||
|
if (mode === ThreadNotificationMode.MentionsOnly) return Icons.BellPing;
|
||||||
|
if (mode === ThreadNotificationMode.All) return Icons.BellRing;
|
||||||
|
|
||||||
|
return Icons.Bell;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useThreadNotificationModes = (): ThreadNotificationMode[] =>
|
||||||
|
useMemo(
|
||||||
|
() => [
|
||||||
|
ThreadNotificationMode.Default,
|
||||||
|
ThreadNotificationMode.All,
|
||||||
|
ThreadNotificationMode.MentionsOnly,
|
||||||
|
ThreadNotificationMode.Mute,
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const useThreadNotificationModeStr = (): Record<ThreadNotificationMode, string> =>
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
[ThreadNotificationMode.Default]: 'Default (participating)',
|
||||||
|
[ThreadNotificationMode.All]: 'All replies',
|
||||||
|
[ThreadNotificationMode.MentionsOnly]: 'Mentions only',
|
||||||
|
[ThreadNotificationMode.Mute]: 'Mute',
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
type ThreadNotificationModeSwitcherProps = {
|
||||||
|
roomId: string;
|
||||||
|
threadId: string;
|
||||||
|
value?: ThreadNotificationMode;
|
||||||
|
children: (
|
||||||
|
handleOpen: MouseEventHandler<HTMLButtonElement>,
|
||||||
|
opened: boolean,
|
||||||
|
changing: boolean,
|
||||||
|
) => ReactNode;
|
||||||
|
};
|
||||||
|
export function ThreadNotificationModeSwitcher({
|
||||||
|
roomId,
|
||||||
|
threadId,
|
||||||
|
value = ThreadNotificationMode.Default,
|
||||||
|
children,
|
||||||
|
}: ThreadNotificationModeSwitcherProps) {
|
||||||
|
const modes = useThreadNotificationModes();
|
||||||
|
const modeToStr = useThreadNotificationModeStr();
|
||||||
|
|
||||||
|
const { modeState, setMode } = useSetThreadNotificationMode(roomId, threadId);
|
||||||
|
const changing = modeState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setMenuCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (mode: ThreadNotificationMode) => {
|
||||||
|
if (changing) return;
|
||||||
|
setMode(mode);
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={menuCords}
|
||||||
|
offset={5}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: handleClose,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{modes.map((mode) => (
|
||||||
|
<MenuItem
|
||||||
|
key={mode}
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
aria-pressed={mode === value}
|
||||||
|
radii="300"
|
||||||
|
disabled={changing}
|
||||||
|
onClick={() => handleSelect(mode)}
|
||||||
|
before={
|
||||||
|
<Icon
|
||||||
|
size="100"
|
||||||
|
src={getThreadNotificationModeIcon(mode)}
|
||||||
|
filled={mode === value}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T300">
|
||||||
|
{mode === value ? <b>{modeToStr[mode]}</b> : modeToStr[mode]}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children(handleOpenMenu, !!menuCords, changing)}
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import katex from 'katex';
|
||||||
|
import 'katex/dist/katex.min.css';
|
||||||
|
|
||||||
|
type KaTeXProps = {
|
||||||
|
/** Raw LaTeX source (without `$`/`$$` delimiters). */
|
||||||
|
latex: string;
|
||||||
|
/** Render as block (display) math when true, inline otherwise. */
|
||||||
|
displayMode?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily-loaded KaTeX renderer.
|
||||||
|
*
|
||||||
|
* This module statically imports `katex` and its stylesheet, so both only enter
|
||||||
|
* the bundle via the dynamic `import()` of this file (see the `lazy()` wrapper
|
||||||
|
* in `react-custom-html-parser.tsx`). They are therefore NOT part of the eager
|
||||||
|
* import graph.
|
||||||
|
*
|
||||||
|
* We render with `throwOnError: false`, so KaTeX itself renders a parse error
|
||||||
|
* inline (in its error colour) rather than throwing. The HTML returned by
|
||||||
|
* `renderToString` is produced by our own trusted call from a fixed options
|
||||||
|
* object — it is safe to inject via `dangerouslySetInnerHTML`.
|
||||||
|
*/
|
||||||
|
export default function KaTeX({ latex, displayMode = false }: KaTeXProps) {
|
||||||
|
const html = katex.renderToString(latex, {
|
||||||
|
displayMode,
|
||||||
|
throwOnError: false,
|
||||||
|
output: 'htmlAndMathml',
|
||||||
|
});
|
||||||
|
|
||||||
|
const Wrapper = displayMode ? 'div' : 'span';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper
|
||||||
|
// KaTeX output is generated by our own render call (trusted-safe).
|
||||||
|
// eslint-disable-next-line react/no-danger
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ type ReplyProps = {
|
|||||||
replyEventId: string;
|
replyEventId: string;
|
||||||
threadRootId?: string | undefined;
|
threadRootId?: string | undefined;
|
||||||
onClick?: MouseEventHandler | undefined;
|
onClick?: MouseEventHandler | undefined;
|
||||||
|
onThreadClick?: ((threadRootId: string) => void) | undefined;
|
||||||
getMemberPowerTag?: GetMemberPowerTag;
|
getMemberPowerTag?: GetMemberPowerTag;
|
||||||
accessibleTagColors?: Map<string, string>;
|
accessibleTagColors?: Map<string, string>;
|
||||||
legacyUsernameColor?: boolean;
|
legacyUsernameColor?: boolean;
|
||||||
@@ -74,6 +75,7 @@ export const Reply = as<'div', ReplyProps>(
|
|||||||
replyEventId,
|
replyEventId,
|
||||||
threadRootId,
|
threadRootId,
|
||||||
onClick,
|
onClick,
|
||||||
|
onThreadClick,
|
||||||
getMemberPowerTag,
|
getMemberPowerTag,
|
||||||
accessibleTagColors,
|
accessibleTagColors,
|
||||||
legacyUsernameColor,
|
legacyUsernameColor,
|
||||||
@@ -110,7 +112,7 @@ export const Reply = as<'div', ReplyProps>(
|
|||||||
<ThreadIndicator
|
<ThreadIndicator
|
||||||
as="button"
|
as="button"
|
||||||
data-event-id={threadRootId}
|
data-event-id={threadRootId}
|
||||||
onClick={onClick}
|
onClick={onThreadClick ? () => onThreadClick(threadRootId) : onClick}
|
||||||
aria-label="View thread"
|
aria-label="View thread"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { SoundboardPackEditor } from './SoundboardPackEditor';
|
||||||
|
import { SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
|
||||||
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
|
import { useRoomSoundboardPack } from '../../hooks/useSoundboardPacks';
|
||||||
|
import { PackAddress } from '../../plugins/custom-emoji/PackAddress';
|
||||||
|
import { randomStr } from '../../utils/common';
|
||||||
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
|
|
||||||
|
type RoomSoundboardPackProps = {
|
||||||
|
room: Room;
|
||||||
|
stateKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoomSoundboardPack({ room, stateKey }: RoomSoundboardPackProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const userId = mx.getUserId()!;
|
||||||
|
const powerLevels = usePowerLevels(room);
|
||||||
|
const creators = useRoomCreators(room);
|
||||||
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
|
const canEdit = permissions.stateEvent(
|
||||||
|
StateEvent.LotusSoundboardRoom as unknown as keyof import('matrix-js-sdk').StateEvents,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fallbackPack = useMemo(
|
||||||
|
() => new SoundboardPack(randomStr(4), {}, new PackAddress(room.roomId, stateKey)),
|
||||||
|
[room.roomId, stateKey],
|
||||||
|
);
|
||||||
|
const pack = useRoomSoundboardPack(room, stateKey) ?? fallbackPack;
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
async (content: SoundboardContent) => {
|
||||||
|
await mx.sendStateEvent(
|
||||||
|
room.roomId,
|
||||||
|
StateEvent.LotusSoundboardRoom as unknown as keyof import('matrix-js-sdk').StateEvents,
|
||||||
|
content as never,
|
||||||
|
stateKey,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[mx, room.roomId, stateKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SoundboardPackEditor pack={pack} canEdit={canEdit} onUpdate={handleUpdate} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
PopOut,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
color,
|
||||||
|
config,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { EmojiBoard } from '../emoji-board';
|
||||||
|
import { SoundboardClip, SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
|
||||||
|
import { uniqueShortcode } from '../../plugins/soundboard/utils';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import {
|
||||||
|
playClipLocally,
|
||||||
|
resolveClipObjectUrl,
|
||||||
|
SOUNDBOARD_ACCEPT,
|
||||||
|
SOUNDBOARD_MAX_CLIP_BYTES,
|
||||||
|
SOUNDBOARD_MAX_CLIPS,
|
||||||
|
} from '../../utils/soundboardClips';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
type ClipDraft = {
|
||||||
|
url: string;
|
||||||
|
body: string;
|
||||||
|
emoji: string;
|
||||||
|
volume: number;
|
||||||
|
info?: SoundboardClip['info'];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SoundboardPackEditorProps = {
|
||||||
|
pack: SoundboardPack;
|
||||||
|
canEdit?: boolean;
|
||||||
|
onUpdate: (content: SoundboardContent) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable single-pack soundboard manager (used by the settings page and the
|
||||||
|
* in-call management mode). Mirrors image-pack-view/ImagePackContent's staged-
|
||||||
|
* edit + batched-save pattern, but per-clip fields are name + emoji + volume.
|
||||||
|
*/
|
||||||
|
export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPackEditorProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
// Staged, unsaved state:
|
||||||
|
const [drafts, setDrafts] = useState<Map<string, ClipDraft>>(new Map()); // shortcode -> edits
|
||||||
|
const [deleted, setDeleted] = useState<Set<string>>(new Set());
|
||||||
|
const [uploads, setUploads] = useState<Array<{ shortcode: string } & ClipDraft>>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
|
||||||
|
const [busyPreview, setBusyPreview] = useState<string>();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const emojiAnchorRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const existing = useMemo(() => pack.getClips(), [pack]);
|
||||||
|
const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length;
|
||||||
|
|
||||||
|
const dirty = drafts.size > 0 || deleted.size > 0 || uploads.length > 0;
|
||||||
|
|
||||||
|
const draftFor = (shortcode: string, base: { body: string; emoji: string; volume: number }) =>
|
||||||
|
drafts.get(shortcode) ?? { url: '', ...base };
|
||||||
|
|
||||||
|
const setDraft = (shortcode: string, patch: Partial<ClipDraft>, base: ClipDraft) => {
|
||||||
|
setDrafts((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(shortcode, { ...base, ...(next.get(shortcode) ?? {}), ...patch });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = useCallback(
|
||||||
|
async (id: string, mxc: string, volume: number) => {
|
||||||
|
setBusyPreview(id);
|
||||||
|
try {
|
||||||
|
const url = await resolveClipObjectUrl(mx, mxc);
|
||||||
|
playClipLocally(url, volume / 100);
|
||||||
|
} catch {
|
||||||
|
/* ignore preview errors */
|
||||||
|
} finally {
|
||||||
|
setBusyPreview(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mx],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFiles = useCallback(
|
||||||
|
async (files: FileList | null) => {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
setUploading(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const taken = new Set<string>([
|
||||||
|
...existing.map((c) => c.shortcode),
|
||||||
|
...uploads.map((u) => u.shortcode),
|
||||||
|
]);
|
||||||
|
for (let i = 0; i < files.length; i += 1) {
|
||||||
|
const file = files[i];
|
||||||
|
if (clipCount + uploads.length >= SOUNDBOARD_MAX_CLIPS) {
|
||||||
|
throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
|
||||||
|
}
|
||||||
|
if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) {
|
||||||
|
throw new Error(`"${file.name}" is too large (max 1 MB).`);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
|
||||||
|
const mxc = res.content_uri;
|
||||||
|
if (!mxc) throw new Error('Upload failed.');
|
||||||
|
const name = file.name.replace(/\.[^/.]+$/, '');
|
||||||
|
const shortcode = uniqueShortcode(name, taken);
|
||||||
|
taken.add(shortcode);
|
||||||
|
setUploads((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
shortcode,
|
||||||
|
url: mxc,
|
||||||
|
body: name,
|
||||||
|
emoji: '',
|
||||||
|
volume: 100,
|
||||||
|
info: { mimetype: file.type || undefined, size: file.size },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Upload failed.');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mx, existing, uploads, clipCount],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [saveState, save] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const clips: Record<string, SoundboardClip> = {};
|
||||||
|
existing.forEach((c) => {
|
||||||
|
if (deleted.has(c.shortcode)) return;
|
||||||
|
const d = drafts.get(c.shortcode);
|
||||||
|
clips[c.shortcode] = {
|
||||||
|
url: c.url,
|
||||||
|
body: d ? d.body : c.body,
|
||||||
|
emoji: d ? d.emoji || undefined : c.emoji,
|
||||||
|
volume: d ? d.volume : c.volume,
|
||||||
|
info: c.info,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
uploads.forEach((u) => {
|
||||||
|
clips[u.shortcode] = {
|
||||||
|
url: u.url,
|
||||||
|
body: u.body,
|
||||||
|
emoji: u.emoji || undefined,
|
||||||
|
volume: u.volume,
|
||||||
|
info: u.info,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await onUpdate({ pack: pack.meta.content, clips });
|
||||||
|
setDrafts(new Map());
|
||||||
|
setDeleted(new Set());
|
||||||
|
setUploads([]);
|
||||||
|
}, [existing, deleted, drafts, uploads, onUpdate, pack]),
|
||||||
|
);
|
||||||
|
const saving = saveState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const renderRow = (key: string, base: ClipDraft, isUpload: boolean, markedDeleted: boolean) => {
|
||||||
|
const d = isUpload ? base : draftFor(key, base);
|
||||||
|
const rowVolume = isUpload ? base.volume : d.volume;
|
||||||
|
const rowBody = isUpload ? base.body : d.body;
|
||||||
|
const rowEmoji = isUpload ? base.emoji : d.emoji;
|
||||||
|
const commit = (patch: Partial<ClipDraft>) => {
|
||||||
|
if (isUpload) {
|
||||||
|
setUploads((prev) => prev.map((u) => (u.shortcode === key ? { ...u, ...patch } : u)));
|
||||||
|
} else {
|
||||||
|
setDraft(key, patch, base);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={key}
|
||||||
|
alignItems="Center"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S200,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
background: color.SurfaceVariant.Container,
|
||||||
|
opacity: markedDeleted ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
disabled={busyPreview === key}
|
||||||
|
onClick={() => preview(key, base.url, rowVolume)}
|
||||||
|
aria-label={`Preview ${rowBody}`}
|
||||||
|
>
|
||||||
|
{busyPreview === key ? <Spinner size="100" /> : <Icon size="100" src={Icons.Play} />}
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
disabled={!canEdit || markedDeleted}
|
||||||
|
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
emojiAnchorRef.current = evt.currentTarget;
|
||||||
|
setEmojiFor(key);
|
||||||
|
}}
|
||||||
|
aria-label="Pick emoji"
|
||||||
|
>
|
||||||
|
<Text size="T400">{rowEmoji || '🔊'}</Text>
|
||||||
|
</IconButton>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Input
|
||||||
|
variant="Surface"
|
||||||
|
size="300"
|
||||||
|
defaultValue={rowBody}
|
||||||
|
readOnly={!canEdit || markedDeleted}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => commit({ body: e.target.value })}
|
||||||
|
aria-label="Clip name"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(120) }}>
|
||||||
|
<Icon size="50" src={Icons.VolumeHigh} />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
defaultValue={rowVolume}
|
||||||
|
disabled={!canEdit || markedDeleted}
|
||||||
|
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
aria-label="Clip volume"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{canEdit && !isUpload && (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant={markedDeleted ? 'Success' : 'Critical'}
|
||||||
|
onClick={() =>
|
||||||
|
setDeleted((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label={markedDeleted ? 'Undo delete' : 'Delete clip'}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={markedDeleted ? Icons.Plus : Icons.Delete} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{canEdit && isUpload && (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Critical"
|
||||||
|
onClick={() => setUploads((prev) => prev.filter((u) => u.shortcode !== key))}
|
||||||
|
aria-label="Remove upload"
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="300">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
aria-label="Upload soundboard clip"
|
||||||
|
type="file"
|
||||||
|
accept={SOUNDBOARD_ACCEPT}
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
handleFiles(e.target.files);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||||
|
<Text size="H4">{pack.meta.name ?? 'Soundboard'}</Text>
|
||||||
|
{canEdit && (
|
||||||
|
<Chip
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
disabled={uploading || clipCount >= SOUNDBOARD_MAX_CLIPS}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
before={uploading ? <Spinner size="100" /> : <Icon size="100" src={Icons.Plus} />}
|
||||||
|
>
|
||||||
|
<Text size="B300">Upload</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
{existing.map((c) =>
|
||||||
|
renderRow(
|
||||||
|
c.shortcode,
|
||||||
|
{ url: c.url, body: c.body ?? c.shortcode, emoji: c.emoji ?? '', volume: c.volume },
|
||||||
|
false,
|
||||||
|
deleted.has(c.shortcode),
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{uploads.map((u) => renderRow(u.shortcode, u, true, false))}
|
||||||
|
{existing.length === 0 && uploads.length === 0 && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
No clips yet. Upload a short audio clip (max 1 MB){canEdit ? '' : ' — ask an admin'}.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canEdit && dirty && (
|
||||||
|
<Box gap="200">
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Success"
|
||||||
|
radii="300"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => save()}
|
||||||
|
before={saving ? <Spinner size="100" fill="Solid" /> : undefined}
|
||||||
|
>
|
||||||
|
<Text size="B300">Save changes</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
setDrafts(new Map());
|
||||||
|
setDeleted(new Set());
|
||||||
|
setUploads([]);
|
||||||
|
setError(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Reset</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PopOut
|
||||||
|
anchor={emojiFor ? emojiAnchorRef.current?.getBoundingClientRect() : undefined}
|
||||||
|
position="Bottom"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setEmojiFor(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EmojiBoard
|
||||||
|
imagePackRooms={[]}
|
||||||
|
returnFocusOnDeactivate={false}
|
||||||
|
onEmojiSelect={(unicode: string) => {
|
||||||
|
const key = emojiFor;
|
||||||
|
setEmojiFor(undefined);
|
||||||
|
if (!key) return;
|
||||||
|
const up = uploads.find((u) => u.shortcode === key);
|
||||||
|
if (up) {
|
||||||
|
setUploads((prev) =>
|
||||||
|
prev.map((u) => (u.shortcode === key ? { ...u, emoji: unicode } : u)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const c = existing.find((x) => x.shortcode === key);
|
||||||
|
if (c)
|
||||||
|
setDraft(
|
||||||
|
key,
|
||||||
|
{ emoji: unicode },
|
||||||
|
{
|
||||||
|
url: c.url,
|
||||||
|
body: c.body ?? c.shortcode,
|
||||||
|
emoji: c.emoji ?? '',
|
||||||
|
volume: c.volume,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
requestClose={() => setEmojiFor(undefined)}
|
||||||
|
/>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
</PopOut>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import { SoundboardPackEditor } from './SoundboardPackEditor';
|
||||||
|
import { SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
||||||
|
import { useUserSoundboardPack } from '../../hooks/useSoundboardPacks';
|
||||||
|
|
||||||
|
export function UserSoundboardPack() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const defaultPack = useMemo(
|
||||||
|
() =>
|
||||||
|
new SoundboardPack(
|
||||||
|
mx.getUserId() ?? '',
|
||||||
|
{ pack: { display_name: 'My Soundboard' } },
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
[mx],
|
||||||
|
);
|
||||||
|
const pack = useUserSoundboardPack() ?? defaultPack;
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
async (content: SoundboardContent) => {
|
||||||
|
await mx.setAccountData(
|
||||||
|
AccountDataEvent.LotusSoundboard as unknown as keyof import('matrix-js-sdk').AccountDataEvents,
|
||||||
|
content as never,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[mx],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SoundboardPackEditor pack={pack} canEdit onUpdate={handleUpdate} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './SoundboardPackEditor';
|
||||||
|
export * from './RoomSoundboardPack';
|
||||||
|
export * from './UserSoundboardPack';
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -351,10 +351,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
|
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
|
||||||
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||||
<ScreenshareAudioButton
|
|
||||||
muted={screenshareAudioMuted}
|
|
||||||
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
{!compact && showVideoGroup && <ControlDivider />}
|
{!compact && showVideoGroup && <ControlDivider />}
|
||||||
{showVideoGroup && (
|
{showVideoGroup && (
|
||||||
@@ -363,12 +359,20 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
user can stop it; once stopped it hides and can't be restarted. */}
|
user can stop it; once stopped it hides and can't be restarted. */}
|
||||||
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||||
{showScreenshare && (
|
{showScreenshare && (
|
||||||
|
<>
|
||||||
<ScreenShareButton
|
<ScreenShareButton
|
||||||
enabled={screenshare}
|
enabled={screenshare}
|
||||||
onToggle={() =>
|
onToggle={() =>
|
||||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Mute-screenshare-audio sits directly next to the screenshare
|
||||||
|
control since they're the same concern. */}
|
||||||
|
<ScreenshareAudioButton
|
||||||
|
muted={screenshareAudioMuted}
|
||||||
|
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{!!document.fullscreenEnabled && (
|
{!!document.fullscreenEnabled && (
|
||||||
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
||||||
|
|||||||
@@ -1,108 +1,114 @@
|
|||||||
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
|
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Chip,
|
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
Menu,
|
Menu,
|
||||||
PopOut,
|
PopOut,
|
||||||
RectCords,
|
RectCords,
|
||||||
|
Scroll,
|
||||||
Spinner,
|
Spinner,
|
||||||
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
color,
|
color,
|
||||||
config,
|
config,
|
||||||
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
import { CallEmbed } from '../../plugins/call';
|
import { CallEmbed } from '../../plugins/call';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useSoundboard } from '../../hooks/useSoundboard';
|
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||||
|
import { useRelevantSoundboardPacks } from '../../hooks/useSoundboardPacks';
|
||||||
|
import { SoundboardClipReader } from '../../plugins/soundboard';
|
||||||
|
import { UserSoundboardPack, RoomSoundboardPack } from '../../components/soundboard-pack-view';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import {
|
import { playClipLocally, resolveClipObjectUrl } from '../../utils/soundboardClips';
|
||||||
SOUNDBOARD_ACCEPT,
|
|
||||||
SOUNDBOARD_MAX_CLIPS,
|
|
||||||
playClipLocally,
|
|
||||||
resolveClipObjectUrl,
|
|
||||||
} from '../../utils/soundboardClips';
|
|
||||||
|
|
||||||
type CallSoundboardProps = {
|
type CallSoundboardProps = {
|
||||||
callEmbed: CallEmbed;
|
callEmbed: CallEmbed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FlatClip = {
|
||||||
|
key: string; // packId|shortcode
|
||||||
|
packId: string;
|
||||||
|
packName: string;
|
||||||
|
clip: SoundboardClipReader;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [P5-15] In-call soundboard: trigger user-uploaded clips into the call. Each
|
* [P5-15 v2] In-call soundboard. Clips come from the aggregated soundboard packs
|
||||||
* clip is published to peers as a separate track by the EC fork
|
* relevant to the call room (the room + parent spaces ∪ the user's personal
|
||||||
* (`io.lotus.inject_audio`) and also played locally for the presser's feedback.
|
* pack), just like custom emoji. Playing a clip publishes it into the call via
|
||||||
* Clips are uploadable/managed here and synced across devices via the
|
* the EC fork (`io.lotus.inject_audio`, max one at a time) and plays it locally.
|
||||||
* `io.lotus.soundboard` account data (like custom emoji/sticker packs).
|
* A management toggle reveals the pack editors (personal + this room, if
|
||||||
|
* permitted). Space-wide packs are managed from Space settings.
|
||||||
*/
|
*/
|
||||||
export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const { clips, addClip, removeClip } = useSoundboard();
|
const { room } = callEmbed;
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
const packRooms = useImagePackRooms(room.roomId, roomToParents);
|
||||||
|
const packs = useRelevantSoundboardPacks(packRooms);
|
||||||
const [soundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
|
const [soundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
|
||||||
|
const master = Math.max(0, Math.min(1, soundboardVolume / 100));
|
||||||
|
|
||||||
const [cords, setCords] = useState<RectCords>();
|
const [cords, setCords] = useState<RectCords>();
|
||||||
const [busyId, setBusyId] = useState<string>();
|
const [manage, setManage] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const volume = Math.max(0, Math.min(1, soundboardVolume / 100));
|
const groups = useMemo(
|
||||||
|
() =>
|
||||||
|
packs
|
||||||
|
.map((pack) => ({
|
||||||
|
id: pack.id,
|
||||||
|
name: pack.meta.name ?? 'Soundboard',
|
||||||
|
clips: pack.getClips(),
|
||||||
|
}))
|
||||||
|
.filter((g) => g.clips.length > 0),
|
||||||
|
[packs],
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
setCords(evt.currentTarget.getBoundingClientRect());
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePlay = useCallback(
|
const play = useCallback(
|
||||||
async (id: string, mxc: string) => {
|
async (flat: FlatClip) => {
|
||||||
setBusyId(id);
|
if (playingKey) return; // one at a time (fork also enforces this)
|
||||||
|
setPlayingKey(flat.key);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
const done = () => setPlayingKey((k) => (k === flat.key ? undefined : k));
|
||||||
try {
|
try {
|
||||||
const objectUrl = await resolveClipObjectUrl(mx, mxc);
|
const url = await resolveClipObjectUrl(mx, flat.clip.url);
|
||||||
callEmbed.control.injectAudio(objectUrl, volume);
|
const vol = (flat.clip.volume / 100) * master;
|
||||||
playClipLocally(objectUrl, volume);
|
callEmbed.control.injectAudio(url, vol);
|
||||||
|
const audio = playClipLocally(url, vol);
|
||||||
|
if (audio) {
|
||||||
|
audio.addEventListener('ended', done, { once: true });
|
||||||
|
audio.addEventListener('error', done, { once: true });
|
||||||
|
} else {
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
// Safety: clear the guard even if the audio never signals end.
|
||||||
|
window.setTimeout(done, 30_000);
|
||||||
} catch {
|
} catch {
|
||||||
setError('Could not play that clip.');
|
setError('Could not play that clip.');
|
||||||
} finally {
|
done();
|
||||||
setBusyId(undefined);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx, callEmbed, volume],
|
[mx, callEmbed, master, playingKey],
|
||||||
);
|
|
||||||
|
|
||||||
const handleFile = useCallback(
|
|
||||||
async (file: File | undefined) => {
|
|
||||||
if (!file) return;
|
|
||||||
setUploading(true);
|
|
||||||
setError(undefined);
|
|
||||||
try {
|
|
||||||
await addClip(file);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Upload failed.');
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[addClip],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept={SOUNDBOARD_ACCEPT}
|
|
||||||
hidden
|
|
||||||
onChange={(e) => {
|
|
||||||
handleFile(e.target.files?.[0]);
|
|
||||||
e.target.value = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<PopOut
|
<PopOut
|
||||||
anchor={cords}
|
anchor={cords}
|
||||||
position="Top"
|
position="Top"
|
||||||
@@ -116,76 +122,105 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu style={{ maxWidth: '320px' }}>
|
<Menu style={{ maxWidth: manage ? toRem(420) : toRem(340), maxHeight: '70vh' }}>
|
||||||
<Box direction="Column" gap="200" style={{ padding: config.space.S200 }}>
|
<Box direction="Column" style={{ maxHeight: '70vh' }}>
|
||||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
<Box
|
||||||
<Text size="L400">Soundboard</Text>
|
shrink="No"
|
||||||
<Chip
|
alignItems="Center"
|
||||||
variant="Secondary"
|
justifyContent="SpaceBetween"
|
||||||
radii="Pill"
|
gap="200"
|
||||||
disabled={uploading || clips.length >= SOUNDBOARD_MAX_CLIPS}
|
style={{
|
||||||
onClick={() => fileInputRef.current?.click()}
|
padding: config.space.S200,
|
||||||
before={
|
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
uploading ? <Spinner size="100" /> : <Icon size="100" src={Icons.Plus} />
|
}}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Text size="B300">Upload</Text>
|
<Text size="L400">Soundboard</Text>
|
||||||
</Chip>
|
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Manage
|
||||||
|
</Text>
|
||||||
|
<Switch variant="Primary" value={manage} onChange={setManage} />
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{clips.length === 0 ? (
|
<Scroll size="300" hideTrack visibility="Hover">
|
||||||
|
<Box direction="Column" gap="300" style={{ padding: config.space.S200 }}>
|
||||||
|
{manage ? (
|
||||||
|
<>
|
||||||
|
<RoomSoundboardPack room={room} stateKey="" />
|
||||||
|
<UserSoundboardPack />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{groups.length === 0 && (
|
||||||
<Text size="T200" priority="300">
|
<Text size="T200" priority="300">
|
||||||
No clips yet. Upload a short audio clip (max 1 MB) to play it into the call.
|
No soundboard clips here yet. Turn on <b>Manage</b> to upload some, or add
|
||||||
Clips sync across your devices.
|
a pack in Space settings.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
)}
|
||||||
|
{groups.map((g) => (
|
||||||
|
<Box key={g.id} direction="Column" gap="100">
|
||||||
|
<Text size="L400">{g.name}</Text>
|
||||||
<Box wrap="Wrap" gap="200">
|
<Box wrap="Wrap" gap="200">
|
||||||
{clips.map((clip) => (
|
{g.clips.map((clip) => {
|
||||||
|
const key = `${g.id}|${clip.shortcode}`;
|
||||||
|
const flat: FlatClip = {
|
||||||
|
key,
|
||||||
|
packId: g.id,
|
||||||
|
packName: g.name,
|
||||||
|
clip,
|
||||||
|
};
|
||||||
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={clip.id}
|
key={key}
|
||||||
|
as="button"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
gap="100"
|
gap="100"
|
||||||
style={{ position: 'relative' }}
|
disabled={!!playingKey}
|
||||||
>
|
onClick={() => play(flat)}
|
||||||
<Chip
|
aria-label={`Play ${clip.name}`}
|
||||||
variant="SurfaceVariant"
|
style={{
|
||||||
radii="300"
|
width: toRem(76),
|
||||||
disabled={busyId === clip.id}
|
height: toRem(76),
|
||||||
onClick={() => handlePlay(clip.id, clip.url)}
|
padding: config.space.S100,
|
||||||
before={
|
borderRadius: config.radii.R400,
|
||||||
busyId === clip.id ? (
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
<Spinner size="100" />
|
background:
|
||||||
) : (
|
playingKey === key
|
||||||
<Icon size="100" src={Icons.Play} />
|
? color.Primary.Container
|
||||||
)
|
: color.SurfaceVariant.Container,
|
||||||
}
|
cursor: playingKey ? 'default' : 'pointer',
|
||||||
after={
|
opacity: playingKey && playingKey !== key ? 0.5 : 1,
|
||||||
<Icon
|
|
||||||
size="50"
|
|
||||||
src={Icons.Cross}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onClick={(e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
removeClip(clip.id);
|
|
||||||
}}
|
}}
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Text size="B300" truncate style={{ maxWidth: '120px' }}>
|
<Text size="H4">
|
||||||
|
{playingKey === key ? (
|
||||||
|
<Spinner size="200" />
|
||||||
|
) : (
|
||||||
|
clip.emoji || '🔊'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" truncate style={{ maxWidth: '100%' }}>
|
||||||
{clip.name}
|
{clip.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Chip>
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
{error}
|
{error}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
</Menu>
|
</Menu>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
}
|
}
|
||||||
@@ -215,6 +250,5 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</PopOut>
|
</PopOut>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
||||||
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { RoomSoundboardPack, UserSoundboardPack } from '../../../components/soundboard-pack-view';
|
||||||
|
|
||||||
|
type SoundboardProps = {
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soundboard management page (Room/Space settings). Mirrors the Emojis &
|
||||||
|
* Stickers page: a shared room/space pack (admin-editable, inherited by child
|
||||||
|
* rooms like emoji packs) plus the user's personal pack. A single default room
|
||||||
|
* pack (state key "") is used per room/space.
|
||||||
|
*/
|
||||||
|
export function Soundboard({ requestClose }: SoundboardProps) {
|
||||||
|
const room = useRoom();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageHeader outlined={false}>
|
||||||
|
<Box grow="Yes" gap="200">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Text as="h2" size="H3" truncate>
|
||||||
|
Soundboard
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Scroll hideTrack visibility="Hover">
|
||||||
|
<PageContent>
|
||||||
|
<Box direction="Column" gap="700">
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text size="L400">This room / space (shared)</Text>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Clips here are shared with everyone, and inherited by every room under this space
|
||||||
|
— just like emoji/sticker packs. Only members with permission can edit.
|
||||||
|
</Text>
|
||||||
|
{room && <RoomSoundboardPack room={room} stateKey="" />}
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text size="L400">Personal</Text>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Your own clips, available in every call and synced across your devices.
|
||||||
|
</Text>
|
||||||
|
<UserSoundboardPack />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageContent>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './Soundboard';
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { DefaultReset, color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
const BAR_HEIGHT = toRem(32);
|
||||||
|
const CONTROL_WIDTH = toRem(46);
|
||||||
|
|
||||||
|
export const TitleBar = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
flexShrink: 0,
|
||||||
|
height: BAR_HEIGHT,
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
borderBottom: `${toRem(1)} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
// Sit above app content but never intercept scroll etc. below the bar.
|
||||||
|
userSelect: 'none',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The draggable region carries `data-tauri-drag-region`; it must expand to fill
|
||||||
|
// the free space so most of the bar is grabbable.
|
||||||
|
export const DragRegion = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
gap: config.space.S200,
|
||||||
|
paddingInline: config.space.S300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Brand = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S200,
|
||||||
|
// Children shouldn't swallow the drag; the region itself owns the attribute.
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Controls = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ControlButton = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: CONTROL_WIDTH,
|
||||||
|
height: '100%',
|
||||||
|
padding: 0,
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'inherit',
|
||||||
|
transition: 'background-color 100ms ease',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: color.SurfaceVariant.ContainerLine,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ControlButtonClose = style({
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: color.Critical.Main,
|
||||||
|
color: color.Critical.OnMain,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import React, { MouseEvent, ReactNode } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { Text } from 'folds';
|
||||||
|
import { customWindowChromeAtom } from '../../state/customWindowChrome';
|
||||||
|
import { invokeTauri, isTauri } from '../../hooks/useTauri';
|
||||||
|
import * as css from './TitleBar.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect macOS from the web side (no `tauri-plugin-os` dependency). We only need
|
||||||
|
* a coarse "is this a Mac" signal to decide which side the window controls sit
|
||||||
|
* on, so the UA/platform sniff is sufficient and stays cross-platform.
|
||||||
|
*/
|
||||||
|
const isMacOS = (): boolean => {
|
||||||
|
const platform =
|
||||||
|
(
|
||||||
|
navigator as unknown as {
|
||||||
|
userAgentData?: { platform?: string };
|
||||||
|
}
|
||||||
|
).userAgentData?.platform ??
|
||||||
|
navigator.platform ??
|
||||||
|
navigator.userAgent;
|
||||||
|
return /mac/i.test(platform);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIN_GLYPH = (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||||
|
<rect x="1" y="4.5" width="8" height="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MAX_GLYPH = (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||||
|
<rect x="1" y="1" width="8" height="8" fill="none" stroke="currentColor" strokeWidth="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CLOSE_GLYPH = (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||||
|
<path d="M1 1 L9 9 M9 1 L1 9" stroke="currentColor" strokeWidth="1" fill="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
type ControlButtonProps = {
|
||||||
|
label: string;
|
||||||
|
glyph: ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
close?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ControlButton({ label, glyph, onClick, close }: ControlButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`${css.ControlButton}${close ? ` ${css.ControlButtonClose}` : ''}`}
|
||||||
|
>
|
||||||
|
{glyph}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-47 — TDS Custom Window Chrome titlebar.
|
||||||
|
*
|
||||||
|
* Renders `null` unless we're inside Tauri **and** the user opted into custom
|
||||||
|
* window chrome. Otherwise it draws a thin (~32px) folds/TDS-styled titlebar: a
|
||||||
|
* draggable region (explicit `window_start_drag` on mousedown, double-press to
|
||||||
|
* maximize) with the app brand, plus minimize / maximize / close controls that
|
||||||
|
* call the native window commands.
|
||||||
|
*
|
||||||
|
* OS-aware: Windows/Linux put the controls on the right; macOS mirrors them to
|
||||||
|
* the left (the native traffic-light position) since decorations — and thus the
|
||||||
|
* real traffic lights — are stripped while custom chrome is on.
|
||||||
|
*/
|
||||||
|
export function TitleBar() {
|
||||||
|
const enabled = useAtomValue(customWindowChromeAtom);
|
||||||
|
|
||||||
|
if (!isTauri() || !enabled) return null;
|
||||||
|
|
||||||
|
const mac = isMacOS();
|
||||||
|
|
||||||
|
// Official Tauri custom-titlebar recipe: primary-button mousedown starts an
|
||||||
|
// OS window drag; a double press (detail === 2) toggles maximize instead. An
|
||||||
|
// explicit `window_start_drag` invoke is used rather than
|
||||||
|
// `data-tauri-drag-region` because the attribute only fires when the exact
|
||||||
|
// element is the event target (children like the brand text wouldn't drag).
|
||||||
|
const handleDragMouseDown = (evt: MouseEvent<HTMLDivElement>): void => {
|
||||||
|
if (evt.button !== 0) return;
|
||||||
|
if (evt.detail === 2) {
|
||||||
|
invokeTauri('window_toggle_maximize');
|
||||||
|
} else {
|
||||||
|
invokeTauri('window_start_drag');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const controls = (
|
||||||
|
<div className={css.Controls}>
|
||||||
|
<ControlButton
|
||||||
|
label="Minimize"
|
||||||
|
glyph={MIN_GLYPH}
|
||||||
|
onClick={() => invokeTauri('window_minimize')}
|
||||||
|
/>
|
||||||
|
<ControlButton
|
||||||
|
label="Maximize"
|
||||||
|
glyph={MAX_GLYPH}
|
||||||
|
onClick={() => invokeTauri('window_toggle_maximize')}
|
||||||
|
/>
|
||||||
|
<ControlButton
|
||||||
|
label="Close"
|
||||||
|
glyph={CLOSE_GLYPH}
|
||||||
|
onClick={() => invokeTauri('window_close')}
|
||||||
|
close
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const dragRegion = (
|
||||||
|
<div className={css.DragRegion} onMouseDown={handleDragMouseDown}>
|
||||||
|
<span className={css.Brand}>
|
||||||
|
<Text as="span" size="T200" truncate>
|
||||||
|
Lotus Chat
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={css.TitleBar}>
|
||||||
|
{mac ? (
|
||||||
|
<>
|
||||||
|
{controls}
|
||||||
|
{dragRegion}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{dragRegion}
|
||||||
|
{controls}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
Line,
|
Line,
|
||||||
toRem,
|
toRem,
|
||||||
Button,
|
Button,
|
||||||
|
Switch,
|
||||||
|
Chip,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
@@ -41,7 +43,9 @@ import {
|
|||||||
ResultGroup,
|
ResultGroup,
|
||||||
useMessageSearch,
|
useMessageSearch,
|
||||||
} from './useMessageSearch';
|
} from './useMessageSearch';
|
||||||
import { useLocalMessageSearch } from './useLocalMessageSearch';
|
import { LocalSearchResult, useLocalMessageSearch } from './useLocalMessageSearch';
|
||||||
|
import { searchCacheEnabledAtom } from '../../state/searchCacheEnabled';
|
||||||
|
import { clearAll as clearSearchCache } from '../../utils/searchCache';
|
||||||
import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches';
|
import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches';
|
||||||
import { SearchResultGroup } from './SearchResultGroup';
|
import { SearchResultGroup } from './SearchResultGroup';
|
||||||
import { SearchInput } from './SearchInput';
|
import { SearchInput } from './SearchInput';
|
||||||
@@ -240,6 +244,10 @@ export function MessageSearch({
|
|||||||
// Bump this whenever more messages are loaded so localResult re-computes
|
// Bump this whenever more messages are loaded so localResult re-computes
|
||||||
const [cacheVersion, setCacheVersion] = useState(0);
|
const [cacheVersion, setCacheVersion] = useState(0);
|
||||||
const handleCacheLoaded = useCallback(() => setCacheVersion((v) => v + 1), []);
|
const handleCacheLoaded = useCallback(() => setCacheVersion((v) => v + 1), []);
|
||||||
|
// Explicit wipe of the persistent on-disk index, then re-run the merge.
|
||||||
|
const handleClearSearchCache = useCallback(() => {
|
||||||
|
clearSearchCache().then(() => setCacheVersion((v) => v + 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// The rooms actually in scope for this search (mirrors server-side logic)
|
// The rooms actually in scope for this search (mirrors server-side logic)
|
||||||
const localSearchRooms = useMemo(
|
const localSearchRooms = useMemo(
|
||||||
@@ -253,24 +261,43 @@ export function MessageSearch({
|
|||||||
const hasActiveSearch = msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length;
|
const hasActiveSearch = msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length;
|
||||||
const senderOnlyMode = !msgSearchParams.term && !!msgSearchParams.senders?.length;
|
const senderOnlyMode = !msgSearchParams.term && !!msgSearchParams.senders?.length;
|
||||||
|
|
||||||
// Run synchronous client-side search immediately.
|
// Run the client-side search whenever inputs change.
|
||||||
// In text-search mode: covers encrypted rooms only (server handles plaintext).
|
// In text-search mode: covers encrypted rooms only (server handles plaintext).
|
||||||
// In sender-only mode: covers all rooms (server has no sender-only search).
|
// In sender-only mode: covers all rooms (server has no sender-only search).
|
||||||
// cacheVersion in deps so it re-runs after "Load more" paginates new events.
|
// The scan is async because — when the persistent cache is enabled — it also
|
||||||
const localResult = useMemo(() => {
|
// reads cached rows from IndexedDB and merges them with the in-memory hits.
|
||||||
if (!hasActiveSearch) return null;
|
// cacheVersion in deps so it re-runs after "Load more" paginates new events;
|
||||||
return searchLocalMessages({
|
// searchCacheEnabled so toggling the cache re-runs the merge.
|
||||||
|
const [searchCacheEnabled, setSearchCacheEnabled] = useAtom(searchCacheEnabledAtom);
|
||||||
|
const [localResult, setLocalResult] = useState<LocalSearchResult | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasActiveSearch) {
|
||||||
|
setLocalResult(null);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
searchLocalMessages({
|
||||||
term: msgSearchParams.term ?? '',
|
term: msgSearchParams.term ?? '',
|
||||||
roomIds: localSearchRooms,
|
roomIds: localSearchRooms,
|
||||||
senders: msgSearchParams.senders,
|
senders: msgSearchParams.senders,
|
||||||
|
fromTs: msgSearchParams.fromTs,
|
||||||
|
toTs: msgSearchParams.toTs,
|
||||||
|
}).then((result) => {
|
||||||
|
if (!cancelled) setLocalResult(result);
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [
|
}, [
|
||||||
searchLocalMessages,
|
searchLocalMessages,
|
||||||
localSearchRooms,
|
localSearchRooms,
|
||||||
msgSearchParams.term,
|
msgSearchParams.term,
|
||||||
msgSearchParams.senders,
|
msgSearchParams.senders,
|
||||||
|
msgSearchParams.fromTs,
|
||||||
|
msgSearchParams.toTs,
|
||||||
|
hasActiveSearch,
|
||||||
cacheVersion,
|
cacheVersion,
|
||||||
|
searchCacheEnabled,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
|
const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
|
||||||
@@ -668,6 +695,37 @@ export function MessageSearch({
|
|||||||
? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.`
|
? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.`
|
||||||
: `No matches in your local cache. Load messages below to search further back.`}
|
: `No matches in your local cache. Load messages below to search further back.`}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Box
|
||||||
|
alignItems="Center"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S200,
|
||||||
|
background: color.SurfaceVariant.Container,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={searchCacheEnabled}
|
||||||
|
onChange={setSearchCacheEnabled}
|
||||||
|
/>
|
||||||
|
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
|
||||||
|
<Text size="T300">Persist search index on this device</Text>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Stores decrypted text on this device
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{searchCacheEnabled && (
|
||||||
|
<Chip
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={handleClearSearchCache}
|
||||||
|
before={<Icon size="100" src={Icons.Delete} />}
|
||||||
|
>
|
||||||
|
<Text size="T200">Clear cached index</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
<Line size="300" variant="Surface" />
|
<Line size="300" variant="Surface" />
|
||||||
</Box>
|
</Box>
|
||||||
{localGroups.length > 0 && (
|
{localGroups.length > 0 && (
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { EventType } from 'matrix-js-sdk';
|
import { EventType, MatrixEvent } from 'matrix-js-sdk';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { ResultGroup, ResultItem } from './useMessageSearch';
|
import { ResultGroup, ResultItem } from './useMessageSearch';
|
||||||
|
import { searchCacheEnabledAtom } from '../../state/searchCacheEnabled';
|
||||||
|
import {
|
||||||
|
mergeSearchResults,
|
||||||
|
queryRoom,
|
||||||
|
saveRoomIndex,
|
||||||
|
SearchCacheRow,
|
||||||
|
} from '../../utils/searchCache';
|
||||||
|
|
||||||
export type LocalSearchParams = {
|
export type LocalSearchParams = {
|
||||||
term: string;
|
term: string;
|
||||||
roomIds: string[];
|
roomIds: string[];
|
||||||
senders?: string[];
|
senders?: string[];
|
||||||
|
/** Optional date-range filter (ms). Applied to both memory and cached rows. */
|
||||||
|
fromTs?: number;
|
||||||
|
toTs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LocalSearchResult = {
|
export type LocalSearchResult = {
|
||||||
@@ -17,19 +28,110 @@ export type LocalSearchResult = {
|
|||||||
searchedRoomsCount: number;
|
searchedRoomsCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Extracted, searchable plaintext for a single message event. */
|
||||||
|
type ExtractedText = {
|
||||||
|
body: string;
|
||||||
|
formattedBody: string;
|
||||||
|
pollText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const POLL_START_TYPES = ['m.poll.start', 'org.matrix.msc3381.poll.start'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the text we index/search from a decrypted event's content. Returns
|
||||||
|
* `null` for events that carry no searchable text (e.g. stickers).
|
||||||
|
*/
|
||||||
|
const extractText = (event: MatrixEvent): ExtractedText | null => {
|
||||||
|
const evType = event.getType();
|
||||||
|
const content = event.getContent();
|
||||||
|
|
||||||
|
if (POLL_START_TYPES.includes(evType)) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as any;
|
||||||
|
if (!poll) return null;
|
||||||
|
const qBody =
|
||||||
|
(poll.question?.['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
|
||||||
|
(poll.question?.body as string | undefined) ??
|
||||||
|
'';
|
||||||
|
const answerBodies = ((poll.answers ?? []) as Array<Record<string, unknown>>)
|
||||||
|
.map(
|
||||||
|
(a) =>
|
||||||
|
((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(a['org.matrix.msc3381.poll.answer'] as any)?.body ??
|
||||||
|
'') as string,
|
||||||
|
)
|
||||||
|
.join(' ');
|
||||||
|
const pollText = `${qBody} ${answerBodies}`.trim();
|
||||||
|
return pollText ? { body: '', formattedBody: '', pollText } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evType !== EventType.RoomMessage) return null;
|
||||||
|
|
||||||
|
const body = (content.body as string | undefined) ?? '';
|
||||||
|
const formattedBody = (content.formatted_body as string | undefined) ?? '';
|
||||||
|
if (!body && !formattedBody) return null;
|
||||||
|
return { body, formattedBody, pollText: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Does the extracted text contain the (already-lowercased) term? */
|
||||||
|
const matchesTerm = (text: ExtractedText, termLower: string): boolean =>
|
||||||
|
text.body.toLowerCase().includes(termLower) ||
|
||||||
|
text.formattedBody.toLowerCase().includes(termLower) ||
|
||||||
|
text.pollText.toLowerCase().includes(termLower);
|
||||||
|
|
||||||
|
const rowMatchesTerm = (row: SearchCacheRow, termLower: string): boolean =>
|
||||||
|
row.body.toLowerCase().includes(termLower) ||
|
||||||
|
(row.formattedBody ?? '').toLowerCase().includes(termLower) ||
|
||||||
|
(row.pollText ?? '').toLowerCase().includes(termLower);
|
||||||
|
|
||||||
|
/** Build the synthetic result item a cached row renders as (text message). */
|
||||||
|
const rowToResultItem = (row: SearchCacheRow): ResultItem => {
|
||||||
|
const bodyText = row.body || row.pollText || '';
|
||||||
|
const content: Record<string, unknown> = { msgtype: 'm.text', body: bodyText };
|
||||||
|
if (row.formattedBody) {
|
||||||
|
content.format = 'org.matrix.custom.html';
|
||||||
|
content.formatted_body = row.formattedBody;
|
||||||
|
}
|
||||||
|
const syntheticEvent = {
|
||||||
|
room_id: row.roomId,
|
||||||
|
event_id: row.eventId,
|
||||||
|
type: EventType.RoomMessage,
|
||||||
|
sender: row.sender,
|
||||||
|
origin_server_ts: row.ts,
|
||||||
|
content,
|
||||||
|
unsigned: {},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
rank: 0,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
event: syntheticEvent as any,
|
||||||
|
context: { events_before: [], events_after: [], profile_info: {} },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client-side full-text search over locally cached events in encrypted rooms.
|
* Client-side full-text search over locally cached events in encrypted rooms.
|
||||||
* The homeserver cannot search E2EE message content, so we scan whatever the
|
* The homeserver cannot search E2EE message content, so we scan whatever the
|
||||||
* client has already received and decrypted in memory.
|
* client has already received and decrypted in memory.
|
||||||
*
|
*
|
||||||
* Limitation: only messages present in the live timeline window are covered.
|
* When the persistent search cache is enabled (opt-in), the in-memory scan is
|
||||||
* Rooms that haven't been opened yet will return no results.
|
* also persisted to IndexedDB (fire-and-forget) and merged with prior cached
|
||||||
|
* coverage so results survive reloads. When disabled, zero cache reads/writes
|
||||||
|
* occur.
|
||||||
*/
|
*/
|
||||||
export const useLocalMessageSearch = () => {
|
export const useLocalMessageSearch = () => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const cacheEnabled = useAtomValue(searchCacheEnabledAtom);
|
||||||
|
|
||||||
const search = useCallback(
|
const search = useCallback(
|
||||||
({ term, roomIds, senders }: LocalSearchParams): LocalSearchResult => {
|
async ({
|
||||||
|
term,
|
||||||
|
roomIds,
|
||||||
|
senders,
|
||||||
|
fromTs,
|
||||||
|
toTs,
|
||||||
|
}: LocalSearchParams): Promise<LocalSearchResult> => {
|
||||||
const trimmedTerm = term.trim();
|
const trimmedTerm = term.trim();
|
||||||
const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
|
const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
|
||||||
|
|
||||||
@@ -41,6 +143,9 @@ export const useLocalMessageSearch = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const termLower = trimmedTerm.toLowerCase();
|
const termLower = trimmedTerm.toLowerCase();
|
||||||
|
const inRange = (ts: number): boolean =>
|
||||||
|
(fromTs === undefined || ts >= fromTs) && (toTs === undefined || ts <= toTs);
|
||||||
|
|
||||||
const groups: ResultGroup[] = [];
|
const groups: ResultGroup[] = [];
|
||||||
let encryptedRoomsCount = 0;
|
let encryptedRoomsCount = 0;
|
||||||
let searchedRoomsCount = 0;
|
let searchedRoomsCount = 0;
|
||||||
@@ -61,106 +166,99 @@ export const useLocalMessageSearch = () => {
|
|||||||
.getUnfilteredTimelineSet()
|
.getUnfilteredTimelineSet()
|
||||||
.getTimelines()
|
.getTimelines()
|
||||||
.flatMap((tl) => tl.getEvents());
|
.flatMap((tl) => tl.getEvents());
|
||||||
if (events.length === 0) continue;
|
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const cachedRows = cacheEnabled ? await queryRoom(roomId) : [];
|
||||||
|
|
||||||
|
if (events.length === 0 && cachedRows.length === 0) continue;
|
||||||
|
|
||||||
searchedRoomsCount += 1;
|
searchedRoomsCount += 1;
|
||||||
|
|
||||||
const items: ResultItem[] = [];
|
const memoryItems: ResultItem[] = [];
|
||||||
|
const rowsToPersist: SearchCacheRow[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < events.length; i += 1) {
|
for (let i = 0; i < events.length; i += 1) {
|
||||||
const event = events[i];
|
const event = events[i];
|
||||||
|
|
||||||
// In sender-only mode: include all message types; skip non-message events
|
|
||||||
if (event.getType() !== EventType.RoomMessage) {
|
|
||||||
if (senderOnlyMode) continue;
|
|
||||||
const evType = event.getType();
|
|
||||||
const isSticker = evType === 'm.sticker';
|
|
||||||
const isPoll = evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
|
|
||||||
if (!isSticker && !isPoll) continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.isDecryptionFailure()) continue;
|
if (event.isDecryptionFailure()) continue;
|
||||||
if (event.isRedacted()) continue;
|
if (event.isRedacted()) continue;
|
||||||
if (senderSet && !senderSet.has(event.getSender() ?? '')) continue;
|
|
||||||
|
|
||||||
// getContent() returns decrypted plaintext regardless of encryption
|
|
||||||
const content = event.getContent();
|
|
||||||
|
|
||||||
// Sender-only mode: no text filter needed
|
|
||||||
if (!senderOnlyMode) {
|
|
||||||
const evType = event.getType();
|
const evType = event.getType();
|
||||||
const isPoll = evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
|
const isSticker = evType === 'm.sticker';
|
||||||
|
const isMessageLike =
|
||||||
|
evType === EventType.RoomMessage || POLL_START_TYPES.includes(evType);
|
||||||
|
|
||||||
let body = '';
|
// Sender-only mode indexes/returns all message types; text mode needs text.
|
||||||
let formattedBody = '';
|
if (!senderOnlyMode && !isMessageLike && !isSticker) continue;
|
||||||
if (!isPoll) {
|
|
||||||
body = (content.body as string | undefined) ?? '';
|
|
||||||
formattedBody = (content.formatted_body as string | undefined) ?? '';
|
|
||||||
} else {
|
|
||||||
// Poll — index question text and all answer options
|
|
||||||
const poll = (content['m.poll'] ??
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
content['org.matrix.msc3381.poll.start']) as any;
|
|
||||||
if (poll) {
|
|
||||||
const qBody =
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(poll.question?.['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
|
|
||||||
(poll.question?.body as string | undefined) ??
|
|
||||||
'';
|
|
||||||
const answerBodies = ((poll.answers ?? []) as Array<Record<string, unknown>>)
|
|
||||||
.map(
|
|
||||||
(a) =>
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(a['org.matrix.msc3381.poll.answer'] as any)?.body ??
|
|
||||||
'') as string,
|
|
||||||
)
|
|
||||||
.join(' ');
|
|
||||||
body = `${qBody} ${answerBodies}`.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
const sender = event.getSender() ?? '';
|
||||||
!body.toLowerCase().includes(termLower) &&
|
const ts = event.getTs();
|
||||||
!formattedBody.toLowerCase().includes(termLower)
|
const text = extractText(event);
|
||||||
)
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a synthetic IEventWithRoomId using decrypted content so the
|
// Persist every indexable (text-bearing) event we scanned, regardless
|
||||||
// existing SearchResultGroup renderer works without modification.
|
// of whether it matches the current term — future searches benefit.
|
||||||
const syntheticEvent = {
|
if (cacheEnabled && text && event.getId()) {
|
||||||
room_id: roomId,
|
rowsToPersist.push({
|
||||||
event_id: event.getId() ?? '',
|
roomId,
|
||||||
type: event.getType(),
|
eventId: event.getId() as string,
|
||||||
sender: event.getSender() ?? '',
|
ts,
|
||||||
origin_server_ts: event.getTs(),
|
sender,
|
||||||
content,
|
body: text.body,
|
||||||
unsigned: event.getUnsigned(),
|
...(text.formattedBody ? { formattedBody: text.formattedBody } : {}),
|
||||||
};
|
...(text.pollText ? { pollText: text.pollText } : {}),
|
||||||
|
|
||||||
items.push({
|
|
||||||
rank: 0,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
event: syntheticEvent as any,
|
|
||||||
context: {
|
|
||||||
events_before: [],
|
|
||||||
events_after: [],
|
|
||||||
profile_info: {},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (senderSet && !senderSet.has(sender)) continue;
|
||||||
|
if (!inRange(ts)) continue;
|
||||||
|
|
||||||
|
if (!senderOnlyMode) {
|
||||||
|
if (!text || !matchesTerm(text, termLower)) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = event.getContent();
|
||||||
|
const syntheticEvent = {
|
||||||
|
room_id: roomId,
|
||||||
|
event_id: event.getId() ?? '',
|
||||||
|
type: evType,
|
||||||
|
sender,
|
||||||
|
origin_server_ts: ts,
|
||||||
|
content,
|
||||||
|
unsigned: event.getUnsigned(),
|
||||||
|
};
|
||||||
|
memoryItems.push({
|
||||||
|
rank: 0,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
event: syntheticEvent as any,
|
||||||
|
context: { events_before: [], events_after: [], profile_info: {} },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match cached rows (skip ids already present in memory happens in merge).
|
||||||
|
const cachedItems: ResultItem[] = [];
|
||||||
|
cachedRows.forEach((row) => {
|
||||||
|
if (senderSet && !senderSet.has(row.sender)) return;
|
||||||
|
if (!inRange(row.ts)) return;
|
||||||
|
if (!senderOnlyMode && !rowMatchesTerm(row, termLower)) return;
|
||||||
|
cachedItems.push(rowToResultItem(row));
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = mergeSearchResults(memoryItems, cachedItems);
|
||||||
|
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
items.sort((a, b) => (b.event.origin_server_ts ?? 0) - (a.event.origin_server_ts ?? 0));
|
|
||||||
groups.push({ roomId, items });
|
groups.push({ roomId, items });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fire-and-forget persist of freshly scanned rows + coverage.
|
||||||
|
// saveRoomIndex swallows all errors internally, so a floating promise
|
||||||
|
// here can never reject.
|
||||||
|
if (cacheEnabled && rowsToPersist.length > 0) {
|
||||||
|
saveRoomIndex(roomId, rowsToPersist);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { groups, encryptedRoomsCount, searchedRoomsCount };
|
return { groups, encryptedRoomsCount, searchedRoomsCount };
|
||||||
},
|
},
|
||||||
[mx],
|
[mx, cacheEnabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
return search;
|
return search;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
|||||||
import { General } from './general';
|
import { General } from './general';
|
||||||
import { Members } from '../common-settings/members';
|
import { Members } from '../common-settings/members';
|
||||||
import { EmojisStickers } from '../common-settings/emojis-stickers';
|
import { EmojisStickers } from '../common-settings/emojis-stickers';
|
||||||
|
import { Soundboard } from '../common-settings/soundboard';
|
||||||
import { Permissions } from './permissions';
|
import { Permissions } from './permissions';
|
||||||
import { RoomSettingsPage } from '../../state/roomSettings';
|
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
@@ -53,6 +54,11 @@ const BASE_MENU_ITEMS: RoomSettingsMenuItem[] = [
|
|||||||
name: 'Emojis & Stickers',
|
name: 'Emojis & Stickers',
|
||||||
icon: Icons.Smile,
|
icon: Icons.Smile,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
page: RoomSettingsPage.SoundboardPage,
|
||||||
|
name: 'Soundboard',
|
||||||
|
icon: Icons.Bell,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
page: RoomSettingsPage.DeveloperToolsPage,
|
page: RoomSettingsPage.DeveloperToolsPage,
|
||||||
name: 'Developer Tools',
|
name: 'Developer Tools',
|
||||||
@@ -226,6 +232,9 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
|
|||||||
{activePage === RoomSettingsPage.EmojisStickersPage && (
|
{activePage === RoomSettingsPage.EmojisStickersPage && (
|
||||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
<EmojisStickers requestClose={handlePageRequestClose} />
|
||||||
)}
|
)}
|
||||||
|
{activePage === RoomSettingsPage.SoundboardPage && (
|
||||||
|
<Soundboard requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
{activePage === RoomSettingsPage.DeveloperToolsPage && (
|
{activePage === RoomSettingsPage.DeveloperToolsPage && (
|
||||||
<DeveloperTools requestClose={handlePageRequestClose} />
|
<DeveloperTools requestClose={handlePageRequestClose} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { keyframes, style } from '@vanilla-extract/css';
|
||||||
|
import { color, toRem } from 'folds';
|
||||||
|
|
||||||
|
// A brief, gentle acknowledgement when a draft first becomes persisted.
|
||||||
|
// Guarded by `prefers-reduced-motion` so it only plays for users who opt in.
|
||||||
|
const savedPulse = keyframes({
|
||||||
|
'0%': { opacity: 0.4, transform: 'scale(0.7)' },
|
||||||
|
'45%': { opacity: 1, transform: 'scale(1.15)' },
|
||||||
|
'100%': { opacity: 1, transform: 'scale(1)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DraftIndicatorBase = style({
|
||||||
|
userSelect: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DraftDot = style({
|
||||||
|
width: toRem(6),
|
||||||
|
height: toRem(6),
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: color.Success.Main,
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DraftDotPulse = style({
|
||||||
|
'@media': {
|
||||||
|
'(prefers-reduced-motion: no-preference)': {
|
||||||
|
animation: `${savedPulse} 600ms ease-out`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { Box, Text, config } from 'folds';
|
||||||
|
|
||||||
|
import { roomIdToMsgDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||||
|
import { toPlainText } from '../../components/editor';
|
||||||
|
import { DraftDot, DraftDotPulse, DraftIndicatorBase } from './DraftIndicator.css';
|
||||||
|
|
||||||
|
const PULSE_DURATION = 600;
|
||||||
|
|
||||||
|
type DraftIndicatorProps = {
|
||||||
|
roomId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subtle, non-distracting status shown near the composer when the current room
|
||||||
|
* has a persisted (unsent) message draft. It reacts to the shared draft atom
|
||||||
|
* (`roomIdToMsgDraftAtomFamily`) — the same source that backs the
|
||||||
|
* `draft-msg-${roomId}` localStorage persistence — so it never introduces a
|
||||||
|
* parallel persistence path.
|
||||||
|
*
|
||||||
|
* A short "Saved" pulse plays the moment a draft becomes persisted, then the
|
||||||
|
* indicator settles into a quiet, muted resting state. The pulse is gated behind
|
||||||
|
* `prefers-reduced-motion` in CSS, so motion-averse users only ever see the
|
||||||
|
* static label.
|
||||||
|
*/
|
||||||
|
export function DraftIndicator({ roomId }: DraftIndicatorProps) {
|
||||||
|
const draft = useAtomValue(roomIdToMsgDraftAtomFamily(roomId));
|
||||||
|
// Real content, not just an empty paragraph.
|
||||||
|
const hasDraft = toPlainText(draft, false).trim().length > 0;
|
||||||
|
|
||||||
|
const [pulse, setPulse] = useState(false);
|
||||||
|
const hadDraft = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasDraft && !hadDraft.current) {
|
||||||
|
hadDraft.current = true;
|
||||||
|
setPulse(true);
|
||||||
|
const timeout = setTimeout(() => setPulse(false), PULSE_DURATION);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
hadDraft.current = hasDraft;
|
||||||
|
return undefined;
|
||||||
|
}, [hasDraft]);
|
||||||
|
|
||||||
|
if (!hasDraft) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={DraftIndicatorBase}
|
||||||
|
as="span"
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="200"
|
||||||
|
style={{ padding: `0 ${config.space.S100}` }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<span className={`${DraftDot}${pulse ? ` ${DraftDotPulse}` : ''}`} />
|
||||||
|
<Text as="span" size="T200" priority="300">
|
||||||
|
Draft saved
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -22,6 +22,8 @@ import { callChatAtom } from '../../state/callEmbed';
|
|||||||
import { CallChatView } from './CallChatView';
|
import { CallChatView } from './CallChatView';
|
||||||
import { useCallEmbed } from '../../hooks/useCallEmbed';
|
import { useCallEmbed } from '../../hooks/useCallEmbed';
|
||||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||||
|
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||||
|
import { ThreadPanel } from './thread';
|
||||||
|
|
||||||
export function Room() {
|
export function Room() {
|
||||||
const { eventId } = useParams();
|
const { eventId } = useParams();
|
||||||
@@ -33,6 +35,8 @@ export function Room() {
|
|||||||
const callEmbed = useCallEmbed();
|
const callEmbed = useCallEmbed();
|
||||||
|
|
||||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
|
const activeThreadId = useAtomValue(roomIdToActiveThreadIdAtomFamily(room.roomId));
|
||||||
|
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
|
||||||
const galleryOpen = useAtomValue(mediaGalleryAtom);
|
const galleryOpen = useAtomValue(mediaGalleryAtom);
|
||||||
const setGalleryOpen = useSetAtom(mediaGalleryAtom);
|
const setGalleryOpen = useSetAtom(mediaGalleryAtom);
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
@@ -45,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">
|
||||||
@@ -82,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" />
|
||||||
@@ -90,7 +125,20 @@ export function Room() {
|
|||||||
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
|
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!callView && isDrawer && (
|
{showThreadPanel && activeThreadId && (
|
||||||
|
<>
|
||||||
|
{screenSize === ScreenSize.Desktop && (
|
||||||
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
|
)}
|
||||||
|
<ThreadPanel
|
||||||
|
key={`${room.roomId}${activeThreadId}`}
|
||||||
|
room={room}
|
||||||
|
threadId={activeThreadId}
|
||||||
|
requestClose={() => setActiveThreadId(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showMembers && (
|
||||||
<>
|
<>
|
||||||
{screenSize === ScreenSize.Desktop && (
|
{screenSize === ScreenSize.Desktop && (
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
|
|||||||
+240
-134
@@ -1,9 +1,11 @@
|
|||||||
import React, {
|
import React, {
|
||||||
KeyboardEventHandler,
|
KeyboardEventHandler,
|
||||||
|
ReactNode,
|
||||||
RefObject,
|
RefObject,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
@@ -98,7 +100,11 @@ import { safeFile } from '../../utils/mimeTypes';
|
|||||||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import {
|
||||||
|
ComposerToolbarButtonKey,
|
||||||
|
normalizeComposerToolbarOrder,
|
||||||
|
settingsAtom,
|
||||||
|
} from '../../state/settings';
|
||||||
import {
|
import {
|
||||||
getAudioMsgContent,
|
getAudioMsgContent,
|
||||||
getFileMsgContent,
|
getFileMsgContent,
|
||||||
@@ -128,7 +134,9 @@ import { PollCreator } from './PollCreator';
|
|||||||
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
|
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
|
||||||
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||||
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
|
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
|
||||||
|
import { DraftIndicator } from './DraftIndicator';
|
||||||
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
||||||
|
import { getThreadDraftKey } from '../../state/room/thread';
|
||||||
|
|
||||||
const GifPicker = React.lazy(() =>
|
const GifPicker = React.lazy(() =>
|
||||||
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
||||||
@@ -142,9 +150,10 @@ interface RoomInputProps {
|
|||||||
fileDropContainerRef: RefObject<HTMLElement>;
|
fileDropContainerRef: RefObject<HTMLElement>;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
room: Room;
|
room: Room;
|
||||||
|
threadRootId?: string;
|
||||||
}
|
}
|
||||||
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
({ editor, fileDropContainerRef, roomId, room }, ref) => {
|
({ editor, fileDropContainerRef, roomId, room, threadRootId }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
@@ -177,8 +186,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
|
const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
|
||||||
|
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
|
// Scope drafts/replies/uploads by thread so a thread composer stays fully
|
||||||
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
|
// isolated from the main room composer (and from other threads).
|
||||||
|
const draftKey = threadRootId ? getThreadDraftKey(roomId, threadRootId) : roomId;
|
||||||
|
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey));
|
||||||
|
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey));
|
||||||
const replyUserID = replyDraft?.userId;
|
const replyUserID = replyDraft?.userId;
|
||||||
|
|
||||||
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
||||||
@@ -199,7 +211,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor;
|
legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor;
|
||||||
|
|
||||||
const [uploadBoard, setUploadBoard] = useState(true);
|
const [uploadBoard, setUploadBoard] = useState(true);
|
||||||
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
|
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey));
|
||||||
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
|
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
|
||||||
roomUploadAtomFamily,
|
roomUploadAtomFamily,
|
||||||
selectedFiles.map((f) => f.file),
|
selectedFiles.map((f) => f.file),
|
||||||
@@ -218,7 +230,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
||||||
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
||||||
const showVoice = composerToolbarButtons?.showVoice ?? true;
|
const showVoice = composerToolbarButtons?.showVoice ?? true;
|
||||||
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
|
// Schedule-send is hidden in thread mode (v1 reduction).
|
||||||
|
const showSchedule = (composerToolbarButtons?.showSchedule ?? true) && !threadRootId;
|
||||||
|
const composerButtonOrder = useMemo(
|
||||||
|
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
|
||||||
|
[composerToolbarButtons?.order],
|
||||||
|
);
|
||||||
const [locating, setLocating] = React.useState(false);
|
const [locating, setLocating] = React.useState(false);
|
||||||
const [locationError, setLocationError] = React.useState<string | null>(null);
|
const [locationError, setLocationError] = React.useState<string | null>(null);
|
||||||
const handleShareLocation = useCallback(() => {
|
const handleShareLocation = useCallback(() => {
|
||||||
@@ -233,7 +250,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
setLocating(false);
|
setLocating(false);
|
||||||
const { latitude, longitude } = pos.coords;
|
const { latitude, longitude } = pos.coords;
|
||||||
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
|
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
|
||||||
mx.sendMessage(roomId, {
|
mx.sendMessage(roomId, threadRootId ?? null, {
|
||||||
msgtype: 'm.location',
|
msgtype: 'm.location',
|
||||||
body: `Location: ${geoUri}`,
|
body: `Location: ${geoUri}`,
|
||||||
geo_uri: geoUri,
|
geo_uri: geoUri,
|
||||||
@@ -252,7 +269,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
},
|
},
|
||||||
{ timeout: 10000 },
|
{ timeout: 10000 },
|
||||||
);
|
);
|
||||||
}, [mx, roomId]);
|
}, [mx, roomId, threadRootId]);
|
||||||
|
|
||||||
const handleVoiceSend = useCallback(
|
const handleVoiceSend = useCallback(
|
||||||
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
|
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
|
||||||
@@ -268,7 +285,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
if (room.hasEncryptionStateEvent()) {
|
if (room.hasEncryptionStateEvent()) {
|
||||||
const { encInfo, file: encBlob } = await encryptFile(blob);
|
const { encInfo, file: encBlob } = await encryptFile(blob);
|
||||||
const uploadResult = await mx.uploadContent(encBlob);
|
const uploadResult = await mx.uploadContent(encBlob);
|
||||||
mx.sendMessage(roomId, {
|
mx.sendMessage(roomId, threadRootId ?? null, {
|
||||||
...baseContent,
|
...baseContent,
|
||||||
file: { ...encInfo, url: uploadResult.content_uri },
|
file: { ...encInfo, url: uploadResult.content_uri },
|
||||||
} as any);
|
} as any);
|
||||||
@@ -277,13 +294,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
name: 'voice-message.ogg',
|
name: 'voice-message.ogg',
|
||||||
type: mimeType,
|
type: mimeType,
|
||||||
});
|
});
|
||||||
mx.sendMessage(roomId, {
|
mx.sendMessage(roomId, threadRootId ?? null, {
|
||||||
...baseContent,
|
...baseContent,
|
||||||
url: uploadResult.content_uri,
|
url: uploadResult.content_uri,
|
||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx, room, roomId],
|
[mx, room, roomId, threadRootId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [autocompleteQuery, setAutocompleteQuery] =
|
const [autocompleteQuery, setAutocompleteQuery] =
|
||||||
@@ -353,33 +370,37 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
} else {
|
} else {
|
||||||
// Jotai draft is empty (page reload) — try localStorage fallback
|
// Jotai draft is empty (page reload) — try localStorage fallback
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(`draft-msg-${roomId}`);
|
const stored = localStorage.getItem(`draft-msg-${draftKey}`);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const nodes = JSON.parse(stored);
|
const nodes = JSON.parse(stored);
|
||||||
if (Array.isArray(nodes) && nodes.length > 0) {
|
if (Array.isArray(nodes) && nodes.length > 0) {
|
||||||
Transforms.insertFragment(editor, nodes);
|
Transforms.insertFragment(editor, nodes);
|
||||||
|
// Mirror the restored draft into the atom so the draft indicator
|
||||||
|
// (reads roomIdToMsgDraftAtomFamily) reflects a persisted draft
|
||||||
|
// after a page reload — not only on same-session room re-entry.
|
||||||
|
setMsgDraft(nodes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore malformed stored draft
|
// Ignore malformed stored draft
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [editor, msgDraft, roomId]);
|
}, [editor, msgDraft, draftKey, setMsgDraft]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
if (!isEmptyEditor(editor)) {
|
if (!isEmptyEditor(editor)) {
|
||||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||||
setMsgDraft(parsedDraft);
|
setMsgDraft(parsedDraft);
|
||||||
localStorage.setItem(`draft-msg-${roomId}`, JSON.stringify(parsedDraft));
|
localStorage.setItem(`draft-msg-${draftKey}`, JSON.stringify(parsedDraft));
|
||||||
} else {
|
} else {
|
||||||
setMsgDraft([]);
|
setMsgDraft([]);
|
||||||
localStorage.removeItem(`draft-msg-${roomId}`);
|
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||||
}
|
}
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
},
|
},
|
||||||
[roomId, editor, setMsgDraft],
|
[draftKey, editor, setMsgDraft],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFileMetadata = useCallback(
|
const handleFileMetadata = useCallback(
|
||||||
@@ -472,15 +493,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
});
|
});
|
||||||
handleCancelUpload(uploads);
|
handleCancelUpload(uploads);
|
||||||
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
||||||
contents.forEach((content) => mx.sendMessage(roomId, content as any));
|
contents.forEach((content) => mx.sendMessage(roomId, threadRootId ?? null, content as any));
|
||||||
},
|
},
|
||||||
[mx, roomId, selectedFiles, handleCancelUpload],
|
[mx, roomId, threadRootId, selectedFiles, handleCancelUpload],
|
||||||
);
|
);
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
const submit = useCallback(() => {
|
||||||
uploadBoardHandlers.current?.handleSend();
|
uploadBoardHandlers.current?.handleSend();
|
||||||
|
|
||||||
const commandName = getBeginCommand(editor);
|
// Slash-command interpretation is disabled in thread mode (v1): "/foo"
|
||||||
|
// sends literally rather than being parsed as a command.
|
||||||
|
const commandName = threadRootId ? undefined : getBeginCommand(editor);
|
||||||
let plainText = toPlainText(editor.children, isMarkdown).trim();
|
let plainText = toPlainText(editor.children, isMarkdown).trim();
|
||||||
let customHtml = trimCustomHtml(
|
let customHtml = trimCustomHtml(
|
||||||
toMatrixCustomHTML(editor.children, {
|
toMatrixCustomHTML(editor.children, {
|
||||||
@@ -553,13 +576,24 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
content['m.relates_to'].is_falling_back = false;
|
content['m.relates_to'].is_falling_back = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mx.sendMessage(roomId, content as any);
|
mx.sendMessage(roomId, threadRootId ?? null, content as any);
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
localStorage.removeItem(`draft-msg-${roomId}`);
|
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
|
}, [
|
||||||
|
mx,
|
||||||
|
roomId,
|
||||||
|
threadRootId,
|
||||||
|
draftKey,
|
||||||
|
editor,
|
||||||
|
replyDraft,
|
||||||
|
sendTypingStatus,
|
||||||
|
setReplyDraft,
|
||||||
|
isMarkdown,
|
||||||
|
commands,
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a text message content object from the current editor state.
|
* Build a text message content object from the current editor state.
|
||||||
@@ -628,11 +662,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
});
|
});
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
localStorage.removeItem(`draft-msg-${roomId}`);
|
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
},
|
},
|
||||||
[setScheduledMessages, roomId, editor, setReplyDraft, sendTypingStatus],
|
[setScheduledMessages, roomId, draftKey, editor, setReplyDraft, sendTypingStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||||
@@ -645,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;
|
||||||
}
|
}
|
||||||
|
if (replyDraft) {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing],
|
[submit, replyDraft, setReplyDraft, enterForNewline, autocompleteQuery, isComposing],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyUp: KeyboardEventHandler = useCallback(
|
const handleKeyUp: KeyboardEventHandler = useCallback(
|
||||||
@@ -727,7 +769,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
);
|
);
|
||||||
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
|
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
|
||||||
if (!mxcUrl) return;
|
if (!mxcUrl) return;
|
||||||
mx.sendMessage(roomId, {
|
mx.sendMessage(roomId, threadRootId ?? null, {
|
||||||
msgtype: MsgType.Image,
|
msgtype: MsgType.Image,
|
||||||
body: 'image.gif',
|
body: 'image.gif',
|
||||||
url: mxcUrl,
|
url: mxcUrl,
|
||||||
@@ -742,7 +784,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
if (alive()) setGifUploading(false);
|
if (alive()) setGifUploading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx, roomId, alive],
|
[mx, roomId, threadRootId, alive],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStickerSelect = useCallback(
|
const handleStickerSelect = useCallback(
|
||||||
@@ -755,13 +797,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
await getImageUrlBlob(stickerUrl),
|
await getImageUrlBlob(stickerUrl),
|
||||||
);
|
);
|
||||||
|
|
||||||
mx.sendEvent(roomId, EventType.Sticker, {
|
mx.sendEvent(roomId, threadRootId ?? null, EventType.Sticker, {
|
||||||
body: label,
|
body: label,
|
||||||
url: mxc,
|
url: mxc,
|
||||||
info,
|
info,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[mx, roomId, useAuthentication],
|
[mx, roomId, threadRootId, useAuthentication],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (room.getType() === 'm.server_notice') {
|
if (room.getType() === 'm.server_notice') {
|
||||||
@@ -954,10 +996,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
<Icon src={Icons.PlusCircle} />
|
<Icon src={Icons.PlusCircle} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
}
|
}
|
||||||
after={
|
after={(() => {
|
||||||
<>
|
const formatButton = showFormat ? (
|
||||||
{showFormat && (
|
|
||||||
<IconButton
|
<IconButton
|
||||||
|
key="showFormat"
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
@@ -968,10 +1010,59 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
>
|
>
|
||||||
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
) : null;
|
||||||
{(showEmoji || showSticker) && (
|
|
||||||
<UseStateProvider initial={undefined}>
|
// Emoji and Sticker share a single EmojiBoard PopOut anchored to the
|
||||||
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
// emoji button, so they are rendered together as one unit. Their
|
||||||
|
// relative order still follows the saved order.
|
||||||
|
const emojiStickerBlock =
|
||||||
|
showEmoji || showSticker ? (
|
||||||
|
<UseStateProvider key="showEmojiSticker" initial={undefined}>
|
||||||
|
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => {
|
||||||
|
const stickerBtn =
|
||||||
|
showSticker && !hideStickerBtn ? (
|
||||||
|
<IconButton
|
||||||
|
key="showSticker"
|
||||||
|
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||||
|
aria-label="Insert sticker"
|
||||||
|
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
style={touchTarget}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
src={Icons.Sticker}
|
||||||
|
filled={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
) : null;
|
||||||
|
const emojiBtn = showEmoji ? (
|
||||||
|
<IconButton
|
||||||
|
key="showEmoji"
|
||||||
|
ref={emojiBtnRef}
|
||||||
|
aria-label="Insert emoji"
|
||||||
|
aria-pressed={
|
||||||
|
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||||||
|
}
|
||||||
|
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
style={touchTarget}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
src={Icons.Smile}
|
||||||
|
filled={
|
||||||
|
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
) : null;
|
||||||
|
const emojiFirst =
|
||||||
|
composerButtonOrder.indexOf('showEmoji') <
|
||||||
|
composerButtonOrder.indexOf('showSticker');
|
||||||
|
return (
|
||||||
<PopOut
|
<PopOut
|
||||||
offset={16}
|
offset={16}
|
||||||
alignOffset={-44}
|
alignOffset={-44}
|
||||||
@@ -1005,51 +1096,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{showSticker && !hideStickerBtn && (
|
{emojiFirst ? [emojiBtn, stickerBtn] : [stickerBtn, emojiBtn]}
|
||||||
<IconButton
|
|
||||||
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
|
||||||
aria-label="Insert sticker"
|
|
||||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
style={touchTarget}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
src={Icons.Sticker}
|
|
||||||
filled={emojiBoardTab === EmojiBoardTab.Sticker}
|
|
||||||
/>
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
{showEmoji && (
|
|
||||||
<IconButton
|
|
||||||
ref={emojiBtnRef}
|
|
||||||
aria-label="Insert emoji"
|
|
||||||
aria-pressed={
|
|
||||||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
|
||||||
}
|
|
||||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
style={touchTarget}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
src={Icons.Smile}
|
|
||||||
filled={
|
|
||||||
hideStickerBtn
|
|
||||||
? !!emojiBoardTab
|
|
||||||
: emojiBoardTab === EmojiBoardTab.Emoji
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</PopOut>
|
</PopOut>
|
||||||
)}
|
);
|
||||||
|
}}
|
||||||
</UseStateProvider>
|
</UseStateProvider>
|
||||||
)}
|
) : null;
|
||||||
{!!gifApiKey && showGif && (
|
|
||||||
<UseStateProvider initial={false}>
|
const gifButton =
|
||||||
|
!!gifApiKey && showGif ? (
|
||||||
|
<UseStateProvider key="showGif" initial={false}>
|
||||||
{(gifOpen: boolean, setGifOpen) => (
|
{(gifOpen: boolean, setGifOpen) => (
|
||||||
<PopOut
|
<PopOut
|
||||||
offset={16}
|
offset={16}
|
||||||
@@ -1101,7 +1157,108 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
</PopOut>
|
</PopOut>
|
||||||
)}
|
)}
|
||||||
</UseStateProvider>
|
</UseStateProvider>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const locationButton = showLocation ? (
|
||||||
|
<IconButton
|
||||||
|
key="showLocation"
|
||||||
|
onClick={handleShareLocation}
|
||||||
|
disabled={locating}
|
||||||
|
aria-label="Share location"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
title="Share location"
|
||||||
|
style={touchTarget}
|
||||||
|
>
|
||||||
|
{locating ? (
|
||||||
|
<Spinner variant="Secondary" size="100" />
|
||||||
|
) : (
|
||||||
|
<Icon src={Icons.SpaceGlobe} size="100" />
|
||||||
)}
|
)}
|
||||||
|
</IconButton>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const pollButton = showPoll ? (
|
||||||
|
<IconButton
|
||||||
|
key="showPoll"
|
||||||
|
onClick={() => setPollOpen(true)}
|
||||||
|
aria-label="Create poll"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
title="Create poll"
|
||||||
|
style={touchTarget}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.OrderList} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const voiceButton = showVoice ? (
|
||||||
|
<VoiceMessageRecorder
|
||||||
|
key="showVoice"
|
||||||
|
onSend={handleVoiceSend}
|
||||||
|
onError={(err) => {
|
||||||
|
setLocationError(err);
|
||||||
|
setTimeout(() => setLocationError(null), 4000);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const scheduleButton = showSchedule ? (
|
||||||
|
<IconButton
|
||||||
|
key="showSchedule"
|
||||||
|
onClick={handleScheduleClick}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
style={touchTarget}
|
||||||
|
aria-label="Schedule message"
|
||||||
|
title="Schedule message"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Clock} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const orderedButtons: ReactNode[] = [];
|
||||||
|
let emojiStickerRendered = false;
|
||||||
|
composerButtonOrder.forEach((key: ComposerToolbarButtonKey) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'showFormat':
|
||||||
|
if (formatButton) orderedButtons.push(formatButton);
|
||||||
|
break;
|
||||||
|
case 'showEmoji':
|
||||||
|
case 'showSticker':
|
||||||
|
// Rendered once as a combined unit at whichever of the two
|
||||||
|
// keys comes first in the order.
|
||||||
|
if (!emojiStickerRendered) {
|
||||||
|
emojiStickerRendered = true;
|
||||||
|
if (emojiStickerBlock) orderedButtons.push(emojiStickerBlock);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'showGif':
|
||||||
|
if (gifButton) orderedButtons.push(gifButton);
|
||||||
|
break;
|
||||||
|
case 'showLocation':
|
||||||
|
if (locationButton) orderedButtons.push(locationButton);
|
||||||
|
break;
|
||||||
|
case 'showPoll':
|
||||||
|
if (pollButton) orderedButtons.push(pollButton);
|
||||||
|
break;
|
||||||
|
case 'showVoice':
|
||||||
|
if (voiceButton) orderedButtons.push(voiceButton);
|
||||||
|
break;
|
||||||
|
case 'showSchedule':
|
||||||
|
if (scheduleButton) orderedButtons.push(scheduleButton);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{orderedButtons}
|
||||||
{gifError && (
|
{gifError && (
|
||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
@@ -1128,46 +1285,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
{locationError}
|
{locationError}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{showLocation && (
|
<DraftIndicator roomId={draftKey} />
|
||||||
<IconButton
|
|
||||||
onClick={handleShareLocation}
|
|
||||||
disabled={locating}
|
|
||||||
aria-label="Share location"
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
title="Share location"
|
|
||||||
style={touchTarget}
|
|
||||||
>
|
|
||||||
{locating ? (
|
|
||||||
<Spinner variant="Secondary" size="100" />
|
|
||||||
) : (
|
|
||||||
<Icon src={Icons.SpaceGlobe} size="100" />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
{showPoll && (
|
|
||||||
<IconButton
|
|
||||||
onClick={() => setPollOpen(true)}
|
|
||||||
aria-label="Create poll"
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
title="Create poll"
|
|
||||||
style={touchTarget}
|
|
||||||
>
|
|
||||||
<Icon src={Icons.OrderList} size="100" />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
{showVoice && (
|
|
||||||
<VoiceMessageRecorder
|
|
||||||
onSend={handleVoiceSend}
|
|
||||||
onError={(err) => {
|
|
||||||
setLocationError(err);
|
|
||||||
setTimeout(() => setLocationError(null), 4000);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{charCount > 0 && (
|
{charCount > 0 && (
|
||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
@@ -1183,19 +1301,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
{charCount}
|
{charCount}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{showSchedule && (
|
|
||||||
<IconButton
|
|
||||||
onClick={handleScheduleClick}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
style={touchTarget}
|
|
||||||
aria-label="Schedule message"
|
|
||||||
title="Schedule message"
|
|
||||||
>
|
|
||||||
<Icon src={Icons.Clock} size="100" />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
@@ -1207,7 +1312,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
<Icon src={Icons.Send} />
|
<Icon src={Icons.Send} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</>
|
</>
|
||||||
}
|
);
|
||||||
|
})()}
|
||||||
bottom={
|
bottom={
|
||||||
toolbar && (
|
toolbar && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ import {
|
|||||||
IContent,
|
IContent,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
|
RelationType,
|
||||||
Room,
|
Room,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
RoomEventHandlerMap,
|
RoomEventHandlerMap,
|
||||||
|
ThreadEvent,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
@@ -103,6 +105,8 @@ import * as css from './RoomTimeline.css';
|
|||||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
||||||
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
|
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
|
||||||
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||||
|
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||||
|
import { ThreadSummary } from './thread/ThreadSummary';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
@@ -245,13 +249,26 @@ const useEventTimelineLoader = (
|
|||||||
room: Room,
|
room: Room,
|
||||||
onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void,
|
onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void,
|
||||||
onError: (err: Error | null) => void,
|
onError: (err: Error | null) => void,
|
||||||
|
onThreadRedirect: (threadRootId: string) => void,
|
||||||
) => {
|
) => {
|
||||||
const loadEventTimeline = useCallback(
|
const loadEventTimeline = useCallback(
|
||||||
async (eventId: string) => {
|
async (eventId: string) => {
|
||||||
const [err, replyEvtTimeline] = await to(
|
const [err, replyEvtTimeline] = await to(
|
||||||
mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId),
|
mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId),
|
||||||
);
|
);
|
||||||
|
// Thread events aren't locatable in the main timeline set (getEventTimeline
|
||||||
|
// returns undefined / no abs index). Best-effort: redirect to the thread panel
|
||||||
|
// when the fetched event belongs to a thread instead of surfacing an error.
|
||||||
|
const redirectToThread = () => {
|
||||||
|
const threadRootId = room.findEventById(eventId)?.threadRootId;
|
||||||
|
if (threadRootId) {
|
||||||
|
onThreadRedirect(threadRootId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
if (!replyEvtTimeline) {
|
if (!replyEvtTimeline) {
|
||||||
|
if (redirectToThread()) return;
|
||||||
onError(err ?? null);
|
onError(err ?? null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -259,13 +276,14 @@ const useEventTimelineLoader = (
|
|||||||
const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);
|
const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);
|
||||||
|
|
||||||
if (absIndex === undefined) {
|
if (absIndex === undefined) {
|
||||||
|
if (redirectToThread()) return;
|
||||||
onError(err ?? null);
|
onError(err ?? null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoad(eventId, linkedTimelines, absIndex);
|
onLoad(eventId, linkedTimelines, absIndex);
|
||||||
},
|
},
|
||||||
[mx, room, onLoad, onError],
|
[mx, room, onLoad, onError, onThreadRedirect],
|
||||||
);
|
);
|
||||||
|
|
||||||
return loadEventTimeline;
|
return loadEventTimeline;
|
||||||
@@ -460,6 +478,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
||||||
|
|
||||||
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
||||||
|
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
|
||||||
|
// Thread summary chips only mount for events that already carry thread data
|
||||||
|
// (perf: a chip subscribes room-level listeners, so mounting one per rendered
|
||||||
|
// message would exceed the SDK's emitter cap). This single room-level
|
||||||
|
// ThreadEvent.New subscription re-renders the timeline once when a brand-new
|
||||||
|
// thread appears, so the root's chip shows up without unrelated activity.
|
||||||
|
const [, setThreadNewTick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const handleThreadNew = () => setThreadNewTick((c) => c + 1);
|
||||||
|
room.on(ThreadEvent.New, handleThreadNew);
|
||||||
|
return () => {
|
||||||
|
room.removeListener(ThreadEvent.New, handleThreadNew);
|
||||||
|
};
|
||||||
|
}, [room]);
|
||||||
const powerLevels = usePowerLevelsContext();
|
const powerLevels = usePowerLevelsContext();
|
||||||
const creators = useRoomCreators(room);
|
const creators = useRoomCreators(room);
|
||||||
|
|
||||||
@@ -622,6 +654,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = false;
|
scrollToBottomRef.current.smooth = false;
|
||||||
}, [alive, room]),
|
}, [alive, room]),
|
||||||
|
useCallback(
|
||||||
|
(threadRootId: string) => {
|
||||||
|
if (!alive()) return;
|
||||||
|
setActiveThreadId(threadRootId);
|
||||||
|
},
|
||||||
|
[alive, setActiveThreadId],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
useLiveEventArrive(
|
useLiveEventArrive(
|
||||||
@@ -982,14 +1021,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
console.warn('Button should have "data-event-id" attribute!');
|
console.warn('Button should have "data-event-id" attribute!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (startThread) {
|
||||||
|
// Open the thread panel instead of arming an m.thread reply in the main composer.
|
||||||
|
setActiveThreadId(replyId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const replyEvt = room.findEventById(replyId);
|
const replyEvt = room.findEventById(replyId);
|
||||||
if (!replyEvt) return;
|
if (!replyEvt) return;
|
||||||
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
|
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
|
||||||
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
||||||
const { body, formatted_body: formattedBody } = content;
|
const { body, formatted_body: formattedBody } = content;
|
||||||
const { 'm.relates_to': relation } = startThread
|
const { 'm.relates_to': relation } = replyEvt.getWireContent();
|
||||||
? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } }
|
|
||||||
: replyEvt.getWireContent();
|
|
||||||
const senderId = replyEvt.getSender();
|
const senderId = replyEvt.getSender();
|
||||||
if (senderId && typeof body === 'string') {
|
if (senderId && typeof body === 'string') {
|
||||||
setReplyDraft({
|
setReplyDraft({
|
||||||
@@ -1002,7 +1044,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
setTimeout(() => ReactEditor.focus(editor), 100);
|
setTimeout(() => ReactEditor.focus(editor), 100);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[room, setReplyDraft, editor],
|
[room, setReplyDraft, setActiveThreadId, editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleReactionToggle = useCallback(
|
const handleReactionToggle = useCallback(
|
||||||
@@ -1090,6 +1132,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
replyEventId={replyEventId}
|
replyEventId={replyEventId}
|
||||||
threadRootId={threadRootId}
|
threadRootId={threadRootId}
|
||||||
onClick={handleOpenReply}
|
onClick={handleOpenReply}
|
||||||
|
onThreadClick={setActiveThreadId}
|
||||||
getMemberPowerTag={getMemberPowerTag}
|
getMemberPowerTag={getMemberPowerTag}
|
||||||
accessibleTagColors={accessiblePowerTagColors}
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
@@ -1097,7 +1140,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
reactions={
|
reactions={
|
||||||
reactionRelations && (
|
<>
|
||||||
|
{reactionRelations && (
|
||||||
<Reactions
|
<Reactions
|
||||||
style={{ marginTop: config.space.S200 }}
|
style={{ marginTop: config.space.S200 }}
|
||||||
room={room}
|
room={room}
|
||||||
@@ -1106,7 +1150,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
canSendReaction={canSendReaction}
|
canSendReaction={canSendReaction}
|
||||||
onReactionToggle={handleReactionToggle}
|
onReactionToggle={handleReactionToggle}
|
||||||
/>
|
/>
|
||||||
)
|
)}
|
||||||
|
{(!threadRootId || threadRootId === mEventId) &&
|
||||||
|
(mEvent.getThread() !== undefined ||
|
||||||
|
mEvent.getServerAggregatedRelation(RelationType.Thread) !== undefined) && (
|
||||||
|
<ThreadSummary rootEvent={mEvent} room={room} onOpen={setActiveThreadId} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
@@ -1175,6 +1225,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
replyEventId={replyEventId}
|
replyEventId={replyEventId}
|
||||||
threadRootId={threadRootId}
|
threadRootId={threadRootId}
|
||||||
onClick={handleOpenReply}
|
onClick={handleOpenReply}
|
||||||
|
onThreadClick={setActiveThreadId}
|
||||||
getMemberPowerTag={getMemberPowerTag}
|
getMemberPowerTag={getMemberPowerTag}
|
||||||
accessibleTagColors={accessiblePowerTagColors}
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
@@ -1182,7 +1233,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
reactions={
|
reactions={
|
||||||
reactionRelations && (
|
<>
|
||||||
|
{reactionRelations && (
|
||||||
<Reactions
|
<Reactions
|
||||||
style={{ marginTop: config.space.S200 }}
|
style={{ marginTop: config.space.S200 }}
|
||||||
room={room}
|
room={room}
|
||||||
@@ -1191,7 +1243,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
canSendReaction={canSendReaction}
|
canSendReaction={canSendReaction}
|
||||||
onReactionToggle={handleReactionToggle}
|
onReactionToggle={handleReactionToggle}
|
||||||
/>
|
/>
|
||||||
)
|
)}
|
||||||
|
{(!threadRootId || threadRootId === mEventId) &&
|
||||||
|
(mEvent.getThread() !== undefined ||
|
||||||
|
mEvent.getServerAggregatedRelation(RelationType.Thread) !== undefined) && (
|
||||||
|
<ThreadSummary rootEvent={mEvent} room={room} onOpen={setActiveThreadId} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
|
|||||||
@@ -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,7 +63,12 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }} aria-live="polite" aria-atomic="false">
|
<div style={{ position: 'relative' }}>
|
||||||
|
{/* Persistently mounted so the FIRST "X is typing" is announced. */}
|
||||||
|
<span className={css.SrOnly} role="status" aria-live="polite" aria-atomic="true">
|
||||||
|
{typingAnnouncement}
|
||||||
|
</span>
|
||||||
|
{typingNames.length > 0 && (
|
||||||
<Box
|
<Box
|
||||||
className={classNames(css.RoomViewTyping, className)}
|
className={classNames(css.RoomViewTyping, className)}
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
@@ -59,7 +77,7 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<TypingIndicator />
|
<TypingIndicator />
|
||||||
<Text className={css.TypingText} size="T300" truncate>
|
<Text className={css.TypingText} size="T300" truncate aria-hidden>
|
||||||
{typingNames.length === 1 && (
|
{typingNames.length === 1 && (
|
||||||
<>
|
<>
|
||||||
<b>{typingNames[0]}</b>
|
<b>{typingNames[0]}</b>
|
||||||
@@ -127,6 +145,7 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
|||||||
<Icon size="50" src={Icons.Cross} />
|
<Icon size="50" src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</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,13 +142,13 @@ 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}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Box alignItems="Center" gap="200">
|
||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
priority="400"
|
priority="400"
|
||||||
@@ -148,7 +159,9 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
|||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)'}
|
{typeof msg.content.body === 'string'
|
||||||
|
? (msg.content.body as string)
|
||||||
|
: '(message)'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
{formatSendAt(msg.sendAt)}
|
{formatSendAt(msg.sendAt)}
|
||||||
@@ -167,6 +180,15 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
|||||||
<Icon src={Icons.Cross} size="50" />
|
<Icon src={Icons.Cross} size="50" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { ChangeEvent, useCallback, useState } from 'react';
|
import React, { ChangeEvent, useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
|
color,
|
||||||
config,
|
config,
|
||||||
Header,
|
Header,
|
||||||
Icon,
|
Icon,
|
||||||
@@ -28,6 +29,7 @@ import { mDirectAtom } from '../../../state/mDirectList';
|
|||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
||||||
|
import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||||
|
|
||||||
type RoomRowProps = {
|
type RoomRowProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -86,35 +88,83 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
const modalStyle = useModalStyle(400);
|
const modalStyle = useModalStyle(400);
|
||||||
const directs = useAtomValue(mDirectAtom);
|
const directs = useAtomValue(mDirectAtom);
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const allRooms = mx
|
const allRooms = useMemo(
|
||||||
|
() =>
|
||||||
|
mx
|
||||||
.getRooms()
|
.getRooms()
|
||||||
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
|
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
|
||||||
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0));
|
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0)),
|
||||||
|
[mx],
|
||||||
|
);
|
||||||
|
|
||||||
const filtered = query
|
const filtered = useMemo(() => {
|
||||||
? allRooms.filter((r) => r.name.toLowerCase().includes(query.toLowerCase()))
|
if (!query) return allRooms;
|
||||||
: allRooms;
|
const q = query.toLowerCase();
|
||||||
|
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||||
|
}, [allRooms, query]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the content to forward:
|
||||||
|
* - undecryptable events are refused (would forward `m.bad.encrypted` junk)
|
||||||
|
* - edited messages forward the LATEST edit (`m.new_content`), not the
|
||||||
|
* original pre-edit body
|
||||||
|
* - reply fallbacks (`> <@user> …` quote + `<mx-reply>` block) are stripped
|
||||||
|
* along with the `m.relates_to` reply/thread relation, so the forwarded
|
||||||
|
* message stands alone in the target room
|
||||||
|
*/
|
||||||
|
const buildForwardContent = useCallback((): Record<string, unknown> | undefined => {
|
||||||
|
if (mEvent.isDecryptionFailure()) return undefined;
|
||||||
|
|
||||||
|
let content = { ...mEvent.getContent() };
|
||||||
|
|
||||||
|
const eventId = mEvent.getId();
|
||||||
|
const room = mx.getRoom(mEvent.getRoomId());
|
||||||
|
if (eventId && room) {
|
||||||
|
const editedEvent = getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet());
|
||||||
|
const newContent = editedEvent?.getContent()['m.new_content'];
|
||||||
|
if (newContent && typeof newContent === 'object') {
|
||||||
|
content = { ...(newContent as Record<string, unknown>) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete content['m.relates_to'];
|
||||||
|
if (typeof content.body === 'string') {
|
||||||
|
content.body = trimReplyFromBody(content.body);
|
||||||
|
}
|
||||||
|
if (typeof content.formatted_body === 'string') {
|
||||||
|
content.formatted_body = trimReplyFromFormattedBody(content.formatted_body);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}, [mx, mEvent]);
|
||||||
|
|
||||||
const forward = useCallback(
|
const forward = useCallback(
|
||||||
async (room: Room) => {
|
async (room: Room) => {
|
||||||
if (sending) return;
|
if (sending) return;
|
||||||
|
const fwdContent = buildForwardContent();
|
||||||
|
if (!fwdContent) {
|
||||||
|
setError('This message could not be decrypted, so it cannot be forwarded.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSending(true);
|
setSending(true);
|
||||||
const fwdContent: Record<string, unknown> = { ...mEvent.getContent() };
|
setError(null);
|
||||||
delete fwdContent['m.relates_to'];
|
|
||||||
try {
|
try {
|
||||||
|
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
await (mx as any).sendEvent(room.roomId, mEvent.getType(), fwdContent);
|
await mx.sendEvent(room.roomId, null, mEvent.getType() as any, fwdContent);
|
||||||
setSentTo(room.name);
|
setSentTo(room.name);
|
||||||
setTimeout(onClose, 1400);
|
setTimeout(onClose, 1400);
|
||||||
} catch {
|
} catch {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
|
setError(`Failed to forward to ${room.name}. Try again.`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx, mEvent, onClose, sending],
|
[mx, mEvent, onClose, sending, buildForwardContent],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -122,7 +172,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: () => searchInputRef.current ?? false,
|
||||||
onDeactivate: onClose,
|
onDeactivate: onClose,
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
@@ -153,8 +203,13 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
{!sentTo && (
|
{!sentTo && (
|
||||||
<Box shrink="No" style={{ padding: `${config.space.S200} ${config.space.S400}` }}>
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
direction="Column"
|
||||||
|
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
variant="Background"
|
variant="Background"
|
||||||
size="400"
|
size="400"
|
||||||
radii="400"
|
radii="400"
|
||||||
@@ -163,6 +218,14 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
|
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{error && (
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Line size="300" />
|
<Line size="300" />
|
||||||
|
|||||||
@@ -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,8 +44,14 @@ 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) => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
await addReminder({
|
await addReminder({
|
||||||
roomId,
|
roomId,
|
||||||
eventId,
|
eventId,
|
||||||
@@ -52,6 +59,10 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
|||||||
message: previewText || 'Reminder',
|
message: previewText || 'Reminder',
|
||||||
});
|
});
|
||||||
onClose();
|
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>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const ThreadPanel = style({
|
||||||
|
width: toRem(360),
|
||||||
|
'@media': {
|
||||||
|
'(max-width: 750px)': {
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
zIndex: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThreadPanelHeader = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThreadPanelContent = style({
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThreadPanelInput = style({
|
||||||
|
padding: config.space.S200,
|
||||||
|
borderTopWidth: config.borderWidth.B300,
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
TooltipProvider,
|
||||||
|
} from 'folds';
|
||||||
|
import { Room, RoomEvent, ThreadEvent } from 'matrix-js-sdk';
|
||||||
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import * as css from './ThreadPanel.css';
|
||||||
|
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||||
|
import { ThreadTimeline } from './ThreadTimeline';
|
||||||
|
import { markThreadAsRead, useThreadInstance } from './useThread';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useEditor } from '../../../components/editor';
|
||||||
|
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
import { RoomInput } from '../RoomInput';
|
||||||
|
import {
|
||||||
|
getThreadNotificationModeIcon,
|
||||||
|
ThreadNotificationModeSwitcher,
|
||||||
|
} from '../../../components/ThreadNotificationModeSwitcher';
|
||||||
|
import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications';
|
||||||
|
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
|
||||||
|
|
||||||
|
type ThreadPanelHeaderProps = {
|
||||||
|
room: Room;
|
||||||
|
threadId: string;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
|
||||||
|
const mode = useThreadNotificationMode(room.roomId, threadId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Header className={css.ThreadPanelHeader} variant="Background" size="600">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Text size="H5" truncate>
|
||||||
|
Thread
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||||
|
{room.name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" alignItems="Center" gap="100">
|
||||||
|
<ThreadNotificationModeSwitcher roomId={room.roomId} threadId={threadId} value={mode}>
|
||||||
|
{(handleOpen, opened) => (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>Notifications</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="Background"
|
||||||
|
aria-label="Thread notifications"
|
||||||
|
aria-pressed={opened}
|
||||||
|
onClick={handleOpen}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
src={getThreadNotificationModeIcon(mode)}
|
||||||
|
filled={mode !== ThreadNotificationMode.Default}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</ThreadNotificationModeSwitcher>
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>Close</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="Background"
|
||||||
|
aria-label="Close thread"
|
||||||
|
onClick={requestClose}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThreadPanelProps = {
|
||||||
|
room: Room;
|
||||||
|
threadId: string;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const editor = useEditor();
|
||||||
|
const thread = useThreadInstance(room, threadId);
|
||||||
|
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
|
||||||
|
const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||||
|
|
||||||
|
useKeyDown(
|
||||||
|
window,
|
||||||
|
useCallback(
|
||||||
|
(evt) => {
|
||||||
|
if (isKeyHotkey('escape', evt)) {
|
||||||
|
// The composer preventDefaults Escape when it consumes it (dismissing
|
||||||
|
// autocomplete / clearing a reply draft). Don't close the panel in
|
||||||
|
// that case — only when Escape wasn't already handled.
|
||||||
|
if (evt.defaultPrevented) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
requestClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[requestClose],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark the thread read when the panel is open and on each new thread event.
|
||||||
|
// Deduped on the latest event id: RoomEvent.Timeline re-emits per event during
|
||||||
|
// backfill and for every edit/reaction, and sendReadReceipt POSTs
|
||||||
|
// unconditionally — without the guard, opening a thread with N replies would
|
||||||
|
// fire up to N receipt requests at the same event.
|
||||||
|
const lastReadEventIdRef = useRef<string | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
lastReadEventIdRef.current = undefined;
|
||||||
|
if (!thread) return undefined;
|
||||||
|
const markRead = () => {
|
||||||
|
const events = thread.liveTimeline.getEvents();
|
||||||
|
let latestId: string | undefined;
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const evt = events[i];
|
||||||
|
if (evt && !evt.isSending()) {
|
||||||
|
latestId = evt.getId() ?? undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!latestId || latestId === lastReadEventIdRef.current) return;
|
||||||
|
lastReadEventIdRef.current = latestId;
|
||||||
|
markThreadAsRead(mx, thread, privateReadReceipts).catch(() => {
|
||||||
|
// Allow a retry on the next event if the receipt POST failed.
|
||||||
|
if (lastReadEventIdRef.current === latestId) {
|
||||||
|
lastReadEventIdRef.current = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
markRead();
|
||||||
|
thread.on(ThreadEvent.NewReply, markRead);
|
||||||
|
thread.on(RoomEvent.Timeline, markRead);
|
||||||
|
return () => {
|
||||||
|
thread.off(ThreadEvent.NewReply, markRead);
|
||||||
|
thread.off(RoomEvent.Timeline, markRead);
|
||||||
|
};
|
||||||
|
}, [mx, thread, privateReadReceipts]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={classNames(css.ThreadPanel, ContainerColor({ variant: 'Background' }))}
|
||||||
|
shrink="No"
|
||||||
|
direction="Column"
|
||||||
|
>
|
||||||
|
<ThreadPanelHeader room={room} threadId={threadId} requestClose={requestClose} />
|
||||||
|
{!thread ? (
|
||||||
|
<Box grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
||||||
|
<Spinner size="400" variant="Secondary" />
|
||||||
|
<Text size="T300">Loading thread…</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box grow="Yes" className={css.ThreadPanelContent} direction="Column">
|
||||||
|
<ThreadTimeline room={room} thread={thread} editor={editor} />
|
||||||
|
</Box>
|
||||||
|
<Box className={css.ThreadPanelInput} shrink="No" direction="Column">
|
||||||
|
<RoomInput
|
||||||
|
room={room}
|
||||||
|
roomId={room.roomId}
|
||||||
|
threadRootId={threadId}
|
||||||
|
editor={editor}
|
||||||
|
fileDropContainerRef={fileDropContainerRef}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Badge, Box, Chip, Icon, Icons, Text, config } from 'folds';
|
||||||
|
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||||
|
import { useThreadSummary } from '../../../hooks/useThreadSummary';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
import { timeDayMonthYear, timeHourMinute, today } from '../../../utils/time';
|
||||||
|
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
|
||||||
|
|
||||||
|
type ThreadSummaryProps = {
|
||||||
|
rootEvent: MatrixEvent;
|
||||||
|
room: Room;
|
||||||
|
onOpen: (threadId: string) => void;
|
||||||
|
};
|
||||||
|
export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
|
||||||
|
const { summary, unread, mode } = useThreadSummary(rootEvent, room);
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
|
||||||
|
if (!summary || summary.count === 0) return null;
|
||||||
|
|
||||||
|
const { count, latestTs } = summary;
|
||||||
|
const latestStr =
|
||||||
|
latestTs !== undefined
|
||||||
|
? today(latestTs)
|
||||||
|
? timeHourMinute(latestTs, hour24Clock)
|
||||||
|
: timeDayMonthYear(latestTs)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ marginTop: config.space.S200 }}>
|
||||||
|
<Chip
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="300"
|
||||||
|
before={<Icon size="50" src={Icons.Thread} />}
|
||||||
|
after={
|
||||||
|
unread > 0 ? <Badge variant="Success" fill="Solid" radii="Pill" size="200" /> : undefined
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const threadId = rootEvent.getId();
|
||||||
|
if (threadId) onOpen(threadId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="T200">
|
||||||
|
{count === 1 ? '1 reply' : `${count} replies`}
|
||||||
|
{latestStr ? ` · ${latestStr}` : ''}
|
||||||
|
</Text>
|
||||||
|
{mode === ThreadNotificationMode.Mute && <Icon size="50" src={Icons.BellMute} />}
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config } from 'folds';
|
||||||
|
|
||||||
|
export const ThreadTimeline = style({
|
||||||
|
height: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThreadTimelineContent = style({
|
||||||
|
minHeight: '100%',
|
||||||
|
padding: `${config.space.S400} 0`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThreadTimelineFloat = style({
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: config.space.S400,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 1,
|
||||||
|
minWidth: 'max-content',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThreadCentered = style({
|
||||||
|
height: '100%',
|
||||||
|
padding: config.space.S700,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RootMessage = style({
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
marginBottom: config.space.S100,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RepliesDivider = style({
|
||||||
|
padding: `${config.space.S200} ${config.space.S400}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const NoReplies = style({
|
||||||
|
padding: config.space.S400,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PendingMessage = style({
|
||||||
|
opacity: 0.6,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PendingFailed = style({
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
@@ -0,0 +1,982 @@
|
|||||||
|
import React, {
|
||||||
|
Dispatch,
|
||||||
|
MouseEventHandler,
|
||||||
|
ReactNode,
|
||||||
|
SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
Direction,
|
||||||
|
EventStatus,
|
||||||
|
EventTimeline,
|
||||||
|
EventTimelineSet,
|
||||||
|
EventTimelineSetHandlerMap,
|
||||||
|
MatrixClient,
|
||||||
|
MatrixEvent,
|
||||||
|
RelationType,
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
Thread,
|
||||||
|
ThreadEvent,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||||
|
import { Editor } from 'slate';
|
||||||
|
import { ReactEditor } from 'slate-react';
|
||||||
|
import to from 'await-to-js';
|
||||||
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
|
import { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, config } from 'folds';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||||
|
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
|
||||||
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
|
import { scrollToBottom } from '../../../utils/dom';
|
||||||
|
import {
|
||||||
|
DefaultPlaceholder,
|
||||||
|
MessageBase,
|
||||||
|
Reply,
|
||||||
|
RedactedContent,
|
||||||
|
MSticker,
|
||||||
|
MessageUnsupportedContent,
|
||||||
|
MessageNotDecryptedContent,
|
||||||
|
ImageContent,
|
||||||
|
} from '../../../components/message';
|
||||||
|
import {
|
||||||
|
factoryRenderLinkifyWithMention,
|
||||||
|
getReactCustomHtmlParser,
|
||||||
|
LINKIFY_OPTS,
|
||||||
|
makeMentionCustomProps,
|
||||||
|
renderMatrixMention,
|
||||||
|
} from '../../../plugins/react-custom-html-parser';
|
||||||
|
import {
|
||||||
|
decryptAllTimelineEvent,
|
||||||
|
getEditedEvent,
|
||||||
|
getEventReactions,
|
||||||
|
getMemberDisplayName,
|
||||||
|
getReactionContent,
|
||||||
|
reactionOrEditEvent,
|
||||||
|
} from '../../../utils/room';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { MessageLayout, settingsAtom } from '../../../state/settings';
|
||||||
|
import { Message, Reactions, EncryptedContent } from '../message';
|
||||||
|
import { RenderMessageContent } from '../../../components/RenderMessageContent';
|
||||||
|
import { Image } from '../../../components/media';
|
||||||
|
import { ImageViewer } from '../../../components/image-viewer';
|
||||||
|
import * as css from './ThreadTimeline.css';
|
||||||
|
import {
|
||||||
|
inSameDay,
|
||||||
|
minuteDifference,
|
||||||
|
timeDayMonthYear,
|
||||||
|
today,
|
||||||
|
yesterday,
|
||||||
|
} from '../../../utils/time';
|
||||||
|
import { createMentionElement, moveCursor } from '../../../components/editor';
|
||||||
|
import { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts';
|
||||||
|
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
|
||||||
|
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import {
|
||||||
|
getIntersectionObserverEntry,
|
||||||
|
useIntersectionObserver,
|
||||||
|
} from '../../../hooks/useIntersectionObserver';
|
||||||
|
import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
|
||||||
|
import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler';
|
||||||
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
|
||||||
|
import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
|
||||||
|
import { useIsDirectRoom } from '../../../hooks/useRoom';
|
||||||
|
import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
|
||||||
|
import { useSpaceOptionally } from '../../../hooks/useSpace';
|
||||||
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
|
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||||
|
import {
|
||||||
|
useAccessiblePowerTagColors,
|
||||||
|
useGetMemberPowerTag,
|
||||||
|
} from '../../../hooks/useMemberPowerTag';
|
||||||
|
import { useTheme } from '../../../hooks/useTheme';
|
||||||
|
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
|
||||||
|
import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
|
||||||
|
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||||
|
import { EditHistoryModal } from '../message/EditHistoryModal';
|
||||||
|
import {
|
||||||
|
getLinkedTimelines,
|
||||||
|
getTimelineAndBaseIndex,
|
||||||
|
getTimelineEvent,
|
||||||
|
getTimelineRelativeIndex,
|
||||||
|
getTimelinesEventsCount,
|
||||||
|
timelineToEventsCount,
|
||||||
|
} from '../RoomTimeline';
|
||||||
|
import { getThreadDraftKey } from '../../../state/room/thread';
|
||||||
|
import { useThreadLinkedTimelines, useThreadPendingEvents } from './useThread';
|
||||||
|
|
||||||
|
// Virtual window size (how many items render around the viewport).
|
||||||
|
const PAGINATION_LIMIT = 50;
|
||||||
|
// Network page size for backward /relations pagination of the thread timeline.
|
||||||
|
const THREAD_PAGE_LIMIT = 30;
|
||||||
|
|
||||||
|
type Timeline = {
|
||||||
|
linkedTimelines: EventTimeline[];
|
||||||
|
range: ItemRange;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEmptyTimeline = (): Timeline => ({
|
||||||
|
linkedTimelines: [],
|
||||||
|
range: { start: 0, end: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const getInitialThreadTimeline = (thread: Thread, timelines?: EventTimeline[]): Timeline => {
|
||||||
|
const linkedTimelines =
|
||||||
|
timelines && timelines.length > 0 ? timelines : getLinkedTimelines(thread.liveTimeline);
|
||||||
|
const evLength = getTimelinesEventsCount(linkedTimelines);
|
||||||
|
return {
|
||||||
|
linkedTimelines,
|
||||||
|
range: {
|
||||||
|
start: Math.max(evLength - PAGINATION_LIMIT, 0),
|
||||||
|
end: evLength,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy of RoomTimeline's `useTimelinePagination` pattern (not exported from RoomTimeline
|
||||||
|
* as its ~35 hooks are hardwired to the room live timeline). Works transparently against
|
||||||
|
* the thread timeline's /relations pagination.
|
||||||
|
*/
|
||||||
|
const useThreadTimelinePagination = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
timeline: Timeline,
|
||||||
|
setTimeline: Dispatch<SetStateAction<Timeline>>,
|
||||||
|
limit: number,
|
||||||
|
) => {
|
||||||
|
const timelineRef = useRef(timeline);
|
||||||
|
timelineRef.current = timeline;
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
|
const handleTimelinePagination = useMemo(() => {
|
||||||
|
let fetching = false;
|
||||||
|
|
||||||
|
const recalibratePagination = (
|
||||||
|
linkedTimelines: EventTimeline[],
|
||||||
|
timelinesEventsCount: number[],
|
||||||
|
backwards: boolean,
|
||||||
|
) => {
|
||||||
|
const topTimeline = linkedTimelines[0];
|
||||||
|
const timelineMatch = (mt: EventTimeline) => (t: EventTimeline) => t === mt;
|
||||||
|
|
||||||
|
const newLTimelines = getLinkedTimelines(topTimeline);
|
||||||
|
const topTmIndex = newLTimelines.findIndex(timelineMatch(topTimeline));
|
||||||
|
const topAddedTm = topTmIndex === -1 ? [] : newLTimelines.slice(0, topTmIndex);
|
||||||
|
|
||||||
|
const topTmAddedEvt =
|
||||||
|
timelineToEventsCount(newLTimelines[topTmIndex]) - timelinesEventsCount[0];
|
||||||
|
const offsetRange = getTimelinesEventsCount(topAddedTm) + (backwards ? topTmAddedEvt : 0);
|
||||||
|
|
||||||
|
setTimeline((currentTimeline) => ({
|
||||||
|
linkedTimelines: newLTimelines,
|
||||||
|
range:
|
||||||
|
offsetRange > 0
|
||||||
|
? {
|
||||||
|
start: currentTimeline.range.start + offsetRange,
|
||||||
|
end: currentTimeline.range.end + offsetRange,
|
||||||
|
}
|
||||||
|
: { ...currentTimeline.range },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return async (backwards: boolean) => {
|
||||||
|
if (fetching) return;
|
||||||
|
const { linkedTimelines: lTimelines } = timelineRef.current;
|
||||||
|
const timelinesEventsCount = lTimelines.map(timelineToEventsCount);
|
||||||
|
|
||||||
|
const timelineToPaginate = backwards ? lTimelines[0] : lTimelines[lTimelines.length - 1];
|
||||||
|
if (!timelineToPaginate) return;
|
||||||
|
|
||||||
|
const paginationToken = timelineToPaginate.getPaginationToken(
|
||||||
|
backwards ? Direction.Backward : Direction.Forward,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!paginationToken &&
|
||||||
|
getTimelinesEventsCount(lTimelines) !==
|
||||||
|
getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate))
|
||||||
|
) {
|
||||||
|
recalibratePagination(lTimelines, timelinesEventsCount, backwards);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetching = true;
|
||||||
|
const [err] = await to(
|
||||||
|
mx.paginateEventTimeline(timelineToPaginate, {
|
||||||
|
backwards,
|
||||||
|
limit,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (err) {
|
||||||
|
fetching = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fetchedTimeline =
|
||||||
|
timelineToPaginate.getNeighbouringTimeline(
|
||||||
|
backwards ? Direction.Backward : Direction.Forward,
|
||||||
|
) ?? timelineToPaginate;
|
||||||
|
// Decrypt all event ahead of render cycle
|
||||||
|
const roomId = fetchedTimeline.getRoomId();
|
||||||
|
const room = roomId ? mx.getRoom(roomId) : null;
|
||||||
|
|
||||||
|
if (room?.hasEncryptionStateEvent()) {
|
||||||
|
await to(decryptAllTimelineEvent(mx, fetchedTimeline));
|
||||||
|
}
|
||||||
|
|
||||||
|
fetching = false;
|
||||||
|
if (alive()) {
|
||||||
|
recalibratePagination(lTimelines, timelinesEventsCount, backwards);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [mx, alive, setTimeline, limit]);
|
||||||
|
return handleTimelinePagination;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThreadTimelineProps = {
|
||||||
|
room: Room;
|
||||||
|
thread: Thread;
|
||||||
|
editor: Editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const alive = useAlive();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
|
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
|
||||||
|
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||||||
|
const [perMessageProfiles] = useSetting(settingsAtom, 'perMessageProfiles');
|
||||||
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||||
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
|
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
|
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||||
|
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||||
|
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
|
const direct = useIsDirectRoom();
|
||||||
|
const ignoredUsersList = useIgnoredUsers();
|
||||||
|
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
||||||
|
|
||||||
|
const setReplyDraft = useSetAtom(
|
||||||
|
roomIdToReplyDraftAtomFamily(getThreadDraftKey(room.roomId, thread.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const powerLevels = usePowerLevelsContext();
|
||||||
|
const creators = useRoomCreators(room);
|
||||||
|
const creatorsTag = useRoomCreatorsTag();
|
||||||
|
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
||||||
|
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
|
const accessiblePowerTagColors = useAccessiblePowerTagColors(
|
||||||
|
theme.kind,
|
||||||
|
creatorsTag,
|
||||||
|
powerLevelTags,
|
||||||
|
);
|
||||||
|
|
||||||
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
|
const canRedact = permissions.action('redact', mx.getSafeUserId());
|
||||||
|
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
|
||||||
|
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
|
||||||
|
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
|
||||||
|
|
||||||
|
const mentionClickHandler = useMentionClickHandler(room.roomId);
|
||||||
|
const spoilerClickHandler = useSpoilerClickHandler();
|
||||||
|
const openUserRoomProfile = useOpenUserRoomProfile();
|
||||||
|
const space = useSpaceOptionally();
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
||||||
|
|
||||||
|
const [editId, setEditId] = useState<string>();
|
||||||
|
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
|
||||||
|
|
||||||
|
const linkifyOpts = useMemo<LinkifyOpts>(
|
||||||
|
() => ({
|
||||||
|
...LINKIFY_OPTS,
|
||||||
|
render: factoryRenderLinkifyWithMention((href) =>
|
||||||
|
renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[mx, room, mentionClickHandler],
|
||||||
|
);
|
||||||
|
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
|
||||||
|
() =>
|
||||||
|
getReactCustomHtmlParser(mx, room.roomId, {
|
||||||
|
linkifyOpts,
|
||||||
|
useAuthentication,
|
||||||
|
handleSpoilerClick: spoilerClickHandler,
|
||||||
|
handleMentionClick: mentionClickHandler,
|
||||||
|
}),
|
||||||
|
[mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { timelines, ready } = useThreadLinkedTimelines(mx, thread);
|
||||||
|
const pendingEvents = useThreadPendingEvents(room, thread.id, thread);
|
||||||
|
|
||||||
|
const [timeline, setTimeline] = useState<Timeline>(() =>
|
||||||
|
ready ? getInitialThreadTimeline(thread, timelines) : getEmptyTimeline(),
|
||||||
|
);
|
||||||
|
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
|
||||||
|
|
||||||
|
const canPaginateBack =
|
||||||
|
typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string';
|
||||||
|
const rangeAtStart = timeline.range.start === 0;
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const atBottomAnchorRef = useRef<HTMLElement>(null);
|
||||||
|
const [atBottom, setAtBottom] = useState(true);
|
||||||
|
const atBottomRef = useRef(atBottom);
|
||||||
|
atBottomRef.current = atBottom;
|
||||||
|
const scrollToBottomRef = useRef({ count: 0, smooth: true });
|
||||||
|
|
||||||
|
const handleTimelinePagination = useThreadTimelinePagination(
|
||||||
|
mx,
|
||||||
|
timeline,
|
||||||
|
setTimeline,
|
||||||
|
THREAD_PAGE_LIMIT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const getScrollElement = useCallback(() => scrollRef.current, []);
|
||||||
|
|
||||||
|
const { getItems, scrollToItem, observeBackAnchor } = useVirtualPaginator({
|
||||||
|
count: eventsLength,
|
||||||
|
limit: PAGINATION_LIMIT,
|
||||||
|
range: timeline.range,
|
||||||
|
onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
|
||||||
|
getScrollElement,
|
||||||
|
getItemElement: useCallback(
|
||||||
|
(index: number) =>
|
||||||
|
(scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
|
||||||
|
undefined,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
onEnd: handleTimelinePagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed local timeline once the thread has fetched its initial events.
|
||||||
|
const seededRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready || seededRef.current) return;
|
||||||
|
seededRef.current = true;
|
||||||
|
setTimeline(getInitialThreadTimeline(thread, timelines));
|
||||||
|
scrollToBottomRef.current.count += 1;
|
||||||
|
scrollToBottomRef.current.smooth = false;
|
||||||
|
if (room.hasEncryptionStateEvent()) {
|
||||||
|
to(decryptAllTimelineEvent(mx, thread.liveTimeline)).then(() => {
|
||||||
|
if (alive()) setTimeline((ct) => ({ ...ct }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- seed once when ready flips
|
||||||
|
}, [ready, thread]);
|
||||||
|
|
||||||
|
// Re-render / stick-to-bottom on live thread activity.
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
|
||||||
|
mEvent,
|
||||||
|
eventRoom,
|
||||||
|
toStartOfTimeline,
|
||||||
|
removed,
|
||||||
|
data,
|
||||||
|
) => {
|
||||||
|
if (!data?.liveEvent) return;
|
||||||
|
if (atBottomRef.current) {
|
||||||
|
scrollToBottomRef.current.count += 1;
|
||||||
|
scrollToBottomRef.current.smooth = true;
|
||||||
|
setTimeline((ct) => ({
|
||||||
|
...ct,
|
||||||
|
range: {
|
||||||
|
start: ct.range.start + 1,
|
||||||
|
end: ct.range.end + 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeline((ct) => ({ ...ct }));
|
||||||
|
};
|
||||||
|
const handleUpdate = () => setTimeline((ct) => ({ ...ct }));
|
||||||
|
// A gappy sync / updateThreadMetadata resets the thread's live timeline —
|
||||||
|
// the stored linkedTimelines would then point at a detached timeline, so
|
||||||
|
// reseed the window from the fresh liveTimeline.
|
||||||
|
const handleReset = () => {
|
||||||
|
setTimeline(getInitialThreadTimeline(thread, getLinkedTimelines(thread.liveTimeline)));
|
||||||
|
scrollToBottomRef.current.count += 1;
|
||||||
|
scrollToBottomRef.current.smooth = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
thread.on(RoomEvent.Timeline, handleTimeline);
|
||||||
|
thread.on(ThreadEvent.Update, handleUpdate);
|
||||||
|
thread.on(RoomEvent.TimelineReset, handleReset);
|
||||||
|
return () => {
|
||||||
|
thread.removeListener(RoomEvent.Timeline, handleTimeline);
|
||||||
|
thread.removeListener(ThreadEvent.Update, handleUpdate);
|
||||||
|
thread.removeListener(RoomEvent.TimelineReset, handleReset);
|
||||||
|
};
|
||||||
|
}, [thread]);
|
||||||
|
|
||||||
|
// atBottom detection
|
||||||
|
useIntersectionObserver(
|
||||||
|
useCallback((entries) => {
|
||||||
|
const target = atBottomAnchorRef.current;
|
||||||
|
if (!target) return;
|
||||||
|
const entry = getIntersectionObserverEntry(target, entries);
|
||||||
|
if (entry) setAtBottom(entry.isIntersecting);
|
||||||
|
}, []),
|
||||||
|
useCallback(
|
||||||
|
() => ({
|
||||||
|
root: getScrollElement(),
|
||||||
|
rootMargin: '100px',
|
||||||
|
}),
|
||||||
|
[getScrollElement],
|
||||||
|
),
|
||||||
|
useCallback(() => atBottomAnchorRef.current, []),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial scroll to bottom on mount.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (scrollEl) scrollToBottom(scrollEl);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Scroll to bottom when requested.
|
||||||
|
const scrollToBottomCount = scrollToBottomRef.current.count;
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (scrollToBottomCount > 0) {
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (scrollEl)
|
||||||
|
scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
|
||||||
|
}
|
||||||
|
}, [scrollToBottomCount]);
|
||||||
|
|
||||||
|
const handleJumpToBottom = useCallback(() => {
|
||||||
|
scrollToBottomRef.current.count += 1;
|
||||||
|
scrollToBottomRef.current.smooth = true;
|
||||||
|
// Flip atBottom so the layout effect re-runs (count re-read) and live
|
||||||
|
// events resume sticking to the bottom.
|
||||||
|
setAtBottom(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Scroll in-place editor into view.
|
||||||
|
useEffect(() => {
|
||||||
|
if (editId) {
|
||||||
|
const editMsgElement =
|
||||||
|
(scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
|
||||||
|
undefined;
|
||||||
|
editMsgElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [editId]);
|
||||||
|
|
||||||
|
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
const userId = evt.currentTarget.getAttribute('data-user-id');
|
||||||
|
if (!userId) return;
|
||||||
|
openUserRoomProfile(
|
||||||
|
room.roomId,
|
||||||
|
space?.roomId,
|
||||||
|
userId,
|
||||||
|
evt.currentTarget.getBoundingClientRect(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[room, space, openUserRoomProfile],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const userId = evt.currentTarget.getAttribute('data-user-id');
|
||||||
|
if (!userId) return;
|
||||||
|
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||||
|
editor.insertNode(
|
||||||
|
createMentionElement(
|
||||||
|
userId,
|
||||||
|
name.startsWith('@') ? name : `@${name}`,
|
||||||
|
userId === mx.getUserId(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
moveCursor(editor);
|
||||||
|
},
|
||||||
|
[mx, room, editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
const replyId = evt.currentTarget.getAttribute('data-event-id');
|
||||||
|
if (!replyId) return;
|
||||||
|
const replyEvt = thread.findEventById(replyId) ?? room.findEventById(replyId);
|
||||||
|
if (!replyEvt) return;
|
||||||
|
const editedReply = getEditedEvent(replyId, replyEvt, thread.getUnfilteredTimelineSet());
|
||||||
|
const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
||||||
|
const { body, formatted_body: formattedBody } = content;
|
||||||
|
const senderId = replyEvt.getSender();
|
||||||
|
if (senderId && typeof body === 'string') {
|
||||||
|
setReplyDraft({
|
||||||
|
userId: senderId,
|
||||||
|
eventId: replyId,
|
||||||
|
body,
|
||||||
|
formattedBody,
|
||||||
|
relation: { rel_type: RelationType.Thread, event_id: thread.id },
|
||||||
|
});
|
||||||
|
setTimeout(() => ReactEditor.focus(editor), 100);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[room, thread, setReplyDraft, editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReactionToggle = useCallback(
|
||||||
|
(targetEventId: string, key: string, shortcode?: string) => {
|
||||||
|
const timelineSet = thread.getUnfilteredTimelineSet();
|
||||||
|
const relations = getEventReactions(timelineSet, targetEventId);
|
||||||
|
const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
|
||||||
|
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
|
||||||
|
const reactions = reactionsSet ? Array.from(reactionsSet) : [];
|
||||||
|
const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!));
|
||||||
|
|
||||||
|
if (myReaction && !!myReaction.isRelation()) {
|
||||||
|
mx.redactEvent(room.roomId, myReaction.getId()!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rShortcode =
|
||||||
|
shortcode ||
|
||||||
|
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
|
||||||
|
mx.sendEvent(
|
||||||
|
room.roomId,
|
||||||
|
thread.id,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
MessageEvent.Reaction as any,
|
||||||
|
getReactionContent(targetEventId, key, rShortcode),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[mx, room, thread],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEdit = useCallback(
|
||||||
|
(editEvtId?: string) => {
|
||||||
|
if (editEvtId) {
|
||||||
|
setEditId(editEvtId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditId(undefined);
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenReply: MouseEventHandler = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
const targetId = evt.currentTarget.getAttribute('data-event-id');
|
||||||
|
if (!targetId) return;
|
||||||
|
// best-effort: scroll to referenced event if it is inside the loaded thread window
|
||||||
|
let absIndex = -1;
|
||||||
|
let acc = 0;
|
||||||
|
timeline.linkedTimelines.some((tl) => {
|
||||||
|
const idx = tl.getEvents().findIndex((e) => e.getId() === targetId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
absIndex = acc + idx;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
acc += tl.getEvents().length;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (absIndex >= 0) {
|
||||||
|
scrollToItem(absIndex, {
|
||||||
|
behavior: 'smooth',
|
||||||
|
align: 'center',
|
||||||
|
stopInView: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[timeline.linkedTimelines, scrollToItem],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderMessageContent = useCallback(
|
||||||
|
(mEvent: MatrixEvent, mEventId: string, timelineSet: EventTimelineSet): ReactNode => {
|
||||||
|
// Evaluated lazily so EncryptedContent can re-run it (re-reading getType())
|
||||||
|
// after MatrixEventEvent.Decrypted fires — decryption re-emits NEITHER
|
||||||
|
// RoomEvent.Timeline nor ThreadEvent.Update, so without this wrapper a
|
||||||
|
// live-arriving encrypted reply would show "Unable to decrypt" forever.
|
||||||
|
const renderByType = (): ReactNode => {
|
||||||
|
if (mEvent.isRedacted()) {
|
||||||
|
return <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />;
|
||||||
|
}
|
||||||
|
const type = mEvent.getType();
|
||||||
|
if (type === MessageEvent.Sticker) {
|
||||||
|
return (
|
||||||
|
<MSticker
|
||||||
|
content={mEvent.getContent()}
|
||||||
|
renderImageContent={(props) => (
|
||||||
|
<ImageContent
|
||||||
|
{...props}
|
||||||
|
autoPlay={mediaAutoLoad}
|
||||||
|
renderImage={(p) => <Image {...p} loading="lazy" />}
|
||||||
|
renderViewer={(p) => <ImageViewer {...p} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (type === MessageEvent.RoomMessageEncrypted) {
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
<MessageNotDecryptedContent />
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (type !== MessageEvent.RoomMessage) {
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
<MessageUnsupportedContent />
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
|
||||||
|
const getContent = (() =>
|
||||||
|
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
|
||||||
|
const senderId = mEvent.getSender() ?? '';
|
||||||
|
const senderDisplayName =
|
||||||
|
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
||||||
|
return (
|
||||||
|
<RenderMessageContent
|
||||||
|
displayName={senderDisplayName}
|
||||||
|
msgType={mEvent.getContent().msgtype ?? ''}
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
edited={!!editedEvent}
|
||||||
|
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
|
||||||
|
getContent={getContent}
|
||||||
|
mediaAutoLoad={mediaAutoLoad}
|
||||||
|
urlPreview={showUrlPreview}
|
||||||
|
htmlReactParserOptions={htmlReactParserOptions}
|
||||||
|
linkifyOpts={linkifyOpts}
|
||||||
|
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||||
|
eventId={mEventId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) {
|
||||||
|
return <EncryptedContent mEvent={mEvent}>{renderByType}</EncryptedContent>;
|
||||||
|
}
|
||||||
|
return renderByType();
|
||||||
|
},
|
||||||
|
[room, mediaAutoLoad, showUrlPreview, htmlReactParserOptions, linkifyOpts, messageLayout],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderMessage = useCallback(
|
||||||
|
(
|
||||||
|
mEvent: MatrixEvent,
|
||||||
|
opts: { item?: number; collapse: boolean; highlight: boolean; editable: boolean },
|
||||||
|
): ReactNode => {
|
||||||
|
const mEventId = mEvent.getId();
|
||||||
|
if (!mEventId) return null;
|
||||||
|
const timelineSet = thread.getUnfilteredTimelineSet();
|
||||||
|
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
||||||
|
const reactions = reactionRelations?.getSortedAnnotationsByKey();
|
||||||
|
const hasReactions = !!reactions && reactions.length > 0;
|
||||||
|
const { replyEventId, threadRootId } = mEvent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Message
|
||||||
|
key={mEventId}
|
||||||
|
data-message-item={opts.item}
|
||||||
|
data-message-id={mEventId}
|
||||||
|
room={room}
|
||||||
|
mEvent={mEvent}
|
||||||
|
messageSpacing={messageSpacing}
|
||||||
|
messageLayout={messageLayout}
|
||||||
|
collapse={opts.collapse}
|
||||||
|
highlight={opts.highlight}
|
||||||
|
edit={opts.editable && editId === mEventId}
|
||||||
|
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||||
|
canSendReaction={canSendReaction}
|
||||||
|
canPinEvent={canPinEvent}
|
||||||
|
imagePackRooms={imagePackRooms}
|
||||||
|
relations={hasReactions ? reactionRelations : undefined}
|
||||||
|
onUserClick={handleUserClick}
|
||||||
|
onUsernameClick={handleUsernameClick}
|
||||||
|
onReplyClick={handleReplyClick}
|
||||||
|
onReactionToggle={handleReactionToggle}
|
||||||
|
onEditId={opts.editable ? handleEdit : undefined}
|
||||||
|
reply={
|
||||||
|
replyEventId && (
|
||||||
|
<Reply
|
||||||
|
room={room}
|
||||||
|
timelineSet={timelineSet}
|
||||||
|
replyEventId={replyEventId}
|
||||||
|
threadRootId={threadRootId}
|
||||||
|
onClick={handleOpenReply}
|
||||||
|
getMemberPowerTag={getMemberPowerTag}
|
||||||
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
reactions={
|
||||||
|
reactionRelations && (
|
||||||
|
<Reactions
|
||||||
|
style={{ marginTop: config.space.S200 }}
|
||||||
|
room={room}
|
||||||
|
relations={reactionRelations}
|
||||||
|
mEventId={mEventId}
|
||||||
|
canSendReaction={canSendReaction}
|
||||||
|
onReactionToggle={handleReactionToggle}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
|
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
|
||||||
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
lotusTerminal={!!lotusTerminal}
|
||||||
|
>
|
||||||
|
{renderMessageContent(mEvent, mEventId, timelineSet)}
|
||||||
|
</Message>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
thread,
|
||||||
|
room,
|
||||||
|
messageSpacing,
|
||||||
|
messageLayout,
|
||||||
|
editId,
|
||||||
|
canRedact,
|
||||||
|
canDeleteOwn,
|
||||||
|
canSendReaction,
|
||||||
|
canPinEvent,
|
||||||
|
imagePackRooms,
|
||||||
|
handleUserClick,
|
||||||
|
handleUsernameClick,
|
||||||
|
handleReplyClick,
|
||||||
|
handleReactionToggle,
|
||||||
|
handleEdit,
|
||||||
|
handleOpenReply,
|
||||||
|
getMemberPowerTag,
|
||||||
|
accessiblePowerTagColors,
|
||||||
|
legacyUsernameColor,
|
||||||
|
direct,
|
||||||
|
hideActivity,
|
||||||
|
showDeveloperTools,
|
||||||
|
hour24Clock,
|
||||||
|
dateFormatString,
|
||||||
|
lotusTerminal,
|
||||||
|
mx,
|
||||||
|
renderMessageContent,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let prevEvent: MatrixEvent | undefined;
|
||||||
|
let isPrevRendered = false;
|
||||||
|
let dayDivider = false;
|
||||||
|
const eventRenderer = (item: number) => {
|
||||||
|
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
|
||||||
|
if (!eventTimeline) return null;
|
||||||
|
const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
|
||||||
|
const mEventId = mEvent?.getId();
|
||||||
|
if (!mEvent || !mEventId) return null;
|
||||||
|
|
||||||
|
// Skip annotations, edits, and any state/membership events (they can't be threaded).
|
||||||
|
if (reactionOrEditEvent(mEvent) || typeof mEvent.getStateKey() === 'string') {
|
||||||
|
prevEvent = mEvent;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const eventSender = mEvent.getSender();
|
||||||
|
if (eventSender && ignoredUsersSet.has(eventSender)) {
|
||||||
|
prevEvent = mEvent;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRoot = mEventId === thread.id;
|
||||||
|
|
||||||
|
if (!dayDivider) {
|
||||||
|
dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapsed =
|
||||||
|
!isRoot &&
|
||||||
|
!perMessageProfiles &&
|
||||||
|
isPrevRendered &&
|
||||||
|
!dayDivider &&
|
||||||
|
prevEvent !== undefined &&
|
||||||
|
prevEvent.getId() !== thread.id &&
|
||||||
|
prevEvent.getSender() === eventSender &&
|
||||||
|
prevEvent.getType() === mEvent.getType() &&
|
||||||
|
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 5;
|
||||||
|
|
||||||
|
const eventJSX = renderMessage(mEvent, {
|
||||||
|
item,
|
||||||
|
collapse: collapsed,
|
||||||
|
highlight: false,
|
||||||
|
editable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dayDividerJSX =
|
||||||
|
dayDivider && eventJSX && !isRoot ? (
|
||||||
|
<MessageBase space={messageSpacing}>
|
||||||
|
<Box gap="100" justifyContent="Center" alignItems="Center">
|
||||||
|
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
||||||
|
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
|
||||||
|
<Text size="L400">
|
||||||
|
{(() => {
|
||||||
|
if (today(mEvent.getTs())) return 'Today';
|
||||||
|
if (yesterday(mEvent.getTs())) return 'Yesterday';
|
||||||
|
return timeDayMonthYear(mEvent.getTs());
|
||||||
|
})()}
|
||||||
|
</Text>
|
||||||
|
</Badge>
|
||||||
|
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
||||||
|
</Box>
|
||||||
|
</MessageBase>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
prevEvent = mEvent;
|
||||||
|
isPrevRendered = !!eventJSX;
|
||||||
|
if (dayDividerJSX) dayDivider = false;
|
||||||
|
|
||||||
|
// Root gets an emphasized container + a "N replies" divider under it.
|
||||||
|
if (isRoot && eventJSX) {
|
||||||
|
const replyCount = thread.length;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={mEventId}>
|
||||||
|
<div className={css.RootMessage}>{eventJSX}</div>
|
||||||
|
{replyCount > 0 && (
|
||||||
|
<Box
|
||||||
|
className={css.RepliesDivider}
|
||||||
|
gap="100"
|
||||||
|
justifyContent="Center"
|
||||||
|
alignItems="Center"
|
||||||
|
>
|
||||||
|
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
||||||
|
<Text size="L400" priority="300">
|
||||||
|
{replyCount === 1 ? '1 reply' : `${replyCount} replies`}
|
||||||
|
</Text>
|
||||||
|
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventJSX && dayDividerJSX) {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={mEventId}>
|
||||||
|
{dayDividerJSX}
|
||||||
|
{eventJSX}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventJSX;
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = getItems();
|
||||||
|
const showEmptyReplies = ready && thread.length === 0;
|
||||||
|
|
||||||
|
const renderPendingEvent = (mEvent: MatrixEvent) => {
|
||||||
|
const failed =
|
||||||
|
mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={mEvent.getId() ?? mEvent.getTxnId()}
|
||||||
|
className={classNames(failed ? css.PendingFailed : css.PendingMessage)}
|
||||||
|
>
|
||||||
|
{renderMessage(mEvent, { collapse: false, highlight: false, editable: false })}
|
||||||
|
{failed && (
|
||||||
|
<Box style={{ padding: `0 ${config.space.S400}` }}>
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
Failed to send
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={css.ThreadCentered}
|
||||||
|
grow="Yes"
|
||||||
|
direction="Column"
|
||||||
|
justifyContent="Center"
|
||||||
|
alignItems="Center"
|
||||||
|
>
|
||||||
|
<Spinner variant="Secondary" size="600" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={css.ThreadTimeline} grow="Yes">
|
||||||
|
<Scroll ref={scrollRef} visibility="Hover">
|
||||||
|
<Box
|
||||||
|
className={css.ThreadTimelineContent}
|
||||||
|
direction="Column"
|
||||||
|
justifyContent="End"
|
||||||
|
role="log"
|
||||||
|
aria-label="Thread timeline"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{(canPaginateBack || !rangeAtStart) && (
|
||||||
|
<>
|
||||||
|
<MessageBase>
|
||||||
|
<DefaultPlaceholder />
|
||||||
|
</MessageBase>
|
||||||
|
<MessageBase ref={observeBackAnchor}>
|
||||||
|
<DefaultPlaceholder />
|
||||||
|
</MessageBase>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.map(eventRenderer)}
|
||||||
|
|
||||||
|
{showEmptyReplies && (
|
||||||
|
<Box className={css.NoReplies} justifyContent="Center">
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
No replies yet — say something
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pendingEvents.map(renderPendingEvent)}
|
||||||
|
|
||||||
|
<span ref={atBottomAnchorRef} />
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
{!atBottom && (
|
||||||
|
<Box className={css.ThreadTimelineFloat} justifyContent="Center" alignItems="Center">
|
||||||
|
<Chip
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="Pill"
|
||||||
|
outlined
|
||||||
|
before={<Icon size="50" src={Icons.ArrowBottom} />}
|
||||||
|
onClick={handleJumpToBottom}
|
||||||
|
>
|
||||||
|
<Text size="L400">Jump to Latest</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{editHistoryEvent && (
|
||||||
|
<EditHistoryModal
|
||||||
|
room={room}
|
||||||
|
mEvent={editHistoryEvent}
|
||||||
|
onClose={() => setEditHistoryEvent(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './ThreadPanel';
|
||||||
|
export * from './ThreadSummary';
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventStatus, MatrixEvent, RelationType } from 'matrix-js-sdk';
|
||||||
|
import { getThreadSummary, isPendingThreadReply } from './threadSummaryData';
|
||||||
|
|
||||||
|
// getThreadSummary reads either the live Thread (preferred) or the
|
||||||
|
// server-aggregated `m.thread` bundle. We stub only the members it touches and
|
||||||
|
// cast through `unknown` to MatrixEvent, mirroring the light mocking used in
|
||||||
|
// the state tests.
|
||||||
|
|
||||||
|
type ThreadStub = { length: number; lastReplyTs?: number };
|
||||||
|
type BundleStub = { count: number; latestTs?: number };
|
||||||
|
|
||||||
|
const makeRootEvent = (opts: { thread?: ThreadStub; bundle?: BundleStub }): MatrixEvent => {
|
||||||
|
const thread = opts.thread
|
||||||
|
? {
|
||||||
|
length: opts.thread.length,
|
||||||
|
lastReply: () =>
|
||||||
|
opts.thread?.lastReplyTs === undefined
|
||||||
|
? null
|
||||||
|
: ({ getTs: () => opts.thread?.lastReplyTs } as unknown as MatrixEvent),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
getThread: () => thread,
|
||||||
|
getServerAggregatedRelation: (relType: string) => {
|
||||||
|
if (relType !== RelationType.Thread || !opts.bundle) return undefined;
|
||||||
|
return {
|
||||||
|
count: opts.bundle.count,
|
||||||
|
latest_event:
|
||||||
|
opts.bundle.latestTs === undefined
|
||||||
|
? undefined
|
||||||
|
: { origin_server_ts: opts.bundle.latestTs },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as unknown as MatrixEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getThreadSummary
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('prefers the live thread: count from length, latestTs from lastReply', () => {
|
||||||
|
const rootEvent = makeRootEvent({
|
||||||
|
thread: { length: 3, lastReplyTs: 1700 },
|
||||||
|
bundle: { count: 99, latestTs: 1 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(getThreadSummary(rootEvent), { count: 3, latestTs: 1700 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('live thread with no replies yields undefined latestTs', () => {
|
||||||
|
const rootEvent = makeRootEvent({ thread: { length: 0 } });
|
||||||
|
assert.deepEqual(getThreadSummary(rootEvent), { count: 0, latestTs: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the server bundle when no live thread', () => {
|
||||||
|
const rootEvent = makeRootEvent({ bundle: { count: 5, latestTs: 1234 } });
|
||||||
|
assert.deepEqual(getThreadSummary(rootEvent), { count: 5, latestTs: 1234 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bundle without latest_event yields undefined latestTs', () => {
|
||||||
|
const rootEvent = makeRootEvent({ bundle: { count: 2 } });
|
||||||
|
assert.deepEqual(getThreadSummary(rootEvent), { count: 2, latestTs: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined when there is neither a thread nor a bundle', () => {
|
||||||
|
const rootEvent = makeRootEvent({});
|
||||||
|
assert.equal(getThreadSummary(rootEvent), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// isPendingThreadReply
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ROOT = '$root:server';
|
||||||
|
|
||||||
|
const makeReply = (opts: {
|
||||||
|
status: EventStatus | null;
|
||||||
|
threadRootId?: string;
|
||||||
|
relation?: { rel_type?: string; event_id?: string } | null;
|
||||||
|
}): MatrixEvent =>
|
||||||
|
({
|
||||||
|
status: opts.status,
|
||||||
|
threadRootId: opts.threadRootId,
|
||||||
|
getRelation: () => opts.relation ?? null,
|
||||||
|
}) as unknown as MatrixEvent;
|
||||||
|
|
||||||
|
test('SENDING with matching threadRootId is pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.SENDING, threadRootId: ROOT });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NOT_SENT with matching threadRootId is pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.NOT_SENT, threadRootId: ROOT });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENDING resolved via the m.thread relation content is pending', () => {
|
||||||
|
const event = makeReply({
|
||||||
|
status: EventStatus.SENDING,
|
||||||
|
relation: { rel_type: RelationType.Thread, event_id: ROOT },
|
||||||
|
});
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENT (confirmed) event is not pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.SENT, threadRootId: ROOT });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null status is not pending', () => {
|
||||||
|
const event = makeReply({ status: null, threadRootId: ROOT });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENDING but for a different thread is not pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.SENDING, threadRootId: '$other:server' });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENDING with a non-thread relation is not pending', () => {
|
||||||
|
const event = makeReply({
|
||||||
|
status: EventStatus.SENDING,
|
||||||
|
relation: { rel_type: RelationType.Reference, event_id: ROOT },
|
||||||
|
});
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENDING with no relation and no threadRootId is not pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.SENDING });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { EventStatus, IThreadBundledRelationship, MatrixEvent, RelationType } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
export type ThreadSummaryData = {
|
||||||
|
count: number;
|
||||||
|
latestTs: number | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary data for a thread root's "N replies" chip.
|
||||||
|
*
|
||||||
|
* Prefers the live {@link Thread} object when it exists (it reflects local
|
||||||
|
* echo + pagination), otherwise falls back to the server-aggregated bundle
|
||||||
|
* (`unsigned['m.relations']['m.thread']`) so the chip renders before any
|
||||||
|
* Thread object has been created. Returns `undefined` when the root has no
|
||||||
|
* thread at all.
|
||||||
|
*/
|
||||||
|
export const getThreadSummary = (rootEvent: MatrixEvent): ThreadSummaryData | undefined => {
|
||||||
|
const thread = rootEvent.getThread();
|
||||||
|
if (thread) {
|
||||||
|
const lastReply = thread.lastReply();
|
||||||
|
return {
|
||||||
|
count: thread.length,
|
||||||
|
latestTs: lastReply?.getTs(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundle = rootEvent.getServerAggregatedRelation<IThreadBundledRelationship>(
|
||||||
|
RelationType.Thread,
|
||||||
|
);
|
||||||
|
if (bundle) {
|
||||||
|
return {
|
||||||
|
count: bundle.count,
|
||||||
|
latestTs: bundle.latest_event?.origin_server_ts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `event` is a still-in-flight (local echo) reply belonging to the
|
||||||
|
* given thread root. Used to render the pending strip, since pending thread
|
||||||
|
* sends never enter the thread's timelineSet.
|
||||||
|
*/
|
||||||
|
export const isPendingThreadReply = (event: MatrixEvent, threadRootId: string): boolean => {
|
||||||
|
const { status } = event;
|
||||||
|
if (status !== EventStatus.SENDING && status !== EventStatus.NOT_SENT) return false;
|
||||||
|
|
||||||
|
// Prefer the SDK's resolved thread root id; fall back to the raw relation
|
||||||
|
// content for events the SDK hasn't associated with a thread yet.
|
||||||
|
if (event.threadRootId === threadRootId) return true;
|
||||||
|
|
||||||
|
const relation = event.getRelation();
|
||||||
|
return relation?.rel_type === RelationType.Thread && relation.event_id === threadRootId;
|
||||||
|
};
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
EventStatus,
|
||||||
|
EventTimeline,
|
||||||
|
MatrixClient,
|
||||||
|
MatrixEvent,
|
||||||
|
ReceiptType,
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
RoomEventHandlerMap,
|
||||||
|
Thread,
|
||||||
|
ThreadEvent,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import { getLinkedTimelines } from '../RoomTimeline';
|
||||||
|
import { isPendingThreadReply } from './threadSummaryData';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve (or bootstrap) the live {@link Thread} for a root event.
|
||||||
|
*
|
||||||
|
* Uses the existing thread when present, otherwise creates one via
|
||||||
|
* `room.createThread` — the SDK then auto-fetches the thread's events via
|
||||||
|
* `/relations` and inserts the root at the top. If the root event isn't loaded
|
||||||
|
* locally the Thread handles the root fetch itself, so passing `undefined` is
|
||||||
|
* safe. Re-resolves when a matching thread later appears/updates on the room.
|
||||||
|
*/
|
||||||
|
export const useThreadInstance = (room: Room, threadRootId: string): Thread | undefined => {
|
||||||
|
const getInstance = useCallback((): Thread | undefined => {
|
||||||
|
const existing = room.getThread(threadRootId);
|
||||||
|
if (existing) return existing;
|
||||||
|
const rootEvent = room.findEventById(threadRootId);
|
||||||
|
return room.createThread(threadRootId, rootEvent, [], false) ?? undefined;
|
||||||
|
}, [room, threadRootId]);
|
||||||
|
|
||||||
|
const [thread, setThread] = useState<Thread | undefined>(getInstance);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setThread(getInstance());
|
||||||
|
|
||||||
|
const handleThread: RoomEventHandlerMap[ThreadEvent.New] = (newThread) => {
|
||||||
|
if (newThread.id === threadRootId) setThread(newThread);
|
||||||
|
};
|
||||||
|
const handleThreadUpdate: RoomEventHandlerMap[ThreadEvent.Update] = (updatedThread) => {
|
||||||
|
if (updatedThread.id === threadRootId) setThread(updatedThread);
|
||||||
|
};
|
||||||
|
|
||||||
|
room.on(ThreadEvent.New, handleThread);
|
||||||
|
room.on(ThreadEvent.Update, handleThreadUpdate);
|
||||||
|
return () => {
|
||||||
|
room.removeListener(ThreadEvent.New, handleThread);
|
||||||
|
room.removeListener(ThreadEvent.Update, handleThreadUpdate);
|
||||||
|
};
|
||||||
|
}, [room, threadRootId, getInstance]);
|
||||||
|
|
||||||
|
return thread;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the ordered list of linked {@link EventTimeline}s for a thread's live
|
||||||
|
* timeline and track readiness (`thread.initialEventsFetched`). Subscribes to
|
||||||
|
* the Thread's re-emitted timeline events so callers repaginate/re-render as
|
||||||
|
* the thread fills in.
|
||||||
|
*/
|
||||||
|
export const useThreadLinkedTimelines = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
thread: Thread,
|
||||||
|
): { timelines: EventTimeline[]; ready: boolean; refresh: () => void } => {
|
||||||
|
const [timelines, setTimelines] = useState<EventTimeline[]>(() =>
|
||||||
|
getLinkedTimelines(thread.liveTimeline),
|
||||||
|
);
|
||||||
|
const [ready, setReady] = useState<boolean>(() => thread.initialEventsFetched);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setTimelines(getLinkedTimelines(thread.liveTimeline));
|
||||||
|
setReady(thread.initialEventsFetched);
|
||||||
|
}, [thread]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
const handleTimeline = () => refresh();
|
||||||
|
// Thread re-emits RoomEvent.Timeline / RoomEvent.TimelineReset from its
|
||||||
|
// timelineSet, and fires ThreadEvent.Update as it (re)populates.
|
||||||
|
thread.on(RoomEvent.Timeline, handleTimeline);
|
||||||
|
thread.on(RoomEvent.TimelineReset, handleTimeline);
|
||||||
|
thread.on(ThreadEvent.Update, handleTimeline);
|
||||||
|
return () => {
|
||||||
|
thread.removeListener(RoomEvent.Timeline, handleTimeline);
|
||||||
|
thread.removeListener(RoomEvent.TimelineReset, handleTimeline);
|
||||||
|
thread.removeListener(ThreadEvent.Update, handleTimeline);
|
||||||
|
};
|
||||||
|
}, [thread, refresh]);
|
||||||
|
|
||||||
|
return { timelines, ready, refresh };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track in-flight (local echo) replies for a thread.
|
||||||
|
*
|
||||||
|
* Pending thread sends never enter the thread's timelineSet (chronological
|
||||||
|
* pending ordering rejects them; `room.getPendingEvents()` THROWS in this
|
||||||
|
* mode). We instead watch `RoomEvent.LocalEchoUpdated` on the room and keep our
|
||||||
|
* own list of events that are pending replies to this thread and not yet in the
|
||||||
|
* thread timeline. When an event's remote echo arrives (status flips to SENT,
|
||||||
|
* or it lands in the thread) it drops out of the list.
|
||||||
|
*/
|
||||||
|
export const useThreadPendingEvents = (
|
||||||
|
room: Room,
|
||||||
|
threadRootId: string,
|
||||||
|
thread: Thread | undefined,
|
||||||
|
): MatrixEvent[] => {
|
||||||
|
const [pending, setPending] = useState<MatrixEvent[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPending([]);
|
||||||
|
|
||||||
|
const handleLocalEcho: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (event) => {
|
||||||
|
const eventId = event.getId();
|
||||||
|
setPending((prev) => {
|
||||||
|
// Drop any previous entry for this event (same instance across the
|
||||||
|
// temp-id -> real-id transition, or matched by id).
|
||||||
|
const without = prev.filter((e) => e !== event && e.getId() !== eventId);
|
||||||
|
|
||||||
|
const alreadyInThread =
|
||||||
|
eventId !== undefined && thread?.findEventById(eventId) !== undefined;
|
||||||
|
// Keep a tracked event through the SENT window too: the /send response
|
||||||
|
// flips status to SENT before /sync delivers the event into the thread
|
||||||
|
// timeline — dropping it there would make the message flash out of view.
|
||||||
|
// It falls out on the next LocalEchoUpdated once findEventById sees it.
|
||||||
|
const trackedAndAwaitingSync =
|
||||||
|
event.status === EventStatus.SENT &&
|
||||||
|
prev.some((e) => e === event || (eventId !== undefined && e.getId() === eventId));
|
||||||
|
const stillPending =
|
||||||
|
!alreadyInThread && (isPendingThreadReply(event, threadRootId) || trackedAndAwaitingSync);
|
||||||
|
|
||||||
|
if (stillPending) return [...without, event];
|
||||||
|
return without.length === prev.length ? prev : without;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
room.on(RoomEvent.LocalEchoUpdated, handleLocalEcho);
|
||||||
|
return () => {
|
||||||
|
room.removeListener(RoomEvent.LocalEchoUpdated, handleLocalEcho);
|
||||||
|
};
|
||||||
|
}, [room, threadRootId, thread]);
|
||||||
|
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a threaded read receipt up to the latest confirmed event in the thread.
|
||||||
|
*
|
||||||
|
* The receipt is threaded by default (scoped to this thread), which clears the
|
||||||
|
* per-thread unread count. Mirrors the latest-valid-event scan in
|
||||||
|
* `utils/notifications.ts`.
|
||||||
|
*/
|
||||||
|
export const markThreadAsRead = async (
|
||||||
|
mx: MatrixClient,
|
||||||
|
thread: Thread,
|
||||||
|
privateReceipt: boolean,
|
||||||
|
): Promise<void> => {
|
||||||
|
const events = thread.liveTimeline.getEvents();
|
||||||
|
|
||||||
|
let latestEvent: MatrixEvent | undefined;
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const evt = events[i];
|
||||||
|
if (evt && !evt.isSending()) {
|
||||||
|
latestEvent = evt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!latestEvent) return;
|
||||||
|
|
||||||
|
await mx.sendReadReceipt(
|
||||||
|
latestEvent,
|
||||||
|
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read,
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '../../../components/AccountDataEditor';
|
} from '../../../components/AccountDataEditor';
|
||||||
import { copyToClipboard } from '../../../utils/dom';
|
import { copyToClipboard } from '../../../utils/dom';
|
||||||
import { AccountData } from './AccountData';
|
import { AccountData } from './AccountData';
|
||||||
|
import { CryptoDiagnostics } from '../developer/CryptoDiagnostics';
|
||||||
|
|
||||||
type DeveloperToolsProps = {
|
type DeveloperToolsProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
@@ -109,6 +110,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
|||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
)}
|
)}
|
||||||
|
{developerTools && <CryptoDiagnostics />}
|
||||||
</Box>
|
</Box>
|
||||||
{developerTools && (
|
{developerTools && (
|
||||||
<AccountData
|
<AccountData
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { Badge, Box, Button, Text } from 'folds';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useForceUpdate } from '../../../hooks/useForceUpdate';
|
||||||
|
import { useInterval } from '../../../hooks/useInterval';
|
||||||
|
import { buildCryptoDiagReport, getCryptoDiagEntries } from '../../../utils/cryptoDiagLog';
|
||||||
|
|
||||||
|
// Lotus E2EE investigation kit — Crypto Diagnostics settings card.
|
||||||
|
// Mirrors the surrounding Developer Tools cards (see DevelopTools.tsx).
|
||||||
|
|
||||||
|
const REFRESH_MS = 1000;
|
||||||
|
|
||||||
|
export function CryptoDiagnostics() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
// Re-render on a light interval so the live matched-entry count stays fresh
|
||||||
|
// while the settings pane is open.
|
||||||
|
const [, forceUpdate] = useForceUpdate();
|
||||||
|
useInterval(forceUpdate, REFRESH_MS);
|
||||||
|
|
||||||
|
const count = getCryptoDiagEntries().length;
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
const report = buildCryptoDiagReport(mx);
|
||||||
|
const blob = new Blob([report], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `lotus-crypto-diag-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Crypto Diagnostics</Text>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Crypto Diagnostics — captures E2EE error signatures this session"
|
||||||
|
description="Ring-buffers up to 200 matched console warnings/errors for the KE-1..KE-4 bug cluster. Local only — no network calls. The downloaded report includes the matched log lines as evidence."
|
||||||
|
after={
|
||||||
|
<Box alignItems="Center" gap="200" shrink="No">
|
||||||
|
<Badge variant={count > 0 ? 'Critical' : 'Secondary'} fill="Solid" radii="Pill">
|
||||||
|
<Text as="span" size="L400">
|
||||||
|
{count}
|
||||||
|
</Text>
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
onClick={handleDownload}
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
>
|
||||||
|
<Text size="B300">Download report</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import React, {
|
|||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
@@ -34,6 +35,19 @@ import {
|
|||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { HexColorPicker } from 'react-colorful';
|
import { HexColorPicker } from 'react-colorful';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
draggable,
|
||||||
|
dropTargetForElements,
|
||||||
|
monitorForElements,
|
||||||
|
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||||
|
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
|
||||||
|
import { reorder } from '@atlaskit/pragmatic-drag-and-drop/reorder';
|
||||||
|
import {
|
||||||
|
attachClosestEdge,
|
||||||
|
extractClosestEdge,
|
||||||
|
Edge,
|
||||||
|
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
|
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
|
||||||
import { BgSwatch as BgSwatchStyle } from './BgSwatch.css';
|
import { BgSwatch as BgSwatchStyle } from './BgSwatch.css';
|
||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
@@ -47,12 +61,14 @@ import { useSetting } from '../../../state/hooks/settings';
|
|||||||
import {
|
import {
|
||||||
CallAudioBitrate,
|
CallAudioBitrate,
|
||||||
ChatBackground,
|
ChatBackground,
|
||||||
|
ComposerToolbarButtonKey,
|
||||||
ComposerToolbarSettings,
|
ComposerToolbarSettings,
|
||||||
DateFormat,
|
DateFormat,
|
||||||
DenoiseModelId,
|
DenoiseModelId,
|
||||||
MessageLayout,
|
MessageLayout,
|
||||||
MessageSpacing,
|
MessageSpacing,
|
||||||
NoiseSuppressionMode,
|
NoiseSuppressionMode,
|
||||||
|
normalizeComposerToolbarOrder,
|
||||||
RingtoneId,
|
RingtoneId,
|
||||||
ScreenshareBitrate,
|
ScreenshareBitrate,
|
||||||
ScreenshareFramerate,
|
ScreenshareFramerate,
|
||||||
@@ -86,12 +102,33 @@ import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
|||||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||||
|
import { isTauri as isTauriEnv } from '../../../hooks/useTauri';
|
||||||
|
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
|
||||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||||
import { playCallJoinSound } from '../../../utils/callSounds';
|
import { playCallJoinSound } from '../../../utils/callSounds';
|
||||||
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
||||||
import { DenoiseTester } from './DenoiseTester';
|
import { DenoiseTester } from './DenoiseTester';
|
||||||
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-47 — opt-in TDS window chrome toggle (desktop only). Renders nothing in the
|
||||||
|
* browser. Backed by the standalone `customWindowChromeAtom`; `useTauriWindowChrome`
|
||||||
|
* (mounted in App.tsx) applies `set_decorations` when this flips.
|
||||||
|
*/
|
||||||
|
function DesktopChromeSetting() {
|
||||||
|
const [customChrome, setCustomChrome] = useAtom(customWindowChromeAtom);
|
||||||
|
if (!isTauriEnv()) return null;
|
||||||
|
return (
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Custom Window Chrome (Beta)"
|
||||||
|
description="Replace the system title bar with a Lotus-styled one. Desktop only — toggles instantly."
|
||||||
|
after={<Switch variant="Primary" value={customChrome} onChange={setCustomChrome} />}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type ThemeSelectorProps = {
|
type ThemeSelectorProps = {
|
||||||
themeNames: Record<string, string>;
|
themeNames: Record<string, string>;
|
||||||
themes: Theme[];
|
themes: Theme[];
|
||||||
@@ -405,6 +442,8 @@ function Appearance() {
|
|||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
|
||||||
|
<DesktopChromeSetting />
|
||||||
|
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Twitter Emoji"
|
title="Twitter Emoji"
|
||||||
@@ -492,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}
|
||||||
@@ -1025,6 +1065,165 @@ function DateAndTime() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COMPOSER_TOOLBAR_LABELS: Record<ComposerToolbarButtonKey, string> = {
|
||||||
|
showFormat: 'Format',
|
||||||
|
showEmoji: 'Emoji',
|
||||||
|
showSticker: 'Sticker',
|
||||||
|
showGif: 'GIF',
|
||||||
|
showLocation: 'Location',
|
||||||
|
showPoll: 'Poll',
|
||||||
|
showVoice: 'Voice',
|
||||||
|
showSchedule: 'Schedule',
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMPOSER_TOOLBAR_DRAG_TYPE = 'composer-toolbar-button';
|
||||||
|
|
||||||
|
type ComposerToolbarButtonRowProps = {
|
||||||
|
buttonKey: ComposerToolbarButtonKey;
|
||||||
|
index: number;
|
||||||
|
active: boolean;
|
||||||
|
onToggle: (key: ComposerToolbarButtonKey) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ComposerToolbarButtonRow({
|
||||||
|
buttonKey,
|
||||||
|
index,
|
||||||
|
active,
|
||||||
|
onToggle,
|
||||||
|
}: ComposerToolbarButtonRowProps) {
|
||||||
|
const rowRef = useRef<HTMLDivElement>(null);
|
||||||
|
const handleRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = rowRef.current;
|
||||||
|
const dragHandle = handleRef.current;
|
||||||
|
if (!element || !dragHandle) return undefined;
|
||||||
|
|
||||||
|
return combine(
|
||||||
|
draggable({
|
||||||
|
element,
|
||||||
|
dragHandle,
|
||||||
|
getInitialData: () => ({ type: COMPOSER_TOOLBAR_DRAG_TYPE, buttonKey, index }),
|
||||||
|
onDragStart: () => setDragging(true),
|
||||||
|
onDrop: () => setDragging(false),
|
||||||
|
}),
|
||||||
|
dropTargetForElements({
|
||||||
|
element,
|
||||||
|
canDrop: ({ source }) => source.data.type === COMPOSER_TOOLBAR_DRAG_TYPE,
|
||||||
|
getData: ({ input }) =>
|
||||||
|
attachClosestEdge(
|
||||||
|
{ type: COMPOSER_TOOLBAR_DRAG_TYPE, buttonKey, index },
|
||||||
|
{ element, input, allowedEdges: ['top', 'bottom'] },
|
||||||
|
),
|
||||||
|
getIsSticky: () => true,
|
||||||
|
onDrag: ({ self, source }) => {
|
||||||
|
if (source.data.buttonKey === buttonKey) {
|
||||||
|
setClosestEdge(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setClosestEdge(extractClosestEdge(self.data));
|
||||||
|
},
|
||||||
|
onDragLeave: () => setClosestEdge(null),
|
||||||
|
onDrop: () => setClosestEdge(null),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [buttonKey, index]);
|
||||||
|
|
||||||
|
let boxShadow: string | undefined;
|
||||||
|
if (closestEdge === 'top') boxShadow = `inset 0 2px 0 0 ${color.Primary.Main}`;
|
||||||
|
else if (closestEdge === 'bottom') boxShadow = `inset 0 -2px 0 0 ${color.Primary.Main}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={rowRef}
|
||||||
|
alignItems="Center"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
padding: `${config.space.S200} ${config.space.S400}`,
|
||||||
|
opacity: dragging ? 0.5 : undefined,
|
||||||
|
boxShadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
ref={handleRef}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
style={{ cursor: 'grab' }}
|
||||||
|
aria-label={`Reorder ${COMPOSER_TOOLBAR_LABELS[buttonKey]}`}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.VerticalDots} />
|
||||||
|
</IconButton>
|
||||||
|
<Text style={{ flexGrow: 1 }} size="T300">
|
||||||
|
{COMPOSER_TOOLBAR_LABELS[buttonKey]}
|
||||||
|
</Text>
|
||||||
|
<Chip
|
||||||
|
variant={active ? 'Primary' : 'Secondary'}
|
||||||
|
outlined={active}
|
||||||
|
radii="Pill"
|
||||||
|
onClick={() => onToggle(buttonKey)}
|
||||||
|
aria-pressed={active}
|
||||||
|
>
|
||||||
|
<Text size="T200">{active ? 'Shown' : 'Hidden'}</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComposerToolbarReorderProps = {
|
||||||
|
order: ComposerToolbarButtonKey[];
|
||||||
|
buttons: ComposerToolbarSettings;
|
||||||
|
onReorder: (startIndex: number, finishIndex: number) => void;
|
||||||
|
onToggle: (key: ComposerToolbarButtonKey) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ComposerToolbarReorder({
|
||||||
|
order,
|
||||||
|
buttons,
|
||||||
|
onReorder,
|
||||||
|
onToggle,
|
||||||
|
}: ComposerToolbarReorderProps) {
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
monitorForElements({
|
||||||
|
canMonitor: ({ source }) => source.data.type === COMPOSER_TOOLBAR_DRAG_TYPE,
|
||||||
|
onDrop: ({ location, source }) => {
|
||||||
|
const target = location.current.dropTargets[0];
|
||||||
|
if (!target) return;
|
||||||
|
const startIndex = source.data.index;
|
||||||
|
const indexOfTarget = target.data.index;
|
||||||
|
if (typeof startIndex !== 'number' || typeof indexOfTarget !== 'number') return;
|
||||||
|
const closestEdgeOfTarget = extractClosestEdge(target.data);
|
||||||
|
|
||||||
|
// Insert relative to the target row, then compensate for the source
|
||||||
|
// row being removed from its original position.
|
||||||
|
let finishIndex = closestEdgeOfTarget === 'bottom' ? indexOfTarget + 1 : indexOfTarget;
|
||||||
|
if (startIndex < finishIndex) finishIndex -= 1;
|
||||||
|
|
||||||
|
if (finishIndex === startIndex) return;
|
||||||
|
onReorder(startIndex, finishIndex);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[onReorder],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column">
|
||||||
|
{order.map((key, index) => (
|
||||||
|
<ComposerToolbarButtonRow
|
||||||
|
key={key}
|
||||||
|
buttonKey={key}
|
||||||
|
index={index}
|
||||||
|
active={buttons[key]}
|
||||||
|
onToggle={onToggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Editor() {
|
function Editor() {
|
||||||
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||||
@@ -1034,20 +1233,31 @@ function Editor() {
|
|||||||
'composerToolbarButtons',
|
'composerToolbarButtons',
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleToolbarButton = (key: keyof ComposerToolbarSettings) => {
|
const composerToolbarOrder = useMemo(
|
||||||
setComposerToolbarButtons({ ...composerToolbarButtons, [key]: !composerToolbarButtons[key] });
|
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
|
||||||
};
|
[composerToolbarButtons?.order],
|
||||||
|
);
|
||||||
|
|
||||||
const TOOLBAR_CHIPS: Array<{ key: keyof ComposerToolbarSettings; label: string }> = [
|
const toggleToolbarButton = useCallback(
|
||||||
{ key: 'showFormat', label: 'Format' },
|
(key: ComposerToolbarButtonKey) => {
|
||||||
{ key: 'showEmoji', label: 'Emoji' },
|
setComposerToolbarButtons((current) => ({ ...current, [key]: !current[key] }));
|
||||||
{ key: 'showSticker', label: 'Sticker' },
|
},
|
||||||
{ key: 'showGif', label: 'GIF' },
|
[setComposerToolbarButtons],
|
||||||
{ key: 'showLocation', label: 'Location' },
|
);
|
||||||
{ key: 'showPoll', label: 'Poll' },
|
|
||||||
{ key: 'showVoice', label: 'Voice' },
|
const reorderToolbarButtons = useCallback(
|
||||||
{ key: 'showSchedule', label: 'Schedule' },
|
(startIndex: number, finishIndex: number) => {
|
||||||
];
|
setComposerToolbarButtons((current) => ({
|
||||||
|
...current,
|
||||||
|
order: reorder({
|
||||||
|
list: normalizeComposerToolbarOrder(current.order),
|
||||||
|
startIndex,
|
||||||
|
finishIndex,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[setComposerToolbarButtons],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
@@ -1082,28 +1292,15 @@ function Editor() {
|
|||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Composer Toolbar"
|
title="Composer Toolbar"
|
||||||
description="Tap a button to show or hide it in the message composer."
|
description="Drag to reorder buttons, and tap a button to show or hide it in the message composer."
|
||||||
|
/>
|
||||||
|
<Box direction="Column" style={{ paddingBottom: config.space.S200 }}>
|
||||||
|
<ComposerToolbarReorder
|
||||||
|
order={composerToolbarOrder}
|
||||||
|
buttons={composerToolbarButtons}
|
||||||
|
onReorder={reorderToolbarButtons}
|
||||||
|
onToggle={toggleToolbarButton}
|
||||||
/>
|
/>
|
||||||
<Box
|
|
||||||
wrap="Wrap"
|
|
||||||
gap="200"
|
|
||||||
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
|
||||||
>
|
|
||||||
{TOOLBAR_CHIPS.map(({ key, label }) => {
|
|
||||||
const active = composerToolbarButtons?.[key] ?? true;
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
key={key}
|
|
||||||
variant={active ? 'Primary' : 'Secondary'}
|
|
||||||
outlined={active}
|
|
||||||
radii="Pill"
|
|
||||||
onClick={() => toggleToolbarButton(key)}
|
|
||||||
aria-pressed={active}
|
|
||||||
>
|
|
||||||
<Text size="T300">{label}</Text>
|
|
||||||
</Chip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
</Box>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -1467,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';
|
||||||
@@ -13,6 +13,7 @@ import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
|||||||
import { SpaceSettingsPage } from '../../state/spaceSettings';
|
import { SpaceSettingsPage } from '../../state/spaceSettings';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
import { EmojisStickers } from '../common-settings/emojis-stickers';
|
import { EmojisStickers } from '../common-settings/emojis-stickers';
|
||||||
|
import { Soundboard } from '../common-settings/soundboard';
|
||||||
import { Members } from '../common-settings/members';
|
import { Members } from '../common-settings/members';
|
||||||
import { DeveloperTools } from '../common-settings/developer-tools';
|
import { DeveloperTools } from '../common-settings/developer-tools';
|
||||||
import { General } from './general';
|
import { General } from './general';
|
||||||
@@ -48,6 +49,11 @@ const BASE_SPACE_MENU_ITEMS: SpaceSettingsMenuItem[] = [
|
|||||||
name: 'Emojis & Stickers',
|
name: 'Emojis & Stickers',
|
||||||
icon: Icons.Smile,
|
icon: Icons.Smile,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
page: SpaceSettingsPage.SoundboardPage,
|
||||||
|
name: 'Soundboard',
|
||||||
|
icon: Icons.Bell,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
page: SpaceSettingsPage.DeveloperToolsPage,
|
page: SpaceSettingsPage.DeveloperToolsPage,
|
||||||
name: 'Developer Tools',
|
name: 'Developer Tools',
|
||||||
@@ -190,6 +196,9 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
|
|||||||
{activePage === SpaceSettingsPage.EmojisStickersPage && (
|
{activePage === SpaceSettingsPage.EmojisStickersPage && (
|
||||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
<EmojisStickers requestClose={handlePageRequestClose} />
|
||||||
)}
|
)}
|
||||||
|
{activePage === SpaceSettingsPage.SoundboardPage && (
|
||||||
|
<Soundboard requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
{activePage === SpaceSettingsPage.DeveloperToolsPage && (
|
{activePage === SpaceSettingsPage.DeveloperToolsPage && (
|
||||||
<DeveloperTools requestClose={handlePageRequestClose} />
|
<DeveloperTools requestClose={handlePageRequestClose} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useCallback, DragEventHandler, RefObject, useState, useEffect, useRef } from 'react';
|
import { useCallback, DragEventHandler, RefObject, useState, useEffect, useRef } from 'react';
|
||||||
import { getDataTransferFiles } from '../utils/dom';
|
import { collectDroppedFiles } from '../utils/fileEntries';
|
||||||
|
|
||||||
export const useFileDropHandler = (onDrop: (file: File[]) => void): DragEventHandler =>
|
export const useFileDropHandler = (onDrop: (file: File[]) => void): DragEventHandler =>
|
||||||
useCallback(
|
useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
const files = getDataTransferFiles(evt.dataTransfer);
|
// `collectDroppedFiles` synchronously captures the entry list from the
|
||||||
|
// DataTransfer before traversing folders asynchronously.
|
||||||
|
collectDroppedFiles(evt.dataTransfer)
|
||||||
|
.then((files) => {
|
||||||
if (files) onDrop(files);
|
if (files) onDrop(files);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
},
|
},
|
||||||
[onDrop],
|
[onDrop],
|
||||||
);
|
);
|
||||||
@@ -24,8 +29,14 @@ export const useFileDropZone = (
|
|||||||
dragCounterRef.current = 0;
|
dragCounterRef.current = 0;
|
||||||
setActive(false);
|
setActive(false);
|
||||||
if (!evt.dataTransfer) return;
|
if (!evt.dataTransfer) return;
|
||||||
const files = getDataTransferFiles(evt.dataTransfer);
|
// Capture entries synchronously (inside the event) then traverse any
|
||||||
|
// dropped folders asynchronously — the DataTransferItemList is emptied
|
||||||
|
// once this handler returns.
|
||||||
|
collectDroppedFiles(evt.dataTransfer)
|
||||||
|
.then((files) => {
|
||||||
if (files) onDrop(files);
|
if (files) onDrop(files);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
target?.addEventListener('drop', handleDrop);
|
target?.addEventListener('drop', handleDrop);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
ClientEvent,
|
||||||
|
MatrixClient,
|
||||||
|
Room,
|
||||||
|
RoomEmittedEvents,
|
||||||
|
RoomEventHandlerMap,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach `handler` for `event` on every joined/known room, including rooms
|
||||||
|
* created after mount (via `ClientEvent.Room`). All listeners are detached on
|
||||||
|
* unmount or when `mx`/`event` change.
|
||||||
|
*
|
||||||
|
* The handler is stored in a ref (mirroring `useTauriEvent`) so callers don't
|
||||||
|
* need to memoize it — changing the handler identity never re-attaches the
|
||||||
|
* per-room listeners.
|
||||||
|
*
|
||||||
|
* The emitting {@link Room} is PREPENDED as the first argument, before the
|
||||||
|
* event's own args: several room-level SDK events (e.g.
|
||||||
|
* `RoomEvent.UnreadNotifications`) don't include the room in their payload,
|
||||||
|
* which callers need for per-room updates. Prepending (not appending) is
|
||||||
|
* load-bearing — some SDK events emit with VARIABLE arity
|
||||||
|
* (UnreadNotifications fires with 0, 1, or 2 args), so a trailing extra arg
|
||||||
|
* would land in a different positional slot per emit.
|
||||||
|
*/
|
||||||
|
export function useRoomsListener<E extends RoomEmittedEvents>(
|
||||||
|
mx: MatrixClient,
|
||||||
|
event: E,
|
||||||
|
handler: (room: Room, ...args: Parameters<RoomEventHandlerMap[E]>) => void,
|
||||||
|
): void {
|
||||||
|
const handlerRef = useRef(handler);
|
||||||
|
handlerRef.current = handler;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Track attached rooms (and their per-room trampolines) so re-emitted
|
||||||
|
// `ClientEvent.Room` (e.g. on membership changes) never double-subscribes,
|
||||||
|
// and cleanup can detach exactly what was attached.
|
||||||
|
const attached = new Map<string, (...args: unknown[]) => void>();
|
||||||
|
|
||||||
|
const attach = (room: Room) => {
|
||||||
|
if (attached.has(room.roomId)) return;
|
||||||
|
// Per-room trampoline: forwards to the current ref value with the
|
||||||
|
// emitting room PREPENDED (stable slot regardless of emit arity).
|
||||||
|
const roomHandler = (...args: unknown[]) =>
|
||||||
|
(handlerRef.current as (...a: unknown[]) => void)(room, ...args);
|
||||||
|
attached.set(room.roomId, roomHandler);
|
||||||
|
// `event`/`roomHandler` are correlated through E but TS can't prove it
|
||||||
|
// for the open generic, so we assert at the boundary.
|
||||||
|
room.on(event, roomHandler as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.getRooms().forEach(attach);
|
||||||
|
|
||||||
|
const handleRoom = (room: Room) => attach(room);
|
||||||
|
mx.on(ClientEvent.Room, handleRoom);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(ClientEvent.Room, handleRoom);
|
||||||
|
attached.forEach((roomHandler, roomId) => {
|
||||||
|
mx.getRoom(roomId)?.removeListener(event, roomHandler as any);
|
||||||
|
});
|
||||||
|
attached.clear();
|
||||||
|
};
|
||||||
|
}, [mx, event]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { getFallbackSession, subscribeSessionChanges } from '../state/sessions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep this tab in sync with session changes performed in other tabs/windows.
|
||||||
|
*
|
||||||
|
* The coordinator mounts this once inside the authenticated client shell.
|
||||||
|
* `storage` events fire only in tabs that did NOT perform the write, so the
|
||||||
|
* callback here always represents an out-of-tab change.
|
||||||
|
*
|
||||||
|
* Default action is the safest one for auth-critical state — a full reload:
|
||||||
|
* - session REMOVED elsewhere (logout / localStorage.clear()) → the access
|
||||||
|
* token disappears, so we reload; the router bounces to auth on next boot.
|
||||||
|
* - session APPEARED or its access token CHANGED elsewhere (a fresh login or
|
||||||
|
* a token rotation) → we reload so the client re-initialises with the new
|
||||||
|
* credentials rather than running on a stale/revoked token.
|
||||||
|
*
|
||||||
|
* A change that does not alter the access token (e.g. an OIDC metadata-only
|
||||||
|
* rewrite) is ignored, which also collapses the several storage events emitted
|
||||||
|
* by a single dual-write into at most one reload.
|
||||||
|
*/
|
||||||
|
export const useSessionSync = (): void => {
|
||||||
|
useEffect(() => {
|
||||||
|
// Snapshot the credential this tab booted with; compare against it so we
|
||||||
|
// only reload on a genuine credential change.
|
||||||
|
const initialAccessToken = getFallbackSession()?.accessToken ?? null;
|
||||||
|
|
||||||
|
const unsubscribe = subscribeSessionChanges((session) => {
|
||||||
|
const nextAccessToken = session?.accessToken ?? null;
|
||||||
|
if (nextAccessToken === initialAccessToken) return;
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
|
||||||
import { useAccountDataCallback } from './useAccountDataCallback';
|
|
||||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
|
||||||
import {
|
|
||||||
SoundboardClip,
|
|
||||||
SoundboardContent,
|
|
||||||
SOUNDBOARD_MAX_CLIP_BYTES,
|
|
||||||
SOUNDBOARD_MAX_CLIPS,
|
|
||||||
SOUNDBOARD_NAME_MAX,
|
|
||||||
readSoundboardClips,
|
|
||||||
} from '../utils/soundboardClips';
|
|
||||||
|
|
||||||
const KEY = AccountDataEvent.LotusSoundboard;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [P5-15] Read/write the user's personal soundboard, stored in the
|
|
||||||
* `io.lotus.soundboard` account data event (synced across devices like custom
|
|
||||||
* emoji/sticker packs). Uploading writes the audio to the media repo and
|
|
||||||
* appends an mxc reference.
|
|
||||||
*/
|
|
||||||
export function useSoundboard(): {
|
|
||||||
clips: SoundboardClip[];
|
|
||||||
addClip: (file: File, name?: string) => Promise<void>;
|
|
||||||
removeClip: (id: string) => Promise<void>;
|
|
||||||
renameClip: (id: string, name: string) => Promise<void>;
|
|
||||||
} {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const [clips, setClips] = useState<SoundboardClip[]>(() => readSoundboardClips(mx));
|
|
||||||
|
|
||||||
useAccountDataCallback(
|
|
||||||
mx,
|
|
||||||
useCallback((evt) => {
|
|
||||||
if (evt.getType() === KEY) {
|
|
||||||
const content = evt.getContent<SoundboardContent>();
|
|
||||||
setClips(Array.isArray(content?.clips) ? content.clips : []);
|
|
||||||
}
|
|
||||||
}, []),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setClips(readSoundboardClips(mx));
|
|
||||||
}, [mx]);
|
|
||||||
|
|
||||||
const persist = useCallback(
|
|
||||||
async (next: SoundboardClip[]) => {
|
|
||||||
const content: SoundboardContent = { clips: next };
|
|
||||||
await (
|
|
||||||
mx as unknown as { setAccountData: (t: string, c: unknown) => Promise<void> }
|
|
||||||
).setAccountData(KEY, content);
|
|
||||||
},
|
|
||||||
[mx],
|
|
||||||
);
|
|
||||||
|
|
||||||
const addClip = useCallback(
|
|
||||||
async (file: File, name?: string) => {
|
|
||||||
const current = readSoundboardClips(mx);
|
|
||||||
if (current.length >= SOUNDBOARD_MAX_CLIPS) {
|
|
||||||
throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
|
|
||||||
}
|
|
||||||
if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) {
|
|
||||||
throw new Error('Clip is too large (max 1 MB).');
|
|
||||||
}
|
|
||||||
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
|
|
||||||
const mxc = res.content_uri;
|
|
||||||
if (!mxc) throw new Error('Upload failed.');
|
|
||||||
const label = (name ?? file.name.replace(/\.[^/.]+$/, ''))
|
|
||||||
.trim()
|
|
||||||
.slice(0, SOUNDBOARD_NAME_MAX);
|
|
||||||
const clip: SoundboardClip = {
|
|
||||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
||||||
name: label || 'Clip',
|
|
||||||
url: mxc,
|
|
||||||
mimetype: file.type || undefined,
|
|
||||||
size: file.size,
|
|
||||||
};
|
|
||||||
await persist([...current, clip]);
|
|
||||||
},
|
|
||||||
[mx, persist],
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeClip = useCallback(
|
|
||||||
async (id: string) => {
|
|
||||||
const next = readSoundboardClips(mx).filter((c) => c.id !== id);
|
|
||||||
await persist(next);
|
|
||||||
},
|
|
||||||
[mx, persist],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renameClip = useCallback(
|
|
||||||
async (id: string, name: string) => {
|
|
||||||
const trimmed = name.trim().slice(0, SOUNDBOARD_NAME_MAX);
|
|
||||||
if (!trimmed) return;
|
|
||||||
const next = readSoundboardClips(mx).map((c) => (c.id === id ? { ...c, name: trimmed } : c));
|
|
||||||
await persist(next);
|
|
||||||
},
|
|
||||||
[mx, persist],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { clips, addClip, removeClip, renameClip };
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||||
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
|
import {
|
||||||
|
getGlobalSoundboardPacks,
|
||||||
|
getRoomSoundboardPack,
|
||||||
|
getRoomSoundboardPacks,
|
||||||
|
getUserSoundboardPack,
|
||||||
|
SoundboardPack,
|
||||||
|
} from '../plugins/soundboard';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { useAccountDataCallback } from './useAccountDataCallback';
|
||||||
|
import { useStateEventCallback } from './useStateEventCallback';
|
||||||
|
|
||||||
|
// Parallels hooks/useImagePacks.ts (custom emoji). Same aggregation shape.
|
||||||
|
|
||||||
|
export const useUserSoundboardPack = (): SoundboardPack | undefined => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [userPack, setUserPack] = useState(() => getUserSoundboardPack(mx));
|
||||||
|
|
||||||
|
useAccountDataCallback(
|
||||||
|
mx,
|
||||||
|
useCallback(
|
||||||
|
(mEvent) => {
|
||||||
|
if (mEvent.getType() === AccountDataEvent.LotusSoundboard) {
|
||||||
|
setUserPack(getUserSoundboardPack(mx));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mx],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return userPack;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGlobalSoundboardPacks = (): SoundboardPack[] => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [globalPacks, setGlobalPacks] = useState(() => getGlobalSoundboardPacks(mx));
|
||||||
|
|
||||||
|
useAccountDataCallback(
|
||||||
|
mx,
|
||||||
|
useCallback(
|
||||||
|
(mEvent) => {
|
||||||
|
if (mEvent.getType() === AccountDataEvent.LotusSoundboardRooms) {
|
||||||
|
setGlobalPacks(getGlobalSoundboardPacks(mx));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mx],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
useStateEventCallback(
|
||||||
|
mx,
|
||||||
|
useCallback(
|
||||||
|
(mEvent) => {
|
||||||
|
const roomId = mEvent.getRoomId();
|
||||||
|
const stateKey = mEvent.getStateKey();
|
||||||
|
if (
|
||||||
|
mEvent.getType() === StateEvent.LotusSoundboardRoom &&
|
||||||
|
roomId &&
|
||||||
|
typeof stateKey === 'string'
|
||||||
|
) {
|
||||||
|
const isGlobal = !!globalPacks.find(
|
||||||
|
(pack) =>
|
||||||
|
pack.address && pack.address.roomId === roomId && pack.address.stateKey === stateKey,
|
||||||
|
);
|
||||||
|
if (isGlobal) setGlobalPacks(getGlobalSoundboardPacks(mx));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mx, globalPacks],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return globalPacks;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRoomSoundboardPack = (room: Room, stateKey: string): SoundboardPack | undefined => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [roomPack, setRoomPack] = useState(() => getRoomSoundboardPack(room, stateKey));
|
||||||
|
|
||||||
|
useStateEventCallback(
|
||||||
|
mx,
|
||||||
|
useCallback(
|
||||||
|
(mEvent) => {
|
||||||
|
if (
|
||||||
|
mEvent.getRoomId() === room.roomId &&
|
||||||
|
mEvent.getType() === StateEvent.LotusSoundboardRoom &&
|
||||||
|
mEvent.getStateKey() === stateKey
|
||||||
|
) {
|
||||||
|
setRoomPack(getRoomSoundboardPack(room, stateKey));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[room, stateKey],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return roomPack;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRoomSoundboardPacks = (room: Room): SoundboardPack[] => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [roomPacks, setRoomPacks] = useState(() => getRoomSoundboardPacks(room));
|
||||||
|
|
||||||
|
useStateEventCallback(
|
||||||
|
mx,
|
||||||
|
useCallback(
|
||||||
|
(mEvent) => {
|
||||||
|
if (
|
||||||
|
mEvent.getRoomId() === room.roomId &&
|
||||||
|
mEvent.getType() === StateEvent.LotusSoundboardRoom
|
||||||
|
) {
|
||||||
|
setRoomPacks(getRoomSoundboardPacks(room));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[room],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return roomPacks;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRoomsSoundboardPacks = (rooms: Room[]): SoundboardPack[] => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [roomPacks, setRoomPacks] = useState(() => rooms.flatMap(getRoomSoundboardPacks));
|
||||||
|
|
||||||
|
useStateEventCallback(
|
||||||
|
mx,
|
||||||
|
useCallback(
|
||||||
|
(mEvent) => {
|
||||||
|
if (
|
||||||
|
rooms.find((room) => room.roomId === mEvent.getRoomId()) &&
|
||||||
|
mEvent.getType() === StateEvent.LotusSoundboardRoom
|
||||||
|
) {
|
||||||
|
setRoomPacks(rooms.flatMap(getRoomSoundboardPacks));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[rooms],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return roomPacks;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** User ∪ global ∪ room packs, deduped by id, keeping only packs with clips. */
|
||||||
|
export const useRelevantSoundboardPacks = (rooms: Room[]): SoundboardPack[] => {
|
||||||
|
const userPack = useUserSoundboardPack();
|
||||||
|
const globalPacks = useGlobalSoundboardPacks();
|
||||||
|
const roomsPacks = useRoomsSoundboardPacks(rooms);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const packs = userPack ? [userPack] : [];
|
||||||
|
const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
|
||||||
|
const relPacks = packs.concat(
|
||||||
|
globalPacks,
|
||||||
|
roomsPacks.filter((pack) => !globalPackIds.has(pack.id)),
|
||||||
|
);
|
||||||
|
return relPacks.filter((pack) => pack.getClips().length > 0);
|
||||||
|
}, [userPack, globalPacks, roomsPacks]);
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
// Tauri v2 injects `__TAURI_INTERNALS__` into the webview at runtime; we use it
|
||||||
|
// directly so cinny doesn't need `@tauri-apps/api` as a dependency. Native Rust
|
||||||
|
// modules push data back to the web by dispatching DOM CustomEvents (see
|
||||||
|
// `emit_to_web` in cinny-desktop's `native` module), which `useTauriEvent`
|
||||||
|
// subscribes to. This module is the single source for the desktop bridge that
|
||||||
|
// every `useTauri*` feature hook builds on.
|
||||||
|
type Invoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
|
||||||
|
export const tauriInvoke = (): Invoke | undefined =>
|
||||||
|
(window as unknown as { __TAURI_INTERNALS__?: { invoke: Invoke } }).__TAURI_INTERNALS__?.invoke;
|
||||||
|
|
||||||
|
export const isTauri = (): boolean => tauriInvoke() !== undefined;
|
||||||
|
|
||||||
|
/** Fire-and-forget invoke that no-ops (and never throws) outside Tauri. */
|
||||||
|
export const invokeTauri = (cmd: string, args?: Record<string, unknown>): void => {
|
||||||
|
tauriInvoke()?.(cmd, args).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a CustomEvent dispatched from the Rust side via `emit_to_web`.
|
||||||
|
* The handler is kept in a ref so callers don't need to memoize it to avoid
|
||||||
|
* re-subscribing. No-op outside Tauri.
|
||||||
|
*/
|
||||||
|
export function useTauriEvent<T = unknown>(name: string, handler: (detail: T) => void): void {
|
||||||
|
const handlerRef = useRef(handler);
|
||||||
|
handlerRef.current = handler;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauri()) return undefined;
|
||||||
|
const listener = (e: Event): void => handlerRef.current((e as CustomEvent<T>).detail);
|
||||||
|
window.addEventListener(name, listener);
|
||||||
|
return () => window.removeEventListener(name, listener);
|
||||||
|
}, [name]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { callEmbedAtom } from '../state/callEmbed';
|
||||||
|
import { invokeTauri } from './useTauri';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-46 — keep the system awake during calls (call continuity). Mirrors the
|
||||||
|
* call-embed atom (undefined = no active call) onto the native `set_call_active`
|
||||||
|
* command, which holds a `SetThreadExecutionState` request on Windows while a
|
||||||
|
* voice/video call is active and releases it when the call ends. No-op in the
|
||||||
|
* browser.
|
||||||
|
*/
|
||||||
|
export function useTauriCallPower(): void {
|
||||||
|
const callEmbed = useAtomValue(callEmbedAtom);
|
||||||
|
useEffect(() => {
|
||||||
|
invokeTauri('set_call_active', { active: callEmbed !== undefined });
|
||||||
|
}, [callEmbed]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { focusAssistActiveAtom } from '../state/focusAssist';
|
||||||
|
import { useTauriEvent } from './useTauri';
|
||||||
|
|
||||||
|
/** Detail shape of the `focus-assist-changed` event emitted by the native side. */
|
||||||
|
type FocusAssistChangedDetail = {
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-56 — Windows Focus Assist ↔ Do-Not-Disturb sync (desktop). Subscribes to
|
||||||
|
* the native `focus-assist-changed` event (Windows `SHQueryUserNotificationState`
|
||||||
|
* poll, `{ active }`) and mirrors it into `focusAssistActiveAtom`, which the
|
||||||
|
* notification gate reads to suppress notifications while the shell is in Focus
|
||||||
|
* Assist / Quiet Hours, presenting, gaming full-screen, or busy. Inert in the
|
||||||
|
* browser, since `useTauriEvent` only listens under Tauri.
|
||||||
|
*/
|
||||||
|
export function useTauriFocusAssist(): void {
|
||||||
|
const setFocusAssist = useSetAtom(focusAssistActiveAtom);
|
||||||
|
|
||||||
|
useTauriEvent<FocusAssistChangedDetail>('focus-assist-changed', ({ active }) =>
|
||||||
|
setFocusAssist(active),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { allRoomsAtom } from '../state/room-list/roomList';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { isTauri, invokeTauri } from './useTauri';
|
||||||
|
|
||||||
|
/** Cap the Jump List to a small, glanceable set of rooms. */
|
||||||
|
const MAX_ITEMS = 8;
|
||||||
|
|
||||||
|
/** Wait for room activity to settle before re-publishing the (native) list. */
|
||||||
|
const DEBOUNCE_MS = 1500;
|
||||||
|
|
||||||
|
type JumpItem = { title: string; uri: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `matrix:` deep link the desktop deep-link handler understands (see
|
||||||
|
* `useDeepLinkNavigate`): `matrix:r/<alias>` for a canonical alias, otherwise
|
||||||
|
* `matrix:roomid/<id>`. The sigil is dropped and the remainder is percent-encoded
|
||||||
|
* because the handler decodes each segment with `decodeURIComponent`.
|
||||||
|
*/
|
||||||
|
const roomToUri = (room: Room): string => {
|
||||||
|
const alias = room.getCanonicalAlias();
|
||||||
|
if (alias && alias.startsWith('#')) {
|
||||||
|
return `matrix:r/${encodeURIComponent(alias.slice(1))}`;
|
||||||
|
}
|
||||||
|
return `matrix:roomid/${encodeURIComponent(room.roomId.slice(1))}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-36 — publish a Windows taskbar Jump List of the most recently-active rooms.
|
||||||
|
* Rooms come from `allRoomsAtom` (the joined-room list), sorted by
|
||||||
|
* `getLastActiveTimestamp` (mirroring the sort used elsewhere, e.g. the forward
|
||||||
|
* dialog), with spaces excluded. The list is pushed to the native
|
||||||
|
* `set_jump_list` command, debounced so bursts of activity don't thrash the
|
||||||
|
* shell. No-op outside Tauri.
|
||||||
|
*/
|
||||||
|
export function useTauriJumpList(): void {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const allRooms = useAtomValue(allRoomsAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauri()) return undefined;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
const items: JumpItem[] = allRooms
|
||||||
|
.map((roomId) => mx.getRoom(roomId))
|
||||||
|
.filter((room): room is Room => room !== null && !room.isSpaceRoom())
|
||||||
|
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0))
|
||||||
|
.slice(0, MAX_ITEMS)
|
||||||
|
.map((room) => ({ title: room.name || room.roomId, uri: roomToUri(room) }));
|
||||||
|
|
||||||
|
invokeTauri('set_jump_list', { items });
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [mx, allRooms]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { useTauriEvent } from './useTauri';
|
||||||
|
|
||||||
|
/** Detail shape of the `network-changed` event emitted by the native side. */
|
||||||
|
type NetworkChangedDetail = {
|
||||||
|
online: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-49 — Network awareness (desktop). Subscribes to the native
|
||||||
|
* `network-changed` event (Windows Network List Manager poll, `{ online }`) and,
|
||||||
|
* on a transition back to online, calls `mx.retryImmediately()` so the sync loop
|
||||||
|
* retries its backed-off `/sync` at once instead of waiting out the backoff
|
||||||
|
* timer. Returns the last known connectivity (`undefined` until the first
|
||||||
|
* event). Inert in the browser, since `useTauriEvent` only listens under Tauri.
|
||||||
|
*/
|
||||||
|
export function useTauriNetwork(): boolean | undefined {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [online, setOnline] = useState<boolean | undefined>(undefined);
|
||||||
|
// Track the previous value in a ref so we can detect an offline -> online
|
||||||
|
// transition without adding it to a dependency list.
|
||||||
|
const onlineRef = useRef<boolean | undefined>(undefined);
|
||||||
|
|
||||||
|
useTauriEvent<NetworkChangedDetail>('network-changed', ({ online: next }) => {
|
||||||
|
const previous = onlineRef.current;
|
||||||
|
onlineRef.current = next;
|
||||||
|
setOnline(next);
|
||||||
|
// Only nudge the client when connectivity is (re)gained. The initial event
|
||||||
|
// (previous === undefined) also triggers a retry, which is safe: it's a
|
||||||
|
// no-op if nothing is backed off.
|
||||||
|
if (next && previous !== true) {
|
||||||
|
mx.retryImmediately();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return online;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { callEmbedAtom } from '../state/callEmbed';
|
||||||
|
import { useCallControlState } from '../plugins/call';
|
||||||
|
import { invokeTauri, useTauriEvent } from './useTauri';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-43 — expose the active call to the Windows System Media Transport Controls
|
||||||
|
* (the volume-flyout / media overlay). Mirrors the call-embed atom (undefined =
|
||||||
|
* no active call) and the current mic state onto the native
|
||||||
|
* `set_smtc_call_state` command, and translates SMTC button presses back into
|
||||||
|
* call actions:
|
||||||
|
* - Play/Pause (`smtc-action` → `mute`) toggles the microphone.
|
||||||
|
* - Stop (`smtc-action` → `end`) hangs up the call.
|
||||||
|
* No-op in the browser (the native command and events only fire under Tauri).
|
||||||
|
*/
|
||||||
|
type SmtcAction = { action: 'mute' | 'end' };
|
||||||
|
|
||||||
|
export function useTauriSmtc(): void {
|
||||||
|
const callEmbed = useAtomValue(callEmbedAtom);
|
||||||
|
// `microphone` reflects mic-enabled; muted is its inverse while in a call.
|
||||||
|
const { microphone } = useCallControlState(callEmbed?.control);
|
||||||
|
const active = callEmbed !== undefined;
|
||||||
|
const muted = active && !microphone;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invokeTauri('set_smtc_call_state', { active, muted });
|
||||||
|
}, [active, muted]);
|
||||||
|
|
||||||
|
useTauriEvent<SmtcAction>('smtc-action', ({ action }) => {
|
||||||
|
if (!callEmbed) return;
|
||||||
|
if (action === 'mute') {
|
||||||
|
callEmbed.control.toggleMicrophone().catch(() => undefined);
|
||||||
|
} else if (action === 'end') {
|
||||||
|
callEmbed.hangup().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { callEmbedAtom } from '../state/callEmbed';
|
||||||
|
import { useCallControlState } from '../plugins/call';
|
||||||
|
import { invokeTauri, useTauriEvent } from './useTauri';
|
||||||
|
|
||||||
|
type ThumbbarAction = { action: 'mute' | 'deafen' | 'end' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-44 — Taskbar thumbnail toolbar (call controls). While a call is active,
|
||||||
|
* mirrors the mic/sound state onto the native `set_thumbbar` command (three
|
||||||
|
* Mute / Deafen / End-Call buttons on the Windows taskbar thumbnail toolbar) and
|
||||||
|
* hides them when the call ends. Thumb-button clicks come back as the
|
||||||
|
* `thumbbar-action` event and drive the real call controls. No-op in the browser.
|
||||||
|
*/
|
||||||
|
export function useTauriThumbbar(): void {
|
||||||
|
const callEmbed = useAtomValue(callEmbedAtom);
|
||||||
|
const { microphone, sound } = useCallControlState(callEmbed?.control);
|
||||||
|
|
||||||
|
const active = callEmbed !== undefined;
|
||||||
|
// Muted / deafened only make sense while a call is active; report false
|
||||||
|
// otherwise so the buttons render in a sane (hidden) state.
|
||||||
|
const muted = active && !microphone;
|
||||||
|
const deafened = active && !sound;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invokeTauri('set_thumbbar', { active, muted, deafened });
|
||||||
|
}, [active, muted, deafened]);
|
||||||
|
|
||||||
|
useTauriEvent<ThumbbarAction>('thumbbar-action', ({ action }) => {
|
||||||
|
if (!callEmbed) return;
|
||||||
|
if (action === 'mute') {
|
||||||
|
// toggleMicrophone flips the mic; `microphone === false` means muted.
|
||||||
|
// Async transport send — swallow rejection (widget mid-teardown), as SMTC does.
|
||||||
|
callEmbed.control.toggleMicrophone().catch(() => undefined);
|
||||||
|
} else if (action === 'deafen') {
|
||||||
|
// toggleSound flips local audio; `sound === false` means deafened. It also
|
||||||
|
// mutes the mic while deafened, matching the in-app Deafen control.
|
||||||
|
callEmbed.control.toggleSound();
|
||||||
|
} else if (action === 'end') {
|
||||||
|
callEmbed.hangup().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { MsgType } from 'matrix-js-sdk';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { useTauriEvent } from './useTauri';
|
||||||
|
|
||||||
|
/** Payload of the `lotus-notification-activate` event (a plain body click). */
|
||||||
|
interface ActivateDetail {
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload of the `lotus-notification-reply` event (the inline reply box). */
|
||||||
|
interface ReplyDetail {
|
||||||
|
roomId?: string;
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-41 / P5-35 — wire the native WinRT toast's click + quick-reply back into the
|
||||||
|
* client. The Rust side (`show_rich_toast`) dispatches DOM CustomEvents via
|
||||||
|
* `emit_to_web`:
|
||||||
|
* - `lotus-notification-activate` → route to the room the toast was for, reusing
|
||||||
|
* the same `useNavigate(path)` mechanism the web `notificationclick` path uses
|
||||||
|
* (see ClientNonUIFeatures).
|
||||||
|
* - `lotus-notification-reply` → send the typed reply straight to the room.
|
||||||
|
* No-op outside Tauri (the events never fire).
|
||||||
|
*/
|
||||||
|
export function useTauriToastActions(): void {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
useTauriEvent<ActivateDetail>('lotus-notification-activate', ({ path }) => {
|
||||||
|
if (path) navigate(path);
|
||||||
|
});
|
||||||
|
|
||||||
|
useTauriEvent<ReplyDetail>('lotus-notification-reply', ({ roomId, text }) => {
|
||||||
|
if (!roomId || !text) return;
|
||||||
|
mx.sendMessage(roomId, { msgtype: MsgType.Text, body: text }).catch(() => undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { customWindowChromeAtom } from '../state/customWindowChrome';
|
||||||
|
import { invokeTauri, isTauri } from './useTauri';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-47 — drive the native window frame from the `customWindowChromeAtom`.
|
||||||
|
*
|
||||||
|
* On mount and whenever the atom changes, pushes the value onto the native
|
||||||
|
* `set_custom_chrome` command: `enabled = true` strips the OS decorations so the
|
||||||
|
* web `<TitleBar/>` can take over, `enabled = false` restores the native frame.
|
||||||
|
* No-op in the browser (`isTauri()` guard), so it's safe to call unconditionally
|
||||||
|
* from the app shell.
|
||||||
|
*/
|
||||||
|
export function useTauriWindowChrome(): void {
|
||||||
|
const enabled = useAtomValue(customWindowChromeAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauri()) return;
|
||||||
|
invokeTauri('set_custom_chrome', { enabled });
|
||||||
|
}, [enabled]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||||
|
import { threadNotificationsAtom } from '../state/threadNotifications';
|
||||||
|
import {
|
||||||
|
getThreadNotificationMode,
|
||||||
|
pruneThreadNotifications,
|
||||||
|
ThreadNotificationEntry,
|
||||||
|
ThreadNotificationMode,
|
||||||
|
ThreadNotificationsContent,
|
||||||
|
} from '../utils/threadNotifications';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { AsyncState, useAsyncCallback } from './useAsyncCallback';
|
||||||
|
|
||||||
|
/** Read the current notification mode for a thread from the bound atom. */
|
||||||
|
export function useThreadNotificationMode(
|
||||||
|
roomId: string,
|
||||||
|
threadRootId: string,
|
||||||
|
): ThreadNotificationMode {
|
||||||
|
const content = useAtomValue(threadNotificationsAtom);
|
||||||
|
return getThreadNotificationMode(content, roomId, threadRootId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readContent = (mx: MatrixClient): ThreadNotificationsContent =>
|
||||||
|
((mx as any).getAccountData(AccountDataEvent.LotusThreadNotifications)?.getContent() as
|
||||||
|
| ThreadNotificationsContent
|
||||||
|
| undefined) ?? {};
|
||||||
|
|
||||||
|
const getJoinedRoomIds = (mx: MatrixClient): Set<string> => {
|
||||||
|
const joined = new Set<string>();
|
||||||
|
mx.getRooms().forEach((room) => {
|
||||||
|
if (room.getMyMembership() === 'join') {
|
||||||
|
joined.add(room.roomId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return joined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeThreadNotificationMode = async (
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
threadRootId: string,
|
||||||
|
mode: ThreadNotificationMode,
|
||||||
|
): Promise<void> => {
|
||||||
|
const current = readContent(mx);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Work on a mutable clone; prune produces a fresh object so the mutations
|
||||||
|
// below never touch the atom's/account-data's current content.
|
||||||
|
const next: ThreadNotificationsContent = {
|
||||||
|
...current,
|
||||||
|
rooms: Object.fromEntries(
|
||||||
|
Object.entries(current.rooms ?? {}).map(([rid, entries]) => [rid, { ...entries }]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const rooms = next.rooms as Record<string, Record<string, ThreadNotificationEntry>>;
|
||||||
|
|
||||||
|
if (mode === ThreadNotificationMode.Default) {
|
||||||
|
if (rooms[roomId]) {
|
||||||
|
delete rooms[roomId][threadRootId];
|
||||||
|
if (Object.keys(rooms[roomId]).length === 0) {
|
||||||
|
delete rooms[roomId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!rooms[roomId]) {
|
||||||
|
rooms[roomId] = {};
|
||||||
|
}
|
||||||
|
rooms[roomId][threadRootId] = { mode, ts: now };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ALWAYS prune before persisting to keep account data bounded.
|
||||||
|
const finalContent = pruneThreadNotifications(next, getJoinedRoomIds(mx), now);
|
||||||
|
|
||||||
|
await (mx as any).setAccountData(AccountDataEvent.LotusThreadNotifications, finalContent);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSetThreadNotificationMode(
|
||||||
|
roomId: string,
|
||||||
|
threadRootId: string,
|
||||||
|
): {
|
||||||
|
modeState: AsyncState<void, Error>;
|
||||||
|
setMode: (mode: ThreadNotificationMode) => Promise<void>;
|
||||||
|
} {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
const [modeState, setMode] = useAsyncCallback<void, Error, [ThreadNotificationMode]>(
|
||||||
|
useCallback(
|
||||||
|
(mode: ThreadNotificationMode) => writeThreadNotificationMode(mx, roomId, threadRootId, mode),
|
||||||
|
[mx, roomId, threadRootId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { modeState, setMode };
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import {
|
||||||
|
MatrixEvent,
|
||||||
|
NotificationCountType,
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
RoomEventHandlerMap,
|
||||||
|
ThreadEvent,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import { getThreadSummary, ThreadSummaryData } from '../features/room/thread/threadSummaryData';
|
||||||
|
import { threadNotificationsAtom } from '../state/threadNotifications';
|
||||||
|
import { getThreadNotificationMode, ThreadNotificationMode } from '../utils/threadNotifications';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive thread summary + unread count for a root event's "N replies" chip.
|
||||||
|
*
|
||||||
|
* Re-computes the summary on `ThreadEvent.Update` (the SDK re-emits this on the
|
||||||
|
* root MatrixEvent) and the unread count on `RoomEvent.UnreadNotifications`.
|
||||||
|
*/
|
||||||
|
export const useThreadSummary = (
|
||||||
|
rootEvent: MatrixEvent,
|
||||||
|
room: Room,
|
||||||
|
): { summary: ThreadSummaryData | undefined; unread: number; mode: ThreadNotificationMode } => {
|
||||||
|
const threadId = rootEvent.getId();
|
||||||
|
|
||||||
|
const threadNotifications = useAtomValue(threadNotificationsAtom);
|
||||||
|
const mode = threadId
|
||||||
|
? getThreadNotificationMode(threadNotifications, room.roomId, threadId)
|
||||||
|
: ThreadNotificationMode.Default;
|
||||||
|
|
||||||
|
const [summary, setSummary] = useState<ThreadSummaryData | undefined>(() =>
|
||||||
|
getThreadSummary(rootEvent),
|
||||||
|
);
|
||||||
|
const [unread, setUnread] = useState<number>(() =>
|
||||||
|
threadId
|
||||||
|
? (room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Total) ?? 0)
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const refreshSummary = () => setSummary(getThreadSummary(rootEvent));
|
||||||
|
const refreshUnread = () => {
|
||||||
|
if (!threadId) return;
|
||||||
|
setUnread(room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Total) ?? 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
refreshSummary();
|
||||||
|
refreshUnread();
|
||||||
|
|
||||||
|
const handleUnread: RoomEventHandlerMap[RoomEvent.UnreadNotifications] = (_counts, tId) => {
|
||||||
|
if (tId && tId !== threadId) return;
|
||||||
|
refreshUnread();
|
||||||
|
};
|
||||||
|
|
||||||
|
rootEvent.on(ThreadEvent.Update, refreshSummary);
|
||||||
|
room.on(RoomEvent.UnreadNotifications, handleUnread);
|
||||||
|
return () => {
|
||||||
|
rootEvent.removeListener(ThreadEvent.Update, refreshSummary);
|
||||||
|
room.removeListener(RoomEvent.UnreadNotifications, handleUnread);
|
||||||
|
};
|
||||||
|
}, [rootEvent, room, threadId]);
|
||||||
|
|
||||||
|
const muted = mode === ThreadNotificationMode.Mute;
|
||||||
|
|
||||||
|
return { summary, unread: muted ? 0 : unread, mode };
|
||||||
|
};
|
||||||
+37
-1
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { ReactNode, useEffect } from 'react';
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
|
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +25,10 @@ import { useCompositionEndTracking } from '../hooks/useComposingCheck';
|
|||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
||||||
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
||||||
|
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
|
||||||
|
import { isTauri } from '../hooks/useTauri';
|
||||||
|
import { TitleBar } from '../features/desktop/TitleBar';
|
||||||
|
import { customWindowChromeAtom } from '../state/customWindowChrome';
|
||||||
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
|
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
|
||||||
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
|
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
|
||||||
import { zIndices } from '../styles/zIndex';
|
import { zIndices } from '../styles/zIndex';
|
||||||
@@ -88,6 +92,36 @@ function TauriEffects() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P5-47 — opt-in TDS window chrome. `useTauriWindowChrome` keeps the native OS
|
||||||
|
// window decorations in sync with the setting; when a desktop user enables
|
||||||
|
// custom chrome we replace the OS titlebar with <TitleBar/>. When off (the
|
||||||
|
// default, and always in the browser) this returns children unchanged, so there
|
||||||
|
// is zero layout impact for everyone else.
|
||||||
|
function DesktopChrome({ children }: { children: ReactNode }) {
|
||||||
|
const customChrome = useAtomValue(customWindowChromeAtom);
|
||||||
|
useTauriWindowChrome();
|
||||||
|
const useChrome = isTauri() && customChrome;
|
||||||
|
// Keep the wrapper element structure STABLE across the toggle so flipping the
|
||||||
|
// setting never changes the element type in `children`'s ancestry — otherwise
|
||||||
|
// React would unmount/remount the whole RouterProvider subtree (losing scroll,
|
||||||
|
// menus, unsaved composer state). When off, both wrappers use `display:contents`
|
||||||
|
// so they generate no box → zero layout impact (also the browser default path).
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={
|
||||||
|
useChrome
|
||||||
|
? { display: 'flex', flexDirection: 'column', height: '100vh' }
|
||||||
|
: { display: 'contents' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{useChrome && <TitleBar />}
|
||||||
|
<div style={useChrome ? { flexGrow: 1, minHeight: 0 } : { display: 'contents' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function NightLightOverlay() {
|
function NightLightOverlay() {
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
if (!settings.nightLightEnabled) return null;
|
if (!settings.nightLightEnabled) return null;
|
||||||
@@ -160,7 +194,9 @@ function App() {
|
|||||||
<JotaiProvider>
|
<JotaiProvider>
|
||||||
<AppearanceEffects />
|
<AppearanceEffects />
|
||||||
<TauriEffects />
|
<TauriEffects />
|
||||||
|
<DesktopChrome>
|
||||||
<RouterProvider router={createRouter(clientConfig, screenSize)} />
|
<RouterProvider router={createRouter(clientConfig, screenSize)} />
|
||||||
|
</DesktopChrome>
|
||||||
<SeasonalEffect />
|
<SeasonalEffect />
|
||||||
<NightLightOverlay />
|
<NightLightOverlay />
|
||||||
<LotusToastContainer />
|
<LotusToastContainer />
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
import {
|
||||||
|
MatrixEvent,
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
RoomEventHandlerMap,
|
||||||
|
Thread,
|
||||||
|
ThreadEvent,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import { focusAssistActiveAtom } from '../../state/focusAssist';
|
||||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||||
import LogoSVG from '../../../../public/res/lotus.png';
|
import LogoSVG from '../../../../public/res/lotus.png';
|
||||||
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
|
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
|
||||||
@@ -33,6 +41,16 @@ import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
|||||||
import { toastQueueAtom } from '../../state/toast';
|
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 { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
|
||||||
|
import { useRoomsListener } from '../../hooks/useRoomsListener';
|
||||||
|
import { threadNotificationsAtom } from '../../state/threadNotifications';
|
||||||
|
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||||
|
import {
|
||||||
|
getThreadNotificationMode,
|
||||||
|
shouldNotifyThreadReply,
|
||||||
|
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
|
||||||
|
} from '../../utils/threadNotifications';
|
||||||
|
|
||||||
function isInQuietHours(start: string, end: string): boolean {
|
function isInQuietHours(start: string, end: string): boolean {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -109,6 +127,7 @@ function InviteNotifications() {
|
|||||||
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||||
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
||||||
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
||||||
|
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||||
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
||||||
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
||||||
const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId');
|
const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId');
|
||||||
@@ -167,7 +186,8 @@ function InviteNotifications() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
|
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
|
||||||
const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd);
|
const quietActive =
|
||||||
|
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
||||||
if (!quietActive) {
|
if (!quietActive) {
|
||||||
if (showNotifications && notificationPermission('granted')) {
|
if (showNotifications && notificationPermission('granted')) {
|
||||||
notify(invites.length - perviousInviteLen);
|
notify(invites.length - perviousInviteLen);
|
||||||
@@ -189,11 +209,12 @@ function InviteNotifications() {
|
|||||||
quietHoursEnabled,
|
quietHoursEnabled,
|
||||||
quietHoursStart,
|
quietHoursStart,
|
||||||
quietHoursEnd,
|
quietHoursEnd,
|
||||||
|
focusAssistActive,
|
||||||
inviteSoundId,
|
inviteSoundId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
@@ -207,11 +228,14 @@ function PresenceUpdater() {
|
|||||||
function MessageNotifications() {
|
function MessageNotifications() {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
|
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
|
||||||
|
// Per-thread dedupe: threadId -> last notified eventId.
|
||||||
|
const lastNotifiedThreadRef = useRef<Map<string, string>>(new Map());
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||||
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
||||||
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
||||||
|
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||||
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
||||||
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
||||||
const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId');
|
const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId');
|
||||||
@@ -236,6 +260,8 @@ function MessageNotifications() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const notificationSelected = useInboxNotificationsSelected();
|
const notificationSelected = useInboxNotificationsSelected();
|
||||||
const selectedRoomId = useSelectedRoom();
|
const selectedRoomId = useSelectedRoom();
|
||||||
|
const threadPrefs = useAtomValue(threadNotificationsAtom);
|
||||||
|
const activeThreadId = useAtomValue(roomIdToActiveThreadIdAtomFamily(selectedRoomId ?? ''));
|
||||||
|
|
||||||
const notify = useCallback(
|
const notify = useCallback(
|
||||||
({
|
({
|
||||||
@@ -246,6 +272,7 @@ function MessageNotifications() {
|
|||||||
eventId,
|
eventId,
|
||||||
body,
|
body,
|
||||||
encrypted,
|
encrypted,
|
||||||
|
threadId,
|
||||||
}: {
|
}: {
|
||||||
roomName: string;
|
roomName: string;
|
||||||
roomAvatar?: string;
|
roomAvatar?: string;
|
||||||
@@ -254,6 +281,7 @@ function MessageNotifications() {
|
|||||||
eventId: string;
|
eventId: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
encrypted?: boolean;
|
encrypted?: boolean;
|
||||||
|
threadId?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const roomPath = mDirects.has(roomId)
|
const roomPath = mDirects.has(roomId)
|
||||||
? getDirectRoomPath(roomId, eventId)
|
? getDirectRoomPath(roomId, eventId)
|
||||||
@@ -288,7 +316,9 @@ function MessageNotifications() {
|
|||||||
silent: true,
|
silent: true,
|
||||||
// Coalesce repeated notifications for the same room (replaces the old
|
// Coalesce repeated notifications for the same room (replaces the old
|
||||||
// manual notifRef.close() dedup, which a SW notification can't hold).
|
// manual notifRef.close() dedup, which a SW notification can't hold).
|
||||||
tag: roomId,
|
// For thread replies widen the tag to room:thread so each thread
|
||||||
|
// coalesces independently instead of clobbering the room's bucket.
|
||||||
|
tag: threadId ? `${roomId}:${threadId}` : roomId,
|
||||||
data: { path: roomPath },
|
data: { path: roomPath },
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
@@ -320,6 +350,69 @@ function MessageNotifications() {
|
|||||||
audioElement?.play();
|
audioElement?.play();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Shared delivery tail for both the main timeline and per-thread paths:
|
||||||
|
// room-level unread dedup → avatar resolution → OS/toast notify → sound, all
|
||||||
|
// behind the quiet-hours / focus-assist gate. `threadId` (when set) widens the
|
||||||
|
// OS coalescing tag so each thread notifies independently; the click path
|
||||||
|
// stays the room path (RoomTimeline deep-links thread events into the panel).
|
||||||
|
const deliverNotification = useCallback(
|
||||||
|
(room: Room, mEvent: MatrixEvent, threadId?: string) => {
|
||||||
|
const sender = mEvent.getSender();
|
||||||
|
const eventId = mEvent.getId();
|
||||||
|
if (!sender || !eventId) return;
|
||||||
|
|
||||||
|
const unreadInfo = getUnreadInfo(room);
|
||||||
|
const cachedUnreadInfo = unreadCacheRef.current.get(room.roomId);
|
||||||
|
unreadCacheRef.current.set(room.roomId, unreadInfo);
|
||||||
|
|
||||||
|
if (unreadInfo.total === 0) return;
|
||||||
|
if (
|
||||||
|
cachedUnreadInfo &&
|
||||||
|
unreadEqual(unreadInfoToUnread(cachedUnreadInfo), unreadInfoToUnread(unreadInfo))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quietActive =
|
||||||
|
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
||||||
|
if (quietActive) return;
|
||||||
|
|
||||||
|
if (showNotifications && notificationPermission('granted')) {
|
||||||
|
const avatarMxc =
|
||||||
|
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
|
||||||
|
notify({
|
||||||
|
roomName: room.name ?? 'Unknown',
|
||||||
|
roomAvatar: avatarMxc
|
||||||
|
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
|
||||||
|
: undefined,
|
||||||
|
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
|
||||||
|
roomId: room.roomId,
|
||||||
|
eventId,
|
||||||
|
body: (mEvent.getContent().body as string | undefined) ?? '',
|
||||||
|
encrypted: room.hasEncryptionStateEvent(),
|
||||||
|
threadId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notificationSound && messageSoundId !== 'none') {
|
||||||
|
playSound();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
mx,
|
||||||
|
notify,
|
||||||
|
playSound,
|
||||||
|
showNotifications,
|
||||||
|
notificationSound,
|
||||||
|
useAuthentication,
|
||||||
|
quietHoursEnabled,
|
||||||
|
quietHoursStart,
|
||||||
|
quietHoursEnd,
|
||||||
|
focusAssistActive,
|
||||||
|
messageSoundId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = (
|
const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = (
|
||||||
mEvent,
|
mEvent,
|
||||||
@@ -343,62 +436,68 @@ function MessageNotifications() {
|
|||||||
const sender = mEvent.getSender();
|
const sender = mEvent.getSender();
|
||||||
const eventId = mEvent.getId();
|
const eventId = mEvent.getId();
|
||||||
if (!sender || !eventId || mEvent.getSender() === mx.getUserId()) return;
|
if (!sender || !eventId || mEvent.getSender() === mx.getUserId()) return;
|
||||||
const unreadInfo = getUnreadInfo(room);
|
// Single-owner rule: thread replies are delivered by the ThreadEvent.NewReply
|
||||||
const cachedUnreadInfo = unreadCacheRef.current.get(room.roomId);
|
// handler below (per-thread gating), so ignore them here — a reply notifies once.
|
||||||
unreadCacheRef.current.set(room.roomId, unreadInfo);
|
if (mEvent.threadRootId && mEvent.getId() !== mEvent.threadRootId) return;
|
||||||
|
|
||||||
if (unreadInfo.total === 0) return;
|
deliverNotification(room, mEvent);
|
||||||
if (
|
|
||||||
cachedUnreadInfo &&
|
|
||||||
unreadEqual(unreadInfoToUnread(cachedUnreadInfo), unreadInfoToUnread(unreadInfo))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd);
|
|
||||||
if (!quietActive) {
|
|
||||||
if (showNotifications && notificationPermission('granted')) {
|
|
||||||
const avatarMxc =
|
|
||||||
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
|
|
||||||
notify({
|
|
||||||
roomName: room.name ?? 'Unknown',
|
|
||||||
roomAvatar: avatarMxc
|
|
||||||
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
|
|
||||||
: undefined,
|
|
||||||
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
|
|
||||||
roomId: room.roomId,
|
|
||||||
eventId,
|
|
||||||
body: (mEvent.getContent().body as string | undefined) ?? '',
|
|
||||||
encrypted: room.hasEncryptionStateEvent(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notificationSound && messageSoundId !== 'none') {
|
|
||||||
playSound();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
return () => {
|
return () => {
|
||||||
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
};
|
};
|
||||||
}, [
|
}, [mx, notificationSelected, selectedRoomId, deliverNotification]);
|
||||||
mx,
|
|
||||||
notificationSound,
|
const handleNewReply = useCallback(
|
||||||
notificationSelected,
|
// useRoomsListener prepends the emitting Room; the thread's own room lookup
|
||||||
showNotifications,
|
// below is kept as the authority (identical object in practice).
|
||||||
playSound,
|
(_room: Room, thread: Thread, mEvent: MatrixEvent) => {
|
||||||
notify,
|
if (mx.getSyncState() !== 'SYNCING') return;
|
||||||
selectedRoomId,
|
const room = mx.getRoom(thread.roomId);
|
||||||
useAuthentication,
|
if (!room || room.isSpaceRoom()) return;
|
||||||
quietHoursEnabled,
|
if (!isNotificationEvent(mEvent) || mEvent.isSending()) return;
|
||||||
quietHoursStart,
|
const sender = mEvent.getSender();
|
||||||
quietHoursEnd,
|
if (!sender || sender === mx.getUserId()) return;
|
||||||
messageSoundId,
|
// Suppress when the user is actively looking at this thread (or the inbox).
|
||||||
]);
|
if (
|
||||||
|
document.hasFocus() &&
|
||||||
|
(notificationSelected || (selectedRoomId === thread.roomId && activeThreadId === thread.id))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-thread dedupe: a NewReply can re-fire for the same event as the
|
||||||
|
// thread (re)populates; notify at most once per (thread, event).
|
||||||
|
const eventId = mEvent.getId();
|
||||||
|
if (eventId) {
|
||||||
|
if (lastNotifiedThreadRef.current.get(thread.id) === eventId) return;
|
||||||
|
lastNotifiedThreadRef.current.set(thread.id, eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = threadPrefs;
|
||||||
|
const mode = getThreadNotificationMode(content, room.roomId, thread.id);
|
||||||
|
const actions = mx.getPushActionsForEvent(mEvent);
|
||||||
|
const decision = shouldNotifyThreadReply({
|
||||||
|
mode,
|
||||||
|
defaultBehavior: content.default ?? THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
|
||||||
|
participated: thread.hasCurrentUserParticipated,
|
||||||
|
highlight: !!actions?.tweaks?.highlight,
|
||||||
|
notify: !!actions?.notify,
|
||||||
|
roomMuted: getNotificationType(mx, room.roomId) === NotificationType.Mute,
|
||||||
|
});
|
||||||
|
if (decision === 'none') return;
|
||||||
|
|
||||||
|
// E2EE caveat: NewReply can fire before decryption, so MentionsOnly may
|
||||||
|
// under-notify in encrypted rooms (same class as the main timeline path).
|
||||||
|
// Plaintext body suppression for encrypted rooms is handled inside notify().
|
||||||
|
deliverNotification(room, mEvent, thread.id);
|
||||||
|
},
|
||||||
|
[mx, notificationSelected, selectedRoomId, activeThreadId, threadPrefs, deliverNotification],
|
||||||
|
);
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
@@ -544,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 (
|
||||||
<>
|
<>
|
||||||
@@ -555,8 +661,10 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
<ReminderMonitor />
|
<ReminderMonitor />
|
||||||
<TauriUpdateFeature />
|
<TauriUpdateFeature />
|
||||||
|
<TauriDesktopFeatures />
|
||||||
<LotusDenoiseFeature />
|
<LotusDenoiseFeature />
|
||||||
<DeepLinkNavigator />
|
<DeepLinkNavigator />
|
||||||
|
<KeyboardShortcutsFeature />
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,8 +43,15 @@ import { stopPropagation } from '../../utils/keyboard';
|
|||||||
import { SyncStatus } from './SyncStatus';
|
import { SyncStatus } from './SyncStatus';
|
||||||
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
||||||
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
|
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
|
||||||
|
import { useSessionSync } from '../../hooks/useSessionSync';
|
||||||
|
import { installCryptoDiagLog } from '../../utils/cryptoDiagLog';
|
||||||
import { AutoDiscovery } from './AutoDiscovery';
|
import { AutoDiscovery } from './AutoDiscovery';
|
||||||
|
|
||||||
|
// Capture-only E2EE diagnostics ring buffer (KE-1→4 signatures) — installed at
|
||||||
|
// module load so it sees crypto warnings from the very first sync. Idempotent;
|
||||||
|
// report download lives in Settings → Developer Tools → Crypto Diagnostics.
|
||||||
|
installCryptoDiagLog();
|
||||||
|
|
||||||
function ClientRootLoading() {
|
function ClientRootLoading() {
|
||||||
return (
|
return (
|
||||||
<SplashScreen>
|
<SplashScreen>
|
||||||
@@ -178,6 +185,9 @@ export function ClientRoot({ children }: ClientRootProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useLogoutListener(mx);
|
useLogoutListener(mx);
|
||||||
|
// Cross-tab session sync: another tab logging out / in (access token changed
|
||||||
|
// in localStorage) reloads this tab so it never runs with stale credentials.
|
||||||
|
useSessionSync();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadState.status === AsyncStatus.Idle) {
|
if (loadState.status === AsyncStatus.Idle) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -174,6 +175,10 @@ export class CallEmbed {
|
|||||||
denoiseMode === 'browser' ||
|
denoiseMode === 'browser' ||
|
||||||
(denoiseMode === 'ml' && denoiseNativeNS)
|
(denoiseMode === 'ml' && denoiseNativeNS)
|
||||||
).toString(),
|
).toString(),
|
||||||
|
// Turn the browser's auto gain control OFF for the ML tier only: its
|
||||||
|
// dynamic gain fights the in-source ML denoiser (pumping). Browser/off
|
||||||
|
// tiers keep the browser's normal capture pipeline (AGC on).
|
||||||
|
autoGainControl: (denoiseMode !== 'ml').toString(),
|
||||||
audio: initialAudio.toString(),
|
audio: initialAudio.toString(),
|
||||||
video: initialVideo.toString(),
|
video: initialVideo.toString(),
|
||||||
header: 'none',
|
header: 'none',
|
||||||
@@ -201,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;
|
||||||
|
}
|
||||||
+96
-49
@@ -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,6 +111,25 @@ function getGroupIndex(emoji: IEmoji): number | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let emojiDataPromise: Promise<EmojiData> | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily load emojibase data (dynamic import → the `emojibase` chunk). Memoized:
|
||||||
|
* the JSON is fetched/parsed and `emojis`/`emojiGroups` are built exactly once.
|
||||||
|
*/
|
||||||
|
export const loadEmojiData = (): Promise<EmojiData> => {
|
||||||
|
if (!emojiDataPromise) {
|
||||||
|
emojiDataPromise = (async (): Promise<EmojiData> => {
|
||||||
|
const [emojisModule, joypixelsModule, emojibaseModule] = await Promise.all([
|
||||||
|
import('emojibase-data/en/compact.json'),
|
||||||
|
import('emojibase-data/en/shortcodes/joypixels.json'),
|
||||||
|
import('emojibase-data/en/shortcodes/emojibase.json'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
joypixelsShortcodes = joypixelsModule.default as ShortcodeMap;
|
||||||
|
emojibaseShortcodes = emojibaseModule.default as ShortcodeMap;
|
||||||
|
|
||||||
|
const emojisData = emojisModule.default as unknown as CompactEmoji[];
|
||||||
emojisData.forEach((emoji) => {
|
emojisData.forEach((emoji) => {
|
||||||
const myShortCodes = getShortcodesFor(emoji.hexcode);
|
const myShortCodes = getShortcodesFor(emoji.hexcode);
|
||||||
if (!myShortCodes) return;
|
if (!myShortCodes) return;
|
||||||
@@ -112,3 +147,15 @@ emojisData.forEach((emoji) => {
|
|||||||
emojis.push(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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -43,9 +43,14 @@ import { onEnterOrSpace } from '../utils/keyboard';
|
|||||||
import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom';
|
import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom';
|
||||||
import { useTimeoutToggle } from '../hooks/useTimeoutToggle';
|
import { useTimeoutToggle } from '../hooks/useTimeoutToggle';
|
||||||
import { tokenize, tokenStyle } from '../utils/syntaxHighlight';
|
import { tokenize, tokenStyle } from '../utils/syntaxHighlight';
|
||||||
|
import { splitMathSegments } from '../utils/mathParse';
|
||||||
|
|
||||||
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
|
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
|
||||||
|
|
||||||
|
// KaTeX (and its CSS) is heavy, so it is code-split behind this dynamic import
|
||||||
|
// and is NOT part of the eager import graph — see src/app/components/math/KaTeX.tsx.
|
||||||
|
const KaTeXMath = lazy(() => import('../components/math/KaTeX'));
|
||||||
|
|
||||||
/** Languages handled by the custom TDS tokenizer. */
|
/** Languages handled by the custom TDS tokenizer. */
|
||||||
const TDS_TOKENIZER_LANGS = new Set([
|
const TDS_TOKENIZER_LANGS = new Set([
|
||||||
'js',
|
'js',
|
||||||
@@ -78,6 +83,27 @@ function renderTokenizedCode(code: string, lang: string): React.ReactNode {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders LaTeX via the lazily-loaded KaTeX component.
|
||||||
|
*
|
||||||
|
* `suspenseFallback` is shown while the KaTeX chunk loads (the raw LaTeX text).
|
||||||
|
* `errorFallback` is shown if rendering fails outright — for the spec
|
||||||
|
* `data-mx-maths` path this is the element's original children (the spec
|
||||||
|
* fallback content); for the plain-text `$…$` path it is the raw source.
|
||||||
|
*/
|
||||||
|
const renderMath = (
|
||||||
|
latex: string,
|
||||||
|
displayMode: boolean,
|
||||||
|
suspenseFallback: React.ReactNode,
|
||||||
|
errorFallback: React.ReactNode,
|
||||||
|
): JSX.Element => (
|
||||||
|
<ErrorBoundary fallback={<>{errorFallback}</>}>
|
||||||
|
<Suspense fallback={<>{suspenseFallback}</>}>
|
||||||
|
<KaTeXMath latex={latex} displayMode={displayMode} />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
|
||||||
const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g');
|
const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g');
|
||||||
|
|
||||||
export const LINKIFY_OPTS: LinkifyOpts = {
|
export const LINKIFY_OPTS: LinkifyOpts = {
|
||||||
@@ -203,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) => {
|
||||||
|
const shortcode = getShortcodeFor(getHexcodeForEmoji(match[0]));
|
||||||
|
return (
|
||||||
<span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}>
|
<span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}>
|
||||||
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(match[0]))}>
|
<span
|
||||||
|
className={css.Emoticon()}
|
||||||
|
title={shortcode}
|
||||||
|
aria-label={shortcode || undefined}
|
||||||
|
role={shortcode ? 'img' : undefined}
|
||||||
|
>
|
||||||
{match[0]}
|
{match[0]}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
(txt) => txt,
|
(txt) => txt,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -503,6 +537,21 @@ export const getReactCustomHtmlParser = (
|
|||||||
if (mention) return mention;
|
if (mention) return mention;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((name === 'span' || name === 'div') && 'data-mx-maths' in props) {
|
||||||
|
// Spec (CS-API §11.5): render the `data-mx-maths` LaTeX with KaTeX
|
||||||
|
// (block for <div>, inline for <span>). On failure fall back to the
|
||||||
|
// element's existing children, which the spec defines as the fallback
|
||||||
|
// representation.
|
||||||
|
const latex = String(props['data-mx-maths']);
|
||||||
|
const displayMode = name === 'div';
|
||||||
|
const fallback = displayMode ? (
|
||||||
|
<div {...props}>{domToReact(children as unknown as DOMNode[], opts)}</div>
|
||||||
|
) : (
|
||||||
|
<span {...props}>{domToReact(children as unknown as DOMNode[], opts)}</span>
|
||||||
|
);
|
||||||
|
return renderMath(latex, displayMode, latex, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
if (name === 'span' && 'data-mx-spoiler' in props) {
|
if (name === 'span' && 'data-mx-spoiler' in props) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -533,33 +582,71 @@ 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" />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (domNode instanceof DOMText) {
|
if (domNode instanceof DOMText) {
|
||||||
const linkify =
|
const parentName =
|
||||||
!(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'code') &&
|
domNode.parent && 'name' in domNode.parent ? domNode.parent.name : undefined;
|
||||||
!(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'a');
|
const linkify = parentName !== 'code' && parentName !== 'a';
|
||||||
|
// Never parse `$…$`/`$$…$$` math inside <pre>/<code> (verbatim regions).
|
||||||
let jsx = scaleSystemEmoji(domNode.data);
|
const mathAllowed = parentName !== 'code' && parentName !== 'pre';
|
||||||
|
|
||||||
|
const renderTextChunk = (text: string): (string | JSX.Element)[] | JSX.Element => {
|
||||||
|
let jsx = scaleSystemEmoji(text);
|
||||||
if (params.highlightRegex) {
|
if (params.highlightRegex) {
|
||||||
jsx = highlightText(params.highlightRegex, jsx);
|
jsx = highlightText(params.highlightRegex, jsx);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (linkify) {
|
if (linkify) {
|
||||||
return <Linkify options={params.linkifyOpts}>{jsx}</Linkify>;
|
return <Linkify options={params.linkifyOpts}>{jsx}</Linkify>;
|
||||||
}
|
}
|
||||||
return jsx;
|
return jsx;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mathAllowed) {
|
||||||
|
const segments = splitMathSegments(domNode.data);
|
||||||
|
if (segments.some((segment) => segment.type !== 'text')) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{segments.map((segment, index) => {
|
||||||
|
if (segment.type === 'text') {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={index}>{renderTextChunk(segment.value)}</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const raw =
|
||||||
|
segment.type === 'block' ? `$$${segment.value}$$` : `$${segment.value}$`;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
{renderMath(segment.value, segment.type === 'block', raw, raw)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderTextChunk(domNode.data);
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user