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
|
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
|
--- a/node_modules/node-fetch/lib/index.es.js
|
||||||
+++ b/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) {
|
@@ -1231,6 +1231,9 @@ class Request {
|
||||||
// build request object
|
this.compress = init.compress !== undefined ? init.compress : input.compress !== undefined ? input.compress : true;
|
||||||
const request = new Request(url, opts);
|
this.counter = init.counter || input.counter || 0;
|
||||||
const options = getNodeRequestOptions(request);
|
this.agent = init.agent || input.agent;
|
||||||
+ if (opts && opts.ca) {
|
+
|
||||||
+ options.ca = opts.ca;
|
+ // Custom Signal Desktop option
|
||||||
+ }
|
+ this.ca = init.ca || input.ca;
|
||||||
|
}
|
||||||
|
|
||||||
const send = (options.protocol === 'https:' ? https : http).request;
|
get method() {
|
||||||
const signal = request.signal;
|
@@ -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
|
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
|
--- a/node_modules/node-fetch/lib/index.js
|
||||||
+++ b/node_modules/node-fetch/lib/index.js
|
+++ b/node_modules/node-fetch/lib/index.js
|
||||||
@@ -1408,6 +1408,9 @@ function fetch(url, opts) {
|
@@ -1235,6 +1235,9 @@ class Request {
|
||||||
// build request object
|
this.compress = init.compress !== undefined ? init.compress : input.compress !== undefined ? input.compress : true;
|
||||||
const request = new Request(url, opts);
|
this.counter = init.counter || input.counter || 0;
|
||||||
const options = getNodeRequestOptions(request);
|
this.agent = init.agent || input.agent;
|
||||||
+ if (opts && opts.ca) {
|
+
|
||||||
+ options.ca = opts.ca;
|
+ // Custom Signal Desktop option
|
||||||
+ }
|
+ this.ca = init.ca || input.ca;
|
||||||
|
}
|
||||||
|
|
||||||
const send = (options.protocol === 'https:' ? https : http).request;
|
get method() {
|
||||||
const signal = request.signal;
|
@@ -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
|
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
|
--- a/node_modules/node-fetch/lib/index.mjs
|
||||||
+++ b/node_modules/node-fetch/lib/index.mjs
|
+++ b/node_modules/node-fetch/lib/index.mjs
|
||||||
@@ -1402,6 +1402,9 @@ function fetch(url, opts) {
|
@@ -1229,6 +1229,9 @@ class Request {
|
||||||
// build request object
|
this.compress = init.compress !== undefined ? init.compress : input.compress !== undefined ? input.compress : true;
|
||||||
const request = new Request(url, opts);
|
this.counter = init.counter || input.counter || 0;
|
||||||
const options = getNodeRequestOptions(request);
|
this.agent = init.agent || input.agent;
|
||||||
+ if (opts && opts.ca) {
|
+
|
||||||
+ options.ca = opts.ca;
|
+ // Custom Signal Desktop option
|
||||||
+ }
|
+ this.ca = init.ca || input.ca;
|
||||||
|
}
|
||||||
|
|
||||||
const send = (options.protocol === 'https:' ? https : http).request;
|
get method() {
|
||||||
const signal = request.signal;
|
@@ -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 { app } = remote;
|
||||||
const { nativeTheme } = remote.require('electron');
|
const { nativeTheme } = remote.require('electron');
|
||||||
|
|
||||||
|
window.GROUP_CALLING = true;
|
||||||
|
|
||||||
window.PROTO_ROOT = 'protos';
|
window.PROTO_ROOT = 'protos';
|
||||||
const config = require('url').parse(window.location.toString(), true).query;
|
const config = require('url').parse(window.location.toString(), true).query;
|
||||||
|
|
||||||
|
@ -564,6 +566,7 @@ try {
|
||||||
/* eslint-disable global-require, import/no-extraneous-dependencies */
|
/* eslint-disable global-require, import/no-extraneous-dependencies */
|
||||||
require('./ts/test-electron/models/messages_test');
|
require('./ts/test-electron/models/messages_test');
|
||||||
require('./ts/test-electron/linkPreviews/linkPreviewFetch_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/ducks/calling_test');
|
||||||
require('./ts/test-electron/state/selectors/calling_test');
|
require('./ts/test-electron/state/selectors/calling_test');
|
||||||
|
|
||||||
|
|
|
@ -151,3 +151,7 @@ message GroupAttributeBlob {
|
||||||
uint32 disappearingMessagesDuration = 3;
|
uint32 disappearingMessagesDuration = 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message GroupExternalCredential {
|
||||||
|
string token = 1;
|
||||||
|
}
|
||||||
|
|
|
@ -87,6 +87,10 @@ message CallingMessage {
|
||||||
optional uint32 deviceId = 3;
|
optional uint32 deviceId = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message Opaque {
|
||||||
|
optional bytes data = 1;
|
||||||
|
}
|
||||||
|
|
||||||
optional Offer offer = 1;
|
optional Offer offer = 1;
|
||||||
optional Answer answer = 2;
|
optional Answer answer = 2;
|
||||||
repeated IceCandidate iceCandidates = 3;
|
repeated IceCandidate iceCandidates = 3;
|
||||||
|
@ -95,6 +99,7 @@ message CallingMessage {
|
||||||
optional Hangup hangup = 7;
|
optional Hangup hangup = 7;
|
||||||
optional bool supportsMultiRing = 8;
|
optional bool supportsMultiRing = 8;
|
||||||
optional uint32 destinationDeviceId = 9;
|
optional uint32 destinationDeviceId = 9;
|
||||||
|
optional Opaque opaque = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
message DataMessage {
|
message DataMessage {
|
||||||
|
|
|
@ -5860,6 +5860,7 @@ button.module-image__border-overlay:focus {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
justify-content: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
@ -6225,8 +6226,18 @@ button.module-image__border-overlay:focus {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__header {
|
&__container {
|
||||||
|
&--direct {
|
||||||
|
.module-ongoing-call__header {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
}
|
||||||
|
.module-ongoing-call__footer {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
background: linear-gradient($color-black-alpha-40, transparent);
|
background: linear-gradient($color-black-alpha-40, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6237,12 +6248,33 @@ button.module-image__border-overlay:focus {
|
||||||
letter-spacing: -0.0025em;
|
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 {
|
&__footer {
|
||||||
background: linear-gradient(transparent, $color-black-alpha-40);
|
background: linear-gradient(transparent, $color-black-alpha-40);
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&__actions {
|
&__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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import pProps from 'p-props';
|
import pProps from 'p-props';
|
||||||
|
import { chunk } from 'lodash';
|
||||||
|
|
||||||
export function typedArrayToArrayBuffer(typedArray: Uint8Array): ArrayBuffer {
|
export function typedArrayToArrayBuffer(typedArray: Uint8Array): ArrayBuffer {
|
||||||
const { buffer, byteOffset, byteLength } = typedArray;
|
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> {
|
export function splitUuids(arrayBuffer: ArrayBuffer): Array<string | null> {
|
||||||
const uuids = [];
|
const uuids = [];
|
||||||
for (let i = 0; i < arrayBuffer.byteLength; i += 16) {
|
for (let i = 0; i < arrayBuffer.byteLength; i += 16) {
|
||||||
|
|
|
@ -2,11 +2,19 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { noop } from 'lodash';
|
||||||
import { storiesOf } from '@storybook/react';
|
import { storiesOf } from '@storybook/react';
|
||||||
import { action } from '@storybook/addon-actions';
|
import { action } from '@storybook/addon-actions';
|
||||||
|
|
||||||
import { CallManager } from './CallManager';
|
import { CallManager, PropsType } from './CallManager';
|
||||||
import { CallEndedReason, CallState } from '../types/Calling';
|
import {
|
||||||
|
CallEndedReason,
|
||||||
|
CallMode,
|
||||||
|
CallState,
|
||||||
|
GroupCallConnectionState,
|
||||||
|
GroupCallJoinState,
|
||||||
|
} from '../types/Calling';
|
||||||
|
import { ConversationTypeType } from '../state/ducks/conversations';
|
||||||
import { ColorType } from '../types/Colors';
|
import { ColorType } from '../types/Colors';
|
||||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||||
import enMessages from '../../_locales/en/messages.json';
|
import enMessages from '../../_locales/en/messages.json';
|
||||||
|
@ -21,6 +29,9 @@ const conversation = {
|
||||||
name: 'Rick Sanchez',
|
name: 'Rick Sanchez',
|
||||||
phoneNumber: '3051234567',
|
phoneNumber: '3051234567',
|
||||||
profileName: 'Rick Sanchez',
|
profileName: 'Rick Sanchez',
|
||||||
|
markedUnread: false,
|
||||||
|
type: 'direct' as ConversationTypeType,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
|
@ -29,6 +40,17 @@ const defaultProps = {
|
||||||
cancelCall: action('cancel-call'),
|
cancelCall: action('cancel-call'),
|
||||||
closeNeedPermissionScreen: action('close-need-permission-screen'),
|
closeNeedPermissionScreen: action('close-need-permission-screen'),
|
||||||
declineCall: action('decline-call'),
|
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'),
|
hangUp: action('hang-up'),
|
||||||
i18n,
|
i18n,
|
||||||
me: {
|
me: {
|
||||||
|
@ -52,10 +74,11 @@ const permutations = [
|
||||||
props: {},
|
props: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Call Manager (ongoing)',
|
title: 'Call Manager (ongoing direct call)',
|
||||||
props: {
|
props: {
|
||||||
activeCall: {
|
activeCall: {
|
||||||
call: {
|
call: {
|
||||||
|
callMode: CallMode.Direct as CallMode.Direct,
|
||||||
conversationId: '3051234567',
|
conversationId: '3051234567',
|
||||||
callState: CallState.Accepted,
|
callState: CallState.Accepted,
|
||||||
isIncoming: false,
|
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)',
|
title: 'Call Manager (ringing)',
|
||||||
props: {
|
props: {
|
||||||
incomingCall: {
|
incomingCall: {
|
||||||
call: {
|
call: {
|
||||||
|
callMode: CallMode.Direct as CallMode.Direct,
|
||||||
conversationId: '3051234567',
|
conversationId: '3051234567',
|
||||||
callState: CallState.Ringing,
|
callState: CallState.Ringing,
|
||||||
isIncoming: true,
|
isIncoming: true,
|
||||||
|
@ -95,6 +143,7 @@ const permutations = [
|
||||||
props: {
|
props: {
|
||||||
activeCall: {
|
activeCall: {
|
||||||
call: {
|
call: {
|
||||||
|
callMode: CallMode.Direct as CallMode.Direct,
|
||||||
conversationId: '3051234567',
|
conversationId: '3051234567',
|
||||||
callState: CallState.Ended,
|
callState: CallState.Ended,
|
||||||
callEndedReason: CallEndedReason.RemoteHangupNeedPermission,
|
callEndedReason: CallEndedReason.RemoteHangupNeedPermission,
|
||||||
|
@ -118,10 +167,12 @@ const permutations = [
|
||||||
];
|
];
|
||||||
|
|
||||||
storiesOf('Components/CallManager', module).add('Iterations', () => {
|
storiesOf('Components/CallManager', module).add('Iterations', () => {
|
||||||
return permutations.map(({ props, title }) => (
|
return permutations.map(
|
||||||
|
({ props, title }: { props: Partial<PropsType>; title: string }) => (
|
||||||
<>
|
<>
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
<CallManager {...defaultProps} {...props} />
|
<CallManager {...defaultProps} {...props} />
|
||||||
</>
|
</>
|
||||||
));
|
)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,56 +1,58 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
// Copyright 2020 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { CallingPip } from './CallingPip';
|
import { CallingPip } from './CallingPip';
|
||||||
import { CallNeedPermissionScreen } from './CallNeedPermissionScreen';
|
import { CallNeedPermissionScreen } from './CallNeedPermissionScreen';
|
||||||
import { CallingLobby } from './CallingLobby';
|
import { CallingLobby } from './CallingLobby';
|
||||||
import { CallScreen } from './CallScreen';
|
import { CallScreen } from './CallScreen';
|
||||||
import { IncomingCallBar } from './IncomingCallBar';
|
import { IncomingCallBar } from './IncomingCallBar';
|
||||||
import { CallState, CallEndedReason } from '../types/Calling';
|
|
||||||
import {
|
import {
|
||||||
ActiveCallStateType,
|
CallMode,
|
||||||
|
CallState,
|
||||||
|
CallEndedReason,
|
||||||
|
CanvasVideoRenderer,
|
||||||
|
VideoFrameSource,
|
||||||
|
GroupCallJoinState,
|
||||||
|
} from '../types/Calling';
|
||||||
|
import { ConversationType } from '../state/ducks/conversations';
|
||||||
|
import {
|
||||||
AcceptCallType,
|
AcceptCallType,
|
||||||
|
ActiveCallStateType,
|
||||||
|
CancelCallType,
|
||||||
DeclineCallType,
|
DeclineCallType,
|
||||||
DirectCallStateType,
|
DirectCallStateType,
|
||||||
StartCallType,
|
GroupCallStateType,
|
||||||
SetLocalAudioType,
|
|
||||||
HangUpType,
|
HangUpType,
|
||||||
|
SetLocalAudioType,
|
||||||
SetLocalPreviewType,
|
SetLocalPreviewType,
|
||||||
SetLocalVideoType,
|
SetLocalVideoType,
|
||||||
SetRendererCanvasType,
|
SetRendererCanvasType,
|
||||||
|
StartCallType,
|
||||||
} from '../state/ducks/calling';
|
} from '../state/ducks/calling';
|
||||||
import { LocalizerType } from '../types/Util';
|
import { LocalizerType } from '../types/Util';
|
||||||
import { ColorType } from '../types/Colors';
|
import { ColorType } from '../types/Colors';
|
||||||
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
|
|
||||||
interface PropsType {
|
interface ActiveCallType {
|
||||||
activeCall?: {
|
call: DirectCallStateType | GroupCallStateType;
|
||||||
call: DirectCallStateType;
|
|
||||||
activeCallState: ActiveCallStateType;
|
activeCallState: ActiveCallStateType;
|
||||||
conversation: {
|
conversation: ConversationType;
|
||||||
id: string;
|
}
|
||||||
avatarPath?: string;
|
|
||||||
color?: ColorType;
|
export interface PropsType {
|
||||||
title: string;
|
activeCall?: ActiveCallType;
|
||||||
name?: string;
|
|
||||||
phoneNumber?: string;
|
|
||||||
profileName?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
availableCameras: Array<MediaDeviceInfo>;
|
availableCameras: Array<MediaDeviceInfo>;
|
||||||
cancelCall: () => void;
|
cancelCall: (_: CancelCallType) => void;
|
||||||
|
createCanvasVideoRenderer: () => CanvasVideoRenderer;
|
||||||
closeNeedPermissionScreen: () => void;
|
closeNeedPermissionScreen: () => void;
|
||||||
|
getGroupCallVideoFrameSource: (
|
||||||
|
conversationId: string,
|
||||||
|
demuxId: number
|
||||||
|
) => VideoFrameSource;
|
||||||
incomingCall?: {
|
incomingCall?: {
|
||||||
call: DirectCallStateType;
|
call: DirectCallStateType;
|
||||||
conversation: {
|
conversation: ConversationType;
|
||||||
id: string;
|
|
||||||
avatarPath?: string;
|
|
||||||
color?: ColorType;
|
|
||||||
title: string;
|
|
||||||
name?: string;
|
|
||||||
phoneNumber?: string;
|
|
||||||
profileName?: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
renderDeviceSelection: () => JSX.Element;
|
renderDeviceSelection: () => JSX.Element;
|
||||||
startCall: (payload: StartCallType) => void;
|
startCall: (payload: StartCallType) => void;
|
||||||
|
@ -75,16 +77,19 @@ interface PropsType {
|
||||||
toggleSettings: () => void;
|
toggleSettings: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CallManager = ({
|
interface ActiveCallManagerPropsType extends PropsType {
|
||||||
acceptCall,
|
activeCall: ActiveCallType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
|
||||||
activeCall,
|
activeCall,
|
||||||
availableCameras,
|
availableCameras,
|
||||||
cancelCall,
|
cancelCall,
|
||||||
closeNeedPermissionScreen,
|
closeNeedPermissionScreen,
|
||||||
declineCall,
|
createCanvasVideoRenderer,
|
||||||
hangUp,
|
hangUp,
|
||||||
i18n,
|
i18n,
|
||||||
incomingCall,
|
getGroupCallVideoFrameSource,
|
||||||
me,
|
me,
|
||||||
renderDeviceSelection,
|
renderDeviceSelection,
|
||||||
setLocalAudio,
|
setLocalAudio,
|
||||||
|
@ -95,10 +100,8 @@ export const CallManager = ({
|
||||||
toggleParticipants,
|
toggleParticipants,
|
||||||
togglePip,
|
togglePip,
|
||||||
toggleSettings,
|
toggleSettings,
|
||||||
}: PropsType): JSX.Element | null => {
|
}) => {
|
||||||
if (activeCall) {
|
|
||||||
const { call, activeCallState, conversation } = activeCall;
|
const { call, activeCallState, conversation } = activeCall;
|
||||||
const { callState, callEndedReason } = call;
|
|
||||||
const {
|
const {
|
||||||
joinedAt,
|
joinedAt,
|
||||||
hasLocalAudio,
|
hasLocalAudio,
|
||||||
|
@ -107,9 +110,36 @@ export const CallManager = ({
|
||||||
pip,
|
pip,
|
||||||
} = activeCallState;
|
} = 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;
|
const ended = callState === CallState.Ended;
|
||||||
if (ended) {
|
if (
|
||||||
if (callEndedReason === CallEndedReason.RemoteHangupNeedPermission) {
|
ended &&
|
||||||
|
callEndedReason === CallEndedReason.RemoteHangupNeedPermission
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<CallNeedPermissionScreen
|
<CallNeedPermissionScreen
|
||||||
close={closeNeedPermissionScreen}
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<CallingLobby
|
<CallingLobby
|
||||||
|
@ -129,16 +168,12 @@ export const CallManager = ({
|
||||||
hasLocalAudio={hasLocalAudio}
|
hasLocalAudio={hasLocalAudio}
|
||||||
hasLocalVideo={hasLocalVideo}
|
hasLocalVideo={hasLocalVideo}
|
||||||
i18n={i18n}
|
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}
|
isGroupCall={false}
|
||||||
me={me}
|
me={me}
|
||||||
onCallCanceled={cancelCall}
|
onCallCanceled={cancelActiveCall}
|
||||||
onJoinCall={() => {
|
onJoinCall={joinActiveCall}
|
||||||
startCall({
|
|
||||||
conversationId: conversation.id,
|
|
||||||
hasLocalAudio,
|
|
||||||
hasLocalVideo,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
setLocalPreview={setLocalPreview}
|
setLocalPreview={setLocalPreview}
|
||||||
setLocalAudio={setLocalAudio}
|
setLocalAudio={setLocalAudio}
|
||||||
setLocalVideo={setLocalVideo}
|
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);
|
const hasRemoteVideo = Boolean(call.hasRemoteVideo);
|
||||||
|
|
||||||
if (pip) {
|
|
||||||
return (
|
return (
|
||||||
<CallingPip
|
<CallingPip
|
||||||
conversation={conversation}
|
conversation={conversation}
|
||||||
|
@ -170,15 +206,16 @@ export const CallManager = ({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CallScreen
|
<CallScreen
|
||||||
|
call={call}
|
||||||
conversation={conversation}
|
conversation={conversation}
|
||||||
callState={callState}
|
createCanvasVideoRenderer={createCanvasVideoRenderer}
|
||||||
|
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
|
||||||
hangUp={hangUp}
|
hangUp={hangUp}
|
||||||
hasLocalAudio={hasLocalAudio}
|
hasLocalAudio={hasLocalAudio}
|
||||||
hasLocalVideo={hasLocalVideo}
|
hasLocalVideo={hasLocalVideo}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
joinedAt={joinedAt}
|
joinedAt={joinedAt}
|
||||||
me={me}
|
me={me}
|
||||||
hasRemoteVideo={hasRemoteVideo}
|
|
||||||
setLocalPreview={setLocalPreview}
|
setLocalPreview={setLocalPreview}
|
||||||
setRendererCanvas={setRendererCanvas}
|
setRendererCanvas={setRendererCanvas}
|
||||||
setLocalAudio={setLocalAudio}
|
setLocalAudio={setLocalAudio}
|
||||||
|
@ -189,6 +226,15 @@ export const CallManager = ({
|
||||||
{settingsDialogOpen && renderDeviceSelection()}
|
{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.
|
// 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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { noop } from 'lodash';
|
||||||
import { storiesOf } from '@storybook/react';
|
import { storiesOf } from '@storybook/react';
|
||||||
import { boolean, select } from '@storybook/addon-knobs';
|
import { boolean, select } from '@storybook/addon-knobs';
|
||||||
import { action } from '@storybook/addon-actions';
|
import { action } from '@storybook/addon-actions';
|
||||||
|
|
||||||
import { CallState } from '../types/Calling';
|
import { CallMode, CallState } from '../types/Calling';
|
||||||
import { ColorType } from '../types/Colors';
|
import { ColorType } from '../types/Colors';
|
||||||
import { CallScreen, PropsType } from './CallScreen';
|
import { CallScreen, PropsType } from './CallScreen';
|
||||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
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 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: {
|
conversation: {
|
||||||
id: '3051234567',
|
id: '3051234567',
|
||||||
avatarPath: undefined,
|
avatarPath: undefined,
|
||||||
|
@ -23,19 +46,24 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||||
name: 'Rick Sanchez',
|
name: 'Rick Sanchez',
|
||||||
phoneNumber: '3051234567',
|
phoneNumber: '3051234567',
|
||||||
profileName: 'Rick Sanchez',
|
profileName: 'Rick Sanchez',
|
||||||
|
markedUnread: false,
|
||||||
|
type: 'direct',
|
||||||
|
lastUpdated: Date.now(),
|
||||||
},
|
},
|
||||||
callState: select(
|
// We allow `any` here because these are fake and actually come from RingRTC, which we
|
||||||
'callState',
|
// can't import.
|
||||||
CallState,
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
overrideProps.callState || CallState.Accepted
|
createCanvasVideoRenderer: () =>
|
||||||
),
|
({
|
||||||
|
setCanvas: noop,
|
||||||
|
enable: noop,
|
||||||
|
disable: noop,
|
||||||
|
} as any),
|
||||||
|
getGroupCallVideoFrameSource: noop as any,
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
hangUp: action('hang-up'),
|
hangUp: action('hang-up'),
|
||||||
hasLocalAudio: boolean('hasLocalAudio', overrideProps.hasLocalAudio || false),
|
hasLocalAudio: boolean('hasLocalAudio', overrideProps.hasLocalAudio || false),
|
||||||
hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false),
|
hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false),
|
||||||
hasRemoteVideo: boolean(
|
|
||||||
'hasRemoteVideo',
|
|
||||||
overrideProps.hasRemoteVideo || false
|
|
||||||
),
|
|
||||||
i18n,
|
i18n,
|
||||||
joinedAt: Date.now(),
|
joinedAt: Date.now(),
|
||||||
me: {
|
me: {
|
||||||
|
|
|
@ -4,7 +4,10 @@
|
||||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { noop } from 'lodash';
|
import { noop } from 'lodash';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { ConversationType } from '../state/ducks/conversations';
|
||||||
import {
|
import {
|
||||||
|
DirectCallStateType,
|
||||||
|
GroupCallStateType,
|
||||||
HangUpType,
|
HangUpType,
|
||||||
SetLocalAudioType,
|
SetLocalAudioType,
|
||||||
SetLocalPreviewType,
|
SetLocalPreviewType,
|
||||||
|
@ -14,25 +17,27 @@ import {
|
||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
import { CallingButton, CallingButtonType } from './CallingButton';
|
import { CallingButton, CallingButtonType } from './CallingButton';
|
||||||
import { CallBackgroundBlur } from './CallBackgroundBlur';
|
import { CallBackgroundBlur } from './CallBackgroundBlur';
|
||||||
import { CallState } from '../types/Calling';
|
import {
|
||||||
|
CallMode,
|
||||||
|
CallState,
|
||||||
|
GroupCallConnectionState,
|
||||||
|
CanvasVideoRenderer,
|
||||||
|
VideoFrameSource,
|
||||||
|
} from '../types/Calling';
|
||||||
import { ColorType } from '../types/Colors';
|
import { ColorType } from '../types/Colors';
|
||||||
import { LocalizerType } from '../types/Util';
|
import { LocalizerType } from '../types/Util';
|
||||||
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
|
import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant';
|
||||||
|
import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
conversation: {
|
call: DirectCallStateType | GroupCallStateType;
|
||||||
id: string;
|
conversation: ConversationType;
|
||||||
avatarPath?: string;
|
createCanvasVideoRenderer: () => CanvasVideoRenderer;
|
||||||
color?: ColorType;
|
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
|
||||||
title: string;
|
|
||||||
name?: string;
|
|
||||||
phoneNumber?: string;
|
|
||||||
profileName?: string;
|
|
||||||
};
|
|
||||||
callState: CallState;
|
|
||||||
hangUp: (_: HangUpType) => void;
|
hangUp: (_: HangUpType) => void;
|
||||||
hasLocalAudio: boolean;
|
hasLocalAudio: boolean;
|
||||||
hasLocalVideo: boolean;
|
hasLocalVideo: boolean;
|
||||||
hasRemoteVideo: boolean;
|
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
joinedAt?: number;
|
joinedAt?: number;
|
||||||
me: {
|
me: {
|
||||||
|
@ -52,12 +57,13 @@ export type PropsType = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CallScreen: React.FC<PropsType> = ({
|
export const CallScreen: React.FC<PropsType> = ({
|
||||||
callState,
|
call,
|
||||||
conversation,
|
conversation,
|
||||||
|
createCanvasVideoRenderer,
|
||||||
|
getGroupCallVideoFrameSource,
|
||||||
hangUp,
|
hangUp,
|
||||||
hasLocalAudio,
|
hasLocalAudio,
|
||||||
hasLocalVideo,
|
hasLocalVideo,
|
||||||
hasRemoteVideo,
|
|
||||||
i18n,
|
i18n,
|
||||||
joinedAt,
|
joinedAt,
|
||||||
me,
|
me,
|
||||||
|
@ -84,15 +90,11 @@ export const CallScreen: React.FC<PropsType> = ({
|
||||||
const [showControls, setShowControls] = useState(true);
|
const [showControls, setShowControls] = useState(true);
|
||||||
|
|
||||||
const localVideoRef = useRef<HTMLVideoElement | null>(null);
|
const localVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const remoteVideoRef = useRef<HTMLCanvasElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalPreview({ element: localVideoRef });
|
setLocalPreview({ element: localVideoRef });
|
||||||
setRendererCanvas({ element: remoteVideoRef });
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
setLocalPreview({ element: undefined });
|
setLocalPreview({ element: undefined });
|
||||||
setRendererCanvas({ element: undefined });
|
|
||||||
};
|
};
|
||||||
}, [setLocalPreview, setRendererCanvas]);
|
}, [setLocalPreview, setRendererCanvas]);
|
||||||
|
|
||||||
|
@ -142,14 +144,39 @@ export const CallScreen: React.FC<PropsType> = ({
|
||||||
};
|
};
|
||||||
}, [toggleAudio, toggleVideo]);
|
}, [toggleAudio, toggleVideo]);
|
||||||
|
|
||||||
const isAudioOnly = !hasLocalVideo && !hasRemoteVideo;
|
let hasRemoteVideo: boolean;
|
||||||
|
let isConnected: boolean;
|
||||||
|
let remoteParticipants: JSX.Element;
|
||||||
|
|
||||||
const controlsFadeClass = classNames({
|
switch (call.callMode) {
|
||||||
'module-ongoing-call__controls--fadeIn':
|
case CallMode.Direct:
|
||||||
(showControls || isAudioOnly) && callState !== CallState.Accepted,
|
hasRemoteVideo = Boolean(call.hasRemoteVideo);
|
||||||
'module-ongoing-call__controls--fadeOut':
|
isConnected = call.callState === CallState.Accepted;
|
||||||
!showControls && !isAudioOnly && 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
|
const videoButtonType = hasLocalVideo
|
||||||
? CallingButtonType.VIDEO_ON
|
? CallingButtonType.VIDEO_ON
|
||||||
|
@ -158,9 +185,23 @@ export const CallScreen: React.FC<PropsType> = ({
|
||||||
? CallingButtonType.AUDIO_ON
|
? CallingButtonType.AUDIO_ON
|
||||||
: CallingButtonType.AUDIO_OFF;
|
: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="module-calling__container"
|
className={classNames(
|
||||||
|
'module-calling__container',
|
||||||
|
`module-ongoing-call__container--${getCallModeClassSuffix(
|
||||||
|
call.callMode
|
||||||
|
)}`
|
||||||
|
)}
|
||||||
onMouseMove={() => {
|
onMouseMove={() => {
|
||||||
setShowControls(true);
|
setShowControls(true);
|
||||||
}}
|
}}
|
||||||
|
@ -176,7 +217,12 @@ export const CallScreen: React.FC<PropsType> = ({
|
||||||
<div className="module-calling__header--header-name">
|
<div className="module-calling__header--header-name">
|
||||||
{conversation.title}
|
{conversation.title}
|
||||||
</div>
|
</div>
|
||||||
{renderHeaderMessage(i18n, callState, acceptedDuration)}
|
{call.callMode === CallMode.Direct &&
|
||||||
|
renderHeaderMessage(
|
||||||
|
i18n,
|
||||||
|
call.callState || CallState.Prering,
|
||||||
|
acceptedDuration
|
||||||
|
)}
|
||||||
<div className="module-calling-tools">
|
<div className="module-calling-tools">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -184,22 +230,18 @@ export const CallScreen: React.FC<PropsType> = ({
|
||||||
className="module-calling-tools__button module-calling-button__settings"
|
className="module-calling-tools__button module-calling-button__settings"
|
||||||
onClick={toggleSettings}
|
onClick={toggleSettings}
|
||||||
/>
|
/>
|
||||||
|
{/* TODO: Group calls should also support the PiP. See DESKTOP-886. */}
|
||||||
|
{call.callMode === CallMode.Direct && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={i18n('calling__pip')}
|
aria-label={i18n('calling__pip')}
|
||||||
className="module-calling-tools__button module-calling-button__pip"
|
className="module-calling-tools__button module-calling-button__pip"
|
||||||
onClick={togglePip}
|
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">
|
<div className="module-ongoing-call__footer">
|
||||||
{/* This layout-only element is not ideal.
|
{/* This layout-only element is not ideal.
|
||||||
See the comment in _modules.css for more. */}
|
See the comment in _modules.css for more. */}
|
||||||
|
@ -260,40 +302,17 @@ export const CallScreen: React.FC<PropsType> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderAvatar(
|
function getCallModeClassSuffix(
|
||||||
i18n: LocalizerType,
|
callMode: CallMode.Direct | CallMode.Group
|
||||||
{
|
): string {
|
||||||
avatarPath,
|
switch (callMode) {
|
||||||
color,
|
case CallMode.Direct:
|
||||||
name,
|
return 'direct';
|
||||||
phoneNumber,
|
case CallMode.Group:
|
||||||
profileName,
|
return 'group';
|
||||||
title,
|
default:
|
||||||
}: {
|
throw missingCaseError(callMode);
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHeaderMessage(
|
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,
|
ProtoBinaryType,
|
||||||
} from './textsecure.d';
|
} from './textsecure.d';
|
||||||
import { GroupCredentialsType } from './textsecure/WebAPI';
|
import { GroupCredentialsType } from './textsecure/WebAPI';
|
||||||
|
import MessageSender from './textsecure/SendMessage';
|
||||||
import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message';
|
import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message';
|
||||||
import { ConversationModel } from './models/conversations';
|
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
|
// Fetching and applying group changes
|
||||||
|
|
||||||
type MaybeUpdatePropsType = {
|
type MaybeUpdatePropsType = {
|
||||||
|
@ -2603,3 +2684,29 @@ function decryptPendingMember(
|
||||||
|
|
||||||
return member;
|
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,
|
draftText,
|
||||||
firstName: this.get('profileName')!,
|
firstName: this.get('profileName')!,
|
||||||
groupVersion,
|
groupVersion,
|
||||||
|
groupId: this.get('groupId'),
|
||||||
inboxPosition,
|
inboxPosition,
|
||||||
isArchived: this.get('isArchived')!,
|
isArchived: this.get('isArchived')!,
|
||||||
isBlocked: this.isBlocked(),
|
isBlocked: this.isBlocked(),
|
||||||
|
@ -1181,6 +1182,8 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
name: this.get('name')!,
|
name: this.get('name')!,
|
||||||
phoneNumber: this.getNumber()!,
|
phoneNumber: this.getNumber()!,
|
||||||
profileName: this.getProfileName()!,
|
profileName: this.getProfileName()!,
|
||||||
|
publicParams: this.get('publicParams'),
|
||||||
|
secretParams: this.get('secretParams'),
|
||||||
sharedGroupNames: this.get('sharedGroupNames')!,
|
sharedGroupNames: this.get('sharedGroupNames')!,
|
||||||
shouldShowDraft,
|
shouldShowDraft,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
|
|
@ -12,19 +12,51 @@ import {
|
||||||
CallSettings,
|
CallSettings,
|
||||||
CallState,
|
CallState,
|
||||||
CanvasVideoRenderer,
|
CanvasVideoRenderer,
|
||||||
|
ConnectionState,
|
||||||
|
JoinState,
|
||||||
|
HttpMethod,
|
||||||
DeviceId,
|
DeviceId,
|
||||||
|
GroupCall,
|
||||||
|
GroupMemberInfo,
|
||||||
GumVideoCapturer,
|
GumVideoCapturer,
|
||||||
HangupMessage,
|
HangupMessage,
|
||||||
HangupType,
|
HangupType,
|
||||||
OfferType,
|
OfferType,
|
||||||
|
OpaqueMessage,
|
||||||
RingRTC,
|
RingRTC,
|
||||||
UserId,
|
UserId,
|
||||||
|
VideoFrameSource,
|
||||||
} from 'ringrtc';
|
} from 'ringrtc';
|
||||||
|
import { uniqBy, noop } from 'lodash';
|
||||||
|
|
||||||
import { ActionsType as UxActionsType } from '../state/ducks/calling';
|
import { ActionsType as UxActionsType } from '../state/ducks/calling';
|
||||||
|
import { getConversationCallMode } from '../state/ducks/conversations';
|
||||||
import { EnvelopeClass } from '../textsecure.d';
|
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 { 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 {
|
export {
|
||||||
CallState,
|
CallState,
|
||||||
|
@ -45,7 +77,7 @@ export class CallingClass {
|
||||||
|
|
||||||
private deviceReselectionTimer?: NodeJS.Timeout;
|
private deviceReselectionTimer?: NodeJS.Timeout;
|
||||||
|
|
||||||
private callsByConversation: { [conversationId: string]: Call };
|
private callsByConversation: { [conversationId: string]: Call | GroupCall };
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.videoCapturer = new GumVideoCapturer(640, 480, 30);
|
this.videoCapturer = new GumVideoCapturer(640, 480, 30);
|
||||||
|
@ -65,6 +97,8 @@ export class CallingClass {
|
||||||
this
|
this
|
||||||
);
|
);
|
||||||
RingRTC.handleLogMessage = this.handleLogMessage.bind(this);
|
RingRTC.handleLogMessage = this.handleLogMessage.bind(this);
|
||||||
|
RingRTC.handleSendHttpRequest = this.handleSendHttpRequest.bind(this);
|
||||||
|
RingRTC.handleSendCallMessage = this.handleSendCallMessage.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async startCallingLobby(
|
async startCallingLobby(
|
||||||
|
@ -73,14 +107,37 @@ export class CallingClass {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
window.log.info('CallingClass.startCallingLobby()');
|
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) {
|
if (!this.uxActions) {
|
||||||
window.log.error('Missing uxActions, new call not allowed.');
|
window.log.error('Missing uxActions, new call not allowed.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteUserId = this.getRemoteUserIdFromConversation(conversation);
|
if (!this.localDeviceId) {
|
||||||
if (!remoteUserId || !this.localDeviceId) {
|
window.log.error(
|
||||||
window.log.error('Missing identifier, new call not allowed.');
|
'Missing local device identifier, new call not allowed.'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -90,22 +147,48 @@ export class CallingClass {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.log.info('CallingClass.startCallingLobby(): Getting call settings');
|
window.log.info('CallingClass.startCallingLobby(): Starting lobby');
|
||||||
|
|
||||||
// Check state after awaiting to debounce call button.
|
switch (callMode) {
|
||||||
if (RingRTC.call && RingRTC.call.state !== CallState.Ended) {
|
case CallMode.Direct:
|
||||||
window.log.info('Call already in progress, new call not allowed.');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
const groupCall = this.connectGroupCall(conversationProps.id, {
|
||||||
const conversationProps = conversation.format();
|
groupId: conversationProps.groupId,
|
||||||
|
publicParams: conversationProps.publicParams,
|
||||||
window.log.info('CallingClass.startCallingLobby(): Starting lobby');
|
secretParams: conversationProps.secretParams,
|
||||||
this.uxActions.showCallLobby({
|
|
||||||
conversationId: conversationProps.id,
|
|
||||||
isVideoCall,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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();
|
await this.startDeviceReselectionTimer();
|
||||||
|
|
||||||
if (isVideoCall) {
|
if (isVideoCall) {
|
||||||
|
@ -113,18 +196,22 @@ export class CallingClass {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stopCallingLobby(): void {
|
stopCallingLobby(conversationId?: string): void {
|
||||||
this.disableLocalCamera();
|
this.disableLocalCamera();
|
||||||
this.stopDeviceReselectionTimer();
|
this.stopDeviceReselectionTimer();
|
||||||
this.lastMediaDeviceSettings = undefined;
|
this.lastMediaDeviceSettings = undefined;
|
||||||
|
|
||||||
|
if (conversationId) {
|
||||||
|
this.getGroupCall(conversationId)?.disconnect();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async startOutgoingCall(
|
async startOutgoingDirectCall(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
hasLocalAudio: boolean,
|
hasLocalAudio: boolean,
|
||||||
hasLocalVideo: boolean
|
hasLocalVideo: boolean
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
window.log.info('CallingClass.startCallingLobby()');
|
window.log.info('CallingClass.startOutgoingDirectCall()');
|
||||||
|
|
||||||
if (!this.uxActions) {
|
if (!this.uxActions) {
|
||||||
throw new Error('Redux actions not available');
|
throw new Error('Redux actions not available');
|
||||||
|
@ -152,7 +239,9 @@ export class CallingClass {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.log.info('CallingClass.startOutgoingCall(): Getting call settings');
|
window.log.info(
|
||||||
|
'CallingClass.startOutgoingDirectCall(): Getting call settings'
|
||||||
|
);
|
||||||
|
|
||||||
const callSettings = await this.getCallSettings(conversation);
|
const callSettings = await this.getCallSettings(conversation);
|
||||||
|
|
||||||
|
@ -163,7 +252,9 @@ export class CallingClass {
|
||||||
return;
|
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
|
// We could make this faster by getting the call object
|
||||||
// from the RingRTC before we lookup the ICE servers.
|
// from the RingRTC before we lookup the ICE servers.
|
||||||
|
@ -188,8 +279,255 @@ export class CallingClass {
|
||||||
await this.startDeviceReselectionTimer();
|
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 {
|
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> {
|
async accept(conversationId: string, asVideoCall: boolean): Promise<void> {
|
||||||
|
@ -228,33 +566,54 @@ export class CallingClass {
|
||||||
hangup(conversationId: string): void {
|
hangup(conversationId: string): void {
|
||||||
window.log.info('CallingClass.hangup()');
|
window.log.info('CallingClass.hangup()');
|
||||||
|
|
||||||
const callId = this.getCallIdForConversation(conversationId);
|
const call = getOwn(this.callsByConversation, conversationId);
|
||||||
if (!callId) {
|
if (!call) {
|
||||||
window.log.warn('Trying to hang up a non-existent call');
|
window.log.warn('Trying to hang up a non-existent call');
|
||||||
return;
|
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 {
|
setOutgoingAudio(conversationId: string, enabled: boolean): void {
|
||||||
const callId = this.getCallIdForConversation(conversationId);
|
const call = getOwn(this.callsByConversation, conversationId);
|
||||||
if (!callId) {
|
if (!call) {
|
||||||
window.log.warn('Trying to set outgoing audio for a non-existent call');
|
window.log.warn('Trying to set outgoing audio for a non-existent call');
|
||||||
return;
|
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 {
|
setOutgoingVideo(conversationId: string, enabled: boolean): void {
|
||||||
const callId = this.getCallIdForConversation(conversationId);
|
const call = getOwn(this.callsByConversation, conversationId);
|
||||||
if (!callId) {
|
if (!call) {
|
||||||
window.log.warn('Trying to set outgoing video for a non-existent call');
|
window.log.warn('Trying to set outgoing video for a non-existent call');
|
||||||
return;
|
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> {
|
private async startDeviceReselectionTimer(): Promise<void> {
|
||||||
|
@ -554,13 +913,17 @@ export class CallingClass {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sourceUuid = envelope.sourceUuid
|
||||||
|
? uuidToArrayBuffer(envelope.sourceUuid)
|
||||||
|
: null;
|
||||||
|
|
||||||
const messageAgeSec = envelope.messageAgeSec ? envelope.messageAgeSec : 0;
|
const messageAgeSec = envelope.messageAgeSec ? envelope.messageAgeSec : 0;
|
||||||
|
|
||||||
window.log.info('CallingClass.handleCallingMessage(): Handling in RingRTC');
|
window.log.info('CallingClass.handleCallingMessage(): Handling in RingRTC');
|
||||||
|
|
||||||
RingRTC.handleCallingMessage(
|
RingRTC.handleCallingMessage(
|
||||||
remoteUserId,
|
remoteUserId,
|
||||||
null,
|
sourceUuid,
|
||||||
remoteDeviceId,
|
remoteDeviceId,
|
||||||
this.localDeviceId,
|
this.localDeviceId,
|
||||||
messageAgeSec,
|
messageAgeSec,
|
||||||
|
@ -639,6 +1002,21 @@ export class CallingClass {
|
||||||
return false;
|
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(
|
private async handleOutgoingSignaling(
|
||||||
remoteUserId: UserId,
|
remoteUserId: UserId,
|
||||||
message: CallingMessage
|
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(
|
private getRemoteUserIdFromConversation(
|
||||||
conversation: ConversationModel
|
conversation: ConversationModel
|
||||||
): UserId | undefined | null {
|
): UserId | undefined | null {
|
||||||
|
|
|
@ -5,14 +5,17 @@ import { ThunkAction } from 'redux-thunk';
|
||||||
import { CallEndedReason } from 'ringrtc';
|
import { CallEndedReason } from 'ringrtc';
|
||||||
import { has, omit } from 'lodash';
|
import { has, omit } from 'lodash';
|
||||||
import { getOwn } from '../../util/getOwn';
|
import { getOwn } from '../../util/getOwn';
|
||||||
|
import { missingCaseError } from '../../util/missingCaseError';
|
||||||
import { notify } from '../../services/notify';
|
import { notify } from '../../services/notify';
|
||||||
import { calling } from '../../services/calling';
|
import { calling } from '../../services/calling';
|
||||||
import { StateType as RootStateType } from '../reducer';
|
import { StateType as RootStateType } from '../reducer';
|
||||||
import { getActiveCall } from '../selectors/calling';
|
|
||||||
import {
|
import {
|
||||||
CallingDeviceType,
|
CallingDeviceType,
|
||||||
|
CallMode,
|
||||||
CallState,
|
CallState,
|
||||||
ChangeIODevicePayloadType,
|
ChangeIODevicePayloadType,
|
||||||
|
GroupCallConnectionState,
|
||||||
|
GroupCallJoinState,
|
||||||
MediaDeviceSettings,
|
MediaDeviceSettings,
|
||||||
} from '../../types/Calling';
|
} from '../../types/Calling';
|
||||||
import { callingTones } from '../../util/callingTones';
|
import { callingTones } from '../../util/callingTones';
|
||||||
|
@ -25,6 +28,7 @@ import {
|
||||||
// State
|
// State
|
||||||
|
|
||||||
export interface DirectCallStateType {
|
export interface DirectCallStateType {
|
||||||
|
callMode: CallMode.Direct;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
callState?: CallState;
|
callState?: CallState;
|
||||||
callEndedReason?: CallEndedReason;
|
callEndedReason?: CallEndedReason;
|
||||||
|
@ -33,6 +37,22 @@ export interface DirectCallStateType {
|
||||||
hasRemoteVideo?: boolean;
|
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 {
|
export interface ActiveCallStateType {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
joinedAt?: number;
|
joinedAt?: number;
|
||||||
|
@ -44,7 +64,9 @@ export interface ActiveCallStateType {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CallingStateType = MediaDeviceSettings & {
|
export type CallingStateType = MediaDeviceSettings & {
|
||||||
callsByConversation: { [conversationId: string]: DirectCallStateType };
|
callsByConversation: {
|
||||||
|
[conversationId: string]: DirectCallStateType | GroupCallStateType;
|
||||||
|
};
|
||||||
activeCallState?: ActiveCallStateType;
|
activeCallState?: ActiveCallStateType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -63,10 +85,23 @@ export type CallStateChangeType = {
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CancelCallType = {
|
||||||
|
conversationId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type DeclineCallType = {
|
export type DeclineCallType = {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GroupCallStateChangeType = {
|
||||||
|
conversationId: string;
|
||||||
|
connectionState: GroupCallConnectionState;
|
||||||
|
joinState: GroupCallJoinState;
|
||||||
|
hasLocalAudio: boolean;
|
||||||
|
hasLocalVideo: boolean;
|
||||||
|
remoteParticipants: Array<GroupCallRemoteParticipantType>;
|
||||||
|
};
|
||||||
|
|
||||||
export type HangUpType = {
|
export type HangUpType = {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
};
|
};
|
||||||
|
@ -76,11 +111,15 @@ export type IncomingCallType = {
|
||||||
isVideoCall: boolean;
|
isVideoCall: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StartCallType = {
|
interface StartDirectCallType {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
hasLocalAudio: boolean;
|
hasLocalAudio: boolean;
|
||||||
hasLocalVideo: boolean;
|
hasLocalVideo: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
export interface StartCallType extends StartDirectCallType {
|
||||||
|
callMode: CallMode.Direct | CallMode.Group;
|
||||||
|
}
|
||||||
|
|
||||||
export type RemoteVideoChangeType = {
|
export type RemoteVideoChangeType = {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
@ -95,10 +134,22 @@ export type SetLocalVideoType = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ShowCallLobbyType = {
|
export type ShowCallLobbyType =
|
||||||
|
| {
|
||||||
|
callMode: CallMode.Direct;
|
||||||
conversationId: string;
|
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 = {
|
export type SetLocalPreviewType = {
|
||||||
element: React.RefObject<HTMLVideoElement> | undefined;
|
element: React.RefObject<HTMLVideoElement> | undefined;
|
||||||
|
@ -110,6 +161,13 @@ export type SetRendererCanvasType = {
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
||||||
|
export const getActiveCall = ({
|
||||||
|
activeCallState,
|
||||||
|
callsByConversation,
|
||||||
|
}: CallingStateType): undefined | DirectCallStateType | GroupCallStateType =>
|
||||||
|
activeCallState &&
|
||||||
|
getOwn(callsByConversation, activeCallState.conversationId);
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
||||||
const ACCEPT_CALL_PENDING = 'calling/ACCEPT_CALL_PENDING';
|
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 CHANGE_IO_DEVICE_FULFILLED = 'calling/CHANGE_IO_DEVICE_FULFILLED';
|
||||||
const CLOSE_NEED_PERMISSION_SCREEN = 'calling/CLOSE_NEED_PERMISSION_SCREEN';
|
const CLOSE_NEED_PERMISSION_SCREEN = 'calling/CLOSE_NEED_PERMISSION_SCREEN';
|
||||||
const DECLINE_CALL = 'calling/DECLINE_CALL';
|
const DECLINE_CALL = 'calling/DECLINE_CALL';
|
||||||
|
const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE';
|
||||||
const HANG_UP = 'calling/HANG_UP';
|
const HANG_UP = 'calling/HANG_UP';
|
||||||
const INCOMING_CALL = 'calling/INCOMING_CALL';
|
const INCOMING_CALL = 'calling/INCOMING_CALL';
|
||||||
const OUTGOING_CALL = 'calling/OUTGOING_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 REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE';
|
||||||
const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED';
|
const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED';
|
||||||
const SET_LOCAL_VIDEO_FULFILLED = 'calling/SET_LOCAL_VIDEO_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_PARTICIPANTS = 'calling/TOGGLE_PARTICIPANTS';
|
||||||
const TOGGLE_PIP = 'calling/TOGGLE_PIP';
|
const TOGGLE_PIP = 'calling/TOGGLE_PIP';
|
||||||
const TOGGLE_SETTINGS = 'calling/TOGGLE_SETTINGS';
|
const TOGGLE_SETTINGS = 'calling/TOGGLE_SETTINGS';
|
||||||
|
@ -165,6 +224,11 @@ type DeclineCallActionType = {
|
||||||
payload: DeclineCallType;
|
payload: DeclineCallType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GroupCallStateChangeActionType = {
|
||||||
|
type: 'calling/GROUP_CALL_STATE_CHANGE';
|
||||||
|
payload: GroupCallStateChangeType;
|
||||||
|
};
|
||||||
|
|
||||||
type HangUpActionType = {
|
type HangUpActionType = {
|
||||||
type: 'calling/HANG_UP';
|
type: 'calling/HANG_UP';
|
||||||
payload: HangUpType;
|
payload: HangUpType;
|
||||||
|
@ -177,7 +241,7 @@ type IncomingCallActionType = {
|
||||||
|
|
||||||
type OutgoingCallActionType = {
|
type OutgoingCallActionType = {
|
||||||
type: 'calling/OUTGOING_CALL';
|
type: 'calling/OUTGOING_CALL';
|
||||||
payload: StartCallType;
|
payload: StartDirectCallType;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RefreshIODevicesActionType = {
|
type RefreshIODevicesActionType = {
|
||||||
|
@ -205,9 +269,9 @@ type ShowCallLobbyActionType = {
|
||||||
payload: ShowCallLobbyType;
|
payload: ShowCallLobbyType;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StartCallActionType = {
|
type StartDirectCallActionType = {
|
||||||
type: 'calling/START_CALL';
|
type: 'calling/START_DIRECT_CALL';
|
||||||
payload: StartCallType;
|
payload: StartDirectCallType;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToggleParticipantsActionType = {
|
type ToggleParticipantsActionType = {
|
||||||
|
@ -230,6 +294,7 @@ export type CallingActionType =
|
||||||
| ChangeIODeviceFulfilledActionType
|
| ChangeIODeviceFulfilledActionType
|
||||||
| CloseNeedPermissionScreenActionType
|
| CloseNeedPermissionScreenActionType
|
||||||
| DeclineCallActionType
|
| DeclineCallActionType
|
||||||
|
| GroupCallStateChangeActionType
|
||||||
| HangUpActionType
|
| HangUpActionType
|
||||||
| IncomingCallActionType
|
| IncomingCallActionType
|
||||||
| OutgoingCallActionType
|
| OutgoingCallActionType
|
||||||
|
@ -238,7 +303,7 @@ export type CallingActionType =
|
||||||
| SetLocalAudioActionType
|
| SetLocalAudioActionType
|
||||||
| SetLocalVideoFulfilledActionType
|
| SetLocalVideoFulfilledActionType
|
||||||
| ShowCallLobbyActionType
|
| ShowCallLobbyActionType
|
||||||
| StartCallActionType
|
| StartDirectCallActionType
|
||||||
| ToggleParticipantsActionType
|
| ToggleParticipantsActionType
|
||||||
| TogglePipActionType
|
| TogglePipActionType
|
||||||
| ToggleSettingsActionType;
|
| ToggleSettingsActionType;
|
||||||
|
@ -346,8 +411,8 @@ function closeNeedPermissionScreen(): CloseNeedPermissionScreenActionType {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelCall(): CancelCallActionType {
|
function cancelCall(payload: CancelCallType): CancelCallActionType {
|
||||||
window.Signal.Services.calling.stopCallingLobby();
|
calling.stopCallingLobby(payload.conversationId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: CANCEL_CALL,
|
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 {
|
function hangUp(payload: HangUpType): HangUpActionType {
|
||||||
calling.hangup(payload.conversationId);
|
calling.hangup(payload.conversationId);
|
||||||
|
|
||||||
|
@ -381,7 +455,7 @@ function receiveIncomingCall(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function outgoingCall(payload: StartCallType): OutgoingCallActionType {
|
function outgoingCall(payload: StartDirectCallType): OutgoingCallActionType {
|
||||||
callingTones.playRingtone();
|
callingTones.playRingtone();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -428,11 +502,14 @@ function setLocalAudio(
|
||||||
payload: SetLocalAudioType
|
payload: SetLocalAudioType
|
||||||
): ThunkAction<void, RootStateType, unknown, SetLocalAudioActionType> {
|
): ThunkAction<void, RootStateType, unknown, SetLocalAudioActionType> {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const { conversationId } = getActiveCall(getState().calling) || {};
|
const activeCall = getActiveCall(getState().calling);
|
||||||
if (conversationId) {
|
if (!activeCall) {
|
||||||
calling.setOutgoingAudio(conversationId, payload.enabled);
|
window.log.warn('Trying to set local audio when no call is active');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
calling.setOutgoingAudio(activeCall.conversationId, payload.enabled);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: SET_LOCAL_AUDIO_FULFILLED,
|
type: SET_LOCAL_AUDIO_FULFILLED,
|
||||||
payload,
|
payload,
|
||||||
|
@ -444,12 +521,19 @@ function setLocalVideo(
|
||||||
payload: SetLocalVideoType
|
payload: SetLocalVideoType
|
||||||
): ThunkAction<void, RootStateType, unknown, SetLocalVideoFulfilledActionType> {
|
): ThunkAction<void, RootStateType, unknown, SetLocalVideoFulfilledActionType> {
|
||||||
return async (dispatch, getState) => {
|
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;
|
let enabled: boolean;
|
||||||
if (await requestCameraPermissions()) {
|
if (await requestCameraPermissions()) {
|
||||||
const { conversationId, callState } =
|
if (
|
||||||
getActiveCall(getState().calling) || {};
|
activeCall.callMode === CallMode.Group ||
|
||||||
if (conversationId && callState) {
|
(activeCall.callMode === CallMode.Direct && activeCall.callState)
|
||||||
calling.setOutgoingVideo(conversationId, payload.enabled);
|
) {
|
||||||
|
calling.setOutgoingVideo(activeCall.conversationId, payload.enabled);
|
||||||
} else if (payload.enabled) {
|
} else if (payload.enabled) {
|
||||||
calling.enableLocalCamera();
|
calling.enableLocalCamera();
|
||||||
} else {
|
} else {
|
||||||
|
@ -477,16 +561,34 @@ function showCallLobby(payload: ShowCallLobbyType): CallLobbyActionType {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function startCall(payload: StartCallType): StartCallActionType {
|
function startCall(
|
||||||
calling.startOutgoingCall(
|
payload: StartCallType
|
||||||
|
): ThunkAction<void, RootStateType, unknown, StartDirectCallActionType> {
|
||||||
|
return dispatch => {
|
||||||
|
switch (payload.callMode) {
|
||||||
|
case CallMode.Direct:
|
||||||
|
calling.startOutgoingDirectCall(
|
||||||
payload.conversationId,
|
payload.conversationId,
|
||||||
payload.hasLocalAudio,
|
payload.hasLocalAudio,
|
||||||
payload.hasLocalVideo
|
payload.hasLocalVideo
|
||||||
);
|
);
|
||||||
|
dispatch({
|
||||||
return {
|
type: START_DIRECT_CALL,
|
||||||
type: START_CALL,
|
|
||||||
payload,
|
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,
|
changeIODevice,
|
||||||
closeNeedPermissionScreen,
|
closeNeedPermissionScreen,
|
||||||
declineCall,
|
declineCall,
|
||||||
|
groupCallStateChange,
|
||||||
hangUp,
|
hangUp,
|
||||||
receiveIncomingCall,
|
receiveIncomingCall,
|
||||||
outgoingCall,
|
outgoingCall,
|
||||||
|
@ -568,20 +671,41 @@ export function reducer(
|
||||||
const { callsByConversation } = state;
|
const { callsByConversation } = state;
|
||||||
|
|
||||||
if (action.type === SHOW_CALL_LOBBY) {
|
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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
callsByConversation: {
|
callsByConversation: {
|
||||||
...callsByConversation,
|
...callsByConversation,
|
||||||
[action.payload.conversationId]: {
|
[action.payload.conversationId]: call,
|
||||||
conversationId: action.payload.conversationId,
|
|
||||||
isIncoming: false,
|
|
||||||
isVideoCall: action.payload.isVideoCall,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
activeCallState: {
|
activeCallState: {
|
||||||
conversationId: action.payload.conversationId,
|
conversationId: action.payload.conversationId,
|
||||||
hasLocalAudio: true,
|
hasLocalAudio: action.payload.hasLocalAudio,
|
||||||
hasLocalVideo: action.payload.isVideoCall,
|
hasLocalVideo: action.payload.hasLocalVideo,
|
||||||
participantsList: false,
|
participantsList: false,
|
||||||
pip: false,
|
pip: false,
|
||||||
settingsDialogOpen: false,
|
settingsDialogOpen: false,
|
||||||
|
@ -589,12 +713,13 @@ export function reducer(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === START_CALL) {
|
if (action.type === START_DIRECT_CALL) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
callsByConversation: {
|
callsByConversation: {
|
||||||
...callsByConversation,
|
...callsByConversation,
|
||||||
[action.payload.conversationId]: {
|
[action.payload.conversationId]: {
|
||||||
|
callMode: CallMode.Direct,
|
||||||
conversationId: action.payload.conversationId,
|
conversationId: action.payload.conversationId,
|
||||||
callState: CallState.Prering,
|
callState: CallState.Prering,
|
||||||
isIncoming: false,
|
isIncoming: false,
|
||||||
|
@ -636,14 +761,19 @@ export function reducer(
|
||||||
action.type === HANG_UP ||
|
action.type === HANG_UP ||
|
||||||
action.type === CLOSE_NEED_PERMISSION_SCREEN
|
action.type === CLOSE_NEED_PERMISSION_SCREEN
|
||||||
) {
|
) {
|
||||||
if (!state.activeCallState) {
|
const activeCall = getActiveCall(state);
|
||||||
|
if (!activeCall) {
|
||||||
window.log.warn('No active call to remove');
|
window.log.warn('No active call to remove');
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
return removeConversationFromState(
|
switch (activeCall.callMode) {
|
||||||
state,
|
case CallMode.Direct:
|
||||||
state.activeCallState.conversationId
|
return removeConversationFromState(state, activeCall.conversationId);
|
||||||
);
|
case CallMode.Group:
|
||||||
|
return omit(state, 'activeCallState');
|
||||||
|
default:
|
||||||
|
throw missingCaseError(activeCall);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === DECLINE_CALL) {
|
if (action.type === DECLINE_CALL) {
|
||||||
|
@ -656,6 +786,7 @@ export function reducer(
|
||||||
callsByConversation: {
|
callsByConversation: {
|
||||||
...callsByConversation,
|
...callsByConversation,
|
||||||
[action.payload.conversationId]: {
|
[action.payload.conversationId]: {
|
||||||
|
callMode: CallMode.Direct,
|
||||||
conversationId: action.payload.conversationId,
|
conversationId: action.payload.conversationId,
|
||||||
callState: CallState.Prering,
|
callState: CallState.Prering,
|
||||||
isIncoming: true,
|
isIncoming: true,
|
||||||
|
@ -671,6 +802,7 @@ export function reducer(
|
||||||
callsByConversation: {
|
callsByConversation: {
|
||||||
...callsByConversation,
|
...callsByConversation,
|
||||||
[action.payload.conversationId]: {
|
[action.payload.conversationId]: {
|
||||||
|
callMode: CallMode.Direct,
|
||||||
conversationId: action.payload.conversationId,
|
conversationId: action.payload.conversationId,
|
||||||
callState: CallState.Prering,
|
callState: CallState.Prering,
|
||||||
isIncoming: false,
|
isIncoming: false,
|
||||||
|
@ -703,8 +835,8 @@ export function reducer(
|
||||||
state.callsByConversation,
|
state.callsByConversation,
|
||||||
action.payload.conversationId
|
action.payload.conversationId
|
||||||
);
|
);
|
||||||
if (!call) {
|
if (call?.callMode !== CallMode.Direct) {
|
||||||
window.log.warn('Cannot update state for non-existent call');
|
window.log.warn('Cannot update state for a non-direct call');
|
||||||
return state;
|
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) {
|
if (action.type === REMOTE_VIDEO_CHANGE) {
|
||||||
const { conversationId, hasVideo } = action.payload;
|
const { conversationId, hasVideo } = action.payload;
|
||||||
const call = getOwn(state.callsByConversation, conversationId);
|
const call = getOwn(state.callsByConversation, conversationId);
|
||||||
if (!call) {
|
if (call?.callMode !== CallMode.Direct) {
|
||||||
window.log.warn('Cannot update remote video for a non-existent call');
|
window.log.warn('Cannot update remote video for a non-direct call');
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ import { NoopActionType } from './noop';
|
||||||
import { AttachmentType } from '../../types/Attachment';
|
import { AttachmentType } from '../../types/Attachment';
|
||||||
import { ColorType } from '../../types/Colors';
|
import { ColorType } from '../../types/Colors';
|
||||||
import { BodyRangeType } from '../../types/Util';
|
import { BodyRangeType } from '../../types/Util';
|
||||||
|
import { CallMode } from '../../types/Calling';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
||||||
|
@ -91,9 +92,12 @@ export type ConversationType = {
|
||||||
|
|
||||||
sharedGroupNames?: Array<string>;
|
sharedGroupNames?: Array<string>;
|
||||||
groupVersion?: 1 | 2;
|
groupVersion?: 1 | 2;
|
||||||
|
groupId?: string;
|
||||||
isMissingMandatoryProfileSharing?: boolean;
|
isMissingMandatoryProfileSharing?: boolean;
|
||||||
messageRequestsEnabled?: boolean;
|
messageRequestsEnabled?: boolean;
|
||||||
acceptedMessageRequest?: boolean;
|
acceptedMessageRequest?: boolean;
|
||||||
|
secretParams?: string;
|
||||||
|
publicParams?: string;
|
||||||
};
|
};
|
||||||
export type ConversationLookupType = {
|
export type ConversationLookupType = {
|
||||||
[key: string]: ConversationType;
|
[key: string]: ConversationType;
|
||||||
|
@ -188,6 +192,31 @@ export type ConversationsStateType = {
|
||||||
messagesByConversation: MessagesByConversationType;
|
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
|
// Actions
|
||||||
|
|
||||||
type ConversationAddedActionType = {
|
type ConversationAddedActionType = {
|
||||||
|
|
|
@ -3,11 +3,8 @@
|
||||||
|
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
|
||||||
import { CallingStateType } from '../ducks/calling';
|
import { CallingStateType, DirectCallStateType } from '../ducks/calling';
|
||||||
import { CallState } from '../../types/Calling';
|
import { CallMode, CallState } from '../../types/Calling';
|
||||||
import { getOwn } from '../../util/getOwn';
|
|
||||||
|
|
||||||
const getActiveCallState = (state: CallingStateType) => state.activeCallState;
|
|
||||||
|
|
||||||
const getCallsByConversation = (state: CallingStateType) =>
|
const getCallsByConversation = (state: CallingStateType) =>
|
||||||
state.callsByConversation;
|
state.callsByConversation;
|
||||||
|
@ -16,18 +13,14 @@ const getCallsByConversation = (state: CallingStateType) =>
|
||||||
// UI are ready to handle this.
|
// UI are ready to handle this.
|
||||||
export const getIncomingCall = createSelector(
|
export const getIncomingCall = createSelector(
|
||||||
getCallsByConversation,
|
getCallsByConversation,
|
||||||
callsByConversation =>
|
(callsByConversation): undefined | DirectCallStateType => {
|
||||||
Object.values(callsByConversation).find(
|
const result = Object.values(callsByConversation).find(
|
||||||
call => call.isIncoming && call.callState === CallState.Ringing
|
call =>
|
||||||
)
|
call.callMode === CallMode.Direct &&
|
||||||
|
call.isIncoming &&
|
||||||
|
call.callState === CallState.Ringing
|
||||||
|
);
|
||||||
|
// TypeScript needs a little help to be sure that this is a direct call.
|
||||||
|
return result?.callMode === CallMode.Direct ? result : undefined;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getActiveCall = createSelector(
|
|
||||||
getActiveCallState,
|
|
||||||
getCallsByConversation,
|
|
||||||
(activeCallState, callsByConversation) =>
|
|
||||||
activeCallState &&
|
|
||||||
getOwn(callsByConversation, activeCallState.conversationId)
|
|
||||||
);
|
|
||||||
|
|
||||||
export const isCallActive = createSelector(getActiveCall, Boolean);
|
|
||||||
|
|
|
@ -3,10 +3,13 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
import { CanvasVideoRenderer } from 'ringrtc';
|
||||||
import { mapDispatchToProps } from '../actions';
|
import { mapDispatchToProps } from '../actions';
|
||||||
import { CallManager } from '../../components/CallManager';
|
import { CallManager } from '../../components/CallManager';
|
||||||
|
import { calling as callingService } from '../../services/calling';
|
||||||
import { getMe, getConversationSelector } from '../selectors/conversations';
|
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 { StateType } from '../reducer';
|
||||||
|
|
||||||
import { getIntl } from '../selectors/user';
|
import { getIntl } from '../selectors/user';
|
||||||
|
@ -17,6 +20,12 @@ function renderDeviceSelection(): JSX.Element {
|
||||||
return <SmartCallingDeviceSelection />;
|
return <SmartCallingDeviceSelection />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createCanvasVideoRenderer = () => new CanvasVideoRenderer();
|
||||||
|
|
||||||
|
const getGroupCallVideoFrameSource = callingService.getGroupCallVideoFrameSource.bind(
|
||||||
|
callingService
|
||||||
|
);
|
||||||
|
|
||||||
const mapStateToActiveCallProp = (state: StateType) => {
|
const mapStateToActiveCallProp = (state: StateType) => {
|
||||||
const { calling } = state;
|
const { calling } = state;
|
||||||
const { activeCallState } = calling;
|
const { activeCallState } = calling;
|
||||||
|
@ -69,6 +78,8 @@ const mapStateToIncomingCallProp = (state: StateType) => {
|
||||||
const mapStateToProps = (state: StateType) => ({
|
const mapStateToProps = (state: StateType) => ({
|
||||||
activeCall: mapStateToActiveCallProp(state),
|
activeCall: mapStateToActiveCallProp(state),
|
||||||
availableCameras: state.calling.availableCameras,
|
availableCameras: state.calling.availableCameras,
|
||||||
|
createCanvasVideoRenderer,
|
||||||
|
getGroupCallVideoFrameSource,
|
||||||
i18n: getIntl(state),
|
i18n: getIntl(state),
|
||||||
incomingCall: mapStateToIncomingCallProp(state),
|
incomingCall: mapStateToIncomingCallProp(state),
|
||||||
me: getMe(state),
|
me: getMe(state),
|
||||||
|
|
|
@ -6,7 +6,9 @@ import { pick } from 'lodash';
|
||||||
import { ConversationHeader } from '../../components/conversation/ConversationHeader';
|
import { ConversationHeader } from '../../components/conversation/ConversationHeader';
|
||||||
import { getConversationSelector } from '../selectors/conversations';
|
import { getConversationSelector } from '../selectors/conversations';
|
||||||
import { StateType } from '../reducer';
|
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';
|
import { getIntl } from '../selectors/user';
|
||||||
|
|
||||||
export interface OwnProps {
|
export interface OwnProps {
|
||||||
|
@ -36,6 +38,11 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
|
||||||
throw new Error('Could not find conversation');
|
throw new Error('Could not find conversation');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conversationCallMode = getConversationCallMode(conversation);
|
||||||
|
const conversationSupportsCalls =
|
||||||
|
conversationCallMode === CallMode.Direct ||
|
||||||
|
(conversationCallMode === CallMode.Group && window.GROUP_CALLING);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...pick(conversation, [
|
...pick(conversation, [
|
||||||
'acceptedMessageRequest',
|
'acceptedMessageRequest',
|
||||||
|
@ -59,10 +66,7 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
|
||||||
]),
|
]),
|
||||||
i18n: getIntl(state),
|
i18n: getIntl(state),
|
||||||
showBackButton: state.conversations.selectedConversationPanelDepth > 0,
|
showBackButton: state.conversations.selectedConversationPanelDepth > 0,
|
||||||
showCallButtons:
|
showCallButtons: conversationSupportsCalls && !getActiveCall(state.calling),
|
||||||
conversation.type === 'direct' &&
|
|
||||||
!conversation.isMe &&
|
|
||||||
!isCallActive(state.calling),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -5,15 +5,27 @@ import { assert } from 'chai';
|
||||||
import * as sinon from 'sinon';
|
import * as sinon from 'sinon';
|
||||||
import { reducer as rootReducer } from '../../../state/reducer';
|
import { reducer as rootReducer } from '../../../state/reducer';
|
||||||
import { noopAction } from '../../../state/ducks/noop';
|
import { noopAction } from '../../../state/ducks/noop';
|
||||||
import { 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 { calling as callingService } from '../../../services/calling';
|
||||||
import { CallState } from '../../../types/Calling';
|
import {
|
||||||
|
CallMode,
|
||||||
|
CallState,
|
||||||
|
GroupCallConnectionState,
|
||||||
|
GroupCallJoinState,
|
||||||
|
} from '../../../types/Calling';
|
||||||
|
|
||||||
describe('calling duck', () => {
|
describe('calling duck', () => {
|
||||||
const stateWithDirectCall = {
|
const stateWithDirectCall: CallingStateType = {
|
||||||
...getEmptyState(),
|
...getEmptyState(),
|
||||||
callsByConversation: {
|
callsByConversation: {
|
||||||
'fake-direct-call-conversation-id': {
|
'fake-direct-call-conversation-id': {
|
||||||
|
callMode: CallMode.Direct,
|
||||||
conversationId: 'fake-direct-call-conversation-id',
|
conversationId: 'fake-direct-call-conversation-id',
|
||||||
callState: CallState.Accepted,
|
callState: CallState.Accepted,
|
||||||
isIncoming: false,
|
isIncoming: false,
|
||||||
|
@ -23,7 +35,7 @@ describe('calling duck', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const stateWithActiveDirectCall = {
|
const stateWithActiveDirectCall: CallingStateType = {
|
||||||
...stateWithDirectCall,
|
...stateWithDirectCall,
|
||||||
activeCallState: {
|
activeCallState: {
|
||||||
conversationId: 'fake-direct-call-conversation-id',
|
conversationId: 'fake-direct-call-conversation-id',
|
||||||
|
@ -35,10 +47,11 @@ describe('calling duck', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const stateWithIncomingDirectCall = {
|
const stateWithIncomingDirectCall: CallingStateType = {
|
||||||
...getEmptyState(),
|
...getEmptyState(),
|
||||||
callsByConversation: {
|
callsByConversation: {
|
||||||
'fake-direct-call-conversation-id': {
|
'fake-direct-call-conversation-id': {
|
||||||
|
callMode: CallMode.Direct,
|
||||||
conversationId: 'fake-direct-call-conversation-id',
|
conversationId: 'fake-direct-call-conversation-id',
|
||||||
callState: CallState.Ringing,
|
callState: CallState.Ringing,
|
||||||
isIncoming: true,
|
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());
|
const getEmptyRootState = () => rootReducer(undefined, noopAction());
|
||||||
|
|
||||||
beforeEach(function beforeEach() {
|
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', () => {
|
describe('setLocalAudio', () => {
|
||||||
const { setLocalAudio } = actions;
|
const { setLocalAudio } = actions;
|
||||||
|
|
||||||
|
@ -154,7 +465,14 @@ describe('calling duck', () => {
|
||||||
it('dispatches a SET_LOCAL_AUDIO_FULFILLED action', () => {
|
it('dispatches a SET_LOCAL_AUDIO_FULFILLED action', () => {
|
||||||
const dispatch = sinon.spy();
|
const dispatch = sinon.spy();
|
||||||
|
|
||||||
setLocalAudio({ enabled: true })(dispatch, getEmptyRootState, null);
|
setLocalAudio({ enabled: true })(
|
||||||
|
dispatch,
|
||||||
|
() => ({
|
||||||
|
...getEmptyRootState(),
|
||||||
|
calling: stateWithActiveDirectCall,
|
||||||
|
}),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
sinon.assert.calledOnce(dispatch);
|
sinon.assert.calledOnce(dispatch);
|
||||||
sinon.assert.calledWith(dispatch, {
|
sinon.assert.calledWith(dispatch, {
|
||||||
|
@ -201,7 +519,14 @@ describe('calling duck', () => {
|
||||||
|
|
||||||
it('updates the local audio state with SET_LOCAL_AUDIO_FULFILLED', () => {
|
it('updates the local audio state with SET_LOCAL_AUDIO_FULFILLED', () => {
|
||||||
const dispatch = sinon.spy();
|
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 action = dispatch.getCall(0).args[0];
|
||||||
|
|
||||||
const result = reducer(stateWithActiveDirectCall, action);
|
const result = reducer(stateWithActiveDirectCall, action);
|
||||||
|
@ -217,12 +542,15 @@ describe('calling duck', () => {
|
||||||
const result = reducer(
|
const result = reducer(
|
||||||
getEmptyState(),
|
getEmptyState(),
|
||||||
showCallLobby({
|
showCallLobby({
|
||||||
|
callMode: CallMode.Direct,
|
||||||
conversationId: 'fake-conversation-id',
|
conversationId: 'fake-conversation-id',
|
||||||
isVideoCall: true,
|
hasLocalAudio: true,
|
||||||
|
hasLocalVideo: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(result.callsByConversation['fake-conversation-id'], {
|
assert.deepEqual(result.callsByConversation['fake-conversation-id'], {
|
||||||
|
callMode: CallMode.Direct,
|
||||||
conversationId: 'fake-conversation-id',
|
conversationId: 'fake-conversation-id',
|
||||||
isIncoming: false,
|
isIncoming: false,
|
||||||
isVideoCall: true,
|
isVideoCall: true,
|
||||||
|
@ -242,39 +570,65 @@ describe('calling duck', () => {
|
||||||
const { startCall } = actions;
|
const { startCall } = actions;
|
||||||
|
|
||||||
beforeEach(function beforeEach() {
|
beforeEach(function beforeEach() {
|
||||||
this.callingStartOutgoingCall = this.sandbox.stub(
|
this.callingStartOutgoingDirectCall = this.sandbox.stub(
|
||||||
callingService,
|
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({
|
startCall({
|
||||||
|
callMode: CallMode.Direct,
|
||||||
conversationId: '123',
|
conversationId: '123',
|
||||||
hasLocalAudio: true,
|
hasLocalAudio: true,
|
||||||
hasLocalVideo: false,
|
hasLocalVideo: false,
|
||||||
});
|
})(dispatch, getEmptyRootState, null);
|
||||||
|
|
||||||
sinon.assert.calledOnce(this.callingStartOutgoingCall);
|
sinon.assert.calledOnce(this.callingStartOutgoingDirectCall);
|
||||||
sinon.assert.calledWith(
|
sinon.assert.calledWith(
|
||||||
this.callingStartOutgoingCall,
|
this.callingStartOutgoingDirectCall,
|
||||||
'123',
|
'123',
|
||||||
true,
|
true,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
|
sinon.assert.notCalled(this.callingJoinGroupCall);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('saves the call and makes it active', () => {
|
it('asks the calling service to join a group call', function test() {
|
||||||
const result = reducer(
|
const dispatch = sinon.spy();
|
||||||
getEmptyState(),
|
|
||||||
startCall({
|
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',
|
conversationId: 'fake-conversation-id',
|
||||||
hasLocalAudio: true,
|
hasLocalAudio: true,
|
||||||
hasLocalVideo: false,
|
hasLocalVideo: false,
|
||||||
})
|
})(dispatch, getEmptyRootState, null);
|
||||||
);
|
const action = dispatch.getCall(0).args[0];
|
||||||
|
|
||||||
|
const result = reducer(getEmptyState(), action);
|
||||||
|
|
||||||
assert.deepEqual(result.callsByConversation['fake-conversation-id'], {
|
assert.deepEqual(result.callsByConversation['fake-conversation-id'], {
|
||||||
|
callMode: CallMode.Direct,
|
||||||
conversationId: 'fake-conversation-id',
|
conversationId: 'fake-conversation-id',
|
||||||
callState: CallState.Prering,
|
callState: CallState.Prering,
|
||||||
isIncoming: false,
|
isIncoming: false,
|
||||||
|
@ -289,6 +643,18 @@ describe('calling duck', () => {
|
||||||
settingsDialogOpen: false,
|
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', () => {
|
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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
import { CallState } from '../../../types/Calling';
|
import { CallMode, CallState } from '../../../types/Calling';
|
||||||
import {
|
import { getIncomingCall } from '../../../state/selectors/calling';
|
||||||
getIncomingCall,
|
import { getEmptyState, CallingStateType } from '../../../state/ducks/calling';
|
||||||
getActiveCall,
|
|
||||||
isCallActive,
|
|
||||||
} from '../../../state/selectors/calling';
|
|
||||||
import { getEmptyState } from '../../../state/ducks/calling';
|
|
||||||
|
|
||||||
describe('state/selectors/calling', () => {
|
describe('state/selectors/calling', () => {
|
||||||
const stateWithDirectCall = {
|
const stateWithDirectCall: CallingStateType = {
|
||||||
...getEmptyState(),
|
...getEmptyState(),
|
||||||
callsByConversation: {
|
callsByConversation: {
|
||||||
'fake-direct-call-conversation-id': {
|
'fake-direct-call-conversation-id': {
|
||||||
|
callMode: CallMode.Direct,
|
||||||
conversationId: 'fake-direct-call-conversation-id',
|
conversationId: 'fake-direct-call-conversation-id',
|
||||||
callState: CallState.Accepted,
|
callState: CallState.Accepted,
|
||||||
isIncoming: false,
|
isIncoming: false,
|
||||||
|
@ -24,7 +21,7 @@ describe('state/selectors/calling', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const stateWithActiveDirectCall = {
|
const stateWithActiveDirectCall: CallingStateType = {
|
||||||
...stateWithDirectCall,
|
...stateWithDirectCall,
|
||||||
activeCallState: {
|
activeCallState: {
|
||||||
conversationId: 'fake-direct-call-conversation-id',
|
conversationId: 'fake-direct-call-conversation-id',
|
||||||
|
@ -36,10 +33,11 @@ describe('state/selectors/calling', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const stateWithIncomingDirectCall = {
|
const stateWithIncomingDirectCall: CallingStateType = {
|
||||||
...getEmptyState(),
|
...getEmptyState(),
|
||||||
callsByConversation: {
|
callsByConversation: {
|
||||||
'fake-direct-call-conversation-id': {
|
'fake-direct-call-conversation-id': {
|
||||||
|
callMode: CallMode.Direct,
|
||||||
conversationId: 'fake-direct-call-conversation-id',
|
conversationId: 'fake-direct-call-conversation-id',
|
||||||
callState: CallState.Ringing,
|
callState: CallState.Ringing,
|
||||||
isIncoming: true,
|
isIncoming: true,
|
||||||
|
@ -61,6 +59,7 @@ describe('state/selectors/calling', () => {
|
||||||
|
|
||||||
it('returns the incoming call', () => {
|
it('returns the incoming call', () => {
|
||||||
assert.deepEqual(getIncomingCall(stateWithIncomingDirectCall), {
|
assert.deepEqual(getIncomingCall(stateWithIncomingDirectCall), {
|
||||||
|
callMode: CallMode.Direct,
|
||||||
conversationId: 'fake-direct-call-conversation-id',
|
conversationId: 'fake-direct-call-conversation-id',
|
||||||
callState: CallState.Ringing,
|
callState: CallState.Ringing,
|
||||||
isIncoming: true,
|
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;
|
GroupChange: typeof GroupChangeClass;
|
||||||
GroupChanges: typeof GroupChangesClass;
|
GroupChanges: typeof GroupChangesClass;
|
||||||
GroupAttributeBlob: typeof GroupAttributeBlobClass;
|
GroupAttributeBlob: typeof GroupAttributeBlobClass;
|
||||||
|
GroupExternalCredential: typeof GroupExternalCredentialClass;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SignalServiceProtobufTypes = {
|
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 {
|
export declare class GroupAttributeBlobClass {
|
||||||
static decode: (
|
static decode: (
|
||||||
data: ArrayBuffer | ByteBufferClass,
|
data: ArrayBuffer | ByteBufferClass,
|
||||||
|
|
|
@ -34,6 +34,7 @@ import {
|
||||||
DataMessageClass,
|
DataMessageClass,
|
||||||
GroupChangeClass,
|
GroupChangeClass,
|
||||||
GroupClass,
|
GroupClass,
|
||||||
|
GroupExternalCredentialClass,
|
||||||
StorageServiceCallOptionsType,
|
StorageServiceCallOptionsType,
|
||||||
StorageServiceCredentials,
|
StorageServiceCredentials,
|
||||||
SyncMessageClass,
|
SyncMessageClass,
|
||||||
|
@ -1841,4 +1842,10 @@ export default class MessageSender {
|
||||||
): Promise<ArrayBuffer> {
|
): Promise<ArrayBuffer> {
|
||||||
return this.server.modifyStorageRecords(data, options);
|
return this.server.modifyStorageRecords(data, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getGroupMembershipToken(
|
||||||
|
options: GroupCredentialsType
|
||||||
|
): Promise<GroupExternalCredentialClass> {
|
||||||
|
return this.server.getGroupExternalCredential(options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,6 +50,7 @@ import {
|
||||||
GroupChangeClass,
|
GroupChangeClass,
|
||||||
GroupChangesClass,
|
GroupChangesClass,
|
||||||
GroupClass,
|
GroupClass,
|
||||||
|
GroupExternalCredentialClass,
|
||||||
StorageServiceCallOptionsType,
|
StorageServiceCallOptionsType,
|
||||||
StorageServiceCredentials,
|
StorageServiceCredentials,
|
||||||
} from '../textsecure.d';
|
} from '../textsecure.d';
|
||||||
|
@ -625,6 +626,7 @@ const URL_CALLS = {
|
||||||
getStickerPackUpload: 'v1/sticker/pack/form',
|
getStickerPackUpload: 'v1/sticker/pack/form',
|
||||||
groupLog: 'v1/groups/logs',
|
groupLog: 'v1/groups/logs',
|
||||||
groups: 'v1/groups',
|
groups: 'v1/groups',
|
||||||
|
groupToken: 'v1/groups/token',
|
||||||
keys: 'v2/keys',
|
keys: 'v2/keys',
|
||||||
messages: 'v1/messages',
|
messages: 'v1/messages',
|
||||||
profile: 'v1/profile',
|
profile: 'v1/profile',
|
||||||
|
@ -726,6 +728,9 @@ export type WebAPIType = {
|
||||||
startDay: number,
|
startDay: number,
|
||||||
endDay: number
|
endDay: number
|
||||||
) => Promise<Array<GroupCredentialType>>;
|
) => Promise<Array<GroupCredentialType>>;
|
||||||
|
getGroupExternalCredential: (
|
||||||
|
options: GroupCredentialsType
|
||||||
|
) => Promise<GroupExternalCredentialClass>;
|
||||||
getGroupLog: (
|
getGroupLog: (
|
||||||
startVersion: number,
|
startVersion: number,
|
||||||
options: GroupCredentialsType
|
options: GroupCredentialsType
|
||||||
|
@ -779,6 +784,12 @@ export type WebAPIType = {
|
||||||
targetUrl: string,
|
targetUrl: string,
|
||||||
options?: ProxiedRequestOptionsType
|
options?: ProxiedRequestOptionsType
|
||||||
) => Promise<any>;
|
) => Promise<any>;
|
||||||
|
makeSfuRequest: (
|
||||||
|
targetUrl: string,
|
||||||
|
type: HTTPCodeType,
|
||||||
|
headers: HeaderListType,
|
||||||
|
body: ArrayBuffer | undefined
|
||||||
|
) => Promise<ArrayBufferWithDetailsType>;
|
||||||
modifyGroup: (
|
modifyGroup: (
|
||||||
changes: GroupChangeClass.Actions,
|
changes: GroupChangeClass.Actions,
|
||||||
options: GroupCredentialsType
|
options: GroupCredentialsType
|
||||||
|
@ -940,6 +951,7 @@ export function initialize({
|
||||||
getGroup,
|
getGroup,
|
||||||
getGroupAvatar,
|
getGroupAvatar,
|
||||||
getGroupCredentials,
|
getGroupCredentials,
|
||||||
|
getGroupExternalCredential,
|
||||||
getGroupLog,
|
getGroupLog,
|
||||||
getIceServers,
|
getIceServers,
|
||||||
getKeysForIdentifier,
|
getKeysForIdentifier,
|
||||||
|
@ -959,6 +971,7 @@ export function initialize({
|
||||||
fetchLinkPreviewMetadata,
|
fetchLinkPreviewMetadata,
|
||||||
fetchLinkPreviewImage,
|
fetchLinkPreviewImage,
|
||||||
makeProxiedRequest,
|
makeProxiedRequest,
|
||||||
|
makeSfuRequest,
|
||||||
modifyGroup,
|
modifyGroup,
|
||||||
modifyStorageRecords,
|
modifyStorageRecords,
|
||||||
putAttachment,
|
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
|
// Groups
|
||||||
|
|
||||||
function generateGroupAuth(
|
function generateGroupAuth(
|
||||||
|
@ -1860,6 +1891,28 @@ export function initialize({
|
||||||
return response.credentials;
|
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) {
|
function verifyAttributes(attributes: AvatarUploadAttributesClass) {
|
||||||
const {
|
const {
|
||||||
key,
|
key,
|
||||||
|
|
|
@ -1,6 +1,16 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
// Copyright 2020 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// 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
|
// Must be kept in sync with RingRTC.CallState
|
||||||
export enum CallState {
|
export enum CallState {
|
||||||
Prering = 'init',
|
Prering = 'init',
|
||||||
|
@ -31,6 +41,36 @@ export enum CallEndedReason {
|
||||||
CallerIsNotMultiring = 'CallerIsNotMultiring',
|
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
|
// Must be kept in sync with RingRTC.AudioDevice
|
||||||
export interface AudioDevice {
|
export interface AudioDevice {
|
||||||
// Device name.
|
// Device name.
|
||||||
|
|
|
@ -14391,20 +14391,11 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CallScreen.js",
|
"path": "ts/components/CallScreen.js",
|
||||||
"line": " const localVideoRef = react_1.useRef(null);",
|
"line": " const localVideoRef = react_1.useRef(null);",
|
||||||
"lineNumber": 35,
|
"lineNumber": 38,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2020-10-26T21:35:52.858Z",
|
"updated": "2020-10-26T21:35:52.858Z",
|
||||||
"reasonDetail": "Used to get the local video element for rendering."
|
"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",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CallingLobby.js",
|
"path": "ts/components/CallingLobby.js",
|
||||||
|
@ -14594,6 +14585,42 @@
|
||||||
"updated": "2020-10-26T23:56:13.482Z",
|
"updated": "2020-10-26T23:56:13.482Z",
|
||||||
"reasonDetail": "Doesn't refer to a DOM element."
|
"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-$(",
|
"rule": "jQuery-$(",
|
||||||
"path": "ts/components/Intl.js",
|
"path": "ts/components/Intl.js",
|
||||||
|
@ -15164,7 +15191,7 @@
|
||||||
"rule": "jQuery-wrap(",
|
"rule": "jQuery-wrap(",
|
||||||
"path": "ts/textsecure/WebAPI.js",
|
"path": "ts/textsecure/WebAPI.js",
|
||||||
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(quote, 'binary', window.dcodeIO.ByteBuffer.LITTLE_ENDIAN);",
|
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(quote, 'binary', window.dcodeIO.ByteBuffer.LITTLE_ENDIAN);",
|
||||||
"lineNumber": 1233,
|
"lineNumber": 1260,
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-09-08T23:07:22.682Z"
|
"updated": "2020-09-08T23:07:22.682Z"
|
||||||
},
|
},
|
||||||
|
@ -15172,7 +15199,7 @@
|
||||||
"rule": "jQuery-wrap(",
|
"rule": "jQuery-wrap(",
|
||||||
"path": "ts/textsecure/WebAPI.ts",
|
"path": "ts/textsecure/WebAPI.ts",
|
||||||
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(",
|
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(",
|
||||||
"lineNumber": 2105,
|
"lineNumber": 2158,
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-09-08T23:07:22.682Z"
|
"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;
|
hasSignalAccount: (number: string) => boolean;
|
||||||
getServerTrustRoot: () => WhatIsThis;
|
getServerTrustRoot: () => WhatIsThis;
|
||||||
readyForUpdates: () => void;
|
readyForUpdates: () => void;
|
||||||
|
|
||||||
|
// Flags
|
||||||
|
GROUP_CALLING: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Error {
|
interface Error {
|
||||||
|
|
Loading…
Add table
Reference in a new issue