// 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 } 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 { Spinner } from './Spinner'; import { handleOutsideClick } from '../util/handleOutsideClick'; import type { Props as ReactionPickerProps } from './conversation/ReactionPicker'; import type { SmartReactionPicker } from '../state/smart/ReactionPicker'; import { Emoji } from './emoji/Emoji'; import { CallingRaisedHandsList } from './CallingRaisedHandsList'; export type PropsType = { activeCall: ActiveCallType; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; getPresentingSources: () => void; groupMembers?: Array>; hangUpActiveCall: (reason: string) => void; i18n: LocalizerType; isGroupCallRaiseHandEnabled: boolean; isGroupCallReactionsEnabled: boolean; me: ConversationType; openSystemPreferencesAction: () => unknown; renderReactionPicker: ( props: React.ComponentProps ) => JSX.Element; sendGroupCallRaiseHand: (payload: SendGroupCallRaiseHandType) => void; sendGroupCallReaction: (payload: SendGroupCallReactionType) => void; setGroupCallVideoRequest: ( _: Array, 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; export const isInSpeakerView = ( call: Pick | undefined ): boolean => { return Boolean( call?.viewMode === CallViewMode.Presentation || call?.viewMode === CallViewMode.Speaker ); }; const REACTIONS_TOASTS_TRANSITION_FROM = { opacity: 0, }; 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 moreOptionsMenuRef = React.useRef(null); const moreOptionsButtonRef = React.useRef(null); const reactionPickerRef = React.useRef(null); const [showMoreOptions, setShowMoreOptions] = useState(false); const toggleMoreOptions = useCallback(() => { setShowMoreOptions(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(null); useEffect(() => { setLocalPreview({ element: localVideoRef }); return () => { setLocalPreview({ element: undefined }); }; }, [setLocalPreview, setRendererCanvas]); useEffect(() => { if (!showControls || showMoreOptions || stickyControls || controlsHover) { return noop; } const timer = setTimeout(() => { setShowControls(false); }, 5000); return clearTimeout.bind(null, timer); }, [showControls, showMoreOptions, 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 (!showMoreOptions) { return noop; } return handleOutsideClick( () => { setShowMoreOptions(false); return true; }, { containerElements: [moreOptionsButtonRef, moreOptionsMenuRef], name: 'CallScreen.moreOptions', } ); }, [showMoreOptions]); 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 = (
{isSendingVideo ? (