// Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only 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'; 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; }; type CallingToastContextType = { showToast: (toast: CallingToastType) => string; hideToast: (id: string) => void; }; type TimeoutType = | { status: 'active'; timeout: NodeJS.Timeout; endAt: number } | { status: 'paused'; remaining: number }; const CallingToastContext = createContext(null); export function CallingToastProvider({ i18n, children, }: { 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 ( {createPortal(
{transitions((style, item) => ( hideToast(item.key) : undefined } /> ))}
, document.body )} {children}
); } 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] ); }