diff --git a/_locales/en/messages.json b/_locales/en/messages.json index a231cac732..48635ffb57 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/images/icons/v3/check/check-bold.svg b/images/icons/v3/check/check-bold.svg new file mode 100644 index 0000000000..dfe078b810 --- /dev/null +++ b/images/icons/v3/check/check-bold.svg @@ -0,0 +1,3 @@ + + + diff --git a/images/icons/v3/x/x-bold.svg b/images/icons/v3/x/x-bold.svg new file mode 100644 index 0000000000..0b8fc79f83 --- /dev/null +++ b/images/icons/v3/x/x-bold.svg @@ -0,0 +1,3 @@ + + + diff --git a/stylesheets/components/CallingPendingParticipants.scss b/stylesheets/components/CallingPendingParticipants.scss index 1be772ef11..91631d0d6e 100644 --- a/stylesheets/components/CallingPendingParticipants.scss +++ b/stylesheets/components/CallingPendingParticipants.scss @@ -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; } diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index bad0537359..a89d398c54 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -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 => ({ 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(), 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(), remoteAudioLevels: new Map(), }; @@ -365,3 +368,110 @@ export function CallLinkLobbyParticipants3Unknown(): JSX.Element { /> ); } + +export function CallLinkWithJoinRequestsOne(): JSX.Element { + return ( + + ); +} + +export function CallLinkWithJoinRequestsTwo(): JSX.Element { + return ( + + ); +} + +export function CallLinkWithJoinRequestsMany(): JSX.Element { + return ( + + ); +} + +export function CallLinkWithJoinRequestsSystemContact(): JSX.Element { + return ( + + ); +} + +export function CallLinkWithJoinRequestsSystemContactMany(): JSX.Element { + return ( + + ); +} + +export function CallLinkWithJoinRequestsParticipantsOpen(): JSX.Element { + return ( + + ); +} diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index f156d01734..b20a78d142 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -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({ ({ activeCall: createActiveCallProp(overrideProps), approveUser: action('approve-user'), + batchUserAction: action('batch-user-action'), changeCallView: action('change-call-view'), denyUser: action('deny-user'), getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource, diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index b1f0c5813a..850a141376 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -8,6 +8,7 @@ import classNames from 'classnames'; import type { VideoFrameSource } from '@signalapp/ringrtc'; import type { ActiveCallStateType, + 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 ? ( ) : null} diff --git a/ts/components/CallingPendingParticipants.stories.tsx b/ts/components/CallingPendingParticipants.stories.tsx new file mode 100644 index 0000000000..3d66463284 --- /dev/null +++ b/ts/components/CallingPendingParticipants.stories.tsx @@ -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 => ({ + 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; + +export function One(): JSX.Element { + return ( + + ); +} + +export function Two(): JSX.Element { + return ( + + ); +} + +export function Many(): JSX.Element { + return ( + + ); +} + +export function ExpandedOne(): JSX.Element { + return ( + + ); +} + +export function ExpandedTwo(): JSX.Element { + return ( + + ); +} + +export function ExpandedMany(): JSX.Element { + return ( + + ); +} diff --git a/ts/components/CallingPendingParticipants.tsx b/ts/components/CallingPendingParticipants.tsx index bc54826994..db93bab4af 100644 --- a/ts/components/CallingPendingParticipants.tsx +++ b/ts/components/CallingPendingParticipants.tsx @@ -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; + // 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 ( -
-
-
- {i18n('icu:CallingPendingParticipants__RequestsToJoin', { - count: participants.length, - })} + const [isExpanded, setIsExpanded] = useState(defaultIsExpanded ?? false); + const [confirmDialogState, setConfirmDialogState] = + React.useState(ConfirmDialogState.None); + const [serviceIdsStagedForAction, setServiceIdsStagedForAction] = + React.useState>([]); + + const expandedListRef = useRef(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 = []; + 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 ( + <> + + + + ); + }, + [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 ( + + {i18n('icu:CallingPendingParticipants__ConfirmDialogBody--ApproveAll', { + count: serviceIdsStagedForAction.length, + })} + + ); + } + + if (confirmDialogState === ConfirmDialogState.DenyAll) { + return ( + + {i18n('icu:CallingPendingParticipants__ConfirmDialogBody--DenyAll', { + count: serviceIdsStagedForAction.length, + })} + + ); + } + + if (isExpanded) { + return ( +
+
+
+ {i18n('icu:CallingPendingParticipants__RequestsToJoin', { + count: participants.length, + })} +
+
+
    + {participants.map((participant: ConversationType, index: number) => ( +
  • +
    + + + {isInSystemContacts(participant) ? ( + + {' '} + + + ) : null} +
    + {renderApprovalButtons(participant)} +
  • + ))} +
+
+ +
-
    - {participants.map((participant: ConversationType, index: number) => ( -
  • -
    - - {ourServiceId && participant.serviceId === ourServiceId ? ( - - {i18n('icu:you')} - - ) : ( - <> - - {isInSystemContacts(participant) ? ( - - {' '} - - - ) : null} - - )} + ); + } + + const participant = participants[0]; + return ( +
    +
    +
    + +
    +
    + + {isInSystemContacts(participant) ? ( + + ) : null}
    - - -
  • - ))} -
+
+ {i18n('icu:CallingPendingParticipants__WouldLikeToJoin')} +
+
+
+ {renderApprovalButtons(participant)} +
+ {participants.length > 1 && ( +
+ +
+ )} ); } diff --git a/ts/components/ToastManager.stories.tsx b/ts/components/ToastManager.stories.tsx index 87910da565..2a73495d91 100644 --- a/ts/components/ToastManager.stories.tsx +++ b/ts/components/ToastManager.stories.tsx @@ -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: diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx index cc28558da7..6df79fe6b5 100644 --- a/ts/components/ToastManager.tsx +++ b/ts/components/ToastManager.tsx @@ -59,6 +59,16 @@ export function renderToast({ ); } + if (toastType === ToastType.AddedUsersToCall) { + return ( + + {i18n('icu:CallingPendingParticipants__Toast--added-users-to-call', { + count: toast.parameters.count, + })} + + ); + } + if (toastType === ToastType.AlreadyGroupMember) { return ( diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 677c6cc38b..97d4b4f281 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -782,6 +782,11 @@ export type PendingUserActionPayloadType = ReadonlyDeep<{ serviceId: ServiceIdString | undefined; }>; +export type BatchUserActionPayloadType = ReadonlyDeep<{ + action: 'approve' | 'deny'; + serviceIds: Array; +}>; + // 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 { + 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 { @@ -2296,6 +2349,7 @@ function switchFromPresentationView(): SwitchFromPresentationViewActionType { export const actions = { acceptCall, approveUser, + batchUserAction, blockClient, callStateChange, cancelCall, diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index 3897486ea4..5a8bcfe47e 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -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} diff --git a/ts/types/Toast.tsx b/ts/types/Toast.tsx index 687f09dcca..d74f5849c7 100644 --- a/ts/types/Toast.tsx +++ b/ts/types/Toast.tsx @@ -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 } diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 290f166e24..9bce03396f 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -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(null);", + "reasonCategory": "usageTrusted", + "updated": "2024-06-28T01:22:22.509Z", + "reasonDetail": "For outside click handling" + }, { "rule": "React-useRef", "path": "ts/components/CallingPip.tsx",