Compare commits
28 Commits
81904372bc
..
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| 57da9a6ce8 | |||
| eb34b04708 | |||
| fd9e4a9802 | |||
| f12175e76f | |||
| b5db617bd2 | |||
| 4ecc173554 | |||
| 44854a1529 | |||
| 43f4ceb45d | |||
| 17bd50cc4e | |||
| 82e52e1bc7 | |||
| d46b91b1b8 | |||
| 5b94a44eb3 | |||
| ca9abb5363 | |||
| 21276a47fc | |||
| b7788cc79c | |||
| 13d08c3fd7 | |||
| a899d7d3a8 | |||
| dcd8201e16 | |||
| 41149db685 | |||
| 668bdaad7d | |||
| ee6bdd8241 | |||
| 0bbdd7ce94 | |||
| 7c85ad177f | |||
| bbf0800c19 | |||
| abd0753148 | |||
| 8192da5a12 | |||
| 6dc478e989 | |||
| 049472e25f |
@@ -1,686 +0,0 @@
|
|||||||
# HANDOFF — Forking & Self-Building Element Call ("Lotus Call")
|
|
||||||
|
|
||||||
> **Audience:** a fresh Claude/engineer session with **no prior context** on this
|
|
||||||
> project. Read this top-to-bottom before touching anything. This document is the
|
|
||||||
> single source of truth for the Element Call (EC) fork initiative.
|
|
||||||
>
|
|
||||||
> **Status:** **PHASE 0–2 IMPLEMENTED (build-verified, not yet live-tested)**
|
|
||||||
> (2026-06-30). The fork exists, builds, is published, and cinny consumes it
|
|
||||||
> (Phase 0/1). **All 7 Phase-2 EC features are implemented on the fork's `lotus`
|
|
||||||
> branch**, each additive + flag-gated, build+typecheck-clean, per-feature
|
|
||||||
> reviewed (+ a holistic multi-agent review), and pushed. **None are live-tested
|
|
||||||
> yet** — every one needs the `LOTUS_TESTING.md` §D sweep, and the **cinny host
|
|
||||||
> side must be wired** (set flags / send actions / handle call_state) — see §12.
|
|
||||||
> See **§9** Phase 0/1 results, **§10** cutover, **§11** Phase-2 seams, **§12**
|
|
||||||
> Phase-2 status + cinny integration checklist. Created 2026-06 from `LotusGuild/cinny`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Phase 0 Results (verified 2026-06-29)
|
|
||||||
|
|
||||||
**Decisions taken with the user:** scope = Phase 0 recon; consumption model =
|
|
||||||
**private npm package** (§5 option 1). Recommended registry = **Gitea's built-in
|
|
||||||
npm registry** (`code.lotusguild.org`) — zero new infra.
|
|
||||||
|
|
||||||
### 9.1 Version → tag → commit mapping (LOCKED)
|
|
||||||
|
|
||||||
| Source | Value |
|
|
||||||
| :--------------------------------------------------- | :----------------------------------------- |
|
|
||||||
| cinny `package.json` pin | `@element-hq/element-call-embedded@0.20.1` |
|
|
||||||
| Bundle self-report (`VITE_APP_VERSION`/`appVersion`) | `embedded-v0.20.1` |
|
|
||||||
| npm registry `gitHead` for 0.20.1 | `2d74c48151d9edc01c65a22a91478aac81bf24d0` |
|
|
||||||
| GitHub tag `v0.20.1` → commit | `2d74c48…` ✅ **same commit** |
|
|
||||||
|
|
||||||
→ **Fork from upstream tag `v0.20.1` (commit `2d74c48`).** The embedded package
|
|
||||||
version equals the element-call release tag; repo `package.json` version is
|
|
||||||
`0.0.0` and the real version is stamped at publish time from the tag.
|
|
||||||
|
|
||||||
### 9.2 The shipped npm dist is a CLEAN upstream build
|
|
||||||
|
|
||||||
No `lotus`/`denoise`/`rnnoise` strings anywhere in
|
|
||||||
`node_modules/@element-hq/element-call-embedded/dist`. **All Lotus customization
|
|
||||||
(denoise shim) is injected at cinny build time, not baked into the package** — so
|
|
||||||
swapping the source does not disturb cinny's denoise injection layer. The
|
|
||||||
ringtone/reaction assets (`baduntss`, `cat`, `clap`, `call_declined`, …) are
|
|
||||||
upstream EC's own, not ours.
|
|
||||||
|
|
||||||
### 9.3 Build toolchain & mechanism
|
|
||||||
|
|
||||||
- **Node `24`** (`.node-version`), **pnpm `10.33.0`** (`packageManager` field,
|
|
||||||
via corepack).
|
|
||||||
- Build: **`pnpm run build:embedded`** = `vite build --config
|
|
||||||
vite-embedded.config.ts` with `NODE_OPTIONS=--max-old-space-size=16384`.
|
|
||||||
- Output dir is **repo-root `dist/`**; CI stages it into **`embedded/web/dist`**
|
|
||||||
(the `embedded/web/` dir holds the publish template: `package.json`, README,
|
|
||||||
both LICENSE files).
|
|
||||||
- Publish workflow upstream = `.github/workflows/publish-embedded-packages.yaml`:
|
|
||||||
builds → `npm version <tag> --no-git-tag-version` → `npm publish --provenance
|
|
||||||
--access public` to npmjs as `@element-hq/element-call-embedded`. (Also
|
|
||||||
Android/Maven + iOS/SwiftPM — irrelevant; we are web-only.)
|
|
||||||
|
|
||||||
### 9.4 Build reproduction — PARITY CONFIRMED
|
|
||||||
|
|
||||||
Cloned `element-call@v0.20.1` to `/root/code/element-call` (shallow), built with
|
|
||||||
isolated Node 24 / pnpm 10.33.0 (system Node 20 / cinny untouched). Result vs the
|
|
||||||
shipped npm dist:
|
|
||||||
|
|
||||||
- **137 of 147 files byte-identical** (same Vite content-hash): all CSS, fonts,
|
|
||||||
wasm, audio, JSON locale files, and `IndexedDBWorker`.
|
|
||||||
- **Only 5 JS chunks differ** (`index`, `pako.esm`, `polyfill-force`,
|
|
||||||
`rust-crypto`, `spa`) — **cause isolated to the version define**: our local
|
|
||||||
build baked `appVersion:\`dev\``(because`VITE_APP_VERSION`was unset) vs the
|
|
||||||
npm build's`appVersion:\`embedded-v0.20.1\``. `index.html` is identical modulo
|
|
||||||
the hashed asset filenames. **Benign** — our CI sets the version from the git
|
|
||||||
tag, so a tagged CI build will match.
|
|
||||||
|
|
||||||
### 9.5 Fork CI (drafted)
|
|
||||||
|
|
||||||
`.gitea/workflows/ci.yml` is staged in the clone (models cinny's
|
|
||||||
`.gitea/workflows/ci.yml` + upstream's publish flow). Linux-only (`ubuntu-latest`)
|
|
||||||
— the Windows worker is for cinny-desktop/Tauri, not the EC web bundle. Build job
|
|
||||||
on PR/push to `lotus`; publish job on `v*` tag → `@lotusguild/element-call-embedded`
|
|
||||||
to the Gitea npm registry (needs `secrets.GITEA_NPM_TOKEN`).
|
|
||||||
|
|
||||||
### 9.6 Phase 1 — DONE (2026-06-29)
|
|
||||||
|
|
||||||
1. ✅ **Fork repo live:** `code.lotusguild.org/LotusGuild/element-call` (public,
|
|
||||||
AGPL), default branch `lotus`, full history (7018 commits) + tag `v0.20.1`.
|
|
||||||
Branch `lotus` = `v0.20.1` + 2-file diff (CI workflow + embedded package
|
|
||||||
rename).
|
|
||||||
2. ✅ **Package published:** `@lotusguild/element-call-embedded@0.20.1` on the
|
|
||||||
Gitea npm registry (published manually from the version-faithful build while
|
|
||||||
the admin token was available). **Publicly readable** (unauth `npm install`
|
|
||||||
works → devs/CI need no token to consume; only publishing needs one).
|
|
||||||
3. ✅ **cinny wired & built clean** (Node 24): `.npmrc` scope line +
|
|
||||||
`package.json` dep + `vite.config.js` `viteStaticCopy` src. `npm install`
|
|
||||||
swapped the package (resolved from Gitea), `npm run build` succeeded,
|
|
||||||
`dist/public/element-call/` populated, bundle reports `appVersion:
|
|
||||||
embedded-v0.20.1`, **denoise shim injected + all denoise assets copied**
|
|
||||||
(injection layer unchanged). **These cinny edits are staged in the working
|
|
||||||
tree, NOT committed/pushed** — pushing triggers CI → desktop → deploy, so it's
|
|
||||||
gated on the §D live test (see §10).
|
|
||||||
|
|
||||||
### 9.8 Reproducibility note (important)
|
|
||||||
|
|
||||||
A from-source rebuild is **NOT byte-identical** to upstream's npm tarball.
|
|
||||||
137/147 files match exactly (CSS, fonts, wasm, audio, worker); the 5 JS chunks
|
|
||||||
(`index`, `pako.esm`, `polyfill-force`, `rust-crypto`, `spa`) differ because the
|
|
||||||
rolldown/oxc **minifier mangles export names differently** across build
|
|
||||||
environments (and the version-define is one input). This is normal and benign —
|
|
||||||
the code is functionally equivalent. **Do not chase byte-parity; the §D live call
|
|
||||||
test is the real parity gate.**
|
|
||||||
|
|
||||||
### 9.9 Remaining follow-ups (not blocking the cutover)
|
|
||||||
|
|
||||||
- **CI publishing:** `.gitea/workflows/ci.yml` publishes on a `v*` tag but needs
|
|
||||||
(a) a Gitea Actions runner for `LotusGuild/element-call`, and (b) a **durable**
|
|
||||||
`GITEA_NPM_TOKEN` repo secret with package read/write (the admin token used for
|
|
||||||
the manual publish is being deleted, so it was deliberately NOT baked in). Until
|
|
||||||
then, publishing is manual (`npm version <tag>` in `embedded/web` →
|
|
||||||
`npm publish`).
|
|
||||||
- Decide rebase cadence vs upstream (0.20.2 / 0.20.3 already out — see §9.1).
|
|
||||||
|
|
||||||
### 9.7 Ready-to-apply artifacts (staged 2026-06-29)
|
|
||||||
|
|
||||||
**Fork side — already committed** on branch `lotus` in `/root/code/element-call`
|
|
||||||
(remote `lotus` = `code.lotusguild.org/LotusGuild/element-call.git`, push deferred
|
|
||||||
until the repo exists). Minimal 2-file diff vs tag `v0.20.1`:
|
|
||||||
`.gitea/workflows/ci.yml` (new) + `embedded/web/package.json` (rename to
|
|
||||||
`@lotusguild/element-call-embedded`). Push with:
|
|
||||||
`git push -u lotus lotus && git push lotus v0.20.1` (and tag `v0.20.1` on our side
|
|
||||||
to trigger the first publish, or push our own `v0.20.1` tag).
|
|
||||||
|
|
||||||
**cinny side — NOT yet applied** (applying before the package is published breaks
|
|
||||||
`npm ci`). Exactly 3 edits + a lockfile regen:
|
|
||||||
|
|
||||||
1. `.npmrc` — append the scoped-registry line:
|
|
||||||
```
|
|
||||||
@lotusguild:registry=https://code.lotusguild.org/api/packages/LotusGuild/npm/
|
|
||||||
```
|
|
||||||
(CI/auth: `//code.lotusguild.org/api/packages/LotusGuild/npm/:_authToken=${GITEA_NPM_TOKEN}`
|
|
||||||
— inject via env in CI, do not commit a plaintext token.)
|
|
||||||
2. `package.json:104` —
|
|
||||||
`"@element-hq/element-call-embedded": "0.20.1"` →
|
|
||||||
`"@lotusguild/element-call-embedded": "0.20.1"`.
|
|
||||||
3. `vite.config.js:25` — `viteStaticCopy` src:
|
|
||||||
`node_modules/@element-hq/element-call-embedded/dist` →
|
|
||||||
`node_modules/@lotusguild/element-call-embedded/dist`.
|
|
||||||
**`stripBase: 4` stays unchanged** — `node_modules/@lotusguild/element-call-embedded/dist`
|
|
||||||
is still exactly 4 leading segments. (Update the comment's path reference too.)
|
|
||||||
4. `package-lock.json` — regenerated by `npm install`, not hand-edited (drops the
|
|
||||||
`registry.npmjs.org/@element-hq/...` resolved URL for the Gitea one).
|
|
||||||
|
|
||||||
The denoise injection (`lotusDenoise()` in `vite.config.js`) is **unchanged** — it
|
|
||||||
keys off `dist/public/element-call/index.html`, which our fork's bundle still
|
|
||||||
produces identically (verified: `index.html` byte-identical modulo asset hashes).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. TL;DR / The Goal
|
|
||||||
|
|
||||||
We embed **Element Call** (the Matrix group-VoIP/video app) inside Lotus Chat to
|
|
||||||
power voice/video channels. Today we consume Element's **pre-compiled npm
|
|
||||||
bundle** and can only steer it from the outside (a limited widget API + fragile
|
|
||||||
same-origin DOM hacks). Several in-call problems are **unfixable from outside**
|
|
||||||
because they live in EC's compiled JS.
|
|
||||||
|
|
||||||
**We want true ownership: fork `element-hq/element-call`, build it from source
|
|
||||||
ourselves, host our build, and replace the npm bundle with our fork.** Then
|
|
||||||
every in-call behavior becomes editable code.
|
|
||||||
|
|
||||||
**This requires standing up a brand-new repo and build pipeline for our EC fork.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Why fork? (What we cannot fix today)
|
|
||||||
|
|
||||||
These came out of live testing and are documented in `LOTUS_BUGS.md` →
|
|
||||||
"Known Element Call iframe limitations":
|
|
||||||
|
|
||||||
| Issue | What's wrong | Why outside-fixes fail |
|
|
||||||
| :----------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **A6** — avatar decorations in-call | Our profile-decoration overlays don't appear on in-call video tiles | The video grid is rendered by EC's React app inside the iframe. We can only inject overlay DOM (fragile) — we can't make it a first-class part of the tile. |
|
|
||||||
| **A5** — focus camera / fullscreen during screenshare | Can't reliably spotlight a participant's camera while someone screenshares | EC's **layout logic** (screenshare priority, spotlight) is compiled JS we don't control. We currently DOM-click tiles as a hack. |
|
|
||||||
| **A7** — mic dead after EC's "Reconnect" | After EC's own mid-call reconnect, the local mic isn't re-published | EC's reconnect/track-republish path is internal. (Partly entangled with our denoise shim — see §6.) |
|
|
||||||
| Native theming | EC's UI doesn't match Lotus design; we inject CSS hacks | Real theming needs source-level component/token changes. |
|
|
||||||
| Decorations, custom controls, custom layouts, branding | all blocked | all require source access |
|
|
||||||
|
|
||||||
**Bottom line:** the iframe is **same-origin** (we self-host it), so we can read
|
|
||||||
and even write its DOM — but we **do not own its source**, so we can't change its
|
|
||||||
**behavior/logic**, only poke at its rendered output. Forking removes that wall.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. How EC is integrated TODAY (the current architecture)
|
|
||||||
|
|
||||||
Understand this fully before changing it — the fork must slot into the same
|
|
||||||
integration seams.
|
|
||||||
|
|
||||||
### 2.1 Where the EC bundle comes from
|
|
||||||
|
|
||||||
- npm package: **`@element-hq/element-call-embedded`**, pinned to **`0.20.1`** in
|
|
||||||
`cinny/package.json` (line ~104).
|
|
||||||
- It ships a **pre-built `dist/`**. At cinny build time,
|
|
||||||
`vite-plugin-static-copy` copies that `dist/` flat into
|
|
||||||
**`public/element-call/`** (see `cinny/vite.config.js`, the `copyFiles`
|
|
||||||
target with `rename: { stripBase: 4 }` — note the stripBase gotcha documented
|
|
||||||
there; getting this wrong 404s the widget).
|
|
||||||
- It is **NOT committed** to git (`git ls-files public/element-call` → 0). It's a
|
|
||||||
build artifact materialized from `node_modules`.
|
|
||||||
|
|
||||||
### 2.2 How EC is loaded & controlled
|
|
||||||
|
|
||||||
- The widget iframe `src` is **same-origin**:
|
|
||||||
`${BASE_URL}/public/element-call/index.html?<params>` (see
|
|
||||||
`cinny/src/app/plugins/call/CallEmbed.ts`, `getWidget()` /
|
|
||||||
`getIframe()`). Sandbox: `allow-forms allow-scripts allow-same-origin
|
|
||||||
allow-popups allow-modals allow-downloads`; `allow="microphone; camera;
|
|
||||||
display-capture; autoplay; clipboard-write;"`.
|
|
||||||
- **Control surface #1 — the official widget API** (`matrix-widget-api`):
|
|
||||||
`ClientWidgetApi` + a custom `CallWidgetDriver`. This is the robust,
|
|
||||||
version-stable channel (theme change, hangup, capabilities, timeline events).
|
|
||||||
Files: `plugins/call/CallEmbed.ts`, `plugins/call/CallWidgetDriver.ts`,
|
|
||||||
`plugins/call/utils.ts` (capabilities), `plugins/call/CallControl.ts`.
|
|
||||||
- **Control surface #2 — same-origin DOM poking** (fragile, version-coupled):
|
|
||||||
reading `iframe.contentDocument` to detect speakers/mute state and
|
|
||||||
`.click()`-ing tiles to focus a camera. Files:
|
|
||||||
`hooks/useCallSpeakers.ts` (reads `[data-muted]`, `[data-video-fit]`),
|
|
||||||
`plugins/call/CallControl.ts` (`focusCameraParticipant` — tile selectors).
|
|
||||||
**These selectors break on every EC version bump.** A fork lets us replace
|
|
||||||
these hacks with real APIs/props.
|
|
||||||
- **Control surface #3 — URL params + build-time injection** for our denoise
|
|
||||||
shim (see §6).
|
|
||||||
|
|
||||||
### 2.3 Full file inventory (everything that touches EC in cinny)
|
|
||||||
|
|
||||||
Plugin / core:
|
|
||||||
|
|
||||||
- `src/app/plugins/call/CallEmbed.ts` — iframe creation, widget API wiring, theme sync, hangup, load watchdog/self-heal, denoise URL params.
|
|
||||||
- `src/app/plugins/call/CallControl.ts` — control state + **DOM-poking** (`focusCameraParticipant`, spotlight).
|
|
||||||
- `src/app/plugins/call/CallControl.tsx` _(call-status variant)_ and `features/call-status/CallControl.tsx`.
|
|
||||||
- `src/app/plugins/call/CallWidgetDriver.ts` — widget driver (capabilities, event relay).
|
|
||||||
- `src/app/plugins/call/utils.ts` — widget capabilities set.
|
|
||||||
- `src/app/plugins/call/hooks.ts`, `index.ts` — plugin exports/hooks.
|
|
||||||
- `src/app/state/callEmbed.ts` — jotai atoms for the active embed.
|
|
||||||
|
|
||||||
React / UI:
|
|
||||||
|
|
||||||
- `src/app/components/CallEmbedProvider.tsx` — the big one: incoming-call ring/banner, RTCNotification + **RTCDecline** listeners, PiP, mute badges, fullscreen, ringtones.
|
|
||||||
- `src/app/features/call/CallView.tsx` — prescreen lobby vs joined (the iframe placement target), load-error recovery UI.
|
|
||||||
- `src/app/features/call/CallControls.tsx` — in-call control bar (mic/cam/deafen/screenshare/fullscreen/more/PiP).
|
|
||||||
- `src/app/features/call/CallMemberCard.tsx` — **lobby** participant roster (this is where `AvatarDecoration` works today; in-call grid is EC's).
|
|
||||||
- `src/app/features/call/PrescreenControls.tsx` — join controls.
|
|
||||||
- `src/app/features/call-status/*` — `CallStatus.tsx`, `MemberGlance.tsx` (the "Focus camera" menu lives here), `LiveChip.tsx`.
|
|
||||||
- `src/app/features/room-nav/RoomNavItem.tsx`, `features/room/Room.tsx`, `features/room/RoomViewHeader.tsx`, `pages/client/space/Space.tsx`, `pages/CallStatusRenderer.tsx`, `pages/Router.tsx` — call entry points / status surfacing.
|
|
||||||
|
|
||||||
Hooks:
|
|
||||||
|
|
||||||
- `src/app/hooks/useCallEmbed.ts`, `useCall.ts`, `useCallSpeakers.ts` (DOM-poking), `useCallJoinLeaveSounds.ts`, `useAfkAutoMute.ts`.
|
|
||||||
|
|
||||||
Build:
|
|
||||||
|
|
||||||
- `cinny/vite.config.js` — `copyFiles` (EC dist copy) + `lotusDenoise()` plugin (denoise asset copy + index.html shim injection, in `closeBundle`).
|
|
||||||
|
|
||||||
Utils:
|
|
||||||
|
|
||||||
- `src/app/utils/ringtones.ts`, `utils/denoisePipeline.ts`, `utils/lotusDenoiseUtils.ts`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Hosting / infra context (the OTHER repo)
|
|
||||||
|
|
||||||
There are **two repos**:
|
|
||||||
|
|
||||||
1. **`LotusGuild/cinny`** (`/root/code/cinny`) — this Lotus Chat fork. Consumes EC.
|
|
||||||
2. **`LotusGuild/matrix`** (`/root/code/matrix`) — the **infra/homeserver** repo.
|
|
||||||
Subdirs: `livekit/` (the SFU EC talks to), `deploy/`, `draupnir/`,
|
|
||||||
`hookshot/`, `landing/`, `matrixbot/`, `systemd/`. Gitea remote
|
|
||||||
`code.lotusguild.org/LotusGuild/matrix`, branch `main`.
|
|
||||||
|
|
||||||
EC needs a **LiveKit SFU** + the **livekit-jwt-service**; those live in
|
|
||||||
`matrix/livekit/`. A self-hosted EC build must be configured to point at our
|
|
||||||
homeserver (`matrix.lotusguild.org` / synapse) and our LiveKit. EC's runtime
|
|
||||||
`config.json` (homeserver, livekit URL, feature flags) is part of what we'll own
|
|
||||||
once we build it ourselves.
|
|
||||||
|
|
||||||
Deployment today: `chat.lotusguild.org` (the cinny web build, which embeds EC at
|
|
||||||
`/public/element-call/`). cinny-desktop (`LotusGuild/cinny-desktop`, a Tauri
|
|
||||||
wrapper, bumped by cinny CI) embeds the same.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. The plan (proposed — confirm with the user before executing)
|
|
||||||
|
|
||||||
### Decision: **YES, create a new repo.** `LotusGuild/element-call`
|
|
||||||
|
|
||||||
Rationale: EC is a large standalone app (React + LiveKit client SDK + matrixRTC +
|
|
||||||
its own Vite build + heavy deps). Keep it out of cinny so cinny's build stays
|
|
||||||
clean — cinny keeps consuming a **built EC `dist/`**, exactly as today, just
|
|
||||||
sourced from **our fork** instead of npm.
|
|
||||||
|
|
||||||
### Phase 0 — Recon (no code)
|
|
||||||
|
|
||||||
- Fork `github.com/element-hq/element-call` → `LotusGuild/element-call` on Gitea.
|
|
||||||
- Pin to the upstream tag matching **0.20.1** (`element-call-embedded` 0.20.1's
|
|
||||||
corresponding `element-call` release) so behavior matches what's shipping now.
|
|
||||||
Verify the embedded-package version ↔ element-call repo tag mapping.
|
|
||||||
- Read EC's own build docs: it builds the "embedded" widget bundle (the thing
|
|
||||||
currently published as `@element-hq/element-call-embedded`). Reproduce that
|
|
||||||
build locally and confirm the output matches `public/element-call/` today.
|
|
||||||
- **License:** element-call is **AGPL-3.0**, same as Lotus Chat — compatible.
|
|
||||||
Our fork must remain AGPL and publish source.
|
|
||||||
|
|
||||||
### Phase 1 — Reproduce current behavior from our fork (parity, no features)
|
|
||||||
|
|
||||||
- Build our fork's embedded bundle; wire cinny to consume it instead of the npm
|
|
||||||
package (see §5 for the consumption options). Smoke-test: a call works exactly
|
|
||||||
as today (web + desktop), denoise shim still injects, widget API + theme still
|
|
||||||
work. **No behavior change yet** — this de-risks the swap.
|
|
||||||
|
|
||||||
### Phase 2 — Replace the outside hacks with source-level features
|
|
||||||
|
|
||||||
Tackle the §1 issues in EC's source:
|
|
||||||
|
|
||||||
- **A6:** render avatar decorations as part of the video-tile component
|
|
||||||
(read decoration data we pass in via widget data / URL param / a small bridge).
|
|
||||||
- **A5:** fix focus/spotlight + screenshare-coexistence in EC's layout code;
|
|
||||||
expose a clean widget action so cinny can trigger it (kill the DOM `.click()`).
|
|
||||||
- **A7:** fix mic re-publish on reconnect; reconcile with our denoise shim (§6) —
|
|
||||||
ideally move denoise INTO the fork as a real audio-processing step instead of a
|
|
||||||
`getUserMedia` monkeypatch.
|
|
||||||
- Native Lotus theming/branding at the source (kill the injected-CSS hacks).
|
|
||||||
- Then retire the DOM-poking in `useCallSpeakers.ts` / `CallControl.ts` in favor
|
|
||||||
of real widget messages.
|
|
||||||
|
|
||||||
### Phase 3 — Maintenance posture
|
|
||||||
|
|
||||||
- Decide rebase cadence vs. upstream element-call releases. Keep customizations
|
|
||||||
isolated (feature flags / minimal-diff patches) to ease rebasing.
|
|
||||||
- CI in the new repo builds + publishes the embedded dist as a versioned
|
|
||||||
artifact; cinny CI consumes a pinned version.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. How cinny should consume the fork (pick one — decide with user)
|
|
||||||
|
|
||||||
1. **Private npm package** (mirror the current model): our fork's CI publishes
|
|
||||||
`@lotusguild/element-call-embedded` to a registry; cinny depends on it and
|
|
||||||
`viteStaticCopy` keeps working almost unchanged. _Cleanest swap; needs a
|
|
||||||
registry._
|
|
||||||
2. **Git submodule + build in cinny CI:** add the fork as a submodule, build it
|
|
||||||
during cinny's build, copy its `dist/` to `public/element-call/`. _No
|
|
||||||
registry; heavier cinny CI._
|
|
||||||
3. **CI artifact copy:** fork CI uploads a `dist` tarball; cinny CI downloads a
|
|
||||||
pinned version at build. _Decoupled; needs artifact plumbing._
|
|
||||||
|
|
||||||
**Recommendation: Option 1** — it changes the least in cinny (just swap the
|
|
||||||
package name in `package.json` + the `viteStaticCopy` src path) and preserves the
|
|
||||||
clean cinny/EC separation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. The denoise shim — critical interaction (don't break this)
|
|
||||||
|
|
||||||
Lotus ships ML noise suppression by **injecting a same-origin pre-init shim into
|
|
||||||
EC's `index.html` at build time** (cinny `vite.config.js` → `lotusDenoise()`,
|
|
||||||
`closeBundle`). The shim monkeypatches `getUserMedia` **before EC captures the
|
|
||||||
mic** and routes audio through RNNoise/Speex/DTLN AudioWorklets, then EC/LiveKit
|
|
||||||
publishes the processed track. It's activated via URL params
|
|
||||||
(`lotusDenoise=ml&lotusModel=…&lotusGate=…`) set in `CallEmbed.ts`.
|
|
||||||
|
|
||||||
- Assets copied to `public/element-call/denoise/` at build (sapphi RNNoise/Speex/
|
|
||||||
gate worklets + `@workadventure/noise-suppression` DTLN tree).
|
|
||||||
- Related: `utils/denoisePipeline.ts`, `utils/lotusDenoiseUtils.ts`,
|
|
||||||
`settings/general/DenoiseTester.tsx`, `VoiceMessageRecorder.tsx`.
|
|
||||||
- **Known issues:** denoise quality is still poor (tracked separately); and the
|
|
||||||
mic-after-reconnect bug (A7) is suspected to involve the shim's getUserMedia
|
|
||||||
patch handing back a stale processed stream when EC re-acquires the mic.
|
|
||||||
|
|
||||||
**Once we own the fork, the right move is to make denoise a first-class
|
|
||||||
audio-processing stage inside EC** (not an index.html monkeypatch) — more robust,
|
|
||||||
survives reconnects, and removes the build-time injection hack. Until then, the
|
|
||||||
fork's `index.html` must remain injectable the same way, or the shim must be
|
|
||||||
re-homed into the fork.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Doc-accuracy notes / corrections for the new session
|
|
||||||
|
|
||||||
- `LOTUS_TODO.md` (~line 533) calls EC a **"cross-origin iframe"** — **outdated.**
|
|
||||||
EC is **same-origin** today (self-hosted under our domain;
|
|
||||||
`iframe.sandbox` includes `allow-same-origin`; we read `contentDocument`), and
|
|
||||||
**as of 2026-06-29 we own the fork's source** (`@lotusguild/element-call-embedded`).
|
|
||||||
The _practical_ point it made still holds _until we ship the audio-inject API_:
|
|
||||||
**LiveKit's `LocalAudioTrack` lives in EC's module scope**, not on `window`, so
|
|
||||||
cinny can't reach it even same-origin — which is why the in-call soundboard had
|
|
||||||
to be local-playback-only. **The fork removes this wall:** EC can expose a real
|
|
||||||
`io.lotus.inject_audio` widget action (Phase 2) that mixes into the published
|
|
||||||
track from inside its own module scope.
|
|
||||||
- `LOTUS_FEATURES.md` documents the EC upgrade history (0.16.3 → 0.19.4 →
|
|
||||||
0.20.1), the dark-mode CSS injection, and AFK auto-mute — all relevant prior
|
|
||||||
art for what the fork must preserve.
|
|
||||||
- `LOTUS_TESTING.md` §D is the **EC regression sweep** to re-run after the fork
|
|
||||||
swap (Phase 1 parity check).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. First actions for the new session
|
|
||||||
|
|
||||||
1. Read this file, then skim §2.3's files in `cinny` to internalize the seams.
|
|
||||||
2. Confirm with the user: new repo name, consumption model (§5), rebase cadence.
|
|
||||||
3. Phase 0: fork element-call, map 0.20.1 ↔ element-call tag, reproduce the
|
|
||||||
embedded build locally, diff against `public/element-call/`.
|
|
||||||
4. Phase 1: wire cinny to the fork, run `LOTUS_TESTING.md` §D parity sweep.
|
|
||||||
5. Only then start Phase 2 features (A5/A6/A7, theming, denoise-in-source).
|
|
||||||
|
|
||||||
**Cross-references:** `LOTUS_BUGS.md` (EC limitations + verify queue),
|
|
||||||
`LOTUS_TODO.md` (denoise/soundboard constraints), `LOTUS_FEATURES.md` (EC history),
|
|
||||||
`LOTUS_TESTING.md` §D (regression sweep). Infra: `/root/code/matrix` (`livekit/`,
|
|
||||||
`deploy/`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Live cutover — the remaining steps (Phase 1 finish)
|
|
||||||
|
|
||||||
The fork is published and cinny builds against it locally (§9.6). What's left to
|
|
||||||
go live:
|
|
||||||
|
|
||||||
1. **Run `LOTUS_TESTING.md` §D** against a local cinny build (`npm run build` is
|
|
||||||
already proven; serve `dist/` or `npm run dev`). Verify a real call: join,
|
|
||||||
mic/cam, screenshare, theme sync, denoise on, widget hangup — web first.
|
|
||||||
2. **Commit the cinny edits** (currently staged, uncommitted in the working tree):
|
|
||||||
`.npmrc`, `package.json`, `package-lock.json`, `vite.config.js`. Suggested
|
|
||||||
message: `chore(call): consume self-built @lotusguild/element-call-embedded`.
|
|
||||||
3. **Push to `lotus`** → cinny CI builds, then `trigger-desktop` bumps
|
|
||||||
cinny-desktop → Tauri release. Re-run §D on **cinny-desktop** (the path where
|
|
||||||
the old `stripBase` bug bit — verify the widget loads, not a 404).
|
|
||||||
4. Only then start **Phase 2** (A5/A6/A7, theming, denoise-in-source).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Phase 2 — implementation seams (mapped 2026-06-29)
|
|
||||||
|
|
||||||
The exact integration points for each Phase 2 item, found by reading the EC fork
|
|
||||||
|
|
||||||
- cinny source. **All of these are media-path / in-call features that cannot be
|
|
||||||
functionally verified without a live Matrix + LiveKit call** — implement each as
|
|
||||||
a minimal, **feature-flagged, additive** diff (no behavior change unless cinny
|
|
||||||
opts in), build-verify the fork (`pnpm build:embedded`, ~15s) AND cinny
|
|
||||||
(`npm run build`), then gate shipping on `LOTUS_TESTING.md` §D.
|
|
||||||
|
|
||||||
**Shared widget channel (the backbone for #2/#3/#4/#7):**
|
|
||||||
|
|
||||||
- EC→cinny: `widget.api.transport.send("io.lotus.<x>", data)` (see
|
|
||||||
`element-call/src/widget.ts`).
|
|
||||||
- cinny→EC actions: add the action name to the `lazyActions` allow-list in
|
|
||||||
`widget.ts` (the array at ~L101) and handle it in EC; cinny sends via
|
|
||||||
`this.call.transport.send(...)`.
|
|
||||||
- cinny receives EC→cinny actions via the existing `listenAction(type, cb)`
|
|
||||||
helper in `plugins/call/CallEmbed.ts:626` (auto-replies `{}` so the transport
|
|
||||||
doesn't time out — same pattern as `io.element.device_mute`).
|
|
||||||
|
|
||||||
**#2 mute/speaker events** — Source: subscribe to `vm.userMedia$`
|
|
||||||
(`CallViewModel`), per member `speaking$` + `audioEnabled$`
|
|
||||||
(`state/media/UserMediaViewModel.ts:47-48`); aggregate and
|
|
||||||
`transport.send("io.lotus.call_state", {participants:[{id,speaking,audioEnabled}]})`.
|
|
||||||
Mount in `room/InCallView.tsx` via `useEffect` guarded by `widget !== null`.
|
|
||||||
cinny: `listenAction("io.lotus.call_state")` in `CallEmbed.ts`, feed
|
|
||||||
`hooks/useCallSpeakers.ts` → delete its `contentDocument` `[data-muted]` /
|
|
||||||
`[data-video-fit]` scrape. _Additive, low risk._
|
|
||||||
|
|
||||||
**#4 spotlight/focus** — EC: add `io.lotus.focus_participant` to the `lazyActions`
|
|
||||||
list (`widget.ts`), drive `vm`'s spotlight (`spotlightSpeaker$` /
|
|
||||||
`spotlight$` in `CallViewModel.ts:898/1001`) to pin a given identity, coexisting
|
|
||||||
with `hasRemoteScreenShares$` (L1008). cinny: replace
|
|
||||||
`CallControl.ts` `focusCameraParticipant` `.click()` walk with
|
|
||||||
`transport.send("io.lotus.focus_participant", {userId})`. _Additive, low risk._
|
|
||||||
|
|
||||||
**#3 audio-inject** — EC: add `io.lotus.inject_audio` action; mix an
|
|
||||||
`AudioBufferSourceNode` into the published mic track. The local publish path is
|
|
||||||
`state/CallViewModel/localMember/Publisher.ts` + `LocalMember.ts` (LiveKit
|
|
||||||
`localParticipant`); create a `MediaStreamAudioDestinationNode`, mix mic + clip,
|
|
||||||
`replaceTrack`. cinny soundboard calls the action instead of local-only playback.
|
|
||||||
_Medium; touches publish path → live-test carefully._
|
|
||||||
|
|
||||||
**#1 denoise-in-source** — replace the cinny `lotusDenoise()` `getUserMedia`
|
|
||||||
monkeypatch with a real processing stage in EC's mic capture
|
|
||||||
(`Publisher.ts`/`LocalMember.ts`; note EC has a `TrackProcessorContext` +
|
|
||||||
`BlurBackgroundTransformer` precedent in `livekit/`). EC re-runs it on every
|
|
||||||
(re)publish → fixes A7. Remove `vite.config.js` `lotusDenoise()` + URL params in
|
|
||||||
`CallEmbed.ts`; move `denoise/` assets into the fork. _Highest value, highest
|
|
||||||
risk — most live testing._
|
|
||||||
|
|
||||||
**#5 theming** — add a Lotus/TDS theme in EC's theme system (`src/useTheme.ts` +
|
|
||||||
EC theme tokens / CSS); driven by the existing `setTheme()` channel cinny already
|
|
||||||
calls (`CallEmbed.ts:277`). Bake transparent background. Delete cinny's
|
|
||||||
`applyStyles()` injection + `background:none !important`. _Medium._
|
|
||||||
|
|
||||||
**#6 in-call decorations** — render the decoration APNG in EC's tile component
|
|
||||||
(`tile/GridTile.tsx`); pass slugs via widget member data. cinny already has the
|
|
||||||
decoration data + `AvatarDecoration` (lobby `CallMemberCard.tsx`). _Medium-Large._
|
|
||||||
|
|
||||||
**#7 quality controls** — set audio `maxBitrate` via
|
|
||||||
`RTCRtpSender.setParameters` and screenshare `getDisplayMedia` constraints in
|
|
||||||
EC's publish path (`Publisher.ts`); configurable via `config.json` / a widget
|
|
||||||
message. Keep the server `voice-limit-guard` as enforcement. _Medium._
|
|
||||||
|
|
||||||
**Rollback:** revert the 4 cinny files (restores `@element-hq/...@0.20.1` from
|
|
||||||
npmjs). The fork repo/package can stay; nothing else depends on it until pushed.
|
|
||||||
|
|
||||||
### Local repro/build environment (this session, 2026-06-29)
|
|
||||||
|
|
||||||
- Upstream cloned + our `lotus` branch at `/root/code/element-call` (remote
|
|
||||||
`lotus` → Gitea; origin → github upstream, now un-shallowed/full history).
|
|
||||||
- Isolated **Node 24.18.0** lives in the session scratchpad (system Node is 20);
|
|
||||||
cinny's `.node-version` is `24.13.1`, so use Node 24 to build cinny too.
|
|
||||||
- Build the embedded bundle: in `/root/code/element-call`, with Node 24 + pnpm
|
|
||||||
10.33.0 on PATH, `VITE_APP_VERSION=embedded-v0.20.1 pnpm run build:embedded`
|
|
||||||
→ output in `dist/`; stage to `embedded/web/dist` before publishing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Phase 2 — IMPLEMENTED on the fork (2026-06-30)
|
|
||||||
|
|
||||||
All 7 EC features are on the `lotus` branch of `LotusGuild/element-call`, each
|
|
||||||
**additive + feature-flagged** (a vanilla call with no `lotus*` params / no Lotus
|
|
||||||
actions behaves exactly like upstream), build + `tsc` clean, per-feature reviewed
|
|
||||||
(fixes applied) and holistically reviewed. **Not yet live-tested** — all need the
|
|
||||||
`LOTUS_TESTING.md` §D sweep.
|
|
||||||
|
|
||||||
Fork modules live under `element-call/src/lotus/*`; mounts are `useEffect`s in
|
|
||||||
`src/room/InCallView.tsx`. Custom widget actions are in `src/lotus/lotusActions.ts`
|
|
||||||
(toWidget ones allow-listed in `src/widget.ts`).
|
|
||||||
|
|
||||||
| # | Feature | Enable via | EC module |
|
|
||||||
| :--- | :------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------ | ---------------------------------------------------- |
|
|
||||||
| 2 | Speaker/mute/camera state → host | URL `lotusCallState=1` | `lotusCallState.ts` (sends `io.lotus.call_state`) |
|
|
||||||
| 4 | Focus/spotlight a participant (works during screenshare) | action `io.lotus.focus_participant {userId | null}` | `lotusFocus.ts` + `CallViewModel` spotlight override |
|
|
||||||
| 3 | Soundboard audio-inject (heard by peers) | URL `lotusAudioInject=1` + action `io.lotus.inject_audio {url,volume?}` | `lotusAudioInject.ts` |
|
|
||||||
| 7 | Audio/screenshare quality caps | action `io.lotus.set_quality {audioMaxBitrate?,screenshareMaxBitrate?,screenshareMaxFramerate?}` | `lotusQuality.ts` |
|
|
||||||
| 5 | Transparent bg + Lotus theme | URL `lotusTransparent=1` / `lotusTheme=1` | `useTheme.ts` + `index.css` |
|
|
||||||
| 6 | In-call avatar decorations | action `io.lotus.decorations {decorations:{userId:url}}` | `lotusDecorations.ts` + `MediaView.tsx` |
|
|
||||||
| 1 | ML denoise in-source (fixes A7) | URL **`lotusDenoiseSource=1`** (+`lotusModel`,`lotusGate`,`lotusGateThreshold`,`lotusDenoiseBase`) — deliberately NOT the existing `lotusDenoise=ml` (that drives the host shim; reusing it would double-process) | `lotusDenoise.ts` + `lotusDenoiseProcessor.ts` |
|
|
||||||
| P6-2 | Deafen / screenshare-audio-mute at the LiveKit source | action `io.lotus.set_deafen {deafened,screenshareAudioMuted}` — sets remote `RemoteParticipant.setVolume(0/1)` per source (Microphone + ScreenShareAudio), persists to late joiners via `RoomEvent.ParticipantConnected` | `lotusDeafen.ts` |
|
|
||||||
|
|
||||||
### 12.4 P6-2 — deafen action (retires cinny's iframe-DOM `.muted` hack)
|
|
||||||
|
|
||||||
`io.lotus.set_deafen` (fork commit, folded into unpublished **`0.20.1-lotus.2`**) replaces
|
|
||||||
cinny's `CallControl.setSound`/`applyScreenshareAudioMuted` DOM `<audio>.muted` poking —
|
|
||||||
which broke silently on EC re-render / late tracks. **Two-phase rollout:**
|
|
||||||
|
|
||||||
1. **DONE (this batch):** fork action implemented; cinny's `CallControl` now ALSO sends
|
|
||||||
`io.lotus.set_deafen` (gated on join via `forceState`) alongside the retained DOM hack.
|
|
||||||
Against the current pinned bundle (`lotus.1`, no handler) the action is immediately
|
|
||||||
error-replied and swallowed by `.catch` — inert, no timeout.
|
|
||||||
2. **TODO — needs YOU to publish, then me:** publish the fork (`0.20.1-lotus.2`) to npm →
|
|
||||||
I bump cinny's pin `0.20.1-lotus.1` → `lotus.2`, `npm install`, then DELETE the DOM
|
|
||||||
`.muted` code from `CallControl.ts` (the hack is fully retired only here).
|
|
||||||
|
|
||||||
**Known divergence to confirm:** deafen silences remote Microphone + ScreenShareAudio, but
|
|
||||||
NOT injected/soundboard audio (`Track.Source.Unknown` — livekit-client's `setVolume` type
|
|
||||||
only accepts Microphone|ScreenShareAudio). So a deafened user still hears host-triggered
|
|
||||||
soundboard clips. Defensible (short, host-gated); confirm it's the desired UX.
|
|
||||||
|
|
||||||
**Security hardening applied** (holistic audit): `lotusDenoiseBase` forced
|
|
||||||
same-origin before `audioWorklet.addModule` (was an arbitrary-code-load vector
|
|
||||||
via a crafted link); audio-inject gated behind `lotusAudioInject=1`; decoration
|
|
||||||
roster capped. Only `https`/`blob` URLs accepted for inject/decoration assets.
|
|
||||||
|
|
||||||
### 12.1 cinny host integration checklist (REQUIRED to light these up)
|
|
||||||
|
|
||||||
> ✅ **STATUS (2026-06): COMPLETE.** All items below are shipped. call_state,
|
|
||||||
> focus_participant, decorations, and transparent background are active; the
|
|
||||||
> in-source denoise cutover is done (flag `lotusDenoiseSource=1`, **all four**
|
|
||||||
> models in-source); and the two formerly-dormant capabilities now have cinny
|
|
||||||
> UI — **soundboard** (`io.lotus.inject_audio`, P5-15) and **quality controls +
|
|
||||||
> room permissions** (`io.lotus.set_quality` + `io.lotus.room_quality`, P5-31,
|
|
||||||
> with server-side enforcement in `LotusGuild/matrix`). See `LOTUS_FEATURES.md`
|
|
||||||
> → "Element Call — Self-Built Fork". The checklist is kept below as the record
|
|
||||||
> of what was wired. (One open denoise item tracked separately: the "Series
|
|
||||||
> Suppression" native-NS toggle is not wired to the real call path.)
|
|
||||||
|
|
||||||
The EC side is additive and dormant until cinny opts in. Host work (in
|
|
||||||
`src/app/plugins/call/CallEmbed.ts` unless noted) — **done**:
|
|
||||||
|
|
||||||
> ⚠️ **CRITICAL TIMING (protocol audit F1):** only send `io.lotus.*` **toWidget**
|
|
||||||
> actions (#3 focus, #6 decorations, #7 quality, audio-inject) **after** the call
|
|
||||||
> is joined (`CallEmbed.onCallJoined` / `this.joined`). Those actions are
|
|
||||||
> allow-listed at EC app-init (so `preventDefault` suppresses the auto-error)
|
|
||||||
> but their handlers only mount with `InCallView` (post-join). Sending earlier
|
|
||||||
> leaves the host's `transport.send` pending until the **10s timeout**. Queue and
|
|
||||||
> flush on join, or no-op before join.
|
|
||||||
>
|
|
||||||
> Also: **F3 (RESOLVED)** — all four models (`rnnoise`/`speex`/`dtln`/
|
|
||||||
> `deepfilternet`) are now implemented in-source in `lotusDenoiseProcessor.ts`;
|
|
||||||
> the picker offers all four. **F4** — cinny no longer forwards a native-NS flag
|
|
||||||
> in the `ml` branch (the "Series Suppression" toggle is currently a no-op in
|
|
||||||
> real calls — open item). **F7** — no widget _capability_ changes needed;
|
|
||||||
> custom actions bypass capability checks.
|
|
||||||
|
|
||||||
1. **Set the URL flags** on the widget iframe params (the `URLSearchParams` in
|
|
||||||
`CallEmbed`): `lotusCallState=1`, `lotusTransparent=1`/`lotusTheme=1`,
|
|
||||||
`lotusAudioInject=1` as desired. (Denoise sets `lotusDenoiseSource=1` + `lotusModel`/`lotusGate`/`lotusGateThreshold` in the `ml` tier.)
|
|
||||||
2. **Ack `io.lotus.call_state`**: add `listenAction('io.lotus.call_state', …)` —
|
|
||||||
without a reply the fork's sends time out every 250ms. Feed the payload into
|
|
||||||
`useCallSpeakers` and RETIRE its `contentDocument` DOM scrape.
|
|
||||||
3. **Send actions** via `this.call.transport.send(...)`:
|
|
||||||
`io.lotus.focus_participant` (replace `CallControl.focusCameraParticipant`’s
|
|
||||||
`.click()`), `io.lotus.inject_audio` (from the soundboard), `io.lotus.set_quality`
|
|
||||||
(from quality settings), `io.lotus.decorations` (push the MSC4133 decoration
|
|
||||||
map; resolve mxc→https first).
|
|
||||||
4. **#1 denoise cutover**: once verified, STOP injecting the `lotusDenoise()`
|
|
||||||
shim in `cinny/vite.config.js` and remove the `index.html` injection — the
|
|
||||||
fork now does denoise in-source. Keep shipping the `denoise/` assets (the
|
|
||||||
fork loads `./denoise/…` at runtime) until those move into the fork build.
|
|
||||||
5. Re-run `LOTUS_TESTING.md` §D for each feature; only then ship.
|
|
||||||
|
|
||||||
### 12.2 Holistic multi-agent review — outstanding follow-ups (non-blocking)
|
|
||||||
|
|
||||||
Four aspect-agents reviewed the whole fork. Criticals were fixed in-branch (the
|
|
||||||
denoise restart-silence/A7 bug; the `lotusDenoiseBase` code-load vector;
|
|
||||||
audio-inject opt-in gate; #6 rendering in the wrong component; #7 simulcast cap).
|
|
||||||
Remaining, deliberately deferred:
|
|
||||||
|
|
||||||
- **Denoise H2 (double-processing):** if cinny is set to `lotusDenoise=ml` while
|
|
||||||
ALSO still injecting its build-time `getUserMedia` shim, audio is denoised
|
|
||||||
twice. The #1 cutover MUST remove the cinny-side injection (it currently has
|
|
||||||
none injected into the iframe — keep it that way). Hard requirement, not code.
|
|
||||||
- **Denoise M1 (perf):** in-source uses non-SIMD `rnnoise.wasm`; the reference
|
|
||||||
preferred SIMD with detection. Perf-only; add SIMD detection later.
|
|
||||||
- **dtln/deepfilternet (F3): RESOLVED** — all four models
|
|
||||||
(rnnoise/speex/dtln/deepfilternet) are now implemented in
|
|
||||||
`lotusDenoiseProcessor.ts` (faithful port of cinny's `build/lotus-denoise.js`
|
|
||||||
pipeline). This also fixed a real bug (the gate worklet name was `noiseGate`;
|
|
||||||
correct is the hyphenated `noise-gate`) and added per-model sample rates
|
|
||||||
(DTLN 16 kHz, others 48 kHz), context `resume()`, and SIMD wasm selection.
|
|
||||||
Still needs live §D testing per model, and depends on cinny shipping the
|
|
||||||
DTLN (`denoise/workadventure/`) + DeepFilterNet (`denoise/deepfilternet/`)
|
|
||||||
asset trees (it already does).
|
|
||||||
- **Rebase-fragility (build agent MED):** the `CallViewModel` spotlight override
|
|
||||||
edits hot upstream lines (renamed `spotlightSpeaker$`→`autoSpotlightSpeaker$`).
|
|
||||||
For cheaper future rebases, refactor it into a `src/lotus/lotusSpotlight.ts`
|
|
||||||
wrapper that takes the upstream stream and returns the overridden one, leaving
|
|
||||||
upstream's definition byte-identical (a single import + two token swaps).
|
|
||||||
- **Denoise asset coupling (build agent HIGH):** the fork loads `./denoise/*`
|
|
||||||
shipped by cinny, not by the fork build (documented in the processor). Add an
|
|
||||||
integration smoke-check that `GET …/element-call/denoise/rnnoise.wasm` == 200,
|
|
||||||
and pin the `@sapphi-red/web-noise-suppressor` version both repos expect.
|
|
||||||
- **Unconditional effect registration (build agent LOW):** focus/audio-inject/
|
|
||||||
quality/decorations register widget handlers on every embedded call (true
|
|
||||||
no-ops for a non-Lotus host). Intentional; gate behind a coarse `lotus=1` flag
|
|
||||||
if strict zero-footprint is desired.
|
|
||||||
- **Privacy (security agent):** decoration/inject URLs accept any `https`; ideally
|
|
||||||
restrict to the homeserver media origin host-side. Call-state exposes
|
|
||||||
userId/deviceId/speaking to the (trusted, same-origin) host — documented.
|
|
||||||
|
|
||||||
**Nothing here blocks the §D live test — but every feature still needs it.**
|
|
||||||
|
|
||||||
### 12.3 Safe rollout when prod is the only test environment
|
|
||||||
|
|
||||||
Every Phase-2 feature is now **dormant by default** — with the flags cinny sets
|
|
||||||
today, the fork behaves identically to the parity build (`#1` was decoupled onto
|
|
||||||
`lotusDenoiseSource=1` so it no longer collides with the host's `lotusDenoise=ml`
|
|
||||||
shim). This enables a low-risk incremental rollout even without a staging env:
|
|
||||||
|
|
||||||
1. **Ship dormant first.** Publish the `lotus` branch (e.g. `0.20.1-lotus.1`),
|
|
||||||
bump cinny's pin, deploy. With no Lotus flags set / no Lotus actions sent,
|
|
||||||
this is upstream-equivalent (only inert, holistically-reviewed code runs).
|
|
||||||
"Testing" here = confirm a normal call still works.
|
|
||||||
2. **Enable ONE feature at a time**, each independently revertable:
|
|
||||||
- URL-flag features (#2 `lotusCallState`, #5 `lotusTransparent`/`lotusTheme`,
|
|
||||||
#1 `lotusDenoiseSource`): add the flag in `CallEmbed.getWidget`, deploy,
|
|
||||||
test that one feature, roll back just that flag if needed.
|
|
||||||
- Action features (#3,#4,#6,#7): wire the host send + (for #2) the
|
|
||||||
`listenAction` ack, gated on join (§12.1 F1).
|
|
||||||
3. **#1 denoise cutover is a coordinated 2-step** (do together): set
|
|
||||||
`lotusDenoiseSource=1` AND remove the `lotusDenoise()` shim injection +
|
|
||||||
`lotusDenoise=ml` param in cinny — otherwise audio is denoised twice.
|
|
||||||
Roll back = revert both.
|
|
||||||
4. Baseline is always upstream-equivalent, so any single feature can be disabled
|
|
||||||
by flipping its flag/send off without touching the rest.
|
|
||||||
|
|
||||||
**Blocker to step 1:** publishing the `lotus` branch needs a Gitea npm token
|
|
||||||
(the admin token used for the `0.20.1` parity publish was deleted). Either
|
|
||||||
provide a token for a manual `npm publish`, or stand up the Gitea Actions runner
|
|
||||||
|
|
||||||
- `GITEA_NPM_TOKEN` secret so a `v0.20.1-lotus.1` tag auto-publishes.
|
|
||||||
-188
@@ -1,188 +0,0 @@
|
|||||||
# Lotus Chat — Open Bugs & Technical Debt
|
|
||||||
|
|
||||||
**Only OPEN and awaiting-verification items live here.** Resolved findings
|
|
||||||
(fixed-and-verified, false-positives, won't-fix) have been removed to keep this
|
|
||||||
actionable — the full history is in git. Items fixed in code but not yet
|
|
||||||
verified in a real environment are in **Needs Verification** below and have
|
|
||||||
step-by-step checks in [`LOTUS_TESTING.md`](./LOTUS_TESTING.md).
|
|
||||||
|
|
||||||
> Design rules for any fix here: follow the **Native-Cinny Law** and **TDS
|
|
||||||
> Design Law** in [`LOTUS_TODO.md`](./LOTUS_TODO.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ Needs Verification — fixed in code, awaiting live testing
|
|
||||||
|
|
||||||
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
|
|
||||||
|
|
||||||
| ID | Item | File / area | Test |
|
|
||||||
| :--- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
|
|
||||||
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
|
|
||||||
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
|
|
||||||
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
|
|
||||||
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
|
|
||||||
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
|
|
||||||
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
|
|
||||||
| #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 |
|
|
||||||
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
|
|
||||||
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
|
|
||||||
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
|
|
||||||
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
|
|
||||||
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
|
|
||||||
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
|
|
||||||
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
|
|
||||||
| P3-8 | Thread Panel (side drawer, chips, threaded receipts, thread composer) | `features/room/thread/*`, `RoomTimeline/RoomInput` | 6-step checklist in LOTUS_TODO §P3-8 |
|
|
||||||
| P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk) | `utils/mathParse.ts`, `components/math/` | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text) |
|
|
||||||
| P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes |
|
|
||||||
| N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth |
|
|
||||||
| P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math) | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread` | 6-step checklist in LOTUS_TODO §P4-1 |
|
|
||||||
| AW-1 | Scheduled-message cancel no longer ghost-sends (error row on failure) | `ScheduledMessagesTray.tsx` | schedule → cancel with network cut → item stays + error; retry works |
|
|
||||||
| AW-2 | Emoji lazy-load (search/autocomplete/recents fill in; board opens fast) | `plugins/emoji.ts` + consumers | first emoji-board open of a session: grid+search populate; reactions still label |
|
|
||||||
| AW-3 | SW precache (repeat-visit near-instant; deploys still picked up immediately) | `sw.ts`, `vite.config.js` | load app twice (2nd = cached assets); deploy → reload picks new version |
|
|
||||||
| AW-4 | Desktop CSP tighten + Escape/panel fixes + thread Jump to Latest | `tauri.conf.json`, Room/ThreadPanel | desktop: boots, avatars/media load, VT323 font renders, location maps embed, calls connect, deep links work |
|
|
||||||
| P3-4 | Accessibility compliance pass (collapsed-msg SR sender, form/overlay labels, typing announce, focus-return, `?` help, jsx-a11y CI gate) | `message/*`, `RoomViewTyping`, `features/shortcuts/*`, `eslint.config.mjs` | LOTUS_TESTING §P — axe-core + VoiceOver/NVDA on the golden path |
|
|
||||||
| P6-1 | Desktop Linux parity (no-sleep in calls, launcher badge), autostart toggle, tray Do-Not-Disturb | `native/power.rs`, `lib.rs`, `useTauriDnd`, `General.tsx` | Linux desktop: no display sleep during a call; tray DND silences notifications; launch-on-login persists; Unity badge (Ubuntu); DND toggle polarity |
|
|
||||||
| P6-2 | EC deafen/screenshare-audio-mute via `io.lotus.set_deafen` (retires the `<audio>.muted` iframe hack) | fork `lotusDeafen.ts`, cinny `CallControl.ts` | AFTER publish+pin-bump: deafen silences remote audio + survives a reconnect / new screenshare / late joiner (the cases the DOM hack failed); screenshare-audio-mute toggles independently |
|
|
||||||
| P6-3 | Forward-to-multiple-rooms (multi-select + partial-failure summary) + live bookmark previews (edits/redactions, snapshot fallback) | `ForwardMessageDialog.tsx`+`forwardContent.ts`, `BookmarksPanel.tsx` | forward one msg to 3 rooms (incl. 1 you cannot post to = partial summary); bookmark then edit shows edited; redact shows deleted; leave room shows snapshot |
|
|
||||||
| P6-4 | HSTS + Permissions-Policy on prod nginx (+ contrib examples) | `matrix/cinny/nginx.conf`, `contrib/nginx`, `contrib/caddy` | after `nginx -s reload`: `curl -sI https://chat.lotusguild.org` shows HSTS + Permissions-Policy; a call (cam/mic/screenshare) + location share still work |
|
|
||||||
|
|
||||||
**Verified working in live testing (2026-06):** A2, B1–B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧩 Element Call source-level items — now actionable via the fork
|
|
||||||
|
|
||||||
> 🔱 **[EC-FORK]** **UPDATE 2026-06-30: Phase 2 IMPLEMENTED.** We own and
|
|
||||||
> self-build Element Call (`LotusGuild/element-call` →
|
|
||||||
> `@lotusguild/element-call-embedded@0.20.1-lotus.1`, cinny wired). A5/A6/A7
|
|
||||||
> below are **fixed in the fork** — they are now ⚠️ awaiting **live
|
|
||||||
> verification** (`LOTUS_TESTING.md` §D2), not open work. See
|
|
||||||
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md) §10. Delete each
|
|
||||||
> row once verified live.
|
|
||||||
|
|
||||||
The in-call participant grid is rendered **inside EC's app** — now editable source
|
|
||||||
(previously a prebuilt npm bundle we could only style around). Status of the items
|
|
||||||
from testing:
|
|
||||||
|
|
||||||
- **A5 — "Focus camera": ⚠️ FIXED in fork, awaiting verify (D2-3).** cinny now
|
|
||||||
sends an `io.lotus.focus_participant` widget action that pins a participant in
|
|
||||||
EC's layout (coexisting with / overriding the screenshare spotlight); the old
|
|
||||||
`.click()`-the-tile DOM hack in `CallControl.ts` is deleted.
|
|
||||||
- **A6 — avatar decorations in-call: ⚠️ FIXED in fork, awaiting verify (D2-4).**
|
|
||||||
cinny pushes `io.lotus.decorations` (per-user APNG URLs) and the fork renders
|
|
||||||
them on EC's participant video-tile avatars — not just our pre-join lobby roster.
|
|
||||||
- **A7 — mic dead after EC's "Reconnect": ⚠️ FIXED in fork, awaiting verify
|
|
||||||
(D2-1).** Denoise moved into EC's mic-capture/publish pipeline as a first-class
|
|
||||||
LiveKit `TrackProcessor` (flag `lotusDenoiseSource=1`); EC re-runs it on every
|
|
||||||
(re)publish, so reconnects keep denoise alive natively. The build-time
|
|
||||||
`getUserMedia`/`index.html` injection (the root cause) is removed. **Highest
|
|
||||||
blast radius — everyone's mic; verify D2-1 carefully.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔴 Open — Actionable
|
|
||||||
|
|
||||||
### 🧨 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
|
|
||||||
> very likely **interrelated** (see KE-1 → KE-2). Do **not** spot-fix — they need
|
|
||||||
> a dedicated cross-system planning session with the homeserver owner. Capture
|
|
||||||
> full client console + a synapse-side trace for the same call before starting.
|
|
||||||
> **None of these are caused by the EC fork work** (the issues reproduce on the
|
|
||||||
> old build; the local mic/denoise path is unrelated to key distribution).
|
|
||||||
|
|
||||||
- **KE-1 — One-time-key (OTK) upload conflict storm (CRITICAL, root-cause candidate).**
|
|
||||||
`POST /_matrix/client/v3/keys/upload` returns `400 M_UNKNOWN: One time key
|
|
||||||
signed_curve25519:AAAAAAAAAGQ already exists. Old key: {…} new key: {…}` —
|
|
||||||
firing **continuously** (many/sec). The client repeatedly tries to publish an
|
|
||||||
OTK at a key id the server already holds **with a different value**, i.e. the
|
|
||||||
rust-crypto key store and Synapse have **diverged OTK state**. Impact: floods
|
|
||||||
the crypto outgoing-request loop and is the prime suspect for the downstream
|
|
||||||
missing-key failures (no fresh OTKs ⇒ no new Olm sessions ⇒ undecryptable
|
|
||||||
to-device key events). _Investigate:_ device/key-store reset-or-restore
|
|
||||||
mismatch, OTK id-counter desync, RC-SDK (`41.6.0-rc.0`) regression, or a
|
|
||||||
Synapse OTK bug. Repro signature: grep console for `already exists`.
|
|
||||||
**Extreme — planning session.**
|
|
||||||
**Update 2026-07 (investigation §6):** upstream `matrix-rust-sdk#5200` (still
|
|
||||||
OPEN) confirms the mechanism — on the 400, `mark_request_as_sent()` never fires
|
|
||||||
so the SDK re-issues the identical upload forever. **`41.7.0` does NOT fix it**
|
|
||||||
(crypto-wasm 17→18.3.1 has no OTK/upload change; 18.3.x was to-device security
|
|
||||||
only) — the SDK-pin lever is closed. Root cause = **store↔server OTK
|
|
||||||
divergence**; the leading **web-specific** trigger is that cinny never calls
|
|
||||||
**`navigator.storage.persist()`**, so the IndexedDB crypto store is evictable
|
|
||||||
while the `localStorage` session/device-id survives → device resurrects with a
|
|
||||||
blank store → re-uploads OTKs the server still holds. **Actionable preventive
|
|
||||||
fix (buildable now, no call needed):** request persistent storage on login
|
|
||||||
(+ optional multi-tab guard + 400-loop→recovery-prompt). Healing an already-
|
|
||||||
diverged device still needs a clean **logout+login** (not just "clear
|
|
||||||
storage"). See `LOTUS_E2EE_INVESTIGATION.md` §6.
|
|
||||||
|
|
||||||
- **KE-2 — Element Call media keys not arriving/decrypting → audio & video cut out (CRITICAL).**
|
|
||||||
`MissingKey: missing key at index N for participant @user`, `skipping decryption
|
|
||||||
due to missing key`, `MissingKey: key set not found for @user at index 0`, and
|
|
||||||
rust-crypto `WARN … Received an unexpected encrypted to-device event …
|
|
||||||
event_type="io.element.call.encryption_keys"`. EC distributes per-participant
|
|
||||||
media keys as **encrypted to-device `io.element.call.encryption_keys`** events;
|
|
||||||
these aren't being received/decrypted in order, so remote LiveKit audio/video
|
|
||||||
can't be decrypted — **this is the "friend's audio cuts out occasionally"
|
|
||||||
symptom.** Almost certainly downstream of **KE-1** (broken Olm sessions). Spans
|
|
||||||
EC's MatrixRTC E2EE + rust-crypto to-device + Synapse. **Extreme — planning
|
|
||||||
session.**
|
|
||||||
|
|
||||||
- **KE-3 — Timeline decryption error: missing `algorithm` field (HIGH).**
|
|
||||||
`Error decrypting event (… type=m.room.encrypted …): DecryptionError[msg:
|
|
||||||
missing field 'algorithm' at line 1 column 138 …]`. A malformed/legacy
|
|
||||||
encrypted event (or a serialization mismatch in the RC SDK) that rust-crypto
|
|
||||||
can't parse. Lower frequency than KE-1/2 but a distinct decode-path failure —
|
|
||||||
capture the offending event id (`$SASBBzoqj…` seen) and inspect its raw content.
|
|
||||||
|
|
||||||
- **KE-4 — MatrixRTC delayed-event / membership timeouts (MEDIUM-HIGH, reliability).**
|
|
||||||
`[MembershipManager] Network local timeout error while sending event, immediate
|
|
||||||
retry … AbortError: Restart delayed event timed out before the HS responded`,
|
|
||||||
with repeated `org.matrix.msc4157.update_delayed_event`. MSC4140/4157
|
|
||||||
delayed-event reliability against `matrix.lotusguild.org` — can cause stale/ghost
|
|
||||||
call membership and missed leave events. May be partly **homeserver
|
|
||||||
responsiveness**; correlate with synapse latency/load. Include in the same
|
|
||||||
planning session since it shares the call-reliability + HS-interaction surface.
|
|
||||||
|
|
||||||
### Security & Privacy
|
|
||||||
|
|
||||||
- **N97 — Access token stored in plaintext `localStorage`** (`state/sessions.ts`), vulnerable to XSS; device ID likewise. Architectural — needs a token-protection / session-storage redesign.
|
|
||||||
- ~~**Session writes are non-atomic and not cross-tab synced**~~ — **done (2026-07):** atomic single-key `cinny_session_v1` blob (legacy-key migration + dual-write) + `subscribeSessionChanges`/`useSessionSync` cross-tab reload. (The plaintext-token concern in N97 above is the remaining, separate architectural item.)
|
|
||||||
- **Persisted PII without encryption:** user status message + expiry (`settings/account/Profile.tsx`), unsent composer drafts (`room/RoomInput.tsx`). Leak risk on shared devices.
|
|
||||||
|
|
||||||
### PWA / Offline / Notifications
|
|
||||||
|
|
||||||
- **N107 — SW has no `push` handler** — Web Push delivery is entirely non-functional. Needs a `push` listener + a Matrix push-gateway integration.
|
|
||||||
- **No app-asset caching strategy** (`src/sw.ts`) — no offline capability.
|
|
||||||
- ~~**`manifest: false`** may block PWA install~~ — **verified OK (2026-06):** `index.html` links `/manifest.json`, which exists in `public/` and is copied to `dist/`; VitePWA intentionally doesn't generate one. Not a bug.
|
|
||||||
|
|
||||||
### Dependencies & Build
|
|
||||||
|
|
||||||
- ~~**`matrix-js-sdk` pinned to a Release Candidate**~~ — **done (2026-07):** moved to `41.7.0` stable (crypto-wasm 18.3.1 security bump). Deep-audit dep triage: all 16 npm advisories are dev-only/unreachable/dead-dep — zero shipped exposure; dead `dompurify` removed. `@atlaskit`/build-tool pins remain review-worthy but low priority.
|
|
||||||
- **Build-time overhead:** `lotusDenoise` does heavy sequential `fs` work in `closeBundle`; `viteStaticCopy` config is complex with redundant renames — could be streamlined.
|
|
||||||
|
|
||||||
### Code Hygiene / DevEx
|
|
||||||
|
|
||||||
- **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~~ — **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.)
|
|
||||||
- **Architecture notes (low priority):** deep `features/` + `hooks/` nesting, many small coupled hooks, possible dead CSS/components, `SpacingVariant` / `DropTarget` recipe simplification.
|
|
||||||
- **Git workflow (forward-looking):** keep commits scoped — past monolithic "fix all bugs" commits and inconsistent prefixes hurt `git bisect`.
|
|
||||||
|
|
||||||
### Big Projects
|
|
||||||
|
|
||||||
- ~~**#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."
|
|
||||||
@@ -1,504 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 2026-07 investigation update — 41.7.0 delta + web-specific root cause
|
|
||||||
|
|
||||||
New findings this session (code-read + upstream issue triage). These **sharpen
|
|
||||||
KE-1's root cause and close the "just upgrade the SDK" lever**.
|
|
||||||
|
|
||||||
### 6.1 The 41.7.0 upgrade does NOT fix KE-1 (lever closed)
|
|
||||||
|
|
||||||
We are now on **`matrix-js-sdk@41.7.0`** → **`@matrix-org/matrix-sdk-crypto-wasm@18.3.1`**
|
|
||||||
(was `41.6.0-rc.0` when KE-1/2 were observed). Checked both changelogs:
|
|
||||||
|
|
||||||
- 41.7.0's only crypto line is the **security bump to crypto-wasm 18.3.1**. No
|
|
||||||
OTK / keys-upload / Olm-session change.
|
|
||||||
- crypto-wasm 17.0 → 18.3.1: **no entry** for one-time-keys, keys/upload,
|
|
||||||
"already exists", or upload conflicts. The 18.3.x work was **to-device
|
|
||||||
security hardening** (vodozemac 0.10; sender-spoofing check via
|
|
||||||
`sender_device_keys`; MSC4147 validation) — unrelated to the OTK loop.
|
|
||||||
- Upstream **`matrix-rust-sdk#5200`** ("OlmMachine constantly tries to upload
|
|
||||||
keys when restoring session") is **still OPEN** (as of mid-2025). The loop
|
|
||||||
mechanism is confirmed there: on the 400, `mark_request_as_sent()` never
|
|
||||||
fires, so the keys stay "unshared" and the SDK re-issues the identical failing
|
|
||||||
upload every cycle → the storm.
|
|
||||||
|
|
||||||
⇒ **Remediation option 3 (SDK pin) is exhausted for KE-1.** Do not expect a
|
|
||||||
version bump to help; the fix is store-hygiene, below.
|
|
||||||
|
|
||||||
### 6.2 Confirmed root cause + the web-specific trigger we can act on
|
|
||||||
|
|
||||||
Upstream `#5200` + `#1415` pin the root condition to **rust-crypto store ↔
|
|
||||||
server OTK divergence**, from one of:
|
|
||||||
|
|
||||||
1. **Crypto store reset/restore without deregistering the device server-side**
|
|
||||||
— the store forgets OTKs it already published; the server still holds them.
|
|
||||||
2. **Unsafe concurrent access to the crypto store** — e.g. the **same session
|
|
||||||
open in multiple browser tabs**, each running its own OlmMachine against the
|
|
||||||
one IndexedDB crypto store.
|
|
||||||
3. A store that isn't durably persisted, so a restore can't track what was sent.
|
|
||||||
|
|
||||||
**Cinny is a web client and hits two of these by construction (verified in code):**
|
|
||||||
|
|
||||||
- **No `navigator.storage.persist()` anywhere** (`grep` clean). The rust-crypto
|
|
||||||
IndexedDB store is therefore **evictable under storage pressure** — while the
|
|
||||||
**access token + device id live in `localStorage`** (N97), which browsers evict
|
|
||||||
_less_ aggressively. Partial eviction ⇒ the device **resurrects with a blank
|
|
||||||
crypto store but the SAME device id** ⇒ it re-uploads OTKs the server still
|
|
||||||
holds ⇒ the **exact KE-1 "already exists" divergence**, with **no user action**
|
|
||||||
and no visible cause. This is the leading hypothesis for a self-hosted web
|
|
||||||
deployment.
|
|
||||||
- **No multi-tab crypto guard** (no `navigator.locks` / `BroadcastChannel`
|
|
||||||
leader election in `src/`). `initMatrix.ts` calls `mx.initRustCrypto()` with no
|
|
||||||
single-writer coordination, so 2+ tabs = concurrent store access = trigger #2.
|
|
||||||
|
|
||||||
### 6.3 Concrete PREVENTIVE client mitigations (new — buildable, don't need a call)
|
|
||||||
|
|
||||||
Ordered by value/effort. These reduce the _recurrence_ of KE-1; they don't heal
|
|
||||||
an already-diverged device (that still needs remediation option 1: clean
|
|
||||||
logout+login).
|
|
||||||
|
|
||||||
1. **Request persistent storage on login — `navigator.storage.persist()`**
|
|
||||||
_(cheapest, highest value)_. Idempotent, side-effect only, no behavior change
|
|
||||||
if the browser denies it. Directly prevents the eviction-induced divergence in
|
|
||||||
6.2. Best placed at app entry alongside the other module-scope calls (NOT in
|
|
||||||
`initMatrix.ts`, which is off-limits) — e.g. a one-liner in `ClientRoot`/app
|
|
||||||
bootstrap: `if (navigator.storage?.persist) navigator.storage.persist();`
|
|
||||||
Optionally surface `navigator.storage.persisted()` in the Crypto Diagnostics
|
|
||||||
card so a capture records whether the store was evictable.
|
|
||||||
2. **Multi-tab guard** _(medium)_. Detect a second tab of the same session
|
|
||||||
(BroadcastChannel or the Web Locks API) and either (a) warn "Lotus is open in
|
|
||||||
another tab — encryption may misbehave", or (b) make secondary tabs read-only
|
|
||||||
for crypto. Prevents trigger #2.
|
|
||||||
3. **Loop detection → recovery prompt** _(medium)_. Watch for repeated
|
|
||||||
`keys/upload` 400 `M_UNKNOWN … already exists` (the client sees the rejection);
|
|
||||||
after N in a window, stop hammering and surface a "Reset encryption on this
|
|
||||||
device (log out & back in)" prompt instead of looping silently.
|
|
||||||
|
|
||||||
### 6.4 Secondary KE-2 hypothesis to test in the capture
|
|
||||||
|
|
||||||
crypto-wasm **18.3.0 tightened Olm to-device validation** (sender-spoof check +
|
|
||||||
MSC4147). It's therefore possible KE-2's `WARN … unexpected encrypted to-device
|
|
||||||
event … io.element.call.encryption_keys` is **partly** the new validation
|
|
||||||
rejecting EC's media-key events, not _only_ the missing-Olm-session downstream of
|
|
||||||
KE-1. **Capture discriminator:** if KE-2 still occurs in a call where OTK counts
|
|
||||||
are healthy and no KE-1 storm is present (Q1 = NO), suspect the to-device
|
|
||||||
validation path (EC ↔ rust-crypto 18.3.x), not KE-1. If KE-2 only ever co-occurs
|
|
||||||
with the KE-1 storm, the original KE-1⇒KE-2 chain stands.
|
|
||||||
|
|
||||||
### 6.5 What to do now vs. at capture
|
|
||||||
|
|
||||||
- **Now (no call needed):** ship 6.3.1 (`persist()`) — it's safe and preventive.
|
|
||||||
Consider 6.3.3 (loop detection) as a follow-up.
|
|
||||||
- **At the next glitchy call:** run the §4 capture; answer Q1 (divergence?) and
|
|
||||||
6.4's discriminator. For any _currently_ stuck device, remediation option 1
|
|
||||||
(clean **logout + login**, not just "clear storage" — clearing storage without
|
|
||||||
`mx.logout()` leaves the server device + its OTKs and can re-trigger the
|
|
||||||
divergence).
|
|
||||||
+2
-2
@@ -330,7 +330,7 @@ Users can set a custom background color for `@mention` chips that highlight thei
|
|||||||
> pre-built npm bundle. Several in-call behaviors below are now first-class
|
> pre-built npm bundle. Several in-call behaviors below are now first-class
|
||||||
> source changes rather than DOM/widget hacks. Background, plan, and the Phase-2
|
> source changes rather than DOM/widget hacks. Background, plan, and the Phase-2
|
||||||
> work list are in
|
> work list are in
|
||||||
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md).
|
> the Element Call fork reference in [`LOTUS_TODO.md`](./LOTUS_TODO.md).
|
||||||
|
|
||||||
### Element Call — Self-Built Fork (`0.20.1-lotus.1`)
|
### Element Call — Self-Built Fork (`0.20.1-lotus.1`)
|
||||||
|
|
||||||
@@ -1235,7 +1235,7 @@ The session persists as ONE atomic `cinny_session_v1` JSON write (previously ~10
|
|||||||
|
|
||||||
### Crypto Diagnostics (E2EE investigation kit)
|
### Crypto Diagnostics (E2EE investigation kit)
|
||||||
|
|
||||||
**Settings → Developer Tools → Crypto Diagnostics**: a capture-only ring buffer (max 200) hooks `console.warn/error` for E2EE failure signatures (OTK upload conflicts, missing call media keys, decryption errors, delayed-event timeouts) and downloads a JSON report — the evidence input for the KE-1→4 investigation. Companion runbook: [`LOTUS_E2EE_INVESTIGATION.md`](./LOTUS_E2EE_INVESTIGATION.md). `utils/cryptoDiagLog.ts`, `features/settings/developer/CryptoDiagnostics.tsx`.
|
**Settings → Developer Tools → Crypto Diagnostics**: a capture-only ring buffer (max 200) hooks `console.warn/error` for E2EE failure signatures (OTK upload conflicts, missing call media keys, decryption errors, delayed-event timeouts) and downloads a JSON report — the evidence input for the KE-1→4 investigation. Companion diagnosis: the Encryption / E2EE section of [`LOTUS_TODO.md`](./LOTUS_TODO.md). `utils/cryptoDiagLog.ts`, `features/settings/developer/CryptoDiagnostics.tsx`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+67
-3
@@ -328,7 +328,7 @@ _(Requires the guard deployed on LXC 151 — auto-deploys on a `matrix` repo pus
|
|||||||
|
|
||||||
# Backlog of previously-fixed-but-unverified items
|
# Backlog of previously-fixed-but-unverified items
|
||||||
|
|
||||||
> Sections A–D above are **this session's** work. Everything below was fixed in earlier waves and is still flagged **⚠️ UNTESTED** in `LOTUS_BUGS.md` / `LOTUS_TODO.md`. They're grouped by what kind of environment you need (mobile, desktop, screen reader, etc.) so you can knock out a whole category at once. None of these are urgent the way A–D are; do them as you have the right device handy.
|
> Sections A–D above are **this session's** work. Everything below was fixed in earlier waves and is still flagged **⚠️ UNTESTED** (see the outstanding-verification backlog below / `LOTUS_TODO.md`). They're grouped by what kind of environment you need (mobile, desktop, screen reader, etc.) so you can knock out a whole category at once. None of these are urgent the way A–D are; do them as you have the right device handy.
|
||||||
|
|
||||||
## E. Mobile / responsive (needs a real phone, or devtools device emulation)
|
## E. Mobile / responsive (needs a real phone, or devtools device emulation)
|
||||||
|
|
||||||
@@ -575,7 +575,7 @@ Log into **matrix.lotusguild.org** (password) and **matrix.org**.
|
|||||||
|
|
||||||
## O. July 2026 batch — threads, notifications, math, search cache, audit wave
|
## O. July 2026 batch — threads, notifications, math, search cache, audit wave
|
||||||
|
|
||||||
Everything landed after the OIDC work. These mirror the checklists in `LOTUS_TODO.md` (§P3-8, §P4-1) and the Needs-Verification rows in `LOTUS_BUGS.md` (P3-8/P4-1/P4-4/P4-8/N97a/AW-1…4). **⚠️ Threads change the main timeline** — thread replies no longer render inline; that's intended (see O1).
|
Everything landed after the OIDC work. These mirror the checklists in `LOTUS_TODO.md` (§P3-8, §P4-1) and the outstanding-verification backlog below (P3-8/P4-1/P4-4/P4-8/N97a/AW-1…4). **⚠️ Threads change the main timeline** — thread replies no longer render inline; that's intended (see O1).
|
||||||
|
|
||||||
### O1. Thread Panel (P3-8) — 👥 2 people help for live replies
|
### O1. Thread Panel (P3-8) — 👥 2 people help for live replies
|
||||||
|
|
||||||
@@ -626,7 +626,7 @@ The webview CSP was tightened and the full native module set now compiles. Smoke
|
|||||||
|
|
||||||
### O8. E2EE / call-key cluster (KE-1→4) — 👥 2 people, during a real call
|
### O8. E2EE / call-key cluster (KE-1→4) — 👥 2 people, during a real call
|
||||||
|
|
||||||
We shipped the diagnostics kit + a **Crypto Diagnostics** card (**Settings → Developer Tools**). During your next call that glitches (audio cutouts, "Unable to decrypt"), open it and **Download report**, and note whether the symptoms even still occur now that we're on **matrix-js-sdk 41.7.0** (crypto-wasm 18.3.1). Send me the report + `LOTUS_E2EE_INVESTIGATION.md` is the runbook.
|
We shipped the diagnostics kit + a **Crypto Diagnostics** card (**Settings → Developer Tools**). During your next call that glitches (audio cutouts, "Unable to decrypt"), open it and **Download report**, and note whether the symptoms even still occur now that we're on **matrix-js-sdk 41.7.0** (crypto-wasm 18.3.1). Send me the report; the KE-1..4 diagnosis + capture guidance is in `LOTUS_TODO.md` (Encryption / E2EE), with the full original runbook in git history.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -670,3 +670,67 @@ Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view,
|
|||||||
4. **A4** (in-call banner) + **A3** (ringtone) — newest call logic, hardest to reproduce.
|
4. **A4** (in-call banner) + **A3** (ringtone) — newest call logic, hardest to reproduce.
|
||||||
5. **D** (EC control sweep) — guards against the fork breaking calls.
|
5. **D** (EC control sweep) — guards against the fork breaking calls.
|
||||||
6. Everything else.
|
6. Everything else.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Outstanding verification backlog
|
||||||
|
|
||||||
|
**Room Widgets (MSC1236, 2026-07 — needs the CSP `frame-src` widening + `nginx -s reload` first):** In a room, the header **Widgets** button (grid icon, desktop) opens a right-side panel. As an admin (PL to modify widgets): **Add Widget** with a name + an https URL (e.g. an Etherpad `https://…` or any embeddable page) → it appears in the list; click it → it renders in a sandboxed iframe in the panel; **Remove** clears it. A non-admin sees the list + can open widgets but has no Add/Remove. Check: a non-https or same-origin URL is rejected on Add with a clear message; the panel is a full-screen overlay on mobile and is mutually exclusive with the Thread/Gallery/Members panels; if a widget stays blank, the prod CSP `frame-src` still needs widening. Widgets get only benign display capabilities (they can't send/read room events in v1).
|
||||||
|
|
||||||
|
**QR Device Verification (2026-07):** With two logged-in Lotus sessions (or Lotus + Element), start a device verification. On the **Ready** step you now see your own QR code plus a **"Scan their QR code"** button and a **"Verify with emoji instead"** fallback. Have one device **scan** the other's code (grant camera permission) → the showing device asks you to **Confirm**, and both reach **verified**. Check: emoji-SAS still works unchanged; denying camera shows a graceful "verify with emojis instead" message; a deliberately-wrong scan cancels cleanly. Desktop (WebView2) auto-grants the camera; web needs the Permissions-Policy camera allowance (already set).
|
||||||
|
|
||||||
|
**Disappearing Messages (MSC1763 `m.room.retention`, 2026-07):** In Room Settings → General → **Message Retention**, an admin picks Off / 1 Day / 1 Week / 1 Month (non-admins see the buttons disabled). After setting e.g. 1 Day, messages older than a day **vanish from the timeline** for everyone in Lotus (toggle Settings → General → **Show Hidden Events** to reveal them again). Setting back to **Off** restores them. Separately, each user can enable Settings → General → **Enforce Message Retention** (default OFF) → their OWN expired messages then get **permanently redacted** within ~30 s (verify: OTHER people's messages are NEVER redacted by this; only your own). Note true server-side purge also needs Synapse `retention:` configured.
|
||||||
|
|
||||||
|
**Mark as Unread + Low Priority (MSC2867 / m.lowpriority, 2026-07):** Right-click a room in the sidebar → **Mark as Unread** puts a dot on the row (bold name) even with no new messages; opening/reading the room clears it, and it syncs to another device. **Mark as Read** on a marked room clears it too. Right-click → **Add to Low Priority** moves the room into a collapsed "Low Priority" category at the bottom of the room list (and removes it from Favorites if it was there, and vice-versa); **Remove from Low Priority** returns it to Rooms.
|
||||||
|
|
||||||
|
**Windows rich toast (D6, 2026-07 — desktop/Windows build only):** get a message notification while the desktop app is backgrounded → the toast is attributed to **Lotus Chat** (not "PowerShell"/generic) and shows an inline **reply box + Send**; typing a reply + Send **posts it to that room**; clicking the toast body **opens the room**. Previously these silently fell back to a plain toast (no reply/click). If it still falls back, check that a `Lotus Chat.lnk` exists in the Start-Menu Programs folder.
|
||||||
|
|
||||||
|
**Invite QR is now generated LOCALLY (2026-07):** Room settings → Share Room → the QR code renders (a black-on-white SVG in a white box) with **no network request** to `api.qrserver.com` (check DevTools Network — there should be no external QR fetch, and it should work offline / behind strict CSP). **Scan it** with a phone camera / Matrix app → it opens the correct `matrix.to` room-invite link. (`api.qrserver.com` was removed from the prod CSP img-src, so a regression would make the QR blank rather than silently phone home.)
|
||||||
|
|
||||||
|
**Unread dot on federated rooms + avatar-decoration console storm (2026-07):**
|
||||||
|
|
||||||
|
- **Read receipts (regression guard — highest priority):** open several rooms and open the Home/Direct tabs (which mark all orphan rooms read on mount) → rooms **stay read**, unread dots clear and don't come back. (A prior attempt sent a receipt for the thread _root_ when a thread's replies weren't loaded, which the SDK treats as a main receipt at an old event and re-unread every room on every mark-read. Fixed + locked by `notifications.test.ts`.)
|
||||||
|
- **Thread dot:** a room with an unread reply in a thread whose replies are loaded → its dot clears on read; for a thread not yet loaded, the dot clears once you open/load the thread. (mark-as-read now sends a threaded receipt only for a genuine loaded reply, never the root.)
|
||||||
|
- With DevTools console open on federated rooms, the `io.lotus.avatar_decoration` `403`/`502` (and federated media) errors should **not** repeat on every scroll/mount — each failing user is now requested at most ~twice per session, so the storm (and its homeserver load) is gone.
|
||||||
|
|
||||||
|
**Custom Window Chrome (Beta) fix (2026-07):** on the desktop build, Settings → General → toggle **Custom Window Chrome** — it should reload and come up with the Lotus title bar and a normal, stable feed (no screen-expand / auto-scroll-into-the-past). Toggle back off → reloads to the native frame.
|
||||||
|
|
||||||
|
_Ported from the retired `LOTUS_BUGS.md` (2026-07). Compact index of shipped-but-not-live-tested items; the detailed steps are in the lettered sections above._
|
||||||
|
|
||||||
|
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
|
||||||
|
|
||||||
|
| ID | Item | File / area | Test |
|
||||||
|
| :--- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
|
||||||
|
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
|
||||||
|
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
|
||||||
|
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
|
||||||
|
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
|
||||||
|
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
|
||||||
|
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
|
||||||
|
| #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 |
|
||||||
|
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
|
||||||
|
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
|
||||||
|
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
|
||||||
|
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
|
||||||
|
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
|
||||||
|
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
|
||||||
|
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
|
||||||
|
| P3-8 | Thread Panel (side drawer, chips, threaded receipts, thread composer) | `features/room/thread/*`, `RoomTimeline/RoomInput` | 6-step checklist in LOTUS_TODO §P3-8 |
|
||||||
|
| P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk) | `utils/mathParse.ts`, `components/math/` | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text) |
|
||||||
|
| P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes |
|
||||||
|
| N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth |
|
||||||
|
| P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math) | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread` | 6-step checklist in LOTUS_TODO §P4-1 |
|
||||||
|
| AW-1 | Scheduled-message cancel no longer ghost-sends (error row on failure) | `ScheduledMessagesTray.tsx` | schedule → cancel with network cut → item stays + error; retry works |
|
||||||
|
| AW-2 | Emoji lazy-load (search/autocomplete/recents fill in; board opens fast) | `plugins/emoji.ts` + consumers | first emoji-board open of a session: grid+search populate; reactions still label |
|
||||||
|
| AW-3 | SW precache (repeat-visit near-instant; deploys still picked up immediately) | `sw.ts`, `vite.config.js` | load app twice (2nd = cached assets); deploy → reload picks new version |
|
||||||
|
| AW-4 | Desktop CSP tighten + Escape/panel fixes + thread Jump to Latest | `tauri.conf.json`, Room/ThreadPanel | desktop: boots, avatars/media load, VT323 font renders, location maps embed, calls connect, deep links work |
|
||||||
|
| P3-4 | Accessibility compliance pass (collapsed-msg SR sender, form/overlay labels, typing announce, focus-return, `?` help, jsx-a11y CI gate) | `message/*`, `RoomViewTyping`, `features/shortcuts/*`, `eslint.config.mjs` | LOTUS_TESTING §P — axe-core + VoiceOver/NVDA on the golden path |
|
||||||
|
| P6-1 | Desktop Linux parity (no-sleep in calls, launcher badge), autostart toggle, tray Do-Not-Disturb | `native/power.rs`, `lib.rs`, `useTauriDnd`, `General.tsx` | Linux desktop: no display sleep during a call; tray DND silences notifications; launch-on-login persists; Unity badge (Ubuntu); DND toggle polarity |
|
||||||
|
| P6-2 | EC deafen/screenshare-audio-mute via `io.lotus.set_deafen` (retires the `<audio>.muted` iframe hack) | fork `lotusDeafen.ts`, cinny `CallControl.ts` | AFTER publish+pin-bump: deafen silences remote audio + survives a reconnect / new screenshare / late joiner (the cases the DOM hack failed); screenshare-audio-mute toggles independently |
|
||||||
|
| P6-3 | Forward-to-multiple-rooms (multi-select + partial-failure summary) + live bookmark previews (edits/redactions, snapshot fallback) | `ForwardMessageDialog.tsx`+`forwardContent.ts`, `BookmarksPanel.tsx` | forward one msg to 3 rooms (incl. 1 you cannot post to = partial summary); bookmark then edit shows edited; redact shows deleted; leave room shows snapshot |
|
||||||
|
| P6-4 | HSTS + Permissions-Policy on prod nginx (+ contrib examples) | `matrix/cinny/nginx.conf`, `contrib/nginx`, `contrib/caddy` | after `nginx -s reload`: `curl -sI https://chat.lotusguild.org` shows HSTS + Permissions-Policy; a call (cam/mic/screenshare) + location share still work |
|
||||||
|
|
||||||
|
**Verified working in live testing (2026-06):** A2, B1–B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
+197
-777
File diff suppressed because it is too large
Load Diff
@@ -180,10 +180,10 @@ avatar decorations on EC video tiles, and a native transparent background.
|
|||||||
(`io.lotus.inject_audio` → in-call soundboard) and quality controls
|
(`io.lotus.inject_audio` → in-call soundboard) and quality controls
|
||||||
(`io.lotus.set_quality`).
|
(`io.lotus.set_quality`).
|
||||||
|
|
||||||
The full plan and integration map is in
|
The fork's `io.lotus.*` action catalog + the publish procedure are in
|
||||||
**[`HANDOFF_ELEMENT_CALL_FORK.md`](HANDOFF_ELEMENT_CALL_FORK.md)**; infra/hosting +
|
**[`LOTUS_TODO.md`](LOTUS_TODO.md)** ("Element Call fork — operational reference");
|
||||||
build-pipeline notes live in the `LotusGuild/matrix` repo README. Search the docs
|
infra/hosting + build-pipeline notes live in the `LotusGuild/matrix` repo README.
|
||||||
for the **`[EC-FORK]`** tag to find every related note.
|
Search the docs for the **`[EC-FORK]`** tag to find every related note.
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -144,10 +144,16 @@ export default [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Test files commonly define several small mock/fake classes.
|
// Test files commonly define several small mock/fake classes and named
|
||||||
|
// function expressions used as constructor mocks (e.g.
|
||||||
|
// `setGlobal('AudioWorkletNode', function AudioWorkletNode(){})`), which must
|
||||||
|
// NOT be rewritten to arrows (arrows aren't constructable). Relax the
|
||||||
|
// stylistic class/callback rules here.
|
||||||
files: ['**/*.test.ts', '**/*.test.tsx'],
|
files: ['**/*.test.ts', '**/*.test.tsx'],
|
||||||
rules: {
|
rules: {
|
||||||
'max-classes-per-file': 'off',
|
'max-classes-per-file': 'off',
|
||||||
|
'lines-between-class-members': 'off',
|
||||||
|
'prefer-arrow-callback': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Generated
+224
-1
@@ -49,6 +49,7 @@
|
|||||||
"immer": "11.1.8",
|
"immer": "11.1.8",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
"jotai": "2.20.0",
|
"jotai": "2.20.0",
|
||||||
|
"jsqr": "1.4.0",
|
||||||
"katex": "0.16.11",
|
"katex": "0.16.11",
|
||||||
"linkify-react": "4.3.3",
|
"linkify-react": "4.3.3",
|
||||||
"linkifyjs": "4.3.3",
|
"linkifyjs": "4.3.3",
|
||||||
@@ -57,6 +58,8 @@
|
|||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "5.7.284",
|
"pdfjs-dist": "5.7.284",
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
|
"qrcode": "1.5.4",
|
||||||
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-aria": "3.48.0",
|
"react-aria": "3.48.0",
|
||||||
"react-blurhash": "0.3.0",
|
"react-blurhash": "0.3.0",
|
||||||
@@ -86,6 +89,7 @@
|
|||||||
"@types/katex": "0.16.8",
|
"@types/katex": "0.16.8",
|
||||||
"@types/node": "25.9.1",
|
"@types/node": "25.9.1",
|
||||||
"@types/prismjs": "1.26.6",
|
"@types/prismjs": "1.26.6",
|
||||||
|
"@types/qrcode": "1.5.6",
|
||||||
"@types/react": "19.2.15",
|
"@types/react": "19.2.15",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/react-google-recaptcha": "2.1.9",
|
"@types/react-google-recaptcha": "2.1.9",
|
||||||
@@ -3989,6 +3993,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.15",
|
"version": "19.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
|
||||||
@@ -5170,6 +5184,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/camelize": {
|
"node_modules/camelize": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
|
||||||
@@ -5964,6 +5987,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dedent": {
|
"node_modules/dedent": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||||
@@ -6107,6 +6139,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/direction": {
|
"node_modules/direction": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz",
|
||||||
@@ -9056,6 +9094,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsqr": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/jsx-ast-utils": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||||
@@ -10499,6 +10543,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/package-json-from-dist": {
|
"node_modules/package-json-from-dist": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
@@ -10536,7 +10589,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -10651,6 +10703,15 @@
|
|||||||
"pathe": "^2.0.1"
|
"pathe": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
||||||
@@ -10758,6 +10819,150 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode.react": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/raf-schd": {
|
"node_modules/raf-schd": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
||||||
@@ -11178,6 +11383,12 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/resize-observer-polyfill": {
|
"node_modules/resize-observer-polyfill": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||||
@@ -11508,6 +11719,12 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/set-cookie-parser": {
|
"node_modules/set-cookie-parser": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
@@ -12973,6 +13190,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/which-typed-array": {
|
"node_modules/which-typed-array": {
|
||||||
"version": "1.1.20",
|
"version": "1.1.20",
|
||||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
"immer": "11.1.8",
|
"immer": "11.1.8",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
"jotai": "2.20.0",
|
"jotai": "2.20.0",
|
||||||
|
"jsqr": "1.4.0",
|
||||||
"katex": "0.16.11",
|
"katex": "0.16.11",
|
||||||
"linkify-react": "4.3.3",
|
"linkify-react": "4.3.3",
|
||||||
"linkifyjs": "4.3.3",
|
"linkifyjs": "4.3.3",
|
||||||
@@ -82,6 +83,8 @@
|
|||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "5.7.284",
|
"pdfjs-dist": "5.7.284",
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
|
"qrcode": "1.5.4",
|
||||||
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-aria": "3.48.0",
|
"react-aria": "3.48.0",
|
||||||
"react-blurhash": "0.3.0",
|
"react-blurhash": "0.3.0",
|
||||||
@@ -111,6 +114,7 @@
|
|||||||
"@types/katex": "0.16.8",
|
"@types/katex": "0.16.8",
|
||||||
"@types/node": "25.9.1",
|
"@types/node": "25.9.1",
|
||||||
"@types/prismjs": "1.26.6",
|
"@types/prismjs": "1.26.6",
|
||||||
|
"@types/qrcode": "1.5.6",
|
||||||
"@types/react": "19.2.15",
|
"@types/react": "19.2.15",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/react-google-recaptcha": "2.1.9",
|
"@types/react-google-recaptcha": "2.1.9",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
|||||||
import { getChatBg } from '../features/lotus/chatBackground';
|
import { getChatBg } from '../features/lotus/chatBackground';
|
||||||
import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
|
import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
|
||||||
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
||||||
|
import { useReducedMotion } from '../hooks/useReducedMotion';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { getStateEvent, getStateEvents, getMemberDisplayName } from '../utils/room';
|
import { getStateEvent, getStateEvents, getMemberDisplayName } from '../utils/room';
|
||||||
@@ -413,6 +414,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
|
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
|
||||||
const startCall = useCallStart(dm);
|
const startCall = useCallStart(dm);
|
||||||
|
|
||||||
|
// C-L6: handleTimelineEvent awaits decryption before calling setState; guard
|
||||||
|
// against the component unmounting during that await.
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
||||||
async (event, room, toStartOfTimeline, removed, data) => {
|
async (event, room, toStartOfTimeline, removed, data) => {
|
||||||
// only process rtc notification reference events.
|
// only process rtc notification reference events.
|
||||||
@@ -427,6 +438,9 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
await event.getDecryptionPromise();
|
await event.getDecryptionPromise();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// C-L6: bail if we unmounted while awaiting decryption above.
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
// Caller-side: a participant declined a call we're hosting in this room.
|
// Caller-side: a participant declined a call we're hosting in this room.
|
||||||
// Without this the caller's UI keeps "ringing" until the notification
|
// Without this the caller's UI keeps "ringing" until the notification
|
||||||
// lifetime expires, with no indication the callee said no.
|
// lifetime expires, with no indication the callee said no.
|
||||||
@@ -706,9 +720,10 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.kind === ThemeKind.Dark;
|
const isDark = theme.kind === ThemeKind.Dark;
|
||||||
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
|
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
|
||||||
|
const reduced = useReducedMotion();
|
||||||
const wallpaperStyle = React.useMemo(
|
const wallpaperStyle = React.useMemo(
|
||||||
() => getChatBg(chatBackground, isDark),
|
() => getChatBg(chatBackground, isDark, reduced),
|
||||||
[chatBackground, isDark],
|
[chatBackground, isDark, reduced],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
|
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
|
ShowQrCodeCallbacks,
|
||||||
ShowSasCallbacks,
|
ShowSasCallbacks,
|
||||||
VerificationPhase,
|
VerificationPhase,
|
||||||
VerificationRequest,
|
VerificationRequest,
|
||||||
Verifier,
|
Verifier,
|
||||||
} from 'matrix-js-sdk/lib/crypto-api';
|
} from 'matrix-js-sdk/lib/crypto-api';
|
||||||
import React, { CSSProperties, useCallback, useEffect, useState } from 'react';
|
import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
|
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -27,11 +29,13 @@ import {
|
|||||||
useVerificationRequestPhase,
|
useVerificationRequestPhase,
|
||||||
useVerificationRequestReceived,
|
useVerificationRequestReceived,
|
||||||
useVerifierCancel,
|
useVerifierCancel,
|
||||||
|
useVerifierShowReciprocateQr,
|
||||||
useVerifierShowSas,
|
useVerifierShowSas,
|
||||||
} from '../hooks/useVerificationRequest';
|
} from '../hooks/useVerificationRequest';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||||
import { ContainerColor } from '../styles/ContainerColor.css';
|
import { ContainerColor } from '../styles/ContainerColor.css';
|
||||||
import { useModalStyle } from '../hooks/useModalStyle';
|
import { useModalStyle } from '../hooks/useModalStyle';
|
||||||
|
import { QrScanner } from './QrScanner';
|
||||||
|
|
||||||
const DialogHeaderStyles: CSSProperties = {
|
const DialogHeaderStyles: CSSProperties = {
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
@@ -97,32 +101,6 @@ function VerificationAccept({ onAccept }: VerificationAcceptProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VerificationWaitStart() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<Box direction="Column" gap="400">
|
|
||||||
<Text>{t('Organisms.DeviceVerification.request_accepted')}</Text>
|
|
||||||
<WaitingMessage message={t('Organisms.DeviceVerification.waiting_response')} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type VerificationStartProps = {
|
|
||||||
onStart: () => Promise<void>;
|
|
||||||
};
|
|
||||||
function AutoVerificationStart({ onStart }: VerificationStartProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
useEffect(() => {
|
|
||||||
onStart();
|
|
||||||
}, [onStart]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box direction="Column" gap="400">
|
|
||||||
<WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
|
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
|
||||||
@@ -237,6 +215,120 @@ function VerificationCanceled({ onClose }: VerificationCanceledProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function QrCodeImage({ data }: { data: Uint8ClampedArray }) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
// Byte-mode so the raw verification bytes round-trip (a string value would
|
||||||
|
// mangle high bytes via UTF-8).
|
||||||
|
QRCode.toCanvas(canvas, [{ data: new Uint8Array(data), mode: 'byte' }], {
|
||||||
|
width: 220,
|
||||||
|
margin: 2,
|
||||||
|
color: { dark: '#000000', light: '#ffffff' },
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}, [data]);
|
||||||
|
return (
|
||||||
|
<Box justifyContent="Center">
|
||||||
|
<canvas ref={canvasRef} style={{ borderRadius: config.radii.R300 }} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerificationReadyProps = {
|
||||||
|
request: VerificationRequest;
|
||||||
|
onStartSas: () => void;
|
||||||
|
onScanned: (bytes: Uint8ClampedArray) => void;
|
||||||
|
};
|
||||||
|
function VerificationReady({ request, onStartSas, onScanned }: VerificationReadyProps) {
|
||||||
|
const [myQr, setMyQr] = useState<Uint8ClampedArray>();
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const canShowMine = request.otherPartySupportsMethod(VerificationMethod.ScanQrCode);
|
||||||
|
const canScanTheirs = request.otherPartySupportsMethod(VerificationMethod.ShowQrCode);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canShowMine) return;
|
||||||
|
request
|
||||||
|
.generateQRCode()
|
||||||
|
.then((bytes) => {
|
||||||
|
if (bytes) setMyQr(bytes);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, [request, canShowMine]);
|
||||||
|
|
||||||
|
if (scanning) {
|
||||||
|
return <QrScanner onScan={onScanned} onCancel={() => setScanning(false)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="400">
|
||||||
|
{myQr && (
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text size="T300">Scan this code with your other device to verify.</Text>
|
||||||
|
<QrCodeImage data={myQr} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
{canScanTheirs && (
|
||||||
|
<Button variant="Primary" fill="Solid" onClick={() => setScanning(true)}>
|
||||||
|
<Text size="B400">Scan their QR code</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="Secondary" fill="Soft" onClick={onStartSas}>
|
||||||
|
<Text size="B400">Verify with emoji instead</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReciprocateVerificationProps = {
|
||||||
|
verifier: Verifier;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
function ReciprocateVerification({ verifier, onCancel }: ReciprocateVerificationProps) {
|
||||||
|
const [qrCallbacks, setQrCallbacks] = useState<ShowQrCodeCallbacks>();
|
||||||
|
const [confirmState, confirm] = useAsyncCallback(
|
||||||
|
useCallback(async () => qrCallbacks?.confirm(), [qrCallbacks]),
|
||||||
|
);
|
||||||
|
|
||||||
|
useVerifierShowReciprocateQr(verifier, setQrCallbacks);
|
||||||
|
useVerifierCancel(verifier, onCancel);
|
||||||
|
|
||||||
|
const confirming =
|
||||||
|
confirmState.status === AsyncStatus.Loading || confirmState.status === AsyncStatus.Success;
|
||||||
|
|
||||||
|
// The showing side gets ShowReciprocateQr callbacks after the other device
|
||||||
|
// scans; the scanning side never does (it already called verify()) and just
|
||||||
|
// waits for completion.
|
||||||
|
if (!qrCallbacks) {
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="400">
|
||||||
|
<WaitingMessage message="Verifying…" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="400">
|
||||||
|
<Text>The other device scanned this code. Confirm it now shows as verified.</Text>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Button variant="Primary" fill="Soft" onClick={confirm} disabled={confirming}>
|
||||||
|
<Text size="B400">Confirm</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="Primary"
|
||||||
|
fill="Soft"
|
||||||
|
onClick={() => qrCallbacks.cancel()}
|
||||||
|
disabled={confirming}
|
||||||
|
>
|
||||||
|
<Text size="B400">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type DeviceVerificationProps = {
|
type DeviceVerificationProps = {
|
||||||
request: VerificationRequest;
|
request: VerificationRequest;
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
@@ -256,6 +348,17 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
|
|||||||
const handleStart = useCallback(async () => {
|
const handleStart = useCallback(async () => {
|
||||||
await request.startVerification(VerificationMethod.Sas);
|
await request.startVerification(VerificationMethod.Sas);
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
const handleScanned = useCallback(
|
||||||
|
async (bytes: Uint8ClampedArray) => {
|
||||||
|
try {
|
||||||
|
const verifier = await request.scanQRCode(bytes);
|
||||||
|
await verifier.verify();
|
||||||
|
} catch {
|
||||||
|
// A bad/mismatched scan cancels the request; the Cancelled phase renders.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[request],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
@@ -290,15 +393,20 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
|
|||||||
) : (
|
) : (
|
||||||
<VerificationAccept onAccept={handleAccept} />
|
<VerificationAccept onAccept={handleAccept} />
|
||||||
))}
|
))}
|
||||||
{phase === VerificationPhase.Ready &&
|
{phase === VerificationPhase.Ready && (
|
||||||
(request.initiatedByMe ? (
|
<VerificationReady
|
||||||
<AutoVerificationStart onStart={handleStart} />
|
request={request}
|
||||||
) : (
|
onStartSas={handleStart}
|
||||||
<VerificationWaitStart />
|
onScanned={handleScanned}
|
||||||
))}
|
/>
|
||||||
|
)}
|
||||||
{phase === VerificationPhase.Started &&
|
{phase === VerificationPhase.Started &&
|
||||||
(request.verifier ? (
|
(request.verifier ? (
|
||||||
<SasVerification verifier={request.verifier} onCancel={handleCancel} />
|
request.chosenMethod === VerificationMethod.Reciprocate ? (
|
||||||
|
<ReciprocateVerification verifier={request.verifier} onCancel={handleCancel} />
|
||||||
|
) : (
|
||||||
|
<SasVerification verifier={request.verifier} onCancel={handleCancel} />
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<VerificationUnexpected
|
<VerificationUnexpected
|
||||||
message="Unexpected Error! Verification is started but verifier is missing."
|
message="Unexpected Error! Verification is started but verifier is missing."
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import {
|
|||||||
color,
|
color,
|
||||||
Spinner,
|
Spinner,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import to from 'await-to-js';
|
import to from 'await-to-js';
|
||||||
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
|
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
|
||||||
|
import { useSaveFile } from '../hooks/useSaveFile';
|
||||||
import { useModalStyle } from '../hooks/useModalStyle';
|
import { useModalStyle } from '../hooks/useModalStyle';
|
||||||
import { PasswordInput } from './password-input';
|
import { PasswordInput } from './password-input';
|
||||||
import { ContainerColor } from '../styles/ContainerColor.css';
|
import { ContainerColor } from '../styles/ContainerColor.css';
|
||||||
@@ -230,6 +230,7 @@ type RecoveryKeyDisplayProps = {
|
|||||||
};
|
};
|
||||||
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
|
const saveFile = useSaveFile();
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
copyToClipboard(recoveryKey);
|
copyToClipboard(recoveryKey);
|
||||||
@@ -239,7 +240,7 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
|||||||
const blob = new Blob([recoveryKey], {
|
const blob = new Blob([recoveryKey], {
|
||||||
type: 'text/plain;charset=us-ascii',
|
type: 'text/plain;charset=us-ascii',
|
||||||
});
|
});
|
||||||
FileSaver.saveAs(blob, 'recovery-key.txt');
|
saveFile(blob, 'recovery-key.txt');
|
||||||
};
|
};
|
||||||
|
|
||||||
const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*');
|
const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*');
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
config,
|
config,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import FileSaver from 'file-saver';
|
import { useSaveFile } from '../../hooks/useSaveFile';
|
||||||
import * as css from './PdfViewer.css';
|
import * as css from './PdfViewer.css';
|
||||||
import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
||||||
import { useZoom } from '../../hooks/useZoom';
|
import { useZoom } from '../../hooks/useZoom';
|
||||||
@@ -36,6 +36,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
|||||||
({ className, name, src, requestClose, ...props }, ref) => {
|
({ className, name, src, requestClose, ...props }, ref) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const saveFile = useSaveFile();
|
||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||||
|
|
||||||
const [pdfJSState, loadPdfJS] = usePdfJSLoader();
|
const [pdfJSState, loadPdfJS] = usePdfJSLoader();
|
||||||
@@ -76,7 +77,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
|||||||
}, [docState, pageNo, zoom]);
|
}, [docState, pageNo, zoom]);
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
FileSaver.saveAs(src, name);
|
saveFile(src, name);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Box, Button, color, config, Text } from 'folds';
|
||||||
|
import jsQR from 'jsqr';
|
||||||
|
|
||||||
|
type QrScannerProps = {
|
||||||
|
onScan: (bytes: Uint8ClampedArray) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Camera QR scanner. Decodes frames with jsQR and hands back the raw byte
|
||||||
|
// segment (`result.binaryData`) — Matrix QR verification needs the raw bytes,
|
||||||
|
// not a decoded string, so the string-only `BarcodeDetector` can't be used.
|
||||||
|
export function QrScanner({ onScan, onCancel }: QrScannerProps) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const doneRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let stream: MediaStream | undefined;
|
||||||
|
let raf = 0;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!doneRef.current && video && ctx && video.readyState === video.HAVE_ENOUGH_DATA) {
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
|
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
const result = jsQR(image.data, image.width, image.height);
|
||||||
|
if (result && result.binaryData.length > 0) {
|
||||||
|
doneRef.current = true;
|
||||||
|
onScan(new Uint8ClampedArray(result.binaryData));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: 'environment' },
|
||||||
|
});
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = stream;
|
||||||
|
await videoRef.current.play();
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
} catch {
|
||||||
|
setError(
|
||||||
|
'Could not access the camera. Grant camera permission, or verify with emojis instead.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
doneRef.current = true;
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
stream?.getTracks().forEach((track) => track.stop());
|
||||||
|
};
|
||||||
|
}, [onScan]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="400">
|
||||||
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
<Button variant="Secondary" fill="Soft" onClick={onCancel}>
|
||||||
|
<Text size="B400">Back</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="400" alignItems="Center">
|
||||||
|
<Text size="T300" align="Center">
|
||||||
|
Point your camera at the QR code shown on your other device.
|
||||||
|
</Text>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 280,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
background: '#000',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<track kind="captions" />
|
||||||
|
</video>
|
||||||
|
<Button variant="Secondary" fill="Soft" onClick={onCancel}>
|
||||||
|
<Text size="B400">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,7 +31,29 @@ import { ImageViewer } from './image-viewer';
|
|||||||
import { PdfViewer } from './Pdf-viewer';
|
import { PdfViewer } from './Pdf-viewer';
|
||||||
import { TextViewer } from './text-viewer';
|
import { TextViewer } from './text-viewer';
|
||||||
import { testMatrixTo } from '../plugins/matrix-to';
|
import { testMatrixTo } from '../plugins/matrix-to';
|
||||||
import { IImageContent } from '../../types/matrix/common';
|
import { IAudioContent, IFileContent, IImageContent } from '../../types/matrix/common';
|
||||||
|
|
||||||
|
// Audio is frequently sent as m.file (bridges/other clients, or when the browser
|
||||||
|
// reported a non-audio/* mime on upload). Detect that so we can play it inline
|
||||||
|
// like m.audio instead of showing only a download button.
|
||||||
|
const AUDIO_EXT_MIME: Record<string, string> = {
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
m4a: 'audio/mp4',
|
||||||
|
aac: 'audio/aac',
|
||||||
|
oga: 'audio/ogg',
|
||||||
|
ogg: 'audio/ogg',
|
||||||
|
opus: 'audio/ogg',
|
||||||
|
wav: 'audio/wav',
|
||||||
|
flac: 'audio/flac',
|
||||||
|
weba: 'audio/webm',
|
||||||
|
};
|
||||||
|
const resolveInlineAudioMime = (content: IFileContent): string | undefined => {
|
||||||
|
const mime = content.info?.mimetype;
|
||||||
|
if (typeof mime === 'string' && mime.startsWith('audio')) return mime;
|
||||||
|
const name = content.filename ?? content.body ?? '';
|
||||||
|
const ext = name.split('.').pop()?.toLowerCase();
|
||||||
|
return ext ? AUDIO_EXT_MIME[ext] : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
type RenderMessageContentProps = {
|
type RenderMessageContentProps = {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -276,6 +298,29 @@ export function RenderMessageContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msgType === MsgType.File) {
|
if (msgType === MsgType.File) {
|
||||||
|
// If an m.file is actually audio, play it inline (like m.audio) instead of
|
||||||
|
// only offering a download. MAudio falls back to renderFile if playback fails.
|
||||||
|
const audioMime = resolveInlineAudioMime(getContent<IFileContent>());
|
||||||
|
if (audioMime) {
|
||||||
|
const fileContent = getContent<IFileContent>();
|
||||||
|
const audioContent = {
|
||||||
|
...fileContent,
|
||||||
|
info: { ...(fileContent.info ?? {}), mimetype: audioMime },
|
||||||
|
} as unknown as IAudioContent;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MAudio
|
||||||
|
content={audioContent}
|
||||||
|
renderAsFile={renderFile}
|
||||||
|
renderAudioContent={(props) => (
|
||||||
|
<AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
|
||||||
|
)}
|
||||||
|
outlined={outlineAttachment}
|
||||||
|
/>
|
||||||
|
{renderCaption()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
return renderFile();
|
return renderFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export function AvatarDecoration({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<img
|
<img
|
||||||
|
// Force a fresh element per slug so a recycled node whose previous slug
|
||||||
|
// 404'd (and was hidden in onError) can't leak `display:none` onto a
|
||||||
|
// valid decoration.
|
||||||
|
key={slug}
|
||||||
src={decorationUrl(slug)}
|
src={decorationUrl(slug)}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -48,6 +52,9 @@ export function AvatarDecoration({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
onLoad={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.removeProperty('display');
|
||||||
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
||||||
|
import { useSaveFile } from '../../hooks/useSaveFile';
|
||||||
import * as css from './ImageViewer.css';
|
import * as css from './ImageViewer.css';
|
||||||
import { useZoom } from '../../hooks/useZoom';
|
import { useZoom } from '../../hooks/useZoom';
|
||||||
import { usePan } from '../../hooks/usePan';
|
import { usePan } from '../../hooks/usePan';
|
||||||
@@ -17,12 +17,13 @@ export type ImageViewerProps = {
|
|||||||
export const ImageViewer = as<'div', ImageViewerProps>(
|
export const ImageViewer = as<'div', ImageViewerProps>(
|
||||||
({ className, alt, src, requestClose, ...props }, ref) => {
|
({ className, alt, src, requestClose, ...props }, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const saveFile = useSaveFile();
|
||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
const fileContent = await downloadMedia(src);
|
const fileContent = await downloadMedia(src);
|
||||||
FileSaver.saveAs(fileContent, alt);
|
saveFile(fileContent, alt);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
|
import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
|
||||||
import React, { ReactNode, useCallback } from 'react';
|
import React, { ReactNode, useCallback } from 'react';
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
||||||
|
import { useSaveFile } from '../../hooks/useSaveFile';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
@@ -24,6 +24,7 @@ type FileDownloadButtonProps = {
|
|||||||
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
|
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const saveFile = useSaveFile();
|
||||||
|
|
||||||
const [downloadState, download] = useAsyncCallback(
|
const [downloadState, download] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
@@ -34,18 +35,19 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
|
|||||||
: await downloadMedia(mediaUrl);
|
: await downloadMedia(mediaUrl);
|
||||||
|
|
||||||
const fileURL = URL.createObjectURL(fileContent);
|
const fileURL = URL.createObjectURL(fileContent);
|
||||||
FileSaver.saveAs(fileURL, filename);
|
saveFile(fileURL, filename);
|
||||||
return fileURL;
|
return fileURL;
|
||||||
}, [mx, url, useAuthentication, mimeType, encInfo, filename]),
|
}, [mx, url, useAuthentication, mimeType, encInfo, filename, saveFile]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const downloading = downloadState.status === AsyncStatus.Loading;
|
const downloading = downloadState.status === AsyncStatus.Loading;
|
||||||
const hasError = downloadState.status === AsyncStatus.Error;
|
const hasError = downloadState.status === AsyncStatus.Error;
|
||||||
|
const succeeded = downloadState.status === AsyncStatus.Success;
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
disabled={downloading}
|
disabled={downloading}
|
||||||
onClick={download}
|
onClick={download}
|
||||||
variant={hasError ? 'Critical' : 'SurfaceVariant'}
|
variant={hasError ? 'Critical' : succeeded ? 'Success' : 'SurfaceVariant'}
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-label={
|
aria-label={
|
||||||
@@ -53,13 +55,15 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
|
|||||||
? 'Downloading...'
|
? 'Downloading...'
|
||||||
: hasError
|
: hasError
|
||||||
? 'Download failed, click to retry'
|
? 'Download failed, click to retry'
|
||||||
: 'Download file'
|
: succeeded
|
||||||
|
? 'Downloaded — click to download again'
|
||||||
|
: 'Download file'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{downloading ? (
|
{downloading ? (
|
||||||
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
|
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
|
||||||
) : (
|
) : (
|
||||||
<Icon size="100" src={Icons.Download} />
|
<Icon size="100" src={succeeded ? Icons.Check : Icons.Download} />
|
||||||
)}
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -99,9 +99,21 @@ export function AudioContent({
|
|||||||
const [playbackSpeed, setPlaybackSpeed] = useState<0.75 | 1 | 1.5 | 2>(1);
|
const [playbackSpeed, setPlaybackSpeed] = useState<0.75 | 1 | 1.5 | 2>(1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audioRef.current) {
|
const audio = audioRef.current;
|
||||||
audioRef.current.playbackRate = playbackSpeed;
|
if (!audio) return undefined;
|
||||||
}
|
const applyRate = () => {
|
||||||
|
audio.playbackRate = playbackSpeed;
|
||||||
|
};
|
||||||
|
// Apply immediately, and re-apply whenever the media element (re)loads a new
|
||||||
|
// source — e.g. after async decrypt swaps in the blob URL — since the browser
|
||||||
|
// resets playbackRate to 1 on load, discarding the user's speed choice.
|
||||||
|
applyRate();
|
||||||
|
audio.addEventListener('loadedmetadata', applyRate);
|
||||||
|
audio.addEventListener('play', applyRate);
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener('loadedmetadata', applyRate);
|
||||||
|
audio.removeEventListener('play', applyRate);
|
||||||
|
};
|
||||||
}, [playbackSpeed]);
|
}, [playbackSpeed]);
|
||||||
|
|
||||||
const SPEED_STEPS: Array<0.75 | 1 | 1.5 | 2> = [0.75, 1, 1.5, 2];
|
const SPEED_STEPS: Array<0.75 | 1 | 1.5 | 2> = [0.75, 1, 1.5, 2];
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
as,
|
as,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { IFileInfo } from '../../../../types/matrix/common';
|
import { IFileInfo } from '../../../../types/matrix/common';
|
||||||
|
import { useSaveFile } from '../../../hooks/useSaveFile';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { bytesToSize } from '../../../utils/common';
|
import { bytesToSize } from '../../../utils/common';
|
||||||
@@ -252,6 +252,7 @@ export type DownloadFileProps = {
|
|||||||
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
|
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const saveFile = useSaveFile();
|
||||||
|
|
||||||
const [downloadState, download] = useAsyncCallback(
|
const [downloadState, download] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
@@ -262,9 +263,9 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
|
|||||||
: await downloadMedia(mediaUrl);
|
: await downloadMedia(mediaUrl);
|
||||||
|
|
||||||
const fileURL = URL.createObjectURL(fileContent);
|
const fileURL = URL.createObjectURL(fileContent);
|
||||||
FileSaver.saveAs(fileURL, body);
|
saveFile(fileURL, body);
|
||||||
return fileURL;
|
return fileURL;
|
||||||
}, [mx, url, useAuthentication, mimeType, encInfo, body]),
|
}, [mx, url, useAuthentication, mimeType, encInfo, body, saveFile]),
|
||||||
);
|
);
|
||||||
|
|
||||||
return downloadState.status === AsyncStatus.Error ? (
|
return downloadState.status === AsyncStatus.Error ? (
|
||||||
@@ -277,7 +278,7 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
|
|||||||
size="400"
|
size="400"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
downloadState.status === AsyncStatus.Success
|
downloadState.status === AsyncStatus.Success
|
||||||
? FileSaver.saveAs(downloadState.data, body)
|
? saveFile(downloadState.data, body)
|
||||||
: download()
|
: download()
|
||||||
}
|
}
|
||||||
disabled={downloadState.status === AsyncStatus.Loading}
|
disabled={downloadState.status === AsyncStatus.Loading}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
||||||
import { zIndices } from '../../styles/zIndex';
|
import { zIndices } from '../../styles/zIndex';
|
||||||
import { SeasonTheme } from './types';
|
import { SeasonTheme } from './types';
|
||||||
import { getActiveSeason } from './seasonSchedule';
|
import { getActiveSeason } from './seasonSchedule';
|
||||||
@@ -94,8 +95,7 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
|
|||||||
|
|
||||||
export function SeasonalEffect() {
|
export function SeasonalEffect() {
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const reduced =
|
const reduced = useReducedMotion();
|
||||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
||||||
|
|
||||||
const theme = useMemo<SeasonTheme | null>(() => {
|
const theme = useMemo<SeasonTheme | null>(() => {
|
||||||
const override = settings.seasonalThemeOverride ?? 'auto';
|
const override = settings.seasonalThemeOverride ?? 'auto';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -21,6 +21,7 @@ import { uniqueShortcode } from '../../plugins/soundboard/utils';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import {
|
import {
|
||||||
|
getAudioDurationMs,
|
||||||
playClipLocally,
|
playClipLocally,
|
||||||
resolveClipObjectUrl,
|
resolveClipObjectUrl,
|
||||||
SOUNDBOARD_ACCEPT,
|
SOUNDBOARD_ACCEPT,
|
||||||
@@ -29,6 +30,49 @@ import {
|
|||||||
} from '../../utils/soundboardClips';
|
} from '../../utils/soundboardClips';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
// Injected once: the little "now playing" equalizer bars animation.
|
||||||
|
const EQ_STYLE_ID = 'lotus-soundboard-eq-keyframes';
|
||||||
|
function ensureEqKeyframes() {
|
||||||
|
if (typeof document === 'undefined' || document.getElementById(EQ_STYLE_ID)) return;
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = EQ_STYLE_ID;
|
||||||
|
style.textContent = `
|
||||||
|
@keyframes lotusSbEq { 0%,100% { transform: scaleY(0.3); } 50% { transform: scaleY(1); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { @keyframes lotusSbEq { 0%,100% { transform: scaleY(0.6); } } }
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayingBars() {
|
||||||
|
return (
|
||||||
|
<Box alignItems="Center" gap="100" style={{ height: toRem(14) }} aria-hidden>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: toRem(3),
|
||||||
|
height: toRem(14),
|
||||||
|
borderRadius: toRem(2),
|
||||||
|
background: color.Primary.Main,
|
||||||
|
transformOrigin: 'center bottom',
|
||||||
|
animation: `lotusSbEq 0.7s ease-in-out ${i * 0.15}s infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short clip length shown while adjusting a sound: "3.2s", or "1:04" if ≥ 60s.
|
||||||
|
const formatClipSeconds = (seconds: number): string => {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) return '';
|
||||||
|
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = Math.round(seconds % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
type ClipDraft = {
|
type ClipDraft = {
|
||||||
url: string;
|
url: string;
|
||||||
body: string;
|
body: string;
|
||||||
@@ -59,9 +103,20 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
|
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
|
||||||
const [busyPreview, setBusyPreview] = useState<string>();
|
const [busyPreview, setBusyPreview] = useState<string>();
|
||||||
|
const [playingKey, setPlayingKey] = useState<string>();
|
||||||
|
const [durations, setDurations] = useState<Map<string, number>>(new Map()); // shortcode -> seconds
|
||||||
|
const audioElRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const emojiAnchorRef = useRef<HTMLElement | null>(null);
|
const emojiAnchorRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ensureEqKeyframes();
|
||||||
|
return () => {
|
||||||
|
audioElRef.current?.pause();
|
||||||
|
audioElRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const existing = useMemo(() => pack.getClips(), [pack]);
|
const existing = useMemo(() => pack.getClips(), [pack]);
|
||||||
const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length;
|
const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length;
|
||||||
|
|
||||||
@@ -78,19 +133,47 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopPlayback = useCallback(() => {
|
||||||
|
audioElRef.current?.pause();
|
||||||
|
audioElRef.current = null;
|
||||||
|
setPlayingKey(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const preview = useCallback(
|
const preview = useCallback(
|
||||||
async (id: string, mxc: string, volume: number) => {
|
async (id: string, mxc: string, volume: number) => {
|
||||||
|
// Clicking the clip that's already playing stops it (toggle).
|
||||||
|
if (audioElRef.current && playingKey === id) {
|
||||||
|
stopPlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopPlayback(); // stop any other clip first
|
||||||
setBusyPreview(id);
|
setBusyPreview(id);
|
||||||
try {
|
try {
|
||||||
const url = await resolveClipObjectUrl(mx, mxc);
|
const url = await resolveClipObjectUrl(mx, mxc);
|
||||||
playClipLocally(url, volume / 100);
|
const audio = playClipLocally(url, volume / 100);
|
||||||
|
if (audio) {
|
||||||
|
audioElRef.current = audio;
|
||||||
|
setPlayingKey(id);
|
||||||
|
audio.addEventListener('loadedmetadata', () => {
|
||||||
|
if (Number.isFinite(audio.duration)) {
|
||||||
|
setDurations((prev) => new Map(prev).set(id, audio.duration));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const clear = () => {
|
||||||
|
if (audioElRef.current === audio) audioElRef.current = null;
|
||||||
|
setPlayingKey((k) => (k === id ? undefined : k));
|
||||||
|
};
|
||||||
|
audio.addEventListener('ended', clear);
|
||||||
|
audio.addEventListener('pause', clear);
|
||||||
|
audio.addEventListener('error', clear);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore preview errors */
|
/* ignore preview errors */
|
||||||
} finally {
|
} finally {
|
||||||
setBusyPreview(undefined);
|
setBusyPreview(undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx],
|
[mx, playingKey, stopPlayback],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFiles = useCallback(
|
const handleFiles = useCallback(
|
||||||
@@ -112,6 +195,8 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
throw new Error(`"${file.name}" is too large (max 1 MB).`);
|
throw new Error(`"${file.name}" is too large (max 1 MB).`);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const durationMs = await getAudioDurationMs(file);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
|
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
|
||||||
const mxc = res.content_uri;
|
const mxc = res.content_uri;
|
||||||
if (!mxc) throw new Error('Upload failed.');
|
if (!mxc) throw new Error('Upload failed.');
|
||||||
@@ -126,7 +211,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
body: name,
|
body: name,
|
||||||
emoji: '',
|
emoji: '',
|
||||||
volume: 100,
|
volume: 100,
|
||||||
info: { mimetype: file.type || undefined, size: file.size },
|
info: { mimetype: file.type || undefined, size: file.size, duration: durationMs },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -182,6 +267,9 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
setDraft(key, patch, base);
|
setDraft(key, patch, base);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const isPlaying = playingKey === key;
|
||||||
|
const clipSeconds =
|
||||||
|
durations.get(key) ?? (base.info?.duration != null ? base.info.duration / 1000 : undefined);
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={key}
|
key={key}
|
||||||
@@ -197,12 +285,16 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
<IconButton
|
<IconButton
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
variant="Secondary"
|
variant={isPlaying ? 'Primary' : 'Secondary'}
|
||||||
disabled={busyPreview === key}
|
disabled={busyPreview === key}
|
||||||
onClick={() => preview(key, base.url, rowVolume)}
|
onClick={() => preview(key, base.url, rowVolume)}
|
||||||
aria-label={`Preview ${rowBody}`}
|
aria-label={isPlaying ? `Stop ${rowBody}` : `Preview ${rowBody}`}
|
||||||
>
|
>
|
||||||
{busyPreview === key ? <Spinner size="100" /> : <Icon size="100" src={Icons.Play} />}
|
{busyPreview === key ? (
|
||||||
|
<Spinner size="100" />
|
||||||
|
) : (
|
||||||
|
<Icon size="100" src={isPlaying ? Icons.Pause : Icons.Play} filled={isPlaying} />
|
||||||
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="300"
|
size="300"
|
||||||
@@ -227,7 +319,24 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
aria-label="Clip name"
|
aria-label="Clip name"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(120) }}>
|
<Box
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="End"
|
||||||
|
gap="100"
|
||||||
|
shrink="No"
|
||||||
|
style={{ width: toRem(52) }}
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<PlayingBars />
|
||||||
|
) : (
|
||||||
|
clipSeconds !== undefined && (
|
||||||
|
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{formatClipSeconds(clipSeconds)}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(148) }}>
|
||||||
<Icon size="50" src={Icons.VolumeHigh} />
|
<Icon size="50" src={Icons.VolumeHigh} />
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -237,9 +346,12 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
defaultValue={rowVolume}
|
defaultValue={rowVolume}
|
||||||
disabled={!canEdit || markedDeleted}
|
disabled={!canEdit || markedDeleted}
|
||||||
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
|
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
|
||||||
style={{ flexGrow: 1 }}
|
style={{ flexGrow: 1, minWidth: 0 }}
|
||||||
aria-label="Clip volume"
|
aria-label="Clip volume"
|
||||||
/>
|
/>
|
||||||
|
<Text size="T200" priority="300" style={{ width: toRem(30), textAlign: 'right' }}>
|
||||||
|
{rowVolume}%
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{canEdit && !isUpload && (
|
{canEdit && !isUpload && (
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -308,7 +420,13 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
{existing.map((c) =>
|
{existing.map((c) =>
|
||||||
renderRow(
|
renderRow(
|
||||||
c.shortcode,
|
c.shortcode,
|
||||||
{ url: c.url, body: c.body ?? c.shortcode, emoji: c.emoji ?? '', volume: c.volume },
|
{
|
||||||
|
url: c.url,
|
||||||
|
body: c.body ?? c.shortcode,
|
||||||
|
emoji: c.emoji ?? '',
|
||||||
|
volume: c.volume,
|
||||||
|
info: c.info,
|
||||||
|
},
|
||||||
false,
|
false,
|
||||||
deleted.has(c.shortcode),
|
deleted.has(c.shortcode),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
|
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
import { callEmbedAtom } from '../../state/callEmbed';
|
||||||
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
@@ -48,6 +50,7 @@ type CallControlsProps = {
|
|||||||
export function CallControls({ callEmbed }: CallControlsProps) {
|
export function CallControls({ callEmbed }: CallControlsProps) {
|
||||||
const controlRef = useRef<HTMLDivElement>(null);
|
const controlRef = useRef<HTMLDivElement>(null);
|
||||||
const callEmbedRef = useCallEmbedRef();
|
const callEmbedRef = useCallEmbedRef();
|
||||||
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
const [compact, setCompact] = useState(document.body.clientWidth < 500);
|
const [compact, setCompact] = useState(document.body.clientWidth < 500);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
@@ -175,22 +178,28 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
};
|
};
|
||||||
if (isEditable(target)) return;
|
if (isEditable(target)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
// C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState)
|
||||||
|
// doesn't treat this transient unmute as a user-initiated undeafen.
|
||||||
|
callEmbed.control.pttActive = true;
|
||||||
if (!microphoneRef.current) callEmbed.control.setMicrophone(true);
|
if (!microphoneRef.current) callEmbed.control.setMicrophone(true);
|
||||||
pttActiveRef.current = true;
|
pttActiveRef.current = true;
|
||||||
setPttActive(true);
|
setPttActive(true);
|
||||||
};
|
};
|
||||||
const onKeyUp = (e: KeyboardEvent) => {
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
if (e.code !== pttKey) return;
|
if (e.code !== pttKey) return;
|
||||||
|
callEmbed.control.pttActive = false;
|
||||||
callEmbed.control.setMicrophone(false);
|
callEmbed.control.setMicrophone(false);
|
||||||
pttActiveRef.current = false;
|
pttActiveRef.current = false;
|
||||||
setPttActive(false);
|
setPttActive(false);
|
||||||
};
|
};
|
||||||
const onBlur = () => {
|
const onBlur = () => {
|
||||||
|
callEmbed.control.pttActive = false;
|
||||||
callEmbed.control.setMicrophone(false);
|
callEmbed.control.setMicrophone(false);
|
||||||
pttActiveRef.current = false;
|
pttActiveRef.current = false;
|
||||||
setPttActive(false);
|
setPttActive(false);
|
||||||
};
|
};
|
||||||
const onFocus = () => {
|
const onFocus = () => {
|
||||||
|
callEmbed.control.pttActive = false;
|
||||||
callEmbed.control.setMicrophone(false);
|
callEmbed.control.setMicrophone(false);
|
||||||
pttActiveRef.current = false;
|
pttActiveRef.current = false;
|
||||||
setPttActive(false);
|
setPttActive(false);
|
||||||
@@ -215,6 +224,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
iframeWindow?.removeEventListener('focus', onFocus);
|
iframeWindow?.removeEventListener('focus', onFocus);
|
||||||
// BUG-8: if callEmbed changes while PTT is active, release mic on cleanup
|
// BUG-8: if callEmbed changes while PTT is active, release mic on cleanup
|
||||||
if (pttActiveRef.current) {
|
if (pttActiveRef.current) {
|
||||||
|
callEmbed.control.pttActive = false;
|
||||||
callEmbed.control.setMicrophone(false);
|
callEmbed.control.setMicrophone(false);
|
||||||
pttActiveRef.current = false;
|
pttActiveRef.current = false;
|
||||||
setPttActive(false);
|
setPttActive(false);
|
||||||
@@ -242,8 +252,15 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
callEmbed.control.toggleSound();
|
callEmbed.control.toggleSound();
|
||||||
};
|
};
|
||||||
|
// C-L4: also bind the EC iframe window so the deafen key works when focus is
|
||||||
|
// inside the iframe (mirrors the PTT binding above).
|
||||||
|
const iframeWindow = callEmbed.iframe.contentWindow;
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
iframeWindow?.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown);
|
||||||
|
iframeWindow?.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
}, [callEmbed, deafenKey]);
|
}, [callEmbed, deafenKey]);
|
||||||
|
|
||||||
const [hangupState, hangup] = useAsyncCallback(
|
const [hangupState, hangup] = useAsyncCallback(
|
||||||
@@ -252,6 +269,19 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
const exiting =
|
const exiting =
|
||||||
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
|
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
|
||||||
|
|
||||||
|
// C-M4: the normal teardown relies on EC echoing a Close/Hangup action after
|
||||||
|
// it ACKs HangupCall (useCallHangupEvent -> clears callEmbedAtom -> dispose).
|
||||||
|
// If EC ACKs but never echoes, the End button would spin forever. Fall back to
|
||||||
|
// disposing the embed a few seconds after a successful hangup send, unless it
|
||||||
|
// was already torn down by the normal path.
|
||||||
|
useEffect(() => {
|
||||||
|
if (hangupState.status !== AsyncStatus.Success) return undefined;
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
if (!callEmbed.disposed) setCallEmbed(undefined);
|
||||||
|
}, 4000);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [hangupState.status, callEmbed, setCallEmbed]);
|
||||||
|
|
||||||
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
|
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Icon,
|
Icon,
|
||||||
@@ -64,6 +64,16 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard
|
const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
// C-L6: the play() flow schedules a 30s safety timeout that clears playingKey;
|
||||||
|
// guard those setState calls against the component unmounting first.
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const groups = useMemo(
|
const groups = useMemo(
|
||||||
() =>
|
() =>
|
||||||
packs
|
packs
|
||||||
@@ -86,7 +96,10 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
if (playingKey) return; // one at a time (fork also enforces this)
|
if (playingKey) return; // one at a time (fork also enforces this)
|
||||||
setPlayingKey(flat.key);
|
setPlayingKey(flat.key);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
const done = () => setPlayingKey((k) => (k === flat.key ? undefined : k));
|
const done = () => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
setPlayingKey((k) => (k === flat.key ? undefined : k));
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const url = await resolveClipObjectUrl(mx, flat.clip.url);
|
const url = await resolveClipObjectUrl(mx, flat.clip.url);
|
||||||
const vol = (flat.clip.volume / 100) * master;
|
const vol = (flat.clip.volume / 100) * master;
|
||||||
@@ -183,7 +196,8 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
aria-label={`Play ${clip.name}`}
|
aria-label={`Play ${clip.name}`}
|
||||||
style={{
|
style={{
|
||||||
width: toRem(76),
|
width: toRem(76),
|
||||||
height: toRem(76),
|
minHeight: toRem(76),
|
||||||
|
height: 'auto',
|
||||||
padding: config.space.S100,
|
padding: config.space.S100,
|
||||||
borderRadius: config.radii.R400,
|
borderRadius: config.radii.R400,
|
||||||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
@@ -202,7 +216,19 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
clip.emoji || '🔊'
|
clip.emoji || '🔊'
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T200" truncate style={{ maxWidth: '100%' }}>
|
<Text
|
||||||
|
size="T200"
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
textAlign: 'center',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
lineHeight: 1.15,
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{clip.name}
|
{clip.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { Box, Button, color, Spinner, Text } from 'folds';
|
||||||
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||||
|
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||||
|
import { RetentionContent, RETENTION_PRESETS } from '../../../utils/retention';
|
||||||
|
|
||||||
|
type RoomRetentionProps = {
|
||||||
|
permissions: RoomPermissionsAPI;
|
||||||
|
};
|
||||||
|
export function RoomRetention({ permissions }: RoomRetentionProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
|
||||||
|
const canEdit = permissions.stateEvent(StateEvent.RoomRetention, mx.getSafeUserId());
|
||||||
|
|
||||||
|
const event = useStateEvent(room, StateEvent.RoomRetention);
|
||||||
|
const currentMs = event?.getContent<RetentionContent>().max_lifetime ?? 0;
|
||||||
|
|
||||||
|
const [submitState, submit] = useAsyncCallback(
|
||||||
|
useCallback(
|
||||||
|
async (ms: number) => {
|
||||||
|
const content: RetentionContent = ms > 0 ? { max_lifetime: ms } : {};
|
||||||
|
// Lotus custom-state convention: cast the type key (RoomRetention isn't a
|
||||||
|
// typed key in the SDK's StateEvents map).
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await mx.sendStateEvent(room.roomId, StateEvent.RoomRetention as any, content);
|
||||||
|
},
|
||||||
|
[mx, room.roomId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const submitting = submitState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Message Retention"
|
||||||
|
description="Messages older than this window disappear from the timeline. Each member can opt in to permanently delete their own expired messages in Settings → General; full server-side deletion also requires homeserver retention to be configured."
|
||||||
|
>
|
||||||
|
<Box gap="200" alignItems="Center" style={{ flexWrap: 'wrap' }}>
|
||||||
|
{RETENTION_PRESETS.map((preset) => {
|
||||||
|
const active = currentMs === preset.ms;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={preset.label}
|
||||||
|
type="button"
|
||||||
|
size="300"
|
||||||
|
variant={active ? 'Primary' : 'Secondary'}
|
||||||
|
fill={active ? 'Solid' : 'Soft'}
|
||||||
|
radii="300"
|
||||||
|
disabled={!canEdit || submitting}
|
||||||
|
onClick={() => submit(preset.ms)}
|
||||||
|
>
|
||||||
|
<Text size="B300">{preset.label}</Text>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{submitting && <Spinner size="100" variant="Secondary" />}
|
||||||
|
</Box>
|
||||||
|
{submitState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
{(submitState.error as MatrixError).message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</SettingTile>
|
||||||
|
</SequenceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { Box, Button, color, config, Icon, Icons, Text } from 'folds';
|
import { Box, Button, config, Icon, Icons, Text } from 'folds';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
@@ -12,11 +13,9 @@ export function RoomShareInvite() {
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [qrError, setQrError] = useState(false);
|
|
||||||
|
|
||||||
const domain = mx.getDomain() ?? undefined;
|
const domain = mx.getDomain() ?? undefined;
|
||||||
const inviteUrl = getMatrixToRoom(room.roomId, domain ? [domain] : undefined);
|
const inviteUrl = getMatrixToRoom(room.roomId, domain ? [domain] : undefined);
|
||||||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(inviteUrl)}`;
|
|
||||||
|
|
||||||
const handleCopy = useCallback(() => {
|
const handleCopy = useCallback(() => {
|
||||||
navigator.clipboard.writeText(inviteUrl).then(() => {
|
navigator.clipboard.writeText(inviteUrl).then(() => {
|
||||||
@@ -64,35 +63,19 @@ export function RoomShareInvite() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box justifyContent="Center">
|
<Box justifyContent="Center">
|
||||||
{qrError ? (
|
{/* Generated locally (qrcode.react) — no third-party service, works
|
||||||
<Box
|
offline + under strict CSP. White padded quiet-zone so the
|
||||||
direction="Column"
|
default black-on-white code scans on any theme. */}
|
||||||
alignItems="Center"
|
<Box
|
||||||
justifyContent="Center"
|
style={{
|
||||||
gap="100"
|
padding: config.space.S200,
|
||||||
style={{
|
background: '#ffffff',
|
||||||
width: 160,
|
borderRadius: config.radii.R300,
|
||||||
height: 160,
|
lineHeight: 0,
|
||||||
borderRadius: config.radii.R300,
|
}}
|
||||||
background: color.SurfaceVariant.Container,
|
>
|
||||||
}}
|
<QRCodeSVG value={inviteUrl} size={160} level="M" title="Room invite QR code" />
|
||||||
>
|
</Box>
|
||||||
<Icon size="400" src={Icons.Warning} />
|
|
||||||
<Text size="T200" priority="300" align="Center">
|
|
||||||
QR code unavailable
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={qrSrc}
|
|
||||||
alt="QR code for room invite link"
|
|
||||||
width={160}
|
|
||||||
height={160}
|
|
||||||
loading="lazy"
|
|
||||||
onError={() => setQrError(true)}
|
|
||||||
style={{ display: 'block', borderRadius: config.radii.R300 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</CutoutCard>
|
</CutoutCard>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export * from './RoomJoinRules';
|
|||||||
export * from './RoomProfile';
|
export * from './RoomProfile';
|
||||||
export * from './RoomPublish';
|
export * from './RoomPublish';
|
||||||
export * from './RoomQuality';
|
export * from './RoomQuality';
|
||||||
|
export * from './RoomRetention';
|
||||||
export * from './RoomShareInvite';
|
export * from './RoomShareInvite';
|
||||||
export * from './RoomUpgrade';
|
export * from './RoomUpgrade';
|
||||||
export * from './RoomVoiceLimit';
|
export * from './RoomVoiceLimit';
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
Text,
|
Text,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
RectCords,
|
RectCords,
|
||||||
PopOut,
|
PopOut,
|
||||||
Menu,
|
Menu,
|
||||||
@@ -75,15 +77,16 @@ function PeekPermissions({ powerLevels, power, permissionGroups, children }: Pee
|
|||||||
const hasPower = requiredPower <= power;
|
const hasPower = requiredPower <= power;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text
|
<Box
|
||||||
key={itemIndex}
|
key={itemIndex}
|
||||||
size="T200"
|
as="span"
|
||||||
style={{
|
alignItems="Center"
|
||||||
color: hasPower ? undefined : color.Critical.Main,
|
gap="100"
|
||||||
}}
|
style={{ color: hasPower ? undefined : color.Critical.Main }}
|
||||||
>
|
>
|
||||||
{hasPower ? '✅' : '❌'} {item.name}
|
<Icon size="50" src={hasPower ? Icons.Check : Icons.Cross} />
|
||||||
</Text>
|
<Text size="T200">{item.name}</Text>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -137,12 +137,13 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
|
|||||||
export const getChatBg = (
|
export const getChatBg = (
|
||||||
bg: ChatBackground,
|
bg: ChatBackground,
|
||||||
isDark: boolean,
|
isDark: boolean,
|
||||||
pauseAnimations?: boolean,
|
// Whether to strip animation (user "pause animations" setting OR OS
|
||||||
|
// prefers-reduced-motion). Supplied by the caller — e.g. via useReducedMotion —
|
||||||
|
// so this function stays pure and SSR-safe (no matchMedia read at call time).
|
||||||
|
suppressAnimation?: boolean,
|
||||||
): CSSProperties => {
|
): CSSProperties => {
|
||||||
const style = isDark ? DARK[bg] : LIGHT[bg];
|
const style = isDark ? DARK[bg] : LIGHT[bg];
|
||||||
const reducedMotion =
|
if (suppressAnimation && style.animation) {
|
||||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
||||||
if ((pauseAnimations || reducedMotion) && style.animation) {
|
|
||||||
const { animation: _anim, ...rest } = style;
|
const { animation: _anim, ...rest } = style;
|
||||||
return rest;
|
return rest;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { MouseEventHandler, forwardRef, useCallback, useRef, useState } from 'react';
|
import React, { MouseEventHandler, forwardRef, useCallback, useRef, useState } from 'react';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { MatrixClient, Room } from 'matrix-js-sdk';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
@@ -38,6 +38,7 @@ import { nameInitials } from '../../utils/common';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoomUnread } from '../../state/hooks/unread';
|
import { useRoomUnread } from '../../state/hooks/unread';
|
||||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||||
|
import { markedUnreadAtom, setMarkedUnread } from '../../state/room/markedUnread';
|
||||||
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
|
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||||
import { markAsRead } from '../../utils/notifications';
|
import { markAsRead } from '../../utils/notifications';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
@@ -263,27 +264,46 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// localStorage key for timed mute timers
|
// localStorage key for timed mute timers
|
||||||
const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
|
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
|
||||||
|
|
||||||
type MuteTimerEntry = { roomId: string; unmuteAt: number };
|
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
|
||||||
|
// immediately. Clamp long delays to this max (~24.8 days).
|
||||||
|
export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
|
||||||
|
|
||||||
function loadMuteTimers(): MuteTimerEntry[] {
|
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
|
||||||
|
|
||||||
|
export function loadMuteTimers(): MuteTimerEntry[] {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
|
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveMuteTimers(timers: MuteTimerEntry[]): void {
|
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
|
||||||
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
|
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reverse a timed mute: restore the room's notification mode to Unset and drop
|
||||||
|
// its persisted timer. Shared by the in-session timer and the boot-time restore.
|
||||||
|
export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void> {
|
||||||
|
const { setRoomNotificationPreference } =
|
||||||
|
await import('../../hooks/useRoomsNotificationPreferences');
|
||||||
|
await setRoomNotificationPreference(
|
||||||
|
mx,
|
||||||
|
roomId,
|
||||||
|
RoomNotificationMode.Unset,
|
||||||
|
RoomNotificationMode.Mute,
|
||||||
|
).catch(() => {});
|
||||||
|
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
|
function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
|
||||||
const unmuteAt = Date.now() + durationMs;
|
const unmuteAt = Date.now() + durationMs;
|
||||||
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
|
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
|
||||||
saveMuteTimers([...existing, { roomId, unmuteAt }]);
|
saveMuteTimers([...existing, { roomId, unmuteAt }]);
|
||||||
setTimeout(onUnmute, durationMs);
|
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
|
||||||
}
|
}
|
||||||
|
|
||||||
type RoomNavItemMenuProps = {
|
type RoomNavItemMenuProps = {
|
||||||
@@ -310,18 +330,39 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
const isServerNotice = room.getType() === 'm.server_notice';
|
const isServerNotice = room.getType() === 'm.server_notice';
|
||||||
|
|
||||||
const isFavorite = !!room.tags?.['m.favourite'];
|
const isFavorite = !!room.tags?.['m.favourite'];
|
||||||
|
const isLowPriority = !!room.tags?.['m.lowpriority'];
|
||||||
|
|
||||||
const handleToggleFavorite = () => {
|
const handleToggleFavorite = () => {
|
||||||
if (isFavorite) {
|
if (isFavorite) {
|
||||||
mx.deleteRoomTag(room.roomId, 'm.favourite');
|
mx.deleteRoomTag(room.roomId, 'm.favourite');
|
||||||
} else {
|
} else {
|
||||||
|
// Favourite and low-priority are mutually exclusive.
|
||||||
|
if (isLowPriority) mx.deleteRoomTag(room.roomId, 'm.lowpriority');
|
||||||
mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 });
|
mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 });
|
||||||
}
|
}
|
||||||
requestClose();
|
requestClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleLowPriority = () => {
|
||||||
|
if (isLowPriority) {
|
||||||
|
mx.deleteRoomTag(room.roomId, 'm.lowpriority');
|
||||||
|
} else {
|
||||||
|
if (isFavorite) mx.deleteRoomTag(room.roomId, 'm.favourite');
|
||||||
|
mx.setRoomTag(room.roomId, 'm.lowpriority', { order: 0.5 });
|
||||||
|
}
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const markedUnread = useAtomValue(markedUnreadAtom).has(room.roomId);
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = () => {
|
||||||
markAsRead(mx, room.roomId, hideActivity);
|
markAsRead(mx, room.roomId, hideActivity);
|
||||||
|
if (markedUnread) setMarkedUnread(mx, room.roomId, false).catch(() => undefined);
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAsUnread = () => {
|
||||||
|
setMarkedUnread(mx, room.roomId, true).catch(() => undefined);
|
||||||
requestClose();
|
requestClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -338,13 +379,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
if (durationMs !== null) {
|
if (durationMs !== null) {
|
||||||
scheduleMuteTimer(room.roomId, durationMs, () => {
|
scheduleMuteTimer(room.roomId, durationMs, () => {
|
||||||
setRoomNotificationPreference(
|
unmuteRoom(mx, room.roomId);
|
||||||
mx,
|
|
||||||
room.roomId,
|
|
||||||
RoomNotificationMode.Unset,
|
|
||||||
RoomNotificationMode.Mute,
|
|
||||||
).catch(() => {});
|
|
||||||
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== room.roomId));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
requestClose();
|
requestClose();
|
||||||
@@ -380,12 +415,23 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
size="300"
|
size="300"
|
||||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||||
radii="300"
|
radii="300"
|
||||||
disabled={!unread}
|
disabled={!unread && !markedUnread}
|
||||||
>
|
>
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
Mark as Read
|
Mark as Read
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleMarkAsUnread}
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.MessageUnread} />}
|
||||||
|
radii="300"
|
||||||
|
disabled={!!unread || markedUnread}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Mark as Unread
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
|
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
|
||||||
{(handleOpen, opened, changing) => (
|
{(handleOpen, opened, changing) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -480,6 +526,17 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
{isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
|
{isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleToggleLowPriority}
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.ChevronBottom} />}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={isLowPriority}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
{isLowPriority ? 'Remove from Low Priority' : 'Add to Low Priority'}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleInvite}
|
onClick={handleInvite}
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
@@ -597,6 +654,10 @@ function RoomNavItem_({
|
|||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
const [renameDialog, setRenameDialog] = useState(false);
|
const [renameDialog, setRenameDialog] = useState(false);
|
||||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||||
|
// MSC2867: an explicit "mark as unread" lights the row even with no unread
|
||||||
|
// count. `hasUnread` drives the bold name / icon emphasis below.
|
||||||
|
const markedUnread = useAtomValue(markedUnreadAtom).has(room.roomId);
|
||||||
|
const hasUnread = !!unread || markedUnread;
|
||||||
const typingMember = useRoomTypingMember(room.roomId).filter(
|
const typingMember = useRoomTypingMember(room.roomId).filter(
|
||||||
(receipt) => receipt.userId !== mx.getUserId(),
|
(receipt) => receipt.userId !== mx.getUserId(),
|
||||||
);
|
);
|
||||||
@@ -679,7 +740,7 @@ function RoomNavItem_({
|
|||||||
<NavItem
|
<NavItem
|
||||||
variant="Background"
|
variant="Background"
|
||||||
radii="400"
|
radii="400"
|
||||||
highlight={unread !== undefined}
|
highlight={hasUnread}
|
||||||
aria-selected={selected}
|
aria-selected={selected}
|
||||||
data-hover={!!menuAnchor}
|
data-hover={!!menuAnchor}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
@@ -708,7 +769,7 @@ function RoomNavItem_({
|
|||||||
) : (
|
) : (
|
||||||
<RoomIcon
|
<RoomIcon
|
||||||
style={{
|
style={{
|
||||||
opacity: unread ? config.opacity.P500 : config.opacity.P300,
|
opacity: hasUnread ? config.opacity.P500 : config.opacity.P300,
|
||||||
}}
|
}}
|
||||||
filled={selected}
|
filled={selected}
|
||||||
size="100"
|
size="100"
|
||||||
@@ -719,7 +780,7 @@ function RoomNavItem_({
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
<Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}>
|
<Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}>
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap="100">
|
<Box as="span" grow="Yes" alignItems="Center" gap="100">
|
||||||
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
<Text priority={hasUnread ? '500' : '300'} as="span" size="Inherit" truncate>
|
||||||
{roomName}
|
{roomName}
|
||||||
</Text>
|
</Text>
|
||||||
{hasLocalName && (
|
{hasLocalName && (
|
||||||
@@ -760,7 +821,7 @@ function RoomNavItem_({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
|
{!optionsVisible && !hasUnread && !selected && typingMember.length > 0 && (
|
||||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||||
<TypingIndicator size="300" disableAnimation />
|
<TypingIndicator size="300" disableAnimation />
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -770,6 +831,11 @@ function RoomNavItem_({
|
|||||||
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
||||||
</UnreadBadgeCenter>
|
</UnreadBadgeCenter>
|
||||||
)}
|
)}
|
||||||
|
{!optionsVisible && !unread && markedUnread && (
|
||||||
|
<UnreadBadgeCenter>
|
||||||
|
<UnreadBadge highlight={false} count={0} />
|
||||||
|
</UnreadBadgeCenter>
|
||||||
|
)}
|
||||||
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
|
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
|
||||||
<Icon
|
<Icon
|
||||||
size="50"
|
size="50"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Spinner, Text } from 'folds';
|
import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Text } from 'folds';
|
||||||
import { EventType } from 'matrix-js-sdk';
|
import { EventType } from 'matrix-js-sdk';
|
||||||
import { Page, PageContent, PageHeader } from '../../components/page';
|
import { Page, PageContent, PageHeader } from '../../components/page';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
@@ -16,6 +16,12 @@ const FORMAT_LABELS: Record<ExportFormat, string> = {
|
|||||||
html: 'HTML',
|
html: 'HTML',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PAGE_LIMIT = 100;
|
||||||
|
// Hard cap on back-pagination requests. Without a fromDate, "export all" would
|
||||||
|
// otherwise decrypt and hold every message in the room, hammering the server and
|
||||||
|
// risking an OOM/freeze with no way to stop. 200 pages × 100 ≈ 20,000 events.
|
||||||
|
const MAX_EXPORT_PAGES = 200;
|
||||||
|
|
||||||
type ExportRoomHistoryProps = {
|
type ExportRoomHistoryProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
};
|
};
|
||||||
@@ -30,11 +36,28 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
const [toDate, setToDate] = useState('');
|
const [toDate, setToDate] = useState('');
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [exportCount, setExportCount] = useState(0);
|
const [exportCount, setExportCount] = useState(0);
|
||||||
|
const [notice, setNotice] = useState('');
|
||||||
|
const cancelledRef = useRef(false);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
cancelledRef.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Stop an in-flight export if the panel unmounts (closing settings mid-export
|
||||||
|
// would otherwise keep paginating + decrypting in the background).
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
cancelledRef.current = true;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
const handleExport = useCallback(async () => {
|
||||||
if (exporting) return;
|
if (exporting) return;
|
||||||
|
cancelledRef.current = false;
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
setExportCount(0);
|
setExportCount(0);
|
||||||
|
setNotice('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null;
|
const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null;
|
||||||
@@ -55,6 +78,14 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const timeline = room.getLiveTimeline();
|
const timeline = room.getLiveTimeline();
|
||||||
let canLoadMore = true;
|
let canLoadMore = true;
|
||||||
|
// Track the oldest collected timestamp incrementally so the fromTs check
|
||||||
|
// doesn't rescan the whole `collected` array on every pagination step.
|
||||||
|
let oldestTs = Number.POSITIVE_INFINITY;
|
||||||
|
// Oldest RAW message ts paginated (tracked BEFORE the fromTs filter). The
|
||||||
|
// date-range early-break must use this — oldestTs only ever holds collected
|
||||||
|
// events (all >= fromTs), so it can never fall below fromTs and the export
|
||||||
|
// would over-paginate to the page cap and show a misleading "truncated".
|
||||||
|
let oldestRawTs = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
const addEvents = async (events: ReturnType<typeof timeline.getEvents>) => {
|
const addEvents = async (events: ReturnType<typeof timeline.getEvents>) => {
|
||||||
for (const ev of events) {
|
for (const ev of events) {
|
||||||
@@ -70,12 +101,14 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
if (ev.getType() !== EventType.RoomMessage) continue;
|
if (ev.getType() !== EventType.RoomMessage) continue;
|
||||||
if (ev.isDecryptionFailure()) continue;
|
if (ev.isDecryptionFailure()) continue;
|
||||||
const ts = ev.getTs();
|
const ts = ev.getTs();
|
||||||
|
if (ts < oldestRawTs) oldestRawTs = ts;
|
||||||
if (fromTs !== null && ts < fromTs) continue;
|
if (fromTs !== null && ts < fromTs) continue;
|
||||||
if (toTs !== null && ts > toTs) continue;
|
if (toTs !== null && ts > toTs) continue;
|
||||||
const content = ev.getContent();
|
const content = ev.getContent();
|
||||||
const body: string = content.body ?? '';
|
const body: string = content.body ?? '';
|
||||||
const msgtype: string = content.msgtype ?? '';
|
const msgtype: string = content.msgtype ?? '';
|
||||||
if (!body) continue;
|
if (!body) continue;
|
||||||
|
if (ts < oldestTs) oldestTs = ts;
|
||||||
collected.push({
|
collected.push({
|
||||||
ts,
|
ts,
|
||||||
sender: ev.getSender() ?? '',
|
sender: ev.getSender() ?? '',
|
||||||
@@ -89,25 +122,40 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
|
|
||||||
await addEvents(timeline.getEvents());
|
await addEvents(timeline.getEvents());
|
||||||
|
|
||||||
// Paginate backwards until start or date range exceeded
|
// Paginate backwards until start, date range exceeded, cap hit, or cancel
|
||||||
|
let pageCount = 0;
|
||||||
|
let truncated = false;
|
||||||
|
let cancelled = false;
|
||||||
while (canLoadMore) {
|
while (canLoadMore) {
|
||||||
// If we have a fromTs, check whether the oldest collected event is already
|
if (cancelledRef.current) {
|
||||||
// before it — if so we don't need to paginate further.
|
cancelled = true;
|
||||||
if (fromTs !== null && collected.length > 0) {
|
break;
|
||||||
const oldestTs = Math.min(...collected.map((r) => r.ts));
|
|
||||||
if (oldestTs < fromTs) break;
|
|
||||||
}
|
}
|
||||||
|
// If we've paginated back past the fromTs boundary, there's nothing more
|
||||||
|
// in range to fetch (use the raw paginated ts, not the collected one).
|
||||||
|
if (fromTs !== null && oldestRawTs < fromTs) break;
|
||||||
|
// Hard cap so "export all" can't run away and OOM the tab.
|
||||||
|
if (pageCount >= MAX_EXPORT_PAGES) {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pageCount += 1;
|
||||||
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
canLoadMore = await mx.paginateEventTimeline(timeline, {
|
canLoadMore = await mx.paginateEventTimeline(timeline, {
|
||||||
backwards: true,
|
backwards: true,
|
||||||
limit: 100,
|
limit: PAGE_LIMIT,
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await addEvents(timeline.getEvents());
|
await addEvents(timeline.getEvents());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
setNotice(`Export cancelled after ${collected.length} messages.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Sort chronologically (oldest first)
|
// Sort chronologically (oldest first)
|
||||||
collected.sort((a, b) => a.ts - b.ts);
|
collected.sort((a, b) => a.ts - b.ts);
|
||||||
|
|
||||||
@@ -191,6 +239,12 @@ ${msgRows}
|
|||||||
a.download = `export-${safeRoomName}-${dateStr}.${ext}`;
|
a.download = `export-${safeRoomName}-${dateStr}.${ext}`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
if (truncated) {
|
||||||
|
setNotice(
|
||||||
|
`Export truncated to ${collected.length} messages (reached the ${MAX_EXPORT_PAGES}-page limit). Narrow the date range to export older history.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
@@ -297,24 +351,35 @@ ${msgRows}
|
|||||||
? `Exporting… ${exportCount} messages`
|
? `Exporting… ${exportCount} messages`
|
||||||
: 'Export will download automatically.'}
|
: 'Export will download automatically.'}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
{exporting ? (
|
||||||
size="400"
|
<Button
|
||||||
variant="Primary"
|
size="400"
|
||||||
fill="Solid"
|
variant="Critical"
|
||||||
radii="300"
|
fill="Soft"
|
||||||
disabled={exporting}
|
radii="300"
|
||||||
onClick={handleExport}
|
onClick={handleCancel}
|
||||||
before={
|
before={<Icon src={Icons.Cross} size="100" />}
|
||||||
exporting ? (
|
>
|
||||||
<Spinner size="200" />
|
<Text size="B400">Cancel</Text>
|
||||||
) : (
|
</Button>
|
||||||
<Icon src={Icons.Download} size="100" />
|
) : (
|
||||||
)
|
<Button
|
||||||
}
|
size="400"
|
||||||
>
|
variant="Primary"
|
||||||
<Text size="B400">{exporting ? 'Exporting…' : 'Export'}</Text>
|
fill="Solid"
|
||||||
</Button>
|
radii="300"
|
||||||
|
onClick={handleExport}
|
||||||
|
before={<Icon src={Icons.Download} size="100" />}
|
||||||
|
>
|
||||||
|
<Text size="B400">Export</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
{notice && (
|
||||||
|
<Text size="T200" priority="400">
|
||||||
|
{notice}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ function isGlob(entity: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function recommendationLabel(rec: string): string {
|
function recommendationLabel(rec: string): string {
|
||||||
if (rec === 'm.ban') return 'Ban';
|
// `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy
|
||||||
|
// (pre-stabilization) recommendation still emitted by older bots.
|
||||||
|
if (rec === 'm.ban' || rec === 'org.matrix.mjolnir.ban') return 'Ban';
|
||||||
return rec;
|
return rec;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,9 +105,11 @@ function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
|
|||||||
<Text size="T200">glob</Text>
|
<Text size="T200">glob</Text>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Badge variant="Critical" fill="Soft" radii="Pill">
|
{entry.recommendation && (
|
||||||
<Text size="T200">{recommendationLabel(entry.recommendation)}</Text>
|
<Badge variant="Critical" fill="Soft" radii="Pill">
|
||||||
</Badge>
|
<Text size="T200">{recommendationLabel(entry.recommendation)}</Text>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{entry.reason && (
|
{entry.reason && (
|
||||||
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
|
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ function describeEvent(mx: ReturnType<typeof useMatrixClient>, ev: MatrixEvent):
|
|||||||
if (membership === 'join') {
|
if (membership === 'join') {
|
||||||
if (
|
if (
|
||||||
prevMembership === 'invite' ||
|
prevMembership === 'invite' ||
|
||||||
|
prevMembership === 'knock' ||
|
||||||
prevMembership === undefined ||
|
prevMembership === undefined ||
|
||||||
prevMembership === null
|
prevMembership === null
|
||||||
) {
|
) {
|
||||||
@@ -115,6 +116,19 @@ function describeEvent(mx: ReturnType<typeof useMatrixClient>, ev: MatrixEvent):
|
|||||||
filter: 'members',
|
filter: 'members',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// sender !== stateKey and the target was only invited → the inviter (or a
|
||||||
|
// moderator) retracted the invite; this is not a kick.
|
||||||
|
if (prevMembership === 'invite') {
|
||||||
|
return {
|
||||||
|
text: (
|
||||||
|
<>
|
||||||
|
<strong>{senderName}</strong> withdrew the invite to <strong>{targetName}</strong>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
iconSrc: Icons.User,
|
||||||
|
filter: 'members',
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
text: (
|
text: (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -115,10 +115,16 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
|||||||
const totalMessages = [...msgCounts.values()].reduce((a, b) => a + b, 0);
|
const totalMessages = [...msgCounts.values()].reduce((a, b) => a + b, 0);
|
||||||
const uniqueParticipants = msgCounts.size;
|
const uniqueParticipants = msgCounts.size;
|
||||||
|
|
||||||
const msgEvents = events.filter((ev) => ev.getType() === EventType.RoomMessage);
|
// Single-pass min/max — `Math.min(...allTs)` spreads one arg per message and
|
||||||
const allTs = msgEvents.map((ev) => ev.getTs());
|
// overflows the call stack (RangeError) on a large paginated timeline.
|
||||||
const oldestTs = allTs.length > 0 ? Math.min(...allTs) : null;
|
let oldestTs: number | null = null;
|
||||||
const newestTs = allTs.length > 0 ? Math.max(...allTs) : null;
|
let newestTs: number | null = null;
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.getType() !== EventType.RoomMessage) continue;
|
||||||
|
const ts = ev.getTs();
|
||||||
|
if (oldestTs === null || ts < oldestTs) oldestTs = ts;
|
||||||
|
if (newestTs === null || ts > newestTs) newestTs = ts;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top5,
|
top5,
|
||||||
|
|||||||
@@ -3,16 +3,22 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
Dialog,
|
||||||
|
Header,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
Input,
|
Input,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
Scroll,
|
Scroll,
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
color,
|
color,
|
||||||
config,
|
config,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { Page, PageContent, PageHeader } from '../../components/page';
|
import { Page, PageContent, PageHeader } from '../../components/page';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
@@ -24,6 +30,8 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { SequenceCard } from '../../components/sequence-card';
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -42,20 +50,52 @@ const DEFAULT_ACL: ServerAclContent = {
|
|||||||
// ── Validation ────────────────────────────────────────────────────────────────
|
// ── Validation ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a server name or wildcard pattern.
|
* Validate a server-name glob for an ACL entry.
|
||||||
* Allowed forms:
|
*
|
||||||
* - plain hostname / IP: letters, digits, hyphens, dots
|
* Matrix ACL `allow`/`deny` entries are globs where `*` (any run of chars) and
|
||||||
* - wildcard prefix: *.example.com (asterisk only at the very start)
|
* `?` (single char) may appear ANYWHERE — e.g. `*`, `*.example.com`,
|
||||||
* The Matrix spec allows `*` on its own (match-all wildcard).
|
* `1.2.3.*`, `10.0.0.?`, `*.evil.*`, `*bad*`. We therefore validate the *glob*
|
||||||
|
* rather than a concrete hostname:
|
||||||
|
* - reject empty / whitespace-only
|
||||||
|
* - allow only hostname/IP chars plus the wildcards `*` and `?`
|
||||||
|
* (letters, digits, dots, hyphens, colons for ports/IPv6 — NO underscore)
|
||||||
|
* - reject consecutive/leading/trailing dots (`...`, `.foo`, `foo.`)
|
||||||
|
* - reject entries with no alphanumeric or wildcard char (bare `-`, lone `:`)
|
||||||
*/
|
*/
|
||||||
function isValidServerPattern(value: string): boolean {
|
function isValidServerPattern(value: string): boolean {
|
||||||
if (value === '*') return true;
|
const v = value.trim();
|
||||||
// Strip leading wildcard
|
if (!v) return false;
|
||||||
const rest = value.startsWith('*.') ? value.slice(2) : value;
|
// Only hostname/IP glob chars — wildcards may appear at any position.
|
||||||
// Must not be empty after stripping wildcard
|
if (!/^[A-Za-z0-9.:*?-]+$/.test(v)) return false;
|
||||||
if (!rest) return false;
|
// Structural rules for the dotted parts.
|
||||||
// Remaining part: only letters, digits, dots, hyphens, colons (for IPv6/ports)
|
if (v.startsWith('.') || v.endsWith('.') || v.includes('..')) return false;
|
||||||
return /^[A-Za-z0-9.:_-]+$/.test(rest);
|
// Must carry actual signal — reject pure punctuation like `-`, `:` or `-.-`.
|
||||||
|
if (!/[A-Za-z0-9*?]/.test(v)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an ACL glob (`*` = any run, `?` = single char) to an anchored RegExp,
|
||||||
|
* escaping every other regex metacharacter. Used only for local self-ban
|
||||||
|
* detection — never sent to the server.
|
||||||
|
*/
|
||||||
|
function globToRegExp(glob: string): RegExp {
|
||||||
|
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||||
|
// Case-INsensitive: Synapse's glob_to_regex uses IGNORECASE and hostnames are
|
||||||
|
// case-insensitive, so a deny like `MATRIX.foo.org` must still be detected as
|
||||||
|
// self-banning `matrix.foo.org` (otherwise the warning is a false negative).
|
||||||
|
return new RegExp(`^${pattern}$`, 'i');
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesAnyGlob(domain: string, globs: string[]): boolean {
|
||||||
|
return globs.some((glob) => {
|
||||||
|
try {
|
||||||
|
return globToRegExp(glob).test(domain);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Server list sub-component ─────────────────────────────────────────────────
|
// ── Server list sub-component ─────────────────────────────────────────────────
|
||||||
@@ -78,7 +118,7 @@ function ServerList({ label, entries, canEdit, onAdd, onRemove }: ServerListProp
|
|||||||
if (!value) return;
|
if (!value) return;
|
||||||
|
|
||||||
if (!isValidServerPattern(value)) {
|
if (!isValidServerPattern(value)) {
|
||||||
setError('Invalid server pattern. Use a hostname or *.example.com');
|
setError('Invalid pattern. Use a hostname, IP, or glob (e.g. *.evil.com, 1.2.3.*, 10.0.0.?)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
@@ -181,6 +221,7 @@ type RoomServerACLProps = {
|
|||||||
export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
|
|
||||||
// Power level checks
|
// Power level checks
|
||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
@@ -221,6 +262,26 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
|||||||
const saveError =
|
const saveError =
|
||||||
saveState.status === AsyncStatus.Error ? 'Failed to save ACL. Please try again.' : undefined;
|
saveState.status === AsyncStatus.Error ? 'Failed to save ACL. Please try again.' : undefined;
|
||||||
|
|
||||||
|
// ── Save guards ───────────────────────────────────────────────────────────
|
||||||
|
// #1 Empty allow list denies EVERY server (allow: [] is not "allow all") and
|
||||||
|
// partitions the room from all federation irreversibly — block the save.
|
||||||
|
const emptyAllow = allowList.length === 0;
|
||||||
|
|
||||||
|
// #2 Self-ban: the local homeserver must match at least one allow glob and no
|
||||||
|
// deny glob, otherwise applying this ACL removes our own server from the room.
|
||||||
|
const localDomain = mx.getDomain() ?? '';
|
||||||
|
const selfBanned =
|
||||||
|
localDomain.length > 0 &&
|
||||||
|
(!matchesAnyGlob(localDomain, allowList) || matchesAnyGlob(localDomain, denyList));
|
||||||
|
|
||||||
|
// #4 Gate the destructive write behind a confirmation dialog.
|
||||||
|
const [prompt, setPrompt] = useState(false);
|
||||||
|
|
||||||
|
const handleConfirmSave = () => {
|
||||||
|
setPrompt(false);
|
||||||
|
save();
|
||||||
|
};
|
||||||
|
|
||||||
// Required power level for this state event
|
// Required power level for this state event
|
||||||
const requiredPL = readPowerLevel.state(powerLevels, StateEvent.RoomServerAcl);
|
const requiredPL = readPowerLevel.state(powerLevels, StateEvent.RoomServerAcl);
|
||||||
const myPL = readPowerLevel.user(powerLevels, myUserId);
|
const myPL = readPowerLevel.user(powerLevels, myUserId);
|
||||||
@@ -242,8 +303,8 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
|||||||
variant="Primary"
|
variant="Primary"
|
||||||
fill="Solid"
|
fill="Solid"
|
||||||
radii="300"
|
radii="300"
|
||||||
disabled={saving || !isDirty}
|
disabled={saving || !isDirty || emptyAllow}
|
||||||
onClick={() => save()}
|
onClick={() => setPrompt(true)}
|
||||||
before={saving ? <Spinner size="200" /> : <Icon src={Icons.Check} size="100" />}
|
before={saving ? <Spinner size="200" /> : <Icon src={Icons.Check} size="100" />}
|
||||||
>
|
>
|
||||||
<Text size="B400">{saving ? 'Saving…' : 'Save Changes'}</Text>
|
<Text size="B400">{saving ? 'Saving…' : 'Save Changes'}</Text>
|
||||||
@@ -290,6 +351,24 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* #1 Empty allow list guard — blocks save */}
|
||||||
|
{canEdit && emptyAllow && (
|
||||||
|
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||||
|
The allow list is empty. An empty allow list denies every server and partitions
|
||||||
|
this room from all federation permanently. Add at least one entry (use
|
||||||
|
"*" to allow all servers).
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* #2 Self-ban warning — save allowed but confirmation required */}
|
||||||
|
{canEdit && !emptyAllow && selfBanned && (
|
||||||
|
<Text size="T300" style={{ color: color.Warning.Main }}>
|
||||||
|
Warning: your own homeserver ({localDomain}) is not permitted by this ACL.
|
||||||
|
Applying it will remove your server from the room and you may lose the ability to
|
||||||
|
moderate it.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Allow IP literals toggle */}
|
{/* Allow IP literals toggle */}
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">IP Address Access</Text>
|
<Text size="L400">IP Address Access</Text>
|
||||||
@@ -352,6 +431,82 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
|||||||
</PageContent>
|
</PageContent>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* #4 Confirmation dialog — surfaces the empty-allow (#1) and self-ban (#2)
|
||||||
|
warnings and keeps a safe save one extra click. */}
|
||||||
|
{prompt && (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setPrompt(false),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog
|
||||||
|
variant="Surface"
|
||||||
|
aria-labelledby="server-acl-confirm-title"
|
||||||
|
style={modalStyle}
|
||||||
|
>
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text as="h2" size="H4" id="server-acl-confirm-title">
|
||||||
|
Apply Server ACL
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
onClick={() => setPrompt(false)}
|
||||||
|
radii="300"
|
||||||
|
aria-label="Cancel"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text priority="400">
|
||||||
|
Server ACL changes take effect immediately and control which servers can
|
||||||
|
participate in this room. This cannot be undone by other servers once they are
|
||||||
|
removed.
|
||||||
|
</Text>
|
||||||
|
{emptyAllow && (
|
||||||
|
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||||
|
The allow list is empty — this would deny every server and partition the
|
||||||
|
room from all federation permanently.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!emptyAllow && selfBanned && (
|
||||||
|
<Text size="T300" style={{ color: color.Warning.Main }}>
|
||||||
|
Warning: your own homeserver ({localDomain}) is not permitted by this ACL.
|
||||||
|
Applying it will remove your server from the room and you may lose the
|
||||||
|
ability to moderate it.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant={selfBanned ? 'Critical' : 'Primary'}
|
||||||
|
onClick={handleConfirmSave}
|
||||||
|
disabled={emptyAllow}
|
||||||
|
>
|
||||||
|
<Text size="B400">Apply ACL</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
)}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
RoomPublishedAddresses,
|
RoomPublishedAddresses,
|
||||||
RoomPublish,
|
RoomPublish,
|
||||||
RoomQuality,
|
RoomQuality,
|
||||||
|
RoomRetention,
|
||||||
RoomShareInvite,
|
RoomShareInvite,
|
||||||
RoomUpgrade,
|
RoomUpgrade,
|
||||||
RoomVoiceLimit,
|
RoomVoiceLimit,
|
||||||
@@ -56,6 +57,10 @@ export function General({ requestClose }: GeneralProps) {
|
|||||||
<RoomEncryption permissions={permissions} />
|
<RoomEncryption permissions={permissions} />
|
||||||
<RoomPublish permissions={permissions} />
|
<RoomPublish permissions={permissions} />
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Message Retention</Text>
|
||||||
|
<RoomRetention permissions={permissions} />
|
||||||
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Voice</Text>
|
<Text size="L400">Voice</Text>
|
||||||
<RoomVoiceLimit permissions={permissions} />
|
<RoomVoiceLimit permissions={permissions} />
|
||||||
|
|||||||
@@ -133,6 +133,18 @@ function getSenderName(room: Room, userId: string): string {
|
|||||||
return room.getMember(userId)?.name ?? userId.split(':')[0]?.slice(1) ?? userId;
|
return room.getMember(userId)?.name ?? userId.split(':')[0]?.slice(1) ?? userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the thumbnail/display MXC for an image/video event, mirroring the
|
||||||
|
// grid's preference order (encrypted thumb > file > thumbnail_url > url). Both
|
||||||
|
// the grid and the lightbox must use this so their positional indices stay in
|
||||||
|
// lockstep — otherwise a tile skipped for lack of a thumb would shift the
|
||||||
|
// lightbox and open the wrong media.
|
||||||
|
function getThumbMxc(mEvent: MatrixEvent): string | undefined {
|
||||||
|
const c = mEvent.getContent();
|
||||||
|
const isEnc = !!c.file;
|
||||||
|
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
|
||||||
|
return isEnc ? (info?.thumbnail_file?.url ?? c.file?.url) : (info?.thumbnail_url ?? c.url);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Lightbox ──────────────────────────────────────────────────────────────────
|
// ── Lightbox ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type LightboxItem = {
|
type LightboxItem = {
|
||||||
@@ -585,7 +597,10 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
const lightboxItems: LightboxItem[] = events
|
const lightboxItems: LightboxItem[] = events
|
||||||
.filter((ev) => {
|
.filter((ev) => {
|
||||||
const c = ev.getContent();
|
const c = ev.getContent();
|
||||||
return c.msgtype === MsgType.Image || c.msgtype === MsgType.Video;
|
if (c.msgtype !== MsgType.Image && c.msgtype !== MsgType.Video) return false;
|
||||||
|
// Match the grid's guard exactly: tiles without a thumb are not rendered,
|
||||||
|
// so they must not occupy a lightbox slot either.
|
||||||
|
return !!getThumbMxc(ev);
|
||||||
})
|
})
|
||||||
.map((ev) => {
|
.map((ev) => {
|
||||||
const c = ev.getContent();
|
const c = ev.getContent();
|
||||||
@@ -712,9 +727,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
|
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
|
||||||
|
|
||||||
// Prefer thumbnail_file (encrypted thumb) > file > thumbnail_url > url
|
// Prefer thumbnail_file (encrypted thumb) > file > thumbnail_url > url
|
||||||
const thumbMxc: string | undefined = isEnc
|
const thumbMxc: string | undefined = getThumbMxc(mEvent);
|
||||||
? (info?.thumbnail_file?.url ?? c.file?.url)
|
|
||||||
: (info?.thumbnail_url ?? c.url);
|
|
||||||
const thumbEnc: IEncryptedFile | undefined = isEnc
|
const thumbEnc: IEncryptedFile | undefined = isEnc
|
||||||
? (info?.thumbnail_file ?? c.file)
|
? (info?.thumbnail_file ?? c.file)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { RoomView } from './RoomView';
|
|||||||
import { MembersDrawer } from './MembersDrawer';
|
import { MembersDrawer } from './MembersDrawer';
|
||||||
import { MediaGallery } from './MediaGallery';
|
import { MediaGallery } from './MediaGallery';
|
||||||
import { mediaGalleryAtom } from '../../state/mediaGallery';
|
import { mediaGalleryAtom } from '../../state/mediaGallery';
|
||||||
|
import { WidgetsPanel } from './widgets/WidgetsPanel';
|
||||||
|
import { widgetsPanelAtom } from '../../state/widgetsPanel';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
@@ -39,6 +41,8 @@ export function Room() {
|
|||||||
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
|
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
|
||||||
const galleryOpen = useAtomValue(mediaGalleryAtom);
|
const galleryOpen = useAtomValue(mediaGalleryAtom);
|
||||||
const setGalleryOpen = useSetAtom(mediaGalleryAtom);
|
const setGalleryOpen = useSetAtom(mediaGalleryAtom);
|
||||||
|
const widgetsOpen = useAtomValue(widgetsPanelAtom);
|
||||||
|
const setWidgetsOpen = useSetAtom(widgetsPanelAtom);
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
@@ -64,30 +68,40 @@ export function Room() {
|
|||||||
|
|
||||||
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
|
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
|
||||||
|
|
||||||
// Thread panel and media gallery are mutually exclusive on every screen size:
|
// The content panels (thread / media gallery / widgets) are mutually exclusive
|
||||||
// opening one closes the other. Detect the just-opened transition so whichever
|
// on every screen size: opening one closes the others. Detect the just-opened
|
||||||
// was opened most recently wins.
|
// transition so whichever was opened most recently wins.
|
||||||
const prevThreadRef = useRef(activeThreadId);
|
const prevThreadRef = useRef(activeThreadId);
|
||||||
const prevGalleryRef = useRef(galleryOpen);
|
const prevGalleryRef = useRef(galleryOpen);
|
||||||
|
const prevWidgetsRef = useRef(widgetsOpen);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current;
|
const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current;
|
||||||
const galleryJustOpened = galleryOpen && !prevGalleryRef.current;
|
const galleryJustOpened = galleryOpen && !prevGalleryRef.current;
|
||||||
if (threadJustOpened && galleryOpen) {
|
const widgetsJustOpened = widgetsOpen && !prevWidgetsRef.current;
|
||||||
setGalleryOpen(false);
|
if (threadJustOpened) {
|
||||||
} else if (galleryJustOpened && activeThreadId) {
|
if (galleryOpen) setGalleryOpen(false);
|
||||||
setActiveThreadId(null);
|
if (widgetsOpen) setWidgetsOpen(false);
|
||||||
|
} else if (galleryJustOpened) {
|
||||||
|
if (activeThreadId) setActiveThreadId(null);
|
||||||
|
if (widgetsOpen) setWidgetsOpen(false);
|
||||||
|
} else if (widgetsJustOpened) {
|
||||||
|
if (activeThreadId) setActiveThreadId(null);
|
||||||
|
if (galleryOpen) setGalleryOpen(false);
|
||||||
}
|
}
|
||||||
prevThreadRef.current = activeThreadId;
|
prevThreadRef.current = activeThreadId;
|
||||||
prevGalleryRef.current = galleryOpen;
|
prevGalleryRef.current = galleryOpen;
|
||||||
}, [activeThreadId, galleryOpen, setGalleryOpen, setActiveThreadId]);
|
prevWidgetsRef.current = widgetsOpen;
|
||||||
|
}, [activeThreadId, galleryOpen, widgetsOpen, setGalleryOpen, setActiveThreadId, setWidgetsOpen]);
|
||||||
|
|
||||||
// On non-desktop screens at most one right-side panel may show, priority
|
// On non-desktop screens at most one right-side panel may show, priority
|
||||||
// thread > gallery > members. On desktop thread + members may coexist while
|
// thread > gallery > widgets > members. On desktop thread + members may coexist
|
||||||
// thread + gallery stay mutually exclusive (via the effect above).
|
// while the content panels stay mutually exclusive (via the effect above).
|
||||||
const isDesktop = screenSize === ScreenSize.Desktop;
|
const isDesktop = screenSize === ScreenSize.Desktop;
|
||||||
const showThreadPanel = !callView && Boolean(activeThreadId);
|
const showThreadPanel = !callView && Boolean(activeThreadId);
|
||||||
const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId);
|
const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId);
|
||||||
const showMembers = !callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen));
|
const showWidgets = !callView && widgetsOpen && (isDesktop || (!activeThreadId && !galleryOpen));
|
||||||
|
const showMembers =
|
||||||
|
!callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen && !widgetsOpen));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PowerLevelsContextProvider value={powerLevels}>
|
<PowerLevelsContextProvider value={powerLevels}>
|
||||||
@@ -125,6 +139,18 @@ export function Room() {
|
|||||||
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
|
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{showWidgets && (
|
||||||
|
<>
|
||||||
|
{screenSize === ScreenSize.Desktop && (
|
||||||
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
|
)}
|
||||||
|
<WidgetsPanel
|
||||||
|
key={room.roomId}
|
||||||
|
room={room}
|
||||||
|
requestClose={() => setWidgetsOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{showThreadPanel && activeThreadId && (
|
{showThreadPanel && activeThreadId && (
|
||||||
<>
|
<>
|
||||||
{screenSize === ScreenSize.Desktop && (
|
{screenSize === ScreenSize.Desktop && (
|
||||||
|
|||||||
@@ -456,12 +456,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
|
|
||||||
if (compressionResult) {
|
if (compressionResult) {
|
||||||
const originalFile = fileItem.originalFile as File;
|
const originalFile = fileItem.originalFile as File;
|
||||||
const compressedFile = new File([compressionResult.blob], originalFile.name, {
|
// compressImage re-encodes as JPEG; swap the extension so the file
|
||||||
type: 'image/jpeg',
|
// name and MIME type agree (avoids e.g. a JPEG named "photo.png").
|
||||||
|
const compressedType = compressionResult.type;
|
||||||
|
const compressedName = `${originalFile.name.replace(/\.[^./\\]+$/, '')}.jpg`;
|
||||||
|
const compressedFile = new File([compressionResult.blob], compressedName, {
|
||||||
|
type: compressedType,
|
||||||
});
|
});
|
||||||
const uploadRes = await mx.uploadContent(compressedFile, {
|
const uploadRes = await mx.uploadContent(compressedFile, {
|
||||||
name: originalFile.name,
|
name: compressedName,
|
||||||
type: 'image/jpeg',
|
type: compressedType,
|
||||||
});
|
});
|
||||||
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
|
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
|
||||||
if (compressedMxc) {
|
if (compressedMxc) {
|
||||||
@@ -538,6 +542,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
}
|
}
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
|
setCharCount(0);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -579,6 +584,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
mx.sendMessage(roomId, threadRootId ?? null, content as any);
|
mx.sendMessage(roomId, threadRootId ?? null, content as any);
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
|
setCharCount(0);
|
||||||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
|||||||
import { ThreadSummary } from './thread/ThreadSummary';
|
import { ThreadSummary } from './thread/ThreadSummary';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||||
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
|
import { RetentionContent, isExpired } from '../../utils/retention';
|
||||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
||||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||||
@@ -468,6 +470,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||||
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||||
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||||
|
// MSC1763 retention: messages older than this window are hidden from the
|
||||||
|
// timeline (unless "show hidden events" is on). Reactive so a policy change
|
||||||
|
// re-renders. `undefined` = no policy.
|
||||||
|
const retentionEvent = useStateEvent(room, StateEvent.RoomRetention);
|
||||||
|
const retentionMs = retentionEvent?.getContent<RetentionContent>().max_lifetime;
|
||||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
|
|
||||||
@@ -2043,6 +2050,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
if (eventSender && ignoredUsersSet.has(eventSender)) {
|
if (eventSender && ignoredUsersSet.has(eventSender)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// MSC1763: hide messages past the room's retention window (disappearing
|
||||||
|
// messages). Power users can still inspect via "show hidden events".
|
||||||
|
if (retentionMs && !showHiddenEvents && isExpired(mEvent.getTs(), retentionMs, Date.now())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (mEvent.isRedacted() && !showHiddenEvents) {
|
if (mEvent.isRedacted() && !showHiddenEvents) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||||
const t = mEvent.getType();
|
const t = mEvent.getType();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Page } from '../../components/page';
|
|||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useTheme, ThemeKind } from '../../hooks/useTheme';
|
import { useTheme, ThemeKind } from '../../hooks/useTheme';
|
||||||
|
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
||||||
import { getChatBg } from '../lotus/chatBackground';
|
import { getChatBg } from '../lotus/chatBackground';
|
||||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
import { editableActiveElement } from '../../utils/dom';
|
import { editableActiveElement } from '../../utils/dom';
|
||||||
@@ -65,6 +66,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||||||
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.kind === ThemeKind.Dark;
|
const isDark = theme.kind === ThemeKind.Dark;
|
||||||
|
const reduced = useReducedMotion();
|
||||||
|
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
|
|
||||||
@@ -102,10 +104,11 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||||||
// Background.Container color. SidebarNav mirrors it onto document.body separately
|
// Background.Container color. SidebarNav mirrors it onto document.body separately
|
||||||
// so the glassmorphism sidebar can blur through it.
|
// so the glassmorphism sidebar can blur through it.
|
||||||
const chatBgStyle = useMemo(() => {
|
const chatBgStyle = useMemo(() => {
|
||||||
if (chatBackground !== 'none') return getChatBg(chatBackground, isDark, pauseAnimations);
|
if (chatBackground !== 'none')
|
||||||
if (lotusTerminal) return getChatBg('tactical', isDark, pauseAnimations);
|
return getChatBg(chatBackground, isDark, pauseAnimations || reduced);
|
||||||
|
if (lotusTerminal) return getChatBg('tactical', isDark, pauseAnimations || reduced);
|
||||||
return {};
|
return {};
|
||||||
}, [chatBackground, lotusTerminal, isDark, pauseAnimations]);
|
}, [chatBackground, lotusTerminal, isDark, pauseAnimations, reduced]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page ref={roomViewRef} style={chatBgStyle}>
|
<Page ref={roomViewRef} style={chatBgStyle}>
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
|||||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||||
import { webRTCSupported } from '../../utils/rtc';
|
import { webRTCSupported } from '../../utils/rtc';
|
||||||
import { mediaGalleryAtom } from '../../state/mediaGallery';
|
import { mediaGalleryAtom } from '../../state/mediaGallery';
|
||||||
|
import { widgetsPanelAtom } from '../../state/widgetsPanel';
|
||||||
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
|
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
|
||||||
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
|
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
|
||||||
|
|
||||||
@@ -489,6 +490,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
|
|
||||||
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
const [galleryOpen, setGalleryOpen] = useAtom(mediaGalleryAtom);
|
const [galleryOpen, setGalleryOpen] = useAtom(mediaGalleryAtom);
|
||||||
|
const [widgetsOpen, setWidgetsOpen] = useAtom(widgetsPanelAtom);
|
||||||
const pendingKnocks = usePendingKnocks(room);
|
const pendingKnocks = usePendingKnocks(room);
|
||||||
|
|
||||||
const handleSearchClick = () => {
|
const handleSearchClick = () => {
|
||||||
@@ -725,6 +727,29 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
{screenSize === ScreenSize.Desktop && (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>{widgetsOpen ? 'Hide Widgets' : 'Widgets'}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
fill="None"
|
||||||
|
ref={triggerRef}
|
||||||
|
onClick={() => setWidgetsOpen(!widgetsOpen)}
|
||||||
|
aria-label="Toggle widgets"
|
||||||
|
aria-pressed={widgetsOpen}
|
||||||
|
>
|
||||||
|
<Icon size="400" src={Icons.Category} filled={widgetsOpen} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
{screenSize === ScreenSize.Desktop && (
|
{screenSize === ScreenSize.Desktop && (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export function buildForwardContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete content['m.relates_to'];
|
delete content['m.relates_to'];
|
||||||
|
// Drop intentional mentions so forwarding a message doesn't re-ping the
|
||||||
|
// originally-mentioned users (they're not in the destination room's context).
|
||||||
|
delete content['m.mentions'];
|
||||||
if (typeof content.body === 'string') {
|
if (typeof content.body === 'string') {
|
||||||
content.body = trimReplyFromBody(content.body);
|
content.body = trimReplyFromBody(content.body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -460,12 +460,17 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
|||||||
}, [scrollToBottomCount]);
|
}, [scrollToBottomCount]);
|
||||||
|
|
||||||
const handleJumpToBottom = useCallback(() => {
|
const handleJumpToBottom = useCallback(() => {
|
||||||
|
// Re-anchor the virtual window at the thread tail first. While scrolled up,
|
||||||
|
// live replies deliberately don't extend the window, so without this the chip
|
||||||
|
// would scroll to the bottom of the STALE window (a mid/old event) instead of
|
||||||
|
// the newest reply. Mirrors the main timeline's handleJumpToLatest.
|
||||||
|
setTimeline(getInitialThreadTimeline(thread, getLinkedTimelines(thread.liveTimeline)));
|
||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = true;
|
scrollToBottomRef.current.smooth = true;
|
||||||
// Flip atBottom so the layout effect re-runs (count re-read) and live
|
// Flip atBottom so the layout effect re-runs (count re-read) and live
|
||||||
// events resume sticking to the bottom.
|
// events resume sticking to the bottom.
|
||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
}, []);
|
}, [thread]);
|
||||||
|
|
||||||
// Scroll in-place editor into view.
|
// Scroll in-place editor into view.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { ReceiptType } from 'matrix-js-sdk';
|
||||||
|
import { markThreadAsRead } from './threadReceipt';
|
||||||
|
|
||||||
|
// The regression this guards: sending a receipt for the thread ROOT (when
|
||||||
|
// replies aren't loaded, lastReply() is null / equals the root) becomes a MAIN
|
||||||
|
// receipt at an old event and drags the room's read marker backwards. It must
|
||||||
|
// only ever receipt a genuine loaded reply.
|
||||||
|
|
||||||
|
const evt = (id: string, sending = false) => ({ getId: () => id, isSending: () => sending }) as any;
|
||||||
|
|
||||||
|
const setup = (lastReply: any) => {
|
||||||
|
const calls: Array<{ eventId: string; type: ReceiptType }> = [];
|
||||||
|
const thread = { id: '$root', lastReply: () => lastReply } as any;
|
||||||
|
const mx = {
|
||||||
|
sendReadReceipt: async (e: any, type: ReceiptType) => {
|
||||||
|
calls.push({ eventId: e.getId(), type });
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
return { mx, thread, calls };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('REGRESSION: no loaded reply (lastReply null) → NO receipt (never the root)', async () => {
|
||||||
|
const { mx, thread, calls } = setup(null);
|
||||||
|
await markThreadAsRead(mx, thread, false);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('REGRESSION: lastReply IS the root → NO receipt', async () => {
|
||||||
|
const { mx, thread, calls } = setup(evt('$root'));
|
||||||
|
await markThreadAsRead(mx, thread, false);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('genuine loaded reply → threaded receipt at that reply', async () => {
|
||||||
|
const { mx, thread, calls } = setup(evt('$reply'));
|
||||||
|
await markThreadAsRead(mx, thread, false);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].eventId, '$reply');
|
||||||
|
assert.equal(calls[0].type, ReceiptType.Read);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sending reply is skipped', async () => {
|
||||||
|
const { mx, thread, calls } = setup(evt('$reply', true));
|
||||||
|
await markThreadAsRead(mx, thread, false);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('private flag uses ReadPrivate', async () => {
|
||||||
|
const { mx, thread, calls } = setup(evt('$reply'));
|
||||||
|
await markThreadAsRead(mx, thread, true);
|
||||||
|
assert.equal(calls[0].type, ReceiptType.ReadPrivate);
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { MatrixClient, ReceiptType, Thread } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a threaded read receipt for a thread, clearing its per-thread unread
|
||||||
|
* count.
|
||||||
|
*
|
||||||
|
* CRITICAL: never receipt the thread ROOT. A thread's liveTimeline is
|
||||||
|
* `[root, reply1, …]`, so the latest event IS the root when replies aren't
|
||||||
|
* loaded yet (common — the thread panel fires this on mount before replies
|
||||||
|
* fetch). The root is "in the main timeline", so a receipt for it is written by
|
||||||
|
* the SDK with `thread_id:"main"` at the old root, dragging the room's MAIN read
|
||||||
|
* marker backwards (`getEventReadUpTo` → an old/unloaded event) and re-lighting
|
||||||
|
* the whole room. We only receipt a genuine loaded reply (`thread.lastReply()`);
|
||||||
|
* if none is loaded we bail (the per-thread count clears when the reply loads
|
||||||
|
* and this runs again). Mirrors the root guard in `utils/notifications.ts`.
|
||||||
|
*
|
||||||
|
* Pure (no React/CSS) so it can be unit-tested — see `threadReceipt.test.ts`.
|
||||||
|
*/
|
||||||
|
export const markThreadAsRead = async (
|
||||||
|
mx: MatrixClient,
|
||||||
|
thread: Thread,
|
||||||
|
privateReceipt: boolean,
|
||||||
|
): Promise<void> => {
|
||||||
|
const lastReply = thread.lastReply();
|
||||||
|
if (!lastReply || lastReply.isSending() || lastReply.getId() === thread.id) return;
|
||||||
|
|
||||||
|
await mx.sendReadReceipt(lastReply, privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read);
|
||||||
|
};
|
||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
EventTimeline,
|
EventTimeline,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
ReceiptType,
|
|
||||||
Room,
|
Room,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
RoomEventHandlerMap,
|
RoomEventHandlerMap,
|
||||||
@@ -146,32 +145,6 @@ export const useThreadPendingEvents = (
|
|||||||
return pending;
|
return pending;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// markThreadAsRead moved to ./threadReceipt (pure + unit-tested); re-exported
|
||||||
* Send a threaded read receipt up to the latest confirmed event in the thread.
|
// here for existing import sites.
|
||||||
*
|
export { markThreadAsRead } from './threadReceipt';
|
||||||
* The receipt is threaded by default (scoped to this thread), which clears the
|
|
||||||
* per-thread unread count. Mirrors the latest-valid-event scan in
|
|
||||||
* `utils/notifications.ts`.
|
|
||||||
*/
|
|
||||||
export const markThreadAsRead = async (
|
|
||||||
mx: MatrixClient,
|
|
||||||
thread: Thread,
|
|
||||||
privateReceipt: boolean,
|
|
||||||
): Promise<void> => {
|
|
||||||
const events = thread.liveTimeline.getEvents();
|
|
||||||
|
|
||||||
let latestEvent: MatrixEvent | undefined;
|
|
||||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
||||||
const evt = events[i];
|
|
||||||
if (evt && !evt.isSending()) {
|
|
||||||
latestEvent = evt;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!latestEvent) return;
|
|
||||||
|
|
||||||
await mx.sendReadReceipt(
|
|
||||||
latestEvent,
|
|
||||||
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { type Capability, WidgetDriver } from 'matrix-widget-api';
|
||||||
|
import { filterWidgetCapabilities } from './widgetUtils';
|
||||||
|
|
||||||
|
// A minimal, conservative WidgetDriver for general room widgets. It only narrows
|
||||||
|
// the capabilities a widget may hold (to a benign display-only subset — see
|
||||||
|
// widgetUtils). All data-access methods (sendEvent / readRoomState / sendToDevice
|
||||||
|
// / uploads …) are inherited from the base WidgetDriver and are never reached,
|
||||||
|
// because the capabilities that would gate them are denied here. A richer,
|
||||||
|
// consent-prompt-driven driver is a follow-up.
|
||||||
|
export class GeneralWidgetDriver extends WidgetDriver {
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
|
||||||
|
return filterWidgetCapabilities(requested);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Box, Icon, Icons, Text, color } from 'folds';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { ClientWidgetApi, Widget } from 'matrix-widget-api';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
||||||
|
import { isWidgetUrlSafe } from './widgetUtils';
|
||||||
|
|
||||||
|
type RoomWidgetViewProps = {
|
||||||
|
room: Room;
|
||||||
|
widget: Widget;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hosts one room widget in a sandboxed iframe via ClientWidgetApi (so widgets
|
||||||
|
// that wait on the client handshake load), with a conservative capability driver.
|
||||||
|
// Re-mounts only when the widget id or its (template) URL changes — not on every
|
||||||
|
// unrelated room-state update — so viewing a widget doesn't reload constantly.
|
||||||
|
export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const widgetRef = useRef(widget);
|
||||||
|
widgetRef.current = widget;
|
||||||
|
const [blocked, setBlocked] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return undefined;
|
||||||
|
const current = widgetRef.current;
|
||||||
|
|
||||||
|
const completeUrl = current.getCompleteUrl({
|
||||||
|
currentUserId: mx.getSafeUserId(),
|
||||||
|
widgetRoomId: room.roomId,
|
||||||
|
deviceId: mx.getDeviceId() ?? undefined,
|
||||||
|
baseUrl: mx.baseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Security: never render a same-origin widget with allow-same-origin (a
|
||||||
|
// same-origin frame could break out of the sandbox against our own origin).
|
||||||
|
if (!isWidgetUrlSafe(completeUrl, window.location.origin)) {
|
||||||
|
setBlocked(true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
setBlocked(false);
|
||||||
|
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.title = current.name || 'Widget';
|
||||||
|
iframe.sandbox.value =
|
||||||
|
'allow-forms allow-scripts allow-same-origin allow-popups allow-downloads';
|
||||||
|
iframe.allow = 'autoplay; clipboard-write;';
|
||||||
|
iframe.src = completeUrl;
|
||||||
|
iframe.style.width = '100%';
|
||||||
|
iframe.style.height = '100%';
|
||||||
|
iframe.style.border = 'none';
|
||||||
|
container.append(iframe);
|
||||||
|
|
||||||
|
const clientApi = new ClientWidgetApi(current, iframe, new GeneralWidgetDriver());
|
||||||
|
clientApi.setViewedRoomId(room.roomId);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clientApi.stop();
|
||||||
|
iframe.remove();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [mx, room.roomId, widget.id, widget.templateUrl]);
|
||||||
|
|
||||||
|
if (blocked) {
|
||||||
|
return (
|
||||||
|
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
||||||
|
<Icon size="400" src={Icons.Warning} style={{ color: color.Warning.Main }} />
|
||||||
|
<Text size="T300" align="Center">
|
||||||
|
This widget can't be loaded because its URL is on this app's own origin.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Box ref={containerRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const WidgetsPanel = style({
|
||||||
|
width: toRem(360),
|
||||||
|
'@media': {
|
||||||
|
'(max-width: 750px)': {
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
zIndex: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WidgetsPanelHeader = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WidgetsPanelContent = style({
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import React, { FormEventHandler, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
Scroll,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
TooltipProvider,
|
||||||
|
color,
|
||||||
|
config,
|
||||||
|
} from 'folds';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import * as css from './WidgetsPanel.css';
|
||||||
|
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||||
|
import { RoomWidgetView } from './RoomWidgetView';
|
||||||
|
import { useRoomWidgets } from './useRoomWidgets';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
|
||||||
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
|
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||||
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import { generateWidgetId, validateWidgetUrl, WidgetUrlError } from './widgetUtils';
|
||||||
|
|
||||||
|
const urlErrorMessage = (err: WidgetUrlError): string => {
|
||||||
|
switch (err) {
|
||||||
|
case 'empty':
|
||||||
|
return 'Enter a widget URL.';
|
||||||
|
case 'not-https':
|
||||||
|
return 'Widget URLs must use https.';
|
||||||
|
case 'same-origin':
|
||||||
|
return 'That URL is not allowed (it is on this app’s own origin).';
|
||||||
|
default:
|
||||||
|
return 'That is not a valid URL.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type WidgetsPanelProps = {
|
||||||
|
room: Room;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const widgets = useRoomWidgets(room);
|
||||||
|
const powerLevels = usePowerLevelsContext();
|
||||||
|
const creators = useRoomCreators(room);
|
||||||
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
|
const canModify = permissions.stateEvent(StateEvent.Widget, mx.getSafeUserId());
|
||||||
|
|
||||||
|
const [viewingId, setViewingId] = useState<string | null>(null);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
const viewing = widgets.find((w) => w.id === viewingId) ?? null;
|
||||||
|
|
||||||
|
const handleAdd: FormEventHandler<HTMLFormElement> = async (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const target = evt.target as HTMLFormElement;
|
||||||
|
const nameInput = target.elements.namedItem('widgetName') as HTMLInputElement | null;
|
||||||
|
const urlInput = target.elements.namedItem('widgetUrl') as HTMLInputElement | null;
|
||||||
|
if (!urlInput) return;
|
||||||
|
const urlErr = validateWidgetUrl(urlInput.value, window.location.origin);
|
||||||
|
if (urlErr) {
|
||||||
|
setError(urlErrorMessage(urlErr));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(undefined);
|
||||||
|
setSaving(true);
|
||||||
|
const id = generateWidgetId();
|
||||||
|
const content = {
|
||||||
|
id,
|
||||||
|
type: 'm.custom',
|
||||||
|
url: urlInput.value.trim(),
|
||||||
|
name: nameInput?.value.trim() || 'Widget',
|
||||||
|
creatorUserId: mx.getSafeUserId(),
|
||||||
|
data: {},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await mx.sendStateEvent(room.roomId, StateEvent.Widget as any, content as any, id);
|
||||||
|
setAdding(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (id: string) => {
|
||||||
|
if (viewingId === id) setViewingId(null);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
mx.sendStateEvent(room.roomId, StateEvent.Widget as any, {} as any, id).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
className={classNames(css.WidgetsPanel, ContainerColor({ variant: 'Surface' }))}
|
||||||
|
direction="Column"
|
||||||
|
>
|
||||||
|
<Header className={css.WidgetsPanelHeader} variant="Background" size="600">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Text size="H5" truncate>
|
||||||
|
Widgets
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||||
|
{room.name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>Close</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="Background"
|
||||||
|
aria-label="Close widgets"
|
||||||
|
onClick={requestClose}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Box grow="Yes" className={css.WidgetsPanelContent}>
|
||||||
|
{viewing ? (
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Box shrink="No" style={{ padding: config.space.S200 }}>
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => setViewingId(null)}
|
||||||
|
before={<Icon size="100" src={Icons.ArrowLeft} />}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
{viewing.name || 'Widget'}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<RoomWidgetView room={room} widget={viewing} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Scroll hideTrack visibility="Hover">
|
||||||
|
<Box direction="Column" gap="200" style={{ padding: config.space.S300 }}>
|
||||||
|
{widgets.length === 0 && (
|
||||||
|
<Text size="T200" style={{ opacity: 0.65 }}>
|
||||||
|
No widgets in this room yet.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{widgets.map((widget) => (
|
||||||
|
<Box key={widget.id} alignItems="Center" gap="200">
|
||||||
|
<Box
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
grow="Yes"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="200"
|
||||||
|
onClick={() => setViewingId(widget.id)}
|
||||||
|
style={{ cursor: 'pointer', minWidth: 0 }}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Category} />
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{widget.name || widget.templateUrl}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{canModify && (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Background"
|
||||||
|
aria-label={`Remove ${widget.name || 'widget'}`}
|
||||||
|
onClick={() => handleRemove(widget.id)}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Delete} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{canModify &&
|
||||||
|
(adding ? (
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
direction="Column"
|
||||||
|
gap="200"
|
||||||
|
onSubmit={handleAdd}
|
||||||
|
style={{ marginTop: config.space.S200 }}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
name="widgetName"
|
||||||
|
placeholder="Name (optional)"
|
||||||
|
variant="Secondary"
|
||||||
|
radii="300"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="widgetUrl"
|
||||||
|
placeholder="https://…"
|
||||||
|
variant="Secondary"
|
||||||
|
radii="300"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Box gap="200">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="300"
|
||||||
|
variant="Primary"
|
||||||
|
fill="Solid"
|
||||||
|
radii="300"
|
||||||
|
disabled={saving}
|
||||||
|
before={
|
||||||
|
saving ? <Spinner size="100" variant="Primary" fill="Solid" /> : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="B300">Add</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
setAdding(false);
|
||||||
|
setError(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => setAdding(true)}
|
||||||
|
before={<Icon size="100" src={Icons.Plus} />}
|
||||||
|
style={{ marginTop: config.space.S200 }}
|
||||||
|
>
|
||||||
|
<Text size="B300">Add Widget</Text>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Widget, WidgetParser, IStateEvent } from 'matrix-widget-api';
|
||||||
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import { useRoomState } from '../../../hooks/useRoomState';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All valid `im.vector.modular.widgets` room widgets, reactive on room state.
|
||||||
|
* `WidgetParser` drops empty/removed (`{}`) and malformed entries.
|
||||||
|
*/
|
||||||
|
export const useRoomWidgets = (room: Room): Widget[] => {
|
||||||
|
const state = useRoomState(room);
|
||||||
|
return useMemo(() => {
|
||||||
|
const widgetEvents = state.get(StateEvent.Widget);
|
||||||
|
if (!widgetEvents) return [];
|
||||||
|
const stateEvents = Array.from(widgetEvents.values()).map(
|
||||||
|
(event) => event.getEffectiveEvent() as unknown as IStateEvent,
|
||||||
|
);
|
||||||
|
return WidgetParser.parseWidgetsFromRoomState(stateEvents);
|
||||||
|
}, [state]);
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { MatrixCapabilities, Capability } from 'matrix-widget-api';
|
||||||
|
import {
|
||||||
|
validateWidgetUrl,
|
||||||
|
isWidgetUrlSafe,
|
||||||
|
filterWidgetCapabilities,
|
||||||
|
generateWidgetId,
|
||||||
|
} from './widgetUtils';
|
||||||
|
|
||||||
|
const APP = 'https://chat.lotusguild.org';
|
||||||
|
|
||||||
|
test('validateWidgetUrl accepts a cross-origin https url', () => {
|
||||||
|
assert.equal(validateWidgetUrl('https://pad.example.org/p/room', APP), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateWidgetUrl rejects empty / invalid / http / same-origin', () => {
|
||||||
|
assert.equal(validateWidgetUrl(' ', APP), 'empty');
|
||||||
|
assert.equal(validateWidgetUrl('not a url', APP), 'invalid');
|
||||||
|
assert.equal(validateWidgetUrl('http://example.org', APP), 'not-https');
|
||||||
|
assert.equal(validateWidgetUrl('https://chat.lotusguild.org/evil', APP), 'same-origin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isWidgetUrlSafe rejects same-origin + garbage, accepts cross-origin', () => {
|
||||||
|
assert.equal(isWidgetUrlSafe('https://chat.lotusguild.org/x', APP), false);
|
||||||
|
assert.equal(isWidgetUrlSafe('https://other.example/x', APP), true);
|
||||||
|
assert.equal(isWidgetUrlSafe('garbage', APP), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterWidgetCapabilities keeps only the benign allowlist', () => {
|
||||||
|
const requested = new Set<Capability>([
|
||||||
|
MatrixCapabilities.AlwaysOnScreen,
|
||||||
|
'm.send.event:m.room.message',
|
||||||
|
'org.matrix.msc2762.receive.state_event:m.room.member',
|
||||||
|
MatrixCapabilities.Screenshots,
|
||||||
|
]);
|
||||||
|
const allowed = filterWidgetCapabilities(requested);
|
||||||
|
assert.ok(allowed.has(MatrixCapabilities.AlwaysOnScreen));
|
||||||
|
assert.ok(allowed.has(MatrixCapabilities.Screenshots));
|
||||||
|
assert.equal(allowed.has('m.send.event:m.room.message'), false);
|
||||||
|
assert.equal(allowed.size, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateWidgetId is prefixed and unique across calls', () => {
|
||||||
|
const a = generateWidgetId();
|
||||||
|
const b = generateWidgetId();
|
||||||
|
assert.match(a, /^lotus_/);
|
||||||
|
assert.notEqual(a, b);
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Capability, MatrixCapabilities } from 'matrix-widget-api';
|
||||||
|
|
||||||
|
// Conservative v1 capability policy: approve only benign display capabilities.
|
||||||
|
// Everything else (room-event send/receive, to-device, uploads, user-directory,
|
||||||
|
// delayed events, TURN servers) is denied — a random widget must not be able to
|
||||||
|
// act as the user or read room data without an explicit consent flow (follow-up).
|
||||||
|
export const ALLOWED_WIDGET_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
|
||||||
|
MatrixCapabilities.AlwaysOnScreen,
|
||||||
|
MatrixCapabilities.RequiresClient,
|
||||||
|
MatrixCapabilities.Screenshots,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const filterWidgetCapabilities = (requested: Set<Capability>): Set<Capability> =>
|
||||||
|
new Set([...requested].filter((cap) => ALLOWED_WIDGET_CAPABILITIES.has(cap)));
|
||||||
|
|
||||||
|
export type WidgetUrlError = 'empty' | 'invalid' | 'not-https' | 'same-origin';
|
||||||
|
|
||||||
|
// A widget URL to ADD must be https and NOT our own origin: a same-origin frame
|
||||||
|
// with allow-same-origin + allow-scripts can break out of the sandbox against us.
|
||||||
|
export const validateWidgetUrl = (raw: string, appOrigin: string): WidgetUrlError | undefined => {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return 'empty';
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(trimmed);
|
||||||
|
} catch {
|
||||||
|
return 'invalid';
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'https:') return 'not-https';
|
||||||
|
if (url.origin === appOrigin) return 'same-origin';
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Is an already-resolved (complete) widget URL safe to render in a sandboxed
|
||||||
|
// iframe that carries allow-same-origin? Rejects same-origin URLs (breakout).
|
||||||
|
export const isWidgetUrlSafe = (completeUrl: string, appOrigin: string): boolean => {
|
||||||
|
try {
|
||||||
|
return new URL(completeUrl).origin !== appOrigin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateWidgetId = (): string =>
|
||||||
|
`lotus_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
@@ -35,6 +35,9 @@ import { SequenceCard } from '../../../components/sequence-card';
|
|||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { UserAvatar } from '../../../components/user-avatar';
|
import { UserAvatar } from '../../../components/user-avatar';
|
||||||
@@ -319,8 +322,8 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
|
export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
|
||||||
const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
|
export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
|
||||||
|
|
||||||
const CLEAR_AFTER_OPTIONS = [
|
const CLEAR_AFTER_OPTIONS = [
|
||||||
{ label: 'Never', value: '0' },
|
{ label: 'Never', value: '0' },
|
||||||
@@ -347,6 +350,8 @@ function ProfileStatus() {
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const userId = mx.getUserId()!;
|
const userId = mx.getUserId()!;
|
||||||
const presence = useUserPresence(userId);
|
const presence = useUserPresence(userId);
|
||||||
|
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
|
||||||
|
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
|
||||||
|
|
||||||
const [statusMsg, setStatusMsg] = useState<string>(
|
const [statusMsg, setStatusMsg] = useState<string>(
|
||||||
presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '',
|
presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '',
|
||||||
@@ -357,12 +362,6 @@ function ProfileStatus() {
|
|||||||
const [clearAfter, setClearAfter] = useState('0');
|
const [clearAfter, setClearAfter] = useState('0');
|
||||||
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
||||||
|
|
||||||
// Initialise expiry from localStorage so timer survives page reload
|
|
||||||
const [expiryTs, setExpiryTs] = useState<number>(() => {
|
|
||||||
const stored = localStorage.getItem(STATUS_EXPIRY_KEY(userId));
|
|
||||||
return stored ? parseInt(stored, 10) : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync input when another device changes the status.
|
// Sync input when another device changes the status.
|
||||||
// Skipped while the user has unsaved local edits to avoid clobbering
|
// Skipped while the user has unsaved local edits to avoid clobbering
|
||||||
// mid-flight input (e.g. an emoji being inserted).
|
// mid-flight input (e.g. an emoji being inserted).
|
||||||
@@ -373,32 +372,16 @@ function ProfileStatus() {
|
|||||||
}
|
}
|
||||||
}, [presence?.status, userId]);
|
}, [presence?.status, userId]);
|
||||||
|
|
||||||
// Drive the auto-clear timer off expiryTs so re-saving cancels the old timer
|
|
||||||
useEffect(() => {
|
|
||||||
if (!expiryTs) return undefined;
|
|
||||||
const remaining = expiryTs - Date.now();
|
|
||||||
const clearStatus = () => {
|
|
||||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
|
||||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
|
||||||
setExpiryTs(0);
|
|
||||||
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
|
|
||||||
};
|
|
||||||
if (remaining <= 0) {
|
|
||||||
clearStatus();
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const timer = window.setTimeout(clearStatus, remaining);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [expiryTs, userId, mx]);
|
|
||||||
|
|
||||||
const [saveState, saveStatus] = useAsyncCallback(
|
const [saveState, saveStatus] = useAsyncCallback(
|
||||||
useCallback(
|
useCallback(
|
||||||
(msg: string) =>
|
(msg: string) =>
|
||||||
mx.setPresence({
|
mx.setPresence({
|
||||||
presence: 'online',
|
// Derive presence from the user's chosen setting so writing a status
|
||||||
|
// never overrides Invisible/DND/Idle (e.g. outing an Invisible user).
|
||||||
|
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||||
status_msg: msg,
|
status_msg: msg,
|
||||||
}),
|
}),
|
||||||
[mx],
|
[mx, presenceStatus, hidePresence],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const saving = saveState.status === AsyncStatus.Loading;
|
const saving = saveState.status === AsyncStatus.Loading;
|
||||||
@@ -429,12 +412,12 @@ function ProfileStatus() {
|
|||||||
|
|
||||||
const delayMs = getMsFromOption(clearAfter);
|
const delayMs = getMsFromOption(clearAfter);
|
||||||
if (msg && delayMs > 0) {
|
if (msg && delayMs > 0) {
|
||||||
|
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
|
||||||
|
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
|
||||||
const ts = Date.now() + delayMs;
|
const ts = Date.now() + delayMs;
|
||||||
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(ts));
|
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(ts));
|
||||||
setExpiryTs(ts);
|
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||||
setExpiryTs(0);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -443,8 +426,11 @@ function ProfileStatus() {
|
|||||||
setStatusMsg('');
|
setStatusMsg('');
|
||||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||||
setExpiryTs(0);
|
// Preserve the user's chosen presence when clearing the status message.
|
||||||
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
|
mx.setPresence({
|
||||||
|
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||||
|
status_msg: '',
|
||||||
|
}).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasChanges = statusMsg !== (presence?.status ?? '');
|
const hasChanges = statusMsg !== (presence?.status ?? '');
|
||||||
@@ -751,10 +737,22 @@ function ProfileTimezone() {
|
|||||||
const [saveState, saveTimezone] = useAsyncCallback(
|
const [saveState, saveTimezone] = useAsyncCallback(
|
||||||
useCallback(
|
useCallback(
|
||||||
(value: string) =>
|
(value: string) =>
|
||||||
(mx as any).setAccountData('im.lotus.timezone', { timezone: value }).then(() => {
|
Promise.all([
|
||||||
|
// Self-fallback: account data is readable by useExtendedProfile for the
|
||||||
|
// own user even on servers without extended-profile (m.tz) support.
|
||||||
|
(mx as any).setAccountData('im.lotus.timezone', { timezone: value }),
|
||||||
|
// Mirror the pronouns write path so OTHER users can read the timezone
|
||||||
|
// via the m.tz profile field. Best-effort: standard Synapse rejects
|
||||||
|
// unknown profile fields, so a failure here must not fail the save.
|
||||||
|
mx.http
|
||||||
|
.authedRequest(Method.Put, `/profile/${encodeURIComponent(userId)}/m.tz`, undefined, {
|
||||||
|
'm.tz': value,
|
||||||
|
})
|
||||||
|
.catch(() => undefined),
|
||||||
|
]).then(() => {
|
||||||
setSavedTimezone(value);
|
setSavedTimezone(value);
|
||||||
}),
|
}),
|
||||||
[mx],
|
[mx, userId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const saving = saveState.status === AsyncStatus.Loading;
|
const saving = saveState.status === AsyncStatus.Loading;
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function DecorationPreviewCell({
|
|||||||
<img
|
<img
|
||||||
src={`${DECORATION_CDN}/${slug}.png`}
|
src={`${DECORATION_CDN}/${slug}.png`}
|
||||||
alt={name}
|
alt={name}
|
||||||
loading="eager"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
||||||
import { Box, Button, color, Icon, Icons, Spinner, Text, toRem } from 'folds';
|
import { Box, Button, color, Icon, Icons, Spinner, Text, toRem } from 'folds';
|
||||||
import FileSaver from 'file-saver';
|
import { useSaveFile } from '../../../hooks/useSaveFile';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
@@ -15,6 +15,7 @@ import { useFilePicker } from '../../../hooks/useFilePicker';
|
|||||||
function ExportKeys() {
|
function ExportKeys() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
|
const saveFile = useSaveFile();
|
||||||
|
|
||||||
const [exportState, exportKeys] = useAsyncCallback<void, Error, [string]>(
|
const [exportState, exportKeys] = useAsyncCallback<void, Error, [string]>(
|
||||||
useCallback(
|
useCallback(
|
||||||
@@ -28,9 +29,9 @@ function ExportKeys() {
|
|||||||
const blob = new Blob([encKeys], {
|
const blob = new Blob([encKeys], {
|
||||||
type: 'text/plain;charset=us-ascii',
|
type: 'text/plain;charset=us-ascii',
|
||||||
});
|
});
|
||||||
FileSaver.saveAs(blob, 'lotus-keys.txt');
|
saveFile(blob, 'lotus-keys.txt');
|
||||||
},
|
},
|
||||||
[mx],
|
[mx, saveFile],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
|||||||
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
|
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
|
||||||
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
|
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
|
||||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||||
|
import { useReducedMotion } from '../../../hooks/useReducedMotion';
|
||||||
import { playCallJoinSound } from '../../../utils/callSounds';
|
import { playCallJoinSound } from '../../../utils/callSounds';
|
||||||
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
||||||
import { DenoiseTester } from './DenoiseTester';
|
import { DenoiseTester } from './DenoiseTester';
|
||||||
@@ -118,12 +119,21 @@ import { SettingsSelect } from '../../../components/settings-select/SettingsSele
|
|||||||
function DesktopChromeSetting() {
|
function DesktopChromeSetting() {
|
||||||
const [customChrome, setCustomChrome] = useAtom(customWindowChromeAtom);
|
const [customChrome, setCustomChrome] = useAtom(customWindowChromeAtom);
|
||||||
if (!isTauriEnv()) return null;
|
if (!isTauriEnv()) return null;
|
||||||
|
// Persist the flag, then reload so the window layout is rebuilt from scratch.
|
||||||
|
// Toggling live reflows the whole app while the room timeline is mounted, which
|
||||||
|
// resizes its virtualized scroll container and triggers runaway back-pagination
|
||||||
|
// (the "screen expands + auto-scrolls into the past" bug). A reload applies the
|
||||||
|
// chrome cleanly against a fresh, correct layout.
|
||||||
|
const handleToggle = (value: boolean) => {
|
||||||
|
setCustomChrome(value);
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Custom Window Chrome (Beta)"
|
title="Custom Window Chrome (Beta)"
|
||||||
description="Replace the system title bar with a Lotus-styled one. Desktop only — toggles instantly."
|
description="Replace the system title bar with a Lotus-styled one. Desktop only — reloads to apply."
|
||||||
after={<Switch variant="Primary" value={customChrome} onChange={setCustomChrome} />}
|
after={<Switch variant="Primary" value={customChrome} onChange={handleToggle} />}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
);
|
);
|
||||||
@@ -2045,6 +2055,7 @@ function ChatBgGrid() {
|
|||||||
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.kind === ThemeKind.Dark;
|
const isDark = theme.kind === ThemeKind.Dark;
|
||||||
|
const reduced = useReducedMotion();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -2070,7 +2081,7 @@ function ChatBgGrid() {
|
|||||||
style={{
|
style={{
|
||||||
width: toRem(76),
|
width: toRem(76),
|
||||||
height: toRem(50),
|
height: toRem(50),
|
||||||
...getChatBg(opt.value as ChatBackground, isDark, pauseAnimations),
|
...getChatBg(opt.value as ChatBackground, isDark, pauseAnimations || reduced),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
@@ -2240,6 +2251,10 @@ function Messages() {
|
|||||||
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
|
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||||
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||||
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||||
|
const [enforceRetentionLocally, setEnforceRetentionLocally] = useSetting(
|
||||||
|
settingsAtom,
|
||||||
|
'enforceRetentionLocally',
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
@@ -2337,6 +2352,19 @@ function Messages() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Enforce Message Retention"
|
||||||
|
description="Permanently delete your own messages once a room's retention window (Room Settings → Message Retention) has passed. Off by default; only affects your own messages."
|
||||||
|
after={
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={enforceRetentionLocally}
|
||||||
|
onChange={setEnforceRetentionLocally}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { Box, Button, Text, IconButton, Icon, Icons, Scroll, config, toRem } from 'folds';
|
import { Box, Button, Text, IconButton, Icon, Icons, IconSrc, Scroll, config, toRem } from 'folds';
|
||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { SystemNotification } from './SystemNotification';
|
import { SystemNotification } from './SystemNotification';
|
||||||
import { AllMessagesNotifications } from './AllMessages';
|
import { AllMessagesNotifications } from './AllMessages';
|
||||||
@@ -14,13 +14,13 @@ import { settingsAtom, Settings } from '../../../state/settings';
|
|||||||
|
|
||||||
const PRESETS: Array<{
|
const PRESETS: Array<{
|
||||||
label: string;
|
label: string;
|
||||||
emoji: string;
|
icon: IconSrc;
|
||||||
description: string;
|
description: string;
|
||||||
patch: Partial<Settings>;
|
patch: Partial<Settings>;
|
||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
label: 'Gaming',
|
label: 'Gaming',
|
||||||
emoji: '🎮',
|
icon: Icons.Ball,
|
||||||
description: 'Notifications on, sounds off',
|
description: 'Notifications on, sounds off',
|
||||||
patch: {
|
patch: {
|
||||||
showNotifications: true,
|
showNotifications: true,
|
||||||
@@ -32,7 +32,7 @@ const PRESETS: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Work',
|
label: 'Work',
|
||||||
emoji: '💼',
|
icon: Icons.Monitor,
|
||||||
description: 'All notifications and sounds on',
|
description: 'All notifications and sounds on',
|
||||||
patch: {
|
patch: {
|
||||||
showNotifications: true,
|
showNotifications: true,
|
||||||
@@ -44,7 +44,7 @@ const PRESETS: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Sleep',
|
label: 'Sleep',
|
||||||
emoji: '🌙',
|
icon: Icons.BellMute,
|
||||||
description: 'All notifications off',
|
description: 'All notifications off',
|
||||||
patch: {
|
patch: {
|
||||||
showNotifications: false,
|
showNotifications: false,
|
||||||
@@ -83,7 +83,7 @@ function NotificationPresets() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box direction="Column" alignItems="Center" gap="100">
|
<Box direction="Column" alignItems="Center" gap="100">
|
||||||
<span style={{ fontSize: toRem(24) }}>{preset.emoji}</span>
|
<Icon size="400" src={preset.icon} />
|
||||||
<Text size="T300" style={{ fontWeight: config.fontWeight.W600 }}>
|
<Text size="T300" style={{ fontWeight: config.fontWeight.W600 }}>
|
||||||
{preset.label}
|
{preset.label}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -171,7 +171,11 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') handleCardClick();
|
if (e.key === 'Enter' || e.key === ' ') handleCardClick();
|
||||||
}}
|
}}
|
||||||
aria-label={`Notification from ${toast.displayName} in ${toast.roomName}`}
|
aria-label={
|
||||||
|
toast.roomName
|
||||||
|
? `Notification from ${toast.displayName} in ${toast.roomName}`
|
||||||
|
: `${toast.displayName}: ${toast.body}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span style={{ position: 'absolute', top: config.space.S100, right: config.space.S100 }}>
|
<span style={{ position: 'absolute', top: config.space.S100, right: config.space.S100 }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -187,7 +191,11 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
<div style={rowStyle}>
|
<div style={rowStyle}>
|
||||||
{toast.avatarUrl ? (
|
{toast.iconSrc ? (
|
||||||
|
<div style={initialsStyle} aria-hidden="true">
|
||||||
|
<Icon size="100" src={toast.iconSrc} />
|
||||||
|
</div>
|
||||||
|
) : toast.avatarUrl ? (
|
||||||
<img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" />
|
<img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" />
|
||||||
) : (
|
) : (
|
||||||
<div style={initialsStyle} aria-hidden="true">
|
<div style={initialsStyle} aria-hidden="true">
|
||||||
@@ -197,7 +205,7 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
<span style={nameStyle}>{toast.displayName}</span>
|
<span style={nameStyle}>{toast.displayName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={bodyStyle}>{toast.body}</div>
|
<div style={bodyStyle}>{toast.body}</div>
|
||||||
<div style={roomNameStyle}>{toast.roomName}</div>
|
{toast.roomName && <div style={roomNameStyle}>{toast.roomName}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,84 +4,86 @@ import { CallEmbed, useCallControlState } from '../plugins/call';
|
|||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { toastQueueAtom } from '../state/toast';
|
import { toastQueueAtom } from '../state/toast';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
|
||||||
const SILENCE_RMS_THRESHOLD = 0.008;
|
|
||||||
const CHECK_INTERVAL_MS = 500;
|
const CHECK_INTERVAL_MS = 500;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Monitors microphone audio while in a call. If the mic stays unmuted but
|
* Monitors microphone activity while in a call. If the mic stays unmuted but
|
||||||
* silent for longer than the configured timeout, the mic is muted and a toast
|
* the user is not speaking for longer than the configured timeout, the mic is
|
||||||
* is shown.
|
* muted and a toast is shown.
|
||||||
*
|
*
|
||||||
* The level-monitoring capture (`getUserMedia`) is opened ONLY while the mic is
|
* [C-H2] Activity is read from the EC fork's `io.lotus.call_state` stream
|
||||||
* unmuted — there is nothing to auto-mute once you are already muted, so
|
* (getLotusParticipants) — i.e. the VAD state of the user's ACTUAL published
|
||||||
* holding the capture would keep the OS recording indicator lit even though the
|
* track on their SELECTED input device. The previous implementation opened its
|
||||||
* UI shows you as muted (N95). Muting therefore releases our stream; unmuting
|
* own `getUserMedia({ audio: true })`, which captured the browser DEFAULT mic
|
||||||
* re-acquires it. The AudioContext + stream are also torn down on unmount.
|
* (not necessarily the device EC publishes from): it could measure silence
|
||||||
|
* while the user spoke on a different device (auto-muting an active speaker) and
|
||||||
|
* lit a second OS microphone indicator. Sourcing from the fork removes both
|
||||||
|
* problems and needs no extra capture.
|
||||||
|
*
|
||||||
|
* If the fork hasn't reported call-state yet (getLotusParticipants() === null —
|
||||||
|
* e.g. plain EC, or immediately after join), we cannot tell whether the user is
|
||||||
|
* publishing, so we fail SAFE and never auto-mute during that window.
|
||||||
*/
|
*/
|
||||||
export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
|
export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
|
||||||
|
const mx = useMatrixClient();
|
||||||
const [enabled] = useSetting(settingsAtom, 'afkAutoMute');
|
const [enabled] = useSetting(settingsAtom, 'afkAutoMute');
|
||||||
const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
||||||
const setToast = useSetAtom(toastQueueAtom);
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
const { microphone } = useCallControlState(callEmbed?.control);
|
const { microphone } = useCallControlState(callEmbed?.control);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only capture while in a call, enabled, AND unmuted (see N95 note above).
|
// Only monitor while in a call, enabled, AND unmuted — there is nothing to
|
||||||
|
// auto-mute once you are already muted.
|
||||||
if (!callEmbed || !enabled || !microphone) return undefined;
|
if (!callEmbed || !enabled || !microphone) return undefined;
|
||||||
|
|
||||||
let stream: MediaStream | undefined;
|
const localUserId = mx.getSafeUserId();
|
||||||
let audioCtx: AudioContext | undefined;
|
const timeoutMs = timeoutMinutes * 60 * 1000;
|
||||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
|
||||||
let silenceStart: number | null = null;
|
let silenceStart: number | null = null;
|
||||||
let active = true;
|
let active = true;
|
||||||
const timeoutMs = timeoutMinutes * 60 * 1000;
|
|
||||||
|
|
||||||
navigator.mediaDevices
|
// undefined = fork hasn't reported call-state yet (can't tell — fail safe).
|
||||||
.getUserMedia({ audio: true, video: false })
|
const isLocalSpeaking = (): boolean | undefined => {
|
||||||
.then((s) => {
|
const participants = callEmbed.getLotusParticipants();
|
||||||
if (!active) {
|
// null = fork not reported; [] = malformed/spurious payload (CallEmbed
|
||||||
s.getTracks().forEach((t) => t.stop());
|
// stores [] for a non-array). You are ALWAYS present in your own joined
|
||||||
return;
|
// call, so an empty list means "no usable data", NOT "silent" — matching
|
||||||
}
|
// useCallSpeakers / useRemoteAllMuted. Treating [] as silent would let the
|
||||||
stream = s;
|
// timer mute an active speaker. Fail safe on both.
|
||||||
audioCtx = new AudioContext();
|
if (participants === null || participants.length === 0) return undefined;
|
||||||
const source = audioCtx.createMediaStreamSource(stream);
|
return participants.some((p) => p.userId === localUserId && p.audioEnabled && p.speaking);
|
||||||
const analyser = audioCtx.createAnalyser();
|
};
|
||||||
analyser.fftSize = 256;
|
|
||||||
source.connect(analyser);
|
|
||||||
const buffer = new Float32Array(analyser.fftSize);
|
|
||||||
|
|
||||||
intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
analyser.getFloatTimeDomainData(buffer);
|
const speaking = isLocalSpeaking();
|
||||||
const rms = Math.sqrt(buffer.reduce((sum, v) => sum + v * v, 0) / buffer.length);
|
|
||||||
|
|
||||||
if (rms > SILENCE_RMS_THRESHOLD) {
|
if (speaking === undefined) {
|
||||||
// Audio detected — reset the silence timer.
|
// No usable signal — don't risk muting an active speaker.
|
||||||
silenceStart = null;
|
silenceStart = null;
|
||||||
} else if (silenceStart === null) {
|
} else if (speaking) {
|
||||||
// Mic is unmuted (effect only runs while unmuted) but silent — start the timer.
|
// Voice detected on the published track — reset the silence timer.
|
||||||
silenceStart = Date.now();
|
silenceStart = null;
|
||||||
} else if (Date.now() - silenceStart >= timeoutMs) {
|
} else if (silenceStart === null) {
|
||||||
callEmbed.control.setMicrophone(false);
|
// Mic is unmuted (effect only runs while unmuted) but silent — start the timer.
|
||||||
setToast({
|
silenceStart = Date.now();
|
||||||
id: `afk-mute-${Date.now()}`,
|
} else if (Date.now() - silenceStart >= timeoutMs) {
|
||||||
displayName: 'Lotus Chat',
|
callEmbed.control.setMicrophone(false);
|
||||||
body: 'Your microphone was muted after inactivity.',
|
setToast({
|
||||||
roomName: 'Voice call',
|
id: `afk-mute-${Date.now()}`,
|
||||||
roomId: callEmbed.roomId,
|
displayName: 'Lotus Chat',
|
||||||
});
|
body: 'Your microphone was muted after inactivity.',
|
||||||
silenceStart = null;
|
roomName: 'Voice call',
|
||||||
}
|
roomId: callEmbed.roomId,
|
||||||
}, CHECK_INTERVAL_MS);
|
});
|
||||||
})
|
silenceStart = null;
|
||||||
.catch(() => undefined);
|
}
|
||||||
|
}, CHECK_INTERVAL_MS);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
if (intervalId !== undefined) clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
stream?.getTracks().forEach((t) => t.stop());
|
|
||||||
audioCtx?.close().catch(() => undefined);
|
|
||||||
};
|
};
|
||||||
}, [callEmbed, enabled, timeoutMinutes, setToast, microphone]);
|
}, [callEmbed, enabled, timeoutMinutes, setToast, microphone, mx]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
|||||||
const cache = new Map<string, string | null>();
|
const cache = new Map<string, string | null>();
|
||||||
// Callbacks waiting for a userId's result
|
// Callbacks waiting for a userId's result
|
||||||
const pending = new Map<string, Array<(val: string | null) => void>>();
|
const pending = new Map<string, Array<(val: string | null) => void>>();
|
||||||
|
// Transient-failure attempt counts (userId → n) so a flaky federated lookup
|
||||||
|
// can retry a couple of times, then gives up for the session.
|
||||||
|
const failures = new Map<string, number>();
|
||||||
|
|
||||||
function fetchDecoration(
|
function fetchDecoration(
|
||||||
authedRequest: (method: Method, path: string) => Promise<Record<string, string>>,
|
authedRequest: (method: Method, path: string) => Promise<Record<string, string>>,
|
||||||
@@ -33,16 +36,23 @@ function fetchDecoration(
|
|||||||
return val;
|
return val;
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
// A 404 (M_NOT_FOUND) means the field is genuinely unset → cache "no
|
|
||||||
// decoration". A transient failure (429 rate-limit, 5xx, network) must
|
|
||||||
// NOT be cached: doing so permanently hides the user's decoration for the
|
|
||||||
// whole session. This matters most for the member list and timeline, which
|
|
||||||
// mount many avatars at once and can trip homeserver rate limits — a
|
|
||||||
// single 429 in that burst would otherwise wipe the decoration until a
|
|
||||||
// full reload. Leaving the cache unset lets the next mount retry.
|
|
||||||
const status = err instanceof MatrixError ? err.httpStatus : undefined;
|
const status = err instanceof MatrixError ? err.httpStatus : undefined;
|
||||||
if (status === 404) {
|
// Definitive rejections — the field is unset (404) or the server won't
|
||||||
|
// serve it (400/403). This is the common case for FEDERATED users whose
|
||||||
|
// homeserver doesn't support extended profiles / rejects the field. Cache
|
||||||
|
// "no decoration" so we never refetch: otherwise every avatar mount
|
||||||
|
// re-requests and floods our homeserver with failing federated profile
|
||||||
|
// lookups (the 403/502 console storm + real HS load).
|
||||||
|
if (status === 404 || status === 403 || status === 400) {
|
||||||
cache.set(userId, null);
|
cache.set(userId, null);
|
||||||
|
} else {
|
||||||
|
// Transient (429 rate-limit / 5xx / network). Allow a couple of retries
|
||||||
|
// — a single 429 in a member-list burst shouldn't permanently hide a
|
||||||
|
// decoration — then give up for the session so a persistently-failing
|
||||||
|
// federated link (e.g. a 502'ing remote server) can't loop forever.
|
||||||
|
const attempts = (failures.get(userId) ?? 0) + 1;
|
||||||
|
failures.set(userId, attempts);
|
||||||
|
if (attempts >= 2) cache.set(userId, null);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { MatrixClient } from 'matrix-js-sdk';
|
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { useAccountDataCallback } from './useAccountDataCallback';
|
|
||||||
|
|
||||||
export type Bookmark = {
|
export type Bookmark = {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -25,6 +24,75 @@ function readBookmarks(mx: MatrixClient): Bookmark[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Module-scoped serialization state.
|
||||||
|
//
|
||||||
|
// useBookmarks() is mounted once per message row (dozens of live instances), so
|
||||||
|
// a per-instance latest/queue would only serialize writes within a single row —
|
||||||
|
// bookmarking message A then message B from different rows (before the server
|
||||||
|
// echo lands) would let B compute from a stale snapshot and clobber A
|
||||||
|
// (setAccountData replaces the whole content, no server merge). We therefore
|
||||||
|
// keep a single shared latest ref + write queue, keyed off the active client.
|
||||||
|
type BookmarksModuleState = {
|
||||||
|
mx: MatrixClient;
|
||||||
|
latest: Bookmark[];
|
||||||
|
writeQueue: Promise<unknown>;
|
||||||
|
listeners: Set<(list: Bookmark[]) => void>;
|
||||||
|
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
|
||||||
|
};
|
||||||
|
|
||||||
|
let moduleState: BookmarksModuleState | null = null;
|
||||||
|
|
||||||
|
// Lazily initialize the shared state for the given client. On a client change
|
||||||
|
// (login/logout swaps the MatrixClient) we tear down the old subscription and
|
||||||
|
// re-initialize against the new client so we never leak or double-subscribe.
|
||||||
|
function ensureModuleState(mx: MatrixClient): BookmarksModuleState {
|
||||||
|
if (moduleState && moduleState.mx === mx) {
|
||||||
|
return moduleState;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moduleState) {
|
||||||
|
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: BookmarksModuleState = {
|
||||||
|
mx,
|
||||||
|
latest: readBookmarks(mx),
|
||||||
|
writeQueue: Promise.resolve(),
|
||||||
|
listeners: new Set(),
|
||||||
|
// Reassigned below once `state` is captured.
|
||||||
|
onAccountData: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.onAccountData = (evt) => {
|
||||||
|
if (evt.getType() === BOOKMARKS_KEY) {
|
||||||
|
const list = evt.getContent<BookmarksContent>()?.bookmarks ?? [];
|
||||||
|
state.latest = list;
|
||||||
|
state.listeners.forEach((listener) => listener(list));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.on(ClientEvent.AccountData, state.onAccountData);
|
||||||
|
moduleState = state;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueBookmarkWrite(
|
||||||
|
mx: MatrixClient,
|
||||||
|
compute: (current: Bookmark[]) => Bookmark[],
|
||||||
|
): Promise<void> {
|
||||||
|
const state = ensureModuleState(mx);
|
||||||
|
const run = state.writeQueue.then(async () => {
|
||||||
|
const next = compute(state.latest);
|
||||||
|
state.latest = next;
|
||||||
|
state.listeners.forEach((listener) => listener(next));
|
||||||
|
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
|
||||||
|
});
|
||||||
|
// Keep the chain alive even if one write rejects, but propagate the
|
||||||
|
// rejection to this caller so it can react (e.g. retry).
|
||||||
|
state.writeQueue = run.catch(() => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
export function useBookmarks(): {
|
export function useBookmarks(): {
|
||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
addBookmark: (b: Bookmark) => Promise<void>;
|
addBookmark: (b: Bookmark) => Promise<void>;
|
||||||
@@ -32,45 +100,37 @@ export function useBookmarks(): {
|
|||||||
isBookmarked: (eventId: string) => boolean;
|
isBookmarked: (eventId: string) => boolean;
|
||||||
} {
|
} {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => readBookmarks(mx));
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => ensureModuleState(mx).latest);
|
||||||
|
|
||||||
useAccountDataCallback(
|
// Subscribe to the shared module state. A single AccountData listener is
|
||||||
mx,
|
// installed per client (in ensureModuleState); each hook instance only
|
||||||
useCallback(
|
// registers a local setter and unregisters it on unmount / client change.
|
||||||
(evt) => {
|
|
||||||
if (evt.getType() === BOOKMARKS_KEY) {
|
|
||||||
setBookmarks(evt.getContent<BookmarksContent>()?.bookmarks ?? []);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setBookmarks],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Re-read on mx change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBookmarks(readBookmarks(mx));
|
const state = ensureModuleState(mx);
|
||||||
|
setBookmarks(state.latest);
|
||||||
|
state.listeners.add(setBookmarks);
|
||||||
|
return () => {
|
||||||
|
state.listeners.delete(setBookmarks);
|
||||||
|
};
|
||||||
}, [mx]);
|
}, [mx]);
|
||||||
|
|
||||||
const addBookmark = useCallback(
|
const addBookmark = useCallback(
|
||||||
async (b: Bookmark) => {
|
(b: Bookmark) =>
|
||||||
const current = readBookmarks(mx);
|
enqueueBookmarkWrite(mx, (current) => {
|
||||||
// Avoid duplicates
|
// Avoid duplicates
|
||||||
const filtered = current.filter((bk) => bk.eventId !== b.eventId);
|
const filtered = current.filter((bk) => bk.eventId !== b.eventId);
|
||||||
let next = [b, ...filtered];
|
let next = [b, ...filtered];
|
||||||
if (next.length > MAX_BOOKMARKS) {
|
if (next.length > MAX_BOOKMARKS) {
|
||||||
next = next.slice(0, MAX_BOOKMARKS);
|
next = next.slice(0, MAX_BOOKMARKS);
|
||||||
}
|
}
|
||||||
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
|
return next;
|
||||||
},
|
}),
|
||||||
[mx],
|
[mx],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeBookmark = useCallback(
|
const removeBookmark = useCallback(
|
||||||
async (eventId: string) => {
|
(eventId: string) =>
|
||||||
const current = readBookmarks(mx);
|
enqueueBookmarkWrite(mx, (current) => current.filter((bk) => bk.eventId !== eventId)),
|
||||||
const next = current.filter((bk) => bk.eventId !== eventId);
|
|
||||||
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
|
|
||||||
},
|
|
||||||
[mx],
|
[mx],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,25 @@ import { settingsAtom } from '../state/settings';
|
|||||||
const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
const ACTIVITY_THROTTLE_MS = 1000;
|
const ACTIVITY_THROTTLE_MS = 1000;
|
||||||
|
|
||||||
|
export type PresenceSetting = 'auto' | 'online' | 'idle' | 'dnd' | 'invisible';
|
||||||
|
export type PresenceState = 'online' | 'unavailable' | 'offline';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for mapping the user's presence preference to the
|
||||||
|
* Matrix presence value: auto/online → 'online', idle/dnd → 'unavailable',
|
||||||
|
* invisible (or the hidePresence override) → 'offline'. Shared with the Profile
|
||||||
|
* status writer so setting/clearing a status message never overrides the user's
|
||||||
|
* chosen presence (e.g. outing an Invisible user as online).
|
||||||
|
*/
|
||||||
|
export function presenceStateFromSetting(
|
||||||
|
presenceStatus: PresenceSetting,
|
||||||
|
hidePresence: boolean,
|
||||||
|
): PresenceState {
|
||||||
|
if (hidePresence || presenceStatus === 'invisible') return 'offline';
|
||||||
|
if (presenceStatus === 'idle' || presenceStatus === 'dnd') return 'unavailable';
|
||||||
|
return 'online';
|
||||||
|
}
|
||||||
|
|
||||||
export function usePresenceUpdater() {
|
export function usePresenceUpdater() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
|
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||||
|
|
||||||
|
const readReducedMotion = (): boolean =>
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
typeof window.matchMedia === 'function' &&
|
||||||
|
window.matchMedia(REDUCED_MOTION_QUERY).matches;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactively tracks the OS `prefers-reduced-motion: reduce` setting.
|
||||||
|
*
|
||||||
|
* Unlike a one-off `window.matchMedia(...).matches` read, this subscribes to the
|
||||||
|
* media query's `change` event, so toggling the OS setting mid-session updates
|
||||||
|
* the returned value (and any animation gated on it) without a page reload.
|
||||||
|
* SSR/undefined-safe: returns `false` when `window`/`matchMedia` is unavailable.
|
||||||
|
*/
|
||||||
|
export function useReducedMotion(): boolean {
|
||||||
|
const [reduced, setReduced] = useState<boolean>(readReducedMotion);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const mql = window.matchMedia(REDUCED_MOTION_QUERY);
|
||||||
|
const onChange = (event: MediaQueryListEvent) => setReduced(event.matches);
|
||||||
|
// Re-sync in case the setting changed between the initial render and this effect.
|
||||||
|
setReduced(mql.matches);
|
||||||
|
mql.addEventListener('change', onChange);
|
||||||
|
return () => mql.removeEventListener('change', onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return reduced;
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { MatrixClient } from 'matrix-js-sdk';
|
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { useAccountDataCallback } from './useAccountDataCallback';
|
|
||||||
|
|
||||||
export type Reminder = {
|
export type Reminder = {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -23,6 +22,75 @@ function readReminders(mx: MatrixClient): Reminder[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Module-scoped serialization state.
|
||||||
|
//
|
||||||
|
// The latest snapshot and the write queue must be shared across every hook
|
||||||
|
// instance: ReminderMonitor (auto-removes fired reminders) and RemindMeDialog
|
||||||
|
// (adds reminders) mount separate hooks, and a per-instance queue would let a
|
||||||
|
// remove and an add race across instances and clobber each other (setAccountData
|
||||||
|
// replaces the whole content, no server merge). We therefore keep a single
|
||||||
|
// shared queue + latest ref, keyed off the active MatrixClient.
|
||||||
|
type ReminderModuleState = {
|
||||||
|
mx: MatrixClient;
|
||||||
|
latest: Reminder[];
|
||||||
|
writeQueue: Promise<unknown>;
|
||||||
|
listeners: Set<(list: Reminder[]) => void>;
|
||||||
|
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
|
||||||
|
};
|
||||||
|
|
||||||
|
let moduleState: ReminderModuleState | null = null;
|
||||||
|
|
||||||
|
// Lazily initialize the shared state for the given client. On a client change
|
||||||
|
// (login/logout swaps the MatrixClient) we tear down the old subscription and
|
||||||
|
// re-initialize against the new client so we never leak or double-subscribe.
|
||||||
|
function ensureModuleState(mx: MatrixClient): ReminderModuleState {
|
||||||
|
if (moduleState && moduleState.mx === mx) {
|
||||||
|
return moduleState;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moduleState) {
|
||||||
|
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: ReminderModuleState = {
|
||||||
|
mx,
|
||||||
|
latest: readReminders(mx),
|
||||||
|
writeQueue: Promise.resolve(),
|
||||||
|
listeners: new Set(),
|
||||||
|
// Reassigned below once `state` is captured.
|
||||||
|
onAccountData: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.onAccountData = (evt) => {
|
||||||
|
if (evt.getType() === REMINDERS_KEY) {
|
||||||
|
const list = evt.getContent<RemindersContent>()?.reminders ?? [];
|
||||||
|
state.latest = list;
|
||||||
|
state.listeners.forEach((listener) => listener(list));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.on(ClientEvent.AccountData, state.onAccountData);
|
||||||
|
moduleState = state;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueReminderWrite(
|
||||||
|
mx: MatrixClient,
|
||||||
|
compute: (current: Reminder[]) => Reminder[],
|
||||||
|
): Promise<void> {
|
||||||
|
const state = ensureModuleState(mx);
|
||||||
|
const run = state.writeQueue.then(async () => {
|
||||||
|
const next = compute(state.latest);
|
||||||
|
state.latest = next;
|
||||||
|
state.listeners.forEach((listener) => listener(next));
|
||||||
|
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
|
||||||
|
});
|
||||||
|
// Keep the chain alive even if one write rejects, but propagate the
|
||||||
|
// rejection to this caller so it can react (e.g. retry).
|
||||||
|
state.writeQueue = run.catch(() => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
export function useReminders(): {
|
export function useReminders(): {
|
||||||
reminders: Reminder[];
|
reminders: Reminder[];
|
||||||
addReminder: (r: Reminder) => Promise<void>;
|
addReminder: (r: Reminder) => Promise<void>;
|
||||||
@@ -30,69 +98,34 @@ export function useReminders(): {
|
|||||||
getReminders: () => Reminder[];
|
getReminders: () => Reminder[];
|
||||||
} {
|
} {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [reminders, setReminders] = useState<Reminder[]>(() => readReminders(mx));
|
const [reminders, setReminders] = useState<Reminder[]>(() => ensureModuleState(mx).latest);
|
||||||
|
|
||||||
// Authoritative local snapshot used to compute mutations. Reading
|
// Subscribe to the shared module state. A single AccountData listener is
|
||||||
// mx.getAccountData() per-mutation is racy: two quick add/remove calls both
|
// installed per client (in ensureModuleState); each hook instance only
|
||||||
// read the same stale baseline and the second write clobbers the first
|
// registers a local setter and unregisters it on unmount / client change.
|
||||||
// (N113). We instead mutate from this ref, kept in sync with server echoes.
|
|
||||||
const latestRef = useRef<Reminder[]>(reminders);
|
|
||||||
// Serialize writes so overlapping setAccountData calls can't land out of
|
|
||||||
// order on the server (last-write-wins would otherwise drop data).
|
|
||||||
const writeQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
|
||||||
|
|
||||||
const applyServerState = useCallback((list: Reminder[]) => {
|
|
||||||
latestRef.current = list;
|
|
||||||
setReminders(list);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useAccountDataCallback(
|
|
||||||
mx,
|
|
||||||
useCallback(
|
|
||||||
(evt) => {
|
|
||||||
if (evt.getType() === REMINDERS_KEY) {
|
|
||||||
applyServerState(evt.getContent<RemindersContent>()?.reminders ?? []);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[applyServerState],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Re-read on mx change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyServerState(readReminders(mx));
|
const state = ensureModuleState(mx);
|
||||||
}, [mx, applyServerState]);
|
setReminders(state.latest);
|
||||||
|
state.listeners.add(setReminders);
|
||||||
const enqueueWrite = useCallback(
|
return () => {
|
||||||
(compute: (current: Reminder[]) => Reminder[]): Promise<void> => {
|
state.listeners.delete(setReminders);
|
||||||
const run = writeQueueRef.current.then(async () => {
|
};
|
||||||
const next = compute(latestRef.current);
|
}, [mx]);
|
||||||
latestRef.current = next;
|
|
||||||
setReminders(next);
|
|
||||||
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
|
|
||||||
});
|
|
||||||
// Keep the chain alive even if one write rejects, but propagate the
|
|
||||||
// rejection to this caller so it can react (e.g. retry).
|
|
||||||
writeQueueRef.current = run.catch(() => undefined);
|
|
||||||
return run;
|
|
||||||
},
|
|
||||||
[mx],
|
|
||||||
);
|
|
||||||
|
|
||||||
const addReminder = useCallback(
|
const addReminder = useCallback(
|
||||||
(r: Reminder) => enqueueWrite((current) => [...current, r]),
|
(r: Reminder) => enqueueReminderWrite(mx, (current) => [...current, r]),
|
||||||
[enqueueWrite],
|
[mx],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeReminder = useCallback(
|
const removeReminder = useCallback(
|
||||||
(eventId: string, timestamp: number) =>
|
(eventId: string, timestamp: number) =>
|
||||||
enqueueWrite((current) =>
|
enqueueReminderWrite(mx, (current) =>
|
||||||
current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)),
|
current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)),
|
||||||
),
|
),
|
||||||
[enqueueWrite],
|
[mx],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getReminders = useCallback(() => reminders, [reminders]);
|
const getReminders = useCallback(() => ensureModuleState(mx).latest, [mx]);
|
||||||
|
|
||||||
return { reminders, addReminder, removeReminder, getReminders };
|
return { reminders, addReminder, removeReminder, getReminders };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
import {
|
||||||
|
MatrixEvent,
|
||||||
|
MatrixEventEvent,
|
||||||
|
MatrixEventHandlerMap,
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
RoomEventHandlerMap,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
@@ -45,11 +52,20 @@ export const useRoomLatestRenderedEvent = (room: Room) => {
|
|||||||
const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = () => {
|
const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = () => {
|
||||||
setLatestEvent(getLatestEvent());
|
setLatestEvent(getLatestEvent());
|
||||||
};
|
};
|
||||||
|
// An E2EE message often arrives as an undecrypted placeholder and is decrypted
|
||||||
|
// shortly after — decryption does NOT re-fire RoomEvent.Timeline, so without this
|
||||||
|
// the DM preview stays stale ("Encrypted message") until the next timeline event.
|
||||||
|
const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (event) => {
|
||||||
|
if (event.getRoomId() !== room.roomId) return;
|
||||||
|
setLatestEvent(getLatestEvent());
|
||||||
|
};
|
||||||
setLatestEvent(getLatestEvent());
|
setLatestEvent(getLatestEvent());
|
||||||
|
|
||||||
room.on(RoomEvent.Timeline, handleTimelineEvent);
|
room.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
|
room.client.on(MatrixEventEvent.Decrypted, handleDecrypted);
|
||||||
return () => {
|
return () => {
|
||||||
room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
|
room.client.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
|
||||||
};
|
};
|
||||||
}, [room, hideMembershipEvents, hideNickAvatarEvents, showHiddenEvents]);
|
}, [room, hideMembershipEvents, hideNickAvatarEvents, showHiddenEvents]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { Icons } from 'folds';
|
||||||
|
import FileSaver from 'file-saver';
|
||||||
|
import { createDownloadToast, toastQueueAtom } from '../state/toast';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a blob/URL to disk AND surface a "Downloaded <filename>" toast.
|
||||||
|
*
|
||||||
|
* The desktop (Tauri) app has no native download UI, so `FileSaver.saveAs` saved
|
||||||
|
* files silently — users re-clicked because nothing confirmed success. This gives
|
||||||
|
* uniform, visible feedback across web + desktop for every download call site.
|
||||||
|
*/
|
||||||
|
export const useSaveFile = () => {
|
||||||
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(data: Blob | string, filename: string) => {
|
||||||
|
FileSaver.saveAs(data, filename);
|
||||||
|
setToast(createDownloadToast(filename, Icons.Check));
|
||||||
|
},
|
||||||
|
[setToast],
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { manualDndAtom } from '../state/manualDnd';
|
import { manualDndAtom } from '../state/manualDnd';
|
||||||
import { useTauriEvent } from './useTauri';
|
import { tauriInvoke, useTauriEvent } from './useTauri';
|
||||||
|
|
||||||
/** Detail shape of the `lotus-dnd-changed` event emitted by the native side. */
|
/** Detail shape of the `lotus-dnd-changed` event emitted by the native side. */
|
||||||
type DndChangedDetail = {
|
type DndChangedDetail = {
|
||||||
@@ -18,4 +19,17 @@ export function useTauriDnd(): void {
|
|||||||
const setDnd = useSetAtom(manualDndAtom);
|
const setDnd = useSetAtom(manualDndAtom);
|
||||||
|
|
||||||
useTauriEvent<DndChangedDetail>('lotus-dnd-changed', ({ active }) => setDnd(active));
|
useTauriEvent<DndChangedDetail>('lotus-dnd-changed', ({ active }) => setDnd(active));
|
||||||
|
|
||||||
|
// Re-hydrate on mount. The tray CheckMenuItem persists its checkstate, but
|
||||||
|
// `manualDndAtom` is in-memory and resets to false on every reload (the
|
||||||
|
// custom-chrome toggle, logout). Without this the tray could show DND ON while
|
||||||
|
// notifications resume firing. Query the native tray state (`get_tray_dnd`) so
|
||||||
|
// they stay in sync. No-op in the browser.
|
||||||
|
useEffect(() => {
|
||||||
|
tauriInvoke()?.('get_tray_dnd')
|
||||||
|
.then((active) => {
|
||||||
|
if (typeof active === 'boolean') setDnd(active);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, [setDnd]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export function useTauriNotificationBadge() {
|
|||||||
|
|
||||||
let totalHighlights = 0;
|
let totalHighlights = 0;
|
||||||
roomToUnread.forEach((unread) => {
|
roomToUnread.forEach((unread) => {
|
||||||
|
// Sum only leaf rooms (from === null); roomToUnread also holds per-ancestor
|
||||||
|
// space aggregates (from = Set), so counting all entries double-counts a
|
||||||
|
// space-nested room. Mirrors the favicon fix in ClientNonUIFeatures.
|
||||||
|
if (unread.from !== null) return;
|
||||||
totalHighlights += unread.highlight;
|
totalHighlights += unread.highlight;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export function useTauriUpdater() {
|
|||||||
setStatus({ state: 'installing' });
|
setStatus({ state: 'installing' });
|
||||||
try {
|
try {
|
||||||
await invoke('install_update');
|
await invoke('install_update');
|
||||||
|
// On a successful install the native side calls app.restart(), so this
|
||||||
|
// resolve is only reached when nothing was installed (no update found) —
|
||||||
|
// don't leave the UI stuck on "installing".
|
||||||
|
setStatus({ state: 'up-to-date' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus({ state: 'error', message: String(e) });
|
setStatus({ state: 'error', message: String(e) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { MatrixClient } from 'matrix-js-sdk';
|
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { useAccountDataCallback } from './useAccountDataCallback';
|
|
||||||
|
|
||||||
const NOTES_KEY = 'io.lotus.user_notes';
|
const NOTES_KEY = 'io.lotus.user_notes';
|
||||||
export const USER_NOTE_MAX_LENGTH = 500;
|
export const USER_NOTE_MAX_LENGTH = 500;
|
||||||
@@ -12,39 +11,108 @@ function readNotes(mx: MatrixClient): UserNotesContent {
|
|||||||
return (mx.getAccountData(NOTES_KEY as any)?.getContent() as UserNotesContent | undefined) ?? {};
|
return (mx.getAccountData(NOTES_KEY as any)?.getContent() as UserNotesContent | undefined) ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Module-scoped serialization state.
|
||||||
|
//
|
||||||
|
// useUserNotes() can be mounted by many components at once, so a per-instance
|
||||||
|
// latest/queue would only serialize writes within one instance. Notes for
|
||||||
|
// different users saved from different instances (before the server echo lands)
|
||||||
|
// would each compute from a stale snapshot and clobber each other, since
|
||||||
|
// setAccountData replaces the whole record with no server merge. We therefore
|
||||||
|
// keep a single shared latest record + write queue, keyed off the active client.
|
||||||
|
type UserNotesModuleState = {
|
||||||
|
mx: MatrixClient;
|
||||||
|
latest: UserNotesContent;
|
||||||
|
writeQueue: Promise<unknown>;
|
||||||
|
listeners: Set<(record: UserNotesContent) => void>;
|
||||||
|
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
|
||||||
|
};
|
||||||
|
|
||||||
|
let moduleState: UserNotesModuleState | null = null;
|
||||||
|
|
||||||
|
// Lazily initialize the shared state for the given client. On a client change
|
||||||
|
// (login/logout swaps the MatrixClient) we tear down the old subscription and
|
||||||
|
// re-initialize against the new client so we never leak or double-subscribe.
|
||||||
|
function ensureModuleState(mx: MatrixClient): UserNotesModuleState {
|
||||||
|
if (moduleState && moduleState.mx === mx) {
|
||||||
|
return moduleState;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moduleState) {
|
||||||
|
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: UserNotesModuleState = {
|
||||||
|
mx,
|
||||||
|
latest: readNotes(mx),
|
||||||
|
writeQueue: Promise.resolve(),
|
||||||
|
listeners: new Set(),
|
||||||
|
// Reassigned below once `state` is captured.
|
||||||
|
onAccountData: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.onAccountData = (evt) => {
|
||||||
|
if (evt.getType() === NOTES_KEY) {
|
||||||
|
const record = evt.getContent<UserNotesContent>() ?? {};
|
||||||
|
state.latest = record;
|
||||||
|
state.listeners.forEach((listener) => listener(record));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.on(ClientEvent.AccountData, state.onAccountData);
|
||||||
|
moduleState = state;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueNotesWrite(
|
||||||
|
mx: MatrixClient,
|
||||||
|
compute: (current: UserNotesContent) => UserNotesContent,
|
||||||
|
): Promise<void> {
|
||||||
|
const state = ensureModuleState(mx);
|
||||||
|
const run = state.writeQueue.then(async () => {
|
||||||
|
const next = compute(state.latest);
|
||||||
|
state.latest = next;
|
||||||
|
state.listeners.forEach((listener) => listener(next));
|
||||||
|
await (mx as any).setAccountData(NOTES_KEY, next);
|
||||||
|
});
|
||||||
|
// Keep the chain alive even if one write rejects, but propagate the
|
||||||
|
// rejection to this caller so it can react (e.g. retry).
|
||||||
|
state.writeQueue = run.catch(() => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
export function useUserNotes(): {
|
export function useUserNotes(): {
|
||||||
getNote: (userId: string) => string;
|
getNote: (userId: string) => string;
|
||||||
setNote: (userId: string, note: string) => Promise<void>;
|
setNote: (userId: string, note: string) => Promise<void>;
|
||||||
} {
|
} {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [notes, setNotes] = useState<UserNotesContent>(() => readNotes(mx));
|
const [notes, setNotes] = useState<UserNotesContent>(() => ensureModuleState(mx).latest);
|
||||||
|
|
||||||
useAccountDataCallback(
|
|
||||||
mx,
|
|
||||||
useCallback((evt) => {
|
|
||||||
if (evt.getType() === NOTES_KEY) {
|
|
||||||
setNotes(evt.getContent<UserNotesContent>() ?? {});
|
|
||||||
}
|
|
||||||
}, []),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// Subscribe to the shared module state. A single AccountData listener is
|
||||||
|
// installed per client (in ensureModuleState); each hook instance only
|
||||||
|
// registers a local setter and unregisters it on unmount / client change.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNotes(readNotes(mx));
|
const state = ensureModuleState(mx);
|
||||||
|
setNotes(state.latest);
|
||||||
|
state.listeners.add(setNotes);
|
||||||
|
return () => {
|
||||||
|
state.listeners.delete(setNotes);
|
||||||
|
};
|
||||||
}, [mx]);
|
}, [mx]);
|
||||||
|
|
||||||
const getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]);
|
const getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]);
|
||||||
|
|
||||||
const setNote = useCallback(
|
const setNote = useCallback(
|
||||||
async (userId: string, note: string) => {
|
(userId: string, note: string) => {
|
||||||
const current = readNotes(mx);
|
|
||||||
const updated = { ...current };
|
|
||||||
const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH);
|
const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH);
|
||||||
if (trimmed) {
|
return enqueueNotesWrite(mx, (current) => {
|
||||||
updated[userId] = trimmed;
|
const updated = { ...current };
|
||||||
} else {
|
if (trimmed) {
|
||||||
delete updated[userId];
|
updated[userId] = trimmed;
|
||||||
}
|
} else {
|
||||||
await (mx as any).setAccountData(NOTES_KEY, updated);
|
delete updated[userId];
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[mx],
|
[mx],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
|
|||||||
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
|
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Re-seed when the User object appears/changes after first render — the
|
||||||
|
// useState initializer only ran if `user` already existed at mount, so a
|
||||||
|
// late-arriving user would otherwise show no presence until the next event.
|
||||||
|
if (user) setPresence(getUserPresence(user));
|
||||||
// Subscribe on mx (MatrixClient) rather than on individual User objects.
|
// Subscribe on mx (MatrixClient) rather than on individual User objects.
|
||||||
// User objects have a default 10-listener limit; the same user can appear
|
// User objects have a default 10-listener limit; the same user can appear
|
||||||
// in many components simultaneously (avatars, member list, etc.) and
|
// in many components simultaneously (avatars, member list, etc.) and
|
||||||
|
|||||||
@@ -110,7 +110,11 @@ function DesktopChrome({ children }: { children: ReactNode }) {
|
|||||||
<div
|
<div
|
||||||
style={
|
style={
|
||||||
useChrome
|
useChrome
|
||||||
? { display: 'flex', flexDirection: 'column', height: '100vh' }
|
? // Match html/#root (100dvh), NOT 100vh — in the Tauri webview 100vh
|
||||||
|
// can exceed the visible height after decorations are stripped, which
|
||||||
|
// makes the timeline's scroll container taller than the viewport and
|
||||||
|
// sends the virtual paginator into a runaway back-pagination loop.
|
||||||
|
{ display: 'flex', flexDirection: 'column', height: '100dvh' }
|
||||||
: { display: 'contents' }
|
: { display: 'contents' }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { focusAssistActiveAtom } from '../../state/focusAssist';
|
import { focusAssistActiveAtom } from '../../state/focusAssist';
|
||||||
import { manualDndAtom } from '../../state/manualDnd';
|
import { manualDndAtom } from '../../state/manualDnd';
|
||||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||||
import LogoSVG from '../../../../public/res/lotus.png';
|
import LogoSVG from '../../../../public/res/lotus.png';
|
||||||
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
|
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
|
||||||
import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png';
|
import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png';
|
||||||
@@ -32,15 +32,23 @@ import {
|
|||||||
getUnreadInfo,
|
getUnreadInfo,
|
||||||
isNotificationEvent,
|
isNotificationEvent,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
|
import { NotificationType } from '../../../types/matrix/room';
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
||||||
|
import {
|
||||||
|
MAX_MUTE_TIMEOUT_MS,
|
||||||
|
MuteTimerEntry,
|
||||||
|
loadMuteTimers,
|
||||||
|
unmuteRoom,
|
||||||
|
} from '../../features/room-nav/RoomNavItem';
|
||||||
|
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
|
||||||
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||||
import { toastQueueAtom } from '../../state/toast';
|
import { toastQueueAtom } from '../../state/toast';
|
||||||
import { useReminders } from '../../hooks/useReminders';
|
import { useReminders } from '../../hooks/useReminders';
|
||||||
|
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
|
||||||
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
|
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
|
||||||
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
||||||
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
|
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
|
||||||
@@ -96,6 +104,11 @@ function FaviconUpdater() {
|
|||||||
let totalNotif = 0;
|
let totalNotif = 0;
|
||||||
let totalHighlight = 0;
|
let totalHighlight = 0;
|
||||||
roomToUnread.forEach((unread) => {
|
roomToUnread.forEach((unread) => {
|
||||||
|
// roomToUnread holds BOTH leaf rooms and per-ancestor space aggregates
|
||||||
|
// (leaves have `from === null`, aggregates a Set). Sum only leaves —
|
||||||
|
// otherwise a space-nested room is counted once as the leaf and again in
|
||||||
|
// every ancestor space, inflating the tab title / favicon count.
|
||||||
|
if (unread.from !== null) return;
|
||||||
totalNotif += unread.total;
|
totalNotif += unread.total;
|
||||||
totalHighlight += unread.highlight;
|
totalHighlight += unread.highlight;
|
||||||
});
|
});
|
||||||
@@ -230,9 +243,95 @@ function PresenceUpdater() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restores timed-mute timers persisted by RoomNavItem across reloads. Bare
|
||||||
|
// setTimeouts don't survive a page reload, so without this a scheduled unmute is
|
||||||
|
// lost and the room stays muted forever. On boot: unmute anything already
|
||||||
|
// past-due and re-arm a timer for each future entry (clamped to setTimeout's max).
|
||||||
|
function MuteTimerRestore() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timers = loadMuteTimers();
|
||||||
|
if (timers.length === 0) return undefined;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const pastDue: MuteTimerEntry[] = [];
|
||||||
|
const future: MuteTimerEntry[] = [];
|
||||||
|
timers.forEach((entry) => (entry.unmuteAt <= now ? pastDue : future).push(entry));
|
||||||
|
|
||||||
|
pastDue.forEach((entry) => {
|
||||||
|
unmuteRoom(mx, entry.roomId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handles = future.map((entry) =>
|
||||||
|
setTimeout(
|
||||||
|
() => {
|
||||||
|
unmuteRoom(mx, entry.roomId);
|
||||||
|
},
|
||||||
|
Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
handles.forEach(clearTimeout);
|
||||||
|
};
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fires the custom-status auto-clear even when Settings→Profile is closed. The
|
||||||
|
// expiry setTimeout used to live in ProfileStatus, which unmounts on close, so
|
||||||
|
// the status never cleared. This always-mounted watcher polls the persisted
|
||||||
|
// expiry key and clears (preserving the user's chosen presence) when due.
|
||||||
|
function StatusExpiryMonitor() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
|
||||||
|
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
|
||||||
|
// Read latest settings via refs so the poll interval isn't torn down/restarted
|
||||||
|
// (resetting its countdown) whenever the presence setting changes.
|
||||||
|
const presenceStatusRef = useRef(presenceStatus);
|
||||||
|
presenceStatusRef.current = presenceStatus;
|
||||||
|
const hidePresenceRef = useRef(hidePresence);
|
||||||
|
hidePresenceRef.current = hidePresence;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
if (!userId) return undefined;
|
||||||
|
const expiryKey = STATUS_EXPIRY_KEY(userId);
|
||||||
|
const msgKey = STATUS_MSG_KEY(userId);
|
||||||
|
|
||||||
|
const check = () => {
|
||||||
|
const stored = localStorage.getItem(expiryKey);
|
||||||
|
if (!stored) return;
|
||||||
|
const ts = parseInt(stored, 10);
|
||||||
|
if (!ts || Date.now() < ts) return;
|
||||||
|
localStorage.removeItem(msgKey);
|
||||||
|
localStorage.removeItem(expiryKey);
|
||||||
|
mx.setPresence({
|
||||||
|
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
|
||||||
|
status_msg: '',
|
||||||
|
}).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
check();
|
||||||
|
const interval = setInterval(check, 30_000);
|
||||||
|
const onVisible = () => {
|
||||||
|
if (document.visibilityState === 'visible') check();
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
document.removeEventListener('visibilitychange', onVisible);
|
||||||
|
};
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function MessageNotifications() {
|
function MessageNotifications() {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
|
const lastNotifiedEventRef = useRef<Map<string, string>>(new Map());
|
||||||
// Per-thread dedupe: threadId -> last notified eventId.
|
// Per-thread dedupe: threadId -> last notified eventId.
|
||||||
const lastNotifiedThreadRef = useRef<Map<string, string>>(new Map());
|
const lastNotifiedThreadRef = useRef<Map<string, string>>(new Map());
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
@@ -367,17 +466,21 @@ function MessageNotifications() {
|
|||||||
const eventId = mEvent.getId();
|
const eventId = mEvent.getId();
|
||||||
if (!sender || !eventId) return;
|
if (!sender || !eventId) return;
|
||||||
|
|
||||||
const unreadInfo = getUnreadInfo(room);
|
// Dedupe on the event id (per room): the same event can re-fire (decryption,
|
||||||
const cachedUnreadInfo = unreadCacheRef.current.get(room.roomId);
|
// edit, thread repopulation). This replaces the old unread-COUNT dedupe,
|
||||||
unreadCacheRef.current.set(room.roomId, unreadInfo);
|
// which suppressed a genuinely-new message whenever its post-read count
|
||||||
|
// matched the previously-notified count — i.e. "read a DM, next message
|
||||||
|
// never notifies/sounds" (the common one-at-a-time cadence).
|
||||||
|
if (lastNotifiedEventRef.current.get(room.roomId) === eventId) return;
|
||||||
|
|
||||||
if (unreadInfo.total === 0) return;
|
// Main-timeline path respects push rules: don't notify when the room has no
|
||||||
if (
|
// notification count (e.g. a non-mention in a Mentions-only room). The
|
||||||
cachedUnreadInfo &&
|
// thread path is already gated by shouldNotifyThreadReply, so it must NOT
|
||||||
unreadEqual(unreadInfoToUnread(cachedUnreadInfo), unreadInfoToUnread(unreadInfo))
|
// re-gate on the room count — otherwise an explicit per-thread "All replies"
|
||||||
) {
|
// override in a Mentions-only room is silently dropped.
|
||||||
return;
|
if (!threadId && getUnreadInfo(room).total === 0) return;
|
||||||
}
|
|
||||||
|
lastNotifiedEventRef.current.set(room.roomId, eventId);
|
||||||
|
|
||||||
const quietActive =
|
const quietActive =
|
||||||
focusAssistActive ||
|
focusAssistActive ||
|
||||||
@@ -585,6 +688,62 @@ function ReminderMonitor() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MSC1763: opt-in local enforcement of room retention. When enabled, permanently
|
||||||
|
// redacts the user's OWN messages once a room's retention window passes. Own-only
|
||||||
|
// (no redact PL needed); scoped to loaded live-timeline events; dedupes in-flight
|
||||||
|
// redactions and retries on the next tick. Default-off, so nothing auto-deletes
|
||||||
|
// unless the user turns it on.
|
||||||
|
function RetentionSweeper() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [enforceRetentionLocally] = useSetting(settingsAtom, 'enforceRetentionLocally');
|
||||||
|
const enabledRef = useRef(enforceRetentionLocally);
|
||||||
|
enabledRef.current = enforceRetentionLocally;
|
||||||
|
const redactingRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () => {
|
||||||
|
if (!enabledRef.current) return;
|
||||||
|
const myId = mx.getUserId();
|
||||||
|
if (!myId) return;
|
||||||
|
const now = Date.now();
|
||||||
|
mx.getRooms().forEach((room) => {
|
||||||
|
const maxLifetime = getRoomRetentionMs(room);
|
||||||
|
if (!maxLifetime) return;
|
||||||
|
room
|
||||||
|
.getLiveTimeline()
|
||||||
|
.getEvents()
|
||||||
|
.forEach((ev) => {
|
||||||
|
const evId = ev.getId();
|
||||||
|
if (!evId || ev.getSender() !== myId) return;
|
||||||
|
if (ev.isState() || ev.isRedacted() || ev.isSending()) return;
|
||||||
|
const t = ev.getType();
|
||||||
|
// Only actual messages — never our membership/topic/reactions.
|
||||||
|
if (t !== 'm.room.message' && t !== 'm.room.encrypted' && t !== 'm.sticker') return;
|
||||||
|
if (!isExpired(ev.getTs(), maxLifetime, now)) return;
|
||||||
|
if (redactingRef.current.has(evId)) return;
|
||||||
|
redactingRef.current.add(evId);
|
||||||
|
mx.redactEvent(room.roomId, evId, undefined, { reason: 'expired' }).catch(() => {
|
||||||
|
redactingRef.current.delete(evId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
check();
|
||||||
|
const interval = setInterval(check, 30_000);
|
||||||
|
const onVisible = () => {
|
||||||
|
if (document.visibilityState === 'visible') check();
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
document.removeEventListener('visibilitychange', onVisible);
|
||||||
|
};
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const TAURI_UPDATE_CHECK_INTERVAL = 12 * 60 * 60_000; // 12 hours
|
const TAURI_UPDATE_CHECK_INTERVAL = 12 * 60 * 60_000; // 12 hours
|
||||||
const TAURI_UPDATE_LAST_CHECK_KEY = 'lotus.tauriUpdateLastCheck';
|
const TAURI_UPDATE_LAST_CHECK_KEY = 'lotus.tauriUpdateLastCheck';
|
||||||
|
|
||||||
@@ -666,9 +825,12 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<PageZoomFeature />
|
<PageZoomFeature />
|
||||||
<FaviconUpdater />
|
<FaviconUpdater />
|
||||||
<PresenceUpdater />
|
<PresenceUpdater />
|
||||||
|
<MuteTimerRestore />
|
||||||
|
<StatusExpiryMonitor />
|
||||||
<InviteNotifications />
|
<InviteNotifications />
|
||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
<ReminderMonitor />
|
<ReminderMonitor />
|
||||||
|
<RetentionSweeper />
|
||||||
<TauriUpdateFeature />
|
<TauriUpdateFeature />
|
||||||
<TauriDesktopFeatures />
|
<TauriDesktopFeatures />
|
||||||
<LotusDenoiseFeature />
|
<LotusDenoiseFeature />
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
logoutClient,
|
logoutClient,
|
||||||
startClient,
|
startClient,
|
||||||
} from '../../../client/initMatrix';
|
} from '../../../client/initMatrix';
|
||||||
|
import { deleteSearchCacheDatabase } from '../../utils/searchCache';
|
||||||
import { SplashScreen } from '../../components/splash-screen';
|
import { SplashScreen } from '../../components/splash-screen';
|
||||||
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
|
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
|
||||||
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
||||||
@@ -43,6 +44,8 @@ import { stopPropagation } from '../../utils/keyboard';
|
|||||||
import { SyncStatus } from './SyncStatus';
|
import { SyncStatus } from './SyncStatus';
|
||||||
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
||||||
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
|
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
|
||||||
|
import { pushSessionToSW } from '../../../sw-session';
|
||||||
|
import { revokeOidcTokens } from '../../../client/oidcLogout';
|
||||||
import { useSessionSync } from '../../hooks/useSessionSync';
|
import { useSessionSync } from '../../hooks/useSessionSync';
|
||||||
import { installCryptoDiagLog } from '../../utils/cryptoDiagLog';
|
import { installCryptoDiagLog } from '../../utils/cryptoDiagLog';
|
||||||
import { AutoDiscovery } from './AutoDiscovery';
|
import { AutoDiscovery } from './AutoDiscovery';
|
||||||
@@ -142,8 +145,23 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) {
|
|||||||
const useLogoutListener = (mx?: MatrixClient) => {
|
const useLogoutListener = (mx?: MatrixClient) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
|
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
|
||||||
|
// Clear the SW's cached bearer token so it stops attaching the now-revoked
|
||||||
|
// token to media fetches (mirrors the manual logoutClient path).
|
||||||
|
pushSessionToSW();
|
||||||
mx?.stopClient();
|
mx?.stopClient();
|
||||||
|
// Best-effort issuer revocation for OIDC sessions (the token is already
|
||||||
|
// server-revoked here, but revoke the refresh token too). Before we drop
|
||||||
|
// the stored session below.
|
||||||
|
const loggedOutSession = getFallbackSession();
|
||||||
|
if (loggedOutSession?.oidc) {
|
||||||
|
await revokeOidcTokens(loggedOutSession).catch(() => undefined);
|
||||||
|
}
|
||||||
await mx?.clearStores();
|
await mx?.clearStores();
|
||||||
|
// The opt-in local search index holds DECRYPTED message plaintext. Wipe it
|
||||||
|
// on server-forced logout too (token expiry / remote sign-out / password
|
||||||
|
// change) — the manual logout path already does, but this path didn't, so
|
||||||
|
// the plaintext survived on disk (and persist() makes it non-evictable).
|
||||||
|
await deleteSearchCacheDatabase();
|
||||||
// Remove only the session credential keys — NOT settings, drafts, and
|
// Remove only the session credential keys — NOT settings, drafts, and
|
||||||
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
|
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
|
||||||
// window.localStorage.clear() is reserved for the explicit reset path.
|
// window.localStorage.clear() is reserved for the explicit reset path.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { CreateTab } from './sidebar/CreateTab';
|
|||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useTheme, ThemeKind } from '../../hooks/useTheme';
|
import { useTheme, ThemeKind } from '../../hooks/useTheme';
|
||||||
|
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
||||||
import { getChatBg } from '../../features/lotus/chatBackground';
|
import { getChatBg } from '../../features/lotus/chatBackground';
|
||||||
|
|
||||||
export function SidebarNav() {
|
export function SidebarNav() {
|
||||||
@@ -34,6 +35,7 @@ export function SidebarNav() {
|
|||||||
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.kind === ThemeKind.Dark;
|
const isDark = theme.kind === ThemeKind.Dark;
|
||||||
|
const reduced = useReducedMotion();
|
||||||
|
|
||||||
// backdrop-filter only blurs content directly behind the element in the z-axis.
|
// backdrop-filter only blurs content directly behind the element in the z-axis.
|
||||||
// The sidebar is a flex sibling of the room view, so nothing sits behind it by default.
|
// The sidebar is a flex sibling of the room view, so nothing sits behind it by default.
|
||||||
@@ -53,17 +55,26 @@ export function SidebarNav() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const effectiveBg = chatBackground !== 'none' ? chatBackground : 'tactical';
|
const effectiveBg = chatBackground !== 'none' ? chatBackground : 'tactical';
|
||||||
const bgStyle = getChatBg(effectiveBg, isDark, pauseAnimations);
|
const bgStyle = getChatBg(effectiveBg, isDark, pauseAnimations || reduced);
|
||||||
style.backgroundImage = (bgStyle.backgroundImage as string | undefined) ?? '';
|
style.backgroundImage = (bgStyle.backgroundImage as string | undefined) ?? '';
|
||||||
style.backgroundColor = (bgStyle.backgroundColor as string | undefined) ?? '';
|
style.backgroundColor = (bgStyle.backgroundColor as string | undefined) ?? '';
|
||||||
style.backgroundSize = (bgStyle.backgroundSize as string | undefined) ?? '';
|
style.backgroundSize = (bgStyle.backgroundSize as string | undefined) ?? '';
|
||||||
style.backgroundPosition = (bgStyle.backgroundPosition as string | undefined) ?? '';
|
style.backgroundPosition = (bgStyle.backgroundPosition as string | undefined) ?? '';
|
||||||
style.animation = (bgStyle.animation as string | undefined) ?? '';
|
// The animated body mirror (animation + will-change) exists solely so the
|
||||||
// Promote animated backgrounds to their own compositor layer so the browser
|
// glassmorphism sidebar can blur through document.body. When glass is OFF nothing
|
||||||
// doesn't repaint the overlaid text/UI content on every animation frame.
|
// samples this layer, yet SidebarNav is always mounted, so writing an animated bg +
|
||||||
if (bgStyle.animation) {
|
// will-change here would leave a permanent invisible animated compositor layer
|
||||||
style.willChange = 'background-position, background-size';
|
// app-wide. Only mirror the animation when glass is on; the static background above
|
||||||
|
// (needed by lotusTerminal / non-animated cases) is still written regardless.
|
||||||
|
if (glassmorphismSidebar) {
|
||||||
|
style.animation = (bgStyle.animation as string | undefined) ?? '';
|
||||||
|
if (bgStyle.animation) {
|
||||||
|
style.willChange = 'background-position, background-size';
|
||||||
|
} else {
|
||||||
|
style.removeProperty('will-change');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
style.removeProperty('animation');
|
||||||
style.removeProperty('will-change');
|
style.removeProperty('will-change');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +86,7 @@ export function SidebarNav() {
|
|||||||
style.removeProperty('animation');
|
style.removeProperty('animation');
|
||||||
style.removeProperty('will-change');
|
style.removeProperty('will-change');
|
||||||
};
|
};
|
||||||
}, [glassmorphismSidebar, chatBackground, lotusTerminal, isDark, pauseAnimations]);
|
}, [glassmorphismSidebar, chatBackground, lotusTerminal, isDark, pauseAnimations, reduced]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar className={classNames(glassmorphismSidebar && SidebarGlass)}>
|
<Sidebar className={classNames(glassmorphismSidebar && SidebarGlass)}>
|
||||||
|
|||||||
@@ -321,11 +321,7 @@ export function Direct() {
|
|||||||
const selected = selectedRoomId === roomId;
|
const selected = selectedRoomId === roomId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VirtualTile
|
<VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
|
||||||
virtualItem={vItem}
|
|
||||||
key={vItem.index}
|
|
||||||
ref={virtualizer.measureElement}
|
|
||||||
>
|
|
||||||
<RoomNavItem
|
<RoomNavItem
|
||||||
room={room}
|
room={room}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ const factoryRoomIdByUnread =
|
|||||||
|
|
||||||
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
||||||
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
|
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
|
||||||
|
const LOW_PRIORITY_CATEGORY_ID = makeNavCategoryId('home', 'lowpriority');
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
useNavToActivePathMapper('home');
|
useNavToActivePathMapper('home');
|
||||||
@@ -261,29 +262,66 @@ export function Home() {
|
|||||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||||
const [sortMenuAnchor, setSortMenuAnchor] = useState<RectCords>();
|
const [sortMenuAnchor, setSortMenuAnchor] = useState<RectCords>();
|
||||||
|
|
||||||
const { favoriteRooms, otherRooms } = useMemo(() => {
|
const { favoriteRooms, lowPriorityRooms, otherRooms } = useMemo(() => {
|
||||||
const favs: string[] = [];
|
const favs: string[] = [];
|
||||||
|
const low: string[] = [];
|
||||||
const others: string[] = [];
|
const others: string[] = [];
|
||||||
rooms.forEach((rId) => {
|
rooms.forEach((rId) => {
|
||||||
const room = mx.getRoom(rId);
|
const room = mx.getRoom(rId);
|
||||||
if (room?.tags?.['m.favourite']) {
|
if (room?.tags?.['m.favourite']) {
|
||||||
favs.push(rId);
|
favs.push(rId);
|
||||||
|
} else if (room?.tags?.['m.lowpriority']) {
|
||||||
|
low.push(rId);
|
||||||
} else {
|
} else {
|
||||||
others.push(rId);
|
others.push(rId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return { favoriteRooms: favs, otherRooms: others };
|
return { favoriteRooms: favs, lowPriorityRooms: low, otherRooms: others };
|
||||||
}, [mx, rooms]);
|
}, [mx, rooms]);
|
||||||
|
|
||||||
const sortedFavoriteRooms = useMemo(
|
const sortedFavoriteRooms = useMemo(() => {
|
||||||
() =>
|
const isClosed = closedCategories.has(FAVORITES_CATEGORY_ID);
|
||||||
Array.from(favoriteRooms).sort(
|
const items = Array.from(favoriteRooms).sort(
|
||||||
closedCategories.has(FAVORITES_CATEGORY_ID)
|
isClosed ? factoryRoomIdByActivity(mx) : factoryRoomIdByAtoZ(mx),
|
||||||
? factoryRoomIdByActivity(mx)
|
);
|
||||||
: factoryRoomIdByAtoZ(mx),
|
if (isClosed) {
|
||||||
),
|
return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId);
|
||||||
[mx, favoriteRooms, closedCategories],
|
}
|
||||||
);
|
return items;
|
||||||
|
}, [mx, favoriteRooms, closedCategories, roomsWithUnreadSet, selectedRoomId]);
|
||||||
|
|
||||||
|
const filteredFavoriteRooms = useMemo(() => {
|
||||||
|
if (!filterQuery.trim()) return sortedFavoriteRooms;
|
||||||
|
const query = filterQuery.toLowerCase();
|
||||||
|
const localNames = getLocalRoomNamesContent(mx);
|
||||||
|
return sortedFavoriteRooms.filter((rId) => {
|
||||||
|
const localName = localNames.rooms[rId];
|
||||||
|
const matrixName = mx.getRoom(rId)?.name ?? '';
|
||||||
|
return (localName ?? matrixName).toLowerCase().includes(query);
|
||||||
|
});
|
||||||
|
}, [mx, sortedFavoriteRooms, filterQuery]);
|
||||||
|
|
||||||
|
const sortedLowPriorityRooms = useMemo(() => {
|
||||||
|
const isClosed = closedCategories.has(LOW_PRIORITY_CATEGORY_ID);
|
||||||
|
const items = Array.from(lowPriorityRooms).sort(
|
||||||
|
isClosed ? factoryRoomIdByActivity(mx) : factoryRoomIdByAtoZ(mx),
|
||||||
|
);
|
||||||
|
if (isClosed) {
|
||||||
|
return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [mx, lowPriorityRooms, closedCategories, roomsWithUnreadSet, selectedRoomId]);
|
||||||
|
|
||||||
|
const filteredLowPriorityRooms = useMemo(() => {
|
||||||
|
if (!filterQuery.trim()) return sortedLowPriorityRooms;
|
||||||
|
const query = filterQuery.toLowerCase();
|
||||||
|
const localNames = getLocalRoomNamesContent(mx);
|
||||||
|
return sortedLowPriorityRooms.filter((rId) => {
|
||||||
|
const localName = localNames.rooms[rId];
|
||||||
|
const matrixName = mx.getRoom(rId)?.name ?? '';
|
||||||
|
return (localName ?? matrixName).toLowerCase().includes(query);
|
||||||
|
});
|
||||||
|
}, [mx, sortedLowPriorityRooms, filterQuery]);
|
||||||
|
|
||||||
const sortedRooms = useMemo(() => {
|
const sortedRooms = useMemo(() => {
|
||||||
const isClosed = closedCategories.has(DEFAULT_CATEGORY_ID);
|
const isClosed = closedCategories.has(DEFAULT_CATEGORY_ID);
|
||||||
@@ -324,7 +362,7 @@ export function Home() {
|
|||||||
}, [mx, sortedRooms, filterQuery]);
|
}, [mx, sortedRooms, filterQuery]);
|
||||||
|
|
||||||
const favVirtualizer = useVirtualizer({
|
const favVirtualizer = useVirtualizer({
|
||||||
count: sortedFavoriteRooms.length,
|
count: filteredFavoriteRooms.length,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
estimateSize: () => 38,
|
estimateSize: () => 38,
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
@@ -337,6 +375,13 @@ export function Home() {
|
|||||||
overscan: 10,
|
overscan: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const lowVirtualizer = useVirtualizer({
|
||||||
|
count: filteredLowPriorityRooms.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => 38,
|
||||||
|
overscan: 10,
|
||||||
|
});
|
||||||
|
|
||||||
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
|
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
|
||||||
closedCategories.has(categoryId),
|
closedCategories.has(categoryId),
|
||||||
);
|
);
|
||||||
@@ -453,7 +498,7 @@ export function Home() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
{sortedFavoriteRooms.length > 0 && (
|
{favoriteRooms.length > 0 && (
|
||||||
<NavCategory>
|
<NavCategory>
|
||||||
<NavCategoryHeader>
|
<NavCategoryHeader>
|
||||||
<RoomNavCategoryButton
|
<RoomNavCategoryButton
|
||||||
@@ -466,13 +511,13 @@ export function Home() {
|
|||||||
</NavCategoryHeader>
|
</NavCategoryHeader>
|
||||||
<div style={{ position: 'relative', height: favVirtualizer.getTotalSize() }}>
|
<div style={{ position: 'relative', height: favVirtualizer.getTotalSize() }}>
|
||||||
{favVirtualizer.getVirtualItems().map((vItem) => {
|
{favVirtualizer.getVirtualItems().map((vItem) => {
|
||||||
const roomId = sortedFavoriteRooms[vItem.index];
|
const roomId = filteredFavoriteRooms[vItem.index];
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
return (
|
return (
|
||||||
<VirtualTile
|
<VirtualTile
|
||||||
virtualItem={vItem}
|
virtualItem={vItem}
|
||||||
key={vItem.index}
|
key={roomId}
|
||||||
ref={favVirtualizer.measureElement}
|
ref={favVirtualizer.measureElement}
|
||||||
>
|
>
|
||||||
<RoomNavItem
|
<RoomNavItem
|
||||||
@@ -611,11 +656,7 @@ export function Home() {
|
|||||||
const selected = selectedRoomId === roomId;
|
const selected = selectedRoomId === roomId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VirtualTile
|
<VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
|
||||||
virtualItem={vItem}
|
|
||||||
key={vItem.index}
|
|
||||||
ref={virtualizer.measureElement}
|
|
||||||
>
|
|
||||||
<RoomNavItem
|
<RoomNavItem
|
||||||
room={room}
|
room={room}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
@@ -630,6 +671,43 @@ export function Home() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
|
{lowPriorityRooms.length > 0 && (
|
||||||
|
<NavCategory>
|
||||||
|
<NavCategoryHeader>
|
||||||
|
<RoomNavCategoryButton
|
||||||
|
closed={closedCategories.has(LOW_PRIORITY_CATEGORY_ID)}
|
||||||
|
data-category-id={LOW_PRIORITY_CATEGORY_ID}
|
||||||
|
onClick={handleCategoryClick}
|
||||||
|
>
|
||||||
|
Low Priority
|
||||||
|
</RoomNavCategoryButton>
|
||||||
|
</NavCategoryHeader>
|
||||||
|
<div style={{ position: 'relative', height: lowVirtualizer.getTotalSize() }}>
|
||||||
|
{lowVirtualizer.getVirtualItems().map((vItem) => {
|
||||||
|
const roomId = filteredLowPriorityRooms[vItem.index];
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return null;
|
||||||
|
return (
|
||||||
|
<VirtualTile
|
||||||
|
virtualItem={vItem}
|
||||||
|
key={roomId}
|
||||||
|
ref={lowVirtualizer.measureElement}
|
||||||
|
>
|
||||||
|
<RoomNavItem
|
||||||
|
room={room}
|
||||||
|
selected={selectedRoomId === roomId}
|
||||||
|
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
||||||
|
notificationMode={getRoomNotificationMode(
|
||||||
|
notificationPreferences,
|
||||||
|
room.roomId,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</VirtualTile>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</NavCategory>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</PageNavContent>
|
</PageNavContent>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -29,12 +29,26 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
|
|
||||||
private controlMutationObserver: MutationObserver;
|
private controlMutationObserver: MutationObserver;
|
||||||
|
|
||||||
|
// C-H3: coalesces bursts of body-subtree mutations into a single debounced
|
||||||
|
// re-observe pass so a busy EC re-render doesn't thrash the control observer.
|
||||||
|
private bodyMutationTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
private _pipMode = false;
|
private _pipMode = false;
|
||||||
|
|
||||||
|
// C-M3: last quality payload requested via setQuality(). Held so we can (re)send
|
||||||
|
// it once joined (io.lotus.set_quality must not be sent before call-join — a
|
||||||
|
// pre-join send pends to a 10s widget timeout, mirroring the deafen gate).
|
||||||
|
private lastQuality: LotusQualityPayload | null = null;
|
||||||
|
|
||||||
|
// C-M5: set true by CallControls while a push-to-talk key is held. A PTT hold
|
||||||
|
// unmutes the mic transiently, and onMediaState() must NOT treat that as a
|
||||||
|
// user-initiated unmute that auto-undeafens the user.
|
||||||
|
public pttActive = false;
|
||||||
|
|
||||||
// P6-2: mirrors CallEmbed.joined. Set true from forceState(), which CallEmbed
|
// P6-2: mirrors CallEmbed.joined. Set true from forceState(), which CallEmbed
|
||||||
// invokes only from onCallJoined(). Gates io.lotus.set_deafen so we never send
|
// invokes only from onCallJoined(). Gates io.lotus.set_deafen so we never send
|
||||||
// before the fork's widget handler mounts (pre-join sends pend to a 10s
|
// before the fork's widget handler mounts (pre-join sends pend to a 10s
|
||||||
// timeout — HANDOFF_ELEMENT_CALL_FORK.md §12.1 F1).
|
// timeout — io.lotus toWidget actions must only be sent after call-join).
|
||||||
private joined = false;
|
private joined = false;
|
||||||
|
|
||||||
private get document(): Document | undefined {
|
private get document(): Document | undefined {
|
||||||
@@ -153,19 +167,43 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
// this.joined was still false, so it was gated — this is the first send.)
|
// this.joined was still false, so it was gated — this is the first send.)
|
||||||
this.joined = true;
|
this.joined = true;
|
||||||
this.sendDeafenState();
|
this.sendDeafenState();
|
||||||
|
this.sendQuality();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* C-H1 / C-M3: re-push the sticky fork-side state (deafen + quality) after an
|
||||||
|
* EC reconnect. Unlike forceState() this does NOT touch mic/video, so a
|
||||||
|
* reconnect can't clobber the user's live media state — it only re-arms the
|
||||||
|
* fork handlers that remount on reconnect.
|
||||||
|
*/
|
||||||
|
public resendForkState(): void {
|
||||||
|
this.sendDeafenState();
|
||||||
|
this.sendQuality();
|
||||||
}
|
}
|
||||||
|
|
||||||
public startObserving() {
|
public startObserving() {
|
||||||
if (!this.document) return;
|
if (!this.document) return;
|
||||||
|
|
||||||
|
// C-H3: watch the whole body subtree (not just direct children) so we
|
||||||
|
// re-bind the control observer when EC re-renders its controls deeper in the
|
||||||
|
// tree. Debounced via onBodyMutation() to avoid thrashing on busy renders.
|
||||||
this.bodyMutationObserver.observe(this.document.body, {
|
this.bodyMutationObserver.observe(this.document.body, {
|
||||||
childList: true,
|
childList: true,
|
||||||
subtree: false, // only direct children of body
|
subtree: true,
|
||||||
});
|
});
|
||||||
this.onBodyMutation();
|
this.applyBodyMutation();
|
||||||
}
|
}
|
||||||
|
|
||||||
private onBodyMutation() {
|
private onBodyMutation() {
|
||||||
|
// C-H3: coalesce a burst of subtree mutations into one debounced pass.
|
||||||
|
if (this.bodyMutationTimer !== undefined) return;
|
||||||
|
this.bodyMutationTimer = setTimeout(() => {
|
||||||
|
this.bodyMutationTimer = undefined;
|
||||||
|
this.applyBodyMutation();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyBodyMutation() {
|
||||||
if (!this.document) return;
|
if (!this.document) return;
|
||||||
|
|
||||||
this.document.body.style.setProperty('background', 'none', 'important');
|
this.document.body.style.setProperty('background', 'none', 'important');
|
||||||
@@ -266,22 +304,43 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
this.state = state;
|
this.state = state;
|
||||||
this.emitStateUpdate();
|
this.emitStateUpdate();
|
||||||
|
|
||||||
if (this.microphone && !this.sound) {
|
// C-M5: auto-undeafen when the mic turns on, but NOT for a transient
|
||||||
|
// push-to-talk unmute — a PTT tap while deafened must not silently
|
||||||
|
// un-deafen the user.
|
||||||
|
if (this.microphone && !this.sound && !this.pttActive) {
|
||||||
this.toggleSound();
|
this.toggleSound();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private onControlMutation() {
|
private onControlMutation() {
|
||||||
|
const wasScreensharing = this.screenshare;
|
||||||
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
|
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
|
||||||
const spotlight: boolean = this.spotlightButton?.checked ?? false;
|
const spotlight: boolean = this.spotlightButton?.checked ?? false;
|
||||||
|
|
||||||
|
// C-M6: when a screenshare stops, clear the screenshare-audio mute so a
|
||||||
|
// later screenshare doesn't start pre-muted.
|
||||||
|
const screenshareAudioMuted =
|
||||||
|
wasScreensharing && !screenshare ? false : this.screenshareAudioMuted;
|
||||||
|
|
||||||
|
// C-H3: the body observer now watches subtree:true, so this fires on any DOM
|
||||||
|
// churn in EC's controls. Only re-emit (→ re-render every consumer) when one
|
||||||
|
// of the values this method derives actually changed — microphone/video/sound
|
||||||
|
// are copied unchanged from the current state here.
|
||||||
|
if (
|
||||||
|
this.state.screenshare === screenshare &&
|
||||||
|
this.state.spotlight === spotlight &&
|
||||||
|
this.state.screenshareAudioMuted === screenshareAudioMuted
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.state = new CallControlState(
|
this.state = new CallControlState(
|
||||||
this.microphone,
|
this.microphone,
|
||||||
this.video,
|
this.video,
|
||||||
this.sound,
|
this.sound,
|
||||||
screenshare,
|
screenshare,
|
||||||
spotlight,
|
spotlight,
|
||||||
this.screenshareAudioMuted,
|
screenshareAudioMuted,
|
||||||
);
|
);
|
||||||
this.emitStateUpdate();
|
this.emitStateUpdate();
|
||||||
}
|
}
|
||||||
@@ -423,10 +482,25 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
* clamped fork-side, so out-of-range input can't brick the encoder.
|
* clamped fork-side, so out-of-range input can't brick the encoder.
|
||||||
*/
|
*/
|
||||||
public setQuality(settings: LotusQualityPayload): void {
|
public setQuality(settings: LotusQualityPayload): void {
|
||||||
this.call.transport.send('io.lotus.set_quality', settings).catch(() => undefined);
|
// C-M3: remember the request and only send once joined; sendQuality() gates
|
||||||
|
// on this.joined so a pre-join call is a no-op that we replay on join.
|
||||||
|
this.lastQuality = settings;
|
||||||
|
this.sendQuality();
|
||||||
|
}
|
||||||
|
|
||||||
|
// C-M3: push the last-requested quality to the fork. Gated on this.joined so
|
||||||
|
// we never send io.lotus.set_quality before the fork's handler mounts (a
|
||||||
|
// pre-join send would pend to a 10s widget timeout).
|
||||||
|
private sendQuality(): void {
|
||||||
|
if (!this.joined || !this.lastQuality) return;
|
||||||
|
this.call.transport.send('io.lotus.set_quality', this.lastQuality).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose() {
|
public dispose() {
|
||||||
|
if (this.bodyMutationTimer !== undefined) {
|
||||||
|
clearTimeout(this.bodyMutationTimer);
|
||||||
|
this.bodyMutationTimer = undefined;
|
||||||
|
}
|
||||||
this.bodyMutationObserver.disconnect();
|
this.bodyMutationObserver.disconnect();
|
||||||
this.controlMutationObserver.disconnect();
|
this.controlMutationObserver.disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ export class CallEmbed {
|
|||||||
|
|
||||||
public joined = false;
|
public joined = false;
|
||||||
|
|
||||||
|
// C-M4: set once dispose() has run so the hangup fallback timer can tell
|
||||||
|
// whether the embed was already torn down by the normal Close/Hangup echo.
|
||||||
|
public disposed = false;
|
||||||
|
|
||||||
// [lotus #2] Latest per-participant state from io.lotus.call_state, or null
|
// [lotus #2] Latest per-participant state from io.lotus.call_state, or null
|
||||||
// until the fork sends the first one. When non-null, the speaker/mute hooks
|
// until the fork sends the first one. When non-null, the speaker/mute hooks
|
||||||
// read it instead of scraping the EC iframe DOM.
|
// read it instead of scraping the EC iframe DOM.
|
||||||
@@ -403,6 +407,8 @@ export class CallEmbed {
|
|||||||
* @param opts
|
* @param opts
|
||||||
*/
|
*/
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.disposed = true;
|
||||||
this.disposables.forEach((disposable) => {
|
this.disposables.forEach((disposable) => {
|
||||||
disposable();
|
disposable();
|
||||||
});
|
});
|
||||||
@@ -501,9 +507,19 @@ export class CallEmbed {
|
|||||||
|
|
||||||
private onCallJoined(): void {
|
private onCallJoined(): void {
|
||||||
this.settleLoad();
|
this.settleLoad();
|
||||||
this.joined = true;
|
|
||||||
this.applyStyles();
|
this.applyStyles();
|
||||||
this.control.startObserving();
|
this.control.startObserving();
|
||||||
|
|
||||||
|
// C-H1: EC fires JoinCall again on an EC reconnect (this action has no
|
||||||
|
// once-guard). forceState() would reset live mic/video/deafen back to the
|
||||||
|
// join-time snapshot, so only run it on the FIRST join. On a rejoin we just
|
||||||
|
// re-apply styles/observers (above) and re-push the sticky fork state
|
||||||
|
// (deafen/quality), leaving the user's live media state untouched.
|
||||||
|
if (this.joined) {
|
||||||
|
this.control.resendForkState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.joined = true;
|
||||||
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
|
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
|
||||||
this.control.forceState(this.initialState);
|
this.control.forceState(this.initialState);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { allInvitesAtom, useBindAllInvitesAtom } from '../room-list/inviteList';
|
|||||||
import { allRoomsAtom, useBindAllRoomsAtom } from '../room-list/roomList';
|
import { allRoomsAtom, useBindAllRoomsAtom } from '../room-list/roomList';
|
||||||
import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
|
import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
|
||||||
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread';
|
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread';
|
||||||
|
import { markedUnreadAtom, useBindMarkedUnreadAtom } from '../room/markedUnread';
|
||||||
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
|
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
|
||||||
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
|
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
|
||||||
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
|
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
|
||||||
@@ -14,6 +15,7 @@ export const useBindAtoms = (mx: MatrixClient) => {
|
|||||||
useBindRoomToParentsAtom(mx, roomToParentsAtom);
|
useBindRoomToParentsAtom(mx, roomToParentsAtom);
|
||||||
useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
|
useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
|
||||||
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
|
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
|
||||||
|
useBindMarkedUnreadAtom(mx, markedUnreadAtom);
|
||||||
|
|
||||||
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
|
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { MatrixEvent } from 'matrix-js-sdk';
|
||||||
|
import { myMainReceiptPresent, receiptIsMine, setMarkedUnread } from './markedUnread';
|
||||||
|
|
||||||
|
// MSC2867 mark-as-unread: reading a room (our own receipt) clears the flag, so
|
||||||
|
// `receiptIsMine` must detect only OUR receipt and ignore others'. And a write
|
||||||
|
// must land on BOTH the stable `m.marked_unread` and the unstable
|
||||||
|
// `com.famedly.marked_unread` key so it round-trips across servers/clients.
|
||||||
|
|
||||||
|
const ME = '@me:server';
|
||||||
|
const OTHER = '@friend:server';
|
||||||
|
|
||||||
|
const receiptEvent = (content: object): MatrixEvent =>
|
||||||
|
({ getContent: () => content }) as MatrixEvent;
|
||||||
|
|
||||||
|
test('receiptIsMine: true when the receipt content carries our user id', () => {
|
||||||
|
const event = receiptEvent({
|
||||||
|
$abc: { 'm.read': { [ME]: { ts: 1 } } },
|
||||||
|
});
|
||||||
|
assert.equal(receiptIsMine(event, ME), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('receiptIsMine: false when only another user has a receipt', () => {
|
||||||
|
const event = receiptEvent({
|
||||||
|
$abc: { 'm.read': { [OTHER]: { ts: 1 } } },
|
||||||
|
});
|
||||||
|
assert.equal(receiptIsMine(event, ME), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('receiptIsMine: tolerates empty / malformed content', () => {
|
||||||
|
assert.equal(receiptIsMine(receiptEvent({}), ME), false);
|
||||||
|
assert.equal(receiptIsMine(receiptEvent({ $x: {} }), ME), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// myMainReceiptPresent gates the auto-clear to main-timeline reads, so reading a
|
||||||
|
// single thread does not wipe the whole-room marked-unread flag.
|
||||||
|
test('myMainReceiptPresent: true for an unthreaded receipt (no thread_id)', () => {
|
||||||
|
const event = receiptEvent({ $abc: { 'm.read': { [ME]: { ts: 1 } } } });
|
||||||
|
assert.equal(myMainReceiptPresent(event, ME), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('myMainReceiptPresent: true for a thread_id "main" receipt', () => {
|
||||||
|
const event = receiptEvent({ $abc: { 'm.read': { [ME]: { ts: 1, thread_id: 'main' } } } });
|
||||||
|
assert.equal(myMainReceiptPresent(event, ME), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('myMainReceiptPresent: false for a thread-scoped receipt', () => {
|
||||||
|
const event = receiptEvent({ $abc: { 'm.read': { [ME]: { ts: 1, thread_id: '$root:server' } } } });
|
||||||
|
assert.equal(myMainReceiptPresent(event, ME), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('myMainReceiptPresent: false when only another user has a main receipt', () => {
|
||||||
|
const event = receiptEvent({ $abc: { 'm.read': { [OTHER]: { ts: 1 } } } });
|
||||||
|
assert.equal(myMainReceiptPresent(event, ME), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setMarkedUnread writes both the stable and unstable keys with the flag', async () => {
|
||||||
|
const calls: Array<{ type: string; content: unknown }> = [];
|
||||||
|
const mx = {
|
||||||
|
setRoomAccountData: (_roomId: string, type: string, content: unknown) => {
|
||||||
|
calls.push({ type, content });
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
await setMarkedUnread(mx, '!room:server', true);
|
||||||
|
|
||||||
|
const types = calls.map((c) => c.type).sort();
|
||||||
|
assert.deepEqual(types, ['com.famedly.marked_unread', 'm.marked_unread']);
|
||||||
|
assert.ok(calls.every((c) => (c.content as { unread: boolean }).unread === true));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setMarkedUnread(false) clears both keys and does not reject if the unstable write fails', async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const mx = {
|
||||||
|
setRoomAccountData: (_roomId: string, type: string) => {
|
||||||
|
seen.push(type);
|
||||||
|
// Simulate an older server rejecting the unstable key — must not reject.
|
||||||
|
if (type === 'com.famedly.marked_unread') return Promise.reject(new Error('unknown type'));
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
await assert.doesNotReject(() => setMarkedUnread(mx, '!room:server', false));
|
||||||
|
assert.ok(seen.includes('m.marked_unread'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { atom, useSetAtom } from 'jotai';
|
||||||
|
import { MatrixClient, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
||||||
|
|
||||||
|
// MSC2867 — "mark a room as unread". A per-room account-data flag `{ unread }`.
|
||||||
|
// Stable type `m.marked_unread`; servers/clients predating the stabilization use
|
||||||
|
// the unstable `com.famedly.marked_unread`. We read either and write both so the
|
||||||
|
// flag round-trips across the ecosystem.
|
||||||
|
const UNSTABLE_MARKED_UNREAD = 'com.famedly.marked_unread';
|
||||||
|
|
||||||
|
export const readMarkedUnread = (room: Room): boolean => {
|
||||||
|
const stable = room.getAccountData(AccountDataEvent.MarkedUnread)?.getContent()?.unread;
|
||||||
|
if (typeof stable === 'boolean') return stable;
|
||||||
|
return room.getAccountData(UNSTABLE_MARKED_UNREAD)?.getContent()?.unread === true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Set of room ids the user has explicitly marked as unread. */
|
||||||
|
export const markedUnreadAtom = atom<Set<string>>(new Set<string>());
|
||||||
|
|
||||||
|
/** Write (or clear) the marked-unread flag on both the stable + unstable keys. */
|
||||||
|
export const setMarkedUnread = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
unread: boolean,
|
||||||
|
): Promise<unknown> =>
|
||||||
|
Promise.all([
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
mx.setRoomAccountData(roomId, AccountDataEvent.MarkedUnread as any, { unread }),
|
||||||
|
// Best-effort mirror for older servers; never fail the primary write on it.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
mx.setRoomAccountData(roomId, UNSTABLE_MARKED_UNREAD as any, { unread }).catch(() => undefined),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const receiptIsMine = (event: MatrixEvent, userId: string): boolean => {
|
||||||
|
const content = event.getContent();
|
||||||
|
return Object.keys(content).some((eventId) =>
|
||||||
|
Object.keys(content[eventId] ?? {}).some(
|
||||||
|
(receiptType) => content[eventId][receiptType]?.[userId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// True only when OUR receipt in this event is for the main timeline — either
|
||||||
|
// unthreaded (no thread_id) or thread_id "main". A receipt scoped to a specific
|
||||||
|
// thread (thread_id === <threadRootId>) must NOT clear the whole-room marked
|
||||||
|
// flag, since only that one thread was read.
|
||||||
|
export const myMainReceiptPresent = (event: MatrixEvent, userId: string): boolean => {
|
||||||
|
const content = event.getContent();
|
||||||
|
return Object.keys(content).some((eventId) =>
|
||||||
|
Object.keys(content[eventId] ?? {}).some((receiptType) => {
|
||||||
|
const receipt = content[eventId][receiptType]?.[userId];
|
||||||
|
if (!receipt) return false;
|
||||||
|
const threadId = (receipt as { thread_id?: string }).thread_id;
|
||||||
|
return threadId === undefined || threadId === 'main';
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useBindMarkedUnreadAtom = (mx: MatrixClient, anAtom: typeof markedUnreadAtom) => {
|
||||||
|
const setAtom = useSetAtom(anAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const seed = new Set<string>();
|
||||||
|
mx.getRooms().forEach((room) => {
|
||||||
|
if (readMarkedUnread(room)) seed.add(room.roomId);
|
||||||
|
});
|
||||||
|
setAtom(seed);
|
||||||
|
|
||||||
|
const syncRoom = (room: Room) => {
|
||||||
|
const marked = readMarkedUnread(room);
|
||||||
|
setAtom((prev) => {
|
||||||
|
if (marked === prev.has(room.roomId)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (marked) next.add(room.roomId);
|
||||||
|
else next.delete(room.roomId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAccountData: RoomEventHandlerMap[RoomEvent.AccountData] = (_event, room) => {
|
||||||
|
syncRoom(room);
|
||||||
|
};
|
||||||
|
// Reading a room clears its marked-unread flag (MSC2867): when our own
|
||||||
|
// MAIN-timeline read receipt lands for a room that's currently marked, clear
|
||||||
|
// it. Gated to main/unthreaded receipts so reading a single thread doesn't
|
||||||
|
// wipe the whole-room flag. (This also fires for receipts from our other
|
||||||
|
// devices; the local read path clears via markAsRead in notifications.ts.)
|
||||||
|
const onReceipt: RoomEventHandlerMap[RoomEvent.Receipt] = (event, room) => {
|
||||||
|
const myId = mx.getUserId();
|
||||||
|
if (!myId || !readMarkedUnread(room)) return;
|
||||||
|
if (myMainReceiptPresent(event, myId)) {
|
||||||
|
setMarkedUnread(mx, room.roomId, false).catch(() => undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onMembership: RoomEventHandlerMap[RoomEvent.MyMembership] = (room) => {
|
||||||
|
if (room.getMyMembership() !== 'join') {
|
||||||
|
setAtom((prev) => {
|
||||||
|
if (!prev.has(room.roomId)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(room.roomId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.on(RoomEvent.AccountData, onAccountData);
|
||||||
|
mx.on(RoomEvent.Receipt, onReceipt);
|
||||||
|
mx.on(RoomEvent.MyMembership, onMembership);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(RoomEvent.AccountData, onAccountData);
|
||||||
|
mx.removeListener(RoomEvent.Receipt, onReceipt);
|
||||||
|
mx.removeListener(RoomEvent.MyMembership, onMembership);
|
||||||
|
};
|
||||||
|
}, [mx, setAtom]);
|
||||||
|
};
|
||||||
@@ -116,6 +116,23 @@ test('PUT with unchanged counts is skipped (same map reference)', () => {
|
|||||||
assert.equal(before, after);
|
assert.equal(before, after);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('PUT of { total: 0, highlight: 0 } removes the room (collapses to DELETE)', () => {
|
||||||
|
const store = createStore();
|
||||||
|
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 3, 1) });
|
||||||
|
// A phantom zero-count PUT (e.g. UnreadNotifications after the server zeroes
|
||||||
|
// counts) must clear the entry, not leave a stuck dot.
|
||||||
|
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 0, 0) });
|
||||||
|
assert.equal(get(store).has('!r:s'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT of { 0, 0 } on an absent room is a no-op (same map reference)', () => {
|
||||||
|
const store = createStore();
|
||||||
|
const before = get(store);
|
||||||
|
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 0, 0) });
|
||||||
|
assert.equal(before, get(store));
|
||||||
|
assert.equal(get(store).has('!r:s'), false);
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// roomToUnreadAtom: PUT with parent aggregation
|
// roomToUnreadAtom: PUT with parent aggregation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set<string>, r
|
|||||||
allParents.forEach((parentId) => {
|
allParents.forEach((parentId) => {
|
||||||
const oldParentUnread = roomToUnread.get(parentId);
|
const oldParentUnread = roomToUnread.get(parentId);
|
||||||
if (!oldParentUnread) return;
|
if (!oldParentUnread) return;
|
||||||
const newFrom = new Set([...(oldParentUnread.from ?? roomId)]);
|
// `from` is always a Set for parent aggregates; the fallback must be an
|
||||||
|
// iterable of ids, NOT the roomId string (which would spread into chars).
|
||||||
|
const newFrom = new Set([...(oldParentUnread.from ?? [])]);
|
||||||
newFrom.delete(roomId);
|
newFrom.delete(roomId);
|
||||||
if (newFrom.size === 0) {
|
if (newFrom.size === 0) {
|
||||||
roomToUnread.delete(parentId);
|
roomToUnread.delete(parentId);
|
||||||
@@ -136,6 +138,27 @@ export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefin
|
|||||||
}
|
}
|
||||||
if (action.type === 'PUT') {
|
if (action.type === 'PUT') {
|
||||||
const { unreadInfo } = action;
|
const { unreadInfo } = action;
|
||||||
|
// A { total: 0, highlight: 0 } entry is still a *present* map key, and the
|
||||||
|
// nav dot lights on any present entry — so a phantom zero-count PUT (e.g.
|
||||||
|
// the UnreadNotifications listener firing once the server zeroes counts)
|
||||||
|
// would leave a stuck dot. Collapse it to a DELETE so a fully-read room
|
||||||
|
// actually clears. Done before the unreadEqual short-circuit so an
|
||||||
|
// already-stuck { 0, 0 } gets removed too.
|
||||||
|
if (unreadInfo.total === 0 && unreadInfo.highlight === 0) {
|
||||||
|
if (get(baseRoomToUnread).has(unreadInfo.roomId)) {
|
||||||
|
set(
|
||||||
|
baseRoomToUnread,
|
||||||
|
produce(get(baseRoomToUnread), (draftRoomToUnread) =>
|
||||||
|
deleteUnreadInfo(
|
||||||
|
draftRoomToUnread,
|
||||||
|
getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
|
||||||
|
unreadInfo.roomId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const currentUnread = get(baseRoomToUnread).get(unreadInfo.roomId);
|
const currentUnread = get(baseRoomToUnread).get(unreadInfo.roomId);
|
||||||
if (currentUnread && unreadEqual(currentUnread, unreadInfoToUnread(unreadInfo))) {
|
if (currentUnread && unreadEqual(currentUnread, unreadInfoToUnread(unreadInfo))) {
|
||||||
// Do not update if unread data has not changes
|
// Do not update if unread data has not changes
|
||||||
@@ -253,6 +276,15 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (isMyReceipt) {
|
if (isMyReceipt) {
|
||||||
|
// Optimistically clear on our own receipt (upstream cinny behavior).
|
||||||
|
// Do NOT recompute from getUnreadInfo here: getUnreadNotificationCount is
|
||||||
|
// server-computed and STALE on the synchronous synthetic receipt echo
|
||||||
|
// (the SDK only zeroes it immediately when the last live event is our own
|
||||||
|
// message), so recomputing PUTs the stale non-zero count back → the dot
|
||||||
|
// sticks / resurrects. The RoomEvent.UnreadNotifications listener below
|
||||||
|
// re-asserts the accurate badge (incl. restoring the main badge after a
|
||||||
|
// thread read) once the server acks, and a { 0, 0 } PUT collapses to a
|
||||||
|
// DELETE in the reducer.
|
||||||
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
|
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -264,7 +264,21 @@ export const removeFallbackSession = () => {
|
|||||||
// the next setFallbackSession then persists the blob. When both exist the blob
|
// the next setFallbackSession then persists the blob. When both exist the blob
|
||||||
// wins by construction.
|
// wins by construction.
|
||||||
export const getFallbackSession = (): Session | undefined => {
|
export const getFallbackSession = (): Session | undefined => {
|
||||||
const persisted = readSessionBlob() ?? readLegacyKeys();
|
const blob = readSessionBlob();
|
||||||
|
const legacy = readLegacyKeys();
|
||||||
|
// Prefer the atomic blob, EXCEPT when the legacy keys carry a later expiry: a
|
||||||
|
// pre-blob build's token refresh writes only the legacy keys, so a
|
||||||
|
// downgrade→upgrade can leave a stale blob newer than fresh legacy keys →
|
||||||
|
// booting on a dead token. Whichever has the later expiresAt wins.
|
||||||
|
let persisted = blob ?? legacy;
|
||||||
|
if (
|
||||||
|
blob &&
|
||||||
|
legacy &&
|
||||||
|
typeof legacy.expiresAt === 'number' &&
|
||||||
|
(typeof blob.expiresAt !== 'number' || legacy.expiresAt > blob.expiresAt)
|
||||||
|
) {
|
||||||
|
persisted = legacy;
|
||||||
|
}
|
||||||
if (!persisted) return undefined;
|
if (!persisted) return undefined;
|
||||||
return sessionFromPersisted(persisted);
|
return sessionFromPersisted(persisted);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -183,6 +183,9 @@ export interface Settings {
|
|||||||
urlPreview: boolean;
|
urlPreview: boolean;
|
||||||
encUrlPreview: boolean;
|
encUrlPreview: boolean;
|
||||||
showHiddenEvents: boolean;
|
showHiddenEvents: boolean;
|
||||||
|
// [MSC1763] Opt-in: permanently redact your OWN messages once a room's
|
||||||
|
// retention window passes (default off — nothing auto-deletes by surprise).
|
||||||
|
enforceRetentionLocally: boolean;
|
||||||
legacyUsernameColor: boolean;
|
legacyUsernameColor: boolean;
|
||||||
|
|
||||||
showNotifications: boolean;
|
showNotifications: boolean;
|
||||||
@@ -288,6 +291,7 @@ const defaultSettings: Settings = {
|
|||||||
urlPreview: true,
|
urlPreview: true,
|
||||||
encUrlPreview: true,
|
encUrlPreview: true,
|
||||||
showHiddenEvents: false,
|
showHiddenEvents: false,
|
||||||
|
enforceRetentionLocally: false,
|
||||||
legacyUsernameColor: false,
|
legacyUsernameColor: false,
|
||||||
|
|
||||||
showNotifications: true,
|
showNotifications: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createStore } from 'jotai';
|
import { createStore } from 'jotai';
|
||||||
import { toastQueueAtom, dismissToastAtom, ToastNotif } from './toast';
|
import { toastQueueAtom, dismissToastAtom, ToastNotif, createDownloadToast } from './toast';
|
||||||
|
|
||||||
// The queue lives in an unexported baseAtom; we drive the two write-only setters
|
// The queue lives in an unexported baseAtom; we drive the two write-only setters
|
||||||
// (toastQueueAtom append + null no-op guard, dismissToastAtom remove-by-id)
|
// (toastQueueAtom append + null no-op guard, dismissToastAtom remove-by-id)
|
||||||
@@ -85,3 +85,15 @@ test('dismissToastAtom for an unknown id is a no-op', () => {
|
|||||||
['a'],
|
['a'],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createDownloadToast: filename in body, no room navigation, unique ids', () => {
|
||||||
|
const a = createDownloadToast('photo.jpg');
|
||||||
|
assert.equal(a.displayName, 'Downloaded');
|
||||||
|
assert.equal(a.body, 'photo.jpg');
|
||||||
|
// roomId empty + an onClick present → clicking dismisses without navigating to a room.
|
||||||
|
assert.equal(a.roomId, '');
|
||||||
|
assert.equal(a.roomName, '');
|
||||||
|
assert.equal(typeof a.onClick, 'function');
|
||||||
|
const b = createDownloadToast('photo.jpg');
|
||||||
|
assert.notEqual(a.id, b.id);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { atom } from 'jotai';
|
import { atom } from 'jotai';
|
||||||
|
import type { IconSrc } from 'folds';
|
||||||
|
|
||||||
export type ToastNotif = {
|
export type ToastNotif = {
|
||||||
id: string;
|
id: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
iconSrc?: IconSrc; // folds Icon src for a "system" toast (shown instead of an avatar/initials)
|
||||||
displayName: string;
|
displayName: string;
|
||||||
body: string;
|
body: string;
|
||||||
roomName: string;
|
roomName: string;
|
||||||
@@ -12,6 +14,19 @@ export type ToastNotif = {
|
|||||||
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
|
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build a "download complete" system toast. Kept folds-free here (the icon src is
|
||||||
|
// passed in) so this stays a pure, testable builder. roomId is empty + onClick is
|
||||||
|
// set so a click only dismisses (never navigates to a room).
|
||||||
|
export const createDownloadToast = (filename: string, iconSrc?: IconSrc): ToastNotif => ({
|
||||||
|
id: `download-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
displayName: 'Downloaded',
|
||||||
|
body: filename,
|
||||||
|
roomName: '',
|
||||||
|
roomId: '',
|
||||||
|
iconSrc,
|
||||||
|
onClick: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
const baseAtom = atom<ToastNotif[]>([]);
|
const baseAtom = atom<ToastNotif[]>([]);
|
||||||
|
|
||||||
// Write-only setter used in ClientNonUIFeatures
|
// Write-only setter used in ClientNonUIFeatures
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
// Whether the room's Widgets side-panel is open (mirrors mediaGalleryAtom).
|
||||||
|
export const widgetsPanelAtom = atom<boolean>(false);
|
||||||
@@ -5,7 +5,7 @@ import pkg from '../../../package.json';
|
|||||||
//
|
//
|
||||||
// Installs pass-through wrappers around `console.warn` / `console.error` that
|
// Installs pass-through wrappers around `console.warn` / `console.error` that
|
||||||
// ring-buffer any log line matching the KE-1..KE-4 bug-cluster signatures
|
// 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
|
// (E2EE KE-1..4 capture; see LOTUS_TODO.md). It NEVER swallows a log call — the
|
||||||
// original console method is always invoked — and it performs NO network I/O.
|
// 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
|
// The report metadata is limited to SDK version / device id / user id / sync
|
||||||
// state; the captured log lines themselves are intentional evidence and may
|
// state; the captured log lines themselves are intentional evidence and may
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export type CompressionResult = {
|
export type CompressionResult = {
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
|
/** MIME type of the produced blob (currently always image/jpeg). */
|
||||||
|
type: string;
|
||||||
originalSize: number;
|
originalSize: number;
|
||||||
compressedSize: number;
|
compressedSize: number;
|
||||||
width: number;
|
width: number;
|
||||||
@@ -17,22 +19,47 @@ export function isCompressible(file: File | Blob): boolean {
|
|||||||
return isCompressibleType(file.type);
|
return isCompressibleType(file.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const JPEG_OUTPUT_TYPE = 'image/jpeg';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compress an image file via canvas.toBlob → JPEG at the given quality.
|
* Compress an image file via canvas.toBlob → JPEG at the given quality.
|
||||||
* Returns null if the browser cannot render the image (e.g. unsupported codec).
|
* Returns null if the browser cannot render the image (e.g. unsupported codec)
|
||||||
|
* or if the source is left untouched to avoid data loss (see below).
|
||||||
|
*
|
||||||
|
* PNG is skipped entirely: it may carry an alpha channel, and re-encoding to
|
||||||
|
* JPEG composites transparency onto an opaque (black) background, corrupting the
|
||||||
|
* image. Returning null makes callers fall back to uploading the lossless
|
||||||
|
* original. The image is decoded with `imageOrientation: 'from-image'` so any
|
||||||
|
* EXIF orientation is baked into the pixels instead of being silently dropped.
|
||||||
*/
|
*/
|
||||||
export async function compressImage(
|
export async function compressImage(
|
||||||
file: File | Blob,
|
file: File | Blob,
|
||||||
quality = 0.82,
|
quality = 0.82,
|
||||||
): Promise<CompressionResult | null> {
|
): Promise<CompressionResult | null> {
|
||||||
if (!isCompressibleType(file.type)) return null;
|
if (!isCompressibleType(file.type)) return null;
|
||||||
|
// Skip PNG (potential alpha) — re-encoding to JPEG would flatten transparency.
|
||||||
|
if (file.type === 'image/png') return null;
|
||||||
|
|
||||||
const img = await loadImage(file);
|
let bitmap: ImageBitmap;
|
||||||
|
try {
|
||||||
|
bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
|
||||||
|
} catch {
|
||||||
|
// Corrupt/unsupported source: fall back to uploading the lossless original
|
||||||
|
// (the caller uses the original file on a null result) rather than rejecting,
|
||||||
|
// which would drop the file entirely from the Promise.allSettled upload.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { width, height } = bitmap;
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = img.naturalWidth;
|
canvas.width = width;
|
||||||
canvas.height = img.naturalHeight;
|
canvas.height = height;
|
||||||
const ctx = canvas.getContext('2d')!;
|
const ctx = canvas.getContext('2d');
|
||||||
ctx.drawImage(img, 0, 0);
|
if (!ctx) {
|
||||||
|
bitmap.close();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ctx.drawImage(bitmap, 0, 0);
|
||||||
|
bitmap.close();
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
@@ -43,31 +70,19 @@ export async function compressImage(
|
|||||||
}
|
}
|
||||||
resolve({
|
resolve({
|
||||||
blob,
|
blob,
|
||||||
|
type: JPEG_OUTPUT_TYPE,
|
||||||
originalSize: file.size,
|
originalSize: file.size,
|
||||||
compressedSize: blob.size,
|
compressedSize: blob.size,
|
||||||
width: img.naturalWidth,
|
width,
|
||||||
height: img.naturalHeight,
|
height,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
'image/jpeg',
|
JPEG_OUTPUT_TYPE,
|
||||||
quality,
|
quality,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadImage(file: File | Blob): Promise<HTMLImageElement> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
resolve(img);
|
|
||||||
};
|
|
||||||
img.onerror = reject;
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatFileSize(bytes: number): string {
|
export function formatFileSize(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { NotificationCountType, ReceiptType } from 'matrix-js-sdk';
|
||||||
|
import { markAsRead } from './notifications';
|
||||||
|
|
||||||
|
// markAsRead sends an unthreaded read receipt at the latest main-timeline event,
|
||||||
|
// plus a THREADED receipt at each unread thread's latest loaded reply. The
|
||||||
|
// regression these tests guard against: a thread whose replies aren't loaded
|
||||||
|
// (lastReply() === null) must NOT produce a receipt for the thread root — that
|
||||||
|
// resolves to a MAIN receipt at an old event and permanently unreads the room.
|
||||||
|
|
||||||
|
type ReceiptCall = { eventId: string; receiptType: ReceiptType; unthreaded?: boolean };
|
||||||
|
|
||||||
|
const evt = (id: string, sending = false) => ({ getId: () => id, isSending: () => sending }) as any;
|
||||||
|
|
||||||
|
const thread = (id: string, lastReply: any) => ({ id, lastReply: () => lastReply }) as any;
|
||||||
|
|
||||||
|
type RoomOpts = {
|
||||||
|
timeline?: any[];
|
||||||
|
readUpTo?: string | null;
|
||||||
|
threads?: any[];
|
||||||
|
threadUnread?: Record<string, number>;
|
||||||
|
markedUnread?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setup = (opts: RoomOpts) => {
|
||||||
|
const calls: ReceiptCall[] = [];
|
||||||
|
const accountDataWrites: Array<{ type: string; content: any }> = [];
|
||||||
|
const room = {
|
||||||
|
getLiveTimeline: () => ({ getEvents: () => opts.timeline ?? [] }),
|
||||||
|
getEventReadUpTo: () => opts.readUpTo ?? null,
|
||||||
|
getThreads: () => opts.threads ?? [],
|
||||||
|
getThreadUnreadNotificationCount: (threadId: string, _type: NotificationCountType) =>
|
||||||
|
opts.threadUnread?.[threadId] ?? 0,
|
||||||
|
getAccountData: (type: string) =>
|
||||||
|
opts.markedUnread && type === 'm.marked_unread'
|
||||||
|
? { getContent: () => ({ unread: true }) }
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
const mx = {
|
||||||
|
getRoom: () => room,
|
||||||
|
getUserId: () => '@me:server',
|
||||||
|
sendReadReceipt: async (event: any, receiptType: ReceiptType, unthreaded?: boolean) => {
|
||||||
|
calls.push({ eventId: event.getId(), receiptType, unthreaded });
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
setRoomAccountData: async (_roomId: string, type: string, content: any) => {
|
||||||
|
accountDataWrites.push({ type, content });
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
return { mx, calls, accountDataWrites };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('main timeline: unthreaded receipt at the latest event', async () => {
|
||||||
|
const { mx, calls } = setup({ timeline: [evt('a'), evt('b'), evt('c')], readUpTo: 'a' });
|
||||||
|
await markAsRead(mx, '!r:server', false);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.deepEqual(calls[0], { eventId: 'c', receiptType: ReceiptType.Read, unthreaded: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('REGRESSION: an unread thread with unloaded replies (lastReply null) sends NO root receipt', async () => {
|
||||||
|
const t = thread('$root', null); // replies not loaded
|
||||||
|
const { mx, calls } = setup({
|
||||||
|
timeline: [evt('a'), evt('b')],
|
||||||
|
readUpTo: 'a',
|
||||||
|
threads: [t],
|
||||||
|
threadUnread: { $root: 3 },
|
||||||
|
});
|
||||||
|
await markAsRead(mx, '!r:server', false);
|
||||||
|
// Only the main unthreaded receipt — never a receipt for the thread root.
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].eventId, 'b');
|
||||||
|
assert.equal(calls[0].unthreaded, true);
|
||||||
|
assert.ok(!calls.some((c) => c.eventId === '$root'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unread thread with a loaded reply sends a threaded receipt at that reply', async () => {
|
||||||
|
const t = thread('$root', evt('$reply'));
|
||||||
|
const { mx, calls } = setup({
|
||||||
|
timeline: [evt('a'), evt('b')],
|
||||||
|
readUpTo: 'a',
|
||||||
|
threads: [t],
|
||||||
|
threadUnread: { $root: 1 },
|
||||||
|
});
|
||||||
|
await markAsRead(mx, '!r:server', false);
|
||||||
|
const main = calls.find((c) => c.eventId === 'b');
|
||||||
|
const threaded = calls.find((c) => c.eventId === '$reply');
|
||||||
|
assert.ok(main && main.unthreaded === true);
|
||||||
|
assert.ok(threaded && threaded.unthreaded === false);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('main already read but a thread is unread: no main receipt, threaded receipt only', async () => {
|
||||||
|
const t = thread('$root', evt('$reply'));
|
||||||
|
const { mx, calls } = setup({
|
||||||
|
timeline: [evt('a'), evt('b')],
|
||||||
|
readUpTo: 'b', // latest main event already read → getLatestValidEvent() null
|
||||||
|
threads: [t],
|
||||||
|
threadUnread: { $root: 2 },
|
||||||
|
});
|
||||||
|
await markAsRead(mx, '!r:server', false);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].eventId, '$reply');
|
||||||
|
assert.equal(calls[0].unthreaded, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('everything read: no receipts sent', async () => {
|
||||||
|
const t = thread('$root', evt('$reply'));
|
||||||
|
const { mx, calls } = setup({
|
||||||
|
timeline: [evt('a'), evt('b')],
|
||||||
|
readUpTo: 'b',
|
||||||
|
threads: [t],
|
||||||
|
threadUnread: { $root: 0 }, // thread read too
|
||||||
|
});
|
||||||
|
await markAsRead(mx, '!r:server', false);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marked-unread + already fully read: clears the flag even though no receipt is sent', async () => {
|
||||||
|
const { mx, calls, accountDataWrites } = setup({
|
||||||
|
timeline: [evt('a'), evt('b')],
|
||||||
|
readUpTo: 'b', // nothing newer → no receipt
|
||||||
|
markedUnread: true,
|
||||||
|
});
|
||||||
|
await markAsRead(mx, '!r:server', false);
|
||||||
|
assert.equal(calls.length, 0); // no receipt (the stuck-dot case)
|
||||||
|
// ...but the marked-unread flag is cleared directly (both keys, unread:false)
|
||||||
|
assert.ok(accountDataWrites.some((w) => w.type === 'm.marked_unread' && w.content.unread === false));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('not marked-unread: markAsRead does not touch account data', async () => {
|
||||||
|
const { mx, accountDataWrites } = setup({
|
||||||
|
timeline: [evt('a'), evt('b')],
|
||||||
|
readUpTo: 'a',
|
||||||
|
});
|
||||||
|
await markAsRead(mx, '!r:server', false);
|
||||||
|
assert.equal(accountDataWrites.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sending thread reply is skipped', async () => {
|
||||||
|
const t = thread('$root', evt('$reply', true)); // isSending → skip
|
||||||
|
const { mx, calls } = setup({
|
||||||
|
timeline: [evt('a'), evt('b')],
|
||||||
|
readUpTo: 'b',
|
||||||
|
threads: [t],
|
||||||
|
threadUnread: { $root: 1 },
|
||||||
|
});
|
||||||
|
await markAsRead(mx, '!r:server', false);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('private receipt flag uses ReadPrivate', async () => {
|
||||||
|
const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'a' });
|
||||||
|
await markAsRead(mx, '!r:server', true);
|
||||||
|
assert.equal(calls[0].receiptType, ReceiptType.ReadPrivate);
|
||||||
|
});
|
||||||
@@ -1,11 +1,22 @@
|
|||||||
import { MatrixClient, ReceiptType } from 'matrix-js-sdk';
|
import { MatrixClient, NotificationCountType, ReceiptType } from 'matrix-js-sdk';
|
||||||
import { getSettings } from '../state/settings';
|
import { getSettings } from '../state/settings';
|
||||||
|
import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread';
|
||||||
|
|
||||||
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
|
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
|
||||||
const { privateReadReceipts } = getSettings();
|
const { privateReadReceipts } = getSettings();
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
|
|
||||||
|
// Reading a room clears an explicit "mark as unread" (MSC2867). The binder's
|
||||||
|
// receipt-driven auto-clear does NOT fire when the room is already fully read
|
||||||
|
// (no receipt is sent below in that case), so clear it directly here.
|
||||||
|
if (readMarkedUnread(room)) {
|
||||||
|
setMarkedUnread(mx, roomId, false).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const receiptType =
|
||||||
|
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read;
|
||||||
|
|
||||||
const timeline = room.getLiveTimeline().getEvents();
|
const timeline = room.getLiveTimeline().getEvents();
|
||||||
const readEventId = room.getEventReadUpTo(mx.getUserId()!);
|
const readEventId = room.getEventReadUpTo(mx.getUserId()!);
|
||||||
|
|
||||||
@@ -17,17 +28,39 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
if (timeline.length === 0) return;
|
|
||||||
const latestEvent = getLatestValidEvent();
|
|
||||||
if (latestEvent === null) return;
|
|
||||||
|
|
||||||
// Unthreaded receipt: with client threadSupport enabled the SDK would
|
const latestEvent = timeline.length > 0 ? getLatestValidEvent() : null;
|
||||||
// otherwise scope this to the main timeline (thread_id: "main"), leaving
|
if (latestEvent) {
|
||||||
// per-thread notification counts permanently unread. Unthreaded preserves
|
// Unthreaded receipt: with client threadSupport enabled the SDK would
|
||||||
// the pre-threads wire behavior — one receipt clears everything.
|
// otherwise scope this to the main timeline (thread_id: "main"). Unthreaded
|
||||||
await mx.sendReadReceipt(
|
// clears the main timeline + every event up to this one.
|
||||||
latestEvent,
|
await mx.sendReadReceipt(latestEvent, receiptType, true);
|
||||||
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read,
|
}
|
||||||
true,
|
|
||||||
|
// Clear per-thread notification counts too — the room's unread dot sums them,
|
||||||
|
// so an unread thread reply keeps the dot lit even after the main timeline is
|
||||||
|
// read (threadSupport moves thread replies out of the main timeline, so the
|
||||||
|
// unthreaded receipt above doesn't necessarily cover them).
|
||||||
|
//
|
||||||
|
// CRITICAL: only send for a GENUINE loaded thread reply, via thread.lastReply().
|
||||||
|
// NEVER fall back to the thread root: a root event is "in the main timeline",
|
||||||
|
// so sendReadReceipt(root, false) resolves (via threadIdForReceipt) to a MAIN
|
||||||
|
// receipt at that old root event. If the root isn't in the loaded timeline it
|
||||||
|
// moves the main read receipt onto an event we don't have -> getEventReadUpTo()
|
||||||
|
// returns null -> the room is reported unread on every mark-read call (this was
|
||||||
|
// the P6 regression, amplified by the bulk mark-all-orphan-rooms-read callers).
|
||||||
|
// If a thread's replies aren't loaded (lastReply() null), just skip it.
|
||||||
|
const threads = room.getThreads();
|
||||||
|
await Promise.all(
|
||||||
|
threads.map((thread) => {
|
||||||
|
const unread =
|
||||||
|
room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
|
||||||
|
if (unread <= 0) return undefined;
|
||||||
|
const lastReply = thread.lastReply();
|
||||||
|
if (!lastReply || lastReply.isSending()) return undefined;
|
||||||
|
// Threaded receipt (unthreaded = false → the SDK scopes it to this thread
|
||||||
|
// via the reply's real threadRootId; it never touches the main marker).
|
||||||
|
return mx.sendReadReceipt(lastReply, receiptType, false).catch(() => undefined);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { isExpired, RETENTION_PRESETS, RETENTION_MIN_MS } from './retention';
|
||||||
|
|
||||||
|
// MSC1763 retention: `isExpired` decides whether a message is past the room's
|
||||||
|
// retention window. It must be strict (> window, not >=) and a disabled policy
|
||||||
|
// (0) must never expire anything.
|
||||||
|
|
||||||
|
const HOUR = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
test('isExpired: an event older than the window is expired', () => {
|
||||||
|
const now = 10 * HOUR;
|
||||||
|
assert.equal(isExpired(now - 2 * HOUR, HOUR, now), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isExpired: an event within the window is NOT expired', () => {
|
||||||
|
const now = 10 * HOUR;
|
||||||
|
assert.equal(isExpired(now - HOUR / 2, HOUR, now), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isExpired: exactly at the boundary is NOT expired (strict >)', () => {
|
||||||
|
const now = 10 * HOUR;
|
||||||
|
assert.equal(isExpired(now - HOUR, HOUR, now), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isExpired: a disabled policy (0 / negative) never expires', () => {
|
||||||
|
const now = 10 * HOUR;
|
||||||
|
assert.equal(isExpired(now - 100 * HOUR, 0, now), false);
|
||||||
|
assert.equal(isExpired(0, -1, now), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('presets: Off is 0 and the rest are strictly increasing, all >= the floor', () => {
|
||||||
|
assert.equal(RETENTION_PRESETS[0].ms, 0);
|
||||||
|
const nonZero = RETENTION_PRESETS.slice(1).map((p) => p.ms);
|
||||||
|
for (let i = 1; i < nonZero.length; i += 1) {
|
||||||
|
assert.ok(nonZero[i] > nonZero[i - 1], 'presets increase');
|
||||||
|
}
|
||||||
|
assert.ok(
|
||||||
|
nonZero.every((ms) => ms >= RETENTION_MIN_MS),
|
||||||
|
'all presets above the floor',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
|
|
||||||
|
// MSC1763 — per-room message retention (`m.room.retention`). `max_lifetime` is a
|
||||||
|
// duration in milliseconds after which a message is considered expired.
|
||||||
|
export type RetentionContent = {
|
||||||
|
max_lifetime?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Floor to avoid foot-guns (an admin fat-fingering a tiny value nuking a room).
|
||||||
|
export const RETENTION_MIN_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
export type RetentionPreset = { label: string; ms: number };
|
||||||
|
export const RETENTION_PRESETS: RetentionPreset[] = [
|
||||||
|
{ label: 'Off', ms: 0 },
|
||||||
|
{ label: '1 Day', ms: DAY_MS },
|
||||||
|
{ label: '1 Week', ms: 7 * DAY_MS },
|
||||||
|
{ label: '1 Month', ms: 30 * DAY_MS },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The room's active retention window in ms, or `undefined` when unset/disabled. */
|
||||||
|
export const getRoomRetentionMs = (room: Room): number | undefined => {
|
||||||
|
const event = room.currentState.getStateEvents(StateEvent.RoomRetention, '');
|
||||||
|
const ms = event?.getContent<RetentionContent>()?.max_lifetime;
|
||||||
|
return typeof ms === 'number' && ms > 0 ? ms : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True when an event at `tsMs` has passed the `maxLifetimeMs` retention window. */
|
||||||
|
export const isExpired = (tsMs: number, maxLifetimeMs: number, nowMs: number): boolean =>
|
||||||
|
maxLifetimeMs > 0 && nowMs - tsMs > maxLifetimeMs;
|
||||||
@@ -269,7 +269,15 @@ export const getUnreadInfos = (
|
|||||||
|
|
||||||
if (roomHaveNotification(room) || roomHaveUnread(mx, room)) {
|
if (roomHaveNotification(room) || roomHaveUnread(mx, room)) {
|
||||||
const mutedThreads = content ? getMutedThreads(content, room.roomId) : undefined;
|
const mutedThreads = content ? getMutedThreads(content, room.roomId) : undefined;
|
||||||
unread.push(getUnreadInfo(room, mutedThreads));
|
const info = getUnreadInfo(room, mutedThreads);
|
||||||
|
// Skip a phantom {0,0} entry: a room whose ONLY unread is a muted thread has
|
||||||
|
// roomHaveNotification true (the server room total includes the muted
|
||||||
|
// thread's count), but getUnreadInfo subtracts it back to zero. Pushing it
|
||||||
|
// would still light the nav row + pollute "unread only" filters. Keep it
|
||||||
|
// only if there's real unread (count > 0) or a genuine unread marker.
|
||||||
|
if (info.total > 0 || info.highlight > 0 || roomHaveUnread(mx, room)) {
|
||||||
|
unread.push(info);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return unread;
|
return unread;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export async function scheduleMessage(
|
|||||||
content: IContent,
|
content: IContent,
|
||||||
sendAtMs: number,
|
sendAtMs: number,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
// A past/near target floors at 1000ms (send ~immediately) — an intentional,
|
||||||
|
// tested contract; the ScheduleMessageModal already guards ≥60s in the future.
|
||||||
const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now()));
|
const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now()));
|
||||||
const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||||
const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`;
|
const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user