diff --git a/_locales/en/messages.json b/_locales/en/messages.json index c53dd76a3871..761ea56da7d3 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1652,6 +1652,10 @@ "messageformat": "Microphone muted due to the size of the call", "description": "Shown in a call lobby toast if there are a lot of people already on the call" }, + "icu:calling__toasts--aria-label": { + "messageformat": "Call notifications", + "description": "Aria label for region of toasts shown during a call (for, e.g. reconnecting, person joined, audio device changed notifications)" + }, "icu:calling__call-is-full": { "messageformat": "Call is full", "description": "Text in the call lobby when you can't join because the call is full" diff --git a/stylesheets/components/CallingToast.scss b/stylesheets/components/CallingToast.scss index 8ad11673f2f8..af8988238b3f 100644 --- a/stylesheets/components/CallingToast.scss +++ b/stylesheets/components/CallingToast.scss @@ -1,25 +1,39 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -.CallingToast { - @include button-reset(); - @include font-body-1-bold; - background-color: $color-gray-75; - border-radius: 8px; - color: $color-white; - max-width: 80%; - opacity: 1; - padding: 12px; - position: absolute; - text-align: center; - top: 12px; - transform: translateY(0); - transition: transform 200ms ease-out, opacity 200ms ease-out; - user-select: none; - z-index: $z-index-above-above-base; +.CallingToasts { + position: fixed; + z-index: $z-index-toast; + top: 32px; + width: 100%; + display: flex; + justify-content: center; +} - &--hidden { - opacity: 0; - transform: translateY(-7px); +.CallingToasts__inner { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 8px; +} + +.CallingToast--dismissable { + @include button-reset(); +} + +.CallingToast { + @include font-body-1; + background-color: $color-gray-75; + border-radius: 22px; + color: $color-white; + padding-block: 11px; + padding-inline: 20px; + text-align: center; + user-select: none; + &__reconnecting { + display: flex; + align-items: center; + gap: 8px; } } diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 543141c81c0c..ac746fae6fe6 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -41,6 +41,7 @@ import type { } from '../state/ducks/calling'; import type { LocalizerType, ThemeType } from '../types/Util'; import { missingCaseError } from '../util/missingCaseError'; +import { CallingToastProvider } from './CallingToast'; const GROUP_CALL_RING_DURATION = 60 * 1000; @@ -439,7 +440,11 @@ export function CallManager(props: PropsType): JSX.Element | null { if (activeCall) { // `props` should logically have an `activeCall` at this point, but TypeScript can't // figure that out, so we pass it in again. - return ; + return ( + + + + ); } // In the future, we may want to show the incoming call bar when a call is active. diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx index 06e699243d07..d96e81f6c647 100644 --- a/ts/components/CallScreen.stories.tsx +++ b/ts/components/CallScreen.stories.tsx @@ -18,7 +18,7 @@ import { generateAci } from '../types/ServiceId'; import type { ConversationType } from '../state/ducks/conversations'; import { AvatarColors } from '../types/Colors'; import type { PropsType } from './CallScreen'; -import { CallScreen } from './CallScreen'; +import { CallScreen as UnwrappedCallScreen } from './CallScreen'; import { setupI18n } from '../util/setupI18n'; import { missingCaseError } from '../util/missingCaseError'; import { @@ -27,6 +27,7 @@ import { } from '../test-both/helpers/getDefaultConversation'; import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource'; import enMessages from '../../_locales/en/messages.json'; +import { CallingToastProvider, useCallingToasts } from './CallingToast'; const MAX_PARTICIPANTS = 64; @@ -171,6 +172,14 @@ const createProps = ( toggleSpeakerView: action('toggle-speaker-view'), }); +function CallScreen(props: ReturnType): JSX.Element { + return ( + + + + ); +} + export default { title: 'Components/CallScreen', argTypes: {}, @@ -382,3 +391,59 @@ export function GroupCallSomeoneIsSharingScreenAndYoureReconnecting(): JSX.Eleme /> ); } + +export function GroupCallSomeoneStoppedSharingScreen(): JSX.Element { + const [remoteParticipants, setRemoteParticipants] = React.useState( + allRemoteParticipants.slice(0, 5).map((participant, index) => ({ + ...participant, + presenting: index === 1, + sharingScreen: index === 1, + })) + ); + + React.useEffect(() => { + setTimeout( + () => setRemoteParticipants(allRemoteParticipants.slice(0, 5)), + 1000 + ); + }); + + return ( + + ); +} + +function ToastEmitter(): null { + const { showToast } = useCallingToasts(); + const toastCount = React.useRef(0); + React.useEffect(() => { + const interval = setInterval(() => { + const autoClose = toastCount.current % 2 === 0; + showToast({ + key: Date.now().toString(), + content: `${ + autoClose ? 'Disappearing' : 'Non-disappearing' + } toast sent: ${Date.now()}`, + dismissable: true, + autoClose, + }); + toastCount.current += 1; + }, 1500); + return () => clearInterval(interval); + }, [showToast]); + return null; +} + +export function CallScreenToastAPalooza(): JSX.Element { + return ( + + + + + ); +} diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index 8a842630a3c6..6c5be061704b 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -32,7 +32,10 @@ import { } from '../types/Calling'; import { AvatarColors } from '../types/Colors'; import type { ConversationType } from '../state/ducks/conversations'; -import { CallingToastManager } from './CallingToastManager'; +import { + useReconnectingToast, + useScreenSharingStoppedToast, +} from './CallingToastManager'; import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant'; import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants'; import type { LocalizerType } from '../types/Util'; @@ -257,6 +260,9 @@ export function CallScreen({ }; }, [toggleAudio, toggleVideo]); + useReconnectingToast({ activeCall, i18n }); + useScreenSharingStoppedToast({ activeCall, i18n }); + const currentPresenter = remoteParticipants.find( participant => participant.presenting ); @@ -476,7 +482,6 @@ export function CallScreen({ openSystemPreferencesAction={openSystemPreferencesAction} /> ) : null} -
diff --git a/ts/components/CallingLobby.stories.tsx b/ts/components/CallingLobby.stories.tsx index adff3383014d..e8e70d25e709 100644 --- a/ts/components/CallingLobby.stories.tsx +++ b/ts/components/CallingLobby.stories.tsx @@ -10,7 +10,7 @@ import type { Meta } from '@storybook/react'; import { AvatarColors } from '../types/Colors'; import type { ConversationType } from '../state/ducks/conversations'; import type { PropsType } from './CallingLobby'; -import { CallingLobby } from './CallingLobby'; +import { CallingLobby as UnwrappedCallingLobby } from './CallingLobby'; import { setupI18n } from '../util/setupI18n'; import { generateAci } from '../types/ServiceId'; import enMessages from '../../_locales/en/messages.json'; @@ -18,6 +18,7 @@ import { getDefaultConversation, getDefaultConversationWithServiceId, } from '../test-both/helpers/getDefaultConversation'; +import { CallingToastProvider } from './CallingToast'; const i18n = setupI18n('en', enMessages); @@ -74,6 +75,14 @@ const createProps = (overrideProps: Partial = {}): PropsType => { }; }; +function CallingLobby(props: ReturnType) { + return ( + + + + ); +} + const fakePeekedParticipant = (conversationProps: Partial) => getDefaultConversationWithServiceId({ ...conversationProps, diff --git a/ts/components/CallingLobby.tsx b/ts/components/CallingLobby.tsx index 48431b8639d4..5d911ef90ce7 100644 --- a/ts/components/CallingLobby.tsx +++ b/ts/components/CallingLobby.tsx @@ -13,7 +13,6 @@ import { CallingButton, CallingButtonType } from './CallingButton'; import { TooltipPlacement } from './Tooltip'; import { CallBackgroundBlur } from './CallBackgroundBlur'; import { CallingHeader } from './CallingHeader'; -import { CallingToast, DEFAULT_LIFETIME } from './CallingToast'; import { CallingPreCallInfo, RingMode } from './CallingPreCallInfo'; import { CallingLobbyJoinButton, @@ -23,6 +22,7 @@ import type { LocalizerType } from '../types/Util'; import { useIsOnline } from '../hooks/useIsOnline'; import * as KeyboardLayout from '../services/keyboardLayout'; import type { ConversationType } from '../state/ducks/conversations'; +import { useCallingToasts } from './CallingToast'; export type PropsType = { availableCameras: Array; @@ -89,21 +89,6 @@ export function CallingLobby({ toggleSettings, outgoingRing, }: PropsType): JSX.Element { - const [isMutedToastVisible, setIsMutedToastVisible] = React.useState( - !hasLocalAudio - ); - React.useEffect(() => { - if (!isMutedToastVisible) { - return; - } - const timeout = setTimeout(() => { - setIsMutedToastVisible(false); - }, DEFAULT_LIFETIME); - return () => { - clearTimeout(timeout); - }; - }, [isMutedToastVisible]); - const localVideoRef = React.useRef(null); const shouldShowLocalVideo = hasLocalVideo && availableCameras.length > 0; @@ -158,6 +143,8 @@ export function CallingLobby({ const [isCallConnecting, setIsCallConnecting] = React.useState(false); + useWasInitiallyMutedToast(hasLocalAudio, i18n); + // eslint-disable-next-line no-nested-ternary const videoButtonType = hasLocalVideo ? CallingButtonType.VIDEO_ON @@ -231,15 +218,6 @@ export function CallingLobby({ /> )} - setIsMutedToastVisible(false)} - > - {i18n( - 'icu:calling__lobby-automatically-muted-because-there-are-a-lot-of-people' - )} - - ); } + +function useWasInitiallyMutedToast( + hasLocalAudio: boolean, + i18n: LocalizerType +) { + const [wasInitiallyMuted] = React.useState(!hasLocalAudio); + const { showToast, hideToast } = useCallingToasts(); + const INITIALLY_MUTED_KEY = 'initially-muted-group-size'; + React.useEffect(() => { + if (wasInitiallyMuted) { + showToast({ + key: INITIALLY_MUTED_KEY, + content: i18n( + 'icu:calling__lobby-automatically-muted-because-there-are-a-lot-of-people' + ), + autoClose: true, + dismissable: true, + onlyShowOnce: true, + }); + } + }, [wasInitiallyMuted, i18n, showToast]); + + // Hide this toast if the user unmutes + React.useEffect(() => { + if (wasInitiallyMuted && hasLocalAudio) { + hideToast(INITIALLY_MUTED_KEY); + } + }, [hideToast, wasInitiallyMuted, hasLocalAudio]); +} diff --git a/ts/components/CallingToast.tsx b/ts/components/CallingToast.tsx index 64ad1bc6f53c..334438e12624 100644 --- a/ts/components/CallingToast.tsx +++ b/ts/components/CallingToast.tsx @@ -1,32 +1,324 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from 'react'; +import { useFocusWithin, useHover, mergeProps } from 'react-aria'; +import { createPortal } from 'react-dom'; +import { useTransition, animated } from '@react-spring/web'; import classNames from 'classnames'; +import { v4 as uuid } from 'uuid'; +import { useIsMounted } from '../hooks/useIsMounted'; +import type { LocalizerType } from '../types/I18N'; -type PropsType = { - isVisible: boolean; - onClick: () => unknown; - children?: JSX.Element | string; +const DEFAULT_LIFETIME = 5000; + +export type CallingToastType = { + // If key is provided, calls to showToast will be idempotent; otherwise an + // auto-generated key will be returned + key?: string; + content: JSX.Element | string; + autoClose: boolean; + dismissable?: boolean; +} & ( + | { + // key must be provided if the toast is 'only-show-once' + key: string; + onlyShowOnce: true; + } + | { + onlyShowOnce?: never; + } +); + +type CallingToastStateType = CallingToastType & { + key: string; }; -export const DEFAULT_LIFETIME = 5000; +type CallingToastContextType = { + showToast: (toast: CallingToastType) => string; + hideToast: (id: string) => void; +}; -export function CallingToast({ - isVisible, - onClick, +type TimeoutType = + | { status: 'active'; timeout: NodeJS.Timeout; endAt: number } + | { status: 'paused'; remaining: number }; + +const CallingToastContext = createContext(null); + +export function CallingToastProvider({ + i18n, children, -}: PropsType): JSX.Element { +}: { + i18n: LocalizerType; + children: React.ReactNode; +}): JSX.Element { + const [toasts, setToasts] = React.useState>([]); + const timeouts = React.useRef>(new Map()); + // All toasts are paused on hover or focus so that toasts don't disappear while a user + // is attempting to interact with them + const timeoutsStatus = React.useRef<'active' | 'paused'>('active'); + const shownToasts = React.useRef>(new Set()); + const isMounted = useIsMounted(); + + const hideToast = useCallback( + (key: string) => { + if (!isMounted()) { + return; + } + + const timeout = timeouts.current.get(key); + if (timeout?.status === 'active') { + clearTimeout(timeout.timeout); + } + timeouts.current.delete(key); + + setToasts(state => { + const existingIndex = state.findIndex(toast => toast.key === key); + if (existingIndex === -1) { + // Important to return the same state object here to avoid infinite recursion if + // hideToast is in a useEffect dependency array + return state; + } + return [ + ...state.slice(0, existingIndex), + ...state.slice(existingIndex + 1), + ]; + }); + }, + [isMounted] + ); + + const startTimer = useCallback( + (key: string, duration: number) => { + if (timeoutsStatus.current === 'paused') { + timeouts.current.set(key, { status: 'paused', remaining: duration }); + } else { + timeouts.current.set(key, { + timeout: setTimeout(() => hideToast(key), duration), + status: 'active', + endAt: Date.now() + duration, + }); + } + }, + [hideToast] + ); + + const showToast = useCallback( + (toast: CallingToastType): string => { + if (toast.onlyShowOnce && shownToasts.current.has(toast.key)) { + return toast.key; + } + + const key = toast.key ?? uuid(); + + setToasts(state => { + const isCurrentlyBeingShown = state.some( + existingToast => toast.key === existingToast.key + ); + + if (isCurrentlyBeingShown) { + return state; + } + + if (toast.autoClose) { + startTimer(key, DEFAULT_LIFETIME); + } + shownToasts.current.add(key); + + return [{ ...toast, key }, ...state]; + }); + + return key; + }, + [startTimer] + ); + + const pauseAll = useCallback(() => { + const now = Date.now(); + timeoutsStatus.current = 'paused'; + + for (const [key, timeout] of [...timeouts.current.entries()]) { + if (!timeout || timeout.status !== 'active') { + return; + } + clearTimeout(timeout.timeout); + + timeouts.current.set(key, { + status: 'paused', + remaining: timeout.endAt - now, + }); + } + }, []); + + const resumeAll = useCallback(() => { + timeoutsStatus.current = 'active'; + + for (const [key, timeout] of [...timeouts.current.entries()]) { + if (!timeout || timeout.status !== 'paused') { + return; + } + + startTimer(key, timeout.remaining); + } + }, [startTimer]); + + const { hoverProps } = useHover({ + onHoverStart: () => pauseAll(), + onHoverEnd: () => resumeAll(), + }); + const { focusWithinProps } = useFocusWithin({ + onFocusWithin: () => pauseAll(), + onBlurWithin: () => resumeAll(), + }); + + const TOAST_HEIGHT_PX = 42; + const TOAST_GAP_PX = 8; + const transitions = useTransition(toasts, { + from: { opacity: 0, marginTop: `${-1 * TOAST_HEIGHT_PX}px` }, + enter: { + opacity: 1, + zIndex: 1, + marginTop: '0px', + config: (key: string) => { + if (key === 'marginTop') { + return { + velocity: 0.005, + friction: 30, + }; + } + return {}; + }, + }, + leave: (_item, index) => ({ + zIndex: 0, + opacity: 0, + marginTop: + // If the last toast in the list is leaving, we don't need to move it up. Its + // index is toasts.length instead of toasts.length - 1 since it has already been + // removed from state + index === toasts.length + ? 0 + : `${-1 * (TOAST_HEIGHT_PX + TOAST_GAP_PX)}px`, + config: (key: string) => { + if (key === 'zIndex') { + return { duration: 0 }; + } + if (key === 'opacity') { + return { duration: 100 }; + } + return { + duration: 300, + }; + }, + }), + }); + + const contextValue = useMemo(() => { + return { + showToast, + hideToast, + }; + }, [showToast, hideToast]); + return ( - + + ); +} + +function CallingToast( + props: CallingToastType & { + onClick?: VoidFunction; + } +): JSX.Element { + const className = classNames( + 'CallingToast', + props.dismissable && 'CallingToast--dismissable' + ); + + const elementHtmlProps = { + role: 'alert', + ariaLive: props.autoClose ? 'assertive' : 'polite', + }; + + if (props.dismissable) { + return ( +
+ +
+ ); + } + return ( +
+ {props.content} +
+ ); +} + +// Preferred way of showing/hiding toasts: useCallingToasts is a helper function which +// will hide all toasts shown when the component from which you are using it unmounts +export function useCallingToasts(): CallingToastContextType { + const callingToastContext = useContext(CallingToastContext); + + if (!callingToastContext) { + throw new Error('Calling Toasts must be wrapped in CallingToastProvider'); + } + const toastsShown = useRef>(new Set()); + + const wrappedShowToast = useCallback( + (toast: CallingToastType) => { + const key = callingToastContext.showToast(toast); + toastsShown.current.add(key); + return key; + }, + [callingToastContext] + ); + + const hideAllShownToasts = useCallback(() => { + [...toastsShown.current].forEach(callingToastContext.hideToast); + }, [callingToastContext]); + + useEffect(() => { + return hideAllShownToasts; + }, [hideAllShownToasts]); + + return useMemo( + () => ({ + ...callingToastContext, + showToast: wrappedShowToast, + }), + [wrappedShowToast, callingToastContext] ); } diff --git a/ts/components/CallingToastManager.tsx b/ts/components/CallingToastManager.tsx index 054f0e505b1c..65ce586e6e9c 100644 --- a/ts/components/CallingToastManager.tsx +++ b/ts/components/CallingToastManager.tsx @@ -1,35 +1,40 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import type { ActiveCallType } from '../types/Calling'; import { CallMode } from '../types/Calling'; import type { ConversationType } from '../state/ducks/conversations'; import type { LocalizerType } from '../types/Util'; -import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary'; -import { CallingToast, DEFAULT_LIFETIME } from './CallingToast'; import { isReconnecting } from '../util/callingIsReconnecting'; +import { useCallingToasts } from './CallingToast'; +import { Spinner } from './Spinner'; type PropsType = { activeCall: ActiveCallType; i18n: LocalizerType; }; -type ToastType = - | { - message: string; - type: 'dismissable' | 'static'; - } - | undefined; +export function useReconnectingToast({ activeCall, i18n }: PropsType): void { + const { showToast, hideToast } = useCallingToasts(); + const RECONNECTING_TOAST_KEY = 'reconnecting'; -function getReconnectingToast({ activeCall, i18n }: PropsType): ToastType { - if (isReconnecting(activeCall)) { - return { - message: i18n('icu:callReconnecting'), - type: 'static', - }; - } - return undefined; + useEffect(() => { + if (isReconnecting(activeCall)) { + showToast({ + key: RECONNECTING_TOAST_KEY, + content: ( + + + {i18n('icu:callReconnecting')} + + ), + autoClose: false, + }); + } else { + hideToast(RECONNECTING_TOAST_KEY); + } + }, [activeCall, i18n, showToast, hideToast]); } const ME = Symbol('me'); @@ -54,34 +59,14 @@ function getCurrentPresenter( return undefined; } -function useScreenSharingToast({ activeCall, i18n }: PropsType): ToastType { - const [result, setResult] = useState(undefined); - +export function useScreenSharingStoppedToast({ + activeCall, + i18n, +}: PropsType): void { const [previousPresenter, setPreviousPresenter] = useState< undefined | { id: string | typeof ME; title?: string } >(undefined); - - const previousPresenterId = previousPresenter?.id; - const previousPresenterTitle = previousPresenter?.title; - - useEffect(() => { - const currentPresenter = getCurrentPresenter(activeCall); - if (!currentPresenter && previousPresenterId) { - if (previousPresenterId === ME) { - setResult({ - type: 'dismissable', - message: i18n('icu:calling__presenting--you-stopped'), - }); - } else if (previousPresenterTitle) { - setResult({ - type: 'dismissable', - message: i18n('icu:calling__presenting--person-stopped', { - name: previousPresenterTitle, - }), - }); - } - } - }, [activeCall, i18n, previousPresenterId, previousPresenterTitle]); + const { showToast } = useCallingToasts(); useEffect(() => { const currentPresenter = getCurrentPresenter(activeCall); @@ -97,49 +82,19 @@ function useScreenSharingToast({ activeCall, i18n }: PropsType): ToastType { } }, [activeCall]); - return result; -} - -// In the future, this component should show toasts when users join or leave. See -// DESKTOP-902. -export function CallingToastManager(props: PropsType): JSX.Element { - const reconnectingToast = getReconnectingToast(props); - const screenSharingToast = useScreenSharingToast(props); - - let toast: ToastType; - if (reconnectingToast) { - toast = reconnectingToast; - } else if (screenSharingToast) { - toast = screenSharingToast; - } - - const [isVisible, setIsVisible] = useState(false); - const timeoutRef = useRef(null); - - const dismissToast = useCallback(() => { - if (timeoutRef) { - setIsVisible(false); - } - }, [setIsVisible, timeoutRef]); - useEffect(() => { - setIsVisible(toast !== undefined); - }, [toast]); + const currentPresenter = getCurrentPresenter(activeCall); - useEffect(() => { - if (toast?.type === 'dismissable') { - clearTimeoutIfNecessary(timeoutRef.current); - timeoutRef.current = setTimeout(dismissToast, DEFAULT_LIFETIME); + if (!currentPresenter && previousPresenter && previousPresenter.title) { + showToast({ + content: + previousPresenter.id === ME + ? i18n('icu:calling__presenting--you-stopped') + : i18n('icu:calling__presenting--person-stopped', { + name: previousPresenter.title, + }), + autoClose: true, + }); } - - return () => { - clearTimeoutIfNecessary(timeoutRef.current); - }; - }, [dismissToast, setIsVisible, timeoutRef, toast]); - - return ( - - {toast?.message} - - ); + }, [activeCall, previousPresenter, showToast, i18n]); } diff --git a/ts/hooks/useIsMounted.ts b/ts/hooks/useIsMounted.ts new file mode 100644 index 000000000000..cdebec5f53ca --- /dev/null +++ b/ts/hooks/useIsMounted.ts @@ -0,0 +1,28 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { useCallback, useEffect, useRef } from 'react'; + +/** + * If you get a warning like: + * + * Warning: Can't perform a React state update on an unmounted component. + * + * your component is probably trying to set state after it has unmounted, e.g. after a + * timeout or async call. If you can, clear the timeout when the component unmounts (e.g. + * on useEffect cleanup). Otherwise, use this hook to check if the component is mounted + * before updating state. + */ + +export function useIsMounted(): () => boolean { + const isMounted = useRef(false); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + return useCallback(() => isMounted.current === true, []); +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 8f81b2df3f97..0c40950692d2 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -2994,26 +2994,45 @@ }, { "rule": "React-useRef", - "path": "ts/components/CallingToastManager.tsx", - "line": " const timeoutRef = useRef(null);", + "path": "ts/components/CallingToast.tsx", + "line": " const shownToasts = React.useRef>(new Set());", "reasonCategory": "usageTrusted", - "updated": "2021-07-30T16:57:33.618Z" + "updated": "2023-10-04T20:50:45.297Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/CallingToast.tsx", + "line": " const timeouts = React.useRef>(new Map());", + "reasonCategory": "usageTrusted", + "updated": "2023-10-10T17:05:02.468Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/CallingToast.tsx", + "line": " const timeoutsStatus = React.useRef<'active' | 'paused'>('active');", + "reasonCategory": "usageTrusted", + "updated": "2023-10-10T17:05:02.468Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/CallingToast.tsx", + "line": " const toastsShown = useRef>(new Set());", + "reasonCategory": "usageTrusted", + "updated": "2023-10-10T17:05:02.468Z" }, { "rule": "React-useRef", "path": "ts/components/CallsList.tsx", "line": " const infiniteLoaderRef = useRef(null);", "reasonCategory": "usageTrusted", - "updated": "2023-08-02T00:21:37.858Z", - "reasonDetail": "" + "updated": "2023-08-02T00:21:37.858Z" }, { "rule": "React-useRef", "path": "ts/components/CallsList.tsx", "line": " const listRef = useRef(null);", "reasonCategory": "usageTrusted", - "updated": "2023-08-02T00:21:37.858Z", - "reasonDetail": "" + "updated": "2023-08-02T00:21:37.858Z" }, { "rule": "React-useRef", @@ -3700,6 +3719,13 @@ "reasonCategory": "usageTrusted", "updated": "2021-09-17T20:16:37.959Z" }, + { + "rule": "React-useRef", + "path": "ts/hooks/useIsMounted.ts", + "line": " const isMounted = useRef(false);", + "reasonCategory": "usageTrusted", + "updated": "2023-10-04T20:50:45.297Z" + }, { "rule": "React-useRef", "path": "ts/hooks/usePrevious.ts",