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 = ( api: ClientWidgetApi | undefined, type: string, callback: (event: CustomEvent) => 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 ( action: string, data: T, ): Promise => 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(() => 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; };