Compare commits

...

12 Commits

Author SHA1 Message Date
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
23 changed files with 641 additions and 294 deletions
+270
View File
@@ -0,0 +1,270 @@
# 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:** NOT STARTED. This is the planning/handoff artifact. Created
> 2026-06 from the Lotus Chat (`LotusGuild/cinny`) repo.
---
## 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`). The
_practical_ point it makes still holds: **LiveKit's `LocalAudioTrack` lives in
EC's module scope**, not on `window`, so we can't reach it from cinny even
same-origin — which is exactly why the in-call soundboard had to be
local-playback-only, and another reason to fork (a fork could expose a real
audio-inject API).
- `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/`).
+45 -18
View File
@@ -15,24 +15,51 @@ 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 |
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 | | EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 | | Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 | | a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I | **Verified working in live testing (2026-06):** A2, 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.
---
## 🧩 Known Element Call iframe limitations (not fixable from our side)
> 🔱 **[EC-FORK]** These are the motivating issues for the **Element Call fork
> initiative** — see [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md).
> Once we build EC from our own source, A5/A6/A7 below become normal code fixes.
> (Correction: the iframe is actually **same-origin** / self-hosted — we just
> don't own EC's compiled source today.)
The in-call participant grid is rendered **inside EC's iframe** (a pre-built npm
bundle we don't own), which we can style/place around but cannot change the logic
of. Consequences from testing:
- **A5 — "Focus camera":** EC already supports native tile-pinning (click a video
tile). Our bottom-bar "Focus camera" is a programmatic wrapper that clicks that
tile; it can't live inside EC's UI. During a screenshare EC spotlights the
shared screen and a camera pin may not override it. _Decision: keep the
shortcut, revisit with the larger call-UI/EC work._
- **A6 — avatar decorations in-call:** decorations render on **our** pre-join
lobby roster (`CallMemberCard`) but cannot be drawn on EC's in-call video
tiles. Working as designed given the iframe boundary.
- **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. Tied to the denoise rework.
--- ---
+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**.
+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.
--- ---
+17
View File
@@ -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
+30 -2
View File
@@ -37,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';
@@ -64,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;
@@ -323,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,
@@ -404,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;
@@ -423,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 ||
@@ -485,7 +513,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
setCallInfo(info); setCallInfo(info);
}, },
[mx, directs], [mx, directs, callEmbed, setToast],
); );
useEffect(() => { useEffect(() => {
@@ -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>
}
/>
</>
);
}
@@ -133,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'}
@@ -153,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>
)} )}
@@ -656,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>
); );
} }
@@ -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
+14 -63
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';
@@ -541,38 +542,15 @@ function ProfileStatus() {
</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
@@ -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
@@ -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 -77
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();
@@ -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"
@@ -4,6 +4,7 @@ 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 { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/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';
@@ -214,7 +215,7 @@ 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: config.space.S200, gap: config.space.S200,
+7 -3
View File
@@ -145,13 +145,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;
+2 -1
View File
@@ -27,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",
@@ -95,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})`,
}} }}
/> />
+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;