217 lines
6.7 KiB
TypeScript
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>
|
|
);
|
|
}
|