Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 bb675fa50e feat(lotus): in-frame "Share your screen?" prompt replaces the corner button (cinny #43)
CI / Publish to Gitea npm registry (pull_request) Skipped
CI / Build embedded bundle (pull_request) Successful in 3m25s
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
2026-09-27 12:48:12 -04:00
10 changed files with 347 additions and 148 deletions
@@ -1,8 +0,0 @@
/* [cinny #43] EC's screenshare button, floating in the frame's bottom-right
corner while the host draws the rest of the call bar. */
.floating {
position: fixed;
right: var(--cpd-space-4x);
bottom: calc(env(safe-area-inset-bottom) + var(--cpd-space-4x));
z-index: 1;
}
-88
View File
@@ -1,88 +0,0 @@
/*
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, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TooltipProvider } from "@vector-im/compound-web";
import { BehaviorSubject } from "rxjs";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { LotusFrameScreenshare } from "./LotusFrameScreenshare";
import {
lotusFrameScreenshareEnabled,
lotusFrameScreenshareVisible$,
parseFrameScreensharePayload,
} from "./lotusControls";
// lotusFlag memoizes the URL params on first read, so set them once here.
window.history.pushState(
{},
"",
"/?lotusHostControls=1&lotusFrameScreenshare=1",
);
const mockVm = (
toggle: (() => void) | null,
sharing = new BehaviorSubject(false),
): CallViewModel =>
({
sharingScreen$: sharing,
toggleScreenSharing: toggle,
}) as unknown as CallViewModel;
const renderButton = (vm: CallViewModel): void => {
render(
<TooltipProvider>
<LotusFrameScreenshare vm={vm} />
</TooltipProvider>,
);
};
describe("LotusFrameScreenshare", () => {
it("is enabled by lotusHostControls + lotusFrameScreenshare", () => {
expect(lotusFrameScreenshareEnabled()).toBe(true);
});
it("toggles the share from a click inside the frame", async () => {
const toggle = vi.fn();
renderButton(mockVm(toggle));
await userEvent.click(screen.getByTestId("lotus_frame_screenshare"));
expect(toggle).toHaveBeenCalledTimes(1);
});
it("follows the sharing state", () => {
const sharing = new BehaviorSubject(false);
renderButton(mockVm(vi.fn(), sharing));
const button = screen.getByTestId("lotus_frame_screenshare");
expect(button).toHaveAttribute("aria-checked", "false");
act(() => sharing.next(true));
expect(button).toHaveAttribute("aria-checked", "true");
});
it("hides while the host's policy forbids sharing, unless a share is live", () => {
const sharing = new BehaviorSubject(false);
act(() => lotusFrameScreenshareVisible$.next(false));
renderButton(mockVm(vi.fn(), sharing));
expect(screen.queryByTestId("lotus_frame_screenshare")).toBeNull();
act(() => sharing.next(true));
expect(screen.getByTestId("lotus_frame_screenshare")).toBeInTheDocument();
act(() => lotusFrameScreenshareVisible$.next(true));
});
it("parses the set_frame_screenshare payload", () => {
expect(parseFrameScreensharePayload({ visible: false })).toBe(false);
expect(parseFrameScreensharePayload({ visible: true })).toBe(true);
expect(parseFrameScreensharePayload({})).toBeUndefined();
expect(parseFrameScreensharePayload(null)).toBeUndefined();
});
it("renders nothing when screensharing is unavailable", () => {
renderButton(mockVm(null));
expect(screen.queryByTestId("lotus_frame_screenshare")).toBeNull();
});
});
-41
View File
@@ -1,41 +0,0 @@
/*
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 FC } from "react";
import { ShareScreenButton } from "../button";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior";
import {
lotusFrameScreenshareEnabled,
lotusFrameScreenshareVisible$,
} from "./lotusControls";
import styles from "./LotusFrameScreenshare.module.css";
/**
* [cinny #43] EC's screenshare button, shown inside the frame when the host
* can't start a share for us (`lotusFrameScreenshare`). The rest of EC's
* footer stays hidden behind the host's call bar.
*/
export const LotusFrameScreenshare: FC<{ vm: CallViewModel }> = ({ vm }) => {
const sharing = useBehavior(vm.sharingScreen$);
const visible = useBehavior(lotusFrameScreenshareVisible$);
if (!lotusFrameScreenshareEnabled() || !vm.toggleScreenSharing) return null;
// Hidden by the host's call policy, but never while a share is live: the
// user must still be able to stop it.
if (!visible && !sharing) return null;
return (
<div className={styles.floating}>
<ShareScreenButton
size="md"
enabled={sharing}
onClick={vm.toggleScreenSharing}
data-testid="lotus_frame_screenshare"
/>
</div>
);
};
@@ -0,0 +1,43 @@
/* [cinny #43] "Share your screen?" card, drawn inside the frame just above the
host's call bar (the frame's bottom edge). */
.prompt {
position: fixed;
left: 50%;
bottom: calc(env(safe-area-inset-bottom) + var(--cpd-space-4x));
transform: translateX(-50%);
z-index: 2;
box-sizing: border-box;
width: max-content;
max-width: min(360px, calc(100% - 2 * var(--cpd-space-2x)));
padding: var(--cpd-space-4x);
display: flex;
flex-direction: column;
gap: var(--cpd-space-2x);
background: var(--cpd-color-bg-canvas-default);
border: 1px solid var(--cpd-color-border-interactive-secondary);
border-radius: var(--cpd-space-3x);
box-shadow: 0 8px 32px rgb(0 0 0 / 35%);
color: var(--cpd-color-text-primary);
}
.title {
margin: 0;
}
.buttons {
display: flex;
gap: var(--cpd-space-2x);
}
/* The call's picture-in-picture window is only ~158px tall: keep the card to
the question and the two buttons. */
@media (max-height: 240px) {
.prompt {
bottom: var(--cpd-space-2x);
padding: var(--cpd-space-2x) var(--cpd-space-3x);
gap: var(--cpd-space-1-5x);
}
.description {
display: none;
}
}
+125
View File
@@ -0,0 +1,125 @@
/*
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 { afterEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TooltipProvider } from "@vector-im/compound-web";
import { BehaviorSubject } from "rxjs";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { LotusScreensharePrompt } from "./LotusScreensharePrompt";
import {
lotusFrameScreenshareEnabled,
lotusFrameScreenshareVisible$,
lotusScreensharePrompt$,
shouldPromptScreenshare,
} from "./lotusControls";
// lotusFlag memoizes the URL params on first read, so set them once here.
window.history.pushState(
{},
"",
"/?lotusHostControls=1&lotusFrameScreenshare=1",
);
const mockVm = (
toggle: (() => void) | null,
sharing = new BehaviorSubject(false),
): CallViewModel =>
({
sharingScreen$: sharing,
toggleScreenSharing: toggle,
}) as unknown as CallViewModel;
const renderPrompt = (vm: CallViewModel): void => {
render(
<TooltipProvider>
<LotusScreensharePrompt vm={vm} />
</TooltipProvider>,
);
};
const prompt = (): HTMLElement | null =>
screen.queryByTestId("lotus_screenshare_prompt");
afterEach(() => {
act(() => {
lotusScreensharePrompt$.next(false);
lotusFrameScreenshareVisible$.next(true);
});
});
describe("LotusScreensharePrompt", () => {
it("is enabled by lotusHostControls + lotusFrameScreenshare", () => {
expect(lotusFrameScreenshareEnabled()).toBe(true);
});
it("stays hidden until the host asks", () => {
renderPrompt(mockVm(vi.fn()));
expect(prompt()).toBeNull();
act(() => lotusScreensharePrompt$.next(true));
expect(prompt()).toBeInTheDocument();
expect(
screen.getByRole("alertdialog", { name: "Share your screen?" }),
).toBeInTheDocument();
});
it("focuses Share, and Share starts the share and closes", async () => {
const toggle = vi.fn();
renderPrompt(mockVm(toggle));
act(() => lotusScreensharePrompt$.next(true));
const share = screen.getByTestId("lotus_screenshare_prompt_share");
expect(share).toHaveFocus();
await userEvent.click(share);
expect(toggle).toHaveBeenCalledTimes(1);
expect(prompt()).toBeNull();
});
it("Cancel and Escape close without sharing", async () => {
const toggle = vi.fn();
renderPrompt(mockVm(toggle));
act(() => lotusScreensharePrompt$.next(true));
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(prompt()).toBeNull();
act(() => lotusScreensharePrompt$.next(true));
await userEvent.keyboard("{Escape}");
expect(prompt()).toBeNull();
expect(toggle).not.toHaveBeenCalled();
});
it("closes when a share starts some other way", () => {
const sharing = new BehaviorSubject(false);
renderPrompt(mockVm(vi.fn(), sharing));
act(() => lotusScreensharePrompt$.next(true));
act(() => sharing.next(true));
expect(prompt()).toBeNull();
});
it("closes when the room's policy stops allowing screensharing", () => {
renderPrompt(mockVm(vi.fn()));
act(() => lotusScreensharePrompt$.next(true));
act(() => lotusFrameScreenshareVisible$.next(false));
expect(prompt()).toBeNull();
});
it("renders nothing when screensharing is unavailable", () => {
renderPrompt(mockVm(null));
act(() => lotusScreensharePrompt$.next(true));
expect(prompt()).toBeNull();
});
});
describe("shouldPromptScreenshare", () => {
const ok = { enabled: true, allowed: true, canShare: true, sharing: false };
it("prompts only in prompt mode, when allowed, able and not sharing", () => {
expect(shouldPromptScreenshare(ok)).toBe(true);
expect(shouldPromptScreenshare({ ...ok, enabled: false })).toBe(false);
expect(shouldPromptScreenshare({ ...ok, allowed: false })).toBe(false);
expect(shouldPromptScreenshare({ ...ok, canShare: false })).toBe(false);
expect(shouldPromptScreenshare({ ...ok, sharing: true })).toBe(false);
});
});
+102
View File
@@ -0,0 +1,102 @@
/*
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 FC, useCallback, useEffect, useId, useRef } from "react";
import { Button, Text } from "@vector-im/compound-web";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior";
import {
lotusFrameScreenshareEnabled,
lotusFrameScreenshareVisible$,
lotusScreensharePrompt$,
} from "./lotusControls";
import styles from "./LotusScreensharePrompt.module.css";
/**
* [cinny #43] "Share your screen?" asked inside the call frame, for hosts that
* can't hand the user's click to this frame (Firefox, Safari, WebKitGTK). The
* host's call-bar button sends `io.lotus.prompt_screenshare`; the Share click
* lands here, so getDisplayMedia runs with this frame's own activation. Same
* two clicks as the host's own confirm on Chromium.
*/
export const LotusScreensharePrompt: FC<{ vm: CallViewModel }> = ({ vm }) => {
const open = useBehavior(lotusScreensharePrompt$);
const sharing = useBehavior(vm.sharingScreen$);
const allowed = useBehavior(lotusFrameScreenshareVisible$);
const shareRef = useRef<HTMLButtonElement>(null);
const titleId = useId();
const descriptionId = useId();
const close = useCallback(() => lotusScreensharePrompt$.next(false), []);
// Stale once a share is running (started some other way) or the room's
// policy stops allowing it.
useEffect(() => {
if (open && (sharing || !allowed)) close();
}, [open, sharing, allowed, close]);
useEffect(() => {
if (open) shareRef.current?.focus();
}, [open]);
useEffect(() => {
if (!open) return undefined;
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key !== "Escape") return;
e.preventDefault();
close();
};
window.addEventListener("keydown", onKeyDown, true);
return (): void => window.removeEventListener("keydown", onKeyDown, true);
}, [open, close]);
const toggle = vm.toggleScreenSharing;
if (!open || !lotusFrameScreenshareEnabled() || !toggle) return null;
const onShare = (): void => {
close();
// Synchronously inside this click: getDisplayMedia needs its activation.
toggle();
};
return (
<div
className={styles.prompt}
role="alertdialog"
aria-labelledby={titleId}
aria-describedby={descriptionId}
data-testid="lotus_screenshare_prompt"
>
<Text
as="h2"
id={titleId}
size="md"
weight="semibold"
className={styles.title}
>
Share your screen?
</Text>
<Text id={descriptionId} size="md" className={styles.description}>
Your screen will be visible to everyone in this call.
</Text>
<div className={styles.buttons}>
<Button
ref={shareRef}
size="md"
onClick={onShare}
data-testid="lotus_screenshare_prompt_share"
>
Share
</Button>
<Button size="md" kind="secondary" onClick={close}>
Cancel
</Button>
</div>
</div>
);
};
+1
View File
@@ -33,6 +33,7 @@ describe("LotusWidgetActions", () => {
LotusWidgetActions.SetScreenshare,
LotusWidgetActions.SetHotkeys,
LotusWidgetActions.SetFrameScreenshare,
LotusWidgetActions.PromptScreenshare,
];
expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget));
+9 -1
View File
@@ -92,6 +92,12 @@ export enum LotusWidgetActions {
* policy forbids screensharing (cinny #43). Defaults to visible.
*/
SetFrameScreenshare = "io.lotus.set_frame_screenshare",
/**
* toWidget: `{}` — show the "Share your screen?" prompt inside this frame
* (cinny #43). For hosts that can't delegate the user's click: the prompt's
* Share button is clicked here, so getDisplayMedia gets its activation.
*/
PromptScreenshare = "io.lotus.prompt_screenshare",
/**
* fromWidget: a watched key went down/up here, or this window's focus
* changed; see `LotusHotkeyReport` (cinny #43).
@@ -99,7 +105,8 @@ export enum LotusWidgetActions {
Hotkey = "io.lotus.hotkey",
/**
* fromWidget: `{ screensharing: boolean, layout: "grid" | "spotlight" | null,
* screenshareAction: true, hotkeys: true, frameScreenshare: boolean }`
* screenshareAction: true, hotkeys: true, frameScreenshare: false,
* screensharePrompt: boolean, screensharePromptOpen: boolean }`
* 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.
*/
@@ -125,4 +132,5 @@ export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [
LotusWidgetActions.SetScreenshare,
LotusWidgetActions.SetHotkeys,
LotusWidgetActions.SetFrameScreenshare,
LotusWidgetActions.PromptScreenshare,
];
+65 -8
View File
@@ -26,23 +26,39 @@ export interface LotusControlsState {
/** This fork handles `io.lotus.set_hotkeys` (always true). */
hotkeys: true;
/**
* EC's own screenshare button is shown inside the frame (see
* `lotusFrameScreenshare`), so the host should hide its own.
* 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: boolean;
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.
* So EC's own screenshare button stays visible in the frame, where the click
* is native, instead of the host clicking it through the DOM.
* 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
@@ -50,6 +66,21 @@ export function lotusFrameScreenshareEnabled(): boolean {
*/
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,
@@ -138,30 +169,51 @@ export function startLotusControls(vm: CallViewModel): () => void {
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]): LotusControlsState => ({
([screensharing, layout, promptOpen]): LotusControlsState => ({
screensharing,
layout,
screenshareAction: true,
hotkeys: true,
frameScreenshare: lotusFrameScreenshareEnabled(),
frameScreenshare: false,
screensharePrompt: lotusFrameScreenshareEnabled(),
screensharePromptOpen: promptOpen,
}),
),
distinctUntilChanged(
(a, b) => a.screensharing === b.screensharing && a.layout === b.layout,
(a, b) =>
a.screensharing === b.screensharing &&
a.layout === b.layout &&
a.screensharePromptOpen === b.screensharePromptOpen,
),
)
.subscribe((state) => {
@@ -178,5 +230,10 @@ export function startLotusControls(vm: CallViewModel): () => void {
LotusWidgetActions.SetFrameScreenshare,
onSetFrameScreenshare,
);
w.lazyActions.off(
LotusWidgetActions.PromptScreenshare,
onPromptScreenshare,
);
lotusScreensharePrompt$.next(false);
};
}
+2 -2
View File
@@ -33,7 +33,7 @@ import { startLotusCallState } from "../lotus/lotusCallState";
import { startLotusFocus } from "../lotus/lotusFocus";
import { startLotusControls } from "../lotus/lotusControls";
import { startLotusHotkeys } from "../lotus/lotusHotkeys";
import { LotusFrameScreenshare } from "../lotus/LotusFrameScreenshare";
import { LotusScreensharePrompt } from "../lotus/LotusScreensharePrompt";
import { startLotusMicLevel } from "../lotus/lotusMicLevel";
import { startLotusAudioInject } from "../lotus/lotusAudioInject";
import { startLotusQuality } from "../lotus/lotusQuality";
@@ -693,7 +693,7 @@ export const InCallView: FC<InCallViewProps> = ({
{earpieceOverlay}
<ReactionsOverlay vm={vm} />
{footer}
<LotusFrameScreenshare vm={vm} />
<LotusScreensharePrompt vm={vm} />
{showModals && (
<>
<RageshakeRequestModal {...rageshakeRequestModalProps} />