// 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 { noop } from 'lodash'; import classNames from 'classnames'; import type { VideoFrameSource } from '@signalapp/ringrtc'; import type { ActiveCallStateType, SetLocalAudioType, SetLocalPreviewType, SetLocalVideoType, SetRendererCanvasType, } from '../state/ducks/calling'; import { Avatar, AvatarSize } from './Avatar'; import { CallingHeader } from './CallingHeader'; import { CallingPreCallInfo, RingMode } from './CallingPreCallInfo'; import { CallingButton, CallingButtonType } from './CallingButton'; import { CallBackgroundBlur } from './CallBackgroundBlur'; import type { ActiveCallType, GroupCallVideoRequest, PresentedSource, } from '../types/Calling'; import { CallMode, CallViewMode, CallState, GroupCallConnectionState, GroupCallJoinState, } from '../types/Calling'; import { AvatarColors } from '../types/Colors'; import type { ConversationType } from '../state/ducks/conversations'; import { CallingToastManager } from './CallingToastManager'; import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant'; import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants'; import type { LocalizerType } from '../types/Util'; import { NeedsScreenRecordingPermissionsModal } from './NeedsScreenRecordingPermissionsModal'; import { missingCaseError } from '../util/missingCaseError'; import * as KeyboardLayout from '../services/keyboardLayout'; import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting'; import { CallingAudioIndicator, SPEAKING_LINGER_MS, } from './CallingAudioIndicator'; import { useActiveCallShortcuts, useKeyboardShortcuts, } from '../hooks/useKeyboardShortcuts'; import { useValueAtFixedRate } from '../hooks/useValueAtFixedRate'; export type PropsType = { activeCall: ActiveCallType; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; getPresentingSources: () => void; groupMembers?: Array>; hangUpActiveCall: (reason: string) => void; i18n: LocalizerType; joinedAt?: number; me: ConversationType; openSystemPreferencesAction: () => unknown; 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; toggleSpeakerView: () => void; }; type DirectCallHeaderMessagePropsType = { i18n: LocalizerType; callState: CallState; joinedAt?: number; }; export const isInSpeakerView = ( call: Pick | undefined ): boolean => { return Boolean( call?.viewMode === CallViewMode.Presentation || call?.viewMode === CallViewMode.Speaker ); }; function DirectCallHeaderMessage({ callState, i18n, joinedAt, }: DirectCallHeaderMessagePropsType): JSX.Element | null { const [acceptedDuration, setAcceptedDuration] = useState< number | undefined >(); useEffect(() => { if (!joinedAt) { 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 (callState === CallState.Reconnecting) { return <>{i18n('icu:callReconnecting')}; } if (callState === CallState.Accepted && acceptedDuration) { return ( <> {i18n('icu:callDuration', { duration: renderDuration(acceptedDuration), })} ); } return null; } export function CallScreen({ activeCall, getGroupCallVideoFrameSource, getPresentingSources, groupMembers, hangUpActiveCall, i18n, joinedAt, me, openSystemPreferencesAction, setGroupCallVideoRequest, setLocalAudio, setLocalVideo, setLocalPreview, setPresenting, setRendererCanvas, stickyControls, switchToPresentationView, switchFromPresentationView, toggleParticipants, togglePip, toggleScreenRecordingPermissionsDialog, toggleSettings, toggleSpeakerView, }: PropsType): JSX.Element { const { conversation, hasLocalAudio, hasLocalVideo, localAudioLevel, presentingSource, remoteParticipants, showNeedsScreenRecordingPermissionsWarning, showParticipantsList, } = 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 [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 || stickyControls || controlsHover) { return noop; } const timer = setTimeout(() => { setShowControls(false); }, 5000); return clearTimeout.bind(null, timer); }, [showControls, 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]); const currentPresenter = remoteParticipants.find( participant => participant.presenting ); const hasRemoteVideo = remoteParticipants.some( remoteParticipant => remoteParticipant.hasRemoteVideo ); const isSendingVideo = hasLocalVideo || presentingSource; let isRinging: boolean; let hasCallStarted: boolean; let headerMessage: ReactNode | undefined; let headerTitle: string | undefined; let isConnected: boolean; let participantCount: number; let remoteParticipantsElement: ReactNode; switch (activeCall.callMode) { case CallMode.Direct: { isRinging = activeCall.callState === CallState.Prering || activeCall.callState === CallState.Ringing; hasCallStarted = !isRinging; headerMessage = ( ); headerTitle = isRinging ? undefined : conversation.title; isConnected = activeCall.callState === CallState.Accepted; participantCount = isConnected ? 2 : 0; remoteParticipantsElement = hasCallStarted ? ( ) : (
); 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; if (isRinging) { headerTitle = undefined; } else if (currentPresenter) { headerTitle = i18n('icu:calling__presenting--person-ongoing', { name: currentPresenter.title, }); } else if (!activeCall.remoteParticipants.length) { headerTitle = i18n('icu:calling__in-this-call--zero'); } isConnected = activeCall.connectionState === GroupCallConnectionState.Connected; remoteParticipantsElement = ( ); break; default: throw missingCaseError(activeCall); } let lonelyInGroupNode: ReactNode; let localPreviewNode: ReactNode; if ( activeCall.callMode === CallMode.Group && !activeCall.remoteParticipants.length ) { lonelyInGroupNode = (
{isSendingVideo ? (
); } else { localPreviewNode = isSendingVideo ? (