- {callDetails.title}
+ {conversation.title}
{isGroupCall ? (
@@ -136,8 +132,8 @@ export const CallingLobby = ({
) : (
diff --git a/ts/components/CallingPip.stories.tsx b/ts/components/CallingPip.stories.tsx
index 5d8109f3efc4..c6a018816b6a 100644
--- a/ts/components/CallingPip.stories.tsx
+++ b/ts/components/CallingPip.stories.tsx
@@ -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 => ({
- 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,
},
});
diff --git a/ts/components/CallingPip.tsx b/ts/components/CallingPip.tsx
index d497b9a7ccab..344111135b50 100644
--- a/ts/components/CallingPip.tsx
+++ b/ts/components/CallingPip.tsx
@@ -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 (
@@ -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 ? (
- {callDetails.isVideoCall ? (
+ {isVideoCall ? (
<>
{
- declineCall({ callId });
+ declineCall({ conversationId });
}}
tabIndex={0}
tooltipContent={i18n('declineCall')}
@@ -125,7 +127,7 @@ export const IncomingCallBar = ({
{
- acceptCall({ callId, asVideoCall: false });
+ acceptCall({ conversationId, asVideoCall: false });
}}
tabIndex={0}
tooltipContent={i18n('acceptCallWithoutVideo')}
@@ -133,7 +135,7 @@ export const IncomingCallBar = ({
{
- acceptCall({ callId, asVideoCall: true });
+ acceptCall({ conversationId, asVideoCall: true });
}}
tabIndex={0}
tooltipContent={i18n('acceptCall')}
@@ -144,7 +146,7 @@ export const IncomingCallBar = ({
{
- declineCall({ callId });
+ declineCall({ conversationId });
}}
tabIndex={0}
tooltipContent={i18n('declineCall')}
@@ -152,7 +154,7 @@ export const IncomingCallBar = ({
{
- acceptCall({ callId, asVideoCall: false });
+ acceptCall({ conversationId, asVideoCall: false });
}}
tabIndex={0}
tooltipContent={i18n('acceptCall')}
diff --git a/ts/services/calling.ts b/ts/services/calling.ts
index 557d481d85ac..5235e06dba79 100644
--- a/ts/services/calling.ts
+++ b/ts/services/calling.ts
@@ -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 {
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 {
+ private getCallIdForConversation(conversationId: string): undefined | CallId {
+ return this.callsByConversation[conversationId]?.callId;
+ }
+
+ async accept(conversationId: string, asVideoCall: boolean): Promise {
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,
diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts
index 9a8c7b3d4dd8..fa4ee3b703c9 100644
--- a/ts/state/ducks/calling.ts
+++ b/ts/state/ducks/calling.ts
@@ -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 | 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 {
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 {
+ 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 {
- 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,
+ },
};
}
diff --git a/ts/state/ducks/noop.ts b/ts/state/ducks/noop.ts
index 32f955525cf7..c175e935efcf 100644
--- a/ts/state/ducks/noop.ts
+++ b/ts/state/ducks/noop.ts
@@ -5,3 +5,10 @@ export type NoopActionType = {
type: 'NOOP';
payload: null;
};
+
+export function noopAction(): NoopActionType {
+ return {
+ type: 'NOOP',
+ payload: null,
+ };
+}
diff --git a/ts/state/selectors/calling.ts b/ts/state/selectors/calling.ts
new file mode 100644
index 000000000000..2594b334d6d0
--- /dev/null
+++ b/ts/state/selectors/calling.ts
@@ -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);
diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx
index 432dde958a34..627759d61194 100644
--- a/ts/state/smart/CallManager.tsx
+++ b/ts/state/smart/CallManager.tsx
@@ -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 ;
}
-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);
diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx
index 6b8ddd6994cc..cfc62bd2ee02 100644
--- a/ts/state/smart/ConversationHeader.tsx
+++ b/ts/state/smart/ConversationHeader.tsx
@@ -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 {
diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts
index 2126c2c983e6..6e492939a5f8 100644
--- a/ts/test-electron/state/ducks/calling_test.ts
+++ b/ts/test-electron/state/ducks/calling_test.ts
@@ -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);
});
});
});
diff --git a/ts/test-electron/state/selectors/calling_test.ts b/ts/test-electron/state/selectors/calling_test.ts
new file mode 100644
index 000000000000..d859f5c00ca2
--- /dev/null
+++ b/ts/test-electron/state/selectors/calling_test.ts
@@ -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));
+ });
+ });
+});
diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json
index a42aefe8be1a..a7938f058cd0 100644
--- a/ts/util/lint/exceptions.json
+++ b/ts/util/lint/exceptions.json
@@ -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"
}
-]
+]
\ No newline at end of file