Redux state: Allow multiple calls to be stored

This commit is contained in:
Evan Hahn 2020-11-06 11:36:37 -06:00 committed by GitHub
parent 753e0279c6
commit 3468de255d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1191 additions and 515 deletions

View File

@ -565,6 +565,7 @@ try {
require('./ts/test-electron/models/messages_test');
require('./ts/test-electron/linkPreviews/linkPreviewFetch_test');
require('./ts/test-electron/state/ducks/calling_test');
require('./ts/test-electron/state/selectors/calling_test');
delete window.describe;

View File

@ -13,11 +13,7 @@ import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const callDetails = {
callId: 0,
isIncoming: true,
isVideoCall: true,
const conversation = {
id: '3051234567',
avatarPath: undefined,
color: 'ultramarine' as ColorType,
@ -30,27 +26,20 @@ const callDetails = {
const defaultProps = {
availableCameras: [],
acceptCall: action('accept-call'),
callDetails,
callState: CallState.Accepted,
cancelCall: action('cancel-call'),
closeNeedPermissionScreen: action('close-need-permission-screen'),
declineCall: action('decline-call'),
hangUp: action('hang-up'),
hasLocalAudio: true,
hasLocalVideo: true,
hasRemoteVideo: true,
i18n,
me: {
color: 'ultramarine' as ColorType,
title: 'Morty Smith',
},
pip: false,
renderDeviceSelection: () => <div />,
setLocalAudio: action('set-local-audio'),
setLocalPreview: action('set-local-preview'),
setLocalVideo: action('set-local-video'),
setRendererCanvas: action('set-renderer-canvas'),
settingsDialogOpen: false,
startCall: action('start-call'),
toggleParticipants: action('toggle-participants'),
togglePip: action('toggle-pip'),
@ -59,20 +48,71 @@ const defaultProps = {
const permutations = [
{
title: 'Call Manager (ongoing)',
title: 'Call Manager (no call)',
props: {},
},
{
title: 'Call Manager (ongoing)',
props: {
activeCall: {
call: {
conversationId: '3051234567',
callState: CallState.Accepted,
isIncoming: false,
isVideoCall: true,
hasRemoteVideo: true,
},
activeCallState: {
conversationId: '3051234567',
joinedAt: Date.now(),
hasLocalAudio: true,
hasLocalVideo: false,
participantsList: false,
pip: false,
settingsDialogOpen: false,
},
conversation,
},
},
},
{
title: 'Call Manager (ringing)',
props: {
callState: CallState.Ringing,
incomingCall: {
call: {
conversationId: '3051234567',
callState: CallState.Ringing,
isIncoming: true,
isVideoCall: true,
hasRemoteVideo: true,
},
conversation,
},
},
},
{
title: 'Call Manager (call request needed)',
props: {
callState: CallState.Ended,
callEndedReason: CallEndedReason.RemoteHangupNeedPermission,
activeCall: {
call: {
conversationId: '3051234567',
callState: CallState.Ended,
callEndedReason: CallEndedReason.RemoteHangupNeedPermission,
isIncoming: false,
isVideoCall: true,
hasRemoteVideo: true,
},
activeCallState: {
conversationId: '3051234567',
joinedAt: Date.now(),
hasLocalAudio: true,
hasLocalVideo: false,
participantsList: false,
pip: false,
settingsDialogOpen: false,
},
conversation,
},
},
},
];

View File

@ -5,112 +5,156 @@ import React from 'react';
import { CallingPip } from './CallingPip';
import { CallNeedPermissionScreen } from './CallNeedPermissionScreen';
import { CallingLobby } from './CallingLobby';
import { CallScreen, PropsType as CallScreenPropsType } from './CallScreen';
import {
IncomingCallBar,
PropsType as IncomingCallBarPropsType,
} from './IncomingCallBar';
import { CallScreen } from './CallScreen';
import { IncomingCallBar } from './IncomingCallBar';
import { CallState, CallEndedReason } from '../types/Calling';
import { CallDetailsType, OutgoingCallType } from '../state/ducks/calling';
import {
ActiveCallStateType,
AcceptCallType,
DeclineCallType,
DirectCallStateType,
StartCallType,
SetLocalAudioType,
HangUpType,
SetLocalPreviewType,
SetLocalVideoType,
SetRendererCanvasType,
} from '../state/ducks/calling';
import { LocalizerType } from '../types/Util';
import { ColorType } from '../types/Colors';
type CallManagerPropsType = {
interface PropsType {
activeCall?: {
call: DirectCallStateType;
activeCallState: ActiveCallStateType;
conversation: {
id: string;
avatarPath?: string;
color?: ColorType;
title: string;
name?: string;
phoneNumber?: string;
profileName?: string;
};
};
availableCameras: Array<MediaDeviceInfo>;
callDetails?: CallDetailsType;
callEndedReason?: CallEndedReason;
callState?: CallState;
cancelCall: () => void;
pip: boolean;
closeNeedPermissionScreen: () => void;
incomingCall?: {
call: DirectCallStateType;
conversation: {
id: string;
avatarPath?: string;
color?: ColorType;
title: string;
name?: string;
phoneNumber?: string;
profileName?: string;
};
};
renderDeviceSelection: () => JSX.Element;
settingsDialogOpen: boolean;
startCall: (payload: OutgoingCallType) => void;
startCall: (payload: StartCallType) => void;
toggleParticipants: () => void;
};
type PropsType = IncomingCallBarPropsType &
CallScreenPropsType &
CallManagerPropsType;
acceptCall: (_: AcceptCallType) => void;
declineCall: (_: DeclineCallType) => void;
i18n: LocalizerType;
me: {
avatarPath?: string;
color?: ColorType;
name?: string;
phoneNumber?: string;
profileName?: string;
title: string;
};
setLocalAudio: (_: SetLocalAudioType) => void;
setLocalVideo: (_: SetLocalVideoType) => void;
setLocalPreview: (_: SetLocalPreviewType) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void;
hangUp: (_: HangUpType) => void;
togglePip: () => void;
toggleSettings: () => void;
}
export const CallManager = ({
acceptCall,
activeCall,
availableCameras,
callDetails,
callState,
callEndedReason,
cancelCall,
closeNeedPermissionScreen,
declineCall,
hangUp,
hasLocalAudio,
hasLocalVideo,
hasRemoteVideo,
i18n,
incomingCall,
me,
pip,
renderDeviceSelection,
setLocalAudio,
setLocalPreview,
setLocalVideo,
setRendererCanvas,
settingsDialogOpen,
startCall,
toggleParticipants,
togglePip,
toggleSettings,
}: PropsType): JSX.Element | null => {
if (!callDetails) {
return null;
}
const incoming = callDetails.isIncoming;
const outgoing = !incoming;
const ongoing =
callState === CallState.Accepted || callState === CallState.Reconnecting;
const ringing = callState === CallState.Ringing;
const ended = callState === CallState.Ended;
if (activeCall) {
const { call, activeCallState, conversation } = activeCall;
const { callState, callEndedReason } = call;
const {
joinedAt,
hasLocalAudio,
hasLocalVideo,
settingsDialogOpen,
pip,
} = activeCallState;
if (ended) {
if (callEndedReason === CallEndedReason.RemoteHangupNeedPermission) {
const ended = callState === CallState.Ended;
if (ended) {
if (callEndedReason === CallEndedReason.RemoteHangupNeedPermission) {
return (
<CallNeedPermissionScreen
close={closeNeedPermissionScreen}
conversation={conversation}
i18n={i18n}
/>
);
}
}
if (!callState) {
return (
<CallNeedPermissionScreen
close={closeNeedPermissionScreen}
callDetails={callDetails}
i18n={i18n}
/>
<>
<CallingLobby
availableCameras={availableCameras}
conversation={conversation}
hasLocalAudio={hasLocalAudio}
hasLocalVideo={hasLocalVideo}
i18n={i18n}
isGroupCall={false}
onCallCanceled={cancelCall}
onJoinCall={() => {
startCall({
conversationId: conversation.id,
hasLocalAudio,
hasLocalVideo,
});
}}
setLocalPreview={setLocalPreview}
setLocalAudio={setLocalAudio}
setLocalVideo={setLocalVideo}
toggleParticipants={toggleParticipants}
toggleSettings={toggleSettings}
/>
{settingsDialogOpen && renderDeviceSelection()}
</>
);
}
return null;
}
if (!callState) {
return (
<>
<CallingLobby
availableCameras={availableCameras}
callDetails={callDetails}
hasLocalAudio={hasLocalAudio}
hasLocalVideo={hasLocalVideo}
i18n={i18n}
isGroupCall={false}
onCallCanceled={cancelCall}
onJoinCall={() => {
startCall({ callDetails });
}}
setLocalPreview={setLocalPreview}
setLocalAudio={setLocalAudio}
setLocalVideo={setLocalVideo}
toggleParticipants={toggleParticipants}
toggleSettings={toggleSettings}
/>
{settingsDialogOpen && renderDeviceSelection()}
</>
);
}
const hasRemoteVideo = Boolean(call.hasRemoteVideo);
if (outgoing || ongoing) {
if (pip) {
return (
<CallingPip
callDetails={callDetails}
conversation={conversation}
hangUp={hangUp}
hasLocalVideo={hasLocalVideo}
hasRemoteVideo={hasRemoteVideo}
@ -125,12 +169,13 @@ export const CallManager = ({
return (
<>
<CallScreen
callDetails={callDetails}
conversation={conversation}
callState={callState}
hangUp={hangUp}
hasLocalAudio={hasLocalAudio}
hasLocalVideo={hasLocalVideo}
i18n={i18n}
joinedAt={joinedAt}
me={me}
hasRemoteVideo={hasRemoteVideo}
setLocalPreview={setLocalPreview}
@ -145,17 +190,18 @@ export const CallManager = ({
);
}
if (incoming && ringing) {
// In the future, we may want to show the incoming call bar when a call is active.
if (incomingCall) {
return (
<IncomingCallBar
acceptCall={acceptCall}
callDetails={callDetails}
declineCall={declineCall}
i18n={i18n}
call={incomingCall.call}
conversation={incomingCall.conversation}
/>
);
}
// Incoming && Prering
return null;
};

View File

@ -2,14 +2,21 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useRef, useEffect } from 'react';
import { CallDetailsType } from '../state/ducks/calling';
import { LocalizerType } from '../types/Util';
import { Avatar } from './Avatar';
import { Intl } from './Intl';
import { ContactName } from './conversation/ContactName';
import { ColorType } from '../types/Colors';
interface Props {
callDetails: CallDetailsType;
conversation: {
avatarPath?: string;
color?: ColorType;
name?: string;
phoneNumber?: string;
profileName?: string;
title: string;
};
i18n: LocalizerType;
close: () => void;
}
@ -17,11 +24,11 @@ interface Props {
const AUTO_CLOSE_MS = 10000;
export const CallNeedPermissionScreen: React.FC<Props> = ({
callDetails,
conversation,
i18n,
close,
}) => {
const title = callDetails.title || i18n('unknownContact');
const title = conversation.title || i18n('unknownContact');
const autoCloseAtRef = useRef<number>(Date.now() + AUTO_CLOSE_MS);
useEffect(() => {
@ -32,15 +39,15 @@ export const CallNeedPermissionScreen: React.FC<Props> = ({
return (
<div className="module-call-need-permission-screen">
<Avatar
avatarPath={callDetails.avatarPath}
color={callDetails.color || 'ultramarine'}
avatarPath={conversation.avatarPath}
color={conversation.color || 'ultramarine'}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
name={callDetails.name}
phoneNumber={callDetails.phoneNumber}
profileName={callDetails.profileName}
title={callDetails.title}
name={conversation.name}
phoneNumber={conversation.phoneNumber}
profileName={conversation.profileName}
title={conversation.title}
size={112}
/>

View File

@ -14,23 +14,16 @@ import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const callDetails = {
acceptedTime: Date.now(),
callId: 0,
isIncoming: true,
isVideoCall: true,
id: '3051234567',
avatarPath: undefined,
color: 'ultramarine' as ColorType,
title: 'Rick Sanchez',
name: 'Rick Sanchez',
phoneNumber: '3051234567',
profileName: 'Rick Sanchez',
};
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
callDetails,
conversation: {
id: '3051234567',
avatarPath: undefined,
color: 'ultramarine' as ColorType,
title: 'Rick Sanchez',
name: 'Rick Sanchez',
phoneNumber: '3051234567',
profileName: 'Rick Sanchez',
},
callState: select(
'callState',
CallState,
@ -44,6 +37,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
overrideProps.hasRemoteVideo || false
),
i18n,
joinedAt: Date.now(),
me: {
color: 'ultramarine' as ColorType,
name: 'Morty Smith',

View File

@ -5,7 +5,6 @@ import React, { useState, useRef, useEffect, useCallback } from 'react';
import { noop } from 'lodash';
import classNames from 'classnames';
import {
CallDetailsType,
HangUpType,
SetLocalAudioType,
SetLocalPreviewType,
@ -20,13 +19,22 @@ import { ColorType } from '../types/Colors';
import { LocalizerType } from '../types/Util';
export type PropsType = {
callDetails?: CallDetailsType;
callState?: CallState;
conversation: {
id: string;
avatarPath?: string;
color?: ColorType;
title: string;
name?: string;
phoneNumber?: string;
profileName?: string;
};
callState: CallState;
hangUp: (_: HangUpType) => void;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
hasRemoteVideo: boolean;
i18n: LocalizerType;
joinedAt?: number;
me: {
avatarPath?: string;
color?: ColorType;
@ -44,13 +52,14 @@ export type PropsType = {
};
export const CallScreen: React.FC<PropsType> = ({
callDetails,
callState,
conversation,
hangUp,
hasLocalAudio,
hasLocalVideo,
hasRemoteVideo,
i18n,
joinedAt,
me,
setLocalAudio,
setLocalVideo,
@ -59,29 +68,17 @@ export const CallScreen: React.FC<PropsType> = ({
togglePip,
toggleSettings,
}) => {
const { acceptedTime, callId } = callDetails || {};
const toggleAudio = useCallback(() => {
if (!callId) {
return;
}
setLocalAudio({
callId,
enabled: !hasLocalAudio,
});
}, [callId, setLocalAudio, hasLocalAudio]);
}, [setLocalAudio, hasLocalAudio]);
const toggleVideo = useCallback(() => {
if (!callId) {
return;
}
setLocalVideo({
callId,
enabled: !hasLocalVideo,
});
}, [callId, setLocalVideo, hasLocalVideo]);
}, [setLocalVideo, hasLocalVideo]);
const [acceptedDuration, setAcceptedDuration] = useState<number | null>(null);
const [showControls, setShowControls] = useState(true);
@ -100,15 +97,15 @@ export const CallScreen: React.FC<PropsType> = ({
}, [setLocalPreview, setRendererCanvas]);
useEffect(() => {
if (!acceptedTime) {
if (!joinedAt) {
return noop;
}
// It's really jumpy with a value of 500ms.
const interval = setInterval(() => {
setAcceptedDuration(Date.now() - acceptedTime);
setAcceptedDuration(Date.now() - joinedAt);
}, 100);
return clearInterval.bind(null, interval);
}, [acceptedTime]);
}, [joinedAt]);
useEffect(() => {
if (!showControls) {
@ -147,10 +144,6 @@ export const CallScreen: React.FC<PropsType> = ({
const isAudioOnly = !hasLocalVideo && !hasRemoteVideo;
if (!callDetails || !callState) {
return null;
}
const controlsFadeClass = classNames({
'module-ongoing-call__controls--fadeIn':
(showControls || isAudioOnly) && callState !== CallState.Accepted,
@ -181,7 +174,7 @@ export const CallScreen: React.FC<PropsType> = ({
)}
>
<div className="module-calling__header--header-name">
{callDetails.title}
{conversation.title}
</div>
{renderHeaderMessage(i18n, callState, acceptedDuration)}
<div className="module-calling-tools">
@ -205,7 +198,7 @@ export const CallScreen: React.FC<PropsType> = ({
ref={remoteVideoRef}
/>
) : (
renderAvatar(i18n, callDetails)
renderAvatar(i18n, conversation)
)}
<div className="module-ongoing-call__footer">
{/* This layout-only element is not ideal.
@ -233,7 +226,7 @@ export const CallScreen: React.FC<PropsType> = ({
buttonType={CallingButtonType.HANG_UP}
i18n={i18n}
onClick={() => {
hangUp({ callId });
hangUp({ conversationId: conversation.id });
}}
tooltipDistance={24}
/>
@ -269,16 +262,22 @@ export const CallScreen: React.FC<PropsType> = ({
function renderAvatar(
i18n: LocalizerType,
callDetails: CallDetailsType
): JSX.Element {
const {
{
avatarPath,
color,
name,
phoneNumber,
profileName,
title,
} = callDetails;
}: {
avatarPath?: string;
color?: ColorType;
title: string;
name?: string;
phoneNumber?: string;
profileName?: string;
}
): JSX.Element {
return (
<div className="module-ongoing-call__remote-video-disabled">
<Avatar

View File

@ -13,11 +13,7 @@ import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const callDetails = {
callId: 0,
isIncoming: true,
isVideoCall: true,
const conversation = {
id: '3051234567',
avatarPath: undefined,
color: 'ultramarine' as ColorType,
@ -39,7 +35,7 @@ const camera = {
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
availableCameras: overrideProps.availableCameras || [camera],
callDetails,
conversation,
hasLocalAudio: boolean('hasLocalAudio', overrideProps.hasLocalAudio || false),
hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false),
i18n,
@ -60,8 +56,8 @@ story.add('Default', () => {
return (
<CallingLobby
{...props}
callDetails={{
...callDetails,
conversation={{
...conversation,
avatarPath: 'https://www.stevensegallery.com/600/600',
}}
/>

View File

@ -3,7 +3,6 @@
import React from 'react';
import {
CallDetailsType,
SetLocalAudioType,
SetLocalPreviewType,
SetLocalVideoType,
@ -15,10 +14,15 @@ import {
} from './CallingButton';
import { CallBackgroundBlur } from './CallBackgroundBlur';
import { LocalizerType } from '../types/Util';
import { ColorType } from '../types/Colors';
export type PropsType = {
availableCameras: Array<MediaDeviceInfo>;
callDetails: CallDetailsType;
conversation: {
avatarPath?: string;
color?: ColorType;
title: string;
};
hasLocalAudio: boolean;
hasLocalVideo: boolean;
i18n: LocalizerType;
@ -34,7 +38,7 @@ export type PropsType = {
export const CallingLobby = ({
availableCameras,
callDetails,
conversation,
hasLocalAudio,
hasLocalVideo,
i18n,
@ -50,20 +54,12 @@ export const CallingLobby = ({
const localVideoRef = React.useRef(null);
const toggleAudio = React.useCallback((): void => {
if (!callDetails) {
return;
}
setLocalAudio({ enabled: !hasLocalAudio });
}, [callDetails, hasLocalAudio, setLocalAudio]);
}, [hasLocalAudio, setLocalAudio]);
const toggleVideo = React.useCallback((): void => {
if (!callDetails) {
return;
}
setLocalVideo({ enabled: !hasLocalVideo });
}, [callDetails, hasLocalVideo, setLocalVideo]);
}, [hasLocalVideo, setLocalVideo]);
React.useEffect(() => {
setLocalPreview({ element: localVideoRef });
@ -112,7 +108,7 @@ export const CallingLobby = ({
<div className="module-calling__container">
<div className="module-calling__header">
<div className="module-calling__header--header-name">
{callDetails.title}
{conversation.title}
</div>
<div className="module-calling-tools">
{isGroupCall ? (
@ -136,8 +132,8 @@ export const CallingLobby = ({
<video ref={localVideoRef} autoPlay />
) : (
<CallBackgroundBlur
avatarPath={callDetails.avatarPath}
color={callDetails.color}
avatarPath={conversation.avatarPath}
color={conversation.color}
>
<div className="module-calling-lobby__video-off--icon" />
<span className="module-calling-lobby__video-off--text">

View File

@ -13,11 +13,7 @@ import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const callDetails = {
callId: 0,
isIncoming: true,
isVideoCall: true,
const conversation = {
id: '3051234567',
avatarPath: undefined,
color: 'ultramarine' as ColorType,
@ -28,7 +24,7 @@ const callDetails = {
};
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
callDetails: overrideProps.callDetails || callDetails,
conversation: overrideProps.conversation || conversation,
hangUp: action('hang-up'),
hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false),
hasRemoteVideo: boolean(
@ -50,8 +46,8 @@ story.add('Default', () => {
story.add('Contact (with avatar)', () => {
const props = createProps({
callDetails: {
...callDetails,
conversation: {
...conversation,
avatarPath: 'https://www.fillmurray.com/64/64',
},
});
@ -60,8 +56,8 @@ story.add('Contact (with avatar)', () => {
story.add('Contact (no color)', () => {
const props = createProps({
callDetails: {
...callDetails,
conversation: {
...conversation,
color: undefined,
},
});

View File

@ -3,28 +3,33 @@
import React from 'react';
import {
CallDetailsType,
HangUpType,
SetLocalPreviewType,
SetRendererCanvasType,
} from '../state/ducks/calling';
import { Avatar } from './Avatar';
import { CallBackgroundBlur } from './CallBackgroundBlur';
import { ColorType } from '../types/Colors';
import { LocalizerType } from '../types/Util';
function renderAvatar(
callDetails: CallDetailsType,
i18n: LocalizerType
): JSX.Element {
const {
{
avatarPath,
color,
name,
phoneNumber,
profileName,
title,
} = callDetails;
}: {
avatarPath?: string;
color?: ColorType;
title: string;
name?: string;
phoneNumber?: string;
profileName?: string;
},
i18n: LocalizerType
): JSX.Element {
return (
<div className="module-calling-pip__video--remote">
<CallBackgroundBlur avatarPath={avatarPath} color={color}>
@ -48,7 +53,15 @@ function renderAvatar(
}
export type PropsType = {
callDetails: CallDetailsType;
conversation: {
id: string;
avatarPath?: string;
color?: ColorType;
title: string;
name?: string;
phoneNumber?: string;
profileName?: string;
};
hangUp: (_: HangUpType) => void;
hasLocalVideo: boolean;
hasRemoteVideo: boolean;
@ -64,7 +77,7 @@ const PIP_DEFAULT_Y = 56;
const PIP_PADDING = 8;
export const CallingPip = ({
callDetails,
conversation,
hangUp,
hasLocalVideo,
hasRemoteVideo,
@ -204,7 +217,7 @@ export const CallingPip = ({
ref={remoteVideoRef}
/>
) : (
renderAvatar(callDetails, i18n)
renderAvatar(conversation, i18n)
)}
{hasLocalVideo ? (
<video
@ -219,7 +232,7 @@ export const CallingPip = ({
aria-label={i18n('calling__hangup')}
className="module-calling-pip__button--hangup"
onClick={() => {
hangUp({ callId: callDetails.callId });
hangUp({ conversationId: conversation.id });
}}
/>
<button

View File

@ -15,11 +15,13 @@ const i18n = setupI18n('en', enMessages);
const defaultProps = {
acceptCall: action('accept-call'),
callDetails: {
call: {
conversationId: 'fake-conversation-id',
callId: 0,
isIncoming: true,
isVideoCall: true,
},
conversation: {
id: '3051234567',
avatarPath: undefined,
contactColor: 'ultramarine' as ColorType,
@ -33,24 +35,15 @@ const defaultProps = {
};
const permutations = [
{
title: 'Incoming Call Bar (no call details)',
props: {},
},
{
title: 'Incoming Call Bar (video)',
props: {
callDetails: {
...defaultProps.callDetails,
isVideoCall: true,
},
},
props: {},
},
{
title: 'Incoming Call Bar (audio)',
props: {
callDetails: {
...defaultProps.callDetails,
call: {
...defaultProps.call,
isVideoCall: false,
},
},
@ -69,10 +62,13 @@ storiesOf('Components/IncomingCallBar', module)
return (
<IncomingCallBar
{...defaultProps}
callDetails={{
...defaultProps.callDetails,
color,
call={{
...defaultProps.call,
isVideoCall,
}}
conversation={{
...defaultProps.conversation,
color,
name,
}}
/>

View File

@ -6,17 +6,25 @@ import Tooltip from 'react-tooltip-lite';
import { Avatar } from './Avatar';
import { ContactName } from './conversation/ContactName';
import { LocalizerType } from '../types/Util';
import {
AcceptCallType,
CallDetailsType,
DeclineCallType,
} from '../state/ducks/calling';
import { ColorType } from '../types/Colors';
import { AcceptCallType, DeclineCallType } from '../state/ducks/calling';
export type PropsType = {
acceptCall: (_: AcceptCallType) => void;
callDetails?: CallDetailsType;
declineCall: (_: DeclineCallType) => void;
i18n: LocalizerType;
call: {
isVideoCall: boolean;
};
conversation: {
id: string;
avatarPath?: string;
color?: ColorType;
title: string;
name?: string;
phoneNumber?: string;
profileName?: string;
};
};
type CallButtonProps = {
@ -54,23 +62,21 @@ const CallButton = ({
export const IncomingCallBar = ({
acceptCall,
callDetails,
declineCall,
i18n,
call,
conversation,
}: PropsType): JSX.Element | null => {
if (!callDetails) {
return null;
}
const { isVideoCall } = call;
const {
id: conversationId,
avatarPath,
callId,
color,
title,
name,
phoneNumber,
profileName,
} = callDetails;
} = conversation;
return (
<div className="module-incoming-call">
@ -103,21 +109,17 @@ export const IncomingCallBar = ({
dir="auto"
className="module-incoming-call__contact--message-text"
>
{i18n(
callDetails.isVideoCall
? 'incomingVideoCall'
: 'incomingAudioCall'
)}
{i18n(isVideoCall ? 'incomingVideoCall' : 'incomingAudioCall')}
</div>
</div>
</div>
<div className="module-incoming-call__actions">
{callDetails.isVideoCall ? (
{isVideoCall ? (
<>
<CallButton
classSuffix="decline"
onClick={() => {
declineCall({ callId });
declineCall({ conversationId });
}}
tabIndex={0}
tooltipContent={i18n('declineCall')}
@ -125,7 +127,7 @@ export const IncomingCallBar = ({
<CallButton
classSuffix="accept-video-as-audio"
onClick={() => {
acceptCall({ callId, asVideoCall: false });
acceptCall({ conversationId, asVideoCall: false });
}}
tabIndex={0}
tooltipContent={i18n('acceptCallWithoutVideo')}
@ -133,7 +135,7 @@ export const IncomingCallBar = ({
<CallButton
classSuffix="accept-video"
onClick={() => {
acceptCall({ callId, asVideoCall: true });
acceptCall({ conversationId, asVideoCall: true });
}}
tabIndex={0}
tooltipContent={i18n('acceptCall')}
@ -144,7 +146,7 @@ export const IncomingCallBar = ({
<CallButton
classSuffix="decline"
onClick={() => {
declineCall({ callId });
declineCall({ conversationId });
}}
tabIndex={0}
tooltipContent={i18n('declineCall')}
@ -152,7 +154,7 @@ export const IncomingCallBar = ({
<CallButton
classSuffix="accept-audio"
onClick={() => {
acceptCall({ callId, asVideoCall: false });
acceptCall({ conversationId, asVideoCall: false });
}}
tabIndex={0}
tooltipContent={i18n('acceptCall')}

View File

@ -21,10 +21,7 @@ import {
UserId,
} from 'ringrtc';
import {
ActionsType as UxActionsType,
CallDetailsType,
} from '../state/ducks/calling';
import { ActionsType as UxActionsType } from '../state/ducks/calling';
import { EnvelopeClass } from '../textsecure.d';
import { AudioDevice, MediaDeviceSettings } from '../types/Calling';
import { ConversationModel } from '../models/conversations';
@ -48,9 +45,13 @@ export class CallingClass {
private deviceReselectionTimer?: NodeJS.Timeout;
private callsByConversation: { [conversationId: string]: Call };
constructor() {
this.videoCapturer = new GumVideoCapturer(640, 480, 30);
this.videoRenderer = new CanvasVideoRenderer();
this.callsByConversation = {};
}
initialize(uxActions: UxActionsType): void {
@ -101,12 +102,8 @@ export class CallingClass {
window.log.info('CallingClass.startCallingLobby(): Starting lobby');
this.uxActions.showCallLobby({
callDetails: {
...conversationProps,
callId: undefined,
isIncoming: false,
isVideoCall,
},
conversationId: conversationProps.id,
isVideoCall,
});
await this.startDeviceReselectionTimer();
@ -124,7 +121,8 @@ export class CallingClass {
async startOutgoingCall(
conversationId: string,
isVideoCall: boolean
hasLocalAudio: boolean,
hasLocalVideo: boolean
): Promise<void> {
window.log.info('CallingClass.startCallingLobby()');
@ -147,7 +145,7 @@ export class CallingClass {
return;
}
const haveMediaPermissions = await this.requestPermissions(isVideoCall);
const haveMediaPermissions = await this.requestPermissions(hasLocalVideo);
if (!haveMediaPermissions) {
window.log.info('Permissions were denied, new call not allowed.');
this.stopCallingLobby();
@ -171,24 +169,38 @@ export class CallingClass {
// from the RingRTC before we lookup the ICE servers.
const call = RingRTC.startOutgoingCall(
remoteUserId,
isVideoCall,
hasLocalVideo,
this.localDeviceId,
callSettings
);
await this.startDeviceReselectionTimer();
RingRTC.setOutgoingAudio(call.callId, hasLocalAudio);
RingRTC.setVideoCapturer(call.callId, this.videoCapturer);
RingRTC.setVideoRenderer(call.callId, this.videoRenderer);
this.attachToCall(conversation, call);
this.uxActions.outgoingCall({
callDetails: this.getAcceptedCallDetails(conversation, call),
conversationId: conversation.id,
hasLocalAudio,
hasLocalVideo,
});
await this.startDeviceReselectionTimer();
}
async accept(callId: CallId, asVideoCall: boolean): Promise<void> {
private getCallIdForConversation(conversationId: string): undefined | CallId {
return this.callsByConversation[conversationId]?.callId;
}
async accept(conversationId: string, asVideoCall: boolean): Promise<void> {
window.log.info('CallingClass.accept()');
const callId = this.getCallIdForConversation(conversationId);
if (!callId) {
window.log.warn('Trying to accept a non-existent call');
return;
}
const haveMediaPermissions = await this.requestPermissions(asVideoCall);
if (haveMediaPermissions) {
await this.startDeviceReselectionTimer();
@ -201,23 +213,47 @@ export class CallingClass {
}
}
decline(callId: CallId): void {
decline(conversationId: string): void {
window.log.info('CallingClass.decline()');
const callId = this.getCallIdForConversation(conversationId);
if (!callId) {
window.log.warn('Trying to decline a non-existent call');
return;
}
RingRTC.decline(callId);
}
hangup(callId: CallId): void {
hangup(conversationId: string): void {
window.log.info('CallingClass.hangup()');
const callId = this.getCallIdForConversation(conversationId);
if (!callId) {
window.log.warn('Trying to hang up a non-existent call');
return;
}
RingRTC.hangup(callId);
}
setOutgoingAudio(callId: CallId, enabled: boolean): void {
setOutgoingAudio(conversationId: string, enabled: boolean): void {
const callId = this.getCallIdForConversation(conversationId);
if (!callId) {
window.log.warn('Trying to set outgoing audio for a non-existent call');
return;
}
RingRTC.setOutgoingAudio(callId, enabled);
}
setOutgoingVideo(callId: CallId, enabled: boolean): void {
setOutgoingVideo(conversationId: string, enabled: boolean): void {
const callId = this.getCallIdForConversation(conversationId);
if (!callId) {
window.log.warn('Trying to set outgoing video for a non-existent call');
return;
}
RingRTC.setOutgoingVideo(callId, enabled);
}
@ -673,8 +709,9 @@ export class CallingClass {
this.attachToCall(conversation, call);
this.uxActions.incomingCall({
callDetails: this.getAcceptedCallDetails(conversation, call),
this.uxActions.receiveIncomingCall({
conversationId: conversation.id,
isVideoCall: call.isVideoCall,
});
window.log.info('CallingClass.handleIncomingCall(): Proceeding');
@ -699,6 +736,8 @@ export class CallingClass {
}
private attachToCall(conversation: ConversationModel, call: Call): void {
this.callsByConversation[conversation.id] = call;
const { uxActions } = this;
if (!uxActions) {
return;
@ -709,23 +748,29 @@ export class CallingClass {
// eslint-disable-next-line no-param-reassign
call.handleStateChanged = () => {
if (call.state === CallState.Accepted) {
acceptedTime = Date.now();
acceptedTime = acceptedTime || Date.now();
} else if (call.state === CallState.Ended) {
this.addCallHistoryForEndedCall(conversation, call, acceptedTime);
this.stopDeviceReselectionTimer();
this.lastMediaDeviceSettings = undefined;
delete this.callsByConversation[conversation.id];
}
uxActions.callStateChange({
conversationId: conversation.id,
acceptedTime,
callState: call.state,
callDetails: this.getAcceptedCallDetails(conversation, call),
callEndedReason: call.endedReason,
isIncoming: call.isIncoming,
isVideoCall: call.isVideoCall,
title: conversation.getTitle(),
});
};
// eslint-disable-next-line no-param-reassign
call.handleRemoteVideoEnabled = () => {
uxActions.remoteVideoChange({
remoteVideoEnabled: call.remoteVideoEnabled,
conversationId: conversation.id,
hasVideo: call.remoteVideoEnabled,
});
};
}
@ -797,21 +842,6 @@ export class CallingClass {
};
}
private getAcceptedCallDetails(
conversation: ConversationModel,
call: Call
): CallDetailsType {
const conversationProps = conversation.format();
return {
...conversationProps,
acceptedTime: Date.now(),
callId: call.callId,
isIncoming: call.isIncoming,
isVideoCall: call.isVideoCall,
};
}
private addCallHistoryForEndedCall(
conversation: ConversationModel,
call: Call,

View File

@ -3,16 +3,18 @@
import { ThunkAction } from 'redux-thunk';
import { CallEndedReason } from 'ringrtc';
import { has, omit } from 'lodash';
import { getOwn } from '../../util/getOwn';
import { notify } from '../../services/notify';
import { calling } from '../../services/calling';
import { StateType as RootStateType } from '../reducer';
import { getActiveCall } from '../selectors/calling';
import {
CallingDeviceType,
CallState,
ChangeIODevicePayloadType,
MediaDeviceSettings,
} from '../../types/Calling';
import { ColorType } from '../../types/Colors';
import { callingTones } from '../../util/callingTones';
import { requestCameraPermissions } from '../../util/callingPermissions';
import {
@ -22,76 +24,82 @@ import {
// State
export type CallId = unknown;
export type CallDetailsType = {
acceptedTime?: number;
callId: CallId;
isIncoming: boolean;
isVideoCall: boolean;
id: string;
avatarPath?: string;
color?: ColorType;
name?: string;
phoneNumber?: string;
profileName?: string;
title: string;
};
export type CallingStateType = MediaDeviceSettings & {
callDetails?: CallDetailsType;
export interface DirectCallStateType {
conversationId: string;
callState?: CallState;
callEndedReason?: CallEndedReason;
isIncoming: boolean;
isVideoCall: boolean;
hasRemoteVideo?: boolean;
}
export interface ActiveCallStateType {
conversationId: string;
joinedAt?: number;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
hasRemoteVideo: boolean;
participantsList: boolean;
pip: boolean;
settingsDialogOpen: boolean;
}
export type CallingStateType = MediaDeviceSettings & {
callsByConversation: { [conversationId: string]: DirectCallStateType };
activeCallState?: ActiveCallStateType;
};
export type AcceptCallType = {
callId: CallId;
conversationId: string;
asVideoCall: boolean;
};
export type CallStateChangeType = {
conversationId: string;
acceptedTime?: number;
callState: CallState;
callDetails: CallDetailsType;
callEndedReason?: CallEndedReason;
isIncoming: boolean;
isVideoCall: boolean;
title: string;
};
export type DeclineCallType = {
callId: CallId;
conversationId: string;
};
export type HangUpType = {
callId: CallId;
conversationId: string;
};
export type IncomingCallType = {
callDetails: CallDetailsType;
conversationId: string;
isVideoCall: boolean;
};
export type OutgoingCallType = {
callDetails: CallDetailsType;
export type StartCallType = {
conversationId: string;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
};
export type RemoteVideoChangeType = {
remoteVideoEnabled: boolean;
conversationId: string;
hasVideo: boolean;
};
export type SetLocalAudioType = {
callId?: CallId;
enabled: boolean;
};
export type SetLocalVideoType = {
callId?: CallId;
enabled: boolean;
};
export type ShowCallLobbyType = {
conversationId: string;
isVideoCall: boolean;
};
export type SetLocalPreviewType = {
element: React.RefObject<HTMLVideoElement> | undefined;
};
@ -102,19 +110,6 @@ export type SetRendererCanvasType = {
// Helpers
export function isCallActive({
callDetails,
callState,
}: CallingStateType): boolean {
return Boolean(
callDetails &&
((!callDetails.isIncoming &&
(callState === CallState.Prering || callState === CallState.Ringing)) ||
callState === CallState.Accepted ||
callState === CallState.Reconnecting)
);
}
// Actions
const ACCEPT_CALL_PENDING = 'calling/ACCEPT_CALL_PENDING';
@ -129,7 +124,7 @@ const INCOMING_CALL = 'calling/INCOMING_CALL';
const OUTGOING_CALL = 'calling/OUTGOING_CALL';
const REFRESH_IO_DEVICES = 'calling/REFRESH_IO_DEVICES';
const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE';
const SET_LOCAL_AUDIO = 'calling/SET_LOCAL_AUDIO';
const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED';
const SET_LOCAL_VIDEO_FULFILLED = 'calling/SET_LOCAL_VIDEO_FULFILLED';
const START_CALL = 'calling/START_CALL';
const TOGGLE_PARTICIPANTS = 'calling/TOGGLE_PARTICIPANTS';
@ -147,7 +142,7 @@ type CancelCallActionType = {
type CallLobbyActionType = {
type: 'calling/SHOW_CALL_LOBBY';
payload: OutgoingCallType;
payload: ShowCallLobbyType;
};
type CallStateChangeFulfilledActionType = {
@ -182,7 +177,7 @@ type IncomingCallActionType = {
type OutgoingCallActionType = {
type: 'calling/OUTGOING_CALL';
payload: OutgoingCallType;
payload: StartCallType;
};
type RefreshIODevicesActionType = {
@ -196,7 +191,7 @@ type RemoteVideoChangeActionType = {
};
type SetLocalAudioActionType = {
type: 'calling/SET_LOCAL_AUDIO';
type: 'calling/SET_LOCAL_AUDIO_FULFILLED';
payload: SetLocalAudioType;
};
@ -205,8 +200,14 @@ type SetLocalVideoFulfilledActionType = {
payload: SetLocalVideoType;
};
type ShowCallLobbyActionType = {
type: 'calling/SHOW_CALL_LOBBY';
payload: ShowCallLobbyType;
};
type StartCallActionType = {
type: 'calling/START_CALL';
payload: StartCallType;
};
type ToggleParticipantsActionType = {
@ -236,6 +237,7 @@ export type CallingActionType =
| RemoteVideoChangeActionType
| SetLocalAudioActionType
| SetLocalVideoFulfilledActionType
| ShowCallLobbyActionType
| StartCallActionType
| ToggleParticipantsActionType
| TogglePipActionType
@ -253,7 +255,7 @@ function acceptCall(
});
try {
await calling.accept(payload.callId, payload.asVideoCall);
await calling.accept(payload.conversationId, payload.asVideoCall);
} catch (err) {
window.log.error(`Failed to acceptCall: ${err.stack}`);
}
@ -269,11 +271,10 @@ function callStateChange(
CallStateChangeFulfilledActionType
> {
return async dispatch => {
const { callDetails, callState } = payload;
const { isIncoming } = callDetails;
const { callState, isIncoming, title, isVideoCall } = payload;
if (callState === CallState.Ringing && isIncoming) {
await callingTones.playRingtone();
await showCallNotification(callDetails);
await showCallNotification(title, isVideoCall);
bounceAppIconStart();
}
if (callState !== CallState.Ringing) {
@ -315,12 +316,14 @@ function changeIODevice(
};
}
async function showCallNotification(callDetails: CallDetailsType) {
async function showCallNotification(
title: string,
isVideoCall: boolean
): Promise<void> {
const canNotify = await window.getCallSystemNotification();
if (!canNotify) {
return;
}
const { title, isVideoCall } = callDetails;
notify({
title,
icon: isVideoCall
@ -352,7 +355,7 @@ function cancelCall(): CancelCallActionType {
}
function declineCall(payload: DeclineCallType): DeclineCallActionType {
calling.decline(payload.callId);
calling.decline(payload.conversationId);
return {
type: DECLINE_CALL,
@ -361,7 +364,7 @@ function declineCall(payload: DeclineCallType): DeclineCallActionType {
}
function hangUp(payload: HangUpType): HangUpActionType {
calling.hangup(payload.callId);
calling.hangup(payload.conversationId);
return {
type: HANG_UP,
@ -369,14 +372,16 @@ function hangUp(payload: HangUpType): HangUpActionType {
};
}
function incomingCall(payload: IncomingCallType): IncomingCallActionType {
function receiveIncomingCall(
payload: IncomingCallType
): IncomingCallActionType {
return {
type: INCOMING_CALL,
payload,
};
}
function outgoingCall(payload: OutgoingCallType): OutgoingCallActionType {
function outgoingCall(payload: StartCallType): OutgoingCallActionType {
callingTones.playRingtone();
return {
@ -419,25 +424,32 @@ function setRendererCanvas(
};
}
function setLocalAudio(payload: SetLocalAudioType): SetLocalAudioActionType {
if (payload.callId) {
calling.setOutgoingAudio(payload.callId, payload.enabled);
}
function setLocalAudio(
payload: SetLocalAudioType
): ThunkAction<void, RootStateType, unknown, SetLocalAudioActionType> {
return (dispatch, getState) => {
const { conversationId } = getActiveCall(getState().calling) || {};
if (conversationId) {
calling.setOutgoingAudio(conversationId, payload.enabled);
}
return {
type: SET_LOCAL_AUDIO,
payload,
dispatch({
type: SET_LOCAL_AUDIO_FULFILLED,
payload,
});
};
}
function setLocalVideo(
payload: SetLocalVideoType
): ThunkAction<void, RootStateType, unknown, SetLocalVideoFulfilledActionType> {
return async dispatch => {
return async (dispatch, getState) => {
let enabled: boolean;
if (await requestCameraPermissions()) {
if (payload.callId) {
calling.setOutgoingVideo(payload.callId, payload.enabled);
const { conversationId, callState } =
getActiveCall(getState().calling) || {};
if (conversationId && callState) {
calling.setOutgoingVideo(conversationId, payload.enabled);
} else if (payload.enabled) {
calling.enableLocalCamera();
} else {
@ -458,22 +470,23 @@ function setLocalVideo(
};
}
function showCallLobby(payload: OutgoingCallType): CallLobbyActionType {
function showCallLobby(payload: ShowCallLobbyType): CallLobbyActionType {
return {
type: SHOW_CALL_LOBBY,
payload,
};
}
function startCall(payload: OutgoingCallType): StartCallActionType {
const { callDetails } = payload;
window.Signal.Services.calling.startOutgoingCall(
callDetails.id,
callDetails.isVideoCall
function startCall(payload: StartCallType): StartCallActionType {
calling.startOutgoingCall(
payload.conversationId,
payload.hasLocalAudio,
payload.hasLocalVideo
);
return {
type: START_CALL,
payload,
};
}
@ -503,7 +516,7 @@ export const actions = {
closeNeedPermissionScreen,
declineCall,
hangUp,
incomingCall,
receiveIncomingCall,
outgoingCall,
refreshIODevices,
remoteVideoChange,
@ -527,18 +540,24 @@ export function getEmptyState(): CallingStateType {
availableCameras: [],
availableMicrophones: [],
availableSpeakers: [],
callDetails: undefined,
callState: undefined,
callEndedReason: undefined,
hasLocalAudio: false,
hasLocalVideo: false,
hasRemoteVideo: false,
participantsList: false,
pip: false,
selectedCamera: undefined,
selectedMicrophone: undefined,
selectedSpeaker: undefined,
settingsDialogOpen: false,
callsByConversation: {},
activeCallState: undefined,
};
}
function removeConversationFromState(
state: CallingStateType,
conversationId: string
): CallingStateType {
return {
...(conversationId === state.activeCallState?.conversationId
? omit(state, 'activeCallState')
: state),
callsByConversation: omit(state.callsByConversation, conversationId),
};
}
@ -546,53 +565,126 @@ export function reducer(
state: CallingStateType = getEmptyState(),
action: CallingActionType
): CallingStateType {
const { callsByConversation } = state;
if (action.type === SHOW_CALL_LOBBY) {
return {
...state,
callDetails: action.payload.callDetails,
callState: undefined,
hasLocalAudio: true,
hasLocalVideo: action.payload.callDetails.isVideoCall,
callsByConversation: {
...callsByConversation,
[action.payload.conversationId]: {
conversationId: action.payload.conversationId,
isIncoming: false,
isVideoCall: action.payload.isVideoCall,
},
},
activeCallState: {
conversationId: action.payload.conversationId,
hasLocalAudio: true,
hasLocalVideo: action.payload.isVideoCall,
participantsList: false,
pip: false,
settingsDialogOpen: false,
},
};
}
if (action.type === START_CALL) {
return {
...state,
callState: CallState.Prering,
callsByConversation: {
...callsByConversation,
[action.payload.conversationId]: {
conversationId: action.payload.conversationId,
callState: CallState.Prering,
isIncoming: false,
isVideoCall: action.payload.hasLocalVideo,
},
},
activeCallState: {
conversationId: action.payload.conversationId,
hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo,
participantsList: false,
pip: false,
settingsDialogOpen: false,
},
};
}
if (action.type === ACCEPT_CALL_PENDING) {
if (!has(state.callsByConversation, action.payload.conversationId)) {
window.log.warn('Unable to accept a non-existent call');
return state;
}
return {
...state,
hasLocalAudio: true,
hasLocalVideo: action.payload.asVideoCall,
activeCallState: {
conversationId: action.payload.conversationId,
hasLocalAudio: true,
hasLocalVideo: action.payload.asVideoCall,
participantsList: false,
pip: false,
settingsDialogOpen: false,
},
};
}
if (
action.type === CANCEL_CALL ||
action.type === DECLINE_CALL ||
action.type === HANG_UP ||
action.type === CLOSE_NEED_PERMISSION_SCREEN
) {
return getEmptyState();
if (!state.activeCallState) {
window.log.warn('No active call to remove');
return state;
}
return removeConversationFromState(
state,
state.activeCallState.conversationId
);
}
if (action.type === DECLINE_CALL) {
return removeConversationFromState(state, action.payload.conversationId);
}
if (action.type === INCOMING_CALL) {
return {
...state,
callDetails: action.payload.callDetails,
callState: CallState.Prering,
callsByConversation: {
...callsByConversation,
[action.payload.conversationId]: {
conversationId: action.payload.conversationId,
callState: CallState.Prering,
isIncoming: true,
isVideoCall: action.payload.isVideoCall,
},
},
};
}
if (action.type === OUTGOING_CALL) {
return {
...state,
callDetails: action.payload.callDetails,
callState: CallState.Prering,
callsByConversation: {
...callsByConversation,
[action.payload.conversationId]: {
conversationId: action.payload.conversationId,
callState: CallState.Prering,
isIncoming: false,
isVideoCall: action.payload.hasLocalVideo,
},
},
activeCallState: {
conversationId: action.payload.conversationId,
hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo,
participantsList: false,
pip: false,
settingsDialogOpen: false,
},
};
}
@ -604,33 +696,91 @@ export function reducer(
action.payload.callEndedReason !==
CallEndedReason.RemoteHangupNeedPermission
) {
return getEmptyState();
return removeConversationFromState(state, action.payload.conversationId);
}
const call = getOwn(
state.callsByConversation,
action.payload.conversationId
);
if (!call) {
window.log.warn('Cannot update state for non-existent call');
return state;
}
let activeCallState: undefined | ActiveCallStateType;
if (
state.activeCallState?.conversationId === action.payload.conversationId
) {
activeCallState = {
...state.activeCallState,
joinedAt: action.payload.acceptedTime,
};
} else {
({ activeCallState } = state);
}
return {
...state,
callState: action.payload.callState,
callEndedReason: action.payload.callEndedReason,
callsByConversation: {
...callsByConversation,
[action.payload.conversationId]: {
...call,
callState: action.payload.callState,
callEndedReason: action.payload.callEndedReason,
},
},
activeCallState,
};
}
if (action.type === REMOTE_VIDEO_CHANGE) {
const { conversationId, hasVideo } = action.payload;
const call = getOwn(state.callsByConversation, conversationId);
if (!call) {
window.log.warn('Cannot update remote video for a non-existent call');
return state;
}
return {
...state,
hasRemoteVideo: action.payload.remoteVideoEnabled,
callsByConversation: {
...callsByConversation,
[conversationId]: {
...call,
hasRemoteVideo: hasVideo,
},
},
};
}
if (action.type === SET_LOCAL_AUDIO) {
if (action.type === SET_LOCAL_AUDIO_FULFILLED) {
if (!state.activeCallState) {
window.log.warn('Cannot set local audio with no active call');
return state;
}
return {
...state,
hasLocalAudio: action.payload.enabled,
activeCallState: {
...state.activeCallState,
hasLocalAudio: action.payload.enabled,
},
};
}
if (action.type === SET_LOCAL_VIDEO_FULFILLED) {
if (!state.activeCallState) {
window.log.warn('Cannot set local video with no active call');
return state;
}
return {
...state,
hasLocalVideo: action.payload.enabled,
activeCallState: {
...state.activeCallState,
hasLocalVideo: action.payload.enabled,
},
};
}
@ -674,23 +824,52 @@ export function reducer(
}
if (action.type === TOGGLE_SETTINGS) {
const { activeCallState } = state;
if (!activeCallState) {
window.log.warn('Cannot toggle settings when there is no active call');
return state;
}
return {
...state,
settingsDialogOpen: !state.settingsDialogOpen,
activeCallState: {
...activeCallState,
settingsDialogOpen: !activeCallState.settingsDialogOpen,
},
};
}
if (action.type === TOGGLE_PARTICIPANTS) {
const { activeCallState } = state;
if (!activeCallState) {
window.log.warn(
'Cannot toggle participants list when there is no active call'
);
return state;
}
return {
...state,
participantsList: !state.participantsList,
activeCallState: {
...activeCallState,
participantsList: !activeCallState.participantsList,
},
};
}
if (action.type === TOGGLE_PIP) {
const { activeCallState } = state;
if (!activeCallState) {
window.log.warn('Cannot toggle PiP when there is no active call');
return state;
}
return {
...state,
pip: !state.pip,
activeCallState: {
...activeCallState,
pip: !activeCallState.pip,
},
};
}

View File

@ -5,3 +5,10 @@ export type NoopActionType = {
type: 'NOOP';
payload: null;
};
export function noopAction(): NoopActionType {
return {
type: 'NOOP',
payload: null,
};
}

View File

@ -0,0 +1,33 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createSelector } from 'reselect';
import { CallingStateType } from '../ducks/calling';
import { CallState } from '../../types/Calling';
import { getOwn } from '../../util/getOwn';
const getActiveCallState = (state: CallingStateType) => state.activeCallState;
const getCallsByConversation = (state: CallingStateType) =>
state.callsByConversation;
// In theory, there could be multiple incoming calls. In practice, neither RingRTC nor the
// UI are ready to handle this.
export const getIncomingCall = createSelector(
getCallsByConversation,
callsByConversation =>
Object.values(callsByConversation).find(
call => call.isIncoming && call.callState === CallState.Ringing
)
);
export const getActiveCall = createSelector(
getActiveCallState,
getCallsByConversation,
(activeCallState, callsByConversation) =>
activeCallState &&
getOwn(callsByConversation, activeCallState.conversationId)
);
export const isCallActive = createSelector(getActiveCall, Boolean);

View File

@ -5,7 +5,8 @@ import React from 'react';
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { CallManager } from '../../components/CallManager';
import { getMe } from '../selectors/conversations';
import { getMe, getConversationSelector } from '../selectors/conversations';
import { getActiveCall, getIncomingCall } from '../selectors/calling';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
@ -16,16 +17,64 @@ function renderDeviceSelection(): JSX.Element {
return <SmartCallingDeviceSelection />;
}
const mapStateToProps = (state: StateType) => {
const mapStateToActiveCallProp = (state: StateType) => {
const { calling } = state;
const { activeCallState } = calling;
if (!activeCallState) {
return undefined;
}
const call = getActiveCall(calling);
if (!call) {
window.log.error(
'There was an active call state but no corresponding call'
);
return undefined;
}
const conversation = getConversationSelector(state)(
activeCallState.conversationId
);
if (!conversation) {
window.log.error('The active call has no corresponding conversation');
return undefined;
}
return {
...calling,
i18n: getIntl(state),
me: getMe(state),
renderDeviceSelection,
call,
activeCallState,
conversation,
};
};
const mapStateToIncomingCallProp = (state: StateType) => {
const call = getIncomingCall(state.calling);
if (!call) {
return undefined;
}
const conversation = getConversationSelector(state)(call.conversationId);
if (!conversation) {
window.log.error('The incoming call has no corresponding conversation');
return undefined;
}
return {
call,
conversation,
};
};
const mapStateToProps = (state: StateType) => ({
activeCall: mapStateToActiveCallProp(state),
availableCameras: state.calling.availableCameras,
i18n: getIntl(state),
incomingCall: mapStateToIncomingCallProp(state),
me: getMe(state),
renderDeviceSelection,
});
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartCallManager = smart(CallManager);

View File

@ -6,7 +6,7 @@ import { pick } from 'lodash';
import { ConversationHeader } from '../../components/conversation/ConversationHeader';
import { getConversationSelector } from '../selectors/conversations';
import { StateType } from '../reducer';
import { isCallActive } from '../ducks/calling';
import { isCallActive } from '../selectors/calling';
import { getIntl } from '../selectors/user';
export interface OwnProps {

View File

@ -2,28 +2,309 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import {
CallDetailsType,
actions,
getEmptyState,
isCallActive,
reducer,
} from '../../../state/ducks/calling';
import * as sinon from 'sinon';
import { reducer as rootReducer } from '../../../state/reducer';
import { noopAction } from '../../../state/ducks/noop';
import { actions, getEmptyState, reducer } from '../../../state/ducks/calling';
import { calling as callingService } from '../../../services/calling';
import { CallState } from '../../../types/Calling';
describe('calling duck', () => {
const stateWithDirectCall = {
...getEmptyState(),
callsByConversation: {
'fake-direct-call-conversation-id': {
conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Accepted,
isIncoming: false,
isVideoCall: false,
hasRemoteVideo: false,
},
},
};
const stateWithActiveDirectCall = {
...stateWithDirectCall,
activeCallState: {
conversationId: 'fake-direct-call-conversation-id',
hasLocalAudio: true,
hasLocalVideo: false,
participantsList: false,
pip: false,
settingsDialogOpen: false,
},
};
const stateWithIncomingDirectCall = {
...getEmptyState(),
callsByConversation: {
'fake-direct-call-conversation-id': {
conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Ringing,
isIncoming: true,
isVideoCall: false,
hasRemoteVideo: false,
},
},
};
const getEmptyRootState = () => rootReducer(undefined, noopAction());
beforeEach(function beforeEach() {
this.sandbox = sinon.createSandbox();
});
afterEach(function afterEach() {
this.sandbox.restore();
});
describe('actions', () => {
describe('acceptCall', () => {
const { acceptCall } = actions;
beforeEach(function beforeEach() {
this.callingServiceAccept = this.sandbox
.stub(callingService, 'accept')
.resolves();
});
it('dispatches an ACCEPT_CALL_PENDING action', async () => {
const dispatch = sinon.spy();
await acceptCall({
conversationId: '123',
asVideoCall: true,
})(dispatch, getEmptyRootState, null);
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWith(dispatch, {
type: 'calling/ACCEPT_CALL_PENDING',
payload: {
conversationId: '123',
asVideoCall: true,
},
});
await acceptCall({
conversationId: '456',
asVideoCall: false,
})(dispatch, getEmptyRootState, null);
sinon.assert.calledTwice(dispatch);
sinon.assert.calledWith(dispatch, {
type: 'calling/ACCEPT_CALL_PENDING',
payload: {
conversationId: '456',
asVideoCall: false,
},
});
});
it('asks the calling service to accept the call', async function test() {
const dispatch = sinon.spy();
await acceptCall({
conversationId: '123',
asVideoCall: true,
})(dispatch, getEmptyRootState, null);
sinon.assert.calledOnce(this.callingServiceAccept);
sinon.assert.calledWith(this.callingServiceAccept, '123', true);
await acceptCall({
conversationId: '456',
asVideoCall: false,
})(dispatch, getEmptyRootState, null);
sinon.assert.calledTwice(this.callingServiceAccept);
sinon.assert.calledWith(this.callingServiceAccept, '456', false);
});
it('updates the active call state with ACCEPT_CALL_PENDING', async () => {
const dispatch = sinon.spy();
await acceptCall({
conversationId: 'fake-direct-call-conversation-id',
asVideoCall: true,
})(dispatch, getEmptyRootState, null);
const action = dispatch.getCall(0).args[0];
const result = reducer(stateWithIncomingDirectCall, action);
assert.deepEqual(result.activeCallState, {
conversationId: 'fake-direct-call-conversation-id',
hasLocalAudio: true,
hasLocalVideo: true,
participantsList: false,
pip: false,
settingsDialogOpen: false,
});
});
});
describe('setLocalAudio', () => {
const { setLocalAudio } = actions;
beforeEach(function beforeEach() {
this.callingServiceSetOutgoingAudio = this.sandbox.stub(
callingService,
'setOutgoingAudio'
);
});
it('dispatches a SET_LOCAL_AUDIO_FULFILLED action', () => {
const dispatch = sinon.spy();
setLocalAudio({ enabled: true })(dispatch, getEmptyRootState, null);
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWith(dispatch, {
type: 'calling/SET_LOCAL_AUDIO_FULFILLED',
payload: { enabled: true },
});
});
it('updates the outgoing audio for the active call', function test() {
const dispatch = sinon.spy();
setLocalAudio({ enabled: false })(
dispatch,
() => ({
...getEmptyRootState(),
calling: stateWithActiveDirectCall,
}),
null
);
sinon.assert.calledOnce(this.callingServiceSetOutgoingAudio);
sinon.assert.calledWith(
this.callingServiceSetOutgoingAudio,
'fake-direct-call-conversation-id',
false
);
setLocalAudio({ enabled: true })(
dispatch,
() => ({
...getEmptyRootState(),
calling: stateWithActiveDirectCall,
}),
null
);
sinon.assert.calledTwice(this.callingServiceSetOutgoingAudio);
sinon.assert.calledWith(
this.callingServiceSetOutgoingAudio,
'fake-direct-call-conversation-id',
true
);
});
it('updates the local audio state with SET_LOCAL_AUDIO_FULFILLED', () => {
const dispatch = sinon.spy();
setLocalAudio({ enabled: false })(dispatch, getEmptyRootState, null);
const action = dispatch.getCall(0).args[0];
const result = reducer(stateWithActiveDirectCall, action);
assert.isFalse(result.activeCallState?.hasLocalAudio);
});
});
describe('showCallLobby', () => {
const { showCallLobby } = actions;
it('saves the call and makes it active', () => {
const result = reducer(
getEmptyState(),
showCallLobby({
conversationId: 'fake-conversation-id',
isVideoCall: true,
})
);
assert.deepEqual(result.callsByConversation['fake-conversation-id'], {
conversationId: 'fake-conversation-id',
isIncoming: false,
isVideoCall: true,
});
assert.deepEqual(result.activeCallState, {
conversationId: 'fake-conversation-id',
hasLocalAudio: true,
hasLocalVideo: true,
participantsList: false,
pip: false,
settingsDialogOpen: false,
});
});
});
describe('startCall', () => {
const { startCall } = actions;
beforeEach(function beforeEach() {
this.callingStartOutgoingCall = this.sandbox.stub(
callingService,
'startOutgoingCall'
);
});
it('asks the calling service to start an outgoing call', function test() {
startCall({
conversationId: '123',
hasLocalAudio: true,
hasLocalVideo: false,
});
sinon.assert.calledOnce(this.callingStartOutgoingCall);
sinon.assert.calledWith(
this.callingStartOutgoingCall,
'123',
true,
false
);
});
it('saves the call and makes it active', () => {
const result = reducer(
getEmptyState(),
startCall({
conversationId: 'fake-conversation-id',
hasLocalAudio: true,
hasLocalVideo: false,
})
);
assert.deepEqual(result.callsByConversation['fake-conversation-id'], {
conversationId: 'fake-conversation-id',
callState: CallState.Prering,
isIncoming: false,
isVideoCall: false,
});
assert.deepEqual(result.activeCallState, {
conversationId: 'fake-conversation-id',
hasLocalAudio: true,
hasLocalVideo: false,
participantsList: false,
pip: false,
settingsDialogOpen: false,
});
});
});
describe('toggleSettings', () => {
const { toggleSettings } = actions;
it('toggles the settings dialog', () => {
const afterOneToggle = reducer(getEmptyState(), toggleSettings());
const afterOneToggle = reducer(
stateWithActiveDirectCall,
toggleSettings()
);
const afterTwoToggles = reducer(afterOneToggle, toggleSettings());
const afterThreeToggles = reducer(afterTwoToggles, toggleSettings());
assert.isTrue(afterOneToggle.settingsDialogOpen);
assert.isFalse(afterTwoToggles.settingsDialogOpen);
assert.isTrue(afterThreeToggles.settingsDialogOpen);
assert.isTrue(afterOneToggle.activeCallState?.settingsDialogOpen);
assert.isFalse(afterTwoToggles.activeCallState?.settingsDialogOpen);
assert.isTrue(afterThreeToggles.activeCallState?.settingsDialogOpen);
});
});
@ -31,16 +312,19 @@ describe('calling duck', () => {
const { toggleParticipants } = actions;
it('toggles the participants list', () => {
const afterOneToggle = reducer(getEmptyState(), toggleParticipants());
const afterOneToggle = reducer(
stateWithActiveDirectCall,
toggleParticipants()
);
const afterTwoToggles = reducer(afterOneToggle, toggleParticipants());
const afterThreeToggles = reducer(
afterTwoToggles,
toggleParticipants()
);
assert.isTrue(afterOneToggle.participantsList);
assert.isFalse(afterTwoToggles.participantsList);
assert.isTrue(afterThreeToggles.participantsList);
assert.isTrue(afterOneToggle.activeCallState?.participantsList);
assert.isFalse(afterTwoToggles.activeCallState?.participantsList);
assert.isTrue(afterThreeToggles.activeCallState?.participantsList);
});
});
@ -48,111 +332,13 @@ describe('calling duck', () => {
const { togglePip } = actions;
it('toggles the PiP', () => {
const afterOneToggle = reducer(getEmptyState(), togglePip());
const afterOneToggle = reducer(stateWithActiveDirectCall, togglePip());
const afterTwoToggles = reducer(afterOneToggle, togglePip());
const afterThreeToggles = reducer(afterTwoToggles, togglePip());
assert.isTrue(afterOneToggle.pip);
assert.isFalse(afterTwoToggles.pip);
assert.isTrue(afterThreeToggles.pip);
});
});
});
describe('helpers', () => {
describe('isCallActive', () => {
const fakeCallDetails: CallDetailsType = {
id: 'fake-call',
title: 'Fake Call',
callId: 123,
isIncoming: false,
isVideoCall: false,
};
it('returns false if there are no call details', () => {
assert.isFalse(isCallActive(getEmptyState()));
});
it('returns false if an incoming call is in a pre-reing state', () => {
assert.isFalse(
isCallActive({
...getEmptyState(),
callDetails: {
...fakeCallDetails,
isIncoming: true,
},
callState: CallState.Prering,
})
);
});
it('returns true if an outgoing call is in a pre-reing state', () => {
assert.isTrue(
isCallActive({
...getEmptyState(),
callDetails: {
...fakeCallDetails,
isIncoming: false,
},
callState: CallState.Prering,
})
);
});
it('returns false if an incoming call is ringing', () => {
assert.isFalse(
isCallActive({
...getEmptyState(),
callDetails: {
...fakeCallDetails,
isIncoming: true,
},
callState: CallState.Ringing,
})
);
});
it('returns true if an outgoing call is ringing', () => {
assert.isTrue(
isCallActive({
...getEmptyState(),
callDetails: {
...fakeCallDetails,
isIncoming: false,
},
callState: CallState.Ringing,
})
);
});
it('returns true if a call is in an accepted state', () => {
assert.isTrue(
isCallActive({
...getEmptyState(),
callDetails: fakeCallDetails,
callState: CallState.Accepted,
})
);
});
it('returns true if a call is in a reconnecting state', () => {
assert.isTrue(
isCallActive({
...getEmptyState(),
callDetails: fakeCallDetails,
callState: CallState.Reconnecting,
})
);
});
it('returns false if a call is in an ended state', () => {
assert.isFalse(
isCallActive({
...getEmptyState(),
callDetails: fakeCallDetails,
callState: CallState.Ended,
})
);
assert.isTrue(afterOneToggle.activeCallState?.pip);
assert.isFalse(afterTwoToggles.activeCallState?.pip);
assert.isTrue(afterThreeToggles.activeCallState?.pip);
});
});
});

View File

@ -0,0 +1,106 @@
// Copyright 2019-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { CallState } from '../../../types/Calling';
import {
getIncomingCall,
getActiveCall,
isCallActive,
} from '../../../state/selectors/calling';
import { getEmptyState } from '../../../state/ducks/calling';
describe('state/selectors/calling', () => {
const stateWithDirectCall = {
...getEmptyState(),
callsByConversation: {
'fake-direct-call-conversation-id': {
conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Accepted,
isIncoming: false,
isVideoCall: false,
hasRemoteVideo: false,
},
},
};
const stateWithActiveDirectCall = {
...stateWithDirectCall,
activeCallState: {
conversationId: 'fake-direct-call-conversation-id',
hasLocalAudio: true,
hasLocalVideo: false,
participantsList: false,
pip: false,
settingsDialogOpen: false,
},
};
const stateWithIncomingDirectCall = {
...getEmptyState(),
callsByConversation: {
'fake-direct-call-conversation-id': {
conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Ringing,
isIncoming: true,
isVideoCall: false,
hasRemoteVideo: false,
},
},
};
describe('getIncomingCall', () => {
it('returns undefined if there are no calls', () => {
assert.isUndefined(getIncomingCall(getEmptyState()));
});
it('returns undefined if there is no incoming call', () => {
assert.isUndefined(getIncomingCall(stateWithDirectCall));
assert.isUndefined(getIncomingCall(stateWithActiveDirectCall));
});
it('returns the incoming call', () => {
assert.deepEqual(getIncomingCall(stateWithIncomingDirectCall), {
conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Ringing,
isIncoming: true,
isVideoCall: false,
hasRemoteVideo: false,
});
});
});
describe('getActiveCall', () => {
it('returns undefined if there are no calls', () => {
assert.isUndefined(getActiveCall(getEmptyState()));
});
it('returns undefined if there is no active call', () => {
assert.isUndefined(getActiveCall(stateWithDirectCall));
});
it('returns the active call', () => {
assert.deepEqual(getActiveCall(stateWithActiveDirectCall), {
conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Accepted,
isIncoming: false,
isVideoCall: false,
hasRemoteVideo: false,
});
});
});
describe('isCallActive', () => {
it('returns false if there are no calls', () => {
assert.isFalse(isCallActive(getEmptyState()));
});
it('returns false if there is no active call', () => {
assert.isFalse(isCallActive(stateWithDirectCall));
});
it('returns true if there is an active call', () => {
assert.isTrue(isCallActive(stateWithActiveDirectCall));
});
});
});

View File

@ -14391,7 +14391,7 @@
"rule": "React-useRef",
"path": "ts/components/CallScreen.js",
"line": " const localVideoRef = react_1.useRef(null);",
"lineNumber": 44,
"lineNumber": 35,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T21:35:52.858Z",
"reasonDetail": "Used to get the local video element for rendering."
@ -14400,7 +14400,7 @@
"rule": "React-useRef",
"path": "ts/components/CallScreen.js",
"line": " const remoteVideoRef = react_1.useRef(null);",
"lineNumber": 45,
"lineNumber": 36,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T21:35:52.858Z",
"reasonDetail": "Used to get the remote video element for rendering."
@ -14418,7 +14418,7 @@
"rule": "React-useRef",
"path": "ts/components/CallingLobby.tsx",
"line": " const localVideoRef = React.useRef(null);",
"lineNumber": 50,
"lineNumber": 54,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Used to get the local video element for rendering."
@ -14427,7 +14427,7 @@
"rule": "React-useRef",
"path": "ts/components/CallingPip.js",
"line": " const videoContainerRef = react_1.default.useRef(null);",
"lineNumber": 23,
"lineNumber": 22,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Element is measured. Its HTML is not used."
@ -14436,7 +14436,7 @@
"rule": "React-useRef",
"path": "ts/components/CallingPip.js",
"line": " const localVideoRef = react_1.default.useRef(null);",
"lineNumber": 24,
"lineNumber": 23,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Used to get the local video element for rendering."
@ -14445,7 +14445,7 @@
"rule": "React-useRef",
"path": "ts/components/CallingPip.js",
"line": " const remoteVideoRef = react_1.default.useRef(null);",
"lineNumber": 25,
"lineNumber": 24,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Used to get the remote video element for rendering."
@ -14454,7 +14454,7 @@
"rule": "React-useRef",
"path": "ts/components/CallingPip.tsx",
"line": " const videoContainerRef = React.useRef(null);",
"lineNumber": 76,
"lineNumber": 89,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Element is measured. Its HTML is not used."
@ -14463,7 +14463,7 @@
"rule": "React-useRef",
"path": "ts/components/CallingPip.tsx",
"line": " const localVideoRef = React.useRef(null);",
"lineNumber": 77,
"lineNumber": 90,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Used to get the local video element for rendering."
@ -14472,7 +14472,7 @@
"rule": "React-useRef",
"path": "ts/components/CallingPip.tsx",
"line": " const remoteVideoRef = React.useRef(null);",
"lineNumber": 78,
"lineNumber": 91,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Used to get the remote video element for rendering."
@ -15142,4 +15142,4 @@
"reasonCategory": "falseMatch",
"updated": "2020-09-08T23:07:22.682Z"
}
]
]