Group calling: Peek into a group call

This commit is contained in:
Evan Hahn 2020-11-20 11:19:28 -06:00 committed by Josh Perez
parent af6ec26225
commit 6d53cb1740
15 changed files with 858 additions and 111 deletions

View file

@ -3023,6 +3023,10 @@
"message": "Start a video call",
"description": "Title for the video call button in a conversation"
},
"joinOngoingCall": {
"message": "Join",
"description": "Text that appears in a group when a call is active"
},
"callNeedPermission": {
"message": "$title$ will get a message request from you. You can call once your message request has been accepted.",
"description": "Shown when a call is rejected because the other party hasn't approved the message/call request",

View file

@ -3047,21 +3047,9 @@ span.module-in-contacts-icon__tooltip {
}
}
.module-conversation-header__audio-calling-button {
@include light-theme {
@include color-svg(
'../images/icons/v2/phone-right-outline-24.svg',
$color-gray-75
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/phone-right-solid-24.svg',
$color-gray-15
);
}
height: 24px;
width: 24px;
.module-conversation-header__calling-button {
$icon-size: 24px;
margin-left: 12px;
border: none;
opacity: 0;
@ -3074,31 +3062,65 @@ span.module-in-contacts-icon__tooltip {
&--show {
opacity: 1;
}
}
.module-conversation-header__video-calling-button {
@include light-theme {
@include color-svg(
'../images/icons/v2/video-outline-24.svg',
$color-gray-75
);
}
@include dark-theme {
@include color-svg('../images/icons/v2/video-solid-24.svg', $color-gray-15);
}
height: 24px;
width: 24px;
margin-left: 12px;
border: none;
opacity: 0;
transition: opacity 250ms ease-out;
&:disabled {
cursor: default;
&--video {
@include light-theme {
@include color-svg(
'../images/icons/v2/video-outline-24.svg',
$color-gray-75
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/video-solid-24.svg',
$color-gray-15
);
}
height: $icon-size;
width: $icon-size;
}
&--show {
opacity: 1;
&--audio {
@include light-theme {
@include color-svg(
'../images/icons/v2/phone-right-outline-24.svg',
$color-gray-75
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/phone-right-solid-24.svg',
$color-gray-15
);
}
height: $icon-size;
width: $icon-size;
}
&--join {
@include font-body-1;
align-items: center;
background-color: $color-accent-green;
border-radius: 9999px; // This ensures the borders are completely rounded. (A value like 100% would make it an ellipse.)
color: $color-white;
display: flex;
outline: none;
padding: 5px 18px;
@include keyboard-mode {
&:focus {
box-shadow: 0px 0px 0px 4px $ultramarine-ui-light;
}
}
&:before {
@include color-svg('../images/icons/v2/video-solid-24.svg', $color-white);
content: '';
display: block;
height: $icon-size;
margin-right: 5px;
width: $icon-size;
}
}
}

View file

@ -2653,6 +2653,18 @@ type WhatIsThis = import('./window.d').WhatIsThis;
return Promise.resolve();
}
if (data.message.groupCallUpdate) {
if (data.message.groupV2 && messageDescriptor.type === Message.GROUP) {
window.reduxActions.calling.peekNotConnectedGroupCall({
conversationId: messageDescriptor.id,
});
return Promise.resolve();
}
window.log.warn(
'Received a group call update for a conversation that is not a GV2 group. Ignoring that property and continuing.'
);
}
// Don't wait for handleDataMessage, as it has its own per-conversation queueing
message.handleDataMessage(data.message, event.confirm);

View file

@ -116,6 +116,11 @@ story.add('Ongoing Group Call', () => (
conversationId: '3051234567',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekInfo: {
conversationIds: [],
maxDevices: 16,
deviceCount: 0,
},
remoteParticipants: [],
},
activeCallState: getCallState(),

View file

@ -29,6 +29,11 @@ function getGroupCallState(): GroupCallStateType {
conversationId: '3051234567',
connectionState: 2,
joinState: 2,
peekInfo: {
conversationIds: [],
maxDevices: 16,
deviceCount: 0,
},
remoteParticipants: [],
};
}

View file

@ -113,6 +113,11 @@ story.add('Group Call', () => {
conversationId: '3051234567',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekInfo: {
conversationIds: [],
maxDevices: 16,
deviceCount: 0,
},
remoteParticipants: [],
},
}

View file

@ -208,6 +208,21 @@ const stories: Array<ConversationHeaderStory> = [
outgoingCallButtonStyle: OutgoingCallButtonStyle.JustVideo,
},
},
{
title: 'In a group with an active group call',
props: {
...commonProps,
color: 'signal-blue',
title: 'Typescript support group',
name: 'Typescript support group',
phoneNumber: '',
id: '1',
type: 'group',
expireTimer: 10,
acceptedMessageRequest: true,
outgoingCallButtonStyle: OutgoingCallButtonStyle.Join,
},
},
],
},
{

View file

@ -29,6 +29,7 @@ export enum OutgoingCallButtonStyle {
None,
JustVideo,
Both,
Join,
}
export interface PropsDataType {
@ -280,10 +281,11 @@ export class ConversationHeader extends React.Component<PropsType> {
type="button"
onClick={onOutgoingVideoCallInConversation}
className={classNames(
'module-conversation-header__video-calling-button',
'module-conversation-header__calling-button',
'module-conversation-header__calling-button--video',
showBackButton
? null
: 'module-conversation-header__video-calling-button--show'
: 'module-conversation-header__calling-button--show'
)}
disabled={showBackButton}
aria-label={i18n('makeOutgoingVideoCall')}
@ -303,16 +305,34 @@ export class ConversationHeader extends React.Component<PropsType> {
type="button"
onClick={onOutgoingAudioCallInConversation}
className={classNames(
'module-conversation-header__audio-calling-button',
'module-conversation-header__calling-button',
'module-conversation-header__calling-button--audio',
showBackButton
? null
: 'module-conversation-header__audio-calling-button--show'
: 'module-conversation-header__calling-button--show'
)}
disabled={showBackButton}
aria-label={i18n('makeOutgoingCall')}
/>
</>
);
case OutgoingCallButtonStyle.Join:
return (
<button
type="button"
onClick={onOutgoingVideoCallInConversation}
className={classNames(
'module-conversation-header__calling-button',
'module-conversation-header__calling-button--join',
showBackButton
? null
: 'module-conversation-header__calling-button--show'
)}
disabled={showBackButton}
>
{i18n('joinOngoingCall')}
</button>
);
default:
throw missingCaseError(outgoingCallButtonStyle);
}

View file

@ -23,13 +23,17 @@ import {
HangupType,
OfferType,
OpaqueMessage,
PeekInfo,
RingRTC,
UserId,
VideoFrameSource,
} from 'ringrtc';
import { uniqBy, noop } from 'lodash';
import { ActionsType as UxActionsType } from '../state/ducks/calling';
import {
ActionsType as UxActionsType,
GroupCallPeekInfoType,
} from '../state/ducks/calling';
import { getConversationCallMode } from '../state/ducks/conversations';
import { EnvelopeClass } from '../textsecure.d';
import {
@ -292,6 +296,56 @@ export class CallingClass {
return call instanceof GroupCall ? call : undefined;
}
private getGroupCallMembers(conversationId: string) {
return getMembershipList(conversationId).map(
member =>
new GroupMemberInfo(
uuidToArrayBuffer(member.uuid),
member.uuidCiphertext
)
);
}
public async peekGroupCall(conversationId: string): Promise<PeekInfo> {
// This can be undefined in two cases:
//
// 1. There is no group call instance. This is "stateless peeking", and is expected
// when we want to peek on a call that we've never connected to.
// 2. There is a group call instance but RingRTC doesn't have the peek info yet. This
// should only happen for a brief period as you connect to the call. (You probably
// don't want to call this function while a group call is connected—you should
// instead be grabbing the peek info off of the instance—but we handle it here
// to avoid possible race conditions.)
const statefulPeekInfo = this.getGroupCall(conversationId)?.getPeekInfo();
if (statefulPeekInfo) {
return statefulPeekInfo;
}
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('Missing conversation; not peeking group call');
}
const publicParams = conversation.get('publicParams');
const secretParams = conversation.get('secretParams');
if (!publicParams || !secretParams) {
throw new Error(
'Conversation is missing required parameters. Cannot peek group call'
);
}
const proof = await fetchMembershipProof({ publicParams, secretParams });
if (!proof) {
throw new Error('No membership proof. Cannot peek group call');
}
const membershipProof = new TextEncoder().encode(proof).buffer;
return RingRTC.peekGroupCall(
RINGRTC_SFU_URL,
membershipProof,
this.getGroupCallMembers(conversationId)
);
}
/**
* Connect to a conversation's group call and connect it to Redux.
*
@ -379,16 +433,8 @@ export class CallingClass {
isRequestingMembershipProof = false;
}
},
requestGroupMembers(groupCall) {
groupCall.setGroupMembers(
getMembershipList(conversationId).map(
member =>
new GroupMemberInfo(
uuidToArrayBuffer(member.uuid),
member.uuidCiphertext
)
)
);
requestGroupMembers: groupCall => {
groupCall.setGroupMembers(this.getGroupCallMembers(conversationId));
},
onEnded: noop,
}
@ -480,6 +526,30 @@ export class CallingClass {
}
}
private uuidToConversationId(userId: ArrayBuffer): string {
const result = window.ConversationController.ensureContactIds({
uuid: arrayBufferToUuid(userId),
});
if (!result) {
throw new Error(
'Calling.uuidToConversationId: no conversation found for that UUID'
);
}
return result;
}
public formatGroupCallPeekInfoForRedux(
peekInfo: PeekInfo = { joinedMembers: [], deviceCount: 0 }
): GroupCallPeekInfoType {
return {
conversationIds: peekInfo.joinedMembers.map(this.uuidToConversationId),
creator: peekInfo.creator && this.uuidToConversationId(peekInfo.creator),
eraId: peekInfo.eraId,
maxDevices: peekInfo.maxDevices ?? Infinity,
deviceCount: peekInfo.deviceCount,
};
}
private formatGroupCallForRedux(groupCall: GroupCall) {
const localDeviceState = groupCall.getLocalDeviceState();
@ -491,6 +561,14 @@ export class CallingClass {
remoteDeviceState => remoteDeviceState.demuxId
);
// `GroupCall.prototype.getPeekInfo()` won't return anything at first, so we try to
// set a reasonable default based on the remote device states (which is likely an
// empty array at this point, but we handle the case where it is not).
const peekInfo = groupCall.getPeekInfo() || {
joinedMembers: remoteDeviceStates.map(({ userId }) => userId),
deviceCount: remoteDeviceStates.length,
};
// It should be impossible to be disconnected and Joining or Joined. Just in case, we
// try to handle that case.
const joinState: GroupCallJoinState =
@ -498,7 +576,7 @@ export class CallingClass {
? GroupCallJoinState.NotJoined
: this.convertRingRtcJoinState(localDeviceState.joinState);
const ourId = window.ConversationController.getOurConversationId();
const ourConversationId = window.ConversationController.getOurConversationId();
return {
connectionState: this.convertRingRtcConnectionState(
@ -507,22 +585,17 @@ export class CallingClass {
joinState,
hasLocalAudio: !localDeviceState.audioMuted,
hasLocalVideo: !localDeviceState.videoMuted,
peekInfo: this.formatGroupCallPeekInfoForRedux(peekInfo),
remoteParticipants: remoteDeviceStates.map(remoteDeviceState => {
const uuid = arrayBufferToUuid(remoteDeviceState.userId);
const id = window.ConversationController.ensureContactIds({ uuid });
if (!id) {
throw new Error(
'Calling.formatGroupCallForRedux: no conversation found'
);
}
const conversationId = this.uuidToConversationId(
remoteDeviceState.userId
);
return {
conversationId: id,
conversationId,
demuxId: remoteDeviceState.demuxId,
hasRemoteAudio: !remoteDeviceState.audioMuted,
hasRemoteVideo: !remoteDeviceState.videoMuted,
isSelf: id === ourId,
isSelf: conversationId === ourConversationId,
// If RingRTC doesn't send us an aspect ratio, we make a guess.
videoAspectRatio:
remoteDeviceState.videoAspectRatio ||

View file

@ -26,9 +26,19 @@ import {
bounceAppIconStart,
bounceAppIconStop,
} from '../../shims/bounceAppIcon';
import { sleep } from '../../util/sleep';
import { LatestQueue } from '../../util/LatestQueue';
// State
export interface GroupCallPeekInfoType {
conversationIds: Array<string>;
creator?: string;
eraId?: string;
maxDevices: number;
deviceCount: number;
}
export interface GroupCallParticipantInfoType {
conversationId: string;
demuxId: number;
@ -53,6 +63,7 @@ export interface GroupCallStateType {
conversationId: string;
connectionState: GroupCallConnectionState;
joinState: GroupCallJoinState;
peekInfo: GroupCallPeekInfoType;
remoteParticipants: Array<GroupCallParticipantInfoType>;
}
@ -103,15 +114,20 @@ export type DeclineCallType = {
conversationId: string;
};
export type GroupCallStateChangeType = {
conversationId: string;
type GroupCallStateChangeArgumentType = {
connectionState: GroupCallConnectionState;
joinState: GroupCallJoinState;
conversationId: string;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
joinState: GroupCallJoinState;
peekInfo: GroupCallPeekInfoType;
remoteParticipants: Array<GroupCallParticipantInfoType>;
};
type GroupCallStateChangeActionPayloadType = GroupCallStateChangeArgumentType & {
ourConversationId: string;
};
export type HangUpType = {
conversationId: string;
};
@ -121,6 +137,10 @@ export type IncomingCallType = {
isVideoCall: boolean;
};
type PeekNotConnectedGroupCallType = {
conversationId: string;
};
interface StartDirectCallType {
conversationId: string;
hasLocalAudio: boolean;
@ -158,6 +178,7 @@ export type ShowCallLobbyType =
joinState: GroupCallJoinState;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
peekInfo: GroupCallPeekInfoType;
remoteParticipants: Array<GroupCallParticipantInfoType>;
};
@ -178,6 +199,11 @@ export const getActiveCall = ({
activeCallState &&
getOwn(callsByConversation, activeCallState.conversationId);
export const isAnybodyElseInGroupCall = (
{ conversationIds }: Readonly<GroupCallPeekInfoType>,
ourConversationId: string
): boolean => conversationIds.some(id => id !== ourConversationId);
// Actions
const ACCEPT_CALL_PENDING = 'calling/ACCEPT_CALL_PENDING';
@ -191,6 +217,8 @@ const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE';
const HANG_UP = 'calling/HANG_UP';
const INCOMING_CALL = 'calling/INCOMING_CALL';
const OUTGOING_CALL = 'calling/OUTGOING_CALL';
const PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED =
'calling/PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED';
const REFRESH_IO_DEVICES = 'calling/REFRESH_IO_DEVICES';
const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE';
const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED';
@ -234,9 +262,9 @@ type DeclineCallActionType = {
payload: DeclineCallType;
};
type GroupCallStateChangeActionType = {
export type GroupCallStateChangeActionType = {
type: 'calling/GROUP_CALL_STATE_CHANGE';
payload: GroupCallStateChangeType;
payload: GroupCallStateChangeActionPayloadType;
};
type HangUpActionType = {
@ -254,6 +282,15 @@ type OutgoingCallActionType = {
payload: StartDirectCallType;
};
type PeekNotConnectedGroupCallFulfilledActionType = {
type: 'calling/PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED';
payload: {
conversationId: string;
peekInfo: GroupCallPeekInfoType;
ourConversationId: string;
};
};
type RefreshIODevicesActionType = {
type: 'calling/REFRESH_IO_DEVICES';
payload: MediaDeviceSettings;
@ -308,6 +345,7 @@ export type CallingActionType =
| HangUpActionType
| IncomingCallActionType
| OutgoingCallActionType
| PeekNotConnectedGroupCallFulfilledActionType
| RefreshIODevicesActionType
| RemoteVideoChangeActionType
| SetLocalAudioActionType
@ -439,11 +477,16 @@ function declineCall(payload: DeclineCallType): DeclineCallActionType {
}
function groupCallStateChange(
payload: GroupCallStateChangeType
): GroupCallStateChangeActionType {
return {
type: GROUP_CALL_STATE_CHANGE,
payload,
payload: GroupCallStateChangeArgumentType
): ThunkAction<void, RootStateType, unknown, GroupCallStateChangeActionType> {
return (dispatch, getState) => {
dispatch({
type: GROUP_CALL_STATE_CHANGE,
payload: {
...payload,
ourConversationId: getState().user.ourConversationId,
},
});
};
}
@ -474,6 +517,79 @@ function outgoingCall(payload: StartDirectCallType): OutgoingCallActionType {
};
}
// We might call this function many times in rapid succession (for example, if lots of
// people are joining and leaving at once). We want to make sure to update eventually
// (if people join and leave for an hour, we don't want you to have to wait an hour to
// get an update), and we also don't want to update too often. That's why we use a
// "latest queue".
const peekQueueByConversation = new Map<string, LatestQueue>();
function peekNotConnectedGroupCall(
payload: PeekNotConnectedGroupCallType
): ThunkAction<
void,
RootStateType,
unknown,
PeekNotConnectedGroupCallFulfilledActionType
> {
return (dispatch, getState) => {
const { conversationId } = payload;
let queue = peekQueueByConversation.get(conversationId);
if (!queue) {
queue = new LatestQueue();
queue.onceEmpty(() => {
peekQueueByConversation.delete(conversationId);
});
peekQueueByConversation.set(conversationId, queue);
}
queue.add(async () => {
const state = getState();
// We make sure we're not trying to peek at a connected (or connecting, or
// reconnecting) call. Because this is asynchronous, it's possible that the call
// will connect by the time we dispatch, so we also need to do a similar check in
// the reducer.
const existingCall = getOwn(
state.calling.callsByConversation,
conversationId
);
if (
existingCall?.callMode === CallMode.Group &&
existingCall.connectionState !== GroupCallConnectionState.NotConnected
) {
return;
}
// If we peek right after receiving the message, we may get outdated information.
// This is most noticeable when someone leaves. We add a delay and then make sure
// to only be peeking once.
await sleep(1000);
let peekInfo;
try {
peekInfo = await calling.peekGroupCall(conversationId);
} catch (err) {
window.log.error('Group call peeking failed', err);
return;
}
if (!peekInfo) {
return;
}
dispatch({
type: PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED,
payload: {
conversationId,
peekInfo: calling.formatGroupCallPeekInfoForRedux(peekInfo),
ourConversationId: state.user.ourConversationId,
},
});
});
};
}
function refreshIODevices(
payload: MediaDeviceSettings
): RefreshIODevicesActionType {
@ -631,6 +747,7 @@ export const actions = {
hangUp,
receiveIncomingCall,
outgoingCall,
peekNotConnectedGroupCall,
refreshIODevices,
remoteVideoChange,
setLocalPreview,
@ -699,6 +816,7 @@ export function reducer(
conversationId: action.payload.conversationId,
connectionState: action.payload.connectionState,
joinState: action.payload.joinState,
peekInfo: action.payload.peekInfo,
remoteParticipants: action.payload.remoteParticipants,
};
break;
@ -878,23 +996,40 @@ export function reducer(
if (action.type === GROUP_CALL_STATE_CHANGE) {
const {
conversationId,
connectionState,
joinState,
conversationId,
hasLocalAudio,
hasLocalVideo,
joinState,
ourConversationId,
peekInfo,
remoteParticipants,
} = action.payload;
let newActiveCallState: ActiveCallStateType | undefined;
if (connectionState === GroupCallConnectionState.NotConnected) {
return {
...state,
callsByConversation: omit(callsByConversation, conversationId),
activeCallState:
state.activeCallState?.conversationId === conversationId
? undefined
: state.activeCallState,
};
newActiveCallState =
state.activeCallState?.conversationId === conversationId
? undefined
: state.activeCallState;
if (!isAnybodyElseInGroupCall(peekInfo, ourConversationId)) {
return {
...state,
callsByConversation: omit(callsByConversation, conversationId),
activeCallState: newActiveCallState,
};
}
} else {
newActiveCallState =
state.activeCallState?.conversationId === conversationId
? {
...state.activeCallState,
hasLocalAudio,
hasLocalVideo,
}
: state.activeCallState;
}
return {
@ -906,17 +1041,63 @@ export function reducer(
conversationId,
connectionState,
joinState,
peekInfo,
remoteParticipants,
},
},
activeCallState:
state.activeCallState?.conversationId === conversationId
? {
...state.activeCallState,
hasLocalAudio,
hasLocalVideo,
}
: state.activeCallState,
activeCallState: newActiveCallState,
};
}
if (action.type === PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED) {
const { conversationId, peekInfo, ourConversationId } = action.payload;
const existingCall = getOwn(state.callsByConversation, conversationId) || {
callMode: CallMode.Group,
conversationId,
connectionState: GroupCallConnectionState.NotConnected,
joinState: GroupCallJoinState.NotJoined,
peekInfo: {
conversationIds: [],
maxDevices: Infinity,
deviceCount: 0,
},
remoteParticipants: [],
};
if (existingCall.callMode !== CallMode.Group) {
window.log.error(
'Unexpected state: trying to update a non-group call. Doing nothing'
);
return state;
}
// This action should only update non-connected group calls. It's not necessarily a
// mistake if this action is dispatched "over" a connected call. Here's a valid
// sequence of events:
//
// 1. We ask RingRTC to peek, kicking off an asynchronous operation.
// 2. The associated group call is joined.
// 3. The peek promise from step 1 resolves.
if (
existingCall.connectionState !== GroupCallConnectionState.NotConnected
) {
return state;
}
if (!isAnybodyElseInGroupCall(peekInfo, ourConversationId)) {
return removeConversationFromState(state, conversationId);
}
return {
...state,
callsByConversation: {
...callsByConversation,
[conversationId]: {
...existingCall,
peekInfo,
},
},
};
}

View file

@ -14,8 +14,9 @@ import {
ConversationType,
getConversationCallMode,
} from '../ducks/conversations';
import { getActiveCall } from '../ducks/calling';
import { getIntl } from '../selectors/user';
import { getActiveCall, isAnybodyElseInGroupCall } from '../ducks/calling';
import { getUserConversationId, getIntl } from '../selectors/user';
import { getOwn } from '../../util/getOwn';
import { missingCaseError } from '../../util/missingCaseError';
export interface OwnProps {
@ -43,7 +44,9 @@ const getOutgoingCallButtonStyle = (
conversation: ConversationType,
state: StateType
): OutgoingCallButtonStyle => {
if (getActiveCall(state.calling)) {
const { calling } = state;
if (getActiveCall(calling)) {
return OutgoingCallButtonStyle.None;
}
@ -53,11 +56,19 @@ const getOutgoingCallButtonStyle = (
return OutgoingCallButtonStyle.None;
case CallMode.Direct:
return OutgoingCallButtonStyle.Both;
case CallMode.Group:
case CallMode.Group: {
if (!window.GROUP_CALLING) {
return OutgoingCallButtonStyle.None;
}
const call = getOwn(calling.callsByConversation, conversation.id);
if (
call?.callMode === CallMode.Group &&
isAnybodyElseInGroupCall(call.peekInfo, getUserConversationId(state))
) {
return OutgoingCallButtonStyle.Join;
}
return OutgoingCallButtonStyle.JustVideo;
}
default:
throw missingCaseError(conversationCallMode);
}

View file

@ -7,9 +7,11 @@ import { reducer as rootReducer } from '../../../state/reducer';
import { noopAction } from '../../../state/ducks/noop';
import {
CallingStateType,
GroupCallStateChangeActionType,
actions,
getActiveCall,
getEmptyState,
isAnybodyElseInGroupCall,
reducer,
} from '../../../state/ducks/calling';
import { calling as callingService } from '../../../services/calling';
@ -69,6 +71,13 @@ describe('calling duck', () => {
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined,
peekInfo: {
conversationIds: ['456'],
creator: '456',
eraId: 'xyz',
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
conversationId: '123',
@ -95,7 +104,18 @@ describe('calling duck', () => {
},
};
const getEmptyRootState = () => rootReducer(undefined, noopAction());
const ourConversationId = 'ebf5fd79-9344-4ec1-b5c9-af463572caf5';
const getEmptyRootState = () => {
const rootState = rootReducer(undefined, noopAction());
return {
...rootState,
user: {
...rootState.user,
ourConversationId,
},
};
};
beforeEach(function beforeEach() {
this.sandbox = sinon.createSandbox();
@ -235,15 +255,30 @@ describe('calling duck', () => {
describe('groupCallStateChange', () => {
const { groupCallStateChange } = actions;
it('ignores new calls that are not connected', () => {
function getAction(
...args: Parameters<typeof groupCallStateChange>
): GroupCallStateChangeActionType {
const dispatch = sinon.spy();
groupCallStateChange(...args)(dispatch, getEmptyRootState, null);
return dispatch.getCall(0).args[0];
}
it('ignores non-connected calls with no peeked participants', () => {
const result = reducer(
getEmptyState(),
groupCallStateChange({
getAction({
conversationId: 'abc123',
connectionState: GroupCallConnectionState.NotConnected,
joinState: GroupCallJoinState.NotJoined,
hasLocalAudio: false,
hasLocalVideo: false,
peekInfo: {
conversationIds: [],
maxDevices: 16,
deviceCount: 0,
},
remoteParticipants: [],
})
);
@ -251,15 +286,20 @@ describe('calling duck', () => {
assert.deepEqual(result, getEmptyState());
});
it('removes the call from the map of conversations if the call is disconnected', () => {
it('removes the call from the map of conversations if the call is not connected and has no peeked participants', () => {
const result = reducer(
stateWithGroupCall,
groupCallStateChange({
getAction({
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.NotConnected,
joinState: GroupCallJoinState.NotJoined,
hasLocalAudio: false,
hasLocalVideo: false,
peekInfo: {
conversationIds: [],
maxDevices: 16,
deviceCount: 0,
},
remoteParticipants: [],
})
);
@ -270,15 +310,65 @@ describe('calling duck', () => {
);
});
it('drops the active call if it is disconnected', () => {
it('removes the call from the map of conversations if the call is not connected and has 1 peeked participant: you', () => {
const result = reducer(
stateWithActiveGroupCall,
groupCallStateChange({
stateWithGroupCall,
getAction({
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.NotConnected,
joinState: GroupCallJoinState.NotJoined,
hasLocalAudio: false,
hasLocalVideo: false,
peekInfo: {
conversationIds: [ourConversationId],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [],
})
);
assert.notProperty(
result.callsByConversation,
'fake-group-call-conversation-id'
);
});
it('drops the active call if it is disconnected with no peeked participants', () => {
const result = reducer(
stateWithActiveGroupCall,
getAction({
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.NotConnected,
joinState: GroupCallJoinState.NotJoined,
hasLocalAudio: false,
hasLocalVideo: false,
peekInfo: {
conversationIds: [],
maxDevices: 16,
deviceCount: 0,
},
remoteParticipants: [],
})
);
assert.isUndefined(result.activeCallState);
});
it('drops the active call if it is disconnected with 1 peeked participant (you)', () => {
const result = reducer(
stateWithActiveGroupCall,
getAction({
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.NotConnected,
joinState: GroupCallJoinState.NotJoined,
hasLocalAudio: false,
hasLocalVideo: false,
peekInfo: {
conversationIds: [ourConversationId],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [],
})
);
@ -289,12 +379,19 @@ describe('calling duck', () => {
it('saves a new call to the map of conversations', () => {
const result = reducer(
getEmptyState(),
groupCallStateChange({
getAction({
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joining,
hasLocalAudio: true,
hasLocalVideo: false,
peekInfo: {
conversationIds: ['456'],
creator: '456',
eraId: 'xyz',
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
conversationId: '123',
@ -315,6 +412,13 @@ describe('calling duck', () => {
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joining,
peekInfo: {
conversationIds: ['456'],
creator: '456',
eraId: 'xyz',
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
conversationId: '123',
@ -329,15 +433,55 @@ describe('calling duck', () => {
);
});
it('saves a new call to the map of conversations if the call is disconnected by has peeked participants that are not you', () => {
const result = reducer(
stateWithGroupCall,
getAction({
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.NotConnected,
joinState: GroupCallJoinState.NotJoined,
hasLocalAudio: false,
hasLocalVideo: false,
peekInfo: {
conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [],
})
);
assert.deepEqual(
result.callsByConversation['fake-group-call-conversation-id'],
{
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.NotConnected,
joinState: GroupCallJoinState.NotJoined,
peekInfo: {
conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [],
}
);
});
it('updates a call in the map of conversations', () => {
const result = reducer(
stateWithGroupCall,
groupCallStateChange({
getAction({
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
hasLocalAudio: true,
hasLocalVideo: false,
peekInfo: {
conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
conversationId: '123',
@ -358,6 +502,11 @@ describe('calling duck', () => {
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekInfo: {
conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
conversationId: '123',
@ -375,12 +524,17 @@ describe('calling duck', () => {
it("if no call is active, doesn't touch the active call state", () => {
const result = reducer(
stateWithGroupCall,
groupCallStateChange({
getAction({
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
hasLocalAudio: true,
hasLocalVideo: false,
peekInfo: {
conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
conversationId: '123',
@ -400,12 +554,17 @@ describe('calling duck', () => {
it("if the call is not active, doesn't touch the active call state", () => {
const result = reducer(
stateWithActiveGroupCall,
groupCallStateChange({
getAction({
conversationId: 'another-fake-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
hasLocalAudio: true,
hasLocalVideo: true,
peekInfo: {
conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
conversationId: '123',
@ -432,12 +591,17 @@ describe('calling duck', () => {
it('if the call is active, updates the active call state', () => {
const result = reducer(
stateWithActiveGroupCall,
groupCallStateChange({
getAction({
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
hasLocalAudio: true,
hasLocalVideo: true,
peekInfo: {
conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
conversationId: '123',
@ -460,6 +624,67 @@ describe('calling duck', () => {
});
});
describe('peekNotConnectedGroupCall', () => {
const { peekNotConnectedGroupCall } = actions;
beforeEach(function beforeEach() {
this.callingServicePeekGroupCall = this.sandbox.stub(
callingService,
'peekGroupCall'
);
this.clock = this.sandbox.useFakeTimers();
});
describe('thunk', () => {
function noopTest(connectionState: GroupCallConnectionState) {
return async function test(this: Mocha.ITestCallbackContext) {
const dispatch = sinon.spy();
await peekNotConnectedGroupCall({
conversationId: 'fake-group-call-conversation-id',
})(
dispatch,
() => ({
...getEmptyRootState(),
calling: {
...stateWithGroupCall,
callsByConversation: {
'fake-group-call-conversation-id': {
...stateWithGroupCall.callsByConversation[
'fake-group-call-conversation-id'
],
connectionState,
},
},
},
}),
null
);
sinon.assert.notCalled(dispatch);
sinon.assert.notCalled(this.callingServicePeekGroupCall);
};
}
it(
'no-ops if trying to peek at a connecting group call',
noopTest(GroupCallConnectionState.Connecting)
);
it(
'no-ops if trying to peek at a connected group call',
noopTest(GroupCallConnectionState.Connected)
);
it(
'no-ops if trying to peek at a reconnecting group call',
noopTest(GroupCallConnectionState.Reconnecting)
);
// These tests are incomplete.
});
});
describe('setLocalAudio', () => {
const { setLocalAudio } = actions;
@ -738,5 +963,52 @@ describe('calling duck', () => {
});
});
});
describe('isAnybodyElseInGroupCall', () => {
const fakePeekInfo = (conversationIds: Array<string>) => ({
conversationIds,
maxDevices: 16,
deviceCount: conversationIds.length,
});
it('returns false if the peek info has no participants', () => {
assert.isFalse(
isAnybodyElseInGroupCall(
fakePeekInfo([]),
'2cd7b14c-3433-4b3c-9685-1ef1e2d26db2'
)
);
});
it('returns false if the peek info has one participant, you', () => {
assert.isFalse(
isAnybodyElseInGroupCall(
fakePeekInfo(['2cd7b14c-3433-4b3c-9685-1ef1e2d26db2']),
'2cd7b14c-3433-4b3c-9685-1ef1e2d26db2'
)
);
});
it('returns true if the peek info has one participant, someone else', () => {
assert.isTrue(
isAnybodyElseInGroupCall(
fakePeekInfo(['ca0ae16c-2936-4c68-86b1-a6f82e8fe67f']),
'2cd7b14c-3433-4b3c-9685-1ef1e2d26db2'
)
);
});
it('returns true if the peek info has two participants, you and someone else', () => {
assert.isTrue(
isAnybodyElseInGroupCall(
fakePeekInfo([
'ca0ae16c-2936-4c68-86b1-a6f82e8fe67f',
'2cd7b14c-3433-4b3c-9685-1ef1e2d26db2',
]),
'2cd7b14c-3433-4b3c-9685-1ef1e2d26db2'
)
);
});
});
});
});

View file

@ -0,0 +1,53 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as sinon from 'sinon';
import { LatestQueue } from '../../util/LatestQueue';
describe('LatestQueue', () => {
it('if the queue is empty, new tasks are started immediately', done => {
new LatestQueue().add(async () => {
done();
});
});
it('only enqueues the latest operation', done => {
const queue = new LatestQueue();
const spy = sinon.spy();
let openFirstTaskGate: undefined | (() => void);
const firstTaskGate = new Promise(resolve => {
openFirstTaskGate = resolve;
});
if (!openFirstTaskGate) {
throw new Error('Test is misconfigured; cannot grab inner resolve');
}
queue.add(async () => {
await firstTaskGate;
spy('first');
});
queue.add(async () => {
spy('second');
});
queue.add(async () => {
spy('third');
});
sinon.assert.notCalled(spy);
openFirstTaskGate();
queue.onceEmpty(() => {
sinon.assert.calledTwice(spy);
sinon.assert.calledWith(spy, 'first');
sinon.assert.calledWith(spy, 'third');
done();
});
});
});

69
ts/util/LatestQueue.ts Normal file
View file

@ -0,0 +1,69 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/**
* This class tries to enforce a state machine that looks something like this:
*
* .--------------------. called .-----------. called .---------------------.
* | | --------> | | -------> | |
* | Nothing is running | | 1 running | | 1 running, 1 queued |
* | | <-------- | | <------- | |
* '--------------------' done '-----------' done '---------------------'
* | ^
* '-----------'
* called
*
* Most notably, if something is queued and the function is called again, we discard the
* previously queued task completely.
*/
export class LatestQueue {
private isRunning: boolean;
private queuedTask?: () => Promise<void>;
private onceEmptyCallbacks: Array<() => unknown>;
constructor() {
this.isRunning = false;
this.onceEmptyCallbacks = [];
}
/**
* Does one of the following:
*
* 1. Runs the task immediately.
* 2. Enqueues the task, destroying any previously-enqueued task. In other words, 0 or 1
* tasks will be enqueued at a time.
*/
add(task: () => Promise<void>): void {
if (this.isRunning) {
this.queuedTask = task;
} else {
this.isRunning = true;
task().finally(() => {
this.isRunning = false;
const { queuedTask } = this;
if (queuedTask) {
this.queuedTask = undefined;
this.add(queuedTask);
} else {
try {
this.onceEmptyCallbacks.forEach(callback => {
callback();
});
} finally {
this.onceEmptyCallbacks = [];
}
}
});
}
}
/**
* Adds a callback to be called the first time the queue goes from "running" to "empty".
*/
onceEmpty(callback: () => unknown): void {
this.onceEmptyCallbacks.push(callback);
}
}

View file

@ -14728,7 +14728,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/ConversationHeader.js",
"line": " this.menuTriggerRef = react_1.default.createRef();",
"lineNumber": 28,
"lineNumber": 29,
"reasonCategory": "usageTrusted",
"updated": "2020-08-28T16:12:19.904Z",
"reasonDetail": "Used to reference popup menu"
@ -14737,7 +14737,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/ConversationHeader.tsx",
"line": " this.menuTriggerRef = React.createRef();",
"lineNumber": 100,
"lineNumber": 101,
"reasonCategory": "usageTrusted",
"updated": "2020-05-20T20:10:43.540Z",
"reasonDetail": "Used to reference popup menu"