feat(call): send io.lotus.set_deafen to the fork (P6-2 phase 1)

CallControl now sends the new io.lotus.set_deafen action (join-gated via
forceState) on every deafen / screenshare-audio-mute toggle + on join, ALONGSIDE
the retained iframe-DOM .muted hack (transitional). Against the current pinned
bundle the action is immediately error-replied + swallowed by .catch — inert, no
timeout. Reordered toggleSound() to commit state before setSound() so the sent
deafen value isn't inverted.

Phase 2 (after the fork is published): bump the pin lotus.1 -> lotus.2 and delete
the DOM hack. Docs: HANDOFF §12.4, LOTUS_TODO P6-2, LOTUS_BUGS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 14:12:08 -04:00
parent 804caa5130
commit 4ff07ea2bd
4 changed files with 102 additions and 42 deletions
+29 -9
View File
@@ -531,15 +531,35 @@ 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` `src/room/InCallView.tsx`. Custom widget actions are in `src/lotus/lotusActions.ts`
(toWidget ones allow-listed in `src/widget.ts`). (toWidget ones allow-listed in `src/widget.ts`).
| # | Feature | Enable via | EC module | | # | Feature | Enable via | EC module |
| :-- | :------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------ | ---------------------------------------------------- | | :--- | :------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------ | ---------------------------------------------------- |
| 2 | Speaker/mute/camera state → host | URL `lotusCallState=1` | `lotusCallState.ts` (sends `io.lotus.call_state`) | | 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 | | 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` | | 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` | | 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` | | 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` | | 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` | | 1 | ML denoise in-source (fixes A7) | URL **`lotusDenoiseSource=1`** (+`lotusModel`,`lotusGate`,`lotusGateThreshold`,`lotusDenoiseBase`) — deliberately NOT the existing `lotusDenoise=ml` (that drives the host shim; reusing it would double-process) | `lotusDenoise.ts` + `lotusDenoiseProcessor.ts` |
| P6-2 | Deafen / screenshare-audio-mute at the LiveKit source | action `io.lotus.set_deafen {deafened,screenshareAudioMuted}` — sets remote `RemoteParticipant.setVolume(0/1)` per source (Microphone + ScreenShareAudio), persists to late joiners via `RoomEvent.ParticipantConnected` | `lotusDeafen.ts` |
### 12.4 P6-2 — deafen action (retires cinny's iframe-DOM `.muted` hack)
`io.lotus.set_deafen` (fork commit, folded into unpublished **`0.20.1-lotus.2`**) replaces
cinny's `CallControl.setSound`/`applyScreenshareAudioMuted` DOM `<audio>.muted` poking —
which broke silently on EC re-render / late tracks. **Two-phase rollout:**
1. **DONE (this batch):** fork action implemented; cinny's `CallControl` now ALSO sends
`io.lotus.set_deafen` (gated on join via `forceState`) alongside the retained DOM hack.
Against the current pinned bundle (`lotus.1`, no handler) the action is immediately
error-replied and swallowed by `.catch` — inert, no timeout.
2. **TODO — needs YOU to publish, then me:** publish the fork (`0.20.1-lotus.2`) to npm →
I bump cinny's pin `0.20.1-lotus.1` → `lotus.2`, `npm install`, then DELETE the DOM
`.muted` code from `CallControl.ts` (the hack is fully retired only here).
**Known divergence to confirm:** deafen silences remote Microphone + ScreenShareAudio, but
NOT injected/soundboard audio (`Track.Source.Unknown` — livekit-client's `setVolume` type
only accepts Microphone|ScreenShareAudio). So a deafened user still hears host-triggered
soundboard clips. Defensible (short, host-gated); confirm it's the desired UX.
**Security hardening applied** (holistic audit): `lotusDenoiseBase` forced **Security hardening applied** (holistic audit): `lotusDenoiseBase` forced
same-origin before `audioWorklet.addModule` (was an arbitrary-code-load vector same-origin before `audioWorklet.addModule` (was an arbitrary-code-load vector
+29 -28
View File
@@ -15,34 +15,35 @@ 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 |
| :--- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------- | | :--- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| #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 | | #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 | | #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 | | #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 | | #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 | | #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 | | #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
| #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 | | #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 |
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 | | N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 | | N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 | | 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 |
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room | | N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 | | 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 |
| P3-8 | Thread Panel (side drawer, chips, threaded receipts, thread composer) | `features/room/thread/*`, `RoomTimeline/RoomInput` | 6-step checklist in LOTUS_TODO §P3-8 | | P3-8 | Thread Panel (side drawer, chips, threaded receipts, thread composer) | `features/room/thread/*`, `RoomTimeline/RoomInput` | 6-step checklist in LOTUS_TODO §P3-8 |
| P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk) | `utils/mathParse.ts`, `components/math/` | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text) | | P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk) | `utils/mathParse.ts`, `components/math/` | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text) |
| P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes | | P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes |
| N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth | | N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth |
| P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math) | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread` | 6-step checklist in LOTUS_TODO §P4-1 | | P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math) | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread` | 6-step checklist in LOTUS_TODO §P4-1 |
| AW-1 | Scheduled-message cancel no longer ghost-sends (error row on failure) | `ScheduledMessagesTray.tsx` | schedule → cancel with network cut → item stays + error; retry works | | AW-1 | Scheduled-message cancel no longer ghost-sends (error row on failure) | `ScheduledMessagesTray.tsx` | schedule → cancel with network cut → item stays + error; retry works |
| AW-2 | Emoji lazy-load (search/autocomplete/recents fill in; board opens fast) | `plugins/emoji.ts` + consumers | first emoji-board open of a session: grid+search populate; reactions still label | | AW-2 | Emoji lazy-load (search/autocomplete/recents fill in; board opens fast) | `plugins/emoji.ts` + consumers | first emoji-board open of a session: grid+search populate; reactions still label |
| AW-3 | SW precache (repeat-visit near-instant; deploys still picked up immediately) | `sw.ts`, `vite.config.js` | load app twice (2nd = cached assets); deploy → reload picks new version | | AW-3 | SW precache (repeat-visit near-instant; deploys still picked up immediately) | `sw.ts`, `vite.config.js` | load app twice (2nd = cached assets); deploy → reload picks new version |
| AW-4 | Desktop CSP tighten + Escape/panel fixes + thread Jump to Latest | `tauri.conf.json`, Room/ThreadPanel | desktop: boots, avatars/media load, VT323 font renders, location maps embed, calls connect, deep links work | | AW-4 | Desktop CSP tighten + Escape/panel fixes + thread Jump to Latest | `tauri.conf.json`, Room/ThreadPanel | desktop: boots, avatars/media load, VT323 font renders, location maps embed, calls connect, deep links work |
| P3-4 | Accessibility compliance pass (collapsed-msg SR sender, form/overlay labels, typing announce, focus-return, `?` help, jsx-a11y CI gate) | `message/*`, `RoomViewTyping`, `features/shortcuts/*`, `eslint.config.mjs` | LOTUS_TESTING §P — axe-core + VoiceOver/NVDA on the golden path | | P3-4 | Accessibility compliance pass (collapsed-msg SR sender, form/overlay labels, typing announce, focus-return, `?` help, jsx-a11y CI gate) | `message/*`, `RoomViewTyping`, `features/shortcuts/*`, `eslint.config.mjs` | LOTUS_TESTING §P — axe-core + VoiceOver/NVDA on the golden path |
| P6-1 | Desktop Linux parity (no-sleep in calls, launcher badge), autostart toggle, tray Do-Not-Disturb | `native/power.rs`, `lib.rs`, `useTauriDnd`, `General.tsx` | Linux desktop: no display sleep during a call; tray DND silences notifications; launch-on-login persists; Unity badge (Ubuntu); DND toggle polarity | | P6-1 | Desktop Linux parity (no-sleep in calls, launcher badge), autostart toggle, tray Do-Not-Disturb | `native/power.rs`, `lib.rs`, `useTauriDnd`, `General.tsx` | Linux desktop: no display sleep during a call; tray DND silences notifications; launch-on-login persists; Unity badge (Ubuntu); DND toggle polarity |
| P6-2 | EC deafen/screenshare-audio-mute via `io.lotus.set_deafen` (retires the `<audio>.muted` iframe hack) | fork `lotusDeafen.ts`, cinny `CallControl.ts` | AFTER publish+pin-bump: deafen silences remote audio + survives a reconnect / new screenshare / late joiner (the cases the DOM hack failed); screenshare-audio-mute toggles independently |
**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. **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.
+10 -1
View File
@@ -523,7 +523,16 @@ From the desktop audit. Round out the native app now that the full Rust stack co
- **Tray "Do Not Disturb" toggle** — the tray menu is Open/Quit only; add a DND item (reuses the Focus-Assist suppression atom path) so users can silence notifications from the tray. - **Tray "Do Not Disturb" toggle** — the tray menu is Open/Quit only; add a DND item (reuses the Focus-Assist suppression atom path) so users can silence notifications from the tray.
CI-compile-verified (Windows + Linux runners); no local Rust. CI-compile-verified (Windows + Linux runners); no local Rust.
### [ ] P6-2 · Element Call fork — retire the remaining DOM hacks ### [~] P6-2 · Element Call fork — retire the remaining DOM hacks — DEAFEN DONE (2026-07), Phase-2 pending publish
**Shipped (Phase 1):** new `io.lotus.set_deafen` action in the fork (`lotusDeafen.ts`) sets remote `RemoteParticipant.setVolume` per source (mic + screenshare-audio), persisting to late joiners — replaces the brittle `CallControl.setSound`/`applyScreenshareAudioMuted` `<audio>.muted` iframe-DOM hack. cinny now sends it (join-gated) alongside the retained DOM hack (transitional). Folded into unpublished fork `0.20.1-lotus.2`.
**Phase 2 (needs user publish):** publish `0.20.1-lotus.2` to npm → bump cinny pin `lotus.1``lotus.2` → delete the DOM `.muted` code. See HANDOFF §12.4.
**DEFERRED (rationale):** the `useCallSpeakers` DOM-scrape is a dormant _fallback_ behind `io.lotus.call_state` (deleting only removes the safety net); the `.click()`-by-`data-testid` UI toggles (screenshare/grid/spotlight/reactions/settings) are low-value and would balloon fork surface for buttons that just trigger EC's own UI.
**Divergence:** deafen doesn\'t silence soundboard/`Unknown`-source audio (setVolume type limit) — confirm UX.
_Original scope below._
### [ ] P6-2b · Element Call fork — remaining DOM hacks (deferred pieces)
Replace cinny's fragile iframe-`contentDocument` reaches with proper `io.lotus.*` widget actions in the fork (`LotusGuild/element-call`), which break on EC re-renders/version bumps: Replace cinny's fragile iframe-`contentDocument` reaches with proper `io.lotus.*` widget actions in the fork (`LotusGuild/element-call`), which break on EC re-renders/version bumps:
+34 -4
View File
@@ -31,6 +31,12 @@ export class CallControl extends EventEmitter implements CallControlState {
private _pipMode = false; private _pipMode = false;
// P6-2: mirrors CallEmbed.joined. Set true from forceState(), which CallEmbed
// invokes only from onCallJoined(). Gates io.lotus.set_deafen so we never send
// before the fork's widget handler mounts (pre-join sends pend to a 10s
// timeout — HANDOFF_ELEMENT_CALL_FORK.md §12.1 F1).
private joined = false;
private get document(): Document | undefined { private get document(): Document | undefined {
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document; return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
} }
@@ -141,6 +147,12 @@ export class CallControl extends EventEmitter implements CallControlState {
this.spotlight, this.spotlight,
); );
await this.applyState(); await this.applyState();
// P6-2: CallEmbed calls forceState() only from onCallJoined(), so this is
// the join transition. Flip the gate open, then push the current deafen
// state to the fork's freshly-mounted handler. (setSound() above ran while
// this.joined was still false, so it was gated — this is the first send.)
this.joined = true;
this.sendDeafenState();
} }
public startObserving() { public startObserving() {
@@ -209,6 +221,7 @@ export class CallControl extends EventEmitter implements CallControlState {
el.muted = !sound || (isScreenshareAudio && this.screenshareAudioMuted); el.muted = !sound || (isScreenshareAudio && this.screenshareAudioMuted);
}); });
} }
this.sendDeafenState();
} }
private applyScreenshareAudioMuted(): void { private applyScreenshareAudioMuted(): void {
@@ -221,6 +234,20 @@ export class CallControl extends EventEmitter implements CallControlState {
el.muted = this.screenshareAudioMuted; el.muted = this.screenshareAudioMuted;
}); });
} }
this.sendDeafenState();
}
// P6-2: send deafen state to the fork (io.lotus.set_deafen). The DOM .muted
// code above is a transitional fallback — remove once the fork ships & the
// pin is bumped.
private sendDeafenState(): void {
if (!this.joined) return;
this.call.transport
.send('io.lotus.set_deafen', {
deafened: !this.sound,
screenshareAudioMuted: this.screenshareAudioMuted,
})
.catch(() => undefined);
} }
public onMediaState(evt: CustomEvent<ElementMediaStateDetail>) { public onMediaState(evt: CustomEvent<ElementMediaStateDetail>) {
@@ -286,10 +313,8 @@ export class CallControl extends EventEmitter implements CallControlState {
public toggleSound() { public toggleSound() {
const sound = !this.sound; const sound = !this.sound;
this.setSound(sound); // P6-2: commit state before setSound()/applyScreenshareAudioMuted() so
// After un-deafening, re-apply screenshare audio mute if active // sendDeafenState() (which reads this.sound) reports the new value.
if (sound) this.applyScreenshareAudioMuted();
const state = new CallControlState( const state = new CallControlState(
this.microphone, this.microphone,
this.video, this.video,
@@ -299,6 +324,11 @@ export class CallControl extends EventEmitter implements CallControlState {
this.screenshareAudioMuted, this.screenshareAudioMuted,
); );
this.state = state; this.state = state;
this.setSound(sound);
// After un-deafening, re-apply screenshare audio mute if active
if (sound) this.applyScreenshareAudioMuted();
this.emitStateUpdate(); this.emitStateUpdate();
if (!this.sound && this.microphone) { if (!this.sound && this.microphone) {