import React, { MouseEventHandler, useCallback, useRef, useState, useEffect } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; 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 { callEmbedAtom } from '../../state/callEmbed'; import { useResizeObserver } from '../../hooks/useResizeObserver'; import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize'; import { stopPropagation } from '../../utils/keyboard'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useCallEmbedRef } from '../../hooks/useCallEmbed'; import { pttActiveAtom } from '../../hooks/useCallHotkeys'; import { CallSoundboard } from './CallSoundboard'; import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy'; import { ScreenshareConfirm } from './ScreenshareConfirm'; type CallControlsProps = { callEmbed: CallEmbed; }; export function CallControls({ callEmbed }: CallControlsProps) { const controlRef = useRef(null); const callEmbedRef = useCallEmbedRef(); const setCallEmbed = useSetAtom(callEmbedAtom); const screenSize = useScreenSize(); const [narrowContainer, setNarrowContainer] = useState(document.body.clientWidth < 500); const [isFullscreen, setIsFullscreen] = useState(false); useResizeObserver( useCallback(() => { const element = controlRef.current; if (!element) return; setNarrowContainer(element.clientWidth < 500); }, []), useCallback(() => controlRef.current, []), ); // Collapse to the stacked/compact layout whenever the bar's own container is // narrow (a small desktop call window) OR the viewport is a phone. The old // element-only `< 500` check left the ~11-control row overflowing off-screen // in the 500–750px band (landscape phones / small tablets). const compact = narrowContainer || screenSize === ScreenSize.Mobile; 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); const [pttMode] = useSetting(settingsAtom, 'pttMode'); const [pttKey] = useSetting(settingsAtom, 'pttKey'); const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled'); // [Gitea #9] PTT/deafen key handling and AFK auto-mute live in useCallHotkeys // / useAfkAutoMute, mounted from CallEmbedProvider for the embed's lifetime // (this component only renders while the call room is selected). Only the // visual PTT chip remains here. const pttActive = useAtomValue(pttActiveAtom); // [P5-31 / Gitea #101] Hard room publish policy — hide controls the server // will refuse so users don't click dead buttons. Absent/true = allowed. // Shared with the app-wide CallStatus bar's CallControl via useRoomCallPolicy // so both surfaces apply the same gating. const { allowCamera, allowScreenshare } = useRoomCallPolicy(callEmbed.room); // Keep a forbidden control visible while its track is still live (so the user // can stop it); otherwise hide it entirely. const showCamera = allowCamera || video; const showScreenshare = allowScreenshare || screenshare; const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled; 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 [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}`} )} { callEmbed.control.toggleScreenshare(); setShareConfirm(false); }} onCancel={() => setShareConfirm(false)} /> 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 } > ); }