Group calls: mute in the lobby if joining a large call

This commit is contained in:
Evan Hahn 2022-01-07 12:01:23 -06:00 committed by GitHub
parent 09af7eeece
commit f8bbf5c998
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 499 additions and 279 deletions

View File

@ -1447,6 +1447,10 @@
"message": "Return to Call", "message": "Return to Call",
"description": "Button label in the call lobby for returning to a call" "description": "Button label in the call lobby for returning to a call"
}, },
"calling__lobby-automatically-muted-because-there-are-a-lot-of-people": {
"message": "Your microphone is muted due to the size of the call",
"description": "Shown in a call lobby toast if there are a lot of people already on the call"
},
"calling__call-is-full": { "calling__call-is-full": {
"message": "Call is full", "message": "Call is full",
"description": "Text in the call lobby when you can't join because the call is full" "description": "Text in the call lobby when you can't join because the call is full"

View File

@ -1,4 +1,4 @@
// Copyright 2018-2021 Signal Messenger, LLC // Copyright 2018-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
// Using BEM syntax explained here: https://csswizardry.com/2013/01/mindbemding-getting-your-head-round-bem-syntax/ // Using BEM syntax explained here: https://csswizardry.com/2013/01/mindbemding-getting-your-head-round-bem-syntax/
@ -4285,28 +4285,6 @@ button.module-image__border-overlay:focus {
fill-mode: forwards; fill-mode: forwards;
} }
} }
&__toast {
@include button-reset();
@include font-body-1-bold;
background-color: $color-gray-75;
border-radius: 8px;
color: $color-white;
max-width: 80%;
opacity: 1;
padding: 12px;
position: absolute;
text-align: center;
top: 12px;
transition: top 200ms ease-out, opacity 200ms ease-out;
user-select: none;
z-index: $z-index-above-above-base;
&--hidden {
opacity: 0;
top: 5px;
}
}
} }
.module-calling-tools { .module-calling-tools {

View File

@ -0,0 +1,24 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.CallingToast {
@include button-reset();
@include font-body-1-bold;
background-color: $color-gray-75;
border-radius: 8px;
color: $color-white;
max-width: 80%;
opacity: 1;
padding: 12px;
position: absolute;
text-align: center;
top: 12px;
transition: top 200ms ease-out, opacity 200ms ease-out;
user-select: none;
z-index: $z-index-above-above-base;
&--hidden {
opacity: 0;
top: 5px;
}
}

View File

@ -1,4 +1,4 @@
// Copyright 2014-2021 Signal Messenger, LLC // Copyright 2014-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
// Global Settings, Variables, and Mixins // Global Settings, Variables, and Mixins
@ -43,6 +43,7 @@
@import './components/CallingPreCallInfo.scss'; @import './components/CallingPreCallInfo.scss';
@import './components/CallingScreenSharingController.scss'; @import './components/CallingScreenSharingController.scss';
@import './components/CallingSelectPresentingSourcesModal.scss'; @import './components/CallingSelectPresentingSourcesModal.scss';
@import './components/CallingToast.scss';
@import './components/ChatColorPicker.scss'; @import './components/ChatColorPicker.scss';
@import './components/Checkbox.scss'; @import './components/Checkbox.scss';
@import './components/CompositionArea.scss'; @import './components/CompositionArea.scss';

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC // Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
@ -51,11 +51,11 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => {
(isGroupCall ? times(3, () => getDefaultConversation()) : undefined), (isGroupCall ? times(3, () => getDefaultConversation()) : undefined),
hasLocalAudio: boolean( hasLocalAudio: boolean(
'hasLocalAudio', 'hasLocalAudio',
overrideProps.hasLocalAudio || false overrideProps.hasLocalAudio ?? true
), ),
hasLocalVideo: boolean( hasLocalVideo: boolean(
'hasLocalVideo', 'hasLocalVideo',
overrideProps.hasLocalVideo || false overrideProps.hasLocalVideo ?? false
), ),
i18n, i18n,
isGroupCall, isGroupCall,
@ -122,9 +122,9 @@ story.add('Local Video', () => {
return <CallingLobby {...props} />; return <CallingLobby {...props} />;
}); });
story.add('Local Video', () => { story.add('Initially muted', () => {
const props = createProps({ const props = createProps({
hasLocalVideo: true, hasLocalAudio: false,
}); });
return <CallingLobby {...props} />; return <CallingLobby {...props} />;
}); });

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC // Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React from 'react';
@ -13,6 +13,7 @@ import { CallingButton, CallingButtonType } from './CallingButton';
import { TooltipPlacement } from './Tooltip'; import { TooltipPlacement } from './Tooltip';
import { CallBackgroundBlur } from './CallBackgroundBlur'; import { CallBackgroundBlur } from './CallBackgroundBlur';
import { CallingHeader } from './CallingHeader'; import { CallingHeader } from './CallingHeader';
import { CallingToast, DEFAULT_LIFETIME } from './CallingToast';
import { CallingPreCallInfo, RingMode } from './CallingPreCallInfo'; import { CallingPreCallInfo, RingMode } from './CallingPreCallInfo';
import { import {
CallingLobbyJoinButton, CallingLobbyJoinButton,
@ -92,6 +93,21 @@ export const CallingLobby = ({
toggleSettings, toggleSettings,
outgoingRing, outgoingRing,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
const [isMutedToastVisible, setIsMutedToastVisible] = React.useState(
!hasLocalAudio
);
React.useEffect(() => {
if (!isMutedToastVisible) {
return;
}
const timeout = setTimeout(() => {
setIsMutedToastVisible(false);
}, DEFAULT_LIFETIME);
return () => {
clearTimeout(timeout);
};
}, [isMutedToastVisible]);
const localVideoRef = React.useRef<null | HTMLVideoElement>(null); const localVideoRef = React.useRef<null | HTMLVideoElement>(null);
const shouldShowLocalVideo = hasLocalVideo && availableCameras.length > 0; const shouldShowLocalVideo = hasLocalVideo && availableCameras.length > 0;
@ -221,6 +237,15 @@ export const CallingLobby = ({
/> />
)} )}
<CallingToast
isVisible={isMutedToastVisible}
onClick={() => setIsMutedToastVisible(false)}
>
{i18n(
'calling__lobby-automatically-muted-because-there-are-a-lot-of-people'
)}
</CallingToast>
<CallingHeader <CallingHeader
i18n={i18n} i18n={i18n}
isGroupCall={isGroupCall} isGroupCall={isGroupCall}

View File

@ -0,0 +1,27 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FunctionComponent } from 'react';
import React from 'react';
import classNames from 'classnames';
type PropsType = {
isVisible: boolean;
onClick: () => unknown;
};
export const DEFAULT_LIFETIME = 5000;
export const CallingToast: FunctionComponent<PropsType> = ({
isVisible,
onClick,
children,
}) => (
<button
className={classNames('CallingToast', !isVisible && 'CallingToast--hidden')}
type="button"
onClick={onClick}
>
{children}
</button>
);

View File

@ -1,12 +1,12 @@
// Copyright 2020-2021 Signal Messenger, LLC // Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import type { ActiveCallType } from '../types/Calling'; import type { ActiveCallType } from '../types/Calling';
import { CallMode, GroupCallConnectionState } from '../types/Calling'; import { CallMode, GroupCallConnectionState } from '../types/Calling';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import { CallingToast, DEFAULT_LIFETIME } from './CallingToast';
type PropsType = { type PropsType = {
activeCall: ActiveCallType; activeCall: ActiveCallType;
@ -101,8 +101,6 @@ function useScreenSharingToast({ activeCall, i18n }: PropsType): ToastType {
return result; return result;
} }
const DEFAULT_DELAY = 5000;
// In the future, this component should show toasts when users join or leave. See // In the future, this component should show toasts when users join or leave. See
// DESKTOP-902. // DESKTOP-902.
export const CallingToastManager: React.FC<PropsType> = props => { export const CallingToastManager: React.FC<PropsType> = props => {
@ -131,7 +129,7 @@ export const CallingToastManager: React.FC<PropsType> = props => {
if (timeoutRef && timeoutRef.current) { if (timeoutRef && timeoutRef.current) {
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
} }
timeoutRef.current = setTimeout(dismissToast, DEFAULT_DELAY); timeoutRef.current = setTimeout(dismissToast, DEFAULT_LIFETIME);
} }
setToastMessage(toast.message); setToastMessage(toast.message);
@ -144,17 +142,9 @@ export const CallingToastManager: React.FC<PropsType> = props => {
}; };
}, [dismissToast, setToastMessage, timeoutRef, toast]); }, [dismissToast, setToastMessage, timeoutRef, toast]);
const isVisible = Boolean(toastMessage);
return ( return (
<button <CallingToast isVisible={Boolean(toastMessage)} onClick={dismissToast}>
className={classNames('module-ongoing-call__toast', {
'module-ongoing-call__toast--hidden': !isVisible,
})}
type="button"
onClick={dismissToast}
>
{toastMessage} {toastMessage}
</button> </CallingToast>
); );
}; };

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC // Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { DesktopCapturerSource } from 'electron'; import type { DesktopCapturerSource } from 'electron';
@ -39,10 +39,11 @@ import { uniqBy, noop } from 'lodash';
import type { import type {
ActionsType as UxActionsType, ActionsType as UxActionsType,
GroupCallParticipantInfoType,
GroupCallPeekInfoType, GroupCallPeekInfoType,
} from '../state/ducks/calling'; } from '../state/ducks/calling';
import type { ConversationType } from '../state/ducks/conversations';
import { getConversationCallMode } from '../state/ducks/conversations'; import { getConversationCallMode } from '../state/ducks/conversations';
import { isConversationTooBigToRing } from '../conversations/isConversationTooBigToRing';
import { isMe } from '../util/whatTypeOfConversation'; import { isMe } from '../util/whatTypeOfConversation';
import type { import type {
AvailableIODevicesType, AvailableIODevicesType,
@ -99,6 +100,7 @@ import {
FALLBACK_NOTIFICATION_TITLE, FALLBACK_NOTIFICATION_TITLE,
} from './notifications'; } from './notifications';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { assert } from '../util/assert';
const { const {
processGroupCallRingRequest, processGroupCallRingRequest,
@ -308,30 +310,47 @@ export class CallingClass {
RingRTC.setSelfUuid(Buffer.from(uuidToBytes(ourUuid))); RingRTC.setSelfUuid(Buffer.from(uuidToBytes(ourUuid)));
} }
async startCallingLobby( async startCallingLobby({
conversationId: string, conversation,
isVideoCall: boolean hasLocalAudio,
): Promise<void> { hasLocalVideo,
}: Readonly<{
conversation: Readonly<ConversationType>;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
}>): Promise<
| undefined
| ({ hasLocalAudio: boolean; hasLocalVideo: boolean } & (
| { callMode: CallMode.Direct }
| {
callMode: CallMode.Group;
connectionState: GroupCallConnectionState;
joinState: GroupCallJoinState;
peekInfo?: GroupCallPeekInfoType;
remoteParticipants: Array<GroupCallParticipantInfoType>;
}
))
> {
log.info('CallingClass.startCallingLobby()'); log.info('CallingClass.startCallingLobby()');
const conversation = window.ConversationController.get(conversationId); const callMode = getConversationCallMode(conversation);
if (!conversation) {
log.error('Could not find conversation, cannot start call lobby');
return;
}
const conversationProps = conversation.format();
const callMode = getConversationCallMode(conversationProps);
switch (callMode) { switch (callMode) {
case CallMode.None: case CallMode.None:
log.error('Conversation does not support calls, new call not allowed.'); log.error('Conversation does not support calls, new call not allowed.');
return; return;
case CallMode.Direct: case CallMode.Direct: {
if (!this.getRemoteUserIdFromConversation(conversation)) { const conversationModel = window.ConversationController.get(
conversation.id
);
if (
!conversationModel ||
!this.getRemoteUserIdFromConversation(conversationModel)
) {
log.error('Missing remote user identifier, new call not allowed.'); log.error('Missing remote user identifier, new call not allowed.');
return; return;
} }
break; break;
}
case CallMode.Group: case CallMode.Group:
break; break;
default: default:
@ -348,7 +367,7 @@ export class CallingClass {
return; return;
} }
const haveMediaPermissions = await this.requestPermissions(isVideoCall); const haveMediaPermissions = await this.requestPermissions(hasLocalVideo);
if (!haveMediaPermissions) { if (!haveMediaPermissions) {
log.info('Permissions were denied, new call not allowed.'); log.info('Permissions were denied, new call not allowed.');
return; return;
@ -374,51 +393,53 @@ export class CallingClass {
// is fixed. See DESKTOP-1032. // is fixed. See DESKTOP-1032.
await this.startDeviceReselectionTimer(); await this.startDeviceReselectionTimer();
const enableLocalCameraIfNecessary = hasLocalVideo
? () => this.enableLocalCamera()
: noop;
switch (callMode) { switch (callMode) {
case CallMode.Direct: case CallMode.Direct:
this.uxActions.showCallLobby({ // We could easily support this in the future if we need to.
assert(
hasLocalAudio,
'Expected local audio to be enabled for direct call lobbies'
);
enableLocalCameraIfNecessary();
return {
callMode: CallMode.Direct, callMode: CallMode.Direct,
conversationId: conversationProps.id, hasLocalAudio,
hasLocalAudio: true, hasLocalVideo,
hasLocalVideo: isVideoCall, };
});
break;
case CallMode.Group: { case CallMode.Group: {
if ( if (
!conversationProps.groupId || !conversation.groupId ||
!conversationProps.publicParams || !conversation.publicParams ||
!conversationProps.secretParams !conversation.secretParams
) { ) {
log.error( log.error(
'Conversation is missing required parameters. Cannot connect group call' 'Conversation is missing required parameters. Cannot connect group call'
); );
return; return;
} }
const groupCall = this.connectGroupCall(conversationProps.id, { const groupCall = this.connectGroupCall(conversation.id, {
groupId: conversationProps.groupId, groupId: conversation.groupId,
publicParams: conversationProps.publicParams, publicParams: conversation.publicParams,
secretParams: conversationProps.secretParams, secretParams: conversation.secretParams,
}); });
groupCall.setOutgoingAudioMuted(false); groupCall.setOutgoingAudioMuted(!hasLocalAudio);
groupCall.setOutgoingVideoMuted(!isVideoCall); groupCall.setOutgoingVideoMuted(!hasLocalVideo);
this.uxActions.showCallLobby({ enableLocalCameraIfNecessary();
return {
callMode: CallMode.Group, callMode: CallMode.Group,
conversationId: conversationProps.id,
isConversationTooBigToRing:
isConversationTooBigToRing(conversationProps),
...this.formatGroupCallForRedux(groupCall), ...this.formatGroupCallForRedux(groupCall),
}); };
break;
} }
default: default:
throw missingCaseError(callMode); throw missingCaseError(callMode);
} }
if (isVideoCall) {
this.enableLocalCamera();
}
} }
stopCallingLobby(conversationId?: string): void { stopCallingLobby(conversationId?: string): void {
@ -443,7 +464,6 @@ export class CallingClass {
} }
const conversation = window.ConversationController.get(conversationId); const conversation = window.ConversationController.get(conversationId);
if (!conversation) { if (!conversation) {
log.error('Could not find conversation, cannot start call'); log.error('Could not find conversation, cannot start call');
this.stopCallingLobby(); this.stopCallingLobby();

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC // Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { ipcRenderer } from 'electron'; import { ipcRenderer } from 'electron';
@ -38,6 +38,7 @@ import { LatestQueue } from '../../util/LatestQueue';
import type { UUIDStringType } from '../../types/UUID'; import type { UUIDStringType } from '../../types/UUID';
import type { ConversationChangedActionType } from './conversations'; import type { ConversationChangedActionType } from './conversations';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { strictAssert } from '../../util/assert';
// State // State
@ -223,7 +224,7 @@ export type StartCallingLobbyType = {
isVideoCall: boolean; isVideoCall: boolean;
}; };
export type ShowCallLobbyType = type StartCallingLobbyPayloadType =
| { | {
callMode: CallMode.Direct; callMode: CallMode.Direct;
conversationId: string; conversationId: string;
@ -299,7 +300,7 @@ const ACCEPT_CALL_PENDING = 'calling/ACCEPT_CALL_PENDING';
const CANCEL_CALL = 'calling/CANCEL_CALL'; const CANCEL_CALL = 'calling/CANCEL_CALL';
const CANCEL_INCOMING_GROUP_CALL_RING = const CANCEL_INCOMING_GROUP_CALL_RING =
'calling/CANCEL_INCOMING_GROUP_CALL_RING'; 'calling/CANCEL_INCOMING_GROUP_CALL_RING';
const SHOW_CALL_LOBBY = 'calling/SHOW_CALL_LOBBY'; const START_CALLING_LOBBY = 'calling/START_CALLING_LOBBY';
const CALL_STATE_CHANGE_FULFILLED = 'calling/CALL_STATE_CHANGE_FULFILLED'; const CALL_STATE_CHANGE_FULFILLED = 'calling/CALL_STATE_CHANGE_FULFILLED';
const CHANGE_IO_DEVICE_FULFILLED = 'calling/CHANGE_IO_DEVICE_FULFILLED'; const CHANGE_IO_DEVICE_FULFILLED = 'calling/CHANGE_IO_DEVICE_FULFILLED';
const CLOSE_NEED_PERMISSION_SCREEN = 'calling/CLOSE_NEED_PERMISSION_SCREEN'; const CLOSE_NEED_PERMISSION_SCREEN = 'calling/CLOSE_NEED_PERMISSION_SCREEN';
@ -344,9 +345,9 @@ type CancelIncomingGroupCallRingActionType = {
payload: CancelIncomingGroupCallRingType; payload: CancelIncomingGroupCallRingType;
}; };
type CallLobbyActionType = { type StartCallingLobbyActionType = {
type: 'calling/SHOW_CALL_LOBBY'; type: 'calling/START_CALLING_LOBBY';
payload: ShowCallLobbyType; payload: StartCallingLobbyPayloadType;
}; };
type CallStateChangeFulfilledActionType = { type CallStateChangeFulfilledActionType = {
@ -460,8 +461,8 @@ type SetOutgoingRingActionType = {
}; };
type ShowCallLobbyActionType = { type ShowCallLobbyActionType = {
type: 'calling/SHOW_CALL_LOBBY'; type: 'calling/START_CALLING_LOBBY';
payload: ShowCallLobbyType; payload: StartCallingLobbyPayloadType;
}; };
type StartDirectCallActionType = { type StartDirectCallActionType = {
@ -493,7 +494,7 @@ export type CallingActionType =
| AcceptCallPendingActionType | AcceptCallPendingActionType
| CancelCallActionType | CancelCallActionType
| CancelIncomingGroupCallRingActionType | CancelIncomingGroupCallRingActionType
| CallLobbyActionType | StartCallingLobbyActionType
| CallStateChangeFulfilledActionType | CallStateChangeFulfilledActionType
| ChangeIODeviceFulfilledActionType | ChangeIODeviceFulfilledActionType
| CloseNeedPermissionScreenActionType | CloseNeedPermissionScreenActionType
@ -1081,20 +1082,50 @@ function setOutgoingRing(payload: boolean): SetOutgoingRingActionType {
}; };
} }
function startCallingLobby( function startCallingLobby({
payload: StartCallingLobbyType conversationId,
): ThunkAction<void, RootStateType, unknown, never> { isVideoCall,
return () => { }: StartCallingLobbyType): ThunkAction<
calling.startCallingLobby(payload.conversationId, payload.isVideoCall); void,
}; RootStateType,
} unknown,
StartCallingLobbyActionType
> {
return async (dispatch, getState) => {
const state = getState();
const conversation = getOwn(
state.conversations.conversationLookup,
conversationId
);
strictAssert(
conversation,
"startCallingLobby: can't start lobby without a conversation"
);
// TODO: This action should be replaced with an action dispatched in the // The group call device count is considered 0 for a direct call.
// `startCallingLobby` thunk. const groupCall = getGroupCall(conversationId, state.calling);
function showCallLobby(payload: ShowCallLobbyType): CallLobbyActionType { const groupCallDeviceCount =
return { groupCall?.peekInfo.deviceCount ||
type: SHOW_CALL_LOBBY, groupCall?.remoteParticipants.length ||
payload, 0;
const callLobbyData = await calling.startCallingLobby({
conversation,
hasLocalAudio: groupCallDeviceCount < 8,
hasLocalVideo: isVideoCall,
});
if (!callLobbyData) {
return;
}
dispatch({
type: START_CALLING_LOBBY,
payload: {
...callLobbyData,
conversationId,
isConversationTooBigToRing: isConversationTooBigToRing(conversation),
},
});
}; };
} }
@ -1207,7 +1238,6 @@ export const actions = {
setPresenting, setPresenting,
setRendererCanvas, setRendererCanvas,
setOutgoingRing, setOutgoingRing,
showCallLobby,
startCall, startCall,
startCallingLobby, startCallingLobby,
toggleParticipants, toggleParticipants,
@ -1261,7 +1291,7 @@ export function reducer(
): CallingStateType { ): CallingStateType {
const { callsByConversation } = state; const { callsByConversation } = state;
if (action.type === SHOW_CALL_LOBBY) { if (action.type === START_CALLING_LOBBY) {
const { conversationId } = action.payload; const { conversationId } = action.payload;
let call: DirectCallStateType | GroupCallStateType; let call: DirectCallStateType | GroupCallStateType;

View File

@ -1,8 +1,10 @@
// Copyright 2020-2021 Signal Messenger, LLC // Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai'; import { assert } from 'chai';
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { cloneDeep, noop } from 'lodash';
import type { StateType as RootStateType } from '../../../state/reducer';
import { reducer as rootReducer } from '../../../state/reducer'; import { reducer as rootReducer } from '../../../state/reducer';
import { noopAction } from '../../../state/ducks/noop'; import { noopAction } from '../../../state/ducks/noop';
import type { import type {
@ -25,6 +27,8 @@ import {
} from '../../../types/Calling'; } from '../../../types/Calling';
import { UUID } from '../../../types/UUID'; import { UUID } from '../../../types/UUID';
import type { UUIDStringType } from '../../../types/UUID'; import type { UUIDStringType } from '../../../types/UUID';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import type { UnwrapPromise } from '../../../types/Util';
describe('calling duck', () => { describe('calling duck', () => {
const stateWithDirectCall: CallingStateType = { const stateWithDirectCall: CallingStateType = {
@ -1606,48 +1610,175 @@ describe('calling duck', () => {
}); });
}); });
describe('showCallLobby', () => { describe('startCallingLobby', () => {
const { showCallLobby } = actions; const { startCallingLobby } = actions;
it('saves a direct call and makes it active', () => { let rootState: RootStateType;
const result = reducer( let startCallingLobbyStub: sinon.SinonStub;
getEmptyState(),
showCallLobby({ beforeEach(function beforeEach() {
callMode: CallMode.Direct, 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', 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);
callingState.callsByConversation[
'fake-group-call-conversation-id'
].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, hasLocalAudio: true,
hasLocalVideo: true, hasLocalVideo: true,
}) });
);
assert.deepEqual(result.callsByConversation['fake-conversation-id'], { const dispatch = sinon.stub();
callMode: CallMode.Direct,
conversationId: 'fake-conversation-id', await startCallingLobby({
isIncoming: false, conversationId: 'fake-conversation-id',
isVideoCall: true, isVideoCall: true,
})(dispatch, () => rootState, null);
sinon.assert.calledOnce(dispatch);
}); });
assert.deepEqual(result.activeCallState, {
conversationId: 'fake-conversation-id', it("doesn't dispatch an action if the calling lobby returns nothing", async () => {
hasLocalAudio: true, const dispatch = sinon.stub();
hasLocalVideo: true,
isInSpeakerView: false, await startCallingLobby({
showParticipantsList: false, conversationId: 'fake-conversation-id',
safetyNumberChangedUuids: [], isVideoCall: true,
pip: false, })(dispatch, () => rootState, null);
settingsDialogOpen: false,
outgoingRing: true, sinon.assert.notCalled(dispatch);
}); });
}); });
it('saves a group call and makes it active', () => { describe('action', () => {
const result = reducer( const getState = async (
getEmptyState(), callingState: CallingStateType,
showCallLobby({ callingServiceResult: UnwrapPromise<
callMode: CallMode.Group, 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, {
conversationId: 'fake-conversation-id', conversationId: 'fake-conversation-id',
hasLocalAudio: true, hasLocalAudio: true,
hasLocalVideo: true, hasLocalVideo: true,
isConversationTooBigToRing: false, isInSpeakerView: false,
showParticipantsList: false,
safetyNumberChangedUuids: [],
pip: false,
settingsDialogOpen: false,
outgoingRing: true,
});
});
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, connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined, joinState: GroupCallJoinState.NotJoined,
peekInfo: { peekInfo: {
@ -1668,74 +1799,63 @@ describe('calling duck', () => {
videoAspectRatio: 4 / 3, videoAspectRatio: 4 / 3,
}, },
], ],
}) });
);
assert.deepEqual(result.callsByConversation['fake-conversation-id'], { assert.deepEqual(result.callsByConversation['fake-conversation-id'], {
callMode: CallMode.Group, callMode: CallMode.Group,
conversationId: 'fake-conversation-id', conversationId: 'fake-conversation-id',
connectionState: GroupCallConnectionState.Connected, connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined, joinState: GroupCallJoinState.NotJoined,
peekInfo: { peekInfo: {
uuids: [creatorUuid], uuids: [creatorUuid],
creatorUuid, creatorUuid,
eraId: 'xyz', eraId: 'xyz',
maxDevices: 16, maxDevices: 16,
deviceCount: 1, deviceCount: 1,
}, },
remoteParticipants: [ remoteParticipants: [
{ {
uuid: remoteUuid, uuid: remoteUuid,
demuxId: 123, demuxId: 123,
hasRemoteAudio: true, hasRemoteAudio: true,
hasRemoteVideo: true, hasRemoteVideo: true,
presenting: false, presenting: false,
sharingScreen: false, sharingScreen: false,
videoAspectRatio: 4 / 3, videoAspectRatio: 4 / 3,
}, },
], ],
}); });
assert.deepEqual( assert.deepEqual(
result.activeCallState?.conversationId, result.activeCallState?.conversationId,
'fake-conversation-id' 'fake-conversation-id'
); );
assert.isFalse(result.activeCallState?.outgoingRing); assert.isFalse(result.activeCallState?.outgoingRing);
}); });
it('chooses fallback peek info if none is sent and there is no existing call', () => { it('chooses fallback peek info if none is sent and there is no existing call', async () => {
const result = reducer( const result = await getState(getEmptyState(), {
getEmptyState(),
showCallLobby({
callMode: CallMode.Group, callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
hasLocalAudio: true, hasLocalAudio: true,
hasLocalVideo: true, hasLocalVideo: true,
isConversationTooBigToRing: false,
connectionState: GroupCallConnectionState.Connected, connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined, joinState: GroupCallJoinState.NotJoined,
peekInfo: undefined, peekInfo: undefined,
remoteParticipants: [], remoteParticipants: [],
}) });
);
const call = const call = result.callsByConversation['fake-conversation-id'];
result.callsByConversation['fake-group-call-conversation-id']; assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, {
assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { uuids: [],
uuids: [], maxDevices: Infinity,
maxDevices: Infinity, deviceCount: 0,
deviceCount: 0, });
}); });
});
it("doesn't overwrite an existing group call's peek info if none was sent", () => { it("doesn't overwrite an existing group call's peek info if none was sent", async () => {
const result = reducer( const result = await getState(stateWithGroupCall, {
stateWithGroupCall,
showCallLobby({
callMode: CallMode.Group, callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
hasLocalAudio: true, hasLocalAudio: true,
hasLocalVideo: true, hasLocalVideo: true,
isConversationTooBigToRing: false,
connectionState: GroupCallConnectionState.Connected, connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined, joinState: GroupCallJoinState.NotJoined,
peekInfo: undefined, peekInfo: undefined,
@ -1750,29 +1870,36 @@ describe('calling duck', () => {
videoAspectRatio: 4 / 3, videoAspectRatio: 4 / 3,
}, },
], ],
}) });
);
const call = const call =
result.callsByConversation['fake-group-call-conversation-id']; result.callsByConversation['fake-group-call-conversation-id'];
assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, {
uuids: [creatorUuid], uuids: [creatorUuid],
creatorUuid, creatorUuid,
eraId: 'xyz', eraId: 'xyz',
maxDevices: 16, maxDevices: 16,
deviceCount: 1, deviceCount: 1,
});
}); });
});
it("can overwrite an existing group call's peek info", () => { it("can overwrite an existing group call's peek info", async () => {
const result = reducer( const state = {
stateWithGroupCall, ...getEmptyState(),
showCallLobby({ callsByConversation: {
'fake-conversation-id': {
...stateWithGroupCall.callsByConversation[
'fake-group-call-conversation-id'
],
conversationId: 'fake-conversation-id',
},
},
};
const result = await getState(state, {
callMode: CallMode.Group, callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
hasLocalAudio: true, hasLocalAudio: true,
hasLocalVideo: true, hasLocalVideo: true,
isConversationTooBigToRing: false,
connectionState: GroupCallConnectionState.Connected, connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined, joinState: GroupCallJoinState.NotJoined,
peekInfo: { peekInfo: {
@ -1793,65 +1920,62 @@ describe('calling duck', () => {
videoAspectRatio: 4 / 3, videoAspectRatio: 4 / 3,
}, },
], ],
}) });
);
const call = const call = result.callsByConversation['fake-conversation-id'];
result.callsByConversation['fake-group-call-conversation-id']; assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, {
assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { uuids: [differentCreatorUuid],
uuids: [differentCreatorUuid], creatorUuid: differentCreatorUuid,
creatorUuid: differentCreatorUuid, eraId: 'abc',
eraId: 'abc', maxDevices: 5,
maxDevices: 5, deviceCount: 1,
deviceCount: 1, });
}); });
});
it("doesn't overwrite an existing group call's ring state if it was set previously", () => { it("doesn't overwrite an existing group call's ring state if it was set previously", async () => {
const result = reducer( const result = await getState(
{ {
...stateWithGroupCall, ...stateWithGroupCall,
callsByConversation: { callsByConversation: {
'fake-group-call-conversation-id': { 'fake-group-call-conversation-id': {
...stateWithGroupCall.callsByConversation[ ...stateWithGroupCall.callsByConversation[
'fake-group-call-conversation-id' 'fake-group-call-conversation-id'
], ],
ringId: BigInt(987), ringId: BigInt(987),
ringerUuid, ringerUuid,
},
}, },
}, },
}, {
showCallLobby({ callMode: CallMode.Group,
callMode: CallMode.Group, hasLocalAudio: true,
conversationId: 'fake-group-call-conversation-id', hasLocalVideo: true,
hasLocalAudio: true, connectionState: GroupCallConnectionState.Connected,
hasLocalVideo: true, joinState: GroupCallJoinState.NotJoined,
isConversationTooBigToRing: false, peekInfo: undefined,
connectionState: GroupCallConnectionState.Connected, remoteParticipants: [
joinState: GroupCallJoinState.NotJoined, {
peekInfo: undefined, uuid: remoteUuid,
remoteParticipants: [ demuxId: 123,
{ hasRemoteAudio: true,
uuid: remoteUuid, hasRemoteVideo: true,
demuxId: 123, presenting: false,
hasRemoteAudio: true, sharingScreen: false,
hasRemoteVideo: true, videoAspectRatio: 4 / 3,
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.
const call = if (call?.callMode !== CallMode.Group) {
result.callsByConversation['fake-group-call-conversation-id']; throw new Error('Expected to find a group call');
// 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.ringId, BigInt(987));
assert.strictEqual(call.ringerUuid, ringerUuid); assert.strictEqual(call.ringerUuid, ringerUuid);
});
}); });
}); });

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC // Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable camelcase */ /* eslint-disable camelcase */
@ -657,7 +657,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
async onOutgoingVideoCallInConversation(): Promise<void> { async onOutgoingVideoCallInConversation(): Promise<void> {
log.info('onOutgoingVideoCallInConversation: about to start a video call'); log.info('onOutgoingVideoCallInConversation: about to start a video call');
const isVideoCall = true;
if (this.model.get('announcementsOnly') && !this.model.areWeAdmin()) { if (this.model.get('announcementsOnly') && !this.model.areWeAdmin()) {
showToast(ToastCannotStartGroupCall); showToast(ToastCannotStartGroupCall);
@ -668,10 +667,10 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
log.info( log.info(
'onOutgoingVideoCallInConversation: call is deemed "safe". Making call' 'onOutgoingVideoCallInConversation: call is deemed "safe". Making call'
); );
await window.Signal.Services.calling.startCallingLobby( window.reduxActions.calling.startCallingLobby({
this.model.id, conversationId: this.model.id,
isVideoCall isVideoCall: true,
); });
log.info('onOutgoingVideoCallInConversation: started the call'); log.info('onOutgoingVideoCallInConversation: started the call');
} else { } else {
log.info( log.info(
@ -683,16 +682,14 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
async onOutgoingAudioCallInConversation(): Promise<void> { async onOutgoingAudioCallInConversation(): Promise<void> {
log.info('onOutgoingAudioCallInConversation: about to start an audio call'); log.info('onOutgoingAudioCallInConversation: about to start an audio call');
const isVideoCall = false;
if (await this.isCallSafe()) { if (await this.isCallSafe()) {
log.info( log.info(
'onOutgoingAudioCallInConversation: call is deemed "safe". Making call' 'onOutgoingAudioCallInConversation: call is deemed "safe". Making call'
); );
await window.Signal.Services.calling.startCallingLobby( window.reduxActions.calling.startCallingLobby({
this.model.id, conversationId: this.model.id,
isVideoCall isVideoCall: false,
); });
log.info('onOutgoingAudioCallInConversation: started the call'); log.info('onOutgoingAudioCallInConversation: started the call');
} else { } else {
log.info( log.info(