Initial group calling support

This commit is contained in:
Evan Hahn 2020-11-13 13:57:55 -06:00 committed by Josh Perez
parent e398520db0
commit 022c4bd0f4
31 changed files with 2530 additions and 414 deletions

View file

@ -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

View file

@ -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');

View file

@ -151,3 +151,7 @@ message GroupAttributeBlob {
uint32 disappearingMessagesDuration = 3; uint32 disappearingMessagesDuration = 3;
} }
} }
message GroupExternalCredential {
string token = 1;
}

View file

@ -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 {

View file

@ -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;
} }
&__container {
&--direct {
.module-ongoing-call__header {
position: absolute;
}
.module-ongoing-call__footer {
position: absolute;
}
}
}
&__header { &__header {
position: absolute;
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 {

View file

@ -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)
);
});
});
}); });

View file

@ -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) {

View file

@ -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> <>
<CallManager {...defaultProps} {...props} /> <h3>{title}</h3>
</> <CallManager {...defaultProps} {...props} />
)); </>
)
);
}); });

View file

@ -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: ConversationType;
conversation: { }
id: string;
avatarPath?: string; export interface PropsType {
color?: ColorType; activeCall?: ActiveCallType;
title: string;
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,21 +100,46 @@ 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 {
const { callState, callEndedReason } = call; joinedAt,
const { hasLocalAudio,
joinedAt, hasLocalVideo,
settingsDialogOpen,
pip,
} = activeCallState;
const cancelActiveCall = useCallback(() => {
cancelCall({ conversationId: conversation.id });
}, [cancelCall, conversation.id]);
const joinActiveCall = useCallback(() => {
startCall({
callMode: call.callMode,
conversationId: conversation.id,
hasLocalAudio, hasLocalAudio,
hasLocalVideo, hasLocalVideo,
settingsDialogOpen, });
pip, }, [startCall, call.callMode, conversation.id, hasLocalAudio, hasLocalVideo]);
} = activeCallState;
const ended = callState === CallState.Ended; const getGroupCallVideoFrameSourceForActiveCall = useCallback(
if (ended) { (demuxId: number) => {
if (callEndedReason === CallEndedReason.RemoteHangupNeedPermission) { return getGroupCallVideoFrameSource(conversation.id, demuxId);
},
[getGroupCallVideoFrameSource, conversation.id]
);
let showCallLobby: boolean;
switch (call.callMode) {
case CallMode.Direct: {
const { callState, callEndedReason } = call;
const ended = callState === CallState.Ended;
if (
ended &&
callEndedReason === CallEndedReason.RemoteHangupNeedPermission
) {
return ( return (
<CallNeedPermissionScreen <CallNeedPermissionScreen
close={closeNeedPermissionScreen} close={closeNeedPermissionScreen}
@ -118,72 +148,36 @@ export const CallManager = ({
/> />
); );
} }
showCallLobby = !callState;
break;
} }
case CallMode.Group: {
if (!callState) { showCallLobby = call.joinState === GroupCallJoinState.NotJoined;
return ( break;
<>
<CallingLobby
availableCameras={availableCameras}
conversation={conversation}
hasLocalAudio={hasLocalAudio}
hasLocalVideo={hasLocalVideo}
i18n={i18n}
isGroupCall={false}
me={me}
onCallCanceled={cancelCall}
onJoinCall={() => {
startCall({
conversationId: conversation.id,
hasLocalAudio,
hasLocalVideo,
});
}}
setLocalPreview={setLocalPreview}
setLocalAudio={setLocalAudio}
setLocalVideo={setLocalVideo}
toggleParticipants={toggleParticipants}
toggleSettings={toggleSettings}
/>
{settingsDialogOpen && renderDeviceSelection()}
</>
);
}
const hasRemoteVideo = Boolean(call.hasRemoteVideo);
if (pip) {
return (
<CallingPip
conversation={conversation}
hangUp={hangUp}
hasLocalVideo={hasLocalVideo}
hasRemoteVideo={hasRemoteVideo}
i18n={i18n}
setLocalPreview={setLocalPreview}
setRendererCanvas={setRendererCanvas}
togglePip={togglePip}
/>
);
} }
default:
throw missingCaseError(call);
}
if (showCallLobby) {
return ( return (
<> <>
<CallScreen <CallingLobby
availableCameras={availableCameras}
conversation={conversation} conversation={conversation}
callState={callState}
hangUp={hangUp}
hasLocalAudio={hasLocalAudio} hasLocalAudio={hasLocalAudio}
hasLocalVideo={hasLocalVideo} hasLocalVideo={hasLocalVideo}
i18n={i18n} i18n={i18n}
joinedAt={joinedAt} // TODO: Set this to `true` for group calls. We can get away with this for
// now because it only affects rendering. See DESKTOP-888 and DESKTOP-889.
isGroupCall={false}
me={me} me={me}
hasRemoteVideo={hasRemoteVideo} onCallCanceled={cancelActiveCall}
onJoinCall={joinActiveCall}
setLocalPreview={setLocalPreview} setLocalPreview={setLocalPreview}
setRendererCanvas={setRendererCanvas}
setLocalAudio={setLocalAudio} setLocalAudio={setLocalAudio}
setLocalVideo={setLocalVideo} setLocalVideo={setLocalVideo}
togglePip={togglePip} toggleParticipants={toggleParticipants}
toggleSettings={toggleSettings} toggleSettings={toggleSettings}
/> />
{settingsDialogOpen && renderDeviceSelection()} {settingsDialogOpen && renderDeviceSelection()}
@ -191,6 +185,58 @@ 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);
return (
<CallingPip
conversation={conversation}
hangUp={hangUp}
hasLocalVideo={hasLocalVideo}
hasRemoteVideo={hasRemoteVideo}
i18n={i18n}
setLocalPreview={setLocalPreview}
setRendererCanvas={setRendererCanvas}
togglePip={togglePip}
/>
);
}
return (
<>
<CallScreen
call={call}
conversation={conversation}
createCanvasVideoRenderer={createCanvasVideoRenderer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
hangUp={hangUp}
hasLocalAudio={hasLocalAudio}
hasLocalVideo={hasLocalVideo}
i18n={i18n}
joinedAt={joinedAt}
me={me}
setLocalPreview={setLocalPreview}
setRendererCanvas={setRendererCanvas}
setLocalAudio={setLocalAudio}
setLocalVideo={setLocalVideo}
togglePip={togglePip}
toggleSettings={toggleSettings}
/>
{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.
if (incomingCall) { if (incomingCall) {
return ( return (

View file

@ -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: {

View file

@ -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}
/> />
<button {/* TODO: Group calls should also support the PiP. See DESKTOP-886. */}
type="button" {call.callMode === CallMode.Direct && (
aria-label={i18n('calling__pip')} <button
className="module-calling-tools__button module-calling-button__pip" type="button"
onClick={togglePip} aria-label={i18n('calling__pip')}
/> className="module-calling-tools__button module-calling-button__pip"
onClick={togglePip}
/>
)}
</div> </div>
</div> </div>
{hasRemoteVideo ? ( {remoteParticipants}
<canvas
className="module-ongoing-call__remote-video-enabled"
ref={remoteVideoRef}
/>
) : (
renderAvatar(i18n, conversation)
)}
<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(

View 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>
);
}

View 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>
);
};

View 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
);
}

View file

@ -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 };
});
}

View file

@ -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,

View file

@ -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,21 +147,47 @@ export class CallingClass {
return; return;
} }
window.log.info('CallingClass.startCallingLobby(): Getting call settings');
// Check state after awaiting to debounce call button.
if (RingRTC.call && RingRTC.call.state !== CallState.Ended) {
window.log.info('Call already in progress, new call not allowed.');
return;
}
const conversationProps = conversation.format();
window.log.info('CallingClass.startCallingLobby(): Starting lobby'); window.log.info('CallingClass.startCallingLobby(): Starting lobby');
this.uxActions.showCallLobby({
conversationId: conversationProps.id, switch (callMode) {
isVideoCall, case CallMode.Direct:
}); this.uxActions.showCallLobby({
callMode: CallMode.Direct,
conversationId: conversationProps.id,
hasLocalAudio: true,
hasLocalVideo: isVideoCall,
});
break;
case CallMode.Group: {
if (
!conversationProps.groupId ||
!conversationProps.publicParams ||
!conversationProps.secretParams
) {
window.log.error(
'Conversation is missing required parameters. Cannot connect group call'
);
return;
}
const groupCall = this.connectGroupCall(conversationProps.id, {
groupId: conversationProps.groupId,
publicParams: conversationProps.publicParams,
secretParams: conversationProps.secretParams,
});
groupCall.setOutgoingAudioMuted(false);
groupCall.setOutgoingVideoMuted(!isVideoCall);
this.uxActions.showCallLobby({
callMode: CallMode.Group,
conversationId: conversationProps.id,
...this.formatGroupCallForRedux(groupCall),
});
break;
}
default:
throw missingCaseError(callMode);
}
await this.startDeviceReselectionTimer(); await this.startDeviceReselectionTimer();
@ -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 {

View file

@ -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 =
conversationId: string; | {
isVideoCall: boolean; callMode: CallMode.Direct;
}; conversationId: string;
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
payload.conversationId, ): ThunkAction<void, RootStateType, unknown, StartDirectCallActionType> {
payload.hasLocalAudio, return dispatch => {
payload.hasLocalVideo switch (payload.callMode) {
); case CallMode.Direct:
calling.startOutgoingDirectCall(
return { payload.conversationId,
type: START_CALL, payload.hasLocalAudio,
payload, payload.hasLocalVideo
);
dispatch({
type: START_DIRECT_CALL,
payload,
});
break;
case CallMode.Group:
calling.joinGroupCall(
payload.conversationId,
payload.hasLocalAudio,
payload.hasLocalVideo
);
// The calling service should already be wired up to Redux so we don't need to
// dispatch anything here.
break;
default:
throw missingCaseError(payload.callMode);
}
}; };
} }
@ -515,6 +617,7 @@ export const actions = {
changeIODevice, 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;
} }

View file

@ -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 = {

View file

@ -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);

View file

@ -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),

View file

@ -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),
}; };
}; };

View file

@ -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: 'fake-conversation-id', conversationId: '123',
hasLocalAudio: true, hasLocalAudio: true,
hasLocalVideo: false, hasLocalVideo: false,
}) })(dispatch, getEmptyRootState, null);
);
sinon.assert.calledOnce(this.callingJoinGroupCall);
sinon.assert.calledWith(this.callingJoinGroupCall, '123', true, false);
sinon.assert.notCalled(this.callingStartOutgoingDirectCall);
});
it('saves direct calls and makes them active', () => {
const dispatch = sinon.spy();
startCall({
callMode: CallMode.Direct,
conversationId: 'fake-conversation-id',
hasLocalAudio: true,
hasLocalVideo: false,
})(dispatch, getEmptyRootState, null);
const action = dispatch.getCall(0).args[0];
const result = reducer(getEmptyState(), action);
assert.deepEqual(result.callsByConversation['fake-conversation-id'], { 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,
});
});
});
});
}); });

View 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
);
});
});
});
});

View file

@ -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
View file

@ -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,

View file

@ -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);
}
} }

View file

@ -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,

View file

@ -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.

View file

@ -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
View file

@ -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 {