Compare commits
13 Commits
a0fcdf74da
...
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| 39cfc23ebe | |||
| 7a8cadc6ec | |||
| 91bd360125 | |||
| 7da960ac8c | |||
| ed51c39fe7 | |||
| c1efa7b94e | |||
| e31b84c08e | |||
| 258e3ec620 | |||
| 3336abb66f | |||
| a184ee0221 | |||
| 4509a2b6d3 | |||
| 7e38baa7b6 | |||
| aab7e5ae20 |
+9
-7
@@ -69,12 +69,14 @@ from testing:
|
||||
|
||||
## 🔴 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
|
||||
|
||||
> 🧰 **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
|
||||
> **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
|
||||
@@ -144,10 +146,10 @@ retry … AbortError: Restart delayed event timed out before the HS responded`,
|
||||
|
||||
### 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.
|
||||
- **`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`.
|
||||
- **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.)
|
||||
@@ -156,4 +158,4 @@ retry … AbortError: Restart delayed event timed out before the HS responded`,
|
||||
|
||||
### 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,402 @@
|
||||
# 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.
|
||||
+59
-1
@@ -25,7 +25,8 @@ Last updated: June 2026.
|
||||
16. [Notifications](#notifications)
|
||||
17. [Server Integration](#server-integration)
|
||||
18. [Infrastructure](#infrastructure)
|
||||
19. [Key Custom Files](#key-custom-files)
|
||||
19. [Desktop App Features](#desktop-app-features)
|
||||
20. [Key Custom Files](#key-custom-files)
|
||||
|
||||
---
|
||||
|
||||
@@ -1161,6 +1162,63 @@ The `encUrlPreview` setting defaults to `true` rather than `false`. A security a
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
| File | Purpose |
|
||||
|
||||
+68
-51
@@ -164,7 +164,7 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
|
||||
|
||||
### [ ] P3-8 · Thread Panel (full side drawer)
|
||||
|
||||
**⚠️ LARGEST FEATURE — requires its own planning session before implementation.**
|
||||
**⚠️ LARGEST FEATURE — 🟢 DESIGN COMPLETE (2026-07), READY FOR ITS OWN EXECUTION SESSION.** The full architecture (SDK-evidence-backed decisions, file inventory, 4-agent partition, risks, verification checklist) is in the Implementation Reference section below — no further planning needed, just a dedicated build session.
|
||||
**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:
|
||||
@@ -196,10 +196,10 @@ Features:
|
||||
|
||||
## 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.
|
||||
**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
|
||||
|
||||
@@ -257,7 +257,7 @@ Features:
|
||||
- Account mgmt: `settings/account/OidcManageAccount.tsx`.
|
||||
- 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.
|
||||
**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.**
|
||||
|
||||
---
|
||||
|
||||
@@ -334,16 +334,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.
|
||||
**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.
|
||||
**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.
|
||||
**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.
|
||||
@@ -352,78 +352,86 @@ 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.
|
||||
**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.
|
||||
**Approach:** Implement a headless Rust sidecar to fetch unread counts/notifications while the webview is suspended to ensure instant notification delivery.
|
||||
**What:** Keep receiving messages/notifications instantly while the app is closed to the tray.
|
||||
**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.
|
||||
**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.
|
||||
**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.
|
||||
**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.
|
||||
**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.
|
||||
**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.
|
||||
**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.
|
||||
**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."
|
||||
**Approach:** Implement a zero-leak boundary for personas (e.g., Work vs. Personal) by isolating `IndexedDB`, filesystem caches, and session persistence per context.
|
||||
**What:** Compartmentalize sessions, local databases, and caches into isolated "Contexts" (e.g. Work vs. Personal) with a zero-leak boundary.
|
||||
**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).
|
||||
|
||||
### [~] 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.
|
||||
**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.
|
||||
**What:** Granular per-room sync tuning (frequency, event-type filtering).
|
||||
**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.
|
||||
**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.
|
||||
**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.
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
@@ -474,9 +482,9 @@ Check back after each Synapse upgrade — re-run `/matrix/client/versions` and `
|
||||
|
||||
## 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).
|
||||
|
||||
---
|
||||
|
||||
@@ -484,26 +492,35 @@ 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).
|
||||
|
||||
### 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`):**
|
||||
```typescript
|
||||
export const activeThreadIdAtom = atom<string | null>(null);
|
||||
```
|
||||
- **Layout (`src/app/features/room/Room.tsx`):** Insert `ThreadPanel` conditionally alongside `RoomTimeline`:
|
||||
```tsx
|
||||
{
|
||||
activeThreadId && (
|
||||
<>
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
<ThreadPanel roomId={roomId} threadId={activeThreadId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
- **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.
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Thread rendering | **New lean `ThreadTimeline`** reusing `Message`, `useVirtualPaginator`, and RoomTimeline's exported timeline helpers (lines 156-227). Do NOT refactor 2214-line RoomTimeline (its ~35 hooks are hardwired to the room live timeline). |
|
||||
| threadSupport | **Enable `threadSupport: true`** in `initMatrix.ts` (~line 39). ⚠️ Thread replies then LEAVE the main timeline (`room.js eventShouldLiveIn` → `shouldLiveInRoom:false`), retroactively on reload — MUST ship the "N replies" summary chip in the same release. Roots stay in both timelines. |
|
||||
| State | `roomIdToActiveThreadIdAtomFamily` (per-room, mirrors `roomIdToReplyDraftAtomFamily`) in new `state/room/thread.ts` + `getThreadDraftKey(roomId, threadRootId)` = `` `${roomId}::${threadRootId}` `` |
|
||||
| Composer | **Reuse RoomInput**: add optional `threadRootId` prop; scope its 3 atom-family lookups by draftKey (isolates thread drafts from the main composer); pass `threadRootId ?? null` at all 7 `mx.sendMessage/sendEvent` call sites — the SDK's `addThreadRelationIfNeeded` then emits spec-correct `m.thread` relations incl. reply-in-thread. Separate `useEditor()` instance in the panel. Hide schedule + commands in thread mode v1. |
|
||||
| Unreads | v1 = unread badge on the summary chip (`room.getThreadUnreadNotificationCount` — counts already synced independent of threadSupport) + `markThreadAsRead` threaded receipt when panel open at bottom. |
|
||||
| Mobile | Pure CSS like `MembersDrawer.css.ts`: fixed width toRem(360) desktop, `position:fixed; inset:0` under 750px. |
|
||||
|
||||
**Critical side-effect fixes (one-liners, land FIRST):**
|
||||
1. `initMatrix.ts` → `threadSupport: true`.
|
||||
2. `utils/notifications.ts:24` → `sendReadReceipt(latestEvent, type, /*unthreaded*/ true)` — otherwise markAsRead becomes `main`-scoped and room badges stick permanently unread (room unread total includes thread counts).
|
||||
|
||||
**Known SDK traps (verified):**
|
||||
- **Local echo gap:** chronological pending ordering means the thread timelineSet never receives pending events (`canContain` rejects; `room.getPendingEvents()` THROWS in this mode) — ThreadTimeline must render its own pending strip via `RoomEvent.LocalEchoUpdated` filtering on `threadRootId`, deduped against `thread.findEventById`.
|
||||
- **Bootstrap:** `room.getThread(id) ?? room.createThread(id, room.findEventById(id), [], false)` — the SDK auto-fetches via `/relations` and inserts the root at top; gate rendering on `thread.initialEventsFetched`; decrypt with `decryptAllTimelineEvent` after init + each pagination.
|
||||
- **Deep links:** `getEventTimeline(mainSet, threadEventId)` returns undefined for thread events — redirect jump-to-event to the panel (best-effort v1).
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
@@ -635,7 +652,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`.
|
||||
|
||||
|
||||
@@ -139,6 +139,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.
|
||||
|
||||
### 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
|
||||
|
||||
Generated
+34
@@ -51,6 +51,7 @@
|
||||
"immer": "11.1.8",
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.20.0",
|
||||
"katex": "0.16.11",
|
||||
"linkify-react": "4.3.3",
|
||||
"linkifyjs": "4.3.3",
|
||||
"matrix-js-sdk": "41.6.0-rc.0",
|
||||
@@ -83,6 +84,7 @@
|
||||
"@types/chroma-js": "3.1.2",
|
||||
"@types/file-saver": "2.0.7",
|
||||
"@types/is-hotkey": "0.1.10",
|
||||
"@types/katex": "0.16.8",
|
||||
"@types/node": "25.9.1",
|
||||
"@types/prismjs": "1.26.6",
|
||||
"@types/react": "19.2.15",
|
||||
@@ -3974,6 +3976,13 @@
|
||||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||
"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": {
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
@@ -9087,6 +9096,31 @@
|
||||
"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": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"immer": "11.1.8",
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.20.0",
|
||||
"katex": "0.16.11",
|
||||
"linkify-react": "4.3.3",
|
||||
"linkifyjs": "4.3.3",
|
||||
"matrix-js-sdk": "41.6.0-rc.0",
|
||||
@@ -108,6 +109,7 @@
|
||||
"@types/chroma-js": "3.1.2",
|
||||
"@types/file-saver": "2.0.7",
|
||||
"@types/is-hotkey": "0.1.10",
|
||||
"@types/katex": "0.16.8",
|
||||
"@types/node": "25.9.1",
|
||||
"@types/prismjs": "1.26.6",
|
||||
"@types/react": "19.2.15",
|
||||
|
||||
@@ -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,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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
toRem,
|
||||
Button,
|
||||
Switch,
|
||||
Chip,
|
||||
} from 'folds';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
@@ -41,7 +43,9 @@ import {
|
||||
ResultGroup,
|
||||
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 { SearchResultGroup } from './SearchResultGroup';
|
||||
import { SearchInput } from './SearchInput';
|
||||
@@ -240,6 +244,10 @@ export function MessageSearch({
|
||||
// Bump this whenever more messages are loaded so localResult re-computes
|
||||
const [cacheVersion, setCacheVersion] = useState(0);
|
||||
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)
|
||||
const localSearchRooms = useMemo(
|
||||
@@ -253,24 +261,43 @@ export function MessageSearch({
|
||||
const hasActiveSearch = msgSearchParams.term !== undefined || !!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 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.
|
||||
const localResult = useMemo(() => {
|
||||
if (!hasActiveSearch) return null;
|
||||
return searchLocalMessages({
|
||||
// The scan is async because — when the persistent cache is enabled — it also
|
||||
// reads cached rows from IndexedDB and merges them with the in-memory hits.
|
||||
// cacheVersion in deps so it re-runs after "Load more" paginates new events;
|
||||
// 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 ?? '',
|
||||
roomIds: localSearchRooms,
|
||||
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,
|
||||
localSearchRooms,
|
||||
msgSearchParams.term,
|
||||
msgSearchParams.senders,
|
||||
msgSearchParams.fromTs,
|
||||
msgSearchParams.toTs,
|
||||
hasActiveSearch,
|
||||
cacheVersion,
|
||||
searchCacheEnabled,
|
||||
]);
|
||||
|
||||
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.`
|
||||
: `No matches in your local cache. Load messages below to search further back.`}
|
||||
</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" />
|
||||
</Box>
|
||||
{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 { useAtomValue } from 'jotai';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { ResultGroup, ResultItem } from './useMessageSearch';
|
||||
import { searchCacheEnabledAtom } from '../../state/searchCacheEnabled';
|
||||
import {
|
||||
mergeSearchResults,
|
||||
queryRoom,
|
||||
saveRoomIndex,
|
||||
SearchCacheRow,
|
||||
} from '../../utils/searchCache';
|
||||
|
||||
export type LocalSearchParams = {
|
||||
term: string;
|
||||
roomIds: string[];
|
||||
senders?: string[];
|
||||
/** Optional date-range filter (ms). Applied to both memory and cached rows. */
|
||||
fromTs?: number;
|
||||
toTs?: number;
|
||||
};
|
||||
|
||||
export type LocalSearchResult = {
|
||||
@@ -17,19 +28,110 @@ export type LocalSearchResult = {
|
||||
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.
|
||||
* The homeserver cannot search E2EE message content, so we scan whatever the
|
||||
* client has already received and decrypted in memory.
|
||||
*
|
||||
* Limitation: only messages present in the live timeline window are covered.
|
||||
* Rooms that haven't been opened yet will return no results.
|
||||
* When the persistent search cache is enabled (opt-in), the in-memory scan is
|
||||
* 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 = () => {
|
||||
const mx = useMatrixClient();
|
||||
const cacheEnabled = useAtomValue(searchCacheEnabledAtom);
|
||||
|
||||
const search = useCallback(
|
||||
({ term, roomIds, senders }: LocalSearchParams): LocalSearchResult => {
|
||||
async ({
|
||||
term,
|
||||
roomIds,
|
||||
senders,
|
||||
fromTs,
|
||||
toTs,
|
||||
}: LocalSearchParams): Promise<LocalSearchResult> => {
|
||||
const trimmedTerm = term.trim();
|
||||
const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
|
||||
|
||||
@@ -41,6 +143,9 @@ export const useLocalMessageSearch = () => {
|
||||
}
|
||||
|
||||
const termLower = trimmedTerm.toLowerCase();
|
||||
const inRange = (ts: number): boolean =>
|
||||
(fromTs === undefined || ts >= fromTs) && (toTs === undefined || ts <= toTs);
|
||||
|
||||
const groups: ResultGroup[] = [];
|
||||
let encryptedRoomsCount = 0;
|
||||
let searchedRoomsCount = 0;
|
||||
@@ -61,106 +166,99 @@ export const useLocalMessageSearch = () => {
|
||||
.getUnfilteredTimelineSet()
|
||||
.getTimelines()
|
||||
.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;
|
||||
|
||||
const items: ResultItem[] = [];
|
||||
const memoryItems: ResultItem[] = [];
|
||||
const rowsToPersist: SearchCacheRow[] = [];
|
||||
|
||||
for (let i = 0; i < events.length; i += 1) {
|
||||
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.isRedacted()) continue;
|
||||
if (senderSet && !senderSet.has(event.getSender() ?? '')) continue;
|
||||
|
||||
// getContent() returns decrypted plaintext regardless of encryption
|
||||
const content = event.getContent();
|
||||
const evType = event.getType();
|
||||
const isSticker = evType === 'm.sticker';
|
||||
const isMessageLike =
|
||||
evType === EventType.RoomMessage || POLL_START_TYPES.includes(evType);
|
||||
|
||||
// Sender-only mode: no text filter needed
|
||||
if (!senderOnlyMode) {
|
||||
const evType = event.getType();
|
||||
const isPoll = evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
|
||||
// Sender-only mode indexes/returns all message types; text mode needs text.
|
||||
if (!senderOnlyMode && !isMessageLike && !isSticker) continue;
|
||||
|
||||
let body = '';
|
||||
let formattedBody = '';
|
||||
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();
|
||||
}
|
||||
}
|
||||
const sender = event.getSender() ?? '';
|
||||
const ts = event.getTs();
|
||||
const text = extractText(event);
|
||||
|
||||
if (
|
||||
!body.toLowerCase().includes(termLower) &&
|
||||
!formattedBody.toLowerCase().includes(termLower)
|
||||
)
|
||||
continue;
|
||||
// Persist every indexable (text-bearing) event we scanned, regardless
|
||||
// of whether it matches the current term — future searches benefit.
|
||||
if (cacheEnabled && text && event.getId()) {
|
||||
rowsToPersist.push({
|
||||
roomId,
|
||||
eventId: event.getId() as string,
|
||||
ts,
|
||||
sender,
|
||||
body: text.body,
|
||||
...(text.formattedBody ? { formattedBody: text.formattedBody } : {}),
|
||||
...(text.pollText ? { pollText: text.pollText } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Build a synthetic IEventWithRoomId using decrypted content so the
|
||||
// existing SearchResultGroup renderer works without modification.
|
||||
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: event.getType(),
|
||||
sender: event.getSender() ?? '',
|
||||
origin_server_ts: event.getTs(),
|
||||
type: evType,
|
||||
sender,
|
||||
origin_server_ts: ts,
|
||||
content,
|
||||
unsigned: event.getUnsigned(),
|
||||
};
|
||||
|
||||
items.push({
|
||||
memoryItems.push({
|
||||
rank: 0,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
event: syntheticEvent as any,
|
||||
context: {
|
||||
events_before: [],
|
||||
events_after: [],
|
||||
profile_info: {},
|
||||
},
|
||||
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) {
|
||||
items.sort((a, b) => (b.event.origin_server_ts ?? 0) - (a.event.origin_server_ts ?? 0));
|
||||
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 };
|
||||
},
|
||||
[mx],
|
||||
[mx, cacheEnabled],
|
||||
);
|
||||
|
||||
return search;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+260
-181
@@ -1,9 +1,11 @@
|
||||
import React, {
|
||||
KeyboardEventHandler,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
@@ -98,7 +100,11 @@ import { safeFile } from '../../utils/mimeTypes';
|
||||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import {
|
||||
ComposerToolbarButtonKey,
|
||||
normalizeComposerToolbarOrder,
|
||||
settingsAtom,
|
||||
} from '../../state/settings';
|
||||
import {
|
||||
getAudioMsgContent,
|
||||
getFileMsgContent,
|
||||
@@ -128,6 +134,7 @@ import { PollCreator } from './PollCreator';
|
||||
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
|
||||
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
|
||||
import { DraftIndicator } from './DraftIndicator';
|
||||
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
||||
|
||||
const GifPicker = React.lazy(() =>
|
||||
@@ -219,6 +226,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
||||
const showVoice = composerToolbarButtons?.showVoice ?? true;
|
||||
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
|
||||
const composerButtonOrder = useMemo(
|
||||
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
|
||||
[composerToolbarButtons?.order],
|
||||
);
|
||||
const [locating, setLocating] = React.useState(false);
|
||||
const [locationError, setLocationError] = React.useState<string | null>(null);
|
||||
const handleShareLocation = useCallback(() => {
|
||||
@@ -358,13 +369,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const nodes = JSON.parse(stored);
|
||||
if (Array.isArray(nodes) && nodes.length > 0) {
|
||||
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 {
|
||||
// Ignore malformed stored draft
|
||||
}
|
||||
}
|
||||
}, [editor, msgDraft, roomId]);
|
||||
}, [editor, msgDraft, roomId, setMsgDraft]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -954,59 +969,33 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
<Icon src={Icons.PlusCircle} />
|
||||
</IconButton>
|
||||
}
|
||||
after={
|
||||
<>
|
||||
{showFormat && (
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
style={touchTarget}
|
||||
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
|
||||
aria-pressed={toolbar}
|
||||
onClick={() => setToolbar(!toolbar)}
|
||||
>
|
||||
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||
</IconButton>
|
||||
)}
|
||||
{(showEmoji || showSticker) && (
|
||||
<UseStateProvider initial={undefined}>
|
||||
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
||||
<PopOut
|
||||
offset={16}
|
||||
alignOffset={-44}
|
||||
position="Top"
|
||||
align="End"
|
||||
anchor={
|
||||
emojiBoardTab === undefined
|
||||
? undefined
|
||||
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
|
||||
}
|
||||
content={
|
||||
<React.Suspense fallback={null}>
|
||||
<EmojiBoard
|
||||
tab={emojiBoardTab}
|
||||
onTabChange={setEmojiBoardTab}
|
||||
imagePackRooms={imagePackRooms}
|
||||
returnFocusOnDeactivate={false}
|
||||
onEmojiSelect={handleEmoticonSelect}
|
||||
onCustomEmojiSelect={handleEmoticonSelect}
|
||||
onStickerSelect={handleStickerSelect}
|
||||
requestClose={() => {
|
||||
setEmojiBoardTab((t) => {
|
||||
if (t) {
|
||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||
return undefined;
|
||||
}
|
||||
return t;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
}
|
||||
>
|
||||
{showSticker && !hideStickerBtn && (
|
||||
after={(() => {
|
||||
const formatButton = showFormat ? (
|
||||
<IconButton
|
||||
key="showFormat"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
style={touchTarget}
|
||||
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
|
||||
aria-pressed={toolbar}
|
||||
onClick={() => setToolbar(!toolbar)}
|
||||
>
|
||||
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||
</IconButton>
|
||||
) : null;
|
||||
|
||||
// Emoji and Sticker share a single EmojiBoard PopOut anchored to the
|
||||
// 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)}
|
||||
@@ -1020,36 +1009,76 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
filled={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
{showEmoji && (
|
||||
<IconButton
|
||||
ref={emojiBtnRef}
|
||||
aria-label="Insert emoji"
|
||||
aria-pressed={
|
||||
) : 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
|
||||
}
|
||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
style={touchTarget}
|
||||
>
|
||||
<Icon
|
||||
src={Icons.Smile}
|
||||
filled={
|
||||
hideStickerBtn
|
||||
? !!emojiBoardTab
|
||||
: emojiBoardTab === EmojiBoardTab.Emoji
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</PopOut>
|
||||
)}
|
||||
/>
|
||||
</IconButton>
|
||||
) : null;
|
||||
const emojiFirst =
|
||||
composerButtonOrder.indexOf('showEmoji') <
|
||||
composerButtonOrder.indexOf('showSticker');
|
||||
return (
|
||||
<PopOut
|
||||
offset={16}
|
||||
alignOffset={-44}
|
||||
position="Top"
|
||||
align="End"
|
||||
anchor={
|
||||
emojiBoardTab === undefined
|
||||
? undefined
|
||||
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
|
||||
}
|
||||
content={
|
||||
<React.Suspense fallback={null}>
|
||||
<EmojiBoard
|
||||
tab={emojiBoardTab}
|
||||
onTabChange={setEmojiBoardTab}
|
||||
imagePackRooms={imagePackRooms}
|
||||
returnFocusOnDeactivate={false}
|
||||
onEmojiSelect={handleEmoticonSelect}
|
||||
onCustomEmojiSelect={handleEmoticonSelect}
|
||||
onStickerSelect={handleStickerSelect}
|
||||
requestClose={() => {
|
||||
setEmojiBoardTab((t) => {
|
||||
if (t) {
|
||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||
return undefined;
|
||||
}
|
||||
return t;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
}
|
||||
>
|
||||
{emojiFirst ? [emojiBtn, stickerBtn] : [stickerBtn, emojiBtn]}
|
||||
</PopOut>
|
||||
);
|
||||
}}
|
||||
</UseStateProvider>
|
||||
)}
|
||||
{!!gifApiKey && showGif && (
|
||||
<UseStateProvider initial={false}>
|
||||
) : null;
|
||||
|
||||
const gifButton =
|
||||
!!gifApiKey && showGif ? (
|
||||
<UseStateProvider key="showGif" initial={false}>
|
||||
{(gifOpen: boolean, setGifOpen) => (
|
||||
<PopOut
|
||||
offset={16}
|
||||
@@ -1101,113 +1130,163 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
</PopOut>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
)}
|
||||
{gifError && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{
|
||||
color: color.Critical.Main,
|
||||
padding: '2px 6px',
|
||||
alignSelf: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{gifError}
|
||||
</Text>
|
||||
)}
|
||||
{locationError && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{
|
||||
color: color.Critical.Main,
|
||||
padding: '2px 6px',
|
||||
alignSelf: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{locationError}
|
||||
</Text>
|
||||
)}
|
||||
{showLocation && (
|
||||
<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 && (
|
||||
<Text
|
||||
size="T200"
|
||||
priority="300"
|
||||
style={{
|
||||
padding: `0 ${config.space.S100}`,
|
||||
alignSelf: 'center',
|
||||
userSelect: 'none',
|
||||
minWidth: '2rem',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{charCount}
|
||||
</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>
|
||||
)}
|
||||
) : null;
|
||||
|
||||
const locationButton = showLocation ? (
|
||||
<IconButton
|
||||
onClick={submit}
|
||||
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="Send message"
|
||||
aria-label="Schedule message"
|
||||
title="Schedule message"
|
||||
>
|
||||
<Icon src={Icons.Send} />
|
||||
<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 && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{
|
||||
color: color.Critical.Main,
|
||||
padding: '2px 6px',
|
||||
alignSelf: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{gifError}
|
||||
</Text>
|
||||
)}
|
||||
{locationError && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{
|
||||
color: color.Critical.Main,
|
||||
padding: '2px 6px',
|
||||
alignSelf: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{locationError}
|
||||
</Text>
|
||||
)}
|
||||
<DraftIndicator roomId={roomId} />
|
||||
{charCount > 0 && (
|
||||
<Text
|
||||
size="T200"
|
||||
priority="300"
|
||||
style={{
|
||||
padding: `0 ${config.space.S100}`,
|
||||
alignSelf: 'center',
|
||||
userSelect: 'none',
|
||||
minWidth: '2rem',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{charCount}
|
||||
</Text>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={submit}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
style={touchTarget}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Icon src={Icons.Send} />
|
||||
</IconButton>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
bottom={
|
||||
toolbar && (
|
||||
<div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../../../components/AccountDataEditor';
|
||||
import { copyToClipboard } from '../../../utils/dom';
|
||||
import { AccountData } from './AccountData';
|
||||
import { CryptoDiagnostics } from '../developer/CryptoDiagnostics';
|
||||
|
||||
type DeveloperToolsProps = {
|
||||
requestClose: () => void;
|
||||
@@ -109,6 +110,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||
/>
|
||||
</SequenceCard>
|
||||
)}
|
||||
{developerTools && <CryptoDiagnostics />}
|
||||
</Box>
|
||||
{developerTools && (
|
||||
<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,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
@@ -34,6 +35,19 @@ import {
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
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 { BgSwatch as BgSwatchStyle } from './BgSwatch.css';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
@@ -47,12 +61,14 @@ import { useSetting } from '../../../state/hooks/settings';
|
||||
import {
|
||||
CallAudioBitrate,
|
||||
ChatBackground,
|
||||
ComposerToolbarButtonKey,
|
||||
ComposerToolbarSettings,
|
||||
DateFormat,
|
||||
DenoiseModelId,
|
||||
MessageLayout,
|
||||
MessageSpacing,
|
||||
NoiseSuppressionMode,
|
||||
normalizeComposerToolbarOrder,
|
||||
RingtoneId,
|
||||
ScreenshareBitrate,
|
||||
ScreenshareFramerate,
|
||||
@@ -86,12 +102,33 @@ import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||
import { isTauri as isTauriEnv } from '../../../hooks/useTauri';
|
||||
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
|
||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||
import { playCallJoinSound } from '../../../utils/callSounds';
|
||||
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
||||
import { DenoiseTester } from './DenoiseTester';
|
||||
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 = {
|
||||
themeNames: Record<string, string>;
|
||||
themes: Theme[];
|
||||
@@ -405,6 +442,8 @@ function Appearance() {
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<DesktopChromeSetting />
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Twitter Emoji"
|
||||
@@ -1025,6 +1064,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() {
|
||||
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
@@ -1034,20 +1232,31 @@ function Editor() {
|
||||
'composerToolbarButtons',
|
||||
);
|
||||
|
||||
const toggleToolbarButton = (key: keyof ComposerToolbarSettings) => {
|
||||
setComposerToolbarButtons({ ...composerToolbarButtons, [key]: !composerToolbarButtons[key] });
|
||||
};
|
||||
const composerToolbarOrder = useMemo(
|
||||
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
|
||||
[composerToolbarButtons?.order],
|
||||
);
|
||||
|
||||
const TOOLBAR_CHIPS: Array<{ key: keyof ComposerToolbarSettings; label: string }> = [
|
||||
{ key: 'showFormat', label: 'Format' },
|
||||
{ key: 'showEmoji', label: 'Emoji' },
|
||||
{ key: 'showSticker', label: 'Sticker' },
|
||||
{ key: 'showGif', label: 'GIF' },
|
||||
{ key: 'showLocation', label: 'Location' },
|
||||
{ key: 'showPoll', label: 'Poll' },
|
||||
{ key: 'showVoice', label: 'Voice' },
|
||||
{ key: 'showSchedule', label: 'Schedule' },
|
||||
];
|
||||
const toggleToolbarButton = useCallback(
|
||||
(key: ComposerToolbarButtonKey) => {
|
||||
setComposerToolbarButtons((current) => ({ ...current, [key]: !current[key] }));
|
||||
},
|
||||
[setComposerToolbarButtons],
|
||||
);
|
||||
|
||||
const reorderToolbarButtons = useCallback(
|
||||
(startIndex: number, finishIndex: number) => {
|
||||
setComposerToolbarButtons((current) => ({
|
||||
...current,
|
||||
order: reorder({
|
||||
list: normalizeComposerToolbarOrder(current.order),
|
||||
startIndex,
|
||||
finishIndex,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
[setComposerToolbarButtons],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
@@ -1082,28 +1291,15 @@ function Editor() {
|
||||
>
|
||||
<SettingTile
|
||||
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
|
||||
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 direction="Column" style={{ paddingBottom: config.space.S200 }}>
|
||||
<ComposerToolbarReorder
|
||||
order={composerToolbarOrder}
|
||||
buttons={composerToolbarButtons}
|
||||
onReorder={reorderToolbarButtons}
|
||||
onToggle={toggleToolbarButton}
|
||||
/>
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
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 =>
|
||||
useCallback(
|
||||
(evt) => {
|
||||
const files = getDataTransferFiles(evt.dataTransfer);
|
||||
if (files) onDrop(files);
|
||||
// `collectDroppedFiles` synchronously captures the entry list from the
|
||||
// DataTransfer before traversing folders asynchronously.
|
||||
collectDroppedFiles(evt.dataTransfer)
|
||||
.then((files) => {
|
||||
if (files) onDrop(files);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
},
|
||||
[onDrop],
|
||||
);
|
||||
@@ -24,8 +29,14 @@ export const useFileDropZone = (
|
||||
dragCounterRef.current = 0;
|
||||
setActive(false);
|
||||
if (!evt.dataTransfer) return;
|
||||
const files = getDataTransferFiles(evt.dataTransfer);
|
||||
if (files) onDrop(files);
|
||||
// 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);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
target?.addEventListener('drop', handleDrop);
|
||||
|
||||
@@ -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;
|
||||
}, []);
|
||||
};
|
||||
@@ -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]);
|
||||
}
|
||||
+38
-2
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { ReactNode, useEffect } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
|
||||
import {
|
||||
@@ -25,6 +25,10 @@ import { useCompositionEndTracking } from '../hooks/useComposingCheck';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
||||
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 { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
|
||||
import { zIndices } from '../styles/zIndex';
|
||||
@@ -88,6 +92,36 @@ function TauriEffects() {
|
||||
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() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
if (!settings.nightLightEnabled) return null;
|
||||
@@ -160,7 +194,9 @@ function App() {
|
||||
<JotaiProvider>
|
||||
<AppearanceEffects />
|
||||
<TauriEffects />
|
||||
<RouterProvider router={createRouter(clientConfig, screenSize)} />
|
||||
<DesktopChrome>
|
||||
<RouterProvider router={createRouter(clientConfig, screenSize)} />
|
||||
</DesktopChrome>
|
||||
<SeasonalEffect />
|
||||
<NightLightOverlay />
|
||||
<LotusToastContainer />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { focusAssistActiveAtom } from '../../state/focusAssist';
|
||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||
import LogoSVG from '../../../../public/res/lotus.png';
|
||||
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
|
||||
@@ -33,6 +34,7 @@ import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||
import { toastQueueAtom } from '../../state/toast';
|
||||
import { useReminders } from '../../hooks/useReminders';
|
||||
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
|
||||
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
||||
|
||||
function isInQuietHours(start: string, end: string): boolean {
|
||||
const now = new Date();
|
||||
@@ -109,6 +111,7 @@ function InviteNotifications() {
|
||||
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
||||
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
||||
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
||||
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
||||
const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId');
|
||||
@@ -167,7 +170,8 @@ function InviteNotifications() {
|
||||
|
||||
useEffect(() => {
|
||||
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
|
||||
const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd);
|
||||
const quietActive =
|
||||
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
||||
if (!quietActive) {
|
||||
if (showNotifications && notificationPermission('granted')) {
|
||||
notify(invites.length - perviousInviteLen);
|
||||
@@ -189,6 +193,7 @@ function InviteNotifications() {
|
||||
quietHoursEnabled,
|
||||
quietHoursStart,
|
||||
quietHoursEnd,
|
||||
focusAssistActive,
|
||||
inviteSoundId,
|
||||
]);
|
||||
|
||||
@@ -212,6 +217,7 @@ function MessageNotifications() {
|
||||
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
||||
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
||||
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
||||
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
||||
const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId');
|
||||
@@ -355,7 +361,8 @@ function MessageNotifications() {
|
||||
return;
|
||||
}
|
||||
|
||||
const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd);
|
||||
const quietActive =
|
||||
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
||||
if (!quietActive) {
|
||||
if (showNotifications && notificationPermission('granted')) {
|
||||
const avatarMxc =
|
||||
@@ -394,6 +401,7 @@ function MessageNotifications() {
|
||||
quietHoursEnabled,
|
||||
quietHoursStart,
|
||||
quietHoursEnd,
|
||||
focusAssistActive,
|
||||
messageSoundId,
|
||||
]);
|
||||
|
||||
@@ -555,6 +563,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
<MessageNotifications />
|
||||
<ReminderMonitor />
|
||||
<TauriUpdateFeature />
|
||||
<TauriDesktopFeatures />
|
||||
<LotusDenoiseFeature />
|
||||
<DeepLinkNavigator />
|
||||
{children}
|
||||
|
||||
@@ -43,8 +43,15 @@ import { stopPropagation } from '../../utils/keyboard';
|
||||
import { SyncStatus } from './SyncStatus';
|
||||
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
||||
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
|
||||
import { useSessionSync } from '../../hooks/useSessionSync';
|
||||
import { installCryptoDiagLog } from '../../utils/cryptoDiagLog';
|
||||
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() {
|
||||
return (
|
||||
<SplashScreen>
|
||||
@@ -178,6 +185,9 @@ export function ClientRoot({ children }: ClientRootProps) {
|
||||
);
|
||||
|
||||
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(() => {
|
||||
if (loadState.status === AsyncStatus.Idle) {
|
||||
|
||||
@@ -43,9 +43,14 @@ import { onEnterOrSpace } from '../utils/keyboard';
|
||||
import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom';
|
||||
import { useTimeoutToggle } from '../hooks/useTimeoutToggle';
|
||||
import { tokenize, tokenStyle } from '../utils/syntaxHighlight';
|
||||
import { splitMathSegments } from '../utils/mathParse';
|
||||
|
||||
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. */
|
||||
const TDS_TOKENIZER_LANGS = new Set([
|
||||
'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');
|
||||
|
||||
export const LINKIFY_OPTS: LinkifyOpts = {
|
||||
@@ -503,6 +529,21 @@ export const getReactCustomHtmlParser = (
|
||||
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) {
|
||||
return (
|
||||
<span
|
||||
@@ -546,20 +587,50 @@ export const getReactCustomHtmlParser = (
|
||||
}
|
||||
|
||||
if (domNode instanceof DOMText) {
|
||||
const linkify =
|
||||
!(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'code') &&
|
||||
!(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'a');
|
||||
const parentName =
|
||||
domNode.parent && 'name' in domNode.parent ? domNode.parent.name : undefined;
|
||||
const linkify = parentName !== 'code' && parentName !== 'a';
|
||||
// Never parse `$…$`/`$$…$$` math inside <pre>/<code> (verbatim regions).
|
||||
const mathAllowed = parentName !== 'code' && parentName !== 'pre';
|
||||
|
||||
let jsx = scaleSystemEmoji(domNode.data);
|
||||
const renderTextChunk = (text: string): (string | JSX.Element)[] | JSX.Element => {
|
||||
let jsx = scaleSystemEmoji(text);
|
||||
if (params.highlightRegex) {
|
||||
jsx = highlightText(params.highlightRegex, jsx);
|
||||
}
|
||||
if (linkify) {
|
||||
return <Linkify options={params.linkifyOpts}>{jsx}</Linkify>;
|
||||
}
|
||||
return jsx;
|
||||
};
|
||||
|
||||
if (params.highlightRegex) {
|
||||
jsx = highlightText(params.highlightRegex, jsx);
|
||||
if (mathAllowed) {
|
||||
const segments = splitMathSegments(domNode.data);
|
||||
if (segments.some((segment) => segment.type !== 'text')) {
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === 'text') {
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
return (
|
||||
<React.Fragment key={index}>{renderTextChunk(segment.value)}</React.Fragment>
|
||||
);
|
||||
}
|
||||
const raw =
|
||||
segment.type === 'block' ? `$$${segment.value}$$` : `$${segment.value}$`;
|
||||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<React.Fragment key={index}>
|
||||
{renderMath(segment.value, segment.type === 'block', raw, raw)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (linkify) {
|
||||
return <Linkify options={params.linkifyOpts}>{jsx}</Linkify>;
|
||||
}
|
||||
return jsx;
|
||||
return renderTextChunk(domNode.data);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
atomWithLocalStorage,
|
||||
getLocalStorageItem,
|
||||
setLocalStorageItem,
|
||||
} from './utils/atomWithLocalStorage';
|
||||
|
||||
const CUSTOM_WINDOW_CHROME = 'customWindowChrome';
|
||||
|
||||
/**
|
||||
* P5-47 — TDS Custom Window Chrome opt-in flag (default `false`).
|
||||
*
|
||||
* Standalone, `localStorage`-backed boolean atom kept separate from
|
||||
* `state/settings.ts` on purpose. When `true` (and running inside Tauri) the app
|
||||
* strips the native window frame and renders its own `<TitleBar/>`; when `false`
|
||||
* the native OS frame is used. The feature is runtime-reversible, so flipping
|
||||
* this atom is all it takes to switch back and forth.
|
||||
*/
|
||||
export const customWindowChromeAtom = atomWithLocalStorage<boolean>(
|
||||
CUSTOM_WINDOW_CHROME,
|
||||
(key) => getLocalStorageItem<boolean>(key, false),
|
||||
(key, value) => setLocalStorageItem(key, value),
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
/**
|
||||
* P5-56 — Windows Focus Assist ↔ Do-Not-Disturb sync (live OS state).
|
||||
*
|
||||
* Standalone, non-persisted boolean atom reflecting whether the shell is
|
||||
* currently suppressing notifications (Focus Assist / Quiet Hours, presentation
|
||||
* mode, full-screen D3D, or "busy"). It is driven at runtime by
|
||||
* `useTauriFocusAssist` from the native `focus-assist-changed` event and read by
|
||||
* the notification gate. Because it mirrors transient OS state — not a user
|
||||
* preference — it is a plain in-memory atom that defaults to `false` and is
|
||||
* intentionally NOT written to `localStorage`.
|
||||
*/
|
||||
export const focusAssistActiveAtom = atom(false);
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
atomWithLocalStorage,
|
||||
getLocalStorageItem,
|
||||
setLocalStorageItem,
|
||||
} from './utils/atomWithLocalStorage';
|
||||
|
||||
const SEARCH_CACHE_ENABLED = 'searchCacheEnabled';
|
||||
|
||||
/**
|
||||
* P4-8 — persistent encrypted-search cache opt-in flag (default `false`).
|
||||
*
|
||||
* Standalone, `localStorage`-backed boolean atom kept separate from
|
||||
* `state/settings.ts` on purpose. When `true`, encrypted-room search persists a
|
||||
* decrypted plaintext index to IndexedDB (`lotus-search-cache`) so coverage
|
||||
* survives reloads. Because this writes decrypted plaintext at rest it must be
|
||||
* explicitly opted into; the cache is clearable from the search UI and wiped on
|
||||
* logout. Toggling this atom off stops all reads/writes but does NOT wipe
|
||||
* existing data — that is the explicit "Clear cached index" button / logout.
|
||||
*/
|
||||
export const searchCacheEnabledAtom = atomWithLocalStorage<boolean>(
|
||||
SEARCH_CACHE_ENABLED,
|
||||
(key) => getLocalStorageItem<boolean>(key, false),
|
||||
(key, value) => setLocalStorageItem(key, value),
|
||||
);
|
||||
@@ -1,6 +1,14 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setFallbackSession, removeFallbackSession, getFallbackSession } from './sessions';
|
||||
import {
|
||||
setFallbackSession,
|
||||
removeFallbackSession,
|
||||
getFallbackSession,
|
||||
subscribeSessionChanges,
|
||||
} from './sessions';
|
||||
|
||||
// The single-key atomic blob (kept in sync with SESSION_BLOB_KEY in sessions.ts).
|
||||
const SESSION_BLOB_KEY = 'cinny_session_v1';
|
||||
|
||||
// The fallback-session helpers read/write specific `cinny_*` keys directly on
|
||||
// `localStorage`. node has none, so install a controllable in-memory mock per
|
||||
@@ -47,8 +55,9 @@ test('getFallbackSession returns undefined when nothing is stored', () => {
|
||||
assert.equal(getFallbackSession(), undefined);
|
||||
});
|
||||
|
||||
test('getFallbackSession returns undefined when a single key is missing', () => {
|
||||
// Every one of the four keys is required; missing any one yields undefined.
|
||||
test('legacy path: undefined when a single legacy key is missing (no blob)', () => {
|
||||
// With no atomic blob, every one of the four legacy keys is required; missing
|
||||
// any one yields undefined (the pre-blob behaviour).
|
||||
const keys = [
|
||||
'cinny_access_token',
|
||||
'cinny_device_id',
|
||||
@@ -59,11 +68,26 @@ test('getFallbackSession returns undefined when a single key is missing', () =>
|
||||
keys.forEach((missing) => {
|
||||
installStorage();
|
||||
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
|
||||
localStorage.removeItem(SESSION_BLOB_KEY);
|
||||
localStorage.removeItem(missing);
|
||||
assert.equal(getFallbackSession(), undefined, `missing ${missing} should yield undefined`);
|
||||
});
|
||||
});
|
||||
|
||||
test('blob wins: a torn legacy key does NOT tear the session while the blob exists', () => {
|
||||
installStorage();
|
||||
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
|
||||
// Simulate a torn legacy write — the authoritative blob must still resolve.
|
||||
localStorage.removeItem('cinny_access_token');
|
||||
assert.deepEqual(getFallbackSession(), {
|
||||
baseUrl: 'https://hs.example.org',
|
||||
userId: '@alice:example.org',
|
||||
deviceId: 'DEVICE1',
|
||||
accessToken: 'token-1',
|
||||
fallbackSdkStores: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('removeFallbackSession clears all keys', () => {
|
||||
const store = installStorage();
|
||||
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
|
||||
@@ -113,4 +137,298 @@ test('a password session carries no OIDC fields, and re-saving clears stale OIDC
|
||||
assert.ok(s);
|
||||
assert.equal(s.oidc, undefined);
|
||||
assert.equal(s.refreshToken, undefined);
|
||||
// The overwritten blob must not retain the stale OIDC state either.
|
||||
const blob = JSON.parse(localStorage.getItem(SESSION_BLOB_KEY)!);
|
||||
assert.equal(blob.oidc, undefined);
|
||||
assert.equal(blob.refreshToken, undefined);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Atomic blob: write/read round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('setFallbackSession writes a single atomic blob under cinny_session_v1', () => {
|
||||
const store = installStorage();
|
||||
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
|
||||
|
||||
const raw = store.get(SESSION_BLOB_KEY);
|
||||
assert.ok(raw, 'blob key must be written');
|
||||
assert.deepEqual(JSON.parse(raw!), {
|
||||
accessToken: 'token-1',
|
||||
deviceId: 'DEVICE1',
|
||||
userId: '@alice:example.org',
|
||||
baseUrl: 'https://hs.example.org',
|
||||
});
|
||||
});
|
||||
|
||||
test('blob round-trips a full OIDC session (absolute expiry stored, remaining read back)', () => {
|
||||
const store = installStorage();
|
||||
setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', {
|
||||
refreshToken: 'refresh-xyz',
|
||||
expiresInMs: 3_600_000,
|
||||
oidc: {
|
||||
issuer: 'https://i',
|
||||
clientId: 'c',
|
||||
redirectUri: 'https://cb',
|
||||
idTokenClaims: { sub: '@bob:mozilla.org' },
|
||||
},
|
||||
});
|
||||
|
||||
const blob = JSON.parse(store.get(SESSION_BLOB_KEY)!);
|
||||
assert.equal(blob.refreshToken, 'refresh-xyz');
|
||||
assert.ok(typeof blob.expiresAt === 'number' && blob.expiresAt > Date.now());
|
||||
assert.deepEqual(blob.oidc, {
|
||||
issuer: 'https://i',
|
||||
clientId: 'c',
|
||||
redirectUri: 'https://cb',
|
||||
idTokenClaims: { sub: '@bob:mozilla.org' },
|
||||
});
|
||||
|
||||
const s = getFallbackSession();
|
||||
assert.ok(s);
|
||||
assert.equal(s.refreshToken, 'refresh-xyz');
|
||||
assert.ok(s.expiresInMs! > 0 && s.expiresInMs! <= 3_600_000);
|
||||
assert.deepEqual(s.oidc, {
|
||||
issuer: 'https://i',
|
||||
clientId: 'c',
|
||||
redirectUri: 'https://cb',
|
||||
idTokenClaims: { sub: '@bob:mozilla.org' },
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migration: legacy-only storage → transparent read → blob persisted on write
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('legacy-only storage (no blob) is read transparently', () => {
|
||||
const store = installStorage();
|
||||
// Simulate an older build: legacy keys present, no blob.
|
||||
store.set('cinny_access_token', 'tok');
|
||||
store.set('cinny_device_id', 'DEV');
|
||||
store.set('cinny_user_id', '@carol:example.org');
|
||||
store.set('cinny_hs_base_url', 'https://hs');
|
||||
assert.equal(store.has(SESSION_BLOB_KEY), false);
|
||||
|
||||
assert.deepEqual(getFallbackSession(), {
|
||||
baseUrl: 'https://hs',
|
||||
userId: '@carol:example.org',
|
||||
deviceId: 'DEV',
|
||||
accessToken: 'tok',
|
||||
fallbackSdkStores: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('first write after a legacy-only read persists the blob (migration)', () => {
|
||||
const store = installStorage();
|
||||
store.set('cinny_access_token', 'old');
|
||||
store.set('cinny_device_id', 'DEV');
|
||||
store.set('cinny_user_id', '@carol:example.org');
|
||||
store.set('cinny_hs_base_url', 'https://hs');
|
||||
|
||||
// Reads are side-effect free — no blob yet.
|
||||
getFallbackSession();
|
||||
assert.equal(store.has(SESSION_BLOB_KEY), false);
|
||||
|
||||
// The next write (e.g. a token refresh) persists the atomic blob.
|
||||
setFallbackSession('new', 'DEV', '@carol:example.org', 'https://hs');
|
||||
assert.ok(store.has(SESSION_BLOB_KEY));
|
||||
assert.equal(getFallbackSession()?.accessToken, 'new');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Corruption / partial blob → legacy fallback; blob wins on disagreement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('corrupt blob (bad JSON) falls back to the legacy keys', () => {
|
||||
const store = installStorage();
|
||||
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
|
||||
// Corrupt the blob but keep the legacy keys intact.
|
||||
store.set(SESSION_BLOB_KEY, '{not valid json');
|
||||
|
||||
assert.deepEqual(getFallbackSession(), {
|
||||
baseUrl: 'https://hs.example.org',
|
||||
userId: '@alice:example.org',
|
||||
deviceId: 'DEVICE1',
|
||||
accessToken: 'token-1',
|
||||
fallbackSdkStores: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('partial blob (missing a required field) falls back to the legacy keys', () => {
|
||||
const store = installStorage();
|
||||
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
|
||||
// A blob missing accessToken is treated as absent.
|
||||
store.set(
|
||||
SESSION_BLOB_KEY,
|
||||
JSON.stringify({ deviceId: 'DEVICE1', userId: '@alice:example.org', baseUrl: 'https://hs' }),
|
||||
);
|
||||
|
||||
assert.equal(getFallbackSession()?.accessToken, 'token-1');
|
||||
});
|
||||
|
||||
test('blob wins when blob and legacy keys disagree', () => {
|
||||
const store = installStorage();
|
||||
setFallbackSession('blob-token', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
|
||||
// Legacy keys drift to a stale token; the blob is authoritative.
|
||||
store.set('cinny_access_token', 'stale-legacy-token');
|
||||
|
||||
assert.equal(getFallbackSession()?.accessToken, 'blob-token');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dual-write keeps blob + legacy in sync; removal clears both
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('dual-write keeps the legacy keys in sync with the blob', () => {
|
||||
const store = installStorage();
|
||||
setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', {
|
||||
refreshToken: 'r',
|
||||
expiresInMs: 1000,
|
||||
oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' },
|
||||
});
|
||||
|
||||
// Legacy credential keys
|
||||
assert.equal(store.get('cinny_access_token'), 'tok');
|
||||
assert.equal(store.get('cinny_device_id'), 'DEV');
|
||||
assert.equal(store.get('cinny_user_id'), '@bob:mozilla.org');
|
||||
assert.equal(store.get('cinny_hs_base_url'), 'https://hs');
|
||||
// Legacy OIDC keys
|
||||
assert.equal(store.get('cinny_refresh_token'), 'r');
|
||||
assert.ok(store.has('cinny_expires_at'));
|
||||
assert.equal(store.get('cinny_oidc_issuer'), 'https://i');
|
||||
assert.equal(store.get('cinny_oidc_client_id'), 'c');
|
||||
assert.equal(store.get('cinny_oidc_redirect_uri'), 'https://cb');
|
||||
// Blob agrees
|
||||
const blob = JSON.parse(store.get(SESSION_BLOB_KEY)!);
|
||||
assert.equal(blob.accessToken, 'tok');
|
||||
assert.equal(blob.refreshToken, 'r');
|
||||
assert.equal(store.get('cinny_expires_at'), String(blob.expiresAt));
|
||||
});
|
||||
|
||||
test('removeFallbackSession clears BOTH the blob and every legacy key', () => {
|
||||
const store = installStorage();
|
||||
setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', {
|
||||
refreshToken: 'r',
|
||||
expiresInMs: 1000,
|
||||
oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' },
|
||||
});
|
||||
assert.ok(store.size > 0);
|
||||
|
||||
removeFallbackSession();
|
||||
assert.equal(store.size, 0, 'no session key may survive removal');
|
||||
assert.equal(getFallbackSession(), undefined);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token-refresh update path (the path LotusOidcTokenRefresher uses)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('token refresh via setFallbackSession updates blob + legacy atomically', () => {
|
||||
const store = installStorage();
|
||||
// Initial OIDC session.
|
||||
setFallbackSession('access-1', 'DEV', '@bob:mozilla.org', 'https://hs', {
|
||||
refreshToken: 'refresh-1',
|
||||
oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' },
|
||||
});
|
||||
|
||||
// LotusOidcTokenRefresher.persistTokens() calls setFallbackSession with the
|
||||
// rotated tokens and the same identity/oidc refs.
|
||||
setFallbackSession('access-2', 'DEV', '@bob:mozilla.org', 'https://hs', {
|
||||
refreshToken: 'refresh-2',
|
||||
oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' },
|
||||
});
|
||||
|
||||
// Blob updated
|
||||
const blob = JSON.parse(store.get(SESSION_BLOB_KEY)!);
|
||||
assert.equal(blob.accessToken, 'access-2');
|
||||
assert.equal(blob.refreshToken, 'refresh-2');
|
||||
// Legacy keys updated in lockstep
|
||||
assert.equal(store.get('cinny_access_token'), 'access-2');
|
||||
assert.equal(store.get('cinny_refresh_token'), 'refresh-2');
|
||||
// Reader sees the fresh token
|
||||
const s = getFallbackSession();
|
||||
assert.equal(s?.accessToken, 'access-2');
|
||||
assert.equal(s?.refreshToken, 'refresh-2');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-tab sync: subscribeSessionChanges
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Minimal window/storage-event harness: node has neither.
|
||||
const installWindow = (): ((evt: { key: string | null }) => void)[] => {
|
||||
const listeners: ((evt: { key: string | null }) => void)[] = [];
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
addEventListener: (type: string, cb: (evt: { key: string | null }) => void) => {
|
||||
if (type === 'storage') listeners.push(cb);
|
||||
},
|
||||
removeEventListener: (type: string, cb: (evt: { key: string | null }) => void) => {
|
||||
if (type !== 'storage') return;
|
||||
const i = listeners.indexOf(cb);
|
||||
if (i !== -1) listeners.splice(i, 1);
|
||||
},
|
||||
};
|
||||
return listeners;
|
||||
};
|
||||
|
||||
test('subscribeSessionChanges fires with the session when a session key changes', () => {
|
||||
installStorage();
|
||||
const listeners = installWindow();
|
||||
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs');
|
||||
|
||||
let received: unknown = 'unset';
|
||||
const unsub = subscribeSessionChanges((s) => {
|
||||
received = s;
|
||||
});
|
||||
|
||||
// Simulate another tab writing a new token, then dispatch the storage event.
|
||||
setFallbackSession('token-2', 'DEVICE1', '@alice:example.org', 'https://hs');
|
||||
listeners.forEach((cb) => cb({ key: SESSION_BLOB_KEY }));
|
||||
|
||||
assert.notEqual(received, 'unset');
|
||||
assert.equal((received as { accessToken?: string })?.accessToken, 'token-2');
|
||||
|
||||
unsub();
|
||||
assert.equal(listeners.length, 0, 'unsubscribe removes the listener');
|
||||
});
|
||||
|
||||
test('subscribeSessionChanges fires with null when the session is removed', () => {
|
||||
installStorage();
|
||||
const listeners = installWindow();
|
||||
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs');
|
||||
subscribeSessionChanges((s) => {
|
||||
assert.equal(s, null);
|
||||
});
|
||||
|
||||
removeFallbackSession();
|
||||
listeners.forEach((cb) => cb({ key: SESSION_BLOB_KEY }));
|
||||
});
|
||||
|
||||
test('subscribeSessionChanges treats a null key (localStorage.clear) as a change', () => {
|
||||
const store = installStorage();
|
||||
const listeners = installWindow();
|
||||
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs');
|
||||
|
||||
let fired = false;
|
||||
subscribeSessionChanges(() => {
|
||||
fired = true;
|
||||
});
|
||||
|
||||
store.clear();
|
||||
listeners.forEach((cb) => cb({ key: null }));
|
||||
assert.equal(fired, true);
|
||||
});
|
||||
|
||||
test('subscribeSessionChanges ignores unrelated storage keys', () => {
|
||||
installStorage();
|
||||
const listeners = installWindow();
|
||||
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs');
|
||||
|
||||
let fired = false;
|
||||
subscribeSessionChanges(() => {
|
||||
fired = true;
|
||||
});
|
||||
|
||||
listeners.forEach((cb) => cb({ key: 'some_unrelated_preference' }));
|
||||
assert.equal(fired, false);
|
||||
});
|
||||
|
||||
+230
-76
@@ -26,6 +26,15 @@ export type Session = {
|
||||
oidc?: OidcSessionMeta;
|
||||
};
|
||||
|
||||
// Legacy per-field localStorage keys. Kept for dual-write (see below) so a
|
||||
// rollback to an older build that only understands these keys still works.
|
||||
const LEGACY_KEYS = {
|
||||
accessToken: 'cinny_access_token',
|
||||
deviceId: 'cinny_device_id',
|
||||
userId: 'cinny_user_id',
|
||||
baseUrl: 'cinny_hs_base_url',
|
||||
} as const;
|
||||
|
||||
// OIDC-only localStorage keys (absent for password/legacy-SSO sessions).
|
||||
const OIDC_KEYS = {
|
||||
refreshToken: 'cinny_refresh_token',
|
||||
@@ -36,6 +45,174 @@ const OIDC_KEYS = {
|
||||
idTokenClaims: 'cinny_oidc_id_token_claims',
|
||||
} as const;
|
||||
|
||||
// Single-key atomic session blob. The whole session is serialised and written
|
||||
// in ONE `setItem`, so a reader can never observe a torn/partial session the
|
||||
// way the multi-key legacy layout could. Bumping the schema means bumping the
|
||||
// `_v1` suffix.
|
||||
const SESSION_BLOB_KEY = 'cinny_session_v1';
|
||||
|
||||
// The exact shape stored inside SESSION_BLOB_KEY. Note it stores an ABSOLUTE
|
||||
// `expiresAt` (ms since epoch) rather than a relative lifetime — identical to
|
||||
// the legacy `cinny_expires_at` semantics — so reads stay drift-free.
|
||||
type PersistedSession = {
|
||||
accessToken: string;
|
||||
deviceId: string;
|
||||
userId: string;
|
||||
baseUrl: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
oidc?: OidcSessionMeta;
|
||||
};
|
||||
|
||||
// Build the persisted shape from the public setFallbackSession arguments. This
|
||||
// is the single source of truth written to BOTH the blob and the legacy keys.
|
||||
const buildPersisted = (
|
||||
accessToken: string,
|
||||
deviceId: string,
|
||||
userId: string,
|
||||
baseUrl: string,
|
||||
extra?: FallbackSessionExtra,
|
||||
): PersistedSession => {
|
||||
const persisted: PersistedSession = { accessToken, deviceId, userId, baseUrl };
|
||||
if (extra?.refreshToken) persisted.refreshToken = extra.refreshToken;
|
||||
// Store ABSOLUTE expiry to avoid drift across reloads.
|
||||
if (typeof extra?.expiresInMs === 'number') persisted.expiresAt = Date.now() + extra.expiresInMs;
|
||||
if (extra?.oidc) persisted.oidc = extra.oidc;
|
||||
return persisted;
|
||||
};
|
||||
|
||||
// Convert a persisted shape into the public Session returned to callers. Keeps
|
||||
// behaviour identical to the original getFallbackSession assembly: derives the
|
||||
// REMAINING lifetime from the absolute expiry, and only surfaces `oidc` when the
|
||||
// three required OIDC fields are present.
|
||||
const sessionFromPersisted = (p: PersistedSession): Session => {
|
||||
const session: Session = {
|
||||
baseUrl: p.baseUrl,
|
||||
userId: p.userId,
|
||||
deviceId: p.deviceId,
|
||||
accessToken: p.accessToken,
|
||||
fallbackSdkStores: true,
|
||||
};
|
||||
|
||||
if (p.refreshToken) session.refreshToken = p.refreshToken;
|
||||
|
||||
if (typeof p.expiresAt === 'number' && Number.isFinite(p.expiresAt)) {
|
||||
// Expose the REMAINING lifetime (clamped at 0); the SDK refreshes on 401.
|
||||
session.expiresInMs = Math.max(0, p.expiresAt - Date.now());
|
||||
}
|
||||
|
||||
if (p.oidc && p.oidc.issuer && p.oidc.clientId && p.oidc.redirectUri) {
|
||||
session.oidc = {
|
||||
issuer: p.oidc.issuer,
|
||||
clientId: p.oidc.clientId,
|
||||
redirectUri: p.oidc.redirectUri,
|
||||
idTokenClaims: p.oidc.idTokenClaims,
|
||||
};
|
||||
}
|
||||
|
||||
return session;
|
||||
};
|
||||
|
||||
// Read the atomic blob. Returns undefined when absent, unparseable, or missing
|
||||
// any of the four required credential fields — callers then fall back to the
|
||||
// legacy keys.
|
||||
const readSessionBlob = (): PersistedSession | undefined => {
|
||||
const raw = localStorage.getItem(SESSION_BLOB_KEY);
|
||||
if (!raw) return undefined;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
// Corrupt JSON — treat as absent and let the legacy path take over.
|
||||
return undefined;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') return undefined;
|
||||
const p = parsed as Partial<PersistedSession>;
|
||||
if (
|
||||
typeof p.accessToken !== 'string' ||
|
||||
typeof p.deviceId !== 'string' ||
|
||||
typeof p.userId !== 'string' ||
|
||||
typeof p.baseUrl !== 'string'
|
||||
) {
|
||||
// Partial/corrupt blob — fall back to legacy assembly.
|
||||
return undefined;
|
||||
}
|
||||
return p as PersistedSession;
|
||||
};
|
||||
|
||||
// Assemble a session from the legacy per-field keys, or undefined when the four
|
||||
// required keys are not all present. Used for transparent migration from builds
|
||||
// that predate the atomic blob.
|
||||
const readLegacyKeys = (): PersistedSession | undefined => {
|
||||
const baseUrl = localStorage.getItem(LEGACY_KEYS.baseUrl);
|
||||
const userId = localStorage.getItem(LEGACY_KEYS.userId);
|
||||
const deviceId = localStorage.getItem(LEGACY_KEYS.deviceId);
|
||||
const accessToken = localStorage.getItem(LEGACY_KEYS.accessToken);
|
||||
|
||||
if (!(baseUrl && userId && deviceId && accessToken)) return undefined;
|
||||
|
||||
const persisted: PersistedSession = { accessToken, deviceId, userId, baseUrl };
|
||||
|
||||
const refreshToken = localStorage.getItem(OIDC_KEYS.refreshToken);
|
||||
if (refreshToken) persisted.refreshToken = refreshToken;
|
||||
|
||||
const expiresAtRaw = localStorage.getItem(OIDC_KEYS.expiresAt);
|
||||
if (expiresAtRaw) {
|
||||
const expiresAt = Number(expiresAtRaw);
|
||||
if (Number.isFinite(expiresAt)) persisted.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
const issuer = localStorage.getItem(OIDC_KEYS.issuer);
|
||||
const clientId = localStorage.getItem(OIDC_KEYS.clientId);
|
||||
const redirectUri = localStorage.getItem(OIDC_KEYS.redirectUri);
|
||||
if (issuer && clientId && redirectUri) {
|
||||
let idTokenClaims: Record<string, unknown> | undefined;
|
||||
const claimsRaw = localStorage.getItem(OIDC_KEYS.idTokenClaims);
|
||||
if (claimsRaw) {
|
||||
try {
|
||||
idTokenClaims = JSON.parse(claimsRaw);
|
||||
} catch {
|
||||
/* corrupt claims — ignore, the refresher will re-validate on use */
|
||||
}
|
||||
}
|
||||
persisted.oidc = { issuer, clientId, redirectUri, idTokenClaims };
|
||||
}
|
||||
|
||||
return persisted;
|
||||
};
|
||||
|
||||
// Write the legacy per-field keys (dual-write half). Mirrors the original
|
||||
// setFallbackSession body so a rollback to an older build keeps working.
|
||||
const writeLegacyKeys = (p: PersistedSession): void => {
|
||||
localStorage.setItem(LEGACY_KEYS.accessToken, p.accessToken);
|
||||
localStorage.setItem(LEGACY_KEYS.deviceId, p.deviceId);
|
||||
localStorage.setItem(LEGACY_KEYS.userId, p.userId);
|
||||
localStorage.setItem(LEGACY_KEYS.baseUrl, p.baseUrl);
|
||||
|
||||
// OIDC fields — written only when present; otherwise cleared so a password
|
||||
// session never carries stale OIDC state.
|
||||
if (p.refreshToken) localStorage.setItem(OIDC_KEYS.refreshToken, p.refreshToken);
|
||||
else localStorage.removeItem(OIDC_KEYS.refreshToken);
|
||||
|
||||
if (typeof p.expiresAt === 'number')
|
||||
localStorage.setItem(OIDC_KEYS.expiresAt, String(p.expiresAt));
|
||||
else localStorage.removeItem(OIDC_KEYS.expiresAt);
|
||||
|
||||
if (p.oidc) {
|
||||
localStorage.setItem(OIDC_KEYS.issuer, p.oidc.issuer);
|
||||
localStorage.setItem(OIDC_KEYS.clientId, p.oidc.clientId);
|
||||
localStorage.setItem(OIDC_KEYS.redirectUri, p.oidc.redirectUri);
|
||||
if (p.oidc.idTokenClaims) {
|
||||
localStorage.setItem(OIDC_KEYS.idTokenClaims, JSON.stringify(p.oidc.idTokenClaims));
|
||||
} else localStorage.removeItem(OIDC_KEYS.idTokenClaims);
|
||||
} else {
|
||||
localStorage.removeItem(OIDC_KEYS.issuer);
|
||||
localStorage.removeItem(OIDC_KEYS.clientId);
|
||||
localStorage.removeItem(OIDC_KEYS.redirectUri);
|
||||
localStorage.removeItem(OIDC_KEYS.idTokenClaims);
|
||||
}
|
||||
};
|
||||
|
||||
export type FallbackSessionExtra = {
|
||||
refreshToken?: string;
|
||||
expiresInMs?: number;
|
||||
@@ -56,6 +233,10 @@ export type SessionStoreName = {
|
||||
// crypto: 'crypto-store',
|
||||
// } as const;
|
||||
|
||||
// Persist the session. Writes the atomic blob FIRST (so the consistent,
|
||||
// never-torn copy is established before the multi-key legacy write), then
|
||||
// dual-writes the legacy keys for rollback safety. Signature is unchanged —
|
||||
// callers (login/register/OIDC callback/token refresher) are untouched.
|
||||
export function setFallbackSession(
|
||||
accessToken: string,
|
||||
deviceId: string,
|
||||
@@ -63,92 +244,65 @@ export function setFallbackSession(
|
||||
baseUrl: string,
|
||||
extra?: FallbackSessionExtra,
|
||||
) {
|
||||
localStorage.setItem('cinny_access_token', accessToken);
|
||||
localStorage.setItem('cinny_device_id', deviceId);
|
||||
localStorage.setItem('cinny_user_id', userId);
|
||||
localStorage.setItem('cinny_hs_base_url', baseUrl);
|
||||
|
||||
// OIDC fields — written only when present; otherwise cleared so a password
|
||||
// session never carries stale OIDC state.
|
||||
if (extra?.refreshToken) localStorage.setItem(OIDC_KEYS.refreshToken, extra.refreshToken);
|
||||
else localStorage.removeItem(OIDC_KEYS.refreshToken);
|
||||
|
||||
if (typeof extra?.expiresInMs === 'number') {
|
||||
// Store ABSOLUTE expiry to avoid drift across reloads.
|
||||
localStorage.setItem(OIDC_KEYS.expiresAt, String(Date.now() + extra.expiresInMs));
|
||||
} else localStorage.removeItem(OIDC_KEYS.expiresAt);
|
||||
|
||||
if (extra?.oidc) {
|
||||
localStorage.setItem(OIDC_KEYS.issuer, extra.oidc.issuer);
|
||||
localStorage.setItem(OIDC_KEYS.clientId, extra.oidc.clientId);
|
||||
localStorage.setItem(OIDC_KEYS.redirectUri, extra.oidc.redirectUri);
|
||||
if (extra.oidc.idTokenClaims) {
|
||||
localStorage.setItem(OIDC_KEYS.idTokenClaims, JSON.stringify(extra.oidc.idTokenClaims));
|
||||
} else localStorage.removeItem(OIDC_KEYS.idTokenClaims);
|
||||
} else {
|
||||
localStorage.removeItem(OIDC_KEYS.issuer);
|
||||
localStorage.removeItem(OIDC_KEYS.clientId);
|
||||
localStorage.removeItem(OIDC_KEYS.redirectUri);
|
||||
localStorage.removeItem(OIDC_KEYS.idTokenClaims);
|
||||
}
|
||||
const persisted = buildPersisted(accessToken, deviceId, userId, baseUrl, extra);
|
||||
// ONE setItem — the blob can never be observed half-written.
|
||||
localStorage.setItem(SESSION_BLOB_KEY, JSON.stringify(persisted));
|
||||
// Dual-write the legacy keys (removal of this half is a future release).
|
||||
writeLegacyKeys(persisted);
|
||||
}
|
||||
|
||||
// Clear BOTH the atomic blob and every legacy key so no reader (blob-preferring
|
||||
// or legacy-fallback) can resurrect a logged-out session.
|
||||
export const removeFallbackSession = () => {
|
||||
localStorage.removeItem('cinny_hs_base_url');
|
||||
localStorage.removeItem('cinny_user_id');
|
||||
localStorage.removeItem('cinny_device_id');
|
||||
localStorage.removeItem('cinny_access_token');
|
||||
localStorage.removeItem(SESSION_BLOB_KEY);
|
||||
Object.values(LEGACY_KEYS).forEach((key) => localStorage.removeItem(key));
|
||||
Object.values(OIDC_KEYS).forEach((key) => localStorage.removeItem(key));
|
||||
};
|
||||
|
||||
// Read the session, preferring the atomic blob. If the blob is absent or
|
||||
// corrupt/partial we transparently assemble from the legacy keys (migration);
|
||||
// the next setFallbackSession then persists the blob. When both exist the blob
|
||||
// wins by construction.
|
||||
export const getFallbackSession = (): Session | undefined => {
|
||||
const baseUrl = localStorage.getItem('cinny_hs_base_url');
|
||||
const userId = localStorage.getItem('cinny_user_id');
|
||||
const deviceId = localStorage.getItem('cinny_device_id');
|
||||
const accessToken = localStorage.getItem('cinny_access_token');
|
||||
|
||||
if (baseUrl && userId && deviceId && accessToken) {
|
||||
const session: Session = {
|
||||
baseUrl,
|
||||
userId,
|
||||
deviceId,
|
||||
accessToken,
|
||||
fallbackSdkStores: true,
|
||||
};
|
||||
|
||||
const refreshToken = localStorage.getItem(OIDC_KEYS.refreshToken);
|
||||
if (refreshToken) session.refreshToken = refreshToken;
|
||||
|
||||
const expiresAtRaw = localStorage.getItem(OIDC_KEYS.expiresAt);
|
||||
if (expiresAtRaw) {
|
||||
const expiresAt = Number(expiresAtRaw);
|
||||
// Expose the REMAINING lifetime (clamped at 0); the SDK refreshes on 401.
|
||||
if (Number.isFinite(expiresAt)) session.expiresInMs = Math.max(0, expiresAt - Date.now());
|
||||
}
|
||||
|
||||
const issuer = localStorage.getItem(OIDC_KEYS.issuer);
|
||||
const clientId = localStorage.getItem(OIDC_KEYS.clientId);
|
||||
const redirectUri = localStorage.getItem(OIDC_KEYS.redirectUri);
|
||||
if (issuer && clientId && redirectUri) {
|
||||
let idTokenClaims: Record<string, unknown> | undefined;
|
||||
const claimsRaw = localStorage.getItem(OIDC_KEYS.idTokenClaims);
|
||||
if (claimsRaw) {
|
||||
try {
|
||||
idTokenClaims = JSON.parse(claimsRaw);
|
||||
} catch {
|
||||
/* corrupt claims — ignore, the refresher will re-validate on use */
|
||||
}
|
||||
}
|
||||
session.oidc = { issuer, clientId, redirectUri, idTokenClaims };
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
const persisted = readSessionBlob() ?? readLegacyKeys();
|
||||
if (!persisted) return undefined;
|
||||
return sessionFromPersisted(persisted);
|
||||
};
|
||||
/**
|
||||
* End of migration code for old session
|
||||
*/
|
||||
|
||||
// Session keys whose cross-tab change indicates a login/logout/token-rotation
|
||||
// in another tab. localStorage.clear() dispatches a storage event with a null
|
||||
// key, which we also treat as a session change.
|
||||
const SESSION_STORAGE_KEYS = new Set<string>([
|
||||
SESSION_BLOB_KEY,
|
||||
...Object.values(LEGACY_KEYS),
|
||||
...Object.values(OIDC_KEYS),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Subscribe to session changes made in OTHER tabs/windows. The browser only
|
||||
* dispatches `storage` events to tabs that did NOT perform the write, so this
|
||||
* is inherently guarded against reacting to our own same-tab writes — no
|
||||
* echo-suppression needed. The callback receives the freshly-read session, or
|
||||
* `null` when the session was removed (logout in another tab, or a full
|
||||
* localStorage.clear()). Returns an unsubscribe function.
|
||||
*/
|
||||
export const subscribeSessionChanges = (
|
||||
callback: (session: Session | null) => void,
|
||||
): (() => void) => {
|
||||
const handleStorage = (evt: StorageEvent) => {
|
||||
// A null key means localStorage.clear(); otherwise only react to our keys.
|
||||
if (evt.key !== null && !SESSION_STORAGE_KEYS.has(evt.key)) return;
|
||||
callback(getFallbackSession() ?? null);
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
};
|
||||
};
|
||||
|
||||
// export const getSessionStoreName = (session: Session): SessionStoreName => {
|
||||
// if (session.fallbackSdkStores) {
|
||||
// return FALLBACK_STORE_NAME;
|
||||
|
||||
@@ -60,6 +60,39 @@ export enum MessageLayout {
|
||||
Bubble = 2,
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys of the toggleable composer toolbar buttons. Also used as the identity
|
||||
* of each button when persisting/restoring a custom drag-and-drop order.
|
||||
*/
|
||||
export const COMPOSER_TOOLBAR_BUTTON_KEYS = [
|
||||
'showFormat',
|
||||
'showEmoji',
|
||||
'showSticker',
|
||||
'showGif',
|
||||
'showLocation',
|
||||
'showPoll',
|
||||
'showVoice',
|
||||
'showSchedule',
|
||||
] as const;
|
||||
|
||||
export type ComposerToolbarButtonKey = (typeof COMPOSER_TOOLBAR_BUTTON_KEYS)[number];
|
||||
|
||||
/**
|
||||
* The fixed order the composer toolbar rendered before reordering existed.
|
||||
* Used as the fallback for users without a saved order, and to append any
|
||||
* new/unknown button keys, so existing users see no change.
|
||||
*/
|
||||
export const DEFAULT_COMPOSER_TOOLBAR_ORDER: ComposerToolbarButtonKey[] = [
|
||||
'showFormat',
|
||||
'showSticker',
|
||||
'showEmoji',
|
||||
'showGif',
|
||||
'showLocation',
|
||||
'showPoll',
|
||||
'showVoice',
|
||||
'showSchedule',
|
||||
];
|
||||
|
||||
export interface ComposerToolbarSettings {
|
||||
showFormat: boolean;
|
||||
showEmoji: boolean;
|
||||
@@ -69,6 +102,7 @@ export interface ComposerToolbarSettings {
|
||||
showPoll: boolean;
|
||||
showVoice: boolean;
|
||||
showSchedule: boolean;
|
||||
order: ComposerToolbarButtonKey[];
|
||||
}
|
||||
|
||||
export const DEFAULT_COMPOSER_TOOLBAR: ComposerToolbarSettings = {
|
||||
@@ -80,6 +114,47 @@ export const DEFAULT_COMPOSER_TOOLBAR: ComposerToolbarSettings = {
|
||||
showPoll: true,
|
||||
showVoice: true,
|
||||
showSchedule: true,
|
||||
order: DEFAULT_COMPOSER_TOOLBAR_ORDER,
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a complete, de-duplicated composer toolbar order:
|
||||
* - drops unknown/duplicate keys from the saved order
|
||||
* - appends any missing keys (new buttons or existing users with no saved
|
||||
* order) at the end in their canonical default position
|
||||
* so a button can never disappear from the toolbar.
|
||||
*/
|
||||
export const normalizeComposerToolbarOrder = (
|
||||
order: ComposerToolbarButtonKey[] | undefined,
|
||||
): ComposerToolbarButtonKey[] => {
|
||||
const known = new Set<ComposerToolbarButtonKey>(COMPOSER_TOOLBAR_BUTTON_KEYS);
|
||||
const seen = new Set<ComposerToolbarButtonKey>();
|
||||
const result: ComposerToolbarButtonKey[] = [];
|
||||
|
||||
(order ?? []).forEach((key) => {
|
||||
if (known.has(key) && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(key);
|
||||
}
|
||||
});
|
||||
// Append missing keys in their canonical default position…
|
||||
DEFAULT_COMPOSER_TOOLBAR_ORDER.forEach((key) => {
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(key);
|
||||
}
|
||||
});
|
||||
// …then any known key not covered by the default order (safety net so a new
|
||||
// button added to COMPOSER_TOOLBAR_BUTTON_KEYS but forgotten in the default
|
||||
// order can still render/reorder rather than being permanently dropped).
|
||||
COMPOSER_TOOLBAR_BUTTON_KEYS.forEach((key) => {
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export interface Settings {
|
||||
@@ -318,6 +393,7 @@ export const getSettings = (): Settings => {
|
||||
composerToolbarButtons: {
|
||||
...DEFAULT_COMPOSER_TOOLBAR,
|
||||
...(saved.composerToolbarButtons ?? {}),
|
||||
order: normalizeComposerToolbarOrder(saved.composerToolbarButtons?.order),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
contrastingText,
|
||||
varNameFromToken,
|
||||
derivePrimaryPalette,
|
||||
deriveAccentExtras,
|
||||
buildAccentCss,
|
||||
} from './accentColor';
|
||||
|
||||
test('hexToRgb parses 6-digit hex (with/without #, trimmed)', () => {
|
||||
@@ -66,3 +68,22 @@ test('derivePrimaryPalette produces the full Primary token set', () => {
|
||||
assert.match(palette.MainHover, /^#[0-9a-f]{6}$/);
|
||||
assert.match(palette.MainActive, /^#[0-9a-f]{6}$/);
|
||||
});
|
||||
|
||||
test('deriveAccentExtras derives focus ring, link and selection from one base', () => {
|
||||
const base = { r: 255, g: 136, b: 0 };
|
||||
const extras = deriveAccentExtras(base);
|
||||
// focus ring keeps the translucent character in the accent hue
|
||||
assert.equal(extras.focusRing, 'rgba(255, 136, 0, 0.5)');
|
||||
// link + selection background are the solid base hex
|
||||
assert.equal(extras.link, '#ff8800');
|
||||
assert.equal(extras.selectionBg, '#ff8800');
|
||||
// selection text is WCAG-aware contrasting text over the base
|
||||
assert.equal(extras.selectionText, contrastingText(base));
|
||||
});
|
||||
|
||||
test('buildAccentCss emits selection rules using the derived palette', () => {
|
||||
const base = { r: 0, g: 0, b: 0 };
|
||||
const css = buildAccentCss(base);
|
||||
assert.match(css, /::selection\{background:#000000;color:#fff;\}/);
|
||||
assert.match(css, /::-moz-selection\{background:#000000;color:#fff;\}/);
|
||||
});
|
||||
|
||||
@@ -74,6 +74,45 @@ const PRIMARY_TOKENS: Record<string, string> = {
|
||||
OnContainer: color.Primary.OnContainer,
|
||||
};
|
||||
|
||||
// The neutral focus-ring token folds uses for the outline on inputs, buttons,
|
||||
// switches, checkboxes and radios. Its default is a semi-transparent grey/black,
|
||||
// so tinting it in the accent hue themes every focus ring without touching the
|
||||
// neutral Secondary family (see below). We keep the same translucent character
|
||||
// so it reads as a ring rather than a fill.
|
||||
const FOCUS_RING_TOKEN = color.Other.FocusRing;
|
||||
|
||||
// `--tc-link` is the global anchor color (index.css `a { color: var(--tc-link) }`);
|
||||
// overriding it themes plain links inside messages, room topics and URL previews.
|
||||
const LINK_VAR = '--tc-link';
|
||||
|
||||
// Injected stylesheet id — carries rules that cannot be expressed as a single
|
||||
// CSS variable (currently text ::selection).
|
||||
const ACCENT_STYLE_ID = 'lotus-accent-style';
|
||||
|
||||
export type AccentExtras = {
|
||||
focusRing: string;
|
||||
link: string;
|
||||
selectionBg: string;
|
||||
selectionText: string;
|
||||
};
|
||||
|
||||
// Derive the extra (non-Primary) accent values from the single base color, using
|
||||
// the same helpers as the Primary palette so everything stays in one hue.
|
||||
export const deriveAccentExtras = (base: Rgb): AccentExtras => ({
|
||||
focusRing: rgba(base, 0.5),
|
||||
link: rgbToHex(base),
|
||||
selectionBg: rgbToHex(base),
|
||||
selectionText: contrastingText(base),
|
||||
});
|
||||
|
||||
// Build the injected stylesheet body. Selection uses a solid accent fill with
|
||||
// WCAG-aware contrasting text so highlighted text stays readable.
|
||||
export const buildAccentCss = (base: Rgb): string => {
|
||||
const { selectionBg, selectionText } = deriveAccentExtras(base);
|
||||
const selection = `background:${selectionBg};color:${selectionText};`;
|
||||
return `::selection{${selection}}::-moz-selection{${selection}}`;
|
||||
};
|
||||
|
||||
// Derive the 10 Primary sub-token values from a single chosen base color.
|
||||
export const derivePrimaryPalette = (base: Rgb): Record<string, string> => {
|
||||
const baseHex = rgbToHex(base);
|
||||
@@ -96,22 +135,46 @@ export const derivePrimaryPalette = (base: Rgb): Record<string, string> => {
|
||||
};
|
||||
|
||||
// Apply a custom accent color by overriding the folds Primary CSS variables on
|
||||
// `document.body`. Returns true when applied, false when the input is invalid.
|
||||
// `document.body`, tinting the focus-ring and link vars, and injecting a small
|
||||
// stylesheet for text selection. Returns true when applied, false when the input
|
||||
// is invalid.
|
||||
export const applyCustomAccent = (hex: string): boolean => {
|
||||
const base = hexToRgb(hex);
|
||||
if (!base) return false;
|
||||
|
||||
const palette = derivePrimaryPalette(base);
|
||||
Object.entries(PRIMARY_TOKENS).forEach(([key, token]) => {
|
||||
const varName = varNameFromToken(token);
|
||||
if (varName) document.body.style.setProperty(varName, palette[key]);
|
||||
});
|
||||
|
||||
const extras = deriveAccentExtras(base);
|
||||
const focusRingVar = varNameFromToken(FOCUS_RING_TOKEN);
|
||||
if (focusRingVar) document.body.style.setProperty(focusRingVar, extras.focusRing);
|
||||
document.body.style.setProperty(LINK_VAR, extras.link);
|
||||
|
||||
let styleEl = document.getElementById(ACCENT_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = ACCENT_STYLE_ID;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = buildAccentCss(base);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Remove all custom accent overrides, reverting to the active theme's defaults.
|
||||
// Idempotent — safe to call even when nothing was applied.
|
||||
export const removeCustomAccent = (): void => {
|
||||
Object.values(PRIMARY_TOKENS).forEach((token) => {
|
||||
const varName = varNameFromToken(token);
|
||||
if (varName) document.body.style.removeProperty(varName);
|
||||
});
|
||||
|
||||
const focusRingVar = varNameFromToken(FOCUS_RING_TOKEN);
|
||||
if (focusRingVar) document.body.style.removeProperty(focusRingVar);
|
||||
document.body.style.removeProperty(LINK_VAR);
|
||||
|
||||
document.getElementById(ACCENT_STYLE_ID)?.remove();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { MatrixClient } from 'matrix-js-sdk';
|
||||
import pkg from '../../../package.json';
|
||||
|
||||
// Lotus E2EE investigation kit — capture-only console diagnostics.
|
||||
//
|
||||
// Installs pass-through wrappers around `console.warn` / `console.error` that
|
||||
// ring-buffer any log line matching the KE-1..KE-4 bug-cluster signatures
|
||||
// (see LOTUS_E2EE_INVESTIGATION.md). It NEVER swallows a log call — the
|
||||
// original console method is always invoked — and it performs NO network I/O.
|
||||
// The report metadata is limited to SDK version / device id / user id / sync
|
||||
// state; the captured log lines themselves are intentional evidence and may
|
||||
// contain event ids or matrix ids exactly as the SDK logged them.
|
||||
|
||||
export type CryptoDiagLevel = 'warn' | 'error';
|
||||
|
||||
export type CryptoDiagEntry = {
|
||||
/** ISO-8601 UTC timestamp of when the line was captured. */
|
||||
ts: string;
|
||||
level: CryptoDiagLevel;
|
||||
/** Which KE bucket the signature belongs to, e.g. `KE-1`. */
|
||||
ke: string;
|
||||
/** Human-readable label of the matched signature. */
|
||||
signature: string;
|
||||
/** The serialized console line (best-effort). */
|
||||
message: string;
|
||||
};
|
||||
|
||||
type Signature = {
|
||||
ke: string;
|
||||
label: string;
|
||||
re: RegExp;
|
||||
};
|
||||
|
||||
// Ordered most-specific-first so the recorded label is the tightest match.
|
||||
const SIGNATURES: Signature[] = [
|
||||
{ ke: 'KE-1', label: 'already exists', re: /already exists/i },
|
||||
{ ke: 'KE-2', label: 'missing key at index', re: /missing key at index/i },
|
||||
{
|
||||
ke: 'KE-2',
|
||||
label: 'io.element.call.encryption_keys',
|
||||
re: /io\.element\.call\.encryption_keys/,
|
||||
},
|
||||
{ ke: 'KE-2', label: 'MissingKey', re: /MissingKey/ },
|
||||
{ ke: 'KE-3', label: 'DecryptionError', re: /DecryptionError/ },
|
||||
{ ke: 'KE-4', label: 'update_delayed_event', re: /update_delayed_event/ },
|
||||
{ ke: 'KE-4', label: 'delayed event', re: /delayed event/i },
|
||||
];
|
||||
|
||||
const MAX_ENTRIES = 200;
|
||||
|
||||
const entries: CryptoDiagEntry[] = [];
|
||||
|
||||
let installed = false;
|
||||
let originalWarn: ((...data: unknown[]) => void) | undefined;
|
||||
let originalError: ((...data: unknown[]) => void) | undefined;
|
||||
|
||||
const stringifyArg = (arg: unknown): string => {
|
||||
if (typeof arg === 'string') return arg;
|
||||
if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
};
|
||||
|
||||
const capture = (level: CryptoDiagLevel, args: unknown[]): void => {
|
||||
const message = args.map(stringifyArg).join(' ');
|
||||
const sig = SIGNATURES.find((s) => s.re.test(message));
|
||||
if (!sig) return;
|
||||
|
||||
entries.push({
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
ke: sig.ke,
|
||||
signature: sig.label,
|
||||
message,
|
||||
});
|
||||
// Ring-buffer: keep only the most recent MAX_ENTRIES.
|
||||
while (entries.length > MAX_ENTRIES) {
|
||||
entries.shift();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Install the capture-only console wrappers. Idempotent — calling it more than
|
||||
* once is a no-op. Safe to call as early as possible during app boot.
|
||||
*/
|
||||
export const installCryptoDiagLog = (): void => {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
|
||||
originalWarn = console.warn.bind(console);
|
||||
originalError = console.error.bind(console);
|
||||
|
||||
console.warn = (...args: unknown[]): void => {
|
||||
capture('warn', args);
|
||||
originalWarn?.(...args);
|
||||
};
|
||||
console.error = (...args: unknown[]): void => {
|
||||
capture('error', args);
|
||||
originalError?.(...args);
|
||||
};
|
||||
};
|
||||
|
||||
/** A snapshot copy of the current capture buffer (most-recent-last). */
|
||||
export const getCryptoDiagEntries = (): CryptoDiagEntry[] => entries.slice();
|
||||
|
||||
const readSdkVersion = (mx?: MatrixClient): string => {
|
||||
// Prefer the value the running client reports; fall back to the declared pin.
|
||||
const declared = (pkg.dependencies as Record<string, string> | undefined)?.['matrix-js-sdk'];
|
||||
const clientVersion = (mx as unknown as { getSdkVersion?: () => string } | undefined)
|
||||
?.getSdkVersion;
|
||||
if (typeof clientVersion === 'function') {
|
||||
try {
|
||||
return clientVersion.call(mx) || declared || 'unknown';
|
||||
} catch {
|
||||
// fall through to the declared pin
|
||||
}
|
||||
}
|
||||
return declared ?? 'unknown';
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a self-contained JSON diagnostic report string. Contains only the SDK
|
||||
* version, device id, user id, sync state, crypto readiness, and the captured
|
||||
* KE signature buffer — no message content, tokens, or other PII.
|
||||
*/
|
||||
export const buildCryptoDiagReport = (mx?: MatrixClient): string => {
|
||||
const buffer = getCryptoDiagEntries();
|
||||
const countsByKe: Record<string, number> = {};
|
||||
buffer.forEach((entry) => {
|
||||
countsByKe[entry.ke] = (countsByKe[entry.ke] ?? 0) + 1;
|
||||
});
|
||||
|
||||
const report = {
|
||||
kind: 'lotus-crypto-diag',
|
||||
generatedAt: new Date().toISOString(),
|
||||
sdkVersion: readSdkVersion(mx),
|
||||
deviceId: mx?.getDeviceId() ?? null,
|
||||
userId: mx?.getUserId() ?? null,
|
||||
syncState: mx?.getSyncState() ?? null,
|
||||
cryptoReady: Boolean(mx?.getCrypto()),
|
||||
entryCount: buffer.length,
|
||||
maxEntries: MAX_ENTRIES,
|
||||
countsByKe,
|
||||
entries: buffer,
|
||||
};
|
||||
|
||||
return JSON.stringify(report, null, 2);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { filesFromEntries } from './fileEntries';
|
||||
|
||||
const fileEntry = (name: string): FileSystemFileEntry =>
|
||||
({
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
name,
|
||||
file: (success: (file: File) => void) => {
|
||||
success(new File(['x'], name, { type: 'text/plain' }));
|
||||
},
|
||||
}) as unknown as FileSystemFileEntry;
|
||||
|
||||
/**
|
||||
* A directory whose reader yields its children in several batches (mirroring
|
||||
* Chromium's `readEntries`, which caps each call) and finally an empty batch.
|
||||
*/
|
||||
const dirEntry = (name: string, children: FileSystemEntry[]): FileSystemDirectoryEntry => {
|
||||
const batches = [children.slice(0, 1), children.slice(1), [] as FileSystemEntry[]];
|
||||
return {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name,
|
||||
createReader: () => {
|
||||
let call = 0;
|
||||
return {
|
||||
readEntries: (success: (entries: FileSystemEntry[]) => void) => {
|
||||
const batch = batches[call] ?? [];
|
||||
call += 1;
|
||||
success(batch);
|
||||
},
|
||||
} as unknown as FileSystemDirectoryReader;
|
||||
},
|
||||
} as unknown as FileSystemDirectoryEntry;
|
||||
};
|
||||
|
||||
test('filesFromEntries flattens nested folders and prefixes relative paths', async () => {
|
||||
const entries: FileSystemEntry[] = [
|
||||
fileEntry('top.txt'),
|
||||
dirEntry('photos', [
|
||||
fileEntry('a.jpg'),
|
||||
dirEntry('2024', [fileEntry('b.jpg'), fileEntry('c.jpg')]),
|
||||
]),
|
||||
];
|
||||
|
||||
const files = await filesFromEntries(entries);
|
||||
const names = files.map((f) => f.name).sort();
|
||||
|
||||
assert.deepEqual(names, ['photos/2024/b.jpg', 'photos/2024/c.jpg', 'photos/a.jpg', 'top.txt']);
|
||||
});
|
||||
|
||||
test('filesFromEntries reads directory entries in batches until empty', async () => {
|
||||
const entries: FileSystemEntry[] = [
|
||||
dirEntry('docs', [fileEntry('one.txt'), fileEntry('two.txt')]),
|
||||
];
|
||||
|
||||
const files = await filesFromEntries(entries);
|
||||
assert.equal(files.length, 2);
|
||||
});
|
||||
|
||||
test('filesFromEntries respects the maxFiles cap', async () => {
|
||||
const entries: FileSystemEntry[] = [
|
||||
dirEntry('many', [fileEntry('a.txt'), fileEntry('b.txt')]),
|
||||
fileEntry('c.txt'),
|
||||
];
|
||||
|
||||
const files = await filesFromEntries(entries, 2);
|
||||
assert.equal(files.length, 2);
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { getDataTransferFiles, renameFile } from './dom';
|
||||
|
||||
// Guard against pathological drops (deeply nested / huge trees) that could
|
||||
// otherwise queue thousands of uploads and freeze the composer.
|
||||
export const MAX_DROPPED_FILES = 500;
|
||||
|
||||
/**
|
||||
* Synchronously collect the `FileSystemEntry` objects for every item in a
|
||||
* drop's `DataTransfer`.
|
||||
*
|
||||
* This MUST be called synchronously inside the drop event handler: the
|
||||
* `DataTransferItemList` is emptied once the handler returns, so calling
|
||||
* `webkitGetAsEntry()` after an `await` yields `null`. Capture the entries
|
||||
* first, then traverse them asynchronously with {@link filesFromEntries}.
|
||||
*
|
||||
* Returns an empty array when `webkitGetAsEntry` is unavailable (non-Chromium
|
||||
* browsers), signalling the caller to fall back to the flat file list.
|
||||
*/
|
||||
export const entriesFromDataTransfer = (dataTransfer: DataTransfer): FileSystemEntry[] => {
|
||||
const entries: FileSystemEntry[] = [];
|
||||
const { items } = dataTransfer;
|
||||
if (!items) return entries;
|
||||
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
const item = items[i];
|
||||
if (item && item.kind === 'file' && typeof item.webkitGetAsEntry === 'function') {
|
||||
const entry = item.webkitGetAsEntry();
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
};
|
||||
|
||||
const fileFromFileEntry = (entry: FileSystemFileEntry): Promise<File> =>
|
||||
new Promise((resolve, reject) => {
|
||||
entry.file(resolve, reject);
|
||||
});
|
||||
|
||||
/**
|
||||
* Read every entry from a directory reader.
|
||||
*
|
||||
* `readEntries` returns results in BATCHES (Chromium yields at most ~100 per
|
||||
* call), so it must be called repeatedly until it resolves with an empty array.
|
||||
*/
|
||||
const readAllDirectoryEntries = (reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const all: FileSystemEntry[] = [];
|
||||
const readBatch = () => {
|
||||
reader.readEntries((batch) => {
|
||||
if (batch.length === 0) {
|
||||
resolve(all);
|
||||
return;
|
||||
}
|
||||
all.push(...batch);
|
||||
readBatch();
|
||||
}, reject);
|
||||
};
|
||||
readBatch();
|
||||
});
|
||||
|
||||
/**
|
||||
* Recursively walk `FileSystemEntry` objects (as produced by
|
||||
* {@link entriesFromDataTransfer}) and resolve them into a flat `File[]`,
|
||||
* descending into every nested directory.
|
||||
*
|
||||
* Nested files keep their relative folder path as a name prefix (e.g.
|
||||
* `photos/2024/pic.jpg`) so uploads remain distinguishable. Traversal stops
|
||||
* once `maxFiles` files have been collected.
|
||||
*/
|
||||
export const filesFromEntries = async (
|
||||
entries: FileSystemEntry[],
|
||||
maxFiles: number = MAX_DROPPED_FILES,
|
||||
): Promise<File[]> => {
|
||||
const files: File[] = [];
|
||||
|
||||
const walk = async (entry: FileSystemEntry, prefix: string): Promise<void> => {
|
||||
if (files.length >= maxFiles) return;
|
||||
|
||||
// A single unreadable file/directory (moved between drop and read, a
|
||||
// permissions/lock error, an OS special file) must NOT abort the whole
|
||||
// traversal — skip it and keep collecting the rest.
|
||||
if (entry.isFile) {
|
||||
try {
|
||||
const file = await fileFromFileEntry(entry as FileSystemFileEntry);
|
||||
if (files.length >= maxFiles) return;
|
||||
files.push(prefix ? renameFile(file, `${prefix}${file.name}`) : file);
|
||||
} catch {
|
||||
/* skip unreadable file */
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.isDirectory) {
|
||||
let childEntries: FileSystemEntry[] = [];
|
||||
try {
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
childEntries = await readAllDirectoryEntries(reader);
|
||||
} catch {
|
||||
return; /* skip unreadable directory */
|
||||
}
|
||||
const childPrefix = `${prefix}${entry.name}/`;
|
||||
for (const child of childEntries) {
|
||||
if (files.length >= maxFiles) break;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await walk(child, childPrefix);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
if (files.length >= maxFiles) break;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await walk(entry, '');
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract dropped files, descending into any dropped folders.
|
||||
*
|
||||
* Captures the `FileSystemEntry` list synchronously (required — see
|
||||
* {@link entriesFromDataTransfer}) then traverses it asynchronously. Falls back
|
||||
* to the flat `dataTransfer.files` list when the directory API is unavailable
|
||||
* (non-Chromium) or when no entries are exposed.
|
||||
*/
|
||||
export const collectDroppedFiles = (dataTransfer: DataTransfer): Promise<File[] | undefined> => {
|
||||
const entries = entriesFromDataTransfer(dataTransfer);
|
||||
if (entries.length === 0) {
|
||||
return Promise.resolve(getDataTransferFiles(dataTransfer));
|
||||
}
|
||||
return filesFromEntries(entries).then((files) => (files.length > 0 ? files : undefined));
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { splitMathSegments } from './mathParse';
|
||||
|
||||
test('plain text with no dollars is a single text segment', () => {
|
||||
assert.deepEqual(splitMathSegments('hello world'), [{ type: 'text', value: 'hello world' }]);
|
||||
});
|
||||
|
||||
test('empty string yields no segments', () => {
|
||||
assert.deepEqual(splitMathSegments(''), []);
|
||||
});
|
||||
|
||||
test('inline $…$ is extracted between surrounding text', () => {
|
||||
assert.deepEqual(splitMathSegments('a $x^2$ b'), [
|
||||
{ type: 'text', value: 'a ' },
|
||||
{ type: 'inline', value: 'x^2' },
|
||||
{ type: 'text', value: ' b' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('block $$…$$ is extracted', () => {
|
||||
assert.deepEqual(splitMathSegments('$$block$$'), [{ type: 'block', value: 'block' }]);
|
||||
});
|
||||
|
||||
test('block math may span newlines', () => {
|
||||
assert.deepEqual(splitMathSegments('$$\na=b\n$$'), [{ type: 'block', value: '\na=b\n' }]);
|
||||
});
|
||||
|
||||
test('currency "$5 and $10" is NOT treated as math', () => {
|
||||
assert.deepEqual(splitMathSegments('$5 and $10'), [{ type: 'text', value: '$5 and $10' }]);
|
||||
});
|
||||
|
||||
test('escaped \\$ never opens or closes math', () => {
|
||||
assert.deepEqual(splitMathSegments('cost \\$5 today'), [
|
||||
{ type: 'text', value: 'cost $5 today' },
|
||||
]);
|
||||
assert.deepEqual(splitMathSegments('\\$x\\$'), [{ type: 'text', value: '$x$' }]);
|
||||
});
|
||||
|
||||
test('unbalanced single $ stays as text', () => {
|
||||
assert.deepEqual(splitMathSegments('price is $ here'), [
|
||||
{ type: 'text', value: 'price is $ here' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('unbalanced $$ stays as text', () => {
|
||||
assert.deepEqual(splitMathSegments('$$x'), [{ type: 'text', value: '$$x' }]);
|
||||
});
|
||||
|
||||
test('inline requires non-space adjacency on both delimiters', () => {
|
||||
// Space right after opening $ -> not math.
|
||||
assert.deepEqual(splitMathSegments('$ x$'), [{ type: 'text', value: '$ x$' }]);
|
||||
// Space right before closing $ -> not math.
|
||||
assert.deepEqual(splitMathSegments('$x $'), [{ type: 'text', value: '$x $' }]);
|
||||
});
|
||||
|
||||
test('multiple inline spans on one line', () => {
|
||||
assert.deepEqual(splitMathSegments('$a$ and $b$'), [
|
||||
{ type: 'inline', value: 'a' },
|
||||
{ type: 'text', value: ' and ' },
|
||||
{ type: 'inline', value: 'b' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('escaped dollar inside inline math is preserved in LaTeX', () => {
|
||||
assert.deepEqual(splitMathSegments('$a\\$b$'), [{ type: 'inline', value: 'a\\$b' }]);
|
||||
});
|
||||
|
||||
test('closing $ followed by a digit is skipped (currency guard) then recovers', () => {
|
||||
// The first candidate closer is followed by `2` so it is skipped; the later
|
||||
// `$` closes the span.
|
||||
assert.deepEqual(splitMathSegments('$x$2 + y$'), [{ type: 'inline', value: 'x$2 + y' }]);
|
||||
});
|
||||
|
||||
test('block and inline mixed with text', () => {
|
||||
assert.deepEqual(splitMathSegments('see $$E=mc^2$$ and $a$ ok'), [
|
||||
{ type: 'text', value: 'see ' },
|
||||
{ type: 'block', value: 'E=mc^2' },
|
||||
{ type: 'text', value: ' and ' },
|
||||
{ type: 'inline', value: 'a' },
|
||||
{ type: 'text', value: ' ok' },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
export type MathSegmentType = 'text' | 'inline' | 'block';
|
||||
|
||||
export type MathSegment = {
|
||||
type: MathSegmentType;
|
||||
/**
|
||||
* For `text` segments this is the literal text. For `inline`/`block` segments
|
||||
* this is the LaTeX source WITHOUT its surrounding `$`/`$$` delimiters.
|
||||
*/
|
||||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempt to match an inline `$…$` span starting at `start` (the index of the
|
||||
* opening `$`).
|
||||
*
|
||||
* Conservative rules (chosen to keep false positives low for prose that merely
|
||||
* mentions currency, e.g. `$5 and $10`):
|
||||
* - The char immediately AFTER the opening `$` must exist, be non-space and not
|
||||
* another `$` (a lone `$` before whitespace, or `$$`, never opens inline math).
|
||||
* - The char immediately BEFORE the closing `$` must be non-space (so `x $` is
|
||||
* not a valid close; we keep scanning for a better `$`).
|
||||
* - The char immediately AFTER the closing `$` must not be a digit (so
|
||||
* `$5 and $10` reads as currency, never math).
|
||||
* - A backslash escapes the following char inside the span, so `\$` is not
|
||||
* treated as a delimiter and stays part of the LaTeX.
|
||||
* - Inline math may not span a newline.
|
||||
* - The LaTeX content must be non-empty.
|
||||
*/
|
||||
const matchInline = (text: string, start: number): { value: string; end: number } | null => {
|
||||
const nextChar = text[start + 1];
|
||||
if (nextChar === undefined || /\s/.test(nextChar) || nextChar === '$') return null;
|
||||
|
||||
let j = start + 1;
|
||||
while (j < text.length) {
|
||||
const c = text[j];
|
||||
if (c === '\\') {
|
||||
// Skip the escaped char (covers `\$` inside the span).
|
||||
j += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === '\n') return null;
|
||||
if (c === '$') {
|
||||
const prev = text[j - 1];
|
||||
// Closing `$` must hug non-space; otherwise this `$` cannot close, keep scanning.
|
||||
if (prev !== undefined && /\s/.test(prev)) {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
const after = text[j + 1];
|
||||
// A `$` directly followed by a digit is treated as currency, not a closer.
|
||||
if (after !== undefined && /\d/.test(after)) {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
const value = text.slice(start + 1, j);
|
||||
if (value.length === 0) return null;
|
||||
return { value, end: j + 1 };
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Split a plain-text string into text/inline-math/block-math segments.
|
||||
*
|
||||
* Delimiter rules:
|
||||
* - `$$…$$` (possibly multi-line) is block math; the first following `$$` closes it.
|
||||
* - `$…$` is inline math, subject to the conservative adjacency rules in
|
||||
* {@link matchInline}.
|
||||
* - `\$` is an escaped literal dollar: it never acts as a delimiter and is
|
||||
* emitted as a plain `$` in the surrounding text.
|
||||
* - Any `$`/`$$` run that cannot be balanced is left verbatim as text.
|
||||
*
|
||||
* This is a PURE function used by the HTML parser to render math with KaTeX. It
|
||||
* must never be applied to text inside `<pre>`/`<code>` (the caller guards that).
|
||||
*/
|
||||
export const splitMathSegments = (text: string): MathSegment[] => {
|
||||
const segments: MathSegment[] = [];
|
||||
let buffer = '';
|
||||
let i = 0;
|
||||
|
||||
const flushText = () => {
|
||||
if (buffer.length > 0) {
|
||||
segments.push({ type: 'text', value: buffer });
|
||||
buffer = '';
|
||||
}
|
||||
};
|
||||
|
||||
while (i < text.length) {
|
||||
// Escaped dollar: consume `\$` and emit a literal `$` as text.
|
||||
if (text[i] === '\\' && text[i + 1] === '$') {
|
||||
buffer += '$';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Block math `$$…$$`.
|
||||
if (text.startsWith('$$', i)) {
|
||||
const close = text.indexOf('$$', i + 2);
|
||||
if (close !== -1) {
|
||||
const value = text.slice(i + 2, close);
|
||||
if (value.trim().length > 0) {
|
||||
flushText();
|
||||
segments.push({ type: 'block', value });
|
||||
i = close + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Unbalanced/empty `$$` — emit a single `$` and continue scanning.
|
||||
buffer += text[i];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inline math `$…$`.
|
||||
if (text[i] === '$') {
|
||||
const match = matchInline(text, i);
|
||||
if (match) {
|
||||
flushText();
|
||||
segments.push({ type: 'inline', value: match.value });
|
||||
i = match.end;
|
||||
continue;
|
||||
}
|
||||
buffer += text[i];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer += text[i];
|
||||
i += 1;
|
||||
}
|
||||
|
||||
flushText();
|
||||
return segments;
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
computeCoverage,
|
||||
mergeSearchResults,
|
||||
putRows,
|
||||
queryRoom,
|
||||
getCoverage,
|
||||
saveRoomIndex,
|
||||
clearRoom,
|
||||
clearAll,
|
||||
deleteSearchCacheDatabase,
|
||||
SearchCacheRow,
|
||||
} from './searchCache';
|
||||
|
||||
// --- Pure helpers: mergeSearchResults ---------------------------------------
|
||||
|
||||
type Item = { event: { event_id: string; origin_server_ts?: number } };
|
||||
const item = (eventId: string, ts?: number): Item => ({
|
||||
event: { event_id: eventId, origin_server_ts: ts },
|
||||
});
|
||||
|
||||
test('mergeSearchResults: sorts by origin_server_ts descending', () => {
|
||||
const out = mergeSearchResults([item('$a', 10), item('$b', 30), item('$c', 20)], []);
|
||||
assert.deepEqual(
|
||||
out.map((i) => i.event.event_id),
|
||||
['$b', '$c', '$a'],
|
||||
);
|
||||
});
|
||||
|
||||
test('mergeSearchResults: dedupes by event_id with in-memory winning', () => {
|
||||
const memory = [{ event: { event_id: '$dup', origin_server_ts: 5 }, tag: 'memory' }];
|
||||
const cached = [
|
||||
{ event: { event_id: '$dup', origin_server_ts: 5 }, tag: 'cached' },
|
||||
{ event: { event_id: '$only', origin_server_ts: 9 }, tag: 'cached' },
|
||||
];
|
||||
const out = mergeSearchResults(memory, cached);
|
||||
assert.equal(out.length, 2);
|
||||
const dup = out.find((i) => i.event.event_id === '$dup');
|
||||
assert.equal(dup?.tag, 'memory');
|
||||
});
|
||||
|
||||
test('mergeSearchResults: cached-only hits are included', () => {
|
||||
const out = mergeSearchResults<Item>([], [item('$c1', 1), item('$c2', 2)]);
|
||||
assert.equal(out.length, 2);
|
||||
});
|
||||
|
||||
test('mergeSearchResults: missing ts sorts as 0 (last)', () => {
|
||||
const out = mergeSearchResults([item('$noTs'), item('$withTs', 100)], []);
|
||||
assert.deepEqual(
|
||||
out.map((i) => i.event.event_id),
|
||||
['$withTs', '$noTs'],
|
||||
);
|
||||
});
|
||||
|
||||
// --- Pure helpers: computeCoverage ------------------------------------------
|
||||
|
||||
const row = (ts: number): Pick<SearchCacheRow, 'ts'> => ({ ts });
|
||||
|
||||
test('computeCoverage: derives oldest/newest from rows', () => {
|
||||
const cov = computeCoverage('!r', [row(30), row(10), row(20)], 3);
|
||||
assert.deepEqual(cov, { roomId: '!r', oldestTs: 10, newestTs: 30, count: 3 });
|
||||
});
|
||||
|
||||
test('computeCoverage: widens the window against previous coverage', () => {
|
||||
const prev = { roomId: '!r', oldestTs: 5, newestTs: 25, count: 2 };
|
||||
const cov = computeCoverage('!r', [row(15), row(40)], 4, prev);
|
||||
assert.equal(cov.oldestTs, 5); // previous oldest kept
|
||||
assert.equal(cov.newestTs, 40); // batch newest wins
|
||||
assert.equal(cov.count, 4); // authoritative count from caller
|
||||
});
|
||||
|
||||
test('computeCoverage: empty rows with no previous yields zeroed window', () => {
|
||||
const cov = computeCoverage('!r', [], 0);
|
||||
assert.deepEqual(cov, { roomId: '!r', oldestTs: 0, newestTs: 0, count: 0 });
|
||||
});
|
||||
|
||||
// --- IDB round-trip: skip when IndexedDB is unavailable (e.g. node --test) ---
|
||||
|
||||
const hasIdb = typeof indexedDB !== 'undefined';
|
||||
|
||||
test('searchCache IDB round-trip', { skip: !hasIdb }, async () => {
|
||||
await clearAll();
|
||||
const rows: SearchCacheRow[] = [
|
||||
{ roomId: '!r1', eventId: '$1', ts: 100, sender: '@a', body: 'hello world' },
|
||||
{
|
||||
roomId: '!r1',
|
||||
eventId: '$2',
|
||||
ts: 200,
|
||||
sender: '@b',
|
||||
body: 'goodbye',
|
||||
formattedBody: '<b>x</b>',
|
||||
},
|
||||
{ roomId: '!r2', eventId: '$3', ts: 300, sender: '@a', body: 'other room' },
|
||||
];
|
||||
await putRows(rows);
|
||||
|
||||
const r1 = await queryRoom('!r1');
|
||||
assert.equal(r1.length, 2);
|
||||
assert.deepEqual(r1.map((x) => x.eventId).sort(), ['$1', '$2']);
|
||||
|
||||
await saveRoomIndex(
|
||||
'!r1',
|
||||
rows.filter((x) => x.roomId === '!r1'),
|
||||
);
|
||||
const cov = await getCoverage('!r1');
|
||||
assert.equal(cov?.count, 2);
|
||||
assert.equal(cov?.oldestTs, 100);
|
||||
assert.equal(cov?.newestTs, 200);
|
||||
|
||||
await clearRoom('!r1');
|
||||
assert.equal((await queryRoom('!r1')).length, 0);
|
||||
assert.equal((await queryRoom('!r2')).length, 1);
|
||||
|
||||
await deleteSearchCacheDatabase();
|
||||
});
|
||||
|
||||
test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb }, async () => {
|
||||
// In this environment IndexedDB is absent; every call must degrade to a
|
||||
// cache-miss rather than throwing.
|
||||
await assert.doesNotReject(
|
||||
putRows([{ roomId: '!r', eventId: '$1', ts: 1, sender: '@a', body: 'x' }]),
|
||||
);
|
||||
assert.deepEqual(await queryRoom('!r'), []);
|
||||
assert.equal(await getCoverage('!r'), null);
|
||||
await assert.doesNotReject(saveRoomIndex('!r', []));
|
||||
await assert.doesNotReject(clearRoom('!r'));
|
||||
await assert.doesNotReject(clearAll());
|
||||
await assert.doesNotReject(deleteSearchCacheDatabase());
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* P4-8 — persistent encrypted-search cache (raw IndexedDB, no new deps).
|
||||
*
|
||||
* The homeserver cannot search E2EE message content, so encrypted-room search
|
||||
* only ever covers what the client has paginated + decrypted this session. This
|
||||
* module persists a local plaintext index so coverage survives reloads.
|
||||
*
|
||||
* PRIVACY: this stores decrypted plaintext at rest. It is opt-in (default OFF),
|
||||
* clearable, and wiped on logout via `deleteSearchCacheDatabase()`.
|
||||
*
|
||||
* Resilience contract: every entry point swallows IndexedDB errors and behaves
|
||||
* as a cache-miss. Nothing here ever throws to the UI.
|
||||
*/
|
||||
|
||||
const DB_NAME = 'lotus-search-cache';
|
||||
const DB_VERSION = 1;
|
||||
const MESSAGES_STORE = 'messages';
|
||||
const COVERAGE_STORE = 'coverage';
|
||||
const ROOM_TS_INDEX = 'roomTs';
|
||||
|
||||
/** A single cached, decrypted message row. Keyed on `[roomId, eventId]`. */
|
||||
export type SearchCacheRow = {
|
||||
roomId: string;
|
||||
eventId: string;
|
||||
ts: number;
|
||||
sender: string;
|
||||
body: string;
|
||||
formattedBody?: string;
|
||||
pollText?: string;
|
||||
};
|
||||
|
||||
/** Per-room coverage stats for the "X / Y cached" UI counters. */
|
||||
export type SearchCacheCoverage = {
|
||||
roomId: string;
|
||||
oldestTs: number;
|
||||
newestTs: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
// A key range that matches every `[roomId, *]` entry in a composite-key store
|
||||
// or `[roomId, ts]` index: an empty array sorts after all other key types, so
|
||||
// `[roomId]` .. `[roomId, []]` brackets the whole room partition.
|
||||
const roomRange = (roomId: string): IDBKeyRange => IDBKeyRange.bound([roomId], [roomId, []]);
|
||||
|
||||
let dbPromise: Promise<IDBDatabase | null> | null = null;
|
||||
|
||||
const openDb = (): Promise<IDBDatabase | null> => {
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = new Promise<IDBDatabase | null>((resolve) => {
|
||||
try {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(MESSAGES_STORE)) {
|
||||
const store = db.createObjectStore(MESSAGES_STORE, {
|
||||
keyPath: ['roomId', 'eventId'],
|
||||
});
|
||||
store.createIndex(ROOM_TS_INDEX, ['roomId', 'ts']);
|
||||
}
|
||||
if (!db.objectStoreNames.contains(COVERAGE_STORE)) {
|
||||
db.createObjectStore(COVERAGE_STORE, { keyPath: 'roomId' });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => {
|
||||
dbPromise = null; // allow a later retry
|
||||
resolve(null);
|
||||
};
|
||||
req.onblocked = () => {
|
||||
dbPromise = null;
|
||||
resolve(null);
|
||||
};
|
||||
} catch {
|
||||
dbPromise = null;
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
return dbPromise;
|
||||
};
|
||||
|
||||
/** Resolve once a write transaction commits (or reject/abort → caller swallows). */
|
||||
const awaitTx = (tx: IDBTransaction): Promise<void> =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
|
||||
/** Upsert message rows. No-op on empty input or when IDB is unavailable. */
|
||||
export const putRows = async (rows: SearchCacheRow[]): Promise<void> => {
|
||||
if (rows.length === 0) return;
|
||||
const db = await openDb();
|
||||
if (!db) return;
|
||||
try {
|
||||
const tx = db.transaction(MESSAGES_STORE, 'readwrite');
|
||||
const store = tx.objectStore(MESSAGES_STORE);
|
||||
rows.forEach((row) => store.put(row));
|
||||
await awaitTx(tx);
|
||||
} catch {
|
||||
// Cache write failures must never surface to the UI.
|
||||
}
|
||||
};
|
||||
|
||||
/** All cached rows for a room, ordered oldest→newest by the `[roomId, ts]` index. */
|
||||
export const queryRoom = async (roomId: string): Promise<SearchCacheRow[]> => {
|
||||
const db = await openDb();
|
||||
if (!db) return [];
|
||||
try {
|
||||
return await new Promise<SearchCacheRow[]>((resolve, reject) => {
|
||||
const tx = db.transaction(MESSAGES_STORE, 'readonly');
|
||||
const index = tx.objectStore(MESSAGES_STORE).index(ROOM_TS_INDEX);
|
||||
const req = index.getAll(roomRange(roomId));
|
||||
req.onsuccess = () => resolve((req.result as SearchCacheRow[]) ?? []);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/** Cursor variant: stream a room's rows through a matcher, collecting hits. */
|
||||
export const searchRoom = async (
|
||||
roomId: string,
|
||||
matcher: (row: SearchCacheRow) => boolean,
|
||||
): Promise<SearchCacheRow[]> => {
|
||||
const db = await openDb();
|
||||
if (!db) return [];
|
||||
try {
|
||||
return await new Promise<SearchCacheRow[]>((resolve, reject) => {
|
||||
const hits: SearchCacheRow[] = [];
|
||||
const tx = db.transaction(MESSAGES_STORE, 'readonly');
|
||||
const index = tx.objectStore(MESSAGES_STORE).index(ROOM_TS_INDEX);
|
||||
const req = index.openCursor(roomRange(roomId));
|
||||
req.onsuccess = () => {
|
||||
const cursor = req.result;
|
||||
if (!cursor) {
|
||||
resolve(hits);
|
||||
return;
|
||||
}
|
||||
const row = cursor.value as SearchCacheRow;
|
||||
if (matcher(row)) hits.push(row);
|
||||
cursor.continue();
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/** Number of cached rows for a room. */
|
||||
export const countRoom = async (roomId: string): Promise<number> => {
|
||||
const db = await openDb();
|
||||
if (!db) return 0;
|
||||
try {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const tx = db.transaction(MESSAGES_STORE, 'readonly');
|
||||
const index = tx.objectStore(MESSAGES_STORE).index(ROOM_TS_INDEX);
|
||||
const req = index.count(roomRange(roomId));
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const getCoverage = async (roomId: string): Promise<SearchCacheCoverage | null> => {
|
||||
const db = await openDb();
|
||||
if (!db) return null;
|
||||
try {
|
||||
return await new Promise<SearchCacheCoverage | null>((resolve, reject) => {
|
||||
const tx = db.transaction(COVERAGE_STORE, 'readonly');
|
||||
const req = tx.objectStore(COVERAGE_STORE).get(roomId);
|
||||
req.onsuccess = () => resolve((req.result as SearchCacheCoverage) ?? null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const putCoverage = async (coverage: SearchCacheCoverage): Promise<void> => {
|
||||
const db = await openDb();
|
||||
if (!db) return;
|
||||
try {
|
||||
const tx = db.transaction(COVERAGE_STORE, 'readwrite');
|
||||
tx.objectStore(COVERAGE_STORE).put(coverage);
|
||||
await awaitTx(tx);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure helper: fold a batch of rows into a coverage record, widening the
|
||||
* `oldestTs`/`newestTs` window against any previous coverage. `count` is
|
||||
* supplied by the caller (authoritative store count) so dedup across sessions
|
||||
* is handled correctly. Exported for testing without IDB.
|
||||
*/
|
||||
export const computeCoverage = (
|
||||
roomId: string,
|
||||
rows: ReadonlyArray<Pick<SearchCacheRow, 'ts'>>,
|
||||
count: number,
|
||||
previous?: SearchCacheCoverage | null,
|
||||
): SearchCacheCoverage => {
|
||||
let oldestTs = previous?.oldestTs ?? Number.POSITIVE_INFINITY;
|
||||
let newestTs = previous?.newestTs ?? Number.NEGATIVE_INFINITY;
|
||||
rows.forEach((row) => {
|
||||
if (row.ts < oldestTs) oldestTs = row.ts;
|
||||
if (row.ts > newestTs) newestTs = row.ts;
|
||||
});
|
||||
if (!Number.isFinite(oldestTs)) oldestTs = 0;
|
||||
if (!Number.isFinite(newestTs)) newestTs = 0;
|
||||
return { roomId, oldestTs, newestTs, count };
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience persist path used by the search hook: upsert a batch of rows for
|
||||
* a room, then recompute + store the room's coverage from the authoritative
|
||||
* store count. Fire-and-forget; never throws.
|
||||
*/
|
||||
export const saveRoomIndex = async (roomId: string, rows: SearchCacheRow[]): Promise<void> => {
|
||||
if (rows.length === 0) return;
|
||||
await putRows(rows);
|
||||
const [count, previous] = await Promise.all([countRoom(roomId), getCoverage(roomId)]);
|
||||
await putCoverage(computeCoverage(roomId, rows, count, previous));
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure helper: merge in-memory result items with cache-derived result items,
|
||||
* deduping by `event.event_id` (in-memory wins), sorted by `origin_server_ts`
|
||||
* descending. Generic over the minimal shape it reads so it is fully testable
|
||||
* without matrix-js-sdk types. Exported for testing.
|
||||
*/
|
||||
export const mergeSearchResults = <
|
||||
T extends { event: { event_id: string; origin_server_ts?: number } },
|
||||
>(
|
||||
memory: ReadonlyArray<T>,
|
||||
cached: ReadonlyArray<T>,
|
||||
): T[] => {
|
||||
const byId = new Map<string, T>();
|
||||
// Seed with cached, then let in-memory overwrite so in-memory always wins.
|
||||
cached.forEach((item) => byId.set(item.event.event_id, item));
|
||||
memory.forEach((item) => byId.set(item.event.event_id, item));
|
||||
return Array.from(byId.values()).sort(
|
||||
(a, b) => (b.event.origin_server_ts ?? 0) - (a.event.origin_server_ts ?? 0),
|
||||
);
|
||||
};
|
||||
|
||||
export const clearRoom = async (roomId: string): Promise<void> => {
|
||||
const db = await openDb();
|
||||
if (!db) return;
|
||||
try {
|
||||
const tx = db.transaction([MESSAGES_STORE, COVERAGE_STORE], 'readwrite');
|
||||
tx.objectStore(MESSAGES_STORE).delete(roomRange(roomId));
|
||||
tx.objectStore(COVERAGE_STORE).delete(roomId);
|
||||
await awaitTx(tx);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
export const clearAll = async (): Promise<void> => {
|
||||
const db = await openDb();
|
||||
if (!db) return;
|
||||
try {
|
||||
const tx = db.transaction([MESSAGES_STORE, COVERAGE_STORE], 'readwrite');
|
||||
tx.objectStore(MESSAGES_STORE).clear();
|
||||
tx.objectStore(COVERAGE_STORE).clear();
|
||||
await awaitTx(tx);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop the entire on-disk database. Wired into the logout path by the
|
||||
* coordinator (initMatrix) so no decrypted plaintext lingers after sign-out.
|
||||
* Closes any open handle first so the delete is not blocked. Never throws.
|
||||
*/
|
||||
export const deleteSearchCacheDatabase = async (): Promise<void> => {
|
||||
try {
|
||||
const existing = dbPromise ? await dbPromise : null;
|
||||
if (existing) existing.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
dbPromise = null;
|
||||
return new Promise<void>((resolve) => {
|
||||
try {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const req = indexedDB.deleteDatabase(DB_NAME);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => resolve();
|
||||
req.onblocked = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { getFallbackSession, removeFallbackSession, Session } from '../app/state
|
||||
import { LotusOidcTokenRefresher } from './oidcTokenRefresher';
|
||||
import { revokeOidcTokens } from './oidcLogout';
|
||||
import { pushSessionToSW } from '../sw-session';
|
||||
import { deleteSearchCacheDatabase } from '../app/utils/searchCache';
|
||||
|
||||
// Thrown when the local IndexedDB has a higher schema version than this SDK expects.
|
||||
// This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted).
|
||||
@@ -87,6 +88,9 @@ export const logoutClient = async (mx: MatrixClient) => {
|
||||
// ignore if failed to logout
|
||||
}
|
||||
await mx.clearStores();
|
||||
// The opt-in local search index stores decrypted plaintext — always wipe it
|
||||
// on logout. (clearLoginData below nukes all IDB databases, covering it too.)
|
||||
await deleteSearchCacheDatabase();
|
||||
// Remove only the session credential keys, preserving user preferences and
|
||||
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
|
||||
removeFallbackSession();
|
||||
|
||||
Reference in New Issue
Block a user