import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react'; import { useSetAtom } from 'jotai'; import { Box, Button, Chip, color, 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 { callEmbedAtom } from '../../state/callEmbed'; import { useResizeObserver } from '../../hooks/useResizeObserver'; import { stopPropagation } from '../../utils/keyboard'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useCallEmbedRef } from '../../hooks/useCallEmbed'; import { useAfkAutoMute } from '../../hooks/useAfkAutoMute'; import { CallSoundboard } from './CallSoundboard'; import { useStateEvent } from '../../hooks/useStateEvent'; import { StateEvent } from '../../../types/matrix/room'; import { RoomQualityContent } from '../../utils/callQuality'; type CallControlsProps = { callEmbed: CallEmbed; }; export function CallControls({ callEmbed }: CallControlsProps) { const controlRef = useRef(null); const callEmbedRef = useCallEmbedRef(); const setCallEmbed = useSetAtom(callEmbedAtom); 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); useAfkAutoMute(callEmbed); 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 [deafenKey] = useSetting(settingsAtom, 'deafenKey'); const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled'); // [P5-31] Hard room publish policy — hide controls the server will refuse so // users don't click dead buttons. Absent/true = allowed. const roomQualityEvent = useStateEvent(callEmbed.room, StateEvent.LotusRoomQuality); const roomQuality = roomQualityEvent?.getContent(); const cameraAllowed = roomQuality?.allow_camera !== false; const screenshareAllowed = roomQuality?.allow_screenshare !== false; // Keep a forbidden control visible while its track is still live (so the user // can stop it); otherwise hide it entirely. const showCamera = cameraAllowed || video; const showScreenshare = screenshareAllowed || screenshare; const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled; 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 handleMicrophoneToggle = useCallback( () => callEmbed.control.toggleMicrophone(), [callEmbed], ); const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]); 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(); // C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState) // doesn't treat this transient unmute as a user-initiated undeafen. callEmbed.control.pttActive = true; if (!microphoneRef.current) callEmbed.control.setMicrophone(true); pttActiveRef.current = true; setPttActive(true); }; const onKeyUp = (e: KeyboardEvent) => { if (e.code !== pttKey) return; callEmbed.control.pttActive = false; callEmbed.control.setMicrophone(false); pttActiveRef.current = false; setPttActive(false); }; const onBlur = () => { callEmbed.control.pttActive = false; callEmbed.control.setMicrophone(false); pttActiveRef.current = false; setPttActive(false); }; const onFocus = () => { callEmbed.control.pttActive = false; 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.pttActive = false; callEmbed.control.setMicrophone(false); pttActiveRef.current = false; setPttActive(false); } }; // microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn }, [pttMode, pttKey, callEmbed]); useEffect(() => { 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; }; const onKeyDown = (e: KeyboardEvent) => { if (e.code !== deafenKey) return; if (e.repeat) return; if (isEditable(e.target as HTMLElement)) return; e.preventDefault(); callEmbed.control.toggleSound(); }; // C-L4: also bind the EC iframe window so the deafen key works when focus is // inside the iframe (mirrors the PTT binding above). const iframeWindow = callEmbed.iframe.contentWindow; window.addEventListener('keydown', onKeyDown); iframeWindow?.addEventListener('keydown', onKeyDown); return () => { window.removeEventListener('keydown', onKeyDown); iframeWindow?.removeEventListener('keydown', onKeyDown); }; }, [callEmbed, deafenKey]); const [hangupState, hangup] = useAsyncCallback( useCallback(() => callEmbed.hangup(), [callEmbed]), ); const exiting = hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success; // C-M4: the normal teardown relies on EC echoing a Close/Hangup action after // it ACKs HangupCall (useCallHangupEvent -> clears callEmbedAtom -> dispose). // If EC ACKs but never echoes, the End button would spin forever. Fall back to // disposing the embed a few seconds after a successful hangup send, unless it // was already torn down by the normal path. useEffect(() => { if (hangupState.status !== AsyncStatus.Success) return undefined; const id = setTimeout(() => { if (!callEmbed.disposed) setCallEmbed(undefined); }, 4000); return () => clearTimeout(id); }, [hangupState.status, callEmbed, setCallEmbed]); const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', ''); return ( {pttMode && ( {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.toggleSound()} /> {!compact && showVideoGroup && } {showVideoGroup && ( {/* Show a forbidden control while its track is still live so the user can stop it; once stopped it hides and can't be restarted. */} {showCamera && } {showScreenshare && ( <> screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true) } /> {/* Mute-screenshare-audio sits directly next to the screenshare control since they're the same concern. */} callEmbed.control.toggleScreenshareAudio()} /> )} {!!document.fullscreenEnabled && ( )} )} {!compact && } {soundboardEnabled && } 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 } > ); }