Group calling: Peek into a group call

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

View File

@ -3023,6 +3023,10 @@
"message": "Start a video call", "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",

View File

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

View File

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

View File

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

View File

@ -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: [],
}; };
} }

View File

@ -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: [],
}, },
} }

View File

@ -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,
},
},
], ],
}, },
{ {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -14728,7 +14728,7 @@
"rule": "React-createRef", "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"