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/models/messages_test');
require('./ts/test-electron/linkPreviews/linkPreviewFetch_test'); require('./ts/test-electron/linkPreviews/linkPreviewFetch_test');
require('./ts/test-electron/state/ducks/calling_test'); require('./ts/test-electron/state/ducks/calling_test');
require('./ts/test-electron/state/selectors/calling_test');
delete window.describe; delete window.describe;

View file

@ -13,11 +13,7 @@ import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
const callDetails = { const conversation = {
callId: 0,
isIncoming: true,
isVideoCall: true,
id: '3051234567', id: '3051234567',
avatarPath: undefined, avatarPath: undefined,
color: 'ultramarine' as ColorType, color: 'ultramarine' as ColorType,
@ -30,27 +26,20 @@ const callDetails = {
const defaultProps = { const defaultProps = {
availableCameras: [], availableCameras: [],
acceptCall: action('accept-call'), acceptCall: action('accept-call'),
callDetails,
callState: CallState.Accepted,
cancelCall: action('cancel-call'), cancelCall: action('cancel-call'),
closeNeedPermissionScreen: action('close-need-permission-screen'), closeNeedPermissionScreen: action('close-need-permission-screen'),
declineCall: action('decline-call'), declineCall: action('decline-call'),
hangUp: action('hang-up'), hangUp: action('hang-up'),
hasLocalAudio: true,
hasLocalVideo: true,
hasRemoteVideo: true,
i18n, i18n,
me: { me: {
color: 'ultramarine' as ColorType, color: 'ultramarine' as ColorType,
title: 'Morty Smith', title: 'Morty Smith',
}, },
pip: false,
renderDeviceSelection: () => <div />, renderDeviceSelection: () => <div />,
setLocalAudio: action('set-local-audio'), setLocalAudio: action('set-local-audio'),
setLocalPreview: action('set-local-preview'), setLocalPreview: action('set-local-preview'),
setLocalVideo: action('set-local-video'), setLocalVideo: action('set-local-video'),
setRendererCanvas: action('set-renderer-canvas'), setRendererCanvas: action('set-renderer-canvas'),
settingsDialogOpen: false,
startCall: action('start-call'), startCall: action('start-call'),
toggleParticipants: action('toggle-participants'), toggleParticipants: action('toggle-participants'),
togglePip: action('toggle-pip'), togglePip: action('toggle-pip'),
@ -59,20 +48,71 @@ const defaultProps = {
const permutations = [ const permutations = [
{ {
title: 'Call Manager (ongoing)', title: 'Call Manager (no call)',
props: {}, 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)', title: 'Call Manager (ringing)',
props: { props: {
incomingCall: {
call: {
conversationId: '3051234567',
callState: CallState.Ringing, callState: CallState.Ringing,
isIncoming: true,
isVideoCall: true,
hasRemoteVideo: true,
},
conversation,
},
}, },
}, },
{ {
title: 'Call Manager (call request needed)', title: 'Call Manager (call request needed)',
props: { props: {
activeCall: {
call: {
conversationId: '3051234567',
callState: CallState.Ended, callState: CallState.Ended,
callEndedReason: CallEndedReason.RemoteHangupNeedPermission, 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,80 +5,119 @@ import React from 'react';
import { CallingPip } from './CallingPip'; import { CallingPip } from './CallingPip';
import { CallNeedPermissionScreen } from './CallNeedPermissionScreen'; import { CallNeedPermissionScreen } from './CallNeedPermissionScreen';
import { CallingLobby } from './CallingLobby'; import { CallingLobby } from './CallingLobby';
import { CallScreen, PropsType as CallScreenPropsType } from './CallScreen'; import { CallScreen } from './CallScreen';
import { import { IncomingCallBar } from './IncomingCallBar';
IncomingCallBar,
PropsType as IncomingCallBarPropsType,
} from './IncomingCallBar';
import { CallState, CallEndedReason } from '../types/Calling'; 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>; availableCameras: Array<MediaDeviceInfo>;
callDetails?: CallDetailsType;
callEndedReason?: CallEndedReason;
callState?: CallState;
cancelCall: () => void; cancelCall: () => void;
pip: boolean;
closeNeedPermissionScreen: () => void; closeNeedPermissionScreen: () => void;
incomingCall?: {
call: DirectCallStateType;
conversation: {
id: string;
avatarPath?: string;
color?: ColorType;
title: string;
name?: string;
phoneNumber?: string;
profileName?: string;
};
};
renderDeviceSelection: () => JSX.Element; renderDeviceSelection: () => JSX.Element;
settingsDialogOpen: boolean; startCall: (payload: StartCallType) => void;
startCall: (payload: OutgoingCallType) => void;
toggleParticipants: () => void; toggleParticipants: () => void;
}; acceptCall: (_: AcceptCallType) => void;
declineCall: (_: DeclineCallType) => void;
type PropsType = IncomingCallBarPropsType & i18n: LocalizerType;
CallScreenPropsType & me: {
CallManagerPropsType; 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 = ({ export const CallManager = ({
acceptCall, acceptCall,
activeCall,
availableCameras, availableCameras,
callDetails,
callState,
callEndedReason,
cancelCall, cancelCall,
closeNeedPermissionScreen, closeNeedPermissionScreen,
declineCall, declineCall,
hangUp, hangUp,
hasLocalAudio,
hasLocalVideo,
hasRemoteVideo,
i18n, i18n,
incomingCall,
me, me,
pip,
renderDeviceSelection, renderDeviceSelection,
setLocalAudio, setLocalAudio,
setLocalPreview, setLocalPreview,
setLocalVideo, setLocalVideo,
setRendererCanvas, setRendererCanvas,
settingsDialogOpen,
startCall, startCall,
toggleParticipants, toggleParticipants,
togglePip, togglePip,
toggleSettings, toggleSettings,
}: PropsType): JSX.Element | null => { }: PropsType): JSX.Element | null => {
if (!callDetails) { if (activeCall) {
return null; const { call, activeCallState, conversation } = activeCall;
} const { callState, callEndedReason } = call;
const incoming = callDetails.isIncoming; const {
const outgoing = !incoming; joinedAt,
const ongoing = hasLocalAudio,
callState === CallState.Accepted || callState === CallState.Reconnecting; hasLocalVideo,
const ringing = callState === CallState.Ringing; settingsDialogOpen,
const ended = callState === CallState.Ended; pip,
} = activeCallState;
const ended = callState === CallState.Ended;
if (ended) { if (ended) {
if (callEndedReason === CallEndedReason.RemoteHangupNeedPermission) { if (callEndedReason === CallEndedReason.RemoteHangupNeedPermission) {
return ( return (
<CallNeedPermissionScreen <CallNeedPermissionScreen
close={closeNeedPermissionScreen} close={closeNeedPermissionScreen}
callDetails={callDetails} conversation={conversation}
i18n={i18n} i18n={i18n}
/> />
); );
} }
return null;
} }
if (!callState) { if (!callState) {
@ -86,14 +125,18 @@ export const CallManager = ({
<> <>
<CallingLobby <CallingLobby
availableCameras={availableCameras} availableCameras={availableCameras}
callDetails={callDetails} conversation={conversation}
hasLocalAudio={hasLocalAudio} hasLocalAudio={hasLocalAudio}
hasLocalVideo={hasLocalVideo} hasLocalVideo={hasLocalVideo}
i18n={i18n} i18n={i18n}
isGroupCall={false} isGroupCall={false}
onCallCanceled={cancelCall} onCallCanceled={cancelCall}
onJoinCall={() => { onJoinCall={() => {
startCall({ callDetails }); startCall({
conversationId: conversation.id,
hasLocalAudio,
hasLocalVideo,
});
}} }}
setLocalPreview={setLocalPreview} setLocalPreview={setLocalPreview}
setLocalAudio={setLocalAudio} setLocalAudio={setLocalAudio}
@ -106,11 +149,12 @@ export const CallManager = ({
); );
} }
if (outgoing || ongoing) { const hasRemoteVideo = Boolean(call.hasRemoteVideo);
if (pip) { if (pip) {
return ( return (
<CallingPip <CallingPip
callDetails={callDetails} conversation={conversation}
hangUp={hangUp} hangUp={hangUp}
hasLocalVideo={hasLocalVideo} hasLocalVideo={hasLocalVideo}
hasRemoteVideo={hasRemoteVideo} hasRemoteVideo={hasRemoteVideo}
@ -125,12 +169,13 @@ export const CallManager = ({
return ( return (
<> <>
<CallScreen <CallScreen
callDetails={callDetails} conversation={conversation}
callState={callState} callState={callState}
hangUp={hangUp} hangUp={hangUp}
hasLocalAudio={hasLocalAudio} hasLocalAudio={hasLocalAudio}
hasLocalVideo={hasLocalVideo} hasLocalVideo={hasLocalVideo}
i18n={i18n} i18n={i18n}
joinedAt={joinedAt}
me={me} me={me}
hasRemoteVideo={hasRemoteVideo} hasRemoteVideo={hasRemoteVideo}
setLocalPreview={setLocalPreview} 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 ( return (
<IncomingCallBar <IncomingCallBar
acceptCall={acceptCall} acceptCall={acceptCall}
callDetails={callDetails}
declineCall={declineCall} declineCall={declineCall}
i18n={i18n} i18n={i18n}
call={incomingCall.call}
conversation={incomingCall.conversation}
/> />
); );
} }
// Incoming && Prering
return null; return null;
}; };

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,6 @@
import React from 'react'; import React from 'react';
import { import {
CallDetailsType,
SetLocalAudioType, SetLocalAudioType,
SetLocalPreviewType, SetLocalPreviewType,
SetLocalVideoType, SetLocalVideoType,
@ -15,10 +14,15 @@ import {
} from './CallingButton'; } from './CallingButton';
import { CallBackgroundBlur } from './CallBackgroundBlur'; import { CallBackgroundBlur } from './CallBackgroundBlur';
import { LocalizerType } from '../types/Util'; import { LocalizerType } from '../types/Util';
import { ColorType } from '../types/Colors';
export type PropsType = { export type PropsType = {
availableCameras: Array<MediaDeviceInfo>; availableCameras: Array<MediaDeviceInfo>;
callDetails: CallDetailsType; conversation: {
avatarPath?: string;
color?: ColorType;
title: string;
};
hasLocalAudio: boolean; hasLocalAudio: boolean;
hasLocalVideo: boolean; hasLocalVideo: boolean;
i18n: LocalizerType; i18n: LocalizerType;
@ -34,7 +38,7 @@ export type PropsType = {
export const CallingLobby = ({ export const CallingLobby = ({
availableCameras, availableCameras,
callDetails, conversation,
hasLocalAudio, hasLocalAudio,
hasLocalVideo, hasLocalVideo,
i18n, i18n,
@ -50,20 +54,12 @@ export const CallingLobby = ({
const localVideoRef = React.useRef(null); const localVideoRef = React.useRef(null);
const toggleAudio = React.useCallback((): void => { const toggleAudio = React.useCallback((): void => {
if (!callDetails) {
return;
}
setLocalAudio({ enabled: !hasLocalAudio }); setLocalAudio({ enabled: !hasLocalAudio });
}, [callDetails, hasLocalAudio, setLocalAudio]); }, [hasLocalAudio, setLocalAudio]);
const toggleVideo = React.useCallback((): void => { const toggleVideo = React.useCallback((): void => {
if (!callDetails) {
return;
}
setLocalVideo({ enabled: !hasLocalVideo }); setLocalVideo({ enabled: !hasLocalVideo });
}, [callDetails, hasLocalVideo, setLocalVideo]); }, [hasLocalVideo, setLocalVideo]);
React.useEffect(() => { React.useEffect(() => {
setLocalPreview({ element: localVideoRef }); setLocalPreview({ element: localVideoRef });
@ -112,7 +108,7 @@ export const CallingLobby = ({
<div className="module-calling__container"> <div className="module-calling__container">
<div className="module-calling__header"> <div className="module-calling__header">
<div className="module-calling__header--header-name"> <div className="module-calling__header--header-name">
{callDetails.title} {conversation.title}
</div> </div>
<div className="module-calling-tools"> <div className="module-calling-tools">
{isGroupCall ? ( {isGroupCall ? (
@ -136,8 +132,8 @@ export const CallingLobby = ({
<video ref={localVideoRef} autoPlay /> <video ref={localVideoRef} autoPlay />
) : ( ) : (
<CallBackgroundBlur <CallBackgroundBlur
avatarPath={callDetails.avatarPath} avatarPath={conversation.avatarPath}
color={callDetails.color} color={conversation.color}
> >
<div className="module-calling-lobby__video-off--icon" /> <div className="module-calling-lobby__video-off--icon" />
<span className="module-calling-lobby__video-off--text"> <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 i18n = setupI18n('en', enMessages);
const callDetails = { const conversation = {
callId: 0,
isIncoming: true,
isVideoCall: true,
id: '3051234567', id: '3051234567',
avatarPath: undefined, avatarPath: undefined,
color: 'ultramarine' as ColorType, color: 'ultramarine' as ColorType,
@ -28,7 +24,7 @@ const callDetails = {
}; };
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
callDetails: overrideProps.callDetails || callDetails, conversation: overrideProps.conversation || conversation,
hangUp: action('hang-up'), hangUp: action('hang-up'),
hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false), hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false),
hasRemoteVideo: boolean( hasRemoteVideo: boolean(
@ -50,8 +46,8 @@ story.add('Default', () => {
story.add('Contact (with avatar)', () => { story.add('Contact (with avatar)', () => {
const props = createProps({ const props = createProps({
callDetails: { conversation: {
...callDetails, ...conversation,
avatarPath: 'https://www.fillmurray.com/64/64', avatarPath: 'https://www.fillmurray.com/64/64',
}, },
}); });
@ -60,8 +56,8 @@ story.add('Contact (with avatar)', () => {
story.add('Contact (no color)', () => { story.add('Contact (no color)', () => {
const props = createProps({ const props = createProps({
callDetails: { conversation: {
...callDetails, ...conversation,
color: undefined, color: undefined,
}, },
}); });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,3 +5,10 @@ export type NoopActionType = {
type: 'NOOP'; type: 'NOOP';
payload: null; 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 { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions'; import { mapDispatchToProps } from '../actions';
import { CallManager } from '../../components/CallManager'; 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 { StateType } from '../reducer';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
@ -16,16 +17,64 @@ function renderDeviceSelection(): JSX.Element {
return <SmartCallingDeviceSelection />; return <SmartCallingDeviceSelection />;
} }
const mapStateToProps = (state: StateType) => { const mapStateToActiveCallProp = (state: StateType) => {
const { calling } = state; 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 { return {
...calling, call,
i18n: getIntl(state), activeCallState,
me: getMe(state), conversation,
renderDeviceSelection,
}; };
}; };
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); const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartCallManager = smart(CallManager); export const SmartCallManager = smart(CallManager);

View file

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

View file

@ -2,28 +2,309 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai'; import { assert } from 'chai';
import { import * as sinon from 'sinon';
CallDetailsType, import { reducer as rootReducer } from '../../../state/reducer';
actions, import { noopAction } from '../../../state/ducks/noop';
getEmptyState, import { actions, getEmptyState, reducer } from '../../../state/ducks/calling';
isCallActive, import { calling as callingService } from '../../../services/calling';
reducer,
} from '../../../state/ducks/calling';
import { CallState } from '../../../types/Calling'; import { CallState } from '../../../types/Calling';
describe('calling duck', () => { 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('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', () => { describe('toggleSettings', () => {
const { toggleSettings } = actions; const { toggleSettings } = actions;
it('toggles the settings dialog', () => { it('toggles the settings dialog', () => {
const afterOneToggle = reducer(getEmptyState(), toggleSettings()); const afterOneToggle = reducer(
stateWithActiveDirectCall,
toggleSettings()
);
const afterTwoToggles = reducer(afterOneToggle, toggleSettings()); const afterTwoToggles = reducer(afterOneToggle, toggleSettings());
const afterThreeToggles = reducer(afterTwoToggles, toggleSettings()); const afterThreeToggles = reducer(afterTwoToggles, toggleSettings());
assert.isTrue(afterOneToggle.settingsDialogOpen); assert.isTrue(afterOneToggle.activeCallState?.settingsDialogOpen);
assert.isFalse(afterTwoToggles.settingsDialogOpen); assert.isFalse(afterTwoToggles.activeCallState?.settingsDialogOpen);
assert.isTrue(afterThreeToggles.settingsDialogOpen); assert.isTrue(afterThreeToggles.activeCallState?.settingsDialogOpen);
}); });
}); });
@ -31,16 +312,19 @@ describe('calling duck', () => {
const { toggleParticipants } = actions; const { toggleParticipants } = actions;
it('toggles the participants list', () => { it('toggles the participants list', () => {
const afterOneToggle = reducer(getEmptyState(), toggleParticipants()); const afterOneToggle = reducer(
stateWithActiveDirectCall,
toggleParticipants()
);
const afterTwoToggles = reducer(afterOneToggle, toggleParticipants()); const afterTwoToggles = reducer(afterOneToggle, toggleParticipants());
const afterThreeToggles = reducer( const afterThreeToggles = reducer(
afterTwoToggles, afterTwoToggles,
toggleParticipants() toggleParticipants()
); );
assert.isTrue(afterOneToggle.participantsList); assert.isTrue(afterOneToggle.activeCallState?.participantsList);
assert.isFalse(afterTwoToggles.participantsList); assert.isFalse(afterTwoToggles.activeCallState?.participantsList);
assert.isTrue(afterThreeToggles.participantsList); assert.isTrue(afterThreeToggles.activeCallState?.participantsList);
}); });
}); });
@ -48,111 +332,13 @@ describe('calling duck', () => {
const { togglePip } = actions; const { togglePip } = actions;
it('toggles the PiP', () => { it('toggles the PiP', () => {
const afterOneToggle = reducer(getEmptyState(), togglePip()); const afterOneToggle = reducer(stateWithActiveDirectCall, togglePip());
const afterTwoToggles = reducer(afterOneToggle, togglePip()); const afterTwoToggles = reducer(afterOneToggle, togglePip());
const afterThreeToggles = reducer(afterTwoToggles, togglePip()); const afterThreeToggles = reducer(afterTwoToggles, togglePip());
assert.isTrue(afterOneToggle.pip); assert.isTrue(afterOneToggle.activeCallState?.pip);
assert.isFalse(afterTwoToggles.pip); assert.isFalse(afterTwoToggles.activeCallState?.pip);
assert.isTrue(afterThreeToggles.pip); assert.isTrue(afterThreeToggles.activeCallState?.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,
})
);
}); });
}); });
}); });

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