Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 3ca252f633 chore(lotus): 0.25.0-lotus.14
CI / Publish to Gitea npm registry (push) Successful in 1m3s
CI / Build embedded bundle (push) Successful in 2m58s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 22:16:39 -04:00
Lotus CIandClaude Opus 5.5 6beebd3ea7 feat(lotus): layout, settings and reactions over the widget API (cinny #43)
The Lotus host drove these by clicking EC's hidden controls in our DOM and
read screenshare/layout state from it, which only works while the frame is
same-origin and breaks whenever a data-testid changes. New actions:

- toWidget io.lotus.set_layout { layout: "grid" | "spotlight" } → the layout
  switch view model's setLayout.
- toWidget io.lotus.open_settings { open?: boolean } → setSettingsOpen
  (toggle when `open` is omitted).
- toWidget io.lotus.toggle_reactions → a window event the reactions button
  listens for.
- fromWidget io.lotus.controls_state { screensharing, layout } on change; its
  arrival tells the host the fork supports the actions above.

Screensharing stays host-driven for now: getDisplayMedia needs the user's
click to reach this frame (Capability Delegation), which a widget message
doesn't carry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 22:16:24 -04:00
7 changed files with 181 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lotusguild/element-call-embedded",
"version": "0.25.0-lotus.13",
"version": "0.25.0-lotus.14",
"files": [
"README.md",
"LICENSE-AGPL-3.0",
+10
View File
@@ -26,6 +26,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
import classNames from "classnames";
import { useReactionsSender } from "../reactions/useReactionsSender";
import { LOTUS_TOGGLE_REACTIONS_EVENT } from "../lotus/lotusControls";
import styles from "./ReactionToggleButton.module.css";
import {
type RaisedHandInfo,
@@ -196,6 +197,15 @@ export function ReactionToggleButton({
setErrorText(undefined);
}, [showReactionsMenu]);
// [cinny #43] The Lotus host's call bar toggles this menu over the widget
// API (lotusControls.ts) instead of clicking this button in our DOM.
useEffect(() => {
const toggle = (): void => setShowReactionsMenu((open) => !open);
window.addEventListener(LOTUS_TOGGLE_REACTIONS_EVENT, toggle);
return (): void =>
window.removeEventListener(LOTUS_TOGGLE_REACTIONS_EVENT, toggle);
}, []);
const sendRelation = useCallback(
async (reaction: ReactionOption) => {
try {
+6
View File
@@ -27,6 +27,9 @@ describe("LotusWidgetActions", () => {
LotusWidgetActions.Decorations,
LotusWidgetActions.SetDeafen,
LotusWidgetActions.SetAudioOutput,
LotusWidgetActions.SetLayout,
LotusWidgetActions.OpenSettings,
LotusWidgetActions.ToggleReactions,
];
expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget));
@@ -44,5 +47,8 @@ describe("LotusWidgetActions", () => {
expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(
LotusWidgetActions.DenoiseState,
);
expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(
LotusWidgetActions.ControlsState,
);
});
});
+21
View File
@@ -62,6 +62,24 @@ export enum LotusWidgetActions {
* else in the call (#39). Each fires at most once per share.
*/
ScreenshareNotice = "io.lotus.screenshare_notice",
/**
* toWidget: switch the call layout `{ layout: "grid" | "spotlight" }`
* (cinny #43 — replaces the host clicking EC's hidden layout radio).
*/
SetLayout = "io.lotus.set_layout",
/**
* toWidget: open EC's settings modal, or close it with `{ open: false }`;
* omit `open` to toggle (cinny #43).
*/
OpenSettings = "io.lotus.open_settings",
/** toWidget: toggle the reactions / raise-hand menu (cinny #43). */
ToggleReactions = "io.lotus.toggle_reactions",
/**
* fromWidget: `{ screensharing: boolean, layout: "grid" | "spotlight" | null }`
* whenever either changes (cinny #43), so the host stops reading EC's DOM for
* them. Its arrival also tells the host this fork supports the actions above.
*/
ControlsState = "io.lotus.controls_state",
}
/** toWidget Lotus actions that must be allow-listed in `initializeWidget`. */
@@ -72,4 +90,7 @@ export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [
LotusWidgetActions.Decorations,
LotusWidgetActions.SetDeafen,
LotusWidgetActions.SetAudioOutput,
LotusWidgetActions.SetLayout,
LotusWidgetActions.OpenSettings,
LotusWidgetActions.ToggleReactions,
];
+35
View File
@@ -0,0 +1,35 @@
/*
Copyright 2026 Lotus Guild
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it } from "vitest";
import { parseLayoutPayload, parseSettingsPayload } from "./lotusControls";
describe("parseLayoutPayload", () => {
it("accepts grid and spotlight", () => {
expect(parseLayoutPayload({ layout: "grid" })).toBe("grid");
expect(parseLayoutPayload({ layout: "spotlight" })).toBe("spotlight");
});
it("rejects anything else", () => {
expect(parseLayoutPayload({ layout: "pip" })).toBeUndefined();
expect(parseLayoutPayload({})).toBeUndefined();
expect(parseLayoutPayload(null)).toBeUndefined();
expect(parseLayoutPayload("grid")).toBeUndefined();
});
});
describe("parseSettingsPayload", () => {
it("uses an explicit open flag", () => {
expect(parseSettingsPayload({ open: true }, true)).toBe(true);
expect(parseSettingsPayload({ open: false }, false)).toBe(false);
});
it("toggles when open is missing or not a boolean", () => {
expect(parseSettingsPayload({}, false)).toBe(true);
expect(parseSettingsPayload(undefined, true)).toBe(false);
expect(parseSettingsPayload({ open: "yes" }, true)).toBe(false);
});
});
+104
View File
@@ -0,0 +1,104 @@
/*
Copyright 2026 Lotus Guild
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type IWidgetApiRequest } from "matrix-widget-api";
import { combineLatest, of, type Subscription } from "rxjs";
import { distinctUntilChanged, map, switchMap } from "rxjs/operators";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { type LayoutMode } from "../state/LayoutSwitchViewModel";
import { widget } from "../widget";
import { LotusWidgetActions, lotusSendToHost } from "./lotusWidget";
/** Window event the reactions button listens for (see ReactionToggleButton). */
export const LOTUS_TOGGLE_REACTIONS_EVENT = "lotus:toggle-reactions";
export interface LotusControlsState {
screensharing: boolean;
/** Null while EC offers no layout switch (e.g. PiP or a 1:1 layout). */
layout: LayoutMode | null;
}
/** `{ layout }` payload → a layout mode, or undefined if invalid. Exported for tests. */
export function parseLayoutPayload(data: unknown): LayoutMode | undefined {
if (typeof data !== "object" || data === null) return undefined;
const { layout } = data as { layout?: unknown };
return layout === "grid" || layout === "spotlight" ? layout : undefined;
}
/** `{ open? }` payload → the settings-open state to apply. Exported for tests. */
export function parseSettingsPayload(data: unknown, current: boolean): boolean {
if (typeof data === "object" && data !== null && "open" in data) {
const { open } = data as { open?: unknown };
if (typeof open === "boolean") return open;
}
return !current;
}
/**
* [cinny #43] Widget-API replacements for the host's DOM access to EC's
* controls: layout switch, settings modal and reactions menu, plus a
* `controls_state` report (screensharing + layout) so the host no longer reads
* EC's DOM for them. Screensharing itself stays host-DOM driven for now:
* `getDisplayMedia` needs the user's click to reach this frame (Capability
* Delegation), which a plain widget message doesn't carry.
*
* No effect unless the host sends the actions; registering is safe whenever
* we're a widget. Returns a teardown function.
*/
export function startLotusControls(vm: CallViewModel): () => void {
const w = widget;
if (!w) return (): void => undefined;
const onSetLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
w.api.transport.reply(ev.detail, {});
const layout = parseLayoutPayload(ev.detail.data);
if (layout) vm.layoutSwitchVm$.value?.setLayout(layout);
};
const onOpenSettings = (ev: CustomEvent<IWidgetApiRequest>): void => {
w.api.transport.reply(ev.detail, {});
vm.setSettingsOpen$.value(
parseSettingsPayload(ev.detail.data, vm.settingsOpen$.value),
);
};
const onToggleReactions = (ev: CustomEvent<IWidgetApiRequest>): void => {
w.api.transport.reply(ev.detail, {});
window.dispatchEvent(new Event(LOTUS_TOGGLE_REACTIONS_EVENT));
};
w.lazyActions.on(LotusWidgetActions.SetLayout, onSetLayout);
w.lazyActions.on(LotusWidgetActions.OpenSettings, onOpenSettings);
w.lazyActions.on(LotusWidgetActions.ToggleReactions, onToggleReactions);
const sub: Subscription = combineLatest([
vm.sharingScreen$,
vm.layoutSwitchVm$.pipe(
switchMap((l) => (l ? l.layout$ : of<LayoutMode | null>(null))),
),
])
.pipe(
map(
([screensharing, layout]): LotusControlsState => ({
screensharing,
layout,
}),
),
distinctUntilChanged(
(a, b) => a.screensharing === b.screensharing && a.layout === b.layout,
),
)
.subscribe((state) => {
lotusSendToHost(LotusWidgetActions.ControlsState, state);
});
return (): void => {
sub.unsubscribe();
w.lazyActions.off(LotusWidgetActions.SetLayout, onSetLayout);
w.lazyActions.off(LotusWidgetActions.OpenSettings, onOpenSettings);
w.lazyActions.off(LotusWidgetActions.ToggleReactions, onToggleReactions);
};
}
+4
View File
@@ -31,6 +31,7 @@ import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { widget } from "../widget";
import { startLotusCallState } from "../lotus/lotusCallState";
import { startLotusFocus } from "../lotus/lotusFocus";
import { startLotusControls } from "../lotus/lotusControls";
import { startLotusAudioInject } from "../lotus/lotusAudioInject";
import { startLotusQuality } from "../lotus/lotusQuality";
import { startLotusDecorations } from "../lotus/lotusDecorations";
@@ -300,6 +301,9 @@ export const InCallView: FC<InCallViewProps> = ({
// [lotus] Handle the host's io.lotus.focus_participant action to pin a
// participant to the spotlight (#4). No-op unless the host sends it.
useEffect(() => startLotusFocus(vm), [vm]);
// [cinny #43] layout / settings / reactions over the widget API, plus a
// screensharing + layout report, replacing the host's DOM access.
useEffect(() => startLotusControls(vm), [vm]);
// [lotus] Handle the host's io.lotus.inject_audio action to mix a soundboard
// clip into the call as a separate track (#3). No-op unless the host sends it.
useEffect(() => startLotusAudioInject(vm), [vm]);