Peek group calls when opening conversations and leaving calls

This commit is contained in:
Evan Hahn 2022-02-08 13:18:51 -06:00 committed by GitHub
parent 5ce26eb91a
commit f5a4cd9ce8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 202 additions and 373 deletions

View file

@ -70,7 +70,7 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
fakeGetGroupCallVideoFrameSource(demuxId), fakeGetGroupCallVideoFrameSource(demuxId),
getPreferredBadge: () => undefined, getPreferredBadge: () => undefined,
getPresentingSources: action('get-presenting-sources'), getPresentingSources: action('get-presenting-sources'),
hangUp: action('hang-up'), hangUpActiveCall: action('hang-up-active-call'),
i18n, i18n,
isGroupCallOutboundRingEnabled: true, isGroupCallOutboundRingEnabled: true,
keyChangeOk: action('key-change-ok'), keyChangeOk: action('key-change-ok'),

View file

@ -31,7 +31,6 @@ import type {
AcceptCallType, AcceptCallType,
CancelCallType, CancelCallType,
DeclineCallType, DeclineCallType,
HangUpType,
KeyChangeOkType, KeyChangeOkType,
SetGroupCallVideoRequestType, SetGroupCallVideoRequestType,
SetLocalAudioType, SetLocalAudioType,
@ -97,7 +96,7 @@ export type PropsType = {
setPresenting: (_?: PresentedSource) => void; setPresenting: (_?: PresentedSource) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void; setRendererCanvas: (_: SetRendererCanvasType) => void;
stopRingtone: () => unknown; stopRingtone: () => unknown;
hangUp: (_: HangUpType) => void; hangUpActiveCall: () => void;
theme: ThemeType; theme: ThemeType;
togglePip: () => void; togglePip: () => void;
toggleScreenRecordingPermissionsDialog: () => unknown; toggleScreenRecordingPermissionsDialog: () => unknown;
@ -114,7 +113,7 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
availableCameras, availableCameras,
cancelCall, cancelCall,
closeNeedPermissionScreen, closeNeedPermissionScreen,
hangUp, hangUpActiveCall,
i18n, i18n,
isGroupCallOutboundRingEnabled, isGroupCallOutboundRingEnabled,
keyChangeOk, keyChangeOk,
@ -270,7 +269,7 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
<CallingPip <CallingPip
activeCall={activeCall} activeCall={activeCall}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall} getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
hangUp={hangUp} hangUpActiveCall={hangUpActiveCall}
hasLocalVideo={hasLocalVideo} hasLocalVideo={hasLocalVideo}
i18n={i18n} i18n={i18n}
setGroupCallVideoRequest={setGroupCallVideoRequestForConversation} setGroupCallVideoRequest={setGroupCallVideoRequestForConversation}
@ -307,7 +306,7 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
getPresentingSources={getPresentingSources} getPresentingSources={getPresentingSources}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall} getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
groupMembers={groupMembers} groupMembers={groupMembers}
hangUp={hangUp} hangUpActiveCall={hangUpActiveCall}
i18n={i18n} i18n={i18n}
joinedAt={joinedAt} joinedAt={joinedAt}
me={me} me={me}
@ -350,9 +349,7 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
contacts={activeCall.conversationsWithSafetyNumberChanges} contacts={activeCall.conversationsWithSafetyNumberChanges}
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
i18n={i18n} i18n={i18n}
onCancel={() => { onCancel={hangUpActiveCall}
hangUp({ conversationId: activeCall.conversation.id });
}}
onConfirm={() => { onConfirm={() => {
keyChangeOk({ conversationId: activeCall.conversation.id }); keyChangeOk({ conversationId: activeCall.conversation.id });
}} }}

View file

