Call link admin key fix and in-call approve, deny, remove

This commit is contained in:
ayumi-signal 2024-04-30 09:36:34 -07:00 committed by GitHub
parent 5df8924197
commit 8ec585d54c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 599 additions and 43 deletions

View file

@ -1685,6 +1685,18 @@
"messageformat": "More options", "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." "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": { "icu:CallingRaisedHandsList__Title": {
"messageformat": "Raised hands · {count, plural, one {# person} other {# people}}", "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" "description": "Shown in the call raised hands list to describe how many people have active raised hands"
@ -3654,6 +3666,10 @@
"messageformat": "Copy link", "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." "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": { "icu:callingDeviceSelection__label--video": {
"messageformat": "Video", "messageformat": "Video",
"description": "Label for video input selector" "description": "Label for video input selector"

View file

@ -4511,6 +4511,13 @@ button.module-image__border-overlay:focus {
$color-white $color-white
); );
} }
&__remove {
@include color-svg(
'../images/icons/v3/minus/minus-circle-compact.svg',
$color-white
);
}
} }
.module-call-need-permission-screen { .module-call-need-permission-screen {

View file

@ -81,3 +81,11 @@
margin-inline: 10px; margin-inline: 10px;
border: 1px solid $color-gray-65; border: 1px solid $color-gray-65;
} }
.CallingAdhocCallInfo__RemoveClient {
@include button-reset;
width: 16px;
height: 16px;
margin-inline: 8px;
background: $color-white;
}

View file

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

View file

@ -45,6 +45,7 @@
@import './components/CallControls.scss'; @import './components/CallControls.scss';
@import './components/CallSettingsButton.scss'; @import './components/CallSettingsButton.scss';
@import './components/CallingLobby.scss'; @import './components/CallingLobby.scss';
@import './components/CallingPendingParticipants.scss';
@import './components/CallingPreCallInfo.scss'; @import './components/CallingPreCallInfo.scss';
@import './components/CallingScreenSharingController.scss'; @import './components/CallingScreenSharingController.scss';
@import './components/CallingSelectPresentingSourcesModal.scss'; @import './components/CallingSelectPresentingSourcesModal.scss';

View file

