// Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useCallback, useEffect } from 'react'; import { noop } from 'lodash'; import type { VideoFrameSource } from '@signalapp/ringrtc'; import { CallNeedPermissionScreen } from './CallNeedPermissionScreen'; import { CallScreen } from './CallScreen'; import { CallingLobby } from './CallingLobby'; import { CallingParticipantsList } from './CallingParticipantsList'; import { CallingSelectPresentingSourcesModal } from './CallingSelectPresentingSourcesModal'; import { CallingPip } from './CallingPip'; import { IncomingCallBar } from './IncomingCallBar'; import type { SafetyNumberProps } from './SafetyNumberChangeDialog'; import { SafetyNumberChangeDialog } from './SafetyNumberChangeDialog'; import type { ActiveCallType, GroupCallVideoRequest, PresentedSource, } from '../types/Calling'; import { CallEndedReason, CallMode, CallState, GroupCallConnectionState, GroupCallJoinState, } from '../types/Calling'; import type { ConversationType } from '../state/ducks/conversations'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { AcceptCallType, CancelCallType, DeclineCallType, KeyChangeOkType, SetGroupCallVideoRequestType, SetLocalAudioType, SetLocalPreviewType, SetLocalVideoType, SetRendererCanvasType, StartCallType, } from '../state/ducks/calling'; import type { LocalizerType, ThemeType } from '../types/Util'; import { missingCaseError } from '../util/missingCaseError'; import { CallingToastProvider } from './CallingToast'; const GROUP_CALL_RING_DURATION = 60 * 1000; export type PropsType = { activeCall?: ActiveCallType; availableCameras: Array; cancelCall: (_: CancelCallType) => void; closeNeedPermissionScreen: () => void; getGroupCallVideoFrameSource: ( conversationId: string, demuxId: number ) => VideoFrameSource; getPreferredBadge: PreferredBadgeSelectorType; getPresentingSources: () => void; incomingCall?: | { callMode: CallMode.Direct; conversation: ConversationType; isVideoCall: boolean; } | { callMode: CallMode.Group; conversation: ConversationType; otherMembersRung: Array>; ringer: Pick; }; keyChangeOk: (_: KeyChangeOkType) => void; renderDeviceSelection: () => JSX.Element; renderSafetyNumberViewer: (props: SafetyNumberProps) => JSX.Element; startCall: (payload: StartCallType) => void; toggleParticipants: () => void; acceptCall: (_: AcceptCallType) => void; bounceAppIconStart: () => unknown; bounceAppIconStop: () => unknown; declineCall: (_: DeclineCallType) => void; i18n: LocalizerType; isGroupCallOutboundRingEnabled: boolean; me: ConversationType; notifyForCall: ( conversationId: string, title: string, isVideoCall: boolean ) => unknown; openSystemPreferencesAction: () => unknown; playRingtone: () => unknown; setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void; setIsCallActive: (_: boolean) => void; setLocalAudio: (_: SetLocalAudioType) => void; setLocalVideo: (_: SetLocalVideoType) => void; setLocalPreview: (_: SetLocalPreviewType) => void; setOutgoingRing: (_: boolean) => void; setPresenting: (_?: PresentedSource) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; stopRingtone: () => unknown; switchToPresentationView: () => void; switchFromPresentationView: () => void; hangUpActiveCall: (reason: string) => void; theme: ThemeType; togglePip: () => void; toggleScreenRecordingPermissionsDialog: () => unknown; toggleSettings: () => void; toggleSpeakerView: () => void; isConversationTooBigToRing: boolean; pauseVoiceNotePlayer: () => void; }; type ActiveCallManagerPropsType = PropsType & { activeCall: ActiveCallType; }; function ActiveCallManager({ activeCall, availableCameras, cancelCall, closeNeedPermissionScreen, hangUpActiveCall, i18n, isGroupCallOutboundRingEnabled, keyChangeOk, getGroupCallVideoFrameSource, getPreferredBadge, getPresentingSources, me, openSystemPreferencesAction, renderDeviceSelection, renderSafetyNumberViewer, setGroupCallVideoRequest, setLocalAudio, setLocalPreview, setLocalVideo, setPresenting, setRendererCanvas, setOutgoingRing, startCall, switchToPresentationView, switchFromPresentationView, theme, toggleParticipants, togglePip, toggleScreenRecordingPermissionsDialog, toggleSettings, toggleSpeakerView, pauseVoiceNotePlayer, }: ActiveCallManagerPropsType): JSX.Element { const { conversation, hasLocalAudio, hasLocalVideo, peekedParticipants, pip, presentingSourcesAvailable, settingsDialogOpen, showParticipantsList, outgoingRing, } = activeCall; const cancelActiveCall = useCallback(() => { cancelCall({ conversationId: conversation.id }); }, [cancelCall, conversation.id]); const joinActiveCall = useCallback(() => { // pause any voice note playback pauseVoiceNotePlayer(); startCall({ callMode: activeCall.callMode, conversationId: conversation.id, hasLocalAudio, hasLocalVideo, }); }, [ startCall, activeCall.callMode, conversation.id, hasLocalAudio, hasLocalVideo, pauseVoiceNotePlayer, ]); const getGroupCallVideoFrameSourceForActiveCall = useCallback( (demuxId: number) => { return getGroupCallVideoFrameSource(conversation.id, demuxId); }, [getGroupCallVideoFrameSource, conversation.id] ); const setGroupCallVideoRequestForConversation = useCallback( (resolutions: Array, speakerHeight: number) => { setGroupCallVideoRequest({ conversationId: conversation.id, resolutions, speakerHeight, }); }, [setGroupCallVideoRequest, conversation.id] ); const onSafetyNumberDialogCancel = useCallback(() => { hangUpActiveCall('safety number dialog cancel'); }, [hangUpActiveCall]); let isCallFull: boolean; let showCallLobby: boolean; let groupMembers: | undefined | Array>; let isConvoTooBigToRing = false; switch (activeCall.callMode) { case CallMode.Direct: { const { callState, callEndedReason } = activeCall; const ended = callState === CallState.Ended; if ( ended && callEndedReason === CallEndedReason.RemoteHangupNeedPermission ) { return ( ); } showCallLobby = !callState; isCallFull = false; groupMembers = undefined; break; } case CallMode.Group: { showCallLobby = activeCall.joinState !== GroupCallJoinState.Joined; isCallFull = activeCall.deviceCount >= activeCall.maxDevices; isConvoTooBigToRing = activeCall.isConversationTooBigToRing; ({ groupMembers } = activeCall); break; } default: throw missingCaseError(activeCall); } if (showCallLobby) { return ( <> {settingsDialogOpen && renderDeviceSelection()} {showParticipantsList && activeCall.callMode === CallMode.Group ? ( ) : null} ); } if (pip) { return ( ); } const groupCallParticipantsForParticipantsList = activeCall.callMode === CallMode.Group ? [ ...activeCall.remoteParticipants.map(participant => ({ ...participant, hasRemoteAudio: participant.hasRemoteAudio, hasRemoteVideo: participant.hasRemoteVideo, presenting: participant.presenting, })), { ...me, hasRemoteAudio: hasLocalAudio, hasRemoteVideo: hasLocalVideo, presenting: Boolean(activeCall.presentingSource), }, ] : []; return ( <> {presentingSourcesAvailable && presentingSourcesAvailable.length ? ( ) : null} {settingsDialogOpen && renderDeviceSelection()} {showParticipantsList && activeCall.callMode === CallMode.Group ? ( ) : null} {activeCall.callMode === CallMode.Group && activeCall.conversationsWithSafetyNumberChanges.length ? ( { keyChangeOk({ conversationId: activeCall.conversation.id }); }} renderSafetyNumber={renderSafetyNumberViewer} theme={theme} /> ) : null} ); } export function CallManager(props: PropsType): JSX.Element | null { const { acceptCall, activeCall, bounceAppIconStart, bounceAppIconStop, declineCall, i18n, incomingCall, notifyForCall, playRingtone, stopRingtone, setIsCallActive, setOutgoingRing, } = props; const isCallActive = Boolean(activeCall); useEffect(() => { setIsCallActive(isCallActive); }, [isCallActive, setIsCallActive]); const shouldRing = getShouldRing(props); useEffect(() => { if (shouldRing) { playRingtone(); return () => { stopRingtone(); }; } stopRingtone(); return noop; }, [shouldRing, playRingtone, stopRingtone]); const mightBeRingingOutgoingGroupCall = activeCall?.callMode === CallMode.Group && activeCall.outgoingRing && activeCall.joinState !== GroupCallJoinState.NotJoined; useEffect(() => { if (!mightBeRingingOutgoingGroupCall) { return noop; } const timeout = setTimeout(() => { setOutgoingRing(false); }, GROUP_CALL_RING_DURATION); return () => { clearTimeout(timeout); }; }, [mightBeRingingOutgoingGroupCall, setOutgoingRing]); if (activeCall) { // `props` should logically have an `activeCall` at this point, but TypeScript can't // figure that out, so we pass it in again. return ( ); } // In the future, we may want to show the incoming call bar when a call is active. if (incomingCall) { return ( ); } return null; } function getShouldRing({ activeCall, incomingCall, isConversationTooBigToRing, }: Readonly< Pick >): boolean { if (incomingCall) { // don't ring a large group if (isConversationTooBigToRing) { return false; } return !activeCall; } if (!activeCall) { return false; } switch (activeCall.callMode) { case CallMode.Direct: return ( activeCall.callState === CallState.Prering || activeCall.callState === CallState.Ringing ); case CallMode.Group: return ( activeCall.outgoingRing && (activeCall.connectionState === GroupCallConnectionState.Connecting || activeCall.connectionState === GroupCallConnectionState.Connected) && activeCall.joinState !== GroupCallJoinState.NotJoined && !activeCall.remoteParticipants.length && (activeCall.conversation.sortedGroupMembers || []).length >= 2 ); default: throw missingCaseError(activeCall); } }