CI / Build & Quality Checks (pull_request) Successful in 4m18s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m27s
With a fork that reports `screensharePrompt` (element-call lotus-screenshare- prompt), the call bar and status bar keep their screenshare button on every engine. Where the click can't be delegated, starting asks the fork to show "Share your screen?" inside the call frame (io.lotus.prompt_screenshare) instead of our own confirm; its Share click starts the share. Stopping works from the bar as before (no click needed in the frame). Chromium is unchanged. - useScreenshareMode: hidden (older fork's corner button) | prompt | direct. - The room's call policy is also pushed to the fork in prompt mode, so the prompt never opens where sharing is forbidden. - Picture-in-picture: the "Return to call" overlay covers the frame; while the fork reports the prompt open it lets clicks through to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
348 lines
13 KiB
TypeScript
348 lines
13 KiB
TypeScript
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,
|
||
useCallMicLevel,
|
||
useScreenshareMode,
|
||
} 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<HTMLDivElement>(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 micLevel = useCallMicLevel(callEmbed);
|
||
|
||
const [cords, setCords] = useState<RectCords>();
|
||
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;
|
||
// [Gitea #43] Where EC shows its own screenshare button in the frame, ours
|
||
// is hidden (the screenshare-audio mute stays); where the click can't be
|
||
// delegated, starting asks for the fork's in-frame "Share your screen?".
|
||
const screenshareMode = useScreenshareMode(callEmbed.control);
|
||
const handleScreenshareToggle = () => {
|
||
if (screenshare) callEmbed.control.toggleScreenshare();
|
||
else if (screenshareMode === 'prompt') callEmbed.control.promptScreenshare();
|
||
else setShareConfirm(true);
|
||
};
|
||
const showScreenshare = allowScreenshare || screenshare;
|
||
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
||
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 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 (
|
||
<Box
|
||
ref={controlRef}
|
||
className={css.CallControlContainer}
|
||
justifyContent="Center"
|
||
alignItems="Center"
|
||
>
|
||
{pttMode && (
|
||
<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>
|
||
)}
|
||
<ScreenshareConfirm
|
||
open={shareConfirm}
|
||
align="Center"
|
||
onConfirm={() => {
|
||
callEmbed.control.toggleScreenshare();
|
||
setShareConfirm(false);
|
||
}}
|
||
onCancel={() => setShareConfirm(false)}
|
||
/>
|
||
<SequenceCard
|
||
className={css.ControlCard}
|
||
variant="SurfaceVariant"
|
||
gap="400"
|
||
radii="500"
|
||
alignItems="Center"
|
||
justifyContent="SpaceBetween"
|
||
wrap="Wrap"
|
||
>
|
||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||
<MicrophoneButton
|
||
enabled={microphone}
|
||
onToggle={handleMicrophoneToggle}
|
||
level={micLevel}
|
||
/>
|
||
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||
</Box>
|
||
{!compact && showVideoGroup && <ControlDivider />}
|
||
{showVideoGroup && (
|
||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||
{/* 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 && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||
{showScreenshare && (
|
||
<>
|
||
{screenshareMode !== 'hidden' && (
|
||
<ScreenShareButton
|
||
enabled={screenshare}
|
||
onToggle={() => handleScreenshareToggle()}
|
||
/>
|
||
)}
|
||
{/* Mute-screenshare-audio sits directly next to the screenshare
|
||
control since they're the same concern. */}
|
||
<ScreenshareAudioButton
|
||
muted={screenshareAudioMuted}
|
||
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
||
/>
|
||
</>
|
||
)}
|
||
{!!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 />
|
||
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
|
||
<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>
|
||
);
|
||
}
|