Compare commits
53 Commits
26f998d243
...
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dc478e989 | |||
| 049472e25f | |||
| 81904372bc | |||
| c82ab5c7f5 | |||
| ebcd8ec926 | |||
| 4ff07ea2bd | |||
| 804caa5130 | |||
| 625f0c2386 | |||
| 4d7a05c0f1 | |||
| b5e7bcc0b8 | |||
| bca371ad38 | |||
| 899a14c119 | |||
| 6728a1274d | |||
| 21dda93d1b | |||
| 4380041014 | |||
| 8729ccfcf5 | |||
| 8ab1ec254b | |||
| 23f715857c | |||
| f589182709 | |||
| ef573376ac | |||
| 34d9272790 | |||
| 96f7187031 | |||
| 664dcd4cd8 | |||
| 7f960b026b | |||
| 992d2b83b3 | |||
| a9505ca5b2 | |||
| dca51a41ef | |||
| 579449acc3 | |||
| 34592d9144 | |||
| 0adce52d37 | |||
| 501d493ca4 | |||
| ffb934fce6 | |||
| 440c1fe948 | |||
| aa62df9c75 | |||
| 15ac538a4b | |||
| 39cfc23ebe | |||
| 7a8cadc6ec | |||
| 91bd360125 | |||
| 7da960ac8c | |||
| ed51c39fe7 | |||
| c1efa7b94e | |||
| e31b84c08e | |||
| 258e3ec620 | |||
| 3336abb66f | |||
| a184ee0221 | |||
| 4509a2b6d3 | |||
| 7e38baa7b6 | |||
| aab7e5ae20 | |||
| a0fcdf74da | |||
| ebc782b16c | |||
| 7939dc92d4 | |||
| 7c06b27c73 | |||
| 02b2ce8109 |
@@ -1,655 +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` |
|
|
||||||
|
|
||||||
**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)
|
|
||||||
|
|
||||||
The EC side is additive and dormant until cinny opts in. Host work needed (in
|
|
||||||
`src/app/plugins/call/CallEmbed.ts` unless noted):
|
|
||||||
|
|
||||||
> ⚠️ **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** — the fork implements only `rnnoise`/`speex`; cinny's `dtln`/
|
|
||||||
> `deepfilternet` selections silently fall back to rnnoise (now logged). Restrict
|
|
||||||
> the embedded-call model picker to rnnoise/speex, or implement the others in
|
|
||||||
> `lotusDenoiseProcessor.ts`. **F4** — cinny sends `lotusNativeNS`, which the
|
|
||||||
> fork ignores; drop it or wire it in. **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 already sets `lotusDenoise=ml` etc.)
|
|
||||||
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.
|
|
||||||
-166
@@ -1,166 +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 |
|
|
||||||
|
|
||||||
**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-29: the fork is live.** We now own and
|
|
||||||
> self-build Element Call (`LotusGuild/element-call` →
|
|
||||||
> `@lotusguild/element-call-embedded`, Phase 1 done & cinny wired). A5/A6/A7
|
|
||||||
> below are **no longer "won't fix"** — they are ordinary source changes. See
|
|
||||||
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md) §10 + the Phase
|
|
||||||
> 2 work list. (The iframe is **same-origin** / self-hosted; the old blocker was
|
|
||||||
> that we didn't own EC's compiled source — which we now do.)
|
|
||||||
|
|
||||||
The in-call participant grid is rendered **inside EC's app**. Previously a
|
|
||||||
pre-built npm bundle we could only style/place around; now editable source.
|
|
||||||
Items from testing, with their fork-level fix path:
|
|
||||||
|
|
||||||
- **A5 — "Focus camera":** EC supports native tile-pinning. Our bottom-bar "Focus
|
|
||||||
camera" is a programmatic wrapper that **`.click()`s the tile** today
|
|
||||||
(`CallControl.ts` `focusCameraParticipant`), and during a screenshare EC
|
|
||||||
spotlights the shared screen so a camera pin may not override it. **Fork fix:**
|
|
||||||
add an `io.lotus.focus_participant` widget action that pins a participant in
|
|
||||||
EC's layout (coexisting with / overriding the screenshare spotlight); cinny
|
|
||||||
sends it via the widget API and the DOM-click hack is deleted. _Status: Open —
|
|
||||||
Actionable (Phase 2)._
|
|
||||||
- **A6 — avatar decorations in-call:** decorations render on **our** pre-join
|
|
||||||
lobby roster (`CallMemberCard`) but not on EC's in-call video tiles. **Fork
|
|
||||||
fix:** render the decoration APNG inside EC's participant-tile component, fed
|
|
||||||
decoration slugs via widget member data. _Status: Open — Actionable (Phase 2)._
|
|
||||||
- **A7 — mic dead after EC's "Reconnect":** the mid-call "Connection lost /
|
|
||||||
Reconnect" screen is **EC's own** (our load watchdog only covers an initial
|
|
||||||
hung load). After EC reconnects, the mic isn't re-published through our denoise
|
|
||||||
`getUserMedia` shim until a clean End+rejoin. **Fork fix:** move denoise into
|
|
||||||
EC's mic-capture/publish pipeline as a first-class audio stage — EC re-runs it
|
|
||||||
on every (re)publish, so reconnects keep denoise alive natively, and the
|
|
||||||
build-time `index.html` injection is removed. _Status: Open — Actionable
|
|
||||||
(Phase 2); root cause is the `getUserMedia` monkeypatch, not EC itself._
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔴 Open — Actionable
|
|
||||||
|
|
||||||
### Calls / Audio
|
|
||||||
|
|
||||||
- ~~**N127 — ML denoise shim is never injected in `vite dev`.**~~ **RESOLVED (dissolved by the A7 denoise cutover).** `vite.config.js` no longer injects a getUserMedia shim at all — the forked Element Call runs ML denoise in-source as a LiveKit `TrackProcessor` (activated by `lotusDenoiseSource=1`), so there is no build-time injection that could be missing in dev. Nothing to fix.
|
|
||||||
|
|
||||||
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED · 👤 SENIOR ENGINEER
|
|
||||||
|
|
||||||
> **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.**
|
|
||||||
|
|
||||||
- **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** (`state/sessions.ts`) — risks inconsistent state / races across tabs.
|
|
||||||
- **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** (`41.6.0-rc.0`); `@atlaskit` and build tools (`vite`, `typescript`, `eslint`) on unstable/experimental pins — review for stable versions; RC SDK is a tree-shaking/bundle-size risk.
|
|
||||||
- **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 — 545 tests across 62 modules, a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Broad pure-logic coverage: utils (common, regex, sanitize/XSS, time, matrix, matrix-uia, mimeTypes, sort, accentColor, findAndReplace, AsyncSearch, ASCIILexicalTable, keyboard, room, matrix-crypto, featureCheck, syntaxHighlight, imageCompression, user-agent, callSounds), state (settings, sessions, recentSearches, upload, typingMembers, lists, room-list, toast, scheduledMessages, backupRestore, callEmbed/callPreferences, spaceRooms, …), plugins (matrix-to, call/utils, via-servers, bad-words, recent-emoji, custom-emoji, markdown block/inline/utils), OIDC (cs-api, useParsedLoginFlows, oidcState), lotus/avatarDecorations, message-search, search filters. Prevention work has caught + fixed **4 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked; `isMacOS` never matching modern Macs; `isMLDenoiseSupported` throwing `ReferenceError` instead of returning false on browsers lacking the `AudioWorkletNode` binding). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface).
|
|
||||||
- **Extensive `as any` casts** across `src/` — gradual typing cleanup.
|
|
||||||
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
|
|
||||||
- **Hardcoded CDN URL** should move to an env var (the decoration CDN is now single-sourced in `avatarDecorations.ts`, but the literal is still in-repo).
|
|
||||||
- **`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.** Current backgrounds are basic CSS; goal is high-fidelity, research-backed, GPU-accelerated designs (layered `oklch`, `backdrop-filter`, `contain:paint`) with WCAG-AA overlay contrast. Treat each as its own design sprint.
|
|
||||||
+283
-25
@@ -18,14 +18,16 @@ Last updated: June 2026.
|
|||||||
9. [Per-Message Read Receipts](#per-message-read-receipts)
|
9. [Per-Message Read Receipts](#per-message-read-receipts)
|
||||||
10. [Delivery Status Indicators](#delivery-status-indicators)
|
10. [Delivery Status Indicators](#delivery-status-indicators)
|
||||||
11. [Messaging Enhancements](#messaging-enhancements)
|
11. [Messaging Enhancements](#messaging-enhancements)
|
||||||
12. [Presence](#presence)
|
12. [Threads (P3-8)](#threads-p3-8)
|
||||||
13. [UX & Composer](#ux--composer)
|
13. [Presence](#presence)
|
||||||
14. [Room Customization](#room-customization)
|
14. [UX & Composer](#ux--composer)
|
||||||
15. [Moderation](#moderation)
|
15. [Room Customization](#room-customization)
|
||||||
16. [Notifications](#notifications)
|
16. [Moderation](#moderation)
|
||||||
17. [Server Integration](#server-integration)
|
17. [Notifications](#notifications)
|
||||||
18. [Infrastructure](#infrastructure)
|
18. [Server Integration](#server-integration)
|
||||||
19. [Key Custom Files](#key-custom-files)
|
19. [Infrastructure](#infrastructure)
|
||||||
|
20. [Desktop App Features](#desktop-app-features)
|
||||||
|
21. [Key Custom Files](#key-custom-files)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -322,14 +324,104 @@ Users can set a custom background color for `@mention` chips that highlight thei
|
|||||||
|
|
||||||
## Voice / Video Call Improvements
|
## Voice / Video Call Improvements
|
||||||
|
|
||||||
> 🔱 **[EC-FORK]** Element Call is embedded as a **pre-built npm bundle** today.
|
> 🔱 **[EC-FORK] LIVE (2026-06).** Element Call is now our **self-built fork**
|
||||||
> The plan to fork & self-build it from source for true ownership — and which of
|
> (`@lotusguild/element-call-embedded@0.20.1-lotus.1`, source at
|
||||||
> the items below would move into our EC source — is in
|
> `LotusGuild/element-call`), served same-origin — no longer the upstream
|
||||||
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md).
|
> 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
|
||||||
|
> work list are in
|
||||||
|
> the Element Call fork reference in [`LOTUS_TODO.md`](./LOTUS_TODO.md).
|
||||||
|
|
||||||
### Element Call Upgrade
|
### Element Call — Self-Built Fork (`0.20.1-lotus.1`)
|
||||||
|
|
||||||
Upgraded embedded Element Call widget from **0.16.3** to **0.19.4**.
|
The embedded widget was upgraded **0.16.3 → 0.19.4 → 0.20.1**, then **forked**.
|
||||||
|
We self-build `LotusGuild/element-call` and publish it to our private Gitea npm
|
||||||
|
registry as `@lotusguild/element-call-embedded`; cinny consumes that instead of
|
||||||
|
`@element-hq/element-call-embedded`. The iframe prints
|
||||||
|
`Element Call embedded-v0.20.1-lotus.1` in its console (vs. `embedded-v0.20.1`
|
||||||
|
upstream) — the quickest way to confirm a deploy landed the fork.
|
||||||
|
|
||||||
|
All custom behavior lives in the fork's `src/lotus/` modules and is **additive
|
||||||
|
and dormant by default**, gated by URL flags / widget actions the host opts into,
|
||||||
|
so a stock EC config is byte-for-byte upstream behavior.
|
||||||
|
|
||||||
|
**Active (cinny drives them today):**
|
||||||
|
|
||||||
|
| # | Feature | Mechanism | Replaces (old hack) |
|
||||||
|
| --- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| A7 | **Denoise in-source** | ML noise suppression runs inside EC as a LiveKit `TrackProcessor<Audio>` (flag `lotusDenoiseSource=1`); re-applied on every (re)publish | the build-time `getUserMedia` monkeypatch injected into `index.html` — **removed**. Fixes mic-dead-after-reconnect. |
|
||||||
|
| #2 | **Speaking / mute events** | EC emits `io.lotus.call_state` (throttled); cinny reads speaker + mute state from it (flag `lotusCallState=1`) | scraping EC's DOM for `[data-lk-speaking]` (kept only as fallback) |
|
||||||
|
| A5 | **Focus participant** | host sends `io.lotus.focus_participant` to pin a tile, coexisting with / overriding the screenshare spotlight | the `.click()`-the-tile DOM hack in `CallControl.ts` — **removed** |
|
||||||
|
| #6 | **In-call avatar decorations** | host pushes `io.lotus.decorations` (per-user APNG URLs); the fork renders them on EC's video-tile avatars | previously impossible — decorations only showed on our pre-join lobby roster |
|
||||||
|
| #5 | **Native transparent background** | flag `lotusTransparent=1` makes EC's surface transparent so the host wallpaper shows through | the injected `background:none !important` CSS |
|
||||||
|
|
||||||
|
**Now wired (cinny drives them — ⚠️ awaiting live verification):**
|
||||||
|
|
||||||
|
| # | Capability | Widget action | cinny surface |
|
||||||
|
| ----- | -------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
|
||||||
|
| P5-15 | **Audio inject** | `io.lotus.inject_audio` — plays a clip into the call as a separately published track | In-Call Soundboard (uploadable clips) — see below |
|
||||||
|
| P5-31 | **Quality controls** | `io.lotus.set_quality` — sets audio/screenshare encoding bitrate/framerate | Call Quality Controls (user settings + room-admin caps) — see below |
|
||||||
|
|
||||||
|
> Both were dormant capabilities; cinny now drives them (armed via
|
||||||
|
> `lotusAudioInject=1`). The **only** EC item still open is the P5-31
|
||||||
|
> **server-side** quality guard (a `voice-limit-guard`-style sidecar reading
|
||||||
|
> `io.lotus.room_quality`) for hard enforcement across all Matrix clients — the
|
||||||
|
> client cap is best-effort.
|
||||||
|
|
||||||
|
### In-Call Soundboard (P5-15)
|
||||||
|
|
||||||
|
A soundboard button (🔔) in the call controls bar opens a popout of the user's
|
||||||
|
clips. Clicking one **injects it into the call as a real published LiveKit
|
||||||
|
track** (every participant hears it, via the fork's `io.lotus.inject_audio`) and
|
||||||
|
plays it locally for the presser (LiveKit doesn't loop your own track back).
|
||||||
|
|
||||||
|
- **User-uploadable, like custom emoji/sticker packs.** Clips are stored in the
|
||||||
|
`io.lotus.soundboard` account data event, so they **sync across all your
|
||||||
|
devices**. Upload short audio (≤ 1 MB, ≤ 40 clips) from the popout; delete
|
||||||
|
inline.
|
||||||
|
- Authenticated media can't be fetched from the widget's realm, so the host
|
||||||
|
resolves each mxc clip → an authenticated download → a same-session `blob:`
|
||||||
|
object URL and hands that to the widget.
|
||||||
|
- Gated by the **Soundboard** toggle (Settings → General → Calls) with a volume
|
||||||
|
slider. The button is hidden when disabled.
|
||||||
|
- Files: `utils/soundboardClips.ts`, `hooks/useSoundboard.ts`,
|
||||||
|
`features/call/CallSoundboard.tsx`, `plugins/call/CallControl.ts#injectAudio`.
|
||||||
|
|
||||||
|
### Call Quality Controls (P5-31)
|
||||||
|
|
||||||
|
Discord-style encoding controls applied to the local tracks via the fork's
|
||||||
|
`io.lotus.set_quality` (`RTCRtpSender.setParameters` across all simulcast
|
||||||
|
encodings, re-applied on every re-publish/reconnect).
|
||||||
|
|
||||||
|
- **User settings** (Settings → General → Calls): Microphone Bitrate,
|
||||||
|
Screenshare Bitrate, Screenshare Framerate (each defaults to **Auto**).
|
||||||
|
- **Room-admin caps**: admins set a ceiling in Room Settings → General → Voice
|
||||||
|
(`io.lotus.room_quality` state event); every Lotus client clamps its per-user
|
||||||
|
quality to `min(user setting, room cap)`.
|
||||||
|
- Applied by the `useCallQuality` hook on join and whenever settings/caps
|
||||||
|
change; `utils/callQuality.ts` builds the payload (unit-tested).
|
||||||
|
|
||||||
|
**Server-enforced call permissions (hard, ALL clients).** The same
|
||||||
|
`io.lotus.room_quality` event carries a **publish-source policy**
|
||||||
|
(`allow_screenshare`, `allow_camera`) enforced server-side by
|
||||||
|
`voice-limit-guard` (matrix repo, LXC 151): it re-signs the LiveKit JWT's
|
||||||
|
`canPublishSources`, so the SFU refuses screenshare/camera tracks for **every**
|
||||||
|
Matrix client (Element, FluffyChat, our fork) — not just Lotus. Admins toggle
|
||||||
|
these in Room Settings → Voice → **Call Permissions**; cinny also hides the
|
||||||
|
blocked buttons in the call bar. Enforcement is **live**: the JWT re-sign covers
|
||||||
|
new joins, and a background reconcile loop revokes an **in-progress**
|
||||||
|
screenshare/camera (via LiveKit `UpdateParticipant`) within ~3 s of an admin
|
||||||
|
flipping the policy — so it kills active shares mid-call, not just future ones.
|
||||||
|
|
||||||
|
- **Why numeric caps aren't server-enforced:** LiveKit is a pure SFU (forwards,
|
||||||
|
never transcodes) and has no publisher bitrate/fps field anywhere in the JWT
|
||||||
|
grant, room config, server `limit:`, or admin API; stock Element Call ignores
|
||||||
|
room metadata for publish quality. Numeric caps are therefore inherently
|
||||||
|
**cooperative** — our fork honors them, which is the design above. The
|
||||||
|
publish-source policy is the one genuine hard, cross-client lever, and it's
|
||||||
|
implemented.
|
||||||
|
- **Not yet**: screenshare resolution control (needs a `getDisplayMedia` hook in
|
||||||
|
the fork).
|
||||||
|
|
||||||
### Camera Default Off
|
### Camera Default Off
|
||||||
|
|
||||||
@@ -422,7 +514,7 @@ A comprehensive mic noise-suppression system in **Settings → General → Calls
|
|||||||
|
|
||||||
**Advanced Features & Test Options:**
|
**Advanced Features & Test Options:**
|
||||||
|
|
||||||
- **Multiple ML Models:** Toggle between **RNNoise** (standard hybrid) and **Speex** (legacy DSP-based) to compare artifact levels and suppression strength.
|
- **Multiple ML Models:** Four in-source models, selectable from a dropdown **ordered by quality/CPU** (best first): **DeepFilterNet 3** (48 kHz, best), **DTLN** (16 kHz), **RNNoise** (48 kHz), **Speex** (48 kHz, lightest). The **tier default is Browser-native**; when a user opts into ML the default model is **DeepFilterNet 3**.
|
||||||
- **Series Suppression (Combination):** Optional toggle to run the browser's native stationary noise filter _before_ the ML model. This allows testing the individual performance of the ML model vs the combined effectiveness at removing fan hum.
|
- **Series Suppression (Combination):** Optional toggle to run the browser's native stationary noise filter _before_ the ML model. This allows testing the individual performance of the ML model vs the combined effectiveness at removing fan hum.
|
||||||
- **Noise Gate:** Configurable hardware-style gate with a dB threshold. Hard-cuts all audio when input is below the threshold, ensuring absolute silence between sentences.
|
- **Noise Gate:** Configurable hardware-style gate with a dB threshold. Hard-cuts all audio when input is below the threshold, ensuring absolute silence between sentences.
|
||||||
- **Live Microphone Meter:** A real-time volume visualizer in the settings panel to help users accurately tune their Noise Gate threshold.
|
- **Live Microphone Meter:** A real-time volume visualizer in the settings panel to help users accurately tune their Noise Gate threshold.
|
||||||
@@ -431,20 +523,44 @@ A comprehensive mic noise-suppression system in **Settings → General → Calls
|
|||||||
- **Support Detection:** UI now detects `AudioWorklet` / `AudioContext` support and disables ML options in unsupported environments.
|
- **Support Detection:** UI now detects `AudioWorklet` / `AudioContext` support and disables ML options in unsupported environments.
|
||||||
- **Status Reporting:** The ML shim notifies the host app via `postMessage`. If initialization fails, a system toast alerts the user of the fallback to the raw microphone.
|
- **Status Reporting:** The ML shim notifies the host app via `postMessage`. If initialization fails, a system toast alerts the user of the fallback to the raw microphone.
|
||||||
|
|
||||||
**Open-Source Model Roadmap:**
|
**Open-Source Models (all now in-source in the EC fork):**
|
||||||
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) |
|
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) | Sample rate |
|
||||||
| :--- | :--- | :--- | :--- |
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
| **RNNoise** | Poor | Moderate | < 5% |
|
| **DeepFilterNet 3** (ML default) | **Excellent** | **Very High** | 25-50%+ | 48 kHz |
|
||||||
| **DTLN** | Good | High | 10-20% |
|
| **DTLN** | Good | High | 10-20% | 16 kHz |
|
||||||
| **DeepFilterNet 3** | **Excellent** | **Very High** | 25-50%+ |
|
| **RNNoise** | Poor | Moderate | < 5% | 48 kHz |
|
||||||
|
| **Speex** | Poor | Low | < 5% | 48 kHz |
|
||||||
|
|
||||||
> **Note:** DeepFilterNet 3 is planned for future inclusion in the desktop build where larger binaries and higher CPU overhead are more acceptable.
|
> **Update (2026-06):** with the EC fork live, denoise runs **inside** Element
|
||||||
|
> Call as a LiveKit `TrackProcessor` and **all four models ship in-source**
|
||||||
|
> (DTLN at 16 kHz, the rest at 48 kHz; the processor degrades to the raw mic
|
||||||
|
> rather than ever going silent). The model picker selects between them.
|
||||||
|
|
||||||
|
> **Update (2026-07) — quality, reliability & AEC/AGC:**
|
||||||
|
>
|
||||||
|
> - **Quality tuning** (addresses the "robotic/underwater" RNNoise reports):
|
||||||
|
> a **dry/wet attenuation floor** (default ~-16 dB) blends a little raw mic
|
||||||
|
> under the denoised signal so suppression can't fully collapse the noise
|
||||||
|
> floor — applied only to the low-latency flat models (RNNoise/Speex); DTLN/DFN
|
||||||
|
> would comb-filter, so they rely on their own level. The **noise gate now runs
|
||||||
|
> after the ML stage**, and **DeepFilterNet 3 level 80 → 60**. Tunable via the
|
||||||
|
> `lotusDenoiseFloor` param.
|
||||||
|
> - **AEC/AGC:** browser **echo cancellation stays ON**, but the ML tier now sets
|
||||||
|
> **auto gain control OFF** (`autoGainControl=false`) so the browser's dynamic
|
||||||
|
> gain doesn't fight the ML model. Browser/off tiers keep AGC on. (Remote
|
||||||
|
> playback stays on standard elements — no AEC-defeat vector.)
|
||||||
|
> - **Reliability:** never-silent watchdog (auto-resume a suspended context),
|
||||||
|
> `resume()` timeout (no track-lock deadlock), rejected-WASM-fetch eviction
|
||||||
|
> (transient failures recover), activation off the local participant (works
|
||||||
|
> solo), and init/build-failure leak fixes.
|
||||||
|
> - Real-call **audio-quality** A/B (model choice, floor value, AGC on/off) is the
|
||||||
|
> open by-ear validation item — see `LOTUS_TESTING.md` §D2-1.
|
||||||
|
|
||||||
### Files
|
### Files
|
||||||
|
|
||||||
- `build/lotus-denoise.js` — multi-model getUserMedia shim
|
- **EC fork** `src/lotus/lotusDenoise.ts` + `lotusDenoiseProcessor.ts` — in-source LiveKit `TrackProcessor` (RNNoise/Speex 48 kHz, DTLN 16 kHz, DeepFilterNet 48 kHz); activated by `lotusDenoiseSource=1`. (The old build-time `getUserMedia` shim `build/lotus-denoise.js` is **removed**.)
|
||||||
- `vite.config.js` — `lotusDenoise()` plugin (copies assets for RNNoise, Speex, and NoiseGate)
|
- `vite.config.js` — `lotusDenoise()` plugin (now only **copies model assets** for the fork to load; no longer injects a shim)
|
||||||
- `src/app/plugins/call/CallEmbed.ts` — advanced tier → widget URL params
|
- `src/app/plugins/call/CallEmbed.ts` — advanced tier → `lotusDenoiseSource` widget URL param
|
||||||
- `src/app/utils/lotusDenoiseUtils.ts` — support detection and model comparison metadata
|
- `src/app/utils/lotusDenoiseUtils.ts` — support detection and model comparison metadata
|
||||||
- `src/app/features/settings/general/General.tsx` — advanced settings UI + mic meter
|
- `src/app/features/settings/general/General.tsx` — advanced settings UI + mic meter
|
||||||
|
|
||||||
@@ -575,6 +691,24 @@ Context menu → **Forward** allows forwarding a message to any room the user is
|
|||||||
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API
|
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API
|
||||||
- A chip shows the active date range with an **×** button to clear it
|
- A chip shows the active date range with an **×** button to clear it
|
||||||
|
|
||||||
|
### Encrypted Search Cache (P4-8, opt-in)
|
||||||
|
|
||||||
|
Persistent local index for encrypted-room search, so coverage survives page reloads instead of requiring re-pagination + re-decryption every session.
|
||||||
|
|
||||||
|
- Raw IndexedDB (`lotus-search-cache`): message rows keyed `[roomId, eventId]` + per-room coverage markers; merged into local search results with in-memory-wins dedupe
|
||||||
|
- **Opt-in, default OFF** (it stores decrypted text at rest): toggle + "Clear cached index" live in the search panel's Encrypted Rooms section, with the privacy note "Stores decrypted text on this device"
|
||||||
|
- Always wiped on logout; any IndexedDB error degrades to a cache-miss (never breaks search)
|
||||||
|
- Files: `src/app/utils/searchCache.ts`, `src/app/state/searchCacheEnabled.ts`, `features/message-search/useLocalMessageSearch.ts`
|
||||||
|
|
||||||
|
### Math / LaTeX Rendering (P4-4)
|
||||||
|
|
||||||
|
KaTeX-rendered math in messages, two paths:
|
||||||
|
|
||||||
|
- **Spec path (CS-API §11.5):** `<span/div data-mx-maths="…">` in `formatted_body` renders the attribute's LaTeX (block for div, inline for span); on render failure the element's child fallback content shows instead
|
||||||
|
- **Plain-text path:** `$…$` (inline) and `$$…$$` (block) with conservative rules — escape-aware (`\$`), currency-guarded (`$5 and $10` stays text), never inside `code`/`pre`
|
||||||
|
- KaTeX + its CSS load lazily on first math encountered — zero cost to the main bundle
|
||||||
|
- Files: `src/app/utils/mathParse.ts` (+14 tests), `components/math/KaTeX.tsx`, `plugins/react-custom-html-parser.tsx`
|
||||||
|
|
||||||
### Image / Video Captions
|
### Image / Video Captions
|
||||||
|
|
||||||
Images and videos can be sent with a caption. The caption and media are sent as a single event.
|
Images and videos can be sent with a caption. The caption and media are sent as a single event.
|
||||||
@@ -650,6 +784,36 @@ Generic (non-domain-specific) cards display a Google S2 favicon. Empty or unpars
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Threads (P3-8)
|
||||||
|
|
||||||
|
Full threaded-conversation support (`m.thread`, matrix-js-sdk `threadSupport`), Element-consistent.
|
||||||
|
|
||||||
|
### Thread Panel
|
||||||
|
|
||||||
|
A right-side drawer (mirrors the members drawer; fullscreen on mobile) with the thread's root message emphasized at top, an "N replies" divider, the full reply timeline (virtualized, back-paginates via `/relations`, decrypts E2EE threads), reactions/edits/redactions, and its own composer. Open it from **Reply in Thread** in the message menu, a reply's thread indicator, or a summary chip; close with **×** or Escape. Reading the panel sends threaded read receipts so per-thread unread counts clear.
|
||||||
|
|
||||||
|
### Summary Chips
|
||||||
|
|
||||||
|
Root messages in the main timeline show a **"N replies · time"** chip (server-aggregated `m.thread` bundle, or the live Thread once loaded) with an unread badge — threaded replies no longer render inline in the main timeline, so the chip is how conversations stay discoverable.
|
||||||
|
|
||||||
|
### Thread Composer
|
||||||
|
|
||||||
|
The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues **isolated per thread** (`roomId::threadRootId` keys). Replies-to-replies produce spec-correct `m.thread` + `m.in_reply_to` (`is_falling_back: false`). Scheduling and slash commands are disabled inside threads (v1).
|
||||||
|
|
||||||
|
### Notifications (Slack-style, P4-1)
|
||||||
|
|
||||||
|
By default you're notified for a thread reply only when you **participate** in that thread (you've posted in it) or the reply **@mentions** you — other threads accumulate quietly behind their chip badges. Every thread can be overridden from the bell menu in the panel header: **Default (participating) / All replies / Mentions only / Mute**. Modes sync across your devices (`io.lotus.thread_notifications` account data, auto-pruned). Muting a thread silences notifications and sounds, removes the chip's unread badge (a small bell-mute glyph shows instead), and subtracts that thread from the room's sidebar unread badge (client-side — other Matrix clients on the account still count it).
|
||||||
|
|
||||||
|
### Under the Hood
|
||||||
|
|
||||||
|
- `threadSupport: true` (startClient) partitions thread events into SDK `Thread` timelines; markAsRead sends **unthreaded** receipts so room badges keep clearing
|
||||||
|
- Thread replies are notified via exactly one path (room-level `ThreadEvent.NewReply` w/ per-thread dedupe + panel-aware focus suppression); the main timeline notifier is thread-guarded, and room badges refresh live on `RoomEvent.UnreadNotifications`
|
||||||
|
- Pending sends render via a `LocalEchoUpdated` strip (chronological local echo never enters thread timelineSets)
|
||||||
|
- Deep links to thread events redirect into the panel
|
||||||
|
- Files: `features/room/thread/*`, `state/room/thread.ts`, `hooks/useThreadSummary.ts` (+35 tests across the stack)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Presence
|
## Presence
|
||||||
|
|
||||||
### Discord-Style Presence Selector
|
### Discord-Style Presence Selector
|
||||||
@@ -741,6 +905,14 @@ Hook: `src/app/hooks/useUserNotes.ts`
|
|||||||
|
|
||||||
## UX & Composer
|
## UX & Composer
|
||||||
|
|
||||||
|
### Forward to Multiple Rooms (P6-3)
|
||||||
|
|
||||||
|
The Forward Message dialog is a checkbox multi-select: pick any number of rooms (search + select persist across queries) and **"Send to N rooms"** forwards in one batch (`Promise.allSettled`). Full success auto-closes; a partial failure keeps the dialog open with a "Forwarded to X/N — failed: …" summary. The forwarded content (latest edit via `m.new_content`, reply-quote stripped, undecryptable refused) is built by the shared, unit-tested `forwardContent.ts`.
|
||||||
|
|
||||||
|
### Live Bookmark Previews (P6-3)
|
||||||
|
|
||||||
|
`BookmarksPanel` resolves each saved message's **live event** (`useRoomEvent`) so previews reflect **edits** and show a **deleted** indicator for redactions, instead of the save-time snapshot. The stored snapshot (`previewText`) remains the fallback while loading, on fetch failure, or when you've **left the room**.
|
||||||
|
|
||||||
### Message Length Counter
|
### Message Length Counter
|
||||||
|
|
||||||
A character count indicator is shown in the composer when `charCount > 0`. The counter resets to zero when switching rooms.
|
A character count indicator is shown in the composer when `charCount > 0`. The counter resets to zero when switching rooms.
|
||||||
@@ -1015,6 +1187,18 @@ Three one-tap presets at the top of **Settings → Notifications** that apply a
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Accessibility (P3-4)
|
||||||
|
|
||||||
|
WCAG 2.1 AA hardening of the golden path (find room → read → reply → send) for keyboard and screen-reader users.
|
||||||
|
|
||||||
|
- **Timeline for screen readers:** each message is `role="article"`; **collapsed messages announce their sender + time** (they drop the visible header, so AT would otherwise hear the body with no attribution). The timeline is a `role="log"` `aria-live="polite"` region so new messages are announced; emoji/emoticons carry text labels.
|
||||||
|
- **Live status:** typing indicators announce via a `role="status"` region; editing a message announces "Editing message from <sender>".
|
||||||
|
- **Forms & overlays:** all inputs have associated labels (visible `<label htmlFor>` or `aria-label`); the Media Gallery and Search overlays are named.
|
||||||
|
- **Focus management:** skip-to-content link + `nav`/`main` landmarks; genuine dialogs return focus to their trigger on close (inline popouts intentionally keep focus in context).
|
||||||
|
- **Keyboard-shortcuts help:** press <kbd>?</kbd> for a dialog of the existing shortcuts (Escape, type-to-focus composer, Enter/Shift+Enter send, message actions).
|
||||||
|
- **Regression gate:** a curated `eslint-plugin-jsx-a11y` rule set (ARIA correctness + label association) runs in CI. Files: `components/message/*`, `features/room/RoomViewTyping.tsx`, `features/shortcuts/*`, `utils/a11y.ts`, `eslint.config.mjs`.
|
||||||
|
- _Known limitation:_ list virtualization keeps far-scrolled history out of the a11y tree (perf trade-off); newly-arriving messages are announced.
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
### Authenticated Media
|
### Authenticated Media
|
||||||
@@ -1045,6 +1229,80 @@ The `useAuthentication` parameter was previously mispositioned, causing unauthen
|
|||||||
|
|
||||||
The `encUrlPreview` setting defaults to `true` rather than `false`. A security advisory chip in **Settings → Privacy** explains the tradeoff (the homeserver can see which URLs are being previewed) so users can make an informed choice.
|
The `encUrlPreview` setting defaults to `true` rather than `false`. A security advisory chip in **Settings → Privacy** explains the tradeoff (the homeserver can see which URLs are being previewed) so users can make an informed choice.
|
||||||
|
|
||||||
|
### Hardened Session Storage (N97 partial, 2026-07)
|
||||||
|
|
||||||
|
The session persists as ONE atomic `cinny_session_v1` JSON write (previously ~10 separate localStorage keys written non-atomically). Reads prefer the blob with transparent migration from the legacy keys (dual-written one release for rollback). Cross-tab sync: logging out or in from one tab reloads the others so no tab runs with stale credentials. `state/sessions.ts` (22 tests), `hooks/useSessionSync.ts`.
|
||||||
|
|
||||||
|
### Crypto Diagnostics (E2EE investigation kit)
|
||||||
|
|
||||||
|
**Settings → Developer Tools → Crypto Diagnostics**: a capture-only ring buffer (max 200) hooks `console.warn/error` for E2EE failure signatures (OTK upload conflicts, missing call media keys, decryption errors, delayed-event timeouts) and downloads a JSON report — the evidence input for the KE-1→4 investigation. Companion diagnosis: the Encryption / E2EE section of [`LOTUS_TODO.md`](./LOTUS_TODO.md). `utils/cryptoDiagLog.ts`, `features/settings/developer/CryptoDiagnostics.tsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Desktop App Features
|
||||||
|
|
||||||
|
Native capabilities of the Lotus Chat **Tauri v2** desktop app (Windows, macOS, Linux) on top of the shared web client. Web hooks live in `src/app/hooks/useTauri*.ts` (each no-ops in the browser) and call Rust commands in `cinny-desktop/src-tauri/src/native/*`. Windows-only pieces are `#[cfg(target_os = "windows")]`, compile-verified in CI (Windows runners).
|
||||||
|
|
||||||
|
### Call Continuity — No-Sleep (P5-46)
|
||||||
|
|
||||||
|
Holds the system awake (`SetThreadExecutionState`) while a voice/video call is active; releases on end. `useTauriCallPower` ↔ `native/power.rs`.
|
||||||
|
|
||||||
|
### Windows Jump List (P5-36)
|
||||||
|
|
||||||
|
Right-click the taskbar icon → a **Recent Rooms** list of your most-active rooms; each entry opens that room via the `matrix:` deep-link. `useTauriJumpList` ↔ `native/jumplist.rs` (`ICustomDestinationList`).
|
||||||
|
|
||||||
|
### Taskbar Thumbnail Toolbar (P5-44)
|
||||||
|
|
||||||
|
Hover the taskbar preview during a call → **Mute / Deafen / End Call** buttons. `useTauriThumbbar` ↔ `native/thumbbar.rs` (`ITaskbarList3` + a window subclass for `THBN_CLICKED`).
|
||||||
|
|
||||||
|
### System Media Transport Controls — SMTC (P5-43)
|
||||||
|
|
||||||
|
Exposes call status + a mute control to the Windows volume-flyout / media overlay (WinRT `SystemMediaTransportControls`). `useTauriSmtc` ↔ `native/smtc.rs`. _Experimental — may require an active audio session to surface._
|
||||||
|
|
||||||
|
### Network Awareness (P5-49)
|
||||||
|
|
||||||
|
Detects Windows connectivity changes (`INetworkListManager`) and nudges the Matrix client to reconnect (`retryImmediately`). `useTauriNetwork` ↔ `native/network.rs`.
|
||||||
|
|
||||||
|
### Instant Background Sync (P5-42)
|
||||||
|
|
||||||
|
Keeps the `/sync` loop + notifications running full-speed while the app is closed to the tray, by disabling Chromium background throttling via WebView2 `additional_browser_args` (`lib.rs`) — no separate background process. Windows/WebView2 only; doesn't block system sleep.
|
||||||
|
|
||||||
|
### Native Rich Notifications (P5-41 / P5-35)
|
||||||
|
|
||||||
|
Windows toasts with **click-to-open-room** and **inline quick reply** (WinRT `ToastNotification`, in-process `Activated` event). Falls back to the standard toast otherwise. `useTauriToastActions` ↔ `native/toast.rs`; the desktop notification bridge routes room notifications to it.
|
||||||
|
|
||||||
|
### Focus Assist Sync (P5-56)
|
||||||
|
|
||||||
|
When Windows Focus Assist / Quiet Hours is active, Lotus suppresses its own notifications + sounds (reuses the quiet-hours gate). `useTauriFocusAssist` + `focusAssistActiveAtom` ↔ `native/focus_assist.rs` (`SHQueryUserNotificationState`).
|
||||||
|
|
||||||
|
### Linux parity + cross-platform extras (P6-1)
|
||||||
|
|
||||||
|
Rounds out the native app beyond Windows (macOS out of scope):
|
||||||
|
|
||||||
|
- **No-sleep during calls on Linux** — a D-Bus `org.freedesktop.ScreenSaver` inhibit (zbus) keeps the display awake mid-call, matching the Windows behavior. `native/power.rs`.
|
||||||
|
- **Launcher unread badge on Linux** — best-effort Unity `LauncherEntry` D-Bus signal (Ubuntu/Dash-to-Dock/KDE), mirroring the Windows taskbar badge.
|
||||||
|
- **Launch on login** — `tauri-plugin-autostart` + a **Settings → General "Launch on login"** toggle (desktop-only).
|
||||||
|
- **Tray "Do Not Disturb"** — a tray checkbox that silences Lotus notifications (feeds `manualDndAtom` into the same quiet-gate as Focus Assist). `useTauriDnd`.
|
||||||
|
|
||||||
|
### Custom Window Chrome (P5-47)
|
||||||
|
|
||||||
|
Opt-in (Settings → General → **Custom Window Chrome**): replaces the OS title bar with a TDS-styled titlebar (min / max / close + drag region), runtime-reversible via `set_decorations`. `features/desktop/TitleBar.tsx` + `useTauriWindowChrome` ↔ `native/chrome.rs`.
|
||||||
|
|
||||||
|
### Proactive Update Toast (P5-40)
|
||||||
|
|
||||||
|
Checks for a new desktop release every 12h and offers a one-click update. `TauriUpdateFeature` (ClientNonUIFeatures) + `useTauriUpdater`.
|
||||||
|
|
||||||
|
### Cross-platform composer niceties
|
||||||
|
|
||||||
|
- **Composer toolbar drag-reorder (P5-55)** — drag to reorder the composer buttons (Settings → General), via `@atlaskit/pragmatic-drag-and-drop`.
|
||||||
|
- **Draft-saved indicator (P5-57)** — a subtle cue in the composer when the current room has a persisted draft.
|
||||||
|
- **Recursive folder drag-drop (P5-48)** — drop a folder to upload every file inside it (all nesting levels), `utils/fileEntries.ts`.
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
- Web: `src/app/hooks/useTauri*.ts`, `src/app/components/TauriDesktopFeatures.tsx`, `src/app/features/desktop/TitleBar.tsx`, `src/app/features/room/DraftIndicator.tsx`, `src/app/utils/fileEntries.ts`, `src/app/state/{customWindowChrome,focusAssist}.ts`.
|
||||||
|
- Native (`cinny-desktop`): `src-tauri/src/native/{power,jumplist,thumbbar,smtc,network,chrome,toast,focus_assist}.rs` + `native/mod.rs` (registered in `lib.rs`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Key Custom Files
|
## Key Custom Files
|
||||||
|
|||||||
+194
-11
@@ -1,6 +1,6 @@
|
|||||||
# Lotus Chat — Manual Testing Guide
|
# Lotus Chat — Manual Testing Guide
|
||||||
|
|
||||||
**Generated:** June 2026
|
**Generated:** June 2026 · **Updated:** July 2026 (added §O — threads, per-thread notifications, math, search cache, session hardening, audit wave, desktop CSP)
|
||||||
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
|
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
|
||||||
|
|
||||||
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
|
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
|
||||||
@@ -267,12 +267,59 @@ Flag: `lotusTransparent=1` (native, replacing the injected `background:none !imp
|
|||||||
- [ ] Call background looks right — host wallpaper/surface shows through; **no** black box, bad
|
- [ ] Call background looks right — host wallpaper/surface shows through; **no** black box, bad
|
||||||
see-through, or layout breakage (also covered loosely by §D2 "looks right").
|
see-through, or layout breakage (also covered loosely by §D2 "looks right").
|
||||||
|
|
||||||
### D2-6. Dormant features — confirm they do NOTHING (no regression)
|
### D2-7. In-Call Soundboard (#3 / P5-15) — 👥 2 people — **NEW**
|
||||||
|
|
||||||
EC ships the capability but cinny has **no UI** to trigger them yet:
|
Flag: `lotusAudioInject=1`. A 🔔 **Soundboard** button now sits in the call controls bar (left group,
|
||||||
|
next to the chat button). Clips are user-uploadable and sync across your devices like emoji packs.
|
||||||
|
_Prereq:_ Settings → General → Calls → **Soundboard** must be ON (default on).
|
||||||
|
|
||||||
- [ ] **Soundboard audio-inject (#3)** and **quality controls (#7)** — there should be no new UI and no
|
- [ ] **Upload:** open the soundboard popout → **Upload** → pick a short audio file (mp3/ogg/wav, ≤ 1 MB).
|
||||||
effect. (Nothing to test; noted so a tester doesn't go hunting.)
|
It appears as a clip tile. (Too-big / too-many shows an error, doesn't crash.)
|
||||||
|
- [ ] **Plays into the call:** with a second person in the call, click a clip. **They hear it**, and
|
||||||
|
**you hear it locally** too. ✅ good if both hear it; ❌ tell us if only one side does.
|
||||||
|
- [ ] **Sync:** the uploaded clip shows up on your **other device**/session (account-data sync).
|
||||||
|
- [ ] **Delete:** the ✕ on a tile removes it (everywhere, after sync).
|
||||||
|
- [ ] **Off switch:** turn Settings → Calls → **Soundboard** off → the call-bar button disappears.
|
||||||
|
- [ ] Injecting a clip does **not** mute/interrupt your mic or anyone else's audio.
|
||||||
|
|
||||||
|
### D2-8. Call Quality Controls (#7 / P5-31) — 👥 2 people — **NEW**
|
||||||
|
|
||||||
|
Action: `io.lotus.set_quality`. User settings in **Settings → General → Calls** (Microphone Bitrate,
|
||||||
|
Screenshare Bitrate, Screenshare Framerate; all default **Auto**). Admin caps in **Room Settings →
|
||||||
|
General → Voice → Call Quality Caps**.
|
||||||
|
|
||||||
|
- [ ] **No regression at Auto:** with everything on **Auto**, calls/screenshare work exactly as before.
|
||||||
|
- [ ] **User cap takes effect:** set Microphone Bitrate to **32 kbps**, rejoin/continue a call — audio
|
||||||
|
still flows (thinner is fine). Set Screenshare Framerate to **15 fps** and share your screen — it
|
||||||
|
still shares. ❌ tell us if any setting kills audio/screenshare.
|
||||||
|
- [ ] **Applies mid-call:** changing a setting **during** a call takes effect without End+rejoin.
|
||||||
|
- [ ] **Room-admin cap (admin needed):** as a room admin, set **Max Microphone Bitrate = 64 kbps** in
|
||||||
|
Room Settings → Voice. A member whose user setting is higher (e.g. 256) should be **clamped to 64**
|
||||||
|
(best-effort/UX — this is client-side; hard server enforcement is a separate follow-up).
|
||||||
|
- [ ] Resetting a setting back to **Auto** removes the cap for the rest of the call.
|
||||||
|
|
||||||
|
> Soundboard + quality are no longer "dormant" — if either does nothing, grab the **EC iframe console**
|
||||||
|
> and check for `io.lotus.inject_audio` / `io.lotus.set_quality` rejections.
|
||||||
|
|
||||||
|
### D2-9. Call Permissions — HARD server-side, cross-client (👥 2 people, admin) — **NEW**
|
||||||
|
|
||||||
|
This is enforced by the `voice-limit-guard` on the server (re-signs the LiveKit JWT), so it applies to
|
||||||
|
**every** client, not just Lotus Chat. Set in **Room Settings → General → Voice → Call Permissions**.
|
||||||
|
_(Requires the guard deployed on LXC 151 — auto-deploys on a `matrix` repo push.)_
|
||||||
|
|
||||||
|
- [ ] **Disable screenshare:** as admin, turn **Allow Screen Sharing** off. In a call, the
|
||||||
|
**screenshare button disappears** in Lotus Chat. ✅ good if no one can screenshare.
|
||||||
|
- [ ] **Cross-client (the important one):** have someone join the **same room from stock Element / Element
|
||||||
|
X** and try to screenshare → the server **refuses** the track (it won't publish). This proves it's
|
||||||
|
not just our client hiding a button.
|
||||||
|
- [ ] **Audio-only room:** turn **Allow Camera** off too → the camera button disappears and cameras are
|
||||||
|
server-blocked for all clients; **microphones still work**.
|
||||||
|
- [ ] **⭐ Live kill (mid-call):** while someone is **actively screensharing**, an admin turns **Allow
|
||||||
|
Screen Sharing** off. Within a few seconds their screenshare should **stop for everyone** on its own
|
||||||
|
(no rejoin needed) — this is the server reconcile loop revoking it live. Works even if the sharer is
|
||||||
|
on stock Element. ✅ good if the share drops within ~3–5 s; ❌ tell us if it keeps going.
|
||||||
|
- [ ] **Turning it back on** restores the ability to screenshare/camera (start a new share).
|
||||||
|
- [ ] **No policy = no change:** a room with Call Permissions left on defaults behaves exactly as before.
|
||||||
|
|
||||||
> If any D2 item fails, grab the **EC iframe console** (right-click the call → inspect the iframe) — a
|
> If any D2 item fails, grab the **EC iframe console** (right-click the call → inspect the iframe) — a
|
||||||
> widget-action/payload mismatch shows up there as a `io.lotus.*` rejection or a `MissingKey`/transport log.
|
> widget-action/payload mismatch shows up there as a `io.lotus.*` rejection or a `MissingKey`/transport log.
|
||||||
@@ -281,7 +328,7 @@ EC ships the capability but cinny has **no UI** to trigger them yet:
|
|||||||
|
|
||||||
# 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)
|
||||||
|
|
||||||
@@ -526,10 +573,146 @@ Log into **matrix.lotusguild.org** (password) and **matrix.org**.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## O. July 2026 batch — threads, notifications, math, search cache, audit wave
|
||||||
|
|
||||||
|
Everything landed after the OIDC work. These mirror the checklists in `LOTUS_TODO.md` (§P3-8, §P4-1) and the 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
|
||||||
|
|
||||||
|
1. Hover a message → **Reply in Thread** (message menu). The right-side **thread panel** opens with that message as the root.
|
||||||
|
2. Send text, an emoji, and a file upload into the thread; have the second person reply too.
|
||||||
|
3. Reply to a reply _inside_ the panel.
|
||||||
|
|
||||||
|
**Expected:** the panel shows the root at top + an "N replies" divider + the reply timeline (own composer at the bottom). Your sends appear immediately (pending → confirmed). A reply-to-a-reply is a proper thread reply. In the **main** timeline the replies do **not** appear inline — the root message instead shows a **"N replies · time"** chip. Clicking the chip (or a reply's thread indicator) opens the panel. **×** or **Escape** closes it; on mobile the panel is fullscreen. Scrolled up in a long thread → a **Jump to Latest** chip appears. Reload the page → the root/reply split persists; in an **encrypted** room the thread replies decrypt (not "Unable to decrypt").
|
||||||
|
|
||||||
|
### O2. Per-thread notifications (P4-1, Slack-style) — 👥 2 people
|
||||||
|
|
||||||
|
1. Have the second person reply in a thread **you have posted in** → expect a notification + sound.
|
||||||
|
2. Have them reply in a thread **you have never touched** and don't @mention you → expect **silence** (only the chip's unread badge updates).
|
||||||
|
3. Have them **@mention** you in any thread → expect a notification regardless of participation.
|
||||||
|
4. Open the panel's **bell menu** (header) → set the thread to **Mute** → expect no notifications, the chip's unread badge gone (bell-mute glyph shown), and the room's **sidebar badge drops** by that thread's count. Try **All** (every reply notifies) and **Mentions only** (only @mentions).
|
||||||
|
5. On a **second device**, confirm the same per-thread modes are set (they sync via account data).
|
||||||
|
6. Room-level **Mute** (room context menu) still silences everything, including thread overrides.
|
||||||
|
|
||||||
|
**Known caveat:** Mentions-only can under-notify in E2EE rooms (the decision runs before decryption). Muted-thread badge subtraction is Lotus-only.
|
||||||
|
|
||||||
|
### O3. Math / LaTeX (P4-4)
|
||||||
|
|
||||||
|
Send each and confirm rendering: `$x^2 + y^2$` (inline), `$$\int_0^1 f(x)\,dx$$` (block, centered), `$5 and $10 for lunch` (**stays plain text** — currency guard), and a code block containing `$x$` (**stays literal** inside the code block). **Expected:** the first two render as math (KaTeX); the last two are untouched. First math of the session may show the raw `$…$` for a beat while the KaTeX chunk lazy-loads, then renders.
|
||||||
|
|
||||||
|
### O4. Encrypted search cache (P4-8) — opt-in
|
||||||
|
|
||||||
|
In an **encrypted** room's message search, enable **"Persist search index on this device"** (Encrypted Rooms panel). Search, then **reload** and search the same term. **Expected:** coverage survives the reload (results without re-paginating everything). **Clear cached index** empties it. **Log out** → the cache is wiped (privacy). Toggling the setting OFF does **not** wipe (only Clear/logout do).
|
||||||
|
|
||||||
|
### O5. Session hardening (N97a) — cross-tab
|
||||||
|
|
||||||
|
1. Log in on a build that predates the change, then load this build → you stay logged in (legacy keys migrate to the `cinny_session_v1` blob; check DevTools → Application → Local Storage).
|
||||||
|
2. Open the app in **two tabs**; **log out** in tab A → tab B reloads to the auth screen within a moment. Log in again in one tab → the other reloads too.
|
||||||
|
|
||||||
|
### O6. Audit-wave correctness fixes (AW-1)
|
||||||
|
|
||||||
|
- **Scheduled-message cancel:** schedule a message, then cancel it **with the network cut** (DevTools offline) → the item **stays** with an inline error (it does **not** silently disappear and still send). Restore network, retry → cancels cleanly.
|
||||||
|
- **Escape coordination:** in a thread panel, open the mention autocomplete or set a reply draft, press **Escape** → it dismisses the autocomplete/reply **without** closing the panel. A bare Escape (nothing to dismiss) still marks the room read / closes the panel as before.
|
||||||
|
- **Panel exclusivity:** on mobile, opening a thread while the media gallery (or members drawer) is open shows only **one** right panel (thread wins), not stacked fullscreen overlays.
|
||||||
|
- **Emoji board (AW-2):** the **first** time you open the emoji board / autocomplete in a session, the grid **and search** populate with unicode emoji (they don't stay empty). Reactions still show a label.
|
||||||
|
|
||||||
|
### O7. Desktop (Tauri) — CSP tighten + native stack (AW-4) — 🖥️ desktop build only
|
||||||
|
|
||||||
|
The webview CSP was tightened and the full native module set now compiles. Smoke-test the desktop build:
|
||||||
|
|
||||||
|
1. App **boots**, avatars + media thumbnails load, the **VT323** terminal font renders (Lotus Terminal theme), a **location message** embeds its OpenStreetMap map, **calls** connect (EC iframe), **deep links** (`matrix:` / clicking a room link) navigate.
|
||||||
|
2. **Native features:** minimize to tray (notifications still arrive), a message notification is a **rich toast** (click opens the room; reply box sends), the taskbar **Jump List** lists recent rooms, in a call the taskbar thumbnail shows **Mute/Deafen/End**, Windows **Focus Assist** silences Lotus.
|
||||||
|
3. **Console** (desktop devtools) shows **no CSP violations** during normal use. If something visual/media is blocked, that's the CSP to loosen — note exactly what and where.
|
||||||
|
|
||||||
|
### O8. E2EE / call-key cluster (KE-1→4) — 👥 2 people, during a real call
|
||||||
|
|
||||||
|
We shipped the diagnostics kit + a **Crypto Diagnostics** card (**Settings → Developer Tools**). During your next call that glitches (audio cutouts, "Unable to decrypt"), open it and **Download report**, and note whether the symptoms even still occur now that we're on **matrix-js-sdk 41.7.0** (crypto-wasm 18.3.1). Send me the report; the KE-1..4 diagnosis + capture guidance is in `LOTUS_TODO.md` (Encryption / E2EE), with the full original runbook in git history.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P. Accessibility (P3-4) — needs a browser + a screen reader
|
||||||
|
|
||||||
|
The compliance fixes are gate-verified in code; these confirm the runtime a11y behavior only a human + AT can check. Tools: browser DevTools "axe" extension / Lighthouse a11y, plus **VoiceOver** (macOS ⌘F5) or **NVDA** (Windows).
|
||||||
|
|
||||||
|
### P1. Keyboard-only golden path (no mouse)
|
||||||
|
|
||||||
|
Tab from page load: **skip-to-content** link appears first (Enter jumps to the timeline). Tab reaches the room list (rooms are focusable, active room announced), open a room (Enter), type a character → focus lands in the composer, send with Enter (or Shift+Enter per your `enterForNewline` setting). No keyboard trap; visible focus ring throughout.
|
||||||
|
|
||||||
|
### P2. `?` shortcuts dialog
|
||||||
|
|
||||||
|
Press **?** (Shift+/) with focus NOT in a text field → the keyboard-shortcuts dialog opens, is focus-trapped, Escape closes it and focus returns to where you were. Pressing `?` while typing in the composer/search inserts a literal `?` (does NOT open the dialog).
|
||||||
|
|
||||||
|
### P3. Screen-reader: reading messages
|
||||||
|
|
||||||
|
With VoiceOver/NVDA on, arrow through the timeline: each message is announced as an article with **sender name + time** — critically, this includes **collapsed messages** (consecutive messages from the same person), which previously announced only the body with no sender. Reactions, "edited", replies, and delivery status are announced with labels.
|
||||||
|
|
||||||
|
### P4. Screen-reader: live announcements
|
||||||
|
|
||||||
|
- **New message** arrives while you're reading → announced (polite).
|
||||||
|
- **Someone starts typing** → "X is typing" announced once (not spammed per keystroke).
|
||||||
|
- **Editing a message** → the edit box announces "Editing message from X".
|
||||||
|
|
||||||
|
### P5. Focus return from dialogs
|
||||||
|
|
||||||
|
Open then close (Escape or ×): the **room topic viewer**, a **reaction viewer** (click a reaction count), and **Search** → focus returns to the button/element you opened them from (not lost to `<body>`). Inline popouts (emoji picker, autocomplete, hover menus) intentionally keep focus in context — that's expected, not a bug.
|
||||||
|
|
||||||
|
### P6. axe / Lighthouse scan
|
||||||
|
|
||||||
|
Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view, Settings, and the login screen. Expect **no critical/serious** "missing accessible name" or "ARIA" violations on the golden path. Report any that appear (note: far-scrolled timeline history being virtualized out is a known, accepted limitation — not a finding).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Priority if you're short on time
|
## Priority if you're short on time
|
||||||
|
|
||||||
1. **A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce.
|
1. **O1 + O2** (threads + per-thread notifications) — the largest new surface; the main-timeline change is user-visible.
|
||||||
2. **B1–B3** (polls on a default theme) — the confirmed visual bug.
|
2. **O7** (desktop CSP smoke) — CI can't catch CSP breakage; a wrong directive silently breaks media/fonts/maps.
|
||||||
3. **D** (EC 0.20.1 control sweep) — guards against the upstream merge breaking calls.
|
3. **O5** (session cross-tab) + **O6** (scheduled-cancel ghost-send) — auth-critical + a real data-loss-class fix.
|
||||||
4. **A7** false-positive check (normal joins don't show the error overlay).
|
4. **A4** (in-call banner) + **A3** (ringtone) — newest call logic, hardest to reproduce.
|
||||||
5. Everything else.
|
5. **D** (EC control sweep) — guards against the fork breaking calls.
|
||||||
|
6. Everything else.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Outstanding verification backlog
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
+365
-108
@@ -35,7 +35,7 @@ Completed features are documented in [LOTUS_FEATURES.md](./LOTUS_FEATURES.md).
|
|||||||
|
|
||||||
## ✅ Done — Awaiting Verification
|
## ✅ Done — Awaiting Verification
|
||||||
|
|
||||||
Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then they graduate to LOTUS_FEATURES.md. (Bug-side fixes awaiting verification live in LOTUS_BUGS.md.)
|
Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then they graduate to LOTUS_FEATURES.md. (Open bugs + the verification backlog now live in this file and LOTUS_TESTING.md.)
|
||||||
|
|
||||||
| Feature | Test guide |
|
| Feature | Test guide |
|
||||||
| :-------------------------------------------------------------------------------- | :---------------- |
|
| :-------------------------------------------------------------------------------- | :---------------- |
|
||||||
@@ -48,6 +48,9 @@ Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then th
|
|||||||
| Desktop — proactive update notifications (Tauri) | J1 |
|
| Desktop — proactive update notifications (Tauri) | J1 |
|
||||||
| Remind Me Later | K1 |
|
| Remind Me Later | K1 |
|
||||||
| Mobile Bookmarks access | E5 |
|
| Mobile Bookmarks access | E5 |
|
||||||
|
| In-Call Soundboard (P5-15, uploadable clips → real call inject) | D2-7 |
|
||||||
|
| Call Quality Controls (P5-31, user + room-admin caps) | D2-8 |
|
||||||
|
| Call Permissions (P5-31, hard server-side screenshare/camera policy) | D2-9 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -72,32 +75,32 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
|
|||||||
|
|
||||||
### Confirmed facts
|
### Confirmed facts
|
||||||
|
|
||||||
| Finding | Impact |
|
| Finding | Impact |
|
||||||
| --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
| -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||||
| **MSC flags ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` · `msc3401_matrix_rtc` | All safe to use now |
|
| **MSC flags ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` · `msc3401_matrix_rtc` | All safe to use now |
|
||||||
| **MSC flags OFF:** `msc4306` (thread subscriptions) · `msc3882` · `msc3912` · `msc4155` | These features are BLOCKED |
|
| **MSC flags OFF:** `msc4306` (thread subscriptions) · `msc3882` · `msc3912` · `msc4155` | These features are BLOCKED |
|
||||||
| **MSC3266** room summary: flag `msc3266_enabled: true` set but `GET /v1/rooms/{id}/summary` still returns 404 (M_UNRECOGNIZED) | Room Preview BLOCKED — endpoint not implemented in Synapse 1.155 |
|
| **MSC3266** room summary: flag `msc3266_enabled: true` set but `GET /v1/rooms/{id}/summary` still returns 404 (M_UNRECOGNIZED) | Room Preview BLOCKED — endpoint not implemented in Synapse 1.155 |
|
||||||
| **MSC3892** relation redaction: not in flags | Reaction Redaction feature BLOCKED |
|
| **MSC3892** relation redaction: not in flags | Reaction Redaction feature BLOCKED |
|
||||||
| **MSC4260** report user: `POST /_matrix/client/v3/users/{userId}/report` returns **200** ✅ | **Report User UNBLOCKED** — endpoint live since Synapse 1.133; ready to build |
|
| **MSC4260** report user: `POST /_matrix/client/v3/users/{userId}/report` returns **200** ✅ | **Report User UNBLOCKED** — endpoint live since Synapse 1.133; ready to build |
|
||||||
| **MSC4151** report room: HTTP 405 on GET = endpoint exists (POST only) | Report Room live ✅ |
|
| **MSC4151** report room: HTTP 405 on GET = endpoint exists (POST only) | Report Room live ✅ |
|
||||||
| `folds AvatarImage` does NOT accept children | Add frame/overlay inside `UserAvatar.tsx` itself — optional `frameName` prop |
|
| `folds AvatarImage` does NOT accept children | Add frame/overlay inside `UserAvatar.tsx` itself — optional `frameName` prop |
|
||||||
| No in-app toast system exists (was) | Built `ToastProvider` + Jotai queue; at `App.tsx:65` |
|
| No in-app toast system exists (was) | Built `ToastProvider` + Jotai queue; at `App.tsx:65` |
|
||||||
| `useUnverifiedDeviceCount()` hook exists | `src/app/hooks/useDeviceVerificationStatus.ts:65-106` |
|
| `useUnverifiedDeviceCount()` hook exists | `src/app/hooks/useDeviceVerificationStatus.ts:65-106` |
|
||||||
| Voice player: `AudioContent.tsx:44-223` | Playback rate on hidden `<audio>` at line 217 |
|
| Voice player: `AudioContent.tsx:44-223` | Playback rate on hidden `<audio>` at line 217 |
|
||||||
| `CallControl.setMicrophone(bool)` at `CallControl.ts:206-212` | For AFK auto-mute |
|
| `CallControl.setMicrophone(bool)` at `CallControl.ts:206-212` | For AFK auto-mute |
|
||||||
| `CallControl.toggleSound()` at `CallControl.ts:230-251` | Push-to-deafen — just wire a hotkey to this |
|
| `CallControl.toggleSound()` at `CallControl.ts:230-251` | Push-to-deafen — just wire a hotkey to this |
|
||||||
| matrix-js-sdk has NO arbitrary profile field methods | Use `mx.http.authedRequest()` for MSC4133 |
|
| matrix-js-sdk has NO arbitrary profile field methods | Use `mx.http.authedRequest()` for MSC4133 |
|
||||||
| Sanitizer (`sanitize.ts`) allows table, div, span, a, code, hr | LFG HTML card is safe locally; test on Element/FluffyChat |
|
| Sanitizer (`sanitize.ts`) allows table, div, span, a, code, hr | LFG HTML card is safe locally; test on Element/FluffyChat |
|
||||||
| Sanitizer STRIPS `<math>`/MathML tags | Math/LaTeX task must also modify sanitizer |
|
| Sanitizer STRIPS `<math>`/MathML tags | Math/LaTeX task must also modify sanitizer |
|
||||||
| Service worker EXISTS at `src/sw.ts` | Quick-reply task: add `notificationclick` handler |
|
| Service worker EXISTS at `src/sw.ts` | Quick-reply task: add `notificationclick` handler |
|
||||||
| `knockSupported()` utility exists at `matrix.ts:376-391` | Knock UX: only need "Request to Join" in `RoomIntro.tsx` |
|
| `knockSupported()` utility exists at `matrix.ts:376-391` | Knock UX: only need "Request to Join" in `RoomIntro.tsx` |
|
||||||
| `KeywordMessages.tsx` already has custom keyword push rules | Full push rule editor: only non-keyword rule types need new UI |
|
| `KeywordMessages.tsx` already has custom keyword push rules | Full push rule editor: only non-keyword rule types need new UI |
|
||||||
| `getMatrixToRoom()` in `matrix-to.ts` generates invite URLs | Invite link: just add QR code to room settings |
|
| `getMatrixToRoom()` in `matrix-to.ts` generates invite URLs | Invite link: just add QR code to room settings |
|
||||||
| Cindy CANNOT inject audio into EC call stream | In-call soundboard must be redesigned as local-only |
|
| ~~Cindy CANNOT inject audio into EC call stream~~ **UNBLOCKED by EC fork** — `io.lotus.inject_audio` widget action publishes a clip as a real call track | In-call soundboard CAN now mix into the call (no longer local-only); needs cinny UI to drive the action |
|
||||||
| Folds uses vanilla-extract in non-TDS, NOT CSS custom properties | Custom accent color: must create new vanilla-extract theme variant dynamically |
|
| Folds uses vanilla-extract in non-TDS, NOT CSS custom properties | Custom accent color: must create new vanilla-extract theme variant dynamically |
|
||||||
| Theme presets need ~50 CSS custom properties each | Significant design work before coding |
|
| Theme presets need ~50 CSS custom properties each | Significant design work before coding |
|
||||||
| `useCallSpeakers.ts` CSS MutationObserver polling | Visual speaking indicator: TDS ring animation on top of existing data |
|
| `useCallSpeakers.ts` CSS MutationObserver polling | Visual speaking indicator: TDS ring animation on top of existing data |
|
||||||
| MSC3489/3672 live location: BOTH false on server | Live Location BLOCKED |
|
| MSC3489/3672 live location: BOTH false on server | Live Location BLOCKED |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -138,7 +141,12 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
|
|||||||
|
|
||||||
## Priority 3 — Higher complexity / lower daily frequency
|
## Priority 3 — Higher complexity / lower daily frequency
|
||||||
|
|
||||||
### [ ] P3-4 · Accessibility Improvements (WCAG 2.1 AA)
|
### [~] P3-4 · Accessibility Improvements (WCAG 2.1 AA) — COMPLIANCE PASS DONE (2026-07), ⚠️ AWAITING LIVE AXE/SR AUDIT
|
||||||
|
|
||||||
|
**Shipped (compliance + shortcuts-help tier):** messages `role="article"` + collapsed-message sender/time announced to AT (the biggest gap — collapsed rows had no sender for a screen reader); ~10 unlabeled form inputs + Media Gallery / Search overlays named; emoji/emoticon aria-labels; typing indicator now announced via a `role="status"` live region; editing a message announces "Editing message from X"; focus now returns to the trigger on close of 4 genuine dialogs (RoomIntro/Reactions/RoomViewHeader-topic/Search — inline popouts correctly left); a `?` keyboard-shortcuts help dialog; and a **jsx-a11y lint gate** (curated ARIA-correctness + label rules, enforced in CI) to prevent regressions. Already-good before this pass: skip link + landmarks, timeline `role="log"`/`aria-live`, ~99% icon-button labels, labeled editor.
|
||||||
|
**DEFERRED (documented):** virtualization keeps scrolled-away history out of the a11y tree (architectural; the live-region announces newly-arriving messages) — not re-architected to avoid perf regression; roving-tabindex + command palette + section-jump shortcuts (user-deferred); the live axe-core / VoiceOver+NVDA audit → LOTUS_TESTING §P.
|
||||||
|
|
||||||
|
_Original scope (for reference):_
|
||||||
|
|
||||||
**What:** Comprehensive audit and fix pass targeting the critical user paths:
|
**What:** Comprehensive audit and fix pass targeting the critical user paths:
|
||||||
|
|
||||||
@@ -159,10 +167,19 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P3-8 · Thread Panel (full side drawer)
|
### [~] P3-8 · Thread Panel (full side drawer) — IMPLEMENTED (2026-07), ⚠️ AWAITING LIVE QA
|
||||||
|
|
||||||
**⚠️ LARGEST FEATURE — requires its own planning session before implementation.**
|
Built per the design below (4-agent build + 2-agent review). Gates green (tsc/eslint/build/tests). **Release note: threaded replies no longer render inline in the main timeline — roots show a "N replies" chip that opens the panel.**
|
||||||
**What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies.
|
|
||||||
|
**Manual QA checklist (post-deploy):**
|
||||||
|
|
||||||
|
1. Reply in Thread (message menu) → panel opens; send text/upload/emoji into it (appears pending → confirmed)
|
||||||
|
2. Reply to a reply inside the panel → event carries `m.thread` + `m.in_reply_to` with `is_falling_back:false`
|
||||||
|
3. Main timeline: root + chip only (replies absent); chip count/time updates live; unread badge appears for others' thread replies and clears after viewing the panel
|
||||||
|
4. Room badge clears via normal markAsRead even with unread threads (unthreaded receipt)
|
||||||
|
5. Reload: partitioning persists; encrypted-room threads decrypt (back-pagination too)
|
||||||
|
6. Escape / × closes; mobile = fullscreen panel; switching rooms and back restores the open thread
|
||||||
|
**What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies.
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
|
|
||||||
@@ -193,22 +210,28 @@ Features:
|
|||||||
|
|
||||||
## Priority 4 — Specialized, high complexity, or low priority
|
## Priority 4 — Specialized, high complexity, or low priority
|
||||||
|
|
||||||
### [ ] P4-7 · Virtualized Infinite Scroll for Search Results
|
### [x] P4-7 · Virtualized Infinite Scroll for Search Results — ALREADY IMPLEMENTED (found 2026-07)
|
||||||
|
|
||||||
**What:** Replace the manual "load more" button with an automated, virtualized infinite scroll for search results.
|
**What:** Replace the manual "load more" button with an automated, virtualized infinite scroll for search results.
|
||||||
**Approach:** Utilize `@tanstack/react-virtual` in `MessageSearch.tsx` to handle the `nextToken` automatically as the user scrolls.
|
**Status:** Done in a prior session — `MessageSearch.tsx` already uses `useVirtualizer` (~line 336) over the result groups AND auto-fetches the `nextToken` page when the last virtual item scrolls into view (~line 469) via `useInfiniteQuery`. Nothing left to build.
|
||||||
|
|
||||||
### [ ] P4-8 · Encrypted Message Search Indexing & Caching
|
### [~] P4-8 · Encrypted Message Search Indexing & Caching — IMPLEMENTED (2026-07), opt-in
|
||||||
|
|
||||||
**What:** Implement a persistent local cache for search results, optimized for encrypted rooms.
|
**Shipped:** `src/app/utils/searchCache.ts` — raw-IndexedDB per-room index (`lotus-search-cache`) of decrypted search rows + coverage markers, merged into local search (in-memory-wins dedupe). **Opt-in, default OFF** (stores plaintext at rest) with a privacy note, Clear button, and logout wipe. Awaiting live QA (LOTUS_TESTING outstanding-verification backlog).
|
||||||
**Approach:** Use `IndexedDB` to store search metadata (event IDs, timestamps) to prevent redundant server-side decryption/fetching.
|
|
||||||
|
|
||||||
### [ ] P4-1 · Thread Notification Mode Per-Thread (MSC3771)
|
### [~] P4-1 · Thread Notification Mode Per-Thread — IMPLEMENTED (2026-07), ⚠️ AWAITING LIVE QA
|
||||||
|
|
||||||
**Spec:** MSC3771 (stable). Depends on Thread Panel (#P3-8).
|
**Shipped (Slack-style):** default = **Participating** (notified only for threads you've posted in or where you're @mentioned); per-thread override **All / Mentions-only / Mute** via the bell menu in the thread panel header; modes sync across devices (`io.lotus.thread_notifications` account data, pruned on write). Mute also suppresses the chip badge and subtracts the thread from the room's sidebar badge (client-side). Also fixed the underlying path: thread replies are notified via exactly one handler (room-level `ThreadEvent.NewReply`), with the main-timeline notifier + unread binder thread-guarded, and live badge refresh on `RoomEvent.UnreadNotifications`.
|
||||||
**What:** Per-thread notification toggle: "All messages" vs "Mentions only". Accessible from the thread panel header. Tracks unread counts separately per thread.
|
|
||||||
**[AUDIT REQUIRED]** — Implement after Thread Panel. Requires understanding how the SDK tracks per-thread unread counts.
|
**Manual QA checklist (post-deploy):**
|
||||||
**Complexity:** Medium (after thread panel exists).
|
|
||||||
|
1. Friend replies in a thread YOU posted in → notification + sound; in a thread you never touched → silent (chip badge only)
|
||||||
|
2. @mention in any thread → notified regardless of participation
|
||||||
|
3. Set a thread to Mute → no notifications, chip badge gone (bell-mute glyph), room sidebar badge drops by that thread's count
|
||||||
|
4. Set to All → every reply notifies; Mentions-only → only @mentions
|
||||||
|
5. Second device shows the same per-thread modes (account-data sync)
|
||||||
|
6. Room-level Mute still silences everything incl. thread overrides
|
||||||
|
**Known caveats:** Mentions-only can under-notify in E2EE rooms (decision runs pre-decryption — same class as the existing notifier); muted-thread badge subtraction is Lotus-only (other clients still count them).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -254,7 +277,7 @@ Features:
|
|||||||
- Account mgmt: `settings/account/OidcManageAccount.tsx`.
|
- Account mgmt: `settings/account/OidcManageAccount.tsx`.
|
||||||
- 13 unit tests (discovery/flow/session/cache/callback parsing). All gates green.
|
- 13 unit tests (discovery/flow/session/cache/callback parsing). All gates green.
|
||||||
**Awaiting verification (needs a real MSC3861 server — lotusguild is NOT one):** deploy + log into **mozilla.org** (requires adding mozilla to the deployed `config.json` homeserverList + its domains to the CSP `connect-src`/`img-src` — see below), OR run a local `matrix-authentication-service` + Synapse `msc3861` dev loop.
|
**Awaiting verification (needs a real MSC3861 server — lotusguild is NOT one):** deploy + log into **mozilla.org** (requires adding mozilla to the deployed `config.json` homeserverList + its domains to the CSP `connect-src`/`img-src` — see below), OR run a local `matrix-authentication-service` + Synapse `msc3861` dev loop.
|
||||||
**To enable the mozilla.org test:** add to `matrix/cinny/config.json` homeserverList `"mozilla.org"`, and to the nginx CSP `connect-src`/`img-src`: `https://mozilla.org https://mozilla.modular.im https://chat.mozilla.org https://vector.im`.
|
**Mozilla.org test enablement: ALREADY DEPLOYED (verified 2026-07)** — `matrix/cinny/config.json` homeserverList includes `mozilla.org` and the nginx CSP `connect-src` includes the mozilla/modular/vector domains (`matrix/cinny/nginx.conf:42`). **Nothing blocks the test — just pick mozilla.org on the login screen and complete an OIDC login.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -266,12 +289,17 @@ Features:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P5-15 · In-Call Soundboard
|
### [~] P5-15 · In-Call Soundboard — IMPLEMENTED (⚠️ awaiting live verification, D2-7)
|
||||||
|
|
||||||
**What:** Grid of short audio clips playable into the call audio stream via Web Audio API (AudioBufferSourceNode → MediaStreamDestinationNode → mixed with mic). Built-in clips + user-uploadable custom clips (stored as mxc://). Accessible from call controls bar.
|
**What:** Soundboard button in the call controls bar → popout grid of the user's clips; clicking one plays it **into the call** as a real published track (peers hear it) and locally (presser hears it). Clips are **user-uploadable, just like custom emojis/stickers**.
|
||||||
**[AUDIT REQUIRED]** Verify the Element Call integration exposes the mic MediaStream for mixing. This is the highest-risk part of this feature.
|
**🔱 [EC-FORK] Fork side + cinny side DONE.** The fork ships `io.lotus.inject_audio` (`LotusWidgetActions.InjectAudio`, allow-listed in `widget.ts`), armed via the `lotusAudioInject=1` flag; it publishes a clip as a separate LiveKit track — a **real** in-call soundboard mixed into the call, not local-only. cinny now drives it.
|
||||||
**🔱 [EC-FORK]** Owning the EC source (see [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md)) would unblock real audio-injection — a proper soundboard mixed into the call — which is impossible against the prebuilt bundle today.
|
**Shipped (cinny):**
|
||||||
**Complexity:** High.
|
|
||||||
|
- Clips stored in `io.lotus.soundboard` account data → **synced across devices like emoji/sticker packs** (`useSoundboard` hook; `AccountDataEvent.LotusSoundboard`).
|
||||||
|
- Upload audio (≤1 MB, ≤40 clips) → `mx.uploadContent` → mxc; play resolves mxc → authed download → `blob:` object URL (the widget can't fetch authenticated media itself) → `control.injectAudio(url, volume)` + local playback.
|
||||||
|
- `CallSoundboard.tsx` popout in the call bar (upload / play / delete), gated on the `soundboardEnabled` setting (Settings → General → Calls, + volume slider).
|
||||||
|
**Remaining:** a dedicated Settings management page (optional — upload/delete already live in the popout); a small default clip set; live verification (D2-7). Files: `utils/soundboardClips.ts`, `hooks/useSoundboard.ts`, `features/call/CallSoundboard.tsx`, `plugins/call/CallControl.ts#injectAudio`.
|
||||||
|
**Complexity:** Medium — done.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -287,39 +315,55 @@ Features:
|
|||||||
### [x] P5-30 · Advanced ML Noise Suppression (Krisp-style)
|
### [x] P5-30 · Advanced ML Noise Suppression (Krisp-style)
|
||||||
|
|
||||||
**What:** High-end background noise cancellation using a pre-trained ML model (RNNoise) running in the browser. Removes dogs, fans, and keyboard clicks from the mic stream.
|
**What:** High-end background noise cancellation using a pre-trained ML model (RNNoise) running in the browser. Removes dogs, fans, and keyboard clicks from the mic stream.
|
||||||
**Shipped:** 3-tier setting (Off / Browser-native / ML) in Settings → General → Calls. ML tier injects a same-origin pre-init shim into the vendored Element Call `index.html` that monkeypatches `getUserMedia` and routes the captured mic through an RNNoise `AudioWorklet` before LiveKit publishes — no EC fork required. See LOTUS_FEATURES.md → "Noise Suppression (Advanced Multi-Tier)".
|
**Shipped:** 3-tier setting (Off / Browser-native / ML) in Settings → General → Calls.
|
||||||
**🔱 [EC-FORK]** Once we own the EC source (see [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md)), denoise should become a first-class audio stage **inside** EC instead of an `index.html` getUserMedia monkeypatch — more robust, survives reconnects (fixes the A7 mic-after-reconnect bug), and removes the build-time injection hack.
|
**🔱 [EC-FORK] DONE — moved in-source (2026-06).** ML denoise is now a first-class audio stage **inside** the forked Element Call: a LiveKit `TrackProcessor<Audio>` activated by `lotusDenoiseSource=1` (cinny sets it when ML is selected). The old build-time `getUserMedia`/`index.html` monkeypatch is **removed**. Because EC re-runs the processor on every (re)publish, denoise now **survives reconnects and mic-device switches** — this is the A7 fix (see `LOTUS_TESTING.md` §D2-1). The processor degrades to the raw mic rather than going silent.
|
||||||
**Key decision:** LiveKit's Krisp filter is LiveKit-Cloud-only (we self-host the SFU); EC's own RNNoise PR #3892 is unmerged. The shim is the same post-capture pipeline #3892 uses, executed from the realm we control, so it survives EC version bumps.
|
**Key decision:** LiveKit's Krisp filter is LiveKit-Cloud-only (we self-host the SFU); EC's own RNNoise PR #3892 is unmerged. Owning the fork let us implement the in-source stage directly.
|
||||||
**AEC note (resolved-as-accepted):** WebAudio capture routing can weaken browser AEC — same tradeoff as EC's upstream feature; mitigated by keeping `echoCancellation`/`autoGainControl` on the raw capture and labeling the tier "beta".
|
|
||||||
|
|
||||||
**Model Roadmap (priority order):**
|
**Models — all in-source in the fork:**
|
||||||
|
|
||||||
- [ ] **Verify DTLN** (16 kHz narrowband fix) in a real call before investing further — wired but unverified.
|
- [x] **DeepFilterNet 3** (48 kHz, **ML default**) · **DTLN** (16 kHz) · **RNNoise** (48 kHz) · **Speex** (48 kHz) — all four wired and selectable; dropdown ordered best-quality first. Tier default is **Browser-native**.
|
||||||
- [ ] **DeepFilterNet 3** — best self-hostable upgrade: Rust→WASM, CPU real-time, 48 kHz fullband. Effort: self-host `df_bg.wasm` + DFN3 ONNX model, wire a 48 kHz worklet.
|
- [x] **Quality tuning (2026-07):** dry/wet **attenuation floor** (~-16 dB, RNNoise/Speex only — the "robotic" fix; DTLN/DFN would comb-filter), **gate-after-ML**, **DFN level 80→60**. Floor tunable via `lotusDenoiseFloor`.
|
||||||
- [ ] **Desktop-only / HW-gated:** FRCRN or NVIDIA Maxine (RTX/Tensor only) — impossible in-browser; would run in Tauri Rust backend + bridge a virtual mic into the webview. Must detect capability and only offer on supported hardware; web falls back to RNNoise.
|
- [x] **AEC/AGC (2026-07):** echo-cancellation ON; **AGC OFF for the ML tier** (`autoGainControl=false`, threaded through EC `UrlParams`→`ConnectionFactory`) so browser AGC doesn't fight the model; playback confirmed no AEC-defeat.
|
||||||
|
- [x] **Reliability (2026-07):** never-silent watchdog, resume-timeout, WASM-cache reject-eviction, activate-off-local-participant, init/build leak fixes.
|
||||||
|
- [ ] **Open verification:** real-call by-ear **A/B** — model choice, floor value, AGC on/off (RNNoise known-weak historically). `LOTUS_TESTING.md` §D2-1 / J2.
|
||||||
|
- [ ] **GTCRN (RESEARCHED — DEFERRED):** tiny MIT 16 kHz model that beats RNNoise, but **no drop-in browser package** — needs a ~1-week from-scratch build: `onnxruntime-web` (WASM, 1 thread) in a **Web Worker** (ORT can't run in an AudioWorklet — issue #13072) behind a custom AudioWorklet ring-buffer node presenting as an `AudioNode`; model `gtcrn_simple.onnx` (~300 KB, stateful — thread `conv/tra/inter` caches per frame); we write STFT/iSTFT (n_fft 512/hop 256). Assets ~3–4 MB via the `lotusDenoise()` vite plugin. Registration checklist known (both repos, incl. the 2nd `denoisePipeline.ts` used by the DenoiseTester). **Revisit only if low-power quality is insufficient after validating the current tuning.**
|
||||||
|
- [ ] **Desktop-only / HW-gated (future):** FRCRN or NVIDIA Maxine (RTX/Tensor only) — impossible in-browser; would run in the Tauri Rust backend + bridge a virtual mic into the webview. Detect capability; web falls back to RNNoise.
|
||||||
- **Excluded:** Krisp (LiveKit Cloud only); FRCRN/Maxine on web (GPU/server-bound).
|
- **Excluded:** Krisp (LiveKit Cloud only); FRCRN/Maxine on web (GPU/server-bound).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P5-31 · Granular Voice & Screenshare Quality Controls (Discord-style)
|
### [~] P5-31 · Granular Voice & Screenshare Quality Controls — IMPLEMENTED (⚠️ awaiting live verification, D2-8)
|
||||||
|
|
||||||
**What:** Let users (or room admins via room settings) adjust audio bitrates (e.g., 64kbps to 512kbps) and screenshare quality (resolution: 720p/1080p/Source, framerate: 15/30/60fps).
|
**What:** Let users (and room admins) adjust audio bitrate and screenshare bitrate/framerate.
|
||||||
**Note:** Requires tight integration with the LiveKit SFU and custom state events for per-room quality caps.
|
**🔱 [EC-FORK] Fork side + client side DONE.** The fork ships `io.lotus.set_quality` (`LotusWidgetActions.SetQuality`) that applies audio/screenshare encoding params (`RTCRtpSender.setParameters`, all simulcast encodings, re-applied on `TrackUnmuted`/republish) inside EC. cinny now drives it.
|
||||||
**[AUDIT REQUIRED]** Must verify if current `lk-jwt-service` can be extended with custom bitrate/resolution claims or if a new sidecar (similar to `voice-limit-guard`) is needed for server-side enforcement.
|
|
||||||
**Complexity:** Extreme.
|
**Shipped (cinny):**
|
||||||
|
|
||||||
|
1. **User settings** (Settings → General → Calls): Microphone Bitrate, Screenshare Bitrate, Screenshare Framerate (`callAudioBitrate` / `screenshareBitrate` / `screenshareFramerate`).
|
||||||
|
2. **Room-admin caps**: `io.lotus.room_quality` state event (`StateEvent.LotusRoomQuality`) + `RoomQuality.tsx` in Room Settings → General → Voice (mirrors `RoomVoiceLimit`).
|
||||||
|
3. **Apply logic**: `useCallQuality` (wired in `CallEmbedProvider`'s `CallUtils`) builds `min(user setting, room cap)` and sends `io.lotus.set_quality` on join / when settings change (`utils/callQuality.ts`, unit-tested).
|
||||||
|
|
||||||
|
**Server-side enforcement (DONE — matrix repo):** extended `voice-limit-guard.py` (LXC 151) to also read `io.lotus.room_quality` and hard-enforce a **publish-source policy** for ALL clients.
|
||||||
|
|
||||||
|
- **Reality (researched, primary-source, LiveKit 1.9.11):** numeric bitrate/fps caps **cannot** be hard-enforced server-side — LiveKit is a pure SFU (forwards, never transcodes); there is NO bitrate/fps field in the JWT grant, `RoomConfiguration`, server `limit:` config, or any admin RPC, and stock Element Call ignores room metadata / custom claims for publish quality. So numeric caps stay **cooperative** (our fork honors them via `min()` → `set_quality`, already shipped).
|
||||||
|
- **What IS hard-enforced cross-client:** `VideoGrant.canPublishSources`. The guard holds the LiveKit secret, so when `io.lotus.room_quality` sets `allow_screenshare:false` / `allow_camera:false` it re-signs the issued JWT with a narrowed source list → the SFU refuses those tracks for **every** client (Element, FluffyChat, our fork). Mic always kept. Fail-open; unit-tested (`livekit/test_voice_limit_guard.py`). Admin UI: Room Settings → Voice → **Call Permissions** switches. cinny also hides the blocked buttons.
|
||||||
|
- **Live (mid-call) enforcement — DONE:** the JWT re-sign covers new joins; for participants **already in the call**, a background reconcile loop in the guard calls LiveKit `UpdateParticipant` every ~3 s to narrow `canPublishSources`, which unpublishes an in-progress screenshare/camera **server-side for all clients** and blocks re-publish (verified LiveKit 1.9.11 auto-unpublishes on permission narrowing). Only removes forbidden sources (never grants), preserves other permission flags, no-ops once compliant. So flipping a room audio-only kills live cameras/screenshares within ~one interval.
|
||||||
|
- **Not enforceable / deferred:** numeric server enforcement (impossible — see above); screenshare **resolution** control (`set_quality` covers bitrate + framerate; resolution needs a `getDisplayMedia` hook inside the fork).
|
||||||
|
|
||||||
|
**Complexity:** DONE — client (cooperative numeric caps) + server (hard publish-source policy). Only the physically-impossible numeric server enforcement is out of scope.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P5-35 · Desktop — Notification Click Opens Room (DEFERRED)
|
### [~] P5-35 · Desktop — Notification Click Opens Room — IMPLEMENTED (Tier B, via the P5-41 WinRT toast: click → open room, reply → send); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Clicking a system tray notification navigates to the relevant room. Quick-reply from the notification toast would send the reply without opening the window.
|
**What:** Clicking a system tray notification navigates to the relevant room. Quick-reply from the notification toast would send the reply without opening the window.
|
||||||
**Status:** Deferred — `tauri-plugin-notification` has no Rust click/action callback API. Quick-reply would need a custom WinRT toast activator + COM registration, which can't be compile-tested without a Windows build environment.
|
**Status:** Deferred (Tier B). Note: the "can't compile-test without a Windows build environment" premise is **outdated** — CI now compiles Windows (Gitea self-hosted `windows` runner + GitHub `windows-latest`), and `windows`-crate/COM code already ships (e.g. `set_badge_count`, and the Tier A jump list). This still depends on P5-41 (the WinRT toast + custom activator), so it rides with that; it was not part of the Tier A wave.
|
||||||
**Note:** Tray icon and `matrix:` deep links already bring the window forward on most interactions. Revisit when tauri-plugin-notification gains click handler support upstream.
|
**Note:** Tray icon and `matrix:` deep links already bring the window forward on most interactions. Revisit when tauri-plugin-notification gains click handler support upstream.
|
||||||
**Complexity:** High (platform-specific native code required).
|
**Complexity:** High (platform-specific native code required).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P5-36 · Desktop — Windows Jump List (DEFERRED)
|
### [~] P5-36 · Desktop — Windows Jump List — IMPLEMENTED (Tier A); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Right-clicking the taskbar icon shows a jump list with recent/favorite rooms for quick navigation.
|
**What:** Right-clicking the taskbar icon shows a jump list with recent/favorite rooms for quick navigation.
|
||||||
**Status:** Deferred — implementing the Windows COM jump list API in Tauri requires iterating on C++/COM code that can only be compile-checked on Windows, making blind CI iteration impractical.
|
**Status:** Deferred — implementing the Windows COM jump list API in Tauri requires iterating on C++/COM code that can only be compile-checked on Windows, making blind CI iteration impractical.
|
||||||
@@ -328,78 +372,87 @@ Features:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P5-41 · Desktop — Native WinRT Toast Notifications
|
### [~] P5-41 · Desktop — Native WinRT Toast Notifications — IMPLEMENTED (Tier B; ToastNotification + reply input + in-process Activated; falls back to tauri-plugin-notification); native CI-compile-pending. Runtime needs a Start-menu shortcut + matching AppUserModelID to surface
|
||||||
|
|
||||||
**What:** Replace emulated notifications with native WinRT Toast notifications.
|
**What:** Replace emulated notifications with native WinRT Toast notifications.
|
||||||
**Approach:** Implement native WinRT Toast integration using `windows-rs` to enable full Action Center integration, including native Quick Reply functionality.
|
**Approach:** Implement native WinRT Toast integration using `windows-rs` to enable full Action Center integration, including native Quick Reply functionality.
|
||||||
|
|
||||||
### [ ] P5-42 · Desktop — Persistent Background Sync
|
### [~] P5-42 · Desktop — Persistent Background Sync — IMPLEMENTED (Batch 3, pragmatic keep-alive); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Maintain light connection to homeserver when WebView2 is suspended.
|
**What:** Keep receiving messages/notifications instantly while the app is closed to the tray.
|
||||||
**Approach:** Implement a headless Rust sidecar to fetch unread counts/notifications while the webview is suspended to ensure instant notification delivery.
|
**Shipped approach (80/20):** rather than a multi-sprint headless Rust sync client, disable Chromium background throttling via WebView2 `additional_browser_args` (`--disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows`, added to the existing Tauri default args) so the existing JS Matrix `/sync` loop keeps running full-speed in the tray. Windows/WebView2 only; does not block system sleep. See `cinny-desktop/src-tauri/src/lib.rs` (WebviewWindowBuilder).
|
||||||
|
**Deferred (not needed):** the full headless Rust sidecar — only revisit if WebView2 ever hard-suspends despite these flags (would require a second Matrix client with its own /sync + push-rule eval + E2EE-aware notification content).
|
||||||
|
|
||||||
### [ ] P5-43 · Desktop — System Media Transport Controls (SMTC)
|
### [~] P5-43 · Desktop — System Media Transport Controls (SMTC) — IMPLEMENTED (Tier A); CI-compile-pending; SMTC may need an active media/audio session to surface — verify on Windows
|
||||||
|
|
||||||
**What:** Integrate with Windows SMTC for volume flyout call/media control.
|
**What:** Integrate with Windows SMTC for volume flyout call/media control.
|
||||||
**Approach:** Use Windows SMTC API to expose call status, mic mute/unmute, and media controls to the Windows volume flyout/media overlay.
|
**Approach:** Use Windows SMTC API to expose call status, mic mute/unmute, and media controls to the Windows volume flyout/media overlay.
|
||||||
|
|
||||||
### [ ] P5-44 · Desktop — Taskbar Thumbnail Toolbar
|
### [~] P5-44 · Desktop — Taskbar Thumbnail Toolbar — IMPLEMENTED (Tier A); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Add persistent call controls to the taskbar preview.
|
**What:** Add persistent call controls to the taskbar preview.
|
||||||
**Approach:** Implement a COM thumbnail toolbar in the application preview window, featuring Mute/Deafen/End Call buttons.
|
**Approach:** Implement a COM thumbnail toolbar in the application preview window, featuring Mute/Deafen/End Call buttons.
|
||||||
|
|
||||||
### [ ] P5-46 · Desktop — System Power Management (Call Continuity)
|
### [~] P5-46 · Desktop — System Power Management (Call Continuity) — IMPLEMENTED (Tier A reference); web verified; native CI-compile-pending (Windows only; macOS/Linux no-op TODO)
|
||||||
|
|
||||||
**What:** Prevent system sleep/hibernate during active calls.
|
**What:** Prevent system sleep/hibernate during active calls.
|
||||||
**Approach:** Use Tauri/Rust `power-manager` or platform-specific APIs to block system power saving states while a voice/video session is active.
|
**Approach:** Use Tauri/Rust `power-manager` or platform-specific APIs to block system power saving states while a voice/video session is active.
|
||||||
|
|
||||||
### [ ] P5-47 · Desktop — TDS-Styled Native Window Chrome
|
### [~] P5-47 · Desktop — TDS-Styled Native Window Chrome — IMPLEMENTED (Tier A, OPT-IN/default-off, runtime-reversible); web verified; native CI-compile-pending
|
||||||
|
|
||||||
**What:** Replace system titlebar with custom Lotus TDS chrome.
|
**What:** Replace system titlebar with custom Lotus TDS chrome.
|
||||||
**Approach:** Configure Tauri window (`decorations: false`) and implement custom, TDS-token compliant titlebar controls (Close/Max/Min) for a cohesive UI.
|
**Approach:** Configure Tauri window (`decorations: false`) and implement custom, TDS-token compliant titlebar controls (Close/Max/Min) for a cohesive UI.
|
||||||
|
|
||||||
### [ ] P5-48 · Desktop — Native File System Drag-and-Drop Improvements
|
### [~] P5-48 · Desktop — Native File System Drag-and-Drop Improvements — IMPLEMENTED (recursive folder upload, web-verified: tsc/build/tests). SCOPED-OUT: `.lnk` shortcut resolution (webview never exposes a dropped file's OS path → native can't resolve the target) and "Send To" (installer/registry shell integration) — deferred
|
||||||
|
|
||||||
**What:** Enhance drag-and-drop support for Windows.
|
**What:** Enhance drag-and-drop support for Windows.
|
||||||
**Approach:** Improve handling for Windows file shortcuts, recursive folder uploads, and shell-integrated "Send To" context menu actions.
|
**Approach:** Improve handling for Windows file shortcuts, recursive folder uploads, and shell-integrated "Send To" context menu actions.
|
||||||
|
|
||||||
### [ ] P5-49 · Desktop — Network Awareness (NCSI Integration)
|
### [~] P5-49 · Desktop — Network Awareness (NCSI Integration) — IMPLEMENTED (Tier A; INetworkListManager poll → mx.retryImmediately); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Proactively detect Windows network connectivity changes.
|
**What:** Proactively detect Windows network connectivity changes.
|
||||||
**Approach:** Integrate with the Windows Network Connectivity Status Indicator (NCSI) API to improve offline mode transition latency and network recovery.
|
**Approach:** Integrate with the Windows Network Connectivity Status Indicator (NCSI) API to improve offline mode transition latency and network recovery.
|
||||||
|
|
||||||
### [ ] P5-50 · Desktop — Windows Hardware-Accelerated Media Pipeline
|
### [WON'T FIX] P5-50 · Desktop — Windows Hardware-Accelerated Media Pipeline
|
||||||
|
|
||||||
**What:** Replace standard browser decoding with native Windows Media Foundation.
|
**What:** Replace standard browser decoding with native Windows Media Foundation.
|
||||||
**Approach:** Leverage DirectShow/Media Foundation to offload video/audio decoding from the CPU to the GPU, significantly reducing power consumption and latency during calls.
|
**Why won't-fix (researched):** WebRTC media (the call pipeline) lives entirely inside WebView2/Chromium — you cannot inject Media Foundation/DirectShow into WebRTC's decode path from the Tauri host. Chromium already uses the platform's hardware decoders (D3D11VA/MF) where the GPU supports the codec, so there is no separate CPU pipeline to offload. Not actionable as described.
|
||||||
|
|
||||||
### [ ] P5-51 · Desktop — Federated "Identity Contexts" (Isolation Manager)
|
### [DEFERRED] P5-51 · Desktop — Federated "Identity Contexts" (Isolation Manager)
|
||||||
|
|
||||||
**What:** Compartmentalize sessions, local databases, and caches into isolated "Contexts."
|
**What:** Compartmentalize sessions, local databases, and caches into isolated "Contexts" (e.g. Work vs. Personal) with a zero-leak boundary.
|
||||||
**Approach:** Implement a zero-leak boundary for personas (e.g., Work vs. Personal) by isolating `IndexedDB`, filesystem caches, and session persistence per context.
|
**Decision:** Deferred (reviewed 2026-07). Multi-sprint, and it touches the auth/crypto/storage core — not worth the risk/effort for a niche need right now. Kept here with a concrete spec so it's actionable later.
|
||||||
|
|
||||||
|
**Future-work spec (why it's big):** the app is currently **single-session**.
|
||||||
|
|
||||||
|
- Session lives in `src/app/state/sessions.ts` under fixed localStorage keys — `cinny_access_token`, `cinny_device_id`, `cinny_user_id`, `cinny_hs_base_url`, plus the OIDC keys (`cinny_refresh_token`, `cinny_expires_at`, `cinny_oidc_*`).
|
||||||
|
- Persistence lives in `src/client/initMatrix.ts`: two fixed IndexedDB stores — `web-sync-store` (`IndexedDBStore`) and `crypto-store` (`IndexedDBCryptoStore`) — feeding one `createClient(...)`.
|
||||||
|
|
||||||
|
True per-context isolation would require: (1) namespace every localStorage key per context (`ctx:<id>:cinny_*`); (2) per-context IndexedDB dbNames for **both** the sync store and the crypto store; (3) a context registry + switcher UI (create/rename/delete/switch); (4) full client teardown + re-init on switch (`initMatrix` currently assumes one global client); (5) per-context settings + notification/quiet-hours state; (6) careful crypto-store isolation so device keys never bleed across contexts. **Smaller intermediate step** if demand appears: plain multi-account (fast account switch) _without_ the hard isolation boundary — much less risky, reuses most of the login flow.
|
||||||
**Priority:** Extreme Low (Multi-sprint/Architectural).
|
**Priority:** Extreme Low (Multi-sprint/Architectural).
|
||||||
|
|
||||||
### [~] P5-52 · Desktop — Room-Level Sync Governor (Performance Control) [STILL_CONSIDERING]
|
### [DROPPED] P5-52 · Desktop — Room-Level Sync Governor (Performance Control)
|
||||||
|
|
||||||
**What:** Granular sync tuning for individual rooms.
|
**What:** Granular per-room sync tuning (frequency, event-type filtering).
|
||||||
**Approach:** Allow per-room overrides for sync frequency and event type filtering (e.g., disable read receipts/typing in heavy rooms) to optimize performance. Implementation requires careful UX to prevent complexity fatigue.
|
**Why dropped (reviewed 2026-07):** matrix-js-sdk can't do **true** per-room sync filtering — all room events still come down the single `/sync` stream, so "disable typing/receipts in heavy rooms" can only be a **cosmetic client-side hide**, not an actual performance/bandwidth win. That, plus the UX-complexity risk flagged originally, makes it not worth building. If per-room quieting is ever wanted, add a simple "mute typing & receipts in this room" toggle to normal room settings — not a "governor."
|
||||||
|
|
||||||
### [ ] P5-53 · Desktop — Local-Only "Scripting" Plugin System (Tampermonkey-like)
|
### [DEFERRED] P5-53 · Desktop — Local-Only "Scripting" Plugin System (Tampermonkey-like)
|
||||||
|
|
||||||
**What:** A sandboxed environment for local execution of user scripts on Matrix events.
|
**What:** A sandboxed environment for local execution of user scripts on Matrix events.
|
||||||
**Approach:** Implement a WASM-based execution engine that allows users to write local-only, client-side scripts to interact with incoming Matrix events, trigger sounds/notifications, or inject custom UI elements based on event payload rules. Designed for privacy — all logic runs exclusively on the local machine.
|
**Decision:** Deferred (reviewed 2026-07). A full WASM execution engine + script registry + management UI + security model is a large surface for a very small (power-user) audience.
|
||||||
|
**Recommended lighter alternative (the ~80/20) if we ever want event automation:** a built-in **automation-rules** feature — declarative "when an incoming event matches X (room / sender / keyword / type) → notify / play sound / highlight / auto-react" rules, configured in Settings. Covers the realistic use cases (custom alerts, keyword pings) with **no arbitrary code execution**, so no sandbox/security burden. Build that instead of a scripting engine if the need arises.
|
||||||
|
|
||||||
### [ ] P5-55 · Desktop — Composer Toolbar Drag-and-Drop Reordering
|
### [~] P5-55 · Desktop — Composer Toolbar Drag-and-Drop Reordering — IMPLEMENTED (Tier A); web-verified (tsc/build/tests). Awaiting live UX check
|
||||||
|
|
||||||
**What:** Allow users to reorder toolbar icons via drag-and-drop.
|
**What:** Allow users to reorder toolbar icons via drag-and-drop.
|
||||||
**Approach:** Extend the current settings-based toolbar toggle system to include a drag-and-drop UI mode in the composer settings, allowing users to personalize their icon order.
|
**Approach:** Extend the current settings-based toolbar toggle system to include a drag-and-drop UI mode in the composer settings, allowing users to personalize their icon order.
|
||||||
|
|
||||||
### [ ] P5-56 · Desktop — Windows "Focus Assist" (DND) Sync
|
### [~] P5-56 · Desktop — Windows "Focus Assist" (DND) Sync — IMPLEMENTED (Tier B; SHQueryUserNotificationState poll → suppresses notifications+sounds via the quiet-hours gate); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Automatically toggle notification state based on Windows Focus Assist.
|
**What:** Automatically toggle notification state based on Windows Focus Assist.
|
||||||
**Approach:** Integrate with the Windows `NotificationCenter` / `Focus` state via Tauri/Rust to automatically enable/disable Lotus Chat's internal notification suppression mode when Windows Focus Assist is toggled.
|
**Approach:** Integrate with the Windows `NotificationCenter` / `Focus` state via Tauri/Rust to automatically enable/disable Lotus Chat's internal notification suppression mode when Windows Focus Assist is toggled.
|
||||||
|
|
||||||
### [ ] P5-57 · Desktop — Visual Draft Persistence Indicator
|
### [~] P5-57 · Desktop — Visual Draft Persistence Indicator — IMPLEMENTED (Tier A); web-verified. Awaiting live UX check
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -450,9 +503,69 @@ Check back after each Synapse upgrade — re-run `/matrix/client/versions` and `
|
|||||||
|
|
||||||
## Pending Audits
|
## Pending Audits
|
||||||
|
|
||||||
### [ ] Audit-3 · Profile banner image — Matrix protocol support
|
### [DEFERRED] Audit-3 · Profile banner image — Matrix protocol support — RESEARCHED (2026-07)
|
||||||
|
|
||||||
Research whether Matrix spec or MSC4133 (v1.16) defines a standard profile banner field. `uk.tcpip.msc4133.stable = true` on our server — check if a `banner_url` or similar field is defined. If no cross-client standard exists, do not implement.
|
**Finding:** [MSC4427 — Custom banners for user profiles](https://github.com/matrix-org/matrix-spec-proposals/pull/4427) defines a `banner_url` profile field on top of the MSC4133 extensible-profile system (which our server supports, `uk.tcpip.msc4133.stable = true`, and which became stable in Matrix v1.16). However MSC4427 is an **open proposal, not merged** — no cross-client standard yet, so per this item's own rule: do not implement. **Revisit when MSC4427 merges** (implementation would then be small: read/write the field via the MSC4133 profile API + render a banner in UserHero/profile popouts).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority 6 — Post-audit batches (2026-07)
|
||||||
|
|
||||||
|
Buildable follow-ups surfaced by the deep-audit wave. Web Push (N107) deliberately deferred. **macOS is out of scope for all of these — Linux is the parity target (Windows already has most native features).**
|
||||||
|
|
||||||
|
### [~] P6-1 · Desktop — cross-platform parity (Linux + Windows; NO macOS) — IMPLEMENTED (2026-07); native CI-compile-pending, runtime-verify on Linux
|
||||||
|
|
||||||
|
From the desktop audit. Round out the native app now that the full Rust stack compiles:
|
||||||
|
|
||||||
|
- **No-sleep during calls on Linux** — `power.rs` is Windows-only (`SetThreadExecutionState`); add a Linux inhibitor (`org.freedesktop.login1.Manager.Inhibit` / ScreenSaver inhibit via zbus/D-Bus) so the display/system doesn't sleep mid-call.
|
||||||
|
- **Taskbar/launcher unread badge on Linux** — `set_badge_count` is Windows-only; add Unity/`com.canonical.Unity.LauncherEntry` (D-Bus) count where supported.
|
||||||
|
- **Launch-on-login** — add `tauri-plugin-autostart` (cross-platform) + a Settings/tray toggle.
|
||||||
|
- **Tray "Do Not Disturb" toggle** — the tray menu is Open/Quit only; add a DND item (reuses the Focus-Assist suppression atom path) so users can silence notifications from the tray.
|
||||||
|
CI-compile-verified (Windows + Linux runners); no local Rust.
|
||||||
|
|
||||||
|
### [~] P6-2 · Element Call fork — retire the remaining DOM hacks — DEAFEN DONE (2026-07), Phase-2 pending publish
|
||||||
|
|
||||||
|
**Shipped (Phase 1):** new `io.lotus.set_deafen` action in the fork (`lotusDeafen.ts`) sets remote `RemoteParticipant.setVolume` per source (mic + screenshare-audio), persisting to late joiners — replaces the brittle `CallControl.setSound`/`applyScreenshareAudioMuted` `<audio>.muted` iframe-DOM hack. cinny now sends it (join-gated) alongside the retained DOM hack (transitional). Folded into unpublished fork `0.20.1-lotus.2`.
|
||||||
|
**Phase 2 (needs user publish):** publish `0.20.1-lotus.2` to npm → bump cinny pin `lotus.1`→`lotus.2` → delete the DOM `.muted` code. See HANDOFF §12.4.
|
||||||
|
**DEFERRED (rationale):** the `useCallSpeakers` DOM-scrape is a dormant _fallback_ behind `io.lotus.call_state` (deleting only removes the safety net); the `.click()`-by-`data-testid` UI toggles (screenshare/grid/spotlight/reactions/settings) are low-value and would balloon fork surface for buttons that just trigger EC's own UI.
|
||||||
|
**Divergence:** deafen doesn\'t silence soundboard/`Unknown`-source audio (setVolume type limit) — confirm UX.
|
||||||
|
|
||||||
|
_Original scope below._
|
||||||
|
|
||||||
|
### [ ] P6-2b · Element Call fork — remaining DOM hacks (deferred pieces)
|
||||||
|
|
||||||
|
Replace cinny's fragile iframe-`contentDocument` reaches with proper `io.lotus.*` widget actions in the fork (`LotusGuild/element-call`), which break on EC re-renders/version bumps:
|
||||||
|
|
||||||
|
- **Deafen / screenshare-audio-mute** → an `io.lotus` action that mutes/attenuates `RemoteAudioTrack`s at the LiveKit source (replaces `CallControl.ts` `setSound`/`applyScreenshareAudioMuted` DOM `.muted` poking).
|
||||||
|
- **UI-toggle actions** (screenshare/spotlight/reactions/settings) → replace the `.click()`-by-`data-testid` calls.
|
||||||
|
- Retire the `useCallSpeakers` DOM-scrape fallback once `io.lotus.call_state` is verified.
|
||||||
|
Fork commits are local (coordinator); publishing needs the user's npm token.
|
||||||
|
|
||||||
|
### [~] P6-3 · Web UX wins - DONE (2026-07): forward multi-select + live bookmark previews
|
||||||
|
|
||||||
|
**Shipped:** Forward Message multi-select (checkbox rooms + "Send to N", batch `Promise.allSettled` with partial-failure summary; content builder extracted to tested `forwardContent.ts`). Live bookmark previews (`BookmarksPanel` renders the live event via `useRoomEvent` - edits + redactions - snapshot as fallback / left-room). Both `lotus`, gate-green (665 tests).
|
||||||
|
|
||||||
|
_Original scope:_
|
||||||
|
|
||||||
|
### [ ] P6-3-orig · Web UX wins (from the audit ADD list)
|
||||||
|
|
||||||
|
- **Forward to multiple rooms** — multi-select (checkbox + "Send to N") in `ForwardMessageDialog` (currently one room per open, capped at 60).
|
||||||
|
- **Live bookmark previews** — `BookmarksPanel` shows a stale snapshot captured at save time; resolve live from the event when cached (edits/redactions), fall back to the snapshot.
|
||||||
|
- Other small paper-cuts as scoped.
|
||||||
|
|
||||||
|
### [~] P6-4 · Hygiene sweep - TRIMMED (2026-07): security headers only
|
||||||
|
|
||||||
|
**Shipped:** HSTS + Permissions-Policy on the real prod nginx (`matrix/cinny/nginx.conf`, already had X-Frame/CSP/Referrer) + synced the `contrib/nginx` + `contrib/caddy` examples (also fixed the caddy `try_files` SPA fallback). Permissions-Policy allows `self` for the features the app uses (camera/mic/display-capture/geolocation/autoplay/fullscreen), denies unused. **User must `nginx -s reload` on the LXC + verify calls/location still work.**
|
||||||
|
**WON'T-DO (rationale):** patch-package migration - the current `patch-folds.mjs` is already robust (fails hard on drift) and patch-package would be more brittle to folds restructuring; `types/matrix` drift - risky spot-fixes with no concrete bug; build-config streamlining - build is already ~5s. Known follow-up: nginx `add_header` isn't inherited by the cache `location` blocks (pre-existing; the SPA entry `/` still gets all headers, so HSTS is delivered).
|
||||||
|
|
||||||
|
_Original scope:_
|
||||||
|
|
||||||
|
### [ ] P6-4-orig · Hygiene sweep
|
||||||
|
|
||||||
|
- `patch-folds.mjs` (edits `node_modules` directly) → `patch-package`.
|
||||||
|
- `contrib/nginx` + `contrib/caddy`: security headers (HSTS/CSP), `try_files` over rewrites, fix the caddy placeholder path.
|
||||||
|
- `types/matrix/` drift (mirrors SDK types) — spot-fix the highest-risk.
|
||||||
|
- Build-config: streamline `lotusDenoise` sequential `fs` work + redundant `viteStaticCopy` renames.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -460,26 +573,37 @@ Research whether Matrix spec or MSC4133 (v1.16) defines a standard profile banne
|
|||||||
|
|
||||||
Exhaustive, low-level implementation details for backlog items. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant).
|
Exhaustive, low-level implementation details for backlog items. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant).
|
||||||
|
|
||||||
### P3-8 · Thread Panel (Full Side Drawer)
|
### P3-8 · Thread Panel (Full Side Drawer) — 🟢 FULL DESIGN (2026-07, ready to execute)
|
||||||
|
|
||||||
**Architecture:** Mirror the `MembersDrawer` pattern but with a specialized timeline.
|
**Decisions (each backed by SDK evidence in node_modules/matrix-js-sdk):**
|
||||||
|
|
||||||
- **State (`src/app/state/room/thread.ts`):**
|
| Question | Decision |
|
||||||
```typescript
|
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
export const activeThreadIdAtom = atom<string | null>(null);
|
| Thread rendering | **New lean `ThreadTimeline`** reusing `Message`, `useVirtualPaginator`, and RoomTimeline's exported timeline helpers (lines 156-227). Do NOT refactor 2214-line RoomTimeline (its ~35 hooks are hardwired to the room live timeline). |
|
||||||
```
|
| threadSupport | **Enable `threadSupport: true`** in `initMatrix.ts` (~line 39). ⚠️ Thread replies then LEAVE the main timeline (`room.js eventShouldLiveIn` → `shouldLiveInRoom:false`), retroactively on reload — MUST ship the "N replies" summary chip in the same release. Roots stay in both timelines. |
|
||||||
- **Layout (`src/app/features/room/Room.tsx`):** Insert `ThreadPanel` conditionally alongside `RoomTimeline`:
|
| State | `roomIdToActiveThreadIdAtomFamily` (per-room, mirrors `roomIdToReplyDraftAtomFamily`) in new `state/room/thread.ts` + `getThreadDraftKey(roomId, threadRootId)` = `` `${roomId}::${threadRootId}` `` |
|
||||||
```tsx
|
| Composer | **Reuse RoomInput**: add optional `threadRootId` prop; scope its 3 atom-family lookups by draftKey (isolates thread drafts from the main composer); pass `threadRootId ?? null` at all 7 `mx.sendMessage/sendEvent` call sites — the SDK's `addThreadRelationIfNeeded` then emits spec-correct `m.thread` relations incl. reply-in-thread. Separate `useEditor()` instance in the panel. Hide schedule + commands in thread mode v1. |
|
||||||
{
|
| Unreads | v1 = unread badge on the summary chip (`room.getThreadUnreadNotificationCount` — counts already synced independent of threadSupport) + `markThreadAsRead` threaded receipt when panel open at bottom. |
|
||||||
activeThreadId && (
|
| Mobile | Pure CSS like `MembersDrawer.css.ts`: fixed width toRem(360) desktop, `position:fixed; inset:0` under 750px. |
|
||||||
<>
|
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
**Critical side-effect fixes (one-liners, land FIRST):**
|
||||||
<ThreadPanel roomId={roomId} threadId={activeThreadId} />
|
|
||||||
</>
|
1. `initMatrix.ts` → `threadSupport: true`.
|
||||||
);
|
2. `utils/notifications.ts:24` → `sendReadReceipt(latestEvent, type, /*unthreaded*/ true)` — otherwise markAsRead becomes `main`-scoped and room badges stick permanently unread (room unread total includes thread counts).
|
||||||
}
|
|
||||||
```
|
**Known SDK traps (verified):**
|
||||||
- **Component (`src/app/features/room/thread/ThreadPanel.tsx`):** Use `room.getThread(threadId)` from the SDK. Render a `Header` with a "Close" button that sets `activeThreadIdAtom` to `null`. Reuse `RoomTimeline` but pass a filtered `EventTimelineSet`. Use `thread.timelineSet` directly for the most accurate thread view.
|
|
||||||
|
- **Local echo gap:** chronological pending ordering means the thread timelineSet never receives pending events (`canContain` rejects; `room.getPendingEvents()` THROWS in this mode) — ThreadTimeline must render its own pending strip via `RoomEvent.LocalEchoUpdated` filtering on `threadRootId`, deduped against `thread.findEventById`.
|
||||||
|
- **Bootstrap:** `room.getThread(id) ?? room.createThread(id, room.findEventById(id), [], false)` — the SDK auto-fetches via `/relations` and inserts the root at top; gate rendering on `thread.initialEventsFetched`; decrypt with `decryptAllTimelineEvent` after init + each pagination.
|
||||||
|
- **Deep links:** `getEventTimeline(mainSet, threadEventId)` returns undefined for thread events — redirect jump-to-event to the panel (best-effort v1).
|
||||||
|
- **Summary chip** must render from the server-aggregated bundle (`unsigned['m.relations']['m.thread']`) so it works before any Thread object exists.
|
||||||
|
- Room-list "latest message" preview may show the root, not the newest reply — cosmetic, accept v1.
|
||||||
|
|
||||||
|
**File inventory — new:** `state/room/thread.ts` (+test), `features/room/thread/{useThread.ts, threadSummary.ts(+test), ThreadTimeline.tsx(+css), ThreadPanel.tsx(+css), ThreadSummary.tsx, index.ts}`, `hooks/useThreadSummary.ts`. **Edited:** `initMatrix.ts` + `utils/notifications.ts` (coordinator, step 0), `RoomInput.tsx` (threadRootId prop), `RoomTimeline.tsx` (handleReplyClick startThread → open panel; ThreadSummary chips at the two Message call sites; Reply onThreadClick; deep-link redirect), `components/message/Reply.tsx`, `Room.tsx` (render panel after MediaGallery block, gated `!callView && activeThreadId`, `key={roomId+threadId}`).
|
||||||
|
|
||||||
|
**4-agent partition:** step 0 (coordinator one-liners) → A: state+SDK glue (+tests) · B: ThreadTimeline (largest; copies the `useTimelinePagination` pattern rather than exporting it) · C: RoomInput changes · D: panel shell + RoomTimeline/Reply integration — all parallel against pinned interface contracts → coordinator wires Room.tsx + gates.
|
||||||
|
|
||||||
|
**Verification:** gates (tsc/eslint/build/tests) + post-merge manual QA: open thread via chip/menu/indicator; pending→confirmed echo; `is_falling_back:false` on reply-in-thread; main timeline shows root+chip only; badge clears; reload keeps partitioning; encrypted threads decrypt. **Release note required:** threaded replies no longer render inline in the main timeline.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -540,7 +664,7 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
|
|||||||
|
|
||||||
> ⚠️ **[Gemini_Found — CORRECTED]** Gemini originally suggested using LiveKit's `LocalAudioTrack.replaceTrack()` to mix audio into the call stream. This is **not possible** from Lotus Chat's realm: Element Call runs in a **cross-origin iframe** controlled via `matrix-widget-api` (postMessage). LiveKit's JS SDK and its `LocalAudioTrack` live inside EC's sandboxed context — inaccessible from our code. This directly contradicts the confirmed constraint already listed in the Server Capabilities table: _"Cindy CANNOT inject audio into EC call stream — In-call soundboard must be redesigned as local-only."_ The soundboard must be a local-playback-only feature (output through the user's speakers, not mixed into the call audio stream).
|
> ⚠️ **[Gemini_Found — CORRECTED]** Gemini originally suggested using LiveKit's `LocalAudioTrack.replaceTrack()` to mix audio into the call stream. This is **not possible** from Lotus Chat's realm: Element Call runs in a **cross-origin iframe** controlled via `matrix-widget-api` (postMessage). LiveKit's JS SDK and its `LocalAudioTrack` live inside EC's sandboxed context — inaccessible from our code. This directly contradicts the confirmed constraint already listed in the Server Capabilities table: _"Cindy CANNOT inject audio into EC call stream — In-call soundboard must be redesigned as local-only."_ The soundboard must be a local-playback-only feature (output through the user's speakers, not mixed into the call audio stream).
|
||||||
>
|
>
|
||||||
> 🔱 **[EC-FORK — partial correction]** The "cross-origin" claim above is **outdated**: EC is now **same-origin** / self-hosted (`iframe.sandbox` has `allow-same-origin`; we read `contentDocument`). The _practical_ blocker still holds — LiveKit's `LocalAudioTrack` lives in EC's **module scope** (not on `window`), so it's unreachable from cinny even same-origin. **Owning the EC source** (see [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md)) is the path to a real call-audio-inject API, which would unblock a true in-call soundboard.
|
> 🔱 **[EC-FORK — RESOLVED]** Both the original claim and the earlier "practical blocker still holds" correction are now **outdated**. EC is same-origin **and** we own the source, so we no longer reach into EC's module scope from cinny — instead the fork **exposes the inject point itself**: the `io.lotus.inject_audio` widget action (`LotusWidgetActions.InjectAudio`) publishes a clip as a separate LiveKit track from inside EC. A **real** in-call soundboard (mixed into the call, not local-only) is therefore unblocked, and the cinny-side soundboard UI is now **built** (P5-15 above): uploadable clips played into the call via this action, stored in `io.lotus.soundboard` account data.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -611,7 +735,7 @@ See shipped implementation in LOTUS_FEATURES.md → "Noise Suppression (Advanced
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### P5-40 · Desktop — Proactive Update Notifications (Tauri)
|
### [x] P5-40 · Desktop — Proactive Update Notifications (Tauri) — DONE (already shipped: `TauriUpdateFeature` in ClientNonUIFeatures.tsx polls every 12h + fires the sticky update toast)
|
||||||
|
|
||||||
**Key Files:** `src/app/hooks/useTauriUpdater.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx`, `src/app/features/toast/LotusToastContainer.tsx`.
|
**Key Files:** `src/app/hooks/useTauriUpdater.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx`, `src/app/features/toast/LotusToastContainer.tsx`.
|
||||||
|
|
||||||
@@ -702,3 +826,136 @@ edit → commit → git push origin lotus
|
|||||||
- **Synapse (Matrix):** LXC 151 on `compute-storage-01` — `pct exec 151 -- bash`
|
- **Synapse (Matrix):** LXC 151 on `compute-storage-01` — `pct exec 151 -- bash`
|
||||||
- **Config:** `/etc/matrix-synapse/homeserver.yaml`
|
- **Config:** `/etc/matrix-synapse/homeserver.yaml`
|
||||||
- **Version check:** `curl -s https://matrix.lotusguild.org/_matrix/client/versions`
|
- **Version check:** `curl -s https://matrix.lotusguild.org/_matrix/client/versions`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Element Call fork — operational reference
|
||||||
|
|
||||||
|
_Ported from the retired `HANDOFF_ELEMENT_CALL_FORK.md` (2026-07; full history in git). The fork lives at `LotusGuild/element-call` (branch `lotus`, forked from upstream tag `v0.20.1`); cinny consumes it as the npm package `@lotusguild/element-call-embedded`, whose built bundle is copied into `public/element-call/`._
|
||||||
|
|
||||||
|
**Publish a new fork version (manual; needs the Gitea npm token):**
|
||||||
|
|
||||||
|
1. In the fork, bump `embedded/web/package.json` version (current unpublished: `0.20.1-lotus.2`).
|
||||||
|
2. Build: `pnpm run build:embedded` (Node 24, pnpm 10.33.0; output → repo `dist/`, staged into `embedded/web/dist`).
|
||||||
|
3. `cd embedded/web && npm version <tag> --no-git-tag-version && npm publish` to the Gitea registry (`code.lotusguild.org`). Publicly readable; only publishing needs the token.
|
||||||
|
4. In cinny: bump the `@lotusguild/element-call-embedded` pin (`package.json`, currently `0.20.1-lotus.1`) → the new version, `npm install`, build.
|
||||||
|
|
||||||
|
**`io.lotus.*` widget actions (fork ↔ cinny host):**
|
||||||
|
| Action | Direction | Purpose | Fork module |
|
||||||
|
| :-- | :-- | :-- | :-- |
|
||||||
|
| `io.lotus.call_state` | EC→host | speaker/mute/camera state stream (URL `lotusCallState=1`) | `lotusCallState.ts` |
|
||||||
|
| `io.lotus.focus_participant` | host→EC | spotlight a participant (works during screenshare) | `lotusFocus.ts` |
|
||||||
|
| `io.lotus.inject_audio` | host→EC | soundboard clip mixed into the call (URL `lotusAudioInject=1`) | `lotusAudioInject.ts` |
|
||||||
|
| `io.lotus.set_quality` | host→EC | audio/screenshare bitrate/fps caps | `lotusQuality.ts` |
|
||||||
|
| `io.lotus.decorations` | host→EC | in-call avatar decorations | `lotusDecorations.ts` |
|
||||||
|
| `io.lotus.set_deafen` | host→EC | deafen / screenshare-audio-mute at the LiveKit source (P6-2) | `lotusDeafen.ts` |
|
||||||
|
|
||||||
|
Also flag-gated (URL params): `lotusTransparent`/`lotusTheme` (theme), `lotusDenoiseSource=1` (in-source ML denoise). New toWidget actions must be added to the enum + `LOTUS_TO_WIDGET_ACTIONS` in `src/lotus/lotusActions.ts` and only SENT after call-join (else a 10s timeout). **P6-2 phase 2 pending:** after publishing lotus.2, bump the cinny pin + delete the `CallControl.ts` `<audio>.muted` fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 Open — Actionable
|
||||||
|
|
||||||
|
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED · 👤 SENIOR ENGINEER
|
||||||
|
|
||||||
|
> 🧰 **Investigation kit ready (2026-07):** `LOTUS_E2EE_INVESTIGATION.md` (git history)
|
||||||
|
> 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"). Full runbook (synapse SQL, capture checklist, §6 diagnosis) is in git history at `LOTUS_E2EE_INVESTIGATION.md` (removed 2026-07).
|
||||||
|
|
||||||
|
- **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."
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
|
|||||||
|
|
||||||
### Messaging
|
### Messaging
|
||||||
|
|
||||||
|
- Threads: reply in a thread and read/write the whole conversation in a side panel — root messages show a "N replies" chip with an unread badge (threaded replies live in the panel now, not inline in the room)
|
||||||
|
- Slack-style thread notifications: by default you're only pinged for threads you're in or where you're @mentioned; set any thread to All / Mentions-only / Mute from the panel's bell menu (muted threads stop bumping badges; syncs across devices)
|
||||||
- See who has read each message, and track delivery status (sending / sent / failed)
|
- See who has read each message, and track delivery status (sending / sent / failed)
|
||||||
- Bookmark any message and revisit saved messages from the sidebar
|
- Bookmark any message and revisit saved messages from the sidebar
|
||||||
- Schedule messages to send at a specific time
|
- Schedule messages to send at a specific time
|
||||||
@@ -33,6 +35,8 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
|
|||||||
- Search for and send GIFs from a built-in GIF picker
|
- Search for and send GIFs from a built-in GIF picker
|
||||||
- Control voice message playback speed: 0.75× / 1× / 1.5× / 2×
|
- Control voice message playback speed: 0.75× / 1× / 1.5× / 2×
|
||||||
- Search messages with a date range filter
|
- Search messages with a date range filter
|
||||||
|
- Optional persistent search index for encrypted rooms (off by default — stores decrypted text on your device; clearable, wiped on logout)
|
||||||
|
- Write math with LaTeX: `$inline$` and `$$block$$` render via KaTeX (spec `data-mx-maths` supported)
|
||||||
- Room topics support rich formatting (bold, links, italics)
|
- Room topics support rich formatting (bold, links, italics)
|
||||||
- Deleted messages show a placeholder instead of disappearing
|
- Deleted messages show a placeholder instead of disappearing
|
||||||
- Code blocks highlight syntax for JS/TS, Python, and Rust
|
- Code blocks highlight syntax for JS/TS, Python, and Rust
|
||||||
@@ -52,6 +56,9 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
|
|||||||
- AFK auto-mute: mic is automatically silenced after a configurable idle timeout (1–30 min); a toast confirms the action
|
- AFK auto-mute: mic is automatically silenced after a configurable idle timeout (1–30 min); a toast confirms the action
|
||||||
- Voice channel user limit: admins can cap how many people can be in a room's call — enforced server-side for every Matrix client (not just Lotus Chat); others see "Channel Full" until a spot opens
|
- Voice channel user limit: admins can cap how many people can be in a room's call — enforced server-side for every Matrix client (not just Lotus Chat); others see "Channel Full" until a spot opens
|
||||||
- Custom join/leave sound effects when someone enters or leaves your call — choose Chime, Soft, Retro, or off
|
- Custom join/leave sound effects when someone enters or leaves your call — choose Chime, Soft, Retro, or off
|
||||||
|
- Soundboard: upload your own short audio clips (like custom emojis — they sync across your devices) and play them into a call so everyone hears them
|
||||||
|
- Call quality settings: cap your microphone bitrate, screenshare bitrate, and screenshare framerate — handy on a slow connection (Settings → Calls)
|
||||||
|
- Room call permissions: admins can turn off screen sharing or make a room audio-only (no cameras) — enforced server-side for every Matrix client, and it stops an in-progress share within seconds of being switched off
|
||||||
|
|
||||||
### Customization & Appearance
|
### Customization & Appearance
|
||||||
|
|
||||||
@@ -136,6 +143,20 @@ When you first run the installer on Windows, you may see a popup that says **"Wi
|
|||||||
|
|
||||||
After the first install, automatic in-app updates handle all future versions — you will not see this prompt again for updates.
|
After the first install, automatic in-app updates handle all future versions — you will not see this prompt again for updates.
|
||||||
|
|
||||||
|
### Desktop-Specific Features
|
||||||
|
|
||||||
|
Beyond the web client, the desktop app adds native OS integration (Windows-focused; graceful no-ops elsewhere). See [`LOTUS_FEATURES.md`](./LOTUS_FEATURES.md#desktop-app-features) for detail.
|
||||||
|
|
||||||
|
- **Native rich notifications** — Windows toasts you can click to open the room or reply to inline, right from the toast.
|
||||||
|
- **Focus Assist sync** — Lotus silences its own notifications while Windows Focus Assist / Quiet Hours is on.
|
||||||
|
- **Windows Jump List** — right-click the taskbar icon for quick access to your most-active rooms.
|
||||||
|
- **Taskbar call controls** — Mute / Deafen / End Call buttons on the taskbar thumbnail during a call, plus call status in the volume flyout (SMTC).
|
||||||
|
- **Stays awake in calls** — the system won't sleep or dim during a voice/video call.
|
||||||
|
- **Network awareness** — reconnects promptly when Windows connectivity changes.
|
||||||
|
- **Custom window chrome** (opt-in) — a Lotus-styled title bar in place of the OS one.
|
||||||
|
- **Recursive folder drag-drop** — drop a whole folder onto the composer to upload everything inside it.
|
||||||
|
- **Automatic background updates** with a one-click update toast.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## For Developers
|
## For Developers
|
||||||
@@ -144,22 +165,25 @@ The source code lives in `/root/code/cinny`. All changes should be made on the `
|
|||||||
|
|
||||||
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
|
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
|
||||||
|
|
||||||
### 🔱 Planned: Element Call fork ("Lotus Call")
|
### 🔱 Element Call fork ("Lotus Call") — LIVE
|
||||||
|
|
||||||
Voice/video channels embed **Element Call**. Today it's a **pre-built npm bundle**
|
Voice/video channels embed **Element Call**, which is now our **self-built fork**
|
||||||
(`@element-hq/element-call-embedded` 0.20.1) copied to `public/element-call/` and
|
(`@lotusguild/element-call-embedded` `0.20.1-lotus.1`, source at
|
||||||
served same-origin; we steer it via the `matrix-widget-api` plus fragile DOM
|
`LotusGuild/element-call`), published to our private Gitea npm registry and served
|
||||||
hacks. Because we don't own its compiled source, several in-call issues (avatar
|
same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
|
||||||
decorations on tiles, camera focus/fullscreen during screenshare, mic recovery
|
behavior is editable source instead of fragile DOM/widget hacks.
|
||||||
after reconnect, native theming, real call-audio injection) are unfixable from
|
|
||||||
outside.
|
|
||||||
|
|
||||||
**The plan is to fork `element-hq/element-call` into a new `LotusGuild/element-call`
|
**Shipped via the fork:** denoise as an in-source LiveKit audio stage (survives
|
||||||
repo, build it from source, and host our own build** for true ownership. The full
|
reconnects), in-call speaking/mute events, focus-a-participant during screenshare,
|
||||||
self-contained plan and integration map — written for a fresh session with no
|
avatar decorations on EC video tiles, and a native transparent background.
|
||||||
prior context — is in **[`HANDOFF_ELEMENT_CALL_FORK.md`](HANDOFF_ELEMENT_CALL_FORK.md)**.
|
**Built but dormant (need cinny UI):** real call-audio injection
|
||||||
Infra/hosting notes also live in the `LotusGuild/matrix` repo README. Search the
|
(`io.lotus.inject_audio` → in-call soundboard) and quality controls
|
||||||
docs for the **`[EC-FORK]`** tag to find every related note.
|
(`io.lotus.set_quality`).
|
||||||
|
|
||||||
|
The fork's `io.lotus.*` action catalog + the publish procedure are in
|
||||||
|
**[`LOTUS_TODO.md`](LOTUS_TODO.md)** ("Element Call fork — operational reference");
|
||||||
|
infra/hosting + build-pipeline notes live in the `LotusGuild/matrix` repo README.
|
||||||
|
Search the docs for the **`[EC-FORK]`** tag to find every related note.
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -1,6 +1,17 @@
|
|||||||
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
|
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
|
||||||
cinny.domain.tld {
|
cinny.domain.tld {
|
||||||
root * /path/to/cinny/dist
|
root * /path/to/cinny/dist
|
||||||
try_files {path} / index.html
|
try_files {path} /index.html
|
||||||
file_server
|
file_server
|
||||||
|
|
||||||
|
# Security headers (generic; add a Content-Security-Policy suited to your
|
||||||
|
# homeserver + any embedded services). Caddy serves HTTPS automatically, so
|
||||||
|
# HSTS is delivered over TLS.
|
||||||
|
header {
|
||||||
|
X-Frame-Options SAMEORIGIN
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
Strict-Transport-Security "max-age=63072000; includeSubDomains"
|
||||||
|
Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ server {
|
|||||||
listen [::]:443 ssl;
|
listen [::]:443 ssl;
|
||||||
server_name cinny.domain.tld;
|
server_name cinny.domain.tld;
|
||||||
|
|
||||||
|
# Security headers (generic; add a Content-Security-Policy suited to your
|
||||||
|
# homeserver + any embedded services). NOTE: nginx does not inherit
|
||||||
|
# server-level add_header into a location that sets its own add_header.
|
||||||
|
add_header X-Frame-Options SAMEORIGIN always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||||
|
add_header Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()" always;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
root /opt/cinny/dist/;
|
root /opt/cinny/dist/;
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ experimental_features:
|
|||||||
msc3861:
|
msc3861:
|
||||||
enabled: true
|
enabled: true
|
||||||
issuer: http://localhost:8090/
|
issuer: http://localhost:8090/
|
||||||
client_id: "0000000000000000000SYNAPSE"
|
client_id: '0000000000000000000SYNAPSE'
|
||||||
client_auth_method: client_secret_basic
|
client_auth_method: client_secret_basic
|
||||||
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET"
|
client_secret: 'REPLACE_WITH_A_SHARED_CLIENT_SECRET'
|
||||||
admin_token: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN"
|
admin_token: 'REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN'
|
||||||
account_management_url: "http://localhost:8090/account"
|
account_management_url: 'http://localhost:8090/account'
|
||||||
|
|
||||||
# With msc3861 enabled, Synapse disables its own password/SSO login and advertises
|
# With msc3861 enabled, Synapse disables its own password/SSO login and advertises
|
||||||
# `m.authentication` in /.well-known/matrix/client — which is exactly what the
|
# `m.authentication` in /.well-known/matrix/client — which is exactly what the
|
||||||
|
|||||||
+28
-1
@@ -25,7 +25,7 @@ export default [
|
|||||||
tsPlugin.configs['flat/eslint-recommended'],
|
tsPlugin.configs['flat/eslint-recommended'],
|
||||||
...tsPlugin.configs['flat/recommended'],
|
...tsPlugin.configs['flat/recommended'],
|
||||||
reactPlugin.configs.flat.recommended,
|
reactPlugin.configs.flat.recommended,
|
||||||
reactHooksPlugin.configs.flat['recommended'],
|
reactHooksPlugin.configs.flat.recommended,
|
||||||
// Register jsx-a11y plugin (rules selectively enabled below)
|
// Register jsx-a11y plugin (rules selectively enabled below)
|
||||||
{ plugins: { 'jsx-a11y': jsxA11yPlugin } },
|
{ plugins: { 'jsx-a11y': jsxA11yPlugin } },
|
||||||
// airbnb-base via FlatCompat (JS/import rules; no React plugin, no getFilename issue)
|
// airbnb-base via FlatCompat (JS/import rules; no React plugin, no getFilename issue)
|
||||||
@@ -115,6 +115,26 @@ export default [
|
|||||||
'jsx-a11y/media-has-caption': 'off',
|
'jsx-a11y/media-has-caption': 'off',
|
||||||
'jsx-a11y/no-noninteractive-element-interactions': 'off',
|
'jsx-a11y/no-noninteractive-element-interactions': 'off',
|
||||||
'jsx-a11y/alt-text': 'off',
|
'jsx-a11y/alt-text': 'off',
|
||||||
|
// A11y regression gate (P3-4). A CURATED set — correctness rules that catch
|
||||||
|
// real WCAG gaps (missing accessible names, malformed ARIA) without
|
||||||
|
// flooding on the pre-existing clickable-div patterns. The heavier
|
||||||
|
// interaction rules (no-static-element-interactions,
|
||||||
|
// click-events-have-key-events) are a separate cleanup and stay OFF.
|
||||||
|
'jsx-a11y/aria-props': 'error',
|
||||||
|
'jsx-a11y/aria-proptypes': 'error',
|
||||||
|
'jsx-a11y/aria-role': ['error', { ignoreNonDOM: true }],
|
||||||
|
'jsx-a11y/aria-unsupported-elements': 'error',
|
||||||
|
'jsx-a11y/role-has-required-aria-props': 'error',
|
||||||
|
'jsx-a11y/role-supports-aria-props': 'error',
|
||||||
|
'jsx-a11y/no-redundant-roles': 'error',
|
||||||
|
'jsx-a11y/anchor-has-content': 'error',
|
||||||
|
'jsx-a11y/heading-has-content': 'error',
|
||||||
|
'jsx-a11y/label-has-associated-control': ['error', { assert: 'either', depth: 5 }],
|
||||||
|
// NOT enabled: control-has-associated-label. This repo labels most inputs
|
||||||
|
// with folds `<Text as="label" htmlFor>` — a component the rule's static
|
||||||
|
// analysis can't see as a <label>, producing false positives on correctly
|
||||||
|
// labeled controls. The genuinely-unlabeled controls it surfaced (sliders,
|
||||||
|
// file input, media players, notes) were fixed directly with aria-label.
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -123,4 +143,11 @@ export default [
|
|||||||
'no-undef': 'off',
|
'no-undef': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Test files commonly define several small mock/fake classes.
|
||||||
|
files: ['**/*.test.ts', '**/*.test.tsx'],
|
||||||
|
rules: {
|
||||||
|
'max-classes-per-file': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Generated
+54
-40
@@ -24,7 +24,6 @@
|
|||||||
"@tanstack/react-query": "5.100.13",
|
"@tanstack/react-query": "5.100.13",
|
||||||
"@tanstack/react-query-devtools": "5.100.13",
|
"@tanstack/react-query-devtools": "5.100.13",
|
||||||
"@tanstack/react-virtual": "3.13.25",
|
"@tanstack/react-virtual": "3.13.25",
|
||||||
"@types/dompurify": "3.2.0",
|
|
||||||
"@workadventure/noise-suppression": "0.0.4",
|
"@workadventure/noise-suppression": "0.0.4",
|
||||||
"await-to-js": "3.0.0",
|
"await-to-js": "3.0.0",
|
||||||
"badwords-list": "2.0.1-4",
|
"badwords-list": "2.0.1-4",
|
||||||
@@ -36,7 +35,6 @@
|
|||||||
"dayjs": "1.11.20",
|
"dayjs": "1.11.20",
|
||||||
"deepfilternet3-noise-filter": "1.2.1",
|
"deepfilternet3-noise-filter": "1.2.1",
|
||||||
"domhandler": "6.0.1",
|
"domhandler": "6.0.1",
|
||||||
"dompurify": "3.4.5",
|
|
||||||
"emojibase": "17.0.0",
|
"emojibase": "17.0.0",
|
||||||
"emojibase-data": "17.0.0",
|
"emojibase-data": "17.0.0",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
@@ -51,9 +49,10 @@
|
|||||||
"immer": "11.1.8",
|
"immer": "11.1.8",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
"jotai": "2.20.0",
|
"jotai": "2.20.0",
|
||||||
|
"katex": "0.16.11",
|
||||||
"linkify-react": "4.3.3",
|
"linkify-react": "4.3.3",
|
||||||
"linkifyjs": "4.3.3",
|
"linkifyjs": "4.3.3",
|
||||||
"matrix-js-sdk": "41.6.0-rc.0",
|
"matrix-js-sdk": "41.7.0",
|
||||||
"matrix-widget-api": "1.17.0",
|
"matrix-widget-api": "1.17.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "5.7.284",
|
"pdfjs-dist": "5.7.284",
|
||||||
@@ -74,7 +73,8 @@
|
|||||||
"slate-history": "0.113.1",
|
"slate-history": "0.113.1",
|
||||||
"slate-react": "0.124.2",
|
"slate-react": "0.124.2",
|
||||||
"styled-components": "6.4.2",
|
"styled-components": "6.4.2",
|
||||||
"ua-parser-js": "2.0.10"
|
"ua-parser-js": "2.0.10",
|
||||||
|
"workbox-precaching": "7.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
|
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
|
||||||
@@ -83,6 +83,7 @@
|
|||||||
"@types/chroma-js": "3.1.2",
|
"@types/chroma-js": "3.1.2",
|
||||||
"@types/file-saver": "2.0.7",
|
"@types/file-saver": "2.0.7",
|
||||||
"@types/is-hotkey": "0.1.10",
|
"@types/is-hotkey": "0.1.10",
|
||||||
|
"@types/katex": "0.16.8",
|
||||||
"@types/node": "25.9.1",
|
"@types/node": "25.9.1",
|
||||||
"@types/prismjs": "1.26.6",
|
"@types/prismjs": "1.26.6",
|
||||||
"@types/react": "19.2.15",
|
"@types/react": "19.2.15",
|
||||||
@@ -2695,9 +2696,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
"version": "18.3.0",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.1.tgz",
|
||||||
"integrity": "sha512-9a4feyt8QLysARu7PHKaRWT+wcCd+IYH074LXp9QK5WqfN4zUXueRhiSSMNT18Bm+8q3sBR/4zxDxOSDR0M8Kg==",
|
"integrity": "sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
@@ -3918,16 +3919,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/dompurify": {
|
|
||||||
"version": "3.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
|
|
||||||
"deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"dompurify": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -3974,6 +3965,13 @@
|
|||||||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/katex": {
|
||||||
|
"version": "0.16.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
|
||||||
|
"integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.9.1",
|
"version": "25.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||||
@@ -4042,7 +4040,7 @@
|
|||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"devOptional": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/ua-parser-js": {
|
"node_modules/@types/ua-parser-js": {
|
||||||
"version": "0.7.39",
|
"version": "0.7.39",
|
||||||
@@ -5541,12 +5539,16 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/content-type": {
|
"node_modules/content-type": {
|
||||||
"version": "1.0.5",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/conventional-commit-types": {
|
"node_modules/conventional-commit-types": {
|
||||||
@@ -6187,15 +6189,6 @@
|
|||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
|
||||||
"version": "3.4.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
|
|
||||||
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
|
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@types/trusted-types": "^2.0.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/domutils": {
|
"node_modules/domutils": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
@@ -9087,6 +9080,31 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/katex": {
|
||||||
|
"version": "0.16.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz",
|
||||||
|
"integrity": "sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://opencollective.com/katex",
|
||||||
|
"https://github.com/sponsors/katex"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^8.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"katex": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/katex/node_modules/commander": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -9937,16 +9955,16 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/matrix-js-sdk": {
|
"node_modules/matrix-js-sdk": {
|
||||||
"version": "41.6.0-rc.0",
|
"version": "41.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.6.0-rc.0.tgz",
|
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.7.0.tgz",
|
||||||
"integrity": "sha512-FcTQyR+Nfh0ASEogYcX393hxGr1936Esg53Z+0f9O4SBsAxl1ZSkLXY3JfLZRLX9dNe38VVwQDQE6QuwnwV7Zw==",
|
"integrity": "sha512-MP0xNv/VVRbshq00TE6EVo77IIXsQk0KjiVtgKV0t9j/V77a6Klt00QrrO0XykkTUsNC0+mQeBMxnx75rZO86Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
"@matrix-org/matrix-sdk-crypto-wasm": "^18.2.0",
|
"@matrix-org/matrix-sdk-crypto-wasm": "^18.3.1",
|
||||||
"another-json": "^0.2.0",
|
"another-json": "^0.2.0",
|
||||||
"bs58": "^6.0.0",
|
"bs58": "^6.0.0",
|
||||||
"content-type": "^1.0.4",
|
"content-type": "^2.0.0",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"loglevel": "^1.9.2",
|
"loglevel": "^1.9.2",
|
||||||
"matrix-events-sdk": "0.0.1",
|
"matrix-events-sdk": "0.0.1",
|
||||||
@@ -13194,7 +13212,6 @@
|
|||||||
"version": "7.4.1",
|
"version": "7.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz",
|
||||||
"integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==",
|
"integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/workbox-expiration": {
|
"node_modules/workbox-expiration": {
|
||||||
@@ -13235,7 +13252,6 @@
|
|||||||
"version": "7.4.1",
|
"version": "7.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz",
|
||||||
"integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==",
|
"integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"workbox-core": "7.4.1",
|
"workbox-core": "7.4.1",
|
||||||
@@ -13272,7 +13288,6 @@
|
|||||||
"version": "7.4.1",
|
"version": "7.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz",
|
||||||
"integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==",
|
"integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"workbox-core": "7.4.1"
|
"workbox-core": "7.4.1"
|
||||||
@@ -13282,7 +13297,6 @@
|
|||||||
"version": "7.4.1",
|
"version": "7.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz",
|
||||||
"integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==",
|
"integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"workbox-core": "7.4.1"
|
"workbox-core": "7.4.1"
|
||||||
|
|||||||
+5
-4
@@ -49,7 +49,6 @@
|
|||||||
"@tanstack/react-query": "5.100.13",
|
"@tanstack/react-query": "5.100.13",
|
||||||
"@tanstack/react-query-devtools": "5.100.13",
|
"@tanstack/react-query-devtools": "5.100.13",
|
||||||
"@tanstack/react-virtual": "3.13.25",
|
"@tanstack/react-virtual": "3.13.25",
|
||||||
"@types/dompurify": "3.2.0",
|
|
||||||
"@workadventure/noise-suppression": "0.0.4",
|
"@workadventure/noise-suppression": "0.0.4",
|
||||||
"await-to-js": "3.0.0",
|
"await-to-js": "3.0.0",
|
||||||
"badwords-list": "2.0.1-4",
|
"badwords-list": "2.0.1-4",
|
||||||
@@ -61,7 +60,6 @@
|
|||||||
"dayjs": "1.11.20",
|
"dayjs": "1.11.20",
|
||||||
"deepfilternet3-noise-filter": "1.2.1",
|
"deepfilternet3-noise-filter": "1.2.1",
|
||||||
"domhandler": "6.0.1",
|
"domhandler": "6.0.1",
|
||||||
"dompurify": "3.4.5",
|
|
||||||
"emojibase": "17.0.0",
|
"emojibase": "17.0.0",
|
||||||
"emojibase-data": "17.0.0",
|
"emojibase-data": "17.0.0",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
@@ -76,9 +74,10 @@
|
|||||||
"immer": "11.1.8",
|
"immer": "11.1.8",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
"jotai": "2.20.0",
|
"jotai": "2.20.0",
|
||||||
|
"katex": "0.16.11",
|
||||||
"linkify-react": "4.3.3",
|
"linkify-react": "4.3.3",
|
||||||
"linkifyjs": "4.3.3",
|
"linkifyjs": "4.3.3",
|
||||||
"matrix-js-sdk": "41.6.0-rc.0",
|
"matrix-js-sdk": "41.7.0",
|
||||||
"matrix-widget-api": "1.17.0",
|
"matrix-widget-api": "1.17.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "5.7.284",
|
"pdfjs-dist": "5.7.284",
|
||||||
@@ -99,7 +98,8 @@
|
|||||||
"slate-history": "0.113.1",
|
"slate-history": "0.113.1",
|
||||||
"slate-react": "0.124.2",
|
"slate-react": "0.124.2",
|
||||||
"styled-components": "6.4.2",
|
"styled-components": "6.4.2",
|
||||||
"ua-parser-js": "2.0.10"
|
"ua-parser-js": "2.0.10",
|
||||||
|
"workbox-precaching": "7.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
|
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
|
||||||
@@ -108,6 +108,7 @@
|
|||||||
"@types/chroma-js": "3.1.2",
|
"@types/chroma-js": "3.1.2",
|
||||||
"@types/file-saver": "2.0.7",
|
"@types/file-saver": "2.0.7",
|
||||||
"@types/is-hotkey": "0.1.10",
|
"@types/is-hotkey": "0.1.10",
|
||||||
|
"@types/katex": "0.16.8",
|
||||||
"@types/node": "25.9.1",
|
"@types/node": "25.9.1",
|
||||||
"@types/prismjs": "1.26.6",
|
"@types/prismjs": "1.26.6",
|
||||||
"@types/react": "19.2.15",
|
"@types/react": "19.2.15",
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
|
|||||||
<Text size="L400">Account Data</Text>
|
<Text size="L400">Account Data</Text>
|
||||||
<Input
|
<Input
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
aria-label="Account data type"
|
||||||
size="400"
|
size="400"
|
||||||
radii="300"
|
radii="300"
|
||||||
readOnly
|
readOnly
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
|
|||||||
import { previewRingtone, startRingtone } from '../utils/ringtones';
|
import { previewRingtone, startRingtone } from '../utils/ringtones';
|
||||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||||
|
import { useCallQuality } from '../hooks/useCallQuality';
|
||||||
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
||||||
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
||||||
import { mDirectAtom } from '../state/mDirectList';
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
@@ -584,6 +585,7 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
|
|||||||
useCallMemberSoundSync(embed);
|
useCallMemberSoundSync(embed);
|
||||||
useCallJoinLeaveSounds(embed);
|
useCallJoinLeaveSounds(embed);
|
||||||
useCallThemeSync(embed);
|
useCallThemeSync(embed);
|
||||||
|
useCallQuality(embed);
|
||||||
useCallHangupEvent(
|
useCallHangupEvent(
|
||||||
embed,
|
embed,
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useTauriCallPower } from '../hooks/useTauriCallPower';
|
||||||
|
import { useTauriJumpList } from '../hooks/useTauriJumpList';
|
||||||
|
import { useTauriThumbbar } from '../hooks/useTauriThumbbar';
|
||||||
|
import { useTauriSmtc } from '../hooks/useTauriSmtc';
|
||||||
|
import { useTauriNetwork } from '../hooks/useTauriNetwork';
|
||||||
|
import { useTauriToastActions } from '../hooks/useTauriToastActions';
|
||||||
|
import { useTauriFocusAssist } from '../hooks/useTauriFocusAssist';
|
||||||
|
import { useTauriDnd } from '../hooks/useTauriDnd';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts the client-scoped native desktop feature hooks (call/room aware). Each
|
||||||
|
* `useTauri*` hook no-ops in the browser (guards on `isTauri`), so this is safe
|
||||||
|
* to render unconditionally. Rendered once by `ClientNonUIFeatures`. App-level
|
||||||
|
* desktop features (window chrome) live in `App.tsx` instead, so they work
|
||||||
|
* before login.
|
||||||
|
*/
|
||||||
|
export function TauriDesktopFeatures(): null {
|
||||||
|
useTauriCallPower(); // P5-46 no-sleep during calls
|
||||||
|
useTauriJumpList(); // P5-36 Windows jump list of recent rooms
|
||||||
|
useTauriThumbbar(); // P5-44 taskbar thumbnail toolbar (mute/deafen/end)
|
||||||
|
useTauriSmtc(); // P5-43 system media transport controls
|
||||||
|
useTauriNetwork(); // P5-49 network-change awareness → sync retry
|
||||||
|
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
|
||||||
|
useTauriFocusAssist(); // P5-56 Windows Focus Assist → DND suppression atom
|
||||||
|
useTauriDnd(); // P6-1 tray "Do Not Disturb" → notification suppression atom
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Box, config, Icon, Icons, IconSrc, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
|
||||||
|
import React, { MouseEventHandler, ReactNode, useMemo, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
import { ThreadNotificationMode } from '../utils/threadNotifications';
|
||||||
|
import { useSetThreadNotificationMode } from '../hooks/useThreadNotifications';
|
||||||
|
import { AsyncStatus } from '../hooks/useAsyncCallback';
|
||||||
|
|
||||||
|
export const getThreadNotificationModeIcon = (mode?: ThreadNotificationMode): IconSrc => {
|
||||||
|
if (mode === ThreadNotificationMode.Mute) return Icons.BellMute;
|
||||||
|
if (mode === ThreadNotificationMode.MentionsOnly) return Icons.BellPing;
|
||||||
|
if (mode === ThreadNotificationMode.All) return Icons.BellRing;
|
||||||
|
|
||||||
|
return Icons.Bell;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useThreadNotificationModes = (): ThreadNotificationMode[] =>
|
||||||
|
useMemo(
|
||||||
|
() => [
|
||||||
|
ThreadNotificationMode.Default,
|
||||||
|
ThreadNotificationMode.All,
|
||||||
|
ThreadNotificationMode.MentionsOnly,
|
||||||
|
ThreadNotificationMode.Mute,
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const useThreadNotificationModeStr = (): Record<ThreadNotificationMode, string> =>
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
[ThreadNotificationMode.Default]: 'Default (participating)',
|
||||||
|
[ThreadNotificationMode.All]: 'All replies',
|
||||||
|
[ThreadNotificationMode.MentionsOnly]: 'Mentions only',
|
||||||
|
[ThreadNotificationMode.Mute]: 'Mute',
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
type ThreadNotificationModeSwitcherProps = {
|
||||||
|
roomId: string;
|
||||||
|
threadId: string;
|
||||||
|
value?: ThreadNotificationMode;
|
||||||
|
children: (
|
||||||
|
handleOpen: MouseEventHandler<HTMLButtonElement>,
|
||||||
|
opened: boolean,
|
||||||
|
changing: boolean,
|
||||||
|
) => ReactNode;
|
||||||
|
};
|
||||||
|
export function ThreadNotificationModeSwitcher({
|
||||||
|
roomId,
|
||||||
|
threadId,
|
||||||
|
value = ThreadNotificationMode.Default,
|
||||||
|
children,
|
||||||
|
}: ThreadNotificationModeSwitcherProps) {
|
||||||
|
const modes = useThreadNotificationModes();
|
||||||
|
const modeToStr = useThreadNotificationModeStr();
|
||||||
|
|
||||||
|
const { modeState, setMode } = useSetThreadNotificationMode(roomId, threadId);
|
||||||
|
const changing = modeState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setMenuCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (mode: ThreadNotificationMode) => {
|
||||||
|
if (changing) return;
|
||||||
|
setMode(mode);
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={menuCords}
|
||||||
|
offset={5}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: handleClose,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{modes.map((mode) => (
|
||||||
|
<MenuItem
|
||||||
|
key={mode}
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
aria-pressed={mode === value}
|
||||||
|
radii="300"
|
||||||
|
disabled={changing}
|
||||||
|
onClick={() => handleSelect(mode)}
|
||||||
|
before={
|
||||||
|
<Icon
|
||||||
|
size="100"
|
||||||
|
src={getThreadNotificationModeIcon(mode)}
|
||||||
|
filled={mode === value}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T300">
|
||||||
|
{mode === value ? <b>{modeToStr[mode]}</b> : modeToStr[mode]}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children(handleOpenMenu, !!menuCords, changing)}
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -282,7 +282,12 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
>
|
>
|
||||||
{previewUrl && (
|
{previewUrl && (
|
||||||
<>
|
<>
|
||||||
<audio ref={previewAudioRef} src={previewUrl} onEnded={() => setPreviewPlaying(false)} />
|
<audio
|
||||||
|
ref={previewAudioRef}
|
||||||
|
src={previewUrl}
|
||||||
|
onEnded={() => setPreviewPlaying(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const audio = previewAudioRef.current;
|
const audio = previewAudioRef.current;
|
||||||
|
|||||||
@@ -78,11 +78,14 @@ export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box shrink="No" direction="Column" gap="100">
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
<Text size="L400">Address (Optional)</Text>
|
<Text as="label" htmlFor="create-room-alias" size="L400">
|
||||||
|
Address (Optional)
|
||||||
|
</Text>
|
||||||
<Text size="T200" priority="300">
|
<Text size="T200" priority="300">
|
||||||
Pick an unique address to make it discoverable.
|
Pick an unique address to make it discoverable.
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
|
id="create-room-alias"
|
||||||
ref={aliasInputRef}
|
ref={aliasInputRef}
|
||||||
onChange={handleAliasChange}
|
onChange={handleAliasChange}
|
||||||
before={
|
before={
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ type CustomEditorProps = {
|
|||||||
maxHeight?: string;
|
maxHeight?: string;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/** Explicit accessible name for the textbox; falls back to the placeholder. */
|
||||||
|
ariaLabel?: string;
|
||||||
onKeyDown?: KeyboardEventHandler;
|
onKeyDown?: KeyboardEventHandler;
|
||||||
onKeyUp?: KeyboardEventHandler;
|
onKeyUp?: KeyboardEventHandler;
|
||||||
onChange?: EditorChangeHandler;
|
onChange?: EditorChangeHandler;
|
||||||
@@ -82,6 +84,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
maxHeight = '50vh',
|
maxHeight = '50vh',
|
||||||
editor,
|
editor,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
ariaLabel,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
onKeyUp,
|
onKeyUp,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -139,7 +142,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
data-editable-name={editableName}
|
data-editable-name={editableName}
|
||||||
className={css.EditorTextarea}
|
className={css.EditorTextarea}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
aria-label={placeholder ?? 'Message input'}
|
aria-label={ariaLabel ?? placeholder ?? 'Message input'}
|
||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
renderPlaceholder={renderPlaceholder}
|
renderPlaceholder={renderPlaceholder}
|
||||||
renderElement={renderElement}
|
renderElement={renderElement}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo } from 'react';
|
import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo, useState } from 'react';
|
||||||
import { Editor } from 'slate';
|
import { Editor } from 'slate';
|
||||||
import { Box, MenuItem, Text, toRem } from 'folds';
|
import { Box, MenuItem, Text, toRem } from 'folds';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
@@ -11,7 +11,7 @@ import { onTabPress } from '../../../utils/keyboard';
|
|||||||
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
|
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
|
||||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||||
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
|
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
|
||||||
import { IEmoji, emojis } from '../../../plugins/emoji';
|
import { IEmoji, emojis, loadEmojiData } from '../../../plugins/emoji';
|
||||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
@@ -47,13 +47,32 @@ export function EmoticonAutocomplete({
|
|||||||
const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
|
const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
|
||||||
const recentEmoji = useRecentEmoji(mx, 20);
|
const recentEmoji = useRecentEmoji(mx, 20);
|
||||||
|
|
||||||
|
// Lazily load emojibase data (see plugins/emoji `loadEmojiData`). Until it
|
||||||
|
// resolves, `emojis` is empty and autocomplete matches only custom-emoji
|
||||||
|
// packs; the unicode emoji list fills in once loaded.
|
||||||
|
const [loadedEmojis, setLoadedEmojis] = useState<IEmoji[]>(() => emojis);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
loadEmojiData()
|
||||||
|
// Fresh array reference: loadEmojiData populates the module-level array
|
||||||
|
// IN PLACE, so state set to the same ref would bail out of re-rendering
|
||||||
|
// and the search list would never gain the unicode emojis.
|
||||||
|
.then((loaded) => {
|
||||||
|
if (alive) setLoadedEmojis(loaded.emojis.slice());
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const searchList = useMemo(() => {
|
const searchList = useMemo(() => {
|
||||||
const list: Array<EmoticonSearchItem> = [];
|
const list: Array<EmoticonSearchItem> = [];
|
||||||
return list.concat(
|
return list.concat(
|
||||||
imagePacks.flatMap((pack) => pack.getImages(ImageUsage.Emoticon)),
|
imagePacks.flatMap((pack) => pack.getImages(ImageUsage.Emoticon)),
|
||||||
emojis,
|
loadedEmojis,
|
||||||
);
|
);
|
||||||
}, [imagePacks]);
|
}, [imagePacks, loadedEmojis]);
|
||||||
|
|
||||||
const [result, search, resetSearch] = useAsyncSearch(
|
const [result, search, resetSearch] = useAsyncSearch(
|
||||||
searchList,
|
searchList,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import React, {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Box, config, Icons, Scroll } from 'folds';
|
import { Box, config, Icons, Scroll } from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
@@ -15,7 +16,7 @@ import { isKeyHotkey } from 'is-hotkey';
|
|||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
|
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { IEmoji, emojiGroups, emojis } from '../../plugins/emoji';
|
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
|
||||||
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||||
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||||
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
||||||
@@ -56,6 +57,33 @@ import { VirtualTile } from '../virtualizer';
|
|||||||
const RECENT_GROUP_ID = 'recent_group';
|
const RECENT_GROUP_ID = 'recent_group';
|
||||||
const SEARCH_GROUP_ID = 'search_group';
|
const SEARCH_GROUP_ID = 'search_group';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily pull in the emojibase data (see plugins/emoji `loadEmojiData`). The
|
||||||
|
* `emojis`/`emojiGroups` arrays are populated in place once the promise
|
||||||
|
* resolves; we wrap them in a fresh object on load so React re-renders and the
|
||||||
|
* board fills in. Before that, both are empty and the board shows only custom
|
||||||
|
* image packs / recents (which is fleeting — the load starts on mount).
|
||||||
|
*/
|
||||||
|
const useEmojiData = (): EmojiData => {
|
||||||
|
const [data, setData] = useState<EmojiData>(() => ({ emojis, emojiGroups }));
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
loadEmojiData()
|
||||||
|
// Fresh array references (not just a fresh wrapper): downstream memos
|
||||||
|
// depend on the arrays themselves, which are populated IN PLACE — same
|
||||||
|
// refs would skip recompute and leave emoji search empty until remount.
|
||||||
|
.then((loaded) => {
|
||||||
|
if (alive)
|
||||||
|
setData({ emojis: loaded.emojis.slice(), emojiGroups: loaded.emojiGroups.slice() });
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
type EmojiGroupItem = {
|
type EmojiGroupItem = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -75,6 +103,7 @@ const useGroups = (
|
|||||||
|
|
||||||
const recentEmojis = useRecentEmoji(mx, 21);
|
const recentEmojis = useRecentEmoji(mx, 21);
|
||||||
const labels = useEmojiGroupLabels();
|
const labels = useEmojiGroupLabels();
|
||||||
|
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
|
||||||
|
|
||||||
const emojiGroupItems = useMemo(() => {
|
const emojiGroupItems = useMemo(() => {
|
||||||
const g: EmojiGroupItem[] = [];
|
const g: EmojiGroupItem[] = [];
|
||||||
@@ -99,7 +128,7 @@ const useGroups = (
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
emojiGroups.forEach((group) => {
|
loadedEmojiGroups.forEach((group) => {
|
||||||
g.push({
|
g.push({
|
||||||
id: group.id,
|
id: group.id,
|
||||||
name: labels[group.id],
|
name: labels[group.id],
|
||||||
@@ -108,7 +137,7 @@ const useGroups = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
return g;
|
return g;
|
||||||
}, [mx, recentEmojis, labels, imagePacks, tab]);
|
}, [mx, recentEmojis, labels, imagePacks, tab, loadedEmojiGroups]);
|
||||||
|
|
||||||
const stickerGroupItems = useMemo(() => {
|
const stickerGroupItems = useMemo(() => {
|
||||||
const g: StickerGroupItem[] = [];
|
const g: StickerGroupItem[] = [];
|
||||||
@@ -177,6 +206,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
|||||||
const usage = ImageUsage.Emoticon;
|
const usage = ImageUsage.Emoticon;
|
||||||
const labels = useEmojiGroupLabels();
|
const labels = useEmojiGroupLabels();
|
||||||
const icons = useEmojiGroupIcons();
|
const icons = useEmojiGroupIcons();
|
||||||
|
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
|
||||||
|
|
||||||
const packLabels = useMemo(() => {
|
const packLabels = useMemo(() => {
|
||||||
const map = new Map<string, string | undefined>();
|
const map = new Map<string, string | undefined>();
|
||||||
@@ -234,7 +264,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SidebarDivider />
|
<SidebarDivider />
|
||||||
{emojiGroups.map((group) => (
|
{loadedEmojiGroups.map((group) => (
|
||||||
<GroupIcon
|
<GroupIcon
|
||||||
key={group.id}
|
key={group.id}
|
||||||
active={activeGroupId === group.id}
|
active={activeGroupId === group.id}
|
||||||
@@ -409,13 +439,14 @@ export function EmojiBoard({
|
|||||||
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
|
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
|
||||||
const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
|
const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
|
||||||
const renderItem = useItemRenderer(tab);
|
const renderItem = useItemRenderer(tab);
|
||||||
|
const { emojis: loadedEmojis } = useEmojiData();
|
||||||
|
|
||||||
const searchList = useMemo(() => {
|
const searchList = useMemo(() => {
|
||||||
let list: Array<PackImageReader | IEmoji> = [];
|
let list: Array<PackImageReader | IEmoji> = [];
|
||||||
list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage)));
|
list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage)));
|
||||||
if (emojiTab) list = list.concat(emojis);
|
if (emojiTab) list = list.concat(loadedEmojis);
|
||||||
return list;
|
return list;
|
||||||
}, [emojiTab, usage, imagePacks]);
|
}, [emojiTab, usage, imagePacks, loadedEmojis]);
|
||||||
|
|
||||||
const [result, search, resetSearch] = useAsyncSearch(
|
const [result, search, resetSearch] = useAsyncSearch(
|
||||||
searchList,
|
searchList,
|
||||||
|
|||||||
@@ -200,12 +200,24 @@ export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfil
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Inherit" gap="100">
|
<Box direction="Inherit" gap="100">
|
||||||
<Text size="L400">Name</Text>
|
<Text as="label" htmlFor="image-pack-name" size="L400">
|
||||||
<Input name="nameInput" defaultValue={meta.name} variant="Secondary" radii="300" required />
|
Name
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
id="image-pack-name"
|
||||||
|
name="nameInput"
|
||||||
|
defaultValue={meta.name}
|
||||||
|
variant="Secondary"
|
||||||
|
radii="300"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Inherit" gap="100">
|
<Box direction="Inherit" gap="100">
|
||||||
<Text size="L400">Attribution</Text>
|
<Text as="label" htmlFor="image-pack-attribution" size="L400">
|
||||||
|
Attribution
|
||||||
|
</Text>
|
||||||
<TextArea
|
<TextArea
|
||||||
|
id="image-pack-attribution"
|
||||||
name="attributionTextArea"
|
name="attributionTextArea"
|
||||||
defaultValue={meta.attribution}
|
defaultValue={meta.attribution}
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
|
|||||||
@@ -261,9 +261,12 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">User ID</Text>
|
<Text as="label" htmlFor="invite-user-id" size="L400">
|
||||||
|
User ID
|
||||||
|
</Text>
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
|
id="invite-user-id"
|
||||||
size="500"
|
size="500"
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
@@ -334,8 +337,11 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Reason (Optional)</Text>
|
<Text as="label" htmlFor="invite-reason" size="L400">
|
||||||
|
Reason (Optional)
|
||||||
|
</Text>
|
||||||
<TextArea
|
<TextArea
|
||||||
|
id="invite-reason"
|
||||||
size="500"
|
size="500"
|
||||||
name="reasonInput"
|
name="reasonInput"
|
||||||
variant="Background"
|
variant="Background"
|
||||||
|
|||||||
@@ -108,8 +108,11 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Address</Text>
|
<Text as="label" htmlFor="join-address" size="L400">
|
||||||
|
Address
|
||||||
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
|
id="join-address"
|
||||||
size="500"
|
size="500"
|
||||||
autoFocus
|
autoFocus
|
||||||
name="addressInput"
|
name="addressInput"
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import katex from 'katex';
|
||||||
|
import 'katex/dist/katex.min.css';
|
||||||
|
|
||||||
|
type KaTeXProps = {
|
||||||
|
/** Raw LaTeX source (without `$`/`$$` delimiters). */
|
||||||
|
latex: string;
|
||||||
|
/** Render as block (display) math when true, inline otherwise. */
|
||||||
|
displayMode?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily-loaded KaTeX renderer.
|
||||||
|
*
|
||||||
|
* This module statically imports `katex` and its stylesheet, so both only enter
|
||||||
|
* the bundle via the dynamic `import()` of this file (see the `lazy()` wrapper
|
||||||
|
* in `react-custom-html-parser.tsx`). They are therefore NOT part of the eager
|
||||||
|
* import graph.
|
||||||
|
*
|
||||||
|
* We render with `throwOnError: false`, so KaTeX itself renders a parse error
|
||||||
|
* inline (in its error colour) rather than throwing. The HTML returned by
|
||||||
|
* `renderToString` is produced by our own trusted call from a fixed options
|
||||||
|
* object — it is safe to inject via `dangerouslySetInnerHTML`.
|
||||||
|
*/
|
||||||
|
export default function KaTeX({ latex, displayMode = false }: KaTeXProps) {
|
||||||
|
const html = katex.renderToString(latex, {
|
||||||
|
displayMode,
|
||||||
|
throwOnError: false,
|
||||||
|
output: 'htmlAndMathml',
|
||||||
|
});
|
||||||
|
|
||||||
|
const Wrapper = displayMode ? 'div' : 'span';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper
|
||||||
|
// KaTeX output is generated by our own render call (trusted-safe).
|
||||||
|
// eslint-disable-next-line react/no-danger
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ type ReplyProps = {
|
|||||||
replyEventId: string;
|
replyEventId: string;
|
||||||
threadRootId?: string | undefined;
|
threadRootId?: string | undefined;
|
||||||
onClick?: MouseEventHandler | undefined;
|
onClick?: MouseEventHandler | undefined;
|
||||||
|
onThreadClick?: ((threadRootId: string) => void) | undefined;
|
||||||
getMemberPowerTag?: GetMemberPowerTag;
|
getMemberPowerTag?: GetMemberPowerTag;
|
||||||
accessibleTagColors?: Map<string, string>;
|
accessibleTagColors?: Map<string, string>;
|
||||||
legacyUsernameColor?: boolean;
|
legacyUsernameColor?: boolean;
|
||||||
@@ -74,6 +75,7 @@ export const Reply = as<'div', ReplyProps>(
|
|||||||
replyEventId,
|
replyEventId,
|
||||||
threadRootId,
|
threadRootId,
|
||||||
onClick,
|
onClick,
|
||||||
|
onThreadClick,
|
||||||
getMemberPowerTag,
|
getMemberPowerTag,
|
||||||
accessibleTagColors,
|
accessibleTagColors,
|
||||||
legacyUsernameColor,
|
legacyUsernameColor,
|
||||||
@@ -110,7 +112,7 @@ export const Reply = as<'div', ReplyProps>(
|
|||||||
<ThreadIndicator
|
<ThreadIndicator
|
||||||
as="button"
|
as="button"
|
||||||
data-event-id={threadRootId}
|
data-event-id={threadRootId}
|
||||||
onClick={onClick}
|
onClick={onThreadClick ? () => onThreadClick(threadRootId) : onClick}
|
||||||
aria-label="View thread"
|
aria-label="View thread"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ export const PageHeroSection = style([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
||||||
export const PageContentCenter = style([
|
export const PageContentCenter = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
|||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
returnFocusOnDeactivate: false,
|
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
onDeactivate: () => setViewTopic(false),
|
onDeactivate: () => setViewTopic(false),
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { SoundboardPackEditor } from './SoundboardPackEditor';
|
||||||
|
import { SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
|
||||||
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
|
import { useRoomSoundboardPack } from '../../hooks/useSoundboardPacks';
|
||||||
|
import { PackAddress } from '../../plugins/custom-emoji/PackAddress';
|
||||||
|
import { randomStr } from '../../utils/common';
|
||||||
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
|
|
||||||
|
type RoomSoundboardPackProps = {
|
||||||
|
room: Room;
|
||||||
|
stateKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoomSoundboardPack({ room, stateKey }: RoomSoundboardPackProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const userId = mx.getUserId()!;
|
||||||
|
const powerLevels = usePowerLevels(room);
|
||||||
|
const creators = useRoomCreators(room);
|
||||||
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
|
const canEdit = permissions.stateEvent(
|
||||||
|
StateEvent.LotusSoundboardRoom as unknown as keyof import('matrix-js-sdk').StateEvents,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fallbackPack = useMemo(
|
||||||
|
() => new SoundboardPack(randomStr(4), {}, new PackAddress(room.roomId, stateKey)),
|
||||||
|
[room.roomId, stateKey],
|
||||||
|
);
|
||||||
|
const pack = useRoomSoundboardPack(room, stateKey) ?? fallbackPack;
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
async (content: SoundboardContent) => {
|
||||||
|
await mx.sendStateEvent(
|
||||||
|
room.roomId,
|
||||||
|
StateEvent.LotusSoundboardRoom as unknown as keyof import('matrix-js-sdk').StateEvents,
|
||||||
|
content as never,
|
||||||
|
stateKey,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[mx, room.roomId, stateKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SoundboardPackEditor pack={pack} canEdit={canEdit} onUpdate={handleUpdate} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
PopOut,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
color,
|
||||||
|
config,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { EmojiBoard } from '../emoji-board';
|
||||||
|
import { SoundboardClip, SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
|
||||||
|
import { uniqueShortcode } from '../../plugins/soundboard/utils';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import {
|
||||||
|
playClipLocally,
|
||||||
|
resolveClipObjectUrl,
|
||||||
|
SOUNDBOARD_ACCEPT,
|
||||||
|
SOUNDBOARD_MAX_CLIP_BYTES,
|
||||||
|
SOUNDBOARD_MAX_CLIPS,
|
||||||
|
} from '../../utils/soundboardClips';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
type ClipDraft = {
|
||||||
|
url: string;
|
||||||
|
body: string;
|
||||||
|
emoji: string;
|
||||||
|
volume: number;
|
||||||
|
info?: SoundboardClip['info'];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SoundboardPackEditorProps = {
|
||||||
|
pack: SoundboardPack;
|
||||||
|
canEdit?: boolean;
|
||||||
|
onUpdate: (content: SoundboardContent) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable single-pack soundboard manager (used by the settings page and the
|
||||||
|
* in-call management mode). Mirrors image-pack-view/ImagePackContent's staged-
|
||||||
|
* edit + batched-save pattern, but per-clip fields are name + emoji + volume.
|
||||||
|
*/
|
||||||
|
export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPackEditorProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
// Staged, unsaved state:
|
||||||
|
const [drafts, setDrafts] = useState<Map<string, ClipDraft>>(new Map()); // shortcode -> edits
|
||||||
|
const [deleted, setDeleted] = useState<Set<string>>(new Set());
|
||||||
|
const [uploads, setUploads] = useState<Array<{ shortcode: string } & ClipDraft>>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
|
||||||
|
const [busyPreview, setBusyPreview] = useState<string>();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const emojiAnchorRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const existing = useMemo(() => pack.getClips(), [pack]);
|
||||||
|
const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length;
|
||||||
|
|
||||||
|
const dirty = drafts.size > 0 || deleted.size > 0 || uploads.length > 0;
|
||||||
|
|
||||||
|
const draftFor = (shortcode: string, base: { body: string; emoji: string; volume: number }) =>
|
||||||
|
drafts.get(shortcode) ?? { url: '', ...base };
|
||||||
|
|
||||||
|
const setDraft = (shortcode: string, patch: Partial<ClipDraft>, base: ClipDraft) => {
|
||||||
|
setDrafts((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(shortcode, { ...base, ...(next.get(shortcode) ?? {}), ...patch });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = useCallback(
|
||||||
|
async (id: string, mxc: string, volume: number) => {
|
||||||
|
setBusyPreview(id);
|
||||||
|
try {
|
||||||
|
const url = await resolveClipObjectUrl(mx, mxc);
|
||||||
|
playClipLocally(url, volume / 100);
|
||||||
|
} catch {
|
||||||
|
/* ignore preview errors */
|
||||||
|
} finally {
|
||||||
|
setBusyPreview(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mx],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFiles = useCallback(
|
||||||
|
async (files: FileList | null) => {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
setUploading(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const taken = new Set<string>([
|
||||||
|
...existing.map((c) => c.shortcode),
|
||||||
|
...uploads.map((u) => u.shortcode),
|
||||||
|
]);
|
||||||
|
for (let i = 0; i < files.length; i += 1) {
|
||||||
|
const file = files[i];
|
||||||
|
if (clipCount + uploads.length >= SOUNDBOARD_MAX_CLIPS) {
|
||||||
|
throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
|
||||||
|
}
|
||||||
|
if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) {
|
||||||
|
throw new Error(`"${file.name}" is too large (max 1 MB).`);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
|
||||||
|
const mxc = res.content_uri;
|
||||||
|
if (!mxc) throw new Error('Upload failed.');
|
||||||
|
const name = file.name.replace(/\.[^/.]+$/, '');
|
||||||
|
const shortcode = uniqueShortcode(name, taken);
|
||||||
|
taken.add(shortcode);
|
||||||
|
setUploads((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
shortcode,
|
||||||
|
url: mxc,
|
||||||
|
body: name,
|
||||||
|
emoji: '',
|
||||||
|
volume: 100,
|
||||||
|
info: { mimetype: file.type || undefined, size: file.size },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Upload failed.');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mx, existing, uploads, clipCount],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [saveState, save] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const clips: Record<string, SoundboardClip> = {};
|
||||||
|
existing.forEach((c) => {
|
||||||
|
if (deleted.has(c.shortcode)) return;
|
||||||
|
const d = drafts.get(c.shortcode);
|
||||||
|
clips[c.shortcode] = {
|
||||||
|
url: c.url,
|
||||||
|
body: d ? d.body : c.body,
|
||||||
|
emoji: d ? d.emoji || undefined : c.emoji,
|
||||||
|
volume: d ? d.volume : c.volume,
|
||||||
|
info: c.info,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
uploads.forEach((u) => {
|
||||||
|
clips[u.shortcode] = {
|
||||||
|
url: u.url,
|
||||||
|
body: u.body,
|
||||||
|
emoji: u.emoji || undefined,
|
||||||
|
volume: u.volume,
|
||||||
|
info: u.info,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await onUpdate({ pack: pack.meta.content, clips });
|
||||||
|
setDrafts(new Map());
|
||||||
|
setDeleted(new Set());
|
||||||
|
setUploads([]);
|
||||||
|
}, [existing, deleted, drafts, uploads, onUpdate, pack]),
|
||||||
|
);
|
||||||
|
const saving = saveState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const renderRow = (key: string, base: ClipDraft, isUpload: boolean, markedDeleted: boolean) => {
|
||||||
|
const d = isUpload ? base : draftFor(key, base);
|
||||||
|
const rowVolume = isUpload ? base.volume : d.volume;
|
||||||
|
const rowBody = isUpload ? base.body : d.body;
|
||||||
|
const rowEmoji = isUpload ? base.emoji : d.emoji;
|
||||||
|
const commit = (patch: Partial<ClipDraft>) => {
|
||||||
|
if (isUpload) {
|
||||||
|
setUploads((prev) => prev.map((u) => (u.shortcode === key ? { ...u, ...patch } : u)));
|
||||||
|
} else {
|
||||||
|
setDraft(key, patch, base);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={key}
|
||||||
|
alignItems="Center"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S200,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
background: color.SurfaceVariant.Container,
|
||||||
|
opacity: markedDeleted ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
disabled={busyPreview === key}
|
||||||
|
onClick={() => preview(key, base.url, rowVolume)}
|
||||||
|
aria-label={`Preview ${rowBody}`}
|
||||||
|
>
|
||||||
|
{busyPreview === key ? <Spinner size="100" /> : <Icon size="100" src={Icons.Play} />}
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
disabled={!canEdit || markedDeleted}
|
||||||
|
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
emojiAnchorRef.current = evt.currentTarget;
|
||||||
|
setEmojiFor(key);
|
||||||
|
}}
|
||||||
|
aria-label="Pick emoji"
|
||||||
|
>
|
||||||
|
<Text size="T400">{rowEmoji || '🔊'}</Text>
|
||||||
|
</IconButton>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Input
|
||||||
|
variant="Surface"
|
||||||
|
size="300"
|
||||||
|
defaultValue={rowBody}
|
||||||
|
readOnly={!canEdit || markedDeleted}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => commit({ body: e.target.value })}
|
||||||
|
aria-label="Clip name"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(120) }}>
|
||||||
|
<Icon size="50" src={Icons.VolumeHigh} />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
defaultValue={rowVolume}
|
||||||
|
disabled={!canEdit || markedDeleted}
|
||||||
|
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
aria-label="Clip volume"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{canEdit && !isUpload && (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant={markedDeleted ? 'Success' : 'Critical'}
|
||||||
|
onClick={() =>
|
||||||
|
setDeleted((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label={markedDeleted ? 'Undo delete' : 'Delete clip'}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={markedDeleted ? Icons.Plus : Icons.Delete} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{canEdit && isUpload && (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Critical"
|
||||||
|
onClick={() => setUploads((prev) => prev.filter((u) => u.shortcode !== key))}
|
||||||
|
aria-label="Remove upload"
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="300">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
aria-label="Upload soundboard clip"
|
||||||
|
type="file"
|
||||||
|
accept={SOUNDBOARD_ACCEPT}
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
handleFiles(e.target.files);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||||
|
<Text size="H4">{pack.meta.name ?? 'Soundboard'}</Text>
|
||||||
|
{canEdit && (
|
||||||
|
<Chip
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
disabled={uploading || clipCount >= SOUNDBOARD_MAX_CLIPS}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
before={uploading ? <Spinner size="100" /> : <Icon size="100" src={Icons.Plus} />}
|
||||||
|
>
|
||||||
|
<Text size="B300">Upload</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
{existing.map((c) =>
|
||||||
|
renderRow(
|
||||||
|
c.shortcode,
|
||||||
|
{ url: c.url, body: c.body ?? c.shortcode, emoji: c.emoji ?? '', volume: c.volume },
|
||||||
|
false,
|
||||||
|
deleted.has(c.shortcode),
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{uploads.map((u) => renderRow(u.shortcode, u, true, false))}
|
||||||
|
{existing.length === 0 && uploads.length === 0 && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
No clips yet. Upload a short audio clip (max 1 MB){canEdit ? '' : ' — ask an admin'}.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canEdit && dirty && (
|
||||||
|
<Box gap="200">
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Success"
|
||||||
|
radii="300"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => save()}
|
||||||
|
before={saving ? <Spinner size="100" fill="Solid" /> : undefined}
|
||||||
|
>
|
||||||
|
<Text size="B300">Save changes</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
setDrafts(new Map());
|
||||||
|
setDeleted(new Set());
|
||||||
|
setUploads([]);
|
||||||
|
setError(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Reset</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PopOut
|
||||||
|
anchor={emojiFor ? emojiAnchorRef.current?.getBoundingClientRect() : undefined}
|
||||||
|
position="Bottom"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setEmojiFor(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EmojiBoard
|
||||||
|
imagePackRooms={[]}
|
||||||
|
returnFocusOnDeactivate={false}
|
||||||
|
onEmojiSelect={(unicode: string) => {
|
||||||
|
const key = emojiFor;
|
||||||
|
setEmojiFor(undefined);
|
||||||
|
if (!key) return;
|
||||||
|
const up = uploads.find((u) => u.shortcode === key);
|
||||||
|
if (up) {
|
||||||
|
setUploads((prev) =>
|
||||||
|
prev.map((u) => (u.shortcode === key ? { ...u, emoji: unicode } : u)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const c = existing.find((x) => x.shortcode === key);
|
||||||
|
if (c)
|
||||||
|
setDraft(
|
||||||
|
key,
|
||||||
|
{ emoji: unicode },
|
||||||
|
{
|
||||||
|
url: c.url,
|
||||||
|
body: c.body ?? c.shortcode,
|
||||||
|
emoji: c.emoji ?? '',
|
||||||
|
volume: c.volume,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
requestClose={() => setEmojiFor(undefined)}
|
||||||
|
/>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
</PopOut>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import { SoundboardPackEditor } from './SoundboardPackEditor';
|
||||||
|
import { SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
||||||
|
import { useUserSoundboardPack } from '../../hooks/useSoundboardPacks';
|
||||||
|
|
||||||
|
export function UserSoundboardPack() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const defaultPack = useMemo(
|
||||||
|
() =>
|
||||||
|
new SoundboardPack(
|
||||||
|
mx.getUserId() ?? '',
|
||||||
|
{ pack: { display_name: 'My Soundboard' } },
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
[mx],
|
||||||
|
);
|
||||||
|
const pack = useUserSoundboardPack() ?? defaultPack;
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
async (content: SoundboardContent) => {
|
||||||
|
await mx.setAccountData(
|
||||||
|
AccountDataEvent.LotusSoundboard as unknown as keyof import('matrix-js-sdk').AccountDataEvents,
|
||||||
|
content as never,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[mx],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SoundboardPackEditor pack={pack} canEdit onUpdate={handleUpdate} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './SoundboardPackEditor';
|
||||||
|
export * from './RoomSoundboardPack';
|
||||||
|
export * from './UserSoundboardPack';
|
||||||
@@ -56,6 +56,7 @@ function PreviewVideo({ fileItem }: PreviewVideoProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<video
|
<video
|
||||||
|
aria-label="Video attachment preview"
|
||||||
style={{
|
style={{
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
|
|||||||
<Input
|
<Input
|
||||||
ref={reasonInputRef}
|
ref={reasonInputRef}
|
||||||
placeholder="Reason"
|
placeholder="Reason"
|
||||||
|
aria-label="Moderation reason"
|
||||||
size="300"
|
size="300"
|
||||||
variant="Background"
|
variant="Background"
|
||||||
radii="300"
|
radii="300"
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ function UserPrivateNotes({ userId }: { userId: string }) {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<textarea
|
<textarea
|
||||||
|
aria-label="Private note about this user"
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
maxLength={USER_NOTE_MAX_LENGTH}
|
maxLength={USER_NOTE_MAX_LENGTH}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { ChangeEvent, useCallback, useEffect, useState } from 'react';
|
import React, { ChangeEvent, ReactNode, useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
@@ -16,6 +17,8 @@ import {
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
|
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useRoomEvent } from '../../hooks/useRoomEvent';
|
||||||
|
import { MessageDeletedContent } from '../../components/message/content/FallbackContent';
|
||||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { RoomAvatar } from '../../components/room-avatar';
|
import { RoomAvatar } from '../../components/room-avatar';
|
||||||
@@ -42,9 +45,11 @@ type BookmarkItemProps = {
|
|||||||
bookmark: Bookmark;
|
bookmark: Bookmark;
|
||||||
onJump: (roomId: string, eventId: string) => void;
|
onJump: (roomId: string, eventId: string) => void;
|
||||||
onRemove: (eventId: string) => void;
|
onRemove: (eventId: string) => void;
|
||||||
|
// Optional live-rendered preview node; falls back to the stored snapshot when absent.
|
||||||
|
preview?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) {
|
function BookmarkItem({ bookmark, onJump, onRemove, preview }: BookmarkItemProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const room = mx.getRoom(bookmark.roomId) ?? undefined;
|
const room = mx.getRoom(bookmark.roomId) ?? undefined;
|
||||||
@@ -104,18 +109,50 @@ function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) {
|
|||||||
style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }}
|
style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }}
|
||||||
>
|
>
|
||||||
<Text className={css.BookmarkPreview} size="T200" priority="400">
|
<Text className={css.BookmarkPreview} size="T200" priority="400">
|
||||||
{bookmark.previewText || '(no preview)'}
|
{preview ?? (bookmark.previewText || '(no preview)')}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LiveBookmarkItemProps = BookmarkItemProps & { room: Room };
|
||||||
|
|
||||||
|
// Renders the same layout as BookmarkItem, but resolves the message body live so
|
||||||
|
// edits (m.replace, applied by useRoomEvent) and redactions are reflected. The
|
||||||
|
// stored snapshot (previewText) remains the fallback for loading/failed/empty states.
|
||||||
|
function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItemProps) {
|
||||||
|
const liveEvent = useRoomEvent(room, bookmark.eventId, () =>
|
||||||
|
room.findEventById(bookmark.eventId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = bookmark.previewText || '(no preview)';
|
||||||
|
let preview: ReactNode = snapshot;
|
||||||
|
|
||||||
|
// undefined (loading) and null (fetch failed / not found) both keep the snapshot.
|
||||||
|
if (liveEvent) {
|
||||||
|
if (liveEvent.isRedacted()) {
|
||||||
|
preview = (
|
||||||
|
<MessageDeletedContent
|
||||||
|
reason={liveEvent.getUnsigned().redacted_because?.content?.reason as string | undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// body is already the edited text since useRoomEvent applied m.replace.
|
||||||
|
const { body } = liveEvent.getContent();
|
||||||
|
preview = typeof body === 'string' && body ? body : snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BookmarkItem bookmark={bookmark} onJump={onJump} onRemove={onRemove} preview={preview} />;
|
||||||
|
}
|
||||||
|
|
||||||
type BookmarksPanelProps = {
|
type BookmarksPanelProps = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
const { bookmarks, removeBookmark } = useBookmarks();
|
const { bookmarks, removeBookmark } = useBookmarks();
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
@@ -228,14 +265,27 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box className={css.BookmarksContent} direction="Column" gap="200">
|
<Box className={css.BookmarksContent} direction="Column" gap="200">
|
||||||
{filtered.map((bk) => (
|
{filtered.map((bk) => {
|
||||||
<BookmarkItem
|
// Live render when the room is joined (useRoomEvent needs a non-null Room);
|
||||||
key={bk.eventId}
|
// otherwise fall back to the stored snapshot for rooms we've left.
|
||||||
bookmark={bk}
|
const room = mx.getRoom(bk.roomId);
|
||||||
onJump={handleJump}
|
return room ? (
|
||||||
onRemove={removeBookmark}
|
<LiveBookmarkItem
|
||||||
/>
|
key={bk.eventId}
|
||||||
))}
|
room={room}
|
||||||
|
bookmark={bk}
|
||||||
|
onJump={handleJump}
|
||||||
|
onRemove={removeBookmark}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<BookmarkItem
|
||||||
|
key={bk.eventId}
|
||||||
|
bookmark={bk}
|
||||||
|
onJump={handleJump}
|
||||||
|
onRemove={removeBookmark}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Scroll>
|
</Scroll>
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ import { stopPropagation } from '../../utils/keyboard';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||||||
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
|
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
|
||||||
|
import { CallSoundboard } from './CallSoundboard';
|
||||||
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
|
import { RoomQualityContent } from '../../utils/callQuality';
|
||||||
|
|
||||||
type CallControlsProps = {
|
type CallControlsProps = {
|
||||||
callEmbed: CallEmbed;
|
callEmbed: CallEmbed;
|
||||||
@@ -88,6 +92,19 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||||
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||||||
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||||
|
const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
|
||||||
|
|
||||||
|
// [P5-31] Hard room publish policy — hide controls the server will refuse so
|
||||||
|
// users don't click dead buttons. Absent/true = allowed.
|
||||||
|
const roomQualityEvent = useStateEvent(callEmbed.room, StateEvent.LotusRoomQuality);
|
||||||
|
const roomQuality = roomQualityEvent?.getContent<RoomQualityContent>();
|
||||||
|
const cameraAllowed = roomQuality?.allow_camera !== false;
|
||||||
|
const screenshareAllowed = roomQuality?.allow_screenshare !== false;
|
||||||
|
// Keep a forbidden control visible while its track is still live (so the user
|
||||||
|
// can stop it); otherwise hide it entirely.
|
||||||
|
const showCamera = cameraAllowed || video;
|
||||||
|
const showScreenshare = screenshareAllowed || screenshare;
|
||||||
|
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
||||||
const [pttActive, setPttActive] = useState(false);
|
const [pttActive, setPttActive] = useState(false);
|
||||||
|
|
||||||
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
||||||
@@ -334,29 +351,40 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
|
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
|
||||||
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||||
<ScreenshareAudioButton
|
|
||||||
muted={screenshareAudioMuted}
|
|
||||||
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
{!compact && <ControlDivider />}
|
|
||||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
|
||||||
<VideoButton enabled={video} onToggle={handleVideoToggle} />
|
|
||||||
<ScreenShareButton
|
|
||||||
enabled={screenshare}
|
|
||||||
onToggle={() =>
|
|
||||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{!!document.fullscreenEnabled && (
|
|
||||||
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
{!compact && showVideoGroup && <ControlDivider />}
|
||||||
|
{showVideoGroup && (
|
||||||
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
|
{/* Show a forbidden control while its track is still live so the
|
||||||
|
user can stop it; once stopped it hides and can't be restarted. */}
|
||||||
|
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||||
|
{showScreenshare && (
|
||||||
|
<>
|
||||||
|
<ScreenShareButton
|
||||||
|
enabled={screenshare}
|
||||||
|
onToggle={() =>
|
||||||
|
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* Mute-screenshare-audio sits directly next to the screenshare
|
||||||
|
control since they're the same concern. */}
|
||||||
|
<ScreenshareAudioButton
|
||||||
|
muted={screenshareAudioMuted}
|
||||||
|
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!!document.fullscreenEnabled && (
|
||||||
|
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{!compact && <ControlDivider />}
|
{!compact && <ControlDivider />}
|
||||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
<ChatButton />
|
<ChatButton />
|
||||||
|
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
|
||||||
<PopOut
|
<PopOut
|
||||||
anchor={cords}
|
anchor={cords}
|
||||||
position="Top"
|
position="Top"
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Menu,
|
||||||
|
PopOut,
|
||||||
|
RectCords,
|
||||||
|
Scroll,
|
||||||
|
Spinner,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
TooltipProvider,
|
||||||
|
color,
|
||||||
|
config,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { CallEmbed } from '../../plugins/call';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||||
|
import { useRelevantSoundboardPacks } from '../../hooks/useSoundboardPacks';
|
||||||
|
import { SoundboardClipReader } from '../../plugins/soundboard';
|
||||||
|
import { UserSoundboardPack, RoomSoundboardPack } from '../../components/soundboard-pack-view';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { playClipLocally, resolveClipObjectUrl } from '../../utils/soundboardClips';
|
||||||
|
|
||||||
|
type CallSoundboardProps = {
|
||||||
|
callEmbed: CallEmbed;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlatClip = {
|
||||||
|
key: string; // packId|shortcode
|
||||||
|
packId: string;
|
||||||
|
packName: string;
|
||||||
|
clip: SoundboardClipReader;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [P5-15 v2] In-call soundboard. Clips come from the aggregated soundboard packs
|
||||||
|
* relevant to the call room (the room + parent spaces ∪ the user's personal
|
||||||
|
* pack), just like custom emoji. Playing a clip publishes it into the call via
|
||||||
|
* the EC fork (`io.lotus.inject_audio`, max one at a time) and plays it locally.
|
||||||
|
* A management toggle reveals the pack editors (personal + this room, if
|
||||||
|
* permitted). Space-wide packs are managed from Space settings.
|
||||||
|
*/
|
||||||
|
export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const { room } = callEmbed;
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
const packRooms = useImagePackRooms(room.roomId, roomToParents);
|
||||||
|
const packs = useRelevantSoundboardPacks(packRooms);
|
||||||
|
const [soundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
|
||||||
|
const master = Math.max(0, Math.min(1, soundboardVolume / 100));
|
||||||
|
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
const [manage, setManage] = useState(false);
|
||||||
|
const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
const groups = useMemo(
|
||||||
|
() =>
|
||||||
|
packs
|
||||||
|
.map((pack) => ({
|
||||||
|
id: pack.id,
|
||||||
|
name: pack.meta.name ?? 'Soundboard',
|
||||||
|
clips: pack.getClips(),
|
||||||
|
}))
|
||||||
|
.filter((g) => g.clips.length > 0),
|
||||||
|
[packs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setError(undefined);
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const play = useCallback(
|
||||||
|
async (flat: FlatClip) => {
|
||||||
|
if (playingKey) return; // one at a time (fork also enforces this)
|
||||||
|
setPlayingKey(flat.key);
|
||||||
|
setError(undefined);
|
||||||
|
const done = () => setPlayingKey((k) => (k === flat.key ? undefined : k));
|
||||||
|
try {
|
||||||
|
const url = await resolveClipObjectUrl(mx, flat.clip.url);
|
||||||
|
const vol = (flat.clip.volume / 100) * master;
|
||||||
|
callEmbed.control.injectAudio(url, vol);
|
||||||
|
const audio = playClipLocally(url, vol);
|
||||||
|
if (audio) {
|
||||||
|
audio.addEventListener('ended', done, { once: true });
|
||||||
|
audio.addEventListener('error', done, { once: true });
|
||||||
|
} else {
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
// Safety: clear the guard even if the audio never signals end.
|
||||||
|
window.setTimeout(done, 30_000);
|
||||||
|
} catch {
|
||||||
|
setError('Could not play that clip.');
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mx, callEmbed, master, playingKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Top"
|
||||||
|
align="Center"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu style={{ maxWidth: manage ? toRem(420) : toRem(340), maxHeight: '70vh' }}>
|
||||||
|
<Box direction="Column" style={{ maxHeight: '70vh' }}>
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="SpaceBetween"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S200,
|
||||||
|
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="L400">Soundboard</Text>
|
||||||
|
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Manage
|
||||||
|
</Text>
|
||||||
|
<Switch variant="Primary" value={manage} onChange={setManage} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Scroll size="300" hideTrack visibility="Hover">
|
||||||
|
<Box direction="Column" gap="300" style={{ padding: config.space.S200 }}>
|
||||||
|
{manage ? (
|
||||||
|
<>
|
||||||
|
<RoomSoundboardPack room={room} stateKey="" />
|
||||||
|
<UserSoundboardPack />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{groups.length === 0 && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
No soundboard clips here yet. Turn on <b>Manage</b> to upload some, or add
|
||||||
|
a pack in Space settings.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{groups.map((g) => (
|
||||||
|
<Box key={g.id} direction="Column" gap="100">
|
||||||
|
<Text size="L400">{g.name}</Text>
|
||||||
|
<Box wrap="Wrap" gap="200">
|
||||||
|
{g.clips.map((clip) => {
|
||||||
|
const key = `${g.id}|${clip.shortcode}`;
|
||||||
|
const flat: FlatClip = {
|
||||||
|
key,
|
||||||
|
packId: g.id,
|
||||||
|
packName: g.name,
|
||||||
|
clip,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={key}
|
||||||
|
as="button"
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
gap="100"
|
||||||
|
disabled={!!playingKey}
|
||||||
|
onClick={() => play(flat)}
|
||||||
|
aria-label={`Play ${clip.name}`}
|
||||||
|
style={{
|
||||||
|
width: toRem(76),
|
||||||
|
height: toRem(76),
|
||||||
|
padding: config.space.S100,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
background:
|
||||||
|
playingKey === key
|
||||||
|
? color.Primary.Container
|
||||||
|
: color.SurfaceVariant.Container,
|
||||||
|
cursor: playingKey ? 'default' : 'pointer',
|
||||||
|
opacity: playingKey && playingKey !== key ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="H4">
|
||||||
|
{playingKey === key ? (
|
||||||
|
<Spinner size="200" />
|
||||||
|
) : (
|
||||||
|
clip.emoji || '🔊'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" truncate style={{ maxWidth: '100%' }}>
|
||||||
|
{clip.name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TooltipProvider
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>Soundboard</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="Surface"
|
||||||
|
fill="Soft"
|
||||||
|
radii="400"
|
||||||
|
size="400"
|
||||||
|
onClick={handleOpen}
|
||||||
|
outlined
|
||||||
|
aria-label="Soundboard"
|
||||||
|
aria-expanded={!!cords}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
>
|
||||||
|
<Icon size="400" src={Icons.BellRing} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Box, Switch, Text } from 'folds';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||||
|
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import {
|
||||||
|
AUDIO_BITRATE_OPTIONS,
|
||||||
|
RoomQualityContent,
|
||||||
|
SCREENSHARE_BITRATE_OPTIONS,
|
||||||
|
SCREENSHARE_FRAMERATE_OPTIONS,
|
||||||
|
} from '../../../utils/callQuality';
|
||||||
|
|
||||||
|
// Only the numeric cap keys are edited via `update`; the boolean policy keys
|
||||||
|
// are handled by `setAllow`.
|
||||||
|
type CapKey = 'audio_max_kbps' | 'screenshare_max_kbps' | 'screenshare_max_fps';
|
||||||
|
|
||||||
|
// String <-> numeric bridge for SettingsSelect (which needs string values).
|
||||||
|
const toValue = (n?: number): string => (typeof n === 'number' ? String(n) : 'auto');
|
||||||
|
|
||||||
|
const CAP_KEYS: (keyof RoomQualityContent)[] = [
|
||||||
|
'audio_max_kbps',
|
||||||
|
'screenshare_max_kbps',
|
||||||
|
'screenshare_max_fps',
|
||||||
|
'allow_screenshare',
|
||||||
|
'allow_camera',
|
||||||
|
];
|
||||||
|
const capsEqual = (a: RoomQualityContent, b: RoomQualityContent): boolean =>
|
||||||
|
CAP_KEYS.every((k) => a[k] === b[k]);
|
||||||
|
|
||||||
|
type RoomQualityProps = {
|
||||||
|
permissions: RoomPermissionsAPI;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* [P5-31] Room-admin quality ceiling. Writes `io.lotus.room_quality`; every
|
||||||
|
* Lotus client clamps its per-user quality to these caps. Hard enforcement for
|
||||||
|
* ALL Matrix clients is a server-side follow-up (see LOTUS_TODO.md P5-31).
|
||||||
|
*/
|
||||||
|
export function RoomQuality({ permissions }: RoomQualityProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
|
||||||
|
const canEdit = permissions.stateEvent(StateEvent.LotusRoomQuality, mx.getSafeUserId());
|
||||||
|
|
||||||
|
const event = useStateEvent(room, StateEvent.LotusRoomQuality);
|
||||||
|
const caps = useMemo<RoomQualityContent>(() => event?.getContent() ?? {}, [event]);
|
||||||
|
|
||||||
|
const [submitState, submit] = useAsyncCallback(
|
||||||
|
useCallback(
|
||||||
|
async (next: RoomQualityContent) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await mx.sendStateEvent(room.roomId, StateEvent.LotusRoomQuality as any, next);
|
||||||
|
},
|
||||||
|
[mx, room.roomId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const submitting = submitState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
// Optimistic mirror: `useStateEvent` only refreshes when the write echoes
|
||||||
|
// back via /sync (not when sendStateEvent resolves), so consecutive edits
|
||||||
|
// must build on the pending write — otherwise a second edit spreads a stale
|
||||||
|
// `caps` and silently drops the first. `effective` is what the UI shows and
|
||||||
|
// what each edit merges into; it's reconciled below once the echo lands.
|
||||||
|
const [pending, setPending] = useState<RoomQualityContent | null>(null);
|
||||||
|
const effective = pending ?? caps;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pending) return;
|
||||||
|
// Revert the optimistic view if the write failed…
|
||||||
|
if (submitState.status === AsyncStatus.Error) {
|
||||||
|
setPending(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// …or drop it once the synced state actually reflects it.
|
||||||
|
if (capsEqual(caps, pending)) setPending(null);
|
||||||
|
}, [caps, pending, submitState.status]);
|
||||||
|
|
||||||
|
const commit = (next: RoomQualityContent) => {
|
||||||
|
setPending(next);
|
||||||
|
submit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (key: CapKey, value: string) => {
|
||||||
|
const next: RoomQualityContent = { ...effective };
|
||||||
|
if (value === 'auto') delete next[key];
|
||||||
|
else next[key] = parseInt(value, 10);
|
||||||
|
commit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAllow = (key: 'allow_screenshare' | 'allow_camera', allowed: boolean) => {
|
||||||
|
const next: RoomQualityContent = { ...effective };
|
||||||
|
// Absent = allowed, so only persist the key when forbidding.
|
||||||
|
if (allowed) delete next[key];
|
||||||
|
else next[key] = false;
|
||||||
|
commit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Absent/true = allowed.
|
||||||
|
const screenshareAllowed = effective.allow_screenshare !== false;
|
||||||
|
const cameraAllowed = effective.allow_camera !== false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Call Permissions"
|
||||||
|
description={
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Control what participants may share in this room. These are enforced on the server for
|
||||||
|
every Matrix client (Element, FluffyChat, Lotus Chat, …).
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Box direction="Column" gap="300">
|
||||||
|
<SettingTile
|
||||||
|
title="Allow Screen Sharing"
|
||||||
|
description="When off, no one can share their screen in this room."
|
||||||
|
after={
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={screenshareAllowed}
|
||||||
|
onChange={(v) => setAllow('allow_screenshare', v)}
|
||||||
|
disabled={!canEdit || submitting}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Allow Camera"
|
||||||
|
description="When off, this is an audio-only room — no one can turn on their camera. Microphones are always allowed."
|
||||||
|
after={
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={cameraAllowed}
|
||||||
|
onChange={(v) => setAllow('allow_camera', v)}
|
||||||
|
disabled={!canEdit || submitting}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<SettingTile
|
||||||
|
title="Call Quality Caps"
|
||||||
|
description={
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Set a maximum microphone bitrate, screenshare bitrate, and screenshare framerate for
|
||||||
|
this room. Lotus Chat clamps each participant to these ceilings (best-effort — applies
|
||||||
|
to Lotus Chat clients). Auto = no cap.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Box direction="Column" gap="300">
|
||||||
|
<SettingTile
|
||||||
|
title="Max Microphone Bitrate"
|
||||||
|
after={
|
||||||
|
<SettingsSelect
|
||||||
|
value={toValue(effective.audio_max_kbps)}
|
||||||
|
onChange={(v) => update('audio_max_kbps', v)}
|
||||||
|
options={AUDIO_BITRATE_OPTIONS}
|
||||||
|
disabled={!canEdit || submitting}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Max Screenshare Bitrate"
|
||||||
|
after={
|
||||||
|
<SettingsSelect
|
||||||
|
value={toValue(effective.screenshare_max_kbps)}
|
||||||
|
onChange={(v) => update('screenshare_max_kbps', v)}
|
||||||
|
options={SCREENSHARE_BITRATE_OPTIONS}
|
||||||
|
disabled={!canEdit || submitting}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Max Screenshare Framerate"
|
||||||
|
after={
|
||||||
|
<SettingsSelect
|
||||||
|
value={toValue(effective.screenshare_max_fps)}
|
||||||
|
onChange={(v) => update('screenshare_max_fps', v)}
|
||||||
|
options={SCREENSHARE_FRAMERATE_OPTIONS}
|
||||||
|
disabled={!canEdit || submitting}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</SequenceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export * from './RoomHistoryVisibility';
|
|||||||
export * from './RoomJoinRules';
|
export * from './RoomJoinRules';
|
||||||
export * from './RoomProfile';
|
export * from './RoomProfile';
|
||||||
export * from './RoomPublish';
|
export * from './RoomPublish';
|
||||||
|
export * from './RoomQuality';
|
||||||
export * from './RoomShareInvite';
|
export * from './RoomShareInvite';
|
||||||
export * from './RoomUpgrade';
|
export * from './RoomUpgrade';
|
||||||
export * from './RoomVoiceLimit';
|
export * from './RoomVoiceLimit';
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
|
|||||||
<Text size="L400">Name</Text>
|
<Text size="L400">Name</Text>
|
||||||
<Input
|
<Input
|
||||||
name="nameInput"
|
name="nameInput"
|
||||||
|
aria-label="Power level name"
|
||||||
defaultValue={tag?.name}
|
defaultValue={tag?.name}
|
||||||
placeholder="Bot"
|
placeholder="Bot"
|
||||||
size="300"
|
size="300"
|
||||||
@@ -160,6 +161,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
|
|||||||
<Input
|
<Input
|
||||||
defaultValue={power}
|
defaultValue={power}
|
||||||
name="powerInput"
|
name="powerInput"
|
||||||
|
aria-label="Power level value"
|
||||||
size="300"
|
size="300"
|
||||||
variant={typeof power === 'number' ? 'SurfaceVariant' : 'Secondary'}
|
variant={typeof power === 'number' ? 'SurfaceVariant' : 'Secondary'}
|
||||||
radii="300"
|
radii="300"
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
||||||
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { RoomSoundboardPack, UserSoundboardPack } from '../../../components/soundboard-pack-view';
|
||||||
|
|
||||||
|
type SoundboardProps = {
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soundboard management page (Room/Space settings). Mirrors the Emojis &
|
||||||
|
* Stickers page: a shared room/space pack (admin-editable, inherited by child
|
||||||
|
* rooms like emoji packs) plus the user's personal pack. A single default room
|
||||||
|
* pack (state key "") is used per room/space.
|
||||||
|
*/
|
||||||
|
export function Soundboard({ requestClose }: SoundboardProps) {
|
||||||
|
const room = useRoom();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageHeader outlined={false}>
|
||||||
|
<Box grow="Yes" gap="200">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Text as="h2" size="H3" truncate>
|
||||||
|
Soundboard
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Scroll hideTrack visibility="Hover">
|
||||||
|
<PageContent>
|
||||||
|
<Box direction="Column" gap="700">
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text size="L400">This room / space (shared)</Text>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Clips here are shared with everyone, and inherited by every room under this space
|
||||||
|
— just like emoji/sticker packs. Only members with permission can edit.
|
||||||
|
</Text>
|
||||||
|
{room && <RoomSoundboardPack room={room} stateKey="" />}
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text size="L400">Personal</Text>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Your own clips, available in every call and synced across your devices.
|
||||||
|
</Text>
|
||||||
|
<UserSoundboardPack />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageContent>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './Soundboard';
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { DefaultReset, color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
const BAR_HEIGHT = toRem(32);
|
||||||
|
const CONTROL_WIDTH = toRem(46);
|
||||||
|
|
||||||
|
export const TitleBar = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
flexShrink: 0,
|
||||||
|
height: BAR_HEIGHT,
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
borderBottom: `${toRem(1)} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
// Sit above app content but never intercept scroll etc. below the bar.
|
||||||
|
userSelect: 'none',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The draggable region carries `data-tauri-drag-region`; it must expand to fill
|
||||||
|
// the free space so most of the bar is grabbable.
|
||||||
|
export const DragRegion = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
gap: config.space.S200,
|
||||||
|
paddingInline: config.space.S300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Brand = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S200,
|
||||||
|
// Children shouldn't swallow the drag; the region itself owns the attribute.
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Controls = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ControlButton = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: CONTROL_WIDTH,
|
||||||
|
height: '100%',
|
||||||
|
padding: 0,
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'inherit',
|
||||||
|
transition: 'background-color 100ms ease',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: color.SurfaceVariant.ContainerLine,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ControlButtonClose = style({
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: color.Critical.Main,
|
||||||
|
color: color.Critical.OnMain,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import React, { MouseEvent, ReactNode } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { Text } from 'folds';
|
||||||
|
import { customWindowChromeAtom } from '../../state/customWindowChrome';
|
||||||
|
import { invokeTauri, isTauri } from '../../hooks/useTauri';
|
||||||
|
import * as css from './TitleBar.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect macOS from the web side (no `tauri-plugin-os` dependency). We only need
|
||||||
|
* a coarse "is this a Mac" signal to decide which side the window controls sit
|
||||||
|
* on, so the UA/platform sniff is sufficient and stays cross-platform.
|
||||||
|
*/
|
||||||
|
const isMacOS = (): boolean => {
|
||||||
|
const platform =
|
||||||
|
(
|
||||||
|
navigator as unknown as {
|
||||||
|
userAgentData?: { platform?: string };
|
||||||
|
}
|
||||||
|
).userAgentData?.platform ??
|
||||||
|
navigator.platform ??
|
||||||
|
navigator.userAgent;
|
||||||
|
return /mac/i.test(platform);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIN_GLYPH = (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||||
|
<rect x="1" y="4.5" width="8" height="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MAX_GLYPH = (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||||
|
<rect x="1" y="1" width="8" height="8" fill="none" stroke="currentColor" strokeWidth="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CLOSE_GLYPH = (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||||
|
<path d="M1 1 L9 9 M9 1 L1 9" stroke="currentColor" strokeWidth="1" fill="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
type ControlButtonProps = {
|
||||||
|
label: string;
|
||||||
|
glyph: ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
close?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ControlButton({ label, glyph, onClick, close }: ControlButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`${css.ControlButton}${close ? ` ${css.ControlButtonClose}` : ''}`}
|
||||||
|
>
|
||||||
|
{glyph}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-47 — TDS Custom Window Chrome titlebar.
|
||||||
|
*
|
||||||
|
* Renders `null` unless we're inside Tauri **and** the user opted into custom
|
||||||
|
* window chrome. Otherwise it draws a thin (~32px) folds/TDS-styled titlebar: a
|
||||||
|
* draggable region (explicit `window_start_drag` on mousedown, double-press to
|
||||||
|
* maximize) with the app brand, plus minimize / maximize / close controls that
|
||||||
|
* call the native window commands.
|
||||||
|
*
|
||||||
|
* OS-aware: Windows/Linux put the controls on the right; macOS mirrors them to
|
||||||
|
* the left (the native traffic-light position) since decorations — and thus the
|
||||||
|
* real traffic lights — are stripped while custom chrome is on.
|
||||||
|
*/
|
||||||
|
export function TitleBar() {
|
||||||
|
const enabled = useAtomValue(customWindowChromeAtom);
|
||||||
|
|
||||||
|
if (!isTauri() || !enabled) return null;
|
||||||
|
|
||||||
|
const mac = isMacOS();
|
||||||
|
|
||||||
|
// Official Tauri custom-titlebar recipe: primary-button mousedown starts an
|
||||||
|
// OS window drag; a double press (detail === 2) toggles maximize instead. An
|
||||||
|
// explicit `window_start_drag` invoke is used rather than
|
||||||
|
// `data-tauri-drag-region` because the attribute only fires when the exact
|
||||||
|
// element is the event target (children like the brand text wouldn't drag).
|
||||||
|
const handleDragMouseDown = (evt: MouseEvent<HTMLDivElement>): void => {
|
||||||
|
if (evt.button !== 0) return;
|
||||||
|
if (evt.detail === 2) {
|
||||||
|
invokeTauri('window_toggle_maximize');
|
||||||
|
} else {
|
||||||
|
invokeTauri('window_start_drag');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const controls = (
|
||||||
|
<div className={css.Controls}>
|
||||||
|
<ControlButton
|
||||||
|
label="Minimize"
|
||||||
|
glyph={MIN_GLYPH}
|
||||||
|
onClick={() => invokeTauri('window_minimize')}
|
||||||
|
/>
|
||||||
|
<ControlButton
|
||||||
|
label="Maximize"
|
||||||
|
glyph={MAX_GLYPH}
|
||||||
|
onClick={() => invokeTauri('window_toggle_maximize')}
|
||||||
|
/>
|
||||||
|
<ControlButton
|
||||||
|
label="Close"
|
||||||
|
glyph={CLOSE_GLYPH}
|
||||||
|
onClick={() => invokeTauri('window_close')}
|
||||||
|
close
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const dragRegion = (
|
||||||
|
<div className={css.DragRegion} onMouseDown={handleDragMouseDown}>
|
||||||
|
<span className={css.Brand}>
|
||||||
|
<Text as="span" size="T200" truncate>
|
||||||
|
Lotus Chat
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={css.TitleBar}>
|
||||||
|
{mac ? (
|
||||||
|
<>
|
||||||
|
{controls}
|
||||||
|
{dragRegion}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{dragRegion}
|
||||||
|
{controls}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
// Aurora Flow — a SLOW, gentle pan of layered soft aurora ribbons.
|
||||||
|
//
|
||||||
|
// The living-aurora illusion is a pure `background-position` drift: each
|
||||||
|
// comma-separated gradient layer is authored larger than the viewport
|
||||||
|
// (backgroundSize 200%–300%, see animAurora.ts) so there is slack to slide it
|
||||||
|
// around. Panning several broad blurred bands by DIFFERENT
|
||||||
|
// amounts and along DIFFERENT paths makes the ribbons appear to curl and cross
|
||||||
|
// like real northern lights — no single layer ever moves in lockstep.
|
||||||
|
//
|
||||||
|
// LAYER ORDER (must match animAurora.ts exactly — one position value per layer):
|
||||||
|
// 1. green ribbon (drifts a wide, lazy horizontal arc)
|
||||||
|
// 2. teal ribbon (drifts on a slower, offset diagonal)
|
||||||
|
// 3. violet ribbon (drifts vertically, the "curtain" fold)
|
||||||
|
// 4. sky/aqua highlight (small counter-drift for shimmer)
|
||||||
|
// 5. calm reading core (STATIC — kept at 50% 50% so the center never moves)
|
||||||
|
// 6. vignette (STATIC — kept at 50% 50% so edges never move)
|
||||||
|
//
|
||||||
|
// SEAMLESS LOOP: every animated layer starts and ends on the SAME position
|
||||||
|
// ('0%'/'100%' being identical sample points of the repeating gradient tile),
|
||||||
|
// so one period returns each band to its origin with no visible jump. The two
|
||||||
|
// static layers list their fixed position at every stop so they never pan.
|
||||||
|
//
|
||||||
|
// SLOW & GENTLE: paired with a long duration + ease-in-out in animAurora.ts, the
|
||||||
|
// motion reads as a barely-perceptible breathing drift, keeping the reading
|
||||||
|
// center calm and text crisp.
|
||||||
|
//
|
||||||
|
// getChatBg adds `willChange: 'background-position'` here and STRIPS the whole
|
||||||
|
// `animation` for prefers-reduced-motion / pause-animations, at which point the
|
||||||
|
// static `backgroundPosition` authored in animAurora.ts is what shows — already
|
||||||
|
// a finished, gorgeous aurora.
|
||||||
|
export const auroraFlow = keyframes({
|
||||||
|
'0%': {
|
||||||
|
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||||
|
},
|
||||||
|
'25%': {
|
||||||
|
backgroundPosition: '35% 45%, 70% 55%, 55% 35%, 45% 60%, 50% 50%, 50% 50%',
|
||||||
|
},
|
||||||
|
'50%': {
|
||||||
|
backgroundPosition: '65% 60%, 40% 40%, 45% 70%, 70% 35%, 50% 50%, 50% 50%',
|
||||||
|
},
|
||||||
|
'75%': {
|
||||||
|
backgroundPosition: '35% 45%, 70% 55%, 55% 35%, 45% 60%, 50% 50%, 50% 50%',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
import { auroraFlow } from './animAurora.css';
|
||||||
|
|
||||||
|
// Aurora Flow — a premium ANIMATED aurora: soft ribbons of northern-lights color
|
||||||
|
// slowly drifting and curling over a deep, calm base.
|
||||||
|
//
|
||||||
|
// CONCEPT
|
||||||
|
// Broad, heavily-feathered gradient bands stacked over a deep midnight base, with
|
||||||
|
// a gentle vignette that darkens the edges and keeps the reading center calm.
|
||||||
|
// The distinct STATIC 'aurora' is a favorite still; this one earns its own slot
|
||||||
|
// by MOVING — see animAurora.css.ts, which slowly pans each ribbon along its own
|
||||||
|
// path via `background-position` so the curtains appear to fold and cross.
|
||||||
|
//
|
||||||
|
// LAYER ORDER (must stay in lockstep with auroraFlow's per-layer position list):
|
||||||
|
// 1. green ribbon 2. teal ribbon 3. violet ribbon 4. sky highlight
|
||||||
|
// 5. calm reading core (static) 6. vignette (static)
|
||||||
|
//
|
||||||
|
// READABILITY
|
||||||
|
// Every ribbon is a wide ellipse fading fully to transparent well before its
|
||||||
|
// edge, at low alpha (~0.05–0.13), so no band ever concentrates enough contrast
|
||||||
|
// under the message column to threaten WCAG-AA. Layer 5 lifts a soft, even wash
|
||||||
|
// through the vertical center — the reading zone — so text always sits on a calm,
|
||||||
|
// low-variance field. oklch() keeps every hue perceptually smooth and low-chroma.
|
||||||
|
//
|
||||||
|
// MOTION / SEAMLESS LOOP
|
||||||
|
// backgroundSize is >100% per animated layer, giving room to drift; the keyframe
|
||||||
|
// returns every band to its start over one long, ease-in-out period, so the loop
|
||||||
|
// is seamless and the motion barely-perceptible. willChange/animation are added
|
||||||
|
// (and stripped for reduced-motion) by getChatBg; the static positions below are
|
||||||
|
// the finished still that shows when motion is off.
|
||||||
|
|
||||||
|
const dark: CSSProperties = {
|
||||||
|
// Deep midnight blue — the polar night sky the aurora glows over.
|
||||||
|
backgroundColor: 'oklch(0.17 0.045 255)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. Green ribbon — the signature aurora band.
|
||||||
|
'radial-gradient(ellipse 70% 45% at 50% 50%, oklch(0.7 0.14 160 / 0.13) 0%, oklch(0.7 0.14 160 / 0.05) 45%, transparent 72%)',
|
||||||
|
// 2. Teal ribbon — cool counterpart, offset.
|
||||||
|
'radial-gradient(ellipse 80% 40% at 50% 50%, oklch(0.65 0.12 200 / 0.12) 0%, oklch(0.65 0.12 200 / 0.04) 48%, transparent 74%)',
|
||||||
|
// 3. Violet ribbon — the high curtain fold.
|
||||||
|
'radial-gradient(ellipse 65% 55% at 50% 50%, oklch(0.55 0.13 300 / 0.11) 0%, oklch(0.55 0.13 300 / 0.04) 46%, transparent 70%)',
|
||||||
|
// 4. Sky/aqua highlight — subtle shimmer that counter-drifts.
|
||||||
|
'radial-gradient(ellipse 55% 35% at 50% 50%, oklch(0.72 0.1 220 / 0.09) 0%, transparent 65%)',
|
||||||
|
// 5. Calm reading core (static) — a soft even wash down the center column so
|
||||||
|
// message text always rests on a low-variance field.
|
||||||
|
'radial-gradient(ellipse 120% 60% at 50% 50%, oklch(0.2 0.04 255 / 0.5) 0%, transparent 70%)',
|
||||||
|
// 6. Vignette (static) — gently darkens the edges for luminous depth.
|
||||||
|
'radial-gradient(ellipse 130% 120% at 50% 50%, transparent 55%, oklch(0.12 0.04 260 / 0.55) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
||||||
|
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
animation: `${auroraFlow} 60s ease-in-out infinite`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const light: CSSProperties = {
|
||||||
|
// Pale cool base — a soft pre-dawn sky the pastel aurora dreams over.
|
||||||
|
backgroundColor: 'oklch(0.97 0.012 240)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. Mint ribbon.
|
||||||
|
'radial-gradient(ellipse 70% 45% at 50% 50%, oklch(0.85 0.08 160 / 0.5) 0%, oklch(0.85 0.08 160 / 0.16) 45%, transparent 72%)',
|
||||||
|
// 2. Sky ribbon.
|
||||||
|
'radial-gradient(ellipse 80% 40% at 50% 50%, oklch(0.83 0.07 220 / 0.48) 0%, oklch(0.83 0.07 220 / 0.14) 48%, transparent 74%)',
|
||||||
|
// 3. Lilac ribbon — the high curtain fold.
|
||||||
|
'radial-gradient(ellipse 65% 55% at 50% 50%, oklch(0.82 0.07 300 / 0.42) 0%, oklch(0.82 0.07 300 / 0.12) 46%, transparent 70%)',
|
||||||
|
// 4. Aqua highlight — subtle shimmer that counter-drifts.
|
||||||
|
'radial-gradient(ellipse 55% 35% at 50% 50%, oklch(0.88 0.06 200 / 0.34) 0%, transparent 65%)',
|
||||||
|
// 5. Calm reading core (static) — a bright even wash down the center column
|
||||||
|
// so dark message text always rests on a light, low-variance field.
|
||||||
|
'radial-gradient(ellipse 120% 60% at 50% 50%, oklch(0.99 0.005 240 / 0.6) 0%, transparent 70%)',
|
||||||
|
// 6. Vignette (static) — a whisper of cool shade at the edges for depth.
|
||||||
|
'radial-gradient(ellipse 130% 120% at 50% 50%, transparent 55%, oklch(0.9 0.02 250 / 0.45) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
||||||
|
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
animation: `${auroraFlow} 60s ease-in-out infinite`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const animAurora: ChatBgVariants = { dark, light };
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
// Fireflies — a slow, gentle PAN of sparse glowing motes across a warm summer
|
||||||
|
// dusk. The scene in animFireflies.ts stacks these background layers:
|
||||||
|
// 1. large bright motes — tile 227x227, brightest core+halo, drifts FASTEST
|
||||||
|
// 2. medium motes — tile 293x293, dimmer, medium drift
|
||||||
|
// 3. tiny far sparks — tile 179x179, faintest, drifts SLOWEST (small step)
|
||||||
|
// 4. center vignette (100% 100%) — STATIC
|
||||||
|
// 5. warm dusk wash A (100% 100%) — STATIC
|
||||||
|
// 6. warm dusk wash B (100% 100%) — STATIC
|
||||||
|
//
|
||||||
|
// Seamless drift: the single `animation` shorthand shares ONE duration across all
|
||||||
|
// layers, so the differing apparent speeds come purely from how FAR each layer
|
||||||
|
// travels. For a jump-free loop every mote layer must translate by an EXACT
|
||||||
|
// integer multiple of its own tile period in BOTH axes, so the mote re-entering
|
||||||
|
// at the wrap is identical to the one that left. Each layer moves exactly one
|
||||||
|
// full tile:
|
||||||
|
// large : -227 / -227 (1 x 227)
|
||||||
|
// medium: -293 / -293 (1 x 293) — bigger tile, same 1-tile move => SLOWER look
|
||||||
|
// far : -179 / -179 (1 x 179) — smallest tile, damped by low opacity so it
|
||||||
|
// reads as the calm distant layer
|
||||||
|
// Because tile sizes differ, one shared 1-tile translation yields three distinct
|
||||||
|
// apparent speeds — the wandering-firefly parallax — while every layer lands back
|
||||||
|
// on an identical phase at 100% for a perfectly seamless repeat.
|
||||||
|
//
|
||||||
|
// The diagonal component (both x and y shift) makes motes feel like they wander
|
||||||
|
// through the meadow rather than slide flatly. The three static layers (vignette
|
||||||
|
// and the two dusk washes) are pinned at '0 0' every frame so the warm ambient
|
||||||
|
// glow and the calm reading center never move under the text.
|
||||||
|
//
|
||||||
|
// The '0%' frame MUST match the static backgroundPosition authored in
|
||||||
|
// animFireflies.ts, so when getChatBg STRIPS this animation for
|
||||||
|
// prefers-reduced-motion the finished scene of glowing motes shows without a jump.
|
||||||
|
export const firefliesDrift = keyframes({
|
||||||
|
'0%': {
|
||||||
|
// large, medium, far, vignette, wash A, wash B
|
||||||
|
backgroundPosition: '0 0, 83px 47px, 131px 101px, 0 0, 0 0, 0 0',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
// large: 0-227 / 0-227
|
||||||
|
// medium: 83-293 / 47-293
|
||||||
|
// far: 131-179 / 101-179
|
||||||
|
backgroundPosition: '-227px -227px, -210px -246px, -48px -78px, 0 0, 0 0, 0 0',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
import { firefliesDrift } from './animFireflies.css';
|
||||||
|
|
||||||
|
// Fireflies — a warm summer-dusk meadow. A few soft golden-green motes drift over
|
||||||
|
// a deep base, each mote a bright core melting into a warm halo. Sparse by design
|
||||||
|
// so the reading column stays clear; the motion is a slow, gentle background-
|
||||||
|
// position PAN (see animFireflies.css.ts) that reads as fireflies wandering.
|
||||||
|
//
|
||||||
|
// Layer stacking order (topmost first — CSS paints image #1 on top):
|
||||||
|
// 1. large bright motes — crisp warm core -> warm halo, sparse, largest step
|
||||||
|
// 2. medium motes — dimmer, smaller, more of them
|
||||||
|
// 3. tiny far sparks — faintest, smallest tile, calm distant layer
|
||||||
|
// 4. center vignette — keeps the reading center the calmest area
|
||||||
|
// 5. warm dusk wash A — ambient glow, upper
|
||||||
|
// 6. warm dusk wash B — ambient glow, lower
|
||||||
|
// Mote tiles use coprime-ish sizes (227/293/179) so their repeats never line up
|
||||||
|
// and the field reads as scattered, not gridded.
|
||||||
|
//
|
||||||
|
// getChatBg STRIPS the `animation` for prefers-reduced-motion / pause, so the
|
||||||
|
// authored backgroundPosition already composes a finished, gorgeous still scene
|
||||||
|
// of glowing motes on its own — the animation only sets them gently adrift.
|
||||||
|
export const animFireflies: ChatBgVariants = {
|
||||||
|
// Dark: warm gold-green glows on a deep forest-navy base with a soft vignette.
|
||||||
|
// Cores sit near oklch(0.85 0.13 110); halos fall to a warm amber-green. All
|
||||||
|
// opacities are kept low so message text stays crisp (WCAG-AA) over the field.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'oklch(0.17 0.035 175)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. large bright motes — golden-green core fading through a warm halo
|
||||||
|
'radial-gradient(circle at center, oklch(0.85 0.13 110 / 0.55) 1.4px, oklch(0.72 0.14 95 / 0.16) 3px, transparent 6px)',
|
||||||
|
// 2. medium motes — a touch cooler-green, dimmer, more numerous
|
||||||
|
'radial-gradient(circle at center, oklch(0.82 0.13 128 / 0.40) 1.1px, oklch(0.70 0.12 110 / 0.12) 2.4px, transparent 5px)',
|
||||||
|
// 3. tiny far sparks — faint warm pinpoints, the calm distant layer
|
||||||
|
'radial-gradient(circle at center, oklch(0.88 0.11 100 / 0.28) 0.8px, transparent 2.4px)',
|
||||||
|
// 4. center vignette — darkens the edges, keeps reading center calmest
|
||||||
|
'radial-gradient(ellipse 125% 95% at 50% 44%, transparent 40%, oklch(0.10 0.03 175 / 0.55) 100%)',
|
||||||
|
// 5. warm dusk wash A — a low amber-green glow drifting in from upper-right
|
||||||
|
'radial-gradient(ellipse 140% 120% at 80% 10%, oklch(0.30 0.07 120 / 0.45) 0%, transparent 58%)',
|
||||||
|
// 6. warm dusk wash B — deep teal-navy pooling into the lower-left
|
||||||
|
'radial-gradient(ellipse 135% 115% at 16% 94%, oklch(0.22 0.05 190 / 0.50) 0%, transparent 60%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'227px 227px', // large motes
|
||||||
|
'293px 293px', // medium motes
|
||||||
|
'179px 179px', // far sparks
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'100% 100%', // wash A
|
||||||
|
'100% 100%', // wash B
|
||||||
|
].join(','),
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // large (matches firefliesDrift 0%)
|
||||||
|
'83px 47px', // medium (offset breaks alignment)
|
||||||
|
'131px 101px', // far (offset again)
|
||||||
|
'0 0', // vignette (static)
|
||||||
|
'0 0', // wash A (static)
|
||||||
|
'0 0', // wash B (static)
|
||||||
|
].join(','),
|
||||||
|
animation: `${firefliesDrift} 44s linear infinite`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Light: a cozy warm dim-dusk. No harsh dots on white — soft amber motes with
|
||||||
|
// gentle halos float on a warm blush->honey gradient. Contrast stays low so the
|
||||||
|
// reading area is comfortable and text remains crisp (WCAG-AA).
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'oklch(0.955 0.02 85)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. large amber motes — warm honey core into a soft amber halo
|
||||||
|
'radial-gradient(circle at center, oklch(0.80 0.11 80 / 0.30) 1.4px, oklch(0.85 0.09 70 / 0.12) 3px, transparent 6px)',
|
||||||
|
// 2. medium motes — slightly greener-gold, softer
|
||||||
|
'radial-gradient(circle at center, oklch(0.78 0.10 95 / 0.22) 1.1px, oklch(0.86 0.08 85 / 0.10) 2.4px, transparent 5px)',
|
||||||
|
// 3. tiny far sparks — faint warm pinpoints for texture, never noise
|
||||||
|
'radial-gradient(circle at center, oklch(0.75 0.10 75 / 0.16) 0.8px, transparent 2.4px)',
|
||||||
|
// 4. center vignette — brightens the calm reading center a touch
|
||||||
|
'radial-gradient(ellipse 125% 95% at 50% 44%, oklch(1 0 0 / 0.40) 30%, transparent 100%)',
|
||||||
|
// 5. warm dusk wash A — honey glow from the upper-right
|
||||||
|
'radial-gradient(ellipse 140% 120% at 80% 8%, oklch(0.92 0.06 85 / 0.55) 0%, transparent 60%)',
|
||||||
|
// 6. warm dusk wash B — soft rose blush pooling lower-left
|
||||||
|
'radial-gradient(ellipse 135% 115% at 15% 95%, oklch(0.93 0.05 40 / 0.45) 0%, transparent 62%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'227px 227px', // large motes
|
||||||
|
'293px 293px', // medium motes
|
||||||
|
'179px 179px', // far sparks
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'100% 100%', // wash A
|
||||||
|
'100% 100%', // wash B
|
||||||
|
].join(','),
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // large (matches firefliesDrift 0%)
|
||||||
|
'83px 47px', // medium
|
||||||
|
'131px 101px', // far
|
||||||
|
'0 0', // vignette (static)
|
||||||
|
'0 0', // wash A (static)
|
||||||
|
'0 0', // wash B (static)
|
||||||
|
].join(','),
|
||||||
|
animation: `${firefliesDrift} 44s linear infinite`,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
// Grid Pulse — a slow "energy" glow that sweeps across a static tech grid.
|
||||||
|
//
|
||||||
|
// The motif is a crisp thin grid that pulses. Rather than scaling the grid
|
||||||
|
// (which shifts every line and reads as a jitter behind text), we keep the grid
|
||||||
|
// perfectly still and PAN a single soft radial "bloom" layer diagonally across
|
||||||
|
// it. As the bloom drifts, the grid lines it passes over appear to brighten and
|
||||||
|
// then settle — a calm travelling pulse, never a flash.
|
||||||
|
//
|
||||||
|
// Layer mapping (see animPulse.ts — one background-position value per layer):
|
||||||
|
// 0. grid core lines (vertical) — STATIC ('0 0')
|
||||||
|
// 1. grid core lines (horizontal) — STATIC ('0 0')
|
||||||
|
// 2. grid fine sub-lines (V) — STATIC ('0 0')
|
||||||
|
// 3. grid fine sub-lines (H) — STATIC ('0 0')
|
||||||
|
// 4. TRAVELLING BLOOM — panned here (the only moving layer)
|
||||||
|
// 5. base wash / centre glow — STATIC ('0 0')
|
||||||
|
// 6. vignette — STATIC ('0 0')
|
||||||
|
//
|
||||||
|
// Seamless loop: the bloom layer is authored to tile (its backgroundSize in
|
||||||
|
// animPulse.ts is 480px — an exact 4x multiple of the 120px grid module, and
|
||||||
|
// 8x of the 60px sub-grid). Panning it by EXACTLY one bloom-tile (480px on both
|
||||||
|
// axes) returns every pixel to an identical neighbouring tile, so the wrap at
|
||||||
|
// 100% is invisible. Diagonal travel (both axes move together) makes the sweep
|
||||||
|
// feel organic while still landing on a whole-tile offset.
|
||||||
|
//
|
||||||
|
// getChatBg adds `willChange: 'background-position'` for the animated case, so a
|
||||||
|
// background-position pulse is exactly what the compositor is hinted for. It
|
||||||
|
// STRIPS this whole `animation` for prefers-reduced-motion / pause-animations,
|
||||||
|
// at which point the static bloom position authored in animPulse.ts is what
|
||||||
|
// shows — a finished, gently glowing grid.
|
||||||
|
export const gridPulse = keyframes({
|
||||||
|
'0%': {
|
||||||
|
backgroundPosition: '0 0, 0 0, 0 0, 0 0, 0px 0px, 0 0, 0 0',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
backgroundPosition: '0 0, 0 0, 0 0, 0 0, 480px 480px, 0 0, 0 0',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
import { gridPulse } from './animPulse.css';
|
||||||
|
|
||||||
|
// Grid Pulse (anim-pulse) — a refined sci-fi grid with a slow energy pulse.
|
||||||
|
//
|
||||||
|
// Concept: a crisp thin tech grid over which a single soft radial glow drifts
|
||||||
|
// diagonally, so the lines it crosses seem to charge and settle — a hypnotic
|
||||||
|
// travelling pulse rather than a strobing brightness flash. Three ingredients,
|
||||||
|
// exactly per the quality bar:
|
||||||
|
// 1. a crisp thin grid — two hairline linear layers (V + H) at a 120px module
|
||||||
|
// plus a fainter 60px sub-grid, so the mesh reads as fine machined lattice;
|
||||||
|
// 2. a soft bloom layer — one wide, very-low-opacity radial that TRAVELS across
|
||||||
|
// the grid (the pulse), authored to tile so the loop is seamless;
|
||||||
|
// 3. a radial vignette — keeps the reading centre calm (dark theme darkens it,
|
||||||
|
// light theme brightens it) so text always sits on the quietest region.
|
||||||
|
//
|
||||||
|
// Animation approach & why it's subtle: only ONE layer moves — the bloom — and
|
||||||
|
// it moves by pure background-position (the property getChatBg hints via
|
||||||
|
// willChange). No line ever shifts, no global brightness flicker, so text never
|
||||||
|
// wobbles. The glow itself is barely-there (opacity well under the neon bloom),
|
||||||
|
// so the "pulse" is felt as a slow wash of light passing behind the words. 22s
|
||||||
|
// per cycle makes it meditative, not busy.
|
||||||
|
//
|
||||||
|
// Seamless loop: the bloom's backgroundSize is 480px — an exact 4x multiple of
|
||||||
|
// the 120px grid module (and 8x of the 60px sub-grid). The keyframe pans it by
|
||||||
|
// exactly one 480px tile on both axes, so it wraps onto an identical tile with
|
||||||
|
// no visible seam (see animPulse.css.ts).
|
||||||
|
//
|
||||||
|
// Reduced-motion fallback: getChatBg strips `animation`, leaving the bloom at
|
||||||
|
// its authored static position — parked slightly above-centre so the finished
|
||||||
|
// frame reads as a deliberately-lit, gently glowing grid rather than a frozen
|
||||||
|
// mid-sweep. The grid, wash and vignette are all static regardless, so the
|
||||||
|
// still image is already a complete, premium background.
|
||||||
|
//
|
||||||
|
// Dark vs light: dark is a cool cyan lattice glowing on deep blue-black with a
|
||||||
|
// dim bloom and a centre-darkening vignette. Light is a soft slate-blue lattice
|
||||||
|
// on pale cool-white with a whisper-faint bloom and a centre-BRIGHTENING
|
||||||
|
// vignette, so the reading column lifts toward white. Both keep line + glow
|
||||||
|
// opacity low for WCAG-AA legibility in either app theme.
|
||||||
|
|
||||||
|
export const animPulse: ChatBgVariants = {
|
||||||
|
// Dark: cyan grid on deep blue-black, a dim energy bloom sweeping through.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'oklch(0.16 0.03 240)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 0. grid core — vertical hairlines (cool cyan)
|
||||||
|
'linear-gradient(90deg, oklch(0.75 0.11 200 / 0.14) 0 1px, transparent 1px)',
|
||||||
|
// 1. grid core — horizontal hairlines
|
||||||
|
'linear-gradient(0deg, oklch(0.75 0.11 200 / 0.14) 0 1px, transparent 1px)',
|
||||||
|
// 2. fine sub-grid — vertical (fainter, half module)
|
||||||
|
'linear-gradient(90deg, oklch(0.75 0.11 200 / 0.05) 0 1px, transparent 1px)',
|
||||||
|
// 3. fine sub-grid — horizontal
|
||||||
|
'linear-gradient(0deg, oklch(0.75 0.11 200 / 0.05) 0 1px, transparent 1px)',
|
||||||
|
// 4. TRAVELLING BLOOM — the pulse: a wide soft cyan glow that drifts
|
||||||
|
'radial-gradient(circle at 50% 50%, oklch(0.8 0.12 200 / 0.16) 0%, oklch(0.75 0.11 205 / 0.06) 26%, transparent 55%)',
|
||||||
|
// 5. base wash — a faint steady centre glow so the grid never looks flat
|
||||||
|
'radial-gradient(ellipse 120% 100% at 50% 42%, oklch(0.42 0.07 235 / 0.28) 0%, transparent 62%)',
|
||||||
|
// 6. vignette — darken the edges, keep the reading centre calm & dark
|
||||||
|
'radial-gradient(ellipse 130% 100% at 50% 46%, transparent 34%, oklch(0.11 0.02 245 / 0.72) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'120px 120px', // grid core V
|
||||||
|
'120px 120px', // grid core H
|
||||||
|
'60px 60px', // sub-grid V (exact 1/2 divisor — re-registers)
|
||||||
|
'60px 60px', // sub-grid H
|
||||||
|
'480px 480px', // bloom (4x module — pans one whole tile, seamless)
|
||||||
|
'100% 100%', // base wash
|
||||||
|
'100% 100%', // vignette
|
||||||
|
].join(','),
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // grid core V
|
||||||
|
'0 0', // grid core H
|
||||||
|
'0 0', // sub-grid V
|
||||||
|
'0 0', // sub-grid H
|
||||||
|
'120px 40px', // bloom static (reduced-motion) — parked above-centre
|
||||||
|
'0 0', // base wash
|
||||||
|
'0 0', // vignette
|
||||||
|
].join(','),
|
||||||
|
animation: `${gridPulse} 22s ease-in-out infinite`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Light: soft slate-blue grid on pale cool-white, a gentle luminance breathe.
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'oklch(0.975 0.006 235)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 0. grid core — vertical hairlines (soft slate-blue)
|
||||||
|
'linear-gradient(90deg, oklch(0.55 0.08 245 / 0.15) 0 1px, transparent 1px)',
|
||||||
|
// 1. grid core — horizontal hairlines
|
||||||
|
'linear-gradient(0deg, oklch(0.55 0.08 245 / 0.15) 0 1px, transparent 1px)',
|
||||||
|
// 2. fine sub-grid — vertical (fainter, half module)
|
||||||
|
'linear-gradient(90deg, oklch(0.55 0.08 245 / 0.055) 0 1px, transparent 1px)',
|
||||||
|
// 3. fine sub-grid — horizontal
|
||||||
|
'linear-gradient(0deg, oklch(0.55 0.08 245 / 0.055) 0 1px, transparent 1px)',
|
||||||
|
// 4. TRAVELLING BLOOM — a whisper of slate-blue light drifting through
|
||||||
|
'radial-gradient(circle at 50% 50%, oklch(0.6 0.09 240 / 0.09) 0%, oklch(0.62 0.08 245 / 0.035) 26%, transparent 55%)',
|
||||||
|
// 5. base wash — the faintest cool tint so the grid sits on soft light
|
||||||
|
'radial-gradient(ellipse 120% 100% at 50% 42%, oklch(0.86 0.03 235 / 0.30) 0%, transparent 62%)',
|
||||||
|
// 6. vignette — brighten the calm reading centre toward white for legibility
|
||||||
|
'radial-gradient(ellipse 130% 100% at 50% 46%, oklch(1 0 0 / 0.5) 30%, transparent 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'120px 120px', // grid core V
|
||||||
|
'120px 120px', // grid core H
|
||||||
|
'60px 60px', // sub-grid V
|
||||||
|
'60px 60px', // sub-grid H
|
||||||
|
'480px 480px', // bloom (4x module — seamless one-tile pan)
|
||||||
|
'100% 100%', // base wash
|
||||||
|
'100% 100%', // vignette
|
||||||
|
].join(','),
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // grid core V
|
||||||
|
'0 0', // grid core H
|
||||||
|
'0 0', // sub-grid V
|
||||||
|
'0 0', // sub-grid H
|
||||||
|
'120px 40px', // bloom static (reduced-motion) — parked above-centre
|
||||||
|
'0 0', // base wash
|
||||||
|
'0 0', // vignette
|
||||||
|
].join(','),
|
||||||
|
animation: `${gridPulse} 22s ease-in-out infinite`,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
// Digital Rain — a slow vertical PAN of the streak columns.
|
||||||
|
//
|
||||||
|
// The streak SVG tile is authored 200px tall (see animRain.ts, backgroundSize
|
||||||
|
// height = 200px). The falling illusion is a pure background-position translate
|
||||||
|
// downward by EXACTLY one tile height (200px) over the cycle, so the loop is
|
||||||
|
// perfectly seamless — the pixel at y re-enters where the pixel at y-200 was,
|
||||||
|
// which is identical because the tile repeats.
|
||||||
|
//
|
||||||
|
// Only the first background layer (the streak SVG) is panned; every subsequent
|
||||||
|
// comma-separated layer is kept at its authored position ('0 0') so the base
|
||||||
|
// gradients / vignette stay put while the rain falls over them. Listing a value
|
||||||
|
// per layer is required — a single value would pan ALL layers.
|
||||||
|
//
|
||||||
|
// getChatBg adds `willChange: 'background-position'` for the animated case, and
|
||||||
|
// STRIPS this whole `animation` for reduced-motion, at which point the static
|
||||||
|
// backgroundPosition authored in animRain.ts is what shows.
|
||||||
|
export const rainFall = keyframes({
|
||||||
|
'0%': { backgroundPosition: '0 0, 0 0, 0 0, 0 0' },
|
||||||
|
'100%': { backgroundPosition: '0 200px, 0 0, 0 0, 0 0' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
import { rainFall } from './animRain.css';
|
||||||
|
|
||||||
|
// anim-rain — "Digital Rain" — a premium take on the Matrix code-rain motif.
|
||||||
|
//
|
||||||
|
// Concept: sparse vertical columns of falling glyph-streaks. Each streak is a
|
||||||
|
// soft vertical gradient that fades from a brighter LEADING glyph (the drop's
|
||||||
|
// head) up into a dim trailing tail, punctuated by a scatter of faint monospace
|
||||||
|
// glyph marks so it reads as CODE rather than plain stripes. It floats over a
|
||||||
|
// near-black base carrying a subtle green phosphor cast and a gentle vignette.
|
||||||
|
// Columns are deliberately sparse (only a handful across the 260px-wide tile)
|
||||||
|
// so the reading area breathes and text always wins the contrast fight.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING + PAN — the streak SVG tile is 260×200. Its content is
|
||||||
|
// authored to wrap top↔bottom: each streak's gradient and glyphs are placed so
|
||||||
|
// the tile is vertically continuous, and the animation (see animRain.css.ts)
|
||||||
|
// pans this first layer down by EXACTLY one tile height (200px) per cycle, so
|
||||||
|
// the "fall" loops with no seam. The base / vignette layers are 100% 100% and
|
||||||
|
// stay fixed (the keyframe holds them at '0 0').
|
||||||
|
//
|
||||||
|
// ANIMATION-STRIP SAFETY — getChatBg removes `animation` for reduced-motion /
|
||||||
|
// pause-animations users, so the non-animation properties below already read as
|
||||||
|
// a finished, gorgeous STATIC rain: a frozen frame of streaks over the base.
|
||||||
|
//
|
||||||
|
// CSP / Tauri-safe: inline SVG via encodeURIComponent (NOT base64). oklch used
|
||||||
|
// throughout; alphas kept low so both themes stay WCAG-AA-friendly for text.
|
||||||
|
|
||||||
|
// One vertical streak-column, colour-parameterised. Placed at x within a
|
||||||
|
// 260-wide tile. `head` is the bright leading-glyph colour, `tail` the dim
|
||||||
|
// trailing colour, `glyph` the colour of the riding monospace glyph ticks.
|
||||||
|
const streak = (
|
||||||
|
x: number,
|
||||||
|
headY: number, // y of the leading glyph (drop head)
|
||||||
|
len: number, // trailing tail length upward
|
||||||
|
head: string,
|
||||||
|
tail: string,
|
||||||
|
glyph: string,
|
||||||
|
): string => {
|
||||||
|
const topY = headY - len;
|
||||||
|
const id = `g${x}_${headY}`; // unique even when two columns share an x
|
||||||
|
// Vertical fade: transparent at the tail top → tail colour → bright head.
|
||||||
|
const grad = `
|
||||||
|
<linearGradient id='${id}' x1='0' y1='${topY}' x2='0' y2='${headY}' gradientUnits='userSpaceOnUse'>
|
||||||
|
<stop offset='0' stop-color='${tail}' stop-opacity='0'/>
|
||||||
|
<stop offset='0.55' stop-color='${tail}'/>
|
||||||
|
<stop offset='1' stop-color='${head}'/>
|
||||||
|
</linearGradient>`;
|
||||||
|
// The streak body is a soft, slightly-blurred vertical bar.
|
||||||
|
const bar = `<rect x='${x - 3}' y='${topY}' width='6' height='${len}' rx='3' fill='url(#${id})'/>`;
|
||||||
|
// A few monospace glyph ticks riding the column (short horizontal dashes).
|
||||||
|
const ticks = [0.22, 0.45, 0.68, 0.86]
|
||||||
|
.map((f, i) => {
|
||||||
|
const gy = Math.round(topY + len * f);
|
||||||
|
const gw = i % 2 === 0 ? 5 : 3;
|
||||||
|
const op = i === 3 ? '0.9' : '0.5';
|
||||||
|
return `<rect x='${x - gw / 2}' y='${gy}' width='${gw}' height='1.4' rx='0.7' fill='${glyph}' fill-opacity='${op}'/>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
// The leading glyph: a brighter small square cap at the head.
|
||||||
|
const cap = `<rect x='${x - 2.5}' y='${headY - 3}' width='5' height='5' rx='1' fill='${head}'/>`;
|
||||||
|
return grad + bar + ticks + cap;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Full 260×200 tile. Columns are wrapped vertically: a column whose head sits
|
||||||
|
// low in the tile has its tail running off the top, and a companion column
|
||||||
|
// re-enters that space, so panning by one tile height reads as continuous fall.
|
||||||
|
const tile = (head: string, tail: string, glyph: string): string => {
|
||||||
|
const cols = [
|
||||||
|
streak(24, 150, 140, head, tail, glyph),
|
||||||
|
streak(78, 60, 120, head, tail, glyph),
|
||||||
|
streak(122, 196, 160, head, tail, glyph), // head near bottom → tail wraps up
|
||||||
|
streak(122, 40, 160, head, tail, glyph), // partner near top completes the wrap
|
||||||
|
streak(178, 110, 100, head, tail, glyph),
|
||||||
|
streak(232, 176, 130, head, tail, glyph),
|
||||||
|
].join('');
|
||||||
|
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='260' height='200' viewBox='0 0 260 200'><defs></defs>${cols}</svg>`;
|
||||||
|
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const animRain: ChatBgVariants = {
|
||||||
|
// Dark: phosphor-green streaks on deep near-black with a faint green cast.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'oklch(0.16 0.02 150)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1) the falling streak columns (this is the panned layer)
|
||||||
|
tile(
|
||||||
|
'oklch(0.75 0.14 150 / 0.5)', // head — bright phosphor glyph
|
||||||
|
'oklch(0.68 0.12 150 / 0.28)', // tail — dim phosphor
|
||||||
|
'oklch(0.82 0.1 150 / 0.5)', // glyph ticks — brightest
|
||||||
|
),
|
||||||
|
// 2) soft top-down phosphor haze so the rain has atmosphere
|
||||||
|
'linear-gradient(180deg, oklch(0.24 0.04 150 / 0.55) 0%, transparent 40%)',
|
||||||
|
// 3) subtle green cast pooling toward the bottom
|
||||||
|
'radial-gradient(120% 90% at 50% 100%, oklch(0.28 0.05 150 / 0.45) 0%, transparent 60%)',
|
||||||
|
// 4) vignette — quiet the corners so the reading column stays clean
|
||||||
|
'radial-gradient(140% 140% at 50% 45%, transparent 60%, oklch(0.1 0.02 150 / 0.6) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
||||||
|
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
||||||
|
animation: `${rainFall} 12s linear infinite`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Light: soft teal-grey streaks on a pale cool base — elegant, never neon.
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'oklch(0.97 0.008 165)',
|
||||||
|
backgroundImage: [
|
||||||
|
tile(
|
||||||
|
'oklch(0.55 0.07 165 / 0.4)', // head — soft teal-grey drop
|
||||||
|
'oklch(0.62 0.05 165 / 0.22)', // tail — faint teal-grey
|
||||||
|
'oklch(0.5 0.06 165 / 0.42)', // glyph ticks
|
||||||
|
),
|
||||||
|
// gentle cool wash from the top
|
||||||
|
'linear-gradient(180deg, oklch(0.94 0.015 175 / 0.6) 0%, transparent 42%)',
|
||||||
|
// faint teal pooling at the bottom edge
|
||||||
|
'radial-gradient(120% 90% at 50% 100%, oklch(0.9 0.02 170 / 0.5) 0%, transparent 60%)',
|
||||||
|
// soft vignette in cool grey
|
||||||
|
'radial-gradient(140% 140% at 50% 45%, transparent 62%, oklch(0.88 0.02 165 / 0.5) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
||||||
|
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
||||||
|
animation: `${rainFall} 12s linear infinite`,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
// Star Drift — a slow, serene PAN of a deep-space starfield with real parallax.
|
||||||
|
//
|
||||||
|
// The starfield in animStars.ts stacks six background layers:
|
||||||
|
// 1. near stars — tile 137x137, brighter, drifts FASTEST
|
||||||
|
// 2. mid stars — tile 191x191, medium
|
||||||
|
// 3. far dust — tile 233x233, dimmest, drifts SLOWEST
|
||||||
|
// 4. center vignette (100% 100%) — STATIC
|
||||||
|
// 5. nebula wash A (100% 100%) — STATIC
|
||||||
|
// 6. nebula wash B (100% 100%) — STATIC
|
||||||
|
//
|
||||||
|
// Seamless parallax: the single `animation` shorthand shares ONE duration across
|
||||||
|
// all layers, so speed differences are produced purely by how FAR each layer
|
||||||
|
// travels in the keyframe. For a perfectly seamless loop each star layer must
|
||||||
|
// translate by an EXACT integer multiple of its own tile period, so the pixel
|
||||||
|
// re-entering at the wrap is identical to the one that left. We move:
|
||||||
|
// near : -274px = 2 x 137 (two tiles -> fastest apparent drift)
|
||||||
|
// mid : -191px = 1 x 191 (one tile -> medium)
|
||||||
|
// far : -233px = 1 x 233 (one tile, but larger tile => slowest apparent)
|
||||||
|
// so near/mid/far read as three depths sliding past each other, yet every layer
|
||||||
|
// lands back on an identical phase at 100% for a jump-free repeat.
|
||||||
|
//
|
||||||
|
// A diagonal component (both x and y shift) makes the drift feel like gentle
|
||||||
|
// motion through space rather than a flat slide. The static layers are pinned at
|
||||||
|
// '0 0' every frame so the vignette and nebula never move under the text.
|
||||||
|
//
|
||||||
|
// The start frame ('0%') MUST match the static backgroundPosition authored in
|
||||||
|
// animStars.ts, so that when getChatBg STRIPS this animation for
|
||||||
|
// prefers-reduced-motion the finished starfield shows without a jump.
|
||||||
|
export const starDrift = keyframes({
|
||||||
|
'0%': {
|
||||||
|
backgroundPosition: '0 0, 61px 43px, 113px 97px, 0 0, 0 0, 0 0',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
// near: -274/-274 (2 tiles), mid: 61-191/43-191, far: 113-233/97-233
|
||||||
|
backgroundPosition: '-274px -274px, -130px -148px, -120px -136px, 0 0, 0 0, 0 0',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
import { starDrift } from './animStars.css';
|
||||||
|
|
||||||
|
// animStars ("Star Drift") — a serene deep-space field slowly drifting, with
|
||||||
|
// genuine parallax between a near (brighter, faster) and a far (dim, slower)
|
||||||
|
// star layer, floated on a faint nebula wash and calmed by a center vignette.
|
||||||
|
//
|
||||||
|
// Concept: three tiling star layers at coprime-ish tile sizes (137/191/233 dark,
|
||||||
|
// 149/199/251 light) so their combined repeat is astronomically large and no
|
||||||
|
// seam is ever perceivable. The near layer is crisp and sparse; the far "dust"
|
||||||
|
// layer is dim and dense — the layer that gives depth. Beneath the stars sit a
|
||||||
|
// deep-blue -> violet nebula (two soft ellipses) and a center vignette that keeps
|
||||||
|
// the reading column the calmest, lowest-contrast area of the whole canvas.
|
||||||
|
//
|
||||||
|
// Layer stacking order (CSS paints image #1 on TOP):
|
||||||
|
// 1. near stars — brighter, largest visible drift (tile 137 / 149)
|
||||||
|
// 2. mid stars — softer, medium (tile 191 / 199)
|
||||||
|
// 3. far dust — dimmest, slowest, most-repeated (tile 233 / 251)
|
||||||
|
// 4. center vignette (100% 100%, static)
|
||||||
|
// 5. nebula wash A (100% 100%, static)
|
||||||
|
// 6. nebula wash B (100% 100%, static)
|
||||||
|
//
|
||||||
|
// Animation: `starDrift` (see animStars.css.ts) is a SLOW background-position PAN
|
||||||
|
// that translates each star layer by an exact integer number of its own tiles,
|
||||||
|
// so the loop is seamless AND the three layers drift at different apparent
|
||||||
|
// speeds (parallax). getChatBg adds willChange/contain for the animated case and
|
||||||
|
// STRIPS the `animation` for prefers-reduced-motion — at which point the static
|
||||||
|
// backgroundPosition below (identical to the keyframe's 0% frame) shows as a
|
||||||
|
// fully finished starfield on its own.
|
||||||
|
//
|
||||||
|
// Density is kept modest toward the center by the vignette + conservative dot
|
||||||
|
// sizes, and every star opacity stays low so text over the field always clears
|
||||||
|
// WCAG-AA in both themes.
|
||||||
|
|
||||||
|
export const animStars: ChatBgVariants = {
|
||||||
|
// Dark: cool white + faint blue stars on a near-black cosmos, lifted onto a
|
||||||
|
// deep-blue -> violet nebula with a soft vignette darkening the calm center.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'oklch(0.15 0.03 275)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. near stars — crisp cool-white, sparse, the "fast" parallax layer
|
||||||
|
'radial-gradient(circle at center, oklch(0.98 0.012 255 / 0.85) 0.6px, transparent 1.5px)',
|
||||||
|
// 2. mid stars — softer, a touch blue, more of them
|
||||||
|
'radial-gradient(circle at center, oklch(0.90 0.03 260 / 0.52) 0.6px, transparent 1.3px)',
|
||||||
|
// 3. far dust — faint blue haze, the slow depth layer (most repeats)
|
||||||
|
'radial-gradient(circle at center, oklch(0.78 0.06 255 / 0.28) 0.5px, transparent 1.1px)',
|
||||||
|
// 4. center vignette — keeps the reading column calmest / lowest-contrast
|
||||||
|
'radial-gradient(ellipse 120% 90% at 50% 42%, transparent 40%, oklch(0.09 0.03 270 / 0.58) 100%)',
|
||||||
|
// 5. nebula wash A — deep violet high-right
|
||||||
|
'radial-gradient(ellipse 140% 120% at 78% 10%, oklch(0.26 0.09 285 / 0.55) 0%, transparent 55%)',
|
||||||
|
// 6. nebula wash B — deep blue low-left
|
||||||
|
'radial-gradient(ellipse 130% 110% at 16% 94%, oklch(0.21 0.07 250 / 0.50) 0%, transparent 58%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'137px 137px', // near stars
|
||||||
|
'191px 191px', // mid stars
|
||||||
|
'233px 233px', // far dust
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'100% 100%', // nebula A
|
||||||
|
'100% 100%', // nebula B
|
||||||
|
].join(','),
|
||||||
|
// Must equal starDrift's 0% frame so reduced-motion shows this exact field.
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // near
|
||||||
|
'61px 43px', // mid (offset breaks tile alignment)
|
||||||
|
'113px 97px', // far (offset again)
|
||||||
|
'0 0', // vignette
|
||||||
|
'0 0', // nebula A
|
||||||
|
'0 0', // nebula B
|
||||||
|
].join(','),
|
||||||
|
animation: `${starDrift} 90s linear infinite`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Light: an airy pre-dawn sky. No literal white-on-white stars — instead very
|
||||||
|
// soft pale sparkles plus the merest cool speckles, floated on a gentle cool
|
||||||
|
// gradient. Reads as elegant atmosphere, never as noise over text.
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'oklch(0.965 0.008 255)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. near sparkles — a hair brighter/warmer than the sky
|
||||||
|
'radial-gradient(circle at center, oklch(0.995 0.015 90 / 0.50) 0.6px, transparent 1.5px)',
|
||||||
|
// 2. mid cool speckles — faintest hint of darkness for texture/contrast
|
||||||
|
'radial-gradient(circle at center, oklch(0.60 0.05 260 / 0.15) 0.5px, transparent 1.2px)',
|
||||||
|
// 3. far dust — very soft cool haze, the slow depth layer
|
||||||
|
'radial-gradient(circle at center, oklch(0.70 0.04 255 / 0.11) 0.5px, transparent 1.1px)',
|
||||||
|
// 4. center vignette — subtly brightens the calm reading center
|
||||||
|
'radial-gradient(ellipse 120% 90% at 50% 44%, oklch(1 0 0 / 0.45) 30%, transparent 100%)',
|
||||||
|
// 5. pre-dawn wash A — cool blue high-right
|
||||||
|
'radial-gradient(ellipse 150% 120% at 80% 6%, oklch(0.90 0.05 255 / 0.60) 0%, transparent 60%)',
|
||||||
|
// 6. pre-dawn wash B — warm blush low-left
|
||||||
|
'radial-gradient(ellipse 140% 120% at 14% 96%, oklch(0.93 0.04 40 / 0.42) 0%, transparent 62%)',
|
||||||
|
].join(','),
|
||||||
|
// Same tile sizes as dark (137/191/233). The shared starDrift keyframe pans
|
||||||
|
// each layer by an exact integer multiple of ITS tile (near 2x137, mid 1x191,
|
||||||
|
// far 1x233); reusing these tiles here guarantees the loop wraps seamlessly in
|
||||||
|
// light mode too, since one keyframe drives both themes. Coprime-ish sizes keep
|
||||||
|
// the combined repeat astronomically large so no seam is ever perceivable.
|
||||||
|
backgroundSize: [
|
||||||
|
'137px 137px', // near sparkles
|
||||||
|
'191px 191px', // mid speckles
|
||||||
|
'233px 233px', // far dust
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'100% 100%', // wash A
|
||||||
|
'100% 100%', // wash B
|
||||||
|
].join(','),
|
||||||
|
// Positions mirror the keyframe 0% frame (== reduced-motion static field).
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // near
|
||||||
|
'61px 43px', // mid
|
||||||
|
'113px 97px', // far
|
||||||
|
'0 0', // vignette
|
||||||
|
'0 0', // wash A
|
||||||
|
'0 0', // wash B
|
||||||
|
].join(','),
|
||||||
|
animation: `${starDrift} 100s linear infinite`,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// blueprint — an engineering / architectural drafting sheet.
|
||||||
|
//
|
||||||
|
// Layers (painted top-to-bottom):
|
||||||
|
// 1. SVG draftsman tick-marks + a centred crosshair accent (96px tile — lands
|
||||||
|
// exactly on the major grid; corner quarter-arms tile into a full "+" on
|
||||||
|
// every major intersection).
|
||||||
|
// 2. Major grid lines (heavier) — 96px.
|
||||||
|
// 3. Minor grid lines (fine, fainter) — 16px (96 = 6 × 16, so it nests
|
||||||
|
// seamlessly inside the major grid with no beat/moiré).
|
||||||
|
// 4. A soft radial vignette + a gentle sheet-glow so the surface reads like a
|
||||||
|
// real drafting sheet with subtle dimension rather than a flat tile.
|
||||||
|
//
|
||||||
|
// Everything is kept at low alpha (~0.03–0.16) so the motif is felt, not read:
|
||||||
|
// crisp message text sits comfortably above it in both themes (WCAG-AA safe).
|
||||||
|
|
||||||
|
const DARK_TICKS =
|
||||||
|
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2296%22%20height%3D%2296%22%3E%3Cg%20stroke%3D%22oklch%280.72%200.11%20230%20%2F%200.32%29%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M0%200%20H7%20M0%200%20V7%22%2F%3E%3Cpath%20d%3D%22M96%200%20H89%20M96%200%20V7%22%2F%3E%3Cpath%20d%3D%22M0%2096%20H7%20M0%2096%20V89%22%2F%3E%3Cpath%20d%3D%22M96%2096%20H89%20M96%2096%20V89%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.72%200.11%20230%20%2F%200.18%29%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M48%2044%20V52%20M44%2048%20H52%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
|
||||||
|
|
||||||
|
const LIGHT_TICKS =
|
||||||
|
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2296%22%20height%3D%2296%22%3E%3Cg%20stroke%3D%22oklch%280.48%200.13%20250%20%2F%200.38%29%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M0%200%20H7%20M0%200%20V7%22%2F%3E%3Cpath%20d%3D%22M96%200%20H89%20M96%200%20V7%22%2F%3E%3Cpath%20d%3D%22M0%2096%20H7%20M0%2096%20V89%22%2F%3E%3Cpath%20d%3D%22M96%2096%20H89%20M96%2096%20V89%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.48%200.13%20250%20%2F%200.22%29%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M48%2044%20V52%20M44%2048%20H52%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
|
||||||
|
|
||||||
|
export const blueprint: ChatBgVariants = {
|
||||||
|
// Cyan-blue lines on a deep navy sheet.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'oklch(0.22 0.05 250)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. draftsman ticks + centre crosshair
|
||||||
|
DARK_TICKS,
|
||||||
|
// 4a. sheet-glow: a faint cooler highlight drifting off the top-left,
|
||||||
|
// giving the flat navy some dimension.
|
||||||
|
'radial-gradient(120% 120% at 18% 8%, oklch(0.30 0.06 245 / 0.55) 0%, transparent 55%)',
|
||||||
|
// 4b. vignette: gently darkens the corners like a drafting sheet edge.
|
||||||
|
'radial-gradient(140% 140% at 50% 42%, transparent 58%, oklch(0.14 0.04 255 / 0.5) 100%)',
|
||||||
|
// 2. major grid (heavier)
|
||||||
|
'linear-gradient(oklch(0.72 0.12 230 / 0.13) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, oklch(0.72 0.12 230 / 0.13) 1px, transparent 1px)',
|
||||||
|
// 3. minor grid (fine, fainter)
|
||||||
|
'linear-gradient(oklch(0.72 0.12 230 / 0.05) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, oklch(0.72 0.12 230 / 0.05) 1px, transparent 1px)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'96px 96px', // ticks
|
||||||
|
'100% 100%', // sheet-glow
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'96px 96px', // major V
|
||||||
|
'96px 96px', // major H
|
||||||
|
'16px 16px', // minor V
|
||||||
|
'16px 16px', // minor H
|
||||||
|
].join(','),
|
||||||
|
// All layers share the default top-left (0 0) origin so the tick tile, the
|
||||||
|
// 96px major grid and the 16px minor grid stay phase-locked (96 = 6 × 16) —
|
||||||
|
// no drift, no visible seams. (A per-layer `center` would let the differently
|
||||||
|
// sized tiles center independently and fall out of alignment.)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Blue lines on a cool paper-white sheet.
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'oklch(0.97 0.01 240)',
|
||||||
|
backgroundImage: [
|
||||||
|
LIGHT_TICKS,
|
||||||
|
// sheet-glow: a hint of brighter paper toward the top-left.
|
||||||
|
'radial-gradient(120% 120% at 18% 8%, oklch(0.99 0.008 240 / 0.7) 0%, transparent 55%)',
|
||||||
|
// vignette: soft cool shading into the corners.
|
||||||
|
'radial-gradient(140% 140% at 50% 42%, transparent 60%, oklch(0.90 0.02 245 / 0.55) 100%)',
|
||||||
|
// major grid (heavier)
|
||||||
|
'linear-gradient(oklch(0.48 0.13 250 / 0.15) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, oklch(0.48 0.13 250 / 0.15) 1px, transparent 1px)',
|
||||||
|
// minor grid (fine, fainter)
|
||||||
|
'linear-gradient(oklch(0.48 0.13 250 / 0.06) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, oklch(0.48 0.13 250 / 0.06) 1px, transparent 1px)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'96px 96px',
|
||||||
|
'100% 100%',
|
||||||
|
'100% 100%',
|
||||||
|
'96px 96px',
|
||||||
|
'96px 96px',
|
||||||
|
'16px 16px',
|
||||||
|
'16px 16px',
|
||||||
|
].join(','),
|
||||||
|
// Shared top-left origin keeps the tick tile and both grids phase-locked.
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// chevron — refined woven-upholstery zigzag.
|
||||||
|
//
|
||||||
|
// The motif is a continuous, crisp chevron built to read as *textured fabric*
|
||||||
|
// rather than flat stripes. The zigzag threads themselves are drawn with a
|
||||||
|
// tiny inline-SVG tile (guaranteed geometrically seamless — the "V" path exits
|
||||||
|
// each tile edge exactly where the next tile's path enters, both horizontally
|
||||||
|
// and vertically). Over that, layered CSS gradients add the premium feel:
|
||||||
|
// • a soft light→shade sweep across the weave gives each band an embossed,
|
||||||
|
// woven cross-section (catches light on one diagonal face, shade on the
|
||||||
|
// other);
|
||||||
|
// • a faint two-tone wash alternates the tint of successive chevron rows for
|
||||||
|
// an interlocked-yarn look;
|
||||||
|
// • a gentle centre lift + corner vignette settle the field so text always
|
||||||
|
// sits over the calmer middle.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING
|
||||||
|
// The SVG is a WxH tile whose path is one full zigzag wave: it starts at the
|
||||||
|
// left edge, dips to the vertex, rises to the right edge at the SAME y it
|
||||||
|
// started — so horizontally each tile's end meets the next tile's start with no
|
||||||
|
// step. Two stacked strokes (offset by H) fill the vertical repeat, and the
|
||||||
|
// tile height equals the row pitch, so vertical stacking is seamless too. The
|
||||||
|
// gradient overlays are non-repeating (100% 100%) or share the SVG's tile
|
||||||
|
// width, so none of them introduce a seam.
|
||||||
|
//
|
||||||
|
// Everything sits at low alpha (~0.03–0.11) so the pattern is felt, not read:
|
||||||
|
// crisp message text stays comfortably WCAG-AA in both themes.
|
||||||
|
|
||||||
|
// One zigzag wave, 40px wide × 20px tall. Path enters at (0,4), dips to the
|
||||||
|
// vertex at (20,16), climbs back to (40,4) — identical entry/exit y => seamless
|
||||||
|
// horizontal repeat. A second copy shifted +10 in y keeps a soft double thread.
|
||||||
|
const svg = (stroke: string, faint: string) =>
|
||||||
|
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20' +
|
||||||
|
'width%3D%2240%22%20height%3D%2220%22%3E' +
|
||||||
|
`%3Cpath%20d%3D%22M0%204%20L20%2016%20L40%204%22%20fill%3D%22none%22%20stroke%3D%22${stroke}%22%20stroke-width%3D%223%22%2F%3E` +
|
||||||
|
`%3Cpath%20d%3D%22M0%2014%20L20%2026%20L40%2014%20M0%20-6%20L20%206%20L40%20-6%22%20fill%3D%22none%22%20stroke%3D%22${faint}%22%20stroke-width%3D%222%22%2F%3E` +
|
||||||
|
'%3C%2Fsvg%3E")';
|
||||||
|
|
||||||
|
const dark: CSSProperties = {
|
||||||
|
backgroundColor: 'oklch(0.20 0.022 260)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. The zigzag threads — muted indigo/slate, main + fainter under-thread.
|
||||||
|
svg('oklch(0.55 0.05 265 %2F 0.16)', 'oklch(0.50 0.045 262 %2F 0.07)'),
|
||||||
|
// 2. Woven emboss — a soft diagonal light→shade sweep across the weave so
|
||||||
|
// the bands catch light on one face and fall to shade on the other.
|
||||||
|
'linear-gradient(135deg, oklch(0.62 0.05 265 / 0.05) 0%, transparent 45%, transparent 55%, oklch(0.14 0.02 260 / 0.06) 100%)',
|
||||||
|
// 3. Two-tone weft — a whisper shade on alternate chevron rows.
|
||||||
|
'repeating-linear-gradient(0deg, oklch(0.50 0.04 258 / 0.035) 0px, oklch(0.50 0.04 258 / 0.035) 20px, transparent 20px, transparent 40px)',
|
||||||
|
// 4. Tonal wash — cool centre lift for gentle depth.
|
||||||
|
'radial-gradient(ellipse 90% 75% at 50% 42%, oklch(0.26 0.03 262 / 0.40) 0%, transparent 60%)',
|
||||||
|
// 5. Vignette — feather corners into deeper charcoal-blue.
|
||||||
|
'radial-gradient(ellipse 120% 130% at 50% 45%, transparent 60%, oklch(0.15 0.02 260 / 0.55) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '40px 20px, 100% 100%, 40px 40px, 100% 100%, 100% 100%',
|
||||||
|
};
|
||||||
|
|
||||||
|
const light: CSSProperties = {
|
||||||
|
backgroundColor: 'oklch(0.965 0.006 85)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. The zigzag threads — soft dusty-blue, main + fainter under-thread.
|
||||||
|
svg('oklch(0.55 0.05 255 %2F 0.14)', 'oklch(0.52 0.045 255 %2F 0.06)'),
|
||||||
|
// 2. Woven emboss — diagonal light→shade sweep for a knit-fabric surface.
|
||||||
|
'linear-gradient(135deg, oklch(0.99 0.008 85 / 0.06) 0%, transparent 45%, transparent 55%, oklch(0.55 0.05 255 / 0.05) 100%)',
|
||||||
|
// 3. Two-tone weft — faint alternating-row shade.
|
||||||
|
'repeating-linear-gradient(0deg, oklch(0.52 0.04 255 / 0.03) 0px, oklch(0.52 0.04 255 / 0.03) 20px, transparent 20px, transparent 40px)',
|
||||||
|
// 4. Tonal wash — warm paper highlight through the reading centre.
|
||||||
|
'radial-gradient(ellipse 90% 75% at 50% 42%, oklch(0.99 0.008 85 / 0.55) 0%, transparent 60%)',
|
||||||
|
// 5. Vignette — settle corners into a slightly deeper dusty tone.
|
||||||
|
'radial-gradient(ellipse 120% 130% at 50% 45%, transparent 60%, oklch(0.91 0.012 250 / 0.40) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '40px 20px, 100% 100%, 40px 40px, 100% 100%, 100% 100%',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const chevron: ChatBgVariants = { dark, light };
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// circuit — an elegant printed-circuit board.
|
||||||
|
//
|
||||||
|
// Concept: thin right-angle copper traces route between small pads / vias and
|
||||||
|
// the occasional solder-junction dot, over a deep board base. It reads as an
|
||||||
|
// authentic PCB rather than a plain grid: the routing turns corners, dead-ends
|
||||||
|
// at through-hole pads, and picks up faint via-glows — but stays sparse, with
|
||||||
|
// generous negative space so message text always wins the contrast fight.
|
||||||
|
//
|
||||||
|
// The trace network is a single inline SVG data-URI (encodeURIComponent, NOT
|
||||||
|
// base64 — CSP / Tauri-safe) so the geometry can be real right-angle routing
|
||||||
|
// instead of gradient fakery. It is layered over a subtle board-base gradient.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING — the 120×120 tile is authored so every trace that leaves an
|
||||||
|
// edge re-enters at the identical coordinate on the OPPOSITE edge, so the copper
|
||||||
|
// runs continuously across tile boundaries with no visible seam:
|
||||||
|
// • horizontal runs cross the left/right edges at y = 30 and y = 90
|
||||||
|
// • vertical runs cross the top/bottom edges at x = 40 and x = 88
|
||||||
|
// backgroundSize is set to the tile size (120px) so those crossings line up
|
||||||
|
// exactly on repeat.
|
||||||
|
//
|
||||||
|
// Two hand-tuned SVGs (dark / light) differ only in stroke/fill colour + alpha.
|
||||||
|
// Alphas stay low (≈0.05–0.5 on the accents, traces ~0.1–0.16) so the pattern is
|
||||||
|
// felt, not read — crisp text sits comfortably above it in both themes.
|
||||||
|
|
||||||
|
// Shared geometry, colour-parameterised so the two themes stay pixel-identical
|
||||||
|
// in layout and only diverge in palette.
|
||||||
|
const tile = (
|
||||||
|
trace: string, // trace stroke colour
|
||||||
|
traceW: string, // trace stroke-width
|
||||||
|
pad: string, // pad ring colour
|
||||||
|
padFill: string, // pad centre / board-coloured hole
|
||||||
|
via: string, // via glow colour
|
||||||
|
junction: string, // filled junction-dot colour
|
||||||
|
): string => {
|
||||||
|
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'>
|
||||||
|
<g fill='none' stroke='${trace}' stroke-width='${traceW}' stroke-linecap='round' stroke-linejoin='round'>
|
||||||
|
<path d='M0 30 H26 V58 H60'/>
|
||||||
|
<path d='M60 58 V90 H120'/>
|
||||||
|
<path d='M0 90 H40 V120'/>
|
||||||
|
<path d='M40 0 V22 H88'/>
|
||||||
|
<path d='M88 0 V44 H104'/>
|
||||||
|
<path d='M104 44 V90 H120'/>
|
||||||
|
<path d='M88 120 V90'/>
|
||||||
|
<path d='M60 30 H120'/>
|
||||||
|
<path d='M60 30 V58'/>
|
||||||
|
<path d='M26 58 V90'/>
|
||||||
|
</g>
|
||||||
|
<g fill='none' stroke='${pad}' stroke-width='${traceW}'>
|
||||||
|
<circle cx='26' cy='58' r='3.4'/>
|
||||||
|
<circle cx='40' cy='90' r='3.4'/>
|
||||||
|
<circle cx='88' cy='22' r='3.4'/>
|
||||||
|
<circle cx='104' cy='44' r='3.4'/>
|
||||||
|
</g>
|
||||||
|
<g fill='${padFill}'>
|
||||||
|
<circle cx='26' cy='58' r='1.3'/>
|
||||||
|
<circle cx='40' cy='90' r='1.3'/>
|
||||||
|
<circle cx='88' cy='22' r='1.3'/>
|
||||||
|
<circle cx='104' cy='44' r='1.3'/>
|
||||||
|
</g>
|
||||||
|
<g fill='${junction}'>
|
||||||
|
<circle cx='60' cy='58' r='2'/>
|
||||||
|
<circle cx='60' cy='30' r='2'/>
|
||||||
|
<circle cx='104' cy='90' r='2'/>
|
||||||
|
</g>
|
||||||
|
<g fill='${via}'>
|
||||||
|
<circle cx='26' cy='58' r='7'/>
|
||||||
|
<circle cx='88' cy='22' r='7'/>
|
||||||
|
<circle cx='104' cy='44' r='7'/>
|
||||||
|
</g>
|
||||||
|
</svg>`;
|
||||||
|
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const circuit: ChatBgVariants = {
|
||||||
|
// Faint teal/green copper with dim cyan via-glows on a near-black board.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'oklch(0.17 0.02 165)',
|
||||||
|
backgroundImage: [
|
||||||
|
tile(
|
||||||
|
'oklch(0.7 0.1 165 / 0.16)', // traces — faint teal-green copper
|
||||||
|
'1',
|
||||||
|
'oklch(0.72 0.11 175 / 0.32)', // pad rings — slightly brighter
|
||||||
|
'oklch(0.17 0.02 165)', // pad holes — board colour (drilled look)
|
||||||
|
'oklch(0.78 0.13 200 / 0.14)', // via glow — dim cyan halo
|
||||||
|
'oklch(0.74 0.12 170 / 0.4)', // junction dots — solid copper
|
||||||
|
),
|
||||||
|
// board-base: a gentle diagonal sheen so the flat near-black gains depth.
|
||||||
|
'radial-gradient(130% 130% at 20% 12%, oklch(0.22 0.03 170 / 0.6) 0%, transparent 58%)',
|
||||||
|
// vignette: barely darkens the corners like a laminated board edge.
|
||||||
|
'radial-gradient(140% 140% at 50% 45%, transparent 62%, oklch(0.12 0.02 165 / 0.55) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: ['120px 120px', '100% 100%', '100% 100%'].join(','),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Soft green-grey traces on a pale board.
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'oklch(0.96 0.012 160)',
|
||||||
|
backgroundImage: [
|
||||||
|
tile(
|
||||||
|
'oklch(0.55 0.07 165 / 0.24)', // traces — soft green-grey copper
|
||||||
|
'1',
|
||||||
|
'oklch(0.5 0.08 170 / 0.4)', // pad rings
|
||||||
|
'oklch(0.96 0.012 160)', // pad holes — board colour
|
||||||
|
'oklch(0.6 0.09 200 / 0.1)', // via glow — faint cool halo
|
||||||
|
'oklch(0.5 0.08 165 / 0.42)', // junction dots
|
||||||
|
),
|
||||||
|
// board-base: a hint of brighter laminate toward the top-left.
|
||||||
|
'radial-gradient(130% 130% at 20% 12%, oklch(0.99 0.008 160 / 0.7) 0%, transparent 58%)',
|
||||||
|
// vignette: soft green-grey shading into the corners.
|
||||||
|
'radial-gradient(140% 140% at 50% 45%, transparent 62%, oklch(0.9 0.02 160 / 0.5) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: ['120px 120px', '100% 100%', '100% 100%'].join(','),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// crosshatch — fine pen-and-ink engraving, like a banknote guilloché.
|
||||||
|
// Three hatch directions (right-leaning, left-leaning, near-horizontal cross)
|
||||||
|
// are layered at low opacity so the eye reads a woven ink texture rather than
|
||||||
|
// discrete stripes. Each direction uses a slightly different pitch so the
|
||||||
|
// combined pattern never lines up into a coarse moire, and a barely-there
|
||||||
|
// diagonal tonal gradient lends etched depth.
|
||||||
|
//
|
||||||
|
// Seamless tiling: each hatch is a `repeating-linear-gradient`, which repeats
|
||||||
|
// infinitely by definition, so the layers are left at `backgroundSize: auto`
|
||||||
|
// and tile with no visible seam at any element size (constraining a diagonal
|
||||||
|
// repeat to a small square would clip it mid-period and create a seam). The
|
||||||
|
// tonal wash is a single non-repeating gradient stretched to `cover`.
|
||||||
|
//
|
||||||
|
// Opacities are kept in the 0.02–0.05 range so the texture is felt, not read —
|
||||||
|
// crisp message text sits comfortably above it in both themes (WCAG-AA safe).
|
||||||
|
|
||||||
|
const dark: CSSProperties = {
|
||||||
|
// near-black base with a whisper of cool blue so silver ink reads as engraving
|
||||||
|
backgroundColor: 'oklch(0.16 0.01 255)',
|
||||||
|
backgroundImage: [
|
||||||
|
// faint tonal gradient — top-left slightly lifted for etched depth
|
||||||
|
'linear-gradient(135deg, oklch(0.20 0.012 255 / 0.5) 0%, oklch(0.15 0.01 255 / 0) 55%, oklch(0.14 0.008 260 / 0.45) 100%)',
|
||||||
|
// primary hatch, right-leaning fine lines (cool silver ink), ~9px pitch
|
||||||
|
'repeating-linear-gradient(45deg, oklch(0.75 0.02 250 / 0.05) 0, oklch(0.75 0.02 250 / 0.05) 0.75px, transparent 0.75px, transparent 9px)',
|
||||||
|
// secondary hatch, left-leaning — the cross of the crosshatch
|
||||||
|
'repeating-linear-gradient(135deg, oklch(0.75 0.02 250 / 0.045) 0, oklch(0.75 0.02 250 / 0.045) 0.75px, transparent 0.75px, transparent 9px)',
|
||||||
|
// tertiary hatch, right-leaning at a denser pitch for engraved richness
|
||||||
|
'repeating-linear-gradient(45deg, oklch(0.78 0.018 250 / 0.02) 0, oklch(0.78 0.018 250 / 0.02) 0.5px, transparent 0.5px, transparent 4.5px)',
|
||||||
|
// quaternary near-horizontal fill line, very faint, weaves the mesh together
|
||||||
|
'repeating-linear-gradient(20deg, oklch(0.72 0.015 255 / 0.018) 0, oklch(0.72 0.015 255 / 0.018) 0.5px, transparent 0.5px, transparent 13px)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: 'cover, auto, auto, auto, auto',
|
||||||
|
};
|
||||||
|
|
||||||
|
const light: CSSProperties = {
|
||||||
|
// warm paper base — graphite ink on cream stock
|
||||||
|
backgroundColor: 'oklch(0.975 0.006 85)',
|
||||||
|
backgroundImage: [
|
||||||
|
// faint tonal wash — soft warm depth for aged-paper feel
|
||||||
|
'linear-gradient(135deg, oklch(0.94 0.008 85 / 0.55) 0%, oklch(0.98 0.005 85 / 0) 55%, oklch(0.93 0.01 80 / 0.5) 100%)',
|
||||||
|
// primary hatch, right-leaning graphite lines, ~9px pitch
|
||||||
|
'repeating-linear-gradient(45deg, oklch(0.42 0.01 265 / 0.055) 0, oklch(0.42 0.01 265 / 0.055) 0.75px, transparent 0.75px, transparent 9px)',
|
||||||
|
// secondary hatch, left-leaning — the cross
|
||||||
|
'repeating-linear-gradient(135deg, oklch(0.42 0.01 265 / 0.05) 0, oklch(0.42 0.01 265 / 0.05) 0.75px, transparent 0.75px, transparent 9px)',
|
||||||
|
// tertiary denser right-leaning hatch for engraved fineness
|
||||||
|
'repeating-linear-gradient(45deg, oklch(0.40 0.012 265 / 0.025) 0, oklch(0.40 0.012 265 / 0.025) 0.5px, transparent 0.5px, transparent 4.5px)',
|
||||||
|
// quaternary near-horizontal weave line, barely-there
|
||||||
|
'repeating-linear-gradient(20deg, oklch(0.45 0.01 260 / 0.022) 0, oklch(0.45 0.01 260 / 0.022) 0.5px, transparent 0.5px, transparent 13px)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: 'cover, auto, auto, auto, auto',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const crosshatch: ChatBgVariants = { dark, light };
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// Herringbone — a refined, tactile broken-zigzag weave (the classic parquet / tweed
|
||||||
|
// motif) rather than a flat hairline grid. Each plank is drawn twice in a compact SVG
|
||||||
|
// data-URI tile: a lit "thread" and a 0.6px-offset shadow companion, so every plank
|
||||||
|
// reads as a beveled, three-dimensional strand of fabric instead of a line.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING: planks live on a 12px lattice and their orientation follows the true
|
||||||
|
// herringbone rule orient(cx, cy) = '/' when (cx - cy) mod 4 in {0, 1}, else '\\'.
|
||||||
|
// That rule is exactly periodic every 4 cells in BOTH axes, so the 48x48px tile repeats
|
||||||
|
// with no seam at any scroll offset; segment endpoints all land on lattice corners, so
|
||||||
|
// the broken V's interlock perfectly across tile edges.
|
||||||
|
//
|
||||||
|
// DEPTH: beneath the weave sit two very low-contrast oklch layers — a diagonal two-tone
|
||||||
|
// wash that gives the fabric a faint lit/shadowed side, plus a soft vignette that lets
|
||||||
|
// the centre (where text lives) stay calmest. Everything is kept in the "felt, not read"
|
||||||
|
// opacity band so WCAG-AA body text sits comfortably on top in both themes.
|
||||||
|
|
||||||
|
const WEAVE_DARK =
|
||||||
|
'data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2248%22%20height%3D%2248%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cpath%20d%3D%22M-11.4%200.6L0.6%20-11.4M0.6%200.6L12.6%20-11.4M12.6%20-11.4L24.6%200.6M24.6%20-11.4L36.6%200.6M36.6%200.6L48.6%20-11.4M48.6%200.6L60.6%20-11.4M-11.4%200.6L0.6%2012.6M0.6%2012.6L12.6%200.6M12.6%2012.6L24.6%200.6M24.6%200.6L36.6%2012.6M36.6%200.6L48.6%2012.6M48.6%2012.6L60.6%200.6M-11.4%2012.6L0.6%2024.6M0.6%2012.6L12.6%2024.6M12.6%2024.6L24.6%2012.6M24.6%2024.6L36.6%2012.6M36.6%2012.6L48.6%2024.6M48.6%2012.6L60.6%2024.6M-11.4%2036.6L0.6%2024.6M0.6%2024.6L12.6%2036.6M12.6%2024.6L24.6%2036.6M24.6%2036.6L36.6%2024.6M36.6%2036.6L48.6%2024.6M48.6%2024.6L60.6%2036.6M-11.4%2048.6L0.6%2036.6M0.6%2048.6L12.6%2036.6M12.6%2036.6L24.6%2048.6M24.6%2036.6L36.6%2048.6M36.6%2048.6L48.6%2036.6M48.6%2048.6L60.6%2036.6M-11.4%2048.6L0.6%2060.6M0.6%2060.6L12.6%2048.6M12.6%2060.6L24.6%2048.6M24.6%2048.6L36.6%2060.6M36.6%2048.6L48.6%2060.6M48.6%2060.6L60.6%2048.6%22%20fill%3D%22none%22%20stroke%3D%22rgb%2810%2C8%2C6%29%22%20stroke-width%3D%221.1%22%20stroke-opacity%3D%220.085%22%20stroke-linecap%3D%22round%22%2F%3E%3Cpath%20d%3D%22M-12%200L0%20-12M0%200L12%20-12M12%20-12L24%200M24%20-12L36%200M36%200L48%20-12M48%200L60%20-12M-12%200L0%2012M0%2012L12%200M12%2012L24%200M24%200L36%2012M36%200L48%2012M48%2012L60%200M-12%2012L0%2024M0%2012L12%2024M12%2024L24%2012M24%2024L36%2012M36%2012L48%2024M48%2012L60%2024M-12%2036L0%2024M0%2024L12%2036M12%2024L24%2036M24%2036L36%2024M36%2036L48%2024M48%2024L60%2036M-12%2048L0%2036M0%2048L12%2036M12%2036L24%2048M24%2036L36%2048M36%2048L48%2036M48%2048L60%2036M-12%2048L0%2060M0%2060L12%2048M12%2060L24%2048M24%2048L36%2060M36%2048L48%2060M48%2060L60%2048%22%20fill%3D%22none%22%20stroke%3D%22rgb%28210%2C199%2C180%29%22%20stroke-width%3D%221.1%22%20stroke-opacity%3D%220.111%22%20stroke-linecap%3D%22round%22%2F%3E%3C%2Fsvg%3E';
|
||||||
|
const WEAVE_LIGHT =
|
||||||
|
'data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2248%22%20height%3D%2248%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cpath%20d%3D%22M-11.4%200.6L0.6%20-11.4M0.6%200.6L12.6%20-11.4M12.6%20-11.4L24.6%200.6M24.6%20-11.4L36.6%200.6M36.6%200.6L48.6%20-11.4M48.6%200.6L60.6%20-11.4M-11.4%200.6L0.6%2012.6M0.6%2012.6L12.6%200.6M12.6%2012.6L24.6%200.6M24.6%200.6L36.6%2012.6M36.6%200.6L48.6%2012.6M48.6%2012.6L60.6%200.6M-11.4%2012.6L0.6%2024.6M0.6%2012.6L12.6%2024.6M12.6%2024.6L24.6%2012.6M24.6%2024.6L36.6%2012.6M36.6%2012.6L48.6%2024.6M48.6%2012.6L60.6%2024.6M-11.4%2036.6L0.6%2024.6M0.6%2024.6L12.6%2036.6M12.6%2024.6L24.6%2036.6M24.6%2036.6L36.6%2024.6M36.6%2036.6L48.6%2024.6M48.6%2024.6L60.6%2036.6M-11.4%2048.6L0.6%2036.6M0.6%2048.6L12.6%2036.6M12.6%2036.6L24.6%2048.6M24.6%2036.6L36.6%2048.6M36.6%2048.6L48.6%2036.6M48.6%2048.6L60.6%2036.6M-11.4%2048.6L0.6%2060.6M0.6%2060.6L12.6%2048.6M12.6%2060.6L24.6%2048.6M24.6%2048.6L36.6%2060.6M36.6%2048.6L48.6%2060.6M48.6%2060.6L60.6%2048.6%22%20fill%3D%22none%22%20stroke%3D%22rgb%28126%2C116%2C98%29%22%20stroke-width%3D%221.1%22%20stroke-opacity%3D%220.075%22%20stroke-linecap%3D%22round%22%2F%3E%3Cpath%20d%3D%22M-12%200L0%20-12M0%200L12%20-12M12%20-12L24%200M24%20-12L36%200M36%200L48%20-12M48%200L60%20-12M-12%200L0%2012M0%2012L12%200M12%2012L24%200M24%200L36%2012M36%200L48%2012M48%2012L60%200M-12%2012L0%2024M0%2012L12%2024M12%2024L24%2012M24%2024L36%2012M36%2012L48%2024M48%2012L60%2024M-12%2036L0%2024M0%2024L12%2036M12%2024L24%2036M24%2036L36%2024M36%2036L48%2024M48%2024L60%2036M-12%2048L0%2036M0%2048L12%2036M12%2036L24%2048M24%2036L36%2048M36%2048L48%2036M48%2048L60%2036M-12%2048L0%2060M0%2060L12%2048M12%2060L24%2048M24%2048L36%2060M36%2048L48%2060M48%2060L60%2048%22%20fill%3D%22none%22%20stroke%3D%22rgb%28255%2C253%2C247%29%22%20stroke-width%3D%221.1%22%20stroke-opacity%3D%220.098%22%20stroke-linecap%3D%22round%22%2F%3E%3C%2Fsvg%3E';
|
||||||
|
|
||||||
|
export const herringbone: ChatBgVariants = {
|
||||||
|
// Warm taupe threads (~oklch(0.79 0.02 75)) over a charcoal base. The two-tone wash
|
||||||
|
// runs cool-charcoal -> slightly warmer charcoal across the diagonal so the weave has
|
||||||
|
// a gentle light side; the vignette darkens the far corners a touch for depth.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: '#14120f',
|
||||||
|
backgroundImage: [
|
||||||
|
`url("${WEAVE_DARK}")`,
|
||||||
|
'linear-gradient(135deg, oklch(0.26 0.012 70 / 0.5) 0%, oklch(0.2 0.008 60 / 0.5) 100%)',
|
||||||
|
'radial-gradient(120% 120% at 50% 40%, oklch(0.24 0.01 65 / 0) 55%, oklch(0.12 0.006 55 / 0.45) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '48px 48px, 100% 100%, 100% 100%',
|
||||||
|
backgroundRepeat: 'repeat, no-repeat, no-repeat',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Greige threads (shadow ~oklch(0.6 0.015 75)) with a warm-white highlight over a warm
|
||||||
|
// off-white base. The wash tilts warm-white -> faint greige across the diagonal for the
|
||||||
|
// lit/shadow side; a whisper-soft vignette keeps corners from going flat.
|
||||||
|
light: {
|
||||||
|
backgroundColor: '#f6f3ec',
|
||||||
|
backgroundImage: [
|
||||||
|
`url("${WEAVE_LIGHT}")`,
|
||||||
|
'linear-gradient(135deg, oklch(0.99 0.006 85 / 0.6) 0%, oklch(0.93 0.01 80 / 0.6) 100%)',
|
||||||
|
'radial-gradient(120% 120% at 50% 40%, oklch(0.98 0.006 85 / 0) 58%, oklch(0.87 0.012 78 / 0.4) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '48px 48px, 100% 100%, 100% 100%',
|
||||||
|
backgroundRepeat: 'repeat, no-repeat, no-repeat',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// hexgrid — a refined sci-fi HUD honeycomb lattice.
|
||||||
|
//
|
||||||
|
// The motif is a crisp pointy-top hexagon honeycomb, drawn as thin interlocking
|
||||||
|
// outlines like the readout of a sci-fi interface. It is layered over a soft
|
||||||
|
// depth sheen: a faint central glow lifts the middle of the field and a gentle
|
||||||
|
// vignette settles the corners, so the lattice reads as a lit HUD surface with
|
||||||
|
// dimension rather than a flat repeating tile. Everything is kept at low alpha
|
||||||
|
// (hex lines ~0.14–0.16, washes well under legibility thresholds) so the motif
|
||||||
|
// is *felt, not read* — crisp message text stays comfortably WCAG-AA in both
|
||||||
|
// themes.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING
|
||||||
|
// The hex outlines live in a single inline-SVG data-URI tile of exactly
|
||||||
|
// √3·s × 3·s = 34.641 × 60 (side length s = 20). That is the natural repeat cell
|
||||||
|
// of a pointy-top honeycomb: one full central hexagon plus the six neighbours
|
||||||
|
// whose bodies straddle the tile edges. Because each straddling hexagon is drawn
|
||||||
|
// in full, the half that spills past one edge is completed pixel-for-pixel by the
|
||||||
|
// matching half re-entering from the opposite edge on the next repeat — the six
|
||||||
|
// vertical side edges land exactly on x = 0 and x = 34.641, the slanted edges
|
||||||
|
// meet across y = 0 / y = 60, so the lattice interlocks with no seam and no
|
||||||
|
// moiré. `backgroundSize: 34.641px 60px` locks the tile to that period; the glow
|
||||||
|
// and vignette are single non-repeating layers sized to 100%.
|
||||||
|
//
|
||||||
|
// DARK vs LIGHT
|
||||||
|
// Dark: cool cyan hex lines (oklch 0.72 0.1 200) on a deep blue-black base, with
|
||||||
|
// a soft cyan-tinted central glow — the classic "cold HUD" look.
|
||||||
|
// Light: soft slate-blue hexes (oklch 0.55 0.07 250) on a pale cool-white sheet,
|
||||||
|
// with a bright paper highlight at centre. Each alpha/lightness is tuned
|
||||||
|
// independently so both feel equally quiet against their own base.
|
||||||
|
|
||||||
|
// One seamless honeycomb tile (√3·20 × 3·20). Colour is injected per-theme.
|
||||||
|
const hexTile = (stroke: string): string =>
|
||||||
|
`url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2234.641%22%20height%3D%2260%22%3E%3Cpath%20d%3D%22M17.32%2010L0%2020L0%2040L17.32%2050L34.64%2040L34.64%2020Z%20M0%20-20L-17.32%20-10L-17.32%2010L0%2020L17.32%2010L17.32%20-10Z%20M34.64%20-20L17.32%20-10L17.32%2010L34.64%2020L51.96%2010L51.96%20-10Z%20M0%2040L-17.32%2050L-17.32%2070L0%2080L17.32%2070L17.32%2050Z%20M34.64%2040L17.32%2050L17.32%2070L34.64%2080L51.96%2070L51.96%2050Z%20M17.32%20-50L0%20-40L0%20-20L17.32%20-10L34.64%20-20L34.64%20-40Z%20M17.32%2070L0%2080L0%20100L17.32%20110L34.64%20100L34.64%2080Z%22%20fill%3D%22none%22%20stroke%3D%22${encodeURIComponent(
|
||||||
|
stroke,
|
||||||
|
)}%22%20stroke-width%3D%220.9%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E")`;
|
||||||
|
|
||||||
|
const dark: CSSProperties = {
|
||||||
|
backgroundColor: 'oklch(0.19 0.03 245)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. the honeycomb lattice — cool cyan hex outlines.
|
||||||
|
hexTile('oklch(0.72 0.1 200 / 0.14)'),
|
||||||
|
// 2. central glow — a soft cyan lift so the field looks lit from within.
|
||||||
|
'radial-gradient(120% 90% at 50% 42%, oklch(0.30 0.05 210 / 0.55) 0%, transparent 60%)',
|
||||||
|
// 3. vignette — settles the corners into the deep base for depth.
|
||||||
|
'radial-gradient(130% 130% at 50% 45%, transparent 55%, oklch(0.13 0.02 240 / 0.55) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '34.641px 60px, 100% 100%, 100% 100%',
|
||||||
|
};
|
||||||
|
|
||||||
|
const light: CSSProperties = {
|
||||||
|
backgroundColor: 'oklch(0.965 0.008 240)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. the honeycomb lattice — soft slate-blue hex outlines.
|
||||||
|
hexTile('oklch(0.55 0.07 250 / 0.16)'),
|
||||||
|
// 2. central highlight — a hint of brighter paper toward the middle.
|
||||||
|
'radial-gradient(120% 90% at 50% 40%, oklch(0.99 0.005 240 / 0.7) 0%, transparent 60%)',
|
||||||
|
// 3. vignette — feather the edges into a slightly cooler paper.
|
||||||
|
'radial-gradient(130% 130% at 50% 45%, transparent 58%, oklch(0.90 0.015 245 / 0.5) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '34.641px 60px, 100% 100%, 100% 100%',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hexgrid: ChatBgVariants = { dark, light };
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// neon — a synthwave neon grid with real bloom, kept restrained for readability.
|
||||||
|
//
|
||||||
|
// Concept: a retro-futuristic magenta/cyan grid that *glows* rather than shouts.
|
||||||
|
// The glow is built the way a real neon tube reads: a crisp hairline of light
|
||||||
|
// sitting inside a much wider, softer halo of the same hue. We achieve this per
|
||||||
|
// axis by stacking TWO linear-gradient layers that share the identical tile size
|
||||||
|
// (so their lines land on exactly the same pixel column/row across every repeat):
|
||||||
|
// - a wide "bloom" line: a fat, very-low-opacity band with a soft gradient
|
||||||
|
// falloff on both sides (transparent -> colour -> transparent), reading as
|
||||||
|
// out-of-focus glow;
|
||||||
|
// - a crisp "core" line: a 1px bright hairline centred in that bloom.
|
||||||
|
// A dark radial vignette then pulls the whole grid back toward the edges and
|
||||||
|
// keeps the reading column — the calm centre — darkest and highest-contrast, so
|
||||||
|
// text stays crisp. Pure CSS: only linear + radial gradients, no assets.
|
||||||
|
//
|
||||||
|
// Seamless tiling: every grid layer uses the SAME backgroundSize per axis
|
||||||
|
// (magenta and cyan share one 88px module in dark; the fine cyan sub-grid is an
|
||||||
|
// exact 1/2 divisor at 44px so it re-registers). Because the bloom and core for
|
||||||
|
// an axis share a size and a 0/0 position, their lines are always co-registered
|
||||||
|
// and no seam is possible. Vignette/wash layers are 100% 100% and never tile.
|
||||||
|
|
||||||
|
export const neon: ChatBgVariants = {
|
||||||
|
// Dark: magenta + cyan tubes glowing over near-black, bloom kept low so the
|
||||||
|
// lines are felt, not read. Vignette darkens the centre for legibility.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'oklch(0.135 0.02 285)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. magenta core hairlines — crisp, bright, thin (vertical + horizontal)
|
||||||
|
'linear-gradient(90deg, oklch(0.68 0.21 350 / 0.34) 0 1px, transparent 1px)',
|
||||||
|
'linear-gradient(0deg, oklch(0.68 0.21 350 / 0.34) 0 1px, transparent 1px)',
|
||||||
|
// 2. magenta bloom — a wide soft halo hugging the same lines
|
||||||
|
'linear-gradient(90deg, transparent 0, oklch(0.66 0.2 350 / 0.11) 3px, transparent 7px)',
|
||||||
|
'linear-gradient(0deg, transparent 0, oklch(0.66 0.2 350 / 0.11) 3px, transparent 7px)',
|
||||||
|
// 3. cyan core hairlines on the offset half-grid — the cross accent
|
||||||
|
'linear-gradient(90deg, oklch(0.82 0.13 200 / 0.20) 0 1px, transparent 1px)',
|
||||||
|
'linear-gradient(0deg, oklch(0.82 0.13 200 / 0.20) 0 1px, transparent 1px)',
|
||||||
|
// 4. cyan bloom — soft cool halo on the same half-grid lines
|
||||||
|
'linear-gradient(90deg, transparent 0, oklch(0.80 0.12 200 / 0.07) 2px, transparent 5px)',
|
||||||
|
'linear-gradient(0deg, transparent 0, oklch(0.80 0.12 200 / 0.07) 2px, transparent 5px)',
|
||||||
|
// 5. vignette — recede the grid, keep the reading centre calm & dark
|
||||||
|
'radial-gradient(ellipse 125% 95% at 50% 44%, transparent 34%, oklch(0.10 0.02 285 / 0.72) 100%)',
|
||||||
|
// 6. horizon wash — a faint magenta->cyan synthwave glow low on the canvas
|
||||||
|
'radial-gradient(ellipse 150% 90% at 50% 108%, oklch(0.4 0.14 340 / 0.30) 0%, transparent 60%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'88px 88px', // magenta core V
|
||||||
|
'88px 88px', // magenta core H
|
||||||
|
'88px 88px', // magenta bloom V
|
||||||
|
'88px 88px', // magenta bloom H
|
||||||
|
'44px 44px', // cyan core V (exact 1/2 divisor — re-registers)
|
||||||
|
'44px 44px', // cyan core H
|
||||||
|
'44px 44px', // cyan bloom V
|
||||||
|
'44px 44px', // cyan bloom H
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'100% 100%', // horizon wash
|
||||||
|
].join(','),
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // magenta core V
|
||||||
|
'0 0', // magenta core H
|
||||||
|
'-3px 0', // magenta bloom V — centre the 7px halo on the 1px core
|
||||||
|
'0 -3px', // magenta bloom H
|
||||||
|
'22px 22px', // cyan core V — sit the fine grid between magenta lines
|
||||||
|
'22px 22px', // cyan core H
|
||||||
|
'20px 22px', // cyan bloom V — centre the 5px halo on the cyan core
|
||||||
|
'22px 20px', // cyan bloom H
|
||||||
|
'0 0', // vignette
|
||||||
|
'0 0', // horizon wash
|
||||||
|
].join(','),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Light: "neon" reinterpreted as a soft luminous violet/teal grid on a pale
|
||||||
|
// cool-white base — no glow-on-black, just gentle coloured light. Bloom is even
|
||||||
|
// lighter here; a subtle centre-brightening vignette lifts the reading column.
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'oklch(0.972 0.006 275)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. violet core hairlines — soft but defined
|
||||||
|
'linear-gradient(90deg, oklch(0.55 0.17 330 / 0.16) 0 1px, transparent 1px)',
|
||||||
|
'linear-gradient(0deg, oklch(0.55 0.17 330 / 0.16) 0 1px, transparent 1px)',
|
||||||
|
// 2. violet bloom — the merest wide halo for luminosity
|
||||||
|
'linear-gradient(90deg, transparent 0, oklch(0.6 0.16 330 / 0.06) 3px, transparent 7px)',
|
||||||
|
'linear-gradient(0deg, transparent 0, oklch(0.6 0.16 330 / 0.06) 3px, transparent 7px)',
|
||||||
|
// 3. teal core hairlines on the offset half-grid — cool accent
|
||||||
|
'linear-gradient(90deg, oklch(0.58 0.11 200 / 0.11) 0 1px, transparent 1px)',
|
||||||
|
'linear-gradient(0deg, oklch(0.58 0.11 200 / 0.11) 0 1px, transparent 1px)',
|
||||||
|
// 4. teal bloom — faint cool halo on the same half-grid lines
|
||||||
|
'linear-gradient(90deg, transparent 0, oklch(0.62 0.1 200 / 0.045) 2px, transparent 5px)',
|
||||||
|
'linear-gradient(0deg, transparent 0, oklch(0.62 0.1 200 / 0.045) 2px, transparent 5px)',
|
||||||
|
// 5. vignette — brighten the calm reading centre for max legibility
|
||||||
|
'radial-gradient(ellipse 125% 95% at 50% 44%, oklch(1 0 0 / 0.50) 30%, transparent 100%)',
|
||||||
|
// 6. horizon wash — a whisper of violet->teal light low on the canvas
|
||||||
|
'radial-gradient(ellipse 150% 90% at 50% 108%, oklch(0.8 0.09 320 / 0.28) 0%, transparent 60%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'88px 88px', // violet core V
|
||||||
|
'88px 88px', // violet core H
|
||||||
|
'88px 88px', // violet bloom V
|
||||||
|
'88px 88px', // violet bloom H
|
||||||
|
'44px 44px', // teal core V
|
||||||
|
'44px 44px', // teal core H
|
||||||
|
'44px 44px', // teal bloom V
|
||||||
|
'44px 44px', // teal bloom H
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'100% 100%', // horizon wash
|
||||||
|
].join(','),
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // violet core V
|
||||||
|
'0 0', // violet core H
|
||||||
|
'-3px 0', // violet bloom V
|
||||||
|
'0 -3px', // violet bloom H
|
||||||
|
'22px 22px', // teal core V
|
||||||
|
'22px 22px', // teal core H
|
||||||
|
'20px 22px', // teal bloom V
|
||||||
|
'22px 20px', // teal bloom H
|
||||||
|
'0 0', // vignette
|
||||||
|
'0 0', // horizon wash
|
||||||
|
].join(','),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// plaid — an authentic woven tartan, muted to a heather-wool hush.
|
||||||
|
//
|
||||||
|
// Real tartan is not a grid of lines: it is a *sett* — a repeating sequence of
|
||||||
|
// coloured bands of different widths — thrown in BOTH warp (vertical) and weft
|
||||||
|
// (horizontal) directions with the SAME sequence. Where a warp band crosses a
|
||||||
|
// weft band of the same colour the yarn density doubles and the colour visibly
|
||||||
|
// deepens; that reinforced overlap at every crossing is exactly what makes cloth
|
||||||
|
// read as woven rather than printed. We reproduce that physically with
|
||||||
|
// semi-transparent bands: a vertical band at alpha a and a horizontal band at
|
||||||
|
// alpha a stack to ~2a where they cross (over transparent to 1x elsewhere), so
|
||||||
|
// the crossings darken on their own with no extra layer.
|
||||||
|
//
|
||||||
|
// THE SETT (band widths across one tile)
|
||||||
|
// We use a few closely-related widths for a wool-flannel rhythm rather than a
|
||||||
|
// clean check: a wide ground band, a medium companion, and a thin accent
|
||||||
|
// over-stripe of a warmer hue (the classic single guard line). The identical
|
||||||
|
// sequence in warp and weft yields the tartan lattice. A faint diagonal twill
|
||||||
|
// hatch sits on top at very low alpha to suggest the 2/2 twill thread angle of
|
||||||
|
// woven wool. A soft central wash lifts the reading zone and a gentle vignette
|
||||||
|
// settles the corners.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING
|
||||||
|
// Every band layer is a `repeating-linear-gradient` whose stop sequence is
|
||||||
|
// expressed in px and whose period divides the tile exactly (dark tile 96px:
|
||||||
|
// wide=48, medium=24, accent=96; light tile 88px similarly). Warp layers repeat
|
||||||
|
// at 0deg-across (90deg gradient) and weft at 0deg, sharing one square
|
||||||
|
// `backgroundSize`, so the sett closes on itself with no seam in either axis.
|
||||||
|
// The twill hatch is a repeating-linear-gradient on a small square tile that
|
||||||
|
// divides the main tile. Wash and vignette are single non-repeating gradients
|
||||||
|
// at 100% 100%, so they never seam.
|
||||||
|
|
||||||
|
const dark: CSSProperties = {
|
||||||
|
// Deep muted forest-charcoal ground.
|
||||||
|
backgroundColor: 'oklch(0.19 0.018 155)',
|
||||||
|
backgroundImage: [
|
||||||
|
// Twill hatch — whisper-faint diagonal thread angle of the weave itself.
|
||||||
|
'repeating-linear-gradient(45deg,' +
|
||||||
|
' oklch(0.55 0.03 155 / 0.03) 0px, oklch(0.55 0.03 155 / 0.03) 1px,' +
|
||||||
|
' transparent 1px, transparent 4px)',
|
||||||
|
|
||||||
|
// WEFT (horizontal bands) --------------------------------------------
|
||||||
|
// Wide muted-forest ground band.
|
||||||
|
'repeating-linear-gradient(0deg,' +
|
||||||
|
' oklch(0.45 0.05 150 / 0.14) 0px, oklch(0.45 0.05 150 / 0.14) 22px,' +
|
||||||
|
' transparent 22px, transparent 48px)',
|
||||||
|
// Medium companion band (cooler, offset into the ground gap).
|
||||||
|
'repeating-linear-gradient(0deg,' +
|
||||||
|
' transparent 0px, transparent 60px,' +
|
||||||
|
' oklch(0.42 0.035 175 / 0.11) 60px, oklch(0.42 0.035 175 / 0.11) 72px,' +
|
||||||
|
' transparent 72px, transparent 96px)',
|
||||||
|
// Thin warm amber guard line — the single accent over-stripe.
|
||||||
|
'repeating-linear-gradient(0deg,' +
|
||||||
|
' transparent 0px, transparent 36px,' +
|
||||||
|
' oklch(0.60 0.08 40 / 0.13) 36px, oklch(0.60 0.08 40 / 0.13) 38px,' +
|
||||||
|
' transparent 38px, transparent 96px)',
|
||||||
|
|
||||||
|
// WARP (vertical bands, identical sett) -------------------------------
|
||||||
|
'repeating-linear-gradient(90deg,' +
|
||||||
|
' oklch(0.45 0.05 150 / 0.14) 0px, oklch(0.45 0.05 150 / 0.14) 22px,' +
|
||||||
|
' transparent 22px, transparent 48px)',
|
||||||
|
'repeating-linear-gradient(90deg,' +
|
||||||
|
' transparent 0px, transparent 60px,' +
|
||||||
|
' oklch(0.42 0.035 175 / 0.11) 60px, oklch(0.42 0.035 175 / 0.11) 72px,' +
|
||||||
|
' transparent 72px, transparent 96px)',
|
||||||
|
'repeating-linear-gradient(90deg,' +
|
||||||
|
' transparent 0px, transparent 36px,' +
|
||||||
|
' oklch(0.60 0.08 40 / 0.13) 36px, oklch(0.60 0.08 40 / 0.13) 38px,' +
|
||||||
|
' transparent 38px, transparent 96px)',
|
||||||
|
|
||||||
|
// Tonal wash — soft warm-green lift through the reading centre.
|
||||||
|
'radial-gradient(ellipse 92% 78% at 50% 42%, oklch(0.27 0.03 150 / 0.38) 0%, transparent 62%)',
|
||||||
|
// Vignette — feather the corners into deeper forest-charcoal.
|
||||||
|
'radial-gradient(ellipse 122% 132% at 50% 45%, transparent 58%, oklch(0.14 0.016 155 / 0.55) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize:
|
||||||
|
'8px 8px,' + // twill (multiple of 4px hatch period → seamless)
|
||||||
|
'96px 96px, 96px 96px, 96px 96px,' + // weft: wide, medium, accent
|
||||||
|
'96px 96px, 96px 96px, 96px 96px,' + // warp: wide, medium, accent
|
||||||
|
'100% 100%, 100% 100%', // wash, vignette
|
||||||
|
};
|
||||||
|
|
||||||
|
const light: CSSProperties = {
|
||||||
|
// Warm off-white paper ground.
|
||||||
|
backgroundColor: 'oklch(0.965 0.007 85)',
|
||||||
|
backgroundImage: [
|
||||||
|
// Twill hatch — faint diagonal weave angle on paper.
|
||||||
|
'repeating-linear-gradient(45deg,' +
|
||||||
|
' oklch(0.45 0.03 250 / 0.025) 0px, oklch(0.45 0.03 250 / 0.025) 1px,' +
|
||||||
|
' transparent 1px, transparent 4px)',
|
||||||
|
|
||||||
|
// WEFT (horizontal bands) --------------------------------------------
|
||||||
|
// Wide dusty-blue ground band.
|
||||||
|
'repeating-linear-gradient(0deg,' +
|
||||||
|
' oklch(0.60 0.045 245 / 0.12) 0px, oklch(0.60 0.045 245 / 0.12) 20px,' +
|
||||||
|
' transparent 20px, transparent 44px)',
|
||||||
|
// Medium greige companion band.
|
||||||
|
'repeating-linear-gradient(0deg,' +
|
||||||
|
' transparent 0px, transparent 55px,' +
|
||||||
|
' oklch(0.62 0.018 90 / 0.11) 55px, oklch(0.62 0.018 90 / 0.11) 66px,' +
|
||||||
|
' transparent 66px, transparent 88px)',
|
||||||
|
// Thin warm sand guard line — the single accent over-stripe.
|
||||||
|
'repeating-linear-gradient(0deg,' +
|
||||||
|
' transparent 0px, transparent 33px,' +
|
||||||
|
' oklch(0.68 0.06 55 / 0.12) 33px, oklch(0.68 0.06 55 / 0.12) 35px,' +
|
||||||
|
' transparent 35px, transparent 88px)',
|
||||||
|
|
||||||
|
// WARP (vertical bands, identical sett) -------------------------------
|
||||||
|
'repeating-linear-gradient(90deg,' +
|
||||||
|
' oklch(0.60 0.045 245 / 0.12) 0px, oklch(0.60 0.045 245 / 0.12) 20px,' +
|
||||||
|
' transparent 20px, transparent 44px)',
|
||||||
|
'repeating-linear-gradient(90deg,' +
|
||||||
|
' transparent 0px, transparent 55px,' +
|
||||||
|
' oklch(0.62 0.018 90 / 0.11) 55px, oklch(0.62 0.018 90 / 0.11) 66px,' +
|
||||||
|
' transparent 66px, transparent 88px)',
|
||||||
|
'repeating-linear-gradient(90deg,' +
|
||||||
|
' transparent 0px, transparent 33px,' +
|
||||||
|
' oklch(0.68 0.06 55 / 0.12) 33px, oklch(0.68 0.06 55 / 0.12) 35px,' +
|
||||||
|
' transparent 35px, transparent 88px)',
|
||||||
|
|
||||||
|
// Tonal wash — warm paper highlight through the reading centre.
|
||||||
|
'radial-gradient(ellipse 92% 78% at 50% 42%, oklch(0.99 0.008 85 / 0.55) 0%, transparent 62%)',
|
||||||
|
// Vignette — settle the corners into a slightly deeper dusty tone.
|
||||||
|
'radial-gradient(ellipse 122% 132% at 50% 45%, transparent 58%, oklch(0.90 0.014 245 / 0.40) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize:
|
||||||
|
'8px 8px,' + // twill (multiple of 4px hatch period → seamless)
|
||||||
|
'88px 88px, 88px 88px, 88px 88px,' + // weft: wide, medium, accent
|
||||||
|
'88px 88px, 88px 88px, 88px 88px,' + // warp: wide, medium, accent
|
||||||
|
'100% 100%, 100% 100%', // wash, vignette
|
||||||
|
};
|
||||||
|
|
||||||
|
export const plaid: ChatBgVariants = { dark, light };
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// polka — a grown-up polka dot: embossed leather / fine letterpress stationery,
|
||||||
|
// not childish spots. Each dot is not a flat circle but a soft radial "bump":
|
||||||
|
// an off-centre highlight fading into a faint recessed shadow, so it reads as a
|
||||||
|
// gently raised (or debossed) node catching a single top-left light. Two subtly
|
||||||
|
// different dot sizes are staggered on a half-tile offset for a refined,
|
||||||
|
// hand-set rhythm, and a large single vignette gradient adds quiet depth toward
|
||||||
|
// the edges.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING: both dot layers repeat on the SAME 44px cell (backgroundSize
|
||||||
|
// 44px 44px). The larger "primary" dots sit at 0 0; the smaller "secondary"
|
||||||
|
// dots are shifted by exactly half a tile (22px 22px) so they fall in the gaps
|
||||||
|
// of the primary lattice — a true staggered brick layout that wraps with no
|
||||||
|
// seam. Each radial gradient's highlight/shadow rings are fully enclosed well
|
||||||
|
// inside its cell, so nothing is clipped at a tile boundary. The vignette is a
|
||||||
|
// single non-repeating gradient covering the whole element ('cover').
|
||||||
|
//
|
||||||
|
// SUBTLETY: dot opacities live in the 0.03–0.10 range and every dot fades to
|
||||||
|
// transparent over a soft edge (no hard rim), so the surface is felt as tactile
|
||||||
|
// grain rather than read as dots. Crisp message text sits comfortably above it
|
||||||
|
// in both themes (WCAG-AA safe).
|
||||||
|
|
||||||
|
const dark: CSSProperties = {
|
||||||
|
// deep espresso base — warm, near-black brown
|
||||||
|
backgroundColor: 'oklch(0.19 0.018 65)',
|
||||||
|
backgroundImage: [
|
||||||
|
// vignette — corners settle darker so the field feels like supple leather
|
||||||
|
'radial-gradient(120% 120% at 50% 40%, oklch(0.22 0.02 65 / 0.5) 0%, oklch(0.19 0.018 65 / 0) 55%, oklch(0.15 0.015 60 / 0.55) 100%)',
|
||||||
|
// PRIMARY dot — larger raised pearl. Top-left warm highlight, then the body,
|
||||||
|
// then a whisper of shadow at the lower-right rim for embossed dimension.
|
||||||
|
'radial-gradient(circle at 42% 40%, oklch(0.82 0.02 80 / 0.10) 0%, oklch(0.80 0.02 80 / 0.075) 22%, oklch(0.55 0.02 70 / 0.045) 44%, oklch(0.12 0.01 60 / 0.05) 62%, transparent 72%)',
|
||||||
|
// SECONDARY dot — smaller, staggered into the gaps, fainter for depth layering
|
||||||
|
'radial-gradient(circle at 42% 40%, oklch(0.82 0.02 80 / 0.075) 0%, oklch(0.78 0.02 80 / 0.05) 26%, oklch(0.50 0.02 70 / 0.03) 52%, oklch(0.12 0.01 60 / 0.04) 70%, transparent 82%)',
|
||||||
|
].join(','),
|
||||||
|
// primary dots ~9px, secondary ~6px, both on the same 44px lattice
|
||||||
|
backgroundSize: 'cover, 44px 44px, 44px 44px',
|
||||||
|
// secondary offset by half a tile => staggered brick lattice
|
||||||
|
backgroundPosition: 'center, 0 0, 22px 22px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const light: CSSProperties = {
|
||||||
|
// cream stationery base — warm off-white paper stock
|
||||||
|
backgroundColor: 'oklch(0.975 0.008 85)',
|
||||||
|
backgroundImage: [
|
||||||
|
// vignette — a gentle warm settling toward the edges, like heavy cotton paper
|
||||||
|
'radial-gradient(120% 120% at 50% 40%, oklch(0.99 0.006 85 / 0.5) 0%, oklch(0.975 0.008 85 / 0) 55%, oklch(0.945 0.012 80 / 0.55) 100%)',
|
||||||
|
// PRIMARY dot — soft taupe deboss. Faint paper highlight at top-left, taupe
|
||||||
|
// body, then a soft shadow lower-right so each dot reads pressed into the sheet.
|
||||||
|
'radial-gradient(circle at 42% 40%, oklch(0.99 0.004 85 / 0.35) 0%, oklch(0.72 0.02 70 / 0.075) 30%, oklch(0.60 0.025 65 / 0.09) 50%, oklch(0.55 0.025 60 / 0.05) 66%, transparent 76%)',
|
||||||
|
// SECONDARY dot — smaller, staggered, lighter for a two-tier hand-set rhythm
|
||||||
|
'radial-gradient(circle at 42% 40%, oklch(0.99 0.004 85 / 0.28) 0%, oklch(0.74 0.02 70 / 0.05) 34%, oklch(0.62 0.025 65 / 0.06) 56%, oklch(0.56 0.025 60 / 0.035) 72%, transparent 84%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: 'cover, 44px 44px, 44px 44px',
|
||||||
|
backgroundPosition: 'center, 0 0, 22px 22px',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const polka: ChatBgVariants = { dark, light };
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// stars — a deep-space starfield with subtle depth.
|
||||||
|
//
|
||||||
|
// Concept: three parallax layers of stars at different tile sizes and offsets
|
||||||
|
// (so the repeat never lines up and reads as a genuine random field), lifted
|
||||||
|
// onto a faint deep-blue->violet nebula wash for depth, and finished with a
|
||||||
|
// gentle center vignette that keeps the reading column the calmest area of the
|
||||||
|
// canvas. Every layer is a stacked radial-gradient — pure CSS, no assets.
|
||||||
|
//
|
||||||
|
// Layer stacking order (topmost first, as CSS paints image #1 on top):
|
||||||
|
// 1. bright near stars (crisp, sparse, largest tile)
|
||||||
|
// 2. mid stars (dimmer, medium tile)
|
||||||
|
// 3. faint blue far stars (haze, smallest tile — most repeats, least visible)
|
||||||
|
// 4. calming center vignette
|
||||||
|
// 5. nebula wash (deep blue -> violet)
|
||||||
|
// The three star tiles use coprime-ish sizes (137/191/233 dark) so their least
|
||||||
|
// common repeat is enormous and no seam is perceivable.
|
||||||
|
|
||||||
|
export const stars: ChatBgVariants = {
|
||||||
|
// Dark: bright/dim white + faint blue stars on a near-black cosmos, with a
|
||||||
|
// deep-blue->violet nebula and a soft vignette that darkens the calm center.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'oklch(0.16 0.03 275)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. bright near stars — crisp cool-white, sparse
|
||||||
|
'radial-gradient(circle at center, oklch(0.98 0.01 260 / 0.85) 0.6px, transparent 1.4px)',
|
||||||
|
// 2. mid stars — softer, more of them
|
||||||
|
'radial-gradient(circle at center, oklch(0.92 0.02 265 / 0.55) 0.6px, transparent 1.3px)',
|
||||||
|
// 3. faint blue far dust — the parallax haze
|
||||||
|
'radial-gradient(circle at center, oklch(0.80 0.06 255 / 0.30) 0.5px, transparent 1.1px)',
|
||||||
|
// 4. center vignette — keeps the reading column calmest
|
||||||
|
'radial-gradient(ellipse 120% 90% at 50% 42%, transparent 42%, oklch(0.10 0.03 270 / 0.55) 100%)',
|
||||||
|
// 5. nebula wash — deep blue -> violet drift
|
||||||
|
'radial-gradient(ellipse 140% 120% at 78% 12%, oklch(0.25 0.08 280 / 0.55) 0%, transparent 55%)',
|
||||||
|
'radial-gradient(ellipse 130% 110% at 18% 92%, oklch(0.20 0.06 250 / 0.50) 0%, transparent 58%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'137px 137px', // near stars
|
||||||
|
'191px 191px', // mid stars
|
||||||
|
'233px 233px', // far dust
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'100% 100%', // nebula A
|
||||||
|
'100% 100%', // nebula B
|
||||||
|
].join(','),
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // near
|
||||||
|
'61px 43px', // mid (offset breaks alignment)
|
||||||
|
'113px 97px', // far (offset again)
|
||||||
|
'0 0', // vignette
|
||||||
|
'0 0', // nebula A
|
||||||
|
'0 0', // nebula B
|
||||||
|
].join(','),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Light: an airy pre-dawn sky. No literal white stars on white — instead very
|
||||||
|
// soft pale sparkles paired with the faintest cool-grey speckles, floated on a
|
||||||
|
// gentle cool gradient. Reads as elegant atmosphere, never as noise over text.
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'oklch(0.965 0.008 255)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. pale warm pre-dawn sparkles — a hair brighter than the sky
|
||||||
|
'radial-gradient(circle at center, oklch(0.995 0.015 90 / 0.55) 0.6px, transparent 1.4px)',
|
||||||
|
// 2. tiny cool speckles — the merest hint of darkness for texture/contrast
|
||||||
|
'radial-gradient(circle at center, oklch(0.62 0.05 260 / 0.16) 0.5px, transparent 1.2px)',
|
||||||
|
// 3. faint far dust — very soft, most-repeated layer
|
||||||
|
'radial-gradient(circle at center, oklch(0.70 0.04 255 / 0.12) 0.5px, transparent 1.1px)',
|
||||||
|
// 4. center vignette — brightens the calm reading center slightly
|
||||||
|
'radial-gradient(ellipse 120% 90% at 50% 44%, oklch(1 0 0 / 0.45) 30%, transparent 100%)',
|
||||||
|
// 5. pre-dawn wash — cool blue high, warm blush low
|
||||||
|
'radial-gradient(ellipse 150% 120% at 80% 8%, oklch(0.90 0.05 255 / 0.60) 0%, transparent 60%)',
|
||||||
|
'radial-gradient(ellipse 140% 120% at 15% 95%, oklch(0.93 0.04 40 / 0.45) 0%, transparent 62%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'149px 149px', // sparkles
|
||||||
|
'199px 199px', // speckles
|
||||||
|
'251px 251px', // far dust
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'100% 100%', // wash A
|
||||||
|
'100% 100%', // wash B
|
||||||
|
].join(','),
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // sparkles
|
||||||
|
'71px 53px', // speckles
|
||||||
|
'127px 109px', // far dust
|
||||||
|
'0 0', // vignette
|
||||||
|
'0 0', // wash A
|
||||||
|
'0 0', // wash B
|
||||||
|
].join(','),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// tactical — a military tactical display / recon coordinate grid (MGRS-style).
|
||||||
|
//
|
||||||
|
// The motif is a fine grid nested inside bold sector squares, with a reticle
|
||||||
|
// crosshair (arms + ring) at every sector intersection, small stencil corner
|
||||||
|
// brackets inside each sector, and coordinate tick-marks along the sector edges
|
||||||
|
// — a convincing mil-spec map overlay rather than a plain dot grid.
|
||||||
|
//
|
||||||
|
// Layers (painted top-to-bottom):
|
||||||
|
// 1. SVG reticle/stencil tile (128px). Corner arms + quarter-ring arcs radiate
|
||||||
|
// from each of the four tile corners, so four neighbouring tiles combine
|
||||||
|
// into ONE full crosshair "+" with a full ring at every sector intersection.
|
||||||
|
// The tile also carries L-shaped stencil brackets, edge coordinate ticks and
|
||||||
|
// a micro centre reticle. Because every mark is anchored to the 128px tile
|
||||||
|
// lattice, it stays phase-locked to the grids below — no seams, no drift.
|
||||||
|
// 2. Sector lines (heavier) — 128px.
|
||||||
|
// 3. Fine recon grid (fainter) — 16px (128 = 8 × 16, so it nests
|
||||||
|
// exactly inside every sector with no beat/moiré).
|
||||||
|
// 4. A soft scan vignette that keeps the CENTRE calm and clear for text while
|
||||||
|
// letting the grid fall away slightly toward the edges — dimension without
|
||||||
|
// contrast.
|
||||||
|
//
|
||||||
|
// All strokes sit at low alpha (~0.03–0.30 on 1px marks) so the display is felt,
|
||||||
|
// not read: crisp message text stays comfortably WCAG-AA legible in both themes.
|
||||||
|
// A single shared top-left (0 0) origin keeps the reticle tile, the 128px sector
|
||||||
|
// grid and the 16px fine grid all in phase.
|
||||||
|
|
||||||
|
const DARK_RETICLE =
|
||||||
|
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22128%22%20height%3D%22128%22%3E%3Cg%20stroke%3D%22oklch%280.72%200.13%2085%20%2F%200.30%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M0%200%20H14%20M0%200%20V14%22%2F%3E%3Cpath%20d%3D%22M128%200%20H114%20M128%200%20V14%22%2F%3E%3Cpath%20d%3D%22M0%20128%20H14%20M0%20128%20V114%22%2F%3E%3Cpath%20d%3D%22M128%20128%20H114%20M128%20128%20V114%22%2F%3E%3Ccircle%20cx%3D%220%22%20cy%3D%220%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%220%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%220%22%20cy%3D%22128%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%22128%22%20r%3D%226%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.72%200.10%2095%20%2F%200.22%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M10%2020%20V10%20H20%22%2F%3E%3Cpath%20d%3D%22M118%2020%20V10%20H108%22%2F%3E%3Cpath%20d%3D%22M10%20108%20V118%20H20%22%2F%3E%3Cpath%20d%3D%22M118%20108%20V118%20H108%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.72%200.10%2095%20%2F%200.22%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M64%200%20V6%20M64%20128%20V122%20M0%2064%20H6%20M128%2064%20H122%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.72%200.13%2085%20%2F%200.30%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M64%2058%20V70%20M58%2064%20H70%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
|
||||||
|
|
||||||
|
const LIGHT_RETICLE =
|
||||||
|
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22128%22%20height%3D%22128%22%3E%3Cg%20stroke%3D%22oklch%280.45%200.07%20120%20%2F%200.40%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M0%200%20H14%20M0%200%20V14%22%2F%3E%3Cpath%20d%3D%22M128%200%20H114%20M128%200%20V14%22%2F%3E%3Cpath%20d%3D%22M0%20128%20H14%20M0%20128%20V114%22%2F%3E%3Cpath%20d%3D%22M128%20128%20H114%20M128%20128%20V114%22%2F%3E%3Ccircle%20cx%3D%220%22%20cy%3D%220%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%220%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%220%22%20cy%3D%22128%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%22128%22%20r%3D%226%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.42%200.05%20130%20%2F%200.28%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M10%2020%20V10%20H20%22%2F%3E%3Cpath%20d%3D%22M118%2020%20V10%20H108%22%2F%3E%3Cpath%20d%3D%22M10%20108%20V118%20H20%22%2F%3E%3Cpath%20d%3D%22M118%20108%20V118%20H108%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.42%200.05%20130%20%2F%200.28%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M64%200%20V6%20M64%20128%20V122%20M0%2064%20H6%20M128%2064%20H122%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.45%200.07%20120%20%2F%200.40%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M64%2058%20V70%20M58%2064%20H70%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
|
||||||
|
|
||||||
|
export const tactical: ChatBgVariants = {
|
||||||
|
// Phosphor amber/olive lines glowing on a near-black recon display.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'oklch(0.17 0.012 95)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. reticles + stencil brackets + coordinate ticks
|
||||||
|
DARK_RETICLE,
|
||||||
|
// 4. scan vignette: keeps the centre calm, eases grid contrast at edges.
|
||||||
|
'radial-gradient(135% 120% at 50% 46%, transparent 52%, oklch(0.11 0.01 100 / 0.55) 100%)',
|
||||||
|
// a faint phosphor bloom drifting off the top so the black isn't dead flat.
|
||||||
|
'radial-gradient(120% 90% at 50% 0%, oklch(0.24 0.03 90 / 0.45) 0%, transparent 60%)',
|
||||||
|
// 2. sector lines (heavier)
|
||||||
|
'linear-gradient(oklch(0.72 0.13 85 / 0.11) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, oklch(0.72 0.13 85 / 0.11) 1px, transparent 1px)',
|
||||||
|
// 3. fine recon grid (fainter)
|
||||||
|
'linear-gradient(oklch(0.72 0.11 90 / 0.045) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, oklch(0.72 0.11 90 / 0.045) 1px, transparent 1px)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'128px 128px', // reticle tile
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'100% 100%', // phosphor bloom
|
||||||
|
'128px 128px', // sector V
|
||||||
|
'128px 128px', // sector H
|
||||||
|
'16px 16px', // fine V
|
||||||
|
'16px 16px', // fine H
|
||||||
|
].join(','),
|
||||||
|
// Shared top-left origin: reticle tile + 128px sector grid + 16px fine grid
|
||||||
|
// (128 = 8 × 16) stay phase-locked, so corner arms land on sector crossings.
|
||||||
|
},
|
||||||
|
|
||||||
|
// Olive-graphite recon grid printed on cool tactical paper.
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'oklch(0.95 0.008 120)',
|
||||||
|
backgroundImage: [
|
||||||
|
LIGHT_RETICLE,
|
||||||
|
// scan vignette: gentle cool shading into the corners, calm centre.
|
||||||
|
'radial-gradient(135% 120% at 50% 46%, transparent 56%, oklch(0.86 0.02 125 / 0.5) 100%)',
|
||||||
|
// paper sheen toward the top so the surface reads like a printed sheet.
|
||||||
|
'radial-gradient(120% 90% at 50% 0%, oklch(0.98 0.006 120 / 0.7) 0%, transparent 60%)',
|
||||||
|
// sector lines (heavier)
|
||||||
|
'linear-gradient(oklch(0.45 0.07 120 / 0.14) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, oklch(0.45 0.07 120 / 0.14) 1px, transparent 1px)',
|
||||||
|
// fine recon grid (fainter)
|
||||||
|
'linear-gradient(oklch(0.45 0.06 125 / 0.055) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, oklch(0.45 0.06 125 / 0.055) 1px, transparent 1px)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'128px 128px',
|
||||||
|
'100% 100%',
|
||||||
|
'100% 100%',
|
||||||
|
'128px 128px',
|
||||||
|
'128px 128px',
|
||||||
|
'16px 16px',
|
||||||
|
'16px 16px',
|
||||||
|
].join(','),
|
||||||
|
// Shared top-left origin keeps the reticle tile and both grids phase-locked.
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// topographic — an elegant contour / elevation map.
|
||||||
|
//
|
||||||
|
// The motif is a delicate cartographic contour survey: nested rings suggest two
|
||||||
|
// gentle "peaks" and a shallow "valley", drawn with occasional heavier "index
|
||||||
|
// contour" lines for authenticity, all floating over a soft tonal wash. It is
|
||||||
|
// tuned to be *felt, not read* — line opacities sit well under legibility
|
||||||
|
// thresholds so crisp message text stays comfortably WCAG-AA in both themes.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING
|
||||||
|
// Each contour system is a `repeating-radial-gradient` whose ring period P is a
|
||||||
|
// clean divisor of its `backgroundSize` tile. A repeating-radial-gradient tiles
|
||||||
|
// seamlessly only when the tile edge falls on a whole number of ring periods, so
|
||||||
|
// every layer below uses tile = N * P. Peak A's fine (32px) and index (128px)
|
||||||
|
// layers share one 256px tile (256 = 8*32 = 2*128) AND one center, so the heavy
|
||||||
|
// index lines land exactly on every 4th fine ring — a true index contour, never
|
||||||
|
// drifting out of register. Peak B tiles 288 = 12*24; the valley tiles 384 =
|
||||||
|
// 8*48. The tonal washes/vignette are single non-repeating gradients sized to
|
||||||
|
// the same tiles, so nothing shows a visible seam.
|
||||||
|
|
||||||
|
const dark: CSSProperties = {
|
||||||
|
backgroundColor: 'oklch(0.205 0.018 235)',
|
||||||
|
backgroundImage: [
|
||||||
|
// Peak A — fine contour lines (soft teal), 32px period.
|
||||||
|
'repeating-radial-gradient(circle at 27% 34%, transparent 0, transparent 26px,' +
|
||||||
|
' oklch(0.62 0.055 190 / 0.09) 27px, oklch(0.62 0.055 190 / 0.09) 28px, transparent 29px, transparent 32px)',
|
||||||
|
// Peak A — index (heavier) contour every 4th ring, 128px period.
|
||||||
|
'repeating-radial-gradient(circle at 27% 34%, transparent 0, transparent 122px,' +
|
||||||
|
' oklch(0.66 0.06 190 / 0.10) 123px, oklch(0.66 0.06 190 / 0.10) 125px, transparent 126px, transparent 128px)',
|
||||||
|
// Peak B — fine contour lines (cooler sage-teal), 24px period.
|
||||||
|
'repeating-radial-gradient(circle at 78% 72%, transparent 0, transparent 19px,' +
|
||||||
|
' oklch(0.60 0.05 200 / 0.07) 20px, oklch(0.60 0.05 200 / 0.07) 21px, transparent 22px, transparent 24px)',
|
||||||
|
// Valley — broad shallow rings (very faint), 48px period.
|
||||||
|
'repeating-radial-gradient(circle at 52% 8%, transparent 0, transparent 42px,' +
|
||||||
|
' oklch(0.58 0.045 195 / 0.05) 43px, oklch(0.58 0.045 195 / 0.05) 44px, transparent 45px, transparent 48px)',
|
||||||
|
// Tonal wash — lifts the "peaks", sinks the corners for depth.
|
||||||
|
'radial-gradient(circle at 27% 34%, oklch(0.26 0.03 200 / 0.55) 0%, transparent 46%)',
|
||||||
|
'radial-gradient(circle at 78% 72%, oklch(0.24 0.028 205 / 0.45) 0%, transparent 44%)',
|
||||||
|
// Vignette — soft edge darkening keeps the field calm behind text.
|
||||||
|
'radial-gradient(ellipse 120% 130% at 50% 42%, transparent 58%, oklch(0.15 0.02 235 / 0.55) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize:
|
||||||
|
'256px 256px, 256px 256px, 288px 288px, 384px 384px, 100% 100%, 100% 100%, 100% 100%',
|
||||||
|
};
|
||||||
|
|
||||||
|
const light: CSSProperties = {
|
||||||
|
backgroundColor: 'oklch(0.965 0.008 85)',
|
||||||
|
backgroundImage: [
|
||||||
|
// Peak A — fine contour lines (warm graphite/sand), 32px period.
|
||||||
|
'repeating-radial-gradient(circle at 27% 34%, transparent 0, transparent 26px,' +
|
||||||
|
' oklch(0.45 0.03 70 / 0.08) 27px, oklch(0.45 0.03 70 / 0.08) 28px, transparent 29px, transparent 32px)',
|
||||||
|
// Peak A — index (heavier) contour every 4th ring, 128px period.
|
||||||
|
'repeating-radial-gradient(circle at 27% 34%, transparent 0, transparent 122px,' +
|
||||||
|
' oklch(0.40 0.035 68 / 0.10) 123px, oklch(0.40 0.035 68 / 0.10) 125px, transparent 126px, transparent 128px)',
|
||||||
|
// Peak B — fine contour lines (soft sage-graphite), 24px period.
|
||||||
|
'repeating-radial-gradient(circle at 78% 72%, transparent 0, transparent 19px,' +
|
||||||
|
' oklch(0.47 0.028 120 / 0.06) 20px, oklch(0.47 0.028 120 / 0.06) 21px, transparent 22px, transparent 24px)',
|
||||||
|
// Valley — broad shallow rings (very faint), 48px period.
|
||||||
|
'repeating-radial-gradient(circle at 52% 8%, transparent 0, transparent 42px,' +
|
||||||
|
' oklch(0.46 0.025 75 / 0.045) 43px, oklch(0.46 0.025 75 / 0.045) 44px, transparent 45px, transparent 48px)',
|
||||||
|
// Tonal wash — warm paper highlights over the "peaks".
|
||||||
|
'radial-gradient(circle at 27% 34%, oklch(0.985 0.012 85 / 0.60) 0%, transparent 46%)',
|
||||||
|
'radial-gradient(circle at 78% 72%, oklch(0.945 0.014 95 / 0.45) 0%, transparent 44%)',
|
||||||
|
// Vignette — feather the edges to a slightly deeper sand for depth.
|
||||||
|
'radial-gradient(ellipse 120% 130% at 50% 42%, transparent 58%, oklch(0.90 0.016 80 / 0.45) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize:
|
||||||
|
'256px 256px, 256px 256px, 288px 288px, 384px 384px, 100% 100%, 100% 100%, 100% 100%',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const topographic: ChatBgVariants = { dark, light };
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// triangles — elegant low-poly / faceted-crystal mesh.
|
||||||
|
//
|
||||||
|
// The motif stays true to its name — a triangular tessellation — but is rebuilt
|
||||||
|
// to read as a *faceted crystalline surface* rather than the old flat isometric
|
||||||
|
// lines. Neighbouring triangular facets carry barely-there tonal shifts (one
|
||||||
|
// face catches a whisper of light, the adjacent one falls into a whisper of
|
||||||
|
// shade) so the plane looks gently faceted and dimensional, like brushed slate
|
||||||
|
// or cut glass seen at a shallow angle. A hairline "mesh glint" traces the facet
|
||||||
|
// edges so the crystalline structure is felt, never read. A soft tonal wash and
|
||||||
|
// a feathered vignette give the whole field quiet architectural depth.
|
||||||
|
//
|
||||||
|
// FACET SHADING
|
||||||
|
// An isometric triangle grid is three families of parallel lines at 0deg, 60deg
|
||||||
|
// and 120deg. Each `linear-gradient` below is a *hard-edged* two-band ramp along
|
||||||
|
// one of those axes: a faint tonal band followed by transparent, repeating
|
||||||
|
// across the tile. Overlapping the three axes partitions the plane into small
|
||||||
|
// triangular cells; because each axis contributes its shade to a different set
|
||||||
|
// of cells, up-pointing and down-pointing facets end up carrying subtly
|
||||||
|
// different summed tones — the alternating light/shadow facet look. A separate
|
||||||
|
// hairline layer per axis draws the thin edge glint at the facet borders.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING
|
||||||
|
// Equilateral geometry needs the tile height to be the width times sqrt(3). We
|
||||||
|
// use a 48x83px tile (48 * 1.732 = 83.1, rounded to 83) so the 60deg/120deg
|
||||||
|
// ramps close exactly on the tile box, and the horizontal edge family repeats on
|
||||||
|
// half-height (48x41.5 -> the 0deg hairline is sized to the full tile so its
|
||||||
|
// bands land on tile edges). Every facet-shade and edge layer shares this tile
|
||||||
|
// (or an exact multiple), and the 60/120 layers meet at the tile's mid columns,
|
||||||
|
// so triangles interlock across every seam with no drift. Wash and vignette are
|
||||||
|
// single non-repeating gradients at 100% 100%, so they never seam.
|
||||||
|
|
||||||
|
const dark: CSSProperties = {
|
||||||
|
// Deep navy base — the crystal sits on cool night stone.
|
||||||
|
backgroundColor: 'oklch(0.19 0.028 258)',
|
||||||
|
backgroundImage: [
|
||||||
|
// --- Facet shading: three cool-slate tonal ramps, one per triangle axis.
|
||||||
|
// Ascending-diagonal facets — a soft light band on one face family.
|
||||||
|
'linear-gradient(60deg,' +
|
||||||
|
' oklch(0.46 0.03 250 / 0.07) 0%, oklch(0.46 0.03 250 / 0.07) 50%,' +
|
||||||
|
' transparent 50%, transparent 100%)',
|
||||||
|
// Descending-diagonal facets — the shade family, closing the triangles.
|
||||||
|
'linear-gradient(120deg,' +
|
||||||
|
' oklch(0.34 0.03 255 / 0.06) 0%, oklch(0.34 0.03 255 / 0.06) 50%,' +
|
||||||
|
' transparent 50%, transparent 100%)',
|
||||||
|
// Horizontal facets — a third, fainter slate band so cells read three-sided.
|
||||||
|
'linear-gradient(0deg,' +
|
||||||
|
' oklch(0.42 0.028 248 / 0.045) 0%, oklch(0.42 0.028 248 / 0.045) 50%,' +
|
||||||
|
' transparent 50%, transparent 100%)',
|
||||||
|
// --- Mesh glint: hairline edges tracing the crystalline facet borders.
|
||||||
|
'linear-gradient(60deg, transparent 0, transparent calc(50% - 0.5px),' +
|
||||||
|
' oklch(0.62 0.035 250 / 0.10) calc(50% - 0.5px), oklch(0.62 0.035 250 / 0.10) 50%,' +
|
||||||
|
' transparent 50%)',
|
||||||
|
'linear-gradient(120deg, transparent 0, transparent calc(50% - 0.5px),' +
|
||||||
|
' oklch(0.62 0.035 250 / 0.10) calc(50% - 0.5px), oklch(0.62 0.035 250 / 0.10) 50%,' +
|
||||||
|
' transparent 50%)',
|
||||||
|
// --- Tonal wash — a gentle cool lift through the reading centre for depth.
|
||||||
|
'radial-gradient(ellipse 95% 80% at 50% 40%, oklch(0.28 0.03 255 / 0.45) 0%, transparent 62%)',
|
||||||
|
// --- Vignette — feather the corners into deeper navy.
|
||||||
|
'radial-gradient(ellipse 125% 130% at 50% 45%, transparent 58%, oklch(0.13 0.022 258 / 0.55) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '48px 83px, 48px 83px, 48px 83px, 48px 83px, 48px 83px, 100% 100%, 100% 100%',
|
||||||
|
// Offset the 120deg (shade) and its glint by half a tile so up/down facets
|
||||||
|
// interlock — this is what alternates the light/shadow triangles.
|
||||||
|
backgroundPosition: '0 0, 24px 0, 0 0, 0 0, 24px 0, 0 0, 0 0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const light: CSSProperties = {
|
||||||
|
// Pale ice-white base — cut glass on frosted paper.
|
||||||
|
backgroundColor: 'oklch(0.975 0.004 250)',
|
||||||
|
backgroundImage: [
|
||||||
|
// --- Facet shading: soft cool-grey tonal ramps, one per triangle axis.
|
||||||
|
// Ascending-diagonal facets — a barely-there shade on one face family.
|
||||||
|
'linear-gradient(60deg,' +
|
||||||
|
' oklch(0.66 0.022 252 / 0.09) 0%, oklch(0.66 0.022 252 / 0.09) 50%,' +
|
||||||
|
' transparent 50%, transparent 100%)',
|
||||||
|
// Descending-diagonal facets — a hair darker, closing the triangles.
|
||||||
|
'linear-gradient(120deg,' +
|
||||||
|
' oklch(0.58 0.024 255 / 0.08) 0%, oklch(0.58 0.024 255 / 0.08) 50%,' +
|
||||||
|
' transparent 50%, transparent 100%)',
|
||||||
|
// Horizontal facets — the third, faintest cool-grey band.
|
||||||
|
'linear-gradient(0deg,' +
|
||||||
|
' oklch(0.62 0.02 250 / 0.055) 0%, oklch(0.62 0.02 250 / 0.055) 50%,' +
|
||||||
|
' transparent 50%, transparent 100%)',
|
||||||
|
// --- Mesh glint: crisp hairline facet edges in cool slate.
|
||||||
|
'linear-gradient(60deg, transparent 0, transparent calc(50% - 0.5px),' +
|
||||||
|
' oklch(0.50 0.03 255 / 0.11) calc(50% - 0.5px), oklch(0.50 0.03 255 / 0.11) 50%,' +
|
||||||
|
' transparent 50%)',
|
||||||
|
'linear-gradient(120deg, transparent 0, transparent calc(50% - 0.5px),' +
|
||||||
|
' oklch(0.50 0.03 255 / 0.11) calc(50% - 0.5px), oklch(0.50 0.03 255 / 0.11) 50%,' +
|
||||||
|
' transparent 50%)',
|
||||||
|
// --- Tonal wash — a clean white highlight through the reading centre.
|
||||||
|
'radial-gradient(ellipse 95% 80% at 50% 40%, oklch(0.995 0.003 250 / 0.60) 0%, transparent 62%)',
|
||||||
|
// --- Vignette — settle the corners into a faint cool grey.
|
||||||
|
'radial-gradient(ellipse 125% 130% at 50% 45%, transparent 58%, oklch(0.90 0.012 252 / 0.42) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '48px 83px, 48px 83px, 48px 83px, 48px 83px, 48px 83px, 100% 100%, 100% 100%',
|
||||||
|
backgroundPosition: '0 0, 24px 0, 0 0, 0 0, 24px 0, 0 0, 0 0',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const triangles: ChatBgVariants = { dark, light };
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
// A chat background provides an independently-tuned CSSProperties per app theme:
|
||||||
|
// the `dark` variant is a subtle light-ish pattern on a dark base, the `light`
|
||||||
|
// variant a subtle dark-ish pattern on a light base. Each sits DIRECTLY behind
|
||||||
|
// the chat message list, so both must stay subtle enough to preserve WCAG-AA
|
||||||
|
// text legibility. Animated backgrounds include an `animation`; getChatBg strips
|
||||||
|
// it for prefers-reduced-motion / pause-animations, so the remaining properties
|
||||||
|
// must already read as a finished static background on their own.
|
||||||
|
export type ChatBgVariants = {
|
||||||
|
dark: CSSProperties;
|
||||||
|
light: CSSProperties;
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// waves — a serene, rhythmic ocean swell / sound-wave contour.
|
||||||
|
//
|
||||||
|
// The motif is three stacked sine contours — layered swell at slightly varied
|
||||||
|
// amplitude, weight and opacity — floating over a soft vertical depth wash so
|
||||||
|
// the field reads like gentle water or sculpted sand. It is tuned to be *felt,
|
||||||
|
// not read*: every stroke sits well under legibility thresholds so crisp
|
||||||
|
// message text stays comfortably WCAG-AA in both themes.
|
||||||
|
//
|
||||||
|
// TRUE SINE CURVES VIA INLINE SVG
|
||||||
|
// Gradients can't draw a real sine, so each wave is a polyline sampling of
|
||||||
|
// y = yc - amp*sin(2*pi*N*x/W), rendered as an inline SVG data-URI (fully
|
||||||
|
// URL-encoded, so it is CSP/Tauri-safe and needs no external asset). oklch()
|
||||||
|
// stroke colors give perceptually even, low-chroma lines.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING
|
||||||
|
// The SVG tile is 240x120 with EXACTLY N=2 whole periods across its 240px
|
||||||
|
// width, so the first and last sample of every wave share the same y — the
|
||||||
|
// horizontal repeat has no seam. All three contours live within y = 24..106,
|
||||||
|
// clear of the 0/120 tile edges, so the vertical repeat is seam-free too. To
|
||||||
|
// avoid a rigid stacked look, the same tile is layered a second time shifted by
|
||||||
|
// half a tile (120px x, 60px y) at lower opacity, weaving the rows into a
|
||||||
|
// continuous drifting swell. backgroundSize = 240px 120px keeps the SVG at its
|
||||||
|
// authored scale; the depth wash is a single 100% gradient sized to match.
|
||||||
|
|
||||||
|
const waveTileDark =
|
||||||
|
'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22240%22%20height%3D%22120%22%20viewBox%3D%220%200%20240%20120%22%3E%3Cg%20fill%3D%22none%22%20stroke-linecap%3D%22round%22%3E%3Cpath%20d%3D%22M0%2034%20L5%2031.41%20L10%2029%20L15%2026.93%20L20%2025.34%20L25%2024.34%20L30%2024%20L35%2024.34%20L40%2025.34%20L45%2026.93%20L50%2029%20L55%2031.41%20L60%2034%20L65%2036.59%20L70%2039%20L75%2041.07%20L80%2042.66%20L85%2043.66%20L90%2044%20L95%2043.66%20L100%2042.66%20L105%2041.07%20L110%2039%20L115%2036.59%20L120%2034%20L125%2031.41%20L130%2029%20L135%2026.93%20L140%2025.34%20L145%2024.34%20L150%2024%20L155%2024.34%20L160%2025.34%20L165%2026.93%20L170%2029%20L175%2031.41%20L180%2034%20L185%2036.59%20L190%2039%20L195%2041.07%20L200%2042.66%20L205%2043.66%20L210%2044%20L215%2043.66%20L220%2042.66%20L225%2041.07%20L230%2039%20L235%2036.59%20L240%2034%22%20stroke%3D%22oklch(0.65%200.08%20200%20%2F%200.16)%22%20stroke-width%3D%221.5%22%2F%3E%3Cpath%20d%3D%22M0%2064%20L5%2062.19%20L10%2060.5%20L15%2059.05%20L20%2057.94%20L25%2057.24%20L30%2057%20L35%2057.24%20L40%2057.94%20L45%2059.05%20L50%2060.5%20L55%2062.19%20L60%2064%20L65%2065.81%20L70%2067.5%20L75%2068.95%20L80%2070.06%20L85%2070.76%20L90%2071%20L95%2070.76%20L100%2070.06%20L105%2068.95%20L110%2067.5%20L115%2065.81%20L120%2064%20L125%2062.19%20L130%2060.5%20L135%2059.05%20L140%2057.94%20L145%2057.24%20L150%2057%20L155%2057.24%20L160%2057.94%20L165%2059.05%20L170%2060.5%20L175%2062.19%20L180%2064%20L185%2065.81%20L190%2067.5%20L195%2068.95%20L200%2070.06%20L205%2070.76%20L210%2071%20L215%2070.76%20L220%2070.06%20L225%2068.95%20L230%2067.5%20L235%2065.81%20L240%2064%22%20stroke%3D%22oklch(0.68%200.07%20195%20%2F%200.11)%22%20stroke-width%3D%221.2%22%2F%3E%3Cpath%20d%3D%22M0%2094%20L5%2090.89%20L10%2088%20L15%2085.51%20L20%2083.61%20L25%2082.41%20L30%2082%20L35%2082.41%20L40%2083.61%20L45%2085.51%20L50%2088%20L55%2090.89%20L60%2094%20L65%2097.11%20L70%20100%20L75%20102.49%20L80%20104.39%20L85%20105.59%20L90%20106%20L95%20105.59%20L100%20104.39%20L105%20102.49%20L110%20100%20L115%2097.11%20L120%2094%20L125%2090.89%20L130%2088%20L135%2085.51%20L140%2083.61%20L145%2082.41%20L150%2082%20L155%2082.41%20L160%2083.61%20L165%2085.51%20L170%2088%20L175%2090.89%20L180%2094%20L185%2097.11%20L190%20100%20L195%20102.49%20L200%20104.39%20L205%20105.59%20L210%20106%20L215%20105.59%20L220%20104.39%20L225%20102.49%20L230%20100%20L235%2097.11%20L240%2094%22%20stroke%3D%22oklch(0.62%200.075%20205%20%2F%200.14)%22%20stroke-width%3D%221.6%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
|
||||||
|
|
||||||
|
const waveTileLight =
|
||||||
|
'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22240%22%20height%3D%22120%22%20viewBox%3D%220%200%20240%20120%22%3E%3Cg%20fill%3D%22none%22%20stroke-linecap%3D%22round%22%3E%3Cpath%20d%3D%22M0%2034%20L5%2031.41%20L10%2029%20L15%2026.93%20L20%2025.34%20L25%2024.34%20L30%2024%20L35%2024.34%20L40%2025.34%20L45%2026.93%20L50%2029%20L55%2031.41%20L60%2034%20L65%2036.59%20L70%2039%20L75%2041.07%20L80%2042.66%20L85%2043.66%20L90%2044%20L95%2043.66%20L100%2042.66%20L105%2041.07%20L110%2039%20L115%2036.59%20L120%2034%20L125%2031.41%20L130%2029%20L135%2026.93%20L140%2025.34%20L145%2024.34%20L150%2024%20L155%2024.34%20L160%2025.34%20L165%2026.93%20L170%2029%20L175%2031.41%20L180%2034%20L185%2036.59%20L190%2039%20L195%2041.07%20L200%2042.66%20L205%2043.66%20L210%2044%20L215%2043.66%20L220%2042.66%20L225%2041.07%20L230%2039%20L235%2036.59%20L240%2034%22%20stroke%3D%22oklch(0.62%200.045%20235%20%2F%200.16)%22%20stroke-width%3D%221.5%22%2F%3E%3Cpath%20d%3D%22M0%2064%20L5%2062.19%20L10%2060.5%20L15%2059.05%20L20%2057.94%20L25%2057.24%20L30%2057%20L35%2057.24%20L40%2057.94%20L45%2059.05%20L50%2060.5%20L55%2062.19%20L60%2064%20L65%2065.81%20L70%2067.5%20L75%2068.95%20L80%2070.06%20L85%2070.76%20L90%2071%20L95%2070.76%20L100%2070.06%20L105%2068.95%20L110%2067.5%20L115%2065.81%20L120%2064%20L125%2062.19%20L130%2060.5%20L135%2059.05%20L140%2057.94%20L145%2057.24%20L150%2057%20L155%2057.24%20L160%2057.94%20L165%2059.05%20L170%2060.5%20L175%2062.19%20L180%2064%20L185%2065.81%20L190%2067.5%20L195%2068.95%20L200%2070.06%20L205%2070.76%20L210%2071%20L215%2070.76%20L220%2070.06%20L225%2068.95%20L230%2067.5%20L235%2065.81%20L240%2064%22%20stroke%3D%22oklch(0.66%200.04%20240%20%2F%200.11)%22%20stroke-width%3D%221.2%22%2F%3E%3Cpath%20d%3D%22M0%2094%20L5%2090.89%20L10%2088%20L15%2085.51%20L20%2083.61%20L25%2082.41%20L30%2082%20L35%2082.41%20L40%2083.61%20L45%2085.51%20L50%2088%20L55%2090.89%20L60%2094%20L65%2097.11%20L70%20100%20L75%20102.49%20L80%20104.39%20L85%20105.59%20L90%20106%20L95%20105.59%20L100%20104.39%20L105%20102.49%20L110%20100%20L115%2097.11%20L120%2094%20L125%2090.89%20L130%2088%20L135%2085.51%20L140%2083.61%20L145%2082.41%20L150%2082%20L155%2082.41%20L160%2083.61%20L165%2085.51%20L170%2088%20L175%2090.89%20L180%2094%20L185%2097.11%20L190%20100%20L195%20102.49%20L200%20104.39%20L205%20105.59%20L210%20106%20L215%20105.59%20L220%20104.39%20L225%20102.49%20L230%20100%20L235%2097.11%20L240%2094%22%20stroke%3D%22oklch(0.60%200.05%20230%20%2F%200.14)%22%20stroke-width%3D%221.6%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
|
||||||
|
|
||||||
|
const dark: CSSProperties = {
|
||||||
|
// Deep ink-blue base — the "water" the swell floats on.
|
||||||
|
backgroundColor: 'oklch(0.19 0.03 245)',
|
||||||
|
backgroundImage: [
|
||||||
|
// Primary swell — teal/aqua sine contours.
|
||||||
|
waveTileDark,
|
||||||
|
// Offset echo — same tile shifted half a period, dimmed, to weave rows.
|
||||||
|
waveTileDark,
|
||||||
|
// Depth wash — subtle lift toward the top, sink toward the bottom.
|
||||||
|
'linear-gradient(180deg, oklch(0.24 0.04 240 / 0.5) 0%, oklch(0.16 0.025 250 / 0.5) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '240px 120px, 240px 120px, 100% 100%',
|
||||||
|
backgroundPosition: '0 0, 120px 60px, 0 0',
|
||||||
|
// Dim the offset echo layer relative to the primary swell.
|
||||||
|
backgroundBlendMode: 'normal, soft-light, normal',
|
||||||
|
};
|
||||||
|
|
||||||
|
const light: CSSProperties = {
|
||||||
|
// Soft warm white base — like sunlit paper or pale sand.
|
||||||
|
backgroundColor: 'oklch(0.975 0.006 240)',
|
||||||
|
backgroundImage: [
|
||||||
|
// Primary swell — pale blue-grey sine contours.
|
||||||
|
waveTileLight,
|
||||||
|
// Offset echo — same tile shifted half a period, dimmed, to weave rows.
|
||||||
|
waveTileLight,
|
||||||
|
// Depth wash — faint cool tint feathering toward the bottom for calm depth.
|
||||||
|
'linear-gradient(180deg, oklch(0.99 0.004 240 / 0.5) 0%, oklch(0.95 0.01 245 / 0.5) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '240px 120px, 240px 120px, 100% 100%',
|
||||||
|
backgroundPosition: '0 0, 120px 60px, 0 0',
|
||||||
|
// Dim the offset echo layer relative to the primary swell.
|
||||||
|
backgroundBlendMode: 'normal, multiply, normal',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const waves: ChatBgVariants = { dark, light };
|
||||||
@@ -1,12 +1,24 @@
|
|||||||
import { CSSProperties } from 'react';
|
import { CSSProperties } from 'react';
|
||||||
import { ChatBackground } from '../../state/settings';
|
import { ChatBackground } from '../../state/settings';
|
||||||
import {
|
import { blueprint } from './backgrounds/blueprint';
|
||||||
animRainKeyframe,
|
import { stars } from './backgrounds/stars';
|
||||||
animStarsDriftKeyframe,
|
import { topographic } from './backgrounds/topographic';
|
||||||
animGridPulseKeyframe,
|
import { herringbone } from './backgrounds/herringbone';
|
||||||
animAuroraKeyframe,
|
import { crosshatch } from './backgrounds/crosshatch';
|
||||||
animFirefliesKeyframe,
|
import { chevron } from './backgrounds/chevron';
|
||||||
} from '../../styles/Animations.css';
|
import { polka } from './backgrounds/polka';
|
||||||
|
import { triangles } from './backgrounds/triangles';
|
||||||
|
import { plaid } from './backgrounds/plaid';
|
||||||
|
import { tactical } from './backgrounds/tactical';
|
||||||
|
import { circuit } from './backgrounds/circuit';
|
||||||
|
import { hexgrid } from './backgrounds/hexgrid';
|
||||||
|
import { waves } from './backgrounds/waves';
|
||||||
|
import { neon } from './backgrounds/neon';
|
||||||
|
import { animRain } from './backgrounds/animRain';
|
||||||
|
import { animStars } from './backgrounds/animStars';
|
||||||
|
import { animPulse } from './backgrounds/animPulse';
|
||||||
|
import { animAurora } from './backgrounds/animAurora';
|
||||||
|
import { animFireflies } from './backgrounds/animFireflies';
|
||||||
|
|
||||||
export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
|
export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
|
||||||
{ value: 'none', label: 'None' },
|
{ value: 'none', label: 'None' },
|
||||||
@@ -33,20 +45,14 @@ export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
|
|||||||
{ value: 'anim-fireflies', label: 'Fireflies' },
|
{ value: 'anim-fireflies', label: 'Fireflies' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// `none`, `carbon` and `aurora` stay inline: carbon + aurora are the kept user
|
||||||
|
// favorites, none is the empty layer. Every other background is a premium
|
||||||
|
// per-pattern module under ./backgrounds/ (each exposes a `dark` + `light`
|
||||||
|
// variant). Keeping the whole record here lets getChatBg stay the single entry
|
||||||
|
// point and preserves the Record<ChatBackground, ...> exhaustiveness check.
|
||||||
const DARK: Record<ChatBackground, CSSProperties> = {
|
const DARK: Record<ChatBackground, CSSProperties> = {
|
||||||
none: {},
|
none: {},
|
||||||
|
|
||||||
blueprint: {
|
|
||||||
backgroundColor: '#0a1628',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(100,149,237,0.14) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(100,149,237,0.14) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(rgba(100,149,237,0.05) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(100,149,237,0.05) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '80px 80px, 80px 80px, 16px 16px, 16px 16px',
|
|
||||||
},
|
|
||||||
|
|
||||||
carbon: {
|
carbon: {
|
||||||
backgroundColor: '#0e0e0e',
|
backgroundColor: '#0e0e0e',
|
||||||
backgroundImage: [
|
backgroundImage: [
|
||||||
@@ -55,138 +61,6 @@ const DARK: Record<ChatBackground, CSSProperties> = {
|
|||||||
].join(','),
|
].join(','),
|
||||||
backgroundSize: '8px 8px',
|
backgroundSize: '8px 8px',
|
||||||
},
|
},
|
||||||
|
|
||||||
stars: {
|
|
||||||
backgroundColor: '#050510',
|
|
||||||
backgroundImage: [
|
|
||||||
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)',
|
|
||||||
'radial-gradient(circle, rgba(255,255,255,0.55) 1px, transparent 1px)',
|
|
||||||
'radial-gradient(circle, rgba(200,200,255,0.3) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
|
|
||||||
backgroundPosition: '0 0, 65px 32px, 32px 97px',
|
|
||||||
},
|
|
||||||
|
|
||||||
topographic: {
|
|
||||||
backgroundColor: '#0f0f17',
|
|
||||||
backgroundImage: [
|
|
||||||
'repeating-radial-gradient(circle at 20% 20%, transparent 0, transparent 30px, rgba(152,0,0,0.07) 31px, transparent 32px)',
|
|
||||||
'repeating-radial-gradient(circle at 80% 80%, transparent 0, transparent 25px, rgba(100,100,200,0.06) 26px, transparent 27px)',
|
|
||||||
'repeating-radial-gradient(circle at 50% 10%, transparent 0, transparent 45px, rgba(152,0,0,0.04) 46px, transparent 47px)',
|
|
||||||
].join(','),
|
|
||||||
},
|
|
||||||
|
|
||||||
herringbone: {
|
|
||||||
backgroundColor: '#111118',
|
|
||||||
backgroundImage: [
|
|
||||||
'repeating-linear-gradient(60deg, rgba(180,160,210,0.08) 0, rgba(180,160,210,0.08) 1px, transparent 0, transparent 50%)',
|
|
||||||
'repeating-linear-gradient(120deg, rgba(180,160,210,0.08) 0, rgba(180,160,210,0.08) 1px, transparent 0, transparent 50%)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '20px 36px',
|
|
||||||
},
|
|
||||||
|
|
||||||
crosshatch: {
|
|
||||||
backgroundColor: '#0f0f0f',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(255,255,255,0.06) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(255,255,255,0.06) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(rgba(255,255,255,0.022) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(255,255,255,0.022) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Interlocking zigzag stripes
|
|
||||||
chevron: {
|
|
||||||
backgroundColor: '#0f0f17',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(135deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
|
|
||||||
'linear-gradient(225deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
|
|
||||||
'linear-gradient(315deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
|
|
||||||
'linear-gradient(45deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '20px 20px',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Even dot grid
|
|
||||||
polka: {
|
|
||||||
backgroundColor: '#0e0e14',
|
|
||||||
backgroundImage: 'radial-gradient(circle, rgba(255,255,255,0.2) 2px, transparent 2px)',
|
|
||||||
backgroundSize: '28px 28px',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Isometric triangle grid
|
|
||||||
triangles: {
|
|
||||||
backgroundColor: '#111118',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(60deg, rgba(100,149,237,0.09) 25%, transparent 25%, transparent 75%, rgba(100,149,237,0.09) 75%)',
|
|
||||||
'linear-gradient(120deg, rgba(100,149,237,0.09) 25%, transparent 25%, transparent 75%, rgba(100,149,237,0.09) 75%)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '40px 70px',
|
|
||||||
backgroundPosition: '0 0, 20px 35px',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Tartan-inspired crossing lines with accent colour
|
|
||||||
plaid: {
|
|
||||||
backgroundColor: '#0a1020',
|
|
||||||
backgroundImage: [
|
|
||||||
'repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(100,149,237,0.13) 39px, rgba(100,149,237,0.13) 40px)',
|
|
||||||
'repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(100,149,237,0.13) 39px, rgba(100,149,237,0.13) 40px)',
|
|
||||||
'repeating-linear-gradient(0deg, transparent, transparent 7px, rgba(152,0,0,0.08) 7px, rgba(152,0,0,0.08) 8px)',
|
|
||||||
'repeating-linear-gradient(90deg, transparent, transparent 7px, rgba(152,0,0,0.08) 7px, rgba(152,0,0,0.08) 8px)',
|
|
||||||
].join(','),
|
|
||||||
},
|
|
||||||
|
|
||||||
// LotusGuild TDS exact dot-grid
|
|
||||||
tactical: {
|
|
||||||
backgroundColor: '#030508',
|
|
||||||
backgroundImage: 'radial-gradient(circle, rgba(0,212,255,0.055) 1px, transparent 1px)',
|
|
||||||
backgroundSize: '28px 28px',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Circuit board — green grid with node dots
|
|
||||||
circuit: {
|
|
||||||
backgroundColor: '#040a04',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(0,255,136,0.045) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(0,255,136,0.045) 1px, transparent 1px)',
|
|
||||||
'radial-gradient(circle, rgba(0,255,136,0.20) 1.5px, transparent 1.5px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '40px 40px, 40px 40px, 40px 40px',
|
|
||||||
backgroundPosition: '0 0, 0 0, 20px 20px',
|
|
||||||
},
|
|
||||||
|
|
||||||
// True pointy-top hexagonal grid via SVG data URI
|
|
||||||
hexgrid: {
|
|
||||||
backgroundColor: '#060c14',
|
|
||||||
backgroundImage:
|
|
||||||
'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C212%2C255%2C0.13%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
|
|
||||||
backgroundSize: '29px 50px',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Flowing sine-wave lines
|
|
||||||
waves: {
|
|
||||||
backgroundColor: '#080c18',
|
|
||||||
backgroundImage: [
|
|
||||||
'repeating-radial-gradient(ellipse at 0% 50%, transparent 0, transparent 18px, rgba(80,130,255,0.07) 19px, transparent 20px)',
|
|
||||||
'repeating-radial-gradient(ellipse at 100% 50%, transparent 0, transparent 28px, rgba(80,130,255,0.05) 29px, transparent 30px)',
|
|
||||||
'repeating-radial-gradient(ellipse at 50% 0%, transparent 0, transparent 22px, rgba(100,60,200,0.06) 23px, transparent 24px)',
|
|
||||||
].join(','),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Neon cyberpunk grid — orange/cyan TDS colors
|
|
||||||
neon: {
|
|
||||||
backgroundColor: '#020408',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(255,107,0,0.10) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(255,107,0,0.10) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(rgba(0,212,255,0.05) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(0,212,255,0.05) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Aurora borealis — flowing gradient bands
|
|
||||||
aurora: {
|
aurora: {
|
||||||
backgroundColor: '#030810',
|
backgroundColor: '#030810',
|
||||||
backgroundImage: [
|
backgroundImage: [
|
||||||
@@ -197,86 +71,30 @@ const DARK: Record<ChatBackground, CSSProperties> = {
|
|||||||
].join(','),
|
].join(','),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Animated: Matrix digital rain — scrolling stripe columns + phosphor glow flicker
|
blueprint: blueprint.dark,
|
||||||
'anim-rain': {
|
stars: stars.dark,
|
||||||
backgroundColor: '#010804',
|
topographic: topographic.dark,
|
||||||
backgroundImage: [
|
herringbone: herringbone.dark,
|
||||||
'repeating-linear-gradient(180deg, rgba(0,255,136,0.16) 0px, rgba(0,255,136,0.16) 1px, transparent 1px, transparent 20px)',
|
crosshatch: crosshatch.dark,
|
||||||
'repeating-linear-gradient(180deg, rgba(0,255,136,0.07) 0px, rgba(0,255,136,0.07) 1px, transparent 1px, transparent 8px)',
|
chevron: chevron.dark,
|
||||||
].join(','),
|
polka: polka.dark,
|
||||||
backgroundSize: '40px 200px, 12px 200px',
|
triangles: triangles.dark,
|
||||||
backgroundPosition: '0 0, 0 0',
|
plaid: plaid.dark,
|
||||||
animation: `${animRainKeyframe} 8s linear infinite`,
|
tactical: tactical.dark,
|
||||||
},
|
circuit: circuit.dark,
|
||||||
|
hexgrid: hexgrid.dark,
|
||||||
// Animated: drifting star field — three seamlessly-tiling layers at different speeds
|
waves: waves.dark,
|
||||||
'anim-stars': {
|
neon: neon.dark,
|
||||||
backgroundColor: '#050510',
|
'anim-rain': animRain.dark,
|
||||||
backgroundImage: [
|
'anim-stars': animStars.dark,
|
||||||
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)',
|
'anim-pulse': animPulse.dark,
|
||||||
'radial-gradient(circle, rgba(200,220,255,0.55) 1px, transparent 1px)',
|
'anim-aurora': animAurora.dark,
|
||||||
'radial-gradient(circle, rgba(180,200,255,0.3) 1px, transparent 1px)',
|
'anim-fireflies': animFireflies.dark,
|
||||||
].join(','),
|
|
||||||
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
|
|
||||||
backgroundPosition: '0 0, 65px 32px, 32px 97px',
|
|
||||||
animation: `${animStarsDriftKeyframe} 30s linear infinite`,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Animated: neon grid pulse — size breathe + independent brightness oscillation
|
|
||||||
'anim-pulse': {
|
|
||||||
backgroundColor: '#030508',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(255,107,0,0.12) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(255,107,0,0.12) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(rgba(0,212,255,0.06) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(0,212,255,0.06) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
|
|
||||||
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite`,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Animated: aurora borealis — four bands each travel an independent path
|
|
||||||
'anim-aurora': {
|
|
||||||
backgroundColor: '#020a10',
|
|
||||||
backgroundImage: [
|
|
||||||
'radial-gradient(ellipse 80% 60% at 20% 30%, rgba(0,255,136,0.12) 0%, transparent 70%)',
|
|
||||||
'radial-gradient(ellipse 70% 50% at 80% 70%, rgba(0,100,255,0.12) 0%, transparent 70%)',
|
|
||||||
'radial-gradient(ellipse 90% 70% at 50% 10%, rgba(191,95,255,0.09) 0%, transparent 65%)',
|
|
||||||
'radial-gradient(ellipse 75% 55% at 60% 90%, rgba(0,212,255,0.09) 0%, transparent 65%)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '200% 200%, 250% 250%, 300% 300%, 220% 220%',
|
|
||||||
backgroundPosition: '0% 0%, 100% 0%, 50% 100%, 0% 50%',
|
|
||||||
animation: `${animAuroraKeyframe} 28s ease-in-out infinite`,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Animated: fireflies — drift + brightness glow + opacity blink at prime periods
|
|
||||||
'anim-fireflies': {
|
|
||||||
backgroundColor: '#030508',
|
|
||||||
backgroundImage: [
|
|
||||||
'radial-gradient(circle, rgba(255,220,50,0.7) 1.5px, rgba(255,160,0,0.18) 3px, transparent 4px)',
|
|
||||||
'radial-gradient(circle, rgba(255,200,30,0.55) 1px, rgba(255,140,0,0.14) 2.5px, transparent 3.5px)',
|
|
||||||
'radial-gradient(circle, rgba(255,240,100,0.4) 1px, transparent 2px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
|
|
||||||
backgroundPosition: '0 0, 120px 80px, 60px 140px',
|
|
||||||
animation: `${animFirefliesKeyframe} 30s linear infinite`,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const LIGHT: Record<ChatBackground, CSSProperties> = {
|
const LIGHT: Record<ChatBackground, CSSProperties> = {
|
||||||
none: {},
|
none: {},
|
||||||
|
|
||||||
blueprint: {
|
|
||||||
backgroundColor: '#eef3ff',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(50,100,220,0.16) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(50,100,220,0.16) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(rgba(50,100,220,0.06) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(50,100,220,0.06) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '80px 80px, 80px 80px, 16px 16px, 16px 16px',
|
|
||||||
},
|
|
||||||
|
|
||||||
carbon: {
|
carbon: {
|
||||||
backgroundColor: '#efefef',
|
backgroundColor: '#efefef',
|
||||||
backgroundImage: [
|
backgroundImage: [
|
||||||
@@ -285,129 +103,6 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
|
|||||||
].join(','),
|
].join(','),
|
||||||
backgroundSize: '8px 8px',
|
backgroundSize: '8px 8px',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Stars is intentionally always dark — it's a night-sky theme
|
|
||||||
stars: {
|
|
||||||
backgroundColor: '#050510',
|
|
||||||
backgroundImage: [
|
|
||||||
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)',
|
|
||||||
'radial-gradient(circle, rgba(255,255,255,0.55) 1px, transparent 1px)',
|
|
||||||
'radial-gradient(circle, rgba(200,200,255,0.3) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
|
|
||||||
backgroundPosition: '0 0, 65px 32px, 32px 97px',
|
|
||||||
},
|
|
||||||
|
|
||||||
topographic: {
|
|
||||||
backgroundColor: '#faf8f5',
|
|
||||||
backgroundImage: [
|
|
||||||
'repeating-radial-gradient(circle at 20% 20%, transparent 0, transparent 30px, rgba(100,60,60,0.09) 31px, transparent 32px)',
|
|
||||||
'repeating-radial-gradient(circle at 80% 80%, transparent 0, transparent 25px, rgba(60,60,130,0.07) 26px, transparent 27px)',
|
|
||||||
'repeating-radial-gradient(circle at 50% 10%, transparent 0, transparent 45px, rgba(100,60,60,0.05) 46px, transparent 47px)',
|
|
||||||
].join(','),
|
|
||||||
},
|
|
||||||
|
|
||||||
herringbone: {
|
|
||||||
backgroundColor: '#f9f9f9',
|
|
||||||
backgroundImage: [
|
|
||||||
'repeating-linear-gradient(60deg, rgba(80,70,110,0.09) 0, rgba(80,70,110,0.09) 1px, transparent 0, transparent 50%)',
|
|
||||||
'repeating-linear-gradient(120deg, rgba(80,70,110,0.09) 0, rgba(80,70,110,0.09) 1px, transparent 0, transparent 50%)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '20px 36px',
|
|
||||||
},
|
|
||||||
|
|
||||||
crosshatch: {
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(0,0,0,0.07) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(0,0,0,0.07) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(rgba(0,0,0,0.025) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(0,0,0,0.025) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
|
|
||||||
},
|
|
||||||
|
|
||||||
chevron: {
|
|
||||||
backgroundColor: '#f9f8ff',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(135deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
|
|
||||||
'linear-gradient(225deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
|
|
||||||
'linear-gradient(315deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
|
|
||||||
'linear-gradient(45deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '20px 20px',
|
|
||||||
},
|
|
||||||
|
|
||||||
polka: {
|
|
||||||
backgroundColor: '#fafafa',
|
|
||||||
backgroundImage: 'radial-gradient(circle, rgba(0,0,0,0.18) 2px, transparent 2px)',
|
|
||||||
backgroundSize: '28px 28px',
|
|
||||||
},
|
|
||||||
|
|
||||||
triangles: {
|
|
||||||
backgroundColor: '#f4f7ff',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(60deg, rgba(50,100,220,0.1) 25%, transparent 25%, transparent 75%, rgba(50,100,220,0.1) 75%)',
|
|
||||||
'linear-gradient(120deg, rgba(50,100,220,0.1) 25%, transparent 25%, transparent 75%, rgba(50,100,220,0.1) 75%)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '40px 70px',
|
|
||||||
backgroundPosition: '0 0, 20px 35px',
|
|
||||||
},
|
|
||||||
|
|
||||||
plaid: {
|
|
||||||
backgroundColor: '#f5f0ff',
|
|
||||||
backgroundImage: [
|
|
||||||
'repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(100,50,180,0.15) 39px, rgba(100,50,180,0.15) 40px)',
|
|
||||||
'repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(100,50,180,0.15) 39px, rgba(100,50,180,0.15) 40px)',
|
|
||||||
'repeating-linear-gradient(0deg, transparent, transparent 7px, rgba(200,0,0,0.09) 7px, rgba(200,0,0,0.09) 8px)',
|
|
||||||
'repeating-linear-gradient(90deg, transparent, transparent 7px, rgba(200,0,0,0.09) 7px, rgba(200,0,0,0.09) 8px)',
|
|
||||||
].join(','),
|
|
||||||
},
|
|
||||||
|
|
||||||
tactical: {
|
|
||||||
backgroundColor: '#f0f4fa',
|
|
||||||
backgroundImage: 'radial-gradient(circle, rgba(0,100,200,0.08) 1px, transparent 1px)',
|
|
||||||
backgroundSize: '28px 28px',
|
|
||||||
},
|
|
||||||
|
|
||||||
circuit: {
|
|
||||||
backgroundColor: '#f0f8f0',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(0,160,80,0.06) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(0,160,80,0.06) 1px, transparent 1px)',
|
|
||||||
'radial-gradient(circle, rgba(0,160,80,0.22) 1.5px, transparent 1.5px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '40px 40px, 40px 40px, 40px 40px',
|
|
||||||
backgroundPosition: '0 0, 0 0, 20px 20px',
|
|
||||||
},
|
|
||||||
|
|
||||||
hexgrid: {
|
|
||||||
backgroundColor: '#f4f8ff',
|
|
||||||
backgroundImage:
|
|
||||||
'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%2850%2C100%2C220%2C0.11%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
|
|
||||||
backgroundSize: '29px 50px',
|
|
||||||
},
|
|
||||||
|
|
||||||
waves: {
|
|
||||||
backgroundColor: '#eef3ff',
|
|
||||||
backgroundImage: [
|
|
||||||
'repeating-radial-gradient(ellipse at 0% 50%, transparent 0, transparent 18px, rgba(50,100,220,0.09) 19px, transparent 20px)',
|
|
||||||
'repeating-radial-gradient(ellipse at 100% 50%, transparent 0, transparent 28px, rgba(50,100,220,0.07) 29px, transparent 30px)',
|
|
||||||
'repeating-radial-gradient(ellipse at 50% 0%, transparent 0, transparent 22px, rgba(80,40,180,0.07) 23px, transparent 24px)',
|
|
||||||
].join(','),
|
|
||||||
},
|
|
||||||
|
|
||||||
neon: {
|
|
||||||
backgroundColor: '#fafafa',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(196,78,0,0.12) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(196,78,0,0.12) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(rgba(0,98,184,0.06) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(0,98,184,0.06) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
|
|
||||||
},
|
|
||||||
|
|
||||||
aurora: {
|
aurora: {
|
||||||
backgroundColor: '#f4faf8',
|
backgroundColor: '#f4faf8',
|
||||||
backgroundImage: [
|
backgroundImage: [
|
||||||
@@ -418,67 +113,25 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
|
|||||||
].join(','),
|
].join(','),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Animated light variants
|
blueprint: blueprint.light,
|
||||||
|
stars: stars.light,
|
||||||
'anim-rain': {
|
topographic: topographic.light,
|
||||||
backgroundColor: '#f0fff4',
|
herringbone: herringbone.light,
|
||||||
backgroundImage: [
|
crosshatch: crosshatch.light,
|
||||||
'repeating-linear-gradient(180deg, rgba(0,160,80,0.16) 0px, rgba(0,160,80,0.16) 1px, transparent 1px, transparent 20px)',
|
chevron: chevron.light,
|
||||||
'repeating-linear-gradient(180deg, rgba(0,160,80,0.07) 0px, rgba(0,160,80,0.07) 1px, transparent 1px, transparent 8px)',
|
polka: polka.light,
|
||||||
].join(','),
|
triangles: triangles.light,
|
||||||
backgroundSize: '40px 200px, 12px 200px',
|
plaid: plaid.light,
|
||||||
backgroundPosition: '0 0, 0 0',
|
tactical: tactical.light,
|
||||||
animation: `${animRainKeyframe} 8s linear infinite`,
|
circuit: circuit.light,
|
||||||
},
|
hexgrid: hexgrid.light,
|
||||||
|
waves: waves.light,
|
||||||
'anim-stars': {
|
neon: neon.light,
|
||||||
backgroundColor: '#f5f5ff',
|
'anim-rain': animRain.light,
|
||||||
backgroundImage: [
|
'anim-stars': animStars.light,
|
||||||
'radial-gradient(circle, rgba(60,60,160,0.50) 1px, transparent 1px)',
|
'anim-pulse': animPulse.light,
|
||||||
'radial-gradient(circle, rgba(80,80,180,0.35) 1px, transparent 1px)',
|
'anim-aurora': animAurora.light,
|
||||||
'radial-gradient(circle, rgba(100,100,200,0.20) 1px, transparent 1px)',
|
'anim-fireflies': animFireflies.light,
|
||||||
].join(','),
|
|
||||||
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
|
|
||||||
backgroundPosition: '0 0, 65px 32px, 32px 97px',
|
|
||||||
animation: `${animStarsDriftKeyframe} 30s linear infinite`,
|
|
||||||
},
|
|
||||||
|
|
||||||
'anim-pulse': {
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(0,98,184,0.14) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(0,98,184,0.14) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(rgba(0,98,184,0.06) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(0,98,184,0.06) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
|
|
||||||
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite`,
|
|
||||||
},
|
|
||||||
|
|
||||||
'anim-aurora': {
|
|
||||||
backgroundColor: '#f0f8f4',
|
|
||||||
backgroundImage: [
|
|
||||||
'radial-gradient(ellipse 80% 60% at 20% 30%, rgba(0,160,80,0.13) 0%, transparent 70%)',
|
|
||||||
'radial-gradient(ellipse 70% 50% at 80% 70%, rgba(0,80,200,0.13) 0%, transparent 70%)',
|
|
||||||
'radial-gradient(ellipse 90% 70% at 50% 10%, rgba(140,60,220,0.10) 0%, transparent 65%)',
|
|
||||||
'radial-gradient(ellipse 75% 55% at 60% 90%, rgba(0,160,200,0.10) 0%, transparent 65%)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '200% 200%, 250% 250%, 300% 300%, 220% 220%',
|
|
||||||
backgroundPosition: '0% 0%, 100% 0%, 50% 100%, 0% 50%',
|
|
||||||
animation: `${animAuroraKeyframe} 28s ease-in-out infinite`,
|
|
||||||
},
|
|
||||||
|
|
||||||
'anim-fireflies': {
|
|
||||||
backgroundColor: '#fffdf0',
|
|
||||||
backgroundImage: [
|
|
||||||
'radial-gradient(circle, rgba(180,120,0,0.70) 1.5px, rgba(160,90,0,0.18) 3px, transparent 4px)',
|
|
||||||
'radial-gradient(circle, rgba(160,100,0,0.55) 1px, rgba(140,80,0,0.14) 2.5px, transparent 3.5px)',
|
|
||||||
'radial-gradient(circle, rgba(200,140,0,0.40) 1px, transparent 2px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
|
|
||||||
backgroundPosition: '0 0, 120px 80px, 60px 140px',
|
|
||||||
animation: `${animFirefliesKeyframe} 30s linear infinite`,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getChatBg = (
|
export const getChatBg = (
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
Line,
|
Line,
|
||||||
toRem,
|
toRem,
|
||||||
Button,
|
Button,
|
||||||
|
Switch,
|
||||||
|
Chip,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
@@ -41,7 +43,9 @@ import {
|
|||||||
ResultGroup,
|
ResultGroup,
|
||||||
useMessageSearch,
|
useMessageSearch,
|
||||||
} from './useMessageSearch';
|
} from './useMessageSearch';
|
||||||
import { useLocalMessageSearch } from './useLocalMessageSearch';
|
import { LocalSearchResult, useLocalMessageSearch } from './useLocalMessageSearch';
|
||||||
|
import { searchCacheEnabledAtom } from '../../state/searchCacheEnabled';
|
||||||
|
import { clearAll as clearSearchCache } from '../../utils/searchCache';
|
||||||
import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches';
|
import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches';
|
||||||
import { SearchResultGroup } from './SearchResultGroup';
|
import { SearchResultGroup } from './SearchResultGroup';
|
||||||
import { SearchInput } from './SearchInput';
|
import { SearchInput } from './SearchInput';
|
||||||
@@ -240,6 +244,10 @@ export function MessageSearch({
|
|||||||
// Bump this whenever more messages are loaded so localResult re-computes
|
// Bump this whenever more messages are loaded so localResult re-computes
|
||||||
const [cacheVersion, setCacheVersion] = useState(0);
|
const [cacheVersion, setCacheVersion] = useState(0);
|
||||||
const handleCacheLoaded = useCallback(() => setCacheVersion((v) => v + 1), []);
|
const handleCacheLoaded = useCallback(() => setCacheVersion((v) => v + 1), []);
|
||||||
|
// Explicit wipe of the persistent on-disk index, then re-run the merge.
|
||||||
|
const handleClearSearchCache = useCallback(() => {
|
||||||
|
clearSearchCache().then(() => setCacheVersion((v) => v + 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// The rooms actually in scope for this search (mirrors server-side logic)
|
// The rooms actually in scope for this search (mirrors server-side logic)
|
||||||
const localSearchRooms = useMemo(
|
const localSearchRooms = useMemo(
|
||||||
@@ -253,24 +261,43 @@ export function MessageSearch({
|
|||||||
const hasActiveSearch = msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length;
|
const hasActiveSearch = msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length;
|
||||||
const senderOnlyMode = !msgSearchParams.term && !!msgSearchParams.senders?.length;
|
const senderOnlyMode = !msgSearchParams.term && !!msgSearchParams.senders?.length;
|
||||||
|
|
||||||
// Run synchronous client-side search immediately.
|
// Run the client-side search whenever inputs change.
|
||||||
// In text-search mode: covers encrypted rooms only (server handles plaintext).
|
// In text-search mode: covers encrypted rooms only (server handles plaintext).
|
||||||
// In sender-only mode: covers all rooms (server has no sender-only search).
|
// In sender-only mode: covers all rooms (server has no sender-only search).
|
||||||
// cacheVersion in deps so it re-runs after "Load more" paginates new events.
|
// The scan is async because — when the persistent cache is enabled — it also
|
||||||
const localResult = useMemo(() => {
|
// reads cached rows from IndexedDB and merges them with the in-memory hits.
|
||||||
if (!hasActiveSearch) return null;
|
// cacheVersion in deps so it re-runs after "Load more" paginates new events;
|
||||||
return searchLocalMessages({
|
// searchCacheEnabled so toggling the cache re-runs the merge.
|
||||||
|
const [searchCacheEnabled, setSearchCacheEnabled] = useAtom(searchCacheEnabledAtom);
|
||||||
|
const [localResult, setLocalResult] = useState<LocalSearchResult | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasActiveSearch) {
|
||||||
|
setLocalResult(null);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
searchLocalMessages({
|
||||||
term: msgSearchParams.term ?? '',
|
term: msgSearchParams.term ?? '',
|
||||||
roomIds: localSearchRooms,
|
roomIds: localSearchRooms,
|
||||||
senders: msgSearchParams.senders,
|
senders: msgSearchParams.senders,
|
||||||
|
fromTs: msgSearchParams.fromTs,
|
||||||
|
toTs: msgSearchParams.toTs,
|
||||||
|
}).then((result) => {
|
||||||
|
if (!cancelled) setLocalResult(result);
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [
|
}, [
|
||||||
searchLocalMessages,
|
searchLocalMessages,
|
||||||
localSearchRooms,
|
localSearchRooms,
|
||||||
msgSearchParams.term,
|
msgSearchParams.term,
|
||||||
msgSearchParams.senders,
|
msgSearchParams.senders,
|
||||||
|
msgSearchParams.fromTs,
|
||||||
|
msgSearchParams.toTs,
|
||||||
|
hasActiveSearch,
|
||||||
cacheVersion,
|
cacheVersion,
|
||||||
|
searchCacheEnabled,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
|
const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
|
||||||
@@ -668,6 +695,37 @@ export function MessageSearch({
|
|||||||
? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.`
|
? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.`
|
||||||
: `No matches in your local cache. Load messages below to search further back.`}
|
: `No matches in your local cache. Load messages below to search further back.`}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Box
|
||||||
|
alignItems="Center"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S200,
|
||||||
|
background: color.SurfaceVariant.Container,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={searchCacheEnabled}
|
||||||
|
onChange={setSearchCacheEnabled}
|
||||||
|
/>
|
||||||
|
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
|
||||||
|
<Text size="T300">Persist search index on this device</Text>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Stores decrypted text on this device
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{searchCacheEnabled && (
|
||||||
|
<Chip
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={handleClearSearchCache}
|
||||||
|
before={<Icon size="100" src={Icons.Delete} />}
|
||||||
|
>
|
||||||
|
<Text size="T200">Clear cached index</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
<Line size="300" variant="Surface" />
|
<Line size="300" variant="Surface" />
|
||||||
</Box>
|
</Box>
|
||||||
{localGroups.length > 0 && (
|
{localGroups.length > 0 && (
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { EventType } from 'matrix-js-sdk';
|
import { EventType, MatrixEvent } from 'matrix-js-sdk';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { ResultGroup, ResultItem } from './useMessageSearch';
|
import { ResultGroup, ResultItem } from './useMessageSearch';
|
||||||
|
import { searchCacheEnabledAtom } from '../../state/searchCacheEnabled';
|
||||||
|
import {
|
||||||
|
mergeSearchResults,
|
||||||
|
queryRoom,
|
||||||
|
saveRoomIndex,
|
||||||
|
SearchCacheRow,
|
||||||
|
} from '../../utils/searchCache';
|
||||||
|
|
||||||
export type LocalSearchParams = {
|
export type LocalSearchParams = {
|
||||||
term: string;
|
term: string;
|
||||||
roomIds: string[];
|
roomIds: string[];
|
||||||
senders?: string[];
|
senders?: string[];
|
||||||
|
/** Optional date-range filter (ms). Applied to both memory and cached rows. */
|
||||||
|
fromTs?: number;
|
||||||
|
toTs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LocalSearchResult = {
|
export type LocalSearchResult = {
|
||||||
@@ -17,19 +28,110 @@ export type LocalSearchResult = {
|
|||||||
searchedRoomsCount: number;
|
searchedRoomsCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Extracted, searchable plaintext for a single message event. */
|
||||||
|
type ExtractedText = {
|
||||||
|
body: string;
|
||||||
|
formattedBody: string;
|
||||||
|
pollText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const POLL_START_TYPES = ['m.poll.start', 'org.matrix.msc3381.poll.start'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the text we index/search from a decrypted event's content. Returns
|
||||||
|
* `null` for events that carry no searchable text (e.g. stickers).
|
||||||
|
*/
|
||||||
|
const extractText = (event: MatrixEvent): ExtractedText | null => {
|
||||||
|
const evType = event.getType();
|
||||||
|
const content = event.getContent();
|
||||||
|
|
||||||
|
if (POLL_START_TYPES.includes(evType)) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as any;
|
||||||
|
if (!poll) return null;
|
||||||
|
const qBody =
|
||||||
|
(poll.question?.['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
|
||||||
|
(poll.question?.body as string | undefined) ??
|
||||||
|
'';
|
||||||
|
const answerBodies = ((poll.answers ?? []) as Array<Record<string, unknown>>)
|
||||||
|
.map(
|
||||||
|
(a) =>
|
||||||
|
((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(a['org.matrix.msc3381.poll.answer'] as any)?.body ??
|
||||||
|
'') as string,
|
||||||
|
)
|
||||||
|
.join(' ');
|
||||||
|
const pollText = `${qBody} ${answerBodies}`.trim();
|
||||||
|
return pollText ? { body: '', formattedBody: '', pollText } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evType !== EventType.RoomMessage) return null;
|
||||||
|
|
||||||
|
const body = (content.body as string | undefined) ?? '';
|
||||||
|
const formattedBody = (content.formatted_body as string | undefined) ?? '';
|
||||||
|
if (!body && !formattedBody) return null;
|
||||||
|
return { body, formattedBody, pollText: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Does the extracted text contain the (already-lowercased) term? */
|
||||||
|
const matchesTerm = (text: ExtractedText, termLower: string): boolean =>
|
||||||
|
text.body.toLowerCase().includes(termLower) ||
|
||||||
|
text.formattedBody.toLowerCase().includes(termLower) ||
|
||||||
|
text.pollText.toLowerCase().includes(termLower);
|
||||||
|
|
||||||
|
const rowMatchesTerm = (row: SearchCacheRow, termLower: string): boolean =>
|
||||||
|
row.body.toLowerCase().includes(termLower) ||
|
||||||
|
(row.formattedBody ?? '').toLowerCase().includes(termLower) ||
|
||||||
|
(row.pollText ?? '').toLowerCase().includes(termLower);
|
||||||
|
|
||||||
|
/** Build the synthetic result item a cached row renders as (text message). */
|
||||||
|
const rowToResultItem = (row: SearchCacheRow): ResultItem => {
|
||||||
|
const bodyText = row.body || row.pollText || '';
|
||||||
|
const content: Record<string, unknown> = { msgtype: 'm.text', body: bodyText };
|
||||||
|
if (row.formattedBody) {
|
||||||
|
content.format = 'org.matrix.custom.html';
|
||||||
|
content.formatted_body = row.formattedBody;
|
||||||
|
}
|
||||||
|
const syntheticEvent = {
|
||||||
|
room_id: row.roomId,
|
||||||
|
event_id: row.eventId,
|
||||||
|
type: EventType.RoomMessage,
|
||||||
|
sender: row.sender,
|
||||||
|
origin_server_ts: row.ts,
|
||||||
|
content,
|
||||||
|
unsigned: {},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
rank: 0,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
event: syntheticEvent as any,
|
||||||
|
context: { events_before: [], events_after: [], profile_info: {} },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client-side full-text search over locally cached events in encrypted rooms.
|
* Client-side full-text search over locally cached events in encrypted rooms.
|
||||||
* The homeserver cannot search E2EE message content, so we scan whatever the
|
* The homeserver cannot search E2EE message content, so we scan whatever the
|
||||||
* client has already received and decrypted in memory.
|
* client has already received and decrypted in memory.
|
||||||
*
|
*
|
||||||
* Limitation: only messages present in the live timeline window are covered.
|
* When the persistent search cache is enabled (opt-in), the in-memory scan is
|
||||||
* Rooms that haven't been opened yet will return no results.
|
* also persisted to IndexedDB (fire-and-forget) and merged with prior cached
|
||||||
|
* coverage so results survive reloads. When disabled, zero cache reads/writes
|
||||||
|
* occur.
|
||||||
*/
|
*/
|
||||||
export const useLocalMessageSearch = () => {
|
export const useLocalMessageSearch = () => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const cacheEnabled = useAtomValue(searchCacheEnabledAtom);
|
||||||
|
|
||||||
const search = useCallback(
|
const search = useCallback(
|
||||||
({ term, roomIds, senders }: LocalSearchParams): LocalSearchResult => {
|
async ({
|
||||||
|
term,
|
||||||
|
roomIds,
|
||||||
|
senders,
|
||||||
|
fromTs,
|
||||||
|
toTs,
|
||||||
|
}: LocalSearchParams): Promise<LocalSearchResult> => {
|
||||||
const trimmedTerm = term.trim();
|
const trimmedTerm = term.trim();
|
||||||
const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
|
const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
|
||||||
|
|
||||||
@@ -41,6 +143,9 @@ export const useLocalMessageSearch = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const termLower = trimmedTerm.toLowerCase();
|
const termLower = trimmedTerm.toLowerCase();
|
||||||
|
const inRange = (ts: number): boolean =>
|
||||||
|
(fromTs === undefined || ts >= fromTs) && (toTs === undefined || ts <= toTs);
|
||||||
|
|
||||||
const groups: ResultGroup[] = [];
|
const groups: ResultGroup[] = [];
|
||||||
let encryptedRoomsCount = 0;
|
let encryptedRoomsCount = 0;
|
||||||
let searchedRoomsCount = 0;
|
let searchedRoomsCount = 0;
|
||||||
@@ -61,106 +166,99 @@ export const useLocalMessageSearch = () => {
|
|||||||
.getUnfilteredTimelineSet()
|
.getUnfilteredTimelineSet()
|
||||||
.getTimelines()
|
.getTimelines()
|
||||||
.flatMap((tl) => tl.getEvents());
|
.flatMap((tl) => tl.getEvents());
|
||||||
if (events.length === 0) continue;
|
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const cachedRows = cacheEnabled ? await queryRoom(roomId) : [];
|
||||||
|
|
||||||
|
if (events.length === 0 && cachedRows.length === 0) continue;
|
||||||
|
|
||||||
searchedRoomsCount += 1;
|
searchedRoomsCount += 1;
|
||||||
|
|
||||||
const items: ResultItem[] = [];
|
const memoryItems: ResultItem[] = [];
|
||||||
|
const rowsToPersist: SearchCacheRow[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < events.length; i += 1) {
|
for (let i = 0; i < events.length; i += 1) {
|
||||||
const event = events[i];
|
const event = events[i];
|
||||||
|
|
||||||
// In sender-only mode: include all message types; skip non-message events
|
|
||||||
if (event.getType() !== EventType.RoomMessage) {
|
|
||||||
if (senderOnlyMode) continue;
|
|
||||||
const evType = event.getType();
|
|
||||||
const isSticker = evType === 'm.sticker';
|
|
||||||
const isPoll = evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
|
|
||||||
if (!isSticker && !isPoll) continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.isDecryptionFailure()) continue;
|
if (event.isDecryptionFailure()) continue;
|
||||||
if (event.isRedacted()) continue;
|
if (event.isRedacted()) continue;
|
||||||
if (senderSet && !senderSet.has(event.getSender() ?? '')) continue;
|
|
||||||
|
|
||||||
// getContent() returns decrypted plaintext regardless of encryption
|
const evType = event.getType();
|
||||||
const content = event.getContent();
|
const isSticker = evType === 'm.sticker';
|
||||||
|
const isMessageLike =
|
||||||
|
evType === EventType.RoomMessage || POLL_START_TYPES.includes(evType);
|
||||||
|
|
||||||
// Sender-only mode: no text filter needed
|
// Sender-only mode indexes/returns all message types; text mode needs text.
|
||||||
if (!senderOnlyMode) {
|
if (!senderOnlyMode && !isMessageLike && !isSticker) continue;
|
||||||
const evType = event.getType();
|
|
||||||
const isPoll = evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
|
|
||||||
|
|
||||||
let body = '';
|
const sender = event.getSender() ?? '';
|
||||||
let formattedBody = '';
|
const ts = event.getTs();
|
||||||
if (!isPoll) {
|
const text = extractText(event);
|
||||||
body = (content.body as string | undefined) ?? '';
|
|
||||||
formattedBody = (content.formatted_body as string | undefined) ?? '';
|
|
||||||
} else {
|
|
||||||
// Poll — index question text and all answer options
|
|
||||||
const poll = (content['m.poll'] ??
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
content['org.matrix.msc3381.poll.start']) as any;
|
|
||||||
if (poll) {
|
|
||||||
const qBody =
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(poll.question?.['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
|
|
||||||
(poll.question?.body as string | undefined) ??
|
|
||||||
'';
|
|
||||||
const answerBodies = ((poll.answers ?? []) as Array<Record<string, unknown>>)
|
|
||||||
.map(
|
|
||||||
(a) =>
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(a['org.matrix.msc3381.poll.answer'] as any)?.body ??
|
|
||||||
'') as string,
|
|
||||||
)
|
|
||||||
.join(' ');
|
|
||||||
body = `${qBody} ${answerBodies}`.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
// Persist every indexable (text-bearing) event we scanned, regardless
|
||||||
!body.toLowerCase().includes(termLower) &&
|
// of whether it matches the current term — future searches benefit.
|
||||||
!formattedBody.toLowerCase().includes(termLower)
|
if (cacheEnabled && text && event.getId()) {
|
||||||
)
|
rowsToPersist.push({
|
||||||
continue;
|
roomId,
|
||||||
|
eventId: event.getId() as string,
|
||||||
|
ts,
|
||||||
|
sender,
|
||||||
|
body: text.body,
|
||||||
|
...(text.formattedBody ? { formattedBody: text.formattedBody } : {}),
|
||||||
|
...(text.pollText ? { pollText: text.pollText } : {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a synthetic IEventWithRoomId using decrypted content so the
|
if (senderSet && !senderSet.has(sender)) continue;
|
||||||
// existing SearchResultGroup renderer works without modification.
|
if (!inRange(ts)) continue;
|
||||||
|
|
||||||
|
if (!senderOnlyMode) {
|
||||||
|
if (!text || !matchesTerm(text, termLower)) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = event.getContent();
|
||||||
const syntheticEvent = {
|
const syntheticEvent = {
|
||||||
room_id: roomId,
|
room_id: roomId,
|
||||||
event_id: event.getId() ?? '',
|
event_id: event.getId() ?? '',
|
||||||
type: event.getType(),
|
type: evType,
|
||||||
sender: event.getSender() ?? '',
|
sender,
|
||||||
origin_server_ts: event.getTs(),
|
origin_server_ts: ts,
|
||||||
content,
|
content,
|
||||||
unsigned: event.getUnsigned(),
|
unsigned: event.getUnsigned(),
|
||||||
};
|
};
|
||||||
|
memoryItems.push({
|
||||||
items.push({
|
|
||||||
rank: 0,
|
rank: 0,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
event: syntheticEvent as any,
|
event: syntheticEvent as any,
|
||||||
context: {
|
context: { events_before: [], events_after: [], profile_info: {} },
|
||||||
events_before: [],
|
|
||||||
events_after: [],
|
|
||||||
profile_info: {},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Match cached rows (skip ids already present in memory happens in merge).
|
||||||
|
const cachedItems: ResultItem[] = [];
|
||||||
|
cachedRows.forEach((row) => {
|
||||||
|
if (senderSet && !senderSet.has(row.sender)) return;
|
||||||
|
if (!inRange(row.ts)) return;
|
||||||
|
if (!senderOnlyMode && !rowMatchesTerm(row, termLower)) return;
|
||||||
|
cachedItems.push(rowToResultItem(row));
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = mergeSearchResults(memoryItems, cachedItems);
|
||||||
|
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
items.sort((a, b) => (b.event.origin_server_ts ?? 0) - (a.event.origin_server_ts ?? 0));
|
|
||||||
groups.push({ roomId, items });
|
groups.push({ roomId, items });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fire-and-forget persist of freshly scanned rows + coverage.
|
||||||
|
// saveRoomIndex swallows all errors internally, so a floating promise
|
||||||
|
// here can never reject.
|
||||||
|
if (cacheEnabled && rowsToPersist.length > 0) {
|
||||||
|
saveRoomIndex(roomId, rowsToPersist);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { groups, encryptedRoomsCount, searchedRoomsCount };
|
return { groups, encryptedRoomsCount, searchedRoomsCount };
|
||||||
},
|
},
|
||||||
[mx],
|
[mx, cacheEnabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
return search;
|
return search;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
|||||||
import { General } from './general';
|
import { General } from './general';
|
||||||
import { Members } from '../common-settings/members';
|
import { Members } from '../common-settings/members';
|
||||||
import { EmojisStickers } from '../common-settings/emojis-stickers';
|
import { EmojisStickers } from '../common-settings/emojis-stickers';
|
||||||
|
import { Soundboard } from '../common-settings/soundboard';
|
||||||
import { Permissions } from './permissions';
|
import { Permissions } from './permissions';
|
||||||
import { RoomSettingsPage } from '../../state/roomSettings';
|
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
@@ -53,6 +54,11 @@ const BASE_MENU_ITEMS: RoomSettingsMenuItem[] = [
|
|||||||
name: 'Emojis & Stickers',
|
name: 'Emojis & Stickers',
|
||||||
icon: Icons.Smile,
|
icon: Icons.Smile,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
page: RoomSettingsPage.SoundboardPage,
|
||||||
|
name: 'Soundboard',
|
||||||
|
icon: Icons.Bell,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
page: RoomSettingsPage.DeveloperToolsPage,
|
page: RoomSettingsPage.DeveloperToolsPage,
|
||||||
name: 'Developer Tools',
|
name: 'Developer Tools',
|
||||||
@@ -226,6 +232,9 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
|
|||||||
{activePage === RoomSettingsPage.EmojisStickersPage && (
|
{activePage === RoomSettingsPage.EmojisStickersPage && (
|
||||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
<EmojisStickers requestClose={handlePageRequestClose} />
|
||||||
)}
|
)}
|
||||||
|
{activePage === RoomSettingsPage.SoundboardPage && (
|
||||||
|
<Soundboard requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
{activePage === RoomSettingsPage.DeveloperToolsPage && (
|
{activePage === RoomSettingsPage.DeveloperToolsPage && (
|
||||||
<DeveloperTools requestClose={handlePageRequestClose} />
|
<DeveloperTools requestClose={handlePageRequestClose} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
RoomLocalAddresses,
|
RoomLocalAddresses,
|
||||||
RoomPublishedAddresses,
|
RoomPublishedAddresses,
|
||||||
RoomPublish,
|
RoomPublish,
|
||||||
|
RoomQuality,
|
||||||
RoomShareInvite,
|
RoomShareInvite,
|
||||||
RoomUpgrade,
|
RoomUpgrade,
|
||||||
RoomVoiceLimit,
|
RoomVoiceLimit,
|
||||||
@@ -58,6 +59,7 @@ export function General({ requestClose }: GeneralProps) {
|
|||||||
<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} />
|
||||||
|
<RoomQuality permissions={permissions} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Addresses</Text>
|
<Text size="L400">Addresses</Text>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { keyframes, style } from '@vanilla-extract/css';
|
||||||
|
import { color, toRem } from 'folds';
|
||||||
|
|
||||||
|
// A brief, gentle acknowledgement when a draft first becomes persisted.
|
||||||
|
// Guarded by `prefers-reduced-motion` so it only plays for users who opt in.
|
||||||
|
const savedPulse = keyframes({
|
||||||
|
'0%': { opacity: 0.4, transform: 'scale(0.7)' },
|
||||||
|
'45%': { opacity: 1, transform: 'scale(1.15)' },
|
||||||
|
'100%': { opacity: 1, transform: 'scale(1)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DraftIndicatorBase = style({
|
||||||
|
userSelect: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DraftDot = style({
|
||||||
|
width: toRem(6),
|
||||||
|
height: toRem(6),
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: color.Success.Main,
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DraftDotPulse = style({
|
||||||
|
'@media': {
|
||||||
|
'(prefers-reduced-motion: no-preference)': {
|
||||||
|
animation: `${savedPulse} 600ms ease-out`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { Box, Text, config } from 'folds';
|
||||||
|
|
||||||
|
import { roomIdToMsgDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||||
|
import { toPlainText } from '../../components/editor';
|
||||||
|
import { DraftDot, DraftDotPulse, DraftIndicatorBase } from './DraftIndicator.css';
|
||||||
|
|
||||||
|
const PULSE_DURATION = 600;
|
||||||
|
|
||||||
|
type DraftIndicatorProps = {
|
||||||
|
roomId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subtle, non-distracting status shown near the composer when the current room
|
||||||
|
* has a persisted (unsent) message draft. It reacts to the shared draft atom
|
||||||
|
* (`roomIdToMsgDraftAtomFamily`) — the same source that backs the
|
||||||
|
* `draft-msg-${roomId}` localStorage persistence — so it never introduces a
|
||||||
|
* parallel persistence path.
|
||||||
|
*
|
||||||
|
* A short "Saved" pulse plays the moment a draft becomes persisted, then the
|
||||||
|
* indicator settles into a quiet, muted resting state. The pulse is gated behind
|
||||||
|
* `prefers-reduced-motion` in CSS, so motion-averse users only ever see the
|
||||||
|
* static label.
|
||||||
|
*/
|
||||||
|
export function DraftIndicator({ roomId }: DraftIndicatorProps) {
|
||||||
|
const draft = useAtomValue(roomIdToMsgDraftAtomFamily(roomId));
|
||||||
|
// Real content, not just an empty paragraph.
|
||||||
|
const hasDraft = toPlainText(draft, false).trim().length > 0;
|
||||||
|
|
||||||
|
const [pulse, setPulse] = useState(false);
|
||||||
|
const hadDraft = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasDraft && !hadDraft.current) {
|
||||||
|
hadDraft.current = true;
|
||||||
|
setPulse(true);
|
||||||
|
const timeout = setTimeout(() => setPulse(false), PULSE_DURATION);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
hadDraft.current = hasDraft;
|
||||||
|
return undefined;
|
||||||
|
}, [hasDraft]);
|
||||||
|
|
||||||
|
if (!hasDraft) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={DraftIndicatorBase}
|
||||||
|
as="span"
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="200"
|
||||||
|
style={{ padding: `0 ${config.space.S100}` }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<span className={`${DraftDot}${pulse ? ` ${DraftDotPulse}` : ''}`} />
|
||||||
|
<Text as="span" size="T200" priority="300">
|
||||||
|
Draft saved
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -186,8 +186,8 @@ function LightboxMedia({
|
|||||||
)}
|
)}
|
||||||
{media.status === 'ok' &&
|
{media.status === 'ok' &&
|
||||||
(item.msgtype === MsgType.Video ? (
|
(item.msgtype === MsgType.Video ? (
|
||||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
|
||||||
<video
|
<video
|
||||||
|
aria-label="Video attachment"
|
||||||
src={media.url}
|
src={media.url}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
@@ -261,7 +261,6 @@ function Lightbox({
|
|||||||
escapeDeactivates: false,
|
escapeDeactivates: false,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal
|
aria-modal
|
||||||
@@ -640,13 +639,15 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
className={classNames(css.MediaGalleryDrawer, ContainerColor({ variant: 'Background' }))}
|
className={classNames(css.MediaGalleryDrawer, ContainerColor({ variant: 'Background' }))}
|
||||||
shrink="No"
|
shrink="No"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
|
role="region"
|
||||||
|
aria-labelledby="media-gallery-title"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Header variant="Background" size="600" className={css.MediaGalleryHeader}>
|
<Header variant="Background" size="600" className={css.MediaGalleryHeader}>
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
<Icon size="200" src={Icons.Photo} />
|
<Icon size="200" src={Icons.Photo} />
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4" truncate>
|
<Text id="media-gallery-title" size="H4" truncate>
|
||||||
Media Gallery
|
Media Gallery
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -142,7 +142,6 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
|||||||
placeholder="Ask a question…"
|
placeholder="Ask a question…"
|
||||||
value={question}
|
value={question}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuestion(e.target.value)}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuestion(e.target.value)}
|
||||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -151,7 +150,6 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
|||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Text size="L400">Options</Text>
|
<Text size="L400">Options</Text>
|
||||||
{options.map((opt, index) => (
|
{options.map((opt, index) => (
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
|
||||||
<Box key={index} alignItems="Center" gap="200">
|
<Box key={index} alignItems="Center" gap="200">
|
||||||
<Input
|
<Input
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
import { Box, Line } from 'folds';
|
import { Box, Line } from 'folds';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
@@ -22,6 +22,8 @@ import { callChatAtom } from '../../state/callEmbed';
|
|||||||
import { CallChatView } from './CallChatView';
|
import { CallChatView } from './CallChatView';
|
||||||
import { useCallEmbed } from '../../hooks/useCallEmbed';
|
import { useCallEmbed } from '../../hooks/useCallEmbed';
|
||||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||||
|
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||||
|
import { ThreadPanel } from './thread';
|
||||||
|
|
||||||
export function Room() {
|
export function Room() {
|
||||||
const { eventId } = useParams();
|
const { eventId } = useParams();
|
||||||
@@ -33,6 +35,8 @@ export function Room() {
|
|||||||
const callEmbed = useCallEmbed();
|
const callEmbed = useCallEmbed();
|
||||||
|
|
||||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
|
const activeThreadId = useAtomValue(roomIdToActiveThreadIdAtomFamily(room.roomId));
|
||||||
|
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
|
||||||
const galleryOpen = useAtomValue(mediaGalleryAtom);
|
const galleryOpen = useAtomValue(mediaGalleryAtom);
|
||||||
const setGalleryOpen = useSetAtom(mediaGalleryAtom);
|
const setGalleryOpen = useSetAtom(mediaGalleryAtom);
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
@@ -45,15 +49,46 @@ export function Room() {
|
|||||||
useCallback(
|
useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
if (isKeyHotkey('escape', evt)) {
|
if (isKeyHotkey('escape', evt)) {
|
||||||
|
// Skip when a composer already consumed Escape (it preventDefaults).
|
||||||
|
if (evt.defaultPrevented) return;
|
||||||
|
// Skip while a thread panel is open: listener registration order
|
||||||
|
// means this can run BEFORE the panel's own Escape handler, and the
|
||||||
|
// user's intent there is "close the panel", not "mark room read".
|
||||||
|
if (activeThreadId) return;
|
||||||
markAsRead(mx, room.roomId, hideActivity);
|
markAsRead(mx, room.roomId, hideActivity);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx, room.roomId, hideActivity],
|
[mx, room.roomId, hideActivity, activeThreadId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
|
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
|
||||||
|
|
||||||
|
// Thread panel and media gallery are mutually exclusive on every screen size:
|
||||||
|
// opening one closes the other. Detect the just-opened transition so whichever
|
||||||
|
// was opened most recently wins.
|
||||||
|
const prevThreadRef = useRef(activeThreadId);
|
||||||
|
const prevGalleryRef = useRef(galleryOpen);
|
||||||
|
useEffect(() => {
|
||||||
|
const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current;
|
||||||
|
const galleryJustOpened = galleryOpen && !prevGalleryRef.current;
|
||||||
|
if (threadJustOpened && galleryOpen) {
|
||||||
|
setGalleryOpen(false);
|
||||||
|
} else if (galleryJustOpened && activeThreadId) {
|
||||||
|
setActiveThreadId(null);
|
||||||
|
}
|
||||||
|
prevThreadRef.current = activeThreadId;
|
||||||
|
prevGalleryRef.current = galleryOpen;
|
||||||
|
}, [activeThreadId, galleryOpen, setGalleryOpen, setActiveThreadId]);
|
||||||
|
|
||||||
|
// On non-desktop screens at most one right-side panel may show, priority
|
||||||
|
// thread > gallery > members. On desktop thread + members may coexist while
|
||||||
|
// thread + gallery stay mutually exclusive (via the effect above).
|
||||||
|
const isDesktop = screenSize === ScreenSize.Desktop;
|
||||||
|
const showThreadPanel = !callView && Boolean(activeThreadId);
|
||||||
|
const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId);
|
||||||
|
const showMembers = !callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PowerLevelsContextProvider value={powerLevels}>
|
<PowerLevelsContextProvider value={powerLevels}>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
@@ -82,7 +117,7 @@ export function Room() {
|
|||||||
<CallChatView />
|
<CallChatView />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!callView && galleryOpen && (
|
{showGallery && (
|
||||||
<>
|
<>
|
||||||
{screenSize === ScreenSize.Desktop && (
|
{screenSize === ScreenSize.Desktop && (
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
@@ -90,7 +125,20 @@ export function Room() {
|
|||||||
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
|
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!callView && isDrawer && (
|
{showThreadPanel && activeThreadId && (
|
||||||
|
<>
|
||||||
|
{screenSize === ScreenSize.Desktop && (
|
||||||
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
|
)}
|
||||||
|
<ThreadPanel
|
||||||
|
key={`${room.roomId}${activeThreadId}`}
|
||||||
|
room={room}
|
||||||
|
threadId={activeThreadId}
|
||||||
|
requestClose={() => setActiveThreadId(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showMembers && (
|
||||||
<>
|
<>
|
||||||
{screenSize === ScreenSize.Desktop && (
|
{screenSize === ScreenSize.Desktop && (
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
|
|||||||
+316
-210
@@ -1,9 +1,11 @@
|
|||||||
import React, {
|
import React, {
|
||||||
KeyboardEventHandler,
|
KeyboardEventHandler,
|
||||||
|
ReactNode,
|
||||||
RefObject,
|
RefObject,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
@@ -98,7 +100,11 @@ import { safeFile } from '../../utils/mimeTypes';
|
|||||||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import {
|
||||||
|
ComposerToolbarButtonKey,
|
||||||
|
normalizeComposerToolbarOrder,
|
||||||
|
settingsAtom,
|
||||||
|
} from '../../state/settings';
|
||||||
import {
|
import {
|
||||||
getAudioMsgContent,
|
getAudioMsgContent,
|
||||||
getFileMsgContent,
|
getFileMsgContent,
|
||||||
@@ -128,7 +134,9 @@ import { PollCreator } from './PollCreator';
|
|||||||
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
|
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
|
||||||
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||||
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
|
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
|
||||||
|
import { DraftIndicator } from './DraftIndicator';
|
||||||
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
||||||
|
import { getThreadDraftKey } from '../../state/room/thread';
|
||||||
|
|
||||||
const GifPicker = React.lazy(() =>
|
const GifPicker = React.lazy(() =>
|
||||||
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
||||||
@@ -142,9 +150,10 @@ interface RoomInputProps {
|
|||||||
fileDropContainerRef: RefObject<HTMLElement>;
|
fileDropContainerRef: RefObject<HTMLElement>;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
room: Room;
|
room: Room;
|
||||||
|
threadRootId?: string;
|
||||||
}
|
}
|
||||||
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
({ editor, fileDropContainerRef, roomId, room }, ref) => {
|
({ editor, fileDropContainerRef, roomId, room, threadRootId }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
@@ -177,8 +186,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
|
const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
|
||||||
|
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
|
// Scope drafts/replies/uploads by thread so a thread composer stays fully
|
||||||
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
|
// isolated from the main room composer (and from other threads).
|
||||||
|
const draftKey = threadRootId ? getThreadDraftKey(roomId, threadRootId) : roomId;
|
||||||
|
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey));
|
||||||
|
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey));
|
||||||
const replyUserID = replyDraft?.userId;
|
const replyUserID = replyDraft?.userId;
|
||||||
|
|
||||||
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
||||||
@@ -199,7 +211,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor;
|
legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor;
|
||||||
|
|
||||||
const [uploadBoard, setUploadBoard] = useState(true);
|
const [uploadBoard, setUploadBoard] = useState(true);
|
||||||
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
|
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey));
|
||||||
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
|
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
|
||||||
roomUploadAtomFamily,
|
roomUploadAtomFamily,
|
||||||
selectedFiles.map((f) => f.file),
|
selectedFiles.map((f) => f.file),
|
||||||
@@ -218,7 +230,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
||||||
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
||||||
const showVoice = composerToolbarButtons?.showVoice ?? true;
|
const showVoice = composerToolbarButtons?.showVoice ?? true;
|
||||||
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
|
// Schedule-send is hidden in thread mode (v1 reduction).
|
||||||
|
const showSchedule = (composerToolbarButtons?.showSchedule ?? true) && !threadRootId;
|
||||||
|
const composerButtonOrder = useMemo(
|
||||||
|
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
|
||||||
|
[composerToolbarButtons?.order],
|
||||||
|
);
|
||||||
const [locating, setLocating] = React.useState(false);
|
const [locating, setLocating] = React.useState(false);
|
||||||
const [locationError, setLocationError] = React.useState<string | null>(null);
|
const [locationError, setLocationError] = React.useState<string | null>(null);
|
||||||
const handleShareLocation = useCallback(() => {
|
const handleShareLocation = useCallback(() => {
|
||||||
@@ -233,7 +250,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
setLocating(false);
|
setLocating(false);
|
||||||
const { latitude, longitude } = pos.coords;
|
const { latitude, longitude } = pos.coords;
|
||||||
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
|
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
|
||||||
mx.sendMessage(roomId, {
|
mx.sendMessage(roomId, threadRootId ?? null, {
|
||||||
msgtype: 'm.location',
|
msgtype: 'm.location',
|
||||||
body: `Location: ${geoUri}`,
|
body: `Location: ${geoUri}`,
|
||||||
geo_uri: geoUri,
|
geo_uri: geoUri,
|
||||||
@@ -252,7 +269,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
},
|
},
|
||||||
{ timeout: 10000 },
|
{ timeout: 10000 },
|
||||||
);
|
);
|
||||||
}, [mx, roomId]);
|
}, [mx, roomId, threadRootId]);
|
||||||
|
|
||||||
const handleVoiceSend = useCallback(
|
const handleVoiceSend = useCallback(
|
||||||
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
|
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
|
||||||
@@ -268,7 +285,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
if (room.hasEncryptionStateEvent()) {
|
if (room.hasEncryptionStateEvent()) {
|
||||||
const { encInfo, file: encBlob } = await encryptFile(blob);
|
const { encInfo, file: encBlob } = await encryptFile(blob);
|
||||||
const uploadResult = await mx.uploadContent(encBlob);
|
const uploadResult = await mx.uploadContent(encBlob);
|
||||||
mx.sendMessage(roomId, {
|
mx.sendMessage(roomId, threadRootId ?? null, {
|
||||||
...baseContent,
|
...baseContent,
|
||||||
file: { ...encInfo, url: uploadResult.content_uri },
|
file: { ...encInfo, url: uploadResult.content_uri },
|
||||||
} as any);
|
} as any);
|
||||||
@@ -277,13 +294,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
name: 'voice-message.ogg',
|
name: 'voice-message.ogg',
|
||||||
type: mimeType,
|
type: mimeType,
|
||||||
});
|
});
|
||||||
mx.sendMessage(roomId, {
|
mx.sendMessage(roomId, threadRootId ?? null, {
|
||||||
...baseContent,
|
...baseContent,
|
||||||
url: uploadResult.content_uri,
|
url: uploadResult.content_uri,
|
||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx, room, roomId],
|
[mx, room, roomId, threadRootId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [autocompleteQuery, setAutocompleteQuery] =
|
const [autocompleteQuery, setAutocompleteQuery] =
|
||||||
@@ -353,33 +370,37 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
} else {
|
} else {
|
||||||
// Jotai draft is empty (page reload) — try localStorage fallback
|
// Jotai draft is empty (page reload) — try localStorage fallback
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(`draft-msg-${roomId}`);
|
const stored = localStorage.getItem(`draft-msg-${draftKey}`);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const nodes = JSON.parse(stored);
|
const nodes = JSON.parse(stored);
|
||||||
if (Array.isArray(nodes) && nodes.length > 0) {
|
if (Array.isArray(nodes) && nodes.length > 0) {
|
||||||
Transforms.insertFragment(editor, nodes);
|
Transforms.insertFragment(editor, nodes);
|
||||||
|
// Mirror the restored draft into the atom so the draft indicator
|
||||||
|
// (reads roomIdToMsgDraftAtomFamily) reflects a persisted draft
|
||||||
|
// after a page reload — not only on same-session room re-entry.
|
||||||
|
setMsgDraft(nodes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore malformed stored draft
|
// Ignore malformed stored draft
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [editor, msgDraft, roomId]);
|
}, [editor, msgDraft, draftKey, setMsgDraft]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
if (!isEmptyEditor(editor)) {
|
if (!isEmptyEditor(editor)) {
|
||||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||||
setMsgDraft(parsedDraft);
|
setMsgDraft(parsedDraft);
|
||||||
localStorage.setItem(`draft-msg-${roomId}`, JSON.stringify(parsedDraft));
|
localStorage.setItem(`draft-msg-${draftKey}`, JSON.stringify(parsedDraft));
|
||||||
} else {
|
} else {
|
||||||
setMsgDraft([]);
|
setMsgDraft([]);
|
||||||
localStorage.removeItem(`draft-msg-${roomId}`);
|
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||||
}
|
}
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
},
|
},
|
||||||
[roomId, editor, setMsgDraft],
|
[draftKey, editor, setMsgDraft],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFileMetadata = useCallback(
|
const handleFileMetadata = useCallback(
|
||||||
@@ -472,15 +493,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
});
|
});
|
||||||
handleCancelUpload(uploads);
|
handleCancelUpload(uploads);
|
||||||
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
||||||
contents.forEach((content) => mx.sendMessage(roomId, content as any));
|
contents.forEach((content) => mx.sendMessage(roomId, threadRootId ?? null, content as any));
|
||||||
},
|
},
|
||||||
[mx, roomId, selectedFiles, handleCancelUpload],
|
[mx, roomId, threadRootId, selectedFiles, handleCancelUpload],
|
||||||
);
|
);
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
const submit = useCallback(() => {
|
||||||
uploadBoardHandlers.current?.handleSend();
|
uploadBoardHandlers.current?.handleSend();
|
||||||
|
|
||||||
const commandName = getBeginCommand(editor);
|
// Slash-command interpretation is disabled in thread mode (v1): "/foo"
|
||||||
|
// sends literally rather than being parsed as a command.
|
||||||
|
const commandName = threadRootId ? undefined : getBeginCommand(editor);
|
||||||
let plainText = toPlainText(editor.children, isMarkdown).trim();
|
let plainText = toPlainText(editor.children, isMarkdown).trim();
|
||||||
let customHtml = trimCustomHtml(
|
let customHtml = trimCustomHtml(
|
||||||
toMatrixCustomHTML(editor.children, {
|
toMatrixCustomHTML(editor.children, {
|
||||||
@@ -553,13 +576,24 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
content['m.relates_to'].is_falling_back = false;
|
content['m.relates_to'].is_falling_back = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mx.sendMessage(roomId, content as any);
|
mx.sendMessage(roomId, threadRootId ?? null, content as any);
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
localStorage.removeItem(`draft-msg-${roomId}`);
|
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
|
}, [
|
||||||
|
mx,
|
||||||
|
roomId,
|
||||||
|
threadRootId,
|
||||||
|
draftKey,
|
||||||
|
editor,
|
||||||
|
replyDraft,
|
||||||
|
sendTypingStatus,
|
||||||
|
setReplyDraft,
|
||||||
|
isMarkdown,
|
||||||
|
commands,
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a text message content object from the current editor state.
|
* Build a text message content object from the current editor state.
|
||||||
@@ -628,11 +662,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
});
|
});
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
localStorage.removeItem(`draft-msg-${roomId}`);
|
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
},
|
},
|
||||||
[setScheduledMessages, roomId, editor, setReplyDraft, sendTypingStatus],
|
[setScheduledMessages, roomId, draftKey, editor, setReplyDraft, sendTypingStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||||
@@ -645,15 +679,23 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
submit();
|
submit();
|
||||||
}
|
}
|
||||||
if (isKeyHotkey('escape', evt)) {
|
if (isKeyHotkey('escape', evt)) {
|
||||||
evt.preventDefault();
|
// Only consume Escape (and stop it bubbling to the thread panel / room
|
||||||
|
// window handlers) when the composer actually has something to dismiss.
|
||||||
|
// If we did nothing, let Escape propagate so those handlers can run.
|
||||||
if (autocompleteQuery) {
|
if (autocompleteQuery) {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
setAutocompleteQuery(undefined);
|
setAutocompleteQuery(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setReplyDraft(undefined);
|
if (replyDraft) {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
setReplyDraft(undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing],
|
[submit, replyDraft, setReplyDraft, enterForNewline, autocompleteQuery, isComposing],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyUp: KeyboardEventHandler = useCallback(
|
const handleKeyUp: KeyboardEventHandler = useCallback(
|
||||||
@@ -727,7 +769,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
);
|
);
|
||||||
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
|
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
|
||||||
if (!mxcUrl) return;
|
if (!mxcUrl) return;
|
||||||
mx.sendMessage(roomId, {
|
mx.sendMessage(roomId, threadRootId ?? null, {
|
||||||
msgtype: MsgType.Image,
|
msgtype: MsgType.Image,
|
||||||
body: 'image.gif',
|
body: 'image.gif',
|
||||||
url: mxcUrl,
|
url: mxcUrl,
|
||||||
@@ -742,7 +784,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
if (alive()) setGifUploading(false);
|
if (alive()) setGifUploading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx, roomId, alive],
|
[mx, roomId, threadRootId, alive],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStickerSelect = useCallback(
|
const handleStickerSelect = useCallback(
|
||||||
@@ -755,13 +797,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
await getImageUrlBlob(stickerUrl),
|
await getImageUrlBlob(stickerUrl),
|
||||||
);
|
);
|
||||||
|
|
||||||
mx.sendEvent(roomId, EventType.Sticker, {
|
mx.sendEvent(roomId, threadRootId ?? null, EventType.Sticker, {
|
||||||
body: label,
|
body: label,
|
||||||
url: mxc,
|
url: mxc,
|
||||||
info,
|
info,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[mx, roomId, useAuthentication],
|
[mx, roomId, threadRootId, useAuthentication],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (room.getType() === 'm.server_notice') {
|
if (room.getType() === 'm.server_notice') {
|
||||||
@@ -954,59 +996,33 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
<Icon src={Icons.PlusCircle} />
|
<Icon src={Icons.PlusCircle} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
}
|
}
|
||||||
after={
|
after={(() => {
|
||||||
<>
|
const formatButton = showFormat ? (
|
||||||
{showFormat && (
|
<IconButton
|
||||||
<IconButton
|
key="showFormat"
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
style={touchTarget}
|
style={touchTarget}
|
||||||
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
|
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
|
||||||
aria-pressed={toolbar}
|
aria-pressed={toolbar}
|
||||||
onClick={() => setToolbar(!toolbar)}
|
onClick={() => setToolbar(!toolbar)}
|
||||||
>
|
>
|
||||||
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
) : null;
|
||||||
{(showEmoji || showSticker) && (
|
|
||||||
<UseStateProvider initial={undefined}>
|
// Emoji and Sticker share a single EmojiBoard PopOut anchored to the
|
||||||
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
// emoji button, so they are rendered together as one unit. Their
|
||||||
<PopOut
|
// relative order still follows the saved order.
|
||||||
offset={16}
|
const emojiStickerBlock =
|
||||||
alignOffset={-44}
|
showEmoji || showSticker ? (
|
||||||
position="Top"
|
<UseStateProvider key="showEmojiSticker" initial={undefined}>
|
||||||
align="End"
|
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => {
|
||||||
anchor={
|
const stickerBtn =
|
||||||
emojiBoardTab === undefined
|
showSticker && !hideStickerBtn ? (
|
||||||
? undefined
|
|
||||||
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
|
|
||||||
}
|
|
||||||
content={
|
|
||||||
<React.Suspense fallback={null}>
|
|
||||||
<EmojiBoard
|
|
||||||
tab={emojiBoardTab}
|
|
||||||
onTabChange={setEmojiBoardTab}
|
|
||||||
imagePackRooms={imagePackRooms}
|
|
||||||
returnFocusOnDeactivate={false}
|
|
||||||
onEmojiSelect={handleEmoticonSelect}
|
|
||||||
onCustomEmojiSelect={handleEmoticonSelect}
|
|
||||||
onStickerSelect={handleStickerSelect}
|
|
||||||
requestClose={() => {
|
|
||||||
setEmojiBoardTab((t) => {
|
|
||||||
if (t) {
|
|
||||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return t;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</React.Suspense>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{showSticker && !hideStickerBtn && (
|
|
||||||
<IconButton
|
<IconButton
|
||||||
|
key="showSticker"
|
||||||
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||||
aria-label="Insert sticker"
|
aria-label="Insert sticker"
|
||||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
|
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
|
||||||
@@ -1020,36 +1036,76 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
filled={emojiBoardTab === EmojiBoardTab.Sticker}
|
filled={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
) : null;
|
||||||
{showEmoji && (
|
const emojiBtn = showEmoji ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={emojiBtnRef}
|
key="showEmoji"
|
||||||
aria-label="Insert emoji"
|
ref={emojiBtnRef}
|
||||||
aria-pressed={
|
aria-label="Insert emoji"
|
||||||
|
aria-pressed={
|
||||||
|
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||||||
|
}
|
||||||
|
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
style={touchTarget}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
src={Icons.Smile}
|
||||||
|
filled={
|
||||||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||||||
}
|
}
|
||||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
|
/>
|
||||||
variant="SurfaceVariant"
|
</IconButton>
|
||||||
size="300"
|
) : null;
|
||||||
radii="300"
|
const emojiFirst =
|
||||||
style={touchTarget}
|
composerButtonOrder.indexOf('showEmoji') <
|
||||||
>
|
composerButtonOrder.indexOf('showSticker');
|
||||||
<Icon
|
return (
|
||||||
src={Icons.Smile}
|
<PopOut
|
||||||
filled={
|
offset={16}
|
||||||
hideStickerBtn
|
alignOffset={-44}
|
||||||
? !!emojiBoardTab
|
position="Top"
|
||||||
: emojiBoardTab === EmojiBoardTab.Emoji
|
align="End"
|
||||||
}
|
anchor={
|
||||||
/>
|
emojiBoardTab === undefined
|
||||||
</IconButton>
|
? undefined
|
||||||
)}
|
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
|
||||||
</PopOut>
|
}
|
||||||
)}
|
content={
|
||||||
|
<React.Suspense fallback={null}>
|
||||||
|
<EmojiBoard
|
||||||
|
tab={emojiBoardTab}
|
||||||
|
onTabChange={setEmojiBoardTab}
|
||||||
|
imagePackRooms={imagePackRooms}
|
||||||
|
returnFocusOnDeactivate={false}
|
||||||
|
onEmojiSelect={handleEmoticonSelect}
|
||||||
|
onCustomEmojiSelect={handleEmoticonSelect}
|
||||||
|
onStickerSelect={handleStickerSelect}
|
||||||
|
requestClose={() => {
|
||||||
|
setEmojiBoardTab((t) => {
|
||||||
|
if (t) {
|
||||||
|
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</React.Suspense>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{emojiFirst ? [emojiBtn, stickerBtn] : [stickerBtn, emojiBtn]}
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</UseStateProvider>
|
</UseStateProvider>
|
||||||
)}
|
) : null;
|
||||||
{!!gifApiKey && showGif && (
|
|
||||||
<UseStateProvider initial={false}>
|
const gifButton =
|
||||||
|
!!gifApiKey && showGif ? (
|
||||||
|
<UseStateProvider key="showGif" initial={false}>
|
||||||
{(gifOpen: boolean, setGifOpen) => (
|
{(gifOpen: boolean, setGifOpen) => (
|
||||||
<PopOut
|
<PopOut
|
||||||
offset={16}
|
offset={16}
|
||||||
@@ -1101,113 +1157,163 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
</PopOut>
|
</PopOut>
|
||||||
)}
|
)}
|
||||||
</UseStateProvider>
|
</UseStateProvider>
|
||||||
)}
|
) : null;
|
||||||
{gifError && (
|
|
||||||
<Text
|
const locationButton = showLocation ? (
|
||||||
size="T200"
|
|
||||||
style={{
|
|
||||||
color: color.Critical.Main,
|
|
||||||
padding: '2px 6px',
|
|
||||||
alignSelf: 'center',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{gifError}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{locationError && (
|
|
||||||
<Text
|
|
||||||
size="T200"
|
|
||||||
style={{
|
|
||||||
color: color.Critical.Main,
|
|
||||||
padding: '2px 6px',
|
|
||||||
alignSelf: 'center',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{locationError}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{showLocation && (
|
|
||||||
<IconButton
|
|
||||||
onClick={handleShareLocation}
|
|
||||||
disabled={locating}
|
|
||||||
aria-label="Share location"
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
title="Share location"
|
|
||||||
style={touchTarget}
|
|
||||||
>
|
|
||||||
{locating ? (
|
|
||||||
<Spinner variant="Secondary" size="100" />
|
|
||||||
) : (
|
|
||||||
<Icon src={Icons.SpaceGlobe} size="100" />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
{showPoll && (
|
|
||||||
<IconButton
|
|
||||||
onClick={() => setPollOpen(true)}
|
|
||||||
aria-label="Create poll"
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
title="Create poll"
|
|
||||||
style={touchTarget}
|
|
||||||
>
|
|
||||||
<Icon src={Icons.OrderList} size="100" />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
{showVoice && (
|
|
||||||
<VoiceMessageRecorder
|
|
||||||
onSend={handleVoiceSend}
|
|
||||||
onError={(err) => {
|
|
||||||
setLocationError(err);
|
|
||||||
setTimeout(() => setLocationError(null), 4000);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{charCount > 0 && (
|
|
||||||
<Text
|
|
||||||
size="T200"
|
|
||||||
priority="300"
|
|
||||||
style={{
|
|
||||||
padding: `0 ${config.space.S100}`,
|
|
||||||
alignSelf: 'center',
|
|
||||||
userSelect: 'none',
|
|
||||||
minWidth: '2rem',
|
|
||||||
textAlign: 'right',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{charCount}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{showSchedule && (
|
|
||||||
<IconButton
|
|
||||||
onClick={handleScheduleClick}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
style={touchTarget}
|
|
||||||
aria-label="Schedule message"
|
|
||||||
title="Schedule message"
|
|
||||||
>
|
|
||||||
<Icon src={Icons.Clock} size="100" />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={submit}
|
key="showLocation"
|
||||||
|
onClick={handleShareLocation}
|
||||||
|
disabled={locating}
|
||||||
|
aria-label="Share location"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
title="Share location"
|
||||||
|
style={touchTarget}
|
||||||
|
>
|
||||||
|
{locating ? (
|
||||||
|
<Spinner variant="Secondary" size="100" />
|
||||||
|
) : (
|
||||||
|
<Icon src={Icons.SpaceGlobe} size="100" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const pollButton = showPoll ? (
|
||||||
|
<IconButton
|
||||||
|
key="showPoll"
|
||||||
|
onClick={() => setPollOpen(true)}
|
||||||
|
aria-label="Create poll"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
title="Create poll"
|
||||||
|
style={touchTarget}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.OrderList} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const voiceButton = showVoice ? (
|
||||||
|
<VoiceMessageRecorder
|
||||||
|
key="showVoice"
|
||||||
|
onSend={handleVoiceSend}
|
||||||
|
onError={(err) => {
|
||||||
|
setLocationError(err);
|
||||||
|
setTimeout(() => setLocationError(null), 4000);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const scheduleButton = showSchedule ? (
|
||||||
|
<IconButton
|
||||||
|
key="showSchedule"
|
||||||
|
onClick={handleScheduleClick}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
style={touchTarget}
|
style={touchTarget}
|
||||||
aria-label="Send message"
|
aria-label="Schedule message"
|
||||||
|
title="Schedule message"
|
||||||
>
|
>
|
||||||
<Icon src={Icons.Send} />
|
<Icon src={Icons.Clock} size="100" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</>
|
) : null;
|
||||||
}
|
|
||||||
|
const orderedButtons: ReactNode[] = [];
|
||||||
|
let emojiStickerRendered = false;
|
||||||
|
composerButtonOrder.forEach((key: ComposerToolbarButtonKey) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'showFormat':
|
||||||
|
if (formatButton) orderedButtons.push(formatButton);
|
||||||
|
break;
|
||||||
|
case 'showEmoji':
|
||||||
|
case 'showSticker':
|
||||||
|
// Rendered once as a combined unit at whichever of the two
|
||||||
|
// keys comes first in the order.
|
||||||
|
if (!emojiStickerRendered) {
|
||||||
|
emojiStickerRendered = true;
|
||||||
|
if (emojiStickerBlock) orderedButtons.push(emojiStickerBlock);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'showGif':
|
||||||
|
if (gifButton) orderedButtons.push(gifButton);
|
||||||
|
break;
|
||||||
|
case 'showLocation':
|
||||||
|
if (locationButton) orderedButtons.push(locationButton);
|
||||||
|
break;
|
||||||
|
case 'showPoll':
|
||||||
|
if (pollButton) orderedButtons.push(pollButton);
|
||||||
|
break;
|
||||||
|
case 'showVoice':
|
||||||
|
if (voiceButton) orderedButtons.push(voiceButton);
|
||||||
|
break;
|
||||||
|
case 'showSchedule':
|
||||||
|
if (scheduleButton) orderedButtons.push(scheduleButton);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{orderedButtons}
|
||||||
|
{gifError && (
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
style={{
|
||||||
|
color: color.Critical.Main,
|
||||||
|
padding: '2px 6px',
|
||||||
|
alignSelf: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{gifError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{locationError && (
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
style={{
|
||||||
|
color: color.Critical.Main,
|
||||||
|
padding: '2px 6px',
|
||||||
|
alignSelf: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{locationError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<DraftIndicator roomId={draftKey} />
|
||||||
|
{charCount > 0 && (
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S100}`,
|
||||||
|
alignSelf: 'center',
|
||||||
|
userSelect: 'none',
|
||||||
|
minWidth: '2rem',
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{charCount}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
onClick={submit}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
style={touchTarget}
|
||||||
|
aria-label="Send message"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Send} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
bottom={
|
bottom={
|
||||||
toolbar && (
|
toolbar && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ import {
|
|||||||
IContent,
|
IContent,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
|
RelationType,
|
||||||
Room,
|
Room,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
RoomEventHandlerMap,
|
RoomEventHandlerMap,
|
||||||
|
ThreadEvent,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
@@ -103,6 +105,8 @@ import * as css from './RoomTimeline.css';
|
|||||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
||||||
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
|
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
|
||||||
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||||
|
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||||
|
import { ThreadSummary } from './thread/ThreadSummary';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
@@ -245,13 +249,26 @@ const useEventTimelineLoader = (
|
|||||||
room: Room,
|
room: Room,
|
||||||
onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void,
|
onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void,
|
||||||
onError: (err: Error | null) => void,
|
onError: (err: Error | null) => void,
|
||||||
|
onThreadRedirect: (threadRootId: string) => void,
|
||||||
) => {
|
) => {
|
||||||
const loadEventTimeline = useCallback(
|
const loadEventTimeline = useCallback(
|
||||||
async (eventId: string) => {
|
async (eventId: string) => {
|
||||||
const [err, replyEvtTimeline] = await to(
|
const [err, replyEvtTimeline] = await to(
|
||||||
mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId),
|
mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId),
|
||||||
);
|
);
|
||||||
|
// Thread events aren't locatable in the main timeline set (getEventTimeline
|
||||||
|
// returns undefined / no abs index). Best-effort: redirect to the thread panel
|
||||||
|
// when the fetched event belongs to a thread instead of surfacing an error.
|
||||||
|
const redirectToThread = () => {
|
||||||
|
const threadRootId = room.findEventById(eventId)?.threadRootId;
|
||||||
|
if (threadRootId) {
|
||||||
|
onThreadRedirect(threadRootId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
if (!replyEvtTimeline) {
|
if (!replyEvtTimeline) {
|
||||||
|
if (redirectToThread()) return;
|
||||||
onError(err ?? null);
|
onError(err ?? null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -259,13 +276,14 @@ const useEventTimelineLoader = (
|
|||||||
const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);
|
const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);
|
||||||
|
|
||||||
if (absIndex === undefined) {
|
if (absIndex === undefined) {
|
||||||
|
if (redirectToThread()) return;
|
||||||
onError(err ?? null);
|
onError(err ?? null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoad(eventId, linkedTimelines, absIndex);
|
onLoad(eventId, linkedTimelines, absIndex);
|
||||||
},
|
},
|
||||||
[mx, room, onLoad, onError],
|
[mx, room, onLoad, onError, onThreadRedirect],
|
||||||
);
|
);
|
||||||
|
|
||||||
return loadEventTimeline;
|
return loadEventTimeline;
|
||||||
@@ -460,6 +478,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
||||||
|
|
||||||
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
||||||
|
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
|
||||||
|
// Thread summary chips only mount for events that already carry thread data
|
||||||
|
// (perf: a chip subscribes room-level listeners, so mounting one per rendered
|
||||||
|
// message would exceed the SDK's emitter cap). This single room-level
|
||||||
|
// ThreadEvent.New subscription re-renders the timeline once when a brand-new
|
||||||
|
// thread appears, so the root's chip shows up without unrelated activity.
|
||||||
|
const [, setThreadNewTick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const handleThreadNew = () => setThreadNewTick((c) => c + 1);
|
||||||
|
room.on(ThreadEvent.New, handleThreadNew);
|
||||||
|
return () => {
|
||||||
|
room.removeListener(ThreadEvent.New, handleThreadNew);
|
||||||
|
};
|
||||||
|
}, [room]);
|
||||||
const powerLevels = usePowerLevelsContext();
|
const powerLevels = usePowerLevelsContext();
|
||||||
const creators = useRoomCreators(room);
|
const creators = useRoomCreators(room);
|
||||||
|
|
||||||
@@ -622,6 +654,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = false;
|
scrollToBottomRef.current.smooth = false;
|
||||||
}, [alive, room]),
|
}, [alive, room]),
|
||||||
|
useCallback(
|
||||||
|
(threadRootId: string) => {
|
||||||
|
if (!alive()) return;
|
||||||
|
setActiveThreadId(threadRootId);
|
||||||
|
},
|
||||||
|
[alive, setActiveThreadId],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
useLiveEventArrive(
|
useLiveEventArrive(
|
||||||
@@ -982,14 +1021,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
console.warn('Button should have "data-event-id" attribute!');
|
console.warn('Button should have "data-event-id" attribute!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (startThread) {
|
||||||
|
// Open the thread panel instead of arming an m.thread reply in the main composer.
|
||||||
|
setActiveThreadId(replyId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const replyEvt = room.findEventById(replyId);
|
const replyEvt = room.findEventById(replyId);
|
||||||
if (!replyEvt) return;
|
if (!replyEvt) return;
|
||||||
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
|
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
|
||||||
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
||||||
const { body, formatted_body: formattedBody } = content;
|
const { body, formatted_body: formattedBody } = content;
|
||||||
const { 'm.relates_to': relation } = startThread
|
const { 'm.relates_to': relation } = replyEvt.getWireContent();
|
||||||
? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } }
|
|
||||||
: replyEvt.getWireContent();
|
|
||||||
const senderId = replyEvt.getSender();
|
const senderId = replyEvt.getSender();
|
||||||
if (senderId && typeof body === 'string') {
|
if (senderId && typeof body === 'string') {
|
||||||
setReplyDraft({
|
setReplyDraft({
|
||||||
@@ -1002,7 +1044,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
setTimeout(() => ReactEditor.focus(editor), 100);
|
setTimeout(() => ReactEditor.focus(editor), 100);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[room, setReplyDraft, editor],
|
[room, setReplyDraft, setActiveThreadId, editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleReactionToggle = useCallback(
|
const handleReactionToggle = useCallback(
|
||||||
@@ -1090,6 +1132,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
replyEventId={replyEventId}
|
replyEventId={replyEventId}
|
||||||
threadRootId={threadRootId}
|
threadRootId={threadRootId}
|
||||||
onClick={handleOpenReply}
|
onClick={handleOpenReply}
|
||||||
|
onThreadClick={setActiveThreadId}
|
||||||
getMemberPowerTag={getMemberPowerTag}
|
getMemberPowerTag={getMemberPowerTag}
|
||||||
accessibleTagColors={accessiblePowerTagColors}
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
@@ -1097,16 +1140,23 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
reactions={
|
reactions={
|
||||||
reactionRelations && (
|
<>
|
||||||
<Reactions
|
{reactionRelations && (
|
||||||
style={{ marginTop: config.space.S200 }}
|
<Reactions
|
||||||
room={room}
|
style={{ marginTop: config.space.S200 }}
|
||||||
relations={reactionRelations}
|
room={room}
|
||||||
mEventId={mEventId}
|
relations={reactionRelations}
|
||||||
canSendReaction={canSendReaction}
|
mEventId={mEventId}
|
||||||
onReactionToggle={handleReactionToggle}
|
canSendReaction={canSendReaction}
|
||||||
/>
|
onReactionToggle={handleReactionToggle}
|
||||||
)
|
/>
|
||||||
|
)}
|
||||||
|
{(!threadRootId || threadRootId === mEventId) &&
|
||||||
|
(mEvent.getThread() !== undefined ||
|
||||||
|
mEvent.getServerAggregatedRelation(RelationType.Thread) !== undefined) && (
|
||||||
|
<ThreadSummary rootEvent={mEvent} room={room} onOpen={setActiveThreadId} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
@@ -1175,6 +1225,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
replyEventId={replyEventId}
|
replyEventId={replyEventId}
|
||||||
threadRootId={threadRootId}
|
threadRootId={threadRootId}
|
||||||
onClick={handleOpenReply}
|
onClick={handleOpenReply}
|
||||||
|
onThreadClick={setActiveThreadId}
|
||||||
getMemberPowerTag={getMemberPowerTag}
|
getMemberPowerTag={getMemberPowerTag}
|
||||||
accessibleTagColors={accessiblePowerTagColors}
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
@@ -1182,16 +1233,23 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
reactions={
|
reactions={
|
||||||
reactionRelations && (
|
<>
|
||||||
<Reactions
|
{reactionRelations && (
|
||||||
style={{ marginTop: config.space.S200 }}
|
<Reactions
|
||||||
room={room}
|
style={{ marginTop: config.space.S200 }}
|
||||||
relations={reactionRelations}
|
room={room}
|
||||||
mEventId={mEventId}
|
relations={reactionRelations}
|
||||||
canSendReaction={canSendReaction}
|
mEventId={mEventId}
|
||||||
onReactionToggle={handleReactionToggle}
|
canSendReaction={canSendReaction}
|
||||||
/>
|
onReactionToggle={handleReactionToggle}
|
||||||
)
|
/>
|
||||||
|
)}
|
||||||
|
{(!threadRootId || threadRootId === mEventId) &&
|
||||||
|
(mEvent.getThread() !== undefined ||
|
||||||
|
mEvent.getServerAggregatedRelation(RelationType.Thread) !== undefined) && (
|
||||||
|
<ThreadSummary rootEvent={mEvent} room={room} onOpen={setActiveThreadId} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
|
|||||||
@@ -583,7 +583,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
returnFocusOnDeactivate: false,
|
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
onDeactivate: () => setViewTopic(false),
|
onDeactivate: () => setViewTopic(false),
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
|
|||||||
@@ -25,3 +25,16 @@ export const RoomViewTyping = style([
|
|||||||
export const TypingText = style({
|
export const TypingText = style({
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Visually hidden but available to assistive technology.
|
||||||
|
export const SrOnly = style({
|
||||||
|
position: 'absolute',
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
padding: 0,
|
||||||
|
margin: -1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
clip: 'rect(0, 0, 0, 0)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
border: 0,
|
||||||
|
});
|
||||||
|
|||||||
@@ -33,8 +33,21 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
|||||||
[typingMembers, myUserId, room],
|
[typingMembers, myUserId, room],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (typingNames.length === 0) {
|
// A single, non-truncated string for assistive technology to announce.
|
||||||
return null;
|
// Computed even when empty so the live region can stay mounted (below) —
|
||||||
|
// a `role="status"` region added to the DOM together with its first text
|
||||||
|
// is not reliably announced by some screen readers.
|
||||||
|
let typingAnnouncement = '';
|
||||||
|
if (typingNames.length === 1) {
|
||||||
|
typingAnnouncement = `${typingNames[0]} is typing`;
|
||||||
|
} else if (typingNames.length === 2) {
|
||||||
|
typingAnnouncement = `${typingNames[0]} and ${typingNames[1]} are typing`;
|
||||||
|
} else if (typingNames.length === 3) {
|
||||||
|
typingAnnouncement = `${typingNames[0]}, ${typingNames[1]} and ${typingNames[2]} are typing`;
|
||||||
|
} else {
|
||||||
|
typingAnnouncement = `${typingNames[0]}, ${typingNames[1]}, ${typingNames[2]} and ${
|
||||||
|
typingNames.length - 3
|
||||||
|
} others are typing`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDropAll = () => {
|
const handleDropAll = () => {
|
||||||
@@ -50,83 +63,89 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }} aria-live="polite" aria-atomic="false">
|
<div style={{ position: 'relative' }}>
|
||||||
<Box
|
{/* Persistently mounted so the FIRST "X is typing" is announced. */}
|
||||||
className={classNames(css.RoomViewTyping, className)}
|
<span className={css.SrOnly} role="status" aria-live="polite" aria-atomic="true">
|
||||||
alignItems="Center"
|
{typingAnnouncement}
|
||||||
gap="400"
|
</span>
|
||||||
{...props}
|
{typingNames.length > 0 && (
|
||||||
ref={ref}
|
<Box
|
||||||
>
|
className={classNames(css.RoomViewTyping, className)}
|
||||||
<TypingIndicator />
|
alignItems="Center"
|
||||||
<Text className={css.TypingText} size="T300" truncate>
|
gap="400"
|
||||||
{typingNames.length === 1 && (
|
{...props}
|
||||||
<>
|
ref={ref}
|
||||||
<b>{typingNames[0]}</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{' is typing...'}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{typingNames.length === 2 && (
|
|
||||||
<>
|
|
||||||
<b>{typingNames[0]}</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{' and '}
|
|
||||||
</Text>
|
|
||||||
<b>{typingNames[1]}</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{' are typing...'}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{typingNames.length === 3 && (
|
|
||||||
<>
|
|
||||||
<b>{typingNames[0]}</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{', '}
|
|
||||||
</Text>
|
|
||||||
<b>{typingNames[1]}</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{' and '}
|
|
||||||
</Text>
|
|
||||||
<b>{typingNames[2]}</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{' are typing...'}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{typingNames.length > 3 && (
|
|
||||||
<>
|
|
||||||
<b>{typingNames[0]}</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{', '}
|
|
||||||
</Text>
|
|
||||||
<b>{typingNames[1]}</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{', '}
|
|
||||||
</Text>
|
|
||||||
<b>{typingNames[2]}</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{' and '}
|
|
||||||
</Text>
|
|
||||||
<b>{typingNames.length - 3} others</b>
|
|
||||||
<Text as="span" size="Inherit" priority="300">
|
|
||||||
{' are typing...'}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<IconButton
|
|
||||||
title="Drop Typing Status"
|
|
||||||
aria-label="Drop typing status"
|
|
||||||
size="300"
|
|
||||||
radii="Pill"
|
|
||||||
onClick={handleDropAll}
|
|
||||||
>
|
>
|
||||||
<Icon size="50" src={Icons.Cross} />
|
<TypingIndicator />
|
||||||
</IconButton>
|
<Text className={css.TypingText} size="T300" truncate aria-hidden>
|
||||||
</Box>
|
{typingNames.length === 1 && (
|
||||||
|
<>
|
||||||
|
<b>{typingNames[0]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' is typing...'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{typingNames.length === 2 && (
|
||||||
|
<>
|
||||||
|
<b>{typingNames[0]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' and '}
|
||||||
|
</Text>
|
||||||
|
<b>{typingNames[1]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' are typing...'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{typingNames.length === 3 && (
|
||||||
|
<>
|
||||||
|
<b>{typingNames[0]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{', '}
|
||||||
|
</Text>
|
||||||
|
<b>{typingNames[1]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' and '}
|
||||||
|
</Text>
|
||||||
|
<b>{typingNames[2]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' are typing...'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{typingNames.length > 3 && (
|
||||||
|
<>
|
||||||
|
<b>{typingNames[0]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{', '}
|
||||||
|
</Text>
|
||||||
|
<b>{typingNames[1]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{', '}
|
||||||
|
</Text>
|
||||||
|
<b>{typingNames[2]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' and '}
|
||||||
|
</Text>
|
||||||
|
<b>{typingNames.length - 3} others</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' are typing...'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<IconButton
|
||||||
|
title="Drop Typing Status"
|
||||||
|
aria-label="Drop typing status"
|
||||||
|
size="300"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={handleDropAll}
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
|||||||
const [scheduledMessages, setScheduledMessages] = useAtom(scheduledMessagesAtom);
|
const [scheduledMessages, setScheduledMessages] = useAtom(scheduledMessagesAtom);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
|
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
|
||||||
|
const [cancelErrors, setCancelErrors] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
|
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
|
||||||
|
|
||||||
@@ -68,12 +69,17 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
|||||||
async (msg: ScheduledMessage) => {
|
async (msg: ScheduledMessage) => {
|
||||||
if (cancelling.has(msg.delayId)) return;
|
if (cancelling.has(msg.delayId)) return;
|
||||||
setCancelling((prev) => new Set(prev).add(msg.delayId));
|
setCancelling((prev) => new Set(prev).add(msg.delayId));
|
||||||
|
setCancelErrors((prev) => {
|
||||||
|
if (!prev.has(msg.delayId)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(msg.delayId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await cancelScheduledMessage(mx, msg.delayId);
|
await cancelScheduledMessage(mx, msg.delayId);
|
||||||
} catch {
|
// Only prune local state once the server confirms cancellation. If we
|
||||||
// If cancellation fails on the server, still remove locally
|
// removed it optimistically the still-live delayed event would fire and
|
||||||
// since the user intends to remove it
|
// the "cancelled" message would send anyway.
|
||||||
} finally {
|
|
||||||
setScheduledMessages((prev) => {
|
setScheduledMessages((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
const current = next.get(roomId) ?? [];
|
const current = next.get(roomId) ?? [];
|
||||||
@@ -85,6 +91,11 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
|
// Keep the item (still cancellable) and surface an inline error; the
|
||||||
|
// delayed event is still scheduled on the server.
|
||||||
|
setCancelErrors((prev) => new Set(prev).add(msg.delayId));
|
||||||
|
} finally {
|
||||||
setCancelling((prev) => {
|
setCancelling((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(msg.delayId);
|
next.delete(msg.delayId);
|
||||||
@@ -131,41 +142,52 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
|||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<Box
|
<Box
|
||||||
key={msg.delayId}
|
key={msg.delayId}
|
||||||
alignItems="Center"
|
direction="Column"
|
||||||
gap="200"
|
|
||||||
style={{
|
style={{
|
||||||
padding: `${config.space.S100} ${config.space.S300}`,
|
padding: `${config.space.S100} ${config.space.S300}`,
|
||||||
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Box alignItems="Center" gap="200">
|
||||||
size="T200"
|
<Text
|
||||||
priority="400"
|
size="T200"
|
||||||
style={{
|
priority="400"
|
||||||
flex: 1,
|
style={{
|
||||||
overflow: 'hidden',
|
flex: 1,
|
||||||
textOverflow: 'ellipsis',
|
overflow: 'hidden',
|
||||||
whiteSpace: 'nowrap',
|
textOverflow: 'ellipsis',
|
||||||
}}
|
whiteSpace: 'nowrap',
|
||||||
>
|
}}
|
||||||
{typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)'}
|
>
|
||||||
</Text>
|
{typeof msg.content.body === 'string'
|
||||||
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
? (msg.content.body as string)
|
||||||
{formatSendAt(msg.sendAt)}
|
: '(message)'}
|
||||||
</Text>
|
</Text>
|
||||||
<IconButton
|
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
size="300"
|
{formatSendAt(msg.sendAt)}
|
||||||
radii="300"
|
</Text>
|
||||||
variant="SurfaceVariant"
|
<IconButton
|
||||||
aria-label="Cancel scheduled message"
|
size="300"
|
||||||
disabled={cancelling.has(msg.delayId)}
|
radii="300"
|
||||||
onClick={(e) => {
|
variant="SurfaceVariant"
|
||||||
e.stopPropagation();
|
aria-label="Cancel scheduled message"
|
||||||
handleCancel(msg);
|
disabled={cancelling.has(msg.delayId)}
|
||||||
}}
|
onClick={(e) => {
|
||||||
>
|
e.stopPropagation();
|
||||||
<Icon src={Icons.Cross} size="50" />
|
handleCancel(msg);
|
||||||
</IconButton>
|
}}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} size="50" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
{cancelErrors.has(msg.delayId) && (
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
||||||
|
>
|
||||||
|
Could not cancel this message. Try again.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import React, { ChangeEvent, useCallback, useState } from 'react';
|
import React, { ChangeEvent, useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
color,
|
||||||
config,
|
config,
|
||||||
Header,
|
Header,
|
||||||
Icon,
|
Icon,
|
||||||
@@ -28,15 +31,17 @@ import { mDirectAtom } from '../../../state/mDirectList';
|
|||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
||||||
|
import { buildForwardContent } from './forwardContent';
|
||||||
|
|
||||||
type RoomRowProps = {
|
type RoomRowProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
dm: boolean;
|
dm: boolean;
|
||||||
useAuthentication: boolean;
|
useAuthentication: boolean;
|
||||||
onClick: () => void;
|
selected: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
sending: boolean;
|
sending: boolean;
|
||||||
};
|
};
|
||||||
function RoomRow({ room, dm, useAuthentication, onClick, sending }: RoomRowProps) {
|
function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: RoomRowProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const avatarMxc = room.getMxcAvatarUrl();
|
const avatarMxc = room.getMxcAvatarUrl();
|
||||||
const avatarUrl = avatarMxc
|
const avatarUrl = avatarMxc
|
||||||
@@ -47,8 +52,20 @@ function RoomRow({ room, dm, useAuthentication, onClick, sending }: RoomRowProps
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
onClick={onClick}
|
onClick={onToggle}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
|
after={
|
||||||
|
<Checkbox
|
||||||
|
checked={selected}
|
||||||
|
readOnly
|
||||||
|
variant="Primary"
|
||||||
|
disabled={sending}
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.stopPropagation();
|
||||||
|
onToggle();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
before={
|
before={
|
||||||
<Avatar size="200" radii="300">
|
<Avatar size="200" radii="300">
|
||||||
<RoomAvatar
|
<RoomAvatar
|
||||||
@@ -86,43 +103,95 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
const modalStyle = useModalStyle(400);
|
const modalStyle = useModalStyle(400);
|
||||||
const directs = useAtomValue(mDirectAtom);
|
const directs = useAtomValue(mDirectAtom);
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Selection persists across query changes: a room selected then filtered out
|
||||||
|
// of the rendered slice stays selected.
|
||||||
|
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const allRooms = mx
|
const toggleRoom = useCallback((roomId: string) => {
|
||||||
.getRooms()
|
setSelectedRoomIds((prev) => {
|
||||||
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
|
const next = new Set(prev);
|
||||||
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0));
|
if (next.has(roomId)) {
|
||||||
|
next.delete(roomId);
|
||||||
const filtered = query
|
} else {
|
||||||
? allRooms.filter((r) => r.name.toLowerCase().includes(query.toLowerCase()))
|
next.add(roomId);
|
||||||
: allRooms;
|
|
||||||
|
|
||||||
const forward = useCallback(
|
|
||||||
async (room: Room) => {
|
|
||||||
if (sending) return;
|
|
||||||
setSending(true);
|
|
||||||
const fwdContent: Record<string, unknown> = { ...mEvent.getContent() };
|
|
||||||
delete fwdContent['m.relates_to'];
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
await (mx as any).sendEvent(room.roomId, mEvent.getType(), fwdContent);
|
|
||||||
setSentTo(room.name);
|
|
||||||
setTimeout(onClose, 1400);
|
|
||||||
} catch {
|
|
||||||
setSending(false);
|
|
||||||
}
|
}
|
||||||
},
|
return next;
|
||||||
[mx, mEvent, onClose, sending],
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const allRooms = useMemo(
|
||||||
|
() =>
|
||||||
|
mx
|
||||||
|
.getRooms()
|
||||||
|
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
|
||||||
|
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0)),
|
||||||
|
[mx],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!query) return allRooms;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||||
|
}, [allRooms, query]);
|
||||||
|
|
||||||
|
const sendToSelected = useCallback(async () => {
|
||||||
|
if (sending || selectedRoomIds.size === 0) return;
|
||||||
|
const fwdContent = buildForwardContent(mx, mEvent);
|
||||||
|
if (!fwdContent) {
|
||||||
|
setError('This message could not be decrypted, so it cannot be forwarded.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSending(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const ids = [...selectedRoomIds];
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
ids.map((id) => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const failedIds: string[] = [];
|
||||||
|
const failedNames: string[] = [];
|
||||||
|
results.forEach((result, i) => {
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
failedIds.push(ids[i]);
|
||||||
|
failedNames.push(mx.getRoom(ids[i])?.name ?? ids[i]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = ids.length;
|
||||||
|
const failed = failedNames.length;
|
||||||
|
const succeeded = total - failed;
|
||||||
|
|
||||||
|
if (failed === 0) {
|
||||||
|
setSentTo(`Forwarded to ${total} ${total === 1 ? 'room' : 'rooms'}`);
|
||||||
|
setTimeout(onClose, 1400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSending(false);
|
||||||
|
// Prune to only the failures so a retry doesn't re-send to rooms that
|
||||||
|
// already succeeded (duplicate messages).
|
||||||
|
setSelectedRoomIds(new Set(failedIds));
|
||||||
|
if (succeeded === 0) {
|
||||||
|
setError('Failed to forward. Try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`);
|
||||||
|
}, [mx, mEvent, onClose, sending, selectedRoomIds]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: () => searchInputRef.current ?? false,
|
||||||
onDeactivate: onClose,
|
onDeactivate: onClose,
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
@@ -153,8 +222,13 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
{!sentTo && (
|
{!sentTo && (
|
||||||
<Box shrink="No" style={{ padding: `${config.space.S200} ${config.space.S400}` }}>
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
direction="Column"
|
||||||
|
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
variant="Background"
|
variant="Background"
|
||||||
size="400"
|
size="400"
|
||||||
radii="400"
|
radii="400"
|
||||||
@@ -163,6 +237,14 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
|
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{error && (
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Line size="300" />
|
<Line size="300" />
|
||||||
@@ -174,50 +256,72 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
gap="300"
|
gap="300"
|
||||||
style={{ padding: config.space.S400 }}
|
style={{ padding: config.space.S400 }}
|
||||||
>
|
>
|
||||||
<Text size="T300">✓ Forwarded to {sentTo}</Text>
|
<Text size="T300">✓ {sentTo}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box grow="Yes" style={{ minHeight: 0, position: 'relative' }}>
|
<>
|
||||||
<Scroll size="300" hideTrack visibility="Hover">
|
<Box grow="Yes" style={{ minHeight: 0, position: 'relative' }}>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
<Scroll size="300" hideTrack visibility="Hover">
|
||||||
{filtered.slice(0, 60).map((room) => (
|
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||||
<RoomRow
|
{filtered.slice(0, 60).map((room) => (
|
||||||
key={room.roomId}
|
<RoomRow
|
||||||
room={room}
|
key={room.roomId}
|
||||||
dm={directs.has(room.roomId)}
|
room={room}
|
||||||
useAuthentication={useAuthentication}
|
dm={directs.has(room.roomId)}
|
||||||
onClick={() => forward(room)}
|
useAuthentication={useAuthentication}
|
||||||
sending={sending}
|
selected={selectedRoomIds.has(room.roomId)}
|
||||||
/>
|
onToggle={() => toggleRoom(room.roomId)}
|
||||||
))}
|
sending={sending}
|
||||||
{filtered.length === 0 && (
|
/>
|
||||||
<Box
|
))}
|
||||||
alignItems="Center"
|
{filtered.length === 0 && (
|
||||||
justifyContent="Center"
|
<Box
|
||||||
style={{ padding: config.space.S400 }}
|
alignItems="Center"
|
||||||
>
|
justifyContent="Center"
|
||||||
<Text size="T300" priority="300">
|
style={{ padding: config.space.S400 }}
|
||||||
No rooms found
|
>
|
||||||
</Text>
|
<Text size="T300" priority="300">
|
||||||
</Box>
|
No rooms found
|
||||||
)}
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Scroll>
|
)}
|
||||||
{sending && (
|
</Box>
|
||||||
<Box
|
</Scroll>
|
||||||
alignItems="Center"
|
{sending && (
|
||||||
justifyContent="Center"
|
<Box
|
||||||
style={{
|
alignItems="Center"
|
||||||
position: 'absolute',
|
justifyContent="Center"
|
||||||
inset: 0,
|
style={{
|
||||||
background: 'rgba(0,0,0,0.35)',
|
position: 'absolute',
|
||||||
borderRadius: config.radii.R500,
|
inset: 0,
|
||||||
}}
|
background: 'rgba(0,0,0,0.35)',
|
||||||
|
borderRadius: config.radii.R500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spinner variant="Secondary" size="400" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Line size="300" />
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
direction="Column"
|
||||||
|
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="Primary"
|
||||||
|
size="400"
|
||||||
|
radii="400"
|
||||||
|
disabled={selectedRoomIds.size === 0 || sending}
|
||||||
|
before={sending && <Spinner variant="Primary" fill="Solid" size="200" />}
|
||||||
|
onClick={sendToSelected}
|
||||||
>
|
>
|
||||||
<Spinner variant="Secondary" size="400" />
|
<Text size="B400">
|
||||||
</Box>
|
Send to {selectedRoomIds.size} {selectedRoomIds.size === 1 ? 'room' : 'rooms'}
|
||||||
)}
|
</Text>
|
||||||
</Box>
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
getMemberDisplayName,
|
getMemberDisplayName,
|
||||||
} from '../../../utils/room';
|
} from '../../../utils/room';
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
|
import { messageAriaLabel } from '../../../utils/a11y';
|
||||||
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||||
@@ -972,6 +973,10 @@ export const Message = React.memo(
|
|||||||
[MsgAppearClass]: playAppear,
|
[MsgAppearClass]: playAppear,
|
||||||
[MentionHighlightPulse]: playMentionPulse,
|
[MentionHighlightPulse]: playMentionPulse,
|
||||||
})}
|
})}
|
||||||
|
role="article"
|
||||||
|
aria-label={
|
||||||
|
collapse ? messageAriaLabel(senderDisplayName, mEvent.getTs(), hour24Clock) : undefined
|
||||||
|
}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
space={messageSpacing}
|
space={messageSpacing}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
|
|||||||
@@ -51,7 +51,13 @@ import { UseStateProvider } from '../../../components/UseStateProvider';
|
|||||||
import { EmojiBoard } from '../../../components/emoji-board';
|
import { EmojiBoard } from '../../../components/emoji-board';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room';
|
import {
|
||||||
|
getEditedEvent,
|
||||||
|
getMemberDisplayName,
|
||||||
|
getMentionContent,
|
||||||
|
trimReplyFromFormattedBody,
|
||||||
|
} from '../../../utils/room';
|
||||||
|
import { getMxIdLocalPart } from '../../../utils/matrix';
|
||||||
import { mobileOrTablet } from '../../../utils/user-agent';
|
import { mobileOrTablet } from '../../../utils/user-agent';
|
||||||
import { useComposingCheck } from '../../../hooks/useComposingCheck';
|
import { useComposingCheck } from '../../../hooks/useComposingCheck';
|
||||||
|
|
||||||
@@ -66,6 +72,12 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||||||
({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
|
({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
|
// Accessible name for the edit textbox so screen readers announce which
|
||||||
|
// message is being edited (a11y, P3-4).
|
||||||
|
const editSenderId = mEvent.getSender();
|
||||||
|
const editSenderName = editSenderId
|
||||||
|
? (getMemberDisplayName(room, editSenderId) ?? getMxIdLocalPart(editSenderId) ?? editSenderId)
|
||||||
|
: '';
|
||||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||||
@@ -259,6 +271,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||||||
<CustomEditor
|
<CustomEditor
|
||||||
editor={editor}
|
editor={editor}
|
||||||
placeholder="Edit message..."
|
placeholder="Edit message..."
|
||||||
|
ariaLabel={editSenderId ? `Editing message from ${editSenderName}` : 'Edit message'}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onKeyUp={handleKeyUp}
|
onKeyUp={handleKeyUp}
|
||||||
bottom={
|
bottom={
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ export const Reactions = as<'div', ReactionsProps>(
|
|||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
returnFocusOnDeactivate: false,
|
|
||||||
onDeactivate: () => setViewer(false),
|
onDeactivate: () => setViewer(false),
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
color,
|
||||||
config,
|
config,
|
||||||
Dialog,
|
Dialog,
|
||||||
Header,
|
Header,
|
||||||
@@ -43,15 +44,25 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
|||||||
const modalStyle = useModalStyle(320);
|
const modalStyle = useModalStyle(320);
|
||||||
const { addReminder } = useReminders();
|
const { addReminder } = useReminders();
|
||||||
const presets = useMemo(() => getPresets(), []);
|
const presets = useMemo(() => getPresets(), []);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handlePick = async (ms: number) => {
|
const handlePick = async (ms: number) => {
|
||||||
await addReminder({
|
if (busy) return;
|
||||||
roomId,
|
setBusy(true);
|
||||||
eventId,
|
setError(null);
|
||||||
timestamp: Date.now() + ms,
|
try {
|
||||||
message: previewText || 'Reminder',
|
await addReminder({
|
||||||
});
|
roomId,
|
||||||
onClose();
|
eventId,
|
||||||
|
timestamp: Date.now() + ms,
|
||||||
|
message: previewText || 'Reminder',
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
setBusy(false);
|
||||||
|
setError('Could not set reminder. Try again.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -108,6 +119,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
|||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="300"
|
radii="300"
|
||||||
|
disabled={busy}
|
||||||
onClick={() => handlePick(p.ms)}
|
onClick={() => handlePick(p.ms)}
|
||||||
>
|
>
|
||||||
<Text size="B300" truncate>
|
<Text size="B300" truncate>
|
||||||
@@ -115,6 +127,14 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
|||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
{error && (
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||||
|
import { buildForwardContent } from './forwardContent';
|
||||||
|
|
||||||
|
// Pure content builder buildForwardContent: refuses undecryptable events, forwards
|
||||||
|
// the latest edit (`m.new_content`), and strips reply fallbacks + `m.relates_to`.
|
||||||
|
// MatrixClient / MatrixEvent are mocked minimally. getEditedEvent reads edits off
|
||||||
|
// `timelineSet.relations.getChildEventsForEvent(...).getRelations()`, so the base
|
||||||
|
// client returns no child edits and the edit test injects one.
|
||||||
|
|
||||||
|
const SENDER = '@me:example.org';
|
||||||
|
|
||||||
|
type EventOptions = {
|
||||||
|
content?: Record<string, unknown>;
|
||||||
|
type?: string;
|
||||||
|
id?: string;
|
||||||
|
roomId?: string;
|
||||||
|
decryptionFailure?: boolean;
|
||||||
|
ts?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeEvent = (options: EventOptions = {}): MatrixEvent => {
|
||||||
|
const {
|
||||||
|
content = {},
|
||||||
|
type = 'm.room.message',
|
||||||
|
id = '$evt:example.org',
|
||||||
|
roomId = '!room:example.org',
|
||||||
|
decryptionFailure = false,
|
||||||
|
ts = 0,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return {
|
||||||
|
getContent: () => content,
|
||||||
|
getType: () => type,
|
||||||
|
getId: () => id,
|
||||||
|
getRoomId: () => roomId,
|
||||||
|
getSender: () => SENDER,
|
||||||
|
getTs: () => ts,
|
||||||
|
isDecryptionFailure: () => decryptionFailure,
|
||||||
|
} as unknown as MatrixEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Base client: the timeline reports no `m.replace` edits, so the original content
|
||||||
|
// is forwarded unchanged.
|
||||||
|
const makeClient = (): MatrixClient =>
|
||||||
|
({
|
||||||
|
getRoom: () => ({
|
||||||
|
getUnfilteredTimelineSet: () => ({
|
||||||
|
relations: {
|
||||||
|
getChildEventsForEvent: () => null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}) as unknown as MatrixClient;
|
||||||
|
|
||||||
|
test('plain text forwards the body and strips m.relates_to', () => {
|
||||||
|
const mx = makeClient();
|
||||||
|
const mEvent = makeEvent({
|
||||||
|
content: {
|
||||||
|
msgtype: 'm.text',
|
||||||
|
body: 'hello world',
|
||||||
|
'm.relates_to': { rel_type: 'm.thread', event_id: '$root:example.org' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = buildForwardContent(mx, mEvent);
|
||||||
|
|
||||||
|
assert.ok(content);
|
||||||
|
assert.equal(content.body, 'hello world');
|
||||||
|
assert.equal(content.msgtype, 'm.text');
|
||||||
|
assert.equal(content['m.relates_to'], undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reply quote is stripped from body and formatted_body', () => {
|
||||||
|
const mx = makeClient();
|
||||||
|
const mEvent = makeEvent({
|
||||||
|
content: {
|
||||||
|
msgtype: 'm.text',
|
||||||
|
body: '> <@alice:example.org> original\n\nmy reply',
|
||||||
|
format: 'org.matrix.custom.html',
|
||||||
|
formatted_body: '<mx-reply><blockquote>original</blockquote></mx-reply>my reply',
|
||||||
|
'm.relates_to': { 'm.in_reply_to': { event_id: '$root:example.org' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = buildForwardContent(mx, mEvent);
|
||||||
|
|
||||||
|
assert.ok(content);
|
||||||
|
assert.equal(content.body, 'my reply');
|
||||||
|
assert.equal(content.formatted_body, 'my reply');
|
||||||
|
assert.equal(content['m.relates_to'], undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decryption failure returns undefined', () => {
|
||||||
|
const mx = makeClient();
|
||||||
|
const mEvent = makeEvent({
|
||||||
|
content: { msgtype: 'm.bad.encrypted' },
|
||||||
|
decryptionFailure: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(buildForwardContent(mx, mEvent), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edited message forwards m.new_content', () => {
|
||||||
|
const mEvent = makeEvent({
|
||||||
|
content: {
|
||||||
|
msgtype: 'm.text',
|
||||||
|
body: 'original body',
|
||||||
|
'm.relates_to': { rel_type: 'm.thread', event_id: '$root:example.org' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// The latest `m.replace` edit carries the new content under `m.new_content`.
|
||||||
|
const editEvent = makeEvent({
|
||||||
|
content: { 'm.new_content': { msgtype: 'm.text', body: 'edited body' } },
|
||||||
|
ts: 100,
|
||||||
|
});
|
||||||
|
const mx = {
|
||||||
|
getRoom: () => ({
|
||||||
|
getUnfilteredTimelineSet: () => ({
|
||||||
|
relations: {
|
||||||
|
getChildEventsForEvent: () => ({
|
||||||
|
getRelations: () => [editEvent],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as unknown as MatrixClient;
|
||||||
|
|
||||||
|
const content = buildForwardContent(mx, mEvent);
|
||||||
|
|
||||||
|
assert.ok(content);
|
||||||
|
assert.equal(content.body, 'edited body');
|
||||||
|
assert.equal(content.msgtype, 'm.text');
|
||||||
|
assert.equal(content['m.new_content'], undefined);
|
||||||
|
assert.equal(content['m.relates_to'], undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||||
|
import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the content to forward:
|
||||||
|
* - undecryptable events are refused (would forward `m.bad.encrypted` junk)
|
||||||
|
* - edited messages forward the LATEST edit (`m.new_content`), not the
|
||||||
|
* original pre-edit body
|
||||||
|
* - reply fallbacks (`> <@user> …` quote + `<mx-reply>` block) are stripped
|
||||||
|
* along with the `m.relates_to` reply/thread relation, so the forwarded
|
||||||
|
* message stands alone in the target room
|
||||||
|
*/
|
||||||
|
export function buildForwardContent(
|
||||||
|
mx: MatrixClient,
|
||||||
|
mEvent: MatrixEvent,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
if (mEvent.isDecryptionFailure()) return undefined;
|
||||||
|
|
||||||
|
let content = { ...mEvent.getContent() };
|
||||||
|
|
||||||
|
const eventId = mEvent.getId();
|
||||||
|
const room = mx.getRoom(mEvent.getRoomId());
|
||||||
|
if (eventId && room) {
|
||||||
|
const editedEvent = getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet());
|
||||||
|
const newContent = editedEvent?.getContent()['m.new_content'];
|
||||||
|
if (newContent && typeof newContent === 'object') {
|
||||||
|
content = { ...(newContent as Record<string, unknown>) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete content['m.relates_to'];
|
||||||
|
if (typeof content.body === 'string') {
|
||||||
|
content.body = trimReplyFromBody(content.body);
|
||||||
|
}
|
||||||
|
if (typeof content.formatted_body === 'string') {
|
||||||
|
content.formatted_body = trimReplyFromFormattedBody(content.formatted_body);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const ThreadPanel = style({
|
||||||
|
width: toRem(360),
|
||||||
|
'@media': {
|
||||||
|
'(max-width: 750px)': {
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
zIndex: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThreadPanelHeader = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThreadPanelContent = style({
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThreadPanelInput = style({
|
||||||
|
padding: config.space.S200,
|
||||||
|
borderTopWidth: config.borderWidth.B300,
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
TooltipProvider,
|
||||||
|
} from 'folds';
|
||||||
|
import { Room, RoomEvent, ThreadEvent } from 'matrix-js-sdk';
|
||||||
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import * as css from './ThreadPanel.css';
|
||||||
|
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||||
|
import { ThreadTimeline } from './ThreadTimeline';
|
||||||
|
import { markThreadAsRead, useThreadInstance } from './useThread';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useEditor } from '../../../components/editor';
|
||||||
|
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
import { RoomInput } from '../RoomInput';
|
||||||
|
import {
|
||||||
|
getThreadNotificationModeIcon,
|
||||||
|
ThreadNotificationModeSwitcher,
|
||||||
|
} from '../../../components/ThreadNotificationModeSwitcher';
|
||||||
|
import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications';
|
||||||
|
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
|
||||||
|
|
||||||
|
type ThreadPanelHeaderProps = {
|
||||||
|
room: Room;
|
||||||
|
threadId: string;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
|
||||||
|
const mode = useThreadNotificationMode(room.roomId, threadId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Header className={css.ThreadPanelHeader} variant="Background" size="600">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Text size="H5" truncate>
|
||||||
|
Thread
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||||
|
{room.name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" alignItems="Center" gap="100">
|
||||||
|
<ThreadNotificationModeSwitcher roomId={room.roomId} threadId={threadId} value={mode}>
|
||||||
|
{(handleOpen, opened) => (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>Notifications</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="Background"
|
||||||
|
aria-label="Thread notifications"
|
||||||
|
aria-pressed={opened}
|
||||||
|
onClick={handleOpen}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
src={getThreadNotificationModeIcon(mode)}
|
||||||
|
filled={mode !== ThreadNotificationMode.Default}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</ThreadNotificationModeSwitcher>
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>Close</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="Background"
|
||||||
|
aria-label="Close thread"
|
||||||
|
onClick={requestClose}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThreadPanelProps = {
|
||||||
|
room: Room;
|
||||||
|
threadId: string;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const editor = useEditor();
|
||||||
|
const thread = useThreadInstance(room, threadId);
|
||||||
|
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
|
||||||
|
const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||||
|
|
||||||
|
useKeyDown(
|
||||||
|
window,
|
||||||
|
useCallback(
|
||||||
|
(evt) => {
|
||||||
|
if (isKeyHotkey('escape', evt)) {
|
||||||
|
// The composer preventDefaults Escape when it consumes it (dismissing
|
||||||
|
// autocomplete / clearing a reply draft). Don't close the panel in
|
||||||
|
// that case — only when Escape wasn't already handled.
|
||||||
|
if (evt.defaultPrevented) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
requestClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[requestClose],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark the thread read when the panel is open and on each new thread event.
|
||||||
|
// Deduped on the latest event id: RoomEvent.Timeline re-emits per event during
|
||||||
|
// backfill and for every edit/reaction, and sendReadReceipt POSTs
|
||||||
|
// unconditionally — without the guard, opening a thread with N replies would
|
||||||
|
// fire up to N receipt requests at the same event.
|
||||||
|
const lastReadEventIdRef = useRef<string | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
lastReadEventIdRef.current = undefined;
|
||||||
|
if (!thread) return undefined;
|
||||||
|
const markRead = () => {
|
||||||
|
const events = thread.liveTimeline.getEvents();
|
||||||
|
let latestId: string | undefined;
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const evt = events[i];
|
||||||
|
if (evt && !evt.isSending()) {
|
||||||
|
latestId = evt.getId() ?? undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!latestId || latestId === lastReadEventIdRef.current) return;
|
||||||
|
lastReadEventIdRef.current = latestId;
|
||||||
|
markThreadAsRead(mx, thread, privateReadReceipts).catch(() => {
|
||||||
|
// Allow a retry on the next event if the receipt POST failed.
|
||||||
|
if (lastReadEventIdRef.current === latestId) {
|
||||||
|
lastReadEventIdRef.current = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
markRead();
|
||||||
|
thread.on(ThreadEvent.NewReply, markRead);
|
||||||
|
thread.on(RoomEvent.Timeline, markRead);
|
||||||
|
return () => {
|
||||||
|
thread.off(ThreadEvent.NewReply, markRead);
|
||||||
|
thread.off(RoomEvent.Timeline, markRead);
|
||||||
|
};
|
||||||
|
}, [mx, thread, privateReadReceipts]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={classNames(css.ThreadPanel, ContainerColor({ variant: 'Background' }))}
|
||||||
|
shrink="No"
|
||||||
|
direction="Column"
|
||||||
|
>
|
||||||
|
<ThreadPanelHeader room={room} threadId={threadId} requestClose={requestClose} />
|
||||||
|
{!thread ? (
|
||||||
|
<Box grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
||||||
|
<Spinner size="400" variant="Secondary" />
|
||||||
|
<Text size="T300">Loading thread…</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box grow="Yes" className={css.ThreadPanelContent} direction="Column">
|
||||||
|
<ThreadTimeline room={room} thread={thread} editor={editor} />
|
||||||
|
</Box>
|
||||||
|
<Box className={css.ThreadPanelInput} shrink="No" direction="Column">
|
||||||
|
<RoomInput
|
||||||
|
room={room}
|
||||||
|
roomId={room.roomId}
|
||||||
|
threadRootId={threadId}
|
||||||
|
editor={editor}
|
||||||
|
fileDropContainerRef={fileDropContainerRef}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Badge, Box, Chip, Icon, Icons, Text, config } from 'folds';
|
||||||
|
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||||
|
import { useThreadSummary } from '../../../hooks/useThreadSummary';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
import { timeDayMonthYear, timeHourMinute, today } from '../../../utils/time';
|
||||||
|
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
|
||||||
|
|
||||||
|
type ThreadSummaryProps = {
|
||||||
|
rootEvent: MatrixEvent;
|
||||||
|
room: Room;
|
||||||
|
onOpen: (threadId: string) => void;
|
||||||
|
};
|
||||||
|
export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
|
||||||
|
const { summary, unread, mode } = useThreadSummary(rootEvent, room);
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
|
||||||
|
if (!summary || summary.count === 0) return null;
|
||||||
|
|
||||||
|
const { count, latestTs } = summary;
|
||||||
|
const latestStr =
|
||||||
|
latestTs !== undefined
|
||||||
|
? today(latestTs)
|
||||||
|
? timeHourMinute(latestTs, hour24Clock)
|
||||||
|
: timeDayMonthYear(latestTs)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ marginTop: config.space.S200 }}>
|
||||||
|
<Chip
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="300"
|
||||||
|
before={<Icon size="50" src={Icons.Thread} />}
|
||||||
|
after={
|
||||||
|
unread > 0 ? <Badge variant="Success" fill="Solid" radii="Pill" size="200" /> : undefined
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const threadId = rootEvent.getId();
|
||||||
|
if (threadId) onOpen(threadId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="T200">
|
||||||
|
{count === 1 ? '1 reply' : `${count} replies`}
|
||||||
|
{latestStr ? ` · ${latestStr}` : ''}
|
||||||
|
</Text>
|
||||||
|
{mode === ThreadNotificationMode.Mute && <Icon size="50" src={Icons.BellMute} />}
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config } from 'folds';
|
||||||
|
|
||||||
|
export const ThreadTimeline = style({
|
||||||
|
height: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThreadTimelineContent = style({
|
||||||
|
minHeight: '100%',
|
||||||
|
padding: `${config.space.S400} 0`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThreadTimelineFloat = style({
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: config.space.S400,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 1,
|
||||||
|
minWidth: 'max-content',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThreadCentered = style({
|
||||||
|
height: '100%',
|
||||||
|
padding: config.space.S700,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RootMessage = style({
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
marginBottom: config.space.S100,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RepliesDivider = style({
|
||||||
|
padding: `${config.space.S200} ${config.space.S400}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const NoReplies = style({
|
||||||
|
padding: config.space.S400,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PendingMessage = style({
|
||||||
|
opacity: 0.6,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PendingFailed = style({
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
@@ -0,0 +1,982 @@
|
|||||||
|
import React, {
|
||||||
|
Dispatch,
|
||||||
|
MouseEventHandler,
|
||||||
|
ReactNode,
|
||||||
|
SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
Direction,
|
||||||
|
EventStatus,
|
||||||
|
EventTimeline,
|
||||||
|
EventTimelineSet,
|
||||||
|
EventTimelineSetHandlerMap,
|
||||||
|
MatrixClient,
|
||||||
|
MatrixEvent,
|
||||||
|
RelationType,
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
Thread,
|
||||||
|
ThreadEvent,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||||
|
import { Editor } from 'slate';
|
||||||
|
import { ReactEditor } from 'slate-react';
|
||||||
|
import to from 'await-to-js';
|
||||||
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
|
import { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, config } from 'folds';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||||
|
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
|
||||||
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
|
import { scrollToBottom } from '../../../utils/dom';
|
||||||
|
import {
|
||||||
|
DefaultPlaceholder,
|
||||||
|
MessageBase,
|
||||||
|
Reply,
|
||||||
|
RedactedContent,
|
||||||
|
MSticker,
|
||||||
|
MessageUnsupportedContent,
|
||||||
|
MessageNotDecryptedContent,
|
||||||
|
ImageContent,
|
||||||
|
} from '../../../components/message';
|
||||||
|
import {
|
||||||
|
factoryRenderLinkifyWithMention,
|
||||||
|
getReactCustomHtmlParser,
|
||||||
|
LINKIFY_OPTS,
|
||||||
|
makeMentionCustomProps,
|
||||||
|
renderMatrixMention,
|
||||||
|
} from '../../../plugins/react-custom-html-parser';
|
||||||
|
import {
|
||||||
|
decryptAllTimelineEvent,
|
||||||
|
getEditedEvent,
|
||||||
|
getEventReactions,
|
||||||
|
getMemberDisplayName,
|
||||||
|
getReactionContent,
|
||||||
|
reactionOrEditEvent,
|
||||||
|
} from '../../../utils/room';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { MessageLayout, settingsAtom } from '../../../state/settings';
|
||||||
|
import { Message, Reactions, EncryptedContent } from '../message';
|
||||||
|
import { RenderMessageContent } from '../../../components/RenderMessageContent';
|
||||||
|
import { Image } from '../../../components/media';
|
||||||
|
import { ImageViewer } from '../../../components/image-viewer';
|
||||||
|
import * as css from './ThreadTimeline.css';
|
||||||
|
import {
|
||||||
|
inSameDay,
|
||||||
|
minuteDifference,
|
||||||
|
timeDayMonthYear,
|
||||||
|
today,
|
||||||
|
yesterday,
|
||||||
|
} from '../../../utils/time';
|
||||||
|
import { createMentionElement, moveCursor } from '../../../components/editor';
|
||||||
|
import { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts';
|
||||||
|
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
|
||||||
|
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import {
|
||||||
|
getIntersectionObserverEntry,
|
||||||
|
useIntersectionObserver,
|
||||||
|
} from '../../../hooks/useIntersectionObserver';
|
||||||
|
import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
|
||||||
|
import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler';
|
||||||
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
|
||||||
|
import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
|
||||||
|
import { useIsDirectRoom } from '../../../hooks/useRoom';
|
||||||
|
import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
|
||||||
|
import { useSpaceOptionally } from '../../../hooks/useSpace';
|
||||||
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
|
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||||
|
import {
|
||||||
|
useAccessiblePowerTagColors,
|
||||||
|
useGetMemberPowerTag,
|
||||||
|
} from '../../../hooks/useMemberPowerTag';
|
||||||
|
import { useTheme } from '../../../hooks/useTheme';
|
||||||
|
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
|
||||||
|
import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
|
||||||
|
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||||
|
import { EditHistoryModal } from '../message/EditHistoryModal';
|
||||||
|
import {
|
||||||
|
getLinkedTimelines,
|
||||||
|
getTimelineAndBaseIndex,
|
||||||
|
getTimelineEvent,
|
||||||
|
getTimelineRelativeIndex,
|
||||||
|
getTimelinesEventsCount,
|
||||||
|
timelineToEventsCount,
|
||||||
|
} from '../RoomTimeline';
|
||||||
|
import { getThreadDraftKey } from '../../../state/room/thread';
|
||||||
|
import { useThreadLinkedTimelines, useThreadPendingEvents } from './useThread';
|
||||||
|
|
||||||
|
// Virtual window size (how many items render around the viewport).
|
||||||
|
const PAGINATION_LIMIT = 50;
|
||||||
|
// Network page size for backward /relations pagination of the thread timeline.
|
||||||
|
const THREAD_PAGE_LIMIT = 30;
|
||||||
|
|
||||||
|
type Timeline = {
|
||||||
|
linkedTimelines: EventTimeline[];
|
||||||
|
range: ItemRange;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEmptyTimeline = (): Timeline => ({
|
||||||
|
linkedTimelines: [],
|
||||||
|
range: { start: 0, end: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const getInitialThreadTimeline = (thread: Thread, timelines?: EventTimeline[]): Timeline => {
|
||||||
|
const linkedTimelines =
|
||||||
|
timelines && timelines.length > 0 ? timelines : getLinkedTimelines(thread.liveTimeline);
|
||||||
|
const evLength = getTimelinesEventsCount(linkedTimelines);
|
||||||
|
return {
|
||||||
|
linkedTimelines,
|
||||||
|
range: {
|
||||||
|
start: Math.max(evLength - PAGINATION_LIMIT, 0),
|
||||||
|
end: evLength,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy of RoomTimeline's `useTimelinePagination` pattern (not exported from RoomTimeline
|
||||||
|
* as its ~35 hooks are hardwired to the room live timeline). Works transparently against
|
||||||
|
* the thread timeline's /relations pagination.
|
||||||
|
*/
|
||||||
|
const useThreadTimelinePagination = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
timeline: Timeline,
|
||||||
|
setTimeline: Dispatch<SetStateAction<Timeline>>,
|
||||||
|
limit: number,
|
||||||
|
) => {
|
||||||
|
const timelineRef = useRef(timeline);
|
||||||
|
timelineRef.current = timeline;
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
|
const handleTimelinePagination = useMemo(() => {
|
||||||
|
let fetching = false;
|
||||||
|
|
||||||
|
const recalibratePagination = (
|
||||||
|
linkedTimelines: EventTimeline[],
|
||||||
|
timelinesEventsCount: number[],
|
||||||
|
backwards: boolean,
|
||||||
|
) => {
|
||||||
|
const topTimeline = linkedTimelines[0];
|
||||||
|
const timelineMatch = (mt: EventTimeline) => (t: EventTimeline) => t === mt;
|
||||||
|
|
||||||
|
const newLTimelines = getLinkedTimelines(topTimeline);
|
||||||
|
const topTmIndex = newLTimelines.findIndex(timelineMatch(topTimeline));
|
||||||
|
const topAddedTm = topTmIndex === -1 ? [] : newLTimelines.slice(0, topTmIndex);
|
||||||
|
|
||||||
|
const topTmAddedEvt =
|
||||||
|
timelineToEventsCount(newLTimelines[topTmIndex]) - timelinesEventsCount[0];
|
||||||
|
const offsetRange = getTimelinesEventsCount(topAddedTm) + (backwards ? topTmAddedEvt : 0);
|
||||||
|
|
||||||
|
setTimeline((currentTimeline) => ({
|
||||||
|
linkedTimelines: newLTimelines,
|
||||||
|
range:
|
||||||
|
offsetRange > 0
|
||||||
|
? {
|
||||||
|
start: currentTimeline.range.start + offsetRange,
|
||||||
|
end: currentTimeline.range.end + offsetRange,
|
||||||
|
}
|
||||||
|
: { ...currentTimeline.range },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return async (backwards: boolean) => {
|
||||||
|
if (fetching) return;
|
||||||
|
const { linkedTimelines: lTimelines } = timelineRef.current;
|
||||||
|
const timelinesEventsCount = lTimelines.map(timelineToEventsCount);
|
||||||
|
|
||||||
|
const timelineToPaginate = backwards ? lTimelines[0] : lTimelines[lTimelines.length - 1];
|
||||||
|
if (!timelineToPaginate) return;
|
||||||
|
|
||||||
|
const paginationToken = timelineToPaginate.getPaginationToken(
|
||||||
|
backwards ? Direction.Backward : Direction.Forward,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!paginationToken &&
|
||||||
|
getTimelinesEventsCount(lTimelines) !==
|
||||||
|
getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate))
|
||||||
|
) {
|
||||||
|
recalibratePagination(lTimelines, timelinesEventsCount, backwards);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetching = true;
|
||||||
|
const [err] = await to(
|
||||||
|
mx.paginateEventTimeline(timelineToPaginate, {
|
||||||
|
backwards,
|
||||||
|
limit,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (err) {
|
||||||
|
fetching = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fetchedTimeline =
|
||||||
|
timelineToPaginate.getNeighbouringTimeline(
|
||||||
|
backwards ? Direction.Backward : Direction.Forward,
|
||||||
|
) ?? timelineToPaginate;
|
||||||
|
// Decrypt all event ahead of render cycle
|
||||||
|
const roomId = fetchedTimeline.getRoomId();
|
||||||
|
const room = roomId ? mx.getRoom(roomId) : null;
|
||||||
|
|
||||||
|
if (room?.hasEncryptionStateEvent()) {
|
||||||
|
await to(decryptAllTimelineEvent(mx, fetchedTimeline));
|
||||||
|
}
|
||||||
|
|
||||||
|
fetching = false;
|
||||||
|
if (alive()) {
|
||||||
|
recalibratePagination(lTimelines, timelinesEventsCount, backwards);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [mx, alive, setTimeline, limit]);
|
||||||
|
return handleTimelinePagination;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThreadTimelineProps = {
|
||||||
|
room: Room;
|
||||||
|
thread: Thread;
|
||||||
|
editor: Editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const alive = useAlive();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
|
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
|
||||||
|
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||||||
|
const [perMessageProfiles] = useSetting(settingsAtom, 'perMessageProfiles');
|
||||||
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||||
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
|
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
|
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||||
|
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||||
|
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
|
const direct = useIsDirectRoom();
|
||||||
|
const ignoredUsersList = useIgnoredUsers();
|
||||||
|
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
||||||
|
|
||||||
|
const setReplyDraft = useSetAtom(
|
||||||
|
roomIdToReplyDraftAtomFamily(getThreadDraftKey(room.roomId, thread.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const powerLevels = usePowerLevelsContext();
|
||||||
|
const creators = useRoomCreators(room);
|
||||||
|
const creatorsTag = useRoomCreatorsTag();
|
||||||
|
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
||||||
|
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
|
const accessiblePowerTagColors = useAccessiblePowerTagColors(
|
||||||
|
theme.kind,
|
||||||
|
creatorsTag,
|
||||||
|
powerLevelTags,
|
||||||
|
);
|
||||||
|
|
||||||
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
|
const canRedact = permissions.action('redact', mx.getSafeUserId());
|
||||||
|
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
|
||||||
|
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
|
||||||
|
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
|
||||||
|
|
||||||
|
const mentionClickHandler = useMentionClickHandler(room.roomId);
|
||||||
|
const spoilerClickHandler = useSpoilerClickHandler();
|
||||||
|
const openUserRoomProfile = useOpenUserRoomProfile();
|
||||||
|
const space = useSpaceOptionally();
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
||||||
|
|
||||||
|
const [editId, setEditId] = useState<string>();
|
||||||
|
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
|
||||||
|
|
||||||
|
const linkifyOpts = useMemo<LinkifyOpts>(
|
||||||
|
() => ({
|
||||||
|
...LINKIFY_OPTS,
|
||||||
|
render: factoryRenderLinkifyWithMention((href) =>
|
||||||
|
renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[mx, room, mentionClickHandler],
|
||||||
|
);
|
||||||
|
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
|
||||||
|
() =>
|
||||||
|
getReactCustomHtmlParser(mx, room.roomId, {
|
||||||
|
linkifyOpts,
|
||||||
|
useAuthentication,
|
||||||
|
handleSpoilerClick: spoilerClickHandler,
|
||||||
|
handleMentionClick: mentionClickHandler,
|
||||||
|
}),
|
||||||
|
[mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { timelines, ready } = useThreadLinkedTimelines(mx, thread);
|
||||||
|
const pendingEvents = useThreadPendingEvents(room, thread.id, thread);
|
||||||
|
|
||||||
|
const [timeline, setTimeline] = useState<Timeline>(() =>
|
||||||
|
ready ? getInitialThreadTimeline(thread, timelines) : getEmptyTimeline(),
|
||||||
|
);
|
||||||
|
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
|
||||||
|
|
||||||
|
const canPaginateBack =
|
||||||
|
typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string';
|
||||||
|
const rangeAtStart = timeline.range.start === 0;
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const atBottomAnchorRef = useRef<HTMLElement>(null);
|
||||||
|
const [atBottom, setAtBottom] = useState(true);
|
||||||
|
const atBottomRef = useRef(atBottom);
|
||||||
|
atBottomRef.current = atBottom;
|
||||||
|
const scrollToBottomRef = useRef({ count: 0, smooth: true });
|
||||||
|
|
||||||
|
const handleTimelinePagination = useThreadTimelinePagination(
|
||||||
|
mx,
|
||||||
|
timeline,
|
||||||
|
setTimeline,
|
||||||
|
THREAD_PAGE_LIMIT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const getScrollElement = useCallback(() => scrollRef.current, []);
|
||||||
|
|
||||||
|
const { getItems, scrollToItem, observeBackAnchor } = useVirtualPaginator({
|
||||||
|
count: eventsLength,
|
||||||
|
limit: PAGINATION_LIMIT,
|
||||||
|
range: timeline.range,
|
||||||
|
onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
|
||||||
|
getScrollElement,
|
||||||
|
getItemElement: useCallback(
|
||||||
|
(index: number) =>
|
||||||
|
(scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
|
||||||
|
undefined,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
onEnd: handleTimelinePagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed local timeline once the thread has fetched its initial events.
|
||||||
|
const seededRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready || seededRef.current) return;
|
||||||
|
seededRef.current = true;
|
||||||
|
setTimeline(getInitialThreadTimeline(thread, timelines));
|
||||||
|
scrollToBottomRef.current.count += 1;
|
||||||
|
scrollToBottomRef.current.smooth = false;
|
||||||
|
if (room.hasEncryptionStateEvent()) {
|
||||||
|
to(decryptAllTimelineEvent(mx, thread.liveTimeline)).then(() => {
|
||||||
|
if (alive()) setTimeline((ct) => ({ ...ct }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- seed once when ready flips
|
||||||
|
}, [ready, thread]);
|
||||||
|
|
||||||
|
// Re-render / stick-to-bottom on live thread activity.
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
|
||||||
|
mEvent,
|
||||||
|
eventRoom,
|
||||||
|
toStartOfTimeline,
|
||||||
|
removed,
|
||||||
|
data,
|
||||||
|
) => {
|
||||||
|
if (!data?.liveEvent) return;
|
||||||
|
if (atBottomRef.current) {
|
||||||
|
scrollToBottomRef.current.count += 1;
|
||||||
|
scrollToBottomRef.current.smooth = true;
|
||||||
|
setTimeline((ct) => ({
|
||||||
|
...ct,
|
||||||
|
range: {
|
||||||
|
start: ct.range.start + 1,
|
||||||
|
end: ct.range.end + 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeline((ct) => ({ ...ct }));
|
||||||
|
};
|
||||||
|
const handleUpdate = () => setTimeline((ct) => ({ ...ct }));
|
||||||
|
// A gappy sync / updateThreadMetadata resets the thread's live timeline —
|
||||||
|
// the stored linkedTimelines would then point at a detached timeline, so
|
||||||
|
// reseed the window from the fresh liveTimeline.
|
||||||
|
const handleReset = () => {
|
||||||
|
setTimeline(getInitialThreadTimeline(thread, getLinkedTimelines(thread.liveTimeline)));
|
||||||
|
scrollToBottomRef.current.count += 1;
|
||||||
|
scrollToBottomRef.current.smooth = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
thread.on(RoomEvent.Timeline, handleTimeline);
|
||||||
|
thread.on(ThreadEvent.Update, handleUpdate);
|
||||||
|
thread.on(RoomEvent.TimelineReset, handleReset);
|
||||||
|
return () => {
|
||||||
|
thread.removeListener(RoomEvent.Timeline, handleTimeline);
|
||||||
|
thread.removeListener(ThreadEvent.Update, handleUpdate);
|
||||||
|
thread.removeListener(RoomEvent.TimelineReset, handleReset);
|
||||||
|
};
|
||||||
|
}, [thread]);
|
||||||
|
|
||||||
|
// atBottom detection
|
||||||
|
useIntersectionObserver(
|
||||||
|
useCallback((entries) => {
|
||||||
|
const target = atBottomAnchorRef.current;
|
||||||
|
if (!target) return;
|
||||||
|
const entry = getIntersectionObserverEntry(target, entries);
|
||||||
|
if (entry) setAtBottom(entry.isIntersecting);
|
||||||
|
}, []),
|
||||||
|
useCallback(
|
||||||
|
() => ({
|
||||||
|
root: getScrollElement(),
|
||||||
|
rootMargin: '100px',
|
||||||
|
}),
|
||||||
|
[getScrollElement],
|
||||||
|
),
|
||||||
|
useCallback(() => atBottomAnchorRef.current, []),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial scroll to bottom on mount.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (scrollEl) scrollToBottom(scrollEl);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Scroll to bottom when requested.
|
||||||
|
const scrollToBottomCount = scrollToBottomRef.current.count;
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (scrollToBottomCount > 0) {
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (scrollEl)
|
||||||
|
scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
|
||||||
|
}
|
||||||
|
}, [scrollToBottomCount]);
|
||||||
|
|
||||||
|
const handleJumpToBottom = useCallback(() => {
|
||||||
|
scrollToBottomRef.current.count += 1;
|
||||||
|
scrollToBottomRef.current.smooth = true;
|
||||||
|
// Flip atBottom so the layout effect re-runs (count re-read) and live
|
||||||
|
// events resume sticking to the bottom.
|
||||||
|
setAtBottom(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Scroll in-place editor into view.
|
||||||
|
useEffect(() => {
|
||||||
|
if (editId) {
|
||||||
|
const editMsgElement =
|
||||||
|
(scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
|
||||||
|
undefined;
|
||||||
|
editMsgElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [editId]);
|
||||||
|
|
||||||
|
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
const userId = evt.currentTarget.getAttribute('data-user-id');
|
||||||
|
if (!userId) return;
|
||||||
|
openUserRoomProfile(
|
||||||
|
room.roomId,
|
||||||
|
space?.roomId,
|
||||||
|
userId,
|
||||||
|
evt.currentTarget.getBoundingClientRect(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[room, space, openUserRoomProfile],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const userId = evt.currentTarget.getAttribute('data-user-id');
|
||||||
|
if (!userId) return;
|
||||||
|
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||||
|
editor.insertNode(
|
||||||
|
createMentionElement(
|
||||||
|
userId,
|
||||||
|
name.startsWith('@') ? name : `@${name}`,
|
||||||
|
userId === mx.getUserId(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
moveCursor(editor);
|
||||||
|
},
|
||||||
|
[mx, room, editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
const replyId = evt.currentTarget.getAttribute('data-event-id');
|
||||||
|
if (!replyId) return;
|
||||||
|
const replyEvt = thread.findEventById(replyId) ?? room.findEventById(replyId);
|
||||||
|
if (!replyEvt) return;
|
||||||
|
const editedReply = getEditedEvent(replyId, replyEvt, thread.getUnfilteredTimelineSet());
|
||||||
|
const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
||||||
|
const { body, formatted_body: formattedBody } = content;
|
||||||
|
const senderId = replyEvt.getSender();
|
||||||
|
if (senderId && typeof body === 'string') {
|
||||||
|
setReplyDraft({
|
||||||
|
userId: senderId,
|
||||||
|
eventId: replyId,
|
||||||
|
body,
|
||||||
|
formattedBody,
|
||||||
|
relation: { rel_type: RelationType.Thread, event_id: thread.id },
|
||||||
|
});
|
||||||
|
setTimeout(() => ReactEditor.focus(editor), 100);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[room, thread, setReplyDraft, editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReactionToggle = useCallback(
|
||||||
|
(targetEventId: string, key: string, shortcode?: string) => {
|
||||||
|
const timelineSet = thread.getUnfilteredTimelineSet();
|
||||||
|
const relations = getEventReactions(timelineSet, targetEventId);
|
||||||
|
const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
|
||||||
|
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
|
||||||
|
const reactions = reactionsSet ? Array.from(reactionsSet) : [];
|
||||||
|
const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!));
|
||||||
|
|
||||||
|
if (myReaction && !!myReaction.isRelation()) {
|
||||||
|
mx.redactEvent(room.roomId, myReaction.getId()!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rShortcode =
|
||||||
|
shortcode ||
|
||||||
|
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
|
||||||
|
mx.sendEvent(
|
||||||
|
room.roomId,
|
||||||
|
thread.id,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
MessageEvent.Reaction as any,
|
||||||
|
getReactionContent(targetEventId, key, rShortcode),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[mx, room, thread],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEdit = useCallback(
|
||||||
|
(editEvtId?: string) => {
|
||||||
|
if (editEvtId) {
|
||||||
|
setEditId(editEvtId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditId(undefined);
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenReply: MouseEventHandler = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
const targetId = evt.currentTarget.getAttribute('data-event-id');
|
||||||
|
if (!targetId) return;
|
||||||
|
// best-effort: scroll to referenced event if it is inside the loaded thread window
|
||||||
|
let absIndex = -1;
|
||||||
|
let acc = 0;
|
||||||
|
timeline.linkedTimelines.some((tl) => {
|
||||||
|
const idx = tl.getEvents().findIndex((e) => e.getId() === targetId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
absIndex = acc + idx;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
acc += tl.getEvents().length;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (absIndex >= 0) {
|
||||||
|
scrollToItem(absIndex, {
|
||||||
|
behavior: 'smooth',
|
||||||
|
align: 'center',
|
||||||
|
stopInView: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[timeline.linkedTimelines, scrollToItem],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderMessageContent = useCallback(
|
||||||
|
(mEvent: MatrixEvent, mEventId: string, timelineSet: EventTimelineSet): ReactNode => {
|
||||||
|
// Evaluated lazily so EncryptedContent can re-run it (re-reading getType())
|
||||||
|
// after MatrixEventEvent.Decrypted fires — decryption re-emits NEITHER
|
||||||
|
// RoomEvent.Timeline nor ThreadEvent.Update, so without this wrapper a
|
||||||
|
// live-arriving encrypted reply would show "Unable to decrypt" forever.
|
||||||
|
const renderByType = (): ReactNode => {
|
||||||
|
if (mEvent.isRedacted()) {
|
||||||
|
return <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />;
|
||||||
|
}
|
||||||
|
const type = mEvent.getType();
|
||||||
|
if (type === MessageEvent.Sticker) {
|
||||||
|
return (
|
||||||
|
<MSticker
|
||||||
|
content={mEvent.getContent()}
|
||||||
|
renderImageContent={(props) => (
|
||||||
|
<ImageContent
|
||||||
|
{...props}
|
||||||
|
autoPlay={mediaAutoLoad}
|
||||||
|
renderImage={(p) => <Image {...p} loading="lazy" />}
|
||||||
|
renderViewer={(p) => <ImageViewer {...p} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (type === MessageEvent.RoomMessageEncrypted) {
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
<MessageNotDecryptedContent />
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (type !== MessageEvent.RoomMessage) {
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
<MessageUnsupportedContent />
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
|
||||||
|
const getContent = (() =>
|
||||||
|
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
|
||||||
|
const senderId = mEvent.getSender() ?? '';
|
||||||
|
const senderDisplayName =
|
||||||
|
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
||||||
|
return (
|
||||||
|
<RenderMessageContent
|
||||||
|
displayName={senderDisplayName}
|
||||||
|
msgType={mEvent.getContent().msgtype ?? ''}
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
edited={!!editedEvent}
|
||||||
|
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
|
||||||
|
getContent={getContent}
|
||||||
|
mediaAutoLoad={mediaAutoLoad}
|
||||||
|
urlPreview={showUrlPreview}
|
||||||
|
htmlReactParserOptions={htmlReactParserOptions}
|
||||||
|
linkifyOpts={linkifyOpts}
|
||||||
|
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||||
|
eventId={mEventId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) {
|
||||||
|
return <EncryptedContent mEvent={mEvent}>{renderByType}</EncryptedContent>;
|
||||||
|
}
|
||||||
|
return renderByType();
|
||||||
|
},
|
||||||
|
[room, mediaAutoLoad, showUrlPreview, htmlReactParserOptions, linkifyOpts, messageLayout],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderMessage = useCallback(
|
||||||
|
(
|
||||||
|
mEvent: MatrixEvent,
|
||||||
|
opts: { item?: number; collapse: boolean; highlight: boolean; editable: boolean },
|
||||||
|
): ReactNode => {
|
||||||
|
const mEventId = mEvent.getId();
|
||||||
|
if (!mEventId) return null;
|
||||||
|
const timelineSet = thread.getUnfilteredTimelineSet();
|
||||||
|
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
||||||
|
const reactions = reactionRelations?.getSortedAnnotationsByKey();
|
||||||
|
const hasReactions = !!reactions && reactions.length > 0;
|
||||||
|
const { replyEventId, threadRootId } = mEvent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Message
|
||||||
|
key={mEventId}
|
||||||
|
data-message-item={opts.item}
|
||||||
|
data-message-id={mEventId}
|
||||||
|
room={room}
|
||||||
|
mEvent={mEvent}
|
||||||
|
messageSpacing={messageSpacing}
|
||||||
|
messageLayout={messageLayout}
|
||||||
|
collapse={opts.collapse}
|
||||||
|
highlight={opts.highlight}
|
||||||
|
edit={opts.editable && editId === mEventId}
|
||||||
|
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||||
|
canSendReaction={canSendReaction}
|
||||||
|
canPinEvent={canPinEvent}
|
||||||
|
imagePackRooms={imagePackRooms}
|
||||||
|
relations={hasReactions ? reactionRelations : undefined}
|
||||||
|
onUserClick={handleUserClick}
|
||||||
|
onUsernameClick={handleUsernameClick}
|
||||||
|
onReplyClick={handleReplyClick}
|
||||||
|
onReactionToggle={handleReactionToggle}
|
||||||
|
onEditId={opts.editable ? handleEdit : undefined}
|
||||||
|
reply={
|
||||||
|
replyEventId && (
|
||||||
|
<Reply
|
||||||
|
room={room}
|
||||||
|
timelineSet={timelineSet}
|
||||||
|
replyEventId={replyEventId}
|
||||||
|
threadRootId={threadRootId}
|
||||||
|
onClick={handleOpenReply}
|
||||||
|
getMemberPowerTag={getMemberPowerTag}
|
||||||
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
reactions={
|
||||||
|
reactionRelations && (
|
||||||
|
<Reactions
|
||||||
|
style={{ marginTop: config.space.S200 }}
|
||||||
|
room={room}
|
||||||
|
relations={reactionRelations}
|
||||||
|
mEventId={mEventId}
|
||||||
|
canSendReaction={canSendReaction}
|
||||||
|
onReactionToggle={handleReactionToggle}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
|
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
|
||||||
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
lotusTerminal={!!lotusTerminal}
|
||||||
|
>
|
||||||
|
{renderMessageContent(mEvent, mEventId, timelineSet)}
|
||||||
|
</Message>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
thread,
|
||||||
|
room,
|
||||||
|
messageSpacing,
|
||||||
|
messageLayout,
|
||||||
|
editId,
|
||||||
|
canRedact,
|
||||||
|
canDeleteOwn,
|
||||||
|
canSendReaction,
|
||||||
|
canPinEvent,
|
||||||
|
imagePackRooms,
|
||||||
|
handleUserClick,
|
||||||
|
handleUsernameClick,
|
||||||
|
handleReplyClick,
|
||||||
|
handleReactionToggle,
|
||||||
|
handleEdit,
|
||||||
|
handleOpenReply,
|
||||||
|
getMemberPowerTag,
|
||||||
|
accessiblePowerTagColors,
|
||||||
|
legacyUsernameColor,
|
||||||
|
direct,
|
||||||
|
hideActivity,
|
||||||
|
showDeveloperTools,
|
||||||
|
hour24Clock,
|
||||||
|
dateFormatString,
|
||||||
|
lotusTerminal,
|
||||||
|
mx,
|
||||||
|
renderMessageContent,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let prevEvent: MatrixEvent | undefined;
|
||||||
|
let isPrevRendered = false;
|
||||||
|
let dayDivider = false;
|
||||||
|
const eventRenderer = (item: number) => {
|
||||||
|
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
|
||||||
|
if (!eventTimeline) return null;
|
||||||
|
const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
|
||||||
|
const mEventId = mEvent?.getId();
|
||||||
|
if (!mEvent || !mEventId) return null;
|
||||||
|
|
||||||
|
// Skip annotations, edits, and any state/membership events (they can't be threaded).
|
||||||
|
if (reactionOrEditEvent(mEvent) || typeof mEvent.getStateKey() === 'string') {
|
||||||
|
prevEvent = mEvent;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const eventSender = mEvent.getSender();
|
||||||
|
if (eventSender && ignoredUsersSet.has(eventSender)) {
|
||||||
|
prevEvent = mEvent;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRoot = mEventId === thread.id;
|
||||||
|
|
||||||
|
if (!dayDivider) {
|
||||||
|
dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapsed =
|
||||||
|
!isRoot &&
|
||||||
|
!perMessageProfiles &&
|
||||||
|
isPrevRendered &&
|
||||||
|
!dayDivider &&
|
||||||
|
prevEvent !== undefined &&
|
||||||
|
prevEvent.getId() !== thread.id &&
|
||||||
|
prevEvent.getSender() === eventSender &&
|
||||||
|
prevEvent.getType() === mEvent.getType() &&
|
||||||
|
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 5;
|
||||||
|
|
||||||
|
const eventJSX = renderMessage(mEvent, {
|
||||||
|
item,
|
||||||
|
collapse: collapsed,
|
||||||
|
highlight: false,
|
||||||
|
editable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dayDividerJSX =
|
||||||
|
dayDivider && eventJSX && !isRoot ? (
|
||||||
|
<MessageBase space={messageSpacing}>
|
||||||
|
<Box gap="100" justifyContent="Center" alignItems="Center">
|
||||||
|
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
||||||
|
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
|
||||||
|
<Text size="L400">
|
||||||
|
{(() => {
|
||||||
|
if (today(mEvent.getTs())) return 'Today';
|
||||||
|
if (yesterday(mEvent.getTs())) return 'Yesterday';
|
||||||
|
return timeDayMonthYear(mEvent.getTs());
|
||||||
|
})()}
|
||||||
|
</Text>
|
||||||
|
</Badge>
|
||||||
|
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
||||||
|
</Box>
|
||||||
|
</MessageBase>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
prevEvent = mEvent;
|
||||||
|
isPrevRendered = !!eventJSX;
|
||||||
|
if (dayDividerJSX) dayDivider = false;
|
||||||
|
|
||||||
|
// Root gets an emphasized container + a "N replies" divider under it.
|
||||||
|
if (isRoot && eventJSX) {
|
||||||
|
const replyCount = thread.length;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={mEventId}>
|
||||||
|
<div className={css.RootMessage}>{eventJSX}</div>
|
||||||
|
{replyCount > 0 && (
|
||||||
|
<Box
|
||||||
|
className={css.RepliesDivider}
|
||||||
|
gap="100"
|
||||||
|
justifyContent="Center"
|
||||||
|
alignItems="Center"
|
||||||
|
>
|
||||||
|
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
||||||
|
<Text size="L400" priority="300">
|
||||||
|
{replyCount === 1 ? '1 reply' : `${replyCount} replies`}
|
||||||
|
</Text>
|
||||||
|
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventJSX && dayDividerJSX) {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={mEventId}>
|
||||||
|
{dayDividerJSX}
|
||||||
|
{eventJSX}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventJSX;
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = getItems();
|
||||||
|
const showEmptyReplies = ready && thread.length === 0;
|
||||||
|
|
||||||
|
const renderPendingEvent = (mEvent: MatrixEvent) => {
|
||||||
|
const failed =
|
||||||
|
mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={mEvent.getId() ?? mEvent.getTxnId()}
|
||||||
|
className={classNames(failed ? css.PendingFailed : css.PendingMessage)}
|
||||||
|
>
|
||||||
|
{renderMessage(mEvent, { collapse: false, highlight: false, editable: false })}
|
||||||
|
{failed && (
|
||||||
|
<Box style={{ padding: `0 ${config.space.S400}` }}>
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
Failed to send
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={css.ThreadCentered}
|
||||||
|
grow="Yes"
|
||||||
|
direction="Column"
|
||||||
|
justifyContent="Center"
|
||||||
|
alignItems="Center"
|
||||||
|
>
|
||||||
|
<Spinner variant="Secondary" size="600" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={css.ThreadTimeline} grow="Yes">
|
||||||
|
<Scroll ref={scrollRef} visibility="Hover">
|
||||||
|
<Box
|
||||||
|
className={css.ThreadTimelineContent}
|
||||||
|
direction="Column"
|
||||||
|
justifyContent="End"
|
||||||
|
role="log"
|
||||||
|
aria-label="Thread timeline"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{(canPaginateBack || !rangeAtStart) && (
|
||||||
|
<>
|
||||||
|
<MessageBase>
|
||||||
|
<DefaultPlaceholder />
|
||||||
|
</MessageBase>
|
||||||
|
<MessageBase ref={observeBackAnchor}>
|
||||||
|
<DefaultPlaceholder />
|
||||||
|
</MessageBase>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.map(eventRenderer)}
|
||||||
|
|
||||||
|
{showEmptyReplies && (
|
||||||
|
<Box className={css.NoReplies} justifyContent="Center">
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
No replies yet — say something
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pendingEvents.map(renderPendingEvent)}
|
||||||
|
|
||||||
|
<span ref={atBottomAnchorRef} />
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
{!atBottom && (
|
||||||
|
<Box className={css.ThreadTimelineFloat} justifyContent="Center" alignItems="Center">
|
||||||
|
<Chip
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="Pill"
|
||||||
|
outlined
|
||||||
|
before={<Icon size="50" src={Icons.ArrowBottom} />}
|
||||||
|
onClick={handleJumpToBottom}
|
||||||
|
>
|
||||||
|
<Text size="L400">Jump to Latest</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{editHistoryEvent && (
|
||||||
|
<EditHistoryModal
|
||||||
|
room={room}
|
||||||
|
mEvent={editHistoryEvent}
|
||||||
|
onClose={() => setEditHistoryEvent(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './ThreadPanel';
|
||||||
|
export * from './ThreadSummary';
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventStatus, MatrixEvent, RelationType } from 'matrix-js-sdk';
|
||||||
|
import { getThreadSummary, isPendingThreadReply } from './threadSummaryData';
|
||||||
|
|
||||||
|
// getThreadSummary reads either the live Thread (preferred) or the
|
||||||
|
// server-aggregated `m.thread` bundle. We stub only the members it touches and
|
||||||
|
// cast through `unknown` to MatrixEvent, mirroring the light mocking used in
|
||||||
|
// the state tests.
|
||||||
|
|
||||||
|
type ThreadStub = { length: number; lastReplyTs?: number };
|
||||||
|
type BundleStub = { count: number; latestTs?: number };
|
||||||
|
|
||||||
|
const makeRootEvent = (opts: { thread?: ThreadStub; bundle?: BundleStub }): MatrixEvent => {
|
||||||
|
const thread = opts.thread
|
||||||
|
? {
|
||||||
|
length: opts.thread.length,
|
||||||
|
lastReply: () =>
|
||||||
|
opts.thread?.lastReplyTs === undefined
|
||||||
|
? null
|
||||||
|
: ({ getTs: () => opts.thread?.lastReplyTs } as unknown as MatrixEvent),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
getThread: () => thread,
|
||||||
|
getServerAggregatedRelation: (relType: string) => {
|
||||||
|
if (relType !== RelationType.Thread || !opts.bundle) return undefined;
|
||||||
|
return {
|
||||||
|
count: opts.bundle.count,
|
||||||
|
latest_event:
|
||||||
|
opts.bundle.latestTs === undefined
|
||||||
|
? undefined
|
||||||
|
: { origin_server_ts: opts.bundle.latestTs },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as unknown as MatrixEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getThreadSummary
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('prefers the live thread: count from length, latestTs from lastReply', () => {
|
||||||
|
const rootEvent = makeRootEvent({
|
||||||
|
thread: { length: 3, lastReplyTs: 1700 },
|
||||||
|
bundle: { count: 99, latestTs: 1 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(getThreadSummary(rootEvent), { count: 3, latestTs: 1700 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('live thread with no replies yields undefined latestTs', () => {
|
||||||
|
const rootEvent = makeRootEvent({ thread: { length: 0 } });
|
||||||
|
assert.deepEqual(getThreadSummary(rootEvent), { count: 0, latestTs: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the server bundle when no live thread', () => {
|
||||||
|
const rootEvent = makeRootEvent({ bundle: { count: 5, latestTs: 1234 } });
|
||||||
|
assert.deepEqual(getThreadSummary(rootEvent), { count: 5, latestTs: 1234 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bundle without latest_event yields undefined latestTs', () => {
|
||||||
|
const rootEvent = makeRootEvent({ bundle: { count: 2 } });
|
||||||
|
assert.deepEqual(getThreadSummary(rootEvent), { count: 2, latestTs: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined when there is neither a thread nor a bundle', () => {
|
||||||
|
const rootEvent = makeRootEvent({});
|
||||||
|
assert.equal(getThreadSummary(rootEvent), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// isPendingThreadReply
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ROOT = '$root:server';
|
||||||
|
|
||||||
|
const makeReply = (opts: {
|
||||||
|
status: EventStatus | null;
|
||||||
|
threadRootId?: string;
|
||||||
|
relation?: { rel_type?: string; event_id?: string } | null;
|
||||||
|
}): MatrixEvent =>
|
||||||
|
({
|
||||||
|
status: opts.status,
|
||||||
|
threadRootId: opts.threadRootId,
|
||||||
|
getRelation: () => opts.relation ?? null,
|
||||||
|
}) as unknown as MatrixEvent;
|
||||||
|
|
||||||
|
test('SENDING with matching threadRootId is pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.SENDING, threadRootId: ROOT });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NOT_SENT with matching threadRootId is pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.NOT_SENT, threadRootId: ROOT });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENDING resolved via the m.thread relation content is pending', () => {
|
||||||
|
const event = makeReply({
|
||||||
|
status: EventStatus.SENDING,
|
||||||
|
relation: { rel_type: RelationType.Thread, event_id: ROOT },
|
||||||
|
});
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENT (confirmed) event is not pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.SENT, threadRootId: ROOT });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null status is not pending', () => {
|
||||||
|
const event = makeReply({ status: null, threadRootId: ROOT });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENDING but for a different thread is not pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.SENDING, threadRootId: '$other:server' });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENDING with a non-thread relation is not pending', () => {
|
||||||
|
const event = makeReply({
|
||||||
|
status: EventStatus.SENDING,
|
||||||
|
relation: { rel_type: RelationType.Reference, event_id: ROOT },
|
||||||
|
});
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENDING with no relation and no threadRootId is not pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.SENDING });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user