diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 38fab7478f1a..5e16f2de66f5 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1685,6 +1685,18 @@ "messageformat": "More options", "description": "Tooltip label for button in the calling screen that opens a menu with other call actions such as React or Raise Hand." }, + "icu:CallingPendingParticipants__ApproveUser": { + "messageformat": "Approve join request", + "description": "Tooltip label for check mark button to approve a user's request to join a call." + }, + "icu:CallingPendingParticipants__DenyUser": { + "messageformat": "Deny join request", + "description": "Tooltip label for check mark button to deny a user's request to join a call." + }, + "icu:CallingPendingParticipants__RequestsToJoin": { + "messageformat": "{count, plural, one {# request} other {# requests}} to join the call", + "description": "Shown in the call pending join request list to describe how many people are requesting to join" + }, "icu:CallingRaisedHandsList__Title": { "messageformat": "Raised hands ยท {count, plural, one {# person} other {# people}}", "description": "Shown in the call raised hands list to describe how many people have active raised hands" @@ -3654,6 +3666,10 @@ "messageformat": "Copy link", "description": "Menu item in the in-call info popup for call link calls. The action is to add the call link to the clipboard." }, + "icu:CallingAdhocCallInfo__RemoveClient": { + "messageformat": "Remove this person from the call", + "description": "Button in the in-call info popup for call link calls showing all participants. The action is to remove the participant from the call." + }, "icu:callingDeviceSelection__label--video": { "messageformat": "Video", "description": "Label for video input selector" diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 88a9a45d04c6..1779703e4467 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -4511,6 +4511,13 @@ button.module-image__border-overlay:focus { $color-white ); } + + &__remove { + @include color-svg( + '../images/icons/v3/minus/minus-circle-compact.svg', + $color-white + ); + } } .module-call-need-permission-screen { diff --git a/stylesheets/components/CallingAdhocCallInfo.scss b/stylesheets/components/CallingAdhocCallInfo.scss index 7d41c097084f..6adc5bd5f43b 100644 --- a/stylesheets/components/CallingAdhocCallInfo.scss +++ b/stylesheets/components/CallingAdhocCallInfo.scss @@ -81,3 +81,11 @@ margin-inline: 10px; border: 1px solid $color-gray-65; } + +.CallingAdhocCallInfo__RemoveClient { + @include button-reset; + width: 16px; + height: 16px; + margin-inline: 8px; + background: $color-white; +} diff --git a/stylesheets/components/CallingPendingParticipants.scss b/stylesheets/components/CallingPendingParticipants.scss new file mode 100644 index 000000000000..1be772ef1129 --- /dev/null +++ b/stylesheets/components/CallingPendingParticipants.scss @@ -0,0 +1,34 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.CallingPendingParticipants { + width: 420px; + height: auto; + padding-block-end: 2px; + margin-block-start: auto; + margin-block-end: 36px; + margin-inline-start: auto; + margin-inline-end: auto; +} + +.CallingPendingParticipants__PendingActionButton { + padding-inline: 0; + margin-inline-end: 16px; +} + +.CallingPendingParticipants__PendingActionButton:last-child { + margin-inline-end: 8px; +} + +.CallingPendingParticipants__PendingActionButtonIcon { + width: 20px; + height: 20px; +} + +.CallingPendingParticipants__PendingActionButtonIcon--Approve { + @include color-svg('../images/icons/v3/check/check.svg', $color-white); +} + +.CallingPendingParticipants__PendingActionButtonIcon--Deny { + @include color-svg('../images/icons/v3/x/x.svg', $color-white); +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 9023dc289326..68c3e5ab0b8d 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -45,6 +45,7 @@ @import './components/CallControls.scss'; @import './components/CallSettingsButton.scss'; @import './components/CallingLobby.scss'; +@import './components/CallingPendingParticipants.scss'; @import './components/CallingPreCallInfo.scss'; @import './components/CallingScreenSharingController.scss'; @import './components/CallingSelectPresentingSourcesModal.scss'; diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index 5d9869984f18..bb2b25dbc101 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -59,12 +59,14 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ ...storyProps, availableCameras: [], acceptCall: action('accept-call'), + approveUser: action('approve-user'), bounceAppIconStart: action('bounce-app-icon-start'), bounceAppIconStop: action('bounce-app-icon-stop'), cancelCall: action('cancel-call'), changeCallView: action('change-call-view'), closeNeedPermissionScreen: action('close-need-permission-screen'), declineCall: action('decline-call'), + denyUser: action('deny-user'), getGroupCallVideoFrameSource: (_: string, demuxId: number) => fakeGetGroupCallVideoFrameSource(demuxId), getPresentingSources: action('get-presenting-sources'), @@ -84,6 +86,7 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ notifyForCall: action('notify-for-call'), openSystemPreferencesAction: action('open-system-preferences-action'), playRingtone: action('play-ringtone'), + removeClient: action('remove-client'), renderDeviceSelection: () =>
, renderEmojiPicker: () => <>EmojiPicker, renderReactionPicker: () =>
, @@ -156,6 +159,7 @@ export function OngoingGroupCall(): JSX.Element { groupMembers: [], isConversationTooBigToRing: false, peekedParticipants: [], + pendingParticipants: [], raisedHands: new Set(), remoteParticipants: [], remoteAudioLevels: new Map(), diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index c335a5bc12d3..d4d839aee1a0 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -31,6 +31,8 @@ import type { CancelCallType, DeclineCallType, GroupCallParticipantInfoType, + PendingUserActionPayloadType, + RemoveClientType, SendGroupCallRaiseHandType, SendGroupCallReactionType, SetGroupCallVideoRequestType, @@ -95,9 +97,11 @@ export type PropsType = { startCall: (payload: StartCallType) => void; toggleParticipants: () => void; acceptCall: (_: AcceptCallType) => void; + approveUser: (payload: PendingUserActionPayloadType) => void; bounceAppIconStart: () => unknown; bounceAppIconStop: () => unknown; declineCall: (_: DeclineCallType) => void; + denyUser: (payload: PendingUserActionPayloadType) => void; hasInitialLoadCompleted: boolean; i18n: LocalizerType; isGroupCallRaiseHandEnabled: boolean; @@ -109,6 +113,7 @@ export type PropsType = { ) => unknown; openSystemPreferencesAction: () => unknown; playRingtone: () => unknown; + removeClient: (payload: RemoveClientType) => void; sendGroupCallRaiseHand: (payload: SendGroupCallRaiseHandType) => void; sendGroupCallReaction: (payload: SendGroupCallReactionType) => void; setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void; @@ -151,11 +156,13 @@ type ActiveCallManagerPropsType = { function ActiveCallManager({ activeCall, + approveUser, availableCameras, callLink, cancelCall, changeCallView, closeNeedPermissionScreen, + denyUser, hangUpActiveCall, i18n, isGroupCallRaiseHandEnabled, @@ -166,6 +173,7 @@ function ActiveCallManager({ renderDeviceSelection, renderEmojiPicker, renderReactionPicker, + removeClient, sendGroupCallRaiseHand, sendGroupCallReaction, setGroupCallVideoRequest, @@ -258,6 +266,7 @@ function ActiveCallManager({ let isConvoTooBigToRing = false; let isAdhocAdminApprovalRequired = false; let isAdhocJoinRequestPending = false; + let isCallLinkAdmin = false; switch (activeCall.callMode) { case CallMode.Direct: { @@ -292,6 +301,7 @@ function ActiveCallManager({ isAdhocJoinRequestPending = isAdhocAdminApprovalRequired && activeCall.joinState === GroupCallJoinState.Pending; + isCallLinkAdmin = Boolean(callLink?.adminKey); break; } default: @@ -352,10 +362,12 @@ function ActiveCallManager({ ) : ( ) : ( ; + pendingParticipants?: Array; raisedHands?: Set; remoteParticipants?: Array; remoteAudioLevel?: number; @@ -135,6 +136,7 @@ const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({ isConversationTooBigToRing: false, peekedParticipants: overrideProps.peekedParticipants || overrideProps.remoteParticipants || [], + pendingParticipants: overrideProps.pendingParticipants || [], raisedHands: overrideProps.raisedHands || getRaisedHands(overrideProps) || @@ -181,11 +183,14 @@ const createProps = ( } ): PropsType => ({ activeCall: createActiveCallProp(overrideProps), + approveUser: action('approve-user'), changeCallView: action('change-call-view'), + denyUser: action('deny-user'), getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource, getPresentingSources: action('get-presenting-sources'), hangUpActiveCall: action('hang-up'), i18n, + isCallLinkAdmin: true, isGroupCallRaiseHandEnabled: true, me: getDefaultConversation({ color: AvatarColors[1], diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index d2d69dd74122..c1d6252f88a7 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -8,6 +8,7 @@ import classNames from 'classnames'; import type { VideoFrameSource } from '@signalapp/ringrtc'; import type { ActiveCallStateType, + PendingUserActionPayloadType, SendGroupCallRaiseHandType, SendGroupCallReactionType, SetLocalAudioType, @@ -88,14 +89,18 @@ import { import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall'; import { assertDev } from '../util/assert'; import { emojiToData } from './emoji/lib'; +import { CallingPendingParticipants } from './CallingPendingParticipants'; export type PropsType = { activeCall: ActiveCallType; + approveUser: (payload: PendingUserActionPayloadType) => void; + denyUser: (payload: PendingUserActionPayloadType) => void; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; getPresentingSources: () => void; groupMembers?: Array>; hangUpActiveCall: (reason: string) => void; i18n: LocalizerType; + isCallLinkAdmin: boolean; isGroupCallRaiseHandEnabled: boolean; me: ConversationType; openSystemPreferencesAction: () => unknown; @@ -178,12 +183,15 @@ function CallDuration({ export function CallScreen({ activeCall, + approveUser, changeCallView, + denyUser, getGroupCallVideoFrameSource, getPresentingSources, groupMembers, hangUpActiveCall, i18n, + isCallLinkAdmin, isGroupCallRaiseHandEnabled, me, openSystemPreferencesAction, @@ -396,6 +404,11 @@ export function CallScreen({ throw missingCaseError(activeCall); } + const pendingParticipants = + activeCall.callMode === CallMode.Adhoc && isCallLinkAdmin + ? activeCall.pendingParticipants + : []; + let lonelyInCallNode: ReactNode; let localPreviewNode: ReactNode; @@ -811,6 +824,15 @@ export function CallScreen({ renderRaisedHandsToast={renderRaisedHandsToast} i18n={i18n} /> + {pendingParticipants.length ? ( + + ) : null} {/* We render the local preview first and set the footer flex direction to row-reverse to ensure the preview is visible at low viewport widths. */}
diff --git a/ts/components/CallingAdhocCallInfo.stories.tsx b/ts/components/CallingAdhocCallInfo.stories.tsx index c5b545133c62..5abbdc112cd7 100644 --- a/ts/components/CallingAdhocCallInfo.stories.tsx +++ b/ts/components/CallingAdhocCallInfo.stories.tsx @@ -61,10 +61,12 @@ function getCallLink(overrideProps: Partial = {}): CallLinkType { const createProps = (overrideProps: Partial = {}): PropsType => ({ callLink: getCallLink(overrideProps.callLink || {}), i18n, + isCallLinkAdmin: overrideProps.isCallLinkAdmin || false, ourServiceId: generateAci(), participants: overrideProps.participants || [], onClose: action('on-close'), onCopyCallLink: action('on-copy-call-link'), + removeClient: overrideProps.removeClient || action('remove-client'), }); export default { diff --git a/ts/components/CallingAdhocCallInfo.tsx b/ts/components/CallingAdhocCallInfo.tsx index f0b3c39e6f13..4d5c501806d4 100644 --- a/ts/components/CallingAdhocCallInfo.tsx +++ b/ts/components/CallingAdhocCallInfo.tsx @@ -16,29 +16,35 @@ import { sortByTitle } from '../util/sortByTitle'; import type { ConversationType } from '../state/ducks/conversations'; import { ModalHost } from './ModalHost'; import { isInSystemContacts } from '../util/isInSystemContacts'; +import type { RemoveClientType } from '../state/ducks/calling'; type ParticipantType = ConversationType & { hasRemoteAudio?: boolean; hasRemoteVideo?: boolean; isHandRaised?: boolean; presenting?: boolean; + demuxId?: number; }; export type PropsType = { readonly callLink: CallLinkType; readonly i18n: LocalizerType; + readonly isCallLinkAdmin: boolean; readonly ourServiceId: ServiceIdString | undefined; readonly participants: Array; readonly onClose: () => void; readonly onCopyCallLink: () => void; + readonly removeClient: ((payload: RemoveClientType) => void) | null; }; export function CallingAdhocCallInfo({ i18n, + isCallLinkAdmin, ourServiceId, participants, onClose, onCopyCallLink, + removeClient, }: PropsType): JSX.Element | null { const sortedParticipants = React.useMemo>( () => sortByTitle(participants), @@ -137,6 +143,26 @@ export function CallingAdhocCallInfo({ 'module-calling-participants-list__muted--audio' )} /> + {isCallLinkAdmin && + removeClient && + participant.demuxId && + !(ourServiceId && participant.serviceId === ourServiceId) ? ( + + + + ))} + +
+ ); +} diff --git a/ts/components/CallingPip.stories.tsx b/ts/components/CallingPip.stories.tsx index 29c595ee59f4..e307cb71ffd9 100644 --- a/ts/components/CallingPip.stories.tsx +++ b/ts/components/CallingPip.stories.tsx @@ -139,6 +139,7 @@ export function GroupCall(args: PropsType): JSX.Element { maxDevices: 5, deviceCount: 0, peekedParticipants: [], + pendingParticipants: [], raisedHands: new Set(), remoteParticipants: [], remoteAudioLevels: new Map(), diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 5db78be3afa8..91a9407c41ad 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -40,7 +40,7 @@ import { RingRTC, RingUpdate, } from '@signalapp/ringrtc'; -import { uniqBy, noop } from 'lodash'; +import { uniqBy, noop, compact } from 'lodash'; import Long from 'long'; import type { CallLinkAuthCredentialPresentation } from '@signalapp/libsignal-client/zkgroup'; @@ -125,11 +125,13 @@ import { } from '../util/callDisposition'; import { isNormalNumber } from '../util/isNormalNumber'; import { LocalCallEvent } from '../types/CallDisposition'; -import { isServiceIdString, type ServiceIdString } from '../types/ServiceId'; +import type { AciString, ServiceIdString } from '../types/ServiceId'; +import { isServiceIdString } from '../types/ServiceId'; import { isInSystemContacts } from '../util/isInSystemContacts'; import { getRoomIdFromRootKey, getCallLinkAuthCredentialPresentation, + toAdminKeyBytes, } from '../util/callLinks'; import { isAdhocCallingEnabled } from '../util/isAdhocCallingEnabled'; import { conversationJobQueue } from '../jobs/conversationJobQueue'; @@ -580,10 +582,12 @@ export class CallingClass { async startCallLinkLobby({ callLinkRootKey, + adminPasskey, hasLocalAudio, hasLocalVideo = true, }: Readonly<{ callLinkRootKey: CallLinkRootKey; + adminPasskey: Buffer | undefined; hasLocalAudio: boolean; hasLocalVideo?: boolean; }>): Promise< @@ -610,7 +614,7 @@ export class CallingClass { roomId, authCredentialPresentation, callLinkRootKey, - adminPasskey: undefined, + adminPasskey, }); groupCall.setOutgoingAudioMuted(!hasLocalAudio); @@ -1210,11 +1214,13 @@ export class CallingClass { public async joinCallLinkCall({ roomId, rootKey, + adminKey, hasLocalAudio, hasLocalVideo, }: { roomId: string; rootKey: string; + adminKey: string | undefined; hasLocalAudio: boolean; hasLocalVideo: boolean; }): Promise { @@ -1228,13 +1234,16 @@ export class CallingClass { const callLinkRootKey = CallLinkRootKey.parse(rootKey); const authCredentialPresentation = await getCallLinkAuthCredentialPresentation(callLinkRootKey); + const adminPasskey = adminKey + ? Buffer.from(toAdminKeyBytes(adminKey)) + : undefined; // RingRTC reuses the same type GroupCall between Adhoc and Group calls. const groupCall = this.connectCallLinkCall({ roomId, authCredentialPresentation, callLinkRootKey, - adminPasskey: undefined, + adminPasskey, }); groupCall.setOutgoingAudioMuted(!hasLocalAudio); @@ -1267,6 +1276,33 @@ export class CallingClass { groupCall.setGroupMembers(this.getGroupCallMembers(conversationId)); } + public approveUser(conversationId: string, aci: AciString): void { + const groupCall = this.getGroupCall(conversationId); + if (!groupCall) { + throw new Error('Could not find matching call'); + } + + groupCall.approveUser(Buffer.from(uuidToBytes(aci))); + } + + public denyUser(conversationId: string, aci: AciString): void { + const groupCall = this.getGroupCall(conversationId); + if (!groupCall) { + throw new Error('Could not find matching call'); + } + + groupCall.denyUser(Buffer.from(uuidToBytes(aci))); + } + + public removeClient(conversationId: string, demuxId: number): void { + const groupCall = this.getGroupCall(conversationId); + if (!groupCall) { + throw new Error('Could not find matching call'); + } + + groupCall.removeClient(demuxId); + } + // See the comment in types/Calling.ts to explain why we have to do this conversion. private convertRingRtcConnectionState( connectionState: ConnectionState @@ -1301,6 +1337,18 @@ export class CallingClass { } } + private formatUserId(userId: Buffer): AciString | null { + const uuid = bytesToUuid(userId); + if (uuid && isAciString(uuid)) { + return uuid; + } + + log.error( + 'Calling.formatUserId: could not convert participant UUID Uint8Array to string' + ); + return null; + } + public formatGroupCallPeekInfoForRedux( peekInfo: PeekInfo ): GroupCallPeekInfoType { @@ -1308,17 +1356,10 @@ export class CallingClass { return { acis: peekInfo.devices.map(peekDeviceInfo => { if (peekDeviceInfo.userId) { - const uuid = bytesToUuid(peekDeviceInfo.userId); + const uuid = this.formatUserId(peekDeviceInfo.userId); if (uuid) { - assertDev( - isAciString(uuid), - 'peeked participant uuid must be an ACI' - ); return uuid; } - log.error( - 'Calling.formatGroupCallPeekInfoForRedux: could not convert peek UUID Uint8Array to string; using fallback UUID' - ); } else { log.error( 'Calling.formatGroupCallPeekInfoForRedux: device had no user ID; using fallback UUID' @@ -1329,6 +1370,9 @@ export class CallingClass { 'formatGrouPCallPeekInfoForRedux' ); }), + pendingAcis: compact( + peekInfo.pendingUsers.map(userId => this.formatUserId(userId)) + ), creatorAci: creatorAci !== undefined ? normalizeAci( diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index b94223c2ee48..77d970598d25 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -49,11 +49,12 @@ import { requestCameraPermissions } from '../../util/callingPermissions'; import { CALL_LINK_DEFAULT_STATE, getRoomIdFromRootKey, + toAdminKeyBytes, } from '../../util/callLinks'; import { sendCallLinkUpdateSync } from '../../util/sendCallLinkUpdateSync'; import { sleep } from '../../util/sleep'; import { LatestQueue } from '../../util/LatestQueue'; -import type { AciString } from '../../types/ServiceId'; +import type { AciString, ServiceIdString } from '../../types/ServiceId'; import type { ConversationChangedActionType, ConversationRemovedActionType, @@ -81,11 +82,13 @@ import { SHOW_ERROR_MODAL } from './globalModals'; import { ButtonVariant } from '../../components/Button'; import { getConversationIdForLogging } from '../../util/idForLogging'; import dataInterface from '../../sql/Client'; +import { isAciString } from '../../util/isAciString'; // State export type GroupCallPeekInfoType = ReadonlyDeep<{ acis: Array; + pendingAcis: Array; creatorAci?: AciString; eraId?: string; maxDevices: number; @@ -250,7 +253,7 @@ type HangUpActionPayloadType = ReadonlyDeep<{ conversationId: string; }>; -type HandleCallLinkUpdateType = ReadonlyDeep<{ +export type HandleCallLinkUpdateType = ReadonlyDeep<{ rootKey: string; adminKey: string | null; }>; @@ -309,6 +312,10 @@ type RemoteSharingScreenChangeType = ReadonlyDeep<{ isSharingScreen: boolean; }>; +export type RemoveClientType = ReadonlyDeep<{ + demuxId: number; +}>; + export type SetLocalAudioType = ReadonlyDeep<{ enabled: boolean; }>; @@ -558,10 +565,12 @@ const doGroupCallPeek = ({ // Actions const ACCEPT_CALL_PENDING = 'calling/ACCEPT_CALL_PENDING'; +const APPROVE_USER = 'calling/APPROVE_USER'; const CANCEL_CALL = 'calling/CANCEL_CALL'; const CANCEL_INCOMING_GROUP_CALL_RING = 'calling/CANCEL_INCOMING_GROUP_CALL_RING'; const CHANGE_CALL_VIEW = 'calling/CHANGE_CALL_VIEW'; +const DENY_USER = 'calling/DENY_USER'; const START_CALLING_LOBBY = 'calling/START_CALLING_LOBBY'; const START_CALL_LINK_LOBBY = 'calling/START_CALL_LINK_LOBBY'; const CALL_STATE_CHANGE_FULFILLED = 'calling/CALL_STATE_CHANGE_FULFILLED'; @@ -584,6 +593,7 @@ const RAISE_HAND_GROUP_CALL = 'calling/RAISE_HAND_GROUP_CALL'; const REFRESH_IO_DEVICES = 'calling/REFRESH_IO_DEVICES'; const REMOTE_SHARING_SCREEN_CHANGE = 'calling/REMOTE_SHARING_SCREEN_CHANGE'; const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE'; +const REMOVE_CLIENT = 'calling/REMOVE_CLIENT'; const RETURN_TO_ACTIVE_CALL = 'calling/RETURN_TO_ACTIVE_CALL'; const SEND_GROUP_CALL_REACTION = 'calling/SEND_GROUP_CALL_REACTION'; const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED'; @@ -605,6 +615,10 @@ type AcceptCallPendingActionType = ReadonlyDeep<{ payload: AcceptCallType; }>; +type ApproveUserActionType = ReadonlyDeep<{ + type: 'calling/APPROVE_USER'; +}>; + type CancelCallActionType = ReadonlyDeep<{ type: 'calling/CANCEL_CALL'; }>; @@ -614,6 +628,10 @@ type CancelIncomingGroupCallRingActionType = ReadonlyDeep<{ payload: CancelIncomingGroupCallRingType; }>; +type DenyUserActionType = ReadonlyDeep<{ + type: 'calling/DENY_USER'; +}>; + // eslint-disable-next-line local-rules/type-alias-readonlydeep type StartCallingLobbyActionType = { type: 'calling/START_CALLING_LOBBY'; @@ -751,6 +769,10 @@ export type PeekGroupCallFulfilledActionType = ReadonlyDeep<{ }; }>; +export type PendingUserActionPayloadType = ReadonlyDeep<{ + serviceId: ServiceIdString | undefined; +}>; + // eslint-disable-next-line local-rules/type-alias-readonlydeep type RefreshIODevicesActionType = { type: 'calling/REFRESH_IO_DEVICES'; @@ -767,6 +789,10 @@ type RemoteVideoChangeActionType = ReadonlyDeep<{ payload: RemoteVideoChangeType; }>; +type RemoveClientActionType = ReadonlyDeep<{ + type: 'calling/REMOVE_CLIENT'; +}>; + type ReturnToActiveCallActionType = ReadonlyDeep<{ type: 'calling/RETURN_TO_ACTIVE_CALL'; }>; @@ -833,10 +859,12 @@ type SwitchFromPresentationViewActionType = ReadonlyDeep<{ // eslint-disable-next-line local-rules/type-alias-readonlydeep export type CallingActionType = + | ApproveUserActionType | AcceptCallPendingActionType | CancelCallActionType | CancelIncomingGroupCallRingActionType | ChangeCallViewActionType + | DenyUserActionType | StartCallingLobbyActionType | StartCallLinkLobbyActionType | CallStateChangeFulfilledActionType @@ -860,6 +888,7 @@ export type CallingActionType = | RefreshIODevicesActionType | RemoteSharingScreenChangeActionType | RemoteVideoChangeActionType + | RemoveClientActionType | ReturnToActiveCallActionType | SendGroupCallReactionActionType | SetLocalAudioActionType @@ -911,6 +940,68 @@ function acceptCall( }; } +function approveUser( + payload: PendingUserActionPayloadType +): ThunkAction { + return (dispatch, getState) => { + const activeCall = getActiveCall(getState().calling); + if (!activeCall || !isGroupOrAdhocCallMode(activeCall.callMode)) { + log.warn( + 'approveUser: Trying to approve pending user without active group or adhoc call' + ); + return; + } + if (!isAciString(payload.serviceId)) { + log.warn( + 'approveUser: Trying to approve pending user without valid aci serviceid' + ); + return; + } + + calling.approveUser(activeCall.conversationId, payload.serviceId); + dispatch({ type: APPROVE_USER }); + }; +} + +function denyUser( + payload: PendingUserActionPayloadType +): ThunkAction { + return (dispatch, getState) => { + const activeCall = getActiveCall(getState().calling); + if (!activeCall || !isGroupOrAdhocCallMode(activeCall.callMode)) { + log.warn( + 'approveUser: Trying to approve pending user without active group or adhoc call' + ); + return; + } + if (!isAciString(payload.serviceId)) { + log.warn( + 'approveUser: Trying to approve pending user without valid aci serviceid' + ); + return; + } + + calling.denyUser(activeCall.conversationId, payload.serviceId); + dispatch({ type: DENY_USER }); + }; +} +function removeClient( + payload: RemoveClientType +): ThunkAction { + return (dispatch, getState) => { + const activeCall = getActiveCall(getState().calling); + if (!activeCall || !isGroupOrAdhocCallMode(activeCall.callMode)) { + log.warn( + 'approveUser: Trying to approve pending user without active group or adhoc call' + ); + return; + } + + calling.removeClient(activeCall.conversationId, payload.demuxId); + dispatch({ type: REMOVE_CLIENT }); + }; +} + function callStateChange( payload: CallStateChangeType ): ThunkAction< @@ -1869,8 +1960,13 @@ const _startCallLinkLobby = async ({ groupCall?.remoteParticipants.length || 0; + const { adminKey } = getOwn(state.calling.callLinks, roomId) ?? {}; + const adminPasskey = adminKey + ? Buffer.from(toAdminKeyBytes(adminKey)) + : undefined; const callLobbyData = await calling.startCallLinkLobby({ callLinkRootKey, + adminPasskey, hasLocalAudio: groupCallDeviceCount < 8, }); if (!callLobbyData) { @@ -2003,6 +2099,7 @@ function startCall( await calling.joinCallLinkCall({ roomId: conversationId, rootKey: callLink.rootKey, + adminKey: callLink.adminKey ?? undefined, hasLocalAudio, hasLocalVideo, }); @@ -2061,6 +2158,7 @@ function switchFromPresentationView(): SwitchFromPresentationViewActionType { } export const actions = { acceptCall, + approveUser, callStateChange, cancelCall, cancelIncomingGroupCallRing, @@ -2068,6 +2166,7 @@ export const actions = { changeIODevice, closeNeedPermissionScreen, declineCall, + denyUser, getPresentingSources, groupCallAudioLevelsChange, groupCallEnded, @@ -2089,6 +2188,7 @@ export const actions = { refreshIODevices, remoteSharingScreenChange, remoteVideoChange, + removeClient, returnToActiveCall, sendGroupCallRaiseHand, sendGroupCallReaction, @@ -2237,6 +2337,7 @@ export function reducer( peekInfo: peekInfo || existingCall?.peekInfo || { acis: remoteParticipants.map(({ aci }) => aci), + pendingAcis: [], maxDevices: Infinity, deviceCount: remoteParticipants.length, }, @@ -2286,8 +2387,10 @@ export function reducer( ...callLinks, [conversationId]: { ...action.payload.callLinkState, - rootKey: action.payload.callLinkRootKey, - adminKey: null, + rootKey: + callLinks[conversationId]?.rootKey ?? + action.payload.callLinkRootKey, + adminKey: callLinks[conversationId]?.adminKey, }, } : callLinks, @@ -2478,6 +2581,7 @@ export function reducer( localDemuxId: undefined, peekInfo: { acis: [], + pendingAcis: [], maxDevices: Infinity, deviceCount: 0, }, @@ -2676,6 +2780,7 @@ export function reducer( const newPeekInfo = peekInfo || existingCall?.peekInfo || { acis: remoteParticipants.map(({ aci }) => aci), + pendingAcis: [], maxDevices: Infinity, deviceCount: remoteParticipants.length, }; @@ -2755,6 +2860,7 @@ export function reducer( localDemuxId: undefined, peekInfo: { acis: [], + pendingAcis: [], maxDevices: Infinity, deviceCount: 0, }, diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index babe73069c39..45b593476cd4 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -211,6 +211,7 @@ const mapStateToActiveCallProp = ( const groupMembers: Array = []; const remoteParticipants: Array = []; const peekedParticipants: Array = []; + const pendingParticipants: Array = []; const conversationsByDemuxId: ConversationsByDemuxIdType = new Map(); const { localDemuxId } = call; const raisedHands: Set = new Set(call.raisedHands ?? []); @@ -224,6 +225,7 @@ const mapStateToActiveCallProp = ( deviceCount: 0, maxDevices: Infinity, acis: [], + pendingAcis: [], }, } = call; @@ -294,6 +296,20 @@ const mapStateToActiveCallProp = ( peekedParticipants.push(peekedConversation); } + for (let i = 0; i < peekInfo.pendingAcis.length; i += 1) { + const aci = peekInfo.pendingAcis[i]; + + // In call links, pending users may be unknown until they share profile keys. + // conversationSelectorByAci should create conversations for new contacts. + const pendingConversation = conversationSelectorByAci(aci); + if (!pendingConversation) { + log.error('Pending participant has no corresponding conversation'); + continue; + } + + pendingParticipants.push(pendingConversation); + } + return { ...baseResult, callMode: call.callMode, @@ -306,6 +322,7 @@ const mapStateToActiveCallProp = ( localDemuxId, maxDevices: peekInfo.maxDevices, peekedParticipants, + pendingParticipants, raisedHands, remoteParticipants, remoteAudioLevels: call.remoteAudioLevels || new Map(), @@ -407,6 +424,8 @@ export const SmartCallManager = memo(function SmartCallManager() { : false; const { + approveUser, + denyUser, changeCallView, closeNeedPermissionScreen, getPresentingSources, @@ -416,6 +435,7 @@ export const SmartCallManager = memo(function SmartCallManager() { acceptCall, declineCall, openSystemPreferencesAction, + removeClient, sendGroupCallRaiseHand, sendGroupCallReaction, setGroupCallVideoRequest, @@ -440,6 +460,7 @@ export const SmartCallManager = memo(function SmartCallManager() { = {} ): CallingConversationType { diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index f2772586c8b8..de25542853ec 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -10,12 +10,15 @@ import { reducer as rootReducer } from '../../../state/reducer'; import { noopAction } from '../../../state/ducks/noop'; import type { ActiveCallStateType, + CallingActionType, CallingStateType, DirectCallStateType, GroupCallReactionsReceivedActionType, GroupCallStateChangeActionType, GroupCallStateType, + HandleCallLinkUpdateType, SendGroupCallReactionActionType, + StartCallLinkLobbyType, } from '../../../state/ducks/calling'; import { actions, @@ -36,8 +39,11 @@ import { import { generateAci } from '../../../types/ServiceId'; import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; import type { UnwrapPromise } from '../../../types/Util'; -import { CallLinkRestrictions } from '../../../types/CallLink'; -import { FAKE_CALL_LINK } from '../../../test-both/helpers/fakeCallLink'; +import { + FAKE_CALL_LINK, + FAKE_CALL_LINK_WITH_ADMIN_KEY, + getCallLinkState, +} from '../../../test-both/helpers/fakeCallLink'; const ACI_1 = generateAci(); const NOW = new Date('2020-01-23T04:56:00.000'); @@ -109,6 +115,7 @@ describe('calling duck', () => { localDemuxId: 1, peekInfo: { acis: [creatorAci], + pendingAcis: [], creatorAci, eraId: 'xyz', maxDevices: 16, @@ -902,6 +909,7 @@ describe('calling duck', () => { hasLocalVideo: false, peekInfo: { acis: [creatorAci], + pendingAcis: [], creatorAci, eraId: 'xyz', maxDevices: 16, @@ -932,6 +940,7 @@ describe('calling duck', () => { localDemuxId: 1, peekInfo: { acis: [creatorAci], + pendingAcis: [], creatorAci, eraId: 'xyz', maxDevices: 16, @@ -967,6 +976,7 @@ describe('calling duck', () => { hasLocalVideo: false, peekInfo: { acis: [ACI_1], + pendingAcis: [], maxDevices: 16, deviceCount: 1, }, @@ -995,6 +1005,7 @@ describe('calling duck', () => { localDemuxId: 1, peekInfo: { acis: [ACI_1], + pendingAcis: [], maxDevices: 16, deviceCount: 1, }, @@ -1041,6 +1052,7 @@ describe('calling duck', () => { hasLocalVideo: false, peekInfo: { acis: [ACI_1], + pendingAcis: [], maxDevices: 16, deviceCount: 1, }, @@ -1095,6 +1107,7 @@ describe('calling duck', () => { hasLocalVideo: false, peekInfo: { acis: [ACI_1], + pendingAcis: [], maxDevices: 16, deviceCount: 1, }, @@ -1136,6 +1149,7 @@ describe('calling duck', () => { hasLocalVideo: false, peekInfo: { acis: [ACI_1], + pendingAcis: [], maxDevices: 16, deviceCount: 1, }, @@ -1170,6 +1184,7 @@ describe('calling duck', () => { hasLocalVideo: true, peekInfo: { acis: [ACI_1], + pendingAcis: [], maxDevices: 16, deviceCount: 1, }, @@ -1216,6 +1231,7 @@ describe('calling duck', () => { hasLocalVideo: true, peekInfo: { acis: [ACI_1], + pendingAcis: [], maxDevices: 16, deviceCount: 1, }, @@ -1262,6 +1278,7 @@ describe('calling duck', () => { hasLocalVideo: true, peekInfo: { acis: [], + pendingAcis: [], maxDevices: 16, deviceCount: 0, }, @@ -1292,6 +1309,7 @@ describe('calling duck', () => { hasLocalVideo: true, peekInfo: { acis: [ACI_1], + pendingAcis: [], maxDevices: 16, deviceCount: 1, }, @@ -1304,42 +1322,42 @@ describe('calling duck', () => { }); describe('handleCallLinkUpdate', () => { - const { roomId, rootKey, expiration } = FAKE_CALL_LINK; + const { + roomId, + name, + restrictions, + expiration, + revoked, + rootKey, + adminKey, + } = FAKE_CALL_LINK; beforeEach(function (this: Mocha.Context) { this.callingServiceReadCallLink = this.sandbox .stub(callingService, 'readCallLink') .resolves({ - callLinkState: { - name: 'Signal Call', - restrictions: CallLinkRestrictions.None, - expiration, - revoked: false, - }, + callLinkState: getCallLinkState(FAKE_CALL_LINK), errorStatusCode: undefined, }); }); - it('reads the call link from calling service', async function (this: Mocha.Context) { + const doAction = async ( + payload: HandleCallLinkUpdateType + ): Promise<{ dispatch: sinon.SinonSpy }> => { const { handleCallLinkUpdate } = actions; const dispatch = sinon.spy(); - await handleCallLinkUpdate({ rootKey, adminKey: null })( - dispatch, - getEmptyRootState, - null - ); + await handleCallLinkUpdate(payload)(dispatch, getEmptyRootState, null); + return { dispatch }; + }; + + it('reads the call link from calling service', async function (this: Mocha.Context) { + await doAction({ rootKey, adminKey: null }); sinon.assert.calledOnce(this.callingServiceReadCallLink); }); it('dispatches HANDLE_CALL_LINK_UPDATE', async () => { - const { handleCallLinkUpdate } = actions; - const dispatch = sinon.spy(); - await handleCallLinkUpdate({ rootKey, adminKey: null })( - dispatch, - getEmptyRootState, - null - ); + const { dispatch } = await doAction({ rootKey, adminKey: null }); sinon.assert.calledOnce(dispatch); sinon.assert.calledWith(dispatch, { @@ -1347,16 +1365,115 @@ describe('calling duck', () => { payload: { roomId, callLinkDetails: { - name: 'Signal Call', - restrictions: CallLinkRestrictions.None, + name, + restrictions, expiration, - revoked: false, + revoked, rootKey, - adminKey: null, + adminKey, }, }, }); }); + + it('can save adminKey', async () => { + const { dispatch } = await doAction({ rootKey, adminKey: 'banana' }); + + sinon.assert.calledOnce(dispatch); + sinon.assert.calledWith(dispatch, { + type: 'calling/HANDLE_CALL_LINK_UPDATE', + payload: { + roomId, + callLinkDetails: { + name, + restrictions, + expiration, + revoked, + rootKey, + adminKey: 'banana', + }, + }, + }); + }); + }); + + describe('startCallLinkLobby', () => { + const callLobbyData = { + callMode: CallMode.Adhoc, + connectionState: GroupCallConnectionState.NotConnected, + hasLocalAudio: true, + hasLocalVideo: true, + joinState: GroupCallJoinState.NotJoined, + peekInfo: [], + remoteParticipants: [], + }; + const callLinkState = getCallLinkState(FAKE_CALL_LINK); + + const getStateWithAdminKey = (): RootStateType => ({ + ...getEmptyRootState(), + calling: { + ...getEmptyState(), + callLinks: { + [FAKE_CALL_LINK_WITH_ADMIN_KEY.roomId]: + FAKE_CALL_LINK_WITH_ADMIN_KEY, + }, + }, + }); + + beforeEach(function (this: Mocha.Context) { + this.callingServiceReadCallLink = this.sandbox + .stub(callingService, 'readCallLink') + .resolves({ + callLinkState, + errorStatusCode: undefined, + }); + this.callingServiceStartCallLinkLobby = this.sandbox + .stub(callingService, 'startCallLinkLobby') + .resolves(callLobbyData); + }); + + const doAction = async ( + payload: StartCallLinkLobbyType + ): Promise<{ dispatch: sinon.SinonSpy }> => { + const { startCallLinkLobby } = actions; + const dispatch = sinon.spy(); + await startCallLinkLobby(payload)(dispatch, getEmptyRootState, null); + return { dispatch }; + }; + + it('reads the link and dispatches START_CALL_LINK_LOBBY', async function (this: Mocha.Context) { + const { roomId, rootKey } = FAKE_CALL_LINK; + const { dispatch } = await doAction({ rootKey }); + + sinon.assert.calledOnce(dispatch); + sinon.assert.calledWith(dispatch, { + type: 'calling/START_CALL_LINK_LOBBY', + payload: { + ...callLobbyData, + callLinkState, + callLinkRootKey: rootKey, + conversationId: roomId, + isConversationTooBigToRing: false, + }, + }); + }); + + it('preserves adminKey', () => { + const { startCallLinkLobby } = actions; + const { roomId, rootKey, adminKey } = FAKE_CALL_LINK_WITH_ADMIN_KEY; + const dispatch = sinon.spy(); + const result = reducer( + getStateWithAdminKey().calling, + startCallLinkLobby({ + rootKey, + })( + dispatch, + getStateWithAdminKey, + null + ) as unknown as Readonly + ); + assert.equal(result.callLinks[roomId]?.adminKey, adminKey); + }); }); describe('peekNotConnectedGroupCall', () => { @@ -1503,6 +1620,7 @@ describe('calling duck', () => { localDemuxId: undefined, peekInfo: { acis: [], + pendingAcis: [], maxDevices: Infinity, deviceCount: 0, }, @@ -1956,6 +2074,7 @@ describe('calling duck', () => { joinState: GroupCallJoinState.NotJoined, peekInfo: { acis: [creatorAci], + pendingAcis: [], creatorAci, eraId: 'xyz', maxDevices: 16, @@ -1983,6 +2102,7 @@ describe('calling duck', () => { localDemuxId: undefined, peekInfo: { acis: [creatorAci], + pendingAcis: [], creatorAci, eraId: 'xyz', maxDevices: 16, @@ -2022,6 +2142,7 @@ describe('calling duck', () => { const call = result.callsByConversation['fake-conversation-id']; assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { acis: [], + pendingAcis: [], maxDevices: Infinity, deviceCount: 0, }); @@ -2053,6 +2174,7 @@ describe('calling duck', () => { result.callsByConversation['fake-group-call-conversation-id']; assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { acis: [creatorAci], + pendingAcis: [], creatorAci, eraId: 'xyz', maxDevices: 16, @@ -2081,6 +2203,7 @@ describe('calling duck', () => { joinState: GroupCallJoinState.NotJoined, peekInfo: { acis: [differentCreatorAci], + pendingAcis: [], creatorAci: differentCreatorAci, eraId: 'abc', maxDevices: 5, @@ -2103,6 +2226,7 @@ describe('calling duck', () => { const call = result.callsByConversation['fake-conversation-id']; assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { acis: [differentCreatorAci], + pendingAcis: [], creatorAci: differentCreatorAci, eraId: 'abc', maxDevices: 5, diff --git a/ts/test-electron/state/selectors/calling_test.ts b/ts/test-electron/state/selectors/calling_test.ts index d9cad0e9645c..e589fdebbf6a 100644 --- a/ts/test-electron/state/selectors/calling_test.ts +++ b/ts/test-electron/state/selectors/calling_test.ts @@ -100,6 +100,7 @@ describe('state/selectors/calling', () => { localDemuxId: undefined, peekInfo: { acis: [ACI_1], + pendingAcis: [], creatorAci: ACI_1, maxDevices: Infinity, deviceCount: 1, @@ -180,6 +181,7 @@ describe('state/selectors/calling', () => { ...incomingGroupCall, peekInfo: { acis: [], + pendingAcis: [], maxDevices: Infinity, deviceCount: 1, }, diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index 577ae7cdd135..0ace8b2d58b0 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -97,6 +97,7 @@ export type ActiveGroupCallType = ActiveCallBaseType & { groupMembers: Array>; isConversationTooBigToRing: boolean; peekedParticipants: Array; + pendingParticipants: Array; raisedHands: Set; remoteParticipants: Array; remoteAudioLevels: Map;