403ec3d80c
CI / Build & Quality Checks (push) Successful in 10m25s
B1 - GIF upload progress: spinner on GIF button + disabled state while
fetch+upload is in flight; clears on success or error
B2 - PiP position persistence: drag end saves left/top to localStorage;
entering PiP restores saved position (clamped to current viewport)
B3 - PiP snap-to-corner: double-click the PiP overlay snaps to nearest
corner with a 180ms CSS transition; saves new position
B4 - Device sessions loading state: useOtherUserDevices now returns
{status:'loading'|'error'|'success', devices} instead of bare
array; UserDeviceSessions shows spinner while loading
B5 - Device sessions error state: catch in hook sets status:'error';
panel shows warning icon + 'Could not load sessions' message
B6 - Screenshare fullscreen Safari guard: hide button when
document.fullscreenEnabled is false (iOS Safari, some mobile)
B7 - Status save error: show critical-coloured error text below Save
button when saveState.status === AsyncStatus.Error
B9 - Encrypted search coverage counter: 'X / Y cached' badge next to
'Encrypted Rooms' heading using existing localResult fields
D2 - PiP screenshare spotlight: track auto-spotlight in a ref; release
spotlight when screenshare ends while in PiP mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
462 lines
16 KiB
TypeScript
462 lines
16 KiB
TypeScript
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<HTMLDivElement>(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<RectCords>();
|
|
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<boolean | null>(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<HTMLButtonElement> = (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 (
|
|
<Box
|
|
ref={controlRef}
|
|
className={css.CallControlContainer}
|
|
justifyContent="Center"
|
|
alignItems="Center"
|
|
>
|
|
{pttMode &&
|
|
(lotusTerminal ? (
|
|
<Box
|
|
style={{
|
|
position: 'absolute',
|
|
top: '-2.5rem',
|
|
left: '50%',
|
|
transform: 'translateX(-50%)',
|
|
background: pttActive ? 'rgba(0,255,136,0.18)' : 'rgba(255,107,0,0.12)',
|
|
border: `1px solid ${pttActive ? 'rgba(0,255,136,0.55)' : 'rgba(255,107,0,0.35)'}`,
|
|
borderRadius: '99px',
|
|
padding: '0.2rem 0.9rem',
|
|
pointerEvents: 'none',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
<Text
|
|
size="T200"
|
|
style={{
|
|
color: pttActive ? '#00FF88' : '#FF6B00',
|
|
fontWeight: 700,
|
|
letterSpacing: '0.08em',
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
}}
|
|
>
|
|
{pttActive ? (
|
|
<>
|
|
<span
|
|
style={{
|
|
display: 'inline-block',
|
|
animation: 'pttLivePulse 900ms ease-in-out infinite',
|
|
}}
|
|
>
|
|
●
|
|
</span>
|
|
{' LIVE'}
|
|
</>
|
|
) : (
|
|
`PTT — Hold ${pttKeyLabel}`
|
|
)}
|
|
</Text>
|
|
</Box>
|
|
) : (
|
|
<Chip
|
|
variant={pttActive ? 'Success' : 'Warning'}
|
|
fill="Soft"
|
|
radii="400"
|
|
style={{
|
|
position: 'absolute',
|
|
top: '-2.2rem',
|
|
left: '50%',
|
|
transform: 'translateX(-50%)',
|
|
pointerEvents: 'none',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
outlined
|
|
>
|
|
<Text size="T200" style={{ fontWeight: 700 }}>
|
|
{pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`}
|
|
</Text>
|
|
</Chip>
|
|
))}
|
|
{shareConfirm && (
|
|
<>
|
|
<div
|
|
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
|
|
onClick={() => setShareConfirm(false)}
|
|
aria-hidden="true"
|
|
/>
|
|
<Box
|
|
style={{
|
|
position: 'absolute',
|
|
bottom: '110%',
|
|
left: '50%',
|
|
transform: 'translateX(-50%)',
|
|
background: 'var(--bg-surface)',
|
|
border: '1px solid var(--bg-surface-border)',
|
|
borderRadius: '0.75rem',
|
|
padding: '1rem 1.25rem',
|
|
zIndex: 100,
|
|
minWidth: '260px',
|
|
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '0.75rem',
|
|
}}
|
|
>
|
|
<Text size="T300" style={{ fontWeight: 600 }}>
|
|
Share your screen?
|
|
</Text>
|
|
<Text size="T200" style={{ opacity: 0.75 }}>
|
|
Your screen will be visible to all participants in this call.
|
|
</Text>
|
|
<Box gap="200">
|
|
<Button
|
|
size="300"
|
|
variant="Success"
|
|
fill="Solid"
|
|
radii="300"
|
|
onClick={() => {
|
|
callEmbed.control.toggleScreenshare();
|
|
setShareConfirm(false);
|
|
}}
|
|
>
|
|
<Text size="B300">Share</Text>
|
|
</Button>
|
|
<Button
|
|
size="300"
|
|
variant="Secondary"
|
|
fill="Soft"
|
|
radii="300"
|
|
outlined
|
|
onClick={() => setShareConfirm(false)}
|
|
>
|
|
<Text size="B300">Cancel</Text>
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
</>
|
|
)}
|
|
<SequenceCard
|
|
className={css.ControlCard}
|
|
variant="SurfaceVariant"
|
|
gap="400"
|
|
radii="500"
|
|
alignItems="Center"
|
|
justifyContent="SpaceBetween"
|
|
>
|
|
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
|
<MicrophoneButton
|
|
enabled={microphone}
|
|
onToggle={() => callEmbed.control.toggleMicrophone()}
|
|
/>
|
|
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
|
<ScreenshareAudioButton
|
|
muted={screenshareAudioMuted}
|
|
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
|
/>
|
|
</Box>
|
|
{!compact && <ControlDivider />}
|
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
|
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} />
|
|
<ScreenShareButton
|
|
enabled={screenshare}
|
|
onToggle={() =>
|
|
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
|
}
|
|
/>
|
|
{screenshare && !!document.fullscreenEnabled && (
|
|
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
{!compact && <ControlDivider />}
|
|
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
|
<ChatButton />
|
|
<PopOut
|
|
anchor={cords}
|
|
position="Top"
|
|
align="Center"
|
|
content={
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
onDeactivate: () => setCords(undefined),
|
|
clickOutsideDeactivates: true,
|
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<Menu>
|
|
<Box direction="Column" style={{ padding: config.space.S100 }}>
|
|
<MenuItem
|
|
size="300"
|
|
variant="Surface"
|
|
radii="300"
|
|
onClick={handleSpotlightClick}
|
|
>
|
|
<Text size="B300" truncate>
|
|
{spotlight ? 'Grid View' : 'Spotlight View'}
|
|
</Text>
|
|
</MenuItem>
|
|
<MenuItem
|
|
size="300"
|
|
variant="Surface"
|
|
radii="300"
|
|
onClick={handleReactionsClick}
|
|
>
|
|
<Text size="B300" truncate>
|
|
Reactions
|
|
</Text>
|
|
</MenuItem>
|
|
<MenuItem
|
|
size="300"
|
|
variant="Surface"
|
|
radii="300"
|
|
onClick={handleSettingsClick}
|
|
>
|
|
<Text size="B300" truncate>
|
|
Settings
|
|
</Text>
|
|
</MenuItem>
|
|
</Box>
|
|
</Menu>
|
|
</FocusTrap>
|
|
}
|
|
>
|
|
<IconButton
|
|
variant="Surface"
|
|
fill="Soft"
|
|
radii="400"
|
|
size="400"
|
|
onClick={handleOpenMenu}
|
|
outlined
|
|
aria-label="More options"
|
|
aria-expanded={!!cords}
|
|
aria-haspopup="menu"
|
|
>
|
|
<Icon size="400" src={Icons.VerticalDots} />
|
|
</IconButton>
|
|
</PopOut>
|
|
</Box>
|
|
<Box shrink="No" direction="Column">
|
|
<Button
|
|
style={{ minWidth: toRem(88) }}
|
|
variant="Critical"
|
|
fill="Solid"
|
|
onClick={hangup}
|
|
before={
|
|
exiting ? (
|
|
<Spinner variant="Critical" fill="Solid" size="200" />
|
|
) : (
|
|
<Icon src={Icons.PhoneDown} size="200" filled />
|
|
)
|
|
}
|
|
disabled={exiting}
|
|
>
|
|
<Text size="B400">End</Text>
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
</SequenceCard>
|
|
</Box>
|
|
);
|
|
}
|