2026-09-24 22:16:24 -04:00
|
|
|
/*
|
|
|
|
|
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";
|
2026-09-26 21:48:22 -04:00
|
|
|
import { BehaviorSubject, combineLatest, of, type Subscription } from "rxjs";
|
2026-09-24 22:16:24 -04:00
|
|
|
import { distinctUntilChanged, map, switchMap } from "rxjs/operators";
|
|
|
|
|
|
|
|
|
|
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
|
|
|
|
import { type LayoutMode } from "../state/LayoutSwitchViewModel";
|
|
|
|
|
import { widget } from "../widget";
|
2026-09-26 21:48:22 -04:00
|
|
|
import { LotusWidgetActions, lotusFlag, lotusSendToHost } from "./lotusWidget";
|
2026-09-24 22:16:24 -04:00
|
|
|
|
|
|
|
|
/** 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;
|
2026-09-26 12:49:58 -04:00
|
|
|
/** This fork handles `io.lotus.set_screenshare` (always true). */
|
|
|
|
|
screenshareAction: true;
|
2026-09-26 13:59:34 -04:00
|
|
|
/** This fork handles `io.lotus.set_hotkeys` (always true). */
|
|
|
|
|
hotkeys: true;
|
2026-09-26 21:48:22 -04:00
|
|
|
/**
|
2026-09-27 12:48:12 -04:00
|
|
|
* EC's own screenshare button is shown inside the frame, so the host should
|
|
|
|
|
* hide its own. Always false since the prompt replaced that button; kept so
|
|
|
|
|
* a host that still reads it doesn't hide its button.
|
2026-09-26 21:48:22 -04:00
|
|
|
*/
|
2026-09-27 12:48:12 -04:00
|
|
|
frameScreenshare: false;
|
|
|
|
|
/**
|
|
|
|
|
* The host should start a share with `io.lotus.prompt_screenshare` (the
|
|
|
|
|
* in-frame prompt) instead of `set_screenshare`: see
|
|
|
|
|
* `lotusFrameScreenshareEnabled`.
|
|
|
|
|
*/
|
|
|
|
|
screensharePrompt: boolean;
|
|
|
|
|
/**
|
|
|
|
|
* The prompt is showing. The host must let clicks through to this frame
|
|
|
|
|
* meanwhile (e.g. its picture-in-picture overlay).
|
|
|
|
|
*/
|
|
|
|
|
screensharePromptOpen: boolean;
|
2026-09-26 21:48:22 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* [cinny #43] `lotusFrameScreenshare=1` (with `lotusHostControls`): the host
|
|
|
|
|
* can't hand the user's click into this frame (no Capability Delegation:
|
|
|
|
|
* Firefox, Safari, WebKitGTK), and `getDisplayMedia` needs that click here.
|
2026-09-27 12:48:12 -04:00
|
|
|
* The host's call-bar button then asks for `io.lotus.prompt_screenshare`, and
|
|
|
|
|
* the "Share your screen?" prompt is drawn inside this frame, where the Share
|
|
|
|
|
* click is native.
|
2026-09-26 21:48:22 -04:00
|
|
|
*/
|
|
|
|
|
export function lotusFrameScreenshareEnabled(): boolean {
|
|
|
|
|
return lotusFlag("lotusHostControls") && lotusFlag("lotusFrameScreenshare");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-27 12:48:12 -04:00
|
|
|
/** Whether the in-frame "Share your screen?" prompt is showing. */
|
|
|
|
|
export const lotusScreensharePrompt$ = new BehaviorSubject(false);
|
|
|
|
|
|
2026-09-26 21:48:22 -04:00
|
|
|
/**
|
|
|
|
|
* Host-controlled visibility of the in-frame screenshare button
|
|
|
|
|
* (`io.lotus.set_frame_screenshare`): the host hides it while the room's
|
|
|
|
|
* call policy forbids screensharing, as it does its own call-bar button.
|
|
|
|
|
*/
|
|
|
|
|
export const lotusFrameScreenshareVisible$ = new BehaviorSubject(true);
|
|
|
|
|
|
2026-09-27 12:48:12 -04:00
|
|
|
/**
|
|
|
|
|
* Whether `io.lotus.prompt_screenshare` should show the prompt: only in
|
|
|
|
|
* prompt mode, where the room's policy allows sharing, EC can share, and no
|
|
|
|
|
* share is running (stopping goes through `set_screenshare`, which needs no
|
|
|
|
|
* click). Exported for tests.
|
|
|
|
|
*/
|
|
|
|
|
export function shouldPromptScreenshare(s: {
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
allowed: boolean;
|
|
|
|
|
canShare: boolean;
|
|
|
|
|
sharing: boolean;
|
|
|
|
|
}): boolean {
|
|
|
|
|
return s.enabled && s.allowed && s.canShare && !s.sharing;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 21:48:22 -04:00
|
|
|
/** `{ visible }` payload → the visibility to apply, or undefined. Exported for tests. */
|
|
|
|
|
export function parseFrameScreensharePayload(
|
|
|
|
|
data: unknown,
|
|
|
|
|
): boolean | undefined {
|
|
|
|
|
if (typeof data !== "object" || data === null) return undefined;
|
|
|
|
|
const { visible } = data as { visible?: unknown };
|
|
|
|
|
return typeof visible === "boolean" ? visible : undefined;
|
2026-09-24 22:16:24 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** `{ 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 12:49:58 -04:00
|
|
|
/**
|
|
|
|
|
* `{ on? }` payload + current sharing state → whether to call the toggle.
|
|
|
|
|
* Exported for tests.
|
|
|
|
|
*/
|
|
|
|
|
export function shouldToggleScreenshare(
|
|
|
|
|
data: unknown,
|
|
|
|
|
sharing: boolean,
|
|
|
|
|
): boolean {
|
|
|
|
|
if (typeof data === "object" && data !== null && "on" in data) {
|
|
|
|
|
const { on } = data as { on?: unknown };
|
|
|
|
|
if (typeof on === "boolean") return on !== sharing;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 22:16:24 -04:00
|
|
|
/**
|
|
|
|
|
* [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
|
2026-09-26 12:49:58 -04:00
|
|
|
* EC's DOM for them. Screenshare start/stop too: `getDisplayMedia` needs the
|
|
|
|
|
* user's click in this frame, so the host sends `set_screenshare` with
|
|
|
|
|
* Capability Delegation (Chromium). Elsewhere it keeps clicking EC's button.
|
2026-09-24 22:16:24 -04:00
|
|
|
*
|
|
|
|
|
* 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));
|
|
|
|
|
};
|
2026-09-26 12:49:58 -04:00
|
|
|
const onSetScreenshare = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
|
|
|
|
w.api.transport.reply(ev.detail, {});
|
|
|
|
|
// Call synchronously: the delegated activation is only good for a few
|
|
|
|
|
// seconds after the message arrived.
|
|
|
|
|
if (shouldToggleScreenshare(ev.detail.data, vm.sharingScreen$.value)) {
|
|
|
|
|
vm.toggleScreenSharing?.();
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-09-24 22:16:24 -04:00
|
|
|
|
|
|
|
|
w.lazyActions.on(LotusWidgetActions.SetLayout, onSetLayout);
|
|
|
|
|
w.lazyActions.on(LotusWidgetActions.OpenSettings, onOpenSettings);
|
|
|
|
|
w.lazyActions.on(LotusWidgetActions.ToggleReactions, onToggleReactions);
|
2026-09-26 21:48:22 -04:00
|
|
|
const onSetFrameScreenshare = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
|
|
|
|
w.api.transport.reply(ev.detail, {});
|
|
|
|
|
const visible = parseFrameScreensharePayload(ev.detail.data);
|
|
|
|
|
if (visible !== undefined) lotusFrameScreenshareVisible$.next(visible);
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-27 12:48:12 -04:00
|
|
|
const onPromptScreenshare = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
|
|
|
|
w.api.transport.reply(ev.detail, {});
|
|
|
|
|
if (
|
|
|
|
|
shouldPromptScreenshare({
|
|
|
|
|
enabled: lotusFrameScreenshareEnabled(),
|
|
|
|
|
allowed: lotusFrameScreenshareVisible$.value,
|
|
|
|
|
canShare: !!vm.toggleScreenSharing,
|
|
|
|
|
sharing: vm.sharingScreen$.value,
|
|
|
|
|
})
|
|
|
|
|
) {
|
|
|
|
|
lotusScreensharePrompt$.next(true);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-26 12:49:58 -04:00
|
|
|
w.lazyActions.on(LotusWidgetActions.SetScreenshare, onSetScreenshare);
|
2026-09-26 21:48:22 -04:00
|
|
|
w.lazyActions.on(
|
|
|
|
|
LotusWidgetActions.SetFrameScreenshare,
|
|
|
|
|
onSetFrameScreenshare,
|
|
|
|
|
);
|
2026-09-27 12:48:12 -04:00
|
|
|
w.lazyActions.on(LotusWidgetActions.PromptScreenshare, onPromptScreenshare);
|
2026-09-24 22:16:24 -04:00
|
|
|
|
|
|
|
|
const sub: Subscription = combineLatest([
|
|
|
|
|
vm.sharingScreen$,
|
|
|
|
|
vm.layoutSwitchVm$.pipe(
|
|
|
|
|
switchMap((l) => (l ? l.layout$ : of<LayoutMode | null>(null))),
|
|
|
|
|
),
|
2026-09-27 12:48:12 -04:00
|
|
|
lotusScreensharePrompt$,
|
2026-09-24 22:16:24 -04:00
|
|
|
])
|
|
|
|
|
.pipe(
|
|
|
|
|
map(
|
2026-09-27 12:48:12 -04:00
|
|
|
([screensharing, layout, promptOpen]): LotusControlsState => ({
|
2026-09-24 22:16:24 -04:00
|
|
|
screensharing,
|
|
|
|
|
layout,
|
2026-09-26 12:49:58 -04:00
|
|
|
screenshareAction: true,
|
2026-09-26 13:59:34 -04:00
|
|
|
hotkeys: true,
|
2026-09-27 12:48:12 -04:00
|
|
|
frameScreenshare: false,
|
|
|
|
|
screensharePrompt: lotusFrameScreenshareEnabled(),
|
|
|
|
|
screensharePromptOpen: promptOpen,
|
2026-09-24 22:16:24 -04:00
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
distinctUntilChanged(
|
2026-09-27 12:48:12 -04:00
|
|
|
(a, b) =>
|
|
|
|
|
a.screensharing === b.screensharing &&
|
|
|
|
|
a.layout === b.layout &&
|
|
|
|
|
a.screensharePromptOpen === b.screensharePromptOpen,
|
2026-09-24 22:16:24 -04:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.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);
|
2026-09-26 12:49:58 -04:00
|
|
|
w.lazyActions.off(LotusWidgetActions.SetScreenshare, onSetScreenshare);
|
2026-09-26 21:48:22 -04:00
|
|
|
w.lazyActions.off(
|
|
|
|
|
LotusWidgetActions.SetFrameScreenshare,
|
|
|
|
|
onSetFrameScreenshare,
|
|
|
|
|
);
|
2026-09-27 12:48:12 -04:00
|
|
|
w.lazyActions.off(
|
|
|
|
|
LotusWidgetActions.PromptScreenshare,
|
|
|
|
|
onPromptScreenshare,
|
|
|
|
|
);
|
|
|
|
|
lotusScreensharePrompt$.next(false);
|
2026-09-24 22:16:24 -04:00
|
|
|
};
|
|
|
|
|
}
|