signal-desktop/ts/components/CallScreen.tsx
2024-01-24 09:33:20 -08:00

1213 lines
37 KiB
TypeScript

// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { isEqual, noop, sortBy } from 'lodash';
import classNames from 'classnames';
import type { VideoFrameSource } from '@signalapp/ringrtc';
import type {
ActiveCallStateType,
SendGroupCallRaiseHandType,
SendGroupCallReactionType,
SetLocalAudioType,
SetLocalPreviewType,
SetLocalVideoType,
SetRendererCanvasType,
} from '../state/ducks/calling';
import { Avatar, AvatarSize } from './Avatar';
import { CallingHeader, getCallViewIconClassname } from './CallingHeader';
import { CallingPreCallInfo, RingMode } from './CallingPreCallInfo';
import { CallingButton, CallingButtonType } from './CallingButton';
import { Button, ButtonVariant } from './Button';
import { TooltipPlacement } from './Tooltip';
import { CallBackgroundBlur } from './CallBackgroundBlur';
import type {
ActiveCallType,
ActiveCallReactionsType,
ConversationsByDemuxIdType,
GroupCallVideoRequest,
PresentedSource,
} from '../types/Calling';
import {
CALLING_REACTIONS_LIFETIME,
CallMode,
CallViewMode,
CallState,
GroupCallConnectionState,
GroupCallJoinState,
} from '../types/Calling';
import type { ServiceIdString } from '../types/ServiceId';
import { AvatarColors } from '../types/Colors';
import type { ConversationType } from '../state/ducks/conversations';
import {
CallingButtonToastsContainer,
useScreenSharingStoppedToast,
} from './CallingToastManager';
import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant';
import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants';
import { CallParticipantCount } from './CallParticipantCount';
import type { LocalizerType } from '../types/Util';
import { NeedsScreenRecordingPermissionsModal } from './NeedsScreenRecordingPermissionsModal';
import { missingCaseError } from '../util/missingCaseError';
import * as KeyboardLayout from '../services/keyboardLayout';
import {
usePresenter,
useActivateSpeakerViewOnPresenting,
} from '../hooks/useActivateSpeakerViewOnPresenting';
import {
CallingAudioIndicator,
SPEAKING_LINGER_MS,
} from './CallingAudioIndicator';
import {
useActiveCallShortcuts,
useKeyboardShortcuts,
} from '../hooks/useKeyboardShortcuts';
import { useValueAtFixedRate } from '../hooks/useValueAtFixedRate';
import { isReconnecting as callingIsReconnecting } from '../util/callingIsReconnecting';
import { usePrevious } from '../hooks/usePrevious';
import {
CallingToastProvider,
PersistentCallingToast,
useCallingToasts,
} from './CallingToast';
import { handleOutsideClick } from '../util/handleOutsideClick';
import { Spinner } from './Spinner';
import type { Props as ReactionPickerProps } from './conversation/ReactionPicker';
import type { SmartReactionPicker } from '../state/smart/ReactionPicker';
import { Emoji } from './emoji/Emoji';
import {
CallingRaisedHandsList,
CallingRaisedHandsListButton,
} from './CallingRaisedHandsList';
import type { CallReactionBurstType } from './CallReactionBurst';
import {
CallReactionBurstProvider,
useCallReactionBursts,
} from './CallReactionBurst';
export type PropsType = {
activeCall: ActiveCallType;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
getPresentingSources: () => void;
groupMembers?: Array<Pick<ConversationType, 'id' | 'firstName' | 'title'>>;
hangUpActiveCall: (reason: string) => void;
i18n: LocalizerType;
isGroupCallRaiseHandEnabled: boolean;
isGroupCallReactionsEnabled: boolean;
me: ConversationType;
openSystemPreferencesAction: () => unknown;
renderReactionPicker: (
props: React.ComponentProps<typeof SmartReactionPicker>
) => JSX.Element;
sendGroupCallRaiseHand: (payload: SendGroupCallRaiseHandType) => void;
sendGroupCallReaction: (payload: SendGroupCallReactionType) => void;
setGroupCallVideoRequest: (
_: Array<GroupCallVideoRequest>,
speakerHeight: number
) => void;
setLocalAudio: (_: SetLocalAudioType) => void;
setLocalVideo: (_: SetLocalVideoType) => void;
setLocalPreview: (_: SetLocalPreviewType) => void;
setPresenting: (_?: PresentedSource) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void;
stickyControls: boolean;
switchToPresentationView: () => void;
switchFromPresentationView: () => void;
toggleParticipants: () => void;
togglePip: () => void;
toggleScreenRecordingPermissionsDialog: () => unknown;
toggleSettings: () => void;
changeCallView: (mode: CallViewMode) => void;
} & Pick<ReactionPickerProps, 'renderEmojiPicker'>;
export const isInSpeakerView = (
call: Pick<ActiveCallStateType, 'viewMode'> | undefined
): boolean => {
return Boolean(
call?.viewMode === CallViewMode.Presentation ||
call?.viewMode === CallViewMode.Speaker
);
};
const REACTIONS_TOASTS_TRANSITION_FROM = {
opacity: 0,
};
// How many reactions of the same emoji must occur before a burst.
const REACTIONS_BURST_THRESHOLD = 3;
// Timeframe in which multiple of the same emoji must occur before a burst.
const REACTIONS_BURST_WINDOW = 4000;
// Timeframe after a burst where new reactions of the same emoji are ignored for
// bursting. They are considered part of the recent burst.
const REACTIONS_BURST_TRAILING_WINDOW = 2000;
// Max number of bursts in a short timeframe to avoid overwhelming the user.
const REACTIONS_BURST_MAX_IN_SHORT_WINDOW = 3;
const REACTIONS_BURST_SHORT_WINDOW = 4000;
function CallDuration({
joinedAt,
}: {
joinedAt: number | null;
}): JSX.Element | null {
const [acceptedDuration, setAcceptedDuration] = useState<
number | undefined
>();
useEffect(() => {
if (joinedAt == null) {
return noop;
}
// It's really jumpy with a value of 500ms.
const interval = setInterval(() => {
setAcceptedDuration(Date.now() - joinedAt);
}, 100);
return clearInterval.bind(null, interval);
}, [joinedAt]);
if (acceptedDuration) {
return <>{renderDuration(acceptedDuration)}</>;
}
return null;
}
export function CallScreen({
activeCall,
changeCallView,
getGroupCallVideoFrameSource,
getPresentingSources,
groupMembers,
hangUpActiveCall,
i18n,
isGroupCallRaiseHandEnabled,
isGroupCallReactionsEnabled,
me,
openSystemPreferencesAction,
renderEmojiPicker,
renderReactionPicker,
setGroupCallVideoRequest,
sendGroupCallRaiseHand,
sendGroupCallReaction,
setLocalAudio,
setLocalVideo,
setLocalPreview,
setPresenting,
setRendererCanvas,
stickyControls,
switchToPresentationView,
switchFromPresentationView,
toggleParticipants,
togglePip,
toggleScreenRecordingPermissionsDialog,
toggleSettings,
}: PropsType): JSX.Element {
const {
conversation,
hasLocalAudio,
hasLocalVideo,
localAudioLevel,
presentingSource,
remoteParticipants,
showNeedsScreenRecordingPermissionsWarning,
reactions,
} = activeCall;
const isSpeaking = useValueAtFixedRate(
localAudioLevel > 0,
SPEAKING_LINGER_MS
);
useActivateSpeakerViewOnPresenting({
remoteParticipants,
switchToPresentationView,
switchFromPresentationView,
});
const activeCallShortcuts = useActiveCallShortcuts(hangUpActiveCall);
useKeyboardShortcuts(activeCallShortcuts);
const toggleAudio = useCallback(() => {
setLocalAudio({
enabled: !hasLocalAudio,
});
}, [setLocalAudio, hasLocalAudio]);
const toggleVideo = useCallback(() => {
setLocalVideo({
enabled: !hasLocalVideo,
});
}, [setLocalVideo, hasLocalVideo]);
const togglePresenting = useCallback(() => {
if (presentingSource) {
setPresenting();
} else {
getPresentingSources();
}
}, [getPresentingSources, presentingSource, setPresenting]);
const hangUp = useCallback(() => {
hangUpActiveCall('button click');
}, [hangUpActiveCall]);
const reactButtonRef = React.useRef<null | HTMLDivElement>(null);
const reactionPickerRef = React.useRef<null | HTMLDivElement>(null);
const reactionPickerContainerRef = React.useRef<null | HTMLDivElement>(null);
const [showReactionPicker, setShowReactionPicker] = useState(false);
const toggleReactionPicker = useCallback(() => {
setShowReactionPicker(prevValue => !prevValue);
}, []);
const [showRaisedHandsList, setShowRaisedHandsList] = useState(false);
const toggleRaisedHandsList = useCallback(() => {
setShowRaisedHandsList(prevValue => !prevValue);
}, []);
const [controlsHover, setControlsHover] = useState(false);
const onControlsMouseEnter = useCallback(() => {
setControlsHover(true);
}, [setControlsHover]);
const onControlsMouseLeave = useCallback(() => {
setControlsHover(false);
}, [setControlsHover]);
const [showControls, setShowControls] = useState(true);
const localVideoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
setLocalPreview({ element: localVideoRef });
return () => {
setLocalPreview({ element: undefined });
};
}, [setLocalPreview, setRendererCanvas]);
useEffect(() => {
if (
!showControls ||
showReactionPicker ||
stickyControls ||
controlsHover
) {
return noop;
}
const timer = setTimeout(() => {
setShowControls(false);
}, 5000);
return clearTimeout.bind(null, timer);
}, [showControls, showReactionPicker, stickyControls, controlsHover]);
useEffect(() => {
const 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();
setShowControls(true);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [toggleAudio, toggleVideo]);
useEffect(() => {
if (!showReactionPicker) {
return noop;
}
return handleOutsideClick(
() => {
setShowReactionPicker(false);
return true;
},
{
containerElements: [reactButtonRef, reactionPickerContainerRef],
name: 'CallScreen.reactionPicker',
}
);
}, [showReactionPicker]);
useScreenSharingStoppedToast({ activeCall, i18n });
useViewModeChangedToast({ activeCall, i18n });
const currentPresenter = remoteParticipants.find(
participant => participant.presenting
);
const hasRemoteVideo = remoteParticipants.some(
remoteParticipant => remoteParticipant.hasRemoteVideo
);
const isSendingVideo = hasLocalVideo || presentingSource;
const isReconnecting: boolean = callingIsReconnecting(activeCall);
let isRinging: boolean;
let hasCallStarted: boolean;
let isConnected: boolean;
let participantCount: number;
let conversationsByDemuxId: ConversationsByDemuxIdType;
let localDemuxId: number | undefined;
switch (activeCall.callMode) {
case CallMode.Direct: {
isRinging =
activeCall.callState === CallState.Prering ||
activeCall.callState === CallState.Ringing;
hasCallStarted = !isRinging;
isConnected = activeCall.callState === CallState.Accepted;
participantCount = isConnected ? 2 : 0;
conversationsByDemuxId = new Map();
break;
}
case CallMode.Group:
isRinging =
activeCall.outgoingRing &&
!activeCall.remoteParticipants.length &&
!(groupMembers?.length === 1 && groupMembers[0].id === me.id);
hasCallStarted = activeCall.joinState !== GroupCallJoinState.NotJoined;
participantCount = activeCall.remoteParticipants.length + 1;
conversationsByDemuxId = activeCall.conversationsByDemuxId;
localDemuxId = activeCall.localDemuxId;
isConnected =
activeCall.connectionState === GroupCallConnectionState.Connected;
break;
default:
throw missingCaseError(activeCall);
}
let lonelyInCallNode: ReactNode;
let localPreviewNode: ReactNode;
const isLonelyInCall = !activeCall.remoteParticipants.length;
if (isLonelyInCall) {
lonelyInCallNode = (
<div
className={classNames(
'module-ongoing-call__local-preview-fullsize',
presentingSource &&
'module-ongoing-call__local-preview-fullsize--presenting'
)}
>
{isSendingVideo ? (
<video ref={localVideoRef} autoPlay />
) : (
<CallBackgroundBlur avatarPath={me.avatarPath}>
<div className="module-calling__spacer module-calling__camera-is-off-spacer" />
<div className="module-calling__camera-is-off">
{i18n('icu:calling__your-video-is-off')}
</div>
</CallBackgroundBlur>
)}
</div>
);
} else {
localPreviewNode = isSendingVideo ? (
<video
className={classNames(
'module-ongoing-call__footer__local-preview__video',
presentingSource &&
'module-ongoing-call__footer__local-preview__video--presenting'
)}
ref={localVideoRef}
autoPlay
/>
) : (
<CallBackgroundBlur avatarPath={me.avatarPath}>
<Avatar
acceptedMessageRequest
avatarPath={me.avatarPath}
badge={undefined}
color={me.color || AvatarColors[0]}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
isMe
phoneNumber={me.phoneNumber}
profileName={me.profileName}
title={me.title}
// See comment above about `sharedGroupNames`.
sharedGroupNames={[]}
size={AvatarSize.FORTY}
/>
</CallBackgroundBlur>
);
}
let videoButtonType: CallingButtonType;
if (presentingSource) {
videoButtonType = CallingButtonType.VIDEO_DISABLED;
} else if (hasLocalVideo) {
videoButtonType = CallingButtonType.VIDEO_ON;
} else {
videoButtonType = CallingButtonType.VIDEO_OFF;
}
const audioButtonType = hasLocalAudio
? CallingButtonType.AUDIO_ON
: CallingButtonType.AUDIO_OFF;
const isAudioOnly = !hasLocalVideo && !hasRemoteVideo;
const controlsFadedOut = !showControls && !isAudioOnly && isConnected;
const controlsFadeClass = classNames({
'module-ongoing-call__controls--fadeIn':
(showControls || isAudioOnly) && !isConnected,
'module-ongoing-call__controls--fadeOut': controlsFadedOut,
});
const isGroupCall = activeCall.callMode === CallMode.Group;
let presentingButtonType: CallingButtonType;
if (presentingSource) {
presentingButtonType = CallingButtonType.PRESENTING_ON;
} else if (currentPresenter) {
presentingButtonType = CallingButtonType.PRESENTING_DISABLED;
} else {
presentingButtonType = CallingButtonType.PRESENTING_OFF;
}
const raisedHands =
activeCall.callMode === CallMode.Group ? activeCall.raisedHands : undefined;
// This is the value of our hand raised as seen by remote clients. We should prefer
// to use it in UI so the user understands what remote clients see.
const syncedLocalHandRaised = isHandRaised(raisedHands, localDemuxId);
// Don't call setLocalHandRaised because it only sets local state. Instead call
// toggleRaiseHand() which will set ringrtc state and call setLocalHandRaised.
const [localHandRaised, setLocalHandRaised] = useState<boolean>(
syncedLocalHandRaised
);
const previousLocalHandRaised = usePrevious(localHandRaised, localHandRaised);
const toggleRaiseHand = useCallback(
(raise?: boolean) => {
const nextValue = raise ?? !localHandRaised;
if (nextValue === previousLocalHandRaised) {
return;
}
setLocalHandRaised(nextValue);
// It's possible that the ringrtc call can fail due to flaky network connection.
// In that case, local and remote state (localHandRaised and raisedHands) can
// get out of sync. The user might need to manually toggle raise hand to get to
// a coherent state. It would be nice if this returned a Promise (but it doesn't)
sendGroupCallRaiseHand({
conversationId: conversation.id,
raise: nextValue,
});
},
[
localHandRaised,
previousLocalHandRaised,
conversation.id,
sendGroupCallRaiseHand,
]
);
let raiseHandButtonType: CallingButtonType | undefined;
let reactButtonType: CallingButtonType | undefined;
if (isGroupCall) {
raiseHandButtonType = localHandRaised
? CallingButtonType.RAISE_HAND_ON
: CallingButtonType.RAISE_HAND_OFF;
reactButtonType = showReactionPicker
? CallingButtonType.REACT_ON
: CallingButtonType.REACT_OFF;
}
const renderRaisedHandsToast = React.useCallback(
(hands: Array<number>) => {
// Sort "You" to the front.
const names = sortBy(hands, demuxId =>
demuxId === localDemuxId ? 0 : 1
).map(demuxId =>
demuxId === localDemuxId
? i18n('icu:you')
: conversationsByDemuxId.get(demuxId)?.title
);
let message: string;
let buttonOverride: JSX.Element | undefined;
const count = names.length;
switch (count) {
case 0:
return undefined;
case 1:
if (names[0] === i18n('icu:you')) {
message = i18n('icu:CallControls__RaiseHandsToast--you');
buttonOverride = (
<button
className="CallingRaisedHandsToasts__Link"
onClick={() => toggleRaiseHand(false)}
type="button"
>
{i18n('icu:CallControls__RaiseHands--lower')}
</button>
);
} else {
message = i18n('icu:CallControls__RaiseHandsToast--one', {
name: names[0],
});
}
break;
case 2:
message = i18n('icu:CallControls__RaiseHandsToast--two', {
name: names[0],
otherName: names[1],
});
break;
default:
message = i18n('icu:CallControls__RaiseHandsToast--more', {
name: names[0],
otherName: names[1],
overflowCount: names.length - 2,
});
}
return (
<div className="CallingRaisedHandsToast__Content">
<span className="CallingRaisedHandsToast__HandIcon" />
{message}
{buttonOverride || (
<button
className="link CallingRaisedHandsToasts__Link"
onClick={() => setShowRaisedHandsList(true)}
type="button"
>
{i18n('icu:CallControls__RaiseHands--open-queue')}
</button>
)}
</div>
);
},
[i18n, localDemuxId, conversationsByDemuxId, toggleRaiseHand]
);
const raisedHandsCount: number = raisedHands?.size ?? 0;
const callStatus: ReactNode | string = React.useMemo(() => {
if (isRinging) {
return i18n('icu:outgoingCallRinging');
}
if (isReconnecting) {
return i18n('icu:callReconnecting');
}
if (isGroupCall) {
return (
<CallParticipantCount
i18n={i18n}
participantCount={participantCount}
toggleParticipants={toggleParticipants}
/>
);
}
// joinedAt is only available for direct calls
if (isConnected) {
return <CallDuration joinedAt={activeCall.joinedAt} />;
}
if (hasLocalVideo) {
return i18n('icu:ContactListItem__menu__video-call');
}
if (hasLocalAudio) {
return i18n('icu:CallControls__InfoDisplay--audio-call');
}
return null;
}, [
i18n,
isRinging,
isConnected,
activeCall.joinedAt,
isReconnecting,
isGroupCall,
participantCount,
hasLocalVideo,
hasLocalAudio,
toggleParticipants,
]);
let remoteParticipantsElement: ReactNode;
switch (activeCall.callMode) {
case CallMode.Direct: {
remoteParticipantsElement = hasCallStarted ? (
<DirectCallRemoteParticipant
conversation={conversation}
hasRemoteVideo={hasRemoteVideo}
i18n={i18n}
isReconnecting={isReconnecting}
setRendererCanvas={setRendererCanvas}
/>
) : (
<div className="module-ongoing-call__direct-call-ringing-spacer" />
);
break;
}
case CallMode.Group:
remoteParticipantsElement = (
<GroupCallRemoteParticipants
callViewMode={activeCall.viewMode}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
i18n={i18n}
remoteParticipants={activeCall.remoteParticipants}
setGroupCallVideoRequest={setGroupCallVideoRequest}
remoteAudioLevels={activeCall.remoteAudioLevels}
isCallReconnecting={isReconnecting}
onClickRaisedHand={
raisedHandsCount > 0
? () => setShowRaisedHandsList(true)
: undefined
}
/>
);
break;
default:
throw missingCaseError(activeCall);
}
return (
<div
className={classNames(
'module-calling__container',
`module-ongoing-call__container--${getCallModeClassSuffix(
activeCall.callMode
)}`,
`module-ongoing-call__container--${
hasCallStarted ? 'call-started' : 'call-not-started'
}`,
{ 'module-ongoing-call__container--hide-controls': !showControls },
{
'module-ongoing-call__container--controls-faded-out':
controlsFadedOut,
},
'dark-theme'
)}
onFocus={() => {
setShowControls(true);
}}
onMouseMove={() => {
setShowControls(true);
}}
role="group"
>
{isReconnecting ? (
<PersistentCallingToast>
<span className="CallingToast__reconnecting">
<Spinner svgSize="small" size="16px" />
{i18n('icu:callReconnecting')}
</span>
</PersistentCallingToast>
) : null}
{isLonelyInCall && !isRinging ? (
<PersistentCallingToast>
{i18n('icu:calling__in-this-call--zero')}
</PersistentCallingToast>
) : null}
{currentPresenter ? (
<PersistentCallingToast>
{i18n('icu:calling__presenting--person-ongoing', {
name: currentPresenter.title,
})}
</PersistentCallingToast>
) : null}
{showNeedsScreenRecordingPermissionsWarning ? (
<NeedsScreenRecordingPermissionsModal
toggleScreenRecordingPermissionsDialog={
toggleScreenRecordingPermissionsDialog
}
i18n={i18n}
openSystemPreferencesAction={openSystemPreferencesAction}
/>
) : null}
<div className={controlsFadeClass}>
<CallingHeader
callViewMode={activeCall.viewMode}
changeCallView={changeCallView}
i18n={i18n}
isGroupCall={isGroupCall}
participantCount={participantCount}
togglePip={togglePip}
toggleSettings={toggleSettings}
/>
</div>
{isRinging && (
<>
<div className="module-CallingPreCallInfo-spacer " />
<CallingPreCallInfo
conversation={conversation}
groupMembers={groupMembers}
i18n={i18n}
me={me}
ringMode={RingMode.IsRinging}
/>
</>
)}
{remoteParticipantsElement}
{lonelyInCallNode}
{raisedHands && (
<>
<CallingRaisedHandsListButton
i18n={i18n}
syncedLocalHandRaised={syncedLocalHandRaised}
raisedHandsCount={raisedHandsCount}
onClick={toggleRaisedHandsList}
/>
{showRaisedHandsList && raisedHandsCount > 0 && (
<CallingRaisedHandsList
i18n={i18n}
onClose={() => setShowRaisedHandsList(false)}
onLowerMyHand={() => {
toggleRaiseHand(false);
setShowRaisedHandsList(false);
}}
localDemuxId={localDemuxId}
conversationsByDemuxId={conversationsByDemuxId}
raisedHands={raisedHands}
localHandRaised={syncedLocalHandRaised}
/>
)}
</>
)}
<CallingReactionsToastsContainer
reactions={reactions}
conversationsByDemuxId={conversationsByDemuxId}
localDemuxId={localDemuxId}
i18n={i18n}
/>
<CallingButtonToastsContainer
hasLocalAudio={hasLocalAudio}
outgoingRing={undefined}
raisedHands={raisedHands}
renderRaisedHandsToast={renderRaisedHandsToast}
i18n={i18n}
/>
{/* We render the local preview first and set the footer flex direction to row-reverse
to ensure the preview is visible at low viewport widths. */}
<div className="module-ongoing-call__footer">
{localPreviewNode ? (
<div className="module-ongoing-call__footer__local-preview module-ongoing-call__footer__local-preview--active">
{localPreviewNode}
{!isSendingVideo && (
<div className="CallingStatusIndicator CallingStatusIndicator--Video" />
)}
<CallingAudioIndicator
hasAudio={hasLocalAudio}
audioLevel={localAudioLevel}
shouldShowSpeaking={isSpeaking}
/>
{syncedLocalHandRaised && (
<div className="CallingStatusIndicator CallingStatusIndicator--HandRaised" />
)}
</div>
) : (
<div className="module-ongoing-call__footer__local-preview" />
)}
<div
className={classNames(
'CallControls',
'module-ongoing-call__footer__actions',
controlsFadeClass
)}
>
<div className="CallControls__InfoDisplay">
<div className="CallControls__CallTitle">{conversation.title}</div>
<div className="CallControls__Status">{callStatus}</div>
</div>
{showReactionPicker && (
<div
className="CallControls__ReactionPickerContainer"
ref={reactionPickerContainerRef}
>
{isGroupCallReactionsEnabled &&
renderReactionPicker({
ref: reactionPickerRef,
onClose: () => setShowReactionPicker(false),
onPick: emoji => {
setShowReactionPicker(false);
sendGroupCallReaction({
conversationId: conversation.id,
value: emoji,
});
},
renderEmojiPicker,
})}
</div>
)}
<div className="CallControls__ButtonContainer">
<CallingButton
buttonType={videoButtonType}
i18n={i18n}
onMouseEnter={onControlsMouseEnter}
onMouseLeave={onControlsMouseLeave}
onClick={toggleVideo}
tooltipDirection={TooltipPlacement.Top}
/>
<CallingButton
buttonType={audioButtonType}
i18n={i18n}
onMouseEnter={onControlsMouseEnter}
onMouseLeave={onControlsMouseLeave}
onClick={toggleAudio}
tooltipDirection={TooltipPlacement.Top}
/>
<CallingButton
buttonType={presentingButtonType}
i18n={i18n}
onMouseEnter={onControlsMouseEnter}
onMouseLeave={onControlsMouseLeave}
onClick={togglePresenting}
tooltipDirection={TooltipPlacement.Top}
/>
{isGroupCallRaiseHandEnabled && raiseHandButtonType && (
<CallingButton
buttonType={raiseHandButtonType}
i18n={i18n}
onMouseEnter={onControlsMouseEnter}
onMouseLeave={onControlsMouseLeave}
onClick={() => toggleRaiseHand()}
tooltipDirection={TooltipPlacement.Top}
/>
)}
{isGroupCallReactionsEnabled && reactButtonType && (
<div
className={classNames('CallControls__ReactButtonContainer', {
'CallControls__ReactButtonContainer--menu-shown':
showReactionPicker,
})}
ref={reactButtonRef}
>
<CallingButton
buttonType={reactButtonType}
i18n={i18n}
onMouseEnter={onControlsMouseEnter}
onMouseLeave={onControlsMouseLeave}
onClick={toggleReactionPicker}
tooltipDirection={TooltipPlacement.Top}
/>
</div>
)}
</div>
<div
className="CallControls__JoinLeaveButtonContainer"
onMouseEnter={onControlsMouseEnter}
onMouseLeave={onControlsMouseLeave}
>
<Button
className="CallControls__JoinLeaveButton CallControls__JoinLeaveButton--hangup"
onClick={hangUp}
variant={ButtonVariant.Destructive}
>
{isGroupCall
? i18n('icu:CallControls__JoinLeaveButton--hangup-group')
: i18n('icu:CallControls__JoinLeaveButton--hangup-1-1')}
</Button>
</div>
</div>
<div className="module-calling__spacer CallControls__OuterSpacer" />
</div>
</div>
);
}
function getCallModeClassSuffix(
callMode: CallMode.Direct | CallMode.Group
): string {
switch (callMode) {
case CallMode.Direct:
return 'direct';
case CallMode.Group:
return 'group';
default:
throw missingCaseError(callMode);
}
}
function renderDuration(ms: number): string {
const secs = Math.floor((ms / 1000) % 60)
.toString()
.padStart(2, '0');
const mins = Math.floor((ms / 60000) % 60)
.toString()
.padStart(2, '0');
const hours = Math.floor(ms / 3600000);
if (hours > 0) {
return `${hours}:${mins}:${secs}`;
}
return `${mins}:${secs}`;
}
function useViewModeChangedToast({
activeCall,
i18n,
}: {
activeCall: ActiveCallType;
i18n: LocalizerType;
}): void {
const { viewMode } = activeCall;
const previousViewMode = usePrevious(viewMode, viewMode);
const presenterAci = usePresenter(activeCall.remoteParticipants);
const VIEW_MODE_CHANGED_TOAST_KEY = 'view-mode-changed';
const { showToast, hideToast } = useCallingToasts();
useEffect(() => {
if (viewMode !== previousViewMode) {
if (
// If this is an automated change to presentation mode, don't show toast
viewMode === CallViewMode.Presentation ||
// if this is an automated change away from presentation mode, don't show toast
(previousViewMode === CallViewMode.Presentation && !presenterAci)
) {
return;
}
hideToast(VIEW_MODE_CHANGED_TOAST_KEY);
showToast({
key: VIEW_MODE_CHANGED_TOAST_KEY,
content: (
<div className="CallingToast__viewChanged">
<span
className={classNames(
'CallingToast__viewChanged__icon',
getCallViewIconClassname(viewMode)
)}
/>
{i18n('icu:calling__view_mode--updated')}
</div>
),
autoClose: true,
});
}
}, [
showToast,
hideToast,
i18n,
activeCall,
viewMode,
previousViewMode,
presenterAci,
]);
}
type CallingReactionsToastsType = {
reactions: ActiveCallReactionsType | undefined;
conversationsByDemuxId: Map<number, ConversationType>;
localDemuxId: number | undefined;
i18n: LocalizerType;
};
type UseReactionsToastType = CallingReactionsToastsType & {
showBurst: (toast: CallReactionBurstType) => string;
};
function useReactionsToast(props: UseReactionsToastType): void {
const { reactions, conversationsByDemuxId, localDemuxId, i18n, showBurst } =
props;
const ourServiceId: ServiceIdString | undefined = localDemuxId
? conversationsByDemuxId.get(localDemuxId)?.serviceId
: undefined;
const [previousReactions, setPreviousReactions] = React.useState<
ActiveCallReactionsType | undefined
>(undefined);
const reactionsShown = useRef<
Map<
string,
{ value: string; isBursted: boolean; expireAt: number; demuxId: number }
>
>(new Map());
const burstsShown = useRef<Map<string, number>>(new Map());
const { showToast } = useCallingToasts();
useEffect(() => {
setPreviousReactions(reactions);
}, [reactions]);
useEffect(() => {
if (!reactions || isEqual(reactions, previousReactions)) {
return;
}
const time = Date.now();
let anyReactionWasShown = false;
reactions.forEach(({ timestamp, demuxId, value }) => {
const conversation = conversationsByDemuxId.get(demuxId);
const key = `reactions-${timestamp}-${demuxId}`;
showToast({
key,
onlyShowOnce: true,
autoClose: true,
content: (
<span className="CallingReactionsToasts__reaction">
<Emoji size={28} emoji={value} />
{demuxId === localDemuxId ||
(ourServiceId && conversation?.serviceId === ourServiceId)
? i18n('icu:CallingReactions--me')
: conversation?.title}
</span>
),
});
// Track shown reactions for burst purposes. Skip if it's already tracked.
if (reactionsShown.current.has(key)) {
return;
}
// If there's a recent burst for this emoji, treat it as part of that burst.
const recentBurstTime = burstsShown.current.get(value);
const isBursted = !!(
recentBurstTime &&
recentBurstTime + REACTIONS_BURST_TRAILING_WINDOW > time
);
reactionsShown.current.set(key, {
value,
isBursted,
expireAt: timestamp + REACTIONS_BURST_WINDOW,
demuxId,
});
anyReactionWasShown = true;
});
if (!anyReactionWasShown) {
return;
}
const unburstedEmojis = new Map<string, Set<string>>();
const unburstedEmojisReactorIds = new Map<
string,
Set<ServiceIdString | number>
>();
reactionsShown.current.forEach(
({ value, isBursted, expireAt, demuxId }, key) => {
if (expireAt < time) {
reactionsShown.current.delete(key);
return;
}
if (isBursted) {
return;
}
const reactionKeys = unburstedEmojis.get(value) ?? new Set();
reactionKeys.add(key);
unburstedEmojis.set(value, reactionKeys);
// Only burst when enough unique people react.
const conversation = conversationsByDemuxId.get(demuxId);
const reactorId = conversation?.serviceId || demuxId;
const reactorIdSet = unburstedEmojisReactorIds.get(value) ?? new Set();
reactorIdSet.add(reactorId);
unburstedEmojisReactorIds.set(value, reactorIdSet);
}
);
burstsShown.current.forEach((timestamp, value) => {
if (timestamp < time - REACTIONS_BURST_SHORT_WINDOW) {
burstsShown.current.delete(value);
}
});
if (burstsShown.current.size >= REACTIONS_BURST_MAX_IN_SHORT_WINDOW) {
return;
}
for (const [value, reactorIds] of unburstedEmojisReactorIds.entries()) {
if (reactorIds.size < REACTIONS_BURST_THRESHOLD) {
continue;
}
const reactionKeys = unburstedEmojis.get(value);
if (!reactionKeys) {
unburstedEmojisReactorIds.delete(value);
continue;
}
burstsShown.current.set(value, time);
reactionKeys.forEach(key => {
const reactionShown = reactionsShown.current.get(key);
if (!reactionShown) {
return;
}
reactionShown.isBursted = true;
});
showBurst({ value });
if (burstsShown.current.size >= REACTIONS_BURST_MAX_IN_SHORT_WINDOW) {
break;
}
}
}, [
reactions,
previousReactions,
showBurst,
showToast,
conversationsByDemuxId,
localDemuxId,
i18n,
ourServiceId,
]);
}
function CallingReactionsToastsContainer(
props: CallingReactionsToastsType
): JSX.Element {
const { i18n } = props;
const toastRegionRef = useRef<HTMLDivElement>(null);
const burstRegionRef = useRef<HTMLDivElement>(null);
return (
<CallingToastProvider
i18n={i18n}
maxNonPersistentToasts={5}
region={toastRegionRef}
lifetime={CALLING_REACTIONS_LIFETIME}
transitionFrom={REACTIONS_TOASTS_TRANSITION_FROM}
>
<CallReactionBurstProvider region={burstRegionRef}>
<div className="CallingReactionsToasts" ref={toastRegionRef} />
<div className="CallingReactionsBurstToasts" ref={burstRegionRef} />
<CallingReactionsToasts {...props} />
</CallReactionBurstProvider>
</CallingToastProvider>
);
}
function CallingReactionsToasts(props: CallingReactionsToastsType) {
const { showBurst } = useCallReactionBursts();
useReactionsToast({ ...props, showBurst });
return null;
}
function isHandRaised(
raisedHands: Set<number> | undefined,
demuxId: number | undefined
): boolean {
if (raisedHands === undefined || demuxId === undefined) {
return false;
}
return raisedHands.has(demuxId);
}