@ -148,7 +148,7 @@ const createProps = (
activeCall: createActiveCallProp(overrideProps), activeCall: createActiveCallProp(overrideProps),
getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource, getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource,
getPresentingSources: action('get-presenting-sources'), getPresentingSources: action('get-presenting-sources'),
hangUp: action('hang-up'), hangUpActiveCall: action('hang-up'),
i18n, i18n,
me: { me: {
color: AvatarColors[1], color: AvatarColors[1],

View file

@ -7,7 +7,6 @@ import { noop } from 'lodash';
import classNames from 'classnames'; import classNames from 'classnames';
import type { VideoFrameSource } from 'ringrtc'; import type { VideoFrameSource } from 'ringrtc';
import type { import type {
HangUpType,
SetLocalAudioType, SetLocalAudioType,
SetLocalPreviewType, SetLocalPreviewType,
SetLocalVideoType, SetLocalVideoType,
@ -47,7 +46,7 @@ export type PropsType = {
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
getPresentingSources: () => void; getPresentingSources: () => void;
groupMembers?: Array<Pick<ConversationType, 'id' | 'firstName' | 'title'>>; groupMembers?: Array<Pick<ConversationType, 'id' | 'firstName' | 'title'>>;
hangUp: (_: HangUpType) => void; hangUpActiveCall: () => void;
i18n: LocalizerType; i18n: LocalizerType;
joinedAt?: number; joinedAt?: number;
me: { me: {
@ -115,7 +114,7 @@ export const CallScreen: React.FC<PropsType> = ({
getGroupCallVideoFrameSource, getGroupCallVideoFrameSource,
getPresentingSources, getPresentingSources,
groupMembers, groupMembers,
hangUp, hangUpActiveCall,
i18n, i18n,
joinedAt, joinedAt,
me, me,
@ -510,9 +509,7 @@ export const CallScreen: React.FC<PropsType> = ({
i18n={i18n} i18n={i18n}
onMouseEnter={onControlsMouseEnter} onMouseEnter={onControlsMouseEnter}
onMouseLeave={onControlsMouseLeave} onMouseLeave={onControlsMouseLeave}
onClick={() => { onClick={hangUpActiveCall}
hangUp({ conversationId: conversation.id });
}}
/> />
</div> </div>
<div <div

View file

@ -60,7 +60,7 @@ const defaultCall: ActiveCallType = {
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
activeCall: overrideProps.activeCall || defaultCall, activeCall: overrideProps.activeCall || defaultCall,
getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource, getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource,
hangUp: action('hang-up'), hangUpActiveCall: action('hang-up-active-call'),
hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false), hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false),
i18n, i18n,
setGroupCallVideoRequest: action('set-group-call-video-request'), setGroupCallVideoRequest: action('set-group-call-video-request'),

View file

@ -8,7 +8,6 @@ import { CallingPipRemoteVideo } from './CallingPipRemoteVideo';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import type { ActiveCallType, GroupCallVideoRequest } from '../types/Calling'; import type { ActiveCallType, GroupCallVideoRequest } from '../types/Calling';
import type { import type {
HangUpType,
SetLocalPreviewType, SetLocalPreviewType,
SetRendererCanvasType, SetRendererCanvasType,
} from '../state/ducks/calling'; } from '../state/ducks/calling';
@ -52,7 +51,7 @@ type SnapCandidate = {
export type PropsType = { export type PropsType = {
activeCall: ActiveCallType; activeCall: ActiveCallType;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
hangUp: (_: HangUpType) => void; hangUpActiveCall: () => void;
hasLocalVideo: boolean; hasLocalVideo: boolean;
i18n: LocalizerType; i18n: LocalizerType;
setGroupCallVideoRequest: (_: Array<GroupCallVideoRequest>) => void; setGroupCallVideoRequest: (_: Array<GroupCallVideoRequest>) => void;
@ -70,7 +69,7 @@ const PIP_PADDING = 8;
export const CallingPip = ({ export const CallingPip = ({
activeCall, activeCall,
getGroupCallVideoFrameSource, getGroupCallVideoFrameSource,
hangUp, hangUpActiveCall,
hasLocalVideo, hasLocalVideo,
i18n, i18n,
setGroupCallVideoRequest, setGroupCallVideoRequest,
@ -293,9 +292,7 @@ export const CallingPip = ({
<button <button
aria-label={i18n('calling__hangup')} aria-label={i18n('calling__hangup')}
className="module-calling-pip__button--hangup" className="module-calling-pip__button--hangup"
onClick={() => { onClick={hangUpActiveCall}
hangUp({ conversationId: activeCall.conversation.id });
}}
type="button" type="button"
/> />
<button <button

View file

@ -392,6 +392,8 @@ const actions = () => ({
removeMember: action('removeMember'), removeMember: action('removeMember'),
unblurAvatar: action('unblurAvatar'), unblurAvatar: action('unblurAvatar'),
peekGroupCallForTheFirstTime: action('peekGroupCallForTheFirstTime'),
}); });
const renderItem = ({ const renderItem = ({

View file

@ -170,6 +170,7 @@ export type PropsActionsType = {
onBlockAndReportSpam: (conversationId: string) => unknown; onBlockAndReportSpam: (conversationId: string) => unknown;
onDelete: (conversationId: string) => unknown; onDelete: (conversationId: string) => unknown;
onUnblock: (conversationId: string) => unknown; onUnblock: (conversationId: string) => unknown;
peekGroupCallForTheFirstTime: (conversationId: string) => unknown;
removeMember: (conversationId: string) => unknown; removeMember: (conversationId: string) => unknown;
selectMessage: (messageId: string, conversationId: string) => unknown; selectMessage: (messageId: string, conversationId: string) => unknown;
clearSelectedMessage: () => unknown; clearSelectedMessage: () => unknown;
@ -263,6 +264,7 @@ const getActions = createSelector(
'onBlockAndReportSpam', 'onBlockAndReportSpam',
'onDelete', 'onDelete',
'onUnblock', 'onUnblock',
'peekGroupCallForTheFirstTime',
'removeMember', 'removeMember',
'selectMessage', 'selectMessage',
'clearSelectedMessage', 'clearSelectedMessage',
@ -327,6 +329,8 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
private hasRecentlyScrolledTimeout?: NodeJS.Timeout; private hasRecentlyScrolledTimeout?: NodeJS.Timeout;
private delayedPeekTimeout?: NodeJS.Timeout;
private containerRefMerger = createRefMerger(); private containerRefMerger = createRefMerger();
constructor(props: PropsType) { constructor(props: PropsType) {
@ -958,10 +962,21 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
public override componentDidMount(): void { public override componentDidMount(): void {
this.updateWithVisibleRows(); this.updateWithVisibleRows();
window.registerForActive(this.updateWithVisibleRows); window.registerForActive(this.updateWithVisibleRows);
this.delayedPeekTimeout = setTimeout(() => {
const { id, peekGroupCallForTheFirstTime } = this.props;
peekGroupCallForTheFirstTime(id);
}, 500);
} }
public override componentWillUnmount(): void { public override componentWillUnmount(): void {
const { delayedPeekTimeout } = this;
window.unregisterForActive(this.updateWithVisibleRows); window.unregisterForActive(this.updateWithVisibleRows);
if (delayedPeekTimeout) {
clearTimeout(delayedPeekTimeout);
}
} }
public override componentDidUpdate( public override componentDidUpdate(

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { ipcRenderer } from 'electron'; import { ipcRenderer } from 'electron';
import type { ThunkAction } from 'redux-thunk'; import type { ThunkAction, ThunkDispatch } from 'redux-thunk';
import { CallEndedReason } from 'ringrtc'; import { CallEndedReason } from 'ringrtc';
import { import {
hasScreenCapturePermission, hasScreenCapturePermission,
@ -36,9 +36,14 @@ import { isGroupCallOutboundRingEnabled } from '../../util/isGroupCallOutboundRi
import { sleep } from '../../util/sleep'; import { sleep } from '../../util/sleep';
import { LatestQueue } from '../../util/LatestQueue'; import { LatestQueue } from '../../util/LatestQueue';
import type { UUIDStringType } from '../../types/UUID'; import type { UUIDStringType } from '../../types/UUID';
import type { ConversationChangedActionType } from './conversations'; import type {
ConversationChangedActionType,
ConversationRemovedActionType,
} from './conversations';
import { getConversationCallMode } from './conversations';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
import { waitForOnline } from '../../util/waitForOnline';
import * as setUtil from '../../util/setUtil'; import * as setUtil from '../../util/setUtil';
// State // State
@ -88,7 +93,7 @@ export type GroupCallStateType = {
conversationId: string; conversationId: string;
connectionState: GroupCallConnectionState; connectionState: GroupCallConnectionState;
joinState: GroupCallJoinState; joinState: GroupCallJoinState;
peekInfo: GroupCallPeekInfoType; peekInfo?: GroupCallPeekInfoType;
remoteParticipants: Array<GroupCallParticipantInfoType>; remoteParticipants: Array<GroupCallParticipantInfoType>;
speakingDemuxIds?: Set<number>; speakingDemuxIds?: Set<number>;
} & GroupCallRingStateType; } & GroupCallRingStateType;
@ -161,7 +166,7 @@ type GroupCallStateChangeActionPayloadType =
ourUuid: UUIDStringType; ourUuid: UUIDStringType;
}; };
export type HangUpType = { type HangUpActionPayloadType = {
conversationId: string; conversationId: string;
}; };
@ -285,9 +290,9 @@ export const getIncomingCall = (
}); });
export const isAnybodyElseInGroupCall = ( export const isAnybodyElseInGroupCall = (
{ uuids }: Readonly<GroupCallPeekInfoType>, peekInfo: undefined | Readonly<Pick<GroupCallPeekInfoType, 'uuids'>>,
ourUuid: UUIDStringType ourUuid: UUIDStringType
): boolean => uuids.some(id => id !== ourUuid); ): boolean => Boolean(peekInfo?.uuids.some(id => id !== ourUuid));
const getGroupCallRingState = ( const getGroupCallRingState = (
call: Readonly<undefined | GroupCallStateType> call: Readonly<undefined | GroupCallStateType>
@ -296,6 +301,90 @@ const getGroupCallRingState = (
? {} ? {}
: { ringId: call.ringId, ringerUuid: call.ringerUuid }; : { ringId: call.ringId, ringerUuid: call.ringerUuid };
// 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>();
const doGroupCallPeek = (
conversationId: string,
dispatch: ThunkDispatch<
RootStateType,
unknown,
PeekGroupCallFulfilledActionType
>,
getState: () => RootStateType
) => {
const conversation = getOwn(
getState().conversations.conversationLookup,
conversationId
);
if (
!conversation ||
getConversationCallMode(conversation) !== CallMode.Group
) {
return;
}
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 Promise.all([sleep(1000), waitForOnline(navigator, window)]);
let peekInfo;
try {
peekInfo = await calling.peekGroupCall(conversationId);
} catch (err) {
log.error('Group call peeking failed', Errors.toLogFormat(err));
return;
}
if (!peekInfo) {
return;
}
await calling.updateCallHistoryForGroupCall(conversationId, peekInfo);
const formattedPeekInfo = calling.formatGroupCallPeekInfoForRedux(peekInfo);
dispatch({
type: PEEK_GROUP_CALL_FULFILLED,
payload: {
conversationId,
peekInfo: formattedPeekInfo,
},
});
});
};
// Actions // Actions
const ACCEPT_CALL_PENDING = 'calling/ACCEPT_CALL_PENDING'; const ACCEPT_CALL_PENDING = 'calling/ACCEPT_CALL_PENDING';
@ -315,8 +404,7 @@ const INCOMING_GROUP_CALL = 'calling/INCOMING_GROUP_CALL';
const MARK_CALL_TRUSTED = 'calling/MARK_CALL_TRUSTED'; const MARK_CALL_TRUSTED = 'calling/MARK_CALL_TRUSTED';
const MARK_CALL_UNTRUSTED = 'calling/MARK_CALL_UNTRUSTED'; const MARK_CALL_UNTRUSTED = 'calling/MARK_CALL_UNTRUSTED';
const OUTGOING_CALL = 'calling/OUTGOING_CALL'; const OUTGOING_CALL = 'calling/OUTGOING_CALL';
const PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED = const PEEK_GROUP_CALL_FULFILLED = 'calling/PEEK_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_SHARING_SCREEN_CHANGE = 'calling/REMOTE_SHARING_SCREEN_CHANGE'; const REMOTE_SHARING_SCREEN_CHANGE = 'calling/REMOTE_SHARING_SCREEN_CHANGE';
const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE'; const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE';
@ -390,7 +478,7 @@ export type GroupCallStateChangeActionType = {
type HangUpActionType = { type HangUpActionType = {
type: 'calling/HANG_UP'; type: 'calling/HANG_UP';
payload: HangUpType; payload: HangUpActionPayloadType;
}; };
type IncomingDirectCallActionType = { type IncomingDirectCallActionType = {
@ -420,12 +508,11 @@ type OutgoingCallActionType = {
payload: StartDirectCallType; payload: StartDirectCallType;
}; };
export type PeekNotConnectedGroupCallFulfilledActionType = { export type PeekGroupCallFulfilledActionType = {
type: 'calling/PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED'; type: 'calling/PEEK_GROUP_CALL_FULFILLED';
payload: { payload: {
conversationId: string; conversationId: string;
peekInfo: GroupCallPeekInfoType; peekInfo: GroupCallPeekInfoType;
ourUuid: UUIDStringType;
}; };
}; };
@ -512,6 +599,7 @@ export type CallingActionType =
| ChangeIODeviceFulfilledActionType | ChangeIODeviceFulfilledActionType
| CloseNeedPermissionScreenActionType | CloseNeedPermissionScreenActionType
| ConversationChangedActionType | ConversationChangedActionType
| ConversationRemovedActionType
| DeclineCallActionType | DeclineCallActionType
| GroupCallAudioLevelsChangeActionType | GroupCallAudioLevelsChangeActionType
| GroupCallStateChangeActionType | GroupCallStateChangeActionType
@ -521,7 +609,7 @@ export type CallingActionType =
| KeyChangedActionType | KeyChangedActionType
| KeyChangeOkActionType | KeyChangeOkActionType
| OutgoingCallActionType | OutgoingCallActionType
| PeekNotConnectedGroupCallFulfilledActionType | PeekGroupCallFulfilledActionType
| RefreshIODevicesActionType | RefreshIODevicesActionType
| RemoteSharingScreenChangeActionType | RemoteSharingScreenChangeActionType
| RemoteVideoChangeActionType | RemoteVideoChangeActionType
@ -762,22 +850,13 @@ function groupCallStateChange(
}; };
} }
function hangUp(payload: HangUpType): HangUpActionType {
calling.hangup(payload.conversationId);
return {
type: HANG_UP,
payload,
};
}
function hangUpActiveCall(): ThunkAction< function hangUpActiveCall(): ThunkAction<
void, void,
RootStateType, RootStateType,
unknown, unknown,
HangUpActionType HangUpActionType
> { > {
return (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState(); const state = getState();
const activeCall = getActiveCall(state.calling); const activeCall = getActiveCall(state.calling);
@ -787,12 +866,20 @@ function hangUpActiveCall(): ThunkAction<
const { conversationId } = activeCall; const { conversationId } = activeCall;
calling.hangup(conversationId);
dispatch({ dispatch({
type: HANG_UP, type: HANG_UP,
payload: { payload: {
conversationId, conversationId,
}, },
}); });
if (activeCall.callMode === CallMode.Group) {
// We want to give the group call time to disconnect.
await sleep(1000);
doGroupCallPeek(conversationId, dispatch, getState);
}
}; };
} }
@ -882,83 +969,25 @@ function outgoingCall(payload: StartDirectCallType): OutgoingCallActionType {
}; };
} }
// We might call this function many times in rapid succession (for example, if lots of function peekGroupCallForTheFirstTime(
// people are joining and leaving at once). We want to make sure to update eventually conversationId: string
// (if people join and leave for an hour, we don't want you to have to wait an hour to ): ThunkAction<void, RootStateType, unknown, PeekGroupCallFulfilledActionType> {
// get an update), and we also don't want to update too often. That's why we use a return (dispatch, getState) => {
// "latest queue". const call = getOwn(getState().calling.callsByConversation, conversationId);
const peekQueueByConversation = new Map<string, LatestQueue>(); const shouldPeek =
!call || (call.callMode === CallMode.Group && !call.peekInfo);
if (shouldPeek) {
doGroupCallPeek(conversationId, dispatch, getState);
}
};
}
function peekNotConnectedGroupCall( function peekNotConnectedGroupCall(
payload: PeekNotConnectedGroupCallType payload: PeekNotConnectedGroupCallType
): ThunkAction< ): ThunkAction<void, RootStateType, unknown, PeekGroupCallFulfilledActionType> {
void,
RootStateType,
unknown,
PeekNotConnectedGroupCallFulfilledActionType
> {
return (dispatch, getState) => { return (dispatch, getState) => {
const { conversationId } = payload; const { conversationId } = payload;
doGroupCallPeek(conversationId, dispatch, getState);
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) {
log.error('Group call peeking failed', Errors.toLogFormat(err));
return;
}
if (!peekInfo) {
return;
}
const { ourUuid } = state.user;
await calling.updateCallHistoryForGroupCall(conversationId, peekInfo);
const formattedPeekInfo =
calling.formatGroupCallPeekInfoForRedux(peekInfo);
dispatch({
type: PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED,
payload: {
conversationId,
peekInfo: formattedPeekInfo,
ourUuid,
},
});
});
}; };
} }
@ -1150,7 +1179,7 @@ function startCallingLobby({
// The group call device count is considered 0 for a direct call. // The group call device count is considered 0 for a direct call.
const groupCall = getGroupCall(conversationId, state.calling); const groupCall = getGroupCall(conversationId, state.calling);
const groupCallDeviceCount = const groupCallDeviceCount =
groupCall?.peekInfo.deviceCount || groupCall?.peekInfo?.deviceCount ||
groupCall?.remoteParticipants.length || groupCall?.remoteParticipants.length ||
0; 0;
@ -1264,12 +1293,12 @@ export const actions = {
getPresentingSources, getPresentingSources,
groupCallAudioLevelsChange, groupCallAudioLevelsChange,
groupCallStateChange, groupCallStateChange,
hangUp,
hangUpActiveCall, hangUpActiveCall,
keyChangeOk, keyChangeOk,
keyChanged, keyChanged,
openSystemPreferencesAction, openSystemPreferencesAction,
outgoingCall, outgoingCall,
peekGroupCallForTheFirstTime,
peekNotConnectedGroupCall, peekNotConnectedGroupCall,
receiveIncomingDirectCall, receiveIncomingDirectCall,
receiveIncomingGroupCall, receiveIncomingGroupCall,
@ -1375,7 +1404,7 @@ export function reducer(
outgoingRing = outgoingRing =
isGroupCallOutboundRingEnabled() && isGroupCallOutboundRingEnabled() &&
!ringState.ringId && !ringState.ringId &&
!call.peekInfo.uuids.length && !call.peekInfo?.uuids.length &&
!call.remoteParticipants.length && !call.remoteParticipants.length &&
!action.payload.isConversationTooBigToRing; !action.payload.isConversationTooBigToRing;
break; break;
@ -1481,10 +1510,6 @@ export function reducer(
return state; return state;
} }
if (groupCall.connectionState === GroupCallConnectionState.NotConnected) {
return removeConversationFromState(state, conversationId);
}
return { return {
...state, ...state,
callsByConversation: { callsByConversation: {
@ -1513,6 +1538,10 @@ export function reducer(
}; };
} }
if (action.type === 'CONVERSATION_REMOVED') {
return removeConversationFromState(state, action.payload.id);
}
if (action.type === DECLINE_DIRECT_CALL) { if (action.type === DECLINE_DIRECT_CALL) {
return removeConversationFromState(state, action.payload.conversationId); return removeConversationFromState(state, action.payload.conversationId);
} }
@ -1709,32 +1738,17 @@ export function reducer(
}; };
let newActiveCallState: ActiveCallStateType | undefined; let newActiveCallState: ActiveCallStateType | undefined;
if (state.activeCallState?.conversationId === conversationId) {
if (connectionState === GroupCallConnectionState.NotConnected) {
newActiveCallState = newActiveCallState =
state.activeCallState?.conversationId === conversationId connectionState === GroupCallConnectionState.NotConnected
? undefined ? undefined
: state.activeCallState; : {
if (
!isAnybodyElseInGroupCall(newPeekInfo, ourUuid) &&
(!existingCall || !existingCall.ringerUuid)
) {
return {
...state,
callsByConversation: omit(callsByConversation, conversationId),
activeCallState: newActiveCallState,
};
}
} else {
newActiveCallState =
state.activeCallState?.conversationId === conversationId
? {
...state.activeCallState, ...state.activeCallState,
hasLocalAudio, hasLocalAudio,
hasLocalVideo, hasLocalVideo,
} };
: state.activeCallState; } else {
newActiveCallState = state.activeCallState;
} }
if ( if (
@ -1774,8 +1788,8 @@ export function reducer(
}; };
} }
if (action.type === PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED) { if (action.type === PEEK_GROUP_CALL_FULFILLED) {
const { conversationId, peekInfo, ourUuid } = action.payload; const { conversationId, peekInfo } = action.payload;
const existingCall: GroupCallStateType = getGroupCall( const existingCall: GroupCallStateType = getGroupCall(
conversationId, conversationId,
@ -1806,13 +1820,6 @@ export function reducer(
return state; return state;
} }
if (
!isAnybodyElseInGroupCall(peekInfo, ourUuid) &&
!existingCall.ringerUuid
) {
return removeConversationFromState(state, conversationId);
}
return { return {
...state, ...state,
callsByConversation: { callsByConversation: {

View file

@ -1,4 +1,4 @@
// Copyright 2019-2021 Signal Messenger, LLC // Copyright 2019-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable camelcase */ /* eslint-disable camelcase */
@ -480,7 +480,7 @@ export type ConversationChangedActionType = {
data: ConversationType; data: ConversationType;
}; };
}; };
type ConversationRemovedActionType = { export type ConversationRemovedActionType = {
type: 'CONVERSATION_REMOVED'; type: 'CONVERSATION_REMOVED';
payload: { payload: {
id: string; id: string;

View file

@ -1138,7 +1138,6 @@ export function getPropsForCallHistory(
throw new Error('getPropsForCallHistory: missing conversation ID'); throw new Error('getPropsForCallHistory: missing conversation ID');
} }
const creator = conversationSelector(callHistoryDetails.creatorUuid);
let call = callSelector(conversationId); let call = callSelector(conversationId);
if (call && call.callMode !== CallMode.Group) { if (call && call.callMode !== CallMode.Group) {
log.error( log.error(
@ -1147,14 +1146,18 @@ export function getPropsForCallHistory(
call = undefined; call = undefined;
} }
const creator = conversationSelector(callHistoryDetails.creatorUuid);
const deviceCount = call?.peekInfo?.deviceCount ?? 0;
return { return {
activeCallConversationId: activeCall?.conversationId, activeCallConversationId: activeCall?.conversationId,
callMode: CallMode.Group, callMode: CallMode.Group,
conversationId, conversationId,
creator, creator,
deviceCount: call?.peekInfo.deviceCount ?? 0, deviceCount,
ended: callHistoryDetails.eraId !== call?.peekInfo.eraId, ended:
maxDevices: call?.peekInfo.maxDevices ?? Infinity, callHistoryDetails.eraId !== call?.peekInfo?.eraId || !deviceCount,
maxDevices: call?.peekInfo?.maxDevices ?? Infinity,
startedTime: callHistoryDetails.startedTime, startedTime: callHistoryDetails.startedTime,
}; };
} }

View file

@ -175,6 +175,17 @@ const mapStateToActiveCallProp = (
const peekedParticipants: Array<ConversationType> = []; const peekedParticipants: Array<ConversationType> = [];
const { memberships = [] } = conversation; const { memberships = [] } = conversation;
// Active calls should have peek info, but TypeScript doesn't know that so we have a
// fallback.
const {
peekInfo = {
deviceCount: 0,
maxDevices: Infinity,
uuids: [],
},
} = call;
for (let i = 0; i < memberships.length; i += 1) { for (let i = 0; i < memberships.length; i += 1) {
const { uuid } = memberships[i]; const { uuid } = memberships[i];
@ -226,8 +237,8 @@ const mapStateToActiveCallProp = (
conversationsWithSafetyNumberChanges.push(remoteConversation); conversationsWithSafetyNumberChanges.push(remoteConversation);
} }
for (let i = 0; i < call.peekInfo.uuids.length; i += 1) { for (let i = 0; i < peekInfo.uuids.length; i += 1) {
const peekedParticipantUuid = call.peekInfo.uuids[i]; const peekedParticipantUuid = peekInfo.uuids[i];
const peekedConversation = conversationSelectorByUuid( const peekedConversation = conversationSelectorByUuid(
peekedParticipantUuid peekedParticipantUuid
@ -245,10 +256,10 @@ const mapStateToActiveCallProp = (
callMode: CallMode.Group, callMode: CallMode.Group,
connectionState: call.connectionState, connectionState: call.connectionState,
conversationsWithSafetyNumberChanges, conversationsWithSafetyNumberChanges,
deviceCount: call.peekInfo.deviceCount, deviceCount: peekInfo.deviceCount,
groupMembers, groupMembers,
joinState: call.joinState, joinState: call.joinState,
maxDevices: call.peekInfo.maxDevices, maxDevices: peekInfo.maxDevices,
peekedParticipants, peekedParticipants,
remoteParticipants, remoteParticipants,
speakingDemuxIds: call.speakingDemuxIds || new Set<number>(), speakingDemuxIds: call.speakingDemuxIds || new Set<number>(),

View file

@ -26,7 +26,6 @@ import {
GroupCallJoinState, GroupCallJoinState,
} from '../../../types/Calling'; } from '../../../types/Calling';
import { UUID } from '../../../types/UUID'; import { UUID } from '../../../types/UUID';
import type { UUIDStringType } from '../../../types/UUID';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import type { UnwrapPromise } from '../../../types/Util'; import type { UnwrapPromise } from '../../../types/Util';
@ -607,35 +606,7 @@ describe('calling duck', () => {
assert.strictEqual(result, stateWithIncomingGroupCall); assert.strictEqual(result, stateWithIncomingGroupCall);
}); });
it("removes the call from the state if it's not connected", () => { it('removes the ring state, but not the call', () => {
const state = {
...stateWithGroupCall,
callsByConversation: {
...stateWithGroupCall.callsByConversation,
'fake-group-call-conversation-id': {
...stateWithGroupCall.callsByConversation[
'fake-group-call-conversation-id'
],
connectionState: GroupCallConnectionState.NotConnected,
ringId: BigInt(123),
ringerUuid: UUID.generate().toString(),
},
},
};
const action = cancelIncomingGroupCallRing({
conversationId: 'fake-group-call-conversation-id',
ringId: BigInt(123),
});
const result = reducer(state, action);
assert.notProperty(
result.callsByConversation,
'fake-group-call-conversation-id'
);
});
it("removes the ring state, but not the call, if it's connected", () => {
const action = cancelIncomingGroupCallRing({ const action = cancelIncomingGroupCallRing({
conversationId: 'fake-group-call-conversation-id', conversationId: 'fake-group-call-conversation-id',
ringId: BigInt(123), ringId: BigInt(123),
@ -850,117 +821,6 @@ describe('calling duck', () => {
return dispatch.getCall(0).args[0]; return dispatch.getCall(0).args[0];
} }
it('ignores non-connected calls with no peeked participants', () => {
const result = reducer(
getEmptyState(),
getAction({
conversationId: 'abc123',
connectionState: GroupCallConnectionState.NotConnected,
joinState: GroupCallJoinState.NotJoined,
hasLocalAudio: false,
hasLocalVideo: false,
peekInfo: {
uuids: [],
maxDevices: 16,
deviceCount: 0,
},
remoteParticipants: [],
})
);
assert.deepEqual(result, getEmptyState());
});
it('removes the call from the map of conversations if the call is not connected and has no peeked participants or ringer', () => {
const result = reducer(
stateWithGroupCall,
getAction({
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.NotConnected,
joinState: GroupCallJoinState.NotJoined,
hasLocalAudio: false,
hasLocalVideo: false,
peekInfo: {
uuids: [],
maxDevices: 16,
deviceCount: 0,
},
remoteParticipants: [],
})
);
assert.notProperty(
result.callsByConversation,
'fake-group-call-conversation-id'
);
});
it('removes the call from the map of conversations if the call is not connected and has 1 peeked participant: you', () => {
const result = reducer(
stateWithGroupCall,
getAction({
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.NotConnected,
joinState: GroupCallJoinState.NotJoined,
hasLocalAudio: false,
hasLocalVideo: false,
peekInfo: {
uuids: [ourUuid],
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: {
uuids: [],
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: {
uuids: [ourUuid],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [],
})
);
assert.isUndefined(result.activeCallState);
});
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(),
@ -1020,64 +880,6 @@ 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: {
uuids: ['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: {
uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [],
}
);
});
it('saves a call to the map of conversations if the call had a ringer, even if it was otherwise ignorable', () => {
const result = reducer(
stateWithIncomingGroupCall,
getAction({
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.NotConnected,
joinState: GroupCallJoinState.NotJoined,
hasLocalAudio: false,
hasLocalVideo: false,
peekInfo: {
uuids: [],
maxDevices: 16,
deviceCount: 0,
},
remoteParticipants: [],
})
);
assert.isDefined(
result.callsByConversation['fake-group-call-conversation-id']
);
});
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,
@ -2225,32 +2027,30 @@ describe('calling duck', () => {
}); });
describe('isAnybodyElseInGroupCall', () => { describe('isAnybodyElseInGroupCall', () => {
const fakePeekInfo = (uuids: Array<UUIDStringType>) => ({ it('returns false with no peek info', () => {
uuids, assert.isFalse(isAnybodyElseInGroupCall(undefined, remoteUuid));
maxDevices: 5,
deviceCount: uuids.length,
}); });
it('returns false if the peek info has no participants', () => { it('returns false if the peek info has no participants', () => {
assert.isFalse(isAnybodyElseInGroupCall(fakePeekInfo([]), remoteUuid)); assert.isFalse(isAnybodyElseInGroupCall({ uuids: [] }, remoteUuid));
}); });
it('returns false if the peek info has one participant, you', () => { it('returns false if the peek info has one participant, you', () => {
assert.isFalse( assert.isFalse(
isAnybodyElseInGroupCall(fakePeekInfo([creatorUuid]), creatorUuid) isAnybodyElseInGroupCall({ uuids: [creatorUuid] }, creatorUuid)
); );
}); });
it('returns true if the peek info has one participant, someone else', () => { it('returns true if the peek info has one participant, someone else', () => {
assert.isTrue( assert.isTrue(
isAnybodyElseInGroupCall(fakePeekInfo([creatorUuid]), remoteUuid) isAnybodyElseInGroupCall({ uuids: [creatorUuid] }, remoteUuid)
); );
}); });
it('returns true if the peek info has two participants, you and someone else', () => { it('returns true if the peek info has two participants, you and someone else', () => {
assert.isTrue( assert.isTrue(
isAnybodyElseInGroupCall( isAnybodyElseInGroupCall(
fakePeekInfo([creatorUuid, remoteUuid]), { uuids: [creatorUuid, remoteUuid] },
remoteUuid remoteUuid
) )
); );