diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index c71642e9..233ac728 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -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 { diff --git a/src/lotus/lotusActions.test.ts b/src/lotus/lotusActions.test.ts index ce56bedb..d4b9401e 100644 --- a/src/lotus/lotusActions.test.ts +++ b/src/lotus/lotusActions.test.ts @@ -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, + ); }); }); diff --git a/src/lotus/lotusActions.ts b/src/lotus/lotusActions.ts index c3df969f..1ffed38f 100644 --- a/src/lotus/lotusActions.ts +++ b/src/lotus/lotusActions.ts @@ -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, ]; diff --git a/src/lotus/lotusControls.test.ts b/src/lotus/lotusControls.test.ts new file mode 100644 index 00000000..8b542aa5 --- /dev/null +++ b/src/lotus/lotusControls.test.ts @@ -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); + }); +}); diff --git a/src/lotus/lotusControls.ts b/src/lotus/lotusControls.ts new file mode 100644 index 00000000..3f426fa2 --- /dev/null +++ b/src/lotus/lotusControls.ts @@ -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): void => { + w.api.transport.reply(ev.detail, {}); + const layout = parseLayoutPayload(ev.detail.data); + if (layout) vm.layoutSwitchVm$.value?.setLayout(layout); + }; + const onOpenSettings = (ev: CustomEvent): void => { + w.api.transport.reply(ev.detail, {}); + vm.setSettingsOpen$.value( + parseSettingsPayload(ev.detail.data, vm.settingsOpen$.value), + ); + }; + const onToggleReactions = (ev: CustomEvent): 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(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); + }; +} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index c6ff7022..c669bc97 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -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 = ({ // [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]);