Receive rings for group calls
This commit is contained in:
parent
fe040a2873
commit
79c976668b
27 changed files with 2112 additions and 359 deletions
|
@ -3408,6 +3408,88 @@
|
|||
"message": "Incoming video call...",
|
||||
"description": "Shown in both the incoming call bar and notification for an incoming video call"
|
||||
},
|
||||
"incomingGroupCall__ringing-you": {
|
||||
"message": "$ringer$ is calling you",
|
||||
"description": "Shown in the incoming call bar when someone is ringing you for a group call",
|
||||
"placeholders": {
|
||||
"ringer": {
|
||||
"content": "$1",
|
||||
"example": "Alice"
|
||||
}
|
||||
}
|
||||
},
|
||||
"incomingGroupCall__ringing-1-other": {
|
||||
"message": "$ringer$ is calling you and $otherMember$",
|
||||
"description": "Shown in the incoming call bar when someone is ringing you for a group call",
|
||||
"placeholders": {
|
||||
"ringer": {
|
||||
"content": "$1",
|
||||
"example": "Alice"
|
||||
},
|
||||
"otherMember": {
|
||||
"content": "$2",
|
||||
"example": "Bob"
|
||||
}
|
||||
}
|
||||
},
|
||||
"incomingGroupCall__ringing-2-others": {
|
||||
"message": "$ringer$ is calling you, $first$, and $second$",
|
||||
"description": "Shown in the incoming call bar when someone is ringing you for a group call",
|
||||
"placeholders": {
|
||||
"ringer": {
|
||||
"content": "$1",
|
||||
"example": "Alice"
|
||||
},
|
||||
"first": {
|
||||
"content": "$2",
|
||||
"example": "Bob"
|
||||
},
|
||||
"second": {
|
||||
"content": "$3",
|
||||
"example": "Charlie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"incomingGroupCall__ringing-3-others": {
|
||||
"message": "$ringer$ is calling you, $first$, $second$, and 1 other",
|
||||
"description": "Shown in the incoming call bar when someone is ringing you for a group call",
|
||||
"placeholders": {
|
||||
"ringer": {
|
||||
"content": "$1",
|
||||
"example": "Alice"
|
||||
},
|
||||
"first": {
|
||||
"content": "$2",
|
||||
"example": "Bob"
|
||||
},
|
||||
"second": {
|
||||
"content": "$3",
|
||||
"example": "Charlie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"incomingGroupCall__ringing-many": {
|
||||
"message": "$ringer$ is calling you, $first$, $second$, and $remaining$ others",
|
||||
"description": "Shown in the incoming call bar when someone is ringing you for a group call",
|
||||
"placeholders": {
|
||||
"ringer": {
|
||||
"content": "$1",
|
||||
"example": "Alice"
|
||||
},
|
||||
"first": {
|
||||
"content": "$2",
|
||||
"example": "Bob"
|
||||
},
|
||||
"second": {
|
||||
"content": "$3",
|
||||
"example": "Charlie"
|
||||
},
|
||||
"remaining": {
|
||||
"content": "$4",
|
||||
"example": "5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"outgoingCallPrering": {
|
||||
"message": "Calling...",
|
||||
"description": "Shown in the call screen when placing an outgoing call that isn't ringing yet"
|
||||
|
|
|
@ -153,7 +153,7 @@
|
|||
"redux-ts-utils": "3.2.2",
|
||||
"reselect": "4.0.0",
|
||||
"rimraf": "2.6.2",
|
||||
"ringrtc": "https://github.com/signalapp/signal-ringrtc-node.git#800b31c5d43a1436bcea8b7b3f82a4baf4771bfb",
|
||||
"ringrtc": "https://github.com/signalapp/signal-ringrtc-node.git#031abcc7564e769648a8d8f8bda935fad0d877b9",
|
||||
"rotating-file-stream": "2.1.5",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"sanitize.css": "11.0.0",
|
||||
|
|
|
@ -49,6 +49,8 @@ try {
|
|||
window.GV2_MIGRATION_DISABLE_ADD = false;
|
||||
window.GV2_MIGRATION_DISABLE_INVITE = false;
|
||||
|
||||
window.RING_WHEN_JOINING_GROUP_CALLS = false;
|
||||
|
||||
window.RETRY_DELAY = false;
|
||||
|
||||
window.platform = process.platform;
|
||||
|
|
|
@ -98,7 +98,13 @@ message CallingMessage {
|
|||
}
|
||||
|
||||
message Opaque {
|
||||
optional bytes data = 1;
|
||||
enum Urgency {
|
||||
DROPPABLE = 0;
|
||||
HANDLE_IMMEDIATELY = 1;
|
||||
}
|
||||
|
||||
optional bytes data = 1;
|
||||
optional Urgency urgency = 2;
|
||||
}
|
||||
|
||||
optional Offer offer = 1;
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
&__contact {
|
||||
&__conversation {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
|
|
|
@ -53,20 +53,12 @@ const getCommonActiveCallData = () => ({
|
|||
showParticipantsList: boolean('showParticipantsList', false),
|
||||
});
|
||||
|
||||
const getIncomingCallState = (extraProps = {}) => ({
|
||||
...extraProps,
|
||||
callMode: CallMode.Direct as CallMode.Direct,
|
||||
conversationId: '3051234567',
|
||||
callState: CallState.Ringing,
|
||||
isIncoming: true,
|
||||
isVideoCall: boolean('isVideoCall', true),
|
||||
hasRemoteVideo: true,
|
||||
});
|
||||
|
||||
const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
|
||||
...storyProps,
|
||||
availableCameras: [],
|
||||
acceptCall: action('accept-call'),
|
||||
bounceAppIconStart: action('bounce-app-icon-start'),
|
||||
bounceAppIconStop: action('bounce-app-icon-stop'),
|
||||
cancelCall: action('cancel-call'),
|
||||
closeNeedPermissionScreen: action('close-need-permission-screen'),
|
||||
declineCall: action('decline-call'),
|
||||
|
@ -87,7 +79,9 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
|
|||
}),
|
||||
uuid: 'cb0dd0c8-7393-41e9-a0aa-d631c4109541',
|
||||
},
|
||||
notifyForCall: action('notify-for-call'),
|
||||
openSystemPreferencesAction: action('open-system-preferences-action'),
|
||||
playRingtone: action('play-ringtone'),
|
||||
renderDeviceSelection: () => <div />,
|
||||
renderSafetyNumberViewer: (_: SafetyNumberViewerProps) => <div />,
|
||||
setGroupCallVideoRequest: action('set-group-call-video-request'),
|
||||
|
@ -97,6 +91,7 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
|
|||
setPresenting: action('toggle-presenting'),
|
||||
setRendererCanvas: action('set-renderer-canvas'),
|
||||
startCall: action('start-call'),
|
||||
stopRingtone: action('stop-ringtone'),
|
||||
toggleParticipants: action('toggle-participants'),
|
||||
togglePip: action('toggle-pip'),
|
||||
toggleScreenRecordingPermissionsDialog: action(
|
||||
|
@ -145,12 +140,33 @@ story.add('Ongoing Group Call', () => (
|
|||
/>
|
||||
));
|
||||
|
||||
story.add('Ringing', () => (
|
||||
story.add('Ringing (direct call)', () => (
|
||||
<CallManager
|
||||
{...createProps({
|
||||
incomingCall: {
|
||||
call: getIncomingCallState(),
|
||||
callMode: CallMode.Direct as const,
|
||||
conversation: getConversation(),
|
||||
isVideoCall: true,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Ringing (group call)', () => (
|
||||
<CallManager
|
||||
{...createProps({
|
||||
incomingCall: {
|
||||
callMode: CallMode.Group as const,
|
||||
conversation: {
|
||||
...getConversation(),
|
||||
type: 'group',
|
||||
title: 'Tahoe Trip',
|
||||
},
|
||||
otherMembersRung: [
|
||||
{ firstName: 'Morty', title: 'Morty Smith' },
|
||||
{ firstName: 'Summer', title: 'Summer Smith' },
|
||||
],
|
||||
ringer: { firstName: 'Rick', title: 'Rick Sanchez' },
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { noop } from 'lodash';
|
||||
import { CallNeedPermissionScreen } from './CallNeedPermissionScreen';
|
||||
import { CallScreen } from './CallScreen';
|
||||
import { CallingLobby } from './CallingLobby';
|
||||
|
@ -28,7 +29,6 @@ import {
|
|||
AcceptCallType,
|
||||
CancelCallType,
|
||||
DeclineCallType,
|
||||
DirectCallStateType,
|
||||
HangUpType,
|
||||
KeyChangeOkType,
|
||||
SetGroupCallVideoRequestType,
|
||||
|
@ -55,26 +55,39 @@ export type PropsType = {
|
|||
demuxId: number
|
||||
) => VideoFrameSource;
|
||||
getPresentingSources: () => void;
|
||||
incomingCall?: {
|
||||
call: DirectCallStateType;
|
||||
conversation: ConversationType;
|
||||
};
|
||||
incomingCall?:
|
||||
| {
|
||||
callMode: CallMode.Direct;
|
||||
conversation: ConversationType;
|
||||
isVideoCall: boolean;
|
||||
}
|
||||
| {
|
||||
callMode: CallMode.Group;
|
||||
conversation: ConversationType;
|
||||
otherMembersRung: Array<Pick<ConversationType, 'firstName' | 'title'>>;
|
||||
ringer: Pick<ConversationType, 'firstName' | 'title'>;
|
||||
};
|
||||
keyChangeOk: (_: KeyChangeOkType) => void;
|
||||
renderDeviceSelection: () => JSX.Element;
|
||||
renderSafetyNumberViewer: (props: SafetyNumberProps) => JSX.Element;
|
||||
startCall: (payload: StartCallType) => void;
|
||||
toggleParticipants: () => void;
|
||||
acceptCall: (_: AcceptCallType) => void;
|
||||
bounceAppIconStart: () => unknown;
|
||||
bounceAppIconStop: () => unknown;
|
||||
declineCall: (_: DeclineCallType) => void;
|
||||
i18n: LocalizerType;
|
||||
me: MeType;
|
||||
notifyForCall: (title: string, isVideoCall: boolean) => unknown;
|
||||
openSystemPreferencesAction: () => unknown;
|
||||
playRingtone: () => unknown;
|
||||
setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void;
|
||||
setLocalAudio: (_: SetLocalAudioType) => void;
|
||||
setLocalVideo: (_: SetLocalVideoType) => void;
|
||||
setLocalPreview: (_: SetLocalPreviewType) => void;
|
||||
setPresenting: (_?: PresentedSource) => void;
|
||||
setRendererCanvas: (_: SetRendererCanvasType) => void;
|
||||
stopRingtone: () => unknown;
|
||||
hangUp: (_: HangUpType) => void;
|
||||
togglePip: () => void;
|
||||
toggleScreenRecordingPermissionsDialog: () => unknown;
|
||||
|
@ -330,7 +343,31 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
|
|||
};
|
||||
|
||||
export const CallManager: React.FC<PropsType> = props => {
|
||||
const { activeCall, incomingCall, acceptCall, declineCall, i18n } = props;
|
||||
const {
|
||||
acceptCall,
|
||||
activeCall,
|
||||
bounceAppIconStart,
|
||||
bounceAppIconStop,
|
||||
declineCall,
|
||||
i18n,
|
||||
incomingCall,
|
||||
notifyForCall,
|
||||
playRingtone,
|
||||
stopRingtone,
|
||||
} = props;
|
||||
|
||||
const shouldRing = getShouldRing(props);
|
||||
useEffect(() => {
|
||||
if (shouldRing) {
|
||||
playRingtone();
|
||||
return () => {
|
||||
stopRingtone();
|
||||
};
|
||||
}
|
||||
|
||||
stopRingtone();
|
||||
return noop;
|
||||
}, [shouldRing, playRingtone, stopRingtone]);
|
||||
|
||||
if (activeCall) {
|
||||
// `props` should logically have an `activeCall` at this point, but TypeScript can't
|
||||
|
@ -343,13 +380,40 @@ export const CallManager: React.FC<PropsType> = props => {
|
|||
return (
|
||||
<IncomingCallBar
|
||||
acceptCall={acceptCall}
|
||||
bounceAppIconStart={bounceAppIconStart}
|
||||
bounceAppIconStop={bounceAppIconStop}
|
||||
declineCall={declineCall}
|
||||
i18n={i18n}
|
||||
call={incomingCall.call}
|
||||
conversation={incomingCall.conversation}
|
||||
notifyForCall={notifyForCall}
|
||||
{...incomingCall}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
function getShouldRing({
|
||||
activeCall,
|
||||
incomingCall,
|
||||
}: Readonly<Pick<PropsType, 'activeCall' | 'incomingCall'>>): boolean {
|
||||
if (incomingCall) {
|
||||
return !activeCall;
|
||||
}
|
||||
|
||||
if (!activeCall) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (activeCall.callMode) {
|
||||
case CallMode.Direct:
|
||||
return (
|
||||
activeCall.callState === CallState.Prering ||
|
||||
activeCall.callState === CallState.Ringing
|
||||
);
|
||||
case CallMode.Group:
|
||||
return false;
|
||||
default:
|
||||
throw missingCaseError(activeCall);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import type { ConversationType } from '../state/ducks/conversations';
|
|||
import type { LocalizerType } from '../types/Util';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { Emojify } from './conversation/Emojify';
|
||||
import { getParticipantName } from '../util/callingGetParticipantName';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
|
||||
type PropsType = {
|
||||
|
@ -151,9 +152,3 @@ export const CallingPreCallInfo: FunctionComponent<PropsType> = ({
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getParticipantName(
|
||||
participant: Readonly<Pick<ConversationType, 'firstName' | 'title'>>
|
||||
): string {
|
||||
return participant.firstName || participant.title;
|
||||
}
|
||||
|
|
|
@ -3,20 +3,20 @@
|
|||
|
||||
import * as React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { boolean, select, text } from '@storybook/addon-knobs';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { IncomingCallBar } from './IncomingCallBar';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import { CallMode } from '../types/Calling';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
import { getRandomColor } from '../test-both/helpers/getRandomColor';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const defaultProps = {
|
||||
const commonProps = {
|
||||
acceptCall: action('accept-call'),
|
||||
bounceAppIconStart: action('bounceAppIconStart'),
|
||||
bounceAppIconStop: action('bounceAppIconStop'),
|
||||
call: {
|
||||
conversationId: 'fake-conversation-id',
|
||||
callId: 0,
|
||||
|
@ -33,36 +33,96 @@ const defaultProps = {
|
|||
}),
|
||||
declineCall: action('decline-call'),
|
||||
i18n,
|
||||
notifyForCall: action('notify-for-call'),
|
||||
};
|
||||
|
||||
storiesOf('Components/IncomingCallBar', module)
|
||||
.add('Knobs Playground', () => {
|
||||
const color = select('color', AvatarColors, getRandomColor());
|
||||
const isVideoCall = boolean('isVideoCall', false);
|
||||
const name = text(
|
||||
'name',
|
||||
'Rick Sanchez Foo Bar Baz Spool Cool Mango Fango Wand Mars Venus Jupiter Spark Mirage Water Loop Branch Zeus Element Sail Bananas Cars Horticulture Turtle Lion Zebra Micro Music Garage Iguana Ohio Retro Joy Entertainment Logo Understanding Diary'
|
||||
);
|
||||
const directConversation = getDefaultConversation({
|
||||
id: '3051234567',
|
||||
avatarPath: undefined,
|
||||
name: 'Rick Sanchez',
|
||||
phoneNumber: '3051234567',
|
||||
profileName: 'Rick Sanchez',
|
||||
title: 'Rick Sanchez',
|
||||
});
|
||||
|
||||
return (
|
||||
<IncomingCallBar
|
||||
{...defaultProps}
|
||||
call={{
|
||||
...defaultProps.call,
|
||||
isVideoCall,
|
||||
}}
|
||||
conversation={{
|
||||
...defaultProps.conversation,
|
||||
color,
|
||||
name,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.add('Incoming Call Bar (video)', () => <IncomingCallBar {...defaultProps} />)
|
||||
.add('Incoming Call Bar (audio)', () => (
|
||||
const groupConversation = getDefaultConversation({
|
||||
avatarPath: undefined,
|
||||
name: 'Tahoe Trip',
|
||||
title: 'Tahoe Trip',
|
||||
type: 'group',
|
||||
});
|
||||
|
||||
storiesOf('Components/IncomingCallBar', module)
|
||||
.add('Incoming direct call (video)', () => (
|
||||
<IncomingCallBar
|
||||
{...defaultProps}
|
||||
call={{ ...defaultProps.call, isVideoCall: false }}
|
||||
{...commonProps}
|
||||
conversation={directConversation}
|
||||
callMode={CallMode.Direct}
|
||||
isVideoCall
|
||||
/>
|
||||
))
|
||||
.add('Incoming direct call (audio)', () => (
|
||||
<IncomingCallBar
|
||||
{...commonProps}
|
||||
conversation={directConversation}
|
||||
callMode={CallMode.Direct}
|
||||
isVideoCall={false}
|
||||
/>
|
||||
))
|
||||
.add('Incoming group call (only calling you)', () => (
|
||||
<IncomingCallBar
|
||||
{...commonProps}
|
||||
conversation={groupConversation}
|
||||
callMode={CallMode.Group}
|
||||
otherMembersRung={[]}
|
||||
ringer={{ firstName: 'Rick', title: 'Rick Sanchez' }}
|
||||
/>
|
||||
))
|
||||
.add('Incoming group call (calling you and 1 other)', () => (
|
||||
<IncomingCallBar
|
||||
{...commonProps}
|
||||
conversation={groupConversation}
|
||||
callMode={CallMode.Group}
|
||||
otherMembersRung={[{ firstName: 'Morty', title: 'Morty Smith' }]}
|
||||
ringer={{ firstName: 'Rick', title: 'Rick Sanchez' }}
|
||||
/>
|
||||
))
|
||||
.add('Incoming group call (calling you and 2 others)', () => (
|
||||
<IncomingCallBar
|
||||
{...commonProps}
|
||||
conversation={groupConversation}
|
||||
callMode={CallMode.Group}
|
||||
otherMembersRung={[
|
||||
{ firstName: 'Morty', title: 'Morty Smith' },
|
||||
{ firstName: 'Summer', title: 'Summer Smith' },
|
||||
]}
|
||||
ringer={{ firstName: 'Rick', title: 'Rick Sanchez' }}
|
||||
/>
|
||||
))
|
||||
.add('Incoming group call (calling you and 3 others)', () => (
|
||||
<IncomingCallBar
|
||||
{...commonProps}
|
||||
conversation={groupConversation}
|
||||
callMode={CallMode.Group}
|
||||
otherMembersRung={[
|
||||
{ firstName: 'Morty', title: 'Morty Smith' },
|
||||
{ firstName: 'Summer', title: 'Summer Smith' },
|
||||
{ firstName: 'Beth', title: 'Beth Smith' },
|
||||
]}
|
||||
ringer={{ firstName: 'Rick', title: 'Rick Sanchez' }}
|
||||
/>
|
||||
))
|
||||
.add('Incoming group call (calling you and 4 others)', () => (
|
||||
<IncomingCallBar
|
||||
{...commonProps}
|
||||
conversation={groupConversation}
|
||||
callMode={CallMode.Group}
|
||||
otherMembersRung={[
|
||||
{ firstName: 'Morty', title: 'Morty Smith' },
|
||||
{ firstName: 'Summer', title: 'Summer Smith' },
|
||||
{ firstName: 'Beth', title: 'Beth Sanchez' },
|
||||
{ firstName: 'Jerry', title: 'Beth Smith' },
|
||||
]}
|
||||
ringer={{ firstName: 'Rick', title: 'Rick Sanchez' }}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -1,23 +1,25 @@
|
|||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef, ReactChild } from 'react';
|
||||
import { Avatar } from './Avatar';
|
||||
import { Tooltip } from './Tooltip';
|
||||
import { Intl } from './Intl';
|
||||
import { Theme } from '../util/theme';
|
||||
import { getParticipantName } from '../util/callingGetParticipantName';
|
||||
import { ContactName } from './conversation/ContactName';
|
||||
import { Emojify } from './conversation/Emojify';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import { CallMode } from '../types/Calling';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
import { AcceptCallType, DeclineCallType } from '../state/ducks/calling';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
|
||||
export type PropsType = {
|
||||
acceptCall: (_: AcceptCallType) => void;
|
||||
declineCall: (_: DeclineCallType) => void;
|
||||
i18n: LocalizerType;
|
||||
call: {
|
||||
isVideoCall: boolean;
|
||||
};
|
||||
conversation: Pick<
|
||||
ConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
|
@ -30,8 +32,22 @@ export type PropsType = {
|
|||
| 'profileName'
|
||||
| 'sharedGroupNames'
|
||||
| 'title'
|
||||
| 'type'
|
||||
>;
|
||||
};
|
||||
bounceAppIconStart(): unknown;
|
||||
bounceAppIconStop(): unknown;
|
||||
notifyForCall(conversationTitle: string, isVideoCall: boolean): unknown;
|
||||
} & (
|
||||
| {
|
||||
callMode: CallMode.Direct;
|
||||
isVideoCall: boolean;
|
||||
}
|
||||
| {
|
||||
callMode: CallMode.Group;
|
||||
otherMembersRung: Array<Pick<ConversationType, 'firstName' | 'title'>>;
|
||||
ringer: Pick<ConversationType, 'firstName' | 'title'>;
|
||||
}
|
||||
);
|
||||
|
||||
type CallButtonProps = {
|
||||
classSuffix: string;
|
||||
|
@ -61,14 +77,93 @@ const CallButton = ({
|
|||
);
|
||||
};
|
||||
|
||||
export const IncomingCallBar = ({
|
||||
acceptCall,
|
||||
declineCall,
|
||||
const GroupCallMessage = ({
|
||||
i18n,
|
||||
call,
|
||||
conversation,
|
||||
}: PropsType): JSX.Element | null => {
|
||||
const { isVideoCall } = call;
|
||||
otherMembersRung,
|
||||
ringer,
|
||||
}: Readonly<{
|
||||
i18n: LocalizerType;
|
||||
otherMembersRung: Array<Pick<ConversationType, 'firstName' | 'title'>>;
|
||||
ringer: Pick<ConversationType, 'firstName' | 'title'>;
|
||||
}>): JSX.Element => {
|
||||
// As an optimization, we only process the first two names.
|
||||
const [first, second] = otherMembersRung
|
||||
.slice(0, 2)
|
||||
.map(member => <Emojify text={getParticipantName(member)} />);
|
||||
const ringerNode = <Emojify text={getParticipantName(ringer)} />;
|
||||
|
||||
switch (otherMembersRung.length) {
|
||||
case 0:
|
||||
return (
|
||||
<Intl
|
||||
id="incomingGroupCall__ringing-you"
|
||||
i18n={i18n}
|
||||
components={{ ringer: ringerNode }}
|
||||
/>
|
||||
);
|
||||
case 1:
|
||||
return (
|
||||
<Intl
|
||||
id="incomingGroupCall__ringing-1-other"
|
||||
i18n={i18n}
|
||||
components={{
|
||||
ringer: ringerNode,
|
||||
otherMember: first,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Intl
|
||||
id="incomingGroupCall__ringing-2-others"
|
||||
i18n={i18n}
|
||||
components={{
|
||||
ringer: ringerNode,
|
||||
first,
|
||||
second,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 3:
|
||||
return (
|
||||
<Intl
|
||||
id="incomingGroupCall__ringing-3-others"
|
||||
i18n={i18n}
|
||||
components={{
|
||||
ringer: ringerNode,
|
||||
first,
|
||||
second,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
return (
|
||||
<Intl
|
||||
id="incomingGroupCall__ringing-many"
|
||||
i18n={i18n}
|
||||
components={{
|
||||
ringer: ringerNode,
|
||||
first,
|
||||
second,
|
||||
remaining: String(otherMembersRung.length - 2),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const IncomingCallBar = (props: PropsType): JSX.Element | null => {
|
||||
const {
|
||||
acceptCall,
|
||||
bounceAppIconStart,
|
||||
bounceAppIconStop,
|
||||
conversation,
|
||||
declineCall,
|
||||
i18n,
|
||||
notifyForCall,
|
||||
} = props;
|
||||
const {
|
||||
id: conversationId,
|
||||
acceptedMessageRequest,
|
||||
|
@ -80,19 +175,71 @@ export const IncomingCallBar = ({
|
|||
profileName,
|
||||
sharedGroupNames,
|
||||
title,
|
||||
type: conversationType,
|
||||
} = conversation;
|
||||
|
||||
let isVideoCall: boolean;
|
||||
let headerNode: ReactChild;
|
||||
let messageNode: ReactChild;
|
||||
|
||||
switch (props.callMode) {
|
||||
case CallMode.Direct:
|
||||
({ isVideoCall } = props);
|
||||
headerNode = (
|
||||
<ContactName
|
||||
name={name}
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
title={title}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
messageNode = i18n(
|
||||
isVideoCall ? 'incomingVideoCall' : 'incomingAudioCall'
|
||||
);
|
||||
break;
|
||||
case CallMode.Group: {
|
||||
const { otherMembersRung, ringer } = props;
|
||||
isVideoCall = true;
|
||||
headerNode = <Emojify text={title} />;
|
||||
messageNode = (
|
||||
<GroupCallMessage
|
||||
i18n={i18n}
|
||||
otherMembersRung={otherMembersRung}
|
||||
ringer={ringer}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw missingCaseError(props);
|
||||
}
|
||||
|
||||
// We don't want to re-notify if the title changes.
|
||||
const initialTitleRef = useRef<string>(title);
|
||||
useEffect(() => {
|
||||
const initialTitle = initialTitleRef.current;
|
||||
notifyForCall(initialTitle, isVideoCall);
|
||||
}, [isVideoCall, notifyForCall]);
|
||||
|
||||
useEffect(() => {
|
||||
bounceAppIconStart();
|
||||
return () => {
|
||||
bounceAppIconStop();
|
||||
};
|
||||
}, [bounceAppIconStart, bounceAppIconStop]);
|
||||
|
||||
return (
|
||||
<div className="IncomingCallBar__container">
|
||||
<div className="IncomingCallBar__bar">
|
||||
<div className="IncomingCallBar__contact">
|
||||
<div className="IncomingCallBar__contact--avatar">
|
||||
<div className="IncomingCallBar__conversation">
|
||||
<div className="IncomingCallBar__conversation--avatar">
|
||||
<Avatar
|
||||
acceptedMessageRequest={acceptedMessageRequest}
|
||||
avatarPath={avatarPath}
|
||||
color={color || AvatarColors[0]}
|
||||
noteToSelf={false}
|
||||
conversationType="direct"
|
||||
conversationType={conversationType}
|
||||
i18n={i18n}
|
||||
isMe={isMe}
|
||||
name={name}
|
||||
|
@ -103,18 +250,15 @@ export const IncomingCallBar = ({
|
|||
size={52}
|
||||
/>
|
||||
</div>
|
||||
<div className="IncomingCallBar__contact--name">
|
||||
<div className="IncomingCallBar__contact--name-header">
|
||||
<ContactName
|
||||
name={name}
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
title={title}
|
||||
i18n={i18n}
|
||||
/>
|
||||
<div className="IncomingCallBar__conversation--name">
|
||||
<div className="IncomingCallBar__conversation--name-header">
|
||||
{headerNode}
|
||||
</div>
|
||||
<div dir="auto" className="IncomingCallBar__contact--message-text">
|
||||
{i18n(isVideoCall ? 'incomingVideoCall' : 'incomingAudioCall')}
|
||||
<div
|
||||
dir="auto"
|
||||
className="IncomingCallBar__conversation--message-text"
|
||||
>
|
||||
{messageNode}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
CallId,
|
||||
CallingMessage,
|
||||
CallLogLevel,
|
||||
CallMessageUrgency,
|
||||
CallSettings,
|
||||
CallState,
|
||||
CanvasVideoRenderer,
|
||||
|
@ -24,13 +25,16 @@ import {
|
|||
HangupType,
|
||||
OpaqueMessage,
|
||||
PeekInfo,
|
||||
RingCancelReason,
|
||||
RingRTC,
|
||||
RingUpdate,
|
||||
UserId,
|
||||
VideoFrameSource,
|
||||
VideoRequest,
|
||||
BandwidthMode,
|
||||
} from 'ringrtc';
|
||||
import { uniqBy, noop } from 'lodash';
|
||||
import * as moment from 'moment';
|
||||
|
||||
import {
|
||||
ActionsType as UxActionsType,
|
||||
|
@ -46,6 +50,7 @@ import {
|
|||
MediaDeviceSettings,
|
||||
PresentableSource,
|
||||
PresentedSource,
|
||||
ProcessGroupCallRingRequestResult,
|
||||
} from '../types/Calling';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { ConversationModel } from '../models/conversations';
|
||||
|
@ -72,12 +77,17 @@ import {
|
|||
REQUESTED_VIDEO_HEIGHT,
|
||||
REQUESTED_VIDEO_FRAMERATE,
|
||||
} from '../calling/constants';
|
||||
import { callingMessageToProto } from '../util/callingMessageToProto';
|
||||
import { notify } from './notify';
|
||||
import { getSendOptions } from '../util/getSendOptions';
|
||||
import { SignalService as Proto } from '../protobuf';
|
||||
import dataInterface from '../sql/Client';
|
||||
|
||||
// TODO: remove once we move away from ArrayBuffers
|
||||
const FIXMEU8 = Uint8Array;
|
||||
const {
|
||||
processGroupCallRingRequest,
|
||||
processGroupCallRingCancelation,
|
||||
cleanExpiredGroupCallRings,
|
||||
} = dataInterface;
|
||||
|
||||
const RINGRTC_HTTP_METHOD_TO_OUR_HTTP_METHOD: Map<
|
||||
HttpMethod,
|
||||
|
@ -89,6 +99,10 @@ const RINGRTC_HTTP_METHOD_TO_OUR_HTTP_METHOD: Map<
|
|||
[HttpMethod.Delete, 'DELETE'],
|
||||
]);
|
||||
|
||||
const CLEAN_EXPIRED_GROUP_CALL_RINGS_INTERVAL = moment
|
||||
.duration(10, 'minutes')
|
||||
.asMilliseconds();
|
||||
|
||||
// We send group call update messages to tell other clients to peek, which triggers
|
||||
// notifications, timeline messages, big green "Join" buttons, and so on. This enum
|
||||
// represents the three possible states we can be in. This helps ensure that we don't
|
||||
|
@ -185,76 +199,6 @@ function protoToCallingMessage({
|
|||
};
|
||||
}
|
||||
|
||||
function bufferToProto(
|
||||
value: Buffer | { toArrayBuffer(): ArrayBuffer } | undefined
|
||||
): Uint8Array | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (value instanceof Uint8Array) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new FIXMEU8(value.toArrayBuffer());
|
||||
}
|
||||
|
||||
function callingMessageToProto({
|
||||
offer,
|
||||
answer,
|
||||
iceCandidates,
|
||||
legacyHangup,
|
||||
busy,
|
||||
hangup,
|
||||
supportsMultiRing,
|
||||
destinationDeviceId,
|
||||
opaque,
|
||||
}: CallingMessage): Proto.ICallingMessage {
|
||||
return {
|
||||
offer: offer
|
||||
? {
|
||||
...offer,
|
||||
type: offer.type as number,
|
||||
opaque: bufferToProto(offer.opaque),
|
||||
}
|
||||
: undefined,
|
||||
answer: answer
|
||||
? {
|
||||
...answer,
|
||||
opaque: bufferToProto(answer.opaque),
|
||||
}
|
||||
: undefined,
|
||||
iceCandidates: iceCandidates
|
||||
? iceCandidates.map(candidate => {
|
||||
return {
|
||||
...candidate,
|
||||
opaque: bufferToProto(candidate.opaque),
|
||||
};
|
||||
})
|
||||
: undefined,
|
||||
legacyHangup: legacyHangup
|
||||
? {
|
||||
...legacyHangup,
|
||||
type: legacyHangup.type as number,
|
||||
}
|
||||
: undefined,
|
||||
busy,
|
||||
hangup: hangup
|
||||
? {
|
||||
...hangup,
|
||||
type: hangup.type as number,
|
||||
}
|
||||
: undefined,
|
||||
supportsMultiRing,
|
||||
destinationDeviceId,
|
||||
opaque: opaque
|
||||
? {
|
||||
...opaque,
|
||||
data: bufferToProto(opaque.data),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export class CallingClass {
|
||||
readonly videoCapturer: GumVideoCapturer;
|
||||
|
||||
|
@ -272,6 +216,8 @@ export class CallingClass {
|
|||
|
||||
private hadLocalVideoBeforePresenting?: boolean;
|
||||
|
||||
private hasGivenOurUuidToRingRtc = false;
|
||||
|
||||
constructor() {
|
||||
this.videoCapturer = new GumVideoCapturer({
|
||||
maxWidth: REQUESTED_VIDEO_WIDTH,
|
||||
|
@ -299,10 +245,35 @@ export class CallingClass {
|
|||
RingRTC.handleLogMessage = this.handleLogMessage.bind(this);
|
||||
RingRTC.handleSendHttpRequest = this.handleSendHttpRequest.bind(this);
|
||||
RingRTC.handleSendCallMessage = this.handleSendCallMessage.bind(this);
|
||||
RingRTC.handleSendCallMessageToGroup = this.handleSendCallMessageToGroup.bind(
|
||||
this
|
||||
);
|
||||
RingRTC.handleGroupCallRingUpdate = this.handleGroupCallRingUpdate.bind(
|
||||
this
|
||||
);
|
||||
|
||||
this.attemptToGiveOurUuidToRingRtc();
|
||||
|
||||
ipcRenderer.on('stop-screen-share', () => {
|
||||
uxActions.setPresenting();
|
||||
});
|
||||
|
||||
this.cleanExpiredGroupCallRingsAndLoop();
|
||||
}
|
||||
|
||||
private attemptToGiveOurUuidToRingRtc(): void {
|
||||
if (this.hasGivenOurUuidToRingRtc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ourUuid = window.textsecure.storage.user.getUuid();
|
||||
if (!ourUuid) {
|
||||
// This can happen if we're not linked. It's okay if we hit this case.
|
||||
return;
|
||||
}
|
||||
|
||||
RingRTC.setSelfUuid(Buffer.from(uuidToArrayBuffer(ourUuid)));
|
||||
this.hasGivenOurUuidToRingRtc = true;
|
||||
}
|
||||
|
||||
async startCallingLobby(
|
||||
|
@ -715,6 +686,8 @@ export class CallingClass {
|
|||
hasLocalAudio: boolean,
|
||||
hasLocalVideo: boolean
|
||||
): void {
|
||||
this.attemptToGiveOurUuidToRingRtc();
|
||||
|
||||
const conversation = window.ConversationController.get(
|
||||
conversationId
|
||||
)?.format();
|
||||
|
@ -744,6 +717,13 @@ export class CallingClass {
|
|||
groupCall.setOutgoingVideoMuted(!hasLocalVideo);
|
||||
this.videoCapturer.enableCaptureAndSend(groupCall);
|
||||
|
||||
// This is a temporary flag to help all client teams (Desktop, iOS, and Android)
|
||||
// debug. Soon, this will be exposed in the UI (see DESKTOP-2113).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (window.RING_WHEN_JOINING_GROUP_CALLS) {
|
||||
groupCall.ringAll();
|
||||
}
|
||||
|
||||
groupCall.join();
|
||||
}
|
||||
|
||||
|
@ -959,8 +939,11 @@ export class CallingClass {
|
|||
});
|
||||
}
|
||||
|
||||
async accept(conversationId: string, asVideoCall: boolean): Promise<void> {
|
||||
window.log.info('CallingClass.accept()');
|
||||
async acceptDirectCall(
|
||||
conversationId: string,
|
||||
asVideoCall: boolean
|
||||
): Promise<void> {
|
||||
window.log.info('CallingClass.acceptDirectCall()');
|
||||
|
||||
const callId = this.getCallIdForConversation(conversationId);
|
||||
if (!callId) {
|
||||
|
@ -980,18 +963,43 @@ export class CallingClass {
|
|||
}
|
||||
}
|
||||
|
||||
decline(conversationId: string): void {
|
||||
window.log.info('CallingClass.decline()');
|
||||
declineDirectCall(conversationId: string): void {
|
||||
window.log.info('CallingClass.declineDirectCall()');
|
||||
|
||||
const callId = this.getCallIdForConversation(conversationId);
|
||||
if (!callId) {
|
||||
window.log.warn('Trying to decline a non-existent call');
|
||||
window.log.warn(
|
||||
'declineDirectCall: Trying to decline a non-existent call'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
RingRTC.decline(callId);
|
||||
}
|
||||
|
||||
declineGroupCall(conversationId: string, ringId: bigint): void {
|
||||
window.log.info('CallingClass.declineGroupCall()');
|
||||
|
||||
this.attemptToGiveOurUuidToRingRtc();
|
||||
|
||||
const groupId = window.ConversationController.get(conversationId)?.get(
|
||||
'groupId'
|
||||
);
|
||||
if (!groupId) {
|
||||
window.log.error(
|
||||
'declineGroupCall: could not find the group ID for that conversation'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const groupIdBuffer = Buffer.from(Bytes.fromBase64(groupId));
|
||||
|
||||
RingRTC.cancelGroupRing(
|
||||
groupIdBuffer,
|
||||
ringId,
|
||||
RingCancelReason.DeclinedByUser
|
||||
);
|
||||
}
|
||||
|
||||
hangup(conversationId: string): void {
|
||||
window.log.info('CallingClass.hangup()');
|
||||
|
||||
|
@ -1548,7 +1556,8 @@ export class CallingClass {
|
|||
|
||||
private async handleSendCallMessage(
|
||||
recipient: Uint8Array,
|
||||
data: Uint8Array
|
||||
data: Uint8Array,
|
||||
urgency: CallMessageUrgency
|
||||
): Promise<boolean> {
|
||||
const userId = arrayBufferToUuid(typedArrayToArrayBuffer(recipient));
|
||||
if (!userId) {
|
||||
|
@ -1558,12 +1567,123 @@ export class CallingClass {
|
|||
const message = new CallingMessage();
|
||||
message.opaque = new OpaqueMessage();
|
||||
message.opaque.data = Buffer.from(data);
|
||||
return this.handleOutgoingSignaling(userId, message);
|
||||
return this.handleOutgoingSignaling(userId, message, urgency);
|
||||
}
|
||||
|
||||
private async handleSendCallMessageToGroup(
|
||||
groupIdBytes: Buffer,
|
||||
data: Buffer,
|
||||
urgency: CallMessageUrgency
|
||||
): Promise<void> {
|
||||
this.attemptToGiveOurUuidToRingRtc();
|
||||
|
||||
const groupId = groupIdBytes.toString('base64');
|
||||
const conversation = window.ConversationController.get(groupId);
|
||||
if (!conversation) {
|
||||
window.log.error(
|
||||
'handleSendCallMessageToGroup(): could not find conversation'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
|
||||
const callingMessage = new CallingMessage();
|
||||
callingMessage.opaque = new OpaqueMessage();
|
||||
callingMessage.opaque.data = data;
|
||||
const contentMessage = new Proto.Content();
|
||||
contentMessage.callingMessage = callingMessageToProto(
|
||||
callingMessage,
|
||||
urgency
|
||||
);
|
||||
|
||||
// We "fire and forget" because sending this message is non-essential.
|
||||
// We also don't sync this message.
|
||||
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
||||
await handleMessageSend(
|
||||
window.Signal.Util.sendContentMessageToGroup({
|
||||
contentHint: ContentHint.DEFAULT,
|
||||
contentMessage,
|
||||
conversation,
|
||||
isPartialSend: false,
|
||||
messageId: undefined,
|
||||
recipients: conversation.getRecipients(),
|
||||
sendOptions: await getSendOptions(conversation.attributes),
|
||||
sendType: 'callingMessage',
|
||||
timestamp,
|
||||
}),
|
||||
{ messageIds: [], sendType: 'callingMessage' }
|
||||
);
|
||||
}
|
||||
|
||||
private async handleGroupCallRingUpdate(
|
||||
groupIdBytes: Buffer,
|
||||
ringId: bigint,
|
||||
ringerBytes: Buffer,
|
||||
update: RingUpdate
|
||||
): Promise<void> {
|
||||
window.log.info(`handleGroupCallRingUpdate(): got ring update ${update}`);
|
||||
|
||||
this.attemptToGiveOurUuidToRingRtc();
|
||||
|
||||
const groupId = groupIdBytes.toString('base64');
|
||||
|
||||
const ringerUuid = arrayBufferToUuid(typedArrayToArrayBuffer(ringerBytes));
|
||||
if (!ringerUuid) {
|
||||
window.log.error('handleGroupCallRingUpdate(): ringerUuid was invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = window.ConversationController.get(groupId);
|
||||
if (!conversation) {
|
||||
window.log.error(
|
||||
'handleGroupCallRingUpdate(): could not find conversation'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const conversationId = conversation.id;
|
||||
|
||||
let shouldRing = false;
|
||||
|
||||
if (update === RingUpdate.Requested) {
|
||||
const processResult = await processGroupCallRingRequest(ringId);
|
||||
switch (processResult) {
|
||||
case ProcessGroupCallRingRequestResult.ShouldRing:
|
||||
shouldRing = true;
|
||||
break;
|
||||
case ProcessGroupCallRingRequestResult.RingWasPreviouslyCanceled:
|
||||
RingRTC.cancelGroupRing(groupIdBytes, ringId, null);
|
||||
break;
|
||||
case ProcessGroupCallRingRequestResult.ThereIsAnotherActiveRing:
|
||||
RingRTC.cancelGroupRing(groupIdBytes, ringId, RingCancelReason.Busy);
|
||||
break;
|
||||
default:
|
||||
throw missingCaseError(processResult);
|
||||
}
|
||||
} else {
|
||||
await processGroupCallRingCancelation(ringId);
|
||||
}
|
||||
|
||||
if (shouldRing) {
|
||||
window.log.info('handleGroupCallRingUpdate: ringing');
|
||||
this.uxActions?.receiveIncomingGroupCall({
|
||||
conversationId,
|
||||
ringId,
|
||||
ringerUuid,
|
||||
});
|
||||
} else {
|
||||
window.log.info('handleGroupCallRingUpdate: canceling any existing ring');
|
||||
this.uxActions?.cancelIncomingGroupCallRing({
|
||||
conversationId,
|
||||
ringId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleOutgoingSignaling(
|
||||
remoteUserId: UserId,
|
||||
message: CallingMessage
|
||||
message: CallingMessage,
|
||||
urgency?: CallMessageUrgency
|
||||
): Promise<boolean> {
|
||||
const conversation = window.ConversationController.get(remoteUserId);
|
||||
const sendOptions = conversation
|
||||
|
@ -1579,7 +1699,7 @@ export class CallingClass {
|
|||
const result = await handleMessageSend(
|
||||
window.textsecure.messaging.sendCallingMessage(
|
||||
remoteUserId,
|
||||
callingMessageToProto(message),
|
||||
callingMessageToProto(message, urgency),
|
||||
sendOptions
|
||||
),
|
||||
{ messageIds: [], sendType: 'callingMessage' }
|
||||
|
@ -1640,7 +1760,7 @@ export class CallingClass {
|
|||
|
||||
this.attachToCall(conversation, call);
|
||||
|
||||
this.uxActions.receiveIncomingCall({
|
||||
this.uxActions.receiveIncomingDirectCall({
|
||||
conversationId: conversation.id,
|
||||
isVideoCall: call.isVideoCall,
|
||||
});
|
||||
|
@ -1926,6 +2046,19 @@ export class CallingClass {
|
|||
|
||||
conversation.updateCallHistoryForGroupCall(peekInfo.eraId, creatorUuid);
|
||||
}
|
||||
|
||||
private async cleanExpiredGroupCallRingsAndLoop(): Promise<void> {
|
||||
try {
|
||||
await cleanExpiredGroupCallRings();
|
||||
} catch (err: unknown) {
|
||||
// These errors are ignored here. They should be logged elsewhere and it's okay if
|
||||
// we don't do a cleanup this time.
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.cleanExpiredGroupCallRingsAndLoop();
|
||||
}, CLEAN_EXPIRED_GROUP_CALL_RINGS_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
export const calling = new CallingClass();
|
||||
|
|
|
@ -32,6 +32,7 @@ import { assert } from '../util/assert';
|
|||
import { cleanDataForIpc } from './cleanDataForIpc';
|
||||
import { ReactionType } from '../types/Reactions';
|
||||
import { ConversationColorType, CustomColorType } from '../types/Colors';
|
||||
import type { ProcessGroupCallRingRequestResult } from '../types/Calling';
|
||||
|
||||
import {
|
||||
ConversationModelCollectionType,
|
||||
|
@ -262,6 +263,10 @@ const dataInterface: ClientInterface = {
|
|||
insertJob,
|
||||
deleteJob,
|
||||
|
||||
processGroupCallRingRequest,
|
||||
processGroupCallRingCancelation,
|
||||
cleanExpiredGroupCallRings,
|
||||
|
||||
getStatisticsForLogging,
|
||||
|
||||
// Test-only
|
||||
|
@ -1611,6 +1616,20 @@ function deleteJob(id: string): Promise<void> {
|
|||
return channels.deleteJob(id);
|
||||
}
|
||||
|
||||
function processGroupCallRingRequest(
|
||||
ringId: bigint
|
||||
): Promise<ProcessGroupCallRingRequestResult> {
|
||||
return channels.processGroupCallRingRequest(ringId);
|
||||
}
|
||||
|
||||
function processGroupCallRingCancelation(ringId: bigint): Promise<void> {
|
||||
return channels.processGroupCallRingCancelation(ringId);
|
||||
}
|
||||
|
||||
async function cleanExpiredGroupCallRings(): Promise<void> {
|
||||
await channels.cleanExpiredGroupCallRings();
|
||||
}
|
||||
|
||||
async function updateAllConversationColors(
|
||||
conversationColor?: ConversationColorType,
|
||||
customColorData?: {
|
||||
|
|
|
@ -15,6 +15,7 @@ import type { ConversationModel } from '../models/conversations';
|
|||
import type { StoredJob } from '../jobs/types';
|
||||
import type { ReactionType } from '../types/Reactions';
|
||||
import type { ConversationColorType, CustomColorType } from '../types/Colors';
|
||||
import type { ProcessGroupCallRingRequestResult } from '../types/Calling';
|
||||
import { StorageAccessType } from '../types/Storage.d';
|
||||
import type { AttachmentType } from '../types/Attachment';
|
||||
import { BodyRangesType } from '../types/Util';
|
||||
|
@ -441,6 +442,12 @@ export type DataInterface = {
|
|||
insertJob(job: Readonly<StoredJob>): Promise<void>;
|
||||
deleteJob(id: string): Promise<void>;
|
||||
|
||||
processGroupCallRingRequest(
|
||||
ringId: bigint
|
||||
): Promise<ProcessGroupCallRingRequestResult>;
|
||||
processGroupCallRingCancelation(ringId: bigint): Promise<void>;
|
||||
cleanExpiredGroupCallRings(): Promise<void>;
|
||||
|
||||
updateAllConversationColors: (
|
||||
conversationColor?: ConversationColorType,
|
||||
customColorData?: {
|
||||
|
|
116
ts/sql/Server.ts
116
ts/sql/Server.ts
|
@ -13,6 +13,7 @@ import mkdirp from 'mkdirp';
|
|||
import rimraf from 'rimraf';
|
||||
import SQL, { Database, Statement } from 'better-sqlite3';
|
||||
import pProps from 'p-props';
|
||||
import * as moment from 'moment';
|
||||
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
import {
|
||||
|
@ -42,6 +43,7 @@ import { isNotNil } from '../util/isNotNil';
|
|||
import { parseIntOrThrow } from '../util/parseIntOrThrow';
|
||||
import { formatCountForLogging } from '../logging/formatCountForLogging';
|
||||
import { ConversationColorType, CustomColorType } from '../types/Colors';
|
||||
import { ProcessGroupCallRingRequestResult } from '../types/Calling';
|
||||
|
||||
import {
|
||||
AllItemsType,
|
||||
|
@ -93,8 +95,8 @@ type StickerRow = Readonly<{
|
|||
}>;
|
||||
|
||||
type EmptyQuery = [];
|
||||
type ArrayQuery = Array<Array<null | number | string>>;
|
||||
type Query = { [key: string]: null | number | string | Buffer };
|
||||
type ArrayQuery = Array<Array<null | number | bigint | string>>;
|
||||
type Query = { [key: string]: null | number | bigint | string | Buffer };
|
||||
|
||||
// This value needs to be below SQLITE_MAX_VARIABLE_NUMBER.
|
||||
const MAX_VARIABLE_COUNT = 100;
|
||||
|
@ -251,6 +253,10 @@ const dataInterface: ServerInterface = {
|
|||
insertJob,
|
||||
deleteJob,
|
||||
|
||||
processGroupCallRingRequest,
|
||||
processGroupCallRingCancelation,
|
||||
cleanExpiredGroupCallRings,
|
||||
|
||||
getStatisticsForLogging,
|
||||
|
||||
// Server-only
|
||||
|
@ -2089,6 +2095,27 @@ function updateToSchemaVersion39(currentVersion: number, db: Database) {
|
|||
console.log('updateToSchemaVersion39: success!');
|
||||
}
|
||||
|
||||
function updateToSchemaVersion40(currentVersion: number, db: Database) {
|
||||
if (currentVersion >= 40) {
|
||||
return;
|
||||
}
|
||||
|
||||
db.transaction(() => {
|
||||
db.exec(
|
||||
`
|
||||
CREATE TABLE groupCallRings(
|
||||
ringId INTEGER PRIMARY KEY,
|
||||
isActive INTEGER NOT NULL,
|
||||
createdAt INTEGER NOT NULL
|
||||
);
|
||||
`
|
||||
);
|
||||
|
||||
db.pragma('user_version = 40');
|
||||
})();
|
||||
console.log('updateToSchemaVersion40: success!');
|
||||
}
|
||||
|
||||
const SCHEMA_VERSIONS = [
|
||||
updateToSchemaVersion1,
|
||||
updateToSchemaVersion2,
|
||||
|
@ -2129,6 +2156,7 @@ const SCHEMA_VERSIONS = [
|
|||
updateToSchemaVersion37,
|
||||
updateToSchemaVersion38,
|
||||
updateToSchemaVersion39,
|
||||
updateToSchemaVersion40,
|
||||
];
|
||||
|
||||
function updateSchema(db: Database): void {
|
||||
|
@ -5868,6 +5896,90 @@ async function deleteJob(id: string): Promise<void> {
|
|||
db.prepare<Query>('DELETE FROM jobs WHERE id = $id').run({ id });
|
||||
}
|
||||
|
||||
async function processGroupCallRingRequest(
|
||||
ringId: bigint
|
||||
): Promise<ProcessGroupCallRingRequestResult> {
|
||||
const db = getInstance();
|
||||
|
||||
return db.transaction(() => {
|
||||
let result: ProcessGroupCallRingRequestResult;
|
||||
|
||||
const wasRingPreviouslyCanceled = Boolean(
|
||||
db
|
||||
.prepare<Query>(
|
||||
`
|
||||
SELECT 1 FROM groupCallRings
|
||||
WHERE ringId = $ringId AND isActive = 0
|
||||
LIMIT 1;
|
||||
`
|
||||
)
|
||||
.pluck(true)
|
||||
.get({ ringId })
|
||||
);
|
||||
|
||||
if (wasRingPreviouslyCanceled) {
|
||||
result = ProcessGroupCallRingRequestResult.RingWasPreviouslyCanceled;
|
||||
} else {
|
||||
const isThereAnotherActiveRing = Boolean(
|
||||
db
|
||||
.prepare<EmptyQuery>(
|
||||
`
|
||||
SELECT 1 FROM groupCallRings
|
||||
WHERE isActive = 1
|
||||
LIMIT 1;
|
||||
`
|
||||
)
|
||||
.pluck(true)
|
||||
.get()
|
||||
);
|
||||
if (isThereAnotherActiveRing) {
|
||||
result = ProcessGroupCallRingRequestResult.ThereIsAnotherActiveRing;
|
||||
} else {
|
||||
result = ProcessGroupCallRingRequestResult.ShouldRing;
|
||||
}
|
||||
|
||||
db.prepare<Query>(
|
||||
`
|
||||
INSERT OR IGNORE INTO groupCallRings (ringId, isActive, createdAt)
|
||||
VALUES ($ringId, 1, $createdAt);
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
|
||||
async function processGroupCallRingCancelation(ringId: bigint): Promise<void> {
|
||||
const db = getInstance();
|
||||
|
||||
db.prepare<Query>(
|
||||
`
|
||||
INSERT INTO groupCallRings (ringId, isActive, createdAt)
|
||||
VALUES ($ringId, 0, $createdAt)
|
||||
ON CONFLICT (ringId) DO
|
||||
UPDATE SET isActive = 0;
|
||||
`
|
||||
).run({ ringId, createdAt: Date.now() });
|
||||
}
|
||||
|
||||
// This age, in milliseconds, should be longer than any group call ring duration. Beyond
|
||||
// that, it doesn't really matter what the value is.
|
||||
const MAX_GROUP_CALL_RING_AGE = moment.duration(30, 'minutes').asMilliseconds();
|
||||
|
||||
async function cleanExpiredGroupCallRings(): Promise<void> {
|
||||
const db = getInstance();
|
||||
|
||||
db.prepare<Query>(
|
||||
`
|
||||
DELETE FROM groupCallRings
|
||||
WHERE createdAt < $expiredRingTime;
|
||||
`
|
||||
).run({
|
||||
expiredRingTime: Date.now() - MAX_GROUP_CALL_RING_AGE,
|
||||
});
|
||||
}
|
||||
|
||||
async function getStatisticsForLogging(): Promise<Record<string, string>> {
|
||||
const counts = await pProps({
|
||||
messageCount: getMessageCount(),
|
||||
|
|
|
@ -12,7 +12,6 @@ import { has, omit } from 'lodash';
|
|||
import { getOwn } from '../../util/getOwn';
|
||||
import { getPlatform } from '../selectors/user';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import { notify } from '../../services/notify';
|
||||
import { calling } from '../../services/calling';
|
||||
import { StateType as RootStateType } from '../reducer';
|
||||
import {
|
||||
|
@ -29,10 +28,6 @@ import {
|
|||
} from '../../types/Calling';
|
||||
import { callingTones } from '../../util/callingTones';
|
||||
import { requestCameraPermissions } from '../../util/callingPermissions';
|
||||
import {
|
||||
bounceAppIconStart,
|
||||
bounceAppIconStop,
|
||||
} from '../../shims/bounceAppIcon';
|
||||
import { sleep } from '../../util/sleep';
|
||||
import { LatestQueue } from '../../util/LatestQueue';
|
||||
|
||||
|
@ -68,6 +63,16 @@ export type DirectCallStateType = {
|
|||
hasRemoteVideo?: boolean;
|
||||
};
|
||||
|
||||
type GroupCallRingStateType =
|
||||
| {
|
||||
ringId?: undefined;
|
||||
ringerUuid?: undefined;
|
||||
}
|
||||
| {
|
||||
ringId: bigint;
|
||||
ringerUuid: string;
|
||||
};
|
||||
|
||||
export type GroupCallStateType = {
|
||||
callMode: CallMode.Group;
|
||||
conversationId: string;
|
||||
|
@ -75,7 +80,7 @@ export type GroupCallStateType = {
|
|||
joinState: GroupCallJoinState;
|
||||
peekInfo: GroupCallPeekInfoType;
|
||||
remoteParticipants: Array<GroupCallParticipantInfoType>;
|
||||
};
|
||||
} & GroupCallRingStateType;
|
||||
|
||||
export type ActiveCallStateType = {
|
||||
conversationId: string;
|
||||
|
@ -120,6 +125,11 @@ export type CancelCallType = {
|
|||
conversationId: string;
|
||||
};
|
||||
|
||||
type CancelIncomingGroupCallRingType = {
|
||||
conversationId: string;
|
||||
ringId: bigint;
|
||||
};
|
||||
|
||||
export type DeclineCallType = {
|
||||
conversationId: string;
|
||||
};
|
||||
|
@ -150,11 +160,17 @@ export type KeyChangeOkType = {
|
|||
conversationId: string;
|
||||
};
|
||||
|
||||
export type IncomingCallType = {
|
||||
export type IncomingDirectCallType = {
|
||||
conversationId: string;
|
||||
isVideoCall: boolean;
|
||||
};
|
||||
|
||||
type IncomingGroupCallType = {
|
||||
conversationId: string;
|
||||
ringId: bigint;
|
||||
ringerUuid: string;
|
||||
};
|
||||
|
||||
type PeekNotConnectedGroupCallType = {
|
||||
conversationId: string;
|
||||
};
|
||||
|
@ -237,18 +253,28 @@ export const isAnybodyElseInGroupCall = (
|
|||
ourUuid: string
|
||||
): boolean => uuids.some(id => id !== ourUuid);
|
||||
|
||||
const getGroupCallRingState = (
|
||||
call: Readonly<undefined | GroupCallStateType>
|
||||
): GroupCallRingStateType =>
|
||||
call?.ringId === undefined
|
||||
? {}
|
||||
: { ringId: call.ringId, ringerUuid: call.ringerUuid };
|
||||
|
||||
// Actions
|
||||
|
||||
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 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';
|
||||
const DECLINE_CALL = 'calling/DECLINE_CALL';
|
||||
const DECLINE_DIRECT_CALL = 'calling/DECLINE_DIRECT_CALL';
|
||||
const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE';
|
||||
const HANG_UP = 'calling/HANG_UP';
|
||||
const INCOMING_CALL = 'calling/INCOMING_CALL';
|
||||
const INCOMING_DIRECT_CALL = 'calling/INCOMING_DIRECT_CALL';
|
||||
const INCOMING_GROUP_CALL = 'calling/INCOMING_GROUP_CALL';
|
||||
const MARK_CALL_TRUSTED = 'calling/MARK_CALL_TRUSTED';
|
||||
const MARK_CALL_UNTRUSTED = 'calling/MARK_CALL_UNTRUSTED';
|
||||
const OUTGOING_CALL = 'calling/OUTGOING_CALL';
|
||||
|
@ -279,6 +305,11 @@ type CancelCallActionType = {
|
|||
type: 'calling/CANCEL_CALL';
|
||||
};
|
||||
|
||||
type CancelIncomingGroupCallRingActionType = {
|
||||
type: 'calling/CANCEL_INCOMING_GROUP_CALL_RING';
|
||||
payload: CancelIncomingGroupCallRingType;
|
||||
};
|
||||
|
||||
type CallLobbyActionType = {
|
||||
type: 'calling/SHOW_CALL_LOBBY';
|
||||
payload: ShowCallLobbyType;
|
||||
|
@ -300,7 +331,7 @@ type CloseNeedPermissionScreenActionType = {
|
|||
};
|
||||
|
||||
type DeclineCallActionType = {
|
||||
type: 'calling/DECLINE_CALL';
|
||||
type: 'calling/DECLINE_DIRECT_CALL';
|
||||
payload: DeclineCallType;
|
||||
};
|
||||
|
||||
|
@ -314,9 +345,14 @@ type HangUpActionType = {
|
|||
payload: HangUpType;
|
||||
};
|
||||
|
||||
type IncomingCallActionType = {
|
||||
type: 'calling/INCOMING_CALL';
|
||||
payload: IncomingCallType;
|
||||
type IncomingDirectCallActionType = {
|
||||
type: 'calling/INCOMING_DIRECT_CALL';
|
||||
payload: IncomingDirectCallType;
|
||||
};
|
||||
|
||||
type IncomingGroupCallActionType = {
|
||||
type: 'calling/INCOMING_GROUP_CALL';
|
||||
payload: IncomingGroupCallType;
|
||||
};
|
||||
|
||||
type KeyChangedActionType = {
|
||||
|
@ -417,6 +453,7 @@ type ToggleSpeakerViewActionType = {
|
|||
export type CallingActionType =
|
||||
| AcceptCallPendingActionType
|
||||
| CancelCallActionType
|
||||
| CancelIncomingGroupCallRingActionType
|
||||
| CallLobbyActionType
|
||||
| CallStateChangeFulfilledActionType
|
||||
| ChangeIODeviceFulfilledActionType
|
||||
|
@ -424,7 +461,8 @@ export type CallingActionType =
|
|||
| DeclineCallActionType
|
||||
| GroupCallStateChangeActionType
|
||||
| HangUpActionType
|
||||
| IncomingCallActionType
|
||||
| IncomingDirectCallActionType
|
||||
| IncomingGroupCallActionType
|
||||
| KeyChangedActionType
|
||||
| KeyChangeOkActionType
|
||||
| OutgoingCallActionType
|
||||
|
@ -450,17 +488,30 @@ export type CallingActionType =
|
|||
function acceptCall(
|
||||
payload: AcceptCallType
|
||||
): ThunkAction<void, RootStateType, unknown, AcceptCallPendingActionType> {
|
||||
return async dispatch => {
|
||||
return async (dispatch, getState) => {
|
||||
const { conversationId, asVideoCall } = payload;
|
||||
|
||||
const call = getOwn(getState().calling.callsByConversation, conversationId);
|
||||
if (!call) {
|
||||
window.log.error('Trying to accept a non-existent call');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (call.callMode) {
|
||||
case CallMode.Direct:
|
||||
await calling.acceptDirectCall(conversationId, asVideoCall);
|
||||
break;
|
||||
case CallMode.Group:
|
||||
calling.joinGroupCall(conversationId, true, asVideoCall);
|
||||
break;
|
||||
default:
|
||||
throw missingCaseError(call);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: ACCEPT_CALL_PENDING,
|
||||
payload,
|
||||
});
|
||||
|
||||
try {
|
||||
await calling.accept(payload.conversationId, payload.asVideoCall);
|
||||
} catch (err) {
|
||||
window.log.error(`Failed to acceptCall: ${err.stack}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -473,16 +524,7 @@ function callStateChange(
|
|||
CallStateChangeFulfilledActionType
|
||||
> {
|
||||
return async dispatch => {
|
||||
const { callState, isIncoming, title, isVideoCall } = payload;
|
||||
if (callState === CallState.Ringing && isIncoming) {
|
||||
await callingTones.playRingtone();
|
||||
await showCallNotification(title, isVideoCall);
|
||||
bounceAppIconStart();
|
||||
}
|
||||
if (callState !== CallState.Ringing) {
|
||||
await callingTones.stopRingtone();
|
||||
bounceAppIconStop();
|
||||
}
|
||||
const { callState } = payload;
|
||||
if (callState === CallState.Ended) {
|
||||
await callingTones.playEndCall();
|
||||
ipcRenderer.send('close-screen-share-controller');
|
||||
|
@ -519,30 +561,6 @@ function changeIODevice(
|
|||
};
|
||||
}
|
||||
|
||||
async function showCallNotification(
|
||||
title: string,
|
||||
isVideoCall: boolean
|
||||
): Promise<void> {
|
||||
const shouldNotify =
|
||||
!window.isActive() && window.Events.getCallSystemNotification();
|
||||
if (!shouldNotify) {
|
||||
return;
|
||||
}
|
||||
notify({
|
||||
title,
|
||||
icon: isVideoCall
|
||||
? 'images/icons/v2/video-solid-24.svg'
|
||||
: 'images/icons/v2/phone-right-solid-24.svg',
|
||||
message: window.i18n(
|
||||
isVideoCall ? 'incomingVideoCall' : 'incomingAudioCall'
|
||||
),
|
||||
onNotificationClick: () => {
|
||||
window.showWindow();
|
||||
},
|
||||
silent: false,
|
||||
});
|
||||
}
|
||||
|
||||
function closeNeedPermissionScreen(): CloseNeedPermissionScreenActionType {
|
||||
return {
|
||||
type: CLOSE_NEED_PERMISSION_SCREEN,
|
||||
|
@ -558,15 +576,59 @@ function cancelCall(payload: CancelCallType): CancelCallActionType {
|
|||
};
|
||||
}
|
||||
|
||||
function declineCall(payload: DeclineCallType): DeclineCallActionType {
|
||||
calling.decline(payload.conversationId);
|
||||
|
||||
function cancelIncomingGroupCallRing(
|
||||
payload: CancelIncomingGroupCallRingType
|
||||
): CancelIncomingGroupCallRingActionType {
|
||||
return {
|
||||
type: DECLINE_CALL,
|
||||
type: CANCEL_INCOMING_GROUP_CALL_RING,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function declineCall(
|
||||
payload: DeclineCallType
|
||||
): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
CancelIncomingGroupCallRingActionType | DeclineCallActionType
|
||||
> {
|
||||
return (dispatch, getState) => {
|
||||
const { conversationId } = payload;
|
||||
|
||||
const call = getOwn(getState().calling.callsByConversation, conversationId);
|
||||
if (!call) {
|
||||
window.log.error('Trying to decline a non-existent call');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (call.callMode) {
|
||||
case CallMode.Direct:
|
||||
calling.declineDirectCall(conversationId);
|
||||
dispatch({
|
||||
type: DECLINE_DIRECT_CALL,
|
||||
payload,
|
||||
});
|
||||
break;
|
||||
case CallMode.Group: {
|
||||
const { ringId } = call;
|
||||
if (ringId === undefined) {
|
||||
window.log.error('Trying to decline a group call without a ring ID');
|
||||
} else {
|
||||
calling.declineGroupCall(conversationId, ringId);
|
||||
dispatch({
|
||||
type: CANCEL_INCOMING_GROUP_CALL_RING,
|
||||
payload: { conversationId, ringId },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw missingCaseError(call);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getPresentingSources(): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
|
@ -697,11 +759,20 @@ function keyChangeOk(
|
|||
};
|
||||
}
|
||||
|
||||
function receiveIncomingCall(
|
||||
payload: IncomingCallType
|
||||
): IncomingCallActionType {
|
||||
function receiveIncomingDirectCall(
|
||||
payload: IncomingDirectCallType
|
||||
): IncomingDirectCallActionType {
|
||||
return {
|
||||
type: INCOMING_CALL,
|
||||
type: INCOMING_DIRECT_CALL,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function receiveIncomingGroupCall(
|
||||
payload: IncomingGroupCallType
|
||||
): IncomingGroupCallActionType {
|
||||
return {
|
||||
type: INCOMING_GROUP_CALL,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
@ -718,8 +789,6 @@ function openSystemPreferencesAction(): ThunkAction<
|
|||
}
|
||||
|
||||
function outgoingCall(payload: StartDirectCallType): OutgoingCallActionType {
|
||||
callingTones.playRingtone();
|
||||
|
||||
return {
|
||||
type: OUTGOING_CALL,
|
||||
payload,
|
||||
|
@ -1033,6 +1102,7 @@ export const actions = {
|
|||
acceptCall,
|
||||
callStateChange,
|
||||
cancelCall,
|
||||
cancelIncomingGroupCallRing,
|
||||
changeIODevice,
|
||||
closeNeedPermissionScreen,
|
||||
declineCall,
|
||||
|
@ -1044,7 +1114,8 @@ export const actions = {
|
|||
openSystemPreferencesAction,
|
||||
outgoingCall,
|
||||
peekNotConnectedGroupCall,
|
||||
receiveIncomingCall,
|
||||
receiveIncomingDirectCall,
|
||||
receiveIncomingGroupCall,
|
||||
refreshIODevices,
|
||||
remoteSharingScreenChange,
|
||||
remoteVideoChange,
|
||||
|
@ -1083,14 +1154,12 @@ export function getEmptyState(): CallingStateType {
|
|||
};
|
||||
}
|
||||
|
||||
function getExistingPeekInfo(
|
||||
function getGroupCall(
|
||||
conversationId: string,
|
||||
state: CallingStateType
|
||||
): undefined | GroupCallPeekInfoType {
|
||||
const existingCall = getOwn(state.callsByConversation, conversationId);
|
||||
return existingCall?.callMode === CallMode.Group
|
||||
? existingCall.peekInfo
|
||||
: undefined;
|
||||
state: Readonly<CallingStateType>
|
||||
): undefined | GroupCallStateType {
|
||||
const call = getOwn(state.callsByConversation, conversationId);
|
||||
return call?.callMode === CallMode.Group ? call : undefined;
|
||||
}
|
||||
|
||||
function removeConversationFromState(
|
||||
|
@ -1112,33 +1181,38 @@ export function reducer(
|
|||
const { callsByConversation } = state;
|
||||
|
||||
if (action.type === SHOW_CALL_LOBBY) {
|
||||
const { conversationId } = action.payload;
|
||||
|
||||
let call: DirectCallStateType | GroupCallStateType;
|
||||
switch (action.payload.callMode) {
|
||||
case CallMode.Direct:
|
||||
call = {
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: action.payload.conversationId,
|
||||
conversationId,
|
||||
isIncoming: false,
|
||||
isVideoCall: action.payload.hasLocalVideo,
|
||||
};
|
||||
break;
|
||||
case CallMode.Group:
|
||||
case CallMode.Group: {
|
||||
// We expect to be in this state briefly. The Calling service should update the
|
||||
// call state shortly.
|
||||
const existingCall = getGroupCall(conversationId, state);
|
||||
call = {
|
||||
callMode: CallMode.Group,
|
||||
conversationId: action.payload.conversationId,
|
||||
conversationId,
|
||||
connectionState: action.payload.connectionState,
|
||||
joinState: action.payload.joinState,
|
||||
peekInfo: action.payload.peekInfo ||
|
||||
getExistingPeekInfo(action.payload.conversationId, state) || {
|
||||
existingCall?.peekInfo || {
|
||||
uuids: action.payload.remoteParticipants.map(({ uuid }) => uuid),
|
||||
maxDevices: Infinity,
|
||||
deviceCount: action.payload.remoteParticipants.length,
|
||||
},
|
||||
remoteParticipants: action.payload.remoteParticipants,
|
||||
...getGroupCallRingState(existingCall),
|
||||
};
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw missingCaseError(action.payload);
|
||||
}
|
||||
|
@ -1229,11 +1303,32 @@ export function reducer(
|
|||
}
|
||||
}
|
||||
|
||||
if (action.type === DECLINE_CALL) {
|
||||
if (action.type === CANCEL_INCOMING_GROUP_CALL_RING) {
|
||||
const { conversationId, ringId } = action.payload;
|
||||
|
||||
const groupCall = getGroupCall(conversationId, state);
|
||||
if (!groupCall || groupCall.ringId !== ringId) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (groupCall.connectionState === GroupCallConnectionState.NotConnected) {
|
||||
return removeConversationFromState(state, conversationId);
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
callsByConversation: {
|
||||
...callsByConversation,
|
||||
[conversationId]: omit(groupCall, ['ringId', 'ringerUuid']),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === DECLINE_DIRECT_CALL) {
|
||||
return removeConversationFromState(state, action.payload.conversationId);
|
||||
}
|
||||
|
||||
if (action.type === INCOMING_CALL) {
|
||||
if (action.type === INCOMING_DIRECT_CALL) {
|
||||
return {
|
||||
...state,
|
||||
callsByConversation: {
|
||||
|
@ -1249,6 +1344,52 @@ export function reducer(
|
|||
};
|
||||
}
|
||||
|
||||
if (action.type === INCOMING_GROUP_CALL) {
|
||||
const { conversationId, ringId, ringerUuid } = action.payload;
|
||||
|
||||
let groupCall: GroupCallStateType;
|
||||
const existingGroupCall = getGroupCall(conversationId, state);
|
||||
if (existingGroupCall) {
|
||||
if (existingGroupCall.ringerUuid) {
|
||||
window.log.info('Group call was already ringing');
|
||||
return state;
|
||||
}
|
||||
if (existingGroupCall.joinState !== GroupCallJoinState.NotJoined) {
|
||||
window.log.info("Got a ring for a call we're already in");
|
||||
return state;
|
||||
}
|
||||
|
||||
groupCall = {
|
||||
...existingGroupCall,
|
||||
ringId,
|
||||
ringerUuid,
|
||||
};
|
||||
} else {
|
||||
groupCall = {
|
||||
callMode: CallMode.Group,
|
||||
conversationId,
|
||||
connectionState: GroupCallConnectionState.NotConnected,
|
||||
joinState: GroupCallJoinState.NotJoined,
|
||||
peekInfo: {
|
||||
uuids: [],
|
||||
maxDevices: Infinity,
|
||||
deviceCount: 0,
|
||||
},
|
||||
remoteParticipants: [],
|
||||
ringId,
|
||||
ringerUuid,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
callsByConversation: {
|
||||
...callsByConversation,
|
||||
[conversationId]: groupCall,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === OUTGOING_CALL) {
|
||||
return {
|
||||
...state,
|
||||
|
@ -1333,8 +1474,11 @@ export function reducer(
|
|||
remoteParticipants,
|
||||
} = action.payload;
|
||||
|
||||
const existingCall = getGroupCall(conversationId, state);
|
||||
const existingRingState = getGroupCallRingState(existingCall);
|
||||
|
||||
const newPeekInfo = peekInfo ||
|
||||
getExistingPeekInfo(conversationId, state) || {
|
||||
existingCall?.peekInfo || {
|
||||
uuids: remoteParticipants.map(({ uuid }) => uuid),
|
||||
maxDevices: Infinity,
|
||||
deviceCount: remoteParticipants.length,
|
||||
|
@ -1348,7 +1492,10 @@ export function reducer(
|
|||
? undefined
|
||||
: state.activeCallState;
|
||||
|
||||
if (!isAnybodyElseInGroupCall(newPeekInfo, ourUuid)) {
|
||||
if (
|
||||
!isAnybodyElseInGroupCall(newPeekInfo, ourUuid) &&
|
||||
(!existingCall || !existingCall.ringerUuid)
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
callsByConversation: omit(callsByConversation, conversationId),
|
||||
|
@ -1366,6 +1513,13 @@ export function reducer(
|
|||
: state.activeCallState;
|
||||
}
|
||||
|
||||
let newRingState: GroupCallRingStateType;
|
||||
if (joinState === GroupCallJoinState.NotJoined) {
|
||||
newRingState = existingRingState;
|
||||
} else {
|
||||
newRingState = {};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
callsByConversation: {
|
||||
|
@ -1377,6 +1531,7 @@ export function reducer(
|
|||
joinState,
|
||||
peekInfo: newPeekInfo,
|
||||
remoteParticipants,
|
||||
...newRingState,
|
||||
},
|
||||
},
|
||||
activeCallState: newActiveCallState,
|
||||
|
@ -1386,26 +1541,22 @@ export function reducer(
|
|||
if (action.type === PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED) {
|
||||
const { conversationId, peekInfo, ourConversationId } = action.payload;
|
||||
|
||||
const existingCall = getOwn(state.callsByConversation, conversationId) || {
|
||||
const existingCall: GroupCallStateType = getGroupCall(
|
||||
conversationId,
|
||||
state
|
||||
) || {
|
||||
callMode: CallMode.Group,
|
||||
conversationId,
|
||||
connectionState: GroupCallConnectionState.NotConnected,
|
||||
joinState: GroupCallJoinState.NotJoined,
|
||||
peekInfo: {
|
||||
conversationIds: [],
|
||||
uuids: [],
|
||||
maxDevices: Infinity,
|
||||
deviceCount: 0,
|
||||
},
|
||||
remoteParticipants: [],
|
||||
};
|
||||
|
||||
if (existingCall.callMode !== CallMode.Group) {
|
||||
window.log.error(
|
||||
'Unexpected state: trying to update a non-group call. Doing nothing'
|
||||
);
|
||||
return state;
|
||||
}
|
||||
|
||||
// This action should only update non-connected group calls. It's not necessarily a
|
||||
// mistake if this action is dispatched "over" a connected call. Here's a valid
|
||||
// sequence of events:
|
||||
|
@ -1419,7 +1570,10 @@ export function reducer(
|
|||
return state;
|
||||
}
|
||||
|
||||
if (!isAnybodyElseInGroupCall(peekInfo, ourConversationId)) {
|
||||
if (
|
||||
!isAnybodyElseInGroupCall(peekInfo, ourConversationId) ||
|
||||
!existingCall.ringerUuid
|
||||
) {
|
||||
return removeConversationFromState(state, conversationId);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { createSelector } from 'reselect';
|
||||
|
@ -9,9 +9,16 @@ import {
|
|||
CallsByConversationType,
|
||||
DirectCallStateType,
|
||||
GroupCallStateType,
|
||||
isAnybodyElseInGroupCall,
|
||||
} from '../ducks/calling';
|
||||
import { CallMode, CallState } from '../../types/Calling';
|
||||
import {
|
||||
CallMode,
|
||||
CallState,
|
||||
GroupCallConnectionState,
|
||||
} from '../../types/Calling';
|
||||
import { getUserUuid } from './user';
|
||||
import { getOwn } from '../../util/getOwn';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
|
||||
export type CallStateType = DirectCallStateType | GroupCallStateType;
|
||||
|
||||
|
@ -55,20 +62,29 @@ export const isInCall = createSelector(
|
|||
(call: CallStateType | undefined): boolean => Boolean(call)
|
||||
);
|
||||
|
||||
// In theory, there could be multiple incoming calls. In practice, neither RingRTC nor the
|
||||
// UI are ready to handle this.
|
||||
// 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 = createSelector(
|
||||
getCallsByConversation,
|
||||
getUserUuid,
|
||||
(
|
||||
callsByConversation: CallsByConversationType
|
||||
): undefined | DirectCallStateType => {
|
||||
const result = Object.values(callsByConversation).find(
|
||||
call =>
|
||||
call.callMode === CallMode.Direct &&
|
||||
call.isIncoming &&
|
||||
call.callState === CallState.Ringing
|
||||
);
|
||||
// TypeScript needs a little help to be sure that this is a direct call.
|
||||
return result?.callMode === CallMode.Direct ? result : undefined;
|
||||
callsByConversation: CallsByConversationType,
|
||||
ourUuid: string
|
||||
): undefined | DirectCallStateType | GroupCallStateType => {
|
||||
return Object.values(callsByConversation).find(call => {
|
||||
switch (call.callMode) {
|
||||
case CallMode.Direct:
|
||||
return call.isIncoming && call.callState === CallState.Ringing;
|
||||
case CallMode.Group:
|
||||
return (
|
||||
call.ringerUuid &&
|
||||
call.connectionState === GroupCallConnectionState.NotConnected &&
|
||||
isAnybodyElseInGroupCall(call.peekInfo, ourUuid)
|
||||
);
|
||||
default:
|
||||
throw missingCaseError(call);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
|
|
@ -25,6 +25,12 @@ import {
|
|||
SmartSafetyNumberViewer,
|
||||
Props as SafetyNumberViewerProps,
|
||||
} from './SafetyNumberViewer';
|
||||
import { notify } from '../../services/notify';
|
||||
import { callingTones } from '../../util/callingTones';
|
||||
import {
|
||||
bounceAppIconStart,
|
||||
bounceAppIconStop,
|
||||
} from '../../shims/bounceAppIcon';
|
||||
|
||||
function renderDeviceSelection(): JSX.Element {
|
||||
return <SmartCallingDeviceSelection />;
|
||||
|
@ -38,6 +44,33 @@ const getGroupCallVideoFrameSource = callingService.getGroupCallVideoFrameSource
|
|||
callingService
|
||||
);
|
||||
|
||||
async function notifyForCall(
|
||||
title: string,
|
||||
isVideoCall: boolean
|
||||
): Promise<void> {
|
||||
const shouldNotify =
|
||||
!window.isActive() && window.Events.getCallSystemNotification();
|
||||
if (!shouldNotify) {
|
||||
return;
|
||||
}
|
||||
notify({
|
||||
title,
|
||||
icon: isVideoCall
|
||||
? 'images/icons/v2/video-solid-24.svg'
|
||||
: 'images/icons/v2/phone-right-solid-24.svg',
|
||||
message: window.i18n(
|
||||
isVideoCall ? 'incomingVideoCall' : 'incomingAudioCall'
|
||||
),
|
||||
onNotificationClick: () => {
|
||||
window.showWindow();
|
||||
},
|
||||
silent: false,
|
||||
});
|
||||
}
|
||||
|
||||
const playRingtone = callingTones.playRingtone.bind(callingTones);
|
||||
const stopRingtone = callingTones.stopRingtone.bind(callingTones);
|
||||
|
||||
const mapStateToActiveCallProp = (
|
||||
state: StateType
|
||||
): undefined | ActiveCallType => {
|
||||
|
@ -221,14 +254,41 @@ const mapStateToIncomingCallProp = (state: StateType) => {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
call,
|
||||
conversation,
|
||||
};
|
||||
switch (call.callMode) {
|
||||
case CallMode.Direct:
|
||||
return {
|
||||
callMode: CallMode.Direct as const,
|
||||
conversation,
|
||||
isVideoCall: call.isVideoCall,
|
||||
};
|
||||
case CallMode.Group: {
|
||||
if (!call.ringerUuid) {
|
||||
window.log.error('The incoming group call has no ring state');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const conversationSelector = getConversationSelector(state);
|
||||
const ringer = conversationSelector(call.ringerUuid);
|
||||
const otherMembersRung = (conversation.sortedGroupMembers ?? []).filter(
|
||||
c => c.id !== ringer.id && !c.isMe
|
||||
);
|
||||
|
||||
return {
|
||||
callMode: CallMode.Group as const,
|
||||
conversation,
|
||||
otherMembersRung,
|
||||
ringer,
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw missingCaseError(call);
|
||||
}
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: StateType) => ({
|
||||
activeCall: mapStateToActiveCallProp(state),
|
||||
bounceAppIconStart,
|
||||
bounceAppIconStop,
|
||||
availableCameras: state.calling.availableCameras,
|
||||
getGroupCallVideoFrameSource,
|
||||
i18n: getIntl(state),
|
||||
|
@ -239,6 +299,9 @@ const mapStateToProps = (state: StateType) => ({
|
|||
// according to the type. This ensures one is set.
|
||||
uuid: getUserUuid(state),
|
||||
},
|
||||
notifyForCall,
|
||||
playRingtone,
|
||||
stopRingtone,
|
||||
renderDeviceSelection,
|
||||
renderSafetyNumberViewer,
|
||||
});
|
||||
|
|
23
ts/test-both/util/callingGetParticipantName_test.ts
Normal file
23
ts/test-both/util/callingGetParticipantName_test.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import { getParticipantName } from '../../util/callingGetParticipantName';
|
||||
|
||||
describe('getParticipantName', () => {
|
||||
it('returns the first name if available', () => {
|
||||
const participant = {
|
||||
firstName: 'Foo',
|
||||
title: 'Foo Bar',
|
||||
};
|
||||
|
||||
assert.strictEqual(getParticipantName(participant), 'Foo');
|
||||
});
|
||||
|
||||
it('returns the title if the first name is unavailable', () => {
|
||||
const participant = { title: 'Foo Bar' };
|
||||
|
||||
assert.strictEqual(getParticipantName(participant), 'Foo Bar');
|
||||
});
|
||||
});
|
|
@ -95,6 +95,20 @@ describe('calling duck', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const stateWithIncomingGroupCall = {
|
||||
...stateWithGroupCall,
|
||||
callsByConversation: {
|
||||
...stateWithGroupCall.callsByConversation,
|
||||
'fake-group-call-conversation-id': {
|
||||
...stateWithGroupCall.callsByConversation[
|
||||
'fake-group-call-conversation-id'
|
||||
],
|
||||
ringId: BigInt(123),
|
||||
ringerUuid: '789',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const stateWithActiveGroupCall = {
|
||||
...stateWithGroupCall,
|
||||
activeCallState: {
|
||||
|
@ -318,81 +332,191 @@ describe('calling duck', () => {
|
|||
|
||||
beforeEach(function beforeEach() {
|
||||
this.callingServiceAccept = this.sandbox
|
||||
.stub(callingService, 'accept')
|
||||
.stub(callingService, 'acceptDirectCall')
|
||||
.resolves();
|
||||
this.callingServiceJoin = this.sandbox.stub(
|
||||
callingService,
|
||||
'joinGroupCall'
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches an ACCEPT_CALL_PENDING action', async () => {
|
||||
const dispatch = sinon.spy();
|
||||
describe('accepting a direct call', () => {
|
||||
const getState = () => ({
|
||||
...getEmptyRootState(),
|
||||
calling: stateWithIncomingDirectCall,
|
||||
});
|
||||
|
||||
await acceptCall({
|
||||
conversationId: '123',
|
||||
asVideoCall: true,
|
||||
})(dispatch, getEmptyRootState, null);
|
||||
it('dispatches an ACCEPT_CALL_PENDING action', async () => {
|
||||
const dispatch = sinon.spy();
|
||||
|
||||
sinon.assert.calledOnce(dispatch);
|
||||
sinon.assert.calledWith(dispatch, {
|
||||
type: 'calling/ACCEPT_CALL_PENDING',
|
||||
payload: {
|
||||
conversationId: '123',
|
||||
await acceptCall({
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
asVideoCall: true,
|
||||
},
|
||||
});
|
||||
})(dispatch, getState, null);
|
||||
|
||||
await acceptCall({
|
||||
conversationId: '456',
|
||||
asVideoCall: false,
|
||||
})(dispatch, getEmptyRootState, null);
|
||||
sinon.assert.calledOnce(dispatch);
|
||||
sinon.assert.calledWith(dispatch, {
|
||||
type: 'calling/ACCEPT_CALL_PENDING',
|
||||
payload: {
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
asVideoCall: true,
|
||||
},
|
||||
});
|
||||
|
||||
sinon.assert.calledTwice(dispatch);
|
||||
sinon.assert.calledWith(dispatch, {
|
||||
type: 'calling/ACCEPT_CALL_PENDING',
|
||||
payload: {
|
||||
conversationId: '456',
|
||||
await acceptCall({
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
asVideoCall: false,
|
||||
},
|
||||
})(dispatch, getState, null);
|
||||
|
||||
sinon.assert.calledTwice(dispatch);
|
||||
sinon.assert.calledWith(dispatch, {
|
||||
type: 'calling/ACCEPT_CALL_PENDING',
|
||||
payload: {
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
asVideoCall: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('asks the calling service to accept the call', async function test() {
|
||||
const dispatch = sinon.spy();
|
||||
|
||||
await acceptCall({
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
asVideoCall: true,
|
||||
})(dispatch, getState, null);
|
||||
|
||||
sinon.assert.calledOnce(this.callingServiceAccept);
|
||||
sinon.assert.calledWith(
|
||||
this.callingServiceAccept,
|
||||
'fake-direct-call-conversation-id',
|
||||
true
|
||||
);
|
||||
|
||||
await acceptCall({
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
asVideoCall: false,
|
||||
})(dispatch, getState, null);
|
||||
|
||||
sinon.assert.calledTwice(this.callingServiceAccept);
|
||||
sinon.assert.calledWith(
|
||||
this.callingServiceAccept,
|
||||
'fake-direct-call-conversation-id',
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('updates the active call state with ACCEPT_CALL_PENDING', async () => {
|
||||
const dispatch = sinon.spy();
|
||||
await acceptCall({
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
asVideoCall: true,
|
||||
})(dispatch, getState, null);
|
||||
const action = dispatch.getCall(0).args[0];
|
||||
|
||||
const result = reducer(stateWithIncomingDirectCall, action);
|
||||
|
||||
assert.deepEqual(result.activeCallState, {
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: true,
|
||||
isInSpeakerView: false,
|
||||
showParticipantsList: false,
|
||||
safetyNumberChangedUuids: [],
|
||||
pip: false,
|
||||
settingsDialogOpen: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('asks the calling service to accept the call', async function test() {
|
||||
const dispatch = sinon.spy();
|
||||
describe('accepting a group call', () => {
|
||||
const getState = () => ({
|
||||
...getEmptyRootState(),
|
||||
calling: stateWithIncomingGroupCall,
|
||||
});
|
||||
|
||||
await acceptCall({
|
||||
conversationId: '123',
|
||||
asVideoCall: true,
|
||||
})(dispatch, getEmptyRootState, null);
|
||||
it('dispatches an ACCEPT_CALL_PENDING action', async () => {
|
||||
const dispatch = sinon.spy();
|
||||
|
||||
sinon.assert.calledOnce(this.callingServiceAccept);
|
||||
sinon.assert.calledWith(this.callingServiceAccept, '123', true);
|
||||
await acceptCall({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
asVideoCall: true,
|
||||
})(dispatch, getState, null);
|
||||
|
||||
await acceptCall({
|
||||
conversationId: '456',
|
||||
asVideoCall: false,
|
||||
})(dispatch, getEmptyRootState, null);
|
||||
sinon.assert.calledOnce(dispatch);
|
||||
sinon.assert.calledWith(dispatch, {
|
||||
type: 'calling/ACCEPT_CALL_PENDING',
|
||||
payload: {
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
asVideoCall: true,
|
||||
},
|
||||
});
|
||||
|
||||
sinon.assert.calledTwice(this.callingServiceAccept);
|
||||
sinon.assert.calledWith(this.callingServiceAccept, '456', false);
|
||||
});
|
||||
await acceptCall({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
asVideoCall: false,
|
||||
})(dispatch, getState, null);
|
||||
|
||||
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, getEmptyRootState, null);
|
||||
const action = dispatch.getCall(0).args[0];
|
||||
sinon.assert.calledTwice(dispatch);
|
||||
sinon.assert.calledWith(dispatch, {
|
||||
type: 'calling/ACCEPT_CALL_PENDING',
|
||||
payload: {
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
asVideoCall: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const result = reducer(stateWithIncomingDirectCall, action);
|
||||
it('asks the calling service to join the call', async function test() {
|
||||
const dispatch = sinon.spy();
|
||||
|
||||
assert.deepEqual(result.activeCallState, {
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: true,
|
||||
isInSpeakerView: false,
|
||||
showParticipantsList: false,
|
||||
safetyNumberChangedUuids: [],
|
||||
pip: false,
|
||||
settingsDialogOpen: false,
|
||||
await acceptCall({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
asVideoCall: true,
|
||||
})(dispatch, getState, null);
|
||||
|
||||
sinon.assert.calledOnce(this.callingServiceJoin);
|
||||
sinon.assert.calledWith(
|
||||
this.callingServiceJoin,
|
||||
'fake-group-call-conversation-id',
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
await acceptCall({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
asVideoCall: false,
|
||||
})(dispatch, getState, null);
|
||||
|
||||
sinon.assert.calledTwice(this.callingServiceJoin);
|
||||
sinon.assert.calledWith(
|
||||
this.callingServiceJoin,
|
||||
'fake-group-call-conversation-id',
|
||||
true,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('updates the active call state with ACCEPT_CALL_PENDING', async () => {
|
||||
const dispatch = sinon.spy();
|
||||
await acceptCall({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
asVideoCall: true,
|
||||
})(dispatch, getState, null);
|
||||
const action = dispatch.getCall(0).args[0];
|
||||
|
||||
const result = reducer(stateWithIncomingGroupCall, action);
|
||||
|
||||
assert.deepEqual(result.activeCallState, {
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: true,
|
||||
isInSpeakerView: false,
|
||||
showParticipantsList: false,
|
||||
safetyNumberChangedUuids: [],
|
||||
pip: false,
|
||||
settingsDialogOpen: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -441,6 +565,201 @@ describe('calling duck', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('cancelIncomingGroupCallRing', () => {
|
||||
const { cancelIncomingGroupCallRing } = actions;
|
||||
|
||||
it('does nothing if there is no associated group call', () => {
|
||||
const state = getEmptyState();
|
||||
const action = cancelIncomingGroupCallRing({
|
||||
conversationId: 'garbage',
|
||||
ringId: BigInt(1),
|
||||
});
|
||||
|
||||
const result = reducer(state, action);
|
||||
|
||||
assert.strictEqual(result, state);
|
||||
});
|
||||
|
||||
it("does nothing if the ring to cancel isn't the same one", () => {
|
||||
const action = cancelIncomingGroupCallRing({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
ringId: BigInt(999),
|
||||
});
|
||||
|
||||
const result = reducer(stateWithIncomingGroupCall, action);
|
||||
|
||||
assert.strictEqual(result, stateWithIncomingGroupCall);
|
||||
});
|
||||
|
||||
it("removes the call from the state if it's not connected", () => {
|
||||
const state = {
|
||||
...stateWithGroupCall,
|
||||
callsByConversation: {
|
||||
...stateWithGroupCall.callsByConversation,
|
||||
'fake-group-call-conversation-id': {
|
||||
...stateWithGroupCall.callsByConversation[
|
||||
'fake-group-call-conversation-id'
|
||||
],
|
||||
connectionState: GroupCallConnectionState.NotConnected,
|
||||
ringId: BigInt(123),
|
||||
ringerUuid: '789',
|
||||
},
|
||||
},
|
||||
};
|
||||
const action = cancelIncomingGroupCallRing({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
ringId: BigInt(123),
|
||||
});
|
||||
|
||||
const result = reducer(state, action);
|
||||
|
||||
assert.notProperty(
|
||||
result.callsByConversation,
|
||||
'fake-group-call-conversation-id'
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the ring state, but not the call, if it's connected", () => {
|
||||
const action = cancelIncomingGroupCallRing({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
ringId: BigInt(123),
|
||||
});
|
||||
|
||||
const result = reducer(stateWithIncomingGroupCall, action);
|
||||
const call =
|
||||
result.callsByConversation['fake-group-call-conversation-id'];
|
||||
// It'd be nice to do this with an assert, but Chai doesn't understand it.
|
||||
if (call?.callMode !== CallMode.Group) {
|
||||
throw new Error('Expected to find a group call');
|
||||
}
|
||||
|
||||
assert.isUndefined(call.ringId);
|
||||
assert.isUndefined(call.ringerUuid);
|
||||
});
|
||||
});
|
||||
|
||||
describe('declineCall', () => {
|
||||
const { declineCall } = actions;
|
||||
|
||||
let declineDirectCall: sinon.SinonStub;
|
||||
let declineGroupCall: sinon.SinonStub;
|
||||
|
||||
beforeEach(function beforeEach() {
|
||||
declineDirectCall = this.sandbox.stub(
|
||||
callingService,
|
||||
'declineDirectCall'
|
||||
);
|
||||
declineGroupCall = this.sandbox.stub(
|
||||
callingService,
|
||||
'declineGroupCall'
|
||||
);
|
||||
});
|
||||
|
||||
describe('declining a direct call', () => {
|
||||
const getState = () => ({
|
||||
...getEmptyRootState(),
|
||||
calling: stateWithIncomingDirectCall,
|
||||
});
|
||||
|
||||
it('dispatches a DECLINE_DIRECT_CALL action', () => {
|
||||
const dispatch = sinon.spy();
|
||||
|
||||
declineCall({ conversationId: 'fake-direct-call-conversation-id' })(
|
||||
dispatch,
|
||||
getState,
|
||||
null
|
||||
);
|
||||
|
||||
sinon.assert.calledOnce(dispatch);
|
||||
sinon.assert.calledWith(dispatch, {
|
||||
type: 'calling/DECLINE_DIRECT_CALL',
|
||||
payload: {
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('asks the calling service to decline the call', () => {
|
||||
const dispatch = sinon.spy();
|
||||
|
||||
declineCall({ conversationId: 'fake-direct-call-conversation-id' })(
|
||||
dispatch,
|
||||
getState,
|
||||
null
|
||||
);
|
||||
|
||||
sinon.assert.calledOnce(declineDirectCall);
|
||||
sinon.assert.calledWith(
|
||||
declineDirectCall,
|
||||
'fake-direct-call-conversation-id'
|
||||
);
|
||||
});
|
||||
|
||||
it('removes the call from the state', () => {
|
||||
const dispatch = sinon.spy();
|
||||
declineCall({ conversationId: 'fake-direct-call-conversation-id' })(
|
||||
dispatch,
|
||||
getState,
|
||||
null
|
||||
);
|
||||
const action = dispatch.getCall(0).args[0];
|
||||
|
||||
const result = reducer(stateWithIncomingGroupCall, action);
|
||||
|
||||
assert.notProperty(
|
||||
result.callsByConversation,
|
||||
'fake-direct-call-conversation-id'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('declining a group call', () => {
|
||||
const getState = () => ({
|
||||
...getEmptyRootState(),
|
||||
calling: stateWithIncomingGroupCall,
|
||||
});
|
||||
|
||||
it('dispatches a CANCEL_INCOMING_GROUP_CALL_RING action', () => {
|
||||
const dispatch = sinon.spy();
|
||||
|
||||
declineCall({ conversationId: 'fake-group-call-conversation-id' })(
|
||||
dispatch,
|
||||
getState,
|
||||
null
|
||||
);
|
||||
|
||||
sinon.assert.calledOnce(dispatch);
|
||||
sinon.assert.calledWith(dispatch, {
|
||||
type: 'calling/CANCEL_INCOMING_GROUP_CALL_RING',
|
||||
payload: {
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
ringId: BigInt(123),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('asks the calling service to decline the call', () => {
|
||||
const dispatch = sinon.spy();
|
||||
|
||||
declineCall({ conversationId: 'fake-group-call-conversation-id' })(
|
||||
dispatch,
|
||||
getState,
|
||||
null
|
||||
);
|
||||
|
||||
sinon.assert.calledOnce(declineGroupCall);
|
||||
sinon.assert.calledWith(
|
||||
declineGroupCall,
|
||||
'fake-group-call-conversation-id',
|
||||
BigInt(123)
|
||||
);
|
||||
});
|
||||
|
||||
// NOTE: The state effects of this action are tested with
|
||||
// `cancelIncomingGroupCallRing`.
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupCallStateChange', () => {
|
||||
const { groupCallStateChange } = actions;
|
||||
|
||||
|
@ -475,7 +794,7 @@ describe('calling duck', () => {
|
|||
assert.deepEqual(result, getEmptyState());
|
||||
});
|
||||
|
||||
it('removes the call from the map of conversations if the call is not connected and has no peeked participants', () => {
|
||||
it('removes the call from the map of conversations if the call is not connected and has no peeked participants or ringer', () => {
|
||||
const result = reducer(
|
||||
stateWithGroupCall,
|
||||
getAction({
|
||||
|
@ -659,6 +978,29 @@ describe('calling duck', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('saves a call to the map of conversations if the call had a ringer, even if it was otherwise ignorable', () => {
|
||||
const result = reducer(
|
||||
stateWithIncomingGroupCall,
|
||||
getAction({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.NotConnected,
|
||||
joinState: GroupCallJoinState.NotJoined,
|
||||
hasLocalAudio: false,
|
||||
hasLocalVideo: false,
|
||||
peekInfo: {
|
||||
uuids: [],
|
||||
maxDevices: 16,
|
||||
deviceCount: 0,
|
||||
},
|
||||
remoteParticipants: [],
|
||||
})
|
||||
);
|
||||
|
||||
assert.isDefined(
|
||||
result.callsByConversation['fake-group-call-conversation-id']
|
||||
);
|
||||
});
|
||||
|
||||
it('updates a call in the map of conversations', () => {
|
||||
const result = reducer(
|
||||
stateWithGroupCall,
|
||||
|
@ -714,6 +1056,108 @@ describe('calling duck', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("keeps the existing ring state if you haven't joined the call", () => {
|
||||
const state = {
|
||||
...stateWithGroupCall,
|
||||
callsByConversation: {
|
||||
...stateWithGroupCall.callsByConversation,
|
||||
'fake-group-call-conversation-id': {
|
||||
...stateWithGroupCall.callsByConversation[
|
||||
'fake-group-call-conversation-id'
|
||||
],
|
||||
ringId: BigInt(456),
|
||||
ringerUuid: '55addfd8-09ed-4f5b-b42e-01058898d13b',
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = reducer(
|
||||
state,
|
||||
getAction({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.NotJoined,
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: false,
|
||||
peekInfo: {
|
||||
uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
|
||||
maxDevices: 16,
|
||||
deviceCount: 1,
|
||||
},
|
||||
remoteParticipants: [
|
||||
{
|
||||
uuid: '123',
|
||||
demuxId: 456,
|
||||
hasRemoteAudio: false,
|
||||
hasRemoteVideo: true,
|
||||
presenting: false,
|
||||
sharingScreen: false,
|
||||
videoAspectRatio: 16 / 9,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
assert.include(
|
||||
result.callsByConversation['fake-group-call-conversation-id'],
|
||||
{
|
||||
callMode: CallMode.Group,
|
||||
ringId: BigInt(456),
|
||||
ringerUuid: '55addfd8-09ed-4f5b-b42e-01058898d13b',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the ring state if you've joined the call", () => {
|
||||
const state = {
|
||||
...stateWithGroupCall,
|
||||
callsByConversation: {
|
||||
...stateWithGroupCall.callsByConversation,
|
||||
'fake-group-call-conversation-id': {
|
||||
...stateWithGroupCall.callsByConversation[
|
||||
'fake-group-call-conversation-id'
|
||||
],
|
||||
ringId: BigInt(456),
|
||||
ringerUuid: '55addfd8-09ed-4f5b-b42e-01058898d13b',
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = reducer(
|
||||
state,
|
||||
getAction({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: false,
|
||||
peekInfo: {
|
||||
uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
|
||||
maxDevices: 16,
|
||||
deviceCount: 1,
|
||||
},
|
||||
remoteParticipants: [
|
||||
{
|
||||
uuid: '123',
|
||||
demuxId: 456,
|
||||
hasRemoteAudio: false,
|
||||
hasRemoteVideo: true,
|
||||
presenting: false,
|
||||
sharingScreen: false,
|
||||
videoAspectRatio: 16 / 9,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
assert.notProperty(
|
||||
result.callsByConversation['fake-group-call-conversation-id'],
|
||||
'ringId'
|
||||
);
|
||||
assert.notProperty(
|
||||
result.callsByConversation['fake-group-call-conversation-id'],
|
||||
'ringerUuid'
|
||||
);
|
||||
});
|
||||
|
||||
it("if no call is active, doesn't touch the active call state", () => {
|
||||
const result = reducer(
|
||||
stateWithGroupCall,
|
||||
|
@ -910,6 +1354,88 @@ describe('calling duck', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('receiveIncomingGroupCall', () => {
|
||||
const { receiveIncomingGroupCall } = actions;
|
||||
|
||||
it('does nothing if the call was already ringing', () => {
|
||||
const action = receiveIncomingGroupCall({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
ringId: BigInt(456),
|
||||
ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96',
|
||||
});
|
||||
const result = reducer(stateWithIncomingGroupCall, action);
|
||||
|
||||
assert.strictEqual(result, stateWithIncomingGroupCall);
|
||||
});
|
||||
|
||||
it('does nothing if the call was already joined', () => {
|
||||
const state = {
|
||||
...stateWithGroupCall,
|
||||
callsByConversation: {
|
||||
...stateWithGroupCall.callsByConversation,
|
||||
'fake-group-call-conversation-id': {
|
||||
...stateWithGroupCall.callsByConversation[
|
||||
'fake-group-call-conversation-id'
|
||||
],
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
},
|
||||
},
|
||||
};
|
||||
const action = receiveIncomingGroupCall({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
ringId: BigInt(456),
|
||||
ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96',
|
||||
});
|
||||
const result = reducer(state, action);
|
||||
|
||||
assert.strictEqual(result, state);
|
||||
});
|
||||
|
||||
it('creates a new group call if one did not exist', () => {
|
||||
const action = receiveIncomingGroupCall({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
ringId: BigInt(456),
|
||||
ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96',
|
||||
});
|
||||
const result = reducer(getEmptyState(), action);
|
||||
|
||||
assert.deepEqual(
|
||||
result.callsByConversation['fake-group-call-conversation-id'],
|
||||
{
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.NotConnected,
|
||||
joinState: GroupCallJoinState.NotJoined,
|
||||
peekInfo: {
|
||||
uuids: [],
|
||||
maxDevices: Infinity,
|
||||
deviceCount: 0,
|
||||
},
|
||||
remoteParticipants: [],
|
||||
ringId: BigInt(456),
|
||||
ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('attaches ring state to an existing call', () => {
|
||||
const action = receiveIncomingGroupCall({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
ringId: BigInt(456),
|
||||
ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96',
|
||||
});
|
||||
const result = reducer(stateWithGroupCall, action);
|
||||
|
||||
assert.include(
|
||||
result.callsByConversation['fake-group-call-conversation-id'],
|
||||
{
|
||||
ringId: BigInt(456),
|
||||
ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96',
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLocalAudio', () => {
|
||||
const { setLocalAudio } = actions;
|
||||
|
||||
|
@ -1187,6 +1713,55 @@ describe('calling duck', () => {
|
|||
deviceCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't overwrite an existing group call's ring state if it was set previously", () => {
|
||||
const result = reducer(
|
||||
{
|
||||
...stateWithGroupCall,
|
||||
callsByConversation: {
|
||||
'fake-group-call-conversation-id': {
|
||||
...stateWithGroupCall.callsByConversation[
|
||||
'fake-group-call-conversation-id'
|
||||
],
|
||||
ringId: BigInt(987),
|
||||
ringerUuid: 'd59f05f7-3be8-4d44-a1e8-0d7cb5677ed8',
|
||||
},
|
||||
},
|
||||
},
|
||||
showCallLobby({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: true,
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.NotJoined,
|
||||
peekInfo: undefined,
|
||||
remoteParticipants: [
|
||||
{
|
||||
uuid: '123',
|
||||
demuxId: 123,
|
||||
hasRemoteAudio: true,
|
||||
hasRemoteVideo: true,
|
||||
presenting: false,
|
||||
sharingScreen: false,
|
||||
videoAspectRatio: 4 / 3,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
const call =
|
||||
result.callsByConversation['fake-group-call-conversation-id'];
|
||||
// It'd be nice to do this with an assert, but Chai doesn't understand it.
|
||||
if (call?.callMode !== CallMode.Group) {
|
||||
throw new Error('Expected to find a group call');
|
||||
}
|
||||
|
||||
assert.strictEqual(call.ringId, BigInt(987));
|
||||
assert.strictEqual(
|
||||
call.ringerUuid,
|
||||
'd59f05f7-3be8-4d44-a1e8-0d7cb5677ed8'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startCall', () => {
|
||||
|
|
|
@ -4,14 +4,24 @@
|
|||
import { assert } from 'chai';
|
||||
import { reducer as rootReducer } from '../../../state/reducer';
|
||||
import { noopAction } from '../../../state/ducks/noop';
|
||||
import { CallMode, CallState } from '../../../types/Calling';
|
||||
import {
|
||||
CallMode,
|
||||
CallState,
|
||||
GroupCallConnectionState,
|
||||
GroupCallJoinState,
|
||||
} from '../../../types/Calling';
|
||||
import {
|
||||
getCallsByConversation,
|
||||
getCallSelector,
|
||||
getIncomingCall,
|
||||
isInCall,
|
||||
} from '../../../state/selectors/calling';
|
||||
import { getEmptyState, CallingStateType } from '../../../state/ducks/calling';
|
||||
import {
|
||||
getEmptyState,
|
||||
CallingStateType,
|
||||
DirectCallStateType,
|
||||
GroupCallStateType,
|
||||
} from '../../../state/ducks/calling';
|
||||
|
||||
describe('state/selectors/calling', () => {
|
||||
const getEmptyRootState = () => rootReducer(undefined, noopAction());
|
||||
|
@ -49,17 +59,42 @@ describe('state/selectors/calling', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const incomingDirectCall: DirectCallStateType = {
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
callState: CallState.Ringing,
|
||||
isIncoming: true,
|
||||
isVideoCall: false,
|
||||
hasRemoteVideo: false,
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
'fake-direct-call-conversation-id': incomingDirectCall,
|
||||
},
|
||||
};
|
||||
|
||||
const incomingGroupCall: GroupCallStateType = {
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.NotConnected,
|
||||
joinState: GroupCallJoinState.NotJoined,
|
||||
peekInfo: {
|
||||
uuids: ['c75b51da-d484-4674-9b2c-cc11de00e227'],
|
||||
creatorUuid: 'c75b51da-d484-4674-9b2c-cc11de00e227',
|
||||
maxDevices: Infinity,
|
||||
deviceCount: 1,
|
||||
},
|
||||
remoteParticipants: [],
|
||||
ringId: BigInt(123),
|
||||
ringerUuid: 'c75b51da-d484-4674-9b2c-cc11de00e227',
|
||||
};
|
||||
|
||||
const stateWithIncomingGroupCall: CallingStateType = {
|
||||
...getEmptyState(),
|
||||
callsByConversation: {
|
||||
'fake-group-call-conversation-id': incomingGroupCall,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -119,17 +154,35 @@ describe('state/selectors/calling', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('returns the incoming call', () => {
|
||||
it('returns undefined if there is a group call with no peeked participants', () => {
|
||||
const state = {
|
||||
...stateWithIncomingGroupCall,
|
||||
callsByConversation: {
|
||||
'fake-group-call-conversation-id': {
|
||||
...incomingGroupCall,
|
||||
peekInfo: {
|
||||
uuids: [],
|
||||
maxDevices: Infinity,
|
||||
deviceCount: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.isUndefined(getIncomingCall(getCallingState(state)));
|
||||
});
|
||||
|
||||
it('returns an incoming direct call', () => {
|
||||
assert.deepEqual(
|
||||
getIncomingCall(getCallingState(stateWithIncomingDirectCall)),
|
||||
{
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
callState: CallState.Ringing,
|
||||
isIncoming: true,
|
||||
isVideoCall: false,
|
||||
hasRemoteVideo: false,
|
||||
}
|
||||
incomingDirectCall
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an incoming group call', () => {
|
||||
assert.deepEqual(
|
||||
getIncomingCall(getCallingState(stateWithIncomingGroupCall)),
|
||||
incomingGroupCall
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
89
ts/test-node/util/callingMessageToProto_test.ts
Normal file
89
ts/test-node/util/callingMessageToProto_test.ts
Normal file
|
@ -0,0 +1,89 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
import {
|
||||
CallMessageUrgency,
|
||||
CallingMessage,
|
||||
HangupMessage,
|
||||
HangupType,
|
||||
OpaqueMessage,
|
||||
} from 'ringrtc';
|
||||
import { SignalService as Proto } from '../../protobuf';
|
||||
|
||||
import { callingMessageToProto } from '../../util/callingMessageToProto';
|
||||
|
||||
describe('callingMessageToProto', () => {
|
||||
// NOTE: These tests are incomplete.
|
||||
|
||||
describe('hangup field', () => {
|
||||
it('leaves the field unset if `hangup` is not provided', () => {
|
||||
const result = callingMessageToProto(new CallingMessage());
|
||||
assert.isUndefined(result.hangup);
|
||||
});
|
||||
|
||||
it('attaches the type if provided', () => {
|
||||
const callingMessage = new CallingMessage();
|
||||
callingMessage.hangup = new HangupMessage();
|
||||
callingMessage.hangup.type = HangupType.Busy;
|
||||
|
||||
const result = callingMessageToProto(callingMessage);
|
||||
|
||||
assert.strictEqual(result.hangup?.type, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('opaque field', () => {
|
||||
it('leaves the field unset if neither `opaque` nor urgency are provided', () => {
|
||||
const result = callingMessageToProto(new CallingMessage());
|
||||
assert.isUndefined(result.opaque);
|
||||
});
|
||||
|
||||
it('attaches opaque data', () => {
|
||||
const callingMessage = new CallingMessage();
|
||||
callingMessage.opaque = new OpaqueMessage();
|
||||
callingMessage.opaque.data = Buffer.from([1, 2, 3]);
|
||||
|
||||
const result = callingMessageToProto(callingMessage);
|
||||
|
||||
assert.deepEqual(result.opaque?.data, new Uint8Array([1, 2, 3]));
|
||||
});
|
||||
|
||||
it('attaches urgency if provided', () => {
|
||||
const droppableResult = callingMessageToProto(
|
||||
new CallingMessage(),
|
||||
CallMessageUrgency.Droppable
|
||||
);
|
||||
assert.deepEqual(
|
||||
droppableResult.opaque?.urgency,
|
||||
Proto.CallingMessage.Opaque.Urgency.DROPPABLE
|
||||
);
|
||||
|
||||
const urgentResult = callingMessageToProto(
|
||||
new CallingMessage(),
|
||||
CallMessageUrgency.HandleImmediately
|
||||
);
|
||||
assert.deepEqual(
|
||||
urgentResult.opaque?.urgency,
|
||||
Proto.CallingMessage.Opaque.Urgency.HANDLE_IMMEDIATELY
|
||||
);
|
||||
});
|
||||
|
||||
it('attaches urgency and opaque data if both are provided', () => {
|
||||
const callingMessage = new CallingMessage();
|
||||
callingMessage.opaque = new OpaqueMessage();
|
||||
callingMessage.opaque.data = Buffer.from([1, 2, 3]);
|
||||
|
||||
const result = callingMessageToProto(
|
||||
callingMessage,
|
||||
CallMessageUrgency.HandleImmediately
|
||||
);
|
||||
|
||||
assert.deepEqual(result.opaque?.data, new Uint8Array([1, 2, 3]));
|
||||
assert.deepEqual(
|
||||
result.opaque?.urgency,
|
||||
Proto.CallingMessage.Opaque.Urgency.HANDLE_IMMEDIATELY
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -198,3 +198,9 @@ export type ChangeIODevicePayloadType =
|
|||
| { type: CallingDeviceType.CAMERA; selectedDevice: string }
|
||||
| { type: CallingDeviceType.MICROPHONE; selectedDevice: AudioDevice }
|
||||
| { type: CallingDeviceType.SPEAKER; selectedDevice: AudioDevice };
|
||||
|
||||
export enum ProcessGroupCallRingRequestResult {
|
||||
ShouldRing,
|
||||
RingWasPreviouslyCanceled,
|
||||
ThereIsAnotherActiveRing,
|
||||
}
|
||||
|
|
10
ts/util/callingGetParticipantName.ts
Normal file
10
ts/util/callingGetParticipantName.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
|
||||
export function getParticipantName(
|
||||
participant: Readonly<Pick<ConversationType, 'firstName' | 'title'>>
|
||||
): string {
|
||||
return participant.firstName || participant.title;
|
||||
}
|
106
ts/util/callingMessageToProto.ts
Normal file
106
ts/util/callingMessageToProto.ts
Normal file
|
@ -0,0 +1,106 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { CallingMessage, CallMessageUrgency } from 'ringrtc';
|
||||
import { SignalService as Proto } from '../protobuf';
|
||||
import * as log from '../logging/log';
|
||||
import { missingCaseError } from './missingCaseError';
|
||||
|
||||
// TODO: remove once we move away from ArrayBuffers
|
||||
const FIXMEU8 = Uint8Array;
|
||||
|
||||
export function callingMessageToProto(
|
||||
{
|
||||
offer,
|
||||
answer,
|
||||
iceCandidates,
|
||||
legacyHangup,
|
||||
busy,
|
||||
hangup,
|
||||
supportsMultiRing,
|
||||
destinationDeviceId,
|
||||
opaque,
|
||||
}: CallingMessage,
|
||||
urgency?: CallMessageUrgency
|
||||
): Proto.ICallingMessage {
|
||||
let opaqueField: undefined | Proto.CallingMessage.IOpaque;
|
||||
if (opaque) {
|
||||
opaqueField = {
|
||||
...opaque,
|
||||
data: bufferToProto(opaque.data),
|
||||
};
|
||||
}
|
||||
if (urgency !== undefined) {
|
||||
opaqueField = {
|
||||
...(opaqueField ?? {}),
|
||||
urgency: urgencyToProto(urgency),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
offer: offer
|
||||
? {
|
||||
...offer,
|
||||
type: offer.type as number,
|
||||
opaque: bufferToProto(offer.opaque),
|
||||
}
|
||||
: undefined,
|
||||
answer: answer
|
||||
? {
|
||||
...answer,
|
||||
opaque: bufferToProto(answer.opaque),
|
||||
}
|
||||
: undefined,
|
||||
iceCandidates: iceCandidates
|
||||
? iceCandidates.map(candidate => {
|
||||
return {
|
||||
...candidate,
|
||||
opaque: bufferToProto(candidate.opaque),
|
||||
};
|
||||
})
|
||||
: undefined,
|
||||
legacyHangup: legacyHangup
|
||||
? {
|
||||
...legacyHangup,
|
||||
type: legacyHangup.type as number,
|
||||
}
|
||||
: undefined,
|
||||
busy,
|
||||
hangup: hangup
|
||||
? {
|
||||
...hangup,
|
||||
type: hangup.type as number,
|
||||
}
|
||||
: undefined,
|
||||
supportsMultiRing,
|
||||
destinationDeviceId,
|
||||
opaque: opaqueField,
|
||||
};
|
||||
}
|
||||
|
||||
function bufferToProto(
|
||||
value: Buffer | { toArrayBuffer(): ArrayBuffer } | undefined
|
||||
): Uint8Array | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (value instanceof Uint8Array) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new FIXMEU8(value.toArrayBuffer());
|
||||
}
|
||||
|
||||
function urgencyToProto(
|
||||
urgency: CallMessageUrgency
|
||||
): Proto.CallingMessage.Opaque.Urgency {
|
||||
switch (urgency) {
|
||||
case CallMessageUrgency.Droppable:
|
||||
return Proto.CallingMessage.Opaque.Urgency.DROPPABLE;
|
||||
case CallMessageUrgency.HandleImmediately:
|
||||
return Proto.CallingMessage.Opaque.Urgency.HANDLE_IMMEDIATELY;
|
||||
default:
|
||||
log.error(missingCaseError(urgency));
|
||||
return Proto.CallingMessage.Opaque.Urgency.DROPPABLE;
|
||||
}
|
||||
}
|
|
@ -13465,6 +13465,22 @@
|
|||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2021-07-30T16:57:33.618Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/IncomingCallBar.js",
|
||||
"line": " const initialTitleRef = react_1.useRef(title);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2021-08-16T20:52:11.043Z",
|
||||
"reasonDetail": "Doesn't interact with the DOM."
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/IncomingCallBar.tsx",
|
||||
"line": " const initialTitleRef = useRef<string>(title);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2021-08-16T20:52:11.043Z",
|
||||
"reasonDetail": "Doesn't interact with the DOM."
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/Input.js",
|
||||
|
|
2
ts/window.d.ts
vendored
2
ts/window.d.ts
vendored
|
@ -513,6 +513,8 @@ declare global {
|
|||
GV2_ENABLE_STATE_PROCESSING: boolean;
|
||||
GV2_MIGRATION_DISABLE_ADD: boolean;
|
||||
GV2_MIGRATION_DISABLE_INVITE: boolean;
|
||||
RING_WHEN_JOINING_GROUP_CALLS: boolean;
|
||||
|
||||
RETRY_DELAY: boolean;
|
||||
}
|
||||
|
||||
|
|
|
@ -15710,9 +15710,9 @@ rimraf@^3.0.0, rimraf@^3.0.2, rimraf@~3.0.2:
|
|||
dependencies:
|
||||
glob "^7.1.3"
|
||||
|
||||
"ringrtc@https://github.com/signalapp/signal-ringrtc-node.git#800b31c5d43a1436bcea8b7b3f82a4baf4771bfb":
|
||||
version "2.11.0"
|
||||
resolved "https://github.com/signalapp/signal-ringrtc-node.git#800b31c5d43a1436bcea8b7b3f82a4baf4771bfb"
|
||||
"ringrtc@https://github.com/signalapp/signal-ringrtc-node.git#031abcc7564e769648a8d8f8bda935fad0d877b9":
|
||||
version "2.11.1"
|
||||
resolved "https://github.com/signalapp/signal-ringrtc-node.git#031abcc7564e769648a8d8f8bda935fad0d877b9"
|
||||
|
||||
ripemd160@^2.0.0, ripemd160@^2.0.1:
|
||||
version "2.0.1"
|
||||
|
|
Loading…
Reference in a new issue