Centralize calling toasts and add imperative API

This commit is contained in:
trevor-signal 2023-10-19 14:59:21 -04:00 committed by GitHub
parent dd45b08b0f
commit 0c896ca1f2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 568 additions and 158 deletions

View file

@ -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"

View file

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

View file

@ -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 <ActiveCallManager {...props} activeCall={activeCall} />;
return (
<CallingToastProvider i18n={props.i18n}>
<ActiveCallManager {...props} activeCall={activeCall} />
</CallingToastProvider>
);
}
// In the future, we may want to show the incoming call bar when a call is active.

View file

@ -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<typeof createProps>): JSX.Element {
return (
<CallingToastProvider i18n={i18n}>
<UnwrappedCallScreen {...props} />
</CallingToastProvider>
);
}
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 (
<CallScreen
{...createProps({
callMode: CallMode.Group,
remoteParticipants,
})}
/>
);
}
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 (
<CallingToastProvider i18n={i18n}>
<UnwrappedCallScreen {...createProps()} />
<ToastEmitter />
</CallingToastProvider>
);
}

View file

@ -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}
<CallingToastManager activeCall={activeCall} i18n={i18n} />
<div
className={classNames('module-ongoing-call__header', controlsFadeClass)}
>

View file

@ -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> = {}): PropsType => {
};
};
function CallingLobby(props: ReturnType<typeof createProps>) {
return (
<CallingToastProvider i18n={i18n}>
<UnwrappedCallingLobby {...props} />
</CallingToastProvider>
);
}
const fakePeekedParticipant = (conversationProps: Partial<ConversationType>) =>
getDefaultConversationWithServiceId({
...conversationProps,

View file

@ -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<MediaDeviceInfo>;
@ -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 | HTMLVideoElement>(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({
/>
)}
<CallingToast
isVisible={isMutedToastVisible}
onClick={() => setIsMutedToastVisible(false)}
>
{i18n(
'icu:calling__lobby-automatically-muted-because-there-are-a-lot-of-people'
)}
</CallingToast>
<CallingHeader
i18n={i18n}
isGroupCall={isGroupCall}
@ -306,3 +284,32 @@ export function CallingLobby({
</FocusTrap>
);
}
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]);
}

View file

@ -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<CallingToastContextType | null>(null);
export function CallingToastProvider({
i18n,
children,
}: PropsType): JSX.Element {
}: {
i18n: LocalizerType;
children: React.ReactNode;
}): JSX.Element {
const [toasts, setToasts] = React.useState<Array<CallingToastStateType>>([]);
const timeouts = React.useRef<Map<string, TimeoutType>>(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<Set<string>>(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 (
<button
className={classNames(
'CallingToast',
!isVisible && 'CallingToast--hidden'
<CallingToastContext.Provider value={contextValue}>
{createPortal(
<div className="CallingToasts">
<div
className="CallingToasts__inner"
role="region"
aria-label={i18n('icu:calling__toasts--aria-label')}
{...mergeProps(hoverProps, focusWithinProps)}
>
{transitions((style, item) => (
<animated.div style={style}>
<CallingToast
{...item}
onClick={
item.dismissable ? () => hideToast(item.key) : undefined
}
/>
</animated.div>
))}
</div>
</div>,
document.body
)}
type="button"
onClick={onClick}
>
{children}
</button>
</CallingToastContext.Provider>
);
}
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 (
<div {...elementHtmlProps}>
<button className={className} type="button" onClick={props.onClick}>
{props.content}
</button>
</div>
);
}
return (
<div {...elementHtmlProps} className={className}>
{props.content}
</div>
);
}
// 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<Set<string>>(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]
);
}

View file

@ -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: (
<span className="CallingToast__reconnecting">
<Spinner svgSize="small" size="16px" />
{i18n('icu:callReconnecting')}
</span>
),
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 | ToastType>(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<NodeJS.Timeout | null>(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 (
<CallingToast isVisible={isVisible} onClick={dismissToast}>
{toast?.message}
</CallingToast>
);
}, [activeCall, previousPresenter, showToast, i18n]);
}

28
ts/hooks/useIsMounted.ts Normal file
View file

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

View file

@ -2994,26 +2994,45 @@
},
{
"rule": "React-useRef",
"path": "ts/components/CallingToastManager.tsx",
"line": " const timeoutRef = useRef<NodeJS.Timeout | null>(null);",
"path": "ts/components/CallingToast.tsx",
"line": " const shownToasts = React.useRef<Set<string>>(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<Map<string, TimeoutType>>(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<Set<string>>(new Set());",
"reasonCategory": "usageTrusted",
"updated": "2023-10-10T17:05:02.468Z"
},
{
"rule": "React-useRef",
"path": "ts/components/CallsList.tsx",
"line": " const infiniteLoaderRef = useRef<InfiniteLoader>(null);",
"reasonCategory": "usageTrusted",
"updated": "2023-08-02T00:21:37.858Z",
"reasonDetail": "<optional>"
"updated": "2023-08-02T00:21:37.858Z"
},
{
"rule": "React-useRef",
"path": "ts/components/CallsList.tsx",
"line": " const listRef = useRef<List>(null);",
"reasonCategory": "usageTrusted",
"updated": "2023-08-02T00:21:37.858Z",
"reasonDetail": "<optional>"
"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",