Initial group calling support
This commit is contained in:
parent
e398520db0
commit
022c4bd0f4
31 changed files with 2530 additions and 414 deletions
|
@ -1,42 +1,90 @@
|
|||
diff --git a/node_modules/node-fetch/lib/index.es.js b/node_modules/node-fetch/lib/index.es.js
|
||||
index 61906c9..51ee2bd 100644
|
||||
index 61906c9..f09f5bd 100644
|
||||
--- a/node_modules/node-fetch/lib/index.es.js
|
||||
+++ b/node_modules/node-fetch/lib/index.es.js
|
||||
@@ -1404,6 +1404,9 @@ function fetch(url, opts) {
|
||||
// build request object
|
||||
const request = new Request(url, opts);
|
||||
const options = getNodeRequestOptions(request);
|
||||
+ if (opts && opts.ca) {
|
||||
+ options.ca = opts.ca;
|
||||
+ }
|
||||
@@ -1231,6 +1231,9 @@ class Request {
|
||||
this.compress = init.compress !== undefined ? init.compress : input.compress !== undefined ? input.compress : true;
|
||||
this.counter = init.counter || input.counter || 0;
|
||||
this.agent = init.agent || input.agent;
|
||||
+
|
||||
+ // Custom Signal Desktop option
|
||||
+ this.ca = init.ca || input.ca;
|
||||
}
|
||||
|
||||
const send = (options.protocol === 'https:' ? https : http).request;
|
||||
const signal = request.signal;
|
||||
get method() {
|
||||
@@ -1350,7 +1353,7 @@ function getNodeRequestOptions(request) {
|
||||
method: request.method,
|
||||
headers: exportNodeCompatibleHeaders(headers),
|
||||
agent
|
||||
- });
|
||||
+ }, request.ca ? { ca: request.ca } : {});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1514,7 +1517,8 @@ function fetch(url, opts) {
|
||||
body: request.body,
|
||||
signal: request.signal,
|
||||
timeout: request.timeout,
|
||||
- size: request.size
|
||||
+ size: request.size,
|
||||
+ ca: request.ca,
|
||||
};
|
||||
|
||||
// HTTP-redirect fetch step 9
|
||||
diff --git a/node_modules/node-fetch/lib/index.js b/node_modules/node-fetch/lib/index.js
|
||||
index 4b241bf..88de03f 100644
|
||||
index 4b241bf..23fa901 100644
|
||||
--- a/node_modules/node-fetch/lib/index.js
|
||||
+++ b/node_modules/node-fetch/lib/index.js
|
||||
@@ -1408,6 +1408,9 @@ function fetch(url, opts) {
|
||||
// build request object
|
||||
const request = new Request(url, opts);
|
||||
const options = getNodeRequestOptions(request);
|
||||
+ if (opts && opts.ca) {
|
||||
+ options.ca = opts.ca;
|
||||
+ }
|
||||
@@ -1235,6 +1235,9 @@ class Request {
|
||||
this.compress = init.compress !== undefined ? init.compress : input.compress !== undefined ? input.compress : true;
|
||||
this.counter = init.counter || input.counter || 0;
|
||||
this.agent = init.agent || input.agent;
|
||||
+
|
||||
+ // Custom Signal Desktop option
|
||||
+ this.ca = init.ca || input.ca;
|
||||
}
|
||||
|
||||
const send = (options.protocol === 'https:' ? https : http).request;
|
||||
const signal = request.signal;
|
||||
get method() {
|
||||
@@ -1354,7 +1357,7 @@ function getNodeRequestOptions(request) {
|
||||
method: request.method,
|
||||
headers: exportNodeCompatibleHeaders(headers),
|
||||
agent
|
||||
- });
|
||||
+ }, request.ca ? { ca: request.ca } : {});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1518,7 +1521,8 @@ function fetch(url, opts) {
|
||||
body: request.body,
|
||||
signal: request.signal,
|
||||
timeout: request.timeout,
|
||||
- size: request.size
|
||||
+ size: request.size,
|
||||
+ ca: request.ca,
|
||||
};
|
||||
|
||||
// HTTP-redirect fetch step 9
|
||||
diff --git a/node_modules/node-fetch/lib/index.mjs b/node_modules/node-fetch/lib/index.mjs
|
||||
index ecf59af..b4af5ff 100644
|
||||
index ecf59af..b723a5c 100644
|
||||
--- a/node_modules/node-fetch/lib/index.mjs
|
||||
+++ b/node_modules/node-fetch/lib/index.mjs
|
||||
@@ -1402,6 +1402,9 @@ function fetch(url, opts) {
|
||||
// build request object
|
||||
const request = new Request(url, opts);
|
||||
const options = getNodeRequestOptions(request);
|
||||
+ if (opts && opts.ca) {
|
||||
+ options.ca = opts.ca;
|
||||
+ }
|
||||
@@ -1229,6 +1229,9 @@ class Request {
|
||||
this.compress = init.compress !== undefined ? init.compress : input.compress !== undefined ? input.compress : true;
|
||||
this.counter = init.counter || input.counter || 0;
|
||||
this.agent = init.agent || input.agent;
|
||||
+
|
||||
+ // Custom Signal Desktop option
|
||||
+ this.ca = init.ca || input.ca;
|
||||
}
|
||||
|
||||
const send = (options.protocol === 'https:' ? https : http).request;
|
||||
const signal = request.signal;
|
||||
get method() {
|
||||
@@ -1512,7 +1515,8 @@ function fetch(url, opts) {
|
||||
body: request.body,
|
||||
signal: request.signal,
|
||||
timeout: request.timeout,
|
||||
- size: request.size
|
||||
+ size: request.size,
|
||||
+ ca: request.ca,
|
||||
};
|
||||
|
||||
// HTTP-redirect fetch step 9
|
||||
|
|
|
@ -16,6 +16,8 @@ try {
|
|||
const { app } = remote;
|
||||
const { nativeTheme } = remote.require('electron');
|
||||
|
||||
window.GROUP_CALLING = true;
|
||||
|
||||
window.PROTO_ROOT = 'protos';
|
||||
const config = require('url').parse(window.location.toString(), true).query;
|
||||
|
||||
|
@ -564,6 +566,7 @@ try {
|
|||
/* eslint-disable global-require, import/no-extraneous-dependencies */
|
||||
require('./ts/test-electron/models/messages_test');
|
||||
require('./ts/test-electron/linkPreviews/linkPreviewFetch_test');
|
||||
require('./ts/test-electron/state/ducks/conversations_test');
|
||||
require('./ts/test-electron/state/ducks/calling_test');
|
||||
require('./ts/test-electron/state/selectors/calling_test');
|
||||
|
||||
|
|
|
@ -151,3 +151,7 @@ message GroupAttributeBlob {
|
|||
uint32 disappearingMessagesDuration = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message GroupExternalCredential {
|
||||
string token = 1;
|
||||
}
|
||||
|
|
|
@ -87,6 +87,10 @@ message CallingMessage {
|
|||
optional uint32 deviceId = 3;
|
||||
}
|
||||
|
||||
message Opaque {
|
||||
optional bytes data = 1;
|
||||
}
|
||||
|
||||
optional Offer offer = 1;
|
||||
optional Answer answer = 2;
|
||||
repeated IceCandidate iceCandidates = 3;
|
||||
|
@ -95,6 +99,7 @@ message CallingMessage {
|
|||
optional Hangup hangup = 7;
|
||||
optional bool supportsMultiRing = 8;
|
||||
optional uint32 destinationDeviceId = 9;
|
||||
optional Opaque opaque = 10;
|
||||
}
|
||||
|
||||
message DataMessage {
|
||||
|
|
|
@ -5860,6 +5860,7 @@ button.module-image__border-overlay:focus {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
@ -6225,8 +6226,18 @@ button.module-image__border-overlay:focus {
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
&__header {
|
||||
&__container {
|
||||
&--direct {
|
||||
.module-ongoing-call__header {
|
||||
position: absolute;
|
||||
}
|
||||
.module-ongoing-call__footer {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
background: linear-gradient($color-black-alpha-40, transparent);
|
||||
}
|
||||
|
||||
|
@ -6237,12 +6248,33 @@ button.module-image__border-overlay:focus {
|
|||
letter-spacing: -0.0025em;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__group-call-remote-participant {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
transition: top 200ms linear, left 200ms linear, width 200ms linear,
|
||||
height 200ms linear;
|
||||
|
||||
&__remote-video {
|
||||
// The background-color is seen while the video loads.
|
||||
background-color: $color-gray-75;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
background: linear-gradient(transparent, $color-black-alpha-40);
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
|
||||
&__actions {
|
||||
|
|
|
@ -175,4 +175,93 @@ describe('Crypto', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('uuidToArrayBuffer', () => {
|
||||
const { uuidToArrayBuffer } = Signal.Crypto;
|
||||
|
||||
it('converts valid UUIDs to ArrayBuffers', () => {
|
||||
const expectedResult = new Uint8Array([
|
||||
0x22,
|
||||
0x6e,
|
||||
0x44,
|
||||
0x02,
|
||||
0x7f,
|
||||
0xfc,
|
||||
0x45,
|
||||
0x43,
|
||||
0x85,
|
||||
0xc9,
|
||||
0x46,
|
||||
0x22,
|
||||
0xc5,
|
||||
0x0a,
|
||||
0x5b,
|
||||
0x14,
|
||||
]).buffer;
|
||||
|
||||
assert.deepEqual(
|
||||
uuidToArrayBuffer('226e4402-7ffc-4543-85c9-4622c50a5b14'),
|
||||
expectedResult
|
||||
);
|
||||
assert.deepEqual(
|
||||
uuidToArrayBuffer('226E4402-7FFC-4543-85C9-4622C50A5B14'),
|
||||
expectedResult
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty ArrayBuffer for strings of the wrong length', () => {
|
||||
assert.deepEqual(uuidToArrayBuffer(''), new ArrayBuffer(0));
|
||||
assert.deepEqual(uuidToArrayBuffer('abc'), new ArrayBuffer(0));
|
||||
assert.deepEqual(
|
||||
uuidToArrayBuffer('032deadf0d5e4ee78da28e75b1dfb284'),
|
||||
new ArrayBuffer(0)
|
||||
);
|
||||
assert.deepEqual(
|
||||
uuidToArrayBuffer('deaed5eb-d983-456a-a954-9ad7a006b271aaaaaaaaaa'),
|
||||
new ArrayBuffer(0)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('arrayBufferToUuid', () => {
|
||||
const { arrayBufferToUuid } = Signal.Crypto;
|
||||
|
||||
it('converts valid ArrayBuffers to UUID strings', () => {
|
||||
const buf = new Uint8Array([
|
||||
0x22,
|
||||
0x6e,
|
||||
0x44,
|
||||
0x02,
|
||||
0x7f,
|
||||
0xfc,
|
||||
0x45,
|
||||
0x43,
|
||||
0x85,
|
||||
0xc9,
|
||||
0x46,
|
||||
0x22,
|
||||
0xc5,
|
||||
0x0a,
|
||||
0x5b,
|
||||
0x14,
|
||||
]).buffer;
|
||||
|
||||
assert.deepEqual(
|
||||
arrayBufferToUuid(buf),
|
||||
'226e4402-7ffc-4543-85c9-4622c50a5b14'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined if passed an all-zero buffer', () => {
|
||||
assert.isUndefined(arrayBufferToUuid(new ArrayBuffer(16)));
|
||||
});
|
||||
|
||||
it('returns undefined if passed the wrong number of bytes', () => {
|
||||
assert.isUndefined(arrayBufferToUuid(new ArrayBuffer(0)));
|
||||
assert.isUndefined(arrayBufferToUuid(new Uint8Array([0x22]).buffer));
|
||||
assert.isUndefined(
|
||||
arrayBufferToUuid(new Uint8Array(Array(17).fill(0x22)).buffer)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
31
ts/Crypto.ts
31
ts/Crypto.ts
|
@ -2,6 +2,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import pProps from 'p-props';
|
||||
import { chunk } from 'lodash';
|
||||
|
||||
export function typedArrayToArrayBuffer(typedArray: Uint8Array): ArrayBuffer {
|
||||
const { buffer, byteOffset, byteLength } = typedArray;
|
||||
|
@ -706,6 +707,36 @@ export async function encryptCdsDiscoveryRequest(
|
|||
};
|
||||
}
|
||||
|
||||
export function uuidToArrayBuffer(uuid: string): ArrayBuffer {
|
||||
if (uuid.length !== 36) {
|
||||
window.log.warn(
|
||||
'uuidToArrayBuffer: received a string of invalid length. Returning an empty ArrayBuffer'
|
||||
);
|
||||
return new ArrayBuffer(0);
|
||||
}
|
||||
|
||||
return Uint8Array.from(
|
||||
chunk(uuid.replace(/-/g, ''), 2).map(pair => parseInt(pair.join(''), 16))
|
||||
).buffer;
|
||||
}
|
||||
|
||||
export function arrayBufferToUuid(
|
||||
arrayBuffer: ArrayBuffer
|
||||
): undefined | string {
|
||||
if (arrayBuffer.byteLength !== 16) {
|
||||
window.log.warn(
|
||||
'arrayBufferToUuid: received an ArrayBuffer of invalid length. Returning undefined'
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const uuids = splitUuids(arrayBuffer);
|
||||
if (uuids.length === 1) {
|
||||
return uuids[0] || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function splitUuids(arrayBuffer: ArrayBuffer): Array<string | null> {
|
||||
const uuids = [];
|
||||
for (let i = 0; i < arrayBuffer.byteLength; i += 16) {
|
||||
|
|
|
@ -2,11 +2,19 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
import { noop } from 'lodash';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { CallManager } from './CallManager';
|
||||
import { CallEndedReason, CallState } from '../types/Calling';
|
||||
import { CallManager, PropsType } from './CallManager';
|
||||
import {
|
||||
CallEndedReason,
|
||||
CallMode,
|
||||
CallState,
|
||||
GroupCallConnectionState,
|
||||
GroupCallJoinState,
|
||||
} from '../types/Calling';
|
||||
import { ConversationTypeType } from '../state/ducks/conversations';
|
||||
import { ColorType } from '../types/Colors';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
@ -21,6 +29,9 @@ const conversation = {
|
|||
name: 'Rick Sanchez',
|
||||
phoneNumber: '3051234567',
|
||||
profileName: 'Rick Sanchez',
|
||||
markedUnread: false,
|
||||
type: 'direct' as ConversationTypeType,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
|
@ -29,6 +40,17 @@ const defaultProps = {
|
|||
cancelCall: action('cancel-call'),
|
||||
closeNeedPermissionScreen: action('close-need-permission-screen'),
|
||||
declineCall: action('decline-call'),
|
||||
// We allow `any` here because these are fake and actually come from RingRTC, which we
|
||||
// can't import.
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
createCanvasVideoRenderer: () =>
|
||||
({
|
||||
setCanvas: noop,
|
||||
enable: noop,
|
||||
disable: noop,
|
||||
} as any),
|
||||
getGroupCallVideoFrameSource: noop as any,
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
hangUp: action('hang-up'),
|
||||
i18n,
|
||||
me: {
|
||||
|
@ -52,10 +74,11 @@ const permutations = [
|
|||
props: {},
|
||||
},
|
||||
{
|
||||
title: 'Call Manager (ongoing)',
|
||||
title: 'Call Manager (ongoing direct call)',
|
||||
props: {
|
||||
activeCall: {
|
||||
call: {
|
||||
callMode: CallMode.Direct as CallMode.Direct,
|
||||
conversationId: '3051234567',
|
||||
callState: CallState.Accepted,
|
||||
isIncoming: false,
|
||||
|
@ -75,11 +98,36 @@ const permutations = [
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Call Manager (ongoing group call)',
|
||||
props: {
|
||||
activeCall: {
|
||||
call: {
|
||||
callMode: CallMode.Group as CallMode.Group,
|
||||
conversationId: '3051234567',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
remoteParticipants: [],
|
||||
},
|
||||
activeCallState: {
|
||||
conversationId: '3051234567',
|
||||
joinedAt: Date.now(),
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: false,
|
||||
participantsList: false,
|
||||
pip: false,
|
||||
settingsDialogOpen: false,
|
||||
},
|
||||
conversation,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Call Manager (ringing)',
|
||||
props: {
|
||||
incomingCall: {
|
||||
call: {
|
||||
callMode: CallMode.Direct as CallMode.Direct,
|
||||
conversationId: '3051234567',
|
||||
callState: CallState.Ringing,
|
||||
isIncoming: true,
|
||||
|
@ -95,6 +143,7 @@ const permutations = [
|
|||
props: {
|
||||
activeCall: {
|
||||
call: {
|
||||
callMode: CallMode.Direct as CallMode.Direct,
|
||||
conversationId: '3051234567',
|
||||
callState: CallState.Ended,
|
||||
callEndedReason: CallEndedReason.RemoteHangupNeedPermission,
|
||||
|
@ -118,10 +167,12 @@ const permutations = [
|
|||
];
|
||||
|
||||
storiesOf('Components/CallManager', module).add('Iterations', () => {
|
||||
return permutations.map(({ props, title }) => (
|
||||
return permutations.map(
|
||||
({ props, title }: { props: Partial<PropsType>; title: string }) => (
|
||||
<>
|
||||
<h3>{title}</h3>
|
||||
<CallManager {...defaultProps} {...props} />
|
||||
</>
|
||||
));
|
||||
)
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,56 +1,58 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { CallingPip } from './CallingPip';
|
||||
import { CallNeedPermissionScreen } from './CallNeedPermissionScreen';
|
||||
import { CallingLobby } from './CallingLobby';
|
||||
import { CallScreen } from './CallScreen';
|
||||
import { IncomingCallBar } from './IncomingCallBar';
|
||||
import { CallState, CallEndedReason } from '../types/Calling';
|
||||
import {
|
||||
ActiveCallStateType,
|
||||
CallMode,
|
||||
CallState,
|
||||
CallEndedReason,
|
||||
CanvasVideoRenderer,
|
||||
VideoFrameSource,
|
||||
GroupCallJoinState,
|
||||
} from '../types/Calling';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
import {
|
||||
AcceptCallType,
|
||||
ActiveCallStateType,
|
||||
CancelCallType,
|
||||
DeclineCallType,
|
||||
DirectCallStateType,
|
||||
StartCallType,
|
||||
SetLocalAudioType,
|
||||
GroupCallStateType,
|
||||
HangUpType,
|
||||
SetLocalAudioType,
|
||||
SetLocalPreviewType,
|
||||
SetLocalVideoType,
|
||||
SetRendererCanvasType,
|
||||
StartCallType,
|
||||
} from '../state/ducks/calling';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { ColorType } from '../types/Colors';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
|
||||
interface PropsType {
|
||||
activeCall?: {
|
||||
call: DirectCallStateType;
|
||||
interface ActiveCallType {
|
||||
call: DirectCallStateType | GroupCallStateType;
|
||||
activeCallState: ActiveCallStateType;
|
||||
conversation: {
|
||||
id: string;
|
||||
avatarPath?: string;
|
||||
color?: ColorType;
|
||||
title: string;
|
||||
name?: string;
|
||||
phoneNumber?: string;
|
||||
profileName?: string;
|
||||
};
|
||||
};
|
||||
conversation: ConversationType;
|
||||
}
|
||||
|
||||
export interface PropsType {
|
||||
activeCall?: ActiveCallType;
|
||||
availableCameras: Array<MediaDeviceInfo>;
|
||||
cancelCall: () => void;
|
||||
cancelCall: (_: CancelCallType) => void;
|
||||
createCanvasVideoRenderer: () => CanvasVideoRenderer;
|
||||
closeNeedPermissionScreen: () => void;
|
||||
getGroupCallVideoFrameSource: (
|
||||
conversationId: string,
|
||||
demuxId: number
|
||||
) => VideoFrameSource;
|
||||
incomingCall?: {
|
||||
call: DirectCallStateType;
|
||||
conversation: {
|
||||
id: string;
|
||||
avatarPath?: string;
|
||||
color?: ColorType;
|
||||
title: string;
|
||||
name?: string;
|
||||
phoneNumber?: string;
|
||||
profileName?: string;
|
||||
};
|
||||
conversation: ConversationType;
|
||||
};
|
||||
renderDeviceSelection: () => JSX.Element;
|
||||
startCall: (payload: StartCallType) => void;
|
||||
|
@ -75,16 +77,19 @@ interface PropsType {
|
|||
toggleSettings: () => void;
|
||||
}
|
||||
|
||||
export const CallManager = ({
|
||||
acceptCall,
|
||||
interface ActiveCallManagerPropsType extends PropsType {
|
||||
activeCall: ActiveCallType;
|
||||
}
|
||||
|
||||
const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
|
||||
activeCall,
|
||||
availableCameras,
|
||||
cancelCall,
|
||||
closeNeedPermissionScreen,
|
||||
declineCall,
|
||||
createCanvasVideoRenderer,
|
||||
hangUp,
|
||||
i18n,
|
||||
incomingCall,
|
||||
getGroupCallVideoFrameSource,
|
||||
me,
|
||||
renderDeviceSelection,
|
||||
setLocalAudio,
|
||||
|
@ -95,10 +100,8 @@ export const CallManager = ({
|
|||
toggleParticipants,
|
||||
togglePip,
|
||||
toggleSettings,
|
||||
}: PropsType): JSX.Element | null => {
|
||||
if (activeCall) {
|
||||
}) => {
|
||||
const { call, activeCallState, conversation } = activeCall;
|
||||
const { callState, callEndedReason } = call;
|
||||
const {
|
||||
joinedAt,
|
||||
hasLocalAudio,
|
||||
|
@ -107,9 +110,36 @@ export const CallManager = ({
|
|||
pip,
|
||||
} = activeCallState;
|
||||
|
||||
const cancelActiveCall = useCallback(() => {
|
||||
cancelCall({ conversationId: conversation.id });
|
||||
}, [cancelCall, conversation.id]);
|
||||
|
||||
const joinActiveCall = useCallback(() => {
|
||||
startCall({
|
||||
callMode: call.callMode,
|
||||
conversationId: conversation.id,
|
||||
hasLocalAudio,
|
||||
hasLocalVideo,
|
||||
});
|
||||
}, [startCall, call.callMode, conversation.id, hasLocalAudio, hasLocalVideo]);
|
||||
|
||||
const getGroupCallVideoFrameSourceForActiveCall = useCallback(
|
||||
(demuxId: number) => {
|
||||
return getGroupCallVideoFrameSource(conversation.id, demuxId);
|
||||
},
|
||||
[getGroupCallVideoFrameSource, conversation.id]
|
||||
);
|
||||
|
||||
let showCallLobby: boolean;
|
||||
|
||||
switch (call.callMode) {
|
||||
case CallMode.Direct: {
|
||||
const { callState, callEndedReason } = call;
|
||||
const ended = callState === CallState.Ended;
|
||||
if (ended) {
|
||||
if (callEndedReason === CallEndedReason.RemoteHangupNeedPermission) {
|
||||
if (
|
||||
ended &&
|
||||
callEndedReason === CallEndedReason.RemoteHangupNeedPermission
|
||||
) {
|
||||
return (
|
||||
<CallNeedPermissionScreen
|
||||
close={closeNeedPermissionScreen}
|
||||
|
@ -118,9 +148,18 @@ export const CallManager = ({
|
|||
/>
|
||||
);
|
||||
}
|
||||
showCallLobby = !callState;
|
||||
break;
|
||||
}
|
||||
case CallMode.Group: {
|
||||
showCallLobby = call.joinState === GroupCallJoinState.NotJoined;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw missingCaseError(call);
|
||||
}
|
||||
|
||||
if (!callState) {
|
||||
if (showCallLobby) {
|
||||
return (
|
||||
<>
|
||||
<CallingLobby
|
||||
|
@ -129,16 +168,12 @@ export const CallManager = ({
|
|||
hasLocalAudio={hasLocalAudio}
|
||||
hasLocalVideo={hasLocalVideo}
|
||||
i18n={i18n}
|
||||
// TODO: Set this to `true` for group calls. We can get away with this for
|
||||
// now because it only affects rendering. See DESKTOP-888 and DESKTOP-889.
|
||||
isGroupCall={false}
|
||||
me={me}
|
||||
onCallCanceled={cancelCall}
|
||||
onJoinCall={() => {
|
||||
startCall({
|
||||
conversationId: conversation.id,
|
||||
hasLocalAudio,
|
||||
hasLocalVideo,
|
||||
});
|
||||
}}
|
||||
onCallCanceled={cancelActiveCall}
|
||||
onJoinCall={joinActiveCall}
|
||||
setLocalPreview={setLocalPreview}
|
||||
setLocalAudio={setLocalAudio}
|
||||
setLocalVideo={setLocalVideo}
|
||||
|
@ -150,9 +185,10 @@ export const CallManager = ({
|
|||
);
|
||||
}
|
||||
|
||||
// TODO: Group calls should also support the PiP. See DESKTOP-886.
|
||||
if (pip && call.callMode === CallMode.Direct) {
|
||||
const hasRemoteVideo = Boolean(call.hasRemoteVideo);
|
||||
|
||||
if (pip) {
|
||||
return (
|
||||
<CallingPip
|
||||
conversation={conversation}
|
||||
|
@ -170,15 +206,16 @@ export const CallManager = ({
|
|||
return (
|
||||
<>
|
||||
<CallScreen
|
||||
call={call}
|
||||
conversation={conversation}
|
||||
callState={callState}
|
||||
createCanvasVideoRenderer={createCanvasVideoRenderer}
|
||||
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
|
||||
hangUp={hangUp}
|
||||
hasLocalAudio={hasLocalAudio}
|
||||
hasLocalVideo={hasLocalVideo}
|
||||
i18n={i18n}
|
||||
joinedAt={joinedAt}
|
||||
me={me}
|
||||
hasRemoteVideo={hasRemoteVideo}
|
||||
setLocalPreview={setLocalPreview}
|
||||
setRendererCanvas={setRendererCanvas}
|
||||
setLocalAudio={setLocalAudio}
|
||||
|
@ -189,6 +226,15 @@ export const CallManager = ({
|
|||
{settingsDialogOpen && renderDeviceSelection()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const CallManager: React.FC<PropsType> = props => {
|
||||
const { activeCall, incomingCall, acceptCall, declineCall, i18n } = props;
|
||||
|
||||
if (activeCall) {
|
||||
// `props` should logically have an `activeCall` at this point, but TypeScript can't
|
||||
// figure that out, so we pass it in again.
|
||||
return <ActiveCallManager {...props} activeCall={activeCall} />;
|
||||
}
|
||||
|
||||
// In the future, we may want to show the incoming call bar when a call is active.
|
||||
|
|
|
@ -2,11 +2,12 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
import { noop } from 'lodash';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { boolean, select } from '@storybook/addon-knobs';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { CallState } from '../types/Calling';
|
||||
import { CallMode, CallState } from '../types/Calling';
|
||||
import { ColorType } from '../types/Colors';
|
||||
import { CallScreen, PropsType } from './CallScreen';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
|
@ -14,7 +15,29 @@ import enMessages from '../../_locales/en/messages.json';
|
|||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
const createProps = (
|
||||
overrideProps: {
|
||||
callState?: CallState;
|
||||
hasLocalAudio?: boolean;
|
||||
hasLocalVideo?: boolean;
|
||||
hasRemoteVideo?: boolean;
|
||||
} = {}
|
||||
): PropsType => ({
|
||||
call: {
|
||||
callMode: CallMode.Direct as CallMode.Direct,
|
||||
conversationId: '3051234567',
|
||||
callState: select(
|
||||
'callState',
|
||||
CallState,
|
||||
overrideProps.callState || CallState.Accepted
|
||||
),
|
||||
isIncoming: false,
|
||||
isVideoCall: true,
|
||||
hasRemoteVideo: boolean(
|
||||
'hasRemoteVideo',
|
||||
overrideProps.hasRemoteVideo || false
|
||||
),
|
||||
},
|
||||
conversation: {
|
||||
id: '3051234567',
|
||||
avatarPath: undefined,
|
||||
|
@ -23,19 +46,24 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
name: 'Rick Sanchez',
|
||||
phoneNumber: '3051234567',
|
||||
profileName: 'Rick Sanchez',
|
||||
markedUnread: false,
|
||||
type: 'direct',
|
||||
lastUpdated: Date.now(),
|
||||
},
|
||||
callState: select(
|
||||
'callState',
|
||||
CallState,
|
||||
overrideProps.callState || CallState.Accepted
|
||||
),
|
||||
// We allow `any` here because these are fake and actually come from RingRTC, which we
|
||||
// can't import.
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
createCanvasVideoRenderer: () =>
|
||||
({
|
||||
setCanvas: noop,
|
||||
enable: noop,
|
||||
disable: noop,
|
||||
} as any),
|
||||
getGroupCallVideoFrameSource: noop as any,
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
hangUp: action('hang-up'),
|
||||
hasLocalAudio: boolean('hasLocalAudio', overrideProps.hasLocalAudio || false),
|
||||
hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false),
|
||||
hasRemoteVideo: boolean(
|
||||
'hasRemoteVideo',
|
||||
overrideProps.hasRemoteVideo || false
|
||||
),
|
||||
i18n,
|
||||
joinedAt: Date.now(),
|
||||
me: {
|
||||
|
|
|
@ -4,7 +4,10 @@
|
|||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { noop } from 'lodash';
|
||||
import classNames from 'classnames';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
import {
|
||||
DirectCallStateType,
|
||||
GroupCallStateType,
|
||||
HangUpType,
|
||||
SetLocalAudioType,
|
||||
SetLocalPreviewType,
|
||||
|
@ -14,25 +17,27 @@ import {
|
|||
import { Avatar } from './Avatar';
|
||||
import { CallingButton, CallingButtonType } from './CallingButton';
|
||||
import { CallBackgroundBlur } from './CallBackgroundBlur';
|
||||
import { CallState } from '../types/Calling';
|
||||
import {
|
||||
CallMode,
|
||||
CallState,
|
||||
GroupCallConnectionState,
|
||||
CanvasVideoRenderer,
|
||||
VideoFrameSource,
|
||||
} from '../types/Calling';
|
||||
import { ColorType } from '../types/Colors';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant';
|
||||
import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants';
|
||||
|
||||
export type PropsType = {
|
||||
conversation: {
|
||||
id: string;
|
||||
avatarPath?: string;
|
||||
color?: ColorType;
|
||||
title: string;
|
||||
name?: string;
|
||||
phoneNumber?: string;
|
||||
profileName?: string;
|
||||
};
|
||||
callState: CallState;
|
||||
call: DirectCallStateType | GroupCallStateType;
|
||||
conversation: ConversationType;
|
||||
createCanvasVideoRenderer: () => CanvasVideoRenderer;
|
||||
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
|
||||
hangUp: (_: HangUpType) => void;
|
||||
hasLocalAudio: boolean;
|
||||
hasLocalVideo: boolean;
|
||||
hasRemoteVideo: boolean;
|
||||
i18n: LocalizerType;
|
||||
joinedAt?: number;
|
||||
me: {
|
||||
|
@ -52,12 +57,13 @@ export type PropsType = {
|
|||
};
|
||||
|
||||
export const CallScreen: React.FC<PropsType> = ({
|
||||
callState,
|
||||
call,
|
||||
conversation,
|
||||
createCanvasVideoRenderer,
|
||||
getGroupCallVideoFrameSource,
|
||||
hangUp,
|
||||
hasLocalAudio,
|
||||
hasLocalVideo,
|
||||
hasRemoteVideo,
|
||||
i18n,
|
||||
joinedAt,
|
||||
me,
|
||||
|
@ -84,15 +90,11 @@ export const CallScreen: React.FC<PropsType> = ({
|
|||
const [showControls, setShowControls] = useState(true);
|
||||
|
||||
const localVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const remoteVideoRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalPreview({ element: localVideoRef });
|
||||
setRendererCanvas({ element: remoteVideoRef });
|
||||
|
||||
return () => {
|
||||
setLocalPreview({ element: undefined });
|
||||
setRendererCanvas({ element: undefined });
|
||||
};
|
||||
}, [setLocalPreview, setRendererCanvas]);
|
||||
|
||||
|
@ -142,14 +144,39 @@ export const CallScreen: React.FC<PropsType> = ({
|
|||
};
|
||||
}, [toggleAudio, toggleVideo]);
|
||||
|
||||
const isAudioOnly = !hasLocalVideo && !hasRemoteVideo;
|
||||
let hasRemoteVideo: boolean;
|
||||
let isConnected: boolean;
|
||||
let remoteParticipants: JSX.Element;
|
||||
|
||||
const controlsFadeClass = classNames({
|
||||
'module-ongoing-call__controls--fadeIn':
|
||||
(showControls || isAudioOnly) && callState !== CallState.Accepted,
|
||||
'module-ongoing-call__controls--fadeOut':
|
||||
!showControls && !isAudioOnly && callState === CallState.Accepted,
|
||||
});
|
||||
switch (call.callMode) {
|
||||
case CallMode.Direct:
|
||||
hasRemoteVideo = Boolean(call.hasRemoteVideo);
|
||||
isConnected = call.callState === CallState.Accepted;
|
||||
remoteParticipants = (
|
||||
<DirectCallRemoteParticipant
|
||||
conversation={conversation}
|
||||
hasRemoteVideo={hasRemoteVideo}
|
||||
i18n={i18n}
|
||||
setRendererCanvas={setRendererCanvas}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case CallMode.Group:
|
||||
hasRemoteVideo = call.remoteParticipants.some(
|
||||
remoteParticipant => remoteParticipant.hasRemoteVideo
|
||||
);
|
||||
isConnected = call.connectionState === GroupCallConnectionState.Connected;
|
||||
remoteParticipants = (
|
||||
<GroupCallRemoteParticipants
|
||||
remoteParticipants={call.remoteParticipants}
|
||||
createCanvasVideoRenderer={createCanvasVideoRenderer}
|
||||
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw missingCaseError(call);
|
||||
}
|
||||
|
||||
const videoButtonType = hasLocalVideo
|
||||
? CallingButtonType.VIDEO_ON
|
||||
|
@ -158,9 +185,23 @@ export const CallScreen: React.FC<PropsType> = ({
|
|||
? CallingButtonType.AUDIO_ON
|
||||
: CallingButtonType.AUDIO_OFF;
|
||||
|
||||
const isAudioOnly = !hasLocalVideo && !hasRemoteVideo;
|
||||
|
||||
const controlsFadeClass = classNames({
|
||||
'module-ongoing-call__controls--fadeIn':
|
||||
(showControls || isAudioOnly) && !isConnected,
|
||||
'module-ongoing-call__controls--fadeOut':
|
||||
!showControls && !isAudioOnly && isConnected,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="module-calling__container"
|
||||
className={classNames(
|
||||
'module-calling__container',
|
||||
`module-ongoing-call__container--${getCallModeClassSuffix(
|
||||
call.callMode
|
||||
)}`
|
||||
)}
|
||||
onMouseMove={() => {
|
||||
setShowControls(true);
|
||||
}}
|
||||
|
@ -176,7 +217,12 @@ export const CallScreen: React.FC<PropsType> = ({
|
|||
<div className="module-calling__header--header-name">
|
||||
{conversation.title}
|
||||
</div>
|
||||
{renderHeaderMessage(i18n, callState, acceptedDuration)}
|
||||
{call.callMode === CallMode.Direct &&
|
||||
renderHeaderMessage(
|
||||
i18n,
|
||||
call.callState || CallState.Prering,
|
||||
acceptedDuration
|
||||
)}
|
||||
<div className="module-calling-tools">
|
||||
<button
|
||||
type="button"
|
||||
|
@ -184,22 +230,18 @@ export const CallScreen: React.FC<PropsType> = ({
|
|||
className="module-calling-tools__button module-calling-button__settings"
|
||||
onClick={toggleSettings}
|
||||
/>
|
||||
{/* TODO: Group calls should also support the PiP. See DESKTOP-886. */}
|
||||
{call.callMode === CallMode.Direct && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={i18n('calling__pip')}
|
||||
className="module-calling-tools__button module-calling-button__pip"
|
||||
onClick={togglePip}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasRemoteVideo ? (
|
||||
<canvas
|
||||
className="module-ongoing-call__remote-video-enabled"
|
||||
ref={remoteVideoRef}
|
||||
/>
|
||||
) : (
|
||||
renderAvatar(i18n, conversation)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{remoteParticipants}
|
||||
<div className="module-ongoing-call__footer">
|
||||
{/* This layout-only element is not ideal.
|
||||
See the comment in _modules.css for more. */}
|
||||
|
@ -260,40 +302,17 @@ export const CallScreen: React.FC<PropsType> = ({
|
|||
);
|
||||
};
|
||||
|
||||
function renderAvatar(
|
||||
i18n: LocalizerType,
|
||||
{
|
||||
avatarPath,
|
||||
color,
|
||||
name,
|
||||
phoneNumber,
|
||||
profileName,
|
||||
title,
|
||||
}: {
|
||||
avatarPath?: string;
|
||||
color?: ColorType;
|
||||
title: string;
|
||||
name?: string;
|
||||
phoneNumber?: string;
|
||||
profileName?: string;
|
||||
function getCallModeClassSuffix(
|
||||
callMode: CallMode.Direct | CallMode.Group
|
||||
): string {
|
||||
switch (callMode) {
|
||||
case CallMode.Direct:
|
||||
return 'direct';
|
||||
case CallMode.Group:
|
||||
return 'group';
|
||||
default:
|
||||
throw missingCaseError(callMode);
|
||||
}
|
||||
): JSX.Element {
|
||||
return (
|
||||
<div className="module-ongoing-call__remote-video-disabled">
|
||||
<Avatar
|
||||
avatarPath={avatarPath}
|
||||
color={color || 'ultramarine'}
|
||||
noteToSelf={false}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
name={name}
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
title={title}
|
||||
size={112}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderHeaderMessage(
|
||||
|
|
77
ts/components/DirectCallRemoteParticipant.tsx
Normal file
77
ts/components/DirectCallRemoteParticipant.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { SetRendererCanvasType } from '../state/ducks/calling';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
import { ColorType } from '../types/Colors';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { Avatar } from './Avatar';
|
||||
|
||||
interface PropsType {
|
||||
conversation: ConversationType;
|
||||
hasRemoteVideo: boolean;
|
||||
i18n: LocalizerType;
|
||||
setRendererCanvas: (_: SetRendererCanvasType) => void;
|
||||
}
|
||||
|
||||
export const DirectCallRemoteParticipant: React.FC<PropsType> = ({
|
||||
conversation,
|
||||
hasRemoteVideo,
|
||||
i18n,
|
||||
setRendererCanvas,
|
||||
}) => {
|
||||
const remoteVideoRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRendererCanvas({ element: remoteVideoRef });
|
||||
return () => {
|
||||
setRendererCanvas({ element: undefined });
|
||||
};
|
||||
}, [setRendererCanvas]);
|
||||
|
||||
return hasRemoteVideo ? (
|
||||
<canvas
|
||||
className="module-ongoing-call__remote-video-enabled"
|
||||
ref={remoteVideoRef}
|
||||
/>
|
||||
) : (
|
||||
renderAvatar(i18n, conversation)
|
||||
);
|
||||
};
|
||||
|
||||
function renderAvatar(
|
||||
i18n: LocalizerType,
|
||||
{
|
||||
avatarPath,
|
||||
color,
|
||||
name,
|
||||
phoneNumber,
|
||||
profileName,
|
||||
title,
|
||||
}: {
|
||||
avatarPath?: string;
|
||||
color?: ColorType;
|
||||
title: string;
|
||||
name?: string;
|
||||
phoneNumber?: string;
|
||||
profileName?: string;
|
||||
}
|
||||
): JSX.Element {
|
||||
return (
|
||||
<div className="module-ongoing-call__remote-video-disabled">
|
||||
<Avatar
|
||||
avatarPath={avatarPath}
|
||||
color={color || 'ultramarine'}
|
||||
noteToSelf={false}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
name={name}
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
title={title}
|
||||
size={112}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
86
ts/components/GroupCallRemoteParticipant.tsx
Normal file
86
ts/components/GroupCallRemoteParticipant.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useRef, useEffect, CSSProperties } from 'react';
|
||||
import { noop } from 'lodash';
|
||||
import { CanvasVideoRenderer, VideoFrameSource } from '../types/Calling';
|
||||
import { CallBackgroundBlur } from './CallBackgroundBlur';
|
||||
|
||||
interface PropsType {
|
||||
createCanvasVideoRenderer: () => CanvasVideoRenderer;
|
||||
demuxId: number;
|
||||
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
|
||||
hasRemoteVideo: boolean;
|
||||
height: number;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const GroupCallRemoteParticipant: React.FC<PropsType> = ({
|
||||
createCanvasVideoRenderer,
|
||||
demuxId,
|
||||
getGroupCallVideoFrameSource,
|
||||
hasRemoteVideo,
|
||||
height,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
}) => {
|
||||
const remoteVideoRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const canvasVideoRendererRef = useRef(createCanvasVideoRenderer());
|
||||
|
||||
useEffect(() => {
|
||||
const canvasVideoRenderer = canvasVideoRendererRef.current;
|
||||
|
||||
if (hasRemoteVideo) {
|
||||
canvasVideoRenderer.setCanvas(remoteVideoRef);
|
||||
canvasVideoRenderer.enable(getGroupCallVideoFrameSource(demuxId));
|
||||
return () => {
|
||||
canvasVideoRenderer.disable();
|
||||
};
|
||||
}
|
||||
|
||||
canvasVideoRenderer.disable();
|
||||
return noop;
|
||||
}, [hasRemoteVideo, getGroupCallVideoFrameSource, demuxId]);
|
||||
|
||||
// If our `width` and `height` props don't match the canvas's aspect ratio, we want to
|
||||
// fill the container. This can happen when RingRTC gives us an inaccurate
|
||||
// `videoAspectRatio`.
|
||||
const canvasStyles: CSSProperties = {};
|
||||
const canvasEl = remoteVideoRef.current;
|
||||
if (hasRemoteVideo && canvasEl) {
|
||||
if (canvasEl.width > canvasEl.height) {
|
||||
canvasStyles.width = '100%';
|
||||
} else {
|
||||
canvasStyles.height = '100%';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="module-ongoing-call__group-call-remote-participant"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width,
|
||||
height,
|
||||
top,
|
||||
left,
|
||||
}}
|
||||
>
|
||||
{hasRemoteVideo ? (
|
||||
<canvas
|
||||
className="module-ongoing-call__group-call-remote-participant__remote-video"
|
||||
style={canvasStyles}
|
||||
ref={remoteVideoRef}
|
||||
/>
|
||||
) : (
|
||||
<CallBackgroundBlur>
|
||||
{/* TODO: Improve the styling here. See DESKTOP-894. */}
|
||||
<span />
|
||||
</CallBackgroundBlur>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
242
ts/components/GroupCallRemoteParticipants.tsx
Normal file
242
ts/components/GroupCallRemoteParticipants.tsx
Normal file
|
@ -0,0 +1,242 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import Measure from 'react-measure';
|
||||
import { takeWhile, chunk, maxBy, flatten } from 'lodash';
|
||||
import { CanvasVideoRenderer, VideoFrameSource } from '../types/Calling';
|
||||
import { GroupCallRemoteParticipantType } from '../state/ducks/calling';
|
||||
import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant';
|
||||
|
||||
const MIN_RENDERED_HEIGHT = 10;
|
||||
const PARTICIPANT_MARGIN = 10;
|
||||
|
||||
interface Dimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface GridArrangement {
|
||||
rows: Array<Array<GroupCallRemoteParticipantType>>;
|
||||
scalar: number;
|
||||
}
|
||||
|
||||
interface PropsType {
|
||||
createCanvasVideoRenderer: () => CanvasVideoRenderer;
|
||||
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
|
||||
remoteParticipants: ReadonlyArray<GroupCallRemoteParticipantType>;
|
||||
}
|
||||
|
||||
// This component lays out group call remote participants. It uses a custom layout
|
||||
// algorithm (in other words, nothing that the browser provides, like flexbox) in
|
||||
// order to animate the boxes as they move around, and to figure out the right fits.
|
||||
//
|
||||
// It's worth looking at the UI (or a design of it) to get an idea of how it works. Some
|
||||
// things to notice:
|
||||
//
|
||||
// * Participants are arranged in 0 or more rows.
|
||||
// * Each row is the same height, but each participant may have a different width.
|
||||
// * It's possible, on small screens with lots of participants, to have participants
|
||||
// removed from the grid. This is because participants have a minimum rendered height.
|
||||
//
|
||||
// There should be more specific comments throughout, but the high-level steps are:
|
||||
//
|
||||
// 1. Figure out the maximum number of possible rows that could fit on the screen; this is
|
||||
// `maxRowCount`.
|
||||
// 2. Figure out how many participants should be visible if all participants were rendered
|
||||
// at the minimum height. Most of the time, we'll be able to render all of them, but on
|
||||
// full calls with lots of participants, there could be some lost.
|
||||
// 3. For each possible number of rows (starting at 0 and ending at `maxRowCount`),
|
||||
// distribute participants across the rows at the minimum height. Then find the
|
||||
// "scalar": how much can we scale these boxes up while still fitting them on the
|
||||
// screen? The biggest scalar wins as the "best arrangement".
|
||||
// 4. Lay out this arrangement on the screen.
|
||||
export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
|
||||
createCanvasVideoRenderer,
|
||||
getGroupCallVideoFrameSource,
|
||||
remoteParticipants,
|
||||
}) => {
|
||||
const [containerDimensions, setContainerDimensions] = useState<Dimensions>({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
// 1. Figure out the maximum number of possible rows that could fit on the screen.
|
||||
//
|
||||
// We choose the smaller of these two options:
|
||||
//
|
||||
// - The number of participants, which means there'd be one participant per row.
|
||||
// - The number of possible rows in the container, assuming all participants were
|
||||
// rendered at minimum height. Doesn't rely on the number of participants—it's some
|
||||
// simple division.
|
||||
//
|
||||
// Could be 0 if (a) there are no participants (b) the container's height is small.
|
||||
const maxRowCount = Math.min(
|
||||
remoteParticipants.length,
|
||||
Math.floor(
|
||||
containerDimensions.height / (MIN_RENDERED_HEIGHT + PARTICIPANT_MARGIN)
|
||||
)
|
||||
);
|
||||
|
||||
// 2. Figure out how many participants should be visible if all participants were
|
||||
// rendered at the minimum height. Most of the time, we'll be able to render all of
|
||||
// them, but on full calls with lots of participants, there could be some lost.
|
||||
//
|
||||
// This is primarily memoized for clarity, not performance. We only need the result,
|
||||
// not any of the "intermediate" values.
|
||||
const visibleParticipants: Array<GroupCallRemoteParticipantType> = useMemo(() => {
|
||||
// Imagine that we laid out all of the rows end-to-end. That's the maximum total
|
||||
// width. So if there were 5 rows and the container was 100px wide, then we can't
|
||||
// possibly fit more than 500px of participants.
|
||||
const maxTotalWidth = maxRowCount * containerDimensions.width;
|
||||
|
||||
// We do the same thing for participants, "laying them out end-to-end" until they
|
||||
// exceed the maximum total width.
|
||||
let totalWidth = 0;
|
||||
return takeWhile(remoteParticipants, remoteParticipant => {
|
||||
totalWidth += remoteParticipant.videoAspectRatio * MIN_RENDERED_HEIGHT;
|
||||
return totalWidth < maxTotalWidth;
|
||||
});
|
||||
}, [maxRowCount, containerDimensions.width, remoteParticipants]);
|
||||
|
||||
// 3. For each possible number of rows (starting at 0 and ending at `maxRowCount`),
|
||||
// distribute participants across the rows at the minimum height. Then find the
|
||||
// "scalar": how much can we scale these boxes up while still fitting them on the
|
||||
// screen? The biggest scalar wins as the "best arrangement".
|
||||
const gridArrangement: GridArrangement = useMemo(() => {
|
||||
let bestArrangement: GridArrangement = {
|
||||
scalar: -1,
|
||||
rows: [],
|
||||
};
|
||||
|
||||
if (!visibleParticipants.length) {
|
||||
return bestArrangement;
|
||||
}
|
||||
|
||||
for (let rowCount = 1; rowCount <= maxRowCount; rowCount += 1) {
|
||||
// We do something pretty naïve here and chunk the visible participants into rows.
|
||||
// For example, if there were 12 visible participants and `rowCount === 3`, there
|
||||
// would be 4 participants per row.
|
||||
//
|
||||
// This naïve chunking is suboptimal in terms of absolute best fit, but it is much
|
||||
// faster and simpler than trying to do this perfectly. In practice, this works
|
||||
// fine in the UI from our testing.
|
||||
const numberOfParticipantsInRow = Math.ceil(
|
||||
visibleParticipants.length / rowCount
|
||||
);
|
||||
const rows = chunk(visibleParticipants, numberOfParticipantsInRow);
|
||||
|
||||
// We need to find the scalar for this arrangement. Imagine that we have these
|
||||
// participants at the minimum heights, and we want to scale everything up until
|
||||
// it's about to overflow.
|
||||
//
|
||||
// We don't want it to overflow horizontally or vertically, so we calculate a
|
||||
// "width scalar" and "height scalar" and choose the smaller of the two. (Choosing
|
||||
// the LARGER of the two could cause overflow.)
|
||||
const widestRow = maxBy(rows, totalRemoteParticipantWidthAtMinHeight);
|
||||
if (!widestRow) {
|
||||
window.log.error(
|
||||
'Unable to find the widest row, which should be impossible'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const widthScalar =
|
||||
(containerDimensions.width -
|
||||
(widestRow.length + 1) * PARTICIPANT_MARGIN) /
|
||||
totalRemoteParticipantWidthAtMinHeight(widestRow);
|
||||
const heightScalar =
|
||||
(containerDimensions.height - (rowCount + 1) * PARTICIPANT_MARGIN) /
|
||||
(rowCount * MIN_RENDERED_HEIGHT);
|
||||
const scalar = Math.min(widthScalar, heightScalar);
|
||||
|
||||
// If this scalar is the best one so far, we use that.
|
||||
if (scalar > bestArrangement.scalar) {
|
||||
bestArrangement = { scalar, rows };
|
||||
}
|
||||
}
|
||||
|
||||
return bestArrangement;
|
||||
}, [
|
||||
visibleParticipants,
|
||||
maxRowCount,
|
||||
containerDimensions.width,
|
||||
containerDimensions.height,
|
||||
]);
|
||||
|
||||
// 4. Lay out this arrangement on the screen.
|
||||
const gridParticipantHeight = gridArrangement.scalar * MIN_RENDERED_HEIGHT;
|
||||
const gridParticipantHeightWithMargin =
|
||||
gridParticipantHeight + PARTICIPANT_MARGIN;
|
||||
const gridTotalRowHeightWithMargin =
|
||||
gridParticipantHeightWithMargin * gridArrangement.rows.length;
|
||||
const gridTopOffset = Math.floor(
|
||||
(containerDimensions.height - gridTotalRowHeightWithMargin) / 2
|
||||
);
|
||||
|
||||
const rowElements: Array<Array<JSX.Element>> = gridArrangement.rows.map(
|
||||
(remoteParticipantsInRow, index) => {
|
||||
const top = gridTopOffset + index * gridParticipantHeightWithMargin;
|
||||
|
||||
const totalRowWidthWithoutMargins =
|
||||
totalRemoteParticipantWidthAtMinHeight(remoteParticipantsInRow) *
|
||||
gridArrangement.scalar;
|
||||
const totalRowWidth =
|
||||
totalRowWidthWithoutMargins +
|
||||
PARTICIPANT_MARGIN * (remoteParticipantsInRow.length - 1);
|
||||
const leftOffset = (containerDimensions.width - totalRowWidth) / 2;
|
||||
|
||||
let rowWidthSoFar = 0;
|
||||
return remoteParticipantsInRow.map(remoteParticipant => {
|
||||
const renderedWidth =
|
||||
remoteParticipant.videoAspectRatio * gridParticipantHeight;
|
||||
const left = rowWidthSoFar + leftOffset;
|
||||
|
||||
rowWidthSoFar += renderedWidth + PARTICIPANT_MARGIN;
|
||||
|
||||
return (
|
||||
<GroupCallRemoteParticipant
|
||||
key={remoteParticipant.demuxId}
|
||||
createCanvasVideoRenderer={createCanvasVideoRenderer}
|
||||
demuxId={remoteParticipant.demuxId}
|
||||
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
|
||||
hasRemoteVideo={remoteParticipant.hasRemoteVideo}
|
||||
height={gridParticipantHeight}
|
||||
left={left}
|
||||
top={top}
|
||||
width={renderedWidth}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
const remoteParticipantElements = flatten(rowElements);
|
||||
|
||||
return (
|
||||
<Measure
|
||||
bounds
|
||||
onResize={({ bounds }) => {
|
||||
if (!bounds) {
|
||||
window.log.error('We should be measuring the bounds');
|
||||
return;
|
||||
}
|
||||
setContainerDimensions(bounds);
|
||||
}}
|
||||
>
|
||||
{({ measureRef }) => (
|
||||
<div className="module-ongoing-call__grid" ref={measureRef}>
|
||||
{remoteParticipantElements}
|
||||
</div>
|
||||
)}
|
||||
</Measure>
|
||||
);
|
||||
};
|
||||
|
||||
function totalRemoteParticipantWidthAtMinHeight(
|
||||
remoteParticipants: ReadonlyArray<GroupCallRemoteParticipantType>
|
||||
): number {
|
||||
return remoteParticipants.reduce(
|
||||
(result, { videoAspectRatio }) =>
|
||||
result + videoAspectRatio * MIN_RENDERED_HEIGHT,
|
||||
0
|
||||
);
|
||||
}
|
107
ts/groups.ts
107
ts/groups.ts
|
@ -54,6 +54,7 @@ import {
|
|||
ProtoBinaryType,
|
||||
} from './textsecure.d';
|
||||
import { GroupCredentialsType } from './textsecure/WebAPI';
|
||||
import MessageSender from './textsecure/SendMessage';
|
||||
import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message';
|
||||
import { ConversationModel } from './models/conversations';
|
||||
|
||||
|
@ -360,6 +361,86 @@ export function deriveGroupFields(
|
|||
};
|
||||
}
|
||||
|
||||
async function makeRequestWithTemporalRetry<T>({
|
||||
logId,
|
||||
publicParams,
|
||||
secretParams,
|
||||
request,
|
||||
}: {
|
||||
logId: string;
|
||||
publicParams: string;
|
||||
secretParams: string;
|
||||
request: (sender: MessageSender, options: GroupCredentialsType) => Promise<T>;
|
||||
}): Promise<T> {
|
||||
const data = window.storage.get(GROUP_CREDENTIALS_KEY);
|
||||
if (!data) {
|
||||
throw new Error(
|
||||
`makeRequestWithTemporalRetry/${logId}: No group credentials!`
|
||||
);
|
||||
}
|
||||
const groupCredentials = getCredentialsForToday(data);
|
||||
|
||||
const sender = window.textsecure.messaging;
|
||||
if (!sender) {
|
||||
throw new Error(
|
||||
`makeRequestWithTemporalRetry/${logId}: textsecure.messaging is not available!`
|
||||
);
|
||||
}
|
||||
|
||||
const todayOptions = getGroupCredentials({
|
||||
authCredentialBase64: groupCredentials.today.credential,
|
||||
groupPublicParamsBase64: publicParams,
|
||||
groupSecretParamsBase64: secretParams,
|
||||
serverPublicParamsBase64: window.getServerPublicParams(),
|
||||
});
|
||||
|
||||
try {
|
||||
return await request(sender, todayOptions);
|
||||
} catch (todayError) {
|
||||
if (todayError.code === TEMPORAL_AUTH_REJECTED_CODE) {
|
||||
window.log.warn(
|
||||
`makeRequestWithTemporalRetry/${logId}: Trying again with tomorrow's credentials`
|
||||
);
|
||||
const tomorrowOptions = getGroupCredentials({
|
||||
authCredentialBase64: groupCredentials.tomorrow.credential,
|
||||
groupPublicParamsBase64: publicParams,
|
||||
groupSecretParamsBase64: secretParams,
|
||||
serverPublicParamsBase64: window.getServerPublicParams(),
|
||||
});
|
||||
|
||||
return request(sender, tomorrowOptions);
|
||||
}
|
||||
|
||||
throw todayError;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMembershipProof({
|
||||
publicParams,
|
||||
secretParams,
|
||||
}: {
|
||||
publicParams: string;
|
||||
secretParams: string;
|
||||
}): Promise<string | undefined> {
|
||||
// Ensure we have the credentials we need before attempting GroupsV2 operations
|
||||
await maybeFetchNewCredentials();
|
||||
|
||||
if (!publicParams) {
|
||||
throw new Error('fetchMembershipProof: group was missing publicParams!');
|
||||
}
|
||||
if (!secretParams) {
|
||||
throw new Error('fetchMembershipProof: group was missing secretParams!');
|
||||
}
|
||||
|
||||
const response = await makeRequestWithTemporalRetry({
|
||||
logId: 'fetchMembershipProof',
|
||||
publicParams,
|
||||
secretParams,
|
||||
request: (sender, options) => sender.getGroupMembershipToken(options),
|
||||
});
|
||||
return response.token;
|
||||
}
|
||||
|
||||
// Fetching and applying group changes
|
||||
|
||||
type MaybeUpdatePropsType = {
|
||||
|
@ -2603,3 +2684,29 @@ function decryptPendingMember(
|
|||
|
||||
return member;
|
||||
}
|
||||
|
||||
export function getMembershipList(
|
||||
conversationId: string
|
||||
): Array<{ uuid: string; uuidCiphertext: ArrayBuffer }> {
|
||||
const conversation = window.ConversationController.get(conversationId);
|
||||
if (!conversation) {
|
||||
throw new Error('getMembershipList: cannot find conversation');
|
||||
}
|
||||
|
||||
const secretParams = conversation.get('secretParams');
|
||||
if (!secretParams) {
|
||||
throw new Error('getMembershipList: no secretParams');
|
||||
}
|
||||
|
||||
const clientZkGroupCipher = getClientZkGroupCipher(secretParams);
|
||||
|
||||
return conversation.getMembers().map(member => {
|
||||
const uuid = member.get('uuid');
|
||||
if (!uuid) {
|
||||
throw new Error('getMembershipList: member has no UUID');
|
||||
}
|
||||
|
||||
const uuidCiphertext = encryptUuid(clientZkGroupCipher, uuid);
|
||||
return { uuid, uuidCiphertext };
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1156,6 +1156,7 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
draftText,
|
||||
firstName: this.get('profileName')!,
|
||||
groupVersion,
|
||||
groupId: this.get('groupId'),
|
||||
inboxPosition,
|
||||
isArchived: this.get('isArchived')!,
|
||||
isBlocked: this.isBlocked(),
|
||||
|
@ -1181,6 +1182,8 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
name: this.get('name')!,
|
||||
phoneNumber: this.getNumber()!,
|
||||
profileName: this.getProfileName()!,
|
||||
publicParams: this.get('publicParams'),
|
||||
secretParams: this.get('secretParams'),
|
||||
sharedGroupNames: this.get('sharedGroupNames')!,
|
||||
shouldShowDraft,
|
||||
timestamp,
|
||||
|
|
|
@ -12,19 +12,51 @@ import {
|
|||
CallSettings,
|
||||
CallState,
|
||||
CanvasVideoRenderer,
|
||||
ConnectionState,
|
||||
JoinState,
|
||||
HttpMethod,
|
||||
DeviceId,
|
||||
GroupCall,
|
||||
GroupMemberInfo,
|
||||
GumVideoCapturer,
|
||||
HangupMessage,
|
||||
HangupType,
|
||||
OfferType,
|
||||
OpaqueMessage,
|
||||
RingRTC,
|
||||
UserId,
|
||||
VideoFrameSource,
|
||||
} from 'ringrtc';
|
||||
import { uniqBy, noop } from 'lodash';
|
||||
|
||||
import { ActionsType as UxActionsType } from '../state/ducks/calling';
|
||||
import { getConversationCallMode } from '../state/ducks/conversations';
|
||||
import { EnvelopeClass } from '../textsecure.d';
|
||||
import { AudioDevice, MediaDeviceSettings } from '../types/Calling';
|
||||
import {
|
||||
CallMode,
|
||||
AudioDevice,
|
||||
MediaDeviceSettings,
|
||||
GroupCallConnectionState,
|
||||
GroupCallJoinState,
|
||||
} from '../types/Calling';
|
||||
import { ConversationModel } from '../models/conversations';
|
||||
import {
|
||||
base64ToArrayBuffer,
|
||||
uuidToArrayBuffer,
|
||||
arrayBufferToUuid,
|
||||
} from '../Crypto';
|
||||
import { getOwn } from '../util/getOwn';
|
||||
import { fetchMembershipProof, getMembershipList } from '../groups';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
|
||||
const RINGRTC_HTTP_METHOD_TO_OUR_HTTP_METHOD: Map<
|
||||
HttpMethod,
|
||||
'GET' | 'PUT' | 'POST'
|
||||
> = new Map([
|
||||
[HttpMethod.Get, 'GET'],
|
||||
[HttpMethod.Put, 'PUT'],
|
||||
[HttpMethod.Post, 'POST'],
|
||||
]);
|
||||
|
||||
export {
|
||||
CallState,
|
||||
|
@ -45,7 +77,7 @@ export class CallingClass {
|
|||
|
||||
private deviceReselectionTimer?: NodeJS.Timeout;
|
||||
|
||||
private callsByConversation: { [conversationId: string]: Call };
|
||||
private callsByConversation: { [conversationId: string]: Call | GroupCall };
|
||||
|
||||
constructor() {
|
||||
this.videoCapturer = new GumVideoCapturer(640, 480, 30);
|
||||
|
@ -65,6 +97,8 @@ export class CallingClass {
|
|||
this
|
||||
);
|
||||
RingRTC.handleLogMessage = this.handleLogMessage.bind(this);
|
||||
RingRTC.handleSendHttpRequest = this.handleSendHttpRequest.bind(this);
|
||||
RingRTC.handleSendCallMessage = this.handleSendCallMessage.bind(this);
|
||||
}
|
||||
|
||||
async startCallingLobby(
|
||||
|
@ -73,14 +107,37 @@ export class CallingClass {
|
|||
): Promise<void> {
|
||||
window.log.info('CallingClass.startCallingLobby()');
|
||||
|
||||
const conversationProps = conversation.format();
|
||||
const callMode = getConversationCallMode(conversationProps);
|
||||
switch (callMode) {
|
||||
case CallMode.None:
|
||||
window.log.error(
|
||||
'Conversation does not support calls, new call not allowed.'
|
||||
);
|
||||
return;
|
||||
case CallMode.Direct:
|
||||
if (!this.getRemoteUserIdFromConversation(conversation)) {
|
||||
window.log.error(
|
||||
'Missing remote user identifier, new call not allowed.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case CallMode.Group:
|
||||
break;
|
||||
default:
|
||||
throw missingCaseError(callMode);
|
||||
}
|
||||
|
||||
if (!this.uxActions) {
|
||||
window.log.error('Missing uxActions, new call not allowed.');
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteUserId = this.getRemoteUserIdFromConversation(conversation);
|
||||
if (!remoteUserId || !this.localDeviceId) {
|
||||
window.log.error('Missing identifier, new call not allowed.');
|
||||
if (!this.localDeviceId) {
|
||||
window.log.error(
|
||||
'Missing local device identifier, new call not allowed.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -90,22 +147,48 @@ export class CallingClass {
|
|||
return;
|
||||
}
|
||||
|
||||
window.log.info('CallingClass.startCallingLobby(): Getting call settings');
|
||||
window.log.info('CallingClass.startCallingLobby(): Starting lobby');
|
||||
|
||||
// Check state after awaiting to debounce call button.
|
||||
if (RingRTC.call && RingRTC.call.state !== CallState.Ended) {
|
||||
window.log.info('Call already in progress, new call not allowed.');
|
||||
switch (callMode) {
|
||||
case CallMode.Direct:
|
||||
this.uxActions.showCallLobby({
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: conversationProps.id,
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: isVideoCall,
|
||||
});
|
||||
break;
|
||||
case CallMode.Group: {
|
||||
if (
|
||||
!conversationProps.groupId ||
|
||||
!conversationProps.publicParams ||
|
||||
!conversationProps.secretParams
|
||||
) {
|
||||
window.log.error(
|
||||
'Conversation is missing required parameters. Cannot connect group call'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const conversationProps = conversation.format();
|
||||
|
||||
window.log.info('CallingClass.startCallingLobby(): Starting lobby');
|
||||
this.uxActions.showCallLobby({
|
||||
conversationId: conversationProps.id,
|
||||
isVideoCall,
|
||||
const groupCall = this.connectGroupCall(conversationProps.id, {
|
||||
groupId: conversationProps.groupId,
|
||||
publicParams: conversationProps.publicParams,
|
||||
secretParams: conversationProps.secretParams,
|
||||
});
|
||||
|
||||
groupCall.setOutgoingAudioMuted(false);
|
||||
groupCall.setOutgoingVideoMuted(!isVideoCall);
|
||||
|
||||
this.uxActions.showCallLobby({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: conversationProps.id,
|
||||
...this.formatGroupCallForRedux(groupCall),
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw missingCaseError(callMode);
|
||||
}
|
||||
|
||||
await this.startDeviceReselectionTimer();
|
||||
|
||||
if (isVideoCall) {
|
||||
|
@ -113,18 +196,22 @@ export class CallingClass {
|
|||
}
|
||||
}
|
||||
|
||||
stopCallingLobby(): void {
|
||||
stopCallingLobby(conversationId?: string): void {
|
||||
this.disableLocalCamera();
|
||||
this.stopDeviceReselectionTimer();
|
||||
this.lastMediaDeviceSettings = undefined;
|
||||
|
||||
if (conversationId) {
|
||||
this.getGroupCall(conversationId)?.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async startOutgoingCall(
|
||||
async startOutgoingDirectCall(
|
||||
conversationId: string,
|
||||
hasLocalAudio: boolean,
|
||||
hasLocalVideo: boolean
|
||||
): Promise<void> {
|
||||
window.log.info('CallingClass.startCallingLobby()');
|
||||
window.log.info('CallingClass.startOutgoingDirectCall()');
|
||||
|
||||
if (!this.uxActions) {
|
||||
throw new Error('Redux actions not available');
|
||||
|
@ -152,7 +239,9 @@ export class CallingClass {
|
|||
return;
|
||||
}
|
||||
|
||||
window.log.info('CallingClass.startOutgoingCall(): Getting call settings');
|
||||
window.log.info(
|
||||
'CallingClass.startOutgoingDirectCall(): Getting call settings'
|
||||
);
|
||||
|
||||
const callSettings = await this.getCallSettings(conversation);
|
||||
|
||||
|
@ -163,7 +252,9 @@ export class CallingClass {
|
|||
return;
|
||||
}
|
||||
|
||||
window.log.info('CallingClass.startOutgoingCall(): Starting in RingRTC');
|
||||
window.log.info(
|
||||
'CallingClass.startOutgoingDirectCall(): Starting in RingRTC'
|
||||
);
|
||||
|
||||
// We could make this faster by getting the call object
|
||||
// from the RingRTC before we lookup the ICE servers.
|
||||
|
@ -188,8 +279,255 @@ export class CallingClass {
|
|||
await this.startDeviceReselectionTimer();
|
||||
}
|
||||
|
||||
private getDirectCall(conversationId: string): undefined | Call {
|
||||
const call = getOwn(this.callsByConversation, conversationId);
|
||||
return call instanceof Call ? call : undefined;
|
||||
}
|
||||
|
||||
private getGroupCall(conversationId: string): undefined | GroupCall {
|
||||
const call = getOwn(this.callsByConversation, conversationId);
|
||||
return call instanceof GroupCall ? call : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a conversation's group call and connect it to Redux.
|
||||
*
|
||||
* Should only be called with group call-compatible conversations.
|
||||
*
|
||||
* Idempotent.
|
||||
*/
|
||||
connectGroupCall(
|
||||
conversationId: string,
|
||||
{
|
||||
groupId,
|
||||
publicParams,
|
||||
secretParams,
|
||||
}: {
|
||||
groupId: string;
|
||||
publicParams: string;
|
||||
secretParams: string;
|
||||
}
|
||||
): GroupCall {
|
||||
const existing = this.getGroupCall(conversationId);
|
||||
if (existing) {
|
||||
const isExistingCallNotConnected =
|
||||
existing.getLocalDeviceState().connectionState ===
|
||||
ConnectionState.NotConnected;
|
||||
if (isExistingCallNotConnected) {
|
||||
existing.connect();
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
const groupIdBuffer = base64ToArrayBuffer(groupId);
|
||||
|
||||
let isRequestingMembershipProof = false;
|
||||
|
||||
const outerGroupCall = RingRTC.getGroupCall(groupIdBuffer, {
|
||||
onLocalDeviceStateChanged: groupCall => {
|
||||
const localDeviceState = groupCall.getLocalDeviceState();
|
||||
|
||||
if (localDeviceState.connectionState === ConnectionState.NotConnected) {
|
||||
if (localDeviceState.videoMuted) {
|
||||
this.disableLocalCamera();
|
||||
}
|
||||
|
||||
delete this.callsByConversation[conversationId];
|
||||
} else {
|
||||
this.callsByConversation[conversationId] = groupCall;
|
||||
|
||||
if (localDeviceState.videoMuted) {
|
||||
this.disableLocalCamera();
|
||||
} else {
|
||||
this.enableLocalCamera();
|
||||
}
|
||||
}
|
||||
|
||||
this.syncGroupCallToRedux(conversationId, groupCall);
|
||||
},
|
||||
onRemoteDeviceStatesChanged: groupCall => {
|
||||
this.syncGroupCallToRedux(conversationId, groupCall);
|
||||
},
|
||||
onJoinedMembersChanged: groupCall => {
|
||||
this.syncGroupCallToRedux(conversationId, groupCall);
|
||||
},
|
||||
async requestMembershipProof(groupCall) {
|
||||
if (isRequestingMembershipProof) {
|
||||
return;
|
||||
}
|
||||
isRequestingMembershipProof = true;
|
||||
try {
|
||||
const proof = await fetchMembershipProof({
|
||||
publicParams,
|
||||
secretParams,
|
||||
});
|
||||
if (proof) {
|
||||
const proofArray = new TextEncoder().encode(proof);
|
||||
groupCall.setMembershipProof(proofArray.buffer);
|
||||
}
|
||||
} catch (err) {
|
||||
window.log.error('Failed to fetch membership proof', err);
|
||||
} finally {
|
||||
isRequestingMembershipProof = false;
|
||||
}
|
||||
},
|
||||
requestGroupMembers(groupCall) {
|
||||
groupCall.setGroupMembers(
|
||||
getMembershipList(conversationId).map(
|
||||
member =>
|
||||
new GroupMemberInfo(
|
||||
uuidToArrayBuffer(member.uuid),
|
||||
member.uuidCiphertext
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
onEnded: noop,
|
||||
});
|
||||
|
||||
if (!outerGroupCall) {
|
||||
// This should be very rare, likely due to RingRTC not being able to get a lock
|
||||
// or memory or something like that.
|
||||
throw new Error('Failed to get a group call instance; cannot start call');
|
||||
}
|
||||
|
||||
outerGroupCall.connect();
|
||||
|
||||
this.syncGroupCallToRedux(conversationId, outerGroupCall);
|
||||
|
||||
return outerGroupCall;
|
||||
}
|
||||
|
||||
public joinGroupCall(
|
||||
conversationId: string,
|
||||
hasLocalAudio: boolean,
|
||||
hasLocalVideo: boolean
|
||||
): void {
|
||||
const conversation = window.ConversationController.get(
|
||||
conversationId
|
||||
)?.format();
|
||||
if (!conversation) {
|
||||
window.log.error('Missing conversation; not joining group call');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!conversation.groupId ||
|
||||
!conversation.publicParams ||
|
||||
!conversation.secretParams
|
||||
) {
|
||||
window.log.error(
|
||||
'Conversation is missing required parameters. Cannot join group call'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const groupCall = this.connectGroupCall(conversationId, {
|
||||
groupId: conversation.groupId,
|
||||
publicParams: conversation.publicParams,
|
||||
secretParams: conversation.secretParams,
|
||||
});
|
||||
|
||||
groupCall.setOutgoingAudioMuted(!hasLocalAudio);
|
||||
groupCall.setOutgoingVideoMuted(!hasLocalVideo);
|
||||
this.videoCapturer.enableCaptureAndSend(groupCall);
|
||||
|
||||
groupCall.join();
|
||||
}
|
||||
|
||||
private getCallIdForConversation(conversationId: string): undefined | CallId {
|
||||
return this.callsByConversation[conversationId]?.callId;
|
||||
return this.getDirectCall(conversationId)?.callId;
|
||||
}
|
||||
|
||||
// See the comment in types/Calling.ts to explain why we have to do this conversion.
|
||||
private convertRingRtcConnectionState(
|
||||
connectionState: ConnectionState
|
||||
): GroupCallConnectionState {
|
||||
switch (connectionState) {
|
||||
case ConnectionState.NotConnected:
|
||||
return GroupCallConnectionState.NotConnected;
|
||||
case ConnectionState.Connecting:
|
||||
return GroupCallConnectionState.Connecting;
|
||||
case ConnectionState.Connected:
|
||||
return GroupCallConnectionState.Connected;
|
||||
case ConnectionState.Reconnecting:
|
||||
return GroupCallConnectionState.Reconnecting;
|
||||
default:
|
||||
throw missingCaseError(connectionState);
|
||||
}
|
||||
}
|
||||
|
||||
// See the comment in types/Calling.ts to explain why we have to do this conversion.
|
||||
private convertRingRtcJoinState(joinState: JoinState): GroupCallJoinState {
|
||||
switch (joinState) {
|
||||
case JoinState.NotJoined:
|
||||
return GroupCallJoinState.NotJoined;
|
||||
case JoinState.Joining:
|
||||
return GroupCallJoinState.Joining;
|
||||
case JoinState.Joined:
|
||||
return GroupCallJoinState.Joined;
|
||||
default:
|
||||
throw missingCaseError(joinState);
|
||||
}
|
||||
}
|
||||
|
||||
private formatGroupCallForRedux(groupCall: GroupCall) {
|
||||
const localDeviceState = groupCall.getLocalDeviceState();
|
||||
|
||||
// RingRTC doesn't ensure that the demux ID is unique. This can happen if someone
|
||||
// leaves the call and quickly rejoins; RingRTC will tell us that there are two
|
||||
// participants with the same demux ID in the call.
|
||||
const remoteDeviceStates = uniqBy(
|
||||
groupCall.getRemoteDeviceStates() || [],
|
||||
remoteDeviceState => remoteDeviceState.demuxId
|
||||
);
|
||||
|
||||
// It should be impossible to be disconnected and Joining or Joined. Just in case, we
|
||||
// try to handle that case.
|
||||
const joinState: GroupCallJoinState =
|
||||
localDeviceState.connectionState === ConnectionState.NotConnected
|
||||
? GroupCallJoinState.NotJoined
|
||||
: this.convertRingRtcJoinState(localDeviceState.joinState);
|
||||
|
||||
return {
|
||||
connectionState: this.convertRingRtcConnectionState(
|
||||
localDeviceState.connectionState
|
||||
),
|
||||
joinState,
|
||||
hasLocalAudio: !localDeviceState.audioMuted,
|
||||
hasLocalVideo: !localDeviceState.videoMuted,
|
||||
remoteParticipants: remoteDeviceStates.map(remoteDeviceState => ({
|
||||
demuxId: remoteDeviceState.demuxId,
|
||||
userId: arrayBufferToUuid(remoteDeviceState.userId) || '',
|
||||
hasRemoteAudio: !remoteDeviceState.audioMuted,
|
||||
hasRemoteVideo: !remoteDeviceState.videoMuted,
|
||||
// If RingRTC doesn't send us an aspect ratio, we make a guess.
|
||||
videoAspectRatio:
|
||||
remoteDeviceState.videoAspectRatio ||
|
||||
(remoteDeviceState.videoMuted ? 1 : 4 / 3),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
public getGroupCallVideoFrameSource(
|
||||
conversationId: string,
|
||||
demuxId: number
|
||||
): VideoFrameSource {
|
||||
const groupCall = this.getGroupCall(conversationId);
|
||||
if (!groupCall) {
|
||||
throw new Error('Could not find matching call');
|
||||
}
|
||||
return groupCall.getVideoSource(demuxId);
|
||||
}
|
||||
|
||||
private syncGroupCallToRedux(
|
||||
conversationId: string,
|
||||
groupCall: GroupCall
|
||||
): void {
|
||||
this.uxActions?.groupCallStateChange({
|
||||
conversationId,
|
||||
...this.formatGroupCallForRedux(groupCall),
|
||||
});
|
||||
}
|
||||
|
||||
async accept(conversationId: string, asVideoCall: boolean): Promise<void> {
|
||||
|
@ -228,33 +566,54 @@ export class CallingClass {
|
|||
hangup(conversationId: string): void {
|
||||
window.log.info('CallingClass.hangup()');
|
||||
|
||||
const callId = this.getCallIdForConversation(conversationId);
|
||||
if (!callId) {
|
||||
const call = getOwn(this.callsByConversation, conversationId);
|
||||
if (!call) {
|
||||
window.log.warn('Trying to hang up a non-existent call');
|
||||
return;
|
||||
}
|
||||
|
||||
RingRTC.hangup(callId);
|
||||
if (call instanceof Call) {
|
||||
RingRTC.hangup(call.callId);
|
||||
} else if (call instanceof GroupCall) {
|
||||
// This ensures that we turn off our devices.
|
||||
call.setOutgoingAudioMuted(true);
|
||||
call.setOutgoingVideoMuted(true);
|
||||
call.disconnect();
|
||||
} else {
|
||||
throw missingCaseError(call);
|
||||
}
|
||||
}
|
||||
|
||||
setOutgoingAudio(conversationId: string, enabled: boolean): void {
|
||||
const callId = this.getCallIdForConversation(conversationId);
|
||||
if (!callId) {
|
||||
const call = getOwn(this.callsByConversation, conversationId);
|
||||
if (!call) {
|
||||
window.log.warn('Trying to set outgoing audio for a non-existent call');
|
||||
return;
|
||||
}
|
||||
|
||||
RingRTC.setOutgoingAudio(callId, enabled);
|
||||
if (call instanceof Call) {
|
||||
RingRTC.setOutgoingAudio(call.callId, enabled);
|
||||
} else if (call instanceof GroupCall) {
|
||||
call.setOutgoingAudioMuted(!enabled);
|
||||
} else {
|
||||
throw missingCaseError(call);
|
||||
}
|
||||
}
|
||||
|
||||
setOutgoingVideo(conversationId: string, enabled: boolean): void {
|
||||
const callId = this.getCallIdForConversation(conversationId);
|
||||
if (!callId) {
|
||||
const call = getOwn(this.callsByConversation, conversationId);
|
||||
if (!call) {
|
||||
window.log.warn('Trying to set outgoing video for a non-existent call');
|
||||
return;
|
||||
}
|
||||
|
||||
RingRTC.setOutgoingVideo(callId, enabled);
|
||||
if (call instanceof Call) {
|
||||
RingRTC.setOutgoingVideo(call.callId, enabled);
|
||||
} else if (call instanceof GroupCall) {
|
||||
call.setOutgoingVideoMuted(!enabled);
|
||||
} else {
|
||||
throw missingCaseError(call);
|
||||
}
|
||||
}
|
||||
|
||||
private async startDeviceReselectionTimer(): Promise<void> {
|
||||
|
@ -554,13 +913,17 @@ export class CallingClass {
|
|||
return;
|
||||
}
|
||||
|
||||
const sourceUuid = envelope.sourceUuid
|
||||
? uuidToArrayBuffer(envelope.sourceUuid)
|
||||
: null;
|
||||
|
||||
const messageAgeSec = envelope.messageAgeSec ? envelope.messageAgeSec : 0;
|
||||
|
||||
window.log.info('CallingClass.handleCallingMessage(): Handling in RingRTC');
|
||||
|
||||
RingRTC.handleCallingMessage(
|
||||
remoteUserId,
|
||||
null,
|
||||
sourceUuid,
|
||||
remoteDeviceId,
|
||||
this.localDeviceId,
|
||||
messageAgeSec,
|
||||
|
@ -639,6 +1002,21 @@ export class CallingClass {
|
|||
return false;
|
||||
}
|
||||
|
||||
private async handleSendCallMessage(
|
||||
recipient: ArrayBuffer,
|
||||
data: ArrayBuffer
|
||||
): Promise<boolean> {
|
||||
const userId = arrayBufferToUuid(recipient);
|
||||
if (!userId) {
|
||||
window.log.error('handleSendCallMessage(): bad recipient UUID');
|
||||
return false;
|
||||
}
|
||||
const message = new CallingMessage();
|
||||
message.opaque = new OpaqueMessage();
|
||||
message.opaque.data = data;
|
||||
return this.handleOutgoingSignaling(userId, message);
|
||||
}
|
||||
|
||||
private async handleOutgoingSignaling(
|
||||
remoteUserId: UserId,
|
||||
message: CallingMessage
|
||||
|
@ -797,6 +1175,48 @@ export class CallingClass {
|
|||
}
|
||||
}
|
||||
|
||||
private async handleSendHttpRequest(
|
||||
requestId: number,
|
||||
url: string,
|
||||
method: HttpMethod,
|
||||
headers: { [name: string]: string },
|
||||
body: ArrayBuffer | undefined
|
||||
) {
|
||||
if (!window.textsecure.messaging) {
|
||||
RingRTC.httpRequestFailed(requestId, 'We are offline');
|
||||
return;
|
||||
}
|
||||
|
||||
const httpMethod = RINGRTC_HTTP_METHOD_TO_OUR_HTTP_METHOD.get(method);
|
||||
if (httpMethod === undefined) {
|
||||
RingRTC.httpRequestFailed(
|
||||
requestId,
|
||||
`Unknown method: ${JSON.stringify(method)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await window.textsecure.messaging.server.makeSfuRequest(
|
||||
url,
|
||||
httpMethod,
|
||||
headers,
|
||||
body
|
||||
);
|
||||
} catch (err) {
|
||||
window.log.error('handleSendHttpRequest: fetch failed with error', err);
|
||||
RingRTC.httpRequestFailed(requestId, String(err));
|
||||
return;
|
||||
}
|
||||
|
||||
RingRTC.receivedHttpResponse(
|
||||
requestId,
|
||||
result.response.status,
|
||||
result.data
|
||||
);
|
||||
}
|
||||
|
||||
private getRemoteUserIdFromConversation(
|
||||
conversation: ConversationModel
|
||||
): UserId | undefined | null {
|
||||
|
|
|
@ -5,14 +5,17 @@ import { ThunkAction } from 'redux-thunk';
|
|||
import { CallEndedReason } from 'ringrtc';
|
||||
import { has, omit } from 'lodash';
|
||||
import { getOwn } from '../../util/getOwn';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import { notify } from '../../services/notify';
|
||||
import { calling } from '../../services/calling';
|
||||
import { StateType as RootStateType } from '../reducer';
|
||||
import { getActiveCall } from '../selectors/calling';
|
||||
import {
|
||||
CallingDeviceType,
|
||||
CallMode,
|
||||
CallState,
|
||||
ChangeIODevicePayloadType,
|
||||
GroupCallConnectionState,
|
||||
GroupCallJoinState,
|
||||
MediaDeviceSettings,
|
||||
} from '../../types/Calling';
|
||||
import { callingTones } from '../../util/callingTones';
|
||||
|
@ -25,6 +28,7 @@ import {
|
|||
// State
|
||||
|
||||
export interface DirectCallStateType {
|
||||
callMode: CallMode.Direct;
|
||||
conversationId: string;
|
||||
callState?: CallState;
|
||||
callEndedReason?: CallEndedReason;
|
||||
|
@ -33,6 +37,22 @@ export interface DirectCallStateType {
|
|||
hasRemoteVideo?: boolean;
|
||||
}
|
||||
|
||||
export interface GroupCallRemoteParticipantType {
|
||||
demuxId: number;
|
||||
userId: string;
|
||||
hasRemoteAudio: boolean;
|
||||
hasRemoteVideo: boolean;
|
||||
videoAspectRatio: number;
|
||||
}
|
||||
|
||||
export interface GroupCallStateType {
|
||||
callMode: CallMode.Group;
|
||||
conversationId: string;
|
||||
connectionState: GroupCallConnectionState;
|
||||
joinState: GroupCallJoinState;
|
||||
remoteParticipants: Array<GroupCallRemoteParticipantType>;
|
||||
}
|
||||
|
||||
export interface ActiveCallStateType {
|
||||
conversationId: string;
|
||||
joinedAt?: number;
|
||||
|
@ -44,7 +64,9 @@ export interface ActiveCallStateType {
|
|||
}
|
||||
|
||||
export type CallingStateType = MediaDeviceSettings & {
|
||||
callsByConversation: { [conversationId: string]: DirectCallStateType };
|
||||
callsByConversation: {
|
||||
[conversationId: string]: DirectCallStateType | GroupCallStateType;
|
||||
};
|
||||
activeCallState?: ActiveCallStateType;
|
||||
};
|
||||
|
||||
|
@ -63,10 +85,23 @@ export type CallStateChangeType = {
|
|||
title: string;
|
||||
};
|
||||
|
||||
export type CancelCallType = {
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
export type DeclineCallType = {
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
export type GroupCallStateChangeType = {
|
||||
conversationId: string;
|
||||
connectionState: GroupCallConnectionState;
|
||||
joinState: GroupCallJoinState;
|
||||
hasLocalAudio: boolean;
|
||||
hasLocalVideo: boolean;
|
||||
remoteParticipants: Array<GroupCallRemoteParticipantType>;
|
||||
};
|
||||
|
||||
export type HangUpType = {
|
||||
conversationId: string;
|
||||
};
|
||||
|
@ -76,11 +111,15 @@ export type IncomingCallType = {
|
|||
isVideoCall: boolean;
|
||||
};
|
||||
|
||||
export type StartCallType = {
|
||||
interface StartDirectCallType {
|
||||
conversationId: string;
|
||||
hasLocalAudio: boolean;
|
||||
hasLocalVideo: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StartCallType extends StartDirectCallType {
|
||||
callMode: CallMode.Direct | CallMode.Group;
|
||||
}
|
||||
|
||||
export type RemoteVideoChangeType = {
|
||||
conversationId: string;
|
||||
|
@ -95,9 +134,21 @@ export type SetLocalVideoType = {
|
|||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type ShowCallLobbyType = {
|
||||
export type ShowCallLobbyType =
|
||||
| {
|
||||
callMode: CallMode.Direct;
|
||||
conversationId: string;
|
||||
isVideoCall: boolean;
|
||||
hasLocalAudio: boolean;
|
||||
hasLocalVideo: boolean;
|
||||
}
|
||||
| {
|
||||
callMode: CallMode.Group;
|
||||
conversationId: string;
|
||||
connectionState: GroupCallConnectionState;
|
||||
joinState: GroupCallJoinState;
|
||||
hasLocalAudio: boolean;
|
||||
hasLocalVideo: boolean;
|
||||
remoteParticipants: Array<GroupCallRemoteParticipantType>;
|
||||
};
|
||||
|
||||
export type SetLocalPreviewType = {
|
||||
|
@ -110,6 +161,13 @@ export type SetRendererCanvasType = {
|
|||
|
||||
// Helpers
|
||||
|
||||
export const getActiveCall = ({
|
||||
activeCallState,
|
||||
callsByConversation,
|
||||
}: CallingStateType): undefined | DirectCallStateType | GroupCallStateType =>
|
||||
activeCallState &&
|
||||
getOwn(callsByConversation, activeCallState.conversationId);
|
||||
|
||||
// Actions
|
||||
|
||||
const ACCEPT_CALL_PENDING = 'calling/ACCEPT_CALL_PENDING';
|
||||
|
@ -119,6 +177,7 @@ 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 GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE';
|
||||
const HANG_UP = 'calling/HANG_UP';
|
||||
const INCOMING_CALL = 'calling/INCOMING_CALL';
|
||||
const OUTGOING_CALL = 'calling/OUTGOING_CALL';
|
||||
|
@ -126,7 +185,7 @@ const REFRESH_IO_DEVICES = 'calling/REFRESH_IO_DEVICES';
|
|||
const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE';
|
||||
const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED';
|
||||
const SET_LOCAL_VIDEO_FULFILLED = 'calling/SET_LOCAL_VIDEO_FULFILLED';
|
||||
const START_CALL = 'calling/START_CALL';
|
||||
const START_DIRECT_CALL = 'calling/START_DIRECT_CALL';
|
||||
const TOGGLE_PARTICIPANTS = 'calling/TOGGLE_PARTICIPANTS';
|
||||
const TOGGLE_PIP = 'calling/TOGGLE_PIP';
|
||||
const TOGGLE_SETTINGS = 'calling/TOGGLE_SETTINGS';
|
||||
|
@ -165,6 +224,11 @@ type DeclineCallActionType = {
|
|||
payload: DeclineCallType;
|
||||
};
|
||||
|
||||
type GroupCallStateChangeActionType = {
|
||||
type: 'calling/GROUP_CALL_STATE_CHANGE';
|
||||
payload: GroupCallStateChangeType;
|
||||
};
|
||||
|
||||
type HangUpActionType = {
|
||||
type: 'calling/HANG_UP';
|
||||
payload: HangUpType;
|
||||
|
@ -177,7 +241,7 @@ type IncomingCallActionType = {
|
|||
|
||||
type OutgoingCallActionType = {
|
||||
type: 'calling/OUTGOING_CALL';
|
||||
payload: StartCallType;
|
||||
payload: StartDirectCallType;
|
||||
};
|
||||
|
||||
type RefreshIODevicesActionType = {
|
||||
|
@ -205,9 +269,9 @@ type ShowCallLobbyActionType = {
|
|||
payload: ShowCallLobbyType;
|
||||
};
|
||||
|
||||
type StartCallActionType = {
|
||||
type: 'calling/START_CALL';
|
||||
payload: StartCallType;
|
||||
type StartDirectCallActionType = {
|
||||
type: 'calling/START_DIRECT_CALL';
|
||||
payload: StartDirectCallType;
|
||||
};
|
||||
|
||||
type ToggleParticipantsActionType = {
|
||||
|
@ -230,6 +294,7 @@ export type CallingActionType =
|
|||
| ChangeIODeviceFulfilledActionType
|
||||
| CloseNeedPermissionScreenActionType
|
||||
| DeclineCallActionType
|
||||
| GroupCallStateChangeActionType
|
||||
| HangUpActionType
|
||||
| IncomingCallActionType
|
||||
| OutgoingCallActionType
|
||||
|
@ -238,7 +303,7 @@ export type CallingActionType =
|
|||
| SetLocalAudioActionType
|
||||
| SetLocalVideoFulfilledActionType
|
||||
| ShowCallLobbyActionType
|
||||
| StartCallActionType
|
||||
| StartDirectCallActionType
|
||||
| ToggleParticipantsActionType
|
||||
| TogglePipActionType
|
||||
| ToggleSettingsActionType;
|
||||
|
@ -346,8 +411,8 @@ function closeNeedPermissionScreen(): CloseNeedPermissionScreenActionType {
|
|||
};
|
||||
}
|
||||
|
||||
function cancelCall(): CancelCallActionType {
|
||||
window.Signal.Services.calling.stopCallingLobby();
|
||||
function cancelCall(payload: CancelCallType): CancelCallActionType {
|
||||
calling.stopCallingLobby(payload.conversationId);
|
||||
|
||||
return {
|
||||
type: CANCEL_CALL,
|
||||
|
@ -363,6 +428,15 @@ function declineCall(payload: DeclineCallType): DeclineCallActionType {
|
|||
};
|
||||
}
|
||||
|
||||
function groupCallStateChange(
|
||||
payload: GroupCallStateChangeType
|
||||
): GroupCallStateChangeActionType {
|
||||
return {
|
||||
type: GROUP_CALL_STATE_CHANGE,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function hangUp(payload: HangUpType): HangUpActionType {
|
||||
calling.hangup(payload.conversationId);
|
||||
|
||||
|
@ -381,7 +455,7 @@ function receiveIncomingCall(
|
|||
};
|
||||
}
|
||||
|
||||
function outgoingCall(payload: StartCallType): OutgoingCallActionType {
|
||||
function outgoingCall(payload: StartDirectCallType): OutgoingCallActionType {
|
||||
callingTones.playRingtone();
|
||||
|
||||
return {
|
||||
|
@ -428,11 +502,14 @@ function setLocalAudio(
|
|||
payload: SetLocalAudioType
|
||||
): ThunkAction<void, RootStateType, unknown, SetLocalAudioActionType> {
|
||||
return (dispatch, getState) => {
|
||||
const { conversationId } = getActiveCall(getState().calling) || {};
|
||||
if (conversationId) {
|
||||
calling.setOutgoingAudio(conversationId, payload.enabled);
|
||||
const activeCall = getActiveCall(getState().calling);
|
||||
if (!activeCall) {
|
||||
window.log.warn('Trying to set local audio when no call is active');
|
||||
return;
|
||||
}
|
||||
|
||||
calling.setOutgoingAudio(activeCall.conversationId, payload.enabled);
|
||||
|
||||
dispatch({
|
||||
type: SET_LOCAL_AUDIO_FULFILLED,
|
||||
payload,
|
||||
|
@ -444,12 +521,19 @@ function setLocalVideo(
|
|||
payload: SetLocalVideoType
|
||||
): ThunkAction<void, RootStateType, unknown, SetLocalVideoFulfilledActionType> {
|
||||
return async (dispatch, getState) => {
|
||||
const activeCall = getActiveCall(getState().calling);
|
||||
if (!activeCall) {
|
||||
window.log.warn('Trying to set local video when no call is active');
|
||||
return;
|
||||
}
|
||||
|
||||
let enabled: boolean;
|
||||
if (await requestCameraPermissions()) {
|
||||
const { conversationId, callState } =
|
||||
getActiveCall(getState().calling) || {};
|
||||
if (conversationId && callState) {
|
||||
calling.setOutgoingVideo(conversationId, payload.enabled);
|
||||
if (
|
||||
activeCall.callMode === CallMode.Group ||
|
||||
(activeCall.callMode === CallMode.Direct && activeCall.callState)
|
||||
) {
|
||||
calling.setOutgoingVideo(activeCall.conversationId, payload.enabled);
|
||||
} else if (payload.enabled) {
|
||||
calling.enableLocalCamera();
|
||||
} else {
|
||||
|
@ -477,16 +561,34 @@ function showCallLobby(payload: ShowCallLobbyType): CallLobbyActionType {
|
|||
};
|
||||
}
|
||||
|
||||
function startCall(payload: StartCallType): StartCallActionType {
|
||||
calling.startOutgoingCall(
|
||||
function startCall(
|
||||
payload: StartCallType
|
||||
): ThunkAction<void, RootStateType, unknown, StartDirectCallActionType> {
|
||||
return dispatch => {
|
||||
switch (payload.callMode) {
|
||||
case CallMode.Direct:
|
||||
calling.startOutgoingDirectCall(
|
||||
payload.conversationId,
|
||||
payload.hasLocalAudio,
|
||||
payload.hasLocalVideo
|
||||
);
|
||||
|
||||
return {
|
||||
type: START_CALL,
|
||||
dispatch({
|
||||
type: START_DIRECT_CALL,
|
||||
payload,
|
||||
});
|
||||
break;
|
||||
case CallMode.Group:
|
||||
calling.joinGroupCall(
|
||||
payload.conversationId,
|
||||
payload.hasLocalAudio,
|
||||
payload.hasLocalVideo
|
||||
);
|
||||
// The calling service should already be wired up to Redux so we don't need to
|
||||
// dispatch anything here.
|
||||
break;
|
||||
default:
|
||||
throw missingCaseError(payload.callMode);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -515,6 +617,7 @@ export const actions = {
|
|||
changeIODevice,
|
||||
closeNeedPermissionScreen,
|
||||
declineCall,
|
||||
groupCallStateChange,
|
||||
hangUp,
|
||||
receiveIncomingCall,
|
||||
outgoingCall,
|
||||
|
@ -568,20 +671,41 @@ export function reducer(
|
|||
const { callsByConversation } = state;
|
||||
|
||||
if (action.type === SHOW_CALL_LOBBY) {
|
||||
let call: DirectCallStateType | GroupCallStateType;
|
||||
switch (action.payload.callMode) {
|
||||
case CallMode.Direct:
|
||||
call = {
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: action.payload.conversationId,
|
||||
isIncoming: false,
|
||||
isVideoCall: action.payload.hasLocalVideo,
|
||||
};
|
||||
break;
|
||||
case CallMode.Group:
|
||||
// We expect to be in this state briefly. The Calling service should update the
|
||||
// call state shortly.
|
||||
call = {
|
||||
callMode: CallMode.Group,
|
||||
conversationId: action.payload.conversationId,
|
||||
connectionState: action.payload.connectionState,
|
||||
joinState: action.payload.joinState,
|
||||
remoteParticipants: action.payload.remoteParticipants,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
throw missingCaseError(action.payload);
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
callsByConversation: {
|
||||
...callsByConversation,
|
||||
[action.payload.conversationId]: {
|
||||
conversationId: action.payload.conversationId,
|
||||
isIncoming: false,
|
||||
isVideoCall: action.payload.isVideoCall,
|
||||
},
|
||||
[action.payload.conversationId]: call,
|
||||
},
|
||||
activeCallState: {
|
||||
conversationId: action.payload.conversationId,
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: action.payload.isVideoCall,
|
||||
hasLocalAudio: action.payload.hasLocalAudio,
|
||||
hasLocalVideo: action.payload.hasLocalVideo,
|
||||
participantsList: false,
|
||||
pip: false,
|
||||
settingsDialogOpen: false,
|
||||
|
@ -589,12 +713,13 @@ export function reducer(
|
|||
};
|
||||
}
|
||||
|
||||
if (action.type === START_CALL) {
|
||||
if (action.type === START_DIRECT_CALL) {
|
||||
return {
|
||||
...state,
|
||||
callsByConversation: {
|
||||
...callsByConversation,
|
||||
[action.payload.conversationId]: {
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: action.payload.conversationId,
|
||||
callState: CallState.Prering,
|
||||
isIncoming: false,
|
||||
|
@ -636,14 +761,19 @@ export function reducer(
|
|||
action.type === HANG_UP ||
|
||||
action.type === CLOSE_NEED_PERMISSION_SCREEN
|
||||
) {
|
||||
if (!state.activeCallState) {
|
||||
const activeCall = getActiveCall(state);
|
||||
if (!activeCall) {
|
||||
window.log.warn('No active call to remove');
|
||||
return state;
|
||||
}
|
||||
return removeConversationFromState(
|
||||
state,
|
||||
state.activeCallState.conversationId
|
||||
);
|
||||
switch (activeCall.callMode) {
|
||||
case CallMode.Direct:
|
||||
return removeConversationFromState(state, activeCall.conversationId);
|
||||
case CallMode.Group:
|
||||
return omit(state, 'activeCallState');
|
||||
default:
|
||||
throw missingCaseError(activeCall);
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === DECLINE_CALL) {
|
||||
|
@ -656,6 +786,7 @@ export function reducer(
|
|||
callsByConversation: {
|
||||
...callsByConversation,
|
||||
[action.payload.conversationId]: {
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: action.payload.conversationId,
|
||||
callState: CallState.Prering,
|
||||
isIncoming: true,
|
||||
|
@ -671,6 +802,7 @@ export function reducer(
|
|||
callsByConversation: {
|
||||
...callsByConversation,
|
||||
[action.payload.conversationId]: {
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: action.payload.conversationId,
|
||||
callState: CallState.Prering,
|
||||
isIncoming: false,
|
||||
|
@ -703,8 +835,8 @@ export function reducer(
|
|||
state.callsByConversation,
|
||||
action.payload.conversationId
|
||||
);
|
||||
if (!call) {
|
||||
window.log.warn('Cannot update state for non-existent call');
|
||||
if (call?.callMode !== CallMode.Direct) {
|
||||
window.log.warn('Cannot update state for a non-direct call');
|
||||
return state;
|
||||
}
|
||||
|
||||
|
@ -734,11 +866,55 @@ export function reducer(
|
|||
};
|
||||
}
|
||||
|
||||
if (action.type === GROUP_CALL_STATE_CHANGE) {
|
||||
const {
|
||||
conversationId,
|
||||
connectionState,
|
||||
joinState,
|
||||
hasLocalAudio,
|
||||
hasLocalVideo,
|
||||
remoteParticipants,
|
||||
} = action.payload;
|
||||
|
||||
if (connectionState === GroupCallConnectionState.NotConnected) {
|
||||
return {
|
||||
...state,
|
||||
callsByConversation: omit(callsByConversation, conversationId),
|
||||
activeCallState:
|
||||
state.activeCallState?.conversationId === conversationId
|
||||
? undefined
|
||||
: state.activeCallState,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
callsByConversation: {
|
||||
...callsByConversation,
|
||||
[conversationId]: {
|
||||
callMode: CallMode.Group,
|
||||
conversationId,
|
||||
connectionState,
|
||||
joinState,
|
||||
remoteParticipants,
|
||||
},
|
||||
},
|
||||
activeCallState:
|
||||
state.activeCallState?.conversationId === conversationId
|
||||
? {
|
||||
...state.activeCallState,
|
||||
hasLocalAudio,
|
||||
hasLocalVideo,
|
||||
}
|
||||
: state.activeCallState,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === REMOTE_VIDEO_CHANGE) {
|
||||
const { conversationId, hasVideo } = action.payload;
|
||||
const call = getOwn(state.callsByConversation, conversationId);
|
||||
if (!call) {
|
||||
window.log.warn('Cannot update remote video for a non-existent call');
|
||||
if (call?.callMode !== CallMode.Direct) {
|
||||
window.log.warn('Cannot update remote video for a non-direct call');
|
||||
return state;
|
||||
}
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ import { NoopActionType } from './noop';
|
|||
import { AttachmentType } from '../../types/Attachment';
|
||||
import { ColorType } from '../../types/Colors';
|
||||
import { BodyRangeType } from '../../types/Util';
|
||||
import { CallMode } from '../../types/Calling';
|
||||
|
||||
// State
|
||||
|
||||
|
@ -91,9 +92,12 @@ export type ConversationType = {
|
|||
|
||||
sharedGroupNames?: Array<string>;
|
||||
groupVersion?: 1 | 2;
|
||||
groupId?: string;
|
||||
isMissingMandatoryProfileSharing?: boolean;
|
||||
messageRequestsEnabled?: boolean;
|
||||
acceptedMessageRequest?: boolean;
|
||||
secretParams?: string;
|
||||
publicParams?: string;
|
||||
};
|
||||
export type ConversationLookupType = {
|
||||
[key: string]: ConversationType;
|
||||
|
@ -188,6 +192,31 @@ export type ConversationsStateType = {
|
|||
messagesByConversation: MessagesByConversationType;
|
||||
};
|
||||
|
||||
// Helpers
|
||||
|
||||
export const getConversationCallMode = (
|
||||
conversation: ConversationType
|
||||
): CallMode => {
|
||||
if (
|
||||
conversation.left ||
|
||||
conversation.isBlocked ||
|
||||
conversation.isMe ||
|
||||
!conversation.acceptedMessageRequest
|
||||
) {
|
||||
return CallMode.None;
|
||||
}
|
||||
|
||||
if (conversation.type === 'direct') {
|
||||
return CallMode.Direct;
|
||||
}
|
||||
|
||||
if (conversation.type === 'group' && conversation.groupVersion === 2) {
|
||||
return CallMode.Group;
|
||||
}
|
||||
|
||||
return CallMode.None;
|
||||
};
|
||||
|
||||
// Actions
|
||||
|
||||
type ConversationAddedActionType = {
|
||||
|
|
|
@ -3,11 +3,8 @@
|
|||
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { CallingStateType } from '../ducks/calling';
|
||||
import { CallState } from '../../types/Calling';
|
||||
import { getOwn } from '../../util/getOwn';
|
||||
|
||||
const getActiveCallState = (state: CallingStateType) => state.activeCallState;
|
||||
import { CallingStateType, DirectCallStateType } from '../ducks/calling';
|
||||
import { CallMode, CallState } from '../../types/Calling';
|
||||
|
||||
const getCallsByConversation = (state: CallingStateType) =>
|
||||
state.callsByConversation;
|
||||
|
@ -16,18 +13,14 @@ const getCallsByConversation = (state: CallingStateType) =>
|
|||
// UI are ready to handle this.
|
||||
export const getIncomingCall = createSelector(
|
||||
getCallsByConversation,
|
||||
callsByConversation =>
|
||||
Object.values(callsByConversation).find(
|
||||
call => call.isIncoming && call.callState === CallState.Ringing
|
||||
)
|
||||
(callsByConversation): undefined | DirectCallStateType => {
|
||||
const result = Object.values(callsByConversation).find(
|
||||
call =>
|
||||
call.callMode === CallMode.Direct &&
|
||||
call.isIncoming &&
|
||||
call.callState === CallState.Ringing
|
||||
);
|
||||
|
||||
export const getActiveCall = createSelector(
|
||||
getActiveCallState,
|
||||
getCallsByConversation,
|
||||
(activeCallState, callsByConversation) =>
|
||||
activeCallState &&
|
||||
getOwn(callsByConversation, activeCallState.conversationId)
|
||||
// TypeScript needs a little help to be sure that this is a direct call.
|
||||
return result?.callMode === CallMode.Direct ? result : undefined;
|
||||
}
|
||||
);
|
||||
|
||||
export const isCallActive = createSelector(getActiveCall, Boolean);
|
||||
|
|
|
@ -3,10 +3,13 @@
|
|||
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { CanvasVideoRenderer } from 'ringrtc';
|
||||
import { mapDispatchToProps } from '../actions';
|
||||
import { CallManager } from '../../components/CallManager';
|
||||
import { calling as callingService } from '../../services/calling';
|
||||
import { getMe, getConversationSelector } from '../selectors/conversations';
|
||||
import { getActiveCall, getIncomingCall } from '../selectors/calling';
|
||||
import { getActiveCall } from '../ducks/calling';
|
||||
import { getIncomingCall } from '../selectors/calling';
|
||||
import { StateType } from '../reducer';
|
||||
|
||||
import { getIntl } from '../selectors/user';
|
||||
|
@ -17,6 +20,12 @@ function renderDeviceSelection(): JSX.Element {
|
|||
return <SmartCallingDeviceSelection />;
|
||||
}
|
||||
|
||||
const createCanvasVideoRenderer = () => new CanvasVideoRenderer();
|
||||
|
||||
const getGroupCallVideoFrameSource = callingService.getGroupCallVideoFrameSource.bind(
|
||||
callingService
|
||||
);
|
||||
|
||||
const mapStateToActiveCallProp = (state: StateType) => {
|
||||
const { calling } = state;
|
||||
const { activeCallState } = calling;
|
||||
|
@ -69,6 +78,8 @@ const mapStateToIncomingCallProp = (state: StateType) => {
|
|||
const mapStateToProps = (state: StateType) => ({
|
||||
activeCall: mapStateToActiveCallProp(state),
|
||||
availableCameras: state.calling.availableCameras,
|
||||
createCanvasVideoRenderer,
|
||||
getGroupCallVideoFrameSource,
|
||||
i18n: getIntl(state),
|
||||
incomingCall: mapStateToIncomingCallProp(state),
|
||||
me: getMe(state),
|
||||
|
|
|
@ -6,7 +6,9 @@ import { pick } from 'lodash';
|
|||
import { ConversationHeader } from '../../components/conversation/ConversationHeader';
|
||||
import { getConversationSelector } from '../selectors/conversations';
|
||||
import { StateType } from '../reducer';
|
||||
import { isCallActive } from '../selectors/calling';
|
||||
import { CallMode } from '../../types/Calling';
|
||||
import { getConversationCallMode } from '../ducks/conversations';
|
||||
import { getActiveCall } from '../ducks/calling';
|
||||
import { getIntl } from '../selectors/user';
|
||||
|
||||
export interface OwnProps {
|
||||
|
@ -36,6 +38,11 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
|
|||
throw new Error('Could not find conversation');
|
||||
}
|
||||
|
||||
const conversationCallMode = getConversationCallMode(conversation);
|
||||
const conversationSupportsCalls =
|
||||
conversationCallMode === CallMode.Direct ||
|
||||
(conversationCallMode === CallMode.Group && window.GROUP_CALLING);
|
||||
|
||||
return {
|
||||
...pick(conversation, [
|
||||
'acceptedMessageRequest',
|
||||
|
@ -59,10 +66,7 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
|
|||
]),
|
||||
i18n: getIntl(state),
|
||||
showBackButton: state.conversations.selectedConversationPanelDepth > 0,
|
||||
showCallButtons:
|
||||
conversation.type === 'direct' &&
|
||||
!conversation.isMe &&
|
||||
!isCallActive(state.calling),
|
||||
showCallButtons: conversationSupportsCalls && !getActiveCall(state.calling),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -5,15 +5,27 @@ import { assert } from 'chai';
|
|||
import * as sinon from 'sinon';
|
||||
import { reducer as rootReducer } from '../../../state/reducer';
|
||||
import { noopAction } from '../../../state/ducks/noop';
|
||||
import { actions, getEmptyState, reducer } from '../../../state/ducks/calling';
|
||||
import {
|
||||
CallingStateType,
|
||||
actions,
|
||||
getActiveCall,
|
||||
getEmptyState,
|
||||
reducer,
|
||||
} from '../../../state/ducks/calling';
|
||||
import { calling as callingService } from '../../../services/calling';
|
||||
import { CallState } from '../../../types/Calling';
|
||||
import {
|
||||
CallMode,
|
||||
CallState,
|
||||
GroupCallConnectionState,
|
||||
GroupCallJoinState,
|
||||
} from '../../../types/Calling';
|
||||
|
||||
describe('calling duck', () => {
|
||||
const stateWithDirectCall = {
|
||||
const stateWithDirectCall: CallingStateType = {
|
||||
...getEmptyState(),
|
||||
callsByConversation: {
|
||||
'fake-direct-call-conversation-id': {
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
callState: CallState.Accepted,
|
||||
isIncoming: false,
|
||||
|
@ -23,7 +35,7 @@ describe('calling duck', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const stateWithActiveDirectCall = {
|
||||
const stateWithActiveDirectCall: CallingStateType = {
|
||||
...stateWithDirectCall,
|
||||
activeCallState: {
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
|
@ -35,10 +47,11 @@ describe('calling duck', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const stateWithIncomingDirectCall = {
|
||||
const stateWithIncomingDirectCall: CallingStateType = {
|
||||
...getEmptyState(),
|
||||
callsByConversation: {
|
||||
'fake-direct-call-conversation-id': {
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
callState: CallState.Ringing,
|
||||
isIncoming: true,
|
||||
|
@ -48,6 +61,39 @@ describe('calling duck', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const stateWithGroupCall: CallingStateType = {
|
||||
...getEmptyState(),
|
||||
callsByConversation: {
|
||||
'fake-group-call-conversation-id': {
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.NotJoined,
|
||||
remoteParticipants: [
|
||||
{
|
||||
demuxId: 123,
|
||||
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
|
||||
hasRemoteAudio: true,
|
||||
hasRemoteVideo: true,
|
||||
videoAspectRatio: 4 / 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const stateWithActiveGroupCall: CallingStateType = {
|
||||
...stateWithGroupCall,
|
||||
activeCallState: {
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: false,
|
||||
participantsList: false,
|
||||
pip: false,
|
||||
settingsDialogOpen: false,
|
||||
},
|
||||
};
|
||||
|
||||
const getEmptyRootState = () => rootReducer(undefined, noopAction());
|
||||
|
||||
beforeEach(function beforeEach() {
|
||||
|
@ -141,6 +187,271 @@ describe('calling duck', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('cancelCall', () => {
|
||||
const { cancelCall } = actions;
|
||||
|
||||
beforeEach(function beforeEach() {
|
||||
this.callingServiceStopCallingLobby = this.sandbox.stub(
|
||||
callingService,
|
||||
'stopCallingLobby'
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the calling lobby for that conversation', function test() {
|
||||
cancelCall({ conversationId: '123' });
|
||||
|
||||
sinon.assert.calledOnce(this.callingServiceStopCallingLobby);
|
||||
sinon.assert.calledWith(this.callingServiceStopCallingLobby, '123');
|
||||
});
|
||||
|
||||
it('completely removes an active direct call from the state', () => {
|
||||
const result = reducer(
|
||||
stateWithActiveDirectCall,
|
||||
cancelCall({ conversationId: 'fake-direct-call-conversation-id' })
|
||||
);
|
||||
|
||||
assert.notProperty(
|
||||
result.callsByConversation,
|
||||
'fake-direct-call-conversation-id'
|
||||
);
|
||||
assert.isUndefined(result.activeCallState);
|
||||
});
|
||||
|
||||
it('removes the active group call, but leaves it in the state', () => {
|
||||
const result = reducer(
|
||||
stateWithActiveGroupCall,
|
||||
cancelCall({ conversationId: 'fake-group-call-conversation-id' })
|
||||
);
|
||||
|
||||
assert.property(
|
||||
result.callsByConversation,
|
||||
'fake-group-call-conversation-id'
|
||||
);
|
||||
assert.isUndefined(result.activeCallState);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupCallStateChange', () => {
|
||||
const { groupCallStateChange } = actions;
|
||||
|
||||
it('ignores new calls that are not connected', () => {
|
||||
const result = reducer(
|
||||
getEmptyState(),
|
||||
groupCallStateChange({
|
||||
conversationId: 'abc123',
|
||||
connectionState: GroupCallConnectionState.NotConnected,
|
||||
joinState: GroupCallJoinState.NotJoined,
|
||||
hasLocalAudio: false,
|
||||
hasLocalVideo: false,
|
||||
remoteParticipants: [],
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(result, getEmptyState());
|
||||
});
|
||||
|
||||
it('removes the call from the map of conversations if the call is disconnected', () => {
|
||||
const result = reducer(
|
||||
stateWithGroupCall,
|
||||
groupCallStateChange({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.NotConnected,
|
||||
joinState: GroupCallJoinState.NotJoined,
|
||||
hasLocalAudio: false,
|
||||
hasLocalVideo: false,
|
||||
remoteParticipants: [],
|
||||
})
|
||||
);
|
||||
|
||||
assert.notProperty(
|
||||
result.callsByConversation,
|
||||
'fake-group-call-conversation-id'
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the active call if it is disconnected', () => {
|
||||
const result = reducer(
|
||||
stateWithActiveGroupCall,
|
||||
groupCallStateChange({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.NotConnected,
|
||||
joinState: GroupCallJoinState.NotJoined,
|
||||
hasLocalAudio: false,
|
||||
hasLocalVideo: false,
|
||||
remoteParticipants: [],
|
||||
})
|
||||
);
|
||||
|
||||
assert.isUndefined(result.activeCallState);
|
||||
});
|
||||
|
||||
it('saves a new call to the map of conversations', () => {
|
||||
const result = reducer(
|
||||
getEmptyState(),
|
||||
groupCallStateChange({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joining,
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: false,
|
||||
remoteParticipants: [
|
||||
{
|
||||
demuxId: 123,
|
||||
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
|
||||
hasRemoteAudio: true,
|
||||
hasRemoteVideo: true,
|
||||
videoAspectRatio: 4 / 3,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
result.callsByConversation['fake-group-call-conversation-id'],
|
||||
{
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joining,
|
||||
remoteParticipants: [
|
||||
{
|
||||
demuxId: 123,
|
||||
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
|
||||
hasRemoteAudio: true,
|
||||
hasRemoteVideo: true,
|
||||
videoAspectRatio: 4 / 3,
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('updates a call in the map of conversations', () => {
|
||||
const result = reducer(
|
||||
stateWithGroupCall,
|
||||
groupCallStateChange({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: false,
|
||||
remoteParticipants: [
|
||||
{
|
||||
demuxId: 456,
|
||||
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
|
||||
hasRemoteAudio: false,
|
||||
hasRemoteVideo: true,
|
||||
videoAspectRatio: 16 / 9,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
result.callsByConversation['fake-group-call-conversation-id'],
|
||||
{
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
remoteParticipants: [
|
||||
{
|
||||
demuxId: 456,
|
||||
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
|
||||
hasRemoteAudio: false,
|
||||
hasRemoteVideo: true,
|
||||
videoAspectRatio: 16 / 9,
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("if no call is active, doesn't touch the active call state", () => {
|
||||
const result = reducer(
|
||||
stateWithGroupCall,
|
||||
groupCallStateChange({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: false,
|
||||
remoteParticipants: [
|
||||
{
|
||||
demuxId: 456,
|
||||
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
|
||||
hasRemoteAudio: false,
|
||||
hasRemoteVideo: true,
|
||||
videoAspectRatio: 16 / 9,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
assert.isUndefined(result.activeCallState);
|
||||
});
|
||||
|
||||
it("if the call is not active, doesn't touch the active call state", () => {
|
||||
const result = reducer(
|
||||
stateWithActiveGroupCall,
|
||||
groupCallStateChange({
|
||||
conversationId: 'another-fake-conversation-id',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: true,
|
||||
remoteParticipants: [
|
||||
{
|
||||
demuxId: 456,
|
||||
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
|
||||
hasRemoteAudio: false,
|
||||
hasRemoteVideo: true,
|
||||
videoAspectRatio: 16 / 9,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(result.activeCallState, {
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: false,
|
||||
participantsList: false,
|
||||
pip: false,
|
||||
settingsDialogOpen: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('if the call is active, updates the active call state', () => {
|
||||
const result = reducer(
|
||||
stateWithActiveGroupCall,
|
||||
groupCallStateChange({
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: true,
|
||||
remoteParticipants: [
|
||||
{
|
||||
demuxId: 456,
|
||||
userId: 'aead696f-4373-4e51-b9c2-1bb4d1adccf0',
|
||||
hasRemoteAudio: false,
|
||||
hasRemoteVideo: true,
|
||||
videoAspectRatio: 16 / 9,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
result.activeCallState?.conversationId,
|
||||
'fake-group-call-conversation-id'
|
||||
);
|
||||
assert.isTrue(result.activeCallState?.hasLocalAudio);
|
||||
assert.isTrue(result.activeCallState?.hasLocalVideo);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLocalAudio', () => {
|
||||
const { setLocalAudio } = actions;
|
||||
|
||||
|
@ -154,7 +465,14 @@ describe('calling duck', () => {
|
|||
it('dispatches a SET_LOCAL_AUDIO_FULFILLED action', () => {
|
||||
const dispatch = sinon.spy();
|
||||
|
||||
setLocalAudio({ enabled: true })(dispatch, getEmptyRootState, null);
|
||||
setLocalAudio({ enabled: true })(
|
||||
dispatch,
|
||||
() => ({
|
||||
...getEmptyRootState(),
|
||||
calling: stateWithActiveDirectCall,
|
||||
}),
|
||||
null
|
||||
);
|
||||
|
||||
sinon.assert.calledOnce(dispatch);
|
||||
sinon.assert.calledWith(dispatch, {
|
||||
|
@ -201,7 +519,14 @@ describe('calling duck', () => {
|
|||
|
||||
it('updates the local audio state with SET_LOCAL_AUDIO_FULFILLED', () => {
|
||||
const dispatch = sinon.spy();
|
||||
setLocalAudio({ enabled: false })(dispatch, getEmptyRootState, null);
|
||||
setLocalAudio({ enabled: false })(
|
||||
dispatch,
|
||||
() => ({
|
||||
...getEmptyRootState(),
|
||||
calling: stateWithActiveDirectCall,
|
||||
}),
|
||||
null
|
||||
);
|
||||
const action = dispatch.getCall(0).args[0];
|
||||
|
||||
const result = reducer(stateWithActiveDirectCall, action);
|
||||
|
@ -217,12 +542,15 @@ describe('calling duck', () => {
|
|||
const result = reducer(
|
||||
getEmptyState(),
|
||||
showCallLobby({
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: 'fake-conversation-id',
|
||||
isVideoCall: true,
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: true,
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(result.callsByConversation['fake-conversation-id'], {
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: 'fake-conversation-id',
|
||||
isIncoming: false,
|
||||
isVideoCall: true,
|
||||
|
@ -242,39 +570,65 @@ describe('calling duck', () => {
|
|||
const { startCall } = actions;
|
||||
|
||||
beforeEach(function beforeEach() {
|
||||
this.callingStartOutgoingCall = this.sandbox.stub(
|
||||
this.callingStartOutgoingDirectCall = this.sandbox.stub(
|
||||
callingService,
|
||||
'startOutgoingCall'
|
||||
'startOutgoingDirectCall'
|
||||
);
|
||||
this.callingJoinGroupCall = this.sandbox.stub(
|
||||
callingService,
|
||||
'joinGroupCall'
|
||||
);
|
||||
});
|
||||
|
||||
it('asks the calling service to start an outgoing call', function test() {
|
||||
it('asks the calling service to start an outgoing direct call', function test() {
|
||||
const dispatch = sinon.spy();
|
||||
startCall({
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: '123',
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: false,
|
||||
});
|
||||
})(dispatch, getEmptyRootState, null);
|
||||
|
||||
sinon.assert.calledOnce(this.callingStartOutgoingCall);
|
||||
sinon.assert.calledOnce(this.callingStartOutgoingDirectCall);
|
||||
sinon.assert.calledWith(
|
||||
this.callingStartOutgoingCall,
|
||||
this.callingStartOutgoingDirectCall,
|
||||
'123',
|
||||
true,
|
||||
false
|
||||
);
|
||||
|
||||
sinon.assert.notCalled(this.callingJoinGroupCall);
|
||||
});
|
||||
|
||||
it('saves the call and makes it active', () => {
|
||||
const result = reducer(
|
||||
getEmptyState(),
|
||||
it('asks the calling service to join a group call', function test() {
|
||||
const dispatch = sinon.spy();
|
||||
startCall({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: '123',
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: false,
|
||||
})(dispatch, getEmptyRootState, null);
|
||||
|
||||
sinon.assert.calledOnce(this.callingJoinGroupCall);
|
||||
sinon.assert.calledWith(this.callingJoinGroupCall, '123', true, false);
|
||||
|
||||
sinon.assert.notCalled(this.callingStartOutgoingDirectCall);
|
||||
});
|
||||
|
||||
it('saves direct calls and makes them active', () => {
|
||||
const dispatch = sinon.spy();
|
||||
startCall({
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: 'fake-conversation-id',
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: false,
|
||||
})
|
||||
);
|
||||
})(dispatch, getEmptyRootState, null);
|
||||
const action = dispatch.getCall(0).args[0];
|
||||
|
||||
const result = reducer(getEmptyState(), action);
|
||||
|
||||
assert.deepEqual(result.callsByConversation['fake-conversation-id'], {
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: 'fake-conversation-id',
|
||||
callState: CallState.Prering,
|
||||
isIncoming: false,
|
||||
|
@ -289,6 +643,18 @@ describe('calling duck', () => {
|
|||
settingsDialogOpen: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't dispatch any actions for group calls", () => {
|
||||
const dispatch = sinon.spy();
|
||||
startCall({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: '123',
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: false,
|
||||
})(dispatch, getEmptyRootState, null);
|
||||
|
||||
sinon.assert.notCalled(dispatch);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleSettings', () => {
|
||||
|
@ -342,4 +708,27 @@ describe('calling duck', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('helpers', () => {
|
||||
describe('getActiveCall', () => {
|
||||
it('returns undefined if there are no calls', () => {
|
||||
assert.isUndefined(getActiveCall(getEmptyState()));
|
||||
});
|
||||
|
||||
it('returns undefined if there is no active call', () => {
|
||||
assert.isUndefined(getActiveCall(stateWithDirectCall));
|
||||
});
|
||||
|
||||
it('returns the active call', () => {
|
||||
assert.deepEqual(getActiveCall(stateWithActiveDirectCall), {
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
callState: CallState.Accepted,
|
||||
isIncoming: false,
|
||||
isVideoCall: false,
|
||||
hasRemoteVideo: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
118
ts/test-electron/state/ducks/conversations_test.ts
Normal file
118
ts/test-electron/state/ducks/conversations_test.ts
Normal file
|
@ -0,0 +1,118 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
import {
|
||||
getConversationCallMode,
|
||||
ConversationType,
|
||||
} from '../../../state/ducks/conversations';
|
||||
import { CallMode } from '../../../types/Calling';
|
||||
|
||||
describe('conversations duck', () => {
|
||||
describe('helpers', () => {
|
||||
describe('getConversationCallMode', () => {
|
||||
const fakeConversation: ConversationType = {
|
||||
id: 'id1',
|
||||
e164: '+18005551111',
|
||||
activeAt: Date.now(),
|
||||
name: 'No timestamp',
|
||||
timestamp: 0,
|
||||
inboxPosition: 0,
|
||||
phoneNumber: 'notused',
|
||||
isArchived: false,
|
||||
markedUnread: false,
|
||||
|
||||
type: 'direct',
|
||||
isMe: false,
|
||||
lastUpdated: Date.now(),
|
||||
title: 'No timestamp',
|
||||
unreadCount: 1,
|
||||
isSelected: false,
|
||||
typingContact: {
|
||||
name: 'Someone There',
|
||||
color: 'blue',
|
||||
phoneNumber: '+18005551111',
|
||||
},
|
||||
|
||||
acceptedMessageRequest: true,
|
||||
};
|
||||
|
||||
it("returns CallMode.None if you've left the conversation", () => {
|
||||
assert.strictEqual(
|
||||
getConversationCallMode({
|
||||
...fakeConversation,
|
||||
left: true,
|
||||
}),
|
||||
CallMode.None
|
||||
);
|
||||
});
|
||||
|
||||
it("returns CallMode.None if you've blocked the other person", () => {
|
||||
assert.strictEqual(
|
||||
getConversationCallMode({
|
||||
...fakeConversation,
|
||||
isBlocked: true,
|
||||
}),
|
||||
CallMode.None
|
||||
);
|
||||
});
|
||||
|
||||
it("returns CallMode.None if you haven't accepted message requests", () => {
|
||||
assert.strictEqual(
|
||||
getConversationCallMode({
|
||||
...fakeConversation,
|
||||
acceptedMessageRequest: false,
|
||||
}),
|
||||
CallMode.None
|
||||
);
|
||||
});
|
||||
|
||||
it('returns CallMode.None if the conversation is Note to Self', () => {
|
||||
assert.strictEqual(
|
||||
getConversationCallMode({
|
||||
...fakeConversation,
|
||||
isMe: true,
|
||||
}),
|
||||
CallMode.None
|
||||
);
|
||||
});
|
||||
|
||||
it('returns CallMode.None for v1 groups', () => {
|
||||
assert.strictEqual(
|
||||
getConversationCallMode({
|
||||
...fakeConversation,
|
||||
type: 'group',
|
||||
groupVersion: 1,
|
||||
}),
|
||||
CallMode.None
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
getConversationCallMode({
|
||||
...fakeConversation,
|
||||
type: 'group',
|
||||
}),
|
||||
CallMode.None
|
||||
);
|
||||
});
|
||||
|
||||
it('returns CallMode.Direct if the conversation is a normal direct conversation', () => {
|
||||
assert.strictEqual(
|
||||
getConversationCallMode(fakeConversation),
|
||||
CallMode.Direct
|
||||
);
|
||||
});
|
||||
|
||||
it('returns CallMode.Group if the conversation is a v2 group', () => {
|
||||
assert.strictEqual(
|
||||
getConversationCallMode({
|
||||
...fakeConversation,
|
||||
type: 'group',
|
||||
groupVersion: 2,
|
||||
}),
|
||||
CallMode.Group
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -2,19 +2,16 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { CallState } from '../../../types/Calling';
|
||||
import {
|
||||
getIncomingCall,
|
||||
getActiveCall,
|
||||
isCallActive,
|
||||
} from '../../../state/selectors/calling';
|
||||
import { getEmptyState } from '../../../state/ducks/calling';
|
||||
import { CallMode, CallState } from '../../../types/Calling';
|
||||
import { getIncomingCall } from '../../../state/selectors/calling';
|
||||
import { getEmptyState, CallingStateType } from '../../../state/ducks/calling';
|
||||
|
||||
describe('state/selectors/calling', () => {
|
||||
const stateWithDirectCall = {
|
||||
const stateWithDirectCall: CallingStateType = {
|
||||
...getEmptyState(),
|
||||
callsByConversation: {
|
||||
'fake-direct-call-conversation-id': {
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
callState: CallState.Accepted,
|
||||
isIncoming: false,
|
||||
|
@ -24,7 +21,7 @@ describe('state/selectors/calling', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const stateWithActiveDirectCall = {
|
||||
const stateWithActiveDirectCall: CallingStateType = {
|
||||
...stateWithDirectCall,
|
||||
activeCallState: {
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
|
@ -36,10 +33,11 @@ describe('state/selectors/calling', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const stateWithIncomingDirectCall = {
|
||||
const stateWithIncomingDirectCall: CallingStateType = {
|
||||
...getEmptyState(),
|
||||
callsByConversation: {
|
||||
'fake-direct-call-conversation-id': {
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
callState: CallState.Ringing,
|
||||
isIncoming: true,
|
||||
|
@ -61,6 +59,7 @@ describe('state/selectors/calling', () => {
|
|||
|
||||
it('returns the incoming call', () => {
|
||||
assert.deepEqual(getIncomingCall(stateWithIncomingDirectCall), {
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
callState: CallState.Ringing,
|
||||
isIncoming: true,
|
||||
|
@ -69,38 +68,4 @@ describe('state/selectors/calling', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActiveCall', () => {
|
||||
it('returns undefined if there are no calls', () => {
|
||||
assert.isUndefined(getActiveCall(getEmptyState()));
|
||||
});
|
||||
|
||||
it('returns undefined if there is no active call', () => {
|
||||
assert.isUndefined(getActiveCall(stateWithDirectCall));
|
||||
});
|
||||
|
||||
it('returns the active call', () => {
|
||||
assert.deepEqual(getActiveCall(stateWithActiveDirectCall), {
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
callState: CallState.Accepted,
|
||||
isIncoming: false,
|
||||
isVideoCall: false,
|
||||
hasRemoteVideo: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCallActive', () => {
|
||||
it('returns false if there are no calls', () => {
|
||||
assert.isFalse(isCallActive(getEmptyState()));
|
||||
});
|
||||
|
||||
it('returns false if there is no active call', () => {
|
||||
assert.isFalse(isCallActive(stateWithDirectCall));
|
||||
});
|
||||
|
||||
it('returns true if there is an active call', () => {
|
||||
assert.isTrue(isCallActive(stateWithActiveDirectCall));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
10
ts/textsecure.d.ts
vendored
10
ts/textsecure.d.ts
vendored
|
@ -174,6 +174,7 @@ type GroupsProtobufTypes = {
|
|||
GroupChange: typeof GroupChangeClass;
|
||||
GroupChanges: typeof GroupChangesClass;
|
||||
GroupAttributeBlob: typeof GroupAttributeBlobClass;
|
||||
GroupExternalCredential: typeof GroupExternalCredentialClass;
|
||||
};
|
||||
|
||||
type SignalServiceProtobufTypes = {
|
||||
|
@ -439,6 +440,15 @@ export declare namespace GroupChangesClass {
|
|||
}
|
||||
}
|
||||
|
||||
export declare class GroupExternalCredentialClass {
|
||||
static decode: (
|
||||
data: ArrayBuffer | ByteBufferClass,
|
||||
encoding?: string
|
||||
) => GroupExternalCredentialClass;
|
||||
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export declare class GroupAttributeBlobClass {
|
||||
static decode: (
|
||||
data: ArrayBuffer | ByteBufferClass,
|
||||
|
|
|
@ -34,6 +34,7 @@ import {
|
|||
DataMessageClass,
|
||||
GroupChangeClass,
|
||||
GroupClass,
|
||||
GroupExternalCredentialClass,
|
||||
StorageServiceCallOptionsType,
|
||||
StorageServiceCredentials,
|
||||
SyncMessageClass,
|
||||
|
@ -1841,4 +1842,10 @@ export default class MessageSender {
|
|||
): Promise<ArrayBuffer> {
|
||||
return this.server.modifyStorageRecords(data, options);
|
||||
}
|
||||
|
||||
async getGroupMembershipToken(
|
||||
options: GroupCredentialsType
|
||||
): Promise<GroupExternalCredentialClass> {
|
||||
return this.server.getGroupExternalCredential(options);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,6 +50,7 @@ import {
|
|||
GroupChangeClass,
|
||||
GroupChangesClass,
|
||||
GroupClass,
|
||||
GroupExternalCredentialClass,
|
||||
StorageServiceCallOptionsType,
|
||||
StorageServiceCredentials,
|
||||
} from '../textsecure.d';
|
||||
|
@ -625,6 +626,7 @@ const URL_CALLS = {
|
|||
getStickerPackUpload: 'v1/sticker/pack/form',
|
||||
groupLog: 'v1/groups/logs',
|
||||
groups: 'v1/groups',
|
||||
groupToken: 'v1/groups/token',
|
||||
keys: 'v2/keys',
|
||||
messages: 'v1/messages',
|
||||
profile: 'v1/profile',
|
||||
|
@ -726,6 +728,9 @@ export type WebAPIType = {
|
|||
startDay: number,
|
||||
endDay: number
|
||||
) => Promise<Array<GroupCredentialType>>;
|
||||
getGroupExternalCredential: (
|
||||
options: GroupCredentialsType
|
||||
) => Promise<GroupExternalCredentialClass>;
|
||||
getGroupLog: (
|
||||
startVersion: number,
|
||||
options: GroupCredentialsType
|
||||
|
@ -779,6 +784,12 @@ export type WebAPIType = {
|
|||
targetUrl: string,
|
||||
options?: ProxiedRequestOptionsType
|
||||
) => Promise<any>;
|
||||
makeSfuRequest: (
|
||||
targetUrl: string,
|
||||
type: HTTPCodeType,
|
||||
headers: HeaderListType,
|
||||
body: ArrayBuffer | undefined
|
||||
) => Promise<ArrayBufferWithDetailsType>;
|
||||
modifyGroup: (
|
||||
changes: GroupChangeClass.Actions,
|
||||
options: GroupCredentialsType
|
||||
|
@ -940,6 +951,7 @@ export function initialize({
|
|||
getGroup,
|
||||
getGroupAvatar,
|
||||
getGroupCredentials,
|
||||
getGroupExternalCredential,
|
||||
getGroupLog,
|
||||
getIceServers,
|
||||
getKeysForIdentifier,
|
||||
|
@ -959,6 +971,7 @@ export function initialize({
|
|||
fetchLinkPreviewMetadata,
|
||||
fetchLinkPreviewImage,
|
||||
makeProxiedRequest,
|
||||
makeSfuRequest,
|
||||
modifyGroup,
|
||||
modifyStorageRecords,
|
||||
putAttachment,
|
||||
|
@ -1833,6 +1846,24 @@ export function initialize({
|
|||
};
|
||||
}
|
||||
|
||||
async function makeSfuRequest(
|
||||
targetUrl: string,
|
||||
type: HTTPCodeType,
|
||||
headers: HeaderListType,
|
||||
body: ArrayBuffer | undefined
|
||||
): Promise<ArrayBufferWithDetailsType> {
|
||||
return _outerAjax(targetUrl, {
|
||||
certificateAuthority,
|
||||
data: body,
|
||||
headers,
|
||||
proxyUrl,
|
||||
responseType: 'arraybufferwithdetails',
|
||||
timeout: 0,
|
||||
type,
|
||||
version,
|
||||
});
|
||||
}
|
||||
|
||||
// Groups
|
||||
|
||||
function generateGroupAuth(
|
||||
|
@ -1860,6 +1891,28 @@ export function initialize({
|
|||
return response.credentials;
|
||||
}
|
||||
|
||||
async function getGroupExternalCredential(
|
||||
options: GroupCredentialsType
|
||||
): Promise<GroupExternalCredentialClass> {
|
||||
const basicAuth = generateGroupAuth(
|
||||
options.groupPublicParamsHex,
|
||||
options.authCredentialPresentationHex
|
||||
);
|
||||
|
||||
const response: ArrayBuffer = await _ajax({
|
||||
basicAuth,
|
||||
call: 'groupToken',
|
||||
httpType: 'GET',
|
||||
contentType: 'application/x-protobuf',
|
||||
responseType: 'arraybuffer',
|
||||
host: storageUrl,
|
||||
});
|
||||
|
||||
return window.textsecure.protobuf.GroupExternalCredential.decode(
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
function verifyAttributes(attributes: AvatarUploadAttributesClass) {
|
||||
const {
|
||||
key,
|
||||
|
|
|
@ -1,6 +1,16 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
export enum CallMode {
|
||||
None,
|
||||
Direct,
|
||||
Group,
|
||||
}
|
||||
|
||||
// Ideally, we would import many of these directly from RingRTC. But because Storybook
|
||||
// cannot import RingRTC (as it runs in the browser), we have these copies. That also
|
||||
// means we have to convert the "real" enum to our enum in some cases.
|
||||
|
||||
// Must be kept in sync with RingRTC.CallState
|
||||
export enum CallState {
|
||||
Prering = 'init',
|
||||
|
@ -31,6 +41,36 @@ export enum CallEndedReason {
|
|||
CallerIsNotMultiring = 'CallerIsNotMultiring',
|
||||
}
|
||||
|
||||
// Must be kept in sync with RingRTC's ConnectionState
|
||||
export enum GroupCallConnectionState {
|
||||
NotConnected = 0,
|
||||
Connecting = 1,
|
||||
Connected = 2,
|
||||
Reconnecting = 3,
|
||||
}
|
||||
|
||||
// Must be kept in sync with RingRTC's JoinState
|
||||
export enum GroupCallJoinState {
|
||||
NotJoined = 0,
|
||||
Joining = 1,
|
||||
Joined = 2,
|
||||
}
|
||||
|
||||
// Should match RingRTC's CanvasVideoRenderer
|
||||
interface Ref<T> {
|
||||
readonly current: T | null;
|
||||
}
|
||||
export interface CanvasVideoRenderer {
|
||||
setCanvas(canvas: Ref<HTMLCanvasElement> | undefined): void;
|
||||
enable(source: VideoFrameSource): void;
|
||||
disable(): void;
|
||||
}
|
||||
|
||||
// Should match RingRTC's VideoFrameSource
|
||||
export interface VideoFrameSource {
|
||||
receiveVideoFrame(buffer: ArrayBuffer): [number, number] | undefined;
|
||||
}
|
||||
|
||||
// Must be kept in sync with RingRTC.AudioDevice
|
||||
export interface AudioDevice {
|
||||
// Device name.
|
||||
|
|
|
@ -14391,20 +14391,11 @@
|
|||
"rule": "React-useRef",
|
||||
"path": "ts/components/CallScreen.js",
|
||||
"line": " const localVideoRef = react_1.useRef(null);",
|
||||
"lineNumber": 35,
|
||||
"lineNumber": 38,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2020-10-26T21:35:52.858Z",
|
||||
"reasonDetail": "Used to get the local video element for rendering."
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/CallScreen.js",
|
||||
"line": " const remoteVideoRef = react_1.useRef(null);",
|
||||
"lineNumber": 36,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2020-10-26T21:35:52.858Z",
|
||||
"reasonDetail": "Used to get the remote video element for rendering."
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/CallingLobby.js",
|
||||
|
@ -14594,6 +14585,42 @@
|
|||
"updated": "2020-10-26T23:56:13.482Z",
|
||||
"reasonDetail": "Doesn't refer to a DOM element."
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/DirectCallRemoteParticipant.js",
|
||||
"line": " const remoteVideoRef = react_1.useRef(null);",
|
||||
"lineNumber": 15,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2020-11-11T21:56:04.179Z",
|
||||
"reasonDetail": "Needed to render the remote video element."
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/GroupCallRemoteParticipant.js",
|
||||
"line": " const remoteVideoRef = react_1.useRef(null);",
|
||||
"lineNumber": 16,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2020-11-11T21:56:04.179Z",
|
||||
"reasonDetail": "Needed to render the remote video element."
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/GroupCallRemoteParticipant.js",
|
||||
"line": " const canvasVideoRendererRef = react_1.useRef(createCanvasVideoRenderer());",
|
||||
"lineNumber": 17,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2020-11-11T21:56:04.179Z",
|
||||
"reasonDetail": "Doesn't touch the DOM."
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/GroupCallRemoteParticipant.tsx",
|
||||
"line": " const canvasVideoRendererRef = useRef(createCanvasVideoRenderer());",
|
||||
"lineNumber": 31,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2020-11-11T21:56:04.179Z",
|
||||
"reasonDetail": "Doesn't touch the DOM."
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-$(",
|
||||
"path": "ts/components/Intl.js",
|
||||
|
@ -15164,7 +15191,7 @@
|
|||
"rule": "jQuery-wrap(",
|
||||
"path": "ts/textsecure/WebAPI.js",
|
||||
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(quote, 'binary', window.dcodeIO.ByteBuffer.LITTLE_ENDIAN);",
|
||||
"lineNumber": 1233,
|
||||
"lineNumber": 1260,
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2020-09-08T23:07:22.682Z"
|
||||
},
|
||||
|
@ -15172,7 +15199,7 @@
|
|||
"rule": "jQuery-wrap(",
|
||||
"path": "ts/textsecure/WebAPI.ts",
|
||||
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(",
|
||||
"lineNumber": 2105,
|
||||
"lineNumber": 2158,
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2020-09-08T23:07:22.682Z"
|
||||
}
|
||||
|
|
3
ts/window.d.ts
vendored
3
ts/window.d.ts
vendored
|
@ -478,6 +478,9 @@ declare global {
|
|||
hasSignalAccount: (number: string) => boolean;
|
||||
getServerTrustRoot: () => WhatIsThis;
|
||||
readyForUpdates: () => void;
|
||||
|
||||
// Flags
|
||||
GROUP_CALLING: boolean;
|
||||
}
|
||||
|
||||
interface Error {
|
||||
|
|
Loading…
Add table
Reference in a new issue