diff --git a/_locales/en/messages.json b/_locales/en/messages.json index a41171fd6..b78e1a8f2 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3023,6 +3023,10 @@ "message": "Start a video call", "description": "Title for the video call button in a conversation" }, + "joinOngoingCall": { + "message": "Join", + "description": "Text that appears in a group when a call is active" + }, "callNeedPermission": { "message": "$title$ will get a message request from you. You can call once your message request has been accepted.", "description": "Shown when a call is rejected because the other party hasn't approved the message/call request", diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 821e83603..8f104b517 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3047,21 +3047,9 @@ span.module-in-contacts-icon__tooltip { } } -.module-conversation-header__audio-calling-button { - @include light-theme { - @include color-svg( - '../images/icons/v2/phone-right-outline-24.svg', - $color-gray-75 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/phone-right-solid-24.svg', - $color-gray-15 - ); - } - height: 24px; - width: 24px; +.module-conversation-header__calling-button { + $icon-size: 24px; + margin-left: 12px; border: none; opacity: 0; @@ -3074,31 +3062,65 @@ span.module-in-contacts-icon__tooltip { &--show { opacity: 1; } -} -.module-conversation-header__video-calling-button { - @include light-theme { - @include color-svg( - '../images/icons/v2/video-outline-24.svg', - $color-gray-75 - ); - } - @include dark-theme { - @include color-svg('../images/icons/v2/video-solid-24.svg', $color-gray-15); - } - height: 24px; - width: 24px; - margin-left: 12px; - border: none; - opacity: 0; - transition: opacity 250ms ease-out; - - &:disabled { - cursor: default; + &--video { + @include light-theme { + @include color-svg( + '../images/icons/v2/video-outline-24.svg', + $color-gray-75 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/video-solid-24.svg', + $color-gray-15 + ); + } + height: $icon-size; + width: $icon-size; } - &--show { - opacity: 1; + &--audio { + @include light-theme { + @include color-svg( + '../images/icons/v2/phone-right-outline-24.svg', + $color-gray-75 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/phone-right-solid-24.svg', + $color-gray-15 + ); + } + height: $icon-size; + width: $icon-size; + } + + &--join { + @include font-body-1; + align-items: center; + background-color: $color-accent-green; + border-radius: 9999px; // This ensures the borders are completely rounded. (A value like 100% would make it an ellipse.) + color: $color-white; + display: flex; + outline: none; + padding: 5px 18px; + + @include keyboard-mode { + &:focus { + box-shadow: 0px 0px 0px 4px $ultramarine-ui-light; + } + } + + &:before { + @include color-svg('../images/icons/v2/video-solid-24.svg', $color-white); + content: ''; + display: block; + height: $icon-size; + margin-right: 5px; + width: $icon-size; + } } } diff --git a/ts/background.ts b/ts/background.ts index 2ead62242..665c0d3ae 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -2653,6 +2653,18 @@ type WhatIsThis = import('./window.d').WhatIsThis; return Promise.resolve(); } + if (data.message.groupCallUpdate) { + if (data.message.groupV2 && messageDescriptor.type === Message.GROUP) { + window.reduxActions.calling.peekNotConnectedGroupCall({ + conversationId: messageDescriptor.id, + }); + return Promise.resolve(); + } + window.log.warn( + 'Received a group call update for a conversation that is not a GV2 group. Ignoring that property and continuing.' + ); + } + // Don't wait for handleDataMessage, as it has its own per-conversation queueing message.handleDataMessage(data.message, event.confirm); diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index 74882fdb0..1b9ba9437 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -116,6 +116,11 @@ story.add('Ongoing Group Call', () => ( conversationId: '3051234567', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, + peekInfo: { + conversationIds: [], + maxDevices: 16, + deviceCount: 0, + }, remoteParticipants: [], }, activeCallState: getCallState(), diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx index 12e0bcc6e..73550ac96 100644 --- a/ts/components/CallScreen.stories.tsx +++ b/ts/components/CallScreen.stories.tsx @@ -29,6 +29,11 @@ function getGroupCallState(): GroupCallStateType { conversationId: '3051234567', connectionState: 2, joinState: 2, + peekInfo: { + conversationIds: [], + maxDevices: 16, + deviceCount: 0, + }, remoteParticipants: [], }; } diff --git a/ts/components/CallingPip.stories.tsx b/ts/components/CallingPip.stories.tsx index a563f97cb..6dcae8c6a 100644 --- a/ts/components/CallingPip.stories.tsx +++ b/ts/components/CallingPip.stories.tsx @@ -113,6 +113,11 @@ story.add('Group Call', () => { conversationId: '3051234567', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, + peekInfo: { + conversationIds: [], + maxDevices: 16, + deviceCount: 0, + }, remoteParticipants: [], }, } diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx index aecdb4e0c..dca64fe92 100644 --- a/ts/components/conversation/ConversationHeader.stories.tsx +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -208,6 +208,21 @@ const stories: Array = [ 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, + }, + }, ], }, { diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index 1fa841b6c..216cb6c27 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -29,6 +29,7 @@ export enum OutgoingCallButtonStyle { None, JustVideo, Both, + Join, } export interface PropsDataType { @@ -280,10 +281,11 @@ export class ConversationHeader extends React.Component { type="button" onClick={onOutgoingVideoCallInConversation} className={classNames( - 'module-conversation-header__video-calling-button', + 'module-conversation-header__calling-button', + 'module-conversation-header__calling-button--video', showBackButton ? null - : 'module-conversation-header__video-calling-button--show' + : 'module-conversation-header__calling-button--show' )} disabled={showBackButton} aria-label={i18n('makeOutgoingVideoCall')} @@ -303,16 +305,34 @@ export class ConversationHeader extends React.Component { type="button" onClick={onOutgoingAudioCallInConversation} className={classNames( - 'module-conversation-header__audio-calling-button', + 'module-conversation-header__calling-button', + 'module-conversation-header__calling-button--audio', showBackButton ? null - : 'module-conversation-header__audio-calling-button--show' + : 'module-conversation-header__calling-button--show' )} disabled={showBackButton} aria-label={i18n('makeOutgoingCall')} /> ); + case OutgoingCallButtonStyle.Join: + return ( + + ); default: throw missingCaseError(outgoingCallButtonStyle); } diff --git a/ts/services/calling.ts b/ts/services/calling.ts index f0d6db434..9cd546d2c 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -23,13 +23,17 @@ import { HangupType, OfferType, OpaqueMessage, + PeekInfo, RingRTC, UserId, VideoFrameSource, } from 'ringrtc'; import { uniqBy, noop } from 'lodash'; -import { ActionsType as UxActionsType } from '../state/ducks/calling'; +import { + ActionsType as UxActionsType, + GroupCallPeekInfoType, +} from '../state/ducks/calling'; import { getConversationCallMode } from '../state/ducks/conversations'; import { EnvelopeClass } from '../textsecure.d'; import { @@ -292,6 +296,56 @@ export class CallingClass { return call instanceof GroupCall ? call : undefined; } + private getGroupCallMembers(conversationId: string) { + return getMembershipList(conversationId).map( + member => + new GroupMemberInfo( + uuidToArrayBuffer(member.uuid), + member.uuidCiphertext + ) + ); + } + + public async peekGroupCall(conversationId: string): Promise { + // This can be undefined in two cases: + // + // 1. There is no group call instance. This is "stateless peeking", and is expected + // when we want to peek on a call that we've never connected to. + // 2. There is a group call instance but RingRTC doesn't have the peek info yet. This + // should only happen for a brief period as you connect to the call. (You probably + // don't want to call this function while a group call is connected—you should + // instead be grabbing the peek info off of the instance—but we handle it here + // to avoid possible race conditions.) + const statefulPeekInfo = this.getGroupCall(conversationId)?.getPeekInfo(); + if (statefulPeekInfo) { + return statefulPeekInfo; + } + + const conversation = window.ConversationController.get(conversationId); + if (!conversation) { + throw new Error('Missing conversation; not peeking group call'); + } + const publicParams = conversation.get('publicParams'); + const secretParams = conversation.get('secretParams'); + if (!publicParams || !secretParams) { + throw new Error( + 'Conversation is missing required parameters. Cannot peek group call' + ); + } + + const proof = await fetchMembershipProof({ publicParams, secretParams }); + if (!proof) { + throw new Error('No membership proof. Cannot peek group call'); + } + const membershipProof = new TextEncoder().encode(proof).buffer; + + return RingRTC.peekGroupCall( + RINGRTC_SFU_URL, + membershipProof, + this.getGroupCallMembers(conversationId) + ); + } + /** * Connect to a conversation's group call and connect it to Redux. * @@ -379,16 +433,8 @@ export class CallingClass { isRequestingMembershipProof = false; } }, - requestGroupMembers(groupCall) { - groupCall.setGroupMembers( - getMembershipList(conversationId).map( - member => - new GroupMemberInfo( - uuidToArrayBuffer(member.uuid), - member.uuidCiphertext - ) - ) - ); + requestGroupMembers: groupCall => { + groupCall.setGroupMembers(this.getGroupCallMembers(conversationId)); }, onEnded: noop, } @@ -480,6 +526,30 @@ export class CallingClass { } } + private uuidToConversationId(userId: ArrayBuffer): string { + const result = window.ConversationController.ensureContactIds({ + uuid: arrayBufferToUuid(userId), + }); + if (!result) { + throw new Error( + 'Calling.uuidToConversationId: no conversation found for that UUID' + ); + } + return result; + } + + public formatGroupCallPeekInfoForRedux( + peekInfo: PeekInfo = { joinedMembers: [], deviceCount: 0 } + ): GroupCallPeekInfoType { + return { + conversationIds: peekInfo.joinedMembers.map(this.uuidToConversationId), + creator: peekInfo.creator && this.uuidToConversationId(peekInfo.creator), + eraId: peekInfo.eraId, + maxDevices: peekInfo.maxDevices ?? Infinity, + deviceCount: peekInfo.deviceCount, + }; + } + private formatGroupCallForRedux(groupCall: GroupCall) { const localDeviceState = groupCall.getLocalDeviceState(); @@ -491,6 +561,14 @@ export class CallingClass { remoteDeviceState => remoteDeviceState.demuxId ); + // `GroupCall.prototype.getPeekInfo()` won't return anything at first, so we try to + // set a reasonable default based on the remote device states (which is likely an + // empty array at this point, but we handle the case where it is not). + const peekInfo = groupCall.getPeekInfo() || { + joinedMembers: remoteDeviceStates.map(({ userId }) => userId), + deviceCount: remoteDeviceStates.length, + }; + // It should be impossible to be disconnected and Joining or Joined. Just in case, we // try to handle that case. const joinState: GroupCallJoinState = @@ -498,7 +576,7 @@ export class CallingClass { ? GroupCallJoinState.NotJoined : this.convertRingRtcJoinState(localDeviceState.joinState); - const ourId = window.ConversationController.getOurConversationId(); + const ourConversationId = window.ConversationController.getOurConversationId(); return { connectionState: this.convertRingRtcConnectionState( @@ -507,22 +585,17 @@ export class CallingClass { joinState, hasLocalAudio: !localDeviceState.audioMuted, hasLocalVideo: !localDeviceState.videoMuted, + peekInfo: this.formatGroupCallPeekInfoForRedux(peekInfo), remoteParticipants: remoteDeviceStates.map(remoteDeviceState => { - const uuid = arrayBufferToUuid(remoteDeviceState.userId); - - const id = window.ConversationController.ensureContactIds({ uuid }); - if (!id) { - throw new Error( - 'Calling.formatGroupCallForRedux: no conversation found' - ); - } - + const conversationId = this.uuidToConversationId( + remoteDeviceState.userId + ); return { - conversationId: id, + conversationId, demuxId: remoteDeviceState.demuxId, hasRemoteAudio: !remoteDeviceState.audioMuted, hasRemoteVideo: !remoteDeviceState.videoMuted, - isSelf: id === ourId, + isSelf: conversationId === ourConversationId, // If RingRTC doesn't send us an aspect ratio, we make a guess. videoAspectRatio: remoteDeviceState.videoAspectRatio || diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 32e126b94..f7983b4c9 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -26,9 +26,19 @@ import { bounceAppIconStart, bounceAppIconStop, } from '../../shims/bounceAppIcon'; +import { sleep } from '../../util/sleep'; +import { LatestQueue } from '../../util/LatestQueue'; // State +export interface GroupCallPeekInfoType { + conversationIds: Array; + creator?: string; + eraId?: string; + maxDevices: number; + deviceCount: number; +} + export interface GroupCallParticipantInfoType { conversationId: string; demuxId: number; @@ -53,6 +63,7 @@ export interface GroupCallStateType { conversationId: string; connectionState: GroupCallConnectionState; joinState: GroupCallJoinState; + peekInfo: GroupCallPeekInfoType; remoteParticipants: Array; } @@ -103,15 +114,20 @@ export type DeclineCallType = { conversationId: string; }; -export type GroupCallStateChangeType = { - conversationId: string; +type GroupCallStateChangeArgumentType = { connectionState: GroupCallConnectionState; - joinState: GroupCallJoinState; + conversationId: string; hasLocalAudio: boolean; hasLocalVideo: boolean; + joinState: GroupCallJoinState; + peekInfo: GroupCallPeekInfoType; remoteParticipants: Array; }; +type GroupCallStateChangeActionPayloadType = GroupCallStateChangeArgumentType & { + ourConversationId: string; +}; + export type HangUpType = { conversationId: string; }; @@ -121,6 +137,10 @@ export type IncomingCallType = { isVideoCall: boolean; }; +type PeekNotConnectedGroupCallType = { + conversationId: string; +}; + interface StartDirectCallType { conversationId: string; hasLocalAudio: boolean; @@ -158,6 +178,7 @@ export type ShowCallLobbyType = joinState: GroupCallJoinState; hasLocalAudio: boolean; hasLocalVideo: boolean; + peekInfo: GroupCallPeekInfoType; remoteParticipants: Array; }; @@ -178,6 +199,11 @@ export const getActiveCall = ({ activeCallState && getOwn(callsByConversation, activeCallState.conversationId); +export const isAnybodyElseInGroupCall = ( + { conversationIds }: Readonly, + ourConversationId: string +): boolean => conversationIds.some(id => id !== ourConversationId); + // Actions const ACCEPT_CALL_PENDING = 'calling/ACCEPT_CALL_PENDING'; @@ -191,6 +217,8 @@ const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE'; const HANG_UP = 'calling/HANG_UP'; const INCOMING_CALL = 'calling/INCOMING_CALL'; const OUTGOING_CALL = 'calling/OUTGOING_CALL'; +const PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED = + 'calling/PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED'; const REFRESH_IO_DEVICES = 'calling/REFRESH_IO_DEVICES'; const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE'; const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED'; @@ -234,9 +262,9 @@ type DeclineCallActionType = { payload: DeclineCallType; }; -type GroupCallStateChangeActionType = { +export type GroupCallStateChangeActionType = { type: 'calling/GROUP_CALL_STATE_CHANGE'; - payload: GroupCallStateChangeType; + payload: GroupCallStateChangeActionPayloadType; }; type HangUpActionType = { @@ -254,6 +282,15 @@ type OutgoingCallActionType = { payload: StartDirectCallType; }; +type PeekNotConnectedGroupCallFulfilledActionType = { + type: 'calling/PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED'; + payload: { + conversationId: string; + peekInfo: GroupCallPeekInfoType; + ourConversationId: string; + }; +}; + type RefreshIODevicesActionType = { type: 'calling/REFRESH_IO_DEVICES'; payload: MediaDeviceSettings; @@ -308,6 +345,7 @@ export type CallingActionType = | HangUpActionType | IncomingCallActionType | OutgoingCallActionType + | PeekNotConnectedGroupCallFulfilledActionType | RefreshIODevicesActionType | RemoteVideoChangeActionType | SetLocalAudioActionType @@ -439,11 +477,16 @@ function declineCall(payload: DeclineCallType): DeclineCallActionType { } function groupCallStateChange( - payload: GroupCallStateChangeType -): GroupCallStateChangeActionType { - return { - type: GROUP_CALL_STATE_CHANGE, - payload, + payload: GroupCallStateChangeArgumentType +): ThunkAction { + return (dispatch, getState) => { + dispatch({ + type: GROUP_CALL_STATE_CHANGE, + payload: { + ...payload, + ourConversationId: getState().user.ourConversationId, + }, + }); }; } @@ -474,6 +517,79 @@ function outgoingCall(payload: StartDirectCallType): OutgoingCallActionType { }; } +// We might call this function many times in rapid succession (for example, if lots of +// people are joining and leaving at once). We want to make sure to update eventually +// (if people join and leave for an hour, we don't want you to have to wait an hour to +// get an update), and we also don't want to update too often. That's why we use a +// "latest queue". +const peekQueueByConversation = new Map(); +function peekNotConnectedGroupCall( + payload: PeekNotConnectedGroupCallType +): ThunkAction< + void, + RootStateType, + unknown, + PeekNotConnectedGroupCallFulfilledActionType +> { + return (dispatch, getState) => { + const { conversationId } = payload; + + let queue = peekQueueByConversation.get(conversationId); + if (!queue) { + queue = new LatestQueue(); + queue.onceEmpty(() => { + peekQueueByConversation.delete(conversationId); + }); + peekQueueByConversation.set(conversationId, queue); + } + + queue.add(async () => { + const state = getState(); + + // We make sure we're not trying to peek at a connected (or connecting, or + // reconnecting) call. Because this is asynchronous, it's possible that the call + // will connect by the time we dispatch, so we also need to do a similar check in + // the reducer. + const existingCall = getOwn( + state.calling.callsByConversation, + conversationId + ); + if ( + existingCall?.callMode === CallMode.Group && + existingCall.connectionState !== GroupCallConnectionState.NotConnected + ) { + return; + } + + // If we peek right after receiving the message, we may get outdated information. + // This is most noticeable when someone leaves. We add a delay and then make sure + // to only be peeking once. + await sleep(1000); + + let peekInfo; + try { + peekInfo = await calling.peekGroupCall(conversationId); + } catch (err) { + window.log.error('Group call peeking failed', err); + return; + } + + if (!peekInfo) { + return; + } + + dispatch({ + type: PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED, + payload: { + conversationId, + peekInfo: calling.formatGroupCallPeekInfoForRedux(peekInfo), + ourConversationId: state.user.ourConversationId, + }, + }); + }); + }; +} + function refreshIODevices( payload: MediaDeviceSettings ): RefreshIODevicesActionType { @@ -631,6 +747,7 @@ export const actions = { hangUp, receiveIncomingCall, outgoingCall, + peekNotConnectedGroupCall, refreshIODevices, remoteVideoChange, setLocalPreview, @@ -699,6 +816,7 @@ export function reducer( conversationId: action.payload.conversationId, connectionState: action.payload.connectionState, joinState: action.payload.joinState, + peekInfo: action.payload.peekInfo, remoteParticipants: action.payload.remoteParticipants, }; break; @@ -878,23 +996,40 @@ export function reducer( if (action.type === GROUP_CALL_STATE_CHANGE) { const { - conversationId, connectionState, - joinState, + conversationId, hasLocalAudio, hasLocalVideo, + joinState, + ourConversationId, + peekInfo, remoteParticipants, } = action.payload; + let newActiveCallState: ActiveCallStateType | undefined; + if (connectionState === GroupCallConnectionState.NotConnected) { - return { - ...state, - callsByConversation: omit(callsByConversation, conversationId), - activeCallState: - state.activeCallState?.conversationId === conversationId - ? undefined - : state.activeCallState, - }; + newActiveCallState = + state.activeCallState?.conversationId === conversationId + ? undefined + : state.activeCallState; + + if (!isAnybodyElseInGroupCall(peekInfo, ourConversationId)) { + return { + ...state, + callsByConversation: omit(callsByConversation, conversationId), + activeCallState: newActiveCallState, + }; + } + } else { + newActiveCallState = + state.activeCallState?.conversationId === conversationId + ? { + ...state.activeCallState, + hasLocalAudio, + hasLocalVideo, + } + : state.activeCallState; } return { @@ -906,17 +1041,63 @@ export function reducer( conversationId, connectionState, joinState, + peekInfo, remoteParticipants, }, }, - activeCallState: - state.activeCallState?.conversationId === conversationId - ? { - ...state.activeCallState, - hasLocalAudio, - hasLocalVideo, - } - : state.activeCallState, + activeCallState: newActiveCallState, + }; + } + + if (action.type === PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED) { + const { conversationId, peekInfo, ourConversationId } = action.payload; + + const existingCall = getOwn(state.callsByConversation, conversationId) || { + callMode: CallMode.Group, + conversationId, + connectionState: GroupCallConnectionState.NotConnected, + joinState: GroupCallJoinState.NotJoined, + peekInfo: { + conversationIds: [], + maxDevices: Infinity, + deviceCount: 0, + }, + remoteParticipants: [], + }; + + if (existingCall.callMode !== CallMode.Group) { + window.log.error( + 'Unexpected state: trying to update a non-group call. Doing nothing' + ); + return state; + } + + // This action should only update non-connected group calls. It's not necessarily a + // mistake if this action is dispatched "over" a connected call. Here's a valid + // sequence of events: + // + // 1. We ask RingRTC to peek, kicking off an asynchronous operation. + // 2. The associated group call is joined. + // 3. The peek promise from step 1 resolves. + if ( + existingCall.connectionState !== GroupCallConnectionState.NotConnected + ) { + return state; + } + + if (!isAnybodyElseInGroupCall(peekInfo, ourConversationId)) { + return removeConversationFromState(state, conversationId); + } + + return { + ...state, + callsByConversation: { + ...callsByConversation, + [conversationId]: { + ...existingCall, + peekInfo, + }, + }, }; } diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx index 047bec44a..fd2ff0dd2 100644 --- a/ts/state/smart/ConversationHeader.tsx +++ b/ts/state/smart/ConversationHeader.tsx @@ -14,8 +14,9 @@ import { ConversationType, getConversationCallMode, } from '../ducks/conversations'; -import { getActiveCall } from '../ducks/calling'; -import { getIntl } from '../selectors/user'; +import { getActiveCall, isAnybodyElseInGroupCall } from '../ducks/calling'; +import { getUserConversationId, getIntl } from '../selectors/user'; +import { getOwn } from '../../util/getOwn'; import { missingCaseError } from '../../util/missingCaseError'; export interface OwnProps { @@ -43,7 +44,9 @@ const getOutgoingCallButtonStyle = ( conversation: ConversationType, state: StateType ): OutgoingCallButtonStyle => { - if (getActiveCall(state.calling)) { + const { calling } = state; + + if (getActiveCall(calling)) { return OutgoingCallButtonStyle.None; } @@ -53,11 +56,19 @@ const getOutgoingCallButtonStyle = ( return OutgoingCallButtonStyle.None; case CallMode.Direct: return OutgoingCallButtonStyle.Both; - case CallMode.Group: + case CallMode.Group: { if (!window.GROUP_CALLING) { return OutgoingCallButtonStyle.None; } + const call = getOwn(calling.callsByConversation, conversation.id); + if ( + call?.callMode === CallMode.Group && + isAnybodyElseInGroupCall(call.peekInfo, getUserConversationId(state)) + ) { + return OutgoingCallButtonStyle.Join; + } return OutgoingCallButtonStyle.JustVideo; + } default: throw missingCaseError(conversationCallMode); } diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index f47fe6778..89384169f 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -7,9 +7,11 @@ import { reducer as rootReducer } from '../../../state/reducer'; import { noopAction } from '../../../state/ducks/noop'; import { CallingStateType, + GroupCallStateChangeActionType, actions, getActiveCall, getEmptyState, + isAnybodyElseInGroupCall, reducer, } from '../../../state/ducks/calling'; import { calling as callingService } from '../../../services/calling'; @@ -69,6 +71,13 @@ describe('calling duck', () => { conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, + peekInfo: { + conversationIds: ['456'], + creator: '456', + eraId: 'xyz', + maxDevices: 16, + deviceCount: 1, + }, remoteParticipants: [ { conversationId: '123', @@ -95,7 +104,18 @@ describe('calling duck', () => { }, }; - const getEmptyRootState = () => rootReducer(undefined, noopAction()); + const ourConversationId = 'ebf5fd79-9344-4ec1-b5c9-af463572caf5'; + + const getEmptyRootState = () => { + const rootState = rootReducer(undefined, noopAction()); + return { + ...rootState, + user: { + ...rootState.user, + ourConversationId, + }, + }; + }; beforeEach(function beforeEach() { this.sandbox = sinon.createSandbox(); @@ -235,15 +255,30 @@ describe('calling duck', () => { describe('groupCallStateChange', () => { const { groupCallStateChange } = actions; - it('ignores new calls that are not connected', () => { + function getAction( + ...args: Parameters + ): GroupCallStateChangeActionType { + const dispatch = sinon.spy(); + + groupCallStateChange(...args)(dispatch, getEmptyRootState, null); + + return dispatch.getCall(0).args[0]; + } + + it('ignores non-connected calls with no peeked participants', () => { const result = reducer( getEmptyState(), - groupCallStateChange({ + getAction({ conversationId: 'abc123', connectionState: GroupCallConnectionState.NotConnected, joinState: GroupCallJoinState.NotJoined, hasLocalAudio: false, hasLocalVideo: false, + peekInfo: { + conversationIds: [], + maxDevices: 16, + deviceCount: 0, + }, remoteParticipants: [], }) ); @@ -251,15 +286,20 @@ describe('calling duck', () => { assert.deepEqual(result, getEmptyState()); }); - it('removes the call from the map of conversations if the call is disconnected', () => { + it('removes the call from the map of conversations if the call is not connected and has no peeked participants', () => { const result = reducer( stateWithGroupCall, - groupCallStateChange({ + getAction({ conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.NotConnected, joinState: GroupCallJoinState.NotJoined, hasLocalAudio: false, hasLocalVideo: false, + peekInfo: { + conversationIds: [], + maxDevices: 16, + deviceCount: 0, + }, remoteParticipants: [], }) ); @@ -270,15 +310,65 @@ describe('calling duck', () => { ); }); - it('drops the active call if it is disconnected', () => { + it('removes the call from the map of conversations if the call is not connected and has 1 peeked participant: you', () => { const result = reducer( - stateWithActiveGroupCall, - groupCallStateChange({ + stateWithGroupCall, + getAction({ conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.NotConnected, joinState: GroupCallJoinState.NotJoined, hasLocalAudio: false, hasLocalVideo: false, + peekInfo: { + conversationIds: [ourConversationId], + maxDevices: 16, + deviceCount: 1, + }, + remoteParticipants: [], + }) + ); + + assert.notProperty( + result.callsByConversation, + 'fake-group-call-conversation-id' + ); + }); + + it('drops the active call if it is disconnected with no peeked participants', () => { + const result = reducer( + stateWithActiveGroupCall, + getAction({ + conversationId: 'fake-group-call-conversation-id', + connectionState: GroupCallConnectionState.NotConnected, + joinState: GroupCallJoinState.NotJoined, + hasLocalAudio: false, + hasLocalVideo: false, + peekInfo: { + conversationIds: [], + maxDevices: 16, + deviceCount: 0, + }, + remoteParticipants: [], + }) + ); + + assert.isUndefined(result.activeCallState); + }); + + it('drops the active call if it is disconnected with 1 peeked participant (you)', () => { + const result = reducer( + stateWithActiveGroupCall, + getAction({ + conversationId: 'fake-group-call-conversation-id', + connectionState: GroupCallConnectionState.NotConnected, + joinState: GroupCallJoinState.NotJoined, + hasLocalAudio: false, + hasLocalVideo: false, + peekInfo: { + conversationIds: [ourConversationId], + maxDevices: 16, + deviceCount: 1, + }, remoteParticipants: [], }) ); @@ -289,12 +379,19 @@ describe('calling duck', () => { it('saves a new call to the map of conversations', () => { const result = reducer( getEmptyState(), - groupCallStateChange({ + getAction({ conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joining, hasLocalAudio: true, hasLocalVideo: false, + peekInfo: { + conversationIds: ['456'], + creator: '456', + eraId: 'xyz', + maxDevices: 16, + deviceCount: 1, + }, remoteParticipants: [ { conversationId: '123', @@ -315,6 +412,13 @@ describe('calling duck', () => { conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joining, + peekInfo: { + conversationIds: ['456'], + creator: '456', + eraId: 'xyz', + maxDevices: 16, + deviceCount: 1, + }, remoteParticipants: [ { conversationId: '123', @@ -329,15 +433,55 @@ describe('calling duck', () => { ); }); + it('saves a new call to the map of conversations if the call is disconnected by has peeked participants that are not you', () => { + const result = reducer( + stateWithGroupCall, + getAction({ + conversationId: 'fake-group-call-conversation-id', + connectionState: GroupCallConnectionState.NotConnected, + joinState: GroupCallJoinState.NotJoined, + hasLocalAudio: false, + hasLocalVideo: false, + peekInfo: { + conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'], + maxDevices: 16, + deviceCount: 1, + }, + remoteParticipants: [], + }) + ); + + assert.deepEqual( + result.callsByConversation['fake-group-call-conversation-id'], + { + callMode: CallMode.Group, + conversationId: 'fake-group-call-conversation-id', + connectionState: GroupCallConnectionState.NotConnected, + joinState: GroupCallJoinState.NotJoined, + peekInfo: { + conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'], + maxDevices: 16, + deviceCount: 1, + }, + remoteParticipants: [], + } + ); + }); + it('updates a call in the map of conversations', () => { const result = reducer( stateWithGroupCall, - groupCallStateChange({ + getAction({ conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, hasLocalAudio: true, hasLocalVideo: false, + peekInfo: { + conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'], + maxDevices: 16, + deviceCount: 1, + }, remoteParticipants: [ { conversationId: '123', @@ -358,6 +502,11 @@ describe('calling duck', () => { conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, + peekInfo: { + conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'], + maxDevices: 16, + deviceCount: 1, + }, remoteParticipants: [ { conversationId: '123', @@ -375,12 +524,17 @@ describe('calling duck', () => { it("if no call is active, doesn't touch the active call state", () => { const result = reducer( stateWithGroupCall, - groupCallStateChange({ + getAction({ conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, hasLocalAudio: true, hasLocalVideo: false, + peekInfo: { + conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'], + maxDevices: 16, + deviceCount: 1, + }, remoteParticipants: [ { conversationId: '123', @@ -400,12 +554,17 @@ describe('calling duck', () => { it("if the call is not active, doesn't touch the active call state", () => { const result = reducer( stateWithActiveGroupCall, - groupCallStateChange({ + getAction({ conversationId: 'another-fake-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, hasLocalAudio: true, hasLocalVideo: true, + peekInfo: { + conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'], + maxDevices: 16, + deviceCount: 1, + }, remoteParticipants: [ { conversationId: '123', @@ -432,12 +591,17 @@ describe('calling duck', () => { it('if the call is active, updates the active call state', () => { const result = reducer( stateWithActiveGroupCall, - groupCallStateChange({ + getAction({ conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, hasLocalAudio: true, hasLocalVideo: true, + peekInfo: { + conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'], + maxDevices: 16, + deviceCount: 1, + }, remoteParticipants: [ { conversationId: '123', @@ -460,6 +624,67 @@ describe('calling duck', () => { }); }); + describe('peekNotConnectedGroupCall', () => { + const { peekNotConnectedGroupCall } = actions; + + beforeEach(function beforeEach() { + this.callingServicePeekGroupCall = this.sandbox.stub( + callingService, + 'peekGroupCall' + ); + this.clock = this.sandbox.useFakeTimers(); + }); + + describe('thunk', () => { + function noopTest(connectionState: GroupCallConnectionState) { + return async function test(this: Mocha.ITestCallbackContext) { + const dispatch = sinon.spy(); + + await peekNotConnectedGroupCall({ + conversationId: 'fake-group-call-conversation-id', + })( + dispatch, + () => ({ + ...getEmptyRootState(), + calling: { + ...stateWithGroupCall, + callsByConversation: { + 'fake-group-call-conversation-id': { + ...stateWithGroupCall.callsByConversation[ + 'fake-group-call-conversation-id' + ], + connectionState, + }, + }, + }, + }), + null + ); + + sinon.assert.notCalled(dispatch); + sinon.assert.notCalled(this.callingServicePeekGroupCall); + }; + } + + it( + 'no-ops if trying to peek at a connecting group call', + noopTest(GroupCallConnectionState.Connecting) + ); + + it( + 'no-ops if trying to peek at a connected group call', + noopTest(GroupCallConnectionState.Connected) + ); + + it( + 'no-ops if trying to peek at a reconnecting group call', + noopTest(GroupCallConnectionState.Reconnecting) + ); + + // These tests are incomplete. + }); + }); + describe('setLocalAudio', () => { const { setLocalAudio } = actions; @@ -738,5 +963,52 @@ describe('calling duck', () => { }); }); }); + + describe('isAnybodyElseInGroupCall', () => { + const fakePeekInfo = (conversationIds: Array) => ({ + 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' + ) + ); + }); + }); }); }); diff --git a/ts/test/util/LatestQueue_test.ts b/ts/test/util/LatestQueue_test.ts new file mode 100644 index 000000000..c1003d636 --- /dev/null +++ b/ts/test/util/LatestQueue_test.ts @@ -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(); + }); + }); +}); diff --git a/ts/util/LatestQueue.ts b/ts/util/LatestQueue.ts new file mode 100644 index 000000000..de3021342 --- /dev/null +++ b/ts/util/LatestQueue.ts @@ -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; + + 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 { + 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); + } +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 52315ab1d..bfbbc2cf7 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -14728,7 +14728,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/ConversationHeader.js", "line": " this.menuTriggerRef = react_1.default.createRef();", - "lineNumber": 28, + "lineNumber": 29, "reasonCategory": "usageTrusted", "updated": "2020-08-28T16:12:19.904Z", "reasonDetail": "Used to reference popup menu" @@ -14737,7 +14737,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/ConversationHeader.tsx", "line": " this.menuTriggerRef = React.createRef();", - "lineNumber": 100, + "lineNumber": 101, "reasonCategory": "usageTrusted", "updated": "2020-05-20T20:10:43.540Z", "reasonDetail": "Used to reference popup menu"