@ -59,12 +59,14 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
...storyProps, ...storyProps,
availableCameras: [], availableCameras: [],
acceptCall: action('accept-call'), acceptCall: action('accept-call'),
approveUser: action('approve-user'),
bounceAppIconStart: action('bounce-app-icon-start'), bounceAppIconStart: action('bounce-app-icon-start'),
bounceAppIconStop: action('bounce-app-icon-stop'), bounceAppIconStop: action('bounce-app-icon-stop'),
cancelCall: action('cancel-call'), cancelCall: action('cancel-call'),
changeCallView: action('change-call-view'), changeCallView: action('change-call-view'),
closeNeedPermissionScreen: action('close-need-permission-screen'), closeNeedPermissionScreen: action('close-need-permission-screen'),
declineCall: action('decline-call'), declineCall: action('decline-call'),
denyUser: action('deny-user'),
getGroupCallVideoFrameSource: (_: string, demuxId: number) => getGroupCallVideoFrameSource: (_: string, demuxId: number) =>
fakeGetGroupCallVideoFrameSource(demuxId), fakeGetGroupCallVideoFrameSource(demuxId),
getPresentingSources: action('get-presenting-sources'), getPresentingSources: action('get-presenting-sources'),
@ -84,6 +86,7 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
notifyForCall: action('notify-for-call'), notifyForCall: action('notify-for-call'),
openSystemPreferencesAction: action('open-system-preferences-action'), openSystemPreferencesAction: action('open-system-preferences-action'),
playRingtone: action('play-ringtone'), playRingtone: action('play-ringtone'),
removeClient: action('remove-client'),
renderDeviceSelection: () => <div />, renderDeviceSelection: () => <div />,
renderEmojiPicker: () => <>EmojiPicker</>, renderEmojiPicker: () => <>EmojiPicker</>,
renderReactionPicker: () => <div />, renderReactionPicker: () => <div />,
@ -156,6 +159,7 @@ export function OngoingGroupCall(): JSX.Element {
groupMembers: [], groupMembers: [],
isConversationTooBigToRing: false, isConversationTooBigToRing: false,
peekedParticipants: [], peekedParticipants: [],
pendingParticipants: [],
raisedHands: new Set<number>(), raisedHands: new Set<number>(),
remoteParticipants: [], remoteParticipants: [],
remoteAudioLevels: new Map<number, number>(), remoteAudioLevels: new Map<number, number>(),

View file

@ -31,6 +31,8 @@ import type {
CancelCallType, CancelCallType,
DeclineCallType, DeclineCallType,
GroupCallParticipantInfoType, GroupCallParticipantInfoType,
PendingUserActionPayloadType,
RemoveClientType,
SendGroupCallRaiseHandType, SendGroupCallRaiseHandType,
SendGroupCallReactionType, SendGroupCallReactionType,
SetGroupCallVideoRequestType, SetGroupCallVideoRequestType,
@ -95,9 +97,11 @@ export type PropsType = {
startCall: (payload: StartCallType) => void; startCall: (payload: StartCallType) => void;
toggleParticipants: () => void; toggleParticipants: () => void;
acceptCall: (_: AcceptCallType) => void; acceptCall: (_: AcceptCallType) => void;
approveUser: (payload: PendingUserActionPayloadType) => void;
bounceAppIconStart: () => unknown; bounceAppIconStart: () => unknown;
bounceAppIconStop: () => unknown; bounceAppIconStop: () => unknown;
declineCall: (_: DeclineCallType) => void; declineCall: (_: DeclineCallType) => void;
denyUser: (payload: PendingUserActionPayloadType) => void;
hasInitialLoadCompleted: boolean; hasInitialLoadCompleted: boolean;
i18n: LocalizerType; i18n: LocalizerType;
isGroupCallRaiseHandEnabled: boolean; isGroupCallRaiseHandEnabled: boolean;
@ -109,6 +113,7 @@ export type PropsType = {
) => unknown; ) => unknown;
openSystemPreferencesAction: () => unknown; openSystemPreferencesAction: () => unknown;
playRingtone: () => unknown; playRingtone: () => unknown;
removeClient: (payload: RemoveClientType) => void;
sendGroupCallRaiseHand: (payload: SendGroupCallRaiseHandType) => void; sendGroupCallRaiseHand: (payload: SendGroupCallRaiseHandType) => void;
sendGroupCallReaction: (payload: SendGroupCallReactionType) => void; sendGroupCallReaction: (payload: SendGroupCallReactionType) => void;
setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void; setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void;
@ -151,11 +156,13 @@ type ActiveCallManagerPropsType = {
function ActiveCallManager({ function ActiveCallManager({
activeCall, activeCall,
approveUser,
availableCameras, availableCameras,
callLink, callLink,
cancelCall, cancelCall,
changeCallView, changeCallView,
closeNeedPermissionScreen, closeNeedPermissionScreen,
denyUser,
hangUpActiveCall, hangUpActiveCall,
i18n, i18n,
isGroupCallRaiseHandEnabled, isGroupCallRaiseHandEnabled,
@ -166,6 +173,7 @@ function ActiveCallManager({
renderDeviceSelection, renderDeviceSelection,
renderEmojiPicker, renderEmojiPicker,
renderReactionPicker, renderReactionPicker,
removeClient,
sendGroupCallRaiseHand, sendGroupCallRaiseHand,
sendGroupCallReaction, sendGroupCallReaction,
setGroupCallVideoRequest, setGroupCallVideoRequest,
@ -258,6 +266,7 @@ function ActiveCallManager({
let isConvoTooBigToRing = false; let isConvoTooBigToRing = false;
let isAdhocAdminApprovalRequired = false; let isAdhocAdminApprovalRequired = false;
let isAdhocJoinRequestPending = false; let isAdhocJoinRequestPending = false;
let isCallLinkAdmin = false;
switch (activeCall.callMode) { switch (activeCall.callMode) {
case CallMode.Direct: { case CallMode.Direct: {
@ -292,6 +301,7 @@ function ActiveCallManager({
isAdhocJoinRequestPending = isAdhocJoinRequestPending =
isAdhocAdminApprovalRequired && isAdhocAdminApprovalRequired &&
activeCall.joinState === GroupCallJoinState.Pending; activeCall.joinState === GroupCallJoinState.Pending;
isCallLinkAdmin = Boolean(callLink?.adminKey);
break; break;
} }
default: default:
@ -352,10 +362,12 @@ function ActiveCallManager({
<CallingAdhocCallInfo <CallingAdhocCallInfo
callLink={callLink} callLink={callLink}
i18n={i18n} i18n={i18n}
isCallLinkAdmin={isCallLinkAdmin}
ourServiceId={me.serviceId} ourServiceId={me.serviceId}
participants={peekedParticipants} participants={peekedParticipants}
onClose={toggleParticipants} onClose={toggleParticipants}
onCopyCallLink={onCopyCallLink} onCopyCallLink={onCopyCallLink}
removeClient={removeClient}
/> />
) : ( ) : (
<CallingParticipantsList <CallingParticipantsList
@ -388,6 +400,7 @@ function ActiveCallManager({
hasRemoteVideo: hasLocalVideo, hasRemoteVideo: hasLocalVideo,
isHandRaised, isHandRaised,
presenting: Boolean(activeCall.presentingSource), presenting: Boolean(activeCall.presentingSource),
demuxId: activeCall.localDemuxId,
}, },
] ]
: []; : [];
@ -396,12 +409,15 @@ function ActiveCallManager({
<> <>
<CallScreen <CallScreen
activeCall={activeCall} activeCall={activeCall}
approveUser={approveUser}
changeCallView={changeCallView} changeCallView={changeCallView}
denyUser={denyUser}
getPresentingSources={getPresentingSources} getPresentingSources={getPresentingSources}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall} getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
groupMembers={groupMembers} groupMembers={groupMembers}
hangUpActiveCall={hangUpActiveCall} hangUpActiveCall={hangUpActiveCall}
i18n={i18n} i18n={i18n}
isCallLinkAdmin={isCallLinkAdmin}
isGroupCallRaiseHandEnabled={isGroupCallRaiseHandEnabled} isGroupCallRaiseHandEnabled={isGroupCallRaiseHandEnabled}
me={me} me={me}
openSystemPreferencesAction={openSystemPreferencesAction} openSystemPreferencesAction={openSystemPreferencesAction}
@ -438,10 +454,12 @@ function ActiveCallManager({
<CallingAdhocCallInfo <CallingAdhocCallInfo
callLink={callLink} callLink={callLink}
i18n={i18n} i18n={i18n}
isCallLinkAdmin={isCallLinkAdmin}
ourServiceId={me.serviceId} ourServiceId={me.serviceId}
participants={groupCallParticipantsForParticipantsList} participants={groupCallParticipantsForParticipantsList}
onClose={toggleParticipants} onClose={toggleParticipants}
onCopyCallLink={onCopyCallLink} onCopyCallLink={onCopyCallLink}
removeClient={removeClient}
/> />
) : ( ) : (
<CallingParticipantsList <CallingParticipantsList
@ -458,6 +476,7 @@ function ActiveCallManager({
export function CallManager({ export function CallManager({
acceptCall, acceptCall,
activeCall, activeCall,
approveUser,
availableCameras, availableCameras,
bounceAppIconStart, bounceAppIconStart,
bounceAppIconStop, bounceAppIconStop,
@ -466,6 +485,7 @@ export function CallManager({
changeCallView, changeCallView,
closeNeedPermissionScreen, closeNeedPermissionScreen,
declineCall, declineCall,
denyUser,
getGroupCallVideoFrameSource, getGroupCallVideoFrameSource,
getPresentingSources, getPresentingSources,
hangUpActiveCall, hangUpActiveCall,
@ -479,6 +499,7 @@ export function CallManager({
openSystemPreferencesAction, openSystemPreferencesAction,
pauseVoiceNotePlayer, pauseVoiceNotePlayer,
playRingtone, playRingtone,
removeClient,
renderDeviceSelection, renderDeviceSelection,
renderEmojiPicker, renderEmojiPicker,
renderReactionPicker, renderReactionPicker,
@ -552,10 +573,12 @@ export function CallManager({
<ActiveCallManager <ActiveCallManager
activeCall={activeCall} activeCall={activeCall}
availableCameras={availableCameras} availableCameras={availableCameras}
approveUser={approveUser}
callLink={callLink} callLink={callLink}
cancelCall={cancelCall} cancelCall={cancelCall}
changeCallView={changeCallView} changeCallView={changeCallView}
closeNeedPermissionScreen={closeNeedPermissionScreen} closeNeedPermissionScreen={closeNeedPermissionScreen}
denyUser={denyUser}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource} getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
getPresentingSources={getPresentingSources} getPresentingSources={getPresentingSources}
hangUpActiveCall={hangUpActiveCall} hangUpActiveCall={hangUpActiveCall}
@ -564,6 +587,7 @@ export function CallManager({
me={me} me={me}
openSystemPreferencesAction={openSystemPreferencesAction} openSystemPreferencesAction={openSystemPreferencesAction}
pauseVoiceNotePlayer={pauseVoiceNotePlayer} pauseVoiceNotePlayer={pauseVoiceNotePlayer}
removeClient={removeClient}
renderDeviceSelection={renderDeviceSelection} renderDeviceSelection={renderDeviceSelection}
renderEmojiPicker={renderEmojiPicker} renderEmojiPicker={renderEmojiPicker}
renderReactionPicker={renderReactionPicker} renderReactionPicker={renderReactionPicker}

View file

@ -67,6 +67,7 @@ type GroupCallOverrideProps = OverridePropsBase & {
callMode: CallMode.Group; callMode: CallMode.Group;
connectionState?: GroupCallConnectionState; connectionState?: GroupCallConnectionState;
peekedParticipants?: Array<ConversationType>; peekedParticipants?: Array<ConversationType>;
pendingParticipants?: Array<ConversationType>;
raisedHands?: Set<number>; raisedHands?: Set<number>;
remoteParticipants?: Array<GroupCallRemoteParticipantType>; remoteParticipants?: Array<GroupCallRemoteParticipantType>;
remoteAudioLevel?: number; remoteAudioLevel?: number;
@ -135,6 +136,7 @@ const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({
isConversationTooBigToRing: false, isConversationTooBigToRing: false,
peekedParticipants: peekedParticipants:
overrideProps.peekedParticipants || overrideProps.remoteParticipants || [], overrideProps.peekedParticipants || overrideProps.remoteParticipants || [],
pendingParticipants: overrideProps.pendingParticipants || [],
raisedHands: raisedHands:
overrideProps.raisedHands || overrideProps.raisedHands ||
getRaisedHands(overrideProps) || getRaisedHands(overrideProps) ||
@ -181,11 +183,14 @@ const createProps = (
} }
): PropsType => ({ ): PropsType => ({
activeCall: createActiveCallProp(overrideProps), activeCall: createActiveCallProp(overrideProps),
approveUser: action('approve-user'),
changeCallView: action('change-call-view'), changeCallView: action('change-call-view'),
denyUser: action('deny-user'),
getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource, getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource,
getPresentingSources: action('get-presenting-sources'), getPresentingSources: action('get-presenting-sources'),
hangUpActiveCall: action('hang-up'), hangUpActiveCall: action('hang-up'),
i18n, i18n,
isCallLinkAdmin: true,
isGroupCallRaiseHandEnabled: true, isGroupCallRaiseHandEnabled: true,
me: getDefaultConversation({ me: getDefaultConversation({
color: AvatarColors[1], color: AvatarColors[1],

View file

@ -8,6 +8,7 @@ import classNames from 'classnames';
import type { VideoFrameSource } from '@signalapp/ringrtc'; import type { VideoFrameSource } from '@signalapp/ringrtc';
import type { import type {
ActiveCallStateType, ActiveCallStateType,
PendingUserActionPayloadType,
SendGroupCallRaiseHandType, SendGroupCallRaiseHandType,
SendGroupCallReactionType, SendGroupCallReactionType,
SetLocalAudioType, SetLocalAudioType,
@ -88,14 +89,18 @@ import {
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall'; import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
import { assertDev } from '../util/assert'; import { assertDev } from '../util/assert';
import { emojiToData } from './emoji/lib'; import { emojiToData } from './emoji/lib';
import { CallingPendingParticipants } from './CallingPendingParticipants';
export type PropsType = { export type PropsType = {
activeCall: ActiveCallType; activeCall: ActiveCallType;
approveUser: (payload: PendingUserActionPayloadType) => void;
denyUser: (payload: PendingUserActionPayloadType) => void;
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'>>;
hangUpActiveCall: (reason: string) => void; hangUpActiveCall: (reason: string) => void;
i18n: LocalizerType; i18n: LocalizerType;
isCallLinkAdmin: boolean;
isGroupCallRaiseHandEnabled: boolean; isGroupCallRaiseHandEnabled: boolean;
me: ConversationType; me: ConversationType;
openSystemPreferencesAction: () => unknown; openSystemPreferencesAction: () => unknown;
@ -178,12 +183,15 @@ function CallDuration({
export function CallScreen({ export function CallScreen({
activeCall, activeCall,
approveUser,
changeCallView, changeCallView,
denyUser,
getGroupCallVideoFrameSource, getGroupCallVideoFrameSource,
getPresentingSources, getPresentingSources,
groupMembers, groupMembers,
hangUpActiveCall, hangUpActiveCall,
i18n, i18n,
isCallLinkAdmin,
isGroupCallRaiseHandEnabled, isGroupCallRaiseHandEnabled,
me, me,
openSystemPreferencesAction, openSystemPreferencesAction,
@ -396,6 +404,11 @@ export function CallScreen({
throw missingCaseError(activeCall); throw missingCaseError(activeCall);
} }
const pendingParticipants =
activeCall.callMode === CallMode.Adhoc && isCallLinkAdmin
? activeCall.pendingParticipants
: [];
let lonelyInCallNode: ReactNode; let lonelyInCallNode: ReactNode;
let localPreviewNode: ReactNode; let localPreviewNode: ReactNode;
@ -811,6 +824,15 @@ export function CallScreen({
renderRaisedHandsToast={renderRaisedHandsToast} renderRaisedHandsToast={renderRaisedHandsToast}
i18n={i18n} i18n={i18n}
/> />
{pendingParticipants.length ? (
<CallingPendingParticipants
i18n={i18n}
ourServiceId={me.serviceId}
participants={pendingParticipants}
approveUser={approveUser}
denyUser={denyUser}
/>
) : null}
{/* We render the local preview first and set the footer flex direction to row-reverse {/* 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. */} to ensure the preview is visible at low viewport widths. */}
<div className="module-ongoing-call__footer"> <div className="module-ongoing-call__footer">

View file

@ -61,10 +61,12 @@ function getCallLink(overrideProps: Partial<CallLinkType> = {}): CallLinkType {
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
callLink: getCallLink(overrideProps.callLink || {}), callLink: getCallLink(overrideProps.callLink || {}),
i18n, i18n,
isCallLinkAdmin: overrideProps.isCallLinkAdmin || false,
ourServiceId: generateAci(), ourServiceId: generateAci(),
participants: overrideProps.participants || [], participants: overrideProps.participants || [],
onClose: action('on-close'), onClose: action('on-close'),
onCopyCallLink: action('on-copy-call-link'), onCopyCallLink: action('on-copy-call-link'),
removeClient: overrideProps.removeClient || action('remove-client'),
}); });
export default { export default {

View file

@ -16,29 +16,35 @@ import { sortByTitle } from '../util/sortByTitle';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import { ModalHost } from './ModalHost'; import { ModalHost } from './ModalHost';
import { isInSystemContacts } from '../util/isInSystemContacts'; import { isInSystemContacts } from '../util/isInSystemContacts';
import type { RemoveClientType } from '../state/ducks/calling';
type ParticipantType = ConversationType & { type ParticipantType = ConversationType & {
hasRemoteAudio?: boolean; hasRemoteAudio?: boolean;
hasRemoteVideo?: boolean; hasRemoteVideo?: boolean;
isHandRaised?: boolean; isHandRaised?: boolean;
presenting?: boolean; presenting?: boolean;
demuxId?: number;
}; };
export type PropsType = { export type PropsType = {
readonly callLink: CallLinkType; readonly callLink: CallLinkType;
readonly i18n: LocalizerType; readonly i18n: LocalizerType;
readonly isCallLinkAdmin: boolean;
readonly ourServiceId: ServiceIdString | undefined; readonly ourServiceId: ServiceIdString | undefined;
readonly participants: Array<ParticipantType>; readonly participants: Array<ParticipantType>;
readonly onClose: () => void; readonly onClose: () => void;
readonly onCopyCallLink: () => void; readonly onCopyCallLink: () => void;
readonly removeClient: ((payload: RemoveClientType) => void) | null;
}; };
export function CallingAdhocCallInfo({ export function CallingAdhocCallInfo({
i18n, i18n,
isCallLinkAdmin,
ourServiceId, ourServiceId,
participants, participants,
onClose, onClose,
onCopyCallLink, onCopyCallLink,
removeClient,
}: PropsType): JSX.Element | null { }: PropsType): JSX.Element | null {
const sortedParticipants = React.useMemo<Array<ParticipantType>>( const sortedParticipants = React.useMemo<Array<ParticipantType>>(
() => sortByTitle(participants), () => sortByTitle(participants),
@ -137,6 +143,26 @@ export function CallingAdhocCallInfo({
'module-calling-participants-list__muted--audio' 'module-calling-participants-list__muted--audio'
)} )}
/> />
{isCallLinkAdmin &&
removeClient &&
participant.demuxId &&
!(ourServiceId && participant.serviceId === ourServiceId) ? (
<button
aria-label={i18n('icu:CallingAdhocCallInfo__RemoveClient')}
className={classNames(
'CallingAdhocCallInfo__RemoveClient',
'module-calling-participants-list__status-icon',
'module-calling-participants-list__remove'
)}
onClick={() => {
if (!participant.demuxId) {
return;
}
removeClient({ demuxId: participant.demuxId });
}}
type="button"
/>
) : null}
</li> </li>
) )
)} )}

View file

@ -0,0 +1,101 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable react/no-array-index-key */
import React from 'react';
import { Avatar, AvatarSize } from './Avatar';
import { ContactName } from './conversation/ContactName';
import { InContactsIcon } from './InContactsIcon';
import type { LocalizerType } from '../types/Util';
import type { ConversationType } from '../state/ducks/conversations';
import { isInSystemContacts } from '../util/isInSystemContacts';
import type { PendingUserActionPayloadType } from '../state/ducks/calling';
import type { ServiceIdString } from '../types/ServiceId';
import { Button, ButtonVariant } from './Button';
export type PropsType = {
readonly i18n: LocalizerType;
readonly ourServiceId: ServiceIdString | undefined;
readonly participants: Array<ConversationType>;
readonly approveUser: (payload: PendingUserActionPayloadType) => void;
readonly denyUser: (payload: PendingUserActionPayloadType) => void;
};
export function CallingPendingParticipants({
i18n,
ourServiceId,
participants,
approveUser,
denyUser,
}: PropsType): JSX.Element | null {
return (
<div className="CallingPendingParticipants module-calling-participants-list">
<div className="module-calling-participants-list__header">
<div className="module-calling-participants-list__title">
{i18n('icu:CallingPendingParticipants__RequestsToJoin', {
count: participants.length,
})}
</div>
</div>
<ul className="module-calling-participants-list__list">
{participants.map((participant: ConversationType, index: number) => (
<li className="module-calling-participants-list__contact" key={index}>
<div className="module-calling-participants-list__avatar-and-name">
<Avatar
acceptedMessageRequest={participant.acceptedMessageRequest}
avatarPath={participant.avatarPath}
badge={undefined}
color={participant.color}
conversationType="direct"
i18n={i18n}
isMe={participant.isMe}
profileName={participant.profileName}
title={participant.title}
sharedGroupNames={participant.sharedGroupNames}
size={AvatarSize.THIRTY_TWO}
/>
{ourServiceId && participant.serviceId === ourServiceId ? (
<span className="module-calling-participants-list__name">
{i18n('icu:you')}
</span>
) : (
<>
<ContactName
module="module-calling-participants-list__name"
title={participant.title}
/>
{isInSystemContacts(participant) ? (
<span>
{' '}
<InContactsIcon
className="module-calling-participants-list__contact-icon"
i18n={i18n}
/>
</span>
) : null}
</>
)}
</div>
<Button
aria-label={i18n('icu:CallingPendingParticipants__DenyUser')}
className="CallingPendingParticipants__PendingActionButton CallingButton__icon"
onClick={() => denyUser({ serviceId: participant.serviceId })}
variant={ButtonVariant.Destructive}
>
<span className="CallingPendingParticipants__PendingActionButtonIcon CallingPendingParticipants__PendingActionButtonIcon--Deny" />
</Button>
<Button
aria-label={i18n('icu:CallingPendingParticipants__ApproveUser')}
className="CallingPendingParticipants__PendingActionButton CallingButton__icon"
onClick={() => approveUser({ serviceId: participant.serviceId })}
variant={ButtonVariant.Calling}
>
<span className="CallingPendingParticipants__PendingActionButtonIcon CallingPendingParticipants__PendingActionButtonIcon--Approve" />
</Button>
</li>
))}
</ul>
</div>
);
}

View file

@ -139,6 +139,7 @@ export function GroupCall(args: PropsType): JSX.Element {
maxDevices: 5, maxDevices: 5,
deviceCount: 0, deviceCount: 0,
peekedParticipants: [], peekedParticipants: [],
pendingParticipants: [],
raisedHands: new Set<number>(), raisedHands: new Set<number>(),
remoteParticipants: [], remoteParticipants: [],
remoteAudioLevels: new Map<number, number>(), remoteAudioLevels: new Map<number, number>(),

View file

@ -40,7 +40,7 @@ import {
RingRTC, RingRTC,
RingUpdate, RingUpdate,
} from '@signalapp/ringrtc'; } from '@signalapp/ringrtc';
import { uniqBy, noop } from 'lodash'; import { uniqBy, noop, compact } from 'lodash';
import Long from 'long'; import Long from 'long';
import type { CallLinkAuthCredentialPresentation } from '@signalapp/libsignal-client/zkgroup'; import type { CallLinkAuthCredentialPresentation } from '@signalapp/libsignal-client/zkgroup';
@ -125,11 +125,13 @@ import {
} from '../util/callDisposition'; } from '../util/callDisposition';
import { isNormalNumber } from '../util/isNormalNumber'; import { isNormalNumber } from '../util/isNormalNumber';
import { LocalCallEvent } from '../types/CallDisposition'; 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 { isInSystemContacts } from '../util/isInSystemContacts';
import { import {
getRoomIdFromRootKey, getRoomIdFromRootKey,
getCallLinkAuthCredentialPresentation, getCallLinkAuthCredentialPresentation,
toAdminKeyBytes,
} from '../util/callLinks'; } from '../util/callLinks';
import { isAdhocCallingEnabled } from '../util/isAdhocCallingEnabled'; import { isAdhocCallingEnabled } from '../util/isAdhocCallingEnabled';
import { conversationJobQueue } from '../jobs/conversationJobQueue'; import { conversationJobQueue } from '../jobs/conversationJobQueue';
@ -580,10 +582,12 @@ export class CallingClass {
async startCallLinkLobby({ async startCallLinkLobby({
callLinkRootKey, callLinkRootKey,
adminPasskey,
hasLocalAudio, hasLocalAudio,
hasLocalVideo = true, hasLocalVideo = true,
}: Readonly<{ }: Readonly<{
callLinkRootKey: CallLinkRootKey; callLinkRootKey: CallLinkRootKey;
adminPasskey: Buffer | undefined;
hasLocalAudio: boolean; hasLocalAudio: boolean;
hasLocalVideo?: boolean; hasLocalVideo?: boolean;
}>): Promise< }>): Promise<
@ -610,7 +614,7 @@ export class CallingClass {
roomId, roomId,
authCredentialPresentation, authCredentialPresentation,
callLinkRootKey, callLinkRootKey,
adminPasskey: undefined, adminPasskey,
}); });
groupCall.setOutgoingAudioMuted(!hasLocalAudio); groupCall.setOutgoingAudioMuted(!hasLocalAudio);
@ -1210,11 +1214,13 @@ export class CallingClass {
public async joinCallLinkCall({ public async joinCallLinkCall({
roomId, roomId,
rootKey, rootKey,
adminKey,
hasLocalAudio, hasLocalAudio,
hasLocalVideo, hasLocalVideo,
}: { }: {
roomId: string; roomId: string;
rootKey: string; rootKey: string;
adminKey: string | undefined;
hasLocalAudio: boolean; hasLocalAudio: boolean;
hasLocalVideo: boolean; hasLocalVideo: boolean;
}): Promise<void> { }): Promise<void> {
@ -1228,13 +1234,16 @@ export class CallingClass {
const callLinkRootKey = CallLinkRootKey.parse(rootKey); const callLinkRootKey = CallLinkRootKey.parse(rootKey);
const authCredentialPresentation = const authCredentialPresentation =
await getCallLinkAuthCredentialPresentation(callLinkRootKey); await getCallLinkAuthCredentialPresentation(callLinkRootKey);
const adminPasskey = adminKey
? Buffer.from(toAdminKeyBytes(adminKey))
: undefined;
// RingRTC reuses the same type GroupCall between Adhoc and Group calls. // RingRTC reuses the same type GroupCall between Adhoc and Group calls.
const groupCall = this.connectCallLinkCall({ const groupCall = this.connectCallLinkCall({
roomId, roomId,
authCredentialPresentation, authCredentialPresentation,
callLinkRootKey, callLinkRootKey,
adminPasskey: undefined, adminPasskey,
}); });
groupCall.setOutgoingAudioMuted(!hasLocalAudio); groupCall.setOutgoingAudioMuted(!hasLocalAudio);
@ -1267,6 +1276,33 @@ export class CallingClass {
groupCall.setGroupMembers(this.getGroupCallMembers(conversationId)); 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. // See the comment in types/Calling.ts to explain why we have to do this conversion.
private convertRingRtcConnectionState( private convertRingRtcConnectionState(
connectionState: ConnectionState 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( public formatGroupCallPeekInfoForRedux(
peekInfo: PeekInfo peekInfo: PeekInfo
): GroupCallPeekInfoType { ): GroupCallPeekInfoType {
@ -1308,17 +1356,10 @@ export class CallingClass {
return { return {
acis: peekInfo.devices.map(peekDeviceInfo => { acis: peekInfo.devices.map(peekDeviceInfo => {
if (peekDeviceInfo.userId) { if (peekDeviceInfo.userId) {
const uuid = bytesToUuid(peekDeviceInfo.userId); const uuid = this.formatUserId(peekDeviceInfo.userId);
if (uuid) { if (uuid) {
assertDev(
isAciString(uuid),
'peeked participant uuid must be an ACI'
);
return uuid; return uuid;
} }
log.error(
'Calling.formatGroupCallPeekInfoForRedux: could not convert peek UUID Uint8Array to string; using fallback UUID'
);
} else { } else {
log.error( log.error(
'Calling.formatGroupCallPeekInfoForRedux: device had no user ID; using fallback UUID' 'Calling.formatGroupCallPeekInfoForRedux: device had no user ID; using fallback UUID'
@ -1329,6 +1370,9 @@ export class CallingClass {
'formatGrouPCallPeekInfoForRedux' 'formatGrouPCallPeekInfoForRedux'
); );
}), }),
pendingAcis: compact(
peekInfo.pendingUsers.map(userId => this.formatUserId(userId))
),
creatorAci: creatorAci:
creatorAci !== undefined creatorAci !== undefined
? normalizeAci( ? normalizeAci(

View file

@ -49,11 +49,12 @@ import { requestCameraPermissions } from '../../util/callingPermissions';
import { import {
CALL_LINK_DEFAULT_STATE, CALL_LINK_DEFAULT_STATE,
getRoomIdFromRootKey, getRoomIdFromRootKey,
toAdminKeyBytes,
} from '../../util/callLinks'; } from '../../util/callLinks';
import { sendCallLinkUpdateSync } from '../../util/sendCallLinkUpdateSync'; import { sendCallLinkUpdateSync } from '../../util/sendCallLinkUpdateSync';
import { sleep } from '../../util/sleep'; import { sleep } from '../../util/sleep';
import { LatestQueue } from '../../util/LatestQueue'; import { LatestQueue } from '../../util/LatestQueue';
import type { AciString } from '../../types/ServiceId'; import type { AciString, ServiceIdString } from '../../types/ServiceId';
import type { import type {
ConversationChangedActionType, ConversationChangedActionType,
ConversationRemovedActionType, ConversationRemovedActionType,
@ -81,11 +82,13 @@ import { SHOW_ERROR_MODAL } from './globalModals';
import { ButtonVariant } from '../../components/Button'; import { ButtonVariant } from '../../components/Button';
import { getConversationIdForLogging } from '../../util/idForLogging'; import { getConversationIdForLogging } from '../../util/idForLogging';
import dataInterface from '../../sql/Client'; import dataInterface from '../../sql/Client';
import { isAciString } from '../../util/isAciString';
// State // State
export type GroupCallPeekInfoType = ReadonlyDeep<{ export type GroupCallPeekInfoType = ReadonlyDeep<{
acis: Array<AciString>; acis: Array<AciString>;
pendingAcis: Array<AciString>;
creatorAci?: AciString; creatorAci?: AciString;
eraId?: string; eraId?: string;
maxDevices: number; maxDevices: number;
@ -250,7 +253,7 @@ type HangUpActionPayloadType = ReadonlyDeep<{
conversationId: string; conversationId: string;
}>; }>;
type HandleCallLinkUpdateType = ReadonlyDeep<{ export type HandleCallLinkUpdateType = ReadonlyDeep<{
rootKey: string; rootKey: string;
adminKey: string | null; adminKey: string | null;
}>; }>;
@ -309,6 +312,10 @@ type RemoteSharingScreenChangeType = ReadonlyDeep<{
isSharingScreen: boolean; isSharingScreen: boolean;
}>; }>;
export type RemoveClientType = ReadonlyDeep<{
demuxId: number;
}>;
export type SetLocalAudioType = ReadonlyDeep<{ export type SetLocalAudioType = ReadonlyDeep<{
enabled: boolean; enabled: boolean;
}>; }>;
@ -558,10 +565,12 @@ const doGroupCallPeek = ({
// Actions // Actions
const ACCEPT_CALL_PENDING = 'calling/ACCEPT_CALL_PENDING'; const ACCEPT_CALL_PENDING = 'calling/ACCEPT_CALL_PENDING';
const APPROVE_USER = 'calling/APPROVE_USER';
const CANCEL_CALL = 'calling/CANCEL_CALL'; const CANCEL_CALL = 'calling/CANCEL_CALL';
const CANCEL_INCOMING_GROUP_CALL_RING = const CANCEL_INCOMING_GROUP_CALL_RING =
'calling/CANCEL_INCOMING_GROUP_CALL_RING'; 'calling/CANCEL_INCOMING_GROUP_CALL_RING';
const CHANGE_CALL_VIEW = 'calling/CHANGE_CALL_VIEW'; const CHANGE_CALL_VIEW = 'calling/CHANGE_CALL_VIEW';
const DENY_USER = 'calling/DENY_USER';
const START_CALLING_LOBBY = 'calling/START_CALLING_LOBBY'; const START_CALLING_LOBBY = 'calling/START_CALLING_LOBBY';
const START_CALL_LINK_LOBBY = 'calling/START_CALL_LINK_LOBBY'; const START_CALL_LINK_LOBBY = 'calling/START_CALL_LINK_LOBBY';
const CALL_STATE_CHANGE_FULFILLED = 'calling/CALL_STATE_CHANGE_FULFILLED'; 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 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';
const REMOVE_CLIENT = 'calling/REMOVE_CLIENT';
const RETURN_TO_ACTIVE_CALL = 'calling/RETURN_TO_ACTIVE_CALL'; const RETURN_TO_ACTIVE_CALL = 'calling/RETURN_TO_ACTIVE_CALL';
const SEND_GROUP_CALL_REACTION = 'calling/SEND_GROUP_CALL_REACTION'; const SEND_GROUP_CALL_REACTION = 'calling/SEND_GROUP_CALL_REACTION';
const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED'; const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED';
@ -605,6 +615,10 @@ type AcceptCallPendingActionType = ReadonlyDeep<{
payload: AcceptCallType; payload: AcceptCallType;
}>; }>;
type ApproveUserActionType = ReadonlyDeep<{
type: 'calling/APPROVE_USER';
}>;
type CancelCallActionType = ReadonlyDeep<{ type CancelCallActionType = ReadonlyDeep<{
type: 'calling/CANCEL_CALL'; type: 'calling/CANCEL_CALL';
}>; }>;
@ -614,6 +628,10 @@ type CancelIncomingGroupCallRingActionType = ReadonlyDeep<{
payload: CancelIncomingGroupCallRingType; payload: CancelIncomingGroupCallRingType;
}>; }>;
type DenyUserActionType = ReadonlyDeep<{
type: 'calling/DENY_USER';
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep // eslint-disable-next-line local-rules/type-alias-readonlydeep
type StartCallingLobbyActionType = { type StartCallingLobbyActionType = {
type: 'calling/START_CALLING_LOBBY'; 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 // eslint-disable-next-line local-rules/type-alias-readonlydeep
type RefreshIODevicesActionType = { type RefreshIODevicesActionType = {
type: 'calling/REFRESH_IO_DEVICES'; type: 'calling/REFRESH_IO_DEVICES';
@ -767,6 +789,10 @@ type RemoteVideoChangeActionType = ReadonlyDeep<{
payload: RemoteVideoChangeType; payload: RemoteVideoChangeType;
}>; }>;
type RemoveClientActionType = ReadonlyDeep<{
type: 'calling/REMOVE_CLIENT';
}>;
type ReturnToActiveCallActionType = ReadonlyDeep<{ type ReturnToActiveCallActionType = ReadonlyDeep<{
type: 'calling/RETURN_TO_ACTIVE_CALL'; type: 'calling/RETURN_TO_ACTIVE_CALL';
}>; }>;
@ -833,10 +859,12 @@ type SwitchFromPresentationViewActionType = ReadonlyDeep<{
// eslint-disable-next-line local-rules/type-alias-readonlydeep // eslint-disable-next-line local-rules/type-alias-readonlydeep
export type CallingActionType = export type CallingActionType =
| ApproveUserActionType
| AcceptCallPendingActionType | AcceptCallPendingActionType
| CancelCallActionType | CancelCallActionType
| CancelIncomingGroupCallRingActionType | CancelIncomingGroupCallRingActionType
| ChangeCallViewActionType | ChangeCallViewActionType
| DenyUserActionType
| StartCallingLobbyActionType | StartCallingLobbyActionType
| StartCallLinkLobbyActionType | StartCallLinkLobbyActionType
| CallStateChangeFulfilledActionType | CallStateChangeFulfilledActionType
@ -860,6 +888,7 @@ export type CallingActionType =
| RefreshIODevicesActionType | RefreshIODevicesActionType
| RemoteSharingScreenChangeActionType | RemoteSharingScreenChangeActionType
| RemoteVideoChangeActionType | RemoteVideoChangeActionType
| RemoveClientActionType
| ReturnToActiveCallActionType | ReturnToActiveCallActionType
| SendGroupCallReactionActionType | SendGroupCallReactionActionType
| SetLocalAudioActionType | SetLocalAudioActionType
@ -911,6 +940,68 @@ function acceptCall(
}; };
} }
function approveUser(
payload: PendingUserActionPayloadType
): ThunkAction<void, RootStateType, unknown, ApproveUserActionType> {
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<void, RootStateType, unknown, DenyUserActionType> {
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<void, RootStateType, unknown, RemoveClientActionType> {
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( function callStateChange(
payload: CallStateChangeType payload: CallStateChangeType
): ThunkAction< ): ThunkAction<
@ -1869,8 +1960,13 @@ const _startCallLinkLobby = async ({
groupCall?.remoteParticipants.length || groupCall?.remoteParticipants.length ||
0; 0;
const { adminKey } = getOwn(state.calling.callLinks, roomId) ?? {};
const adminPasskey = adminKey
? Buffer.from(toAdminKeyBytes(adminKey))
: undefined;
const callLobbyData = await calling.startCallLinkLobby({ const callLobbyData = await calling.startCallLinkLobby({
callLinkRootKey, callLinkRootKey,
adminPasskey,
hasLocalAudio: groupCallDeviceCount < 8, hasLocalAudio: groupCallDeviceCount < 8,
}); });
if (!callLobbyData) { if (!callLobbyData) {
@ -2003,6 +2099,7 @@ function startCall(
await calling.joinCallLinkCall({ await calling.joinCallLinkCall({
roomId: conversationId, roomId: conversationId,
rootKey: callLink.rootKey, rootKey: callLink.rootKey,
adminKey: callLink.adminKey ?? undefined,
hasLocalAudio, hasLocalAudio,
hasLocalVideo, hasLocalVideo,
}); });
@ -2061,6 +2158,7 @@ function switchFromPresentationView(): SwitchFromPresentationViewActionType {
} }
export const actions = { export const actions = {
acceptCall, acceptCall,
approveUser,
callStateChange, callStateChange,
cancelCall, cancelCall,
cancelIncomingGroupCallRing, cancelIncomingGroupCallRing,
@ -2068,6 +2166,7 @@ export const actions = {
changeIODevice, changeIODevice,
closeNeedPermissionScreen, closeNeedPermissionScreen,
declineCall, declineCall,
denyUser,
getPresentingSources, getPresentingSources,
groupCallAudioLevelsChange, groupCallAudioLevelsChange,
groupCallEnded, groupCallEnded,
@ -2089,6 +2188,7 @@ export const actions = {
refreshIODevices, refreshIODevices,
remoteSharingScreenChange, remoteSharingScreenChange,
remoteVideoChange, remoteVideoChange,
removeClient,
returnToActiveCall, returnToActiveCall,
sendGroupCallRaiseHand, sendGroupCallRaiseHand,
sendGroupCallReaction, sendGroupCallReaction,
@ -2237,6 +2337,7 @@ export function reducer(
peekInfo: peekInfo || peekInfo: peekInfo ||
existingCall?.peekInfo || { existingCall?.peekInfo || {
acis: remoteParticipants.map(({ aci }) => aci), acis: remoteParticipants.map(({ aci }) => aci),
pendingAcis: [],
maxDevices: Infinity, maxDevices: Infinity,
deviceCount: remoteParticipants.length, deviceCount: remoteParticipants.length,
}, },
@ -2286,8 +2387,10 @@ export function reducer(
...callLinks, ...callLinks,
[conversationId]: { [conversationId]: {
...action.payload.callLinkState, ...action.payload.callLinkState,
rootKey: action.payload.callLinkRootKey, rootKey:
adminKey: null, callLinks[conversationId]?.rootKey ??
action.payload.callLinkRootKey,
adminKey: callLinks[conversationId]?.adminKey,
}, },
} }
: callLinks, : callLinks,
@ -2478,6 +2581,7 @@ export function reducer(
localDemuxId: undefined, localDemuxId: undefined,
peekInfo: { peekInfo: {
acis: [], acis: [],
pendingAcis: [],
maxDevices: Infinity, maxDevices: Infinity,
deviceCount: 0, deviceCount: 0,
}, },
@ -2676,6 +2780,7 @@ export function reducer(
const newPeekInfo = peekInfo || const newPeekInfo = peekInfo ||
existingCall?.peekInfo || { existingCall?.peekInfo || {
acis: remoteParticipants.map(({ aci }) => aci), acis: remoteParticipants.map(({ aci }) => aci),
pendingAcis: [],
maxDevices: Infinity, maxDevices: Infinity,
deviceCount: remoteParticipants.length, deviceCount: remoteParticipants.length,
}; };
@ -2755,6 +2860,7 @@ export function reducer(
localDemuxId: undefined, localDemuxId: undefined,
peekInfo: { peekInfo: {
acis: [], acis: [],
pendingAcis: [],
maxDevices: Infinity, maxDevices: Infinity,
deviceCount: 0, deviceCount: 0,
}, },

View file

@ -211,6 +211,7 @@ const mapStateToActiveCallProp = (
const groupMembers: Array<ConversationType> = []; const groupMembers: Array<ConversationType> = [];
const remoteParticipants: Array<GroupCallRemoteParticipantType> = []; const remoteParticipants: Array<GroupCallRemoteParticipantType> = [];
const peekedParticipants: Array<ConversationType> = []; const peekedParticipants: Array<ConversationType> = [];
const pendingParticipants: Array<ConversationType> = [];
const conversationsByDemuxId: ConversationsByDemuxIdType = new Map(); const conversationsByDemuxId: ConversationsByDemuxIdType = new Map();
const { localDemuxId } = call; const { localDemuxId } = call;
const raisedHands: Set<number> = new Set(call.raisedHands ?? []); const raisedHands: Set<number> = new Set(call.raisedHands ?? []);
@ -224,6 +225,7 @@ const mapStateToActiveCallProp = (
deviceCount: 0, deviceCount: 0,
maxDevices: Infinity, maxDevices: Infinity,
acis: [], acis: [],
pendingAcis: [],
}, },
} = call; } = call;
@ -294,6 +296,20 @@ const mapStateToActiveCallProp = (
peekedParticipants.push(peekedConversation); 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 { return {
...baseResult, ...baseResult,
callMode: call.callMode, callMode: call.callMode,
@ -306,6 +322,7 @@ const mapStateToActiveCallProp = (
localDemuxId, localDemuxId,
maxDevices: peekInfo.maxDevices, maxDevices: peekInfo.maxDevices,
peekedParticipants, peekedParticipants,
pendingParticipants,
raisedHands, raisedHands,
remoteParticipants, remoteParticipants,
remoteAudioLevels: call.remoteAudioLevels || new Map<number, number>(), remoteAudioLevels: call.remoteAudioLevels || new Map<number, number>(),
@ -407,6 +424,8 @@ export const SmartCallManager = memo(function SmartCallManager() {
: false; : false;
const { const {
approveUser,
denyUser,
changeCallView, changeCallView,
closeNeedPermissionScreen, closeNeedPermissionScreen,
getPresentingSources, getPresentingSources,
@ -416,6 +435,7 @@ export const SmartCallManager = memo(function SmartCallManager() {
acceptCall, acceptCall,
declineCall, declineCall,
openSystemPreferencesAction, openSystemPreferencesAction,
removeClient,
sendGroupCallRaiseHand, sendGroupCallRaiseHand,
sendGroupCallReaction, sendGroupCallReaction,
setGroupCallVideoRequest, setGroupCallVideoRequest,
@ -440,6 +460,7 @@ export const SmartCallManager = memo(function SmartCallManager() {
<CallManager <CallManager
acceptCall={acceptCall} acceptCall={acceptCall}
activeCall={activeCall} activeCall={activeCall}
approveUser={approveUser}
availableCameras={availableCameras} availableCameras={availableCameras}
bounceAppIconStart={bounceAppIconStart} bounceAppIconStart={bounceAppIconStart}
bounceAppIconStop={bounceAppIconStop} bounceAppIconStop={bounceAppIconStop}
@ -448,6 +469,7 @@ export const SmartCallManager = memo(function SmartCallManager() {
changeCallView={changeCallView} changeCallView={changeCallView}
closeNeedPermissionScreen={closeNeedPermissionScreen} closeNeedPermissionScreen={closeNeedPermissionScreen}
declineCall={declineCall} declineCall={declineCall}
denyUser={denyUser}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource} getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
getPresentingSources={getPresentingSources} getPresentingSources={getPresentingSources}
hangUpActiveCall={hangUpActiveCall} hangUpActiveCall={hangUpActiveCall}
@ -461,6 +483,7 @@ export const SmartCallManager = memo(function SmartCallManager() {
openSystemPreferencesAction={openSystemPreferencesAction} openSystemPreferencesAction={openSystemPreferencesAction}
pauseVoiceNotePlayer={pauseVoiceNotePlayer} pauseVoiceNotePlayer={pauseVoiceNotePlayer}
playRingtone={playRingtone} playRingtone={playRingtone}
removeClient={removeClient}
renderDeviceSelection={renderDeviceSelection} renderDeviceSelection={renderDeviceSelection}
renderEmojiPicker={renderEmojiPicker} renderEmojiPicker={renderEmojiPicker}
renderReactionPicker={renderReactionPicker} renderReactionPicker={renderReactionPicker}

View file

@ -1,7 +1,7 @@
// Copyright 2024 Signal Messenger, LLC // Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { CallLinkStateType, CallLinkType } from '../../types/CallLink';
import type { CallingConversationType } from '../../types/Calling'; import type { CallingConversationType } from '../../types/Calling';
import type { CallLinkType } from '../../types/CallLink';
import { CallLinkRestrictions } from '../../types/CallLink'; import { CallLinkRestrictions } from '../../types/CallLink';
import { MONTH } from '../../util/durations/constants'; import { MONTH } from '../../util/durations/constants';
@ -26,6 +26,11 @@ export const FAKE_CALL_LINK_WITH_ADMIN_KEY: CallLinkType = {
rootKey: 'bpmc-mrgn-hntf-mffd-mndd-xbxk-zmgq-qszg', rootKey: 'bpmc-mrgn-hntf-mffd-mndd-xbxk-zmgq-qszg',
}; };
export function getCallLinkState(callLink: CallLinkType): CallLinkStateType {
const { name, restrictions, expiration, revoked } = callLink;
return { name, restrictions, expiration, revoked };
}
export function getDefaultCallLinkConversation( export function getDefaultCallLinkConversation(
callLinkOverrideProps: Partial<CallLinkType> = {} callLinkOverrideProps: Partial<CallLinkType> = {}
): CallingConversationType { ): CallingConversationType {

View file

@ -10,12 +10,15 @@ import { reducer as rootReducer } from '../../../state/reducer';
import { noopAction } from '../../../state/ducks/noop'; import { noopAction } from '../../../state/ducks/noop';
import type { import type {
ActiveCallStateType, ActiveCallStateType,
CallingActionType,
CallingStateType, CallingStateType,
DirectCallStateType, DirectCallStateType,
GroupCallReactionsReceivedActionType, GroupCallReactionsReceivedActionType,
GroupCallStateChangeActionType, GroupCallStateChangeActionType,
GroupCallStateType, GroupCallStateType,
HandleCallLinkUpdateType,
SendGroupCallReactionActionType, SendGroupCallReactionActionType,
StartCallLinkLobbyType,
} from '../../../state/ducks/calling'; } from '../../../state/ducks/calling';
import { import {
actions, actions,
@ -36,8 +39,11 @@ import {
import { generateAci } from '../../../types/ServiceId'; import { generateAci } from '../../../types/ServiceId';
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';
import { CallLinkRestrictions } from '../../../types/CallLink'; import {
import { FAKE_CALL_LINK } from '../../../test-both/helpers/fakeCallLink'; FAKE_CALL_LINK,
FAKE_CALL_LINK_WITH_ADMIN_KEY,
getCallLinkState,
} from '../../../test-both/helpers/fakeCallLink';
const ACI_1 = generateAci(); const ACI_1 = generateAci();
const NOW = new Date('2020-01-23T04:56:00.000'); const NOW = new Date('2020-01-23T04:56:00.000');
@ -109,6 +115,7 @@ describe('calling duck', () => {
localDemuxId: 1, localDemuxId: 1,
peekInfo: { peekInfo: {
acis: [creatorAci], acis: [creatorAci],
pendingAcis: [],
creatorAci, creatorAci,
eraId: 'xyz', eraId: 'xyz',
maxDevices: 16, maxDevices: 16,
@ -902,6 +909,7 @@ describe('calling duck', () => {
hasLocalVideo: false, hasLocalVideo: false,
peekInfo: { peekInfo: {
acis: [creatorAci], acis: [creatorAci],
pendingAcis: [],
creatorAci, creatorAci,
eraId: 'xyz', eraId: 'xyz',
maxDevices: 16, maxDevices: 16,
@ -932,6 +940,7 @@ describe('calling duck', () => {
localDemuxId: 1, localDemuxId: 1,
peekInfo: { peekInfo: {
acis: [creatorAci], acis: [creatorAci],
pendingAcis: [],
creatorAci, creatorAci,
eraId: 'xyz', eraId: 'xyz',
maxDevices: 16, maxDevices: 16,
@ -967,6 +976,7 @@ describe('calling duck', () => {
hasLocalVideo: false, hasLocalVideo: false,
peekInfo: { peekInfo: {
acis: [ACI_1], acis: [ACI_1],
pendingAcis: [],
maxDevices: 16, maxDevices: 16,
deviceCount: 1, deviceCount: 1,
}, },
@ -995,6 +1005,7 @@ describe('calling duck', () => {
localDemuxId: 1, localDemuxId: 1,
peekInfo: { peekInfo: {
acis: [ACI_1], acis: [ACI_1],
pendingAcis: [],
maxDevices: 16, maxDevices: 16,
deviceCount: 1, deviceCount: 1,
}, },
@ -1041,6 +1052,7 @@ describe('calling duck', () => {
hasLocalVideo: false, hasLocalVideo: false,
peekInfo: { peekInfo: {
acis: [ACI_1], acis: [ACI_1],
pendingAcis: [],
maxDevices: 16, maxDevices: 16,
deviceCount: 1, deviceCount: 1,
}, },
@ -1095,6 +1107,7 @@ describe('calling duck', () => {
hasLocalVideo: false, hasLocalVideo: false,
peekInfo: { peekInfo: {
acis: [ACI_1], acis: [ACI_1],
pendingAcis: [],
maxDevices: 16, maxDevices: 16,
deviceCount: 1, deviceCount: 1,
}, },
@ -1136,6 +1149,7 @@ describe('calling duck', () => {
hasLocalVideo: false, hasLocalVideo: false,
peekInfo: { peekInfo: {
acis: [ACI_1], acis: [ACI_1],
pendingAcis: [],
maxDevices: 16, maxDevices: 16,
deviceCount: 1, deviceCount: 1,
}, },
@ -1170,6 +1184,7 @@ describe('calling duck', () => {
hasLocalVideo: true, hasLocalVideo: true,
peekInfo: { peekInfo: {
acis: [ACI_1], acis: [ACI_1],
pendingAcis: [],
maxDevices: 16, maxDevices: 16,
deviceCount: 1, deviceCount: 1,
}, },
@ -1216,6 +1231,7 @@ describe('calling duck', () => {
hasLocalVideo: true, hasLocalVideo: true,
peekInfo: { peekInfo: {
acis: [ACI_1], acis: [ACI_1],
pendingAcis: [],
maxDevices: 16, maxDevices: 16,
deviceCount: 1, deviceCount: 1,
}, },
@ -1262,6 +1278,7 @@ describe('calling duck', () => {
hasLocalVideo: true, hasLocalVideo: true,
peekInfo: { peekInfo: {
acis: [], acis: [],
pendingAcis: [],
maxDevices: 16, maxDevices: 16,
deviceCount: 0, deviceCount: 0,
}, },
@ -1292,6 +1309,7 @@ describe('calling duck', () => {
hasLocalVideo: true, hasLocalVideo: true,
peekInfo: { peekInfo: {
acis: [ACI_1], acis: [ACI_1],
pendingAcis: [],
maxDevices: 16, maxDevices: 16,
deviceCount: 1, deviceCount: 1,
}, },
@ -1304,42 +1322,42 @@ describe('calling duck', () => {
}); });
describe('handleCallLinkUpdate', () => { 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) { beforeEach(function (this: Mocha.Context) {
this.callingServiceReadCallLink = this.sandbox this.callingServiceReadCallLink = this.sandbox
.stub(callingService, 'readCallLink') .stub(callingService, 'readCallLink')
.resolves({ .resolves({
callLinkState: { callLinkState: getCallLinkState(FAKE_CALL_LINK),
name: 'Signal Call',
restrictions: CallLinkRestrictions.None,
expiration,
revoked: false,
},
errorStatusCode: undefined, 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 { handleCallLinkUpdate } = actions;
const dispatch = sinon.spy(); const dispatch = sinon.spy();
await handleCallLinkUpdate({ rootKey, adminKey: null })( await handleCallLinkUpdate(payload)(dispatch, getEmptyRootState, null);
dispatch, return { dispatch };
getEmptyRootState, };
null
); it('reads the call link from calling service', async function (this: Mocha.Context) {
await doAction({ rootKey, adminKey: null });
sinon.assert.calledOnce(this.callingServiceReadCallLink); sinon.assert.calledOnce(this.callingServiceReadCallLink);
}); });
it('dispatches HANDLE_CALL_LINK_UPDATE', async () => { it('dispatches HANDLE_CALL_LINK_UPDATE', async () => {
const { handleCallLinkUpdate } = actions; const { dispatch } = await doAction({ rootKey, adminKey: null });
const dispatch = sinon.spy();
await handleCallLinkUpdate({ rootKey, adminKey: null })(
dispatch,
getEmptyRootState,
null
);
sinon.assert.calledOnce(dispatch); sinon.assert.calledOnce(dispatch);
sinon.assert.calledWith(dispatch, { sinon.assert.calledWith(dispatch, {
@ -1347,16 +1365,115 @@ describe('calling duck', () => {
payload: { payload: {
roomId, roomId,
callLinkDetails: { callLinkDetails: {
name: 'Signal Call', name,
restrictions: CallLinkRestrictions.None, restrictions,
expiration, expiration,
revoked: false, revoked,
rootKey, 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<CallingActionType>
);
assert.equal(result.callLinks[roomId]?.adminKey, adminKey);
});
}); });
describe('peekNotConnectedGroupCall', () => { describe('peekNotConnectedGroupCall', () => {
@ -1503,6 +1620,7 @@ describe('calling duck', () => {
localDemuxId: undefined, localDemuxId: undefined,
peekInfo: { peekInfo: {
acis: [], acis: [],
pendingAcis: [],
maxDevices: Infinity, maxDevices: Infinity,
deviceCount: 0, deviceCount: 0,
}, },
@ -1956,6 +2074,7 @@ describe('calling duck', () => {
joinState: GroupCallJoinState.NotJoined, joinState: GroupCallJoinState.NotJoined,
peekInfo: { peekInfo: {
acis: [creatorAci], acis: [creatorAci],
pendingAcis: [],
creatorAci, creatorAci,
eraId: 'xyz', eraId: 'xyz',
maxDevices: 16, maxDevices: 16,
@ -1983,6 +2102,7 @@ describe('calling duck', () => {
localDemuxId: undefined, localDemuxId: undefined,
peekInfo: { peekInfo: {
acis: [creatorAci], acis: [creatorAci],
pendingAcis: [],
creatorAci, creatorAci,
eraId: 'xyz', eraId: 'xyz',
maxDevices: 16, maxDevices: 16,
@ -2022,6 +2142,7 @@ describe('calling duck', () => {
const call = result.callsByConversation['fake-conversation-id']; const call = result.callsByConversation['fake-conversation-id'];
assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, {
acis: [], acis: [],
pendingAcis: [],
maxDevices: Infinity, maxDevices: Infinity,
deviceCount: 0, deviceCount: 0,
}); });
@ -2053,6 +2174,7 @@ describe('calling duck', () => {
result.callsByConversation['fake-group-call-conversation-id']; result.callsByConversation['fake-group-call-conversation-id'];
assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, {
acis: [creatorAci], acis: [creatorAci],
pendingAcis: [],
creatorAci, creatorAci,
eraId: 'xyz', eraId: 'xyz',
maxDevices: 16, maxDevices: 16,
@ -2081,6 +2203,7 @@ describe('calling duck', () => {
joinState: GroupCallJoinState.NotJoined, joinState: GroupCallJoinState.NotJoined,
peekInfo: { peekInfo: {
acis: [differentCreatorAci], acis: [differentCreatorAci],
pendingAcis: [],
creatorAci: differentCreatorAci, creatorAci: differentCreatorAci,
eraId: 'abc', eraId: 'abc',
maxDevices: 5, maxDevices: 5,
@ -2103,6 +2226,7 @@ describe('calling duck', () => {
const call = result.callsByConversation['fake-conversation-id']; const call = result.callsByConversation['fake-conversation-id'];
assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, {
acis: [differentCreatorAci], acis: [differentCreatorAci],
pendingAcis: [],
creatorAci: differentCreatorAci, creatorAci: differentCreatorAci,
eraId: 'abc', eraId: 'abc',
maxDevices: 5, maxDevices: 5,

View file

@ -100,6 +100,7 @@ describe('state/selectors/calling', () => {
localDemuxId: undefined, localDemuxId: undefined,
peekInfo: { peekInfo: {
acis: [ACI_1], acis: [ACI_1],
pendingAcis: [],
creatorAci: ACI_1, creatorAci: ACI_1,
maxDevices: Infinity, maxDevices: Infinity,
deviceCount: 1, deviceCount: 1,
@ -180,6 +181,7 @@ describe('state/selectors/calling', () => {
...incomingGroupCall, ...incomingGroupCall,
peekInfo: { peekInfo: {
acis: [], acis: [],
pendingAcis: [],
maxDevices: Infinity, maxDevices: Infinity,
deviceCount: 1, deviceCount: 1,
}, },

View file

@ -97,6 +97,7 @@ export type ActiveGroupCallType = ActiveCallBaseType & {
groupMembers: Array<Pick<ConversationType, 'id' | 'firstName' | 'title'>>; groupMembers: Array<Pick<ConversationType, 'id' | 'firstName' | 'title'>>;
isConversationTooBigToRing: boolean; isConversationTooBigToRing: boolean;
peekedParticipants: Array<ConversationType>; peekedParticipants: Array<ConversationType>;
pendingParticipants: Array<ConversationType>;
raisedHands: Set<number>; raisedHands: Set<number>;
remoteParticipants: Array<GroupCallRemoteParticipantType>; remoteParticipants: Array<GroupCallRemoteParticipantType>;
remoteAudioLevels: Map<number, number>; remoteAudioLevels: Map<number, number>;