CI / Build & Quality Checks (pull_request) Successful in 4m18s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m27s
With a fork that reports `screensharePrompt` (element-call lotus-screenshare- prompt), the call bar and status bar keep their screenshare button on every engine. Where the click can't be delegated, starting asks the fork to show "Share your screen?" inside the call frame (io.lotus.prompt_screenshare) instead of our own confirm; its Share click starts the share. Stopping works from the bar as before (no click needed in the frame). Chromium is unchanged. - useScreenshareMode: hidden (older fork's corner button) | prompt | direct. - The room's call policy is also pushed to the fork in prompt mode, so the prompt never opens where sharing is forbidden. - Picture-in-picture: the "Return to call" overlay covers the frame; while the fork reports the prompt open it lets clicks through to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
121 lines
4.1 KiB
TypeScript
121 lines
4.1 KiB
TypeScript
import {
|
||
ClientWidgetApi,
|
||
IWidgetApiAcknowledgeResponseData,
|
||
IWidgetApiRequestData,
|
||
} from 'matrix-widget-api';
|
||
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
|
||
import { CallControl, CallControlEvent } from './CallControl';
|
||
import { CallControlState } from './CallControlState';
|
||
import type { CallEmbed } from './CallEmbed';
|
||
|
||
export const useClientWidgetApiEvent = <T>(
|
||
api: ClientWidgetApi | undefined,
|
||
type: string,
|
||
callback: (event: CustomEvent<T>) => void,
|
||
) => {
|
||
useEffect(() => {
|
||
api?.on(`action:${type}`, callback);
|
||
return () => {
|
||
api?.off(`action:${type}`, callback);
|
||
};
|
||
}, [api, type, callback]);
|
||
};
|
||
|
||
export const useSendClientWidgetApiAction = (api: ClientWidgetApi) => {
|
||
const sendWidgetAction = useCallback(
|
||
async <T extends IWidgetApiRequestData = IWidgetApiRequestData>(
|
||
action: string,
|
||
data: T,
|
||
): Promise<IWidgetApiAcknowledgeResponseData> => api.transport.send(action, data),
|
||
[api],
|
||
);
|
||
|
||
return sendWidgetAction;
|
||
};
|
||
|
||
const DEFAULT_CONTROL_STATE = new CallControlState(false, false, false);
|
||
|
||
export const useCallControlState = (control: CallControl | undefined): CallControlState => {
|
||
// Remember which control the stored state came from. When a call starts the
|
||
// control changes, and the effect below only catches up after a render; until
|
||
// then the old (default: all off) state would be returned for the NEW call —
|
||
// one render of "muted + deafened" that the thumbbar, SMTC and tray (#4)
|
||
// pushed to the OS as a flicker. Read the new control directly instead.
|
||
const [entry, setEntry] = useState(() => ({
|
||
control,
|
||
state: control?.getState() ?? DEFAULT_CONTROL_STATE,
|
||
}));
|
||
|
||
useEffect(() => {
|
||
if (!control) {
|
||
setEntry({ control, state: DEFAULT_CONTROL_STATE });
|
||
return;
|
||
}
|
||
setEntry({ control, state: control.getState() });
|
||
const handleUpdate = () => setEntry({ control, state: control.getState() });
|
||
control.on(CallControlEvent.StateUpdate, handleUpdate);
|
||
return () => {
|
||
control.off(CallControlEvent.StateUpdate, handleUpdate);
|
||
};
|
||
}, [control]);
|
||
|
||
if (entry.control !== control) return control?.getState() ?? DEFAULT_CONTROL_STATE;
|
||
return entry.state;
|
||
};
|
||
|
||
const noSubscribe = () => () => undefined;
|
||
|
||
/** [Gitea #146] The local mic level (0–3 bars) reported by the call, or 0. */
|
||
export const useCallMicLevel = (callEmbed: CallEmbed | undefined): number => {
|
||
const subscribe = useCallback(
|
||
(cb: () => void) => (callEmbed ? callEmbed.onMicLevel(cb) : noSubscribe()),
|
||
[callEmbed],
|
||
);
|
||
return useSyncExternalStore(subscribe, () => callEmbed?.getMicLevel() ?? 0);
|
||
};
|
||
|
||
/**
|
||
* [Gitea #43] How the call bar's screenshare button behaves:
|
||
* - `hidden`: EC's own button is inside the frame (fork lotus.20/21), so ours hides;
|
||
* - `prompt`: starting asks the fork for its in-frame "Share your screen?"
|
||
* (no Capability Delegation in this engine);
|
||
* - `direct`: our own confirm, then a (delegated) set_screenshare.
|
||
*/
|
||
export type ScreenshareMode = 'hidden' | 'prompt' | 'direct';
|
||
|
||
const screenshareMode = (control: CallControl): ScreenshareMode => {
|
||
if (control.frameScreenshare) return 'hidden';
|
||
return control.screenshareNeedsPrompt ? 'prompt' : 'direct';
|
||
};
|
||
|
||
export const useScreenshareMode = (control: CallControl | undefined): ScreenshareMode => {
|
||
const [mode, setMode] = useState<ScreenshareMode>(() =>
|
||
control ? screenshareMode(control) : 'direct',
|
||
);
|
||
useEffect(() => {
|
||
if (!control) {
|
||
setMode('direct');
|
||
return undefined;
|
||
}
|
||
const sync = () => setMode(screenshareMode(control));
|
||
sync();
|
||
return control.onFrameScreenshareChange(sync);
|
||
}, [control]);
|
||
return mode;
|
||
};
|
||
|
||
/** [Gitea #43] The fork's in-frame "Share your screen?" prompt is showing. */
|
||
export const useScreensharePromptOpen = (control: CallControl | undefined): boolean => {
|
||
const [open, setOpen] = useState(() => control?.screensharePromptOpen ?? false);
|
||
useEffect(() => {
|
||
if (!control) {
|
||
setOpen(false);
|
||
return undefined;
|
||
}
|
||
const sync = () => setOpen(control.screensharePromptOpen);
|
||
sync();
|
||
return control.onFrameScreenshareChange(sync);
|
||
}, [control]);
|
||
return open;
|
||
};
|