import React, { useCallback } from 'react'; import { useSetAtom, useStore } from 'jotai'; import { useTranslation } from 'react-i18next'; import { CallEmbed, useCallControlState } from '../../plugins/call'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { callEmbedAtom } from '../../state/callEmbed'; import * as css from './styles.css'; import { CallActionButton } from './CallActionButton'; import { CallMicIcon, CallMicMuteIcon, CallPhoneDownIcon, CallScreenShareIcon, CallScreenShareMuteIcon, CallSpeakerIcon, CallSpeakerMuteIcon, CallVideoIcon, CallVideoMuteIcon, } from './callIcons'; import { useCallSpeaker } from '../../hooks/useCallSpeaker'; // 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 CallControlProps = { callEmbed: CallEmbed; compact: boolean; callJoined: boolean; // native = phone app → big labelled discs; web → compact icon buttons. native: boolean; }; export function CallControl({ callEmbed, compact, callJoined, native }: CallControlProps) { const { t } = useTranslation(); const { microphone, video, screenshare } = useCallControlState(callEmbed.control); const { speaker, toggle: toggleSpeaker, available: speakerAvailable } = useCallSpeaker(); 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(); }; // Mic always renders. Speaker (громкая связь) only where the native route // plugin can act (Android). Video joins when the call isn't voice-only — // still shown on mobile so users can flip the camera on a phone; Screenshare // is desktop-only because mobile WebRTC capture is gated natively by EC. const showVideo = !callEmbed.voiceOnly; const showScreenshare = !compact && !callEmbed.voiceOnly; const buttonCompact = !native; return (