Call link batch approve join requests

Co-authored-by: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com>
This commit is contained in:
ayumi-signal 2024-07-01 16:25:18 -07:00 committed by GitHub
parent c046d36851
commit ea37980fc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 759 additions and 78 deletions

View file

@ -1733,10 +1733,46 @@
"messageformat": "Deny join request",
"description": "Tooltip label for check mark button to deny a user's request to join a call."
},
"icu:CallingPendingParticipants__ApproveAll": {
"messageformat": "Approve all",
"description": "Button to approve all pending requests to join a call."
},
"icu:CallingPendingParticipants__DenyAll": {
"messageformat": "Deny all",
"description": "Button to deny all pending requests to join a call."
},
"icu:CallingPendingParticipants__ConfirmDialogTitle--ApproveAll": {
"messageformat": "Approve {count, plural, one {# request} other {# requests}}?",
"description": "Title of confirmation dialog when approving all pending requests to join a call."
},
"icu:CallingPendingParticipants__ConfirmDialogTitle--DenyAll": {
"messageformat": "Deny {count, plural, one {# request} other {# requests}}?",
"description": "Title of confirmation dialog when approving all pending requests to join a call."
},
"icu:CallingPendingParticipants__ConfirmDialogBody--ApproveAll": {
"messageformat": "{count, plural, one {# person} other {# people}} will be added to the call.",
"description": "Title of confirmation dialog when approving all pending requests to join a call."
},
"icu:CallingPendingParticipants__ConfirmDialogBody--DenyAll": {
"messageformat": "{count, plural, one {# person} other {# people}} will not be added to the call.",
"description": "Title of confirmation dialog when denying all pending requests 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:CallingPendingParticipants__WouldLikeToJoin": {
"messageformat": "Would like to join...",
"description": "Participant info text in the call pending join requests compact list, visible only to call admins. Indicates that the participant would like to join the call and is requesting action."
},
"icu:CallingPendingParticipants__AdditionalRequests": {
"messageformat": "{count, plural, one {+# request} other {+# requests}}",
"description": "Button text in the call pending join requests compact list. When clicked the list expands to the full list"
},
"icu:CallingPendingParticipants__Toast--added-users-to-call": {
"messageformat": "{count, plural, one {# person} other {# people}} added to the call",
"description": "Shown in toast after batch approving requests to join a call"
},
"icu:CallingRaisedHandsList__Title": {
"messageformat": "{count, plural, one {# raised hand} other {# raised hands}}",
"description": "Shown in the call raised hands list to describe how many people have active raised hands"

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.0391 5.15773C19.5043 5.45544 19.64 6.07388 19.3423 6.53906L11.0223 19.5391C10.8475 19.8121 10.5512 19.9835 10.2274 19.9989C9.90354 20.0142 9.59235 19.8716 9.39254 19.6163L4.71254 13.6363C4.37216 13.2014 4.44881 12.5729 4.88374 12.2325C5.31866 11.8921 5.94717 11.9688 6.28755 12.4037L10.0982 17.2728L17.6578 5.46095C17.9555 4.99577 18.5739 4.86002 19.0391 5.15773Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 536 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.7071 6.70711C19.0976 6.31658 19.0976 5.68342 18.7071 5.29289C18.3166 4.90237 17.6834 4.90237 17.2929 5.29289L12 10.5858L6.70711 5.29289C6.31658 4.90237 5.68342 4.90237 5.29289 5.29289C4.90237 5.68342 4.90237 6.31658 5.29289 6.70711L10.5858 12L5.29289 17.2929C4.90237 17.6834 4.90237 18.3166 5.29289 18.7071C5.68342 19.0976 6.31658 19.0976 6.70711 18.7071L12 13.4142L17.2929 18.7071C17.6834 19.0976 18.3166 19.0976 18.7071 18.7071C19.0976 18.3166 19.0976 17.6834 18.7071 17.2929L13.4142 12L18.7071 6.70711Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 638 B

View file

@ -4,31 +4,127 @@
.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--Compact {
width: 364px;
padding-inline: 0;
padding-block-start: 0;
background: $color-gray-78;
outline: 0;
}
.CallingPendingParticipants--Expanded {
padding-block-end: 2px;
}
.CallingPendingParticipants__CompactParticipant {
display: flex;
padding-block: 12px;
padding-inline: 12px;
outline: none;
align-items: center;
}
.CallingPendingParticipants__CompactParticipantNameColumn {
margin-inline-start: 8px;
}
.CallingPendingParticipants__ParticipantName {
@include font-body-1-bold;
color: $color-gray-15;
}
.CallingPendingParticipants__WouldLikeToJoin {
@include font-body-2;
color: $color-gray-20;
}
.CallingPendingParticipants__PendingActionButton {
width: 28px;
height: 28px;
flex-shrink: 0;
padding-inline: 0;
margin-inline-end: 16px;
}
.CallingPendingParticipants__PendingActionButton:first-child {
margin-inline-start: 8px;
}
.CallingPendingParticipants__PendingActionButton:last-child {
margin-inline-end: 8px;
}
.CallingPendingParticipants__PendingActionButtonIcon {
width: 20px;
height: 20px;
width: 16px;
height: 16px;
}
.CallingPendingParticipants--Compact {
.CallingPendingParticipants__PendingActionButton {
width: 36px;
height: 36px;
margin-inline-end: 20px;
}
.CallingPendingParticipants__PendingActionButton:last-child {
margin-inline-end: 0;
}
.CallingPendingParticipants__PendingActionButtonIcon {
width: 20px;
height: 20px;
}
}
.CallingPendingParticipants__PendingActionButtonIcon--Approve {
@include color-svg('../images/icons/v3/check/check.svg', $color-white);
@include color-svg('../images/icons/v3/check/check-bold.svg', $color-white);
}
.CallingPendingParticipants__PendingActionButtonIcon--Deny {
@include color-svg('../images/icons/v3/x/x.svg', $color-white);
@include color-svg('../images/icons/v3/x/x-bold.svg', $color-white);
}
.CallingPendingParticipants__ActionPanel {
padding-block: 15px;
text-align: end;
}
.CallingPendingParticipants__ActionPanelButton {
border-radius: 4px;
margin-inline-end: 16px;
}
.CallingPendingParticipants__ActionPanelButton:last-child {
margin-inline-end: 10px;
}
.CallingPendingParticipants__ShowAllRequestsButton {
@include button-reset;
@include font-body-2-bold;
display: flex;
padding-block: 5px;
padding-inline: 15px;
margin-block: 12px;
margin-inline: auto;
color: $color-white-alpha-90;
background: $color-gray-65;
border-radius: 46px;
outline: none;
}
.CallingPendingParticipants__ShowAllRequestsButton:focus {
@include keyboard-mode {
outline: 3px solid $color-ultramarine;
outline-offset: 1px;
}
}
.CallingPendingParticipants__ShowAllRequestsButtonContainer {
background: $color-black-alpha-24;
}

View file

@ -28,6 +28,7 @@ import enMessages from '../../_locales/en/messages.json';
import { StorySendMode } from '../types/Stories';
import {
FAKE_CALL_LINK,
FAKE_CALL_LINK_WITH_ADMIN_KEY,
getDefaultCallLinkConversation,
} from '../test-both/helpers/fakeCallLink';
import { allRemoteParticipants } from './CallScreen.stories';
@ -72,6 +73,7 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
availableCameras: [],
acceptCall: action('accept-call'),
approveUser: action('approve-user'),
batchUserAction: action('batch-user-action'),
bounceAppIconStart: action('bounce-app-icon-start'),
bounceAppIconStop: action('bounce-app-icon-stop'),
cancelCall: action('cancel-call'),
@ -145,10 +147,11 @@ const getActiveCallForCallLink = (
settingsDialogOpen: false,
showParticipantsList: overrideProps.showParticipantsList ?? true,
callMode: CallMode.Adhoc,
connectionState: GroupCallConnectionState.NotConnected,
connectionState:
overrideProps.connectionState ?? GroupCallConnectionState.NotConnected,
conversationsByDemuxId: new Map<number, ConversationType>(),
deviceCount: 0,
joinState: GroupCallJoinState.NotJoined,
joinState: overrideProps.joinState ?? GroupCallJoinState.NotJoined,
localDemuxId: 1,
maxDevices: 5,
groupMembers: [],
@ -156,7 +159,7 @@ const getActiveCallForCallLink = (
peekedParticipants:
overrideProps.peekedParticipants ?? allRemoteParticipants.slice(0, 3),
remoteParticipants: overrideProps.remoteParticipants ?? [],
pendingParticipants: [],
pendingParticipants: overrideProps.pendingParticipants ?? [],
raisedHands: new Set<number>(),
remoteAudioLevels: new Map<number, number>(),
};
@ -365,3 +368,110 @@ export function CallLinkLobbyParticipants3Unknown(): JSX.Element {
/>
);
}
export function CallLinkWithJoinRequestsOne(): JSX.Element {
return (
<CallManager
{...createProps({
activeCall: getActiveCallForCallLink({
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekedParticipants: [allRemoteParticipants[0]],
pendingParticipants: [allRemoteParticipants[1]],
showParticipantsList: false,
}),
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
})}
/>
);
}
export function CallLinkWithJoinRequestsTwo(): JSX.Element {
return (
<CallManager
{...createProps({
activeCall: getActiveCallForCallLink({
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekedParticipants: [allRemoteParticipants[0]],
pendingParticipants: allRemoteParticipants.slice(1, 3),
showParticipantsList: false,
}),
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
})}
/>
);
}
export function CallLinkWithJoinRequestsMany(): JSX.Element {
return (
<CallManager
{...createProps({
activeCall: getActiveCallForCallLink({
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekedParticipants: [allRemoteParticipants[0]],
pendingParticipants: allRemoteParticipants.slice(1, 11),
showParticipantsList: false,
}),
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
})}
/>
);
}
export function CallLinkWithJoinRequestsSystemContact(): JSX.Element {
return (
<CallManager
{...createProps({
activeCall: getActiveCallForCallLink({
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekedParticipants: [allRemoteParticipants[0]],
pendingParticipants: [
{ ...allRemoteParticipants[1], name: 'My System Contact Friend' },
],
showParticipantsList: false,
}),
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
})}
/>
);
}
export function CallLinkWithJoinRequestsSystemContactMany(): JSX.Element {
return (
<CallManager
{...createProps({
activeCall: getActiveCallForCallLink({
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekedParticipants: [allRemoteParticipants[0]],
pendingParticipants: [
{ ...allRemoteParticipants[1], name: 'My System Contact Friend' },
allRemoteParticipants[2],
allRemoteParticipants[3],
],
showParticipantsList: false,
}),
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
})}
/>
);
}
export function CallLinkWithJoinRequestsParticipantsOpen(): JSX.Element {
return (
<CallManager
{...createProps({
activeCall: getActiveCallForCallLink({
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekedParticipants: [allRemoteParticipants[0]],
pendingParticipants: allRemoteParticipants.slice(1, 4),
}),
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
})}
/>
);
}

View file

@ -28,6 +28,7 @@ import {
import type { ConversationType } from '../state/ducks/conversations';
import type {
AcceptCallType,
BatchUserActionPayloadType,
CancelCallType,
DeclineCallType,
GroupCallParticipantInfoType,
@ -101,6 +102,7 @@ export type PropsType = {
toggleParticipants: () => void;
acceptCall: (_: AcceptCallType) => void;
approveUser: (payload: PendingUserActionPayloadType) => void;
batchUserAction: (payload: BatchUserActionPayloadType) => void;
bounceAppIconStart: () => unknown;
bounceAppIconStop: () => unknown;
declineCall: (_: DeclineCallType) => void;
@ -164,6 +166,7 @@ function ActiveCallManager({
activeCall,
approveUser,
availableCameras,
batchUserAction,
blockClient,
callLink,
cancelCall,
@ -445,6 +448,7 @@ function ActiveCallManager({
<CallScreen
activeCall={activeCall}
approveUser={approveUser}
batchUserAction={batchUserAction}
changeCallView={changeCallView}
denyUser={denyUser}
getPresentingSources={getPresentingSources}
@ -519,6 +523,7 @@ export function CallManager({
activeCall,
approveUser,
availableCameras,
batchUserAction,
blockClient,
bounceAppIconStart,
bounceAppIconStop,
@ -618,6 +623,7 @@ export function CallManager({
activeCall={activeCall}
availableCameras={availableCameras}
approveUser={approveUser}
batchUserAction={batchUserAction}
blockClient={blockClient}
callLink={callLink}
cancelCall={cancelCall}

View file

@ -185,6 +185,7 @@ const createProps = (
): PropsType => ({
activeCall: createActiveCallProp(overrideProps),
approveUser: action('approve-user'),
batchUserAction: action('batch-user-action'),
changeCallView: action('change-call-view'),
denyUser: action('deny-user'),
getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource,

View file

@ -8,6 +8,7 @@ import classNames from 'classnames';
import type { VideoFrameSource } from '@signalapp/ringrtc';
import type {
ActiveCallStateType,
BatchUserActionPayloadType,
PendingUserActionPayloadType,
SendGroupCallRaiseHandType,
SendGroupCallReactionType,
@ -95,6 +96,7 @@ import type { CallingImageDataCache } from './CallManager';
export type PropsType = {
activeCall: ActiveCallType;
approveUser: (payload: PendingUserActionPayloadType) => void;
batchUserAction: (payload: BatchUserActionPayloadType) => void;
denyUser: (payload: PendingUserActionPayloadType) => void;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
getPresentingSources: () => void;
@ -186,6 +188,7 @@ function CallDuration({
export function CallScreen({
activeCall,
approveUser,
batchUserAction,
changeCallView,
denyUser,
getGroupCallVideoFrameSource,
@ -856,9 +859,9 @@ export function CallScreen({
{pendingParticipants.length ? (
<CallingPendingParticipants
i18n={i18n}
ourServiceId={me.serviceId}
participants={pendingParticipants}
approveUser={approveUser}
batchUserAction={batchUserAction}
denyUser={denyUser}
/>
) : null}

View file

@ -0,0 +1,91 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { PropsType } from './CallingPendingParticipants';
import { CallingPendingParticipants } from './CallingPendingParticipants';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
import { allRemoteParticipants } from './CallScreen.stories';
const i18n = setupI18n('en', enMessages);
const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
i18n,
participants: [allRemoteParticipants[0], allRemoteParticipants[1]],
approveUser: action('approve-user'),
batchUserAction: action('batch-user-action'),
denyUser: action('deny-user'),
...storyProps,
});
export default {
title: 'Components/CallingPendingParticipants',
argTypes: {},
args: {},
} satisfies Meta<PropsType>;
export function One(): JSX.Element {
return (
<CallingPendingParticipants
{...createProps({
participants: [allRemoteParticipants[0]],
})}
/>
);
}
export function Two(): JSX.Element {
return (
<CallingPendingParticipants
{...createProps({
participants: allRemoteParticipants.slice(0, 2),
})}
/>
);
}
export function Many(): JSX.Element {
return (
<CallingPendingParticipants
{...createProps({
participants: allRemoteParticipants.slice(0, 10),
})}
/>
);
}
export function ExpandedOne(): JSX.Element {
return (
<CallingPendingParticipants
{...createProps({
defaultIsExpanded: true,
participants: [allRemoteParticipants[0]],
})}
/>
);
}
export function ExpandedTwo(): JSX.Element {
return (
<CallingPendingParticipants
{...createProps({
defaultIsExpanded: true,
participants: allRemoteParticipants.slice(0, 2),
})}
/>
);
}
export function ExpandedMany(): JSX.Element {
return (
<CallingPendingParticipants
{...createProps({
defaultIsExpanded: true,
participants: allRemoteParticipants.slice(0, 10),
})}
/>
);
}

View file

@ -3,99 +3,350 @@
/* eslint-disable react/no-array-index-key */
import React from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { noop } from 'lodash';
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 type {
BatchUserActionPayloadType,
PendingUserActionPayloadType,
} from '../state/ducks/calling';
import { Button, ButtonVariant } from './Button';
import type { ServiceIdString } from '../types/ServiceId';
import { handleOutsideClick } from '../util/handleOutsideClick';
import { Theme } from '../util/theme';
import { ConfirmationDialog } from './ConfirmationDialog';
enum ConfirmDialogState {
None = 'None',
ApproveAll = 'ApproveAll',
DenyAll = 'DenyAll',
}
export type PropsType = {
readonly i18n: LocalizerType;
readonly ourServiceId: ServiceIdString | undefined;
readonly participants: Array<ConversationType>;
// For storybook
readonly defaultIsExpanded?: boolean;
readonly approveUser: (payload: PendingUserActionPayloadType) => void;
readonly batchUserAction: (payload: BatchUserActionPayloadType) => void;
readonly denyUser: (payload: PendingUserActionPayloadType) => void;
};
export function CallingPendingParticipants({
defaultIsExpanded,
i18n,
ourServiceId,
participants,
approveUser,
batchUserAction,
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,
})}
const [isExpanded, setIsExpanded] = useState(defaultIsExpanded ?? false);
const [confirmDialogState, setConfirmDialogState] =
React.useState<ConfirmDialogState>(ConfirmDialogState.None);
const [serviceIdsStagedForAction, setServiceIdsStagedForAction] =
React.useState<Array<ServiceIdString>>([]);
const expandedListRef = useRef<HTMLDivElement>(null);
const handleHideAllRequests = useCallback(() => {
setIsExpanded(false);
}, [setIsExpanded]);
// When opening the "Approve all" confirm dialog, save the current list of participants
// to ensure we only approve users who the admin has checked. If additional people
// request to join while the dialog is open, we don't auto approve those.
const stageServiceIdsForAction = useCallback(() => {
const serviceIds: Array<ServiceIdString> = [];
participants.forEach(participant => {
if (participant.serviceId) {
serviceIds.push(participant.serviceId);
}
});
setServiceIdsStagedForAction(serviceIds);
}, [participants, setServiceIdsStagedForAction]);
const hideConfirmDialog = useCallback(() => {
setConfirmDialogState(ConfirmDialogState.None);
setServiceIdsStagedForAction([]);
}, [setConfirmDialogState]);
const handleApprove = useCallback(
(participant: ConversationType) => {
const { serviceId } = participant;
if (!serviceId) {
return;
}
approveUser({ serviceId });
},
[approveUser]
);
const handleDeny = useCallback(
(participant: ConversationType) => {
const { serviceId } = participant;
if (!serviceId) {
return;
}
denyUser({ serviceId });
},
[denyUser]
);
const handleApproveAll = useCallback(() => {
batchUserAction({
action: 'approve',
serviceIds: serviceIdsStagedForAction,
});
hideConfirmDialog();
}, [serviceIdsStagedForAction, batchUserAction, hideConfirmDialog]);
const handleDenyAll = useCallback(() => {
batchUserAction({
action: 'deny',
serviceIds: serviceIdsStagedForAction,
});
hideConfirmDialog();
}, [serviceIdsStagedForAction, batchUserAction, hideConfirmDialog]);
const renderApprovalButtons = useCallback(
(participant: ConversationType) => {
if (participant.serviceId == null) {
return null;
}
return (
<>
<Button
aria-label={i18n('icu:CallingPendingParticipants__DenyUser')}
className="CallingPendingParticipants__PendingActionButton CallingButton__icon"
onClick={() => handleDeny(participant)}
variant={ButtonVariant.Destructive}
>
<span className="CallingPendingParticipants__PendingActionButtonIcon CallingPendingParticipants__PendingActionButtonIcon--Deny" />
</Button>
<Button
aria-label={i18n('icu:CallingPendingParticipants__ApproveUser')}
className="CallingPendingParticipants__PendingActionButton CallingButton__icon"
onClick={() => handleApprove(participant)}
variant={ButtonVariant.Calling}
>
<span className="CallingPendingParticipants__PendingActionButtonIcon CallingPendingParticipants__PendingActionButtonIcon--Approve" />
</Button>
</>
);
},
[i18n, handleApprove, handleDeny]
);
useEffect(() => {
if (!isExpanded) {
return noop;
}
return handleOutsideClick(
() => {
handleHideAllRequests();
return true;
},
{
containerElements: [expandedListRef],
name: 'CallingPendingParticipantsList.expandedList',
}
);
}, [isExpanded, handleHideAllRequests]);
if (confirmDialogState === ConfirmDialogState.ApproveAll) {
return (
<ConfirmationDialog
dialogName="CallingPendingParticipants.confirmDialog"
actions={[
{
action: handleApproveAll,
style: 'affirmative',
text: i18n('icu:CallingPendingParticipants__ApproveAll'),
},
]}
cancelText={i18n('icu:cancel')}
i18n={i18n}
theme={Theme.Dark}
title={i18n(
'icu:CallingPendingParticipants__ConfirmDialogTitle--ApproveAll',
{ count: serviceIdsStagedForAction.length }
)}
onClose={hideConfirmDialog}
>
{i18n('icu:CallingPendingParticipants__ConfirmDialogBody--ApproveAll', {
count: serviceIdsStagedForAction.length,
})}
</ConfirmationDialog>
);
}
if (confirmDialogState === ConfirmDialogState.DenyAll) {
return (
<ConfirmationDialog
dialogName="CallingPendingParticipants.confirmDialog"
actions={[
{
action: handleDenyAll,
style: 'affirmative',
text: i18n('icu:CallingPendingParticipants__DenyAll'),
},
]}
cancelText={i18n('icu:cancel')}
i18n={i18n}
theme={Theme.Dark}
title={i18n(
'icu:CallingPendingParticipants__ConfirmDialogTitle--DenyAll',
{ count: serviceIdsStagedForAction.length }
)}
onClose={hideConfirmDialog}
>
{i18n('icu:CallingPendingParticipants__ConfirmDialogBody--DenyAll', {
count: serviceIdsStagedForAction.length,
})}
</ConfirmationDialog>
);
}
if (isExpanded) {
return (
<div
className="CallingPendingParticipants CallingPendingParticipants--Expanded module-calling-participants-list"
ref={expandedListRef}
>
<div className="module-calling-participants-list__header">
<div className="module-calling-participants-list__title">
{i18n('icu:CallingPendingParticipants__RequestsToJoin', {
count: participants.length,
})}
</div>
<button
type="button"
className="module-calling-participants-list__close"
onClick={handleHideAllRequests}
tabIndex={0}
aria-label={i18n('icu:close')}
/>
</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_SIX}
/>
<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>
{renderApprovalButtons(participant)}
</li>
))}
</ul>
<div className="CallingPendingParticipants__ActionPanel">
<Button
className="CallingPendingParticipants__ActionPanelButton CallingPendingParticipants__ActionPanelButton--DenyAll"
variant={ButtonVariant.Destructive}
onClick={() => {
stageServiceIdsForAction();
setConfirmDialogState(ConfirmDialogState.DenyAll);
}}
>
{i18n('icu:CallingPendingParticipants__DenyAll')}
</Button>
<Button
className="CallingPendingParticipants__ActionPanelButton CallingPendingParticipants__ActionPanelButton--ApproveAll"
variant={ButtonVariant.Calling}
onClick={() => {
stageServiceIdsForAction();
setConfirmDialogState(ConfirmDialogState.ApproveAll);
}}
>
{i18n('icu:CallingPendingParticipants__ApproveAll')}
</Button>
</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}
</>
)}
);
}
const participant = participants[0];
return (
<div className="CallingPendingParticipants CallingPendingParticipants--Compact module-calling-participants-list">
<div className="CallingPendingParticipants__CompactParticipant">
<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.FORTY_EIGHT}
/>
<div className="CallingPendingParticipants__CompactParticipantNameColumn">
<div className="CallingPendingParticipants__ParticipantName">
<ContactName title={participant.title} />
{isInSystemContacts(participant) ? (
<InContactsIcon
className="module-calling-participants-list__contact-icon"
i18n={i18n}
/>
) : 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 className="CallingPendingParticipants__WouldLikeToJoin">
{i18n('icu:CallingPendingParticipants__WouldLikeToJoin')}
</div>
</div>
</div>
{renderApprovalButtons(participant)}
</div>
{participants.length > 1 && (
<div className="CallingPendingParticipants__ShowAllRequestsButtonContainer">
<button
className="CallingPendingParticipants__ShowAllRequestsButton"
onClick={() => setIsExpanded(true)}
type="button"
>
{i18n('icu:CallingPendingParticipants__AdditionalRequests', {
count: participants.length - 1,
})}
</button>
</div>
)}
</div>
);
}

View file

@ -21,6 +21,8 @@ function getToast(toastType: ToastType): AnyToast {
switch (toastType) {
case ToastType.AddingUserToGroup:
return { toastType, parameters: { contact: 'Sam Mirete' } };
case ToastType.AddedUsersToCall:
return { toastType, parameters: { count: 6 } };
case ToastType.AlreadyGroupMember:
return { toastType: ToastType.AlreadyGroupMember };
case ToastType.AlreadyRequestedToJoin:

View file

@ -59,6 +59,16 @@ export function renderToast({
);
}
if (toastType === ToastType.AddedUsersToCall) {
return (
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
{i18n('icu:CallingPendingParticipants__Toast--added-users-to-call', {
count: toast.parameters.count,
})}
</Toast>
);
}
if (toastType === ToastType.AlreadyGroupMember) {
return (
<Toast onClose={hideToast}>

View file

@ -782,6 +782,11 @@ export type PendingUserActionPayloadType = ReadonlyDeep<{
serviceId: ServiceIdString | undefined;
}>;
export type BatchUserActionPayloadType = ReadonlyDeep<{
action: 'approve' | 'deny';
serviceIds: Array<ServiceIdString>;
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
type RefreshIODevicesActionType = {
type: 'calling/REFRESH_IO_DEVICES';
@ -1000,6 +1005,54 @@ function denyUser(
dispatch({ type: DENY_USER });
};
}
function batchUserAction(
payload: BatchUserActionPayloadType
): ThunkAction<void, RootStateType, unknown, ShowToastActionType> {
return (dispatch, getState) => {
const activeCall = getActiveCall(getState().calling);
if (!activeCall || !isGroupOrAdhocCallMode(activeCall.callMode)) {
log.warn(
'batchUserAction: Trying to do pending user without active group or adhoc call'
);
return;
}
const { action, serviceIds } = payload;
let actionFn;
if (action === 'approve') {
actionFn = calling.approveUser;
} else if (action === 'deny') {
actionFn = calling.denyUser;
} else {
throw missingCaseError(action);
}
let count = 0;
for (const serviceId of serviceIds) {
if (!isAciString(serviceId)) {
log.warn(
'batchUserAction: Trying to do user action without valid aci serviceid'
);
continue;
}
actionFn.call(calling, activeCall.conversationId, serviceId);
count += 1;
}
if (count > 0 && action === 'approve') {
dispatch({
type: SHOW_TOAST,
payload: {
toastType: ToastType.AddedUsersToCall,
parameters: { count },
},
});
}
};
}
function removeClient(
payload: RemoveClientType
): ThunkAction<void, RootStateType, unknown, RemoveClientActionType> {
@ -2296,6 +2349,7 @@ function switchFromPresentationView(): SwitchFromPresentationViewActionType {
export const actions = {
acceptCall,
approveUser,
batchUserAction,
blockClient,
callStateChange,
cancelCall,

View file

@ -426,6 +426,7 @@ export const SmartCallManager = memo(function SmartCallManager() {
const {
approveUser,
batchUserAction,
denyUser,
changeCallView,
closeNeedPermissionScreen,
@ -465,6 +466,7 @@ export const SmartCallManager = memo(function SmartCallManager() {
activeCall={activeCall}
approveUser={approveUser}
availableCameras={availableCameras}
batchUserAction={batchUserAction}
blockClient={blockClient}
bounceAppIconStart={bounceAppIconStart}
bounceAppIconStop={bounceAppIconStop}

View file

@ -3,6 +3,7 @@
export enum ToastType {
AddingUserToGroup = 'AddingUserToGroup',
AddedUsersToCall = 'AddedUsersToCall',
AlreadyGroupMember = 'AlreadyGroupMember',
AlreadyRequestedToJoin = 'AlreadyRequestedToJoin',
Blocked = 'Blocked',
@ -69,6 +70,10 @@ export enum ToastType {
export type AnyToast =
| { toastType: ToastType.AddingUserToGroup; parameters: { contact: string } }
| {
toastType: ToastType.AddedUsersToCall;
parameters: { count: number };
}
| { toastType: ToastType.AlreadyGroupMember }
| { toastType: ToastType.AlreadyRequestedToJoin }
| { toastType: ToastType.Blocked }

View file

@ -3250,6 +3250,14 @@
"reasonCategory": "usageTrusted",
"updated": "2021-07-30T16:57:33.618Z"
},
{
"rule": "React-useRef",
"path": "ts/components/CallingPendingParticipants.tsx",
"line": " const expandedListRef = useRef<HTMLDivElement>(null);",
"reasonCategory": "usageTrusted",
"updated": "2024-06-28T01:22:22.509Z",
"reasonDetail": "For outside click handling"
},
{
"rule": "React-useRef",
"path": "ts/components/CallingPip.tsx",