// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; import * as sinon from 'sinon'; import { reducer as rootReducer } from '../../../state/reducer'; import { noopAction } from '../../../state/ducks/noop'; import { CallingStateType, GroupCallStateChangeActionType, actions, getActiveCall, getEmptyState, isAnybodyElseInGroupCall, reducer, } from '../../../state/ducks/calling'; import { calling as callingService } from '../../../services/calling'; import { CallMode, CallState, GroupCallConnectionState, GroupCallJoinState, } from '../../../types/Calling'; describe('calling duck', () => { const stateWithDirectCall: CallingStateType = { ...getEmptyState(), callsByConversation: { 'fake-direct-call-conversation-id': { callMode: CallMode.Direct as CallMode.Direct, 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, isInSpeakerView: false, showParticipantsList: false, safetyNumberChangedUuids: [], pip: false, settingsDialogOpen: false, }, }; const stateWithIncomingDirectCall = { ...getEmptyState(), callsByConversation: { 'fake-direct-call-conversation-id': { callMode: CallMode.Direct as CallMode.Direct, conversationId: 'fake-direct-call-conversation-id', callState: CallState.Ringing, isIncoming: true, isVideoCall: false, hasRemoteVideo: false, }, }, }; const stateWithGroupCall = { ...getEmptyState(), callsByConversation: { 'fake-group-call-conversation-id': { callMode: CallMode.Group as CallMode.Group, conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, peekInfo: { uuids: ['456'], creatorUuid: '456', eraId: 'xyz', maxDevices: 16, deviceCount: 1, }, remoteParticipants: [ { uuid: '123', demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, presenting: false, sharingScreen: false, videoAspectRatio: 4 / 3, }, ], }, }, }; const stateWithIncomingGroupCall = { ...stateWithGroupCall, callsByConversation: { ...stateWithGroupCall.callsByConversation, 'fake-group-call-conversation-id': { ...stateWithGroupCall.callsByConversation[ 'fake-group-call-conversation-id' ], ringId: BigInt(123), ringerUuid: '789', }, }, }; const stateWithActiveGroupCall = { ...stateWithGroupCall, activeCallState: { conversationId: 'fake-group-call-conversation-id', hasLocalAudio: true, hasLocalVideo: false, isInSpeakerView: false, showParticipantsList: false, safetyNumberChangedUuids: [], pip: false, settingsDialogOpen: false, }, }; const ourUuid = 'ebf5fd79-9344-4ec1-b5c9-af463572caf5'; const getEmptyRootState = () => { const rootState = rootReducer(undefined, noopAction()); return { ...rootState, user: { ...rootState.user, ourUuid, }, }; }; beforeEach(function beforeEach() { this.sandbox = sinon.createSandbox(); }); afterEach(function afterEach() { this.sandbox.restore(); }); describe('actions', () => { describe('getPresentingSources', () => { beforeEach(function beforeEach() { this.callingServiceGetPresentingSources = this.sandbox .stub(callingService, 'getPresentingSources') .resolves([ { id: 'foo.bar', name: 'Foo Bar', thumbnail: 'xyz', }, ]); }); it('retrieves sources from the calling service', async function test() { const { getPresentingSources } = actions; const dispatch = sinon.spy(); await getPresentingSources()(dispatch, getEmptyRootState, null); sinon.assert.calledOnce(this.callingServiceGetPresentingSources); }); it('dispatches SET_PRESENTING_SOURCES', async function test() { const { getPresentingSources } = actions; const dispatch = sinon.spy(); await getPresentingSources()(dispatch, getEmptyRootState, null); sinon.assert.calledOnce(dispatch); sinon.assert.calledWith(dispatch, { type: 'calling/SET_PRESENTING_SOURCES', payload: [ { id: 'foo.bar', name: 'Foo Bar', thumbnail: 'xyz', }, ], }); }); }); describe('remoteSharingScreenChange', () => { it("updates whether someone's screen is being shared", () => { const { remoteSharingScreenChange } = actions; const payload = { conversationId: 'fake-direct-call-conversation-id', isSharingScreen: true, }; const state = { ...stateWithActiveDirectCall, }; const nextState = reducer(state, remoteSharingScreenChange(payload)); const expectedState = { ...stateWithActiveDirectCall, callsByConversation: { 'fake-direct-call-conversation-id': { ...stateWithActiveDirectCall.callsByConversation[ 'fake-direct-call-conversation-id' ], isSharingScreen: true, }, }, }; assert.deepEqual(nextState, expectedState); }); }); describe('setPresenting', () => { beforeEach(function beforeEach() { this.callingServiceSetPresenting = this.sandbox.stub( callingService, 'setPresenting' ); }); it('calls setPresenting on the calling service', function test() { const { setPresenting } = actions; const dispatch = sinon.spy(); const presentedSource = { id: 'window:786', name: 'Application', }; const getState = () => ({ ...getEmptyRootState(), calling: { ...stateWithActiveGroupCall, }, }); setPresenting(presentedSource)(dispatch, getState, null); sinon.assert.calledOnce(this.callingServiceSetPresenting); sinon.assert.calledWith( this.callingServiceSetPresenting, 'fake-group-call-conversation-id', false, presentedSource ); }); it('dispatches SET_PRESENTING', () => { const { setPresenting } = actions; const dispatch = sinon.spy(); const presentedSource = { id: 'window:786', name: 'Application', }; const getState = () => ({ ...getEmptyRootState(), calling: { ...stateWithActiveGroupCall, }, }); setPresenting(presentedSource)(dispatch, getState, null); sinon.assert.calledOnce(dispatch); sinon.assert.calledWith(dispatch, { type: 'calling/SET_PRESENTING', payload: presentedSource, }); }); it('turns off presenting when no value is passed in', () => { const dispatch = sinon.spy(); const { setPresenting } = actions; const presentedSource = { id: 'window:786', name: 'Application', }; const getState = () => ({ ...getEmptyRootState(), calling: { ...stateWithActiveGroupCall, }, }); setPresenting(presentedSource)(dispatch, getState, null); const action = dispatch.getCall(0).args[0]; const nextState = reducer(getState().calling, action); assert.isDefined(nextState.activeCallState); assert.equal( nextState.activeCallState?.presentingSource, presentedSource ); assert.isUndefined( nextState.activeCallState?.presentingSourcesAvailable ); }); it('sets the presenting value when one is passed in', () => { const dispatch = sinon.spy(); const { setPresenting } = actions; const getState = () => ({ ...getEmptyRootState(), calling: { ...stateWithActiveGroupCall, }, }); setPresenting()(dispatch, getState, null); const action = dispatch.getCall(0).args[0]; const nextState = reducer(getState().calling, action); assert.isDefined(nextState.activeCallState); assert.isUndefined(nextState.activeCallState?.presentingSource); assert.isUndefined( nextState.activeCallState?.presentingSourcesAvailable ); }); }); describe('acceptCall', () => { const { acceptCall } = actions; beforeEach(function beforeEach() { this.callingServiceAccept = this.sandbox .stub(callingService, 'acceptDirectCall') .resolves(); this.callingServiceJoin = this.sandbox.stub( callingService, 'joinGroupCall' ); }); describe('accepting a direct call', () => { const getState = () => ({ ...getEmptyRootState(), calling: stateWithIncomingDirectCall, }); it('dispatches an ACCEPT_CALL_PENDING action', async () => { const dispatch = sinon.spy(); await acceptCall({ conversationId: 'fake-direct-call-conversation-id', asVideoCall: true, })(dispatch, getState, null); sinon.assert.calledOnce(dispatch); sinon.assert.calledWith(dispatch, { type: 'calling/ACCEPT_CALL_PENDING', payload: { conversationId: 'fake-direct-call-conversation-id', asVideoCall: true, }, }); await acceptCall({ conversationId: 'fake-direct-call-conversation-id', asVideoCall: false, })(dispatch, getState, null); sinon.assert.calledTwice(dispatch); sinon.assert.calledWith(dispatch, { type: 'calling/ACCEPT_CALL_PENDING', payload: { conversationId: 'fake-direct-call-conversation-id', asVideoCall: false, }, }); }); it('asks the calling service to accept the call', async function test() { const dispatch = sinon.spy(); await acceptCall({ conversationId: 'fake-direct-call-conversation-id', asVideoCall: true, })(dispatch, getState, null); sinon.assert.calledOnce(this.callingServiceAccept); sinon.assert.calledWith( this.callingServiceAccept, 'fake-direct-call-conversation-id', true ); await acceptCall({ conversationId: 'fake-direct-call-conversation-id', asVideoCall: false, })(dispatch, getState, null); sinon.assert.calledTwice(this.callingServiceAccept); sinon.assert.calledWith( this.callingServiceAccept, 'fake-direct-call-conversation-id', 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, getState, 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, isInSpeakerView: false, showParticipantsList: false, safetyNumberChangedUuids: [], pip: false, settingsDialogOpen: false, }); }); }); describe('accepting a group call', () => { const getState = () => ({ ...getEmptyRootState(), calling: stateWithIncomingGroupCall, }); it('dispatches an ACCEPT_CALL_PENDING action', async () => { const dispatch = sinon.spy(); await acceptCall({ conversationId: 'fake-group-call-conversation-id', asVideoCall: true, })(dispatch, getState, null); sinon.assert.calledOnce(dispatch); sinon.assert.calledWith(dispatch, { type: 'calling/ACCEPT_CALL_PENDING', payload: { conversationId: 'fake-group-call-conversation-id', asVideoCall: true, }, }); await acceptCall({ conversationId: 'fake-group-call-conversation-id', asVideoCall: false, })(dispatch, getState, null); sinon.assert.calledTwice(dispatch); sinon.assert.calledWith(dispatch, { type: 'calling/ACCEPT_CALL_PENDING', payload: { conversationId: 'fake-group-call-conversation-id', asVideoCall: false, }, }); }); it('asks the calling service to join the call', async function test() { const dispatch = sinon.spy(); await acceptCall({ conversationId: 'fake-group-call-conversation-id', asVideoCall: true, })(dispatch, getState, null); sinon.assert.calledOnce(this.callingServiceJoin); sinon.assert.calledWith( this.callingServiceJoin, 'fake-group-call-conversation-id', true, true ); await acceptCall({ conversationId: 'fake-group-call-conversation-id', asVideoCall: false, })(dispatch, getState, null); sinon.assert.calledTwice(this.callingServiceJoin); sinon.assert.calledWith( this.callingServiceJoin, 'fake-group-call-conversation-id', true, false ); }); it('updates the active call state with ACCEPT_CALL_PENDING', async () => { const dispatch = sinon.spy(); await acceptCall({ conversationId: 'fake-group-call-conversation-id', asVideoCall: true, })(dispatch, getState, null); const action = dispatch.getCall(0).args[0]; const result = reducer(stateWithIncomingGroupCall, action); assert.deepEqual(result.activeCallState, { conversationId: 'fake-group-call-conversation-id', hasLocalAudio: true, hasLocalVideo: true, isInSpeakerView: false, showParticipantsList: false, safetyNumberChangedUuids: [], pip: false, settingsDialogOpen: false, }); }); }); }); describe('cancelCall', () => { const { cancelCall } = actions; beforeEach(function beforeEach() { this.callingServiceStopCallingLobby = this.sandbox.stub( callingService, 'stopCallingLobby' ); }); it('stops the calling lobby for that conversation', function test() { cancelCall({ conversationId: '123' }); sinon.assert.calledOnce(this.callingServiceStopCallingLobby); sinon.assert.calledWith(this.callingServiceStopCallingLobby, '123'); }); it('completely removes an active direct call from the state', () => { const result = reducer( stateWithActiveDirectCall, cancelCall({ conversationId: 'fake-direct-call-conversation-id' }) ); assert.notProperty( result.callsByConversation, 'fake-direct-call-conversation-id' ); assert.isUndefined(result.activeCallState); }); it('removes the active group call, but leaves it in the state', () => { const result = reducer( stateWithActiveGroupCall, cancelCall({ conversationId: 'fake-group-call-conversation-id' }) ); assert.property( result.callsByConversation, 'fake-group-call-conversation-id' ); assert.isUndefined(result.activeCallState); }); }); describe('cancelIncomingGroupCallRing', () => { const { cancelIncomingGroupCallRing } = actions; it('does nothing if there is no associated group call', () => { const state = getEmptyState(); const action = cancelIncomingGroupCallRing({ conversationId: 'garbage', ringId: BigInt(1), }); const result = reducer(state, action); assert.strictEqual(result, state); }); it("does nothing if the ring to cancel isn't the same one", () => { const action = cancelIncomingGroupCallRing({ conversationId: 'fake-group-call-conversation-id', ringId: BigInt(999), }); const result = reducer(stateWithIncomingGroupCall, action); assert.strictEqual(result, stateWithIncomingGroupCall); }); it("removes the call from the state if it's not connected", () => { const state = { ...stateWithGroupCall, callsByConversation: { ...stateWithGroupCall.callsByConversation, 'fake-group-call-conversation-id': { ...stateWithGroupCall.callsByConversation[ 'fake-group-call-conversation-id' ], connectionState: GroupCallConnectionState.NotConnected, ringId: BigInt(123), ringerUuid: '789', }, }, }; const action = cancelIncomingGroupCallRing({ conversationId: 'fake-group-call-conversation-id', ringId: BigInt(123), }); const result = reducer(state, action); assert.notProperty( result.callsByConversation, 'fake-group-call-conversation-id' ); }); it("removes the ring state, but not the call, if it's connected", () => { const action = cancelIncomingGroupCallRing({ conversationId: 'fake-group-call-conversation-id', ringId: BigInt(123), }); const result = reducer(stateWithIncomingGroupCall, action); const call = result.callsByConversation['fake-group-call-conversation-id']; // It'd be nice to do this with an assert, but Chai doesn't understand it. if (call?.callMode !== CallMode.Group) { throw new Error('Expected to find a group call'); } assert.isUndefined(call.ringId); assert.isUndefined(call.ringerUuid); }); }); describe('declineCall', () => { const { declineCall } = actions; let declineDirectCall: sinon.SinonStub; let declineGroupCall: sinon.SinonStub; beforeEach(function beforeEach() { declineDirectCall = this.sandbox.stub( callingService, 'declineDirectCall' ); declineGroupCall = this.sandbox.stub( callingService, 'declineGroupCall' ); }); describe('declining a direct call', () => { const getState = () => ({ ...getEmptyRootState(), calling: stateWithIncomingDirectCall, }); it('dispatches a DECLINE_DIRECT_CALL action', () => { const dispatch = sinon.spy(); declineCall({ conversationId: 'fake-direct-call-conversation-id' })( dispatch, getState, null ); sinon.assert.calledOnce(dispatch); sinon.assert.calledWith(dispatch, { type: 'calling/DECLINE_DIRECT_CALL', payload: { conversationId: 'fake-direct-call-conversation-id', }, }); }); it('asks the calling service to decline the call', () => { const dispatch = sinon.spy(); declineCall({ conversationId: 'fake-direct-call-conversation-id' })( dispatch, getState, null ); sinon.assert.calledOnce(declineDirectCall); sinon.assert.calledWith( declineDirectCall, 'fake-direct-call-conversation-id' ); }); it('removes the call from the state', () => { const dispatch = sinon.spy(); declineCall({ conversationId: 'fake-direct-call-conversation-id' })( dispatch, getState, null ); const action = dispatch.getCall(0).args[0]; const result = reducer(stateWithIncomingGroupCall, action); assert.notProperty( result.callsByConversation, 'fake-direct-call-conversation-id' ); }); }); describe('declining a group call', () => { const getState = () => ({ ...getEmptyRootState(), calling: stateWithIncomingGroupCall, }); it('dispatches a CANCEL_INCOMING_GROUP_CALL_RING action', () => { const dispatch = sinon.spy(); declineCall({ conversationId: 'fake-group-call-conversation-id' })( dispatch, getState, null ); sinon.assert.calledOnce(dispatch); sinon.assert.calledWith(dispatch, { type: 'calling/CANCEL_INCOMING_GROUP_CALL_RING', payload: { conversationId: 'fake-group-call-conversation-id', ringId: BigInt(123), }, }); }); it('asks the calling service to decline the call', () => { const dispatch = sinon.spy(); declineCall({ conversationId: 'fake-group-call-conversation-id' })( dispatch, getState, null ); sinon.assert.calledOnce(declineGroupCall); sinon.assert.calledWith( declineGroupCall, 'fake-group-call-conversation-id', BigInt(123) ); }); // NOTE: The state effects of this action are tested with // `cancelIncomingGroupCallRing`. }); }); describe('groupCallStateChange', () => { const { groupCallStateChange } = actions; function getAction( ...args: Parameters ): GroupCallStateChangeActionType { const dispatch = sinon.spy(); groupCallStateChange(...args)(dispatch, getEmptyRootState, null); return dispatch.getCall(0).args[0]; } it('ignores non-connected calls with no peeked participants', () => { const result = reducer( getEmptyState(), getAction({ conversationId: 'abc123', connectionState: GroupCallConnectionState.NotConnected, joinState: GroupCallJoinState.NotJoined, hasLocalAudio: false, hasLocalVideo: false, peekInfo: { uuids: [], maxDevices: 16, deviceCount: 0, }, remoteParticipants: [], }) ); assert.deepEqual(result, getEmptyState()); }); it('removes the call from the map of conversations if the call is not connected and has no peeked participants or ringer', () => { const result = reducer( stateWithGroupCall, getAction({ conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.NotConnected, joinState: GroupCallJoinState.NotJoined, hasLocalAudio: false, hasLocalVideo: false, peekInfo: { uuids: [], maxDevices: 16, deviceCount: 0, }, remoteParticipants: [], }) ); assert.notProperty( result.callsByConversation, 'fake-group-call-conversation-id' ); }); it('removes the call from the map of conversations if the call is not connected and has 1 peeked participant: you', () => { const result = reducer( stateWithGroupCall, getAction({ conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.NotConnected, joinState: GroupCallJoinState.NotJoined, hasLocalAudio: false, hasLocalVideo: false, peekInfo: { uuids: [ourUuid], maxDevices: 16, deviceCount: 1, }, remoteParticipants: [], }) ); assert.notProperty( result.callsByConversation, 'fake-group-call-conversation-id' ); }); it('drops the active call if it is disconnected with no peeked participants', () => { const result = reducer( stateWithActiveGroupCall, getAction({ conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.NotConnected, joinState: GroupCallJoinState.NotJoined, hasLocalAudio: false, hasLocalVideo: false, peekInfo: { uuids: [], maxDevices: 16, deviceCount: 0, }, remoteParticipants: [], }) ); assert.isUndefined(result.activeCallState); }); it('drops the active call if it is disconnected with 1 peeked participant (you)', () => { const result = reducer( stateWithActiveGroupCall, getAction({ conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.NotConnected, joinState: GroupCallJoinState.NotJoined, hasLocalAudio: false, hasLocalVideo: false, peekInfo: { uuids: [ourUuid], maxDevices: 16, deviceCount: 1, }, remoteParticipants: [], }) ); assert.isUndefined(result.activeCallState); }); it('saves a new call to the map of conversations', () => { const result = reducer( getEmptyState(), getAction({ conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joining, hasLocalAudio: true, hasLocalVideo: false, peekInfo: { uuids: ['456'], creatorUuid: '456', eraId: 'xyz', maxDevices: 16, deviceCount: 1, }, remoteParticipants: [ { uuid: '123', demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, presenting: false, sharingScreen: false, videoAspectRatio: 4 / 3, }, ], }) ); assert.deepEqual( result.callsByConversation['fake-group-call-conversation-id'], { callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joining, peekInfo: { uuids: ['456'], creatorUuid: '456', eraId: 'xyz', maxDevices: 16, deviceCount: 1, }, remoteParticipants: [ { uuid: '123', demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, presenting: false, sharingScreen: false, videoAspectRatio: 4 / 3, }, ], } ); }); it('saves a new call to the map of conversations if the call is disconnected by has peeked participants that are not you', () => { const result = reducer( stateWithGroupCall, getAction({ conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.NotConnected, joinState: GroupCallJoinState.NotJoined, hasLocalAudio: false, hasLocalVideo: false, peekInfo: { uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'], maxDevices: 16, deviceCount: 1, }, remoteParticipants: [], }) ); assert.deepEqual( result.callsByConversation['fake-group-call-conversation-id'], { callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.NotConnected, joinState: GroupCallJoinState.NotJoined, peekInfo: { uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'], maxDevices: 16, deviceCount: 1, }, remoteParticipants: [], } ); }); it('saves a call to the map of conversations if the call had a ringer, even if it was otherwise ignorable', () => { const result = reducer( stateWithIncomingGroupCall, getAction({ conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.NotConnected, joinState: GroupCallJoinState.NotJoined, hasLocalAudio: false, hasLocalVideo: false, peekInfo: { uuids: [], maxDevices: 16, deviceCount: 0, }, remoteParticipants: [], }) ); assert.isDefined( result.callsByConversation['fake-group-call-conversation-id'] ); }); it('updates a call in the map of conversations', () => { const result = reducer( stateWithGroupCall, getAction({ conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, hasLocalAudio: true, hasLocalVideo: false, peekInfo: { uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'], maxDevices: 16, deviceCount: 1, }, remoteParticipants: [ { uuid: '123', demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, presenting: false, sharingScreen: false, videoAspectRatio: 16 / 9, }, ], }) ); assert.deepEqual( result.callsByConversation['fake-group-call-conversation-id'], { callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, peekInfo: { uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'], maxDevices: 16, deviceCount: 1, }, remoteParticipants: [ { uuid: '123', demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, presenting: false, sharingScreen: false, videoAspectRatio: 16 / 9, }, ], } ); }); it("keeps the existing ring state if you haven't joined the call", () => { const state = { ...stateWithGroupCall, callsByConversation: { ...stateWithGroupCall.callsByConversation, 'fake-group-call-conversation-id': { ...stateWithGroupCall.callsByConversation[ 'fake-group-call-conversation-id' ], ringId: BigInt(456), ringerUuid: '55addfd8-09ed-4f5b-b42e-01058898d13b', }, }, }; const result = reducer( state, getAction({ conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, hasLocalAudio: true, hasLocalVideo: false, peekInfo: { uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'], maxDevices: 16, deviceCount: 1, }, remoteParticipants: [ { uuid: '123', demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, presenting: false, sharingScreen: false, videoAspectRatio: 16 / 9, }, ], }) ); assert.include( result.callsByConversation['fake-group-call-conversation-id'], { callMode: CallMode.Group, ringId: BigInt(456), ringerUuid: '55addfd8-09ed-4f5b-b42e-01058898d13b', } ); }); it("removes the ring state if you've joined the call", () => { const state = { ...stateWithGroupCall, callsByConversation: { ...stateWithGroupCall.callsByConversation, 'fake-group-call-conversation-id': { ...stateWithGroupCall.callsByConversation[ 'fake-group-call-conversation-id' ], ringId: BigInt(456), ringerUuid: '55addfd8-09ed-4f5b-b42e-01058898d13b', }, }, }; const result = reducer( state, getAction({ conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, hasLocalAudio: true, hasLocalVideo: false, peekInfo: { uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'], maxDevices: 16, deviceCount: 1, }, remoteParticipants: [ { uuid: '123', demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, presenting: false, sharingScreen: false, videoAspectRatio: 16 / 9, }, ], }) ); assert.notProperty( result.callsByConversation['fake-group-call-conversation-id'], 'ringId' ); assert.notProperty( result.callsByConversation['fake-group-call-conversation-id'], 'ringerUuid' ); }); it("if no call is active, doesn't touch the active call state", () => { const result = reducer( stateWithGroupCall, getAction({ conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, hasLocalAudio: true, hasLocalVideo: false, peekInfo: { uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'], maxDevices: 16, deviceCount: 1, }, remoteParticipants: [ { uuid: '123', demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, presenting: false, sharingScreen: false, videoAspectRatio: 16 / 9, }, ], }) ); assert.isUndefined(result.activeCallState); }); it("if the call is not active, doesn't touch the active call state", () => { const result = reducer( stateWithActiveGroupCall, getAction({ conversationId: 'another-fake-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, hasLocalAudio: true, hasLocalVideo: true, peekInfo: { uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'], maxDevices: 16, deviceCount: 1, }, remoteParticipants: [ { uuid: '123', demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, presenting: false, sharingScreen: false, videoAspectRatio: 16 / 9, }, ], }) ); assert.deepEqual(result.activeCallState, { conversationId: 'fake-group-call-conversation-id', hasLocalAudio: true, hasLocalVideo: false, isInSpeakerView: false, showParticipantsList: false, safetyNumberChangedUuids: [], pip: false, settingsDialogOpen: false, }); }); it('if the call is active, updates the active call state', () => { const result = reducer( stateWithActiveGroupCall, getAction({ conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, hasLocalAudio: true, hasLocalVideo: true, peekInfo: { uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'], maxDevices: 16, deviceCount: 1, }, remoteParticipants: [ { uuid: '123', demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, presenting: false, sharingScreen: false, videoAspectRatio: 16 / 9, }, ], }) ); assert.strictEqual( result.activeCallState?.conversationId, 'fake-group-call-conversation-id' ); assert.isTrue(result.activeCallState?.hasLocalAudio); assert.isTrue(result.activeCallState?.hasLocalVideo); }); }); describe('peekNotConnectedGroupCall', () => { const { peekNotConnectedGroupCall } = actions; beforeEach(function beforeEach() { this.callingServicePeekGroupCall = this.sandbox.stub( callingService, 'peekGroupCall' ); this.callingServiceUpdateCallHistoryForGroupCall = this.sandbox.stub( callingService, 'updateCallHistoryForGroupCall' ); this.clock = this.sandbox.useFakeTimers(); }); describe('thunk', () => { function noopTest(connectionState: GroupCallConnectionState) { return async function test(this: Mocha.ITestCallbackContext) { const dispatch = sinon.spy(); await peekNotConnectedGroupCall({ conversationId: 'fake-group-call-conversation-id', })( dispatch, () => ({ ...getEmptyRootState(), calling: { ...stateWithGroupCall, callsByConversation: { 'fake-group-call-conversation-id': { ...stateWithGroupCall.callsByConversation[ 'fake-group-call-conversation-id' ], connectionState, }, }, }, }), null ); sinon.assert.notCalled(dispatch); sinon.assert.notCalled(this.callingServicePeekGroupCall); }; } it( 'no-ops if trying to peek at a connecting group call', noopTest(GroupCallConnectionState.Connecting) ); it( 'no-ops if trying to peek at a connected group call', noopTest(GroupCallConnectionState.Connected) ); it( 'no-ops if trying to peek at a reconnecting group call', noopTest(GroupCallConnectionState.Reconnecting) ); // These tests are incomplete. }); }); describe('returnToActiveCall', () => { const { returnToActiveCall } = actions; it('does nothing if not in PiP mode', () => { const result = reducer(stateWithActiveDirectCall, returnToActiveCall()); assert.deepEqual(result, stateWithActiveDirectCall); }); it('closes the PiP', () => { const state = { ...stateWithActiveDirectCall, activeCallState: { ...stateWithActiveDirectCall.activeCallState, pip: true, }, }; const result = reducer(state, returnToActiveCall()); assert.deepEqual(result, stateWithActiveDirectCall); }); }); describe('receiveIncomingGroupCall', () => { const { receiveIncomingGroupCall } = actions; it('does nothing if the call was already ringing', () => { const action = receiveIncomingGroupCall({ conversationId: 'fake-group-call-conversation-id', ringId: BigInt(456), ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96', }); const result = reducer(stateWithIncomingGroupCall, action); assert.strictEqual(result, stateWithIncomingGroupCall); }); it('does nothing if the call was already joined', () => { const state = { ...stateWithGroupCall, callsByConversation: { ...stateWithGroupCall.callsByConversation, 'fake-group-call-conversation-id': { ...stateWithGroupCall.callsByConversation[ 'fake-group-call-conversation-id' ], joinState: GroupCallJoinState.Joined, }, }, }; const action = receiveIncomingGroupCall({ conversationId: 'fake-group-call-conversation-id', ringId: BigInt(456), ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96', }); const result = reducer(state, action); assert.strictEqual(result, state); }); it('creates a new group call if one did not exist', () => { const action = receiveIncomingGroupCall({ conversationId: 'fake-group-call-conversation-id', ringId: BigInt(456), ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96', }); const result = reducer(getEmptyState(), action); assert.deepEqual( result.callsByConversation['fake-group-call-conversation-id'], { callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.NotConnected, joinState: GroupCallJoinState.NotJoined, peekInfo: { uuids: [], maxDevices: Infinity, deviceCount: 0, }, remoteParticipants: [], ringId: BigInt(456), ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96', } ); }); it('attaches ring state to an existing call', () => { const action = receiveIncomingGroupCall({ conversationId: 'fake-group-call-conversation-id', ringId: BigInt(456), ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96', }); const result = reducer(stateWithGroupCall, action); assert.include( result.callsByConversation['fake-group-call-conversation-id'], { ringId: BigInt(456), ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96', } ); }); }); 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(), calling: stateWithActiveDirectCall, }), 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(), calling: stateWithActiveDirectCall, }), 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 a direct call and makes it active', () => { const result = reducer( getEmptyState(), showCallLobby({ callMode: CallMode.Direct, conversationId: 'fake-conversation-id', hasLocalAudio: true, hasLocalVideo: true, }) ); assert.deepEqual(result.callsByConversation['fake-conversation-id'], { callMode: CallMode.Direct, conversationId: 'fake-conversation-id', isIncoming: false, isVideoCall: true, }); assert.deepEqual(result.activeCallState, { conversationId: 'fake-conversation-id', hasLocalAudio: true, hasLocalVideo: true, isInSpeakerView: false, showParticipantsList: false, safetyNumberChangedUuids: [], pip: false, settingsDialogOpen: false, }); }); it('saves a group call and makes it active', () => { const result = reducer( getEmptyState(), showCallLobby({ callMode: CallMode.Group, conversationId: 'fake-conversation-id', hasLocalAudio: true, hasLocalVideo: true, connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, peekInfo: { uuids: ['456'], creatorUuid: '456', eraId: 'xyz', maxDevices: 16, deviceCount: 1, }, remoteParticipants: [ { uuid: '123', demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, presenting: false, sharingScreen: false, videoAspectRatio: 4 / 3, }, ], }) ); assert.deepEqual(result.callsByConversation['fake-conversation-id'], { callMode: CallMode.Group, conversationId: 'fake-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, peekInfo: { uuids: ['456'], creatorUuid: '456', eraId: 'xyz', maxDevices: 16, deviceCount: 1, }, remoteParticipants: [ { uuid: '123', demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, presenting: false, sharingScreen: false, videoAspectRatio: 4 / 3, }, ], }); assert.deepEqual( result.activeCallState?.conversationId, 'fake-conversation-id' ); }); it('chooses fallback peek info if none is sent and there is no existing call', () => { const result = reducer( getEmptyState(), showCallLobby({ callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', hasLocalAudio: true, hasLocalVideo: true, connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, peekInfo: undefined, remoteParticipants: [], }) ); const call = result.callsByConversation['fake-group-call-conversation-id']; assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { uuids: [], maxDevices: Infinity, deviceCount: 0, }); }); it("doesn't overwrite an existing group call's peek info if none was sent", () => { const result = reducer( stateWithGroupCall, showCallLobby({ callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', hasLocalAudio: true, hasLocalVideo: true, connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, peekInfo: undefined, remoteParticipants: [ { uuid: '123', demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, presenting: false, sharingScreen: false, videoAspectRatio: 4 / 3, }, ], }) ); const call = result.callsByConversation['fake-group-call-conversation-id']; assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { uuids: ['456'], creatorUuid: '456', eraId: 'xyz', maxDevices: 16, deviceCount: 1, }); }); it("can overwrite an existing group call's peek info", () => { const result = reducer( stateWithGroupCall, showCallLobby({ callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', hasLocalAudio: true, hasLocalVideo: true, connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, peekInfo: { uuids: ['999'], creatorUuid: '999', eraId: 'abc', maxDevices: 5, deviceCount: 1, }, remoteParticipants: [ { uuid: '123', demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, presenting: false, sharingScreen: false, videoAspectRatio: 4 / 3, }, ], }) ); const call = result.callsByConversation['fake-group-call-conversation-id']; assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { uuids: ['999'], creatorUuid: '999', eraId: 'abc', maxDevices: 5, deviceCount: 1, }); }); it("doesn't overwrite an existing group call's ring state if it was set previously", () => { const result = reducer( { ...stateWithGroupCall, callsByConversation: { 'fake-group-call-conversation-id': { ...stateWithGroupCall.callsByConversation[ 'fake-group-call-conversation-id' ], ringId: BigInt(987), ringerUuid: 'd59f05f7-3be8-4d44-a1e8-0d7cb5677ed8', }, }, }, showCallLobby({ callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', hasLocalAudio: true, hasLocalVideo: true, connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, peekInfo: undefined, remoteParticipants: [ { uuid: '123', demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, presenting: false, sharingScreen: false, videoAspectRatio: 4 / 3, }, ], }) ); const call = result.callsByConversation['fake-group-call-conversation-id']; // It'd be nice to do this with an assert, but Chai doesn't understand it. if (call?.callMode !== CallMode.Group) { throw new Error('Expected to find a group call'); } assert.strictEqual(call.ringId, BigInt(987)); assert.strictEqual( call.ringerUuid, 'd59f05f7-3be8-4d44-a1e8-0d7cb5677ed8' ); }); }); describe('startCall', () => { const { startCall } = actions; beforeEach(function beforeEach() { this.callingStartOutgoingDirectCall = this.sandbox.stub( callingService, 'startOutgoingDirectCall' ); this.callingJoinGroupCall = this.sandbox.stub( callingService, 'joinGroupCall' ); }); it('asks the calling service to start an outgoing direct call', function test() { const dispatch = sinon.spy(); startCall({ callMode: CallMode.Direct, conversationId: '123', hasLocalAudio: true, hasLocalVideo: false, })(dispatch, getEmptyRootState, null); sinon.assert.calledOnce(this.callingStartOutgoingDirectCall); sinon.assert.calledWith( this.callingStartOutgoingDirectCall, '123', true, false ); sinon.assert.notCalled(this.callingJoinGroupCall); }); it('asks the calling service to join a group call', function test() { const dispatch = sinon.spy(); startCall({ callMode: CallMode.Group, conversationId: '123', hasLocalAudio: true, hasLocalVideo: false, })(dispatch, getEmptyRootState, null); sinon.assert.calledOnce(this.callingJoinGroupCall); sinon.assert.calledWith(this.callingJoinGroupCall, '123', true, false); sinon.assert.notCalled(this.callingStartOutgoingDirectCall); }); it('saves direct calls and makes them active', () => { const dispatch = sinon.spy(); startCall({ callMode: CallMode.Direct, conversationId: 'fake-conversation-id', hasLocalAudio: true, hasLocalVideo: false, })(dispatch, getEmptyRootState, null); const action = dispatch.getCall(0).args[0]; const result = reducer(getEmptyState(), action); assert.deepEqual(result.callsByConversation['fake-conversation-id'], { callMode: CallMode.Direct, conversationId: 'fake-conversation-id', callState: CallState.Prering, isIncoming: false, isVideoCall: false, }); assert.deepEqual(result.activeCallState, { conversationId: 'fake-conversation-id', hasLocalAudio: true, hasLocalVideo: false, isInSpeakerView: false, showParticipantsList: false, safetyNumberChangedUuids: [], pip: false, settingsDialogOpen: false, }); }); it("doesn't dispatch any actions for group calls", () => { const dispatch = sinon.spy(); startCall({ callMode: CallMode.Group, conversationId: '123', hasLocalAudio: true, hasLocalVideo: false, })(dispatch, getEmptyRootState, null); sinon.assert.notCalled(dispatch); }); }); describe('toggleSettings', () => { const { toggleSettings } = actions; it('toggles the settings dialog', () => { const afterOneToggle = reducer( stateWithActiveDirectCall, toggleSettings() ); const afterTwoToggles = reducer(afterOneToggle, toggleSettings()); const afterThreeToggles = reducer(afterTwoToggles, toggleSettings()); assert.isTrue(afterOneToggle.activeCallState?.settingsDialogOpen); assert.isFalse(afterTwoToggles.activeCallState?.settingsDialogOpen); assert.isTrue(afterThreeToggles.activeCallState?.settingsDialogOpen); }); }); describe('toggleParticipants', () => { const { toggleParticipants } = actions; it('toggles the participants list', () => { const afterOneToggle = reducer( stateWithActiveDirectCall, toggleParticipants() ); const afterTwoToggles = reducer(afterOneToggle, toggleParticipants()); const afterThreeToggles = reducer( afterTwoToggles, toggleParticipants() ); assert.isTrue(afterOneToggle.activeCallState?.showParticipantsList); assert.isFalse(afterTwoToggles.activeCallState?.showParticipantsList); assert.isTrue(afterThreeToggles.activeCallState?.showParticipantsList); }); }); describe('togglePip', () => { const { togglePip } = actions; it('toggles the PiP', () => { const afterOneToggle = reducer(stateWithActiveDirectCall, togglePip()); const afterTwoToggles = reducer(afterOneToggle, togglePip()); const afterThreeToggles = reducer(afterTwoToggles, togglePip()); assert.isTrue(afterOneToggle.activeCallState?.pip); assert.isFalse(afterTwoToggles.activeCallState?.pip); assert.isTrue(afterThreeToggles.activeCallState?.pip); }); }); describe('toggleSpeakerView', () => { const { toggleSpeakerView } = actions; it('toggles speaker view', () => { const afterOneToggle = reducer( stateWithActiveGroupCall, toggleSpeakerView() ); const afterTwoToggles = reducer(afterOneToggle, toggleSpeakerView()); const afterThreeToggles = reducer(afterTwoToggles, toggleSpeakerView()); assert.isTrue(afterOneToggle.activeCallState?.isInSpeakerView); assert.isFalse(afterTwoToggles.activeCallState?.isInSpeakerView); assert.isTrue(afterThreeToggles.activeCallState?.isInSpeakerView); }); }); }); describe('helpers', () => { 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), { callMode: CallMode.Direct, conversationId: 'fake-direct-call-conversation-id', callState: CallState.Accepted, isIncoming: false, isVideoCall: false, hasRemoteVideo: false, }); }); }); describe('isAnybodyElseInGroupCall', () => { const fakePeekInfo = (uuids: Array) => ({ uuids, maxDevices: 5, deviceCount: uuids.length, }); it('returns false if the peek info has no participants', () => { assert.isFalse( isAnybodyElseInGroupCall( fakePeekInfo([]), '2cd7b14c-3433-4b3c-9685-1ef1e2d26db2' ) ); }); it('returns false if the peek info has one participant, you', () => { assert.isFalse( isAnybodyElseInGroupCall( fakePeekInfo(['2cd7b14c-3433-4b3c-9685-1ef1e2d26db2']), '2cd7b14c-3433-4b3c-9685-1ef1e2d26db2' ) ); }); it('returns true if the peek info has one participant, someone else', () => { assert.isTrue( isAnybodyElseInGroupCall( fakePeekInfo(['ca0ae16c-2936-4c68-86b1-a6f82e8fe67f']), '2cd7b14c-3433-4b3c-9685-1ef1e2d26db2' ) ); }); it('returns true if the peek info has two participants, you and someone else', () => { assert.isTrue( isAnybodyElseInGroupCall( fakePeekInfo([ 'ca0ae16c-2936-4c68-86b1-a6f82e8fe67f', '2cd7b14c-3433-4b3c-9685-1ef1e2d26db2', ]), '2cd7b14c-3433-4b3c-9685-1ef1e2d26db2' ) ); }); }); }); });