Compare commits

...

25 Commits

Author SHA1 Message Date
jared 36343baecc call: lint/format cleanup for lotus EC wiring
CI / Build & Quality Checks (push) Successful in 10m27s
CI / Trigger Desktop Build (push) Successful in 25s
Resolve the eslint/prettier failures from the previous commit (non-blocking
in CI, but real): drop the banned `void` operator on fire-and-forget
transport.send().catch() calls, prefix the now-unused _denoiseNativeNS
param, and run prettier on the touched files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:52:45 -04:00
jared 89cf171efc call: consume self-built Element Call fork + activate Lotus features
CI / Build & Quality Checks (push) Successful in 11m5s
CI / Trigger Desktop Build (push) Successful in 25s
Switch to @lotusguild/element-call-embedded@0.20.1-lotus.1 (our self-built
fork) and turn on the source-level features it adds:

- #1 denoise CUTOVER: in-source ML denoise (lotusDenoiseSource=1) replaces
  the build-time getUserMedia shim — removed the shim injection from
  vite.config.js (denoise/ assets still shipped; the processor loads them).
  Survives reconnects (fixes A7).
- #2 call-state: CallEmbed consumes io.lotus.call_state; useCallSpeakers /
  useRemoteAllMuted prefer it over scraping EC's DOM (DOM fallback kept;
  empty payloads ignored).
- #4 focus: CallControl.focusCameraParticipant sends io.lotus.focus_participant
  (works during screenshare), replacing the DOM tile-click hack.
- #5 theming: lotusTransparent=1 (native transparent background).
- #6 decorations: LotusDecorationPusher sends each member's decoration URL
  via io.lotus.decorations -> rendered on in-call tiles.

#3 soundboard / #7 quality ship dormant (EC-ready; no host UI sends them yet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:33:52 -04:00
jared 149ec8e4e4 docs: add Element Call fork handoff + tag all EC-FORK references
CI / Build & Quality Checks (push) Successful in 10m28s
CI / Trigger Desktop Build (push) Successful in 7s
Captures the plan to fork element-hq/element-call and build it from source for
true ownership of the in-call experience (decorations, focus/screenshare,
reconnect mic, native theming, call-audio injection) — none of which are fixable
against the prebuilt @element-hq/element-call-embedded bundle we ship today.

- New HANDOFF_ELEMENT_CALL_FORK.md: self-contained plan for a fresh session
  (current architecture, full file inventory, phases, new-repo decision, the
  denoise-shim interaction, doc corrections).
- Tagged every related note with [EC-FORK] + links: README (For Developers),
  LOTUS_BUGS (EC limitations), LOTUS_TODO (soundboard, denoise, soundboard
  cross-origin correction), LOTUS_FEATURES (call section).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 20:50:10 -04:00
jared d1cd963e4b docs(bugs): record live-test results + EC iframe limitations
Verified passing: A2, B1-B4, C1, C3, D. Re-fixed and awaiting re-test: A1
(ringtone loudness), A3/A4 (caller decline notice), G1 (All-muted badge).
Documented A5/A6/A7 as known Element Call iframe-boundary limitations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:23:13 -04:00
jared 5ef0a1fd3e fix(call): ringtone loudness, caller decline notice, All-Muted badge
Three issues from live testing:
- A1: the 'classic' ringtone (call.ogg, mastered near full scale) was much
  louder than the synthesized styles. Attenuate it (CLASSIC_GAIN 0.45) so all
  ringtones sit at a comparable level.
- A3/A4: the caller had no indication when a DM/group callee declined — their
  UI kept "ringing" until the notification lifetime expired. IncomingCallListener
  now listens for RTCDecline events for a call we're hosting in the room and
  toasts the caller ("<name> declined your call").
- G1: the PiP "All muted" badge fired when any single remote participant muted.
  useRemoteAllMuted now returns true only when there is >=1 remote and every
  remote participant is muted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:13:40 -04:00
jared 6ace96f2cf docs(bugs): native-cinny audit fully closed (nits done)
CI / Build & Quality Checks (push) Successful in 10m32s
CI / Trigger Desktop Build (push) Successful in 20s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:15:49 -04:00
jared 2d71f2ce30 refactor(ui): name the global overlay z-index layers (native-cinny nit)
Centralized the global floating-UI stacking values into styles/zIndex.ts
(inCallBanner 9990 < seasonalEffect 9997 < nightLight 9998 < toast 10001;
folds modals sit at 9999 between). Same values, no behavior change — just
removes the magic numbers and documents the layering so future overlays don't
collide. Component-internal small z-index stays local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:10:29 -04:00
jared 2c3dba55e6 fix(ui): use folds Text priority instead of raw opacity (native-cinny nit)
Replaced raw style={{ opacity: N }} de-emphasis on folds <Text> with the
`priority` prop across search, schedule, profile, and tray UI. Left the cases
that aren't Text-priority candidates (an Icon opacity, a Box-row opacity, and a
Text with an explicit color token).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:44:57 -04:00
jared c7a04dcc70 fix(ui): poll checkmark uses folds Icon instead of Unicode glyph (native-cinny nit)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:20:52 -04:00
jared 4b14c15518 docs(bugs): timezone select + lightbox done; only native-cinny nits remain
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:49:26 -04:00
jared c68ef346bf fix(ui): MediaGallery lightbox uses folds Overlay + FocusTrap (native-cinny audit 8/N)
The full-screen media viewer was a raw <div role="dialog"> rendered in place
with manual focus. Wrapped it in folds Overlay (portal + backdrop, proper
stacking) and FocusTrap (focus management), keeping its own arrow/Escape key
handling. The light-on-dark chrome (#fff over the forced-black media stage) is
kept — it's a justified, always-dark media-viewer scrim, not theme chrome.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:49:24 -04:00
jared c5d7fcc303 fix(ui): timezone picker uses folds SettingsSelect (native-cinny audit 7/N)
Replaced the last raw native <select> (Profile timezone, colorScheme:'dark')
with SettingsSelect. Added an optional `disabled` prop to SettingsSelect for
the saving state. handleSubmit reads the `timezone` state (not the native form
field) so submission is unaffected; the now-unused handleSelectChange was
removed. No raw <select> elements remain in the settings UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:51:13 -04:00
jared 9bf56d5748 docs(bugs): track remaining native-cinny polish items
CI / Build & Quality Checks (push) Successful in 10m31s
CI / Trigger Desktop Build (push) Successful in 29s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:44:27 -04:00
jared d5ce56930b refactor(ui): extract shared SettingsSelect; replace raw <select> (native-cinny audit 6/N)
Extracted the folds-native dropdown (Button+PopOut+Menu) from General.tsx into a
shared components/settings-select/SettingsSelect.tsx, and used it to replace raw
native <select> elements (which render OS-styled and broke under non-default
themes via colorScheme:'dark'):
- Profile "auto-clear after" select
- PushRuleEditor add-rule mode select (dropped the now-unused handleModeChange)

The form-tied timezone <select> in Profile is left for a follow-up (it's wired
to native form submission + a disabled state and needs more care).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:43:18 -04:00
jared 349194e7e5 fix(ui): folds primitives for RouteError + PiP fullscreen button (native-cinny audit 5/N)
CI / Build & Quality Checks (push) Successful in 10m33s
CI / Trigger Desktop Build (push) Successful in 21s
- RouteError: raw <div>/<h2>/<p>/<button> (sans-serif, raw px) -> folds
  Box/Text/Button with config tokens.
- CallEmbedProvider PiP fullscreen control: raw <button> with ⊡/⛶ glyphs ->
  folds IconButton reusing the exported FullscreenIcon/ExitFullscreenIcon SVGs
  from Controls (consistent with the main fullscreen button). The intentional
  dark over-video scrim is kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:32:19 -04:00
jared 24d6460e4c chore: remove Sentry.io entirely
We no longer use Sentry. Removed:
- @sentry/react + @sentry/vite-plugin (package.json + lockfile)
- Sentry.init in index.tsx and the VITE_SENTRY_DSN env (.env.production)
- @sentry/vite-plugin + the SENTRY_AUTH_TOKEN sourcemap-upload path in
  vite.config.js (sourcemap now always false) and the CI env var
- Sentry.ErrorBoundary in App.tsx -> react-error-boundary's ErrorBoundary with a
  folds-native fallback (Box/Text/Button + config tokens), which also resolves
  the native-cinny audit's raw-#hex/#5865f2 fallback finding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:21:09 -04:00
jared 127e783f66 fix(ui): toast cards render on stock themes; gate TDS glow (native-cinny audit 4/N)
LotusToastContainer was styled entirely with --lt-* CSS vars but rendered
unconditionally (not gated on lotusTerminal). Those vars only exist inside the
Lotus Terminal theme's scoped block with no global fallback, so in-app toast
notifications rendered with undefined background/border/colors on every stock
Cinny theme. Now the card uses folds tokens (color.Surface.*/Primary.*,
config.radii/space/borderWidth, color.Other.Shadow) by default, keeping the TDS
--lt-* glow/accents only when lotusTerminal is active. The raw <button> dismiss
control is now a folds IconButton.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:06:33 -04:00
jared 198fd12bb2 fix(ui): folds tokens for ML-denoise panel + screenshare popover (native-cinny audit 3/N)
- General ML noise-suppression panel: ungated --border-color/--bg-card/--bg-input/
  --accent-orange -> color.Surface.ContainerLine/Container, SurfaceVariant.Container,
  Primary.Main. (The lotusTerminal-gated Boot button keeps its TDS --accent-orange.)
- CallControls "Share your screen?" popover: --bg-surface/--bg-surface-border ->
  color.Surface.Container / ContainerLine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:01:17 -04:00
jared 34d5209165 fix(ui): folds tokens for settings/profile/glass invented vars (native-cinny audit 2/N)
- DenoiseTester: --bg-card/--border-color/--accent-green/--accent-orange -> color.Surface.*/Success/Primary
- ProfileDecoration: --accent-cyan/--bg-surface-variant -> color.Primary.Main/SurfaceVariant.Container
- Profile: --tc-critical/warning-normal -> color.Critical/Warning.Main
- UserRoomProfile: --tc-positive/warning-normal/--tc-surface-low-contrast/--bg-surface-variant -> color tokens
- Sidebar glass: hardcoded rgba bg/border -> color-mix on color.Surface.Container + SurfaceVariant.ContainerLine
  (also fixes the glass looking wrong on light themes — was always near-black)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:58:57 -04:00
jared 9684ab75bb fix(ui): replace ungated invented CSS vars with folds tokens (native-cinny audit 1/N)
Audit of our delta vs Cinny v4.12.3 found invented CSS vars (--tc-*, --bg-*)
used outside Lotus-Terminal-gated code, which render unstyled/wrong on stock
themes. Batch 1:
- MemberVerificationBadge: --tc-positive/warning-normal -> color.Success/Warning.Main
- RoomInput (gif/location errors): --tc-danger-normal -> color.Critical.Main
- MsgTypeRenderers (location iframe): --bg-surface-border -> color.SurfaceVariant.ContainerLine
- MessageSearch (cached-room row): --bg-surface-variant -> color.SurfaceVariant.Container
- PrescreenControls (mic-denied): --tc-critical-high -> color.Critical.Main

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:54:42 -04:00
jared 0a6b035a67 docs(readme): correct fork-sync version (v4.12.3) and logo path
Two stale facts in README.md: it said "Forked from Cinny v4.12.1" (we've since
synced through v4.12.3) and referenced the logo as lotus_chat.png (the file is
public/res/Lotus.png). CONTRIBUTING.md is intentionally left as upstream
Cinny's and is not modified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:32:49 -04:00
jared cbfd3e5632 docs: N108 -> Needs-Verification; add L2 maskable-icon test
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:27:57 -04:00
jared 3faf0866a0 feat(pwa): add maskable icons for Android adaptive icons (N108)
The manifest had no purpose:"maskable" icon, so Android cropped a non-safe-zoned
icon (corners clipped / inconsistent shape). Added 192px + 512px maskable icons
(logo centered at ~62% on the app background_color #0a0a0a, inside the 80% safe
zone) generated from the existing logo, and registered them with
purpose:"maskable". They sit beside the existing android-chrome icons and use
the same manifest path convention, so they resolve identically to those.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:25:06 -04:00
jared bab3a160c2 docs: move N95 to Needs-Verification (fixed); update L1 test to verify-mode
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:18:08 -04:00
jared 1778cd0009 fix(calls): release AFK-monitor mic capture when muted (N95)
useAfkAutoMute opened its own getUserMedia capture for the whole call and only
stopped it on unmount, so the OS recording indicator stayed lit even when the
user was muted. The capture is now gated on the reactive mic-on state: it runs
only while unmuted (there's nothing to auto-mute when already muted), so muting
tears down the stream and clears the indicator, and unmuting re-acquires it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:15:31 -04:00
49 changed files with 1484 additions and 1144 deletions
-1
View File
@@ -1,2 +1 @@
VITE_SENTRY_DSN=https://264a5e95c5d31fe080a2e92fb008294d@o4511430568378368.ingest.us.sentry.io/4511430571982849
VITE_APP_VERSION=lotus VITE_APP_VERSION=lotus
-1
View File
@@ -45,7 +45,6 @@ jobs:
run: npm run build run: npm run build
env: env:
NODE_OPTIONS: '--max_old_space_size=4096' NODE_OPTIONS: '--max_old_space_size=4096'
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
VITE_APP_VERSION: ${{ github.sha }} VITE_APP_VERSION: ${{ github.sha }}
# ── Quality checks (informational — pre-existing issues exist) ─────── # ── Quality checks (informational — pre-existing issues exist) ───────
+2 -1
View File
@@ -1,2 +1,3 @@
legacy-peer-deps=true legacy-peer-deps=true
save-exact=true save-exact=true
@lotusguild:registry=https://code.lotusguild.org/api/packages/LotusGuild/npm/
+655
View File
@@ -0,0 +1,655 @@
# 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 02 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.
+57 -20
View File
@@ -15,22 +15,61 @@ step-by-step checks in [`LOTUS_TESTING.md`](./LOTUS_TESTING.md).
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row. Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
| ID | Item | File / area | Test | | ID | Item | File / area | Test |
| :--- | :------------------------------------------------------- | :--------------------------------------------------- | :---- | | :--- | :---------------------------------------------------------------------- | :--------------------------------------------------- | :------- |
| #1 | Camera focus during screenshare ("Focus camera" menu) | `CallControl.ts`, `MemberGlance.tsx` | A5 | | #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 | | #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
| #3 | Avatar decorations on call tiles | `call/CallMemberCard.tsx` | A6 | | #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
| #4 | DM/group ringtone selection + in-call banner | `CallEmbedProvider.tsx`, `ringtones.ts` | A1A4 | | #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 | | #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 | | #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 | | #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 | | #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 |
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 | | N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
| #12 | PiP mute badge attribution (you vs. all-muted) | `CallEmbedProvider.tsx` | G1 | | N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 | | N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 | | EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 | | Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I | | a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
**Verified working in live testing (2026-06):** A2, B1B4, 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._
--- ---
@@ -38,8 +77,7 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
### Calls / Audio ### Calls / Audio
- **N95 — AFK auto-mute keeps the hardware mic active while muted.** `useAfkAutoMute.ts` holds its own `getUserMedia` stream independent of EC's; muting in the UI doesn't stop those tracks, so the OS recording indicator stays lit. Fix: stop the `MediaStream` tracks on mute, re-request on unmute. (Repro: `LOTUS_TESTING.md` L1.) - **N127 — ML denoise shim is never injected in `vite dev`.** The `lotusDenoise` plugin injects only on `closeBundle` (build), so ML noise suppression is silently inactive during local dev. Add a dev-mode injection (`configureServer` / `transformIndexHtml`). Dev-only impact. _Note: this **dissolves entirely** once denoise moves in-source in the fork (A7 fix) — there is then no build-time injection to be missing in dev._
- **N127 — ML denoise shim is never injected in `vite dev`.** The `lotusDenoise` plugin injects only on `closeBundle` (build), so ML noise suppression is silently inactive during local dev. Add a dev-mode injection (`configureServer` / `transformIndexHtml`). Dev-only impact.
### Security & Privacy ### Security & Privacy
@@ -51,7 +89,6 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
- **N105 — Service worker has no `notificationclick` handler** — notification clicks are broken when the tab is closed. Needs `showNotification()` via the SW + a `notificationclick` listener. - **N105 — Service worker has no `notificationclick` handler** — notification clicks are broken when the tab is closed. Needs `showNotification()` via the SW + a `notificationclick` listener.
- **N107 — SW has no `push` handler** — Web Push delivery is entirely non-functional. Needs a `push` listener + a Matrix push-gateway integration. - **N107 — SW has no `push` handler** — Web Push delivery is entirely non-functional. Needs a `push` listener + a Matrix push-gateway integration.
- **N108 — No maskable PWA icon** — Android adaptive icons render incorrectly. Needs a maskable icon asset + `purpose: "maskable"` manifest entry.
- **No app-asset caching strategy** (`src/sw.ts`) — no offline capability. - **No app-asset caching strategy** (`src/sw.ts`) — no offline capability.
- **`manifest: false`** in `vite.config.js` — may block correct PWA install if not handled externally. - **`manifest: false`** in `vite.config.js` — may block correct PWA install if not handled externally.
@@ -68,7 +105,7 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
- **Hardcoded CDN URL** should move to an env var (the decoration CDN is now single-sourced in `avatarDecorations.ts`, but the literal is still in-repo). - **Hardcoded CDN URL** should move to an env var (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`. - **`patch-folds.mjs` edits `node_modules` directly** — consider `patch-package`.
- **Infra docs:** `contrib/nginx` lacks security headers (HSTS/CSP) + uses rewrites over `try_files`; `contrib/caddy` has a placeholder path. CI/CD (`prod-deploy.yml`): sequential deploy, aggressive 1-min Netlify timeout, `package-manager-cache: false`. - **Infra docs:** `contrib/nginx` lacks security headers (HSTS/CSP) + uses rewrites over `try_files`; `contrib/caddy` has a placeholder path. CI/CD (`prod-deploy.yml`): sequential deploy, aggressive 1-min Netlify timeout, `package-manager-cache: false`.
- **README / CONTRIBUTING:** stale upstream bug-tracker/donations/CLA links; README↔CONTRIBUTING misalignment. - **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. - **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`. - **Git workflow (forward-looking):** keep commits scoped — past monolithic "fix all bugs" commits and inconsistent prefixes hurt `git bisect`.
+5
View File
@@ -322,6 +322,11 @@ 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.
> The plan to fork & self-build it from source for true ownership — and which of
> the items below would move into our EC source — is in
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md).
### Element Call Upgrade ### Element Call Upgrade
Upgraded embedded Element Call widget from **0.16.3** to **0.19.4**. Upgraded embedded Element Call widget from **0.16.3** to **0.19.4**.
+12 -9
View File
@@ -342,22 +342,25 @@ Trigger a desktop/browser notification for a new message.
--- ---
## L. Open bugs flagged by audit — reproduction needed before fix ## L. Fixed — verify
### L1. AFK auto-mute keeps the OS microphone indicator lit (N95) — 👥 live call ### L1. AFK auto-mute releases the OS microphone indicator on mute (N95) — 👥 live call
**Context:** `useAfkAutoMute.ts` calls `getUserMedia({ audio: true })` independently of Element Call's managed stream. When you mute in the Lotus UI, the LiveKit mic inside EC's iframe is muted via the widget API — but the separate `MediaStream` held by the AFK hook keeps its tracks running. The OS-level recording indicator (green dot on macOS, mic icon on Windows/Linux) therefore stays lit while your mic is muted. **Context (now FIXED):** `useAfkAutoMute.ts` opened its own `getUserMedia` level-monitor capture for the whole call, so the OS recording indicator (green dot on macOS, mic icon on Windows/Linux) stayed lit even when muted. The capture is now gated on the reactive mic-on state — it runs only while unmuted, so muting releases the stream.
**To reproduce:** **To verify:**
1. Enable **AFK auto-mute** in Settings → Calls and **join a call**. 1. Enable **AFK auto-mute** in Settings → Calls and **join a call**.
2. Manually **mute your mic** using the call controls. 2. Manually **mute your mic** using the call controls → the **OS recording indicator should clear** within ~a second.
3. Check the **OS recording indicator** (macOS: green dot top-right of menu bar; Windows: mic icon in taskbar). 3. **Unmute** → the indicator should re-appear (capture re-acquired).
4. Also confirm AFK still works end-to-end: stay unmuted and silent past the configured timeout → mic auto-mutes with the "muted after inactivity" toast, and the indicator clears.
**Expected (current broken behavior):** the OS recording indicator stays on even though your Lotus mic shows muted. ### L2. Maskable PWA icon (N108) — Android install
**Expected after fix:** the indicator should clear when you mute and re-appear when you unmute.
> **Note:** This is an **open bug** — no fix has been applied yet. Reproduce and confirm the symptom first. The fix involves stopping `MediaStream` tracks on mute and re-requesting `getUserMedia` on unmute (see LOTUS_BUGS.md N95 for full details). Once fixed, re-run this check to verify the indicator clears. 1. On **Android Chrome**, install Lotus Chat as a PWA (Add to Home Screen).
2. Look at the **home-screen icon**.
**Expected:** the icon fills the adaptive-icon shape cleanly (the logo centered with safe-zone padding on the dark background), **not** clipped at the corners or floating in an odd box. Also worth a quick check in Chrome DevTools → Application → Manifest that the two `purpose: maskable` icons load without a 404 (this also validates the manifest's icon paths resolve in production — a pre-existing path convention I couldn't verify statically).
--- ---
+4
View File
@@ -264,6 +264,7 @@ Features:
**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:** 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.
**[AUDIT REQUIRED]** Verify the Element Call integration exposes the mic MediaStream for mixing. This is the highest-risk part of this feature. **[AUDIT REQUIRED]** Verify the Element Call integration exposes the mic MediaStream for mixing. This is the highest-risk part of this feature.
**🔱 [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.
**Complexity:** High. **Complexity:** High.
--- ---
@@ -281,6 +282,7 @@ Features:
**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. 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)".
**🔱 [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.
**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. The shim is the same post-capture pipeline #3892 uses, executed from the realm we control, so it survives EC version bumps.
**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". **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".
@@ -531,6 +533,8 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
- Pass the destination's `.stream` to the call bridge. - Pass the destination's `.stream` to the call bridge.
> ⚠️ **[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.
--- ---
+19 -2
View File
@@ -2,7 +2,7 @@
A Matrix chat client built for Lotus Guild — fast, private, and packed with the features you actually want. A Matrix chat client built for Lotus Guild — fast, private, and packed with the features you actually want.
**Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** &nbsp;|&nbsp; Forked from [Cinny](https://github.com/cinnyapp/cinny) v4.12.1 **Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** &nbsp;|&nbsp; Forked from [Cinny](https://github.com/cinnyapp/cinny), synced through v4.12.3
--- ---
@@ -10,7 +10,7 @@ A Matrix chat client built for Lotus Guild — fast, private, and packed with th
The source code is licensed under [AGPLv3](LICENSE), the same license as the upstream Cinny project. The source for this fork is public at [code.lotusguild.org/LotusGuild/cinny](https://code.lotusguild.org/LotusGuild/cinny). The source code is licensed under [AGPLv3](LICENSE), the same license as the upstream Cinny project. The source for this fork is public at [code.lotusguild.org/LotusGuild/cinny](https://code.lotusguild.org/LotusGuild/cinny).
The Lotus Chat logo (`lotus_chat.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0. The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
--- ---
@@ -144,6 +144,23 @@ 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")
Voice/video channels embed **Element Call**. Today it's a **pre-built npm bundle**
(`@element-hq/element-call-embedded` 0.20.1) copied to `public/element-call/` and
served same-origin; we steer it via the `matrix-widget-api` plus fragile DOM
hacks. Because we don't own its compiled source, several in-call issues (avatar
decorations on tiles, camera focus/fullscreen during screenshare, mic recovery
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`
repo, build it from source, and host our own build** for true ownership. The full
self-contained plan and integration map — written for a fresh session with no
prior context — is in **[`HANDOFF_ELEMENT_CALL_FORK.md`](HANDOFF_ELEMENT_CALL_FORK.md)**.
Infra/hosting notes also live in the `LotusGuild/matrix` repo README. Search the
docs for the **`[EC-FORK]`** tag to find every related note.
### Build ### Build
```bash ```bash
+7 -503
View File
@@ -21,7 +21,6 @@
"@giphy/js-util": "5.2.0", "@giphy/js-util": "5.2.0",
"@giphy/react-components": "10.1.2", "@giphy/react-components": "10.1.2",
"@sapphi-red/web-noise-suppressor": "0.3.5", "@sapphi-red/web-noise-suppressor": "0.3.5",
"@sentry/react": "10.53.1",
"@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",
@@ -78,10 +77,9 @@
"ua-parser-js": "2.0.10" "ua-parser-js": "2.0.10"
}, },
"devDependencies": { "devDependencies": {
"@element-hq/element-call-embedded": "0.20.1", "@lotusguild/element-call-embedded": "0.20.1-lotus.1",
"@rollup/plugin-inject": "5.0.5", "@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2", "@rollup/plugin-wasm": "6.2.2",
"@sentry/vite-plugin": "5.3.0",
"@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",
@@ -1790,12 +1788,6 @@
"node": ">=v18" "node": ">=v18"
} }
}, },
"node_modules/@element-hq/element-call-embedded": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.20.1.tgz",
"integrity": "sha512-ODg2r7UmR8UjRpapLKbn6v1PS8fu/r58zdbvXMYaAlUEAC2f6L/9Moc9S4noG1+ARgWxY+m2vLmNDK9G9uFZYQ==",
"dev": true
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -2695,6 +2687,12 @@
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
}, },
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.20.1-lotus.1",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.20.1-lotus.1/element-call-embedded-0.20.1-lotus.1.tgz",
"integrity": "sha512-hy1KEnFw4MuwvlactUFPPvvtPZh1y56JMK/ehnficUmJNwdJsOhSwThaYp35RZ/ar6RCuiW86yQqlQBOSpZJVQ==",
"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.0",
"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.0.tgz",
@@ -3782,403 +3780,6 @@
"integrity": "sha512-jh3+V9yM+zxLriQexoGm0GatoPaJWjs6ypFIbFYwQp+AoUb55eUXrjKtKQyuC5zShzzeAQUl0M5JzqB7SSrsRA==", "integrity": "sha512-jh3+V9yM+zxLriQexoGm0GatoPaJWjs6ypFIbFYwQp+AoUb55eUXrjKtKQyuC5zShzzeAQUl0M5JzqB7SSrsRA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sentry-internal/browser-utils": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.53.1.tgz",
"integrity": "sha512-X4d6y8sBMjmNhcDW4eMBU3ASsNIMz8dqaFkhyIMN/dkYr/yZKnbRZPaVuVUGvHKjnlficPpIH0/HK9KBjrYxPw==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.53.1.tgz",
"integrity": "sha512-vVpTI/aEYN5d9IgZeYJWMqVaN0+iFgidSrYNAsZTh1US5sJUzF/wrl+68KdpmCtFROrN3jiAn1oPSwL5CKvEJA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.53.1.tgz",
"integrity": "sha512-wZNzTBYkgGUPWMuUQv7L64+OJmoCnz7GQNiTrTFK6EVAjJXFBCSsPp/nhif0bLhbk8+0g4xz633uOhpXuQbFdw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.53.1.tgz",
"integrity": "sha512-aueLaf/2prExwA76BGU5/bOXCKWqtt6jQXWA6WJQNrmKpPEtZJB4ypnpsou0McXQCF8tur2Y8U0TEkwQP13yJQ==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/babel-plugin-component-annotate": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.3.0.tgz",
"integrity": "sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 18"
}
},
"node_modules/@sentry/browser": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.53.1.tgz",
"integrity": "sha512-zXF373hzUOGzUOrqd8xb1U3LQi5uYC3mwv+z5OMKUUinQlu30tTWBs7ypy6YTchtix9QlYaHWlayUF8vBZ5UjA==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.53.1",
"@sentry-internal/feedback": "10.53.1",
"@sentry-internal/replay": "10.53.1",
"@sentry-internal/replay-canvas": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/bundler-plugin-core": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-5.3.0.tgz",
"integrity": "sha512-L5T60sWdAI3qWwdg3Ptwek/0TY59PERrxyqp4XMUkroayQvGd9r5dIW9Q1kSeXX9iJ442nXbFZKAOyCKV4Z13Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.18.5",
"@sentry/babel-plugin-component-annotate": "5.3.0",
"@sentry/cli": "^2.58.5",
"dotenv": "^16.3.1",
"find-up": "^5.0.0",
"glob": "^13.0.6",
"magic-string": "~0.30.8"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/@sentry/cli": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.6.tgz",
"integrity": "sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==",
"dev": true,
"hasInstallScript": true,
"license": "FSL-1.1-MIT",
"dependencies": {
"https-proxy-agent": "^5.0.0",
"node-fetch": "^2.6.7",
"progress": "^2.0.3",
"proxy-from-env": "^1.1.0",
"which": "^2.0.2"
},
"bin": {
"sentry-cli": "bin/sentry-cli"
},
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@sentry/cli-darwin": "2.58.6",
"@sentry/cli-linux-arm": "2.58.6",
"@sentry/cli-linux-arm64": "2.58.6",
"@sentry/cli-linux-i686": "2.58.6",
"@sentry/cli-linux-x64": "2.58.6",
"@sentry/cli-win32-arm64": "2.58.6",
"@sentry/cli-win32-i686": "2.58.6",
"@sentry/cli-win32-x64": "2.58.6"
}
},
"node_modules/@sentry/cli-darwin": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.6.tgz",
"integrity": "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==",
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-linux-arm": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.6.tgz",
"integrity": "sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==",
"cpu": [
"arm"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"linux",
"freebsd",
"android"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-linux-arm64": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.6.tgz",
"integrity": "sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"linux",
"freebsd",
"android"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-linux-i686": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.6.tgz",
"integrity": "sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==",
"cpu": [
"x86",
"ia32"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"linux",
"freebsd",
"android"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-linux-x64": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.6.tgz",
"integrity": "sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"linux",
"freebsd",
"android"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-win32-arm64": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.6.tgz",
"integrity": "sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-win32-i686": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.6.tgz",
"integrity": "sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==",
"cpu": [
"x86",
"ia32"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-win32-x64": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.6.tgz",
"integrity": "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/core": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.53.1.tgz",
"integrity": "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/react": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.53.1.tgz",
"integrity": "sha512-lrwNq5T/zW84l60894TpKHPcvFuc1I/Hnohecc0TfYVpIcYYuw2orCHoU4v4wgkFaJUpegVetbgdOphViyLVjA==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.14.0 || 17.x || 18.x || 19.x"
}
},
"node_modules/@sentry/rollup-plugin": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@sentry/rollup-plugin/-/rollup-plugin-5.3.0.tgz",
"integrity": "sha512-hgPGPYdQJ/G1cGYOxAb7d4z3V+/k/E5/P/5TFPEEBLuIbFFk+JG0CISUDJdzXJjO382Lb99PBJuXGbueBmO79w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sentry/bundler-plugin-core": "5.3.0",
"magic-string": "~0.30.8"
},
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"rollup": ">=3.2.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@sentry/vite-plugin": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-5.3.0.tgz",
"integrity": "sha512-qcoSzo4n2MulVQ70UUPLq6dTleb2a2HwL2wuwvAgWhPChrYTuk6A6mDg6aQb9fairPAwFPiU9PzOANpoDJcz1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sentry/bundler-plugin-core": "5.3.0",
"@sentry/rollup-plugin": "5.3.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/@simple-libs/stream-utils": { "node_modules/@simple-libs/stream-utils": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz",
@@ -4893,18 +4494,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dev": true,
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -6634,19 +6223,6 @@
"url": "https://github.com/fb55/domhandler?sponsor=1" "url": "https://github.com/fb55/domhandler?sponsor=1"
} }
}, },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -8473,19 +8049,6 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dev": true,
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/husky": { "node_modules/husky": {
"version": "9.1.7", "version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
@@ -10599,26 +10162,6 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true "dev": true
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dev": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.19", "version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@@ -11178,16 +10721,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -11198,13 +10731,6 @@
"react-is": "^16.13.1" "react-is": "^16.13.1"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true,
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -12783,12 +12309,6 @@
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"dev": true
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -13336,22 +12856,6 @@
"defaults": "^1.0.3" "defaults": "^1.0.3"
} }
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"dev": true
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dev": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+1 -3
View File
@@ -45,7 +45,6 @@
"@giphy/js-util": "5.2.0", "@giphy/js-util": "5.2.0",
"@giphy/react-components": "10.1.2", "@giphy/react-components": "10.1.2",
"@sapphi-red/web-noise-suppressor": "0.3.5", "@sapphi-red/web-noise-suppressor": "0.3.5",
"@sentry/react": "10.53.1",
"@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",
@@ -102,10 +101,9 @@
"ua-parser-js": "2.0.10" "ua-parser-js": "2.0.10"
}, },
"devDependencies": { "devDependencies": {
"@element-hq/element-call-embedded": "0.20.1", "@lotusguild/element-call-embedded": "0.20.1-lotus.1",
"@rollup/plugin-inject": "5.0.5", "@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2", "@rollup/plugin-wasm": "6.2.2",
"@sentry/vite-plugin": "5.3.0",
"@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",
+12
View File
@@ -54,6 +54,18 @@
"src": "./res/android/android-chrome-512x512.png", "src": "./res/android/android-chrome-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
},
{
"src": "./res/android/maskable-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "./res/android/maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
} }
], ],
"categories": ["social", "communication", "productivity"], "categories": ["social", "communication", "productivity"],
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+39 -14
View File
@@ -9,6 +9,7 @@ import {
config, config,
Dialog, Dialog,
Icon, Icon,
IconButton,
Icons, Icons,
Overlay, Overlay,
OverlayBackdrop, OverlayBackdrop,
@@ -36,6 +37,7 @@ import {
useCallStart, useCallStart,
} from '../hooks/useCallEmbed'; } from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed'; import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { toastQueueAtom } from '../state/toast';
import { CallEmbed, useCallControlState } from '../plugins/call'; import { CallEmbed, useCallControlState } from '../plugins/call';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
@@ -51,6 +53,7 @@ import { mxcUrlToHttp, getMxIdLocalPart } from '../utils/matrix';
import { RoomAvatar, RoomIcon } from './room-avatar'; import { RoomAvatar, RoomIcon } from './room-avatar';
import { useRoomNavigate } from '../hooks/useRoomNavigate'; import { useRoomNavigate } from '../hooks/useRoomNavigate';
import { getChatBg } from '../features/lotus/chatBackground'; import { getChatBg } from '../features/lotus/chatBackground';
import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
import { useTheme, ThemeKind } from '../hooks/useTheme'; import { useTheme, ThemeKind } from '../hooks/useTheme';
import { useSetting } from '../state/hooks/settings'; import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
@@ -62,6 +65,7 @@ import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
import { useLivekitSupport } from '../hooks/useLivekitSupport'; import { useLivekitSupport } from '../hooks/useLivekitSupport';
import { CallAvatarAnimation } from '../styles/Animations.css'; import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc'; import { webRTCSupported } from '../utils/rtc';
import { zIndices } from '../styles/zIndex';
const PIP_MIN_W = 200; const PIP_MIN_W = 200;
const PIP_MIN_H = 112; const PIP_MIN_H = 112;
@@ -321,7 +325,7 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
position: 'fixed', position: 'fixed',
top: config.space.S400, top: config.space.S400,
right: config.space.S400, right: config.space.S400,
zIndex: 9990, zIndex: zIndices.inCallBanner,
width: toRem(300), width: toRem(300),
maxWidth: `calc(100vw - 2 * ${config.space.S400})`, maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
padding: config.space.S300, padding: config.space.S300,
@@ -402,6 +406,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const mx = useMatrixClient(); const mx = useMatrixClient();
const directs = useAtomValue(mDirectAtom); const directs = useAtomValue(mDirectAtom);
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
const setToast = useSetAtom(toastQueueAtom);
const [callInfo, setCallInfo] = useState<IncomingCallInfo>(); const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
const dm = callInfo ? directs.has(callInfo.room.roomId) : false; const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
@@ -421,6 +426,31 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
await event.getDecryptionPromise(); await event.getDecryptionPromise();
} }
// Caller-side: a participant declined a call we're hosting in this room.
// Without this the caller's UI keeps "ringing" until the notification
// lifetime expires, with no indication the callee said no.
if (event.getType() === EventType.RTCDecline) {
const decliner = event.getSender();
if (
data.liveEvent &&
room &&
decliner &&
decliner !== mx.getSafeUserId() &&
callEmbed?.roomId === room.roomId
) {
const declinerName =
getMemberDisplayName(room, decliner) ?? getMxIdLocalPart(decliner) ?? decliner;
setToast({
id: `rtc-decline-${event.getId() ?? decliner}`,
displayName: declinerName,
body: 'Declined your call',
roomName: room.name,
roomId: room.roomId,
});
}
return;
}
if ( if (
!room || !room ||
event.getType() !== EventType.RTCNotification || event.getType() !== EventType.RTCNotification ||
@@ -483,7 +513,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
setCallInfo(info); setCallInfo(info);
}, },
[mx, directs], [mx, directs, callEmbed, setToast],
); );
useEffect(() => { useEffect(() => {
@@ -1095,10 +1125,13 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
> >
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}> <div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
{document.fullscreenEnabled && ( {document.fullscreenEnabled && (
<button <IconButton
type="button" type="button"
size="300"
radii="300"
variant="Surface"
fill="None"
aria-label={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'} aria-label={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
title={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handlePipFullscreen(); handlePipFullscreen();
@@ -1107,19 +1140,11 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
// Dark scrim is intentional for legibility over arbitrary video. // Dark scrim is intentional for legibility over arbitrary video.
background: 'rgba(0,0,0,0.65)', background: 'rgba(0,0,0,0.65)',
backdropFilter: 'blur(4px)', backdropFilter: 'blur(4px)',
border: 'none',
borderRadius: config.radii.R300,
padding: `${config.space.S100} ${config.space.S200}`,
color: '#fff', color: '#fff',
fontSize: '13px',
cursor: 'pointer',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
}} }}
> >
{pipIsFullscreen ? '⊡' : '⛶'} {pipIsFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
</button> </IconButton>
)} )}
<div <div
style={{ style={{
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Icon, Icons, Text, Tooltip, TooltipProvider } from 'folds'; import { color, Icon, Icons, Text, Tooltip, TooltipProvider } from 'folds';
import { useUserVerifiedStatus } from '../hooks/useUserVerifiedStatus'; import { useUserVerifiedStatus } from '../hooks/useUserVerifiedStatus';
type MemberVerificationBadgeProps = { type MemberVerificationBadgeProps = {
@@ -9,8 +9,7 @@ type MemberVerificationBadgeProps = {
export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps) { export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps) {
const vs = useUserVerifiedStatus(userId); const vs = useUserVerifiedStatus(userId);
if (vs === 'unknown') return null; if (vs === 'unknown') return null;
const color = const iconColor = vs === 'verified' ? color.Success.Main : color.Warning.Main;
vs === 'verified' ? 'var(--tc-positive-normal, #5effc4)' : 'var(--tc-warning-normal, #ffcc55)';
const label = vs === 'verified' ? 'Identity verified' : 'Not verified'; const label = vs === 'verified' ? 'Identity verified' : 'Not verified';
return ( return (
<TooltipProvider <TooltipProvider
@@ -27,7 +26,7 @@ export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps
title={label} title={label}
style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }} style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}
> >
<Icon size="100" src={Icons.ShieldUser} style={{ color }} /> <Icon size="100" src={Icons.ShieldUser} style={{ color: iconColor }} />
</span> </span>
)} )}
</TooltipProvider> </TooltipProvider>
@@ -529,7 +529,7 @@ export function MLocation({ content }: MLocationProps) {
style={{ style={{
width: '280px', width: '280px',
height: '160px', height: '160px',
border: '1px solid var(--bg-surface-border)', border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: '8px', borderRadius: '8px',
display: 'block', display: 'block',
}} }}
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Box, color, config, Text, toRem } from 'folds'; import { Box, color, config, Icon, Icons, Text, toRem } from 'folds';
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations'; import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
import { RoomEvent } from 'matrix-js-sdk'; import { RoomEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -339,11 +339,7 @@ export function PollContent({
transition: 'all 0.15s', transition: 'all 0.15s',
}} }}
> >
{selected && isMultiple ? ( {selected && isMultiple ? <Icon size="50" src={Icons.Check} /> : null}
<Text as="span" size="T200" style={{ lineHeight: 1 }}>
</Text>
) : null}
</span> </span>
<Text as="span" size="T300" style={{ flexGrow: 1 }}> <Text as="span" size="T300" style={{ flexGrow: 1 }}>
{text} {text}
@@ -1,6 +1,7 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { zIndices } from '../../styles/zIndex';
import { import {
animSeasonFall, animSeasonFall,
animLeafFall, animLeafFall,
@@ -758,7 +759,7 @@ function SeasonalOverlay({ theme, reduced }: { theme: SeasonTheme; reduced: bool
pointerEvents: 'none', pointerEvents: 'none',
// Below the Night Light overlay (9998) so seasonal particles are tinted // Below the Night Light overlay (9998) so seasonal particles are tinted
// by it, and below modals (9999) so dialogs are never obscured. // by it, and below modals (9999) so dialogs are never obscured.
zIndex: 9997, zIndex: zIndices.seasonalEffect,
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
@@ -0,0 +1,98 @@
import React, { MouseEventHandler, useState } from 'react';
import { Box, Button, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../../utils/keyboard';
export type SettingsSelectOption<T extends string> = {
value: T;
label: string;
disabled?: boolean;
};
/**
* A folds-native dropdown (Button + PopOut + Menu) matching Cinny's select
* pattern used instead of a raw `<select>`, which renders OS-styled and
* breaks under non-default themes.
*/
export function SettingsSelect<T extends string>({
value,
options,
onChange,
disabled,
'aria-label': ariaLabel,
}: {
value: T;
options: SettingsSelectOption<T>[];
onChange: (v: T) => void;
disabled?: boolean;
'aria-label'?: string;
}) {
const [menuCords, setMenuCords] = useState<RectCords>();
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (v: T) => {
onChange(v);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
disabled={disabled}
aria-label={ariaLabel}
aria-haspopup="menu"
aria-expanded={!!menuCords}
>
<Text size="T300">{selectedLabel}</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
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 }}>
{options.map((opt) => (
<MenuItem
key={opt.value}
size="300"
variant={opt.value === value ? 'Primary' : 'Surface'}
radii="300"
disabled={opt.disabled}
onClick={() => !opt.disabled && handleSelect(opt.value)}
>
<Text size="T300">{opt.label}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
+2 -2
View File
@@ -17,10 +17,10 @@ export const Sidebar = style([
]); ]);
export const SidebarGlass = style({ export const SidebarGlass = style({
backgroundColor: 'rgba(3, 5, 8, 0.55)', backgroundColor: `color-mix(in srgb, ${color.Surface.Container} 55%, transparent)`,
backdropFilter: 'blur(12px)', backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)',
borderRight: '1px solid rgba(255,255,255,0.06)', borderRight: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
}); });
export const SidebarStack = style([ export const SidebarStack = style([
@@ -91,10 +91,10 @@ function UserDeviceRow({ userId, device }: UserDeviceRowProps) {
{(status) => { {(status) => {
const deviceColor = const deviceColor =
status === VerificationStatus.Verified status === VerificationStatus.Verified
? 'var(--tc-positive-normal, #5effc4)' ? color.Success.Main
: status === VerificationStatus.Unverified : status === VerificationStatus.Unverified
? 'var(--tc-warning-normal, #ffcc55)' ? color.Warning.Main
: 'var(--tc-surface-low-contrast)'; : color.SurfaceVariant.OnContainer;
return ( return (
<Box alignItems="Center" gap="200"> <Box alignItems="Center" gap="200">
<Icon size="100" src={Icons.ShieldUser} style={{ color: deviceColor, flexShrink: 0 }} /> <Icon size="100" src={Icons.ShieldUser} style={{ color: deviceColor, flexShrink: 0 }} />
@@ -106,7 +106,7 @@ function UserDeviceRow({ userId, device }: UserDeviceRowProps) {
<Text <Text
size="T200" size="T200"
truncate truncate
style={{ color: 'var(--tc-surface-low-contrast)', fontFamily: 'monospace' }} style={{ color: color.SurfaceVariant.OnContainer, fontFamily: 'monospace' }}
> >
{device.deviceId} {device.deviceId}
</Text> </Text>
@@ -160,7 +160,7 @@ function UserDeviceSessions({ userId }: UserDeviceSessionsProps) {
direction="Column" direction="Column"
gap="100" gap="100"
style={{ style={{
background: 'var(--bg-surface-variant)', background: color.SurfaceVariant.Container,
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
padding: config.space.S300, padding: config.space.S300,
}} }}
@@ -171,7 +171,7 @@ function UserDeviceSessions({ userId }: UserDeviceSessionsProps) {
<Text size="T300"> <Text size="T300">
<b>Sessions</b> <b>Sessions</b>
</Text> </Text>
<Text size="T200" style={{ color: 'var(--tc-surface-low-contrast)' }}> <Text size="T200" style={{ color: color.SurfaceVariant.OnContainer }}>
{devices.length} {devices.length}
</Text> </Text>
</Box> </Box>
+3 -2
View File
@@ -3,6 +3,7 @@ import {
Box, Box,
Button, Button,
Chip, Chip,
color,
config, config,
Icon, Icon,
IconButton, IconButton,
@@ -276,8 +277,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
bottom: '110%', bottom: '110%',
left: '50%', left: '50%',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
background: 'var(--bg-surface)', background: color.Surface.Container,
border: '1px solid var(--bg-surface-border)', border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
borderRadius: '0.75rem', borderRadius: '0.75rem',
padding: '1rem 1.25rem', padding: '1rem 1.25rem',
zIndex: 100, zIndex: 100,
+3
View File
@@ -17,6 +17,7 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { useCallMembers, useCallSession } from '../../hooks/useCall'; import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { LotusDecorationPusher } from '../lotus/LotusDecorationPusher';
import { useStateEvent } from '../../hooks/useStateEvent'; import { useStateEvent } from '../../hooks/useStateEvent';
import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit'; import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit';
import { CallMemberRenderer } from './CallMemberCard'; import { CallMemberRenderer } from './CallMemberCard';
@@ -199,6 +200,8 @@ function CallJoined({ joined, containerRef }: CallJoinedProps) {
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
<Box grow="Yes" ref={containerRef} /> <Box grow="Yes" ref={containerRef} />
{callEmbed && joined && <CallControls callEmbed={callEmbed} />} {callEmbed && joined && <CallControls callEmbed={callEmbed} />}
{/* [lotus #6] push avatar decorations to EC's in-call tiles (post-join) */}
{callEmbed && joined && <LotusDecorationPusher callEmbed={callEmbed} />}
</Box> </Box>
); );
} }
+2 -2
View File
@@ -166,13 +166,13 @@ export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps)
); );
} }
const FullscreenIcon = () => ( export const FullscreenIcon = () => (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor"> <svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 3h6v2H5v4H3V3zm12 0h6v6h-2V5h-4V3zM3 15h2v4h4v2H3v-6zm16 4h-4v2h6v-6h-2v4z" /> <path d="M3 3h6v2H5v4H3V3zm12 0h6v6h-2V5h-4V3zM3 15h2v4h4v2H3v-6zm16 4h-4v2h6v-6h-2v4z" />
</svg> </svg>
); );
const ExitFullscreenIcon = () => ( export const ExitFullscreenIcon = () => (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor"> <svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 3H7v4H3v2h6V3zm6 0v6h6V7h-4V3h-2zM3 13v2h4v4h2v-6H3zm14 4v-4h2v6h-6v-2h4z" /> <path d="M9 3H7v4H3v2h6V3zm6 0v6h6V7h-4V3h-2zM3 13v2h4v4h2v-6H3zm14 4v-4h2v6h-6v-2h4z" />
</svg> </svg>
+2 -5
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds'; import { Box, Button, color, Icon, Icons, Spinner, Text } from 'folds';
import { SequenceCard } from '../../components/sequence-card'; import { SequenceCard } from '../../components/sequence-card';
import * as css from './styles.css'; import * as css from './styles.css';
import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton } from './Controls'; import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton } from './Controls';
@@ -78,10 +78,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
</Box> </Box>
<Box grow="Yes" direction="Column" gap="200"> <Box grow="Yes" direction="Column" gap="200">
{micDenied && ( {micDenied && (
<Text <Text size="T200" style={{ color: color.Critical.Main, textAlign: 'center' }}>
size="T200"
style={{ color: 'var(--tc-critical-high, #e53e3e)', textAlign: 'center' }}
>
Microphone access is blocked. Enable it in your browser settings to join. Microphone access is blocked. Enable it in your browser settings to join.
</Text> </Text>
)} )}
@@ -0,0 +1,97 @@
import React, { useCallback, useEffect, useMemo, useRef, type ReactElement } from 'react';
import { type CallEmbed } from '../../plugins/call';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useAvatarDecoration } from '../../hooks/useAvatarDecoration';
import { decorationUrl } from './avatarDecorations';
/**
* [lotus #6] Pushes each call participant's avatar-decoration image URL to the
* forked Element Call (`io.lotus.decorations`), which renders it on the in-call
* video-tile avatars. Mounted only while joined, so the EC-side handler exists.
*
* The decoration roster is per-user slugs resolved via `useAvatarDecoration`;
* we render one invisible probe per member to reuse that hook + its cache, then
* debounce-send the aggregated `{ userId: url }` map whenever it changes.
*/
function DecorationProbe({
userId,
onResolve,
}: {
userId: string;
onResolve: (userId: string, url: string | null) => void;
}): null {
const slug = useAvatarDecoration(userId);
useEffect(() => {
onResolve(userId, slug ? decorationUrl(slug) : null);
}, [userId, slug, onResolve]);
return null;
}
export function LotusDecorationPusher({ callEmbed }: { callEmbed: CallEmbed }): ReactElement {
const session = useCallSession(callEmbed.room);
const members = useCallMembers(session);
const map = useRef<Map<string, string>>(new Map());
const pushTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const userIds = useMemo(
() => Array.from(new Set(members.map((m) => m.userId).filter((u): u is string => !!u))),
[members],
);
const push = useCallback(() => {
const decorations: Record<string, string> = {};
map.current.forEach((url, userId) => {
decorations[userId] = url;
});
callEmbed.call.transport.send('io.lotus.decorations', { decorations }).catch(() => undefined);
}, [callEmbed]);
const schedulePush = useCallback(() => {
if (pushTimer.current) clearTimeout(pushTimer.current);
pushTimer.current = setTimeout(push, 300);
}, [push]);
const onResolve = useCallback(
(userId: string, url: string | null) => {
const prev = map.current.get(userId);
if (url) {
if (prev !== url) {
map.current.set(userId, url);
schedulePush();
}
} else if (prev !== undefined) {
map.current.delete(userId);
schedulePush();
}
},
[schedulePush],
);
// Drop decorations for participants who left the call.
useEffect(() => {
const present = new Set(userIds);
let changed = false;
map.current.forEach((_url, userId) => {
if (!present.has(userId)) {
map.current.delete(userId);
changed = true;
}
});
if (changed) schedulePush();
}, [userIds, schedulePush]);
useEffect(
() => () => {
if (pushTimer.current) clearTimeout(pushTimer.current);
},
[],
);
return (
<>
{userIds.map((userId) => (
<DecorationProbe key={userId} userId={userId} onResolve={onResolve} />
))}
</>
);
}
@@ -1,5 +1,17 @@
import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Text, Box, Icon, Icons, config, Spinner, IconButton, Line, toRem, Button } from 'folds'; import {
Text,
Box,
Icon,
Icons,
color,
config,
Spinner,
IconButton,
Line,
toRem,
Button,
} from 'folds';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
@@ -112,7 +124,7 @@ function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelP
gap="200" gap="200"
style={{ style={{
padding: config.space.S200, padding: config.space.S200,
background: 'var(--bg-surface-variant)', background: color.SurfaceVariant.Container,
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
}} }}
> >
@@ -121,7 +133,7 @@ function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelP
<Text size="T300" truncate> <Text size="T300" truncate>
{room.name} {room.name}
</Text> </Text>
<Text size="T200" style={{ opacity: 0.55 }}> <Text size="T200" priority="300">
{msgEvents.length > 0 {msgEvents.length > 0
? `${msgEvents.length} messages cached · oldest: ${new Date(oldest!.getTs()).toLocaleDateString()}` ? `${msgEvents.length} messages cached · oldest: ${new Date(oldest!.getTs()).toLocaleDateString()}`
: 'No messages cached yet'} : 'No messages cached yet'}
@@ -141,7 +153,7 @@ function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelP
</Button> </Button>
)} )}
{!canLoadMore && events.length > 0 && ( {!canLoadMore && events.length > 0 && (
<Text size="T200" style={{ opacity: 0.5, flexShrink: 0 }}> <Text size="T200" priority="300" style={{ flexShrink: 0 }}>
Fully cached Fully cached
</Text> </Text>
)} )}
@@ -644,7 +656,7 @@ export function MessageSearch({
<Icon size="200" src={senderOnlyMode ? Icons.User : Icons.Lock} /> <Icon size="200" src={senderOnlyMode ? Icons.User : Icons.Lock} />
<Text size="H5">{senderOnlyMode ? 'Messages from user' : 'Encrypted Rooms'}</Text> <Text size="H5">{senderOnlyMode ? 'Messages from user' : 'Encrypted Rooms'}</Text>
{!senderOnlyMode && ( {!senderOnlyMode && (
<Text size="T200" style={{ opacity: 0.55 }}> <Text size="T200" priority="300">
{`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`} {`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`}
</Text> </Text>
)} )}
@@ -280,7 +280,8 @@ export function SearchInput({
<Text <Text
size="T200" size="T200"
truncate truncate
style={{ opacity: 0.6, fontFamily: 'monospace', fontSize: '0.75em' }} priority="300"
style={{ fontFamily: 'monospace', fontSize: '0.75em' }}
> >
{user.userId} {user.userId}
</Text> </Text>
+104 -91
View File
@@ -6,6 +6,8 @@ import {
Icon, Icon,
IconButton, IconButton,
Icons, Icons,
Overlay,
OverlayBackdrop,
Scroll, Scroll,
Spinner, Spinner,
Text, Text,
@@ -15,6 +17,7 @@ import {
config, config,
} from 'folds'; } from 'folds';
import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk'; import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames'; import classNames from 'classnames';
import { useNearViewport } from '../../hooks/useNearViewport'; import { useNearViewport } from '../../hooks/useNearViewport';
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common'; import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
@@ -250,102 +253,112 @@ function Lightbox({
}); });
return ( return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions <Overlay open backdrop={<OverlayBackdrop />}>
<div <FocusTrap
role="dialog" focusTrapOptions={{
aria-modal initialFocus: false,
aria-label="Media viewer" clickOutsideDeactivates: false,
onKeyDown={handleKeyDown} escapeDeactivates: false,
tabIndex={-1}
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
background: 'rgba(0,0,0,0.92)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Header bar */}
<Box
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderBottom: '1px solid rgba(255,255,255,0.08)',
flexShrink: 0,
}} }}
> >
<Box grow="Yes" direction="Column" style={{ overflow: 'hidden' }}> {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<Text size="T400" truncate style={{ color: '#fff', fontWeight: 500 }}> <div
{item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')} role="dialog"
</Text> aria-modal
<Text size="T200" style={{ color: 'rgba(255,255,255,0.5)' }}> aria-label="Media viewer"
{item.sender} · {dateStr} onKeyDown={handleKeyDown}
</Text> tabIndex={-1}
</Box> style={{
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}> position: 'fixed',
{index + 1} / {items.length} inset: 0,
</Text> zIndex: 1000,
<TooltipProvider background: 'rgba(0,0,0,0.92)',
position="Bottom" display: 'flex',
align="End" flexDirection: 'column',
offset={4} }}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
> >
{(ref) => ( {/* Header bar */}
<IconButton ref={ref} variant="Surface" aria-label="Close" onClick={onClose}> <Box
<Icon src={Icons.Cross} /> alignItems="Center"
</IconButton> gap="200"
)} style={{
</TooltipProvider> padding: `${config.space.S200} ${config.space.S300}`,
</Box> borderBottom: '1px solid rgba(255,255,255,0.08)',
flexShrink: 0,
}}
>
<Box grow="Yes" direction="Column" style={{ overflow: 'hidden' }}>
<Text size="T400" truncate style={{ color: '#fff', fontWeight: 500 }}>
{item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')}
</Text>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.5)' }}>
{item.sender} · {dateStr}
</Text>
</Box>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
{index + 1} / {items.length}
</Text>
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(ref) => (
<IconButton ref={ref} variant="Surface" aria-label="Close" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
{/* Media area with nav arrows */} {/* Media area with nav arrows */}
<Box <Box
grow="Yes" grow="Yes"
alignItems="Center" alignItems="Center"
justifyContent="Center" justifyContent="Center"
style={{ overflow: 'hidden', padding: config.space.S400 }} style={{ overflow: 'hidden', padding: config.space.S400 }}
>
{index > 0 && (
<IconButton
variant="Surface"
aria-label="Previous"
onClick={prev}
style={{ flexShrink: 0, marginRight: config.space.S200 }}
> >
<Icon src={Icons.ArrowLeft} /> {index > 0 && (
</IconButton> <IconButton
)} variant="Surface"
<Box aria-label="Previous"
grow="Yes" onClick={prev}
alignItems="Center" style={{ flexShrink: 0, marginRight: config.space.S200 }}
justifyContent="Center" >
style={{ overflow: 'hidden', height: '100%' }} <Icon src={Icons.ArrowLeft} />
> </IconButton>
<LightboxMedia )}
key={`${item.mxcUrl}-${item.ts}`} <Box
item={item} grow="Yes"
useAuthentication={useAuthentication} alignItems="Center"
/> justifyContent="Center"
</Box> style={{ overflow: 'hidden', height: '100%' }}
{index < items.length - 1 && ( >
<IconButton <LightboxMedia
variant="Surface" key={`${item.mxcUrl}-${item.ts}`}
aria-label="Next" item={item}
onClick={next} useAuthentication={useAuthentication}
style={{ flexShrink: 0, marginLeft: config.space.S200 }} />
> </Box>
<Icon src={Icons.ArrowRight} /> {index < items.length - 1 && (
</IconButton> <IconButton
)} variant="Surface"
</Box> aria-label="Next"
</div> onClick={next}
style={{ flexShrink: 0, marginLeft: config.space.S200 }}
>
<Icon src={Icons.ArrowRight} />
</IconButton>
)}
</Box>
</div>
</FocusTrap>
</Overlay>
); );
} }
+2 -2
View File
@@ -1106,7 +1106,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<Text <Text
size="T200" size="T200"
style={{ style={{
color: 'var(--tc-danger-normal)', color: color.Critical.Main,
padding: '2px 6px', padding: '2px 6px',
alignSelf: 'center', alignSelf: 'center',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
@@ -1119,7 +1119,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<Text <Text
size="T200" size="T200"
style={{ style={{
color: 'var(--tc-danger-normal)', color: color.Critical.Main,
padding: '2px 6px', padding: '2px 6px',
alignSelf: 'center', alignSelf: 'center',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
@@ -241,7 +241,7 @@ export function ScheduleMessageModal({
<Text size="L400">Send at</Text> <Text size="L400">Send at</Text>
<Box gap="200"> <Box gap="200">
<Box direction="Column" gap="100" style={{ flex: 1 }}> <Box direction="Column" gap="100" style={{ flex: 1 }}>
<Text as="label" htmlFor="schedule-date" size="T200" style={{ opacity: 0.7 }}> <Text as="label" htmlFor="schedule-date" size="T200" priority="400">
Date Date
</Text> </Text>
<input <input
@@ -253,7 +253,7 @@ export function ScheduleMessageModal({
/> />
</Box> </Box>
<Box direction="Column" gap="100" style={{ flex: 1 }}> <Box direction="Column" gap="100" style={{ flex: 1 }}>
<Text as="label" htmlFor="schedule-time" size="T200" style={{ opacity: 0.7 }}> <Text as="label" htmlFor="schedule-time" size="T200" priority="400">
Time Time
</Text> </Text>
<input <input
@@ -140,17 +140,17 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
> >
<Text <Text
size="T200" size="T200"
priority="400"
style={{ style={{
flex: 1, flex: 1,
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
opacity: 0.8,
}} }}
> >
{typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)'} {typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)'}
</Text> </Text>
<Text size="T200" style={{ opacity: 0.6, whiteSpace: 'nowrap', flexShrink: 0 }}> <Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
{formatSendAt(msg.sendAt)} {formatSendAt(msg.sendAt)}
</Text> </Text>
<IconButton <IconButton
+19 -68
View File
@@ -30,6 +30,7 @@ import {
} from 'folds'; } from 'folds';
import { Method } from 'matrix-js-sdk'; import { Method } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
@@ -482,9 +483,9 @@ function ProfileStatus() {
opacity: statusMsg.length >= 56 ? 1 : 0.45, opacity: statusMsg.length >= 56 ? 1 : 0.45,
color: color:
statusMsg.length >= 64 statusMsg.length >= 64
? 'var(--tc-critical-normal)' ? color.Critical.Main
: statusMsg.length >= 56 : statusMsg.length >= 56
? 'var(--tc-warning-normal)' ? color.Warning.Main
: undefined, : undefined,
}} }}
> >
@@ -536,43 +537,20 @@ function ProfileStatus() {
</Button> </Button>
</Box> </Box>
{saveState.status === AsyncStatus.Error && ( {saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}> <Text size="T200" style={{ color: color.Critical.Main }}>
Failed to save status server may be rate limiting. Try again. Failed to save status server may be rate limiting. Try again.
</Text> </Text>
)} )}
<Box alignItems="Center" gap="200"> <Box alignItems="Center" gap="200">
<Text size="T200" style={{ opacity: 0.6, whiteSpace: 'nowrap', flexShrink: 0 }}> <Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
Auto-clear after: Auto-clear after:
</Text> </Text>
<select <SettingsSelect
value={clearAfter} value={clearAfter}
onChange={(e) => setClearAfter(e.target.value)} options={CLEAR_AFTER_OPTIONS}
onChange={setClearAfter}
aria-label="Auto-clear status after" aria-label="Auto-clear status after"
style={{ />
background: color.SurfaceVariant.Container,
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
color: color.SurfaceVariant.OnContainer,
colorScheme: 'dark',
fontSize: '0.82rem',
padding: `${config.space.S100} ${config.space.S200}`,
cursor: 'pointer',
outline: 'none',
}}
>
{CLEAR_AFTER_OPTIONS.map((opt) => (
<option
key={opt.value}
value={opt.value}
style={{
background: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
}}
>
{opt.label}
</option>
))}
</select>
</Box> </Box>
{(presence?.status || statusMsg) && ( {(presence?.status || statusMsg) && (
<Button <Button
@@ -730,7 +708,7 @@ function ProfilePronouns() {
</Button> </Button>
</Box> </Box>
{saveState.status === AsyncStatus.Error && ( {saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}> <Text size="T200" style={{ color: color.Critical.Main }}>
Failed to save pronouns. Try again. Failed to save pronouns. Try again.
</Text> </Text>
)} )}
@@ -781,10 +759,6 @@ function ProfileTimezone() {
); );
const saving = saveState.status === AsyncStatus.Loading; const saving = saveState.status === AsyncStatus.Loading;
const handleSelectChange = (evt: React.ChangeEvent<HTMLSelectElement>) => {
setTimezone(evt.currentTarget.value);
};
const handleReset = () => { const handleReset = () => {
setTimezone(savedTimezone); setTimezone(savedTimezone);
}; };
@@ -813,39 +787,16 @@ function ProfileTimezone() {
<Box direction="Column" grow="Yes" gap="100"> <Box direction="Column" grow="Yes" gap="100">
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center" aria-disabled={saving}> <Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center" aria-disabled={saving}>
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
<select <SettingsSelect
name="timezoneInput"
aria-label="Timezone"
value={timezone} value={timezone}
onChange={handleSelectChange} options={[
{ value: '', label: '— select timezone —' },
...COMMON_TIMEZONES.map((tz) => ({ value: tz, label: tz })),
]}
onChange={setTimezone}
disabled={saving} disabled={saving}
style={{ aria-label="Timezone"
background: color.SurfaceVariant.Container, />
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
color: color.SurfaceVariant.OnContainer,
colorScheme: 'dark',
fontSize: '0.875rem',
padding: `${config.space.S200} ${config.space.S300}`,
width: '100%',
cursor: 'pointer',
outline: 'none',
}}
>
<option value=""> select timezone </option>
{COMMON_TIMEZONES.map((tz) => (
<option
key={tz}
value={tz}
style={{
background: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
}}
>
{tz}
</option>
))}
</select>
</Box> </Box>
{hasChanges && !saving && ( {hasChanges && !saving && (
<IconButton <IconButton
@@ -873,7 +824,7 @@ function ProfileTimezone() {
</Button> </Button>
</Box> </Box>
{saveState.status === AsyncStatus.Error && ( {saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}> <Text size="T200" style={{ color: color.Critical.Main }}>
Failed to save timezone. Try again. Failed to save timezone. Try again.
</Text> </Text>
)} )}
@@ -37,12 +37,12 @@ function DecorationPreviewCell({
width: CELL_SIZE, width: CELL_SIZE,
height: CELL_SIZE, height: CELL_SIZE,
flexShrink: 0, flexShrink: 0,
border: `2px solid ${selected ? 'var(--accent-cyan)' : 'transparent'}`, border: `2px solid ${selected ? color.Primary.Main : 'transparent'}`,
borderRadius: '50%', borderRadius: '50%',
background: 'var(--bg-surface-variant)', background: color.SurfaceVariant.Container,
cursor: 'pointer', cursor: 'pointer',
padding: 0, padding: 0,
boxShadow: selected ? '0 0 0 1px var(--accent-cyan)' : 'none', boxShadow: selected ? `0 0 0 1px ${color.Primary.Main}` : 'none',
overflow: 'hidden', overflow: 'hidden',
outline: 'none', outline: 'none',
}} }}
@@ -142,7 +142,7 @@ export function ProfileDecoration() {
height: CELL_SIZE, height: CELL_SIZE,
flexShrink: 0, flexShrink: 0,
borderRadius: '50%', borderRadius: '50%',
background: 'var(--bg-surface-variant)', background: color.SurfaceVariant.Container,
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
@@ -218,7 +218,7 @@ export function ProfileDecoration() {
> >
{DECORATION_CATEGORIES.map((category) => ( {DECORATION_CATEGORIES.map((category) => (
<div key={category.id} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <div key={category.id} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Text size="L400" style={{ opacity: 0.7 }}> <Text size="L400" priority="400">
{category.label} {category.label}
</Text> </Text>
<div <div
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Button, Text } from 'folds'; import { Box, Button, color, config, Text } from 'folds';
import { DenoiseModelId } from '../../../state/settings'; import { DenoiseModelId } from '../../../state/settings';
import { DENOISE_MODELS } from '../../../utils/lotusDenoiseUtils'; import { DENOISE_MODELS } from '../../../utils/lotusDenoiseUtils';
import { import {
@@ -49,8 +49,8 @@ function DbMeter({ label, db, threshold }: { label: string; db: number; threshol
style={{ style={{
position: 'relative', position: 'relative',
height: '12px', height: '12px',
background: 'var(--bg-card)', background: color.Surface.Container,
border: '1px solid var(--border-color)', border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
borderRadius: '6px', borderRadius: '6px',
overflow: 'hidden', overflow: 'hidden',
}} }}
@@ -62,7 +62,7 @@ function DbMeter({ label, db, threshold }: { label: string; db: number; threshol
left: 0, left: 0,
bottom: 0, bottom: 0,
width: `${pct}%`, width: `${pct}%`,
background: 'var(--accent-green)', background: color.Success.Main,
transition: 'width 0.05s linear', transition: 'width 0.05s linear',
}} }}
/> />
@@ -74,7 +74,7 @@ function DbMeter({ label, db, threshold }: { label: string; db: number; threshol
bottom: 0, bottom: 0,
left: `${markerPct}%`, left: `${markerPct}%`,
width: '2px', width: '2px',
background: 'var(--accent-orange)', background: color.Primary.Main,
}} }}
/> />
)} )}
+16 -86
View File
@@ -81,6 +81,7 @@ import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { playCallJoinSound } from '../../../utils/callSounds'; import { playCallJoinSound } from '../../../utils/callSounds';
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones'; import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester'; import { DenoiseTester } from './DenoiseTester';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
type ThemeSelectorProps = { type ThemeSelectorProps = {
themeNames: Record<string, string>; themeNames: Record<string, string>;
@@ -169,83 +170,6 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
); );
} }
type SettingsSelectOption<T extends string> = { value: T; label: string; disabled?: boolean };
function SettingsSelect<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: SettingsSelectOption<T>[];
onChange: (v: T) => void;
}) {
const [menuCords, setMenuCords] = useState<RectCords>();
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (v: T) => {
onChange(v);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
>
<Text size="T300">{selectedLabel}</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
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 }}>
{options.map((opt) => (
<MenuItem
key={opt.value}
size="300"
variant={opt.value === value ? 'Primary' : 'Surface'}
radii="300"
disabled={opt.disabled}
onClick={() => !opt.disabled && handleSelect(opt.value)}
>
<Text size="T300">{opt.label}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
function SystemThemePreferences() { function SystemThemePreferences() {
const themeKind = useSystemThemeKind(); const themeKind = useSystemThemeKind();
const themeNames = useThemeNames(); const themeNames = useThemeNames();
@@ -1372,8 +1296,8 @@ function Calls() {
style={{ style={{
padding: '16px', padding: '16px',
marginTop: '8px', marginTop: '8px',
borderTop: '1px solid var(--border-color)', borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
background: 'var(--bg-card)', background: color.Surface.Container,
}} }}
> >
{/* ── Model selection ───────────────────────────────────────── */} {/* ── Model selection ───────────────────────────────────────── */}
@@ -1397,8 +1321,8 @@ function Calls() {
style={{ style={{
padding: '12px', padding: '12px',
borderRadius: '8px', borderRadius: '8px',
border: '1px solid var(--border-color)', border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
background: 'var(--bg-input)', background: color.SurfaceVariant.Container,
}} }}
> >
<Text size="T300">{selectedDenoiseModel.name}</Text> <Text size="T300">{selectedDenoiseModel.name}</Text>
@@ -1436,7 +1360,7 @@ function Calls() {
direction="Row" direction="Row"
gap="100" gap="100"
style={{ style={{
borderBottom: '1px solid var(--border-color)', borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
paddingBottom: '4px', paddingBottom: '4px',
}} }}
> >
@@ -1489,7 +1413,10 @@ function Calls() {
<Box <Box
direction="Column" direction="Column"
gap="300" gap="300"
style={{ paddingTop: '12px', borderTop: '1px solid var(--border-color)' }} style={{
paddingTop: '12px',
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
> >
<Text size="L400">Enhancements</Text> <Text size="L400">Enhancements</Text>
<SettingTile <SettingTile
@@ -1525,7 +1452,7 @@ function Calls() {
step="1" step="1"
value={callDenoiseGateThreshold} value={callDenoiseGateThreshold}
onChange={(e) => setCallDenoiseGateThreshold(parseInt(e.target.value, 10))} onChange={(e) => setCallDenoiseGateThreshold(parseInt(e.target.value, 10))}
style={{ width: '100%', accentColor: 'var(--accent-orange)' }} style={{ width: '100%', accentColor: color.Primary.Main }}
/> />
</Box> </Box>
)} )}
@@ -1535,7 +1462,10 @@ function Calls() {
<Box <Box
direction="Column" direction="Column"
gap="200" gap="200"
style={{ paddingTop: '12px', borderTop: '1px solid var(--border-color)' }} style={{
paddingTop: '12px',
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
> >
<Text size="L400">Test &amp; calibrate</Text> <Text size="L400">Test &amp; calibrate</Text>
<Text size="T200" priority="300"> <Text size="T200" priority="300">
@@ -1658,7 +1588,7 @@ function Calls() {
value={ringtoneVolume} value={ringtoneVolume}
onChange={(e) => setRingtoneVolume(parseInt(e.target.value, 10))} onChange={(e) => setRingtoneVolume(parseInt(e.target.value, 10))}
aria-label="Ringtone volume" aria-label="Ringtone volume"
style={{ flex: 1, accentColor: 'var(--accent-orange)' }} style={{ flex: 1, accentColor: color.Primary.Main }}
/> />
<Text size="T200" style={{ minWidth: '32px', textAlign: 'right' }}> <Text size="T200" style={{ minWidth: '32px', textAlign: 'right' }}>
{ringtoneVolume}% {ringtoneVolume}%
@@ -1,6 +1,7 @@
import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react'; import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react';
import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk'; import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk';
import { Box, Text, Button, Input, config, IconButton, Icons, Icon, Spinner, Switch } from 'folds'; import { Box, Text, Button, Input, config, IconButton, Icons, Icon, Spinner, Switch } from 'folds';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
import { useAccountData } from '../../../hooks/useAccountData'; import { useAccountData } from '../../../hooks/useAccountData';
import { AccountDataEvent } from '../../../../types/matrix/accountData'; import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
@@ -193,10 +194,6 @@ function AddRuleForm({ kind, placeholder, label }: AddRuleFormProps) {
setRuleId(evt.currentTarget.value); setRuleId(evt.currentTarget.value);
}; };
const handleModeChange: ChangeEventHandler<HTMLSelectElement> = (evt) => {
setMode(evt.target.value as NotificationMode);
};
return ( return (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="200"> <Box as="form" onSubmit={handleSubmit} direction="Column" gap="200">
<Text size="T200" priority="300"> <Text size="T200" priority="300">
@@ -217,24 +214,12 @@ function AddRuleForm({ kind, placeholder, label }: AddRuleFormProps) {
/> />
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<select <SettingsSelect
value={mode} value={mode}
onChange={handleModeChange} options={ADD_MODES.map((m) => ({ value: m, label: MODE_LABELS[m] }))}
style={{ onChange={setMode}
background: 'transparent', aria-label="Notification mode"
border: '1px solid currentColor', />
borderRadius: config.radii.R300,
padding: `${config.space.S100} ${config.space.S200}`,
color: 'inherit',
fontSize: 'inherit',
}}
>
{ADD_MODES.map((m) => (
<option key={m} value={m}>
{MODE_LABELS[m]}
</option>
))}
</select>
</Box> </Box>
<Button <Button
size="400" size="400"
+56 -39
View File
@@ -1,6 +1,10 @@
import React, { useEffect, useRef, CSSProperties } from 'react'; import React, { useEffect, useRef, CSSProperties } from 'react';
import { useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import { color, config, Icon, IconButton, Icons } from 'folds';
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast'; import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { zIndices } from '../../styles/zIndex';
// Inject the keyframe animation once // Inject the keyframe animation once
const STYLE_ID = 'lotus-toast-keyframes'; const STYLE_ID = 'lotus-toast-keyframes';
@@ -29,6 +33,10 @@ type ToastCardProps = {
function ToastCard({ toast }: ToastCardProps) { function ToastCard({ toast }: ToastCardProps) {
const dismiss = useSetAtom(dismissToastAtom); const dismiss = useSetAtom(dismissToastAtom);
// Lotus Terminal (TDS) gets its bespoke glow/accents; every other theme uses
// folds tokens so toasts render correctly on stock Cinny themes (the --lt-*
// vars only exist while Terminal mode is active).
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
@@ -56,17 +64,29 @@ function ToastCard({ toast }: ToastCardProps) {
dismiss(toast.id); dismiss(toast.id);
}; };
const accent = toast.sticky ? color.Primary.Main : color.Surface.OnContainer;
const cardStyle: CSSProperties = { const cardStyle: CSSProperties = {
position: 'relative', position: 'relative',
background: 'var(--lt-bg-card)', background: lotusTerminal ? 'var(--lt-bg-card)' : color.Surface.Container,
border: toast.sticky border: `${config.borderWidth.B300} solid ${
? '1px solid var(--lt-accent-cyan-border)' lotusTerminal
: '1px solid var(--lt-border-color)', ? toast.sticky
borderRadius: '12px', ? 'var(--lt-accent-cyan-border)'
padding: '12px 14px', : 'var(--lt-border-color)'
: toast.sticky
? color.Primary.Main
: color.Surface.ContainerLine
}`,
borderRadius: config.radii.R400,
padding: `${config.space.S300} ${config.space.S400}`,
minWidth: '280px', minWidth: '280px',
maxWidth: '340px', maxWidth: '340px',
boxShadow: toast.sticky ? 'var(--lt-box-glow-cyan)' : 'var(--lt-box-glow-orange)', boxShadow: lotusTerminal
? toast.sticky
? 'var(--lt-box-glow-cyan)'
: 'var(--lt-box-glow-orange)'
: `0 8px 24px ${color.Other.Shadow}`,
cursor: 'pointer', cursor: 'pointer',
animation: 'lotusToastIn 0.2s ease-out both', animation: 'lotusToastIn 0.2s ease-out both',
userSelect: 'none', userSelect: 'none',
@@ -75,8 +95,8 @@ function ToastCard({ toast }: ToastCardProps) {
const rowStyle: CSSProperties = { const rowStyle: CSSProperties = {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '8px', gap: config.space.S200,
marginRight: '20px', marginRight: config.space.S500,
}; };
const avatarStyle: CSSProperties = { const avatarStyle: CSSProperties = {
@@ -91,19 +111,25 @@ function ToastCard({ toast }: ToastCardProps) {
width: '24px', width: '24px',
height: '24px', height: '24px',
borderRadius: '50%', borderRadius: '50%',
background: 'var(--lt-accent-orange-dim)', background: lotusTerminal ? 'var(--lt-accent-orange-dim)' : color.Primary.Container,
border: '1px solid var(--lt-accent-orange-border)', border: `${config.borderWidth.B300} solid ${
lotusTerminal ? 'var(--lt-accent-orange-border)' : color.Primary.ContainerLine
}`,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontSize: '10px', fontSize: '10px',
fontWeight: 700, fontWeight: 700,
color: 'var(--lt-accent-orange)', color: lotusTerminal ? 'var(--lt-accent-orange)' : color.Primary.OnContainer,
flexShrink: 0, flexShrink: 0,
}; };
const nameStyle: CSSProperties = { const nameStyle: CSSProperties = {
color: toast.sticky ? 'var(--lt-accent-cyan)' : 'var(--lt-accent-orange)', color: lotusTerminal
? toast.sticky
? 'var(--lt-accent-cyan)'
: 'var(--lt-accent-orange)'
: accent,
fontWeight: 600, fontWeight: 600,
fontSize: '0.85rem', fontSize: '0.85rem',
overflow: 'hidden', overflow: 'hidden',
@@ -111,22 +137,8 @@ function ToastCard({ toast }: ToastCardProps) {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}; };
const dismissBtnStyle: CSSProperties = {
position: 'absolute',
top: '8px',
right: '10px',
background: 'none',
border: 'none',
color: 'var(--lt-text-secondary)',
cursor: 'pointer',
fontSize: '14px',
lineHeight: 1,
padding: '2px 4px',
borderRadius: '4px',
};
const bodyStyle: CSSProperties = { const bodyStyle: CSSProperties = {
color: 'var(--lt-text-primary)', color: lotusTerminal ? 'var(--lt-text-primary)' : color.Surface.OnContainer,
fontSize: '0.82rem', fontSize: '0.82rem',
margin: '4px 0 2px', margin: '4px 0 2px',
overflow: 'hidden', overflow: 'hidden',
@@ -136,7 +148,7 @@ function ToastCard({ toast }: ToastCardProps) {
}; };
const roomNameStyle: CSSProperties = { const roomNameStyle: CSSProperties = {
color: 'var(--lt-text-secondary)', color: lotusTerminal ? 'var(--lt-text-secondary)' : color.SurfaceVariant.OnContainer,
fontSize: '0.75rem', fontSize: '0.75rem',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
@@ -161,14 +173,19 @@ function ToastCard({ toast }: ToastCardProps) {
}} }}
aria-label={`Notification from ${toast.displayName} in ${toast.roomName}`} aria-label={`Notification from ${toast.displayName} in ${toast.roomName}`}
> >
<button <span style={{ position: 'absolute', top: config.space.S100, right: config.space.S100 }}>
type="button" <IconButton
style={dismissBtnStyle} type="button"
onClick={handleDismiss} size="300"
aria-label="Dismiss notification" radii="300"
> variant="Surface"
× fill="None"
</button> onClick={handleDismiss}
aria-label="Dismiss notification"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</span>
<div style={rowStyle}> <div style={rowStyle}>
{toast.avatarUrl ? ( {toast.avatarUrl ? (
<img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" /> <img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" />
@@ -198,10 +215,10 @@ export function LotusToastContainer() {
position: 'fixed', position: 'fixed',
bottom: '1.5rem', bottom: '1.5rem',
right: '1.5rem', right: '1.5rem',
zIndex: 10001, zIndex: zIndices.toast,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '8px', gap: config.space.S200,
pointerEvents: 'auto', pointerEvents: 'auto',
}; };
+27 -23
View File
@@ -1,6 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { CallEmbed } from '../plugins/call'; import { CallEmbed, useCallControlState } from '../plugins/call';
import { useSetting } from '../state/hooks/settings'; import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
import { toastQueueAtom } from '../state/toast'; import { toastQueueAtom } from '../state/toast';
@@ -9,17 +9,25 @@ const SILENCE_RMS_THRESHOLD = 0.008;
const CHECK_INTERVAL_MS = 500; const CHECK_INTERVAL_MS = 500;
/** /**
* Monitors microphone audio while in a call. If the mic stays active but * Monitors microphone audio while in a call. If the mic stays unmuted but
* silent for longer than the configured timeout, the mic is muted and a * silent for longer than the configured timeout, the mic is muted and a toast
* toast is shown. Cleans up its own AudioContext and stream on unmount. * is shown.
*
* The level-monitoring capture (`getUserMedia`) is opened ONLY while the mic is
* unmuted there is nothing to auto-mute once you are already muted, so
* holding the capture would keep the OS recording indicator lit even though the
* UI shows you as muted (N95). Muting therefore releases our stream; unmuting
* re-acquires it. The AudioContext + stream are also torn down on unmount.
*/ */
export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void { export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
const [enabled] = useSetting(settingsAtom, 'afkAutoMute'); const [enabled] = useSetting(settingsAtom, 'afkAutoMute');
const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes'); const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
const setToast = useSetAtom(toastQueueAtom); const setToast = useSetAtom(toastQueueAtom);
const { microphone } = useCallControlState(callEmbed?.control);
useEffect(() => { useEffect(() => {
if (!callEmbed || !enabled) return; // Only capture while in a call, enabled, AND unmuted (see N95 note above).
if (!callEmbed || !enabled || !microphone) return undefined;
let stream: MediaStream | undefined; let stream: MediaStream | undefined;
let audioCtx: AudioContext | undefined; let audioCtx: AudioContext | undefined;
@@ -49,24 +57,20 @@ export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
const rms = Math.sqrt(buffer.reduce((sum, v) => sum + v * v, 0) / buffer.length); const rms = Math.sqrt(buffer.reduce((sum, v) => sum + v * v, 0) / buffer.length);
if (rms > SILENCE_RMS_THRESHOLD) { if (rms > SILENCE_RMS_THRESHOLD) {
// Audio detected — reset the silence timer // Audio detected — reset the silence timer.
silenceStart = null; silenceStart = null;
} else if (callEmbed.control.microphone) { } else if (silenceStart === null) {
// Mic is on but silent — start or advance the timer // Mic is unmuted (effect only runs while unmuted) but silent — start the timer.
if (silenceStart === null) silenceStart = Date.now(); silenceStart = Date.now();
else if (Date.now() - silenceStart >= timeoutMs) { } else if (Date.now() - silenceStart >= timeoutMs) {
callEmbed.control.setMicrophone(false); callEmbed.control.setMicrophone(false);
setToast({ setToast({
id: `afk-mute-${Date.now()}`, id: `afk-mute-${Date.now()}`,
displayName: 'Lotus Chat', displayName: 'Lotus Chat',
body: 'Your microphone was muted after inactivity.', body: 'Your microphone was muted after inactivity.',
roomName: 'Voice call', roomName: 'Voice call',
roomId: callEmbed.roomId, roomId: callEmbed.roomId,
}); });
silenceStart = null;
}
} else {
// Mic is already muted — don't count silence
silenceStart = null; silenceStart = null;
} }
}, CHECK_INTERVAL_MS); }, CHECK_INTERVAL_MS);
@@ -79,5 +83,5 @@ export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
stream?.getTracks().forEach((t) => t.stop()); stream?.getTracks().forEach((t) => t.stop());
audioCtx?.close().catch(() => undefined); audioCtx?.close().catch(() => undefined);
}; };
}, [callEmbed, enabled, timeoutMinutes, setToast]); }, [callEmbed, enabled, timeoutMinutes, setToast, microphone]);
} }
+34 -3
View File
@@ -35,6 +35,19 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
callEmbed.iframe.contentDocument ?? callEmbed.iframe.contentWindow?.document ?? undefined; callEmbed.iframe.contentDocument ?? callEmbed.iframe.contentWindow?.document ?? undefined;
const syncState = (): void => { const syncState = (): void => {
// [lotus #2] Prefer the fork's io.lotus.call_state events over scraping
// EC's rendered DOM. Falls back to the DOM path below when the fork hasn't
// sent yet (null) OR sent a spurious empty list (you're always present in
// your own joined call, so [] means "no usable data", not "nobody").
const lotus = callEmbed.getLotusParticipants();
if (lotus !== null && lotus.length > 0) {
const ls = new Set<string>();
lotus.forEach((p) => {
if (p.speaking && isUserId(p.userId)) ls.add(p.userId);
});
setSpeakers(ls);
return;
}
const doc = getDoc(); const doc = getDoc();
if (!doc) { if (!doc) {
setSpeakers(new Set<string>()); setSpeakers(new Set<string>());
@@ -91,6 +104,8 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
}; };
attachObserver(); attachObserver();
// [lotus #2] Re-derive whenever the fork pushes new call-state.
const unsubLotus = callEmbed.onLotusCallState(syncState);
// If iframe isn't ready yet, wait for body to be available. // If iframe isn't ready yet, wait for body to be available.
let bodyWatcher: MutationObserver | undefined; let bodyWatcher: MutationObserver | undefined;
@@ -109,6 +124,7 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
return () => { return () => {
tileObserver?.disconnect(); tileObserver?.disconnect();
bodyWatcher?.disconnect(); bodyWatcher?.disconnect();
unsubLotus();
}; };
}, [callEmbed, callMembers, joined]); }, [callEmbed, callMembers, joined]);
@@ -137,6 +153,14 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
const localUserId = callEmbed.room.client?.getUserId() ?? ''; const localUserId = callEmbed.room.client?.getUserId() ?? '';
const syncState = (): void => { const syncState = (): void => {
// [lotus #2] Prefer the fork's io.lotus.call_state over DOM scraping;
// ignore a spurious empty list (fall back to DOM).
const lotus = callEmbed.getLotusParticipants();
if (lotus !== null && lotus.length > 0) {
const remote = lotus.filter((p) => p.userId !== localUserId);
setMuted(remote.length > 0 && remote.every((p) => !p.audioEnabled));
return;
}
const doc = getDoc(); const doc = getDoc();
if (!doc) { if (!doc) {
setMuted(false); setMuted(false);
@@ -145,13 +169,17 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
// Each participant's mute icon has data-muted="true"|"false" and // Each participant's mute icon has data-muted="true"|"false" and
// aria-label set to their Matrix user ID. // aria-label set to their Matrix user ID.
const muteIcons = doc.querySelectorAll<HTMLElement>('[data-muted]'); const muteIcons = doc.querySelectorAll<HTMLElement>('[data-muted]');
let anyRemoteMuted = false; let remoteCount = 0;
let remoteMutedCount = 0;
muteIcons.forEach((el) => { muteIcons.forEach((el) => {
const userId = el.getAttribute('aria-label') ?? ''; const userId = el.getAttribute('aria-label') ?? '';
if (userId === localUserId) return; if (userId === localUserId) return;
if (el.getAttribute('data-muted') === 'true') anyRemoteMuted = true; remoteCount += 1;
if (el.getAttribute('data-muted') === 'true') remoteMutedCount += 1;
}); });
setMuted(anyRemoteMuted); // "All muted" badge: true only when there is at least one remote
// participant and every one of them is muted (not merely any single one).
setMuted(remoteCount > 0 && remoteMutedCount === remoteCount);
}; };
let tileObserver: MutationObserver | undefined; let tileObserver: MutationObserver | undefined;
@@ -186,6 +214,8 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
}; };
attachObserver(); attachObserver();
// [lotus #2] Re-derive whenever the fork pushes new call-state.
const unsubLotus = callEmbed.onLotusCallState(syncState);
// If iframe isn't ready yet, wait for body to be available. // If iframe isn't ready yet, wait for body to be available.
let bodyWatcher: MutationObserver | undefined; let bodyWatcher: MutationObserver | undefined;
@@ -204,6 +234,7 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
return () => { return () => {
tileObserver?.disconnect(); tileObserver?.disconnect();
bodyWatcher?.disconnect(); bodyWatcher?.disconnect();
unsubLotus();
}; };
}, [callEmbed]); }, [callEmbed]);
+31 -37
View File
@@ -1,7 +1,16 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import * as Sentry from '@sentry/react'; import { ErrorBoundary } from 'react-error-boundary';
import { Provider as JotaiProvider, useAtomValue } from 'jotai'; import { Provider as JotaiProvider, useAtomValue } from 'jotai';
import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds'; import {
Box,
Button,
config,
OverlayContainerProvider,
PopOutContainerProvider,
Text,
toRem,
TooltipContainerProvider,
} from 'folds';
import { RouterProvider } from 'react-router-dom'; import { RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
@@ -18,6 +27,7 @@ import { LotusToastContainer } from '../features/toast/LotusToastContainer';
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge'; import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect'; import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor'; import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
import { zIndices } from '../styles/zIndex';
const FONT_MAP: Record<string, string> = { const FONT_MAP: Record<string, string> = {
system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
@@ -86,7 +96,7 @@ function NightLightOverlay() {
position: 'fixed', position: 'fixed',
inset: 0, inset: 0,
pointerEvents: 'none', pointerEvents: 'none',
zIndex: 9998, zIndex: zIndices.nightLight,
backgroundColor: `rgba(255, 140, 0, ${(settings.nightLightOpacity ?? 30) / 100})`, backgroundColor: `rgba(255, 140, 0, ${(settings.nightLightOpacity ?? 30) / 100})`,
}} }}
/> />
@@ -102,41 +112,25 @@ function App() {
const portalContainer = document.getElementById('portalContainer') ?? undefined; const portalContainer = document.getElementById('portalContainer') ?? undefined;
return ( return (
<Sentry.ErrorBoundary <ErrorBoundary
fallback={({ error, resetError }) => ( fallbackRender={({ error, resetErrorBoundary }) => (
<div <Box
style={{ direction="Column"
display: 'flex', alignItems="Center"
flexDirection: 'column', justifyContent="Center"
alignItems: 'center', gap="400"
justifyContent: 'center', style={{ height: '100vh', padding: config.space.S700, textAlign: 'center' }}
height: '100vh',
gap: '16px',
fontFamily: 'sans-serif',
padding: '24px',
textAlign: 'center',
}}
> >
<h2 style={{ margin: 0 }}>Something went wrong</h2> <Text size="H2">Something went wrong</Text>
<p style={{ margin: 0, color: '#666', maxWidth: '400px' }}> <Text size="T300" priority="300" style={{ maxWidth: toRem(400) }}>
{error instanceof Error ? error.message : 'An unexpected error occurred.'} {error instanceof Error ? error.message : 'An unexpected error occurred.'}
</p> </Text>
<button <Button variant="Primary" onClick={resetErrorBoundary}>
type="button" <Text as="span" size="B400">
onClick={resetError} Try again
style={{ </Text>
padding: '8px 20px', </Button>
borderRadius: '6px', </Box>
border: 'none',
background: '#5865f2',
color: '#fff',
cursor: 'pointer',
fontSize: '14px',
}}
>
Try again
</button>
</div>
)} )}
> >
<TooltipContainerProvider value={portalContainer}> <TooltipContainerProvider value={portalContainer}>
@@ -171,7 +165,7 @@ function App() {
</OverlayContainerProvider> </OverlayContainerProvider>
</PopOutContainerProvider> </PopOutContainerProvider>
</TooltipContainerProvider> </TooltipContainerProvider>
</Sentry.ErrorBoundary> </ErrorBoundary>
); );
} }
+17 -27
View File
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { useRouteError, isRouteErrorResponse } from 'react-router-dom'; import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
import { Box, Button, config, Text, toRem } from 'folds';
export function RouteError() { export function RouteError() {
const error = useRouteError(); const error = useRouteError();
@@ -11,33 +12,22 @@ export function RouteError() {
: 'An unexpected error occurred.'; : 'An unexpected error occurred.';
return ( return (
<div <Box
style={{ direction="Column"
display: 'flex', alignItems="Center"
flexDirection: 'column', justifyContent="Center"
alignItems: 'center', gap="400"
justifyContent: 'center', style={{ height: '100dvh', padding: config.space.S700 }}
height: '100dvh',
gap: '16px',
padding: '32px',
fontFamily: 'sans-serif',
}}
> >
<h2 style={{ margin: 0, fontSize: '1.25rem' }}>Something went wrong</h2> <Text size="H3">Something went wrong</Text>
<p style={{ margin: 0, opacity: 0.7, textAlign: 'center' }}>{message}</p> <Text size="T300" priority="300" style={{ textAlign: 'center', maxWidth: toRem(400) }}>
<button {message}
type="button" </Text>
onClick={() => window.location.reload()} <Button variant="Primary" onClick={() => window.location.reload()}>
style={{ <Text as="span" size="B400">
padding: '8px 20px', Reload
borderRadius: '8px', </Text>
border: 'none', </Button>
cursor: 'pointer', </Box>
fontWeight: 600,
}}
>
Reload
</button>
</div>
); );
} }
+9 -64
View File
@@ -346,71 +346,16 @@ export class CallControl extends EventEmitter implements CallControlState {
* them yet). * them yet).
*/ */
public focusCameraParticipant(userId: string): void { public focusCameraParticipant(userId: string): void {
const doc = this.document; // [lotus #4] Pin the participant via the fork's widget action instead of
if (!doc) return; // DOM-poking tiles. EC's layout honors it — including surfacing the camera
// alongside a screenshare (A5) — and it's version-stable. The fork always
// acks, so the promise resolves regardless.
this.call.transport.send('io.lotus.focus_participant', { userId }).catch(() => undefined);
}
// EC labels participant tiles inconsistently across versions — the user's /** [lotus #4] Clear any manual spotlight pin and return to speaker-follows. */
// matrix id may be the full aria-label, a substring of it, or carried on a public clearFocusParticipant(): void {
// data attribute (and sometimes the visible label is the display name, not this.call.transport.send('io.lotus.focus_participant', { userId: null }).catch(() => undefined);
// the id at all). Try several strategies before giving up, then walk up to
// the enclosing video tile.
const findTile = (): HTMLElement | undefined => {
const escaped = CSS.escape(userId);
const el =
doc.querySelector<HTMLElement>(`[aria-label="${escaped}"]`) ??
doc.querySelector<HTMLElement>(`[data-testid="videoTile"][aria-label*="${escaped}"]`) ??
doc.querySelector<HTMLElement>(`[aria-label*="${escaped}"]`) ??
doc.querySelector<HTMLElement>(`[data-member-id="${escaped}"]`) ??
doc.querySelector<HTMLElement>(`[data-id="${escaped}"]`) ??
undefined;
return (
el?.closest<HTMLElement>('[data-testid="videoTile"]') ??
el?.closest<HTMLElement>('[data-video-fit]') ??
el ??
undefined
);
};
const applyFocus = () => {
const tile = findTile();
if (tile) {
tile.click();
} else if (import.meta.env.DEV) {
console.warn(`[CallControl] focusCameraParticipant: no tile matched ${userId}`);
}
};
if (this.spotlight) {
// Already in spotlight — pin immediately.
applyFocus();
return;
}
// Switching to spotlight re-renders EC's layout asynchronously; clicking the
// tile in the same tick would land in the old (grid) DOM. A fixed frame
// delay is unreliable (EC's React commit can exceed it on slow devices), so
// watch the iframe DOM for a spotlight video tile to mount, then focus —
// with a hard timeout so the click is always attempted at least once.
this.spotlightButton?.click();
const tileSelector = '[data-testid="videoTile"]';
let settled = false;
let observer: MutationObserver | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
observer?.disconnect();
applyFocus();
};
observer = new MutationObserver(() => {
if (doc.querySelector(tileSelector)) finish();
});
observer.observe(doc.body, { childList: true, subtree: true });
timer = setTimeout(finish, 600);
// A tile may already be present immediately after toggling spotlight.
if (doc.querySelector(tileSelector)) finish();
} }
public dispose() { public dispose() {
+61 -7
View File
@@ -36,6 +36,15 @@ const CALL_LOAD_WATCHDOG_MS = 25_000;
export type CallLoadErrorReason = 'timeout' | 'iframe'; export type CallLoadErrorReason = 'timeout' | 'iframe';
/** Payload entry of the fork's io.lotus.call_state widget event (#2). */
export interface LotusCallParticipant {
id: string;
userId: string;
speaking: boolean;
audioEnabled: boolean;
videoEnabled: boolean;
}
export class CallEmbed { export class CallEmbed {
private mx: MatrixClient; private mx: MatrixClient;
@@ -47,6 +56,13 @@ export class CallEmbed {
public joined = false; public joined = false;
// [lotus #2] Latest per-participant state from io.lotus.call_state, or null
// until the fork sends the first one. When non-null, the speaker/mute hooks
// read it instead of scraping the EC iframe DOM.
private lotusParticipants: LotusCallParticipant[] | null = null;
private lotusCallStateListeners = new Set<() => void>();
public readonly control: CallControl; public readonly control: CallControl;
private readonly container: HTMLElement; private readonly container: HTMLElement;
@@ -122,7 +138,9 @@ export class CallEmbed {
themeKind: ElementCallThemeKind, themeKind: ElementCallThemeKind,
denoiseMode: NoiseSuppressionMode = 'browser', denoiseMode: NoiseSuppressionMode = 'browser',
denoiseModel: string = 'rnnoise', denoiseModel: string = 'rnnoise',
denoiseNativeNS: boolean = true, // [lotus] no longer used by the in-source denoise path; kept positionally
// for callers. Prefixed with _ to satisfy no-unused-vars.
_denoiseNativeNS: boolean = true,
denoiseGate: boolean = false, denoiseGate: boolean = false,
denoiseGateThreshold: number = -45, denoiseGateThreshold: number = -45,
initialAudio = true, initialAudio = true,
@@ -148,20 +166,30 @@ export class CallEmbed {
perParticipantE2EE: room.hasEncryptionStateEvent().toString(), perParticipantE2EE: room.hasEncryptionStateEvent().toString(),
lang: 'en-EN', lang: 'en-EN',
theme: themeKind, theme: themeKind,
// EC's built-in WebRTC suppressor: on only for 'browser' tier. For 'ml' we // EC's built-in WebRTC suppressor: on only for 'browser' tier. For 'ml'
// disable it here so EC doesn't do its own extra processing, and let the // we disable it so EC captures a raw mic and the fork's in-source denoise
// Lotus denoise shim (which keeps native NS on) handle the pipeline. // TrackProcessor (lotusDenoiseSource) handles the pipeline.
noiseSuppression: (denoiseMode === 'browser').toString(), noiseSuppression: (denoiseMode === 'browser').toString(),
audio: initialAudio.toString(), audio: initialAudio.toString(),
video: initialVideo.toString(), video: initialVideo.toString(),
header: 'none', header: 'none',
// [lotus] Activate the self-built fork's in-source features (each is a
// no-op on the EC side unless its flag/action is present):
// - call-state stream (speaking/mute events) -> useCallSpeakers
// - transparent background so the room wallpaper shows through natively
lotusCallState: 'true',
lotusTransparent: 'true',
}); });
if (denoiseMode === 'ml') { if (denoiseMode === 'ml') {
// Signal the Lotus denoise shim to route the mic through the ML processors. // [lotus] In-source ML denoise: the fork runs RNNoise/Speex/DTLN/DFN as a
params.append('lotusDenoise', 'ml'); // real LiveKit audio TrackProcessor (survives reconnects — fixes A7),
// replacing the old build-time getUserMedia shim. The shim injection was
// removed from vite.config.js; the denoise/ assets are still shipped and
// loaded by the processor. lotusDenoiseSource (not lotusDenoise=ml) gates
// it so the two engines can never both run.
params.append('lotusDenoiseSource', 'true');
params.append('lotusModel', denoiseModel); params.append('lotusModel', denoiseModel);
params.append('lotusNativeNS', denoiseNativeNS.toString());
params.append('lotusGate', denoiseGate.toString()); params.append('lotusGate', denoiseGate.toString());
params.append('lotusGateThreshold', denoiseGateThreshold.toString()); params.append('lotusGateThreshold', denoiseGateThreshold.toString());
} }
@@ -318,6 +346,18 @@ export class CallEmbed {
this.disposables.push( this.disposables.push(
this.listenAction(WidgetApiFromWidgetAction.UpdateAlwaysOnScreen, () => {}), this.listenAction(WidgetApiFromWidgetAction.UpdateAlwaysOnScreen, () => {}),
); );
// [lotus #2] Consume the fork's per-participant call-state stream. listenAction
// auto-replies {} so the fork's transport doesn't time out. Stored for the
// speaker/mute hooks (which prefer this over DOM scraping).
this.disposables.push(
this.listenAction('io.lotus.call_state', (evt) => {
const data = (evt.detail as { data?: { participants?: unknown } } | undefined)?.data;
this.lotusParticipants = Array.isArray(data?.participants)
? (data!.participants as LotusCallParticipant[])
: [];
this.lotusCallStateListeners.forEach((l) => l());
}),
);
// Populate the map of "read up to" events for this widget with the current event in every room. // Populate the map of "read up to" events for this widget with the current event in every room.
// This is a bit inefficient, but should be okay. We do this for all rooms in case the widget // This is a bit inefficient, but should be okay. We do this for all rooms in case the widget
@@ -623,6 +663,20 @@ export class CallEmbed {
} }
} }
/** [lotus #2] Latest io.lotus.call_state participants, or null if the fork
* hasn't sent any yet (callers then fall back to DOM scraping). */
public getLotusParticipants(): LotusCallParticipant[] | null {
return this.lotusParticipants;
}
/** [lotus #2] Subscribe to io.lotus.call_state updates. Returns an unsubscribe. */
public onLotusCallState(cb: () => void): () => void {
this.lotusCallStateListeners.add(cb);
return () => {
this.lotusCallStateListeners.delete(cb);
};
}
public listenAction<T>(type: string, callback: (event: CustomEvent<T>) => void) { public listenAction<T>(type: string, callback: (event: CustomEvent<T>) => void) {
const wrapped = (ev: CustomEvent<T>) => { const wrapped = (ev: CustomEvent<T>) => {
ev.preventDefault(); ev.preventDefault();
+16
View File
@@ -0,0 +1,16 @@
/**
* Global overlay stacking layers, centralized so floating Lotus UI doesn't
* collide. (folds `Overlay`/`Dialog` modals resolve to 9999, which sits between
* `nightLight` and `toast`.) Component-internal stacking uses small local
* z-index values and is intentionally not listed here.
*/
export const zIndices = {
/** In-call incoming-call banner — below seasonal/night-light/modals. */
inCallBanner: 9990,
/** Seasonal particle effect — below the night-light tint so particles tint. */
seasonalEffect: 9997,
/** Night Light tint overlay — above effects, below modals. */
nightLight: 9998,
/** Toasts — above everything, including modals. */
toast: 10001,
} as const;
+6 -1
View File
@@ -102,12 +102,17 @@ const playPhrase = (style: SynthStyle, volume: number, destination: AudioNode):
}); });
}; };
// The bundled call.ogg is mastered near full scale, so at equal `volume` it is
// perceptibly much louder than the synthesized styles (which peak at ~0.120.3).
// Attenuate it so all ringtones sit at a comparable loudness.
const CLASSIC_GAIN = 0.45;
const startClassic = (volume: number, loop: boolean): (() => void) => { const startClassic = (volume: number, loop: boolean): (() => void) => {
let audio: HTMLAudioElement | undefined; let audio: HTMLAudioElement | undefined;
try { try {
audio = new Audio(CallSound); audio = new Audio(CallSound);
audio.loop = loop; audio.loop = loop;
audio.volume = clamp01(volume); audio.volume = clamp01(volume) * CLASSIC_GAIN;
audio.play().catch(() => undefined); audio.play().catch(() => undefined);
} catch { } catch {
audio = undefined; audio = undefined;
-26
View File
@@ -1,5 +1,4 @@
/* eslint-disable import/first */ /* eslint-disable import/first */
import * as Sentry from '@sentry/react';
import React from 'react'; import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { enableMapSet } from 'immer'; import { enableMapSet } from 'immer';
@@ -7,31 +6,6 @@ import '@fontsource-variable/inter/index.css';
import 'folds/dist/style.css'; import 'folds/dist/style.css';
import { configClass, varsClass } from 'folds'; import { configClass, varsClass } from 'folds';
const sentryDsn = import.meta.env.VITE_SENTRY_DSN;
if (sentryDsn) {
Sentry.init({
dsn: sentryDsn,
environment: import.meta.env.MODE,
release: import.meta.env.VITE_APP_VERSION,
// browserTracingIntegration omitted — it injects sentry-trace/baggage headers
// into outgoing fetch calls, which breaks Synapse CORS on matrix.lotusguild.org
// No propagation targets — we don't control the Matrix server's CORS allow-list
tracePropagationTargets: [],
tracesSampleRate: 0,
// Don't send PII (IPs, usernames) — this is a private chat app
sendDefaultPii: false,
// Forward Sentry logs to the dashboard
enableLogs: true,
// Suppress benign PostmessageTransport / matrixRTC heartbeat timeouts (upstream library noise)
ignoreErrors: ['Request timed out'],
beforeSend(event) {
// Drop any event that may have leaked an access token into breadcrumbs/data
if (JSON.stringify(event).includes('access_token')) return null;
return event;
},
});
}
enableMapSet(); enableMapSet();
import './index.css'; import './index.css';
+13 -46
View File
@@ -1,6 +1,5 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { sentryVitePlugin } from '@sentry/vite-plugin';
import { wasm } from '@rollup/plugin-wasm'; import { wasm } from '@rollup/plugin-wasm';
import inject from '@rollup/plugin-inject'; import inject from '@rollup/plugin-inject';
import { viteStaticCopy } from 'vite-plugin-static-copy'; import { viteStaticCopy } from 'vite-plugin-static-copy';
@@ -17,13 +16,15 @@ const copyFiles = {
// widget URL (/public/element-call/index.html) resolves. v4.x of // widget URL (/public/element-call/index.html) resolves. v4.x of
// vite-plugin-static-copy preserves the full source path under dest, so // vite-plugin-static-copy preserves the full source path under dest, so
// we strip the 4 leading segments of the source base // we strip the 4 leading segments of the source base
// (node_modules/@element-hq/element-call-embedded/dist) — mirroring the // (node_modules/@lotusguild/element-call-embedded/dist) — mirroring the
// stripBase pattern used by the android/locales targets below. The old // stripBase pattern used by the android/locales targets below. The old
// `rename: 'element-call'` form silently produced // `rename: 'element-call'` form silently produced
// public/node_modules/.../dist/ under v4.x, 404ing the widget (calls // public/node_modules/.../dist/ under v4.x, 404ing the widget (calls
// broke on cinny-desktop; web only worked because its deployed copy was // broke on cinny-desktop; web only worked because its deployed copy was
// a stale artifact from before the vite-plugin-static-copy v4 bump). // a stale artifact from before the vite-plugin-static-copy v4 bump).
src: 'node_modules/@element-hq/element-call-embedded/dist', // Source is our self-built fork (LotusGuild/element-call) published to
// the Gitea npm registry; see HANDOFF_ELEMENT_CALL_FORK.md.
src: 'node_modules/@lotusguild/element-call-embedded/dist',
dest: 'public/element-call', dest: 'public/element-call',
rename: { stripBase: 4 }, rename: { stripBase: 4 },
}, },
@@ -160,34 +161,14 @@ function lotusDenoise() {
fs.copyFileSync(s, d); fs.copyFileSync(s, d);
}); });
const shimSrc = path.resolve('build/lotus-denoise.js'); // [lotus] DENOISE CUTOVER: the getUserMedia shim is no longer injected.
if (!fs.existsSync(shimSrc)) { // Our forked Element Call now runs ML denoise in-source as a real LiveKit
throw new Error(`[lotus-denoise] Missing shim source ${shimSrc} — build aborted.`); // audio TrackProcessor (activated by lotusDenoiseSource=1 in CallEmbed),
} // which survives reconnects (fixes A7). We still copy the denoise/ assets
fs.copyFileSync(shimSrc, path.join(ecDir, 'lotus-denoise.js')); // above because the in-source processor loads its worklets/wasm from
// ./denoise/ at runtime. To roll back to the shim: restore the
// Inject the shim <script> into Element Call's index.html so it runs // copy+inject of build/lotus-denoise.js here and swap lotusDenoiseSource
// before EC captures the mic. Verify the injection actually landed — // back to lotusDenoise=ml in CallEmbed.getWidget.
// if EC's bundle ever drops its deferred module entry the replace would
// no-op and ML would silently never engage, so fail loudly.
const indexPath = path.join(ecDir, 'index.html');
if (fs.existsSync(indexPath)) {
let html = fs.readFileSync(indexPath, 'utf8');
if (!html.includes('lotus-denoise.js')) {
// Classic (non-deferred) script runs before EC's deferred module entry.
html = html.replace(
/<script type="module"/,
'<script src="./lotus-denoise.js"></script><script type="module"',
);
if (!html.includes('lotus-denoise.js')) {
throw new Error(
'[lotus-denoise] Failed to inject shim into Element Call index.html ' +
'(no `<script type="module">` entry found) — build aborted.',
);
}
fs.writeFileSync(indexPath, html);
}
}
}, },
}; };
} }
@@ -261,20 +242,6 @@ export default defineConfig({
react(), react(),
copyPdfWorker(), copyPdfWorker(),
lotusDenoise(), lotusDenoise(),
...(process.env.SENTRY_AUTH_TOKEN
? [
sentryVitePlugin({
org: 'lotus-guild',
project: 'javascript-react',
authToken: process.env.SENTRY_AUTH_TOKEN,
sourcemaps: {
filesToDeleteAfterUpload: ['./dist/**/*.map'],
},
release: { name: process.env.VITE_APP_VERSION ?? 'lotus' },
telemetry: false,
}),
]
: []),
VitePWA({ VitePWA({
srcDir: 'src', srcDir: 'src',
filename: 'sw.ts', filename: 'sw.ts',
@@ -302,7 +269,7 @@ export default defineConfig({
build: { build: {
target: 'esnext', target: 'esnext',
outDir: 'dist', outDir: 'dist',
sourcemap: process.env.SENTRY_AUTH_TOKEN ? 'hidden' : false, sourcemap: false,
copyPublicDir: false, copyPublicDir: false,
// manualChunks must be in rolldownOptions (not rollupOptions) for Vite 8 / Rolldown // manualChunks must be in rolldownOptions (not rollupOptions) for Vite 8 / Rolldown
rolldownOptions: { rolldownOptions: {