diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index 5acba05b595b..d9eec759cc69 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -105,7 +105,7 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ hangUpActiveCall: action('hang-up-active-call'), hasInitialLoadCompleted: true, i18n, - incomingCall: null, + ringingCall: null, callLink: storyProps.callLink ?? undefined, me: { ...getDefaultConversation({ @@ -148,7 +148,6 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ 'toggle-screen-recording-permissions-dialog' ), toggleSettings: action('toggle-settings'), - isConversationTooBigToRing: false, pauseVoiceNotePlayer: action('pause-audio-player'), }); @@ -243,7 +242,7 @@ export function RingingDirectCall(): JSX.Element { return ( VideoFrameSource; getIsSharingPhoneNumberWithEverybody: () => boolean; getPresentingSources: () => void; - incomingCall: DirectIncomingCall | GroupIncomingCall | null; + ringingCall: DirectIncomingCall | GroupIncomingCall | null; renderDeviceSelection: () => JSX.Element; renderReactionPicker: ( props: React.ComponentProps @@ -140,7 +139,6 @@ export type PropsType = { toggleCallLinkPendingParticipantModal: (contactId: string) => void; toggleScreenRecordingPermissionsDialog: () => unknown; toggleSettings: () => void; - isConversationTooBigToRing: boolean; pauseVoiceNotePlayer: () => void; } & Pick; @@ -153,9 +151,9 @@ type ActiveCallManagerPropsType = { | 'bounceAppIconStop' | 'declineCall' | 'hasInitialLoadCompleted' - | 'incomingCall' | 'notifyForCall' | 'playRingtone' + | 'ringingCall' | 'setIsCallActive' | 'stopRingtone' | 'isConversationTooBigToRing' @@ -544,8 +542,6 @@ export function CallManager({ hangUpActiveCall, hasInitialLoadCompleted, i18n, - incomingCall, - isConversationTooBigToRing, getIsSharingPhoneNumberWithEverybody, me, notifyForCall, @@ -556,6 +552,7 @@ export function CallManager({ renderDeviceSelection, renderEmojiPicker, renderReactionPicker, + ringingCall, selectPresentingSource, sendGroupCallRaiseHand, sendGroupCallReaction, @@ -583,16 +580,13 @@ export function CallManager({ setIsCallActive(isCallActive); }, [isCallActive, setIsCallActive]); - const shouldRing = getShouldRing({ - activeCall, - incomingCall, - isConversationTooBigToRing, - hasInitialLoadCompleted, - }); + // It's important not to use the ringingCall itself, because that changes + const ringingCallId = ringingCall?.conversation.id; useEffect(() => { - if (shouldRing) { + if (hasInitialLoadCompleted && ringingCallId) { log.info('CallManager: Playing ringtone'); playRingtone(); + return () => { log.info('CallManager: Stopping ringtone'); stopRingtone(); @@ -601,7 +595,7 @@ export function CallManager({ stopRingtone(); return noop; - }, [shouldRing, playRingtone, stopRingtone]); + }, [hasInitialLoadCompleted, playRingtone, ringingCallId, stopRingtone]); const mightBeRingingOutgoingGroupCall = isGroupOrAdhocActiveCall(activeCall) && @@ -680,7 +674,7 @@ export function CallManager({ } // In the future, we may want to show the incoming call bar when a call is active. - if (incomingCall) { + if (ringingCall) { return ( ); } return null; } - -function isRinging(callState: CallState | undefined): boolean { - return callState === CallState.Prering || callState === CallState.Ringing; -} - -function isConnected(connectionState: GroupCallConnectionState): boolean { - return ( - connectionState === GroupCallConnectionState.Connecting || - connectionState === GroupCallConnectionState.Connected - ); -} - -function isJoined(joinState: GroupCallJoinState): boolean { - return joinState !== GroupCallJoinState.NotJoined; -} - -function hasRemoteParticipants( - remoteParticipants: Array -): boolean { - return remoteParticipants.length > 0; -} - -function isLonelyGroup(conversation: CallingConversationType): boolean { - return (conversation.sortedGroupMembers?.length ?? 0) < 2; -} - -function getShouldRing({ - activeCall, - incomingCall, - isConversationTooBigToRing, - hasInitialLoadCompleted, -}: Readonly< - Pick< - PropsType, - | 'activeCall' - | 'incomingCall' - | 'isConversationTooBigToRing' - | 'hasInitialLoadCompleted' - > ->): boolean { - if (!hasInitialLoadCompleted) { - return false; - } - - if (incomingCall != null) { - // don't ring a large group - if (isConversationTooBigToRing) { - return false; - } - - if (activeCall != null) { - return false; - } - - if (incomingCall.callMode === CallMode.Direct) { - return ( - isRinging(incomingCall.callState) && - incomingCall.callEndedReason == null - ); - } - - if (incomingCall.callMode === CallMode.Group) { - return ( - !isConnected(incomingCall.connectionState) && - !isJoined(incomingCall.joinState) && - !isLonelyGroup(incomingCall.conversation) - ); - } - - // Adhoc calls can't be incoming. - - throw missingCaseError(incomingCall); - } - - if (activeCall != null) { - switch (activeCall.callMode) { - case CallMode.Direct: - return ( - activeCall.callState === CallState.Prering || - activeCall.callState === CallState.Ringing - ); - case CallMode.Group: - case CallMode.Adhoc: - return ( - activeCall.outgoingRing && - isConnected(activeCall.connectionState) && - isJoined(activeCall.joinState) && - !hasRemoteParticipants(activeCall.remoteParticipants) && - !isLonelyGroup(activeCall.conversation) - ); - default: - throw missingCaseError(activeCall); - } - } - - return false; -} diff --git a/ts/state/ducks/callingHelpers.ts b/ts/state/ducks/callingHelpers.ts index 09b49aa63183..159164168bb6 100644 --- a/ts/state/ducks/callingHelpers.ts +++ b/ts/state/ducks/callingHelpers.ts @@ -3,15 +3,22 @@ // Note that this file should not important any binary addons or Node.js modules // because it can be imported by storybook -import { CallState, GroupCallConnectionState } from '../../types/Calling'; +import { + CallState, + GroupCallConnectionState, + GroupCallJoinState, +} from '../../types/Calling'; import { CallMode } from '../../types/CallDisposition'; + +import type { CallingConversationType } from '../../types/Calling'; import type { AciString } from '../../types/ServiceId'; -import { missingCaseError } from '../../util/missingCaseError'; import type { DirectCallStateType, CallsByConversationType, GroupCallPeekInfoType, GroupCallStateType, + GroupCallParticipantInfoType, + ActiveCallStateType, } from './calling'; export const MAX_CALL_PARTICIPANTS_FOR_DEFAULT_MUTE = 8; @@ -19,28 +26,53 @@ export const MAX_CALL_PARTICIPANTS_FOR_DEFAULT_MUTE = 8; // In theory, there could be multiple incoming calls, or an incoming call while there's // an active call. In practice, the UI is not ready for this, and RingRTC doesn't // support it for direct calls. -export const getIncomingCall = ( +// Adhoc calls can not be incoming, so we don't look for them here. +export const getRingingCall = ( callsByConversation: Readonly, + activeCallState: ActiveCallStateType | undefined, ourAci: AciString -): undefined | DirectCallStateType | GroupCallStateType => - Object.values(callsByConversation).find(call => { - switch (call.callMode) { - case CallMode.Direct: - return call.isIncoming && call.callState === CallState.Ringing; - case CallMode.Group: - return ( - call.ringerAci && - call.connectionState === GroupCallConnectionState.NotConnected && - isAnybodyElseInGroupCall(call.peekInfo, ourAci) - ); - case CallMode.Adhoc: - // Adhoc calls cannot be incoming. - return; - default: - throw missingCaseError(call); +): DirectCallStateType | GroupCallStateType | undefined => { + const callList = Object.values(callsByConversation); + const ringingDirect = callList.find(call => { + if (call.callMode !== CallMode.Direct) { + return false; } + + return isRinging(call.callState) && call.callEndedReason == null; }); + if (ringingDirect) { + return ringingDirect; + } + + return callList.find(call => { + if (call.callMode !== CallMode.Group) { + return false; + } + + // Outgoing - ringerAci is not set for outgoing group calls + if ( + activeCallState?.state === 'Active' && + activeCallState.outgoingRing && + activeCallState.conversationId === call.conversationId && + isConnected(call.connectionState) && + isJoined(call.joinState) && + !hasRemoteParticipants(call.remoteParticipants) + ) { + return true; + } + + // Incoming + return ( + call.ringerAci && + call.ringerAci !== ourAci && + !isConnected(call.connectionState) && + !isJoined(call.joinState) && + isAnybodyElseInGroupCall(call.peekInfo, ourAci) + ); + }); +}; + export const isAnybodyElseInGroupCall = ( peekInfo: undefined | Readonly>, ourAci: AciString @@ -60,3 +92,28 @@ export const isGroupCallActiveOnServer = ( ): boolean => { return Boolean(peekInfo?.eraId); }; + +export function isLonelyGroup(conversation: CallingConversationType): boolean { + return (conversation.sortedGroupMembers?.length ?? 0) < 2; +} + +function isRinging(callState: CallState | undefined): boolean { + return callState === CallState.Prering || callState === CallState.Ringing; +} + +function isConnected(connectionState: GroupCallConnectionState): boolean { + return ( + connectionState === GroupCallConnectionState.Connecting || + connectionState === GroupCallConnectionState.Connected + ); +} + +function isJoined(joinState: GroupCallJoinState): boolean { + return joinState !== GroupCallJoinState.NotJoined; +} + +function hasRemoteParticipants( + remoteParticipants: Array +): boolean { + return remoteParticipants.length > 0; +} diff --git a/ts/state/selectors/calling.ts b/ts/state/selectors/calling.ts index cd4e36153a11..4557d330b7df 100644 --- a/ts/state/selectors/calling.ts +++ b/ts/state/selectors/calling.ts @@ -13,7 +13,7 @@ import type { GroupCallStateType, ActiveCallStateType, } from '../ducks/calling'; -import { getIncomingCall as getIncomingCallHelper } from '../ducks/callingHelpers'; +import { getRingingCall as getRingingCallHelper } from '../ducks/callingHelpers'; import type { PresentedSource } from '../../types/Calling'; import { CallMode } from '../../types/CallDisposition'; import { isCallLinkAdmin, type CallLinkType } from '../../types/CallLink'; @@ -152,25 +152,27 @@ export const isInFullScreenCall = createSelector( Boolean(activeCallState && !activeCallState.pip) ); -export const getIncomingCall = createSelector( +export const getRingingCall = createSelector( getCallsByConversation, + getActiveCallState, getUserACI, ( callsByConversation: CallsByConversationType, + activeCallState: ActiveCallStateType | undefined, ourAci: AciString | undefined ): undefined | DirectCallStateType | GroupCallStateType => { if (!ourAci) { return undefined; } - return getIncomingCallHelper(callsByConversation, ourAci); + return getRingingCallHelper(callsByConversation, activeCallState, ourAci); } ); export const areAnyCallsActiveOrRinging = createSelector( getActiveCall, - getIncomingCall, - (activeCall, incomingCall): boolean => Boolean(activeCall || incomingCall) + getRingingCall, + (activeCall, ringingCall): boolean => Boolean(activeCall || ringingCall) ); export const getPresentingSource = createSelector( diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index 40fc6f7229de..3d1fcc930577 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -48,15 +48,16 @@ import { getActiveCallState, getAvailableCameras, getCallLinkSelector, - getIncomingCall, + getRingingCall, } from '../selectors/calling'; import { getConversationSelector, getMe } from '../selectors/conversations'; -import { getIntl } from '../selectors/user'; +import { getIntl, getUserACI } from '../selectors/user'; import { SmartCallingDeviceSelection } from './CallingDeviceSelection'; import { renderEmojiPicker } from './renderEmojiPicker'; import { renderReactionPicker } from './renderReactionPicker'; import { isSharingPhoneNumberWithEverybody as getIsSharingPhoneNumberWithEverybody } from '../../util/phoneNumberSharingMode'; import { useGlobalModalActions } from '../ducks/globalModals'; +import { isLonelyGroup } from '../ducks/callingHelpers'; function renderDeviceSelection(): JSX.Element { return ; @@ -364,56 +365,62 @@ const mapStateToCallLinkProp = (state: StateType): CallLinkType | undefined => { return callLink; }; -const mapStateToIncomingCallProp = ( +const mapStateToRingingCallProp = ( state: StateType ): DirectIncomingCall | GroupIncomingCall | null => { - const call = getIncomingCall(state); - if (!call) { + const ourAci = getUserACI(state); + const ringingCall = getRingingCall(state); + if (!ringingCall) { return null; } - const conversation = getConversationSelector(state)(call.conversationId); + const conversation = getConversationSelector(state)( + ringingCall.conversationId + ); if (!conversation) { log.error('The incoming call has no corresponding conversation'); return null; } - switch (call.callMode) { + switch (ringingCall.callMode) { case CallMode.Direct: return { callMode: CallMode.Direct as const, - callState: call.callState, - callEndedReason: call.callEndedReason, + callState: ringingCall.callState, + callEndedReason: ringingCall.callEndedReason, conversation, - isVideoCall: call.isVideoCall, + isVideoCall: ringingCall.isVideoCall, }; case CallMode.Group: { - if (!call.ringerAci) { - log.error('The incoming group call has no ring state'); + if (getIsConversationTooBigToRing(conversation)) { + return null; + } + + if (isLonelyGroup(conversation)) { return null; } const conversationSelector = getConversationSelector(state); - const ringer = conversationSelector(call.ringerAci); + const ringer = conversationSelector(ringingCall.ringerAci || ourAci); const otherMembersRung = (conversation.sortedGroupMembers ?? []).filter( c => c.id !== ringer.id && !c.isMe ); return { callMode: CallMode.Group as const, - connectionState: call.connectionState, - joinState: call.joinState, + connectionState: ringingCall.connectionState, + joinState: ringingCall.joinState, conversation, otherMembersRung, ringer, - remoteParticipants: call.remoteParticipants, + remoteParticipants: ringingCall.remoteParticipants, }; } case CallMode.Adhoc: log.error('Cannot handle an incoming adhoc call'); return null; default: - throw missingCaseError(call); + throw missingCaseError(ringingCall); } }; @@ -421,13 +428,10 @@ export const SmartCallManager = memo(function SmartCallManager() { const i18n = useSelector(getIntl); const activeCall = useSelector(mapStateToActiveCallProp); const callLink = useSelector(mapStateToCallLinkProp); - const incomingCall = useSelector(mapStateToIncomingCallProp); + const ringingCall = useSelector(mapStateToRingingCallProp); const availableCameras = useSelector(getAvailableCameras); const hasInitialLoadCompleted = useSelector(getHasInitialLoadCompleted); const me = useSelector(getMe); - const isConversationTooBigToRing = incomingCall - ? getIsConversationTooBigToRing(incomingCall.conversation) - : false; const { approveUser, @@ -493,8 +497,6 @@ export const SmartCallManager = memo(function SmartCallManager() { hangUpActiveCall={hangUpActiveCall} hasInitialLoadCompleted={hasInitialLoadCompleted} i18n={i18n} - incomingCall={incomingCall} - isConversationTooBigToRing={isConversationTooBigToRing} me={me} notifyForCall={notifyForCall} openSystemPreferencesAction={openSystemPreferencesAction} @@ -504,6 +506,7 @@ export const SmartCallManager = memo(function SmartCallManager() { renderDeviceSelection={renderDeviceSelection} renderEmojiPicker={renderEmojiPicker} renderReactionPicker={renderReactionPicker} + ringingCall={ringingCall} sendGroupCallRaiseHand={sendGroupCallRaiseHand} sendGroupCallReaction={sendGroupCallReaction} selectPresentingSource={selectPresentingSource} diff --git a/ts/test-electron/state/selectors/calling_test.ts b/ts/test-electron/state/selectors/calling_test.ts index 93a875fc6356..ba73c5ecc0f9 100644 --- a/ts/test-electron/state/selectors/calling_test.ts +++ b/ts/test-electron/state/selectors/calling_test.ts @@ -17,7 +17,7 @@ import { getCallsByConversation, getCallSelector, getHasAnyAdminCallLinks, - getIncomingCall, + getRingingCall, isInCall, } from '../../../state/selectors/calling'; import type { @@ -181,15 +181,15 @@ describe('state/selectors/calling', () => { }); }); - describe('getIncomingCall', () => { + describe('getRingingCall', () => { it('returns undefined if there are no calls', () => { - assert.isUndefined(getIncomingCall(getEmptyRootState())); + assert.isUndefined(getRingingCall(getEmptyRootState())); }); it('returns undefined if there is no incoming call', () => { - assert.isUndefined(getIncomingCall(getCallingState(stateWithDirectCall))); + assert.isUndefined(getRingingCall(getCallingState(stateWithDirectCall))); assert.isUndefined( - getIncomingCall(getCallingState(stateWithActiveDirectCall)) + getRingingCall(getCallingState(stateWithActiveDirectCall)) ); }); @@ -209,19 +209,19 @@ describe('state/selectors/calling', () => { }, }; - assert.isUndefined(getIncomingCall(getCallingState(state))); + assert.isUndefined(getRingingCall(getCallingState(state))); }); it('returns an incoming direct call', () => { assert.deepEqual( - getIncomingCall(getCallingState(stateWithIncomingDirectCall)), + getRingingCall(getCallingState(stateWithIncomingDirectCall)), incomingDirectCall ); }); it('returns an incoming group call', () => { assert.deepEqual( - getIncomingCall(getCallingState(stateWithIncomingGroupCall)), + getRingingCall(getCallingState(stateWithIncomingGroupCall)), incomingGroupCall ); });