signal-desktop/ts/test-electron/state/ducks/calling_test.ts
2024-05-17 16:22:51 -07:00

2554 lines
78 KiB
TypeScript

// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import * as sinon from 'sinon';
import { cloneDeep, noop } from 'lodash';
import type { PeekInfo } from '@signalapp/ringrtc';
import type { StateType as RootStateType } from '../../../state/reducer';
import { reducer as rootReducer } from '../../../state/reducer';
import { noopAction } from '../../../state/ducks/noop';
import type {
ActiveCallStateType,
CallingActionType,
CallingStateType,
DirectCallStateType,
GroupCallReactionsReceivedActionType,
GroupCallStateChangeActionType,
GroupCallStateType,
HandleCallLinkUpdateType,
SendGroupCallReactionActionType,
StartCallLinkLobbyType,
} from '../../../state/ducks/calling';
import {
actions,
getActiveCall,
getEmptyState,
reducer,
} from '../../../state/ducks/calling';
import { isAnybodyElseInGroupCall } from '../../../state/ducks/callingHelpers';
import { truncateAudioLevel } from '../../../calling/truncateAudioLevel';
import { calling as callingService } from '../../../services/calling';
import {
CallMode,
CallState,
CallViewMode,
GroupCallConnectionState,
GroupCallJoinState,
} from '../../../types/Calling';
import { generateAci } from '../../../types/ServiceId';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import type { UnwrapPromise } from '../../../types/Util';
import {
FAKE_CALL_LINK,
FAKE_CALL_LINK_WITH_ADMIN_KEY,
getCallLinkState,
} from '../../../test-both/helpers/fakeCallLink';
const ACI_1 = generateAci();
const NOW = new Date('2020-01-23T04:56:00.000');
type CallingStateTypeWithActiveCall = CallingStateType & {
activeCallState: ActiveCallStateType;
};
describe('calling duck', () => {
const directCallState: DirectCallStateType = {
callMode: CallMode.Direct,
conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Accepted,
isIncoming: false,
isVideoCall: false,
hasRemoteVideo: false,
};
const stateWithDirectCall: CallingStateType = {
...getEmptyState(),
callsByConversation: {
[directCallState.conversationId]: directCallState,
},
};
const stateWithActiveDirectCall: CallingStateTypeWithActiveCall = {
...stateWithDirectCall,
activeCallState: {
callMode: CallMode.Direct,
conversationId: directCallState.conversationId,
hasLocalAudio: true,
hasLocalVideo: false,
localAudioLevel: 0,
viewMode: CallViewMode.Paginated,
showParticipantsList: false,
outgoingRing: true,
pip: false,
settingsDialogOpen: false,
joinedAt: null,
},
};
const stateWithIncomingDirectCall: CallingStateType = {
...getEmptyState(),
callsByConversation: {
'fake-direct-call-conversation-id': {
callMode: CallMode.Direct,
conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Ringing,
isIncoming: true,
isVideoCall: false,
hasRemoteVideo: false,
} satisfies DirectCallStateType,
},
};
const creatorAci = generateAci();
const differentCreatorAci = generateAci();
const remoteAci = generateAci();
const ringerAci = generateAci();
const stateWithGroupCall: CallingStateType = {
...getEmptyState(),
callsByConversation: {
'fake-group-call-conversation-id': {
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined,
localDemuxId: 1,
peekInfo: {
acis: [creatorAci],
pendingAcis: [],
creatorAci,
eraId: 'xyz',
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
aci: remoteAci,
demuxId: 123,
hasRemoteAudio: true,
hasRemoteVideo: true,
mediaKeysReceived: true,
presenting: false,
sharingScreen: false,
videoAspectRatio: 4 / 3,
},
],
} satisfies GroupCallStateType,
},
};
const stateWithIncomingGroupCall: CallingStateType = {
...stateWithGroupCall,
callsByConversation: {
...stateWithGroupCall.callsByConversation,
'fake-group-call-conversation-id': {
...stateWithGroupCall.callsByConversation[
'fake-group-call-conversation-id'
],
ringId: BigInt(123),
ringerAci: generateAci(),
},
},
};
const stateWithActiveGroupCall: CallingStateTypeWithActiveCall = {
...stateWithGroupCall,
activeCallState: {
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
hasLocalAudio: true,
hasLocalVideo: false,
localAudioLevel: 0,
viewMode: CallViewMode.Paginated,
showParticipantsList: false,
outgoingRing: false,
pip: false,
settingsDialogOpen: false,
joinedAt: null,
},
};
const ourAci = generateAci();
const getEmptyRootState = () => {
const rootState = rootReducer(undefined, noopAction());
return {
...rootState,
user: {
...rootState.user,
ourAci,
},
};
};
function useFakeTimers() {
beforeEach(function (this: Mocha.Context) {
this.sandbox = sinon.createSandbox();
this.clock = this.sandbox.useFakeTimers({
now: NOW,
});
});
afterEach(function (this: Mocha.Context) {
this.sandbox.restore();
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let oldEvents: any;
beforeEach(function (this: Mocha.Context) {
this.sandbox = sinon.createSandbox();
oldEvents = window.Events;
window.Events = {
getCallRingtoneNotification: sinon.spy(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
});
afterEach(function (this: Mocha.Context) {
this.sandbox.restore();
window.Events = oldEvents;
});
describe('actions', () => {
describe('getPresentingSources', () => {
beforeEach(function (this: Mocha.Context) {
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 (this: Mocha.Context) {
const { getPresentingSources } = actions;
const dispatch = sinon.spy();
await getPresentingSources()(dispatch, getEmptyRootState, null);
sinon.assert.calledOnce(this.callingServiceGetPresentingSources);
});
it('dispatches SET_PRESENTING_SOURCES', async () => {
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: CallingStateTypeWithActiveCall = {
...stateWithActiveDirectCall,
};
const nextState = reducer(state, remoteSharingScreenChange(payload));
const expectedState: CallingStateTypeWithActiveCall = {
...stateWithActiveDirectCall,
callsByConversation: {
[directCallState.conversationId]: {
...directCallState,
isSharingScreen: true,
} satisfies DirectCallStateType,
},
};
assert.deepEqual(nextState, expectedState);
});
});
describe('setPresenting', () => {
beforeEach(function (this: Mocha.Context) {
this.callingServiceSetPresenting = this.sandbox.stub(
callingService,
'setPresenting'
);
});
it('calls setPresenting on the calling service', async function (this: Mocha.Context) {
const { setPresenting } = actions;
const dispatch = sinon.spy();
const presentedSource = {
id: 'window:786',
name: 'Application',
};
const getState = (): RootStateType => ({
...getEmptyRootState(),
calling: {
...stateWithActiveGroupCall,
},
});
await 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', async () => {
const { setPresenting } = actions;
const dispatch = sinon.spy();
const presentedSource = {
id: 'window:786',
name: 'Application',
};
const getState = (): RootStateType => ({
...getEmptyRootState(),
calling: {
...stateWithActiveGroupCall,
},
});
await 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', async () => {
const dispatch = sinon.spy();
const { setPresenting } = actions;
const presentedSource = {
id: 'window:786',
name: 'Application',
};
const getState = (): RootStateType => ({
...getEmptyRootState(),
calling: {
...stateWithActiveGroupCall,
},
});
await 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', async () => {
const dispatch = sinon.spy();
const { setPresenting } = actions;
const getState = (): RootStateType => ({
...getEmptyRootState(),
calling: {
...stateWithActiveGroupCall,
},
});
await 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 (this: Mocha.Context) {
this.callingServiceAccept = this.sandbox
.stub(callingService, 'acceptDirectCall')
.resolves();
this.callingServiceJoin = this.sandbox
.stub(callingService, 'joinGroupCall')
.resolves();
});
describe('accepting a direct call', () => {
const getState = (): RootStateType => ({
...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 (this: Mocha.Context) {
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, {
callMode: CallMode.Direct,
conversationId: 'fake-direct-call-conversation-id',
hasLocalAudio: true,
hasLocalVideo: true,
localAudioLevel: 0,
viewMode: CallViewMode.Paginated,
showParticipantsList: false,
outgoingRing: false,
pip: false,
settingsDialogOpen: false,
joinedAt: null,
} satisfies ActiveCallStateType);
});
});
describe('accepting a group call', () => {
const getState = (): RootStateType => ({
...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 (this: Mocha.Context) {
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, {
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
hasLocalAudio: true,
hasLocalVideo: true,
localAudioLevel: 0,
viewMode: CallViewMode.Paginated,
showParticipantsList: false,
outgoingRing: false,
pip: false,
settingsDialogOpen: false,
joinedAt: null,
} satisfies ActiveCallStateType);
});
});
});
describe('cancelCall', () => {
const { cancelCall } = actions;
beforeEach(function (this: Mocha.Context) {
this.callingServiceStopCallingLobby = this.sandbox.stub(
callingService,
'stopCallingLobby'
);
});
it('stops the calling lobby for that conversation', function (this: Mocha.Context) {
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 ring state, but not the call', () => {
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.ringerAci);
});
});
describe('declineCall', () => {
const { declineCall } = actions;
let declineDirectCall: sinon.SinonStub;
let declineGroupCall: sinon.SinonStub;
beforeEach(function (this: Mocha.Context) {
declineDirectCall = this.sandbox.stub(
callingService,
'declineDirectCall'
);
declineGroupCall = this.sandbox.stub(
callingService,
'declineGroupCall'
);
});
describe('declining a direct call', () => {
const getState = (): RootStateType => ({
...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 = (): RootStateType => ({
...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('groupCallAudioLevelsChange', () => {
const { groupCallAudioLevelsChange } = actions;
const remoteDeviceStates = [
{ audioLevel: 0.3, demuxId: 1 },
{ audioLevel: 0.4, demuxId: 2 },
{ audioLevel: 0.5, demuxId: 3 },
{ audioLevel: 0.2, demuxId: 7 },
{ audioLevel: 0.1, demuxId: 8 },
{ audioLevel: 0, demuxId: 9 },
];
const remoteAudioLevels = new Map<number, number>([
[1, truncateAudioLevel(0.3)],
[2, truncateAudioLevel(0.4)],
[3, truncateAudioLevel(0.5)],
[7, truncateAudioLevel(0.2)],
[8, truncateAudioLevel(0.1)],
]);
it("does nothing if there's no relevant call", () => {
const action = groupCallAudioLevelsChange({
callMode: CallMode.Group,
conversationId: 'garbage',
localAudioLevel: 1,
remoteDeviceStates,
});
const result = reducer(stateWithActiveGroupCall, action);
assert.strictEqual(result, stateWithActiveGroupCall);
});
it('does nothing if the state change would be a no-op', () => {
const state = {
...stateWithActiveGroupCall,
callsByConversation: {
'fake-group-call-conversation-id': {
...stateWithActiveGroupCall.callsByConversation[
'fake-group-call-conversation-id'
],
remoteAudioLevels,
},
},
};
const action = groupCallAudioLevelsChange({
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
localAudioLevel: 0.001,
remoteDeviceStates,
});
const result = reducer(state, action);
assert.strictEqual(result, state);
});
it('updates the set of speaking participants, including yourself', () => {
const action = groupCallAudioLevelsChange({
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
localAudioLevel: 0.8,
remoteDeviceStates,
});
const result = reducer(stateWithActiveGroupCall, action);
assert.strictEqual(
result.activeCallState?.localAudioLevel,
truncateAudioLevel(0.8)
);
const call =
result.callsByConversation['fake-group-call-conversation-id'];
if (call?.callMode !== CallMode.Group) {
throw new Error('Expected a group call to be found');
}
assert.deepStrictEqual(call.remoteAudioLevels, remoteAudioLevels);
});
});
describe('groupCallStateChange', () => {
const { groupCallStateChange } = actions;
function getAction(
...args: Parameters<typeof groupCallStateChange>
): GroupCallStateChangeActionType {
const dispatch = sinon.spy();
groupCallStateChange(...args)(dispatch, getEmptyRootState, null);
return dispatch.getCall(0).args[0];
}
it('saves a new call to the map of conversations', () => {
const result = reducer(
getEmptyState(),
getAction({
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joining,
localDemuxId: 1,
hasLocalAudio: true,
hasLocalVideo: false,
peekInfo: {
acis: [creatorAci],
pendingAcis: [],
creatorAci,
eraId: 'xyz',
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
aci: remoteAci,
demuxId: 123,
hasRemoteAudio: true,
hasRemoteVideo: true,
mediaKeysReceived: 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,
localDemuxId: 1,
peekInfo: {
acis: [creatorAci],
pendingAcis: [],
creatorAci,
eraId: 'xyz',
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
aci: remoteAci,
demuxId: 123,
hasRemoteAudio: true,
hasRemoteVideo: true,
mediaKeysReceived: true,
presenting: false,
sharingScreen: false,
videoAspectRatio: 4 / 3,
},
],
raisedHands: [],
}
);
});
it('updates a call in the map of conversations', () => {
const result = reducer(
stateWithGroupCall,
getAction({
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
localDemuxId: 1,
hasLocalAudio: true,
hasLocalVideo: false,
peekInfo: {
acis: [ACI_1],
pendingAcis: [],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
aci: remoteAci,
demuxId: 456,
hasRemoteAudio: false,
hasRemoteVideo: true,
mediaKeysReceived: 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,
localDemuxId: 1,
peekInfo: {
acis: [ACI_1],
pendingAcis: [],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
aci: remoteAci,
demuxId: 456,
hasRemoteAudio: false,
hasRemoteVideo: true,
mediaKeysReceived: true,
presenting: false,
sharingScreen: false,
videoAspectRatio: 16 / 9,
},
],
raisedHands: [],
}
);
});
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),
ringerAci,
},
},
};
const result = reducer(
state,
getAction({
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined,
localDemuxId: 1,
hasLocalAudio: true,
hasLocalVideo: false,
peekInfo: {
acis: [ACI_1],
pendingAcis: [],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
aci: remoteAci,
demuxId: 456,
hasRemoteAudio: false,
hasRemoteVideo: true,
mediaKeysReceived: true,
presenting: false,
sharingScreen: false,
videoAspectRatio: 16 / 9,
},
],
})
);
assert.include(
result.callsByConversation['fake-group-call-conversation-id'],
{
callMode: CallMode.Group,
ringId: BigInt(456),
ringerAci,
}
);
});
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),
ringerAci,
},
},
};
const result = reducer(
state,
getAction({
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
localDemuxId: 1,
joinState: GroupCallJoinState.Joined,
hasLocalAudio: true,
hasLocalVideo: false,
peekInfo: {
acis: [ACI_1],
pendingAcis: [],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
aci: remoteAci,
demuxId: 456,
hasRemoteAudio: false,
hasRemoteVideo: true,
mediaKeysReceived: 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'],
'ringerAci'
);
});
it("if no call is active, doesn't touch the active call state", () => {
const result = reducer(
stateWithGroupCall,
getAction({
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
localDemuxId: 1,
hasLocalAudio: true,
hasLocalVideo: false,
peekInfo: {
acis: [ACI_1],
pendingAcis: [],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
aci: remoteAci,
demuxId: 456,
hasRemoteAudio: false,
hasRemoteVideo: true,
mediaKeysReceived: 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({
callMode: CallMode.Group,
conversationId: 'another-fake-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
localDemuxId: 1,
hasLocalAudio: true,
hasLocalVideo: true,
peekInfo: {
acis: [ACI_1],
pendingAcis: [],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
aci: remoteAci,
demuxId: 456,
hasRemoteAudio: false,
hasRemoteVideo: true,
mediaKeysReceived: true,
presenting: false,
sharingScreen: false,
videoAspectRatio: 16 / 9,
},
],
})
);
assert.deepEqual(result.activeCallState, {
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
hasLocalAudio: true,
hasLocalVideo: false,
localAudioLevel: 0,
viewMode: CallViewMode.Paginated,
showParticipantsList: false,
outgoingRing: false,
pip: false,
settingsDialogOpen: false,
joinedAt: null,
} satisfies ActiveCallStateType);
});
it('if the call is active, updates the active call state', () => {
const result = reducer(
stateWithActiveGroupCall,
getAction({
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
localDemuxId: 1,
hasLocalAudio: true,
hasLocalVideo: true,
peekInfo: {
acis: [ACI_1],
pendingAcis: [],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
aci: remoteAci,
demuxId: 456,
hasRemoteAudio: false,
hasRemoteVideo: true,
mediaKeysReceived: 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);
});
it("doesn't stop ringing if nobody is in the call", () => {
const state = {
...stateWithActiveGroupCall,
activeCallState: {
...stateWithActiveGroupCall.activeCallState,
outgoingRing: true,
},
};
const result = reducer(
state,
getAction({
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
localDemuxId: 1,
hasLocalAudio: true,
hasLocalVideo: true,
peekInfo: {
acis: [],
pendingAcis: [],
maxDevices: 16,
deviceCount: 0,
},
remoteParticipants: [],
})
);
assert.isTrue(result.activeCallState?.outgoingRing);
});
it('stops ringing if someone enters the call', () => {
const state: CallingStateType = {
...stateWithActiveGroupCall,
activeCallState: {
...stateWithActiveGroupCall.activeCallState,
outgoingRing: true,
},
};
const result = reducer(
state,
getAction({
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
localDemuxId: 1,
hasLocalAudio: true,
hasLocalVideo: true,
peekInfo: {
acis: [ACI_1],
pendingAcis: [],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [],
})
);
assert.isFalse(result.activeCallState?.outgoingRing);
});
});
describe('handleCallLinkUpdate', () => {
const {
roomId,
name,
restrictions,
expiration,
revoked,
rootKey,
adminKey,
} = FAKE_CALL_LINK;
beforeEach(function (this: Mocha.Context) {
this.callingServiceReadCallLink = this.sandbox
.stub(callingService, 'readCallLink')
.resolves({
callLinkState: getCallLinkState(FAKE_CALL_LINK),
errorStatusCode: undefined,
});
});
const doAction = async (
payload: HandleCallLinkUpdateType
): Promise<{ dispatch: sinon.SinonSpy }> => {
const { handleCallLinkUpdate } = actions;
const dispatch = sinon.spy();
await handleCallLinkUpdate(payload)(dispatch, getEmptyRootState, null);
return { dispatch };
};
it('reads the call link from calling service', async function (this: Mocha.Context) {
await doAction({ rootKey, adminKey: null });
sinon.assert.calledOnce(this.callingServiceReadCallLink);
});
it('dispatches HANDLE_CALL_LINK_UPDATE', async () => {
const { dispatch } = await doAction({ rootKey, adminKey: null });
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWith(dispatch, {
type: 'calling/HANDLE_CALL_LINK_UPDATE',
payload: {
callLink: {
name,
restrictions,
expiration,
revoked,
roomId,
rootKey,
adminKey,
},
},
});
});
it('can save adminKey', async () => {
const { dispatch } = await doAction({ rootKey, adminKey: 'banana' });
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWith(dispatch, {
type: 'calling/HANDLE_CALL_LINK_UPDATE',
payload: {
callLink: {
name,
restrictions,
expiration,
revoked,
roomId,
rootKey,
adminKey: 'banana',
},
},
});
});
});
describe('startCallLinkLobby', () => {
const callLobbyData = {
callMode: CallMode.Adhoc,
connectionState: GroupCallConnectionState.NotConnected,
hasLocalAudio: true,
hasLocalVideo: true,
joinState: GroupCallJoinState.NotJoined,
peekInfo: [],
remoteParticipants: [],
};
const callLinkState = getCallLinkState(FAKE_CALL_LINK);
const getStateWithAdminKey = (): RootStateType => ({
...getEmptyRootState(),
calling: {
...getEmptyState(),
callLinks: {
[FAKE_CALL_LINK_WITH_ADMIN_KEY.roomId]:
FAKE_CALL_LINK_WITH_ADMIN_KEY,
},
},
});
beforeEach(function (this: Mocha.Context) {
this.callingServiceReadCallLink = this.sandbox
.stub(callingService, 'readCallLink')
.resolves({
callLinkState,
errorStatusCode: undefined,
});
this.callingServiceStartCallLinkLobby = this.sandbox
.stub(callingService, 'startCallLinkLobby')
.resolves(callLobbyData);
});
const doAction = async (
payload: StartCallLinkLobbyType
): Promise<{ dispatch: sinon.SinonSpy }> => {
const { startCallLinkLobby } = actions;
const dispatch = sinon.spy();
await startCallLinkLobby(payload)(dispatch, getEmptyRootState, null);
return { dispatch };
};
it('reads the link and dispatches START_CALL_LINK_LOBBY', async function (this: Mocha.Context) {
const { roomId, rootKey } = FAKE_CALL_LINK;
const { dispatch } = await doAction({ rootKey });
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWith(dispatch, {
type: 'calling/START_CALL_LINK_LOBBY',
payload: {
...callLobbyData,
callLinkState,
callLinkRoomId: roomId,
callLinkRootKey: rootKey,
conversationId: roomId,
isConversationTooBigToRing: false,
},
});
});
it('preserves adminKey', () => {
const { startCallLinkLobby } = actions;
const { roomId, rootKey, adminKey } = FAKE_CALL_LINK_WITH_ADMIN_KEY;
const dispatch = sinon.spy();
const result = reducer(
getStateWithAdminKey().calling,
startCallLinkLobby({
rootKey,
})(
dispatch,
getStateWithAdminKey,
null
) as unknown as Readonly<CallingActionType>
);
assert.equal(result.callLinks[roomId]?.adminKey, adminKey);
});
});
describe('peekNotConnectedGroupCall', () => {
const { peekNotConnectedGroupCall } = actions;
beforeEach(function (this: Mocha.Context) {
this.callingServicePeekGroupCall = this.sandbox.stub(
callingService,
'peekGroupCall'
);
this.callingServiceUpdateCallHistoryForGroupCallOnPeek =
this.sandbox.stub(
callingService,
'updateCallHistoryForGroupCallOnPeek'
);
this.clock = this.sandbox.useFakeTimers();
});
describe('thunk', () => {
function noopTest(connectionState: GroupCallConnectionState) {
return async function test(this: Mocha.Context) {
const dispatch = sinon.spy();
await peekNotConnectedGroupCall({
callMode: CallMode.Group,
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: CallingStateType = {
...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),
ringerAci,
});
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),
ringerAci,
});
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),
ringerAci,
});
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,
localDemuxId: undefined,
peekInfo: {
acis: [],
pendingAcis: [],
maxDevices: Infinity,
deviceCount: 0,
},
remoteParticipants: [],
ringId: BigInt(456),
ringerAci,
}
);
});
it('attaches ring state to an existing call', () => {
const action = receiveIncomingGroupCall({
conversationId: 'fake-group-call-conversation-id',
ringId: BigInt(456),
ringerAci,
});
const result = reducer(stateWithGroupCall, action);
assert.include(
result.callsByConversation['fake-group-call-conversation-id'],
{
ringId: BigInt(456),
ringerAci,
}
);
});
});
describe('receiveGroupCallReactions', () => {
useFakeTimers();
const { receiveGroupCallReactions } = actions;
const getState = (): RootStateType => ({
...getEmptyRootState(),
calling: {
...stateWithActiveGroupCall,
},
});
function getAction(
...args: Parameters<typeof receiveGroupCallReactions>
): GroupCallReactionsReceivedActionType {
const dispatch = sinon.spy();
receiveGroupCallReactions(...args)(dispatch, getState, null);
return dispatch.getCall(0).args[0];
}
it('adds reactions by timestamp', function (this: Mocha.Context) {
const firstAction = getAction({
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
reactions: [
{
demuxId: 123,
value: '❤️',
},
],
});
const firstResult = reducer(getState().calling, firstAction);
assert.deepEqual(firstResult.activeCallState?.reactions, [
{
timestamp: NOW.getTime(),
demuxId: 123,
value: '❤️',
},
]);
const secondDate = new Date(NOW.getTime() + 1234);
this.sandbox.useFakeTimers({ now: secondDate });
const secondAction = getAction({
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
reactions: [
{
demuxId: 456,
value: '🎉',
},
],
});
const secondResult = reducer(firstResult, secondAction);
assert.deepEqual(secondResult.activeCallState?.reactions, [
{
timestamp: NOW.getTime(),
demuxId: 123,
value: '❤️',
},
{
timestamp: secondDate.getTime(),
demuxId: 456,
value: '🎉',
},
]);
});
it('sets multiple reactions with the same timestamp', () => {
const action = getAction({
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
reactions: [
{
demuxId: 123,
value: '❤️',
},
{
demuxId: 456,
value: '🎉',
},
],
});
const result = reducer(getState().calling, action);
assert.deepEqual(result.activeCallState?.reactions, [
{
timestamp: NOW.getTime(),
demuxId: 123,
value: '❤️',
},
{
timestamp: NOW.getTime(),
demuxId: 456,
value: '🎉',
},
]);
});
});
describe('sendGroupCallReactions', () => {
useFakeTimers();
beforeEach(function (this: Mocha.Context) {
this.callingServiceSendGroupCallReaction = this.sandbox.stub(
callingService,
'sendGroupCallReaction'
);
});
const { sendGroupCallReaction } = actions;
const getState = (): RootStateType => ({
...getEmptyRootState(),
calling: {
...stateWithActiveGroupCall,
},
});
function getAction(
...args: Parameters<typeof sendGroupCallReaction>
): SendGroupCallReactionActionType {
const dispatch = sinon.spy();
sendGroupCallReaction(...args)(dispatch, getState, null);
return dispatch.getCall(0).args[0];
}
it('adds a local copy', () => {
const action = getAction({
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
value: '❤️',
});
const result = reducer(getState().calling, action);
assert.deepEqual(result.activeCallState?.reactions, [
{
timestamp: NOW.getTime(),
demuxId: 1,
value: '❤️',
},
]);
});
});
describe('setLocalAudio', () => {
const { setLocalAudio } = actions;
beforeEach(function (this: Mocha.Context) {
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 (this: Mocha.Context) {
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('setOutgoingRing', () => {
const { setOutgoingRing } = actions;
it('enables a desire to ring', () => {
const action = setOutgoingRing(true);
const result = reducer(stateWithActiveGroupCall, action);
assert.isTrue(result.activeCallState?.outgoingRing);
});
it('disables a desire to ring', () => {
const action = setOutgoingRing(false);
const result = reducer(stateWithActiveDirectCall, action);
assert.isFalse(result.activeCallState?.outgoingRing);
});
});
describe('startCallingLobby', () => {
const { startCallingLobby } = actions;
let rootState: RootStateType;
let startCallingLobbyStub: sinon.SinonStub;
beforeEach(function (this: Mocha.Context) {
startCallingLobbyStub = this.sandbox
.stub(callingService, 'startCallingLobby')
.resolves();
const emptyRootState = getEmptyRootState();
rootState = {
...emptyRootState,
conversations: {
...emptyRootState.conversations,
conversationLookup: {
'fake-conversation-id': getDefaultConversation(),
},
},
};
});
describe('thunk', () => {
it('asks the calling service to start the lobby', async () => {
await startCallingLobby({
conversationId: 'fake-conversation-id',
isVideoCall: true,
})(noop, () => rootState, null);
sinon.assert.calledOnce(startCallingLobbyStub);
});
it('requests audio by default', async () => {
await startCallingLobby({
conversationId: 'fake-conversation-id',
isVideoCall: true,
})(noop, () => rootState, null);
sinon.assert.calledWithMatch(startCallingLobbyStub, {
hasLocalAudio: true,
});
});
it("doesn't request audio if the group call already has 8 devices", async () => {
await startCallingLobby({
conversationId: 'fake-conversation-id',
isVideoCall: true,
})(
noop,
() => {
const callingState = cloneDeep(stateWithGroupCall);
const call = callingState.callsByConversation[
'fake-group-call-conversation-id'
] as GroupCallStateType;
const peekInfo = call.peekInfo as unknown as PeekInfo;
peekInfo.deviceCount = 8;
return { ...rootState, calling: callingState };
},
null
);
sinon.assert.calledWithMatch(startCallingLobbyStub, {
hasLocalVideo: true,
});
});
it('requests video when starting a video call', async () => {
await startCallingLobby({
conversationId: 'fake-conversation-id',
isVideoCall: true,
})(noop, () => rootState, null);
sinon.assert.calledWithMatch(startCallingLobbyStub, {
hasLocalVideo: true,
});
});
it("doesn't request video when not a video call", async () => {
await startCallingLobby({
conversationId: 'fake-conversation-id',
isVideoCall: false,
})(noop, () => rootState, null);
sinon.assert.calledWithMatch(startCallingLobbyStub, {
hasLocalVideo: false,
});
});
it('dispatches an action if the calling lobby returns something', async () => {
startCallingLobbyStub.resolves({
callMode: CallMode.Direct,
hasLocalAudio: true,
hasLocalVideo: true,
});
const dispatch = sinon.stub();
await startCallingLobby({
conversationId: 'fake-conversation-id',
isVideoCall: true,
})(dispatch, () => rootState, null);
sinon.assert.calledOnce(dispatch);
});
it("doesn't dispatch an action if the calling lobby returns nothing", async () => {
const dispatch = sinon.stub();
await startCallingLobby({
conversationId: 'fake-conversation-id',
isVideoCall: true,
})(dispatch, () => rootState, null);
sinon.assert.notCalled(dispatch);
});
});
describe('action', () => {
const getState = async (
callingState: CallingStateType,
callingServiceResult: UnwrapPromise<
ReturnType<typeof callingService.startCallingLobby>
>,
conversationId = 'fake-conversation-id'
): Promise<CallingStateType> => {
startCallingLobbyStub.resolves(callingServiceResult);
const dispatch = sinon.stub();
await startCallingLobby({
conversationId,
isVideoCall: true,
})(dispatch, () => ({ ...rootState, calling: callingState }), null);
const action = dispatch.getCall(0).args[0];
return reducer(callingState, action);
};
it('saves a direct call and makes it active', async () => {
const result = await getState(getEmptyState(), {
callMode: CallMode.Direct as const,
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, {
callMode: CallMode.Direct,
conversationId: 'fake-conversation-id',
hasLocalAudio: true,
hasLocalVideo: true,
localAudioLevel: 0,
viewMode: CallViewMode.Paginated,
showParticipantsList: false,
pip: false,
settingsDialogOpen: false,
outgoingRing: true,
joinedAt: null,
} satisfies ActiveCallStateType);
});
it('saves a group call and makes it active', async () => {
const result = await getState(getEmptyState(), {
callMode: CallMode.Group,
hasLocalAudio: true,
hasLocalVideo: true,
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined,
peekInfo: {
acis: [creatorAci],
pendingAcis: [],
creatorAci,
eraId: 'xyz',
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
aci: remoteAci,
demuxId: 123,
hasRemoteAudio: true,
hasRemoteVideo: true,
mediaKeysReceived: 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,
localDemuxId: undefined,
peekInfo: {
acis: [creatorAci],
pendingAcis: [],
creatorAci,
eraId: 'xyz',
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
aci: remoteAci,
demuxId: 123,
hasRemoteAudio: true,
hasRemoteVideo: true,
mediaKeysReceived: true,
presenting: false,
sharingScreen: false,
videoAspectRatio: 4 / 3,
},
],
});
assert.deepEqual(
result.activeCallState?.conversationId,
'fake-conversation-id'
);
assert.isFalse(result.activeCallState?.outgoingRing);
});
it('chooses fallback peek info if none is sent and there is no existing call', async () => {
const result = await getState(getEmptyState(), {
callMode: CallMode.Group,
hasLocalAudio: true,
hasLocalVideo: true,
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined,
peekInfo: undefined,
remoteParticipants: [],
});
const call = result.callsByConversation['fake-conversation-id'];
assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, {
acis: [],
pendingAcis: [],
maxDevices: Infinity,
deviceCount: 0,
});
});
it("doesn't overwrite an existing group call's peek info if none was sent", async () => {
const result = await getState(stateWithGroupCall, {
callMode: CallMode.Group,
hasLocalAudio: true,
hasLocalVideo: true,
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined,
peekInfo: undefined,
remoteParticipants: [
{
aci: remoteAci,
demuxId: 123,
hasRemoteAudio: true,
hasRemoteVideo: true,
mediaKeysReceived: 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, {
acis: [creatorAci],
pendingAcis: [],
creatorAci,
eraId: 'xyz',
maxDevices: 16,
deviceCount: 1,
});
});
it("can overwrite an existing group call's peek info", async () => {
const state = {
...getEmptyState(),
callsByConversation: {
'fake-conversation-id': {
...stateWithGroupCall.callsByConversation[
'fake-group-call-conversation-id'
],
conversationId: 'fake-conversation-id',
},
},
};
const result = await getState(state, {
callMode: CallMode.Group,
hasLocalAudio: true,
hasLocalVideo: true,
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined,
peekInfo: {
acis: [differentCreatorAci],
pendingAcis: [],
creatorAci: differentCreatorAci,
eraId: 'abc',
maxDevices: 5,
deviceCount: 1,
},
remoteParticipants: [
{
aci: remoteAci,
demuxId: 123,
hasRemoteAudio: true,
hasRemoteVideo: true,
mediaKeysReceived: true,
presenting: false,
sharingScreen: false,
videoAspectRatio: 4 / 3,
},
],
});
const call = result.callsByConversation['fake-conversation-id'];
assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, {
acis: [differentCreatorAci],
pendingAcis: [],
creatorAci: differentCreatorAci,
eraId: 'abc',
maxDevices: 5,
deviceCount: 1,
});
});
it("doesn't overwrite an existing group call's ring state if it was set previously", async () => {
const result = await getState(
{
...stateWithGroupCall,
callsByConversation: {
'fake-group-call-conversation-id': {
...stateWithGroupCall.callsByConversation[
'fake-group-call-conversation-id'
],
ringId: BigInt(987),
ringerAci,
},
},
},
{
callMode: CallMode.Group,
hasLocalAudio: true,
hasLocalVideo: true,
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined,
peekInfo: undefined,
remoteParticipants: [
{
aci: remoteAci,
demuxId: 123,
hasRemoteAudio: true,
hasRemoteVideo: true,
mediaKeysReceived: 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.ringerAci, ringerAci);
});
it('enables outgoingRing for a group call when there is no existing call', async () => {
const result = await getState(getEmptyState(), {
callMode: CallMode.Group,
hasLocalAudio: true,
hasLocalVideo: true,
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined,
peekInfo: undefined,
remoteParticipants: [],
});
assert.isTrue(result.activeCallState?.outgoingRing);
});
});
});
describe('startCall', () => {
const { startCall } = actions;
beforeEach(function (this: Mocha.Context) {
this.callingStartOutgoingDirectCall = this.sandbox.stub(
callingService,
'startOutgoingDirectCall'
);
this.callingJoinGroupCall = this.sandbox
.stub(callingService, 'joinGroupCall')
.resolves();
});
it('asks the calling service to start an outgoing direct call', async function (this: Mocha.Context) {
const dispatch = sinon.spy();
await 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', async function (this: Mocha.Context) {
const dispatch = sinon.spy();
await 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', async () => {
const dispatch = sinon.spy();
await 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, {
callMode: CallMode.Direct,
conversationId: 'fake-conversation-id',
hasLocalAudio: true,
hasLocalVideo: false,
localAudioLevel: 0,
viewMode: CallViewMode.Paginated,
showParticipantsList: false,
pip: false,
settingsDialogOpen: false,
outgoingRing: true,
joinedAt: null,
});
});
it("doesn't dispatch any actions for group calls", async () => {
const dispatch = sinon.spy();
await 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('switchToPresentationView', () => {
const {
switchToPresentationView,
switchFromPresentationView,
changeCallView,
} = actions;
it('toggles presentation view from paginated view', () => {
const afterOneToggle = reducer(
stateWithActiveGroupCall,
switchToPresentationView()
);
const afterTwoToggles = reducer(
afterOneToggle,
switchToPresentationView()
);
const afterThreeToggles = reducer(
afterOneToggle,
switchFromPresentationView()
);
assert.strictEqual(
afterOneToggle.activeCallState?.viewMode,
CallViewMode.Presentation
);
assert.strictEqual(
afterTwoToggles.activeCallState?.viewMode,
CallViewMode.Presentation
);
assert.strictEqual(
afterThreeToggles.activeCallState?.viewMode,
CallViewMode.Paginated
);
});
it('switches to previously selected view after presentation', () => {
const stateOverflow = reducer(
stateWithActiveGroupCall,
changeCallView(CallViewMode.Overflow)
);
const statePresentation = reducer(
stateOverflow,
switchToPresentationView()
);
const stateAfterPresentation = reducer(
statePresentation,
switchFromPresentationView()
);
assert.strictEqual(
stateAfterPresentation.activeCallState?.viewMode,
CallViewMode.Overflow
);
});
});
});
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', () => {
it('returns false with no peek info', () => {
assert.isFalse(isAnybodyElseInGroupCall(undefined, remoteAci));
});
it('returns false if the peek info has no participants', () => {
assert.isFalse(isAnybodyElseInGroupCall({ acis: [] }, remoteAci));
});
it('returns false if the peek info has one participant, you', () => {
assert.isFalse(
isAnybodyElseInGroupCall({ acis: [creatorAci] }, creatorAci)
);
});
it('returns true if the peek info has one participant, someone else', () => {
assert.isTrue(
isAnybodyElseInGroupCall({ acis: [creatorAci] }, remoteAci)
);
});
it('returns true if the peek info has two participants, you and someone else', () => {
assert.isTrue(
isAnybodyElseInGroupCall({ acis: [creatorAci, remoteAci] }, remoteAci)
);
});
});
});
});