Group calling: Peek into a group call
This commit is contained in:
parent
af6ec26225
commit
6d53cb1740
|
@ -3023,6 +3023,10 @@
|
||||||
"message": "Start a video call",
|
"message": "Start a video call",
|
||||||
"description": "Title for the video call button in a conversation"
|
"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": {
|
"callNeedPermission": {
|
||||||
"message": "$title$ will get a message request from you. You can call once your message request has been accepted.",
|
"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",
|
"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 {
|
.module-conversation-header__calling-button {
|
||||||
@include light-theme {
|
$icon-size: 24px;
|
||||||
@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;
|
|
||||||
margin-left: 12px;
|
margin-left: 12px;
|
||||||
border: none;
|
border: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
@ -3074,31 +3062,65 @@ span.module-in-contacts-icon__tooltip {
|
||||||
&--show {
|
&--show {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.module-conversation-header__video-calling-button {
|
&--video {
|
||||||
@include light-theme {
|
@include light-theme {
|
||||||
@include color-svg(
|
@include color-svg(
|
||||||
'../images/icons/v2/video-outline-24.svg',
|
'../images/icons/v2/video-outline-24.svg',
|
||||||
$color-gray-75
|
$color-gray-75
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@include dark-theme {
|
@include dark-theme {
|
||||||
@include color-svg('../images/icons/v2/video-solid-24.svg', $color-gray-15);
|
@include color-svg(
|
||||||
}
|
'../images/icons/v2/video-solid-24.svg',
|
||||||
height: 24px;
|
$color-gray-15
|
||||||
width: 24px;
|
);
|
||||||
margin-left: 12px;
|
}
|
||||||
border: none;
|
height: $icon-size;
|
||||||
opacity: 0;
|
width: $icon-size;
|
||||||
transition: opacity 250ms ease-out;
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
cursor: default;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&--show {
|
&--audio {
|
||||||
opacity: 1;
|
@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();
|
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
|
// Don't wait for handleDataMessage, as it has its own per-conversation queueing
|
||||||
message.handleDataMessage(data.message, event.confirm);
|
message.handleDataMessage(data.message, event.confirm);
|
||||||
|
|
||||||
|
|
|
@ -116,6 +116,11 @@ story.add('Ongoing Group Call', () => (
|
||||||
conversationId: '3051234567',
|
conversationId: '3051234567',
|
||||||
connectionState: GroupCallConnectionState.Connected,
|
connectionState: GroupCallConnectionState.Connected,
|
||||||
joinState: GroupCallJoinState.Joined,
|
joinState: GroupCallJoinState.Joined,
|
||||||
|
peekInfo: {
|
||||||
|
conversationIds: [],
|
||||||
|
maxDevices: 16,
|
||||||
|
deviceCount: 0,
|
||||||
|
},
|
||||||
remoteParticipants: [],
|
remoteParticipants: [],
|
||||||
},
|
},
|
||||||
activeCallState: getCallState(),
|
activeCallState: getCallState(),
|
||||||
|
|
|
@ -29,6 +29,11 @@ function getGroupCallState(): GroupCallStateType {
|
||||||
conversationId: '3051234567',
|
conversationId: '3051234567',
|
||||||
connectionState: 2,
|
connectionState: 2,
|
||||||
joinState: 2,
|
joinState: 2,
|
||||||
|
peekInfo: {
|
||||||
|
conversationIds: [],
|
||||||
|
maxDevices: 16,
|
||||||
|
deviceCount: 0,
|
||||||
|
},
|
||||||
remoteParticipants: [],
|
remoteParticipants: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -113,6 +113,11 @@ story.add('Group Call', () => {
|
||||||
conversationId: '3051234567',
|
conversationId: '3051234567',
|
||||||
connectionState: GroupCallConnectionState.Connected,
|
connectionState: GroupCallConnectionState.Connected,
|
||||||
joinState: GroupCallJoinState.Joined,
|
joinState: GroupCallJoinState.Joined,
|
||||||
|
peekInfo: {
|
||||||
|
conversationIds: [],
|
||||||
|
maxDevices: 16,
|
||||||
|
deviceCount: 0,
|
||||||
|
},
|
||||||
remoteParticipants: [],
|
remoteParticipants: [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -208,6 +208,21 @@ const stories: Array<ConversationHeaderStory> = [
|
||||||
outgoingCallButtonStyle: OutgoingCallButtonStyle.JustVideo,
|
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,
|
None,
|
||||||
JustVideo,
|
JustVideo,
|
||||||
Both,
|
Both,
|
||||||
|
Join,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PropsDataType {
|
export interface PropsDataType {
|
||||||
|
@ -280,10 +281,11 @@ export class ConversationHeader extends React.Component<PropsType> {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOutgoingVideoCallInConversation}
|
onClick={onOutgoingVideoCallInConversation}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'module-conversation-header__video-calling-button',
|
'module-conversation-header__calling-button',
|
||||||
|
'module-conversation-header__calling-button--video',
|
||||||
showBackButton
|
showBackButton
|
||||||
? null
|
? null
|
||||||
: 'module-conversation-header__video-calling-button--show'
|
: 'module-conversation-header__calling-button--show'
|
||||||
)}
|
)}
|
||||||
disabled={showBackButton}
|
disabled={showBackButton}
|
||||||
aria-label={i18n('makeOutgoingVideoCall')}
|
aria-label={i18n('makeOutgoingVideoCall')}
|
||||||
|
@ -303,16 +305,34 @@ export class ConversationHeader extends React.Component<PropsType> {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOutgoingAudioCallInConversation}
|
onClick={onOutgoingAudioCallInConversation}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'module-conversation-header__audio-calling-button',
|
'module-conversation-header__calling-button',
|
||||||
|
'module-conversation-header__calling-button--audio',
|
||||||
showBackButton
|
showBackButton
|
||||||
? null
|
? null
|
||||||
: 'module-conversation-header__audio-calling-button--show'
|
: 'module-conversation-header__calling-button--show'
|
||||||
)}
|
)}
|
||||||
disabled={showBackButton}
|
disabled={showBackButton}
|
||||||
aria-label={i18n('makeOutgoingCall')}
|
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:
|
default:
|
||||||
throw missingCaseError(outgoingCallButtonStyle);
|
throw missingCaseError(outgoingCallButtonStyle);
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,13 +23,17 @@ import {
|
||||||
HangupType,
|
HangupType,
|
||||||
OfferType,
|
OfferType,
|
||||||
OpaqueMessage,
|
OpaqueMessage,
|
||||||
|
PeekInfo,
|
||||||
RingRTC,
|
RingRTC,
|
||||||
UserId,
|
UserId,
|
||||||
VideoFrameSource,
|
VideoFrameSource,
|
||||||
} from 'ringrtc';
|
} from 'ringrtc';
|
||||||
import { uniqBy, noop } from 'lodash';
|
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 { getConversationCallMode } from '../state/ducks/conversations';
|
||||||
import { EnvelopeClass } from '../textsecure.d';
|
import { EnvelopeClass } from '../textsecure.d';
|
||||||
import {
|
import {
|
||||||
|
@ -292,6 +296,56 @@ export class CallingClass {
|
||||||
return call instanceof GroupCall ? call : undefined;
|
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.
|
* Connect to a conversation's group call and connect it to Redux.
|
||||||
*
|
*
|
||||||
|
@ -379,16 +433,8 @@ export class CallingClass {
|
||||||
isRequestingMembershipProof = false;
|
isRequestingMembershipProof = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
requestGroupMembers(groupCall) {
|
requestGroupMembers: groupCall => {
|
||||||
groupCall.setGroupMembers(
|
groupCall.setGroupMembers(this.getGroupCallMembers(conversationId));
|
||||||
getMembershipList(conversationId).map(
|
|
||||||
member =>
|
|
||||||
new GroupMemberInfo(
|
|
||||||
uuidToArrayBuffer(member.uuid),
|
|
||||||
member.uuidCiphertext
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onEnded: noop,
|
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) {
|
private formatGroupCallForRedux(groupCall: GroupCall) {
|
||||||
const localDeviceState = groupCall.getLocalDeviceState();
|
const localDeviceState = groupCall.getLocalDeviceState();
|
||||||
|
|
||||||
|
@ -491,6 +561,14 @@ export class CallingClass {
|
||||||
remoteDeviceState => remoteDeviceState.demuxId
|
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
|
// It should be impossible to be disconnected and Joining or Joined. Just in case, we
|
||||||
// try to handle that case.
|
// try to handle that case.
|
||||||
const joinState: GroupCallJoinState =
|
const joinState: GroupCallJoinState =
|
||||||
|
@ -498,7 +576,7 @@ export class CallingClass {
|
||||||
? GroupCallJoinState.NotJoined
|
? GroupCallJoinState.NotJoined
|
||||||
: this.convertRingRtcJoinState(localDeviceState.joinState);
|
: this.convertRingRtcJoinState(localDeviceState.joinState);
|
||||||
|
|
||||||
const ourId = window.ConversationController.getOurConversationId();
|
const ourConversationId = window.ConversationController.getOurConversationId();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectionState: this.convertRingRtcConnectionState(
|
connectionState: this.convertRingRtcConnectionState(
|
||||||
|
@ -507,22 +585,17 @@ export class CallingClass {
|
||||||
joinState,
|
joinState,
|
||||||
hasLocalAudio: !localDeviceState.audioMuted,
|
hasLocalAudio: !localDeviceState.audioMuted,
|
||||||
hasLocalVideo: !localDeviceState.videoMuted,
|
hasLocalVideo: !localDeviceState.videoMuted,
|
||||||
|
peekInfo: this.formatGroupCallPeekInfoForRedux(peekInfo),
|
||||||
remoteParticipants: remoteDeviceStates.map(remoteDeviceState => {
|
remoteParticipants: remoteDeviceStates.map(remoteDeviceState => {
|
||||||
const uuid = arrayBufferToUuid(remoteDeviceState.userId);
|
const conversationId = this.uuidToConversationId(
|
||||||
|
remoteDeviceState.userId
|
||||||
const id = window.ConversationController.ensureContactIds({ uuid });
|
);
|
||||||
if (!id) {
|
|
||||||
throw new Error(
|
|
||||||
'Calling.formatGroupCallForRedux: no conversation found'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
conversationId: id,
|
conversationId,
|
||||||
demuxId: remoteDeviceState.demuxId,
|
demuxId: remoteDeviceState.demuxId,
|
||||||
hasRemoteAudio: !remoteDeviceState.audioMuted,
|
hasRemoteAudio: !remoteDeviceState.audioMuted,
|
||||||
hasRemoteVideo: !remoteDeviceState.videoMuted,
|
hasRemoteVideo: !remoteDeviceState.videoMuted,
|
||||||
isSelf: id === ourId,
|
isSelf: conversationId === ourConversationId,
|
||||||
// If RingRTC doesn't send us an aspect ratio, we make a guess.
|
// If RingRTC doesn't send us an aspect ratio, we make a guess.
|
||||||
videoAspectRatio:
|
videoAspectRatio:
|
||||||
remoteDeviceState.videoAspectRatio ||
|
remoteDeviceState.videoAspectRatio ||
|
||||||
|
|
|
@ -26,9 +26,19 @@ import {
|
||||||
bounceAppIconStart,
|
bounceAppIconStart,
|
||||||
bounceAppIconStop,
|
bounceAppIconStop,
|
||||||
} from '../../shims/bounceAppIcon';
|
} from '../../shims/bounceAppIcon';
|
||||||
|
import { sleep } from '../../util/sleep';
|
||||||
|
import { LatestQueue } from '../../util/LatestQueue';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
||||||
|
export interface GroupCallPeekInfoType {
|
||||||
|
conversationIds: Array<string>;
|
||||||
|
creator?: string;
|
||||||
|
eraId?: string;
|
||||||
|
maxDevices: number;
|
||||||
|
deviceCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GroupCallParticipantInfoType {
|
export interface GroupCallParticipantInfoType {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
demuxId: number;
|
demuxId: number;
|
||||||
|
@ -53,6 +63,7 @@ export interface GroupCallStateType {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
connectionState: GroupCallConnectionState;
|
connectionState: GroupCallConnectionState;
|
||||||
joinState: GroupCallJoinState;
|
joinState: GroupCallJoinState;
|
||||||
|
peekInfo: GroupCallPeekInfoType;
|
||||||
remoteParticipants: Array<GroupCallParticipantInfoType>;
|
remoteParticipants: Array<GroupCallParticipantInfoType>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,15 +114,20 @@ export type DeclineCallType = {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GroupCallStateChangeType = {
|
type GroupCallStateChangeArgumentType = {
|
||||||
conversationId: string;
|
|
||||||
connectionState: GroupCallConnectionState;
|
connectionState: GroupCallConnectionState;
|
||||||
joinState: GroupCallJoinState;
|
conversationId: string;
|
||||||
hasLocalAudio: boolean;
|
hasLocalAudio: boolean;
|
||||||
hasLocalVideo: boolean;
|
hasLocalVideo: boolean;
|
||||||
|
joinState: GroupCallJoinState;
|
||||||
|
peekInfo: GroupCallPeekInfoType;
|
||||||
remoteParticipants: Array<GroupCallParticipantInfoType>;
|
remoteParticipants: Array<GroupCallParticipantInfoType>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GroupCallStateChangeActionPayloadType = GroupCallStateChangeArgumentType & {
|
||||||
|
ourConversationId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type HangUpType = {
|
export type HangUpType = {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
};
|
};
|
||||||
|
@ -121,6 +137,10 @@ export type IncomingCallType = {
|
||||||
isVideoCall: boolean;
|
isVideoCall: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PeekNotConnectedGroupCallType = {
|
||||||
|
conversationId: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface StartDirectCallType {
|
interface StartDirectCallType {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
hasLocalAudio: boolean;
|
hasLocalAudio: boolean;
|
||||||
|
@ -158,6 +178,7 @@ export type ShowCallLobbyType =
|
||||||
joinState: GroupCallJoinState;
|
joinState: GroupCallJoinState;
|
||||||
hasLocalAudio: boolean;
|
hasLocalAudio: boolean;
|
||||||
hasLocalVideo: boolean;
|
hasLocalVideo: boolean;
|
||||||
|
peekInfo: GroupCallPeekInfoType;
|
||||||
remoteParticipants: Array<GroupCallParticipantInfoType>;
|
remoteParticipants: Array<GroupCallParticipantInfoType>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -178,6 +199,11 @@ export const getActiveCall = ({
|
||||||
activeCallState &&
|
activeCallState &&
|
||||||
getOwn(callsByConversation, activeCallState.conversationId);
|
getOwn(callsByConversation, activeCallState.conversationId);
|
||||||
|
|
||||||
|
export const isAnybodyElseInGroupCall = (
|
||||||
|
{ conversationIds }: Readonly<GroupCallPeekInfoType>,
|
||||||
|
ourConversationId: string
|
||||||
|
): boolean => conversationIds.some(id => id !== ourConversationId);
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
||||||
const ACCEPT_CALL_PENDING = 'calling/ACCEPT_CALL_PENDING';
|
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 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';
|
||||||
|
const PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED =
|
||||||
|
'calling/PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED';
|
||||||
const REFRESH_IO_DEVICES = 'calling/REFRESH_IO_DEVICES';
|
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';
|
||||||
|
@ -234,9 +262,9 @@ type DeclineCallActionType = {
|
||||||
payload: DeclineCallType;
|
payload: DeclineCallType;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GroupCallStateChangeActionType = {
|
export type GroupCallStateChangeActionType = {
|
||||||
type: 'calling/GROUP_CALL_STATE_CHANGE';
|
type: 'calling/GROUP_CALL_STATE_CHANGE';
|
||||||
payload: GroupCallStateChangeType;
|
payload: GroupCallStateChangeActionPayloadType;
|
||||||
};
|
};
|
||||||
|
|
||||||
type HangUpActionType = {
|
type HangUpActionType = {
|
||||||
|
@ -254,6 +282,15 @@ type OutgoingCallActionType = {
|
||||||
payload: StartDirectCallType;
|
payload: StartDirectCallType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PeekNotConnectedGroupCallFulfilledActionType = {
|
||||||
|
type: 'calling/PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED';
|
||||||
|
payload: {
|
||||||
|
conversationId: string;
|
||||||
|
peekInfo: GroupCallPeekInfoType;
|
||||||
|
ourConversationId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type RefreshIODevicesActionType = {
|
type RefreshIODevicesActionType = {
|
||||||
type: 'calling/REFRESH_IO_DEVICES';
|
type: 'calling/REFRESH_IO_DEVICES';
|
||||||
payload: MediaDeviceSettings;
|
payload: MediaDeviceSettings;
|
||||||
|
@ -308,6 +345,7 @@ export type CallingActionType =
|
||||||
| HangUpActionType
|
| HangUpActionType
|
||||||
| IncomingCallActionType
|
| IncomingCallActionType
|
||||||
| OutgoingCallActionType
|
| OutgoingCallActionType
|
||||||
|
| PeekNotConnectedGroupCallFulfilledActionType
|
||||||
| RefreshIODevicesActionType
|
| RefreshIODevicesActionType
|
||||||
| RemoteVideoChangeActionType
|
| RemoteVideoChangeActionType
|
||||||
| SetLocalAudioActionType
|
| SetLocalAudioActionType
|
||||||
|
@ -439,11 +477,16 @@ function declineCall(payload: DeclineCallType): DeclineCallActionType {
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupCallStateChange(
|
function groupCallStateChange(
|
||||||
payload: GroupCallStateChangeType
|
payload: GroupCallStateChangeArgumentType
|
||||||
): GroupCallStateChangeActionType {
|
): ThunkAction<void, RootStateType, unknown, GroupCallStateChangeActionType> {
|
||||||
return {
|
return (dispatch, getState) => {
|
||||||
type: GROUP_CALL_STATE_CHANGE,
|
dispatch({
|
||||||
payload,
|
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(
|
function refreshIODevices(
|
||||||
payload: MediaDeviceSettings
|
payload: MediaDeviceSettings
|
||||||
): RefreshIODevicesActionType {
|
): RefreshIODevicesActionType {
|
||||||
|
@ -631,6 +747,7 @@ export const actions = {
|
||||||
hangUp,
|
hangUp,
|
||||||
receiveIncomingCall,
|
receiveIncomingCall,
|
||||||
outgoingCall,
|
outgoingCall,
|
||||||
|
peekNotConnectedGroupCall,
|
||||||
refreshIODevices,
|
refreshIODevices,
|
||||||
remoteVideoChange,
|
remoteVideoChange,
|
||||||
setLocalPreview,
|
setLocalPreview,
|
||||||
|
@ -699,6 +816,7 @@ export function reducer(
|
||||||
conversationId: action.payload.conversationId,
|
conversationId: action.payload.conversationId,
|
||||||
connectionState: action.payload.connectionState,
|
connectionState: action.payload.connectionState,
|
||||||
joinState: action.payload.joinState,
|
joinState: action.payload.joinState,
|
||||||
|
peekInfo: action.payload.peekInfo,
|
||||||
remoteParticipants: action.payload.remoteParticipants,
|
remoteParticipants: action.payload.remoteParticipants,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
@ -878,23 +996,40 @@ export function reducer(
|
||||||
|
|
||||||
if (action.type === GROUP_CALL_STATE_CHANGE) {
|
if (action.type === GROUP_CALL_STATE_CHANGE) {
|
||||||
const {
|
const {
|
||||||
conversationId,
|
|
||||||
connectionState,
|
connectionState,
|
||||||
joinState,
|
conversationId,
|
||||||
hasLocalAudio,
|
hasLocalAudio,
|
||||||
hasLocalVideo,
|
hasLocalVideo,
|
||||||
|
joinState,
|
||||||
|
ourConversationId,
|
||||||
|
peekInfo,
|
||||||
remoteParticipants,
|
remoteParticipants,
|
||||||
} = action.payload;
|
} = action.payload;
|
||||||
|
|
||||||
|
let newActiveCallState: ActiveCallStateType | undefined;
|
||||||
|
|
||||||
if (connectionState === GroupCallConnectionState.NotConnected) {
|
if (connectionState === GroupCallConnectionState.NotConnected) {
|
||||||
return {
|
newActiveCallState =
|
||||||
...state,
|
state.activeCallState?.conversationId === conversationId
|
||||||
callsByConversation: omit(callsByConversation, conversationId),
|
? undefined
|
||||||
activeCallState:
|
: state.activeCallState;
|
||||||
state.activeCallState?.conversationId === conversationId
|
|
||||||
? undefined
|
if (!isAnybodyElseInGroupCall(peekInfo, ourConversationId)) {
|
||||||
: state.activeCallState,
|
return {
|
||||||
};
|
...state,
|
||||||
|
callsByConversation: omit(callsByConversation, conversationId),
|
||||||
|
activeCallState: newActiveCallState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newActiveCallState =
|
||||||
|
state.activeCallState?.conversationId === conversationId
|
||||||
|
? {
|
||||||
|
...state.activeCallState,
|
||||||
|
hasLocalAudio,
|
||||||
|
hasLocalVideo,
|
||||||
|
}
|
||||||
|
: state.activeCallState;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -906,17 +1041,63 @@ export function reducer(
|
||||||
conversationId,
|
conversationId,
|
||||||
connectionState,
|
connectionState,
|
||||||
joinState,
|
joinState,
|
||||||
|
peekInfo,
|
||||||
remoteParticipants,
|
remoteParticipants,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
activeCallState:
|
activeCallState: newActiveCallState,
|
||||||
state.activeCallState?.conversationId === conversationId
|
};
|
||||||
? {
|
}
|
||||||
...state.activeCallState,
|
|
||||||
hasLocalAudio,
|
if (action.type === PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED) {
|
||||||
hasLocalVideo,
|
const { conversationId, peekInfo, ourConversationId } = action.payload;
|
||||||
}
|
|
||||||
: state.activeCallState,
|
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,
|
ConversationType,
|
||||||
getConversationCallMode,
|
getConversationCallMode,
|
||||||
} from '../ducks/conversations';
|
} from '../ducks/conversations';
|
||||||
import { getActiveCall } from '../ducks/calling';
|
import { getActiveCall, isAnybodyElseInGroupCall } from '../ducks/calling';
|
||||||
import { getIntl } from '../selectors/user';
|
import { getUserConversationId, getIntl } from '../selectors/user';
|
||||||
|
import { getOwn } from '../../util/getOwn';
|
||||||
import { missingCaseError } from '../../util/missingCaseError';
|
import { missingCaseError } from '../../util/missingCaseError';
|
||||||
|
|
||||||
export interface OwnProps {
|
export interface OwnProps {
|
||||||
|
@ -43,7 +44,9 @@ const getOutgoingCallButtonStyle = (
|
||||||
conversation: ConversationType,
|
conversation: ConversationType,
|
||||||
state: StateType
|
state: StateType
|
||||||
): OutgoingCallButtonStyle => {
|
): OutgoingCallButtonStyle => {
|
||||||
if (getActiveCall(state.calling)) {
|
const { calling } = state;
|
||||||
|
|
||||||
|
if (getActiveCall(calling)) {
|
||||||
return OutgoingCallButtonStyle.None;
|
return OutgoingCallButtonStyle.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,11 +56,19 @@ const getOutgoingCallButtonStyle = (
|
||||||
return OutgoingCallButtonStyle.None;
|
return OutgoingCallButtonStyle.None;
|
||||||
case CallMode.Direct:
|
case CallMode.Direct:
|
||||||
return OutgoingCallButtonStyle.Both;
|
return OutgoingCallButtonStyle.Both;
|
||||||
case CallMode.Group:
|
case CallMode.Group: {
|
||||||
if (!window.GROUP_CALLING) {
|
if (!window.GROUP_CALLING) {
|
||||||
return OutgoingCallButtonStyle.None;
|
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;
|
return OutgoingCallButtonStyle.JustVideo;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw missingCaseError(conversationCallMode);
|
throw missingCaseError(conversationCallMode);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,9 +7,11 @@ import { reducer as rootReducer } from '../../../state/reducer';
|
||||||
import { noopAction } from '../../../state/ducks/noop';
|
import { noopAction } from '../../../state/ducks/noop';
|
||||||
import {
|
import {
|
||||||
CallingStateType,
|
CallingStateType,
|
||||||
|
GroupCallStateChangeActionType,
|
||||||
actions,
|
actions,
|
||||||
getActiveCall,
|
getActiveCall,
|
||||||
getEmptyState,
|
getEmptyState,
|
||||||
|
isAnybodyElseInGroupCall,
|
||||||
reducer,
|
reducer,
|
||||||
} from '../../../state/ducks/calling';
|
} from '../../../state/ducks/calling';
|
||||||
import { calling as callingService } from '../../../services/calling';
|
import { calling as callingService } from '../../../services/calling';
|
||||||
|
@ -69,6 +71,13 @@ describe('calling duck', () => {
|
||||||
conversationId: 'fake-group-call-conversation-id',
|
conversationId: 'fake-group-call-conversation-id',
|
||||||
connectionState: GroupCallConnectionState.Connected,
|
connectionState: GroupCallConnectionState.Connected,
|
||||||
joinState: GroupCallJoinState.NotJoined,
|
joinState: GroupCallJoinState.NotJoined,
|
||||||
|
peekInfo: {
|
||||||
|
conversationIds: ['456'],
|
||||||
|
creator: '456',
|
||||||
|
eraId: 'xyz',
|
||||||
|
maxDevices: 16,
|
||||||
|
deviceCount: 1,
|
||||||
|
},
|
||||||
remoteParticipants: [
|
remoteParticipants: [
|
||||||
{
|
{
|
||||||
conversationId: '123',
|
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() {
|
beforeEach(function beforeEach() {
|
||||||
this.sandbox = sinon.createSandbox();
|
this.sandbox = sinon.createSandbox();
|
||||||
|
@ -235,15 +255,30 @@ describe('calling duck', () => {
|
||||||
describe('groupCallStateChange', () => {
|
describe('groupCallStateChange', () => {
|
||||||
const { groupCallStateChange } = actions;
|
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(
|
const result = reducer(
|
||||||
getEmptyState(),
|
getEmptyState(),
|
||||||
groupCallStateChange({
|
getAction({
|
||||||
conversationId: 'abc123',
|
conversationId: 'abc123',
|
||||||
connectionState: GroupCallConnectionState.NotConnected,
|
connectionState: GroupCallConnectionState.NotConnected,
|
||||||
joinState: GroupCallJoinState.NotJoined,
|
joinState: GroupCallJoinState.NotJoined,
|
||||||
hasLocalAudio: false,
|
hasLocalAudio: false,
|
||||||
hasLocalVideo: false,
|
hasLocalVideo: false,
|
||||||
|
peekInfo: {
|
||||||
|
conversationIds: [],
|
||||||
|
maxDevices: 16,
|
||||||
|
deviceCount: 0,
|
||||||
|
},
|
||||||
remoteParticipants: [],
|
remoteParticipants: [],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -251,15 +286,20 @@ describe('calling duck', () => {
|
||||||
assert.deepEqual(result, getEmptyState());
|
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(
|
const result = reducer(
|
||||||
stateWithGroupCall,
|
stateWithGroupCall,
|
||||||
groupCallStateChange({
|
getAction({
|
||||||
conversationId: 'fake-group-call-conversation-id',
|
conversationId: 'fake-group-call-conversation-id',
|
||||||
connectionState: GroupCallConnectionState.NotConnected,
|
connectionState: GroupCallConnectionState.NotConnected,
|
||||||
joinState: GroupCallJoinState.NotJoined,
|
joinState: GroupCallJoinState.NotJoined,
|
||||||
hasLocalAudio: false,
|
hasLocalAudio: false,
|
||||||
hasLocalVideo: false,
|
hasLocalVideo: false,
|
||||||
|
peekInfo: {
|
||||||
|
conversationIds: [],
|
||||||
|
maxDevices: 16,
|
||||||
|
deviceCount: 0,
|
||||||
|
},
|
||||||
remoteParticipants: [],
|
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(
|
const result = reducer(
|
||||||
stateWithActiveGroupCall,
|
stateWithGroupCall,
|
||||||
groupCallStateChange({
|
getAction({
|
||||||
conversationId: 'fake-group-call-conversation-id',
|
conversationId: 'fake-group-call-conversation-id',
|
||||||
connectionState: GroupCallConnectionState.NotConnected,
|
connectionState: GroupCallConnectionState.NotConnected,
|
||||||
joinState: GroupCallJoinState.NotJoined,
|
joinState: GroupCallJoinState.NotJoined,
|
||||||
hasLocalAudio: false,
|
hasLocalAudio: false,
|
||||||
hasLocalVideo: 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: [],
|
remoteParticipants: [],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -289,12 +379,19 @@ describe('calling duck', () => {
|
||||||
it('saves a new call to the map of conversations', () => {
|
it('saves a new call to the map of conversations', () => {
|
||||||
const result = reducer(
|
const result = reducer(
|
||||||
getEmptyState(),
|
getEmptyState(),
|
||||||
groupCallStateChange({
|
getAction({
|
||||||
conversationId: 'fake-group-call-conversation-id',
|
conversationId: 'fake-group-call-conversation-id',
|
||||||
connectionState: GroupCallConnectionState.Connected,
|
connectionState: GroupCallConnectionState.Connected,
|
||||||
joinState: GroupCallJoinState.Joining,
|
joinState: GroupCallJoinState.Joining,
|
||||||
hasLocalAudio: true,
|
hasLocalAudio: true,
|
||||||
hasLocalVideo: false,
|
hasLocalVideo: false,
|
||||||
|
peekInfo: {
|
||||||
|
conversationIds: ['456'],
|
||||||
|
creator: '456',
|
||||||
|
eraId: 'xyz',
|
||||||
|
maxDevices: 16,
|
||||||
|
deviceCount: 1,
|
||||||
|
},
|
||||||
remoteParticipants: [
|
remoteParticipants: [
|
||||||
{
|
{
|
||||||
conversationId: '123',
|
conversationId: '123',
|
||||||
|
@ -315,6 +412,13 @@ describe('calling duck', () => {
|
||||||
conversationId: 'fake-group-call-conversation-id',
|
conversationId: 'fake-group-call-conversation-id',
|
||||||
connectionState: GroupCallConnectionState.Connected,
|
connectionState: GroupCallConnectionState.Connected,
|
||||||
joinState: GroupCallJoinState.Joining,
|
joinState: GroupCallJoinState.Joining,
|
||||||
|
peekInfo: {
|
||||||
|
conversationIds: ['456'],
|
||||||
|
creator: '456',
|
||||||
|
eraId: 'xyz',
|
||||||
|
maxDevices: 16,
|
||||||
|
deviceCount: 1,
|
||||||
|
},
|
||||||
remoteParticipants: [
|
remoteParticipants: [
|
||||||
{
|
{
|
||||||
conversationId: '123',
|
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', () => {
|
it('updates a call in the map of conversations', () => {
|
||||||
const result = reducer(
|
const result = reducer(
|
||||||
stateWithGroupCall,
|
stateWithGroupCall,
|
||||||
groupCallStateChange({
|
getAction({
|
||||||
conversationId: 'fake-group-call-conversation-id',
|
conversationId: 'fake-group-call-conversation-id',
|
||||||
connectionState: GroupCallConnectionState.Connected,
|
connectionState: GroupCallConnectionState.Connected,
|
||||||
joinState: GroupCallJoinState.Joined,
|
joinState: GroupCallJoinState.Joined,
|
||||||
hasLocalAudio: true,
|
hasLocalAudio: true,
|
||||||
hasLocalVideo: false,
|
hasLocalVideo: false,
|
||||||
|
peekInfo: {
|
||||||
|
conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
|
||||||
|
maxDevices: 16,
|
||||||
|
deviceCount: 1,
|
||||||
|
},
|
||||||
remoteParticipants: [
|
remoteParticipants: [
|
||||||
{
|
{
|
||||||
conversationId: '123',
|
conversationId: '123',
|
||||||
|
@ -358,6 +502,11 @@ describe('calling duck', () => {
|
||||||
conversationId: 'fake-group-call-conversation-id',
|
conversationId: 'fake-group-call-conversation-id',
|
||||||
connectionState: GroupCallConnectionState.Connected,
|
connectionState: GroupCallConnectionState.Connected,
|
||||||
joinState: GroupCallJoinState.Joined,
|
joinState: GroupCallJoinState.Joined,
|
||||||
|
peekInfo: {
|
||||||
|
conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
|
||||||
|
maxDevices: 16,
|
||||||
|
deviceCount: 1,
|
||||||
|
},
|
||||||
remoteParticipants: [
|
remoteParticipants: [
|
||||||
{
|
{
|
||||||
conversationId: '123',
|
conversationId: '123',
|
||||||
|
@ -375,12 +524,17 @@ describe('calling duck', () => {
|
||||||
it("if no call is active, doesn't touch the active call state", () => {
|
it("if no call is active, doesn't touch the active call state", () => {
|
||||||
const result = reducer(
|
const result = reducer(
|
||||||
stateWithGroupCall,
|
stateWithGroupCall,
|
||||||
groupCallStateChange({
|
getAction({
|
||||||
conversationId: 'fake-group-call-conversation-id',
|
conversationId: 'fake-group-call-conversation-id',
|
||||||
connectionState: GroupCallConnectionState.Connected,
|
connectionState: GroupCallConnectionState.Connected,
|
||||||
joinState: GroupCallJoinState.Joined,
|
joinState: GroupCallJoinState.Joined,
|
||||||
hasLocalAudio: true,
|
hasLocalAudio: true,
|
||||||
hasLocalVideo: false,
|
hasLocalVideo: false,
|
||||||
|
peekInfo: {
|
||||||
|
conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
|
||||||
|
maxDevices: 16,
|
||||||
|
deviceCount: 1,
|
||||||
|
},
|
||||||
remoteParticipants: [
|
remoteParticipants: [
|
||||||
{
|
{
|
||||||
conversationId: '123',
|
conversationId: '123',
|
||||||
|
@ -400,12 +554,17 @@ describe('calling duck', () => {
|
||||||
it("if the call is not active, doesn't touch the active call state", () => {
|
it("if the call is not active, doesn't touch the active call state", () => {
|
||||||
const result = reducer(
|
const result = reducer(
|
||||||
stateWithActiveGroupCall,
|
stateWithActiveGroupCall,
|
||||||
groupCallStateChange({
|
getAction({
|
||||||
conversationId: 'another-fake-conversation-id',
|
conversationId: 'another-fake-conversation-id',
|
||||||
connectionState: GroupCallConnectionState.Connected,
|
connectionState: GroupCallConnectionState.Connected,
|
||||||
joinState: GroupCallJoinState.Joined,
|
joinState: GroupCallJoinState.Joined,
|
||||||
hasLocalAudio: true,
|
hasLocalAudio: true,
|
||||||
hasLocalVideo: true,
|
hasLocalVideo: true,
|
||||||
|
peekInfo: {
|
||||||
|
conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
|
||||||
|
maxDevices: 16,
|
||||||
|
deviceCount: 1,
|
||||||
|
},
|
||||||
remoteParticipants: [
|
remoteParticipants: [
|
||||||
{
|
{
|
||||||
conversationId: '123',
|
conversationId: '123',
|
||||||
|
@ -432,12 +591,17 @@ describe('calling duck', () => {
|
||||||
it('if the call is active, updates the active call state', () => {
|
it('if the call is active, updates the active call state', () => {
|
||||||
const result = reducer(
|
const result = reducer(
|
||||||
stateWithActiveGroupCall,
|
stateWithActiveGroupCall,
|
||||||
groupCallStateChange({
|
getAction({
|
||||||
conversationId: 'fake-group-call-conversation-id',
|
conversationId: 'fake-group-call-conversation-id',
|
||||||
connectionState: GroupCallConnectionState.Connected,
|
connectionState: GroupCallConnectionState.Connected,
|
||||||
joinState: GroupCallJoinState.Joined,
|
joinState: GroupCallJoinState.Joined,
|
||||||
hasLocalAudio: true,
|
hasLocalAudio: true,
|
||||||
hasLocalVideo: true,
|
hasLocalVideo: true,
|
||||||
|
peekInfo: {
|
||||||
|
conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
|
||||||
|
maxDevices: 16,
|
||||||
|
deviceCount: 1,
|
||||||
|
},
|
||||||
remoteParticipants: [
|
remoteParticipants: [
|
||||||
{
|
{
|
||||||
conversationId: '123',
|
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', () => {
|
describe('setLocalAudio', () => {
|
||||||
const { setLocalAudio } = actions;
|
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'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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",
|
"rule": "React-createRef",
|
||||||
"path": "ts/components/conversation/ConversationHeader.js",
|
"path": "ts/components/conversation/ConversationHeader.js",
|
||||||
"line": " this.menuTriggerRef = react_1.default.createRef();",
|
"line": " this.menuTriggerRef = react_1.default.createRef();",
|
||||||
"lineNumber": 28,
|
"lineNumber": 29,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2020-08-28T16:12:19.904Z",
|
"updated": "2020-08-28T16:12:19.904Z",
|
||||||
"reasonDetail": "Used to reference popup menu"
|
"reasonDetail": "Used to reference popup menu"
|
||||||
|
@ -14737,7 +14737,7 @@
|
||||||
"rule": "React-createRef",
|
"rule": "React-createRef",
|
||||||
"path": "ts/components/conversation/ConversationHeader.tsx",
|
"path": "ts/components/conversation/ConversationHeader.tsx",
|
||||||
"line": " this.menuTriggerRef = React.createRef();",
|
"line": " this.menuTriggerRef = React.createRef();",
|
||||||
"lineNumber": 100,
|
"lineNumber": 101,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2020-05-20T20:10:43.540Z",
|
"updated": "2020-05-20T20:10:43.540Z",
|
||||||
"reasonDetail": "Used to reference popup menu"
|
"reasonDetail": "Used to reference popup menu"
|
||||||
|
|
Loading…
Reference in New Issue