Where the host can't delegate the user's click (Firefox, Safari, WebKitGTK), lotus.20 showed EC's own screenshare button floating in the frame's corner, apart from the host's call bar. Now the host keeps its bar button and sends the new toWidget io.lotus.prompt_screenshare; the fork shows a small "Share your screen?" card inside the frame, just above the bar, and its Share button starts the share with the frame's own click. Same two clicks as the host's confirm on Chromium. - The card focuses Share; Cancel and Escape close it; it closes if a share starts some other way or the room's call policy stops allowing sharing, and never opens where the policy forbids it (set_frame_screenshare). - Picture-in-picture (~158 px tall): compact card without the description. - controls_state: frameScreenshare is now always false (the corner button is gone), screensharePrompt says the host should use the prompt, and screensharePromptOpen lets the host stop covering the frame meanwhile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
240 lines
8.8 KiB
TypeScript
240 lines
8.8 KiB
TypeScript
/*
|
|
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 { BehaviorSubject, 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, lotusFlag, 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;
|
|
/** This fork handles `io.lotus.set_screenshare` (always true). */
|
|
screenshareAction: true;
|
|
/** This fork handles `io.lotus.set_hotkeys` (always true). */
|
|
hotkeys: true;
|
|
/**
|
|
* 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.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* [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.
|
|
* 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.
|
|
*/
|
|
export function lotusFrameScreenshareEnabled(): boolean {
|
|
return lotusFlag("lotusHostControls") && lotusFlag("lotusFrameScreenshare");
|
|
}
|
|
|
|
/** Whether the in-frame "Share your screen?" prompt is showing. */
|
|
export const lotusScreensharePrompt$ = new BehaviorSubject(false);
|
|
|
|
/**
|
|
* 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);
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/** `{ 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;
|
|
}
|
|
|
|
/** `{ 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;
|
|
}
|
|
|
|
/**
|
|
* `{ 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;
|
|
}
|
|
|
|
/**
|
|
* [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. 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.
|
|
*
|
|
* 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));
|
|
};
|
|
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?.();
|
|
}
|
|
};
|
|
|
|
w.lazyActions.on(LotusWidgetActions.SetLayout, onSetLayout);
|
|
w.lazyActions.on(LotusWidgetActions.OpenSettings, onOpenSettings);
|
|
w.lazyActions.on(LotusWidgetActions.ToggleReactions, onToggleReactions);
|
|
const onSetFrameScreenshare = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
|
w.api.transport.reply(ev.detail, {});
|
|
const visible = parseFrameScreensharePayload(ev.detail.data);
|
|
if (visible !== undefined) lotusFrameScreenshareVisible$.next(visible);
|
|
};
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
w.lazyActions.on(LotusWidgetActions.SetScreenshare, onSetScreenshare);
|
|
w.lazyActions.on(
|
|
LotusWidgetActions.SetFrameScreenshare,
|
|
onSetFrameScreenshare,
|
|
);
|
|
w.lazyActions.on(LotusWidgetActions.PromptScreenshare, onPromptScreenshare);
|
|
|
|
const sub: Subscription = combineLatest([
|
|
vm.sharingScreen$,
|
|
vm.layoutSwitchVm$.pipe(
|
|
switchMap((l) => (l ? l.layout$ : of<LayoutMode | null>(null))),
|
|
),
|
|
lotusScreensharePrompt$,
|
|
])
|
|
.pipe(
|
|
map(
|
|
([screensharing, layout, promptOpen]): LotusControlsState => ({
|
|
screensharing,
|
|
layout,
|
|
screenshareAction: true,
|
|
hotkeys: true,
|
|
frameScreenshare: false,
|
|
screensharePrompt: lotusFrameScreenshareEnabled(),
|
|
screensharePromptOpen: promptOpen,
|
|
}),
|
|
),
|
|
distinctUntilChanged(
|
|
(a, b) =>
|
|
a.screensharing === b.screensharing &&
|
|
a.layout === b.layout &&
|
|
a.screensharePromptOpen === b.screensharePromptOpen,
|
|
),
|
|
)
|
|
.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);
|
|
w.lazyActions.off(LotusWidgetActions.SetScreenshare, onSetScreenshare);
|
|
w.lazyActions.off(
|
|
LotusWidgetActions.SetFrameScreenshare,
|
|
onSetFrameScreenshare,
|
|
);
|
|
w.lazyActions.off(
|
|
LotusWidgetActions.PromptScreenshare,
|
|
onPromptScreenshare,
|
|
);
|
|
lotusScreensharePrompt$.next(false);
|
|
};
|
|
}
|