import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react'; import { Box, Button, Chip, config, Icon, IconButton, Icons, Menu, MenuItem, PopOut, RectCords, Spinner, Text, toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; import { SequenceCard } from '../../components/sequence-card'; import * as css from './styles.css'; import { ChatButton, ControlDivider, FullscreenButton, MicrophoneButton, ScreenShareButton, ScreenshareAudioButton, SoundButton, VideoButton, } from './Controls'; import { CallEmbed, useCallControlState } from '../../plugins/call'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { useResizeObserver } from '../../hooks/useResizeObserver'; import { stopPropagation } from '../../utils/keyboard'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useCallEmbedRef } from '../../hooks/useCallEmbed'; type CallControlsProps = { callEmbed: CallEmbed; }; export function CallControls({ callEmbed }: CallControlsProps) { const controlRef = useRef(null); const callEmbedRef = useCallEmbedRef(); const [compact, setCompact] = useState(document.body.clientWidth < 500); const [isFullscreen, setIsFullscreen] = useState(false); useResizeObserver( useCallback(() => { const element = controlRef.current; if (!element) return; setCompact(element.clientWidth < 500); }, []), useCallback(() => controlRef.current, []), ); useEffect(() => { const onFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener('fullscreenchange', onFullscreenChange); return () => document.removeEventListener('fullscreenchange', onFullscreenChange); }, []); const handleFullscreen = useCallback(() => { if (document.fullscreenElement) { document.exitFullscreen(); } else { callEmbedRef.current?.requestFullscreen(); } }, [callEmbedRef]); const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } = useCallControlState(callEmbed.control); const [cords, setCords] = useState(); const [shareConfirm, setShareConfirm] = useState(false); useEffect(() => { if (!shareConfirm) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') setShareConfirm(false); }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [shareConfirm]); const [pttMode] = useSetting(settingsAtom, 'pttMode'); const [pttKey] = useSetting(settingsAtom, 'pttKey'); const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); const [pttActive, setPttActive] = useState(false); // Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn) const microphoneRef = useRef(microphone); useEffect(() => { microphoneRef.current = microphone; }, [microphone]); // Handle PTT mode toggle mid-call — save/restore mic state (I-4) const pttModeRef = useRef(pttMode); const micBeforePTTRef = useRef(null); useEffect(() => { if (pttMode && !pttModeRef.current) { micBeforePTTRef.current = microphoneRef.current; callEmbed.control.setMicrophone(false); } else if (!pttMode && pttModeRef.current) { callEmbed.control.setMicrophone(micBeforePTTRef.current ?? true); micBeforePTTRef.current = null; } pttModeRef.current = pttMode; }, [pttMode, callEmbed]); const handleOpenMenu: MouseEventHandler = (evt) => { setCords(evt.currentTarget.getBoundingClientRect()); }; const handleSpotlightClick = () => { callEmbed.control.toggleSpotlight(); setCords(undefined); }; const handleReactionsClick = () => { callEmbed.control.toggleReactions(); setCords(undefined); }; const handleSettingsClick = () => { callEmbed.control.toggleSettings(); setCords(undefined); }; const pttActiveRef = useRef(false); useEffect(() => { if (!pttMode) return; const iframeWindow = callEmbed.iframe.contentWindow; const onKeyDown = (e: KeyboardEvent) => { if (e.code !== pttKey || e.repeat) return; const target = e.target as HTMLElement; // BUG-7: use ownerDocument.body so isEditable works inside the EC iframe const isEditable = (el: HTMLElement): boolean => { const tag = el.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; let node: HTMLElement | null = el; while (node && node !== el.ownerDocument.body) { if (node.contentEditable === 'true') return true; if (node.contentEditable === 'false') return false; node = node.parentElement; } return false; }; if (isEditable(target)) return; e.preventDefault(); if (!microphoneRef.current) callEmbed.control.setMicrophone(true); pttActiveRef.current = true; setPttActive(true); }; const onKeyUp = (e: KeyboardEvent) => { if (e.code !== pttKey) return; callEmbed.control.setMicrophone(false); pttActiveRef.current = false; setPttActive(false); }; const onBlur = () => { callEmbed.control.setMicrophone(false); pttActiveRef.current = false; setPttActive(false); }; const onFocus = () => { callEmbed.control.setMicrophone(false); pttActiveRef.current = false; setPttActive(false); }; window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp); window.addEventListener('blur', onBlur); window.addEventListener('focus', onFocus); // BUG-9: also wire iframe blur/focus so stuck-mic release works when focus moves to iframe iframeWindow?.addEventListener('keydown', onKeyDown); iframeWindow?.addEventListener('keyup', onKeyUp); iframeWindow?.addEventListener('blur', onBlur); iframeWindow?.addEventListener('focus', onFocus); return () => { window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); window.removeEventListener('blur', onBlur); window.removeEventListener('focus', onFocus); iframeWindow?.removeEventListener('keydown', onKeyDown); iframeWindow?.removeEventListener('keyup', onKeyUp); iframeWindow?.removeEventListener('blur', onBlur); iframeWindow?.removeEventListener('focus', onFocus); // BUG-8: if callEmbed changes while PTT is active, release mic on cleanup if (pttActiveRef.current) { callEmbed.control.setMicrophone(false); pttActiveRef.current = false; setPttActive(false); } }; // microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn }, [pttMode, pttKey, callEmbed]); const [hangupState, hangup] = useAsyncCallback( useCallback(() => callEmbed.hangup(), [callEmbed]), ); const exiting = hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success; const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', ''); return ( {pttMode && (lotusTerminal ? ( {pttActive ? ( <> {' LIVE'} ) : ( `PTT — Hold ${pttKeyLabel}` )} ) : ( {pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`} ))} {shareConfirm && ( <>
setShareConfirm(false)} aria-hidden="true" /> Share your screen? Your screen will be visible to all participants in this call. )} callEmbed.control.toggleMicrophone()} /> callEmbed.control.toggleSound()} /> callEmbed.control.toggleScreenshareAudio()} /> {!compact && } callEmbed.control.toggleVideo()} /> screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true) } /> {screenshare && !!document.fullscreenEnabled && ( )} {!compact && } setCords(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', escapeDeactivates: stopPropagation, }} > {spotlight ? 'Grid View' : 'Spotlight View'} Reactions Settings } > ); }