diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 5900d87c7..518e70382 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1447,6 +1447,10 @@ "message": "Return to 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": { "message": "Call is full", "description": "Text in the call lobby when you can't join because the call is full" diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index b446458f6..21f60cd36 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -1,4 +1,4 @@ -// Copyright 2018-2021 Signal Messenger, LLC +// Copyright 2018-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // 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; } } - - &__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 { diff --git a/stylesheets/components/CallingToast.scss b/stylesheets/components/CallingToast.scss new file mode 100644 index 000000000..d63408382 --- /dev/null +++ b/stylesheets/components/CallingToast.scss @@ -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; + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 30b800b91..e591f43f6 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -1,4 +1,4 @@ -// Copyright 2014-2021 Signal Messenger, LLC +// Copyright 2014-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // Global Settings, Variables, and Mixins @@ -43,6 +43,7 @@ @import './components/CallingPreCallInfo.scss'; @import './components/CallingScreenSharingController.scss'; @import './components/CallingSelectPresentingSourcesModal.scss'; +@import './components/CallingToast.scss'; @import './components/ChatColorPicker.scss'; @import './components/Checkbox.scss'; @import './components/CompositionArea.scss'; diff --git a/ts/components/CallingLobby.stories.tsx b/ts/components/CallingLobby.stories.tsx index af1ff0681..9c1000e82 100644 --- a/ts/components/CallingLobby.stories.tsx +++ b/ts/components/CallingLobby.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2020-2021 Signal Messenger, LLC +// Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -51,11 +51,11 @@ const createProps = (overrideProps: Partial = {}): PropsType => { (isGroupCall ? times(3, () => getDefaultConversation()) : undefined), hasLocalAudio: boolean( 'hasLocalAudio', - overrideProps.hasLocalAudio || false + overrideProps.hasLocalAudio ?? true ), hasLocalVideo: boolean( 'hasLocalVideo', - overrideProps.hasLocalVideo || false + overrideProps.hasLocalVideo ?? false ), i18n, isGroupCall, @@ -122,9 +122,9 @@ story.add('Local Video', () => { return ; }); -story.add('Local Video', () => { +story.add('Initially muted', () => { const props = createProps({ - hasLocalVideo: true, + hasLocalAudio: false, }); return ; }); diff --git a/ts/components/CallingLobby.tsx b/ts/components/CallingLobby.tsx index bb997cf8f..ecf248553 100644 --- a/ts/components/CallingLobby.tsx +++ b/ts/components/CallingLobby.tsx @@ -1,4 +1,4 @@ -// Copyright 2020-2021 Signal Messenger, LLC +// Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; @@ -13,6 +13,7 @@ import { CallingButton, CallingButtonType } from './CallingButton'; import { TooltipPlacement } from './Tooltip'; import { CallBackgroundBlur } from './CallBackgroundBlur'; import { CallingHeader } from './CallingHeader'; +import { CallingToast, DEFAULT_LIFETIME } from './CallingToast'; import { CallingPreCallInfo, RingMode } from './CallingPreCallInfo'; import { CallingLobbyJoinButton, @@ -92,6 +93,21 @@ export const CallingLobby = ({ toggleSettings, outgoingRing, }: 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); const shouldShowLocalVideo = hasLocalVideo && availableCameras.length > 0; @@ -221,6 +237,15 @@ export const CallingLobby = ({ /> )} + setIsMutedToastVisible(false)} + > + {i18n( + 'calling__lobby-automatically-muted-because-there-are-a-lot-of-people' + )} + + unknown; +}; + +export const DEFAULT_LIFETIME = 5000; + +export const CallingToast: FunctionComponent = ({ + isVisible, + onClick, + children, +}) => ( + +); diff --git a/ts/components/CallingToastManager.tsx b/ts/components/CallingToastManager.tsx index 628878dc9..bb53ec6ff 100644 --- a/ts/components/CallingToastManager.tsx +++ b/ts/components/CallingToastManager.tsx @@ -1,12 +1,12 @@ -// Copyright 2020-2021 Signal Messenger, LLC +// Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useCallback, useEffect, useRef, useState } from 'react'; -import classNames from 'classnames'; import type { ActiveCallType } from '../types/Calling'; import { CallMode, GroupCallConnectionState } from '../types/Calling'; import type { ConversationType } from '../state/ducks/conversations'; import type { LocalizerType } from '../types/Util'; +import { CallingToast, DEFAULT_LIFETIME } from './CallingToast'; type PropsType = { activeCall: ActiveCallType; @@ -101,8 +101,6 @@ function useScreenSharingToast({ activeCall, i18n }: PropsType): ToastType { return result; } -const DEFAULT_DELAY = 5000; - // In the future, this component should show toasts when users join or leave. See // DESKTOP-902. export const CallingToastManager: React.FC = props => { @@ -131,7 +129,7 @@ export const CallingToastManager: React.FC = props => { if (timeoutRef && timeoutRef.current) { clearTimeout(timeoutRef.current); } - timeoutRef.current = setTimeout(dismissToast, DEFAULT_DELAY); + timeoutRef.current = setTimeout(dismissToast, DEFAULT_LIFETIME); } setToastMessage(toast.message); @@ -144,17 +142,9 @@ export const CallingToastManager: React.FC = props => { }; }, [dismissToast, setToastMessage, timeoutRef, toast]); - const isVisible = Boolean(toastMessage); - return ( - + ); }; diff --git a/ts/services/calling.ts b/ts/services/calling.ts index e4dd05132..b783d525f 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -1,4 +1,4 @@ -// Copyright 2020-2021 Signal Messenger, LLC +// Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { DesktopCapturerSource } from 'electron'; @@ -39,10 +39,11 @@ import { uniqBy, noop } from 'lodash'; import type { ActionsType as UxActionsType, + GroupCallParticipantInfoType, GroupCallPeekInfoType, } from '../state/ducks/calling'; +import type { ConversationType } from '../state/ducks/conversations'; import { getConversationCallMode } from '../state/ducks/conversations'; -import { isConversationTooBigToRing } from '../conversations/isConversationTooBigToRing'; import { isMe } from '../util/whatTypeOfConversation'; import type { AvailableIODevicesType, @@ -99,6 +100,7 @@ import { FALLBACK_NOTIFICATION_TITLE, } from './notifications'; import * as log from '../logging/log'; +import { assert } from '../util/assert'; const { processGroupCallRingRequest, @@ -308,30 +310,47 @@ export class CallingClass { RingRTC.setSelfUuid(Buffer.from(uuidToBytes(ourUuid))); } - async startCallingLobby( - conversationId: string, - isVideoCall: boolean - ): Promise { + async startCallingLobby({ + conversation, + hasLocalAudio, + hasLocalVideo, + }: Readonly<{ + conversation: Readonly; + hasLocalAudio: boolean; + hasLocalVideo: boolean; + }>): Promise< + | undefined + | ({ hasLocalAudio: boolean; hasLocalVideo: boolean } & ( + | { callMode: CallMode.Direct } + | { + callMode: CallMode.Group; + connectionState: GroupCallConnectionState; + joinState: GroupCallJoinState; + peekInfo?: GroupCallPeekInfoType; + remoteParticipants: Array; + } + )) + > { log.info('CallingClass.startCallingLobby()'); - const conversation = window.ConversationController.get(conversationId); - if (!conversation) { - log.error('Could not find conversation, cannot start call lobby'); - return; - } - - const conversationProps = conversation.format(); - const callMode = getConversationCallMode(conversationProps); + const callMode = getConversationCallMode(conversation); switch (callMode) { case CallMode.None: log.error('Conversation does not support calls, new call not allowed.'); return; - case CallMode.Direct: - if (!this.getRemoteUserIdFromConversation(conversation)) { + case CallMode.Direct: { + const conversationModel = window.ConversationController.get( + conversation.id + ); + if ( + !conversationModel || + !this.getRemoteUserIdFromConversation(conversationModel) + ) { log.error('Missing remote user identifier, new call not allowed.'); return; } break; + } case CallMode.Group: break; default: @@ -348,7 +367,7 @@ export class CallingClass { return; } - const haveMediaPermissions = await this.requestPermissions(isVideoCall); + const haveMediaPermissions = await this.requestPermissions(hasLocalVideo); if (!haveMediaPermissions) { log.info('Permissions were denied, new call not allowed.'); return; @@ -374,51 +393,53 @@ export class CallingClass { // is fixed. See DESKTOP-1032. await this.startDeviceReselectionTimer(); + const enableLocalCameraIfNecessary = hasLocalVideo + ? () => this.enableLocalCamera() + : noop; + switch (callMode) { 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, - conversationId: conversationProps.id, - hasLocalAudio: true, - hasLocalVideo: isVideoCall, - }); - break; + hasLocalAudio, + hasLocalVideo, + }; case CallMode.Group: { if ( - !conversationProps.groupId || - !conversationProps.publicParams || - !conversationProps.secretParams + !conversation.groupId || + !conversation.publicParams || + !conversation.secretParams ) { log.error( 'Conversation is missing required parameters. Cannot connect group call' ); return; } - const groupCall = this.connectGroupCall(conversationProps.id, { - groupId: conversationProps.groupId, - publicParams: conversationProps.publicParams, - secretParams: conversationProps.secretParams, + const groupCall = this.connectGroupCall(conversation.id, { + groupId: conversation.groupId, + publicParams: conversation.publicParams, + secretParams: conversation.secretParams, }); - groupCall.setOutgoingAudioMuted(false); - groupCall.setOutgoingVideoMuted(!isVideoCall); + groupCall.setOutgoingAudioMuted(!hasLocalAudio); + groupCall.setOutgoingVideoMuted(!hasLocalVideo); - this.uxActions.showCallLobby({ + enableLocalCameraIfNecessary(); + + return { callMode: CallMode.Group, - conversationId: conversationProps.id, - isConversationTooBigToRing: - isConversationTooBigToRing(conversationProps), ...this.formatGroupCallForRedux(groupCall), - }); - break; + }; } default: throw missingCaseError(callMode); } - - if (isVideoCall) { - this.enableLocalCamera(); - } } stopCallingLobby(conversationId?: string): void { @@ -443,7 +464,6 @@ export class CallingClass { } const conversation = window.ConversationController.get(conversationId); - if (!conversation) { log.error('Could not find conversation, cannot start call'); this.stopCallingLobby(); diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 876f29f99..ce3b64097 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -1,4 +1,4 @@ -// Copyright 2020-2021 Signal Messenger, LLC +// Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { ipcRenderer } from 'electron'; @@ -38,6 +38,7 @@ import { LatestQueue } from '../../util/LatestQueue'; import type { UUIDStringType } from '../../types/UUID'; import type { ConversationChangedActionType } from './conversations'; import * as log from '../../logging/log'; +import { strictAssert } from '../../util/assert'; // State @@ -223,7 +224,7 @@ export type StartCallingLobbyType = { isVideoCall: boolean; }; -export type ShowCallLobbyType = +type StartCallingLobbyPayloadType = | { callMode: CallMode.Direct; conversationId: string; @@ -299,7 +300,7 @@ const ACCEPT_CALL_PENDING = 'calling/ACCEPT_CALL_PENDING'; const CANCEL_CALL = 'calling/CANCEL_CALL'; const 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 CHANGE_IO_DEVICE_FULFILLED = 'calling/CHANGE_IO_DEVICE_FULFILLED'; const CLOSE_NEED_PERMISSION_SCREEN = 'calling/CLOSE_NEED_PERMISSION_SCREEN'; @@ -344,9 +345,9 @@ type CancelIncomingGroupCallRingActionType = { payload: CancelIncomingGroupCallRingType; }; -type CallLobbyActionType = { - type: 'calling/SHOW_CALL_LOBBY'; - payload: ShowCallLobbyType; +type StartCallingLobbyActionType = { + type: 'calling/START_CALLING_LOBBY'; + payload: StartCallingLobbyPayloadType; }; type CallStateChangeFulfilledActionType = { @@ -460,8 +461,8 @@ type SetOutgoingRingActionType = { }; type ShowCallLobbyActionType = { - type: 'calling/SHOW_CALL_LOBBY'; - payload: ShowCallLobbyType; + type: 'calling/START_CALLING_LOBBY'; + payload: StartCallingLobbyPayloadType; }; type StartDirectCallActionType = { @@ -493,7 +494,7 @@ export type CallingActionType = | AcceptCallPendingActionType | CancelCallActionType | CancelIncomingGroupCallRingActionType - | CallLobbyActionType + | StartCallingLobbyActionType | CallStateChangeFulfilledActionType | ChangeIODeviceFulfilledActionType | CloseNeedPermissionScreenActionType @@ -1081,20 +1082,50 @@ function setOutgoingRing(payload: boolean): SetOutgoingRingActionType { }; } -function startCallingLobby( - payload: StartCallingLobbyType -): ThunkAction { - return () => { - calling.startCallingLobby(payload.conversationId, payload.isVideoCall); - }; -} +function startCallingLobby({ + conversationId, + isVideoCall, +}: StartCallingLobbyType): ThunkAction< + 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 -// `startCallingLobby` thunk. -function showCallLobby(payload: ShowCallLobbyType): CallLobbyActionType { - return { - type: SHOW_CALL_LOBBY, - payload, + // The group call device count is considered 0 for a direct call. + const groupCall = getGroupCall(conversationId, state.calling); + const groupCallDeviceCount = + groupCall?.peekInfo.deviceCount || + groupCall?.remoteParticipants.length || + 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, setRendererCanvas, setOutgoingRing, - showCallLobby, startCall, startCallingLobby, toggleParticipants, @@ -1261,7 +1291,7 @@ export function reducer( ): CallingStateType { const { callsByConversation } = state; - if (action.type === SHOW_CALL_LOBBY) { + if (action.type === START_CALLING_LOBBY) { const { conversationId } = action.payload; let call: DirectCallStateType | GroupCallStateType; diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index 23e807baa..c700b7c57 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -1,8 +1,10 @@ -// Copyright 2020-2021 Signal Messenger, LLC +// Copyright 2020-2022 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 { StateType as RootStateType } from '../../../state/reducer'; import { reducer as rootReducer } from '../../../state/reducer'; import { noopAction } from '../../../state/ducks/noop'; import type { @@ -25,6 +27,8 @@ import { } from '../../../types/Calling'; import { UUID } 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', () => { const stateWithDirectCall: CallingStateType = { @@ -1606,48 +1610,175 @@ describe('calling duck', () => { }); }); - describe('showCallLobby', () => { - const { showCallLobby } = actions; + describe('startCallingLobby', () => { + const { startCallingLobby } = actions; - it('saves a direct call and makes it active', () => { - const result = reducer( - getEmptyState(), - showCallLobby({ - callMode: CallMode.Direct, + let rootState: RootStateType; + let startCallingLobbyStub: sinon.SinonStub; + + beforeEach(function beforeEach() { + 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); + 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, hasLocalVideo: true, - }) - ); + }); - assert.deepEqual(result.callsByConversation['fake-conversation-id'], { - callMode: CallMode.Direct, - conversationId: 'fake-conversation-id', - isIncoming: false, - isVideoCall: true, + const dispatch = sinon.stub(); + + await startCallingLobby({ + conversationId: 'fake-conversation-id', + isVideoCall: true, + })(dispatch, () => rootState, null); + + sinon.assert.calledOnce(dispatch); }); - assert.deepEqual(result.activeCallState, { - conversationId: 'fake-conversation-id', - hasLocalAudio: true, - hasLocalVideo: true, - isInSpeakerView: false, - showParticipantsList: false, - safetyNumberChangedUuids: [], - pip: false, - settingsDialogOpen: false, - outgoingRing: true, + + 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); }); }); - it('saves a group call and makes it active', () => { - const result = reducer( - getEmptyState(), - showCallLobby({ - callMode: CallMode.Group, + describe('action', () => { + const getState = async ( + callingState: CallingStateType, + callingServiceResult: UnwrapPromise< + ReturnType + >, + conversationId = 'fake-conversation-id' + ): Promise => { + 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', hasLocalAudio: 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, joinState: GroupCallJoinState.NotJoined, peekInfo: { @@ -1668,74 +1799,63 @@ describe('calling duck', () => { videoAspectRatio: 4 / 3, }, ], - }) - ); + }); - assert.deepEqual(result.callsByConversation['fake-conversation-id'], { - callMode: CallMode.Group, - conversationId: 'fake-conversation-id', - connectionState: GroupCallConnectionState.Connected, - joinState: GroupCallJoinState.NotJoined, - peekInfo: { - uuids: [creatorUuid], - creatorUuid, - eraId: 'xyz', - maxDevices: 16, - deviceCount: 1, - }, - remoteParticipants: [ - { - uuid: remoteUuid, - demuxId: 123, - hasRemoteAudio: true, - hasRemoteVideo: 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', () => { - const result = reducer( - getEmptyState(), - showCallLobby({ + assert.deepEqual(result.callsByConversation['fake-conversation-id'], { + callMode: CallMode.Group, + conversationId: 'fake-conversation-id', + connectionState: GroupCallConnectionState.Connected, + joinState: GroupCallJoinState.NotJoined, + peekInfo: { + uuids: [creatorUuid], + creatorUuid, + eraId: 'xyz', + maxDevices: 16, + deviceCount: 1, + }, + remoteParticipants: [ + { + uuid: remoteUuid, + demuxId: 123, + hasRemoteAudio: true, + hasRemoteVideo: 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, - conversationId: 'fake-group-call-conversation-id', hasLocalAudio: true, hasLocalVideo: true, - isConversationTooBigToRing: false, connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, peekInfo: undefined, remoteParticipants: [], - }) - ); + }); - const call = - result.callsByConversation['fake-group-call-conversation-id']; - assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { - uuids: [], - maxDevices: Infinity, - deviceCount: 0, + const call = result.callsByConversation['fake-conversation-id']; + assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { + uuids: [], + maxDevices: Infinity, + deviceCount: 0, + }); }); - }); - it("doesn't overwrite an existing group call's peek info if none was sent", () => { - const result = reducer( - stateWithGroupCall, - showCallLobby({ + it("doesn't overwrite an existing group call's peek info if none was sent", async () => { + const result = await getState(stateWithGroupCall, { callMode: CallMode.Group, - conversationId: 'fake-group-call-conversation-id', hasLocalAudio: true, hasLocalVideo: true, - isConversationTooBigToRing: false, connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, peekInfo: undefined, @@ -1750,29 +1870,36 @@ describe('calling duck', () => { videoAspectRatio: 4 / 3, }, ], - }) - ); + }); - const call = - result.callsByConversation['fake-group-call-conversation-id']; - assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { - uuids: [creatorUuid], - creatorUuid, - eraId: 'xyz', - maxDevices: 16, - deviceCount: 1, + const call = + result.callsByConversation['fake-group-call-conversation-id']; + assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { + uuids: [creatorUuid], + creatorUuid, + eraId: 'xyz', + maxDevices: 16, + deviceCount: 1, + }); }); - }); - it("can overwrite an existing group call's peek info", () => { - const result = reducer( - stateWithGroupCall, - showCallLobby({ + 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, - conversationId: 'fake-group-call-conversation-id', hasLocalAudio: true, hasLocalVideo: true, - isConversationTooBigToRing: false, connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, peekInfo: { @@ -1793,65 +1920,62 @@ describe('calling duck', () => { videoAspectRatio: 4 / 3, }, ], - }) - ); + }); - const call = - result.callsByConversation['fake-group-call-conversation-id']; - assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { - uuids: [differentCreatorUuid], - creatorUuid: differentCreatorUuid, - eraId: 'abc', - maxDevices: 5, - deviceCount: 1, + const call = result.callsByConversation['fake-conversation-id']; + assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { + uuids: [differentCreatorUuid], + creatorUuid: differentCreatorUuid, + eraId: 'abc', + maxDevices: 5, + deviceCount: 1, + }); }); - }); - it("doesn't overwrite an existing group call's ring state if it was set previously", () => { - const result = reducer( - { - ...stateWithGroupCall, - callsByConversation: { - 'fake-group-call-conversation-id': { - ...stateWithGroupCall.callsByConversation[ - 'fake-group-call-conversation-id' - ], - ringId: BigInt(987), - ringerUuid, + 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), + ringerUuid, + }, }, }, - }, - showCallLobby({ - callMode: CallMode.Group, - conversationId: 'fake-group-call-conversation-id', - hasLocalAudio: true, - hasLocalVideo: true, - isConversationTooBigToRing: false, - connectionState: GroupCallConnectionState.Connected, - joinState: GroupCallJoinState.NotJoined, - peekInfo: undefined, - remoteParticipants: [ - { - uuid: remoteUuid, - demuxId: 123, - hasRemoteAudio: true, - hasRemoteVideo: true, - presenting: false, - sharingScreen: false, - videoAspectRatio: 4 / 3, - }, - ], - }) - ); - const call = - result.callsByConversation['fake-group-call-conversation-id']; - // It'd be nice to do this with an assert, but Chai doesn't understand it. - if (call?.callMode !== CallMode.Group) { - throw new Error('Expected to find a group call'); - } + { + callMode: CallMode.Group, + hasLocalAudio: true, + hasLocalVideo: true, + connectionState: GroupCallConnectionState.Connected, + joinState: GroupCallJoinState.NotJoined, + peekInfo: undefined, + remoteParticipants: [ + { + uuid: remoteUuid, + demuxId: 123, + hasRemoteAudio: true, + hasRemoteVideo: true, + presenting: false, + sharingScreen: false, + videoAspectRatio: 4 / 3, + }, + ], + } + ); + const call = + result.callsByConversation['fake-group-call-conversation-id']; + // It'd be nice to do this with an assert, but Chai doesn't understand it. + if (call?.callMode !== CallMode.Group) { + throw new Error('Expected to find a group call'); + } - assert.strictEqual(call.ringId, BigInt(987)); - assert.strictEqual(call.ringerUuid, ringerUuid); + assert.strictEqual(call.ringId, BigInt(987)); + assert.strictEqual(call.ringerUuid, ringerUuid); + }); }); }); diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 1177b94d6..31c2bf51b 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -1,4 +1,4 @@ -// Copyright 2020-2021 Signal Messenger, LLC +// Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable camelcase */ @@ -657,7 +657,6 @@ export class ConversationView extends window.Backbone.View { async onOutgoingVideoCallInConversation(): Promise { log.info('onOutgoingVideoCallInConversation: about to start a video call'); - const isVideoCall = true; if (this.model.get('announcementsOnly') && !this.model.areWeAdmin()) { showToast(ToastCannotStartGroupCall); @@ -668,10 +667,10 @@ export class ConversationView extends window.Backbone.View { log.info( 'onOutgoingVideoCallInConversation: call is deemed "safe". Making call' ); - await window.Signal.Services.calling.startCallingLobby( - this.model.id, - isVideoCall - ); + window.reduxActions.calling.startCallingLobby({ + conversationId: this.model.id, + isVideoCall: true, + }); log.info('onOutgoingVideoCallInConversation: started the call'); } else { log.info( @@ -683,16 +682,14 @@ export class ConversationView extends window.Backbone.View { async onOutgoingAudioCallInConversation(): Promise { log.info('onOutgoingAudioCallInConversation: about to start an audio call'); - const isVideoCall = false; - if (await this.isCallSafe()) { log.info( 'onOutgoingAudioCallInConversation: call is deemed "safe". Making call' ); - await window.Signal.Services.calling.startCallingLobby( - this.model.id, - isVideoCall - ); + window.reduxActions.calling.startCallingLobby({ + conversationId: this.model.id, + isVideoCall: false, + }); log.info('onOutgoingAudioCallInConversation: started the call'); } else { log.info(