diff --git a/e2e/screenshots/simulator/recurring/Recurring limit range/simulator-input-price.jpg b/e2e/screenshots/simulator/recurring/Recurring limit range/simulator-input-price.jpg index fc316a2a2..b8c2c38ac 100644 Binary files a/e2e/screenshots/simulator/recurring/Recurring limit range/simulator-input-price.jpg and b/e2e/screenshots/simulator/recurring/Recurring limit range/simulator-input-price.jpg differ diff --git a/e2e/screenshots/simulator/recurring/Recurring limit range/simulator-results-summary.jpg b/e2e/screenshots/simulator/recurring/Recurring limit range/simulator-results-summary.jpg index 899ea664c..110c8134d 100644 Binary files a/e2e/screenshots/simulator/recurring/Recurring limit range/simulator-results-summary.jpg and b/e2e/screenshots/simulator/recurring/Recurring limit range/simulator-results-summary.jpg differ diff --git a/e2e/screenshots/simulator/recurring/Recurring range limit/simulator-input-price.jpg b/e2e/screenshots/simulator/recurring/Recurring range limit/simulator-input-price.jpg index 6064fe7dd..8d30f6e34 100644 Binary files a/e2e/screenshots/simulator/recurring/Recurring range limit/simulator-input-price.jpg and b/e2e/screenshots/simulator/recurring/Recurring range limit/simulator-input-price.jpg differ diff --git a/e2e/screenshots/strategy/disposable/Disposable buy limit/create/my-strategy.jpg b/e2e/screenshots/strategy/disposable/Disposable buy limit/create/my-strategy.jpg index 08849450e..a21c2e906 100644 Binary files a/e2e/screenshots/strategy/disposable/Disposable buy limit/create/my-strategy.jpg and b/e2e/screenshots/strategy/disposable/Disposable buy limit/create/my-strategy.jpg differ diff --git a/e2e/screenshots/strategy/recurring/Recurring limit limit/create/my-strategy.jpg b/e2e/screenshots/strategy/recurring/Recurring limit limit/create/my-strategy.jpg index c4ee08d9b..22b7201b4 100644 Binary files a/e2e/screenshots/strategy/recurring/Recurring limit limit/create/my-strategy.jpg and b/e2e/screenshots/strategy/recurring/Recurring limit limit/create/my-strategy.jpg differ diff --git a/src/components/activity/ActivityNotification.module.css b/src/components/activity/ActivityNotification.module.css new file mode 100644 index 000000000..eef9e7894 --- /dev/null +++ b/src/components/activity/ActivityNotification.module.css @@ -0,0 +1,62 @@ +.icon::before { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + transform-origin: center; + background-color: currentColor; + opacity: 0; + transform: scale(0); + animation: ripple 2s 0.9s cubic-bezier(0.16, 1, 0.3, 1); + pointer-events: none; +} +.icon { + animation: pulse 1.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} +.icon g { + animation: enter 1.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} +.icon line { + animation: scale 1.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes pulse { + 0% { + transform: scale(0.3); + } + 50% { + transform: scale(1.2); + } + 100% { + transform: scale(1); + } +} + +@keyframes enter { + 0%, 30% { + transform: translateX(-100%); + } + 100% { + transform: translateX(0); + } +} + +@keyframes scale { + 0%, 60% { + transform: scale(0.3); + } + 100% { + transform: scale(1); + } +} + +@keyframes ripple { + from { + transform: scale(0); + opacity: 1; + } + to { + transform: scale(20); + opacity: 0; + } +} diff --git a/src/components/activity/ActivityNotification.tsx b/src/components/activity/ActivityNotification.tsx new file mode 100644 index 000000000..69057fea2 --- /dev/null +++ b/src/components/activity/ActivityNotification.tsx @@ -0,0 +1,106 @@ +import { FC, useId } from 'react'; +import { NotificationActivity } from 'libs/notifications/types'; +import { activityActionName, activityDescription } from './utils'; +import { ReactComponent as IconClose } from 'assets/icons/X.svg'; +import { Link } from '@tanstack/react-router'; +import { cn } from 'utils/helpers'; +import { ActivityAction } from 'libs/queries/extApi/activity'; +import { unix } from 'dayjs'; +import style from './ActivityNotification.module.css'; + +interface Props { + notification: NotificationActivity; + close: () => void; +} + +export const ActivityNotification: FC = ({ notification, close }) => { + const titleId = useId(); + const { activity } = notification; + + return ( +
+ +
+
+

+ {activityActionName[activity.action]} +

+

+ {activityDescription(activity)} +

+
+ + View Activity + +
+
+ + +

+ {unix(notification.timestamp).fromNow(true)} +

+
+
+ ); +}; + +export const AnimatedActionIcon = (props: { action: ActivityAction }) => { + const transform = props.action === 'buy' ? 'rotate(30deg)' : 'rotate(-30deg)'; + return ( +
+ + + + + + + +
+ ); +}; diff --git a/src/components/activity/useActivityNotifiction.ts b/src/components/activity/useActivityNotifiction.ts new file mode 100644 index 000000000..63bfc8e0e --- /dev/null +++ b/src/components/activity/useActivityNotifiction.ts @@ -0,0 +1,42 @@ +import { useWeb3 } from 'libs/web3'; +import { useActivityQuery } from './useActivityQuery'; +import { useEffect, useState } from 'react'; +import { useNotifications } from 'hooks/useNotifications'; + +export const useActivityNotifications = () => { + const { user } = useWeb3(); + const [previousUser, setPreviousUser] = useState(null); + const [previous, setPrevious] = useState(null); + const query = useActivityQuery({ ownerId: user }); + const allActivities = query.data || []; + const buyOrSell = allActivities.filter( + (a) => a.action === 'sell' || a.action === 'buy' + ); + const { dispatchNotification } = useNotifications(); + + useEffect(() => { + if (query.isLoading) return; + // We need to keep this in the same useEffect to force re-evaluate previous in next render + if (user && user !== previousUser) { + setPreviousUser(user); + setPrevious(null); + return; + } + const length = buyOrSell.length; + if (typeof previous === 'number' && length > previous) { + // Sorted by date desc + for (let i = 0; i < length - previous; i++) { + const activity = buyOrSell[i]; + dispatchNotification('activity', { activity }); + } + } + setPrevious(length); + }, [ + buyOrSell, + dispatchNotification, + previous, + query.isLoading, + previousUser, + user, + ]); +}; diff --git a/src/components/activity/useActivityQuery.tsx b/src/components/activity/useActivityQuery.tsx index 732680c25..d72872ddb 100644 --- a/src/components/activity/useActivityQuery.tsx +++ b/src/components/activity/useActivityQuery.tsx @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query'; +import { isAddress } from 'ethers/lib/utils'; import { useTokens } from 'hooks/useTokens'; import { QueryKey } from 'libs/queries'; import { @@ -8,6 +9,7 @@ import { } from 'libs/queries/extApi/activity'; import { Token } from 'libs/tokens'; import { carbonApi } from 'utils/carbonApi'; +import { THIRTY_SEC_IN_MS } from 'utils/time'; const toActivities = ( data: ServerActivity[], @@ -39,8 +41,14 @@ const toActivities = ( }); }; +const isValidParams = (params: QueryActivityParams) => { + if ('ownerId' in params && !isAddress(params.ownerId ?? '')) return false; + return true; +}; + export const useActivityQuery = (params: QueryActivityParams = {}) => { const { tokensMap, isLoading } = useTokens(); + const validParams = isValidParams(params); return useQuery( QueryKey.activities(params), async () => { @@ -50,8 +58,8 @@ export const useActivityQuery = (params: QueryActivityParams = {}) => { }); }, { - enabled: !isLoading, - refetchInterval: 30 * 1000, + enabled: !isLoading && validParams, + refetchInterval: THIRTY_SEC_IN_MS, } ); }; diff --git a/src/components/core/MainContent.tsx b/src/components/core/MainContent.tsx index 219069deb..c4e677fe0 100644 --- a/src/components/core/MainContent.tsx +++ b/src/components/core/MainContent.tsx @@ -14,6 +14,7 @@ import { useRouterState, useScrollRestoration, } from 'libs/routing'; +import { useActivityNotifications } from 'components/activity/useActivityNotifiction'; const paths: Record = { debug: '/debug', @@ -28,6 +29,7 @@ export const MainContent: FC = () => { const tokens = useTokens(); const sdk = useCarbonInit(); useScrollRestoration(); + useActivityNotifications(); useEffect(() => { if (prevPathnameRef.current !== location.pathname) { diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 28df0a339..97f127f20 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -31,7 +31,7 @@ export const useNotifications = () => { }; const checkStatus = async (n: Notification) => { - if (!n.txHash || !provider) return; + if (n.type !== 'tx' || !n.txHash || !provider) return; try { const tx = await provider.getTransactionReceipt(n.txHash); if (tx && tx.status !== null) { diff --git a/src/libs/modals/modals/ModalNotifications.tsx b/src/libs/modals/modals/ModalNotifications.tsx index 76c5cf597..92863c213 100644 --- a/src/libs/modals/modals/ModalNotifications.tsx +++ b/src/libs/modals/modals/ModalNotifications.tsx @@ -4,32 +4,36 @@ import { NotificationLine } from 'libs/notifications/NotificationLine'; import { useNotifications } from 'hooks/useNotifications'; export const ModalNotifications: ModalFC = ({ id }) => { - const { notifications, clearNotifications } = useNotifications(); + const { notifications, clearNotifications, removeNotification } = + useNotifications(); const reversedNotifications = notifications.slice().reverse(); return ( +
Notifications - +
} size="md" > -
- {reversedNotifications.map((notification) => ( -
+ {reversedNotifications.map((n) => ( +
  • - -
  • + removeNotification(n.id)} + /> + ))} -
    +
    ); }; diff --git a/src/libs/notifications/NotificationLine.tsx b/src/libs/notifications/NotificationLine.tsx index 4084425dc..906a2fbfd 100644 --- a/src/libs/notifications/NotificationLine.tsx +++ b/src/libs/notifications/NotificationLine.tsx @@ -1,115 +1,21 @@ -import { FC, useId } from 'react'; -import { Notification, NotificationStatus } from 'libs/notifications/types'; -import { ReactComponent as IconLink } from 'assets/icons/link.svg'; -import { ReactComponent as IconTimes } from 'assets/icons/times.svg'; -import { ReactComponent as IconCheck } from 'assets/icons/check.svg'; -import { getExplorerLink } from 'utils/blockExplorer'; -import { unix } from 'libs/dayjs'; -import { useNotifications } from 'hooks/useNotifications'; -import { useInterval } from 'hooks/useInterval'; -import { NewTabLink } from 'libs/routing'; -import { FOUR_SECONDS_IN_MS } from 'utils/time'; +import { FC } from 'react'; +import { ActivityNotification } from 'components/activity/ActivityNotification'; +import { TxNotification } from './TxNotification'; +import { Notification } from './types'; -const StatusIcon = (status: NotificationStatus) => { - switch (status) { - case 'pending': - return ( -
    -
    -
    - ); - case 'success': - return ( -
    - -
    - ); - case 'failed': - return ( -
    - -
    - ); - } -}; - -const getTitleByStatus = (n: Notification) => { - switch (n.status) { - case 'pending': - return n.title; - case 'success': - return n.successTitle || n.title; - case 'failed': - return n.failedTitle || n.title; - } -}; - -const getDescriptionByStatus = (n: Notification) => { - switch (n.status) { - case 'pending': - return n.description; - case 'success': - return n.successDesc || n.description; - case 'failed': - return n.failedDesc || n.description; - } -}; - -export const NotificationLine: FC<{ +interface NotificationLineProps { + close: () => void; notification: Notification; - isAlert?: boolean; -}> = ({ notification, isAlert }) => { - const titleId = useId(); - const { removeNotification, dismissAlert } = useNotifications(); - - const handleCloseClick = () => { - if (isAlert) { - dismissAlert(notification.id); - } else { - removeNotification(notification.id); - } - }; - - useInterval( - () => dismissAlert(notification.id), - isAlert && notification.status !== 'pending' ? FOUR_SECONDS_IN_MS : null, - false - ); - - return ( -
    -
    {StatusIcon(notification.status)}
    -
    -

    - {getTitleByStatus(notification)} -

    -
    -

    - {getDescriptionByStatus(notification)} -

    - {notification.txHash && ( - - View on Etherscan - - )} -
    -
    - -
    -
    - {unix(notification.timestamp).fromNow(true)} -
    - -
    -
    - ); +} +export const NotificationLine: FC = (props) => { + const { notification, close } = props; + + switch (notification.type) { + case 'activity': + return ; + case 'tx': + return ; + default: + return null; + } }; diff --git a/src/libs/notifications/NotificationsProvider.tsx b/src/libs/notifications/NotificationsProvider.tsx index c825a65a2..ebc343e74 100644 --- a/src/libs/notifications/NotificationsProvider.tsx +++ b/src/libs/notifications/NotificationsProvider.tsx @@ -1,10 +1,11 @@ import { useWeb3 } from 'libs/web3'; -import { FC, useEffect } from 'react'; -import { NotificationLine } from 'libs/notifications/NotificationLine'; +import { FC, useEffect, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { getLSUserNotifications } from 'libs/notifications/utils'; import { useNotifications } from 'hooks/useNotifications'; import { useInterval } from 'hooks/useInterval'; +import { NotificationLine } from './NotificationLine'; +import { Notification } from './types'; export const NotificationAlerts: FC = () => { const { user } = useWeb3(); @@ -13,7 +14,7 @@ export const NotificationAlerts: FC = () => { useInterval(async () => { notifications - .filter((n) => n.status === 'pending') + .filter((n) => n.type === 'tx' && n.status === 'pending') .forEach((n) => checkStatus(n)); }, 2000); @@ -27,28 +28,48 @@ export const NotificationAlerts: FC = () => { }, [user, setNotifications]); return ( -
      +
        {alerts.map((n) => ( - - - + ))}
      ); }; +const NotificationItem: FC<{ notification: Notification }> = (props) => { + const { notification } = props; + const id = notification.id; + const [delay, setDelay] = useState(7 * 1000); + const { dismissAlert } = useNotifications(); + + useEffect(() => { + const timeout = setTimeout(() => dismissAlert(id), delay); + return () => clearTimeout(timeout); + }, [delay, dismissAlert, id]); + + return ( + setDelay(1_000_000)} // Infinity doesn't work with timeout + onMouseLeave={() => setDelay(7 * 1000)} + whileHover="hover" + initial="initial" + animate="animate" + exit="exit" + className="rounded-10 bg-background-900 mb-20 block w-[350px] overflow-hidden border border-white/40 px-16 py-12" + data-testid={`notification-${notification.testid}`} + > + dismissAlert(id)} + /> + + ); +}; + const notificationVariants = { initial: { opacity: 0, diff --git a/src/libs/notifications/TxNotification.tsx b/src/libs/notifications/TxNotification.tsx new file mode 100644 index 000000000..e61d6c42e --- /dev/null +++ b/src/libs/notifications/TxNotification.tsx @@ -0,0 +1,101 @@ +import { FC, useId } from 'react'; +import { NotificationStatus, NotificationTx } from 'libs/notifications/types'; +import { ReactComponent as IconLink } from 'assets/icons/link.svg'; +import { ReactComponent as IconTimes } from 'assets/icons/times.svg'; +import { ReactComponent as IconCheck } from 'assets/icons/check.svg'; +import { ReactComponent as IconClose } from 'assets/icons/X.svg'; +import { getExplorerLink } from 'utils/blockExplorer'; +import { unix } from 'libs/dayjs'; +import { NewTabLink } from 'libs/routing'; + +const StatusIcon = (status: NotificationStatus) => { + switch (status) { + case 'pending': + return ( +
      +
      +
      + ); + case 'success': + return ( +
      + +
      + ); + case 'failed': + return ( +
      + +
      + ); + } +}; + +const getTitleByStatus = (n: NotificationTx) => { + switch (n.status) { + case 'pending': + return n.title; + case 'success': + return n.successTitle || n.title; + case 'failed': + return n.failedTitle || n.title; + } +}; + +const getDescriptionByStatus = (n: NotificationTx) => { + switch (n.status) { + case 'pending': + return n.description; + case 'success': + return n.successDesc || n.description; + case 'failed': + return n.failedDesc || n.description; + } +}; + +interface Props { + notification: NotificationTx; + close: () => void; +} + +export const TxNotification: FC = ({ notification, close }) => { + const titleId = useId(); + + return ( +
      + {StatusIcon(notification.status)} +
      +
      +

      + {getTitleByStatus(notification)} +

      +

      + {getDescriptionByStatus(notification)} +

      +
      + {notification.txHash && ( + + View on Etherscan + + )} +
      + +
      + +
      + {unix(notification.timestamp).fromNow(true)} +
      +
      +
      + ); +}; diff --git a/src/libs/notifications/data.ts b/src/libs/notifications/data.ts index f17c3e3b2..56804c402 100644 --- a/src/libs/notifications/data.ts +++ b/src/libs/notifications/data.ts @@ -1,7 +1,8 @@ -import { NotificationsMap, NotificationNew } from 'libs/notifications/types'; +import { NotificationsMap, NotificationTx } from 'libs/notifications/types'; +import { Activity } from 'libs/queries/extApi/activity'; export interface NotificationSchema { - generic: NotificationNew; + generic: Omit; reject: undefined; revoke: { txHash: string }; approve: { symbol: string; limited: boolean; txHash: string }; @@ -15,11 +16,13 @@ export interface NotificationSchema { depositStrategy: { txHash: string }; deleteStrategy: { txHash: string }; changeRatesStrategy: { txHash: string }; + activity: { activity: Activity }; } export const NOTIFICATIONS_MAP: NotificationsMap = { - generic: (data) => data, + generic: (data) => ({ type: 'tx', ...data }), reject: () => ({ + type: 'tx', status: 'failed', title: 'Transaction Rejected', description: @@ -29,6 +32,7 @@ export const NOTIFICATIONS_MAP: NotificationsMap = { testid: 'reject', }), approve: ({ symbol, limited, txHash }) => ({ + type: 'tx', status: 'pending', title: 'Approving Token ...', txHash, @@ -45,6 +49,7 @@ export const NOTIFICATIONS_MAP: NotificationsMap = { testid: 'approve', }), revoke: ({ txHash }) => ({ + type: 'tx', status: 'pending', title: 'Revoking Past Approval', txHash, @@ -57,6 +62,7 @@ export const NOTIFICATIONS_MAP: NotificationsMap = { testid: 'revoke', }), approveError: ({ symbol }) => ({ + type: 'tx', status: 'failed', title: 'Approve Token failed', description: `Approval for ${symbol} has failed. Please try again or contact support.`, @@ -64,6 +70,7 @@ export const NOTIFICATIONS_MAP: NotificationsMap = { testid: 'approve-error', }), createStrategy: (data) => ({ + type: 'tx', status: 'pending', title: 'Pending Confirmation', description: 'New strategy is being created.', @@ -76,6 +83,7 @@ export const NOTIFICATIONS_MAP: NotificationsMap = { testid: 'create-strategy', }), pauseStrategy: (data) => ({ + type: 'tx', status: 'pending', title: 'Pending Confirmation', description: 'Your request to pause the strategy is being processed.', @@ -88,6 +96,7 @@ export const NOTIFICATIONS_MAP: NotificationsMap = { testid: 'pause-strategy', }), renewStrategy: (data) => ({ + type: 'tx', status: 'pending', title: 'Pending Confirmation', description: 'Your request to renew the strategy is being processed.', @@ -101,6 +110,7 @@ export const NOTIFICATIONS_MAP: NotificationsMap = { testid: 'renew-strategy', }), editStrategyName: (data) => ({ + type: 'tx', status: 'pending', title: 'Pending Confirmation', description: 'Strategy name is being updated.', @@ -113,6 +123,7 @@ export const NOTIFICATIONS_MAP: NotificationsMap = { testid: 'edit-strategy-name', }), withdrawStrategy: (data) => ({ + type: 'tx', status: 'pending', title: 'Pending Confirmation', description: 'Your withdrawal request is being processed.', @@ -125,6 +136,7 @@ export const NOTIFICATIONS_MAP: NotificationsMap = { testid: 'withdraw-strategy', }), depositStrategy: (data) => ({ + type: 'tx', status: 'pending', title: 'Pending Confirmation', description: 'Your deposit request is being processed.', @@ -137,6 +149,7 @@ export const NOTIFICATIONS_MAP: NotificationsMap = { testid: 'deposit-strategy', }), deleteStrategy: (data) => ({ + type: 'tx', status: 'pending', title: 'Pending Confirmation', description: 'Strategy deletion is being processed.', @@ -150,6 +163,7 @@ export const NOTIFICATIONS_MAP: NotificationsMap = { testid: 'delete-strategy', }), changeRatesStrategy: (data) => ({ + type: 'tx', status: 'pending', title: 'Pending Confirmation', description: 'Your edit request is being processed.', @@ -162,6 +176,7 @@ export const NOTIFICATIONS_MAP: NotificationsMap = { testid: 'change-rates-strategy', }), trade: ({ amount, txHash, to, from }) => ({ + type: 'tx', status: 'pending', title: 'Pending Confirmation', description: `Trading ${amount} ${from} for ${to} is being processed.`, @@ -173,4 +188,10 @@ export const NOTIFICATIONS_MAP: NotificationsMap = { showAlert: true, testid: 'trade', }), + activity: ({ activity }) => ({ + type: 'activity', + activity, + showAlert: true, + testid: 'activity', + }), }; diff --git a/src/libs/notifications/types.ts b/src/libs/notifications/types.ts index a6910fd8f..25353054a 100644 --- a/src/libs/notifications/types.ts +++ b/src/libs/notifications/types.ts @@ -1,23 +1,35 @@ import { NotificationSchema } from 'libs/notifications/data'; +import { Activity } from 'libs/queries/extApi/activity'; export type NotificationStatus = 'pending' | 'failed' | 'success'; -export interface Notification { +interface BaseNotification { id: string; + timestamp: number; + testid: string; + showAlert?: boolean; + nonPersistent?: boolean; +} + +export interface NotificationTx extends BaseNotification { + type: 'tx'; status: NotificationStatus; title: string; description: string; - timestamp: number; txHash?: string; successTitle?: string; successDesc?: string; failedTitle?: string; failedDesc?: string; - showAlert?: boolean; - nonPersistent?: boolean; - testid: string; } +export interface NotificationActivity extends BaseNotification { + type: 'activity'; + activity: Activity; +} + +export type Notification = NotificationTx | NotificationActivity; + export type DispatchNotification = ( key: T, data: NotificationSchema[T] @@ -34,10 +46,10 @@ export interface NotificationsContext { hasPendingTx: boolean; } -export type NotificationNew = Omit; - export type NotificationsMap = { [key in keyof NotificationSchema]: ( data: NotificationSchema[key] - ) => NotificationNew; + ) => + | Omit + | Omit; }; diff --git a/src/libs/notifications/utils.ts b/src/libs/notifications/utils.ts index b35617eef..95bc033b9 100644 --- a/src/libs/notifications/utils.ts +++ b/src/libs/notifications/utils.ts @@ -1,9 +1,19 @@ -import { Notification } from 'libs/notifications/types'; +import { Notification, NotificationTx } from 'libs/notifications/types'; import { lsService } from 'services/localeStorage'; +// TODO: Remove this migration after July 2024 +type OldNotification = Omit; +const migrateNotification = ( + n: Notification | OldNotification +): Notification => { + if (!('type' in n)) return { ...n, type: 'tx' }; + return n; +}; + export const getLSUserNotifications = (user?: string): Notification[] => { if (!user) return []; - return lsService.getItem(`notifications-${user}`) || []; + const notifications = lsService.getItem(`notifications-${user}`) || []; + return notifications.map(migrateNotification); }; export const setLSUserNotifications = ( diff --git a/src/store/useNotificationsStore.ts b/src/store/useNotificationsStore.ts index c6b77ce5e..4e21093d5 100644 --- a/src/store/useNotificationsStore.ts +++ b/src/store/useNotificationsStore.ts @@ -12,7 +12,7 @@ export const useNotificationsStore = (): NotificationsStore => { const [notifications, setNotifications] = useState([]); const hasPendingTx = useMemo( - () => notifications.some((n) => n.status === 'pending'), + () => notifications.some((n) => n.type === 'tx' && n.status === 'pending'), [notifications] );