// Credits to sonner // License on the MIT // https://github.com/emilkowalski/sonner/blob/5cb703edc108a23fd74979235c2f3c4005edd2a7/src/index.tsx import { useI18n } from '@affine/i18n'; import { CloseIcon, InformationFillDuotoneIcon } from '@blocksuite/icons/rc'; import * as Toast from '@radix-ui/react-toast'; import clsx from 'clsx'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import type { ReactNode } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import { IconButton } from '../../ui/button'; import { SuccessIcon } from './icons'; import * as styles from './index.css'; import type { Notification } from './index.jotai'; import { expandNotificationCenterAtom, notificationsAtom, pushNotificationAtom, removeNotificationAtom, } from './index.jotai'; export { expandNotificationCenterAtom, pushNotificationAtom, removeNotificationAtom, }; type Height = { height: number; notificationKey: number | string | undefined; }; export type NotificationCardProps = { notification: Notification; notifications: Notification[]; index: number; heights: Height[]; setHeights: React.Dispatch>; }; const typeColorMap = { info: { light: styles.lightInfoStyle, dark: styles.darkInfoStyle, default: '', icon: , }, success: { light: styles.lightSuccessStyle, dark: styles.darkSuccessStyle, default: '', icon: , }, warning: { light: styles.lightWarningStyle, dark: styles.darkWarningStyle, default: '', icon: , }, error: { light: styles.lightErrorStyle, dark: styles.darkErrorStyle, default: '', icon: , }, }; function NotificationCard(props: NotificationCardProps): ReactNode { const t = useI18n(); const removeNotification = useSetAtom(removeNotificationAtom); const { notification, notifications, setHeights, heights, index } = props; const [expand, setExpand] = useAtom(expandNotificationCenterAtom); // const setNotificationRemoveAnimation = useSetAtom(notificationRemoveAnimationAtom); const [mounted, setMounted] = useState(false); const [removed, setRemoved] = useState(false); const [offsetBeforeRemove, setOffsetBeforeRemove] = useState(0); const [initialHeight, setInitialHeight] = useState(0); const [animationKey, setAnimationKey] = useState(0); const animationRef = useRef(null); const notificationRef = useRef(null); const timerIdRef = useRef(); const isFront = index === 0; const isVisible = index + 1 <= 3; const progressDuration = notification.timeout || 3000; const heightIndex = useMemo( () => heights.findIndex( height => height.notificationKey === notification.key ) || 0, [heights, notification.key] ); const duration = notification.timeout || 3000; const offset = useRef(0); const notificationsHeightBefore = useMemo(() => { return heights.reduce((prev, curr, reducerIndex) => { // Calculate offset up until current notification if (reducerIndex >= heightIndex) { return prev; } return prev + curr.height; }, 0); }, [heights, heightIndex]); offset.current = useMemo( () => heightIndex * 14 + notificationsHeightBefore, [heightIndex, notificationsHeightBefore] ); useEffect(() => { // Trigger enter animation without using CSS animation setMounted(true); }, []); useEffect(() => { if (!expand) { animationRef.current?.beginElement(); } }, [expand]); const resetAnimation = () => { setAnimationKey(prevKey => prevKey + 1); }; useLayoutEffect(() => { if (!mounted) return; if (!notificationRef.current) return; const notificationNode = notificationRef.current; const originalHeight = notificationNode.style.height; notificationNode.style.height = 'auto'; const newHeight = notificationNode.getBoundingClientRect().height; notificationNode.style.height = originalHeight; setInitialHeight(newHeight); setHeights(heights => { const alreadyExists = heights.find( height => height.notificationKey === notification.key ); if (!alreadyExists) { return [ { notificationKey: notification.key, height: newHeight }, ...heights, ]; } else { return heights.map(height => height.notificationKey === notification.key ? { ...height, height: newHeight } : height ); } }); }, [notification.title, notification.key, mounted, setHeights]); const typeStyle = typeColorMap[notification.type][notification.theme || 'dark']; const onClickRemove = useCallback(() => { // Save the offset for the exit swipe animation setRemoved(true); setOffsetBeforeRemove(offset.current); setHeights(h => h.filter(height => height.notificationKey !== notification.key) ); window.setTimeout(() => { if (!notification.key) { return; } removeNotification(notification.key); }, 200); }, [setHeights, notification.key, removeNotification, offset]); useEffect(() => { if (timerIdRef.current) { clearTimeout(timerIdRef.current); } if (!expand) { timerIdRef.current = window.setTimeout(() => { onClickRemove(); }, duration); } return () => { if (timerIdRef.current) { clearTimeout(timerIdRef.current); } }; }, [duration, expand, onClickRemove]); const onClickAction = useCallback(() => { if (notification.action) { notification.action().catch(err => { console.error(err); }); } return void 0; }, [notification]); useEffect(() => { const notificationNode = notificationRef.current; if (notificationNode) { const height = notificationNode.getBoundingClientRect().height; // Add toast height tot heights array after the toast is mounted setInitialHeight(height); setHeights(h => [{ notificationKey: notification.key, height }, ...h]); return () => setHeights(h => h.filter(height => height.notificationKey !== notification.key) ); } return; }, [notification.key, setHeights]); return ( { setExpand(true); }} onMouseMove={() => { setExpand(true); }} onMouseLeave={() => { setExpand(false); }} onSwipeEnd={event => event.preventDefault()} onSwipeMove={event => event.preventDefault()} style={ { '--index': index, '--toasts-before': index, '--z-index': notifications.length - index, '--offset': `${removed ? offsetBeforeRemove : offset.current}px`, '--initial-height': `${initialHeight}px`, userSelect: 'auto', } as React.CSSProperties } >
{notification.multimedia ? (
{notification.multimedia}
) : null}
{typeColorMap[notification.type]?.icon ?? ( )}
{notification.title}
{notification.action && (
{notification.actionLabel ?? t['com.arms.keyboardShortcuts.undo']()}
)} {notification.multimedia ? null : ( )}
{notification.message} {notification.progressingBar && (
)}
); } /** * @deprecated use `import { NotificationCenter } from '@affine/component'` instead */ export function NotificationCenter(): ReactNode { const notifications = useAtomValue(notificationsAtom); const [expand, setExpand] = useAtom(expandNotificationCenterAtom); if (notifications.length === 0 && expand) { setExpand(false); } const [heights, setHeights] = useState([]); const listRef = useRef(null); useEffect(() => { // Ensure expanded is always false when no toasts are present / only one left if (notifications.length <= 1) { setExpand(false); } }, [notifications, setExpand]); if (!notifications.length) return null; return ( {notifications.map((notification, index) => notification.key ? ( ) : null )} ); }