import { Box, Icon, IconButton, IconSrc, Spinner, Text, Tooltip, TooltipProvider } from 'folds'; 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 { CallHeadphoneIcon, CallHeadphoneMuteIcon, CallMicIcon, CallMicMuteIcon, CallPhoneDownIcon, CallScreenShareIcon, CallScreenShareMuteIcon, CallVideoIcon, CallVideoMuteIcon, } from './callIcons'; // 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; // All controls share the same shape so the active-call pill reads as a // single visual family with the IncomingCallStrip Decline/Answer buttons: // pill-radius, soft fill, size 500 (~52-56px). Variant changes with state // (Surface = on, Warning = muted/off, Critical = hangup, Success = // camera/screenshare on). const BUTTON_SIZE = '500' as const; const BUTTON_RADII = 'Pill' as const; const BUTTON_FILL = 'Soft' as const; const ICON_SIZE = '300' as const; type ToggleButtonProps = { enabled: boolean; onToggle: () => void | Promise; disabled?: boolean; iconOn: IconSrc; iconOff: IconSrc; labelOn: string; labelOff: string; variantOn?: 'Surface' | 'Success'; variantOff?: 'Warning' | 'Surface'; }; function ToggleButton({ enabled, onToggle, disabled, iconOn, iconOff, labelOn, labelOff, variantOn = 'Surface', variantOff = 'Warning', }: ToggleButtonProps) { return ( {enabled ? labelOn : labelOff} } > {(anchorRef) => ( onToggle()} disabled={disabled} aria-label={enabled ? labelOn : labelOff} > )} ); } type CallControlProps = { callEmbed: CallEmbed; compact: boolean; callJoined: boolean; }; export function CallControl({ callEmbed, compact, callJoined }: CallControlProps) { 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(); }; // Visibility mirrors the legacy strip: Mic + Sound always render, // Video joins when the call isn't voice-only (still shown on mobile — // users want to be able to flip the camera on a phone), Screenshare // is desktop-only because mobile WebRTC capture is meaningfully // different and Element Call gates it natively. const showVideo = !callEmbed.voiceOnly; const showScreenshare = !compact && !callEmbed.voiceOnly; return ( callEmbed.control.toggleMicrophone()} disabled={!callJoined} iconOn={CallMicIcon} iconOff={CallMicMuteIcon} labelOn={t('Call.mic_off')} labelOff={t('Call.mic_on')} /> callEmbed.control.toggleSound()} disabled={!callJoined} iconOn={CallHeadphoneIcon} iconOff={CallHeadphoneMuteIcon} labelOn={t('Call.sound_off')} labelOff={t('Call.sound_on')} /> {showVideo && ( callEmbed.control.toggleVideo()} disabled={!callJoined} iconOn={CallVideoIcon} iconOff={CallVideoMuteIcon} labelOn={t('Call.camera_off')} labelOff={t('Call.camera_on')} variantOn="Success" variantOff="Surface" /> )} {showScreenshare && ( callEmbed.control.toggleScreenshare()} disabled={!callJoined} iconOn={CallScreenShareIcon} iconOff={CallScreenShareMuteIcon} labelOn={t('Call.screenshare_off')} labelOff={t('Call.screenshare_on')} variantOn="Success" variantOff="Surface" /> )} {t('Call.end_call')} } > {(anchorRef) => ( {exiting ? ( ) : ( )} )} ); }