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

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:
Lotus CI
2026-09-26 14:35:44 -04:00
co-authored by Claude Opus 5.5
parent 024edbf546
commit 502b9dfd83
6 changed files with 200 additions and 82 deletions
+94 -10
View File
@@ -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<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
* 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<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. */
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,
+9 -53
View File
@@ -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);
@@ -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);
});