From 502b9dfd8320e6d5f425e9fb900ffc5d86c5f9ed Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 26 Sep 2026 14:35:44 -0400 Subject: [PATCH] feat(call): stop injecting CSS and key listeners into the call frame (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins @lotusguild/element-call-embedded 0.25.0-lotus.19. - Styles: the fork now hides its own footer (`lotusHostControls=1`) and sets its root color-scheme from the theme, so the host no longer injects `#lotus-ec-styles` or sets inline styles on EC's DOM. The two other injected rules matched nothing in EC 0.25 (dead). The transparent background was already the fork's (`lotusTransparent`). - Hotkeys: PTT / deafen keys pressed with focus inside the call frame now arrive as `io.lotus.hotkey` (the host sends the codes via `io.lotus.set_hotkeys`), instead of listeners on the frame's window. The window binding stays only for a fork that doesn't report `hotkeys`. - Fixes (with lotus.19): pressing the deafen key M with focus in the call also hit EC's own "M = toggle mic" shortcut, so the first press turned the mic ON instead of deafening — even in push-to-talk mode. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- package-lock.json | 8 +- package.json | 2 +- src/app/hooks/useCallHotkeys.ts | 66 ++++++++--- src/app/plugins/call/CallControl.ts | 104 ++++++++++++++++-- src/app/plugins/call/CallEmbed.ts | 62 ++--------- src/app/plugins/call/forkHotkeyReport.test.ts | 40 +++++++ 6 files changed, 200 insertions(+), 82 deletions(-) create mode 100644 src/app/plugins/call/forkHotkeyReport.test.ts diff --git a/package-lock.json b/package-lock.json index 4a406513c..8ac1406cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,7 +81,7 @@ }, "devDependencies": { "@axe-core/playwright": "4.13.0", - "@lotusguild/element-call-embedded": "0.25.0-lotus.17", + "@lotusguild/element-call-embedded": "0.25.0-lotus.19", "@playwright/test": "1.63.0", "@rollup/plugin-inject": "5.0.5", "@rollup/plugin-wasm": "6.2.2", @@ -2695,9 +2695,9 @@ "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" }, "node_modules/@lotusguild/element-call-embedded": { - "version": "0.25.0-lotus.17", - "resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.17/element-call-embedded-0.25.0-lotus.17.tgz", - "integrity": "sha512-dxzo2Nw1NEEOe+twQXITAZtb+X7fFJdtqnElsFOu0ArccTB1QLbibc3F0XFp+BTfT5PZIyNaIp7fDArVSnj6qg==", + "version": "0.25.0-lotus.19", + "resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.19/element-call-embedded-0.25.0-lotus.19.tgz", + "integrity": "sha512-xNWgja9PHeDuSgo6fvoI1BJkYHof0xJ9kkLnURqPkrHV0o1UagHgaYcFgbFZMtDsoPnz5UrOGleLsjFo7d5/wQ==", "dev": true }, "node_modules/@matrix-org/matrix-sdk-crypto-wasm": { diff --git a/package.json b/package.json index f0fc024d2..01d817201 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ }, "devDependencies": { "@axe-core/playwright": "4.13.0", - "@lotusguild/element-call-embedded": "0.25.0-lotus.17", + "@lotusguild/element-call-embedded": "0.25.0-lotus.19", "@playwright/test": "1.63.0", "@rollup/plugin-inject": "5.0.5", "@rollup/plugin-wasm": "6.2.2", diff --git a/src/app/hooks/useCallHotkeys.ts b/src/app/hooks/useCallHotkeys.ts index 55a17157f..3d8860c38 100644 --- a/src/app/hooks/useCallHotkeys.ts +++ b/src/app/hooks/useCallHotkeys.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { atom, useSetAtom } from 'jotai'; import { CallEmbed, useCallControlState } from '../plugins/call'; import { useSetting } from '../state/hooks/settings'; @@ -150,6 +150,20 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean const { microphone } = useCallControlState(embed?.control); const setPttActive = useSetAtom(pttActiveAtom); + // [Gitea #43] Once the fork reports it handles hotkeys, keys pressed inside + // the call frame arrive as io.lotus.hotkey and we stop adding listeners to + // the frame's window (which needs same-origin access). + const [forkHotkeys, setForkHotkeys] = useState(false); + useEffect(() => { + if (!embed) { + setForkHotkeys(false); + return undefined; + } + const sync = () => setForkHotkeys(embed.control.forkHandlesHotkeys); + sync(); + return embed.control.onForkHotkeysChange(sync); + }, [embed]); + // Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn) const microphoneRef = useRef(microphone); useEffect(() => { @@ -210,11 +224,12 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean pttActiveRef.current = false; setPttActive(false); }; - const onKeyDown = (e: KeyboardEvent) => { + // `prevent` is absent for keys reported by the fork, which already + // cancelled the default action inside its frame. + const pttDown = (e: KeyLike, editable: boolean, interactive: boolean, prevent?: () => void) => { if (!isPttKeyDown(e, pttKey)) return; - const target = e.target as HTMLElement; - if (isEditable(target)) return; - if (!isInteractive(target)) e.preventDefault(); + if (editable) return; + if (!interactive) prevent?.(); // Key auto-repeat re-fires keydown while held; don't restart the clock. if (pttActiveRef.current) return; // C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState) @@ -225,6 +240,10 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean setPttActive(true); holdWatchdog.current.arm(); }; + const onKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + pttDown(e, isEditable(target), isInteractive(target), () => e.preventDefault()); + }; const onKeyUp = (e: KeyboardEvent) => { if (e.code !== pttKey) return; release(); @@ -249,16 +268,25 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean }; }; const unbindHost = bind(window); - // BUG-9: also wire iframe key/blur/focus so PTT works with focus in the iframe - const unbindIframe = bindIframeWindow(embed.iframe, bind); + // BUG-9: PTT also works with focus in the call frame — reported by the + // fork, or (older fork) via listeners on the frame's window. + embed.control.setHotkeyCodes('ptt', [pttKey]); + const unbindFork = embed.control.onHotkey((r) => { + if (r.type === 'focus') onFocusChange(); + else if (r.type === 'keydown') pttDown(r, r.editable, r.interactive); + else if (r.code === pttKey) release(); + }); + const unbindIframe = forkHotkeys ? () => undefined : bindIframeWindow(embed.iframe, bind); return () => { unbindHost(); + unbindFork(); unbindIframe(); + embed.control.setHotkeyCodes('ptt', []); // BUG-8: if the embed changes while PTT is active, release mic on cleanup if (pttActiveRef.current) release(); }; // microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn - }, [pttMode, pttKey, embed, setPttActive]); + }, [pttMode, pttKey, embed, setPttActive, forkHotkeys]); // [cinny-desktop #2] System-wide PTT/deafen while a game has focus. The // desktop polls the configured keys without consuming them and emits one @@ -330,15 +358,25 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean embed.control.toggleSound(); }; window.addEventListener('keydown', onKeyDown); - // C-L4: also bind the EC iframe window so the deafen key works when focus is - // inside the iframe (mirrors the PTT binding above). - const unbindIframe = bindIframeWindow(embed.iframe, (win) => { - win.addEventListener('keydown', onKeyDown); - return () => win.removeEventListener('keydown', onKeyDown); + // C-L4: the deafen key also works with focus inside the call frame (no + // composer there, so only the editable-field check applies). + embed.control.setHotkeyCodes('deafen', [deafenKey]); + const unbindFork = embed.control.onHotkey((r) => { + if (r.type === 'keydown' && isDeafenKeyDown(r, deafenKey) && !r.editable) { + embed.control.toggleSound(); + } }); + const unbindIframe = forkHotkeys + ? () => undefined + : bindIframeWindow(embed.iframe, (win) => { + win.addEventListener('keydown', onKeyDown); + return () => win.removeEventListener('keydown', onKeyDown); + }); return () => { window.removeEventListener('keydown', onKeyDown); + unbindFork(); unbindIframe(); + embed.control.setHotkeyCodes('deafen', []); }; - }, [embed, deafenKey, deafenHotkey]); + }, [embed, deafenKey, deafenHotkey, forkHotkeys]); } diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index 7b266f93d..10cc8c2e8 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -19,6 +19,41 @@ export type LotusQualityPayload = { screenshareMaxFramerate?: number | null; }; +/** fromWidget `io.lotus.hotkey` (Gitea #43): a watched key in the call frame. */ +export type ForkHotkeyReport = + | { + type: 'keydown' | 'keyup'; + code: string; + repeat: boolean; + ctrlKey: boolean; + altKey: boolean; + metaKey: boolean; + shiftKey: boolean; + /** Went to a text field in the frame. */ + editable: boolean; + /** Went to a button/link in the frame. */ + interactive: boolean; + } + | { type: 'focus' }; + +export function parseForkHotkeyReport(data: unknown): ForkHotkeyReport | null { + if (typeof data !== 'object' || data === null) return null; + const d = data as Record; + if (d.type === 'focus') return { type: 'focus' }; + if ((d.type !== 'keydown' && d.type !== 'keyup') || typeof d.code !== 'string') return null; + return { + type: d.type, + code: d.code, + repeat: d.repeat === true, + ctrlKey: d.ctrlKey === true, + altKey: d.altKey === true, + metaKey: d.metaKey === true, + shiftKey: d.shiftKey === true, + editable: d.editable === true, + interactive: d.interactive === true, + }; +} + /** * Capability Delegation (`postMessage(msg, { delegate })`) ships only in * Chromium; other engines silently ignore the option, so the frame would get @@ -204,6 +239,7 @@ export class CallControl extends EventEmitter implements CallControlState { if (this._audioOutputId !== undefined) this.sendAudioOutput(this._audioOutputId); // [Gitea #17] The pin lives fork-side and is dropped on a handler remount. if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId); + this.sendHotkeyCodes(); } public startObserving() { @@ -230,15 +266,8 @@ export class CallControl extends EventEmitter implements CallControlState { private applyBodyMutation() { if (!this.document) return; - - this.document.body.style.setProperty('background', 'none', 'important'); - - const controls = this.leaveButton?.parentElement?.parentElement; - if (controls) { - controls.style.setProperty('position', 'absolute'); - controls.style.setProperty('visibility', 'hidden'); - } - + // Hiding EC's footer and the transparent background are the fork's job now + // (lotusHostControls / lotusTransparent URL flags, Gitea #43). this.observeControls(); } @@ -350,16 +379,71 @@ export class CallControl extends EventEmitter implements CallControlState { // controls_state). Used only where Capability Delegation exists. private forkScreenshare = false; + // [Gitea #43] The fork reports call hotkeys pressed inside its frame + // (io.lotus.set_hotkeys → io.lotus.hotkey), so the host stops adding key + // listeners to the frame's window. + private forkHotkeys = false; + + private hotkeyCodes = new Map(); + + private hotkeyListeners = new Set<(report: ForkHotkeyReport) => void>(); + + private forkHotkeysListeners = new Set<() => void>(); + + public get forkHandlesHotkeys(): boolean { + return this.forkHotkeys; + } + + /** Subscribe to `forkHandlesHotkeys` turning on. Returns an unsubscribe. */ + public onForkHotkeysChange(cb: () => void): () => void { + this.forkHotkeysListeners.add(cb); + return () => { + this.forkHotkeysListeners.delete(cb); + }; + } + + /** Key codes `source` (e.g. 'ptt', 'deafen') wants reported from the frame. */ + public setHotkeyCodes(source: string, codes: string[]): void { + this.hotkeyCodes.set(source, codes); + this.sendHotkeyCodes(); + } + + private sendHotkeyCodes(): void { + if (!this.joined || !this.forkHotkeys) return; + const codes = [...new Set([...this.hotkeyCodes.values()].flat())]; + this.call.transport.send('io.lotus.set_hotkeys', { codes }).catch(() => undefined); + } + + /** Subscribe to the fork's `io.lotus.hotkey` reports. Returns an unsubscribe. */ + public onHotkey(cb: (report: ForkHotkeyReport) => void): () => void { + this.hotkeyListeners.add(cb); + return () => { + this.hotkeyListeners.delete(cb); + }; + } + + /** [Gitea #43] The fork's `io.lotus.hotkey` report. */ + public onHotkeyReport(data: unknown): void { + const report = parseForkHotkeyReport(data); + if (report) this.hotkeyListeners.forEach((l) => l(report)); + } + /** [Gitea #43] The fork's `io.lotus.controls_state` report. */ public onControlsState(data: unknown) { if (typeof data !== 'object' || data === null) return; - const { screensharing, layout, screenshareAction } = data as { + const { screensharing, layout, screenshareAction, hotkeys } = data as { screensharing?: unknown; layout?: unknown; screenshareAction?: unknown; + hotkeys?: unknown; }; this.forkControls = true; this.forkScreenshare = screenshareAction === true; + if (hotkeys === true && !this.forkHotkeys) { + this.forkHotkeys = true; + this.sendHotkeyCodes(); + this.forkHotkeysListeners.forEach((l) => l()); + } this.applyControls( typeof screensharing === 'boolean' ? screensharing : this.screenshare, layout === 'spotlight' || layout === 'grid' ? layout === 'spotlight' : this.spotlight, diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 950e5cd78..a13043d8f 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -105,8 +105,6 @@ export class CallEmbed { private readonly initialState: CallControlState; - private styleRetryObserver?: MutationObserver; - private themeKind: ElementCallThemeKind = 'dark'; // Watchdog: detects an iframe that never reaches a usable state. @@ -217,6 +215,9 @@ export class CallEmbed { // - transparent background so the room wallpaper shows through natively lotusCallState: 'true', lotusTransparent: 'true', + // [Gitea #43] The fork hides its own footer (we draw the call bar) and + // sets its root color-scheme from the theme, instead of us injecting CSS. + lotusHostControls: 'true', // [lotus #3 / P5-15] Arm the fork's audio-inject handler so the in-call // soundboard can publish clips into the call. Dormant until the host // sends io.lotus.inject_audio (only on an explicit user click), so @@ -353,11 +354,6 @@ export class CallEmbed { public setTheme(theme: ElementCallThemeKind) { this.themeKind = theme; - const doc = this.document; - if (doc && this.joined) { - const styleEl = doc.getElementById('lotus-ec-styles'); - if (styleEl) styleEl.textContent = this.buildStyleContent(); - } return this.call.transport .send(WidgetApiToWidgetAction.ThemeChange, { name: theme }) .catch(() => { @@ -438,6 +434,12 @@ export class CallEmbed { this.control.onControlsState((evt.detail as { data?: unknown } | undefined)?.data); }), ); + // [Gitea #43] PTT / deafen keys pressed while focus is inside the frame. + this.disposables.push( + this.listenAction('io.lotus.hotkey', (evt) => { + this.control.onHotkeyReport((evt.detail as { data?: unknown } | undefined)?.data); + }), + ); this.disposables.push( this.listenAction('io.lotus.call_state', (evt) => { const data = (evt.detail as { data?: { participants?: unknown } } | undefined)?.data; @@ -479,7 +481,6 @@ export class CallEmbed { }); this.clearLoadWatchdog(); this.loadErrorListeners.clear(); - this.styleRetryObserver?.disconnect(); this.call.stop(); this.container.removeChild(this.iframe); this.control.dispose(); @@ -572,7 +573,6 @@ export class CallEmbed { private onCallJoined(): void { this.settleLoad(); - this.applyStyles(); this.control.startObserving(); // C-H1: EC fires JoinCall again on an EC reconnect (this action has no @@ -590,50 +590,6 @@ export class CallEmbed { this.control.forceState(this.initialState); } - private buildStyleContent(): string { - return [ - 'html, body { background: none !important; }', - `:root { color-scheme: ${this.themeKind}; }`, - '[style*="height: 0"][style*="z-index: 1"][style*="align-self: center"] { display: none !important; }', - // EC 0.19.4: avatar uses line-height centering which breaks in some tile sizes; - // override with flexbox for reliable centering of the initial letter. - '._avatarContainer_1mrho_40 ._avatar_va14e_8 { display: flex !important; align-items: center !important; justify-content: center !important; line-height: 1 !important; }', - ].join('\n'); - } - - private applyStyles(): void { - const doc = this.document; - if (!doc) return; - - doc.body.style.setProperty('background', 'none', 'important'); - - if (!doc.getElementById('lotus-ec-styles')) { - const style = doc.createElement('style'); - style.id = 'lotus-ec-styles'; - style.textContent = this.buildStyleContent(); - (doc.head ?? doc.body).appendChild(style); - } else { - const styleEl = doc.getElementById('lotus-ec-styles'); - if (styleEl) styleEl.textContent = this.buildStyleContent(); - } - - // Hide EC built-in controls (we provide our own) - const leaveBtn = doc.body.querySelector('[data-testid="incall_leave"]'); - if (leaveBtn) { - this.styleRetryObserver?.disconnect(); - this.styleRetryObserver = undefined; - const controls = leaveBtn.parentElement?.parentElement; - if (controls) { - controls.style.setProperty('position', 'absolute'); - controls.style.setProperty('visibility', 'hidden'); - } - } else if (!this.styleRetryObserver) { - // Controls not in DOM yet — observe and retry when they appear - this.styleRetryObserver = new MutationObserver(() => this.applyStyles()); - this.styleRetryObserver.observe(doc.body, { childList: true, subtree: true }); - } - } - private onEvent(ev: MatrixEvent): void { this.mx.decryptEventIfNeeded(ev); this.feedEvent(ev); diff --git a/src/app/plugins/call/forkHotkeyReport.test.ts b/src/app/plugins/call/forkHotkeyReport.test.ts new file mode 100644 index 000000000..5f7be61da --- /dev/null +++ b/src/app/plugins/call/forkHotkeyReport.test.ts @@ -0,0 +1,40 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseForkHotkeyReport } from './CallControl'; + +test('parseForkHotkeyReport: key reports keep only known boolean flags', () => { + assert.deepEqual( + parseForkHotkeyReport({ + type: 'keydown', + code: 'Space', + repeat: false, + ctrlKey: false, + altKey: 'yes', + metaKey: false, + shiftKey: true, + editable: false, + interactive: true, + extra: 1, + }), + { + type: 'keydown', + code: 'Space', + repeat: false, + ctrlKey: false, + altKey: false, + metaKey: false, + shiftKey: true, + editable: false, + interactive: true, + }, + ); + assert.equal(parseForkHotkeyReport({ type: 'keyup', code: 'KeyM' })?.type, 'keyup'); +}); + +test('parseForkHotkeyReport: focus reports and junk', () => { + assert.deepEqual(parseForkHotkeyReport({ type: 'focus' }), { type: 'focus' }); + assert.equal(parseForkHotkeyReport({ type: 'keydown' }), null); + assert.equal(parseForkHotkeyReport({ type: 'click', code: 'Space' }), null); + assert.equal(parseForkHotkeyReport(null), null); + assert.equal(parseForkHotkeyReport('keydown'), null); +});