feat(call): drive layout / settings / reactions over the widget API (#43)

Once the fork reports io.lotus.controls_state (element-call
v0.25.0-lotus.14), the call bar's Spotlight/Grid View, Reactions and Settings
send io.lotus.set_layout / toggle_reactions / open_settings instead of
clicking Element Call's hidden buttons, and the call bar's screenshare and
layout state come from that report instead of a MutationObserver on EC's DOM.
Older forks never send the report and keep the DOM path.

Screensharing itself is still started/stopped via EC's button: it needs the
user's click to reach the frame (Capability Delegation, step 3 of #43).

Verified in a two-person local call (Synapse + LiveKit) against a locally
built fork: controls_state arrives on join; Spotlight View / Grid View flip
EC's layout and the reported layout; Reactions opens EC's reactions menu;
Settings opens EC's settings modal; the four actions go over postMessage;
starting and stopping a screenshare flips the host's button via the report.

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-24 22:16:50 -04:00
co-authored by Claude Opus 5.5
parent a932b1999a
commit a0d1c565d0
2 changed files with 49 additions and 1 deletions
+42 -1
View File
@@ -330,10 +330,32 @@ export class CallControl extends EventEmitter implements CallControlState {
}
}
// [Gitea #43] Set once the fork reports io.lotus.controls_state: from then on
// layout / settings / reactions go over the widget API and screenshare +
// layout state come from that report, not from EC's DOM. Older forks never
// send it and keep the DOM path below.
private forkControls = false;
/** [Gitea #43] The fork's `io.lotus.controls_state` report. */
public onControlsState(data: unknown) {
if (typeof data !== 'object' || data === null) return;
const { screensharing, layout } = data as { screensharing?: unknown; layout?: unknown };
this.forkControls = true;
this.applyControls(
typeof screensharing === 'boolean' ? screensharing : this.screenshare,
layout === 'spotlight' || layout === 'grid' ? layout === 'spotlight' : this.spotlight,
);
}
private onControlMutation() {
const wasScreensharing = this.screenshare;
if (this.forkControls) return;
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
const spotlight: boolean = this.spotlightButton?.checked ?? false;
this.applyControls(screenshare, spotlight);
}
private applyControls(screenshare: boolean, spotlight: boolean) {
const wasScreensharing = this.screenshare;
// C-M6: when a screenshare stops, clear the screenshare-audio mute so a
// later screenshare doesn't start pre-muted.
@@ -436,6 +458,12 @@ export class CallControl extends EventEmitter implements CallControlState {
}
public toggleSpotlight() {
if (this.forkControls) {
this.sendForkAction('io.lotus.set_layout', {
layout: this.spotlight ? 'grid' : 'spotlight',
});
return;
}
if (this.spotlight) {
this.gridButton?.click();
return;
@@ -444,13 +472,26 @@ export class CallControl extends EventEmitter implements CallControlState {
}
public toggleReactions() {
if (this.forkControls) {
this.sendForkAction('io.lotus.toggle_reactions', {});
return;
}
this.reactionsButton?.click();
}
public toggleSettings() {
if (this.forkControls) {
// Same as clicking EC's settings button: opens the modal.
this.sendForkAction('io.lotus.open_settings', { open: true });
return;
}
this.settingsButton?.click();
}
private sendForkAction(action: string, data: Record<string, unknown>): void {
this.call.transport.send(action, data).catch(() => undefined);
}
/**
* Focus a specific participant's camera tile in Element Call.
*
+7
View File
@@ -417,6 +417,13 @@ export class CallEmbed {
}
}),
);
// [Gitea #43] Screenshare + layout state from the fork; also switches the
// call bar's layout / settings / reactions buttons to widget actions.
this.disposables.push(
this.listenAction('io.lotus.controls_state', (evt) => {
this.control.onControlsState((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;