Group calling: Peek into a group call
This commit is contained in:
parent
af6ec26225
commit
6d53cb1740
15 changed files with 858 additions and 111 deletions
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -29,6 +29,11 @@ function getGroupCallState(): GroupCallStateType {
|
|||
conversationId: '3051234567',
|
||||
connectionState: 2,
|
||||
joinState: 2,
|
||||
peekInfo: {
|
||||
conversationIds: [],
|
||||
maxDevices: 16,
|
||||
deviceCount: 0,
|
||||
},
|
||||
remoteParticipants: [],
|
||||
};
|
||||
}
|
||||
|
|
|
@ -113,6 +113,11 @@ story.add('Group Call', () => {
|
|||
conversationId: '3051234567',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
peekInfo: {
|
||||
conversationIds: [],
|
||||
maxDevices: 16,
|
||||
deviceCount: 0,
|
||||
},
|
||||
remoteParticipants: [],
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 ||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
53
ts/test/util/LatestQueue_test.ts
Normal file
53
ts/test/util/LatestQueue_test.ts
Normal 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
69
ts/util/LatestQueue.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue