signal-desktop/ts/components/CallingLobby.tsx
2024-06-10 14:12:45 -07:00

434 lines
13 KiB
TypeScript

// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import type {
SetLocalAudioType,
SetLocalPreviewType,
SetLocalVideoType,
} from '../state/ducks/calling';
import { CallingButton, CallingButtonType } from './CallingButton';
import { TooltipPlacement } from './Tooltip';
import { CallBackgroundBlur } from './CallBackgroundBlur';
import { CallParticipantCount } from './CallParticipantCount';
import { CallingHeader } from './CallingHeader';
import { CallingPreCallInfo, RingMode } from './CallingPreCallInfo';
import {
CallingLobbyJoinButton,
CallingLobbyJoinButtonVariant,
} from './CallingLobbyJoinButton';
import { CallMode } from '../types/Calling';
import type { CallingConversationType } from '../types/Calling';
import type { LocalizerType } from '../types/Util';
import { useIsOnline } from '../hooks/useIsOnline';
import * as KeyboardLayout from '../services/keyboardLayout';
import type { ConversationType } from '../state/ducks/conversations';
import { useCallingToasts } from './CallingToast';
import { CallingButtonToastsContainer } from './CallingToastManager';
import { isGroupOrAdhocCallMode } from '../util/isGroupOrAdhocCall';
import { Button, ButtonVariant } from './Button';
import { SpinnerV2 } from './SpinnerV2';
export type PropsType = {
availableCameras: Array<MediaDeviceInfo>;
callMode: CallMode;
conversation: Pick<
CallingConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'isMe'
| 'memberships'
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'systemGivenName'
| 'systemNickname'
| 'title'
| 'type'
| 'unblurredAvatarPath'
>;
getIsSharingPhoneNumberWithEverybody: () => boolean;
groupMembers?: Array<
Pick<
ConversationType,
'id' | 'firstName' | 'systemGivenName' | 'systemNickname' | 'title'
>
>;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
i18n: LocalizerType;
isAdhocAdminApprovalRequired: boolean;
isAdhocJoinRequestPending: boolean;
isConversationTooBigToRing: boolean;
isCallFull?: boolean;
me: Readonly<
Pick<ConversationType, 'avatarPath' | 'color' | 'id' | 'serviceId'>
>;
onCallCanceled: () => void;
onJoinCall: () => void;
outgoingRing: boolean;
peekedParticipants: Array<ConversationType>;
setLocalAudio: (_: SetLocalAudioType) => void;
setLocalVideo: (_: SetLocalVideoType) => void;
setLocalPreview: (_: SetLocalPreviewType) => void;
setOutgoingRing: (_: boolean) => void;
showParticipantsList: boolean;
toggleParticipants: () => void;
togglePip: () => void;
toggleSettings: () => void;
};
export function CallingLobby({
availableCameras,
callMode,
conversation,
groupMembers,
hasLocalAudio,
hasLocalVideo,
i18n,
isAdhocAdminApprovalRequired,
isAdhocJoinRequestPending,
isCallFull = false,
isConversationTooBigToRing,
getIsSharingPhoneNumberWithEverybody,
me,
onCallCanceled,
onJoinCall,
peekedParticipants,
setLocalAudio,
setLocalPreview,
setLocalVideo,
setOutgoingRing,
toggleParticipants,
togglePip,
toggleSettings,
outgoingRing,
}: PropsType): JSX.Element {
const localVideoRef = React.useRef<null | HTMLVideoElement>(null);
const shouldShowLocalVideo = hasLocalVideo && availableCameras.length > 0;
const isGroupOrAdhocCall = isGroupOrAdhocCallMode(callMode);
const toggleAudio = React.useCallback((): void => {
setLocalAudio({ enabled: !hasLocalAudio });
}, [hasLocalAudio, setLocalAudio]);
const toggleVideo = React.useCallback((): void => {
setLocalVideo({ enabled: !hasLocalVideo });
}, [hasLocalVideo, setLocalVideo]);
const toggleOutgoingRing = React.useCallback((): void => {
setOutgoingRing(!outgoingRing);
}, [outgoingRing, setOutgoingRing]);
const togglePipForCallingHeader = isAdhocJoinRequestPending
? togglePip
: undefined;
React.useEffect(() => {
setLocalPreview({ element: localVideoRef });
return () => {
setLocalPreview({ element: undefined });
};
}, [setLocalPreview]);
React.useEffect(() => {
function handleKeyDown(event: KeyboardEvent): void {
let eventHandled = false;
const key = KeyboardLayout.lookup(event);
if (event.shiftKey && (key === 'V' || key === 'v')) {
toggleVideo();
eventHandled = true;
} else if (event.shiftKey && (key === 'M' || key === 'm')) {
toggleAudio();
eventHandled = true;
}
if (eventHandled) {
event.preventDefault();
event.stopPropagation();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [toggleVideo, toggleAudio]);
const isOnline = useIsOnline();
const [isCallConnecting, setIsCallConnecting] = React.useState(
isAdhocJoinRequestPending || false
);
// eslint-disable-next-line no-nested-ternary
const videoButtonType = hasLocalVideo
? CallingButtonType.VIDEO_ON
: availableCameras.length === 0
? CallingButtonType.VIDEO_DISABLED
: CallingButtonType.VIDEO_OFF;
const audioButtonType = hasLocalAudio
? CallingButtonType.AUDIO_ON
: CallingButtonType.AUDIO_OFF;
const isRingButtonVisible: boolean =
isGroupOrAdhocCall &&
peekedParticipants.length === 0 &&
(groupMembers || []).length > 1;
let preCallInfoRingMode: RingMode;
if (isGroupOrAdhocCall) {
preCallInfoRingMode =
outgoingRing && !isConversationTooBigToRing
? RingMode.WillRing
: RingMode.WillNotRing;
} else {
preCallInfoRingMode = RingMode.WillRing;
}
let ringButtonType:
| CallingButtonType.RING_DISABLED
| CallingButtonType.RING_ON
| CallingButtonType.RING_OFF;
if (isRingButtonVisible) {
if (isConversationTooBigToRing) {
ringButtonType = CallingButtonType.RING_DISABLED;
} else if (outgoingRing) {
ringButtonType = CallingButtonType.RING_ON;
} else {
ringButtonType = CallingButtonType.RING_OFF;
}
} else {
ringButtonType = CallingButtonType.RING_DISABLED;
}
const canJoin = !isCallFull && !isCallConnecting && isOnline;
const canLeave =
(isAdhocAdminApprovalRequired && isCallConnecting) ||
isAdhocJoinRequestPending;
let callingLobbyJoinButtonVariant: CallingLobbyJoinButtonVariant;
if (isCallFull) {
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.CallIsFull;
} else if (isCallConnecting) {
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Loading;
} else if (isAdhocAdminApprovalRequired) {
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.AskToJoin;
} else if (peekedParticipants.length || callMode === CallMode.Adhoc) {
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Join;
} else {
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Start;
}
const callStatus = React.useMemo(() => {
if (isGroupOrAdhocCall) {
return (
<CallParticipantCount
callMode={callMode}
i18n={i18n}
isAdhocJoinRequestPending={isAdhocJoinRequestPending}
groupMemberCount={groupMembers?.length ?? 0}
participantCount={peekedParticipants.length}
toggleParticipants={toggleParticipants}
/>
);
}
if (hasLocalVideo) {
return i18n('icu:ContactListItem__menu__video-call');
}
if (hasLocalAudio) {
return i18n('icu:CallControls__InfoDisplay--audio-call');
}
return null;
}, [
callMode,
isAdhocJoinRequestPending,
isGroupOrAdhocCall,
peekedParticipants.length,
i18n,
hasLocalVideo,
hasLocalAudio,
groupMembers?.length,
toggleParticipants,
]);
useWasInitiallyMutedToast(hasLocalAudio, i18n);
return (
<FocusTrap
focusTrapOptions={{
allowOutsideClick: ({ target }) => {
if (!target || !(target instanceof HTMLElement)) {
return false;
}
return target.matches('.Toast, .Toast *');
},
}}
>
<div className="module-calling__container dark-theme">
{shouldShowLocalVideo ? (
<video
className="module-CallingLobby__local-preview module-CallingLobby__local-preview--camera-is-on"
ref={localVideoRef}
autoPlay
/>
) : (
<CallBackgroundBlur
className="module-CallingLobby__local-preview module-CallingLobby__local-preview--camera-is-off"
avatarPath={me.avatarPath}
/>
)}
<CallingHeader
i18n={i18n}
isGroupCall={isGroupOrAdhocCall}
participantCount={peekedParticipants.length}
togglePip={togglePipForCallingHeader}
toggleSettings={toggleSettings}
onCancel={onCallCanceled}
/>
<div className="module-calling__spacer module-CallingPreCallInfo-spacer" />
<CallingPreCallInfo
conversation={conversation}
groupMembers={groupMembers}
i18n={i18n}
isCallFull={isCallFull}
me={me}
peekedParticipants={peekedParticipants}
ringMode={preCallInfoRingMode}
/>
<div
className={classNames(
'module-calling__camera-is-off module-CallingLobby__camera-is-off',
`module-CallingLobby__camera-is-off--${
shouldShowLocalVideo ? 'invisible' : 'visible'
}`
)}
>
{i18n('icu:calling__your-video-is-off')}
</div>
{/* eslint-disable-next-line no-nested-ternary */}
{callMode === CallMode.Adhoc ? (
isAdhocJoinRequestPending ? (
<div className="CallingLobby__CallLinkNotice CallingLobby__CallLinkNotice--join-request-pending">
<SpinnerV2
className="CallingLobby__CallLinkJoinRequestPendingSpinner"
size={16}
strokeWidth={3}
/>
{i18n('icu:CallingLobby__CallLinkNotice--join-request-pending')}
</div>
) : (
<div className="CallingLobby__CallLinkNotice">
{getIsSharingPhoneNumberWithEverybody()
? i18n('icu:CallingLobby__CallLinkNotice--phone-sharing')
: i18n('icu:CallingLobby__CallLinkNotice')}
</div>
)
) : null}
<CallingButtonToastsContainer
hasLocalAudio={hasLocalAudio}
outgoingRing={outgoingRing}
i18n={i18n}
/>
<div className="CallingLobby__Footer">
<div className="module-calling__spacer CallControls__OuterSpacer" />
<div className="CallControls">
<div className="CallControls__InfoDisplay">
<div className="CallControls__CallTitle">
{conversation.title}
</div>
<div className="CallControls__Status">{callStatus}</div>
</div>
<div className="CallControls__ButtonContainer">
<CallingButton
buttonType={videoButtonType}
i18n={i18n}
onClick={toggleVideo}
tooltipDirection={TooltipPlacement.Top}
/>
<CallingButton
buttonType={audioButtonType}
i18n={i18n}
onClick={toggleAudio}
tooltipDirection={TooltipPlacement.Top}
/>
<CallingButton
buttonType={ringButtonType}
i18n={i18n}
isVisible={isRingButtonVisible}
onClick={toggleOutgoingRing}
tooltipDirection={TooltipPlacement.Top}
/>
</div>
<div className="CallControls__JoinLeaveButtonContainer">
{canLeave ? (
<Button
className="CallControls__JoinLeaveButton CallControls__JoinLeaveButton--hangup"
onClick={onCallCanceled}
variant={ButtonVariant.Destructive}
>
{i18n('icu:CallControls__JoinLeaveButton--hangup-group')}
</Button>
) : (
<CallingLobbyJoinButton
disabled={!canJoin}
i18n={i18n}
onClick={() => {
setIsCallConnecting(true);
onJoinCall();
}}
variant={callingLobbyJoinButtonVariant}
/>
)}
</div>
</div>
<div className="module-calling__spacer CallControls__OuterSpacer" />
</div>
</div>
</FocusTrap>
);
}
function useWasInitiallyMutedToast(
hasLocalAudio: boolean,
i18n: LocalizerType
) {
const [wasInitiallyMuted] = React.useState(!hasLocalAudio);
const { showToast, hideToast } = useCallingToasts();
const INITIALLY_MUTED_KEY = 'initially-muted-group-size';
React.useEffect(() => {
if (wasInitiallyMuted) {
showToast({
key: INITIALLY_MUTED_KEY,
content: i18n(
'icu:calling__lobby-automatically-muted-because-there-are-a-lot-of-people'
),
autoClose: true,
dismissable: true,
onlyShowOnce: true,
});
}
}, [wasInitiallyMuted, i18n, showToast]);
// Hide this toast if the user unmutes
React.useEffect(() => {
if (wasInitiallyMuted && hasLocalAudio) {
hideToast(INITIALLY_MUTED_KEY);
}
}, [hideToast, wasInitiallyMuted, hasLocalAudio]);
}