feat(call): stop injecting CSS and key listeners into the call frame (#43)
CI / Build & Quality Checks (push) Successful in 3m5s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 8s
CI / Playwright smoke (e2e) (push) Successful in 12m42s
CI / Build & Quality Checks (push) Successful in 3m5s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 8s
CI / Playwright smoke (e2e) (push) Successful in 12m42s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
024edbf546
commit
502b9dfd83
Generated
+4
-4
@@ -81,7 +81,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@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",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
@@ -2695,9 +2695,9 @@
|
|||||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||||
},
|
},
|
||||||
"node_modules/@lotusguild/element-call-embedded": {
|
"node_modules/@lotusguild/element-call-embedded": {
|
||||||
"version": "0.25.0-lotus.17",
|
"version": "0.25.0-lotus.19",
|
||||||
"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",
|
"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-dxzo2Nw1NEEOe+twQXITAZtb+X7fFJdtqnElsFOu0ArccTB1QLbibc3F0XFp+BTfT5PZIyNaIp7fDArVSnj6qg==",
|
"integrity": "sha512-xNWgja9PHeDuSgo6fvoI1BJkYHof0xJ9kkLnURqPkrHV0o1UagHgaYcFgbFZMtDsoPnz5UrOGleLsjFo7d5/wQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@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",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { atom, useSetAtom } from 'jotai';
|
import { atom, useSetAtom } from 'jotai';
|
||||||
import { CallEmbed, useCallControlState } from '../plugins/call';
|
import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
@@ -150,6 +150,20 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
|
|||||||
const { microphone } = useCallControlState(embed?.control);
|
const { microphone } = useCallControlState(embed?.control);
|
||||||
const setPttActive = useSetAtom(pttActiveAtom);
|
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)
|
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
||||||
const microphoneRef = useRef(microphone);
|
const microphoneRef = useRef(microphone);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -210,11 +224,12 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
|
|||||||
pttActiveRef.current = false;
|
pttActiveRef.current = false;
|
||||||
setPttActive(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;
|
if (!isPttKeyDown(e, pttKey)) return;
|
||||||
const target = e.target as HTMLElement;
|
if (editable) return;
|
||||||
if (isEditable(target)) return;
|
if (!interactive) prevent?.();
|
||||||
if (!isInteractive(target)) e.preventDefault();
|
|
||||||
// Key auto-repeat re-fires keydown while held; don't restart the clock.
|
// Key auto-repeat re-fires keydown while held; don't restart the clock.
|
||||||
if (pttActiveRef.current) return;
|
if (pttActiveRef.current) return;
|
||||||
// C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState)
|
// 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);
|
setPttActive(true);
|
||||||
holdWatchdog.current.arm();
|
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) => {
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
if (e.code !== pttKey) return;
|
if (e.code !== pttKey) return;
|
||||||
release();
|
release();
|
||||||
@@ -249,16 +268,25 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
const unbindHost = bind(window);
|
const unbindHost = bind(window);
|
||||||
// BUG-9: also wire iframe key/blur/focus so PTT works with focus in the iframe
|
// BUG-9: PTT also works with focus in the call frame — reported by the
|
||||||
const unbindIframe = bindIframeWindow(embed.iframe, bind);
|
// 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 () => {
|
return () => {
|
||||||
unbindHost();
|
unbindHost();
|
||||||
|
unbindFork();
|
||||||
unbindIframe();
|
unbindIframe();
|
||||||
|
embed.control.setHotkeyCodes('ptt', []);
|
||||||
// BUG-8: if the embed changes while PTT is active, release mic on cleanup
|
// BUG-8: if the embed changes while PTT is active, release mic on cleanup
|
||||||
if (pttActiveRef.current) release();
|
if (pttActiveRef.current) release();
|
||||||
};
|
};
|
||||||
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
|
// 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
|
// [cinny-desktop #2] System-wide PTT/deafen while a game has focus. The
|
||||||
// desktop polls the configured keys without consuming them and emits one
|
// 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();
|
embed.control.toggleSound();
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
// C-L4: also bind the EC iframe window so the deafen key works when focus is
|
// C-L4: the deafen key also works with focus inside the call frame (no
|
||||||
// inside the iframe (mirrors the PTT binding above).
|
// composer there, so only the editable-field check applies).
|
||||||
const unbindIframe = bindIframeWindow(embed.iframe, (win) => {
|
embed.control.setHotkeyCodes('deafen', [deafenKey]);
|
||||||
win.addEventListener('keydown', onKeyDown);
|
const unbindFork = embed.control.onHotkey((r) => {
|
||||||
return () => win.removeEventListener('keydown', onKeyDown);
|
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 () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', onKeyDown);
|
window.removeEventListener('keydown', onKeyDown);
|
||||||
|
unbindFork();
|
||||||
unbindIframe();
|
unbindIframe();
|
||||||
|
embed.control.setHotkeyCodes('deafen', []);
|
||||||
};
|
};
|
||||||
}, [embed, deafenKey, deafenHotkey]);
|
}, [embed, deafenKey, deafenHotkey, forkHotkeys]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,41 @@ export type LotusQualityPayload = {
|
|||||||
screenshareMaxFramerate?: number | null;
|
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<string, unknown>;
|
||||||
|
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
|
* Capability Delegation (`postMessage(msg, { delegate })`) ships only in
|
||||||
* Chromium; other engines silently ignore the option, so the frame would get
|
* 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);
|
if (this._audioOutputId !== undefined) this.sendAudioOutput(this._audioOutputId);
|
||||||
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
|
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
|
||||||
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
|
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
|
||||||
|
this.sendHotkeyCodes();
|
||||||
}
|
}
|
||||||
|
|
||||||
public startObserving() {
|
public startObserving() {
|
||||||
@@ -230,15 +266,8 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
|
|
||||||
private applyBodyMutation() {
|
private applyBodyMutation() {
|
||||||
if (!this.document) return;
|
if (!this.document) return;
|
||||||
|
// Hiding EC's footer and the transparent background are the fork's job now
|
||||||
this.document.body.style.setProperty('background', 'none', 'important');
|
// (lotusHostControls / lotusTransparent URL flags, Gitea #43).
|
||||||
|
|
||||||
const controls = this.leaveButton?.parentElement?.parentElement;
|
|
||||||
if (controls) {
|
|
||||||
controls.style.setProperty('position', 'absolute');
|
|
||||||
controls.style.setProperty('visibility', 'hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.observeControls();
|
this.observeControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,16 +379,71 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
// controls_state). Used only where Capability Delegation exists.
|
// controls_state). Used only where Capability Delegation exists.
|
||||||
private forkScreenshare = false;
|
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<string, string[]>();
|
||||||
|
|
||||||
|
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. */
|
/** [Gitea #43] The fork's `io.lotus.controls_state` report. */
|
||||||
public onControlsState(data: unknown) {
|
public onControlsState(data: unknown) {
|
||||||
if (typeof data !== 'object' || data === null) return;
|
if (typeof data !== 'object' || data === null) return;
|
||||||
const { screensharing, layout, screenshareAction } = data as {
|
const { screensharing, layout, screenshareAction, hotkeys } = data as {
|
||||||
screensharing?: unknown;
|
screensharing?: unknown;
|
||||||
layout?: unknown;
|
layout?: unknown;
|
||||||
screenshareAction?: unknown;
|
screenshareAction?: unknown;
|
||||||
|
hotkeys?: unknown;
|
||||||
};
|
};
|
||||||
this.forkControls = true;
|
this.forkControls = true;
|
||||||
this.forkScreenshare = screenshareAction === true;
|
this.forkScreenshare = screenshareAction === true;
|
||||||
|
if (hotkeys === true && !this.forkHotkeys) {
|
||||||
|
this.forkHotkeys = true;
|
||||||
|
this.sendHotkeyCodes();
|
||||||
|
this.forkHotkeysListeners.forEach((l) => l());
|
||||||
|
}
|
||||||
this.applyControls(
|
this.applyControls(
|
||||||
typeof screensharing === 'boolean' ? screensharing : this.screenshare,
|
typeof screensharing === 'boolean' ? screensharing : this.screenshare,
|
||||||
layout === 'spotlight' || layout === 'grid' ? layout === 'spotlight' : this.spotlight,
|
layout === 'spotlight' || layout === 'grid' ? layout === 'spotlight' : this.spotlight,
|
||||||
|
|||||||
@@ -105,8 +105,6 @@ export class CallEmbed {
|
|||||||
|
|
||||||
private readonly initialState: CallControlState;
|
private readonly initialState: CallControlState;
|
||||||
|
|
||||||
private styleRetryObserver?: MutationObserver;
|
|
||||||
|
|
||||||
private themeKind: ElementCallThemeKind = 'dark';
|
private themeKind: ElementCallThemeKind = 'dark';
|
||||||
|
|
||||||
// Watchdog: detects an iframe that never reaches a usable state.
|
// 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
|
// - transparent background so the room wallpaper shows through natively
|
||||||
lotusCallState: 'true',
|
lotusCallState: 'true',
|
||||||
lotusTransparent: '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
|
// [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
|
// soundboard can publish clips into the call. Dormant until the host
|
||||||
// sends io.lotus.inject_audio (only on an explicit user click), so
|
// sends io.lotus.inject_audio (only on an explicit user click), so
|
||||||
@@ -353,11 +354,6 @@ export class CallEmbed {
|
|||||||
|
|
||||||
public setTheme(theme: ElementCallThemeKind) {
|
public setTheme(theme: ElementCallThemeKind) {
|
||||||
this.themeKind = theme;
|
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
|
return this.call.transport
|
||||||
.send(WidgetApiToWidgetAction.ThemeChange, { name: theme })
|
.send(WidgetApiToWidgetAction.ThemeChange, { name: theme })
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -438,6 +434,12 @@ export class CallEmbed {
|
|||||||
this.control.onControlsState((evt.detail as { data?: unknown } | undefined)?.data);
|
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.disposables.push(
|
||||||
this.listenAction('io.lotus.call_state', (evt) => {
|
this.listenAction('io.lotus.call_state', (evt) => {
|
||||||
const data = (evt.detail as { data?: { participants?: unknown } } | undefined)?.data;
|
const data = (evt.detail as { data?: { participants?: unknown } } | undefined)?.data;
|
||||||
@@ -479,7 +481,6 @@ export class CallEmbed {
|
|||||||
});
|
});
|
||||||
this.clearLoadWatchdog();
|
this.clearLoadWatchdog();
|
||||||
this.loadErrorListeners.clear();
|
this.loadErrorListeners.clear();
|
||||||
this.styleRetryObserver?.disconnect();
|
|
||||||
this.call.stop();
|
this.call.stop();
|
||||||
this.container.removeChild(this.iframe);
|
this.container.removeChild(this.iframe);
|
||||||
this.control.dispose();
|
this.control.dispose();
|
||||||
@@ -572,7 +573,6 @@ export class CallEmbed {
|
|||||||
|
|
||||||
private onCallJoined(): void {
|
private onCallJoined(): void {
|
||||||
this.settleLoad();
|
this.settleLoad();
|
||||||
this.applyStyles();
|
|
||||||
this.control.startObserving();
|
this.control.startObserving();
|
||||||
|
|
||||||
// C-H1: EC fires JoinCall again on an EC reconnect (this action has no
|
// 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);
|
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 {
|
private onEvent(ev: MatrixEvent): void {
|
||||||
this.mx.decryptEventIfNeeded(ev);
|
this.mx.decryptEventIfNeeded(ev);
|
||||||
this.feedEvent(ev);
|
this.feedEvent(ev);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user