diff --git a/src/components/ToastArea.tsx b/src/components/ToastArea.tsx index 0457db9..5aa9e98 100644 --- a/src/components/ToastArea.tsx +++ b/src/components/ToastArea.tsx @@ -27,9 +27,8 @@ export const ToastArea = ({ swipeDirection, children, className }: ToastAreaProp const $ToastArea = styled.aside` // Params --toasts-gap: 0.5rem; - + // Rules - ${layoutMixins.scrollArea} z-index: 1; pointer-events: none; diff --git a/src/constants/notifications.ts b/src/constants/notifications.ts index a455fd1..d6728d1 100644 --- a/src/constants/notifications.ts +++ b/src/constants/notifications.ts @@ -87,6 +87,8 @@ export type NotificationDisplayData = { slotTitleLeft?: React.ReactNode; slotTitleRight?: React.ReactNode; + groupKey: string; // Grouping key toast notification stacking + // Overrides title/body for Notification in NotificationMenu renderCustomBody?: ({ isToast, diff --git a/src/hooks/useNotificationTypes.tsx b/src/hooks/useNotificationTypes.tsx index 5edaae4..b7be4f3 100644 --- a/src/hooks/useNotificationTypes.tsx +++ b/src/hooks/useNotificationTypes.tsx @@ -81,6 +81,7 @@ export const notificationTypes: NotificationTypeConfig[] = [ body: abacusNotif.text ? stringGetter({ key: abacusNotif.text, params }) : '', toastDuration: DEFAULT_TOAST_AUTO_CLOSE_MS, toastSensitivity: 'foreground', + groupKey: abacusNotificationType, renderCustomBody: ({ isToast, notification }) => ( ( ), toastSensitivity: 'foreground', + groupKey: NotificationType.SquidTransfer, }, [isFinished] ); @@ -265,6 +269,7 @@ export const notificationTypes: NotificationTypeConfig[] = [ }, }), toastSensitivity: 'foreground', + groupKey: NotificationType.ReleaseUpdates, }, [] ); diff --git a/src/layout/NotificationsToastArea.tsx b/src/layout/NotificationsToastArea/NotifcationStack.tsx similarity index 65% rename from src/layout/NotificationsToastArea.tsx rename to src/layout/NotificationsToastArea/NotifcationStack.tsx index 8daa01f..a2f16dd 100644 --- a/src/layout/NotificationsToastArea.tsx +++ b/src/layout/NotificationsToastArea/NotifcationStack.tsx @@ -1,7 +1,11 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import styled, { css } from 'styled-components'; -import { NotificationStatus } from '@/constants/notifications'; +import { + type Notification, + type NotificationDisplayData, + NotificationStatus, +} from '@/constants/notifications'; import { ButtonShape, ButtonSize } from '@/constants/buttons'; import { useNotifications } from '@/hooks/useNotifications'; import { useBreakpoints } from '@/hooks/useBreakpoints'; @@ -13,54 +17,31 @@ import { Toast } from '@/components/Toast'; import { ToastArea } from '@/components/ToastArea'; import { ToggleButton } from '@/components/ToggleButton'; +type ElementProps = { + notifications: { + notification: Notification; + key: string; + displayData: NotificationDisplayData; + }[]; +}; + type StyleProps = { className?: string; }; -const MAX_TOASTS = 10; - -export const NotificationsToastArea = ({ className }: StyleProps) => { - const [shouldStackNotifications, setshouldStackNotifications] = useState(false); - - const { - notifications, - getKey, - getDisplayData, - markUnseen, - markSeen, - isMenuOpen, - onNotificationAction, - } = useNotifications(); +export const NotificationStack = ({ notifications, className }: ElementProps & StyleProps) => { + const [shouldStackNotifications, setshouldStackNotifications] = useState(true); + const { markUnseen, markSeen, onNotificationAction } = useNotifications(); const { isMobile } = useBreakpoints(); - const notificationMap = useMemo(() => { - return ( - Object.values(notifications) - // Sort by time of first trigger - .sort( - (n1, n2) => - n1.timestamps[NotificationStatus.Triggered]! - - n2.timestamps[NotificationStatus.Triggered]! - ) - .map((notification) => ({ - notification, - key: getKey(notification), - displayData: getDisplayData(notification), - })) - .filter( - ({ displayData, notification }) => - displayData && notification.status < NotificationStatus.Unseen - ) - .slice(-MAX_TOASTS) - ); - }, [notifications, getKey, getDisplayData]); - - if (isMenuOpen) return null; - - const hasMultipleToasts = notificationMap.length > 1; + const hasMultipleToasts = notifications.length > 1; return ( - + {hasMultipleToasts && ( { )} - {notificationMap.map(({ notification, key, displayData }, idx) => ( + {notifications.map(({ notification, key, displayData }, idx) => ( 0 && shouldStackNotifications} isOpen={notification.status < NotificationStatus.Unseen} - layer={notificationMap.length - 1 - idx} + layer={idx} notification={notification} slotIcon={displayData.icon} slotTitle={displayData.title} @@ -110,19 +91,13 @@ export const NotificationsToastArea = ({ className }: StyleProps) => { ); }; -const StyledToastArea = styled(ToastArea)` - position: absolute; - width: min(17.5rem, 100%); - inset: 0 0 0 auto; - - padding: 0.75rem 0.75rem 0.75rem 0; - - mask-image: linear-gradient(to left, transparent, white 0.5rem); - - @media ${breakpoints.mobile} { - width: 100%; - position: fixed; - } +const StyledToastArea = styled(ToastArea)<{ size: number }>` + position: relative; + ${({ size }) => + size && + css` + padding-bottom: calc(${size - 1} * 2px); + `} `; const StyledToast = styled(Toast)<{ isStacked?: boolean; layer: number }>` @@ -133,7 +108,7 @@ const StyledToast = styled(Toast)<{ isStacked?: boolean; layer: number }>` position: absolute; left: 0; top: calc(${layer} * 2px); - right: calc(${layer} * -2px); + right: 0; ` : css` margin-bottom: 0.75rem; @@ -145,7 +120,7 @@ const StyledToggleButton = styled(ToggleButton)<{ isPressed: boolean }>` pointer-events: auto; display: none; position: absolute; - top: 4px; + top: -0.5rem; left: calc(50% - 0.75rem); --button-width: 2rem; --button-height: 1rem; diff --git a/src/layout/NotificationsToastArea/index.tsx b/src/layout/NotificationsToastArea/index.tsx new file mode 100644 index 0000000..fea6004 --- /dev/null +++ b/src/layout/NotificationsToastArea/index.tsx @@ -0,0 +1,71 @@ +import { useMemo } from 'react'; +import { groupBy } from 'lodash'; +import styled from 'styled-components'; + +import { breakpoints } from '@/styles'; +import { layoutMixins } from '@/styles/layoutMixins'; + +import { NotificationStatus } from '@/constants/notifications'; +import { useNotifications } from '@/hooks/useNotifications'; + +import { NotificationStack } from './NotifcationStack'; + +type StyleProps = { + className?: string; +}; + +const MAX_TOASTS = 10; + +export const NotificationsToastArea = ({ className }: StyleProps) => { + const { notifications, getKey, getDisplayData, isMenuOpen } = useNotifications(); + + const notificationMapByType = useMemo(() => { + const notificationMap = Object.values(notifications) + // Sort by time of first trigger + .sort( + (n1, n2) => + n1.timestamps[NotificationStatus.Triggered]! - + n2.timestamps[NotificationStatus.Triggered]! + ) + .map((notification) => ({ + notification, + key: getKey(notification), + displayData: getDisplayData(notification), + })) + .filter( + ({ displayData, notification }) => + displayData && notification.status < NotificationStatus.Unseen + ) + .slice(-MAX_TOASTS); + return groupBy(notificationMap, (notification) => notification.displayData?.groupKey); + }, [notifications, getKey, getDisplayData]); + + if (isMenuOpen) return null; + + return ( + + {Object.keys(notificationMapByType).map((groupKey) => ( + + ))} + + ); +}; + +const StyledToastArea = styled.div` + ${layoutMixins.scrollArea} + + position: absolute; + width: min(17.5rem, 100%); + inset: 0 0 0 auto; + + padding: 0.75rem 0.75rem 0.75rem 0; + + mask-image: linear-gradient(to left, transparent, white 0.5rem); + + pointer-events: none; + + @media ${breakpoints.mobile} { + width: 100%; + position: fixed; + } +`;