vojo/src/app/features/call-status/CallControl.tsx

217 lines
6.7 KiB
TypeScript

import { Box, Icon, IconButton, IconSrc, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
import React, { useCallback } from 'react';
import { useSetAtom, useStore } from 'jotai';
import { useTranslation } from 'react-i18next';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed';
import {
CallHeadphoneIcon,
CallHeadphoneMuteIcon,
CallMicIcon,
CallMicMuteIcon,
CallPhoneDownIcon,
CallScreenShareIcon,
CallScreenShareMuteIcon,
CallVideoIcon,
CallVideoMuteIcon,
} from './callIcons';
// Fail-open window for widget hangup ack. If the widget is healthy, its
// im.vector.hangup / io.element.close action lands within a second and
// clearIfCurrent (CallEmbedProvider) drops the atom. If LiveKit is dead and
// the widget never replies, we fall through and force-clear locally — same
// pattern as element-web's performDisconnection TIMEOUT_MS (16s there, 8s
// here to match the mobile-ish feel of our DM flow and PEER_LEAVE_GRACE_MS).
const HANGUP_TIMEOUT_MS = 8000;
// All controls share the same shape so the active-call pill reads as a
// single visual family with the IncomingCallStrip Decline/Answer buttons:
// pill-radius, soft fill, size 500 (~52-56px). Variant changes with state
// (Surface = on, Warning = muted/off, Critical = hangup, Success =
// camera/screenshare on).
const BUTTON_SIZE = '500' as const;
const BUTTON_RADII = 'Pill' as const;
const BUTTON_FILL = 'Soft' as const;
const ICON_SIZE = '300' as const;
type ToggleButtonProps = {
enabled: boolean;
onToggle: () => void | Promise<unknown>;
disabled?: boolean;
iconOn: IconSrc;
iconOff: IconSrc;
labelOn: string;
labelOff: string;
variantOn?: 'Surface' | 'Success';
variantOff?: 'Warning' | 'Surface';
};
function ToggleButton({
enabled,
onToggle,
disabled,
iconOn,
iconOff,
labelOn,
labelOff,
variantOn = 'Surface',
variantOff = 'Warning',
}: ToggleButtonProps) {
return (
<TooltipProvider
position="Top"
tooltip={
<Tooltip>
<Text size="T200">{enabled ? labelOn : labelOff}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? variantOn : variantOff}
fill={BUTTON_FILL}
radii={BUTTON_RADII}
size={BUTTON_SIZE}
onClick={() => onToggle()}
disabled={disabled}
aria-label={enabled ? labelOn : labelOff}
>
<Icon size={ICON_SIZE} src={enabled ? iconOn : iconOff} />
</IconButton>
)}
</TooltipProvider>
);
}
type CallControlProps = {
callEmbed: CallEmbed;
compact: boolean;
callJoined: boolean;
};
export function CallControl({ callEmbed, compact, callJoined }: CallControlProps) {
const { t } = useTranslation();
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
const setCallEmbed = useSetAtom(callEmbedAtom);
const store = useStore();
const [hangupState, hangup] = useAsyncCallback(
useCallback(async () => {
// Transport.send itself can reject (widget dead / unsupported action).
// Swallow and still poll — even a dead widget may later re-emit via the
// Close path, and the force-clear fallback guarantees forward progress.
try {
await callEmbed.hangup();
} catch {
/* noop — fall through to poll + force-clear */
}
const start = Date.now();
while (Date.now() - start < HANGUP_TIMEOUT_MS) {
if (store.get(callEmbedAtom) !== callEmbed) return;
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => {
setTimeout(r, 200);
});
}
if (store.get(callEmbedAtom) === callEmbed) {
setCallEmbed(undefined);
}
}, [callEmbed, store, setCallEmbed])
);
const exiting =
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
const handleHangup = () => {
if (!callJoined) {
setCallEmbed(undefined);
return;
}
hangup();
};
// Visibility mirrors the legacy strip: Mic + Sound always render,
// Video joins when the call isn't voice-only (still shown on mobile —
// users want to be able to flip the camera on a phone), Screenshare
// is desktop-only because mobile WebRTC capture is meaningfully
// different and Element Call gates it natively.
const showVideo = !callEmbed.voiceOnly;
const showScreenshare = !compact && !callEmbed.voiceOnly;
return (
<Box shrink="No" alignItems="Center" gap="200">
<ToggleButton
enabled={microphone}
onToggle={() => callEmbed.control.toggleMicrophone()}
disabled={!callJoined}
iconOn={CallMicIcon}
iconOff={CallMicMuteIcon}
labelOn={t('Call.mic_off')}
labelOff={t('Call.mic_on')}
/>
<ToggleButton
enabled={sound}
onToggle={() => callEmbed.control.toggleSound()}
disabled={!callJoined}
iconOn={CallHeadphoneIcon}
iconOff={CallHeadphoneMuteIcon}
labelOn={t('Call.sound_off')}
labelOff={t('Call.sound_on')}
/>
{showVideo && (
<ToggleButton
enabled={video}
onToggle={() => callEmbed.control.toggleVideo()}
disabled={!callJoined}
iconOn={CallVideoIcon}
iconOff={CallVideoMuteIcon}
labelOn={t('Call.camera_off')}
labelOff={t('Call.camera_on')}
variantOn="Success"
variantOff="Surface"
/>
)}
{showScreenshare && (
<ToggleButton
enabled={screenshare}
onToggle={() => callEmbed.control.toggleScreenshare()}
disabled={!callJoined}
iconOn={CallScreenShareIcon}
iconOff={CallScreenShareMuteIcon}
labelOn={t('Call.screenshare_off')}
labelOff={t('Call.screenshare_on')}
variantOn="Success"
variantOff="Surface"
/>
)}
<TooltipProvider
position="Top"
tooltip={
<Tooltip>
<Text size="T200">{t('Call.end_call')}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant="Critical"
fill={BUTTON_FILL}
radii={BUTTON_RADII}
size={BUTTON_SIZE}
onClick={handleHangup}
disabled={exiting}
aria-label={t('Call.end_call')}
>
{exiting ? (
<Spinner variant="Critical" fill="Soft" size="200" />
) : (
<Icon size={ICON_SIZE} src={CallPhoneDownIcon} />
)}
</IconButton>
)}
</TooltipProvider>
</Box>
);
}