import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds'; import React, { useCallback } from 'react'; import { useSetAtom, useStore } from 'jotai'; import { useTranslation } from 'react-i18next'; import { StatusDivider } from './components'; import { CallEmbed, useCallControlState } from '../../plugins/call'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { callEmbedAtom } from '../../state/callEmbed'; // Fail-open window for widget hangup ack. If the widget is healthy, its // im.vector.hangup / io.element.close action lands within a second and // clearIfCurrent (CallEmbedProvider) drops the atom. If LiveKit is dead and // the widget never replies, we fall through and force-clear locally — same // pattern as element-web's performDisconnection TIMEOUT_MS (16s there, 8s // here to match the mobile-ish feel of our DM flow and PEER_LEAVE_GRACE_MS). const HANGUP_TIMEOUT_MS = 8000; type MicrophoneButtonProps = { enabled: boolean; onToggle: () => Promise; disabled?: boolean; }; function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) { const { t } = useTranslation(); return ( {enabled ? t('Call.mic_off') : t('Call.mic_on')} } > {(anchorRef) => ( onToggle()} outlined disabled={disabled} > )} ); } type SoundButtonProps = { enabled: boolean; onToggle: () => void; disabled?: boolean; }; function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) { const { t } = useTranslation(); return ( {enabled ? t('Call.sound_off') : t('Call.sound_on')} } > {(anchorRef) => ( onToggle()} outlined disabled={disabled} > )} ); } type VideoButtonProps = { enabled: boolean; onToggle: () => Promise; disabled?: boolean; }; function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) { const { t } = useTranslation(); return ( {enabled ? t('Call.camera_off') : t('Call.camera_on')} } > {(anchorRef) => ( onToggle()} outlined disabled={disabled} > )} ); } type ScreenShareButtonProps = { enabled: boolean; onToggle: () => void; disabled?: boolean; }; function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) { const { t } = useTranslation(); return ( {enabled ? t('Call.screenshare_off') : t('Call.screenshare_on')} } > {(anchorRef) => ( )} ); } export function CallControl({ callEmbed, compact, callJoined, }: { callEmbed: CallEmbed; compact: boolean; callJoined: boolean; }) { const { t } = useTranslation(); const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control); const setCallEmbed = useSetAtom(callEmbedAtom); const store = useStore(); const [hangupState, hangup] = useAsyncCallback( useCallback(async () => { // Transport.send itself can reject (widget dead / unsupported action). // Swallow and still poll — even a dead widget may later re-emit via the // Close path, and the force-clear fallback guarantees forward progress. try { await callEmbed.hangup(); } catch { /* noop — fall through to poll + force-clear */ } const start = Date.now(); while (Date.now() - start < HANGUP_TIMEOUT_MS) { if (store.get(callEmbedAtom) !== callEmbed) return; // eslint-disable-next-line no-await-in-loop await new Promise((r) => { setTimeout(r, 200); }); } if (store.get(callEmbedAtom) === callEmbed) { setCallEmbed(undefined); } }, [callEmbed, store, setCallEmbed]) ); const exiting = hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success; const handleHangup = () => { if (!callJoined) { setCallEmbed(undefined); return; } hangup(); }; return ( callEmbed.control.toggleMicrophone()} disabled={!callJoined} /> callEmbed.control.toggleSound()} disabled={!callJoined} /> {!callEmbed.voiceOnly && ( <> {!compact && } callEmbed.control.toggleVideo()} disabled={!callJoined} /> {!compact && ( callEmbed.control.toggleScreenshare()} disabled={!callJoined} /> )} )} ) : ( ) } disabled={exiting} outlined onClick={handleHangup} > {!compact && ( {t('Call.end_call')} )} ); }