From 765f39ec5e58f995b92f718433cda2a227022592 Mon Sep 17 00:00:00 2001 From: Bill He Date: Mon, 28 Aug 2023 23:56:47 -0700 Subject: [PATCH] Deposit/Withdraw Status Notifications --- src/App.tsx | 12 +- src/components/Toast.tsx | 6 +- src/constants/localStorage.ts | 1 + src/constants/localization/app.ts | 5 + src/constants/notifications.ts | 11 +- src/hooks/useNotificationTypes.tsx | 227 ++++++++++-------- src/hooks/useNotifications.tsx | 46 +++- src/layout/NotificationsToastArea.tsx | 1 + src/localization/en/app.json | 7 +- src/{components => views}/TransferStatus.tsx | 58 ++--- src/views/TransferStatusSteps.tsx | 178 ++++++++++++++ .../AccountManagementForms/DepositForm.tsx | 41 +--- .../AccountManagementForms/WithdrawForm.tsx | 9 +- src/views/menus/NotificationsMenu.tsx | 6 +- 14 files changed, 417 insertions(+), 191 deletions(-) rename src/{components => views}/TransferStatus.tsx (71%) create mode 100644 src/views/TransferStatusSteps.tsx diff --git a/src/App.tsx b/src/App.tsx index 26d695c..7040e37 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -106,13 +106,13 @@ const App = () => ( - - - + + + - - - + + + diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 4d84511..7aba924 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -17,6 +17,7 @@ type ElementProps = { slotIcon?: React.ReactNode; slotTitle?: React.ReactNode; slotDescription?: React.ReactNode; + slotCustomContent?: React.ReactNode; slotAction?: React.ReactNode; actionDescription?: string; actionAltText?: string; @@ -33,6 +34,7 @@ export const Toast = ({ slotIcon, slotTitle, slotDescription, + slotCustomContent, slotAction, actionDescription = '', actionAltText = actionDescription, @@ -78,8 +80,8 @@ export const Toast = ({ <$Title>{slotTitle} - - <$Description>{slotDescription} + {!slotCustomContent && <$Description>{slotDescription}} + {slotCustomContent} {actionDescription && ( <$Action asChild altText={actionAltText}> {slotAction} diff --git a/src/constants/localStorage.ts b/src/constants/localStorage.ts index 919900e..03f9f80 100644 --- a/src/constants/localStorage.ts +++ b/src/constants/localStorage.ts @@ -12,6 +12,7 @@ export enum LocalStorageKey { NotificationsLastUpdated = 'dydx.NotificationsLastUpdated', PushNotificationsEnabled = 'dydx.PushNotificationsEnabled', PushNotificationsLastUpdated = 'dydx.PushNotificationsLastUpdated', + TransferNotifications = 'dydx.TransferNotifications', // UI State LastViewedMarket = 'dydx.LastViewedMarket', diff --git a/src/constants/localization/app.ts b/src/constants/localization/app.ts index 0942a2b..573a1c5 100644 --- a/src/constants/localization/app.ts +++ b/src/constants/localization/app.ts @@ -902,6 +902,7 @@ export const APP_STRING_KEYS = { '66_REDUCTION': 'NOTIFICATIONS.66_REDUCTION', ADJUSTED_IMR: 'NOTIFICATIONS.ADJUSTED_IMR', ADJUSTED_IMR_BODY: 'NOTIFICATIONS.ADJUSTED_IMR_BODY', + BRIDGING_TOKENS: "NOTIFICATIONS.BRIDGING_TOKENS", CLOSE_MARKET_POSITIONS: 'NOTIFICATIONS.CLOSE_MARKET_POSITIONS', CLOSE_MARKET_POSITIONS_BODY: 'NOTIFICATIONS.CLOSE_MARKET_POSITIONS_BODY', COMPLIANCE_ALERT: 'NOTIFICATIONS.COMPLIANCE_ALERT', @@ -909,6 +910,7 @@ export const APP_STRING_KEYS = { DEPOSIT_IN_PROGRESS: 'NOTIFICATIONS.DEPOSIT_IN_PROGRESS', DEPOSIT_SUCCESS_DESCRIPTION: 'NOTIFICATIONS.DEPOSIT_SUCCESS_DESCRIPTION', DEPOSIT_SUCCESS: 'NOTIFICATIONS.DEPOSIT_SUCCESS', + DEPOSIT_TO_CHAIN: "NOTIFICATIONS.DEPOSIT_TO_CHAIN", DIRECT_TRANSFER_SUCCESS: 'NOTIFICATIONS.DIRECT_TRANSFER_SUCCESS', DIRECT_TRANSFER_SUCCESS_DESCRIPTION: 'NOTIFICATIONS.DIRECT_TRANSFER_SUCCESS_DESCRIPTION', EPOCH_REWARDS: 'NOTIFICATIONS.EPOCH_REWARDS', @@ -917,6 +919,8 @@ export const APP_STRING_KEYS = { FAST_WITHDRAW_PENDING: 'NOTIFICATIONS.FAST_WITHDRAW_PENDING', FAST_WITHDRAW_SUCCESS_DESCRIPTION: 'NOTIFICATIONS.FAST_WITHDRAW_SUCCESS_DESCRIPTION', FAST_WITHDRAW_SUCCESS: 'NOTIFICATIONS.FAST_WITHDRAW_SUCCESS', + INITIATED_DEPOSIT: "NOTIFICATIONS.INITIATED_DEPOSIT", + INITIATED_WITHDRAWAL: "NOTIFICATIONS.INITIATED_WITHDRAWAL", LEGAL_UPDATES: 'NOTIFICATIONS.LEGAL_UPDATES', LEGAL_UPDATES_DESCRIPTION: 'NOTIFICATIONS.LEGAL_UPDATES_DESCRIPTION', MAINTENANCE_MARGIN_ADJUSTMENTS: 'NOTIFICATIONS.MAINTENANCE_MARGIN_ADJUSTMENTS', @@ -934,6 +938,7 @@ export const APP_STRING_KEYS = { SUSPICIOUS_TRADE: 'NOTIFICATIONS.SUSPICIOUS_TRADE', SUSPICIOUS_TRADE_BODY: 'NOTIFICATIONS.SUSPICIOUS_TRADE_BODY', WITHDRAW_IN_PROGRESS: 'NOTIFICATIONS.WITHDRAW_IN_PROGRESS', + WITHDRAW_TO_CHAIN: "NOTIFICATIONS.WITHDRAW_TO_CHAIN", // Email Notifications ACCOUNT_DESCRIPTION: 'EMAIL_NOTIFICATIONS.ACCOUNT_DESCRIPTION', diff --git a/src/constants/notifications.ts b/src/constants/notifications.ts index bde7409..698158d 100644 --- a/src/constants/notifications.ts +++ b/src/constants/notifications.ts @@ -1,3 +1,5 @@ +import { StatusResponse } from "@0xsquid/sdk"; + /** implemented in useNotificationTypes */ export enum NotificationType { OrderStatusChanged = 'OrderStatusChanged', @@ -83,6 +85,12 @@ export type NotificationDisplayData = { description?: React.ReactNode; + customContent?: React.ReactNode; + + customMenuContent?: React.ReactNode; + + customMenuSlotAfter?: React.ReactNode; + actionDescription?: string; /** Screen reader: instructions for performing toast action after its timer expires */ @@ -112,8 +120,9 @@ export type NotificationDisplayData = { // Notification types export type TransferNotifcation = { txHash: string; - toChainId: string; + toChainId?: string; fromChainId?: string; toAmount?: number; triggeredAt?: number; + status?: StatusResponse; }; diff --git a/src/hooks/useNotificationTypes.tsx b/src/hooks/useNotificationTypes.tsx index ba662e8..cea23f5 100644 --- a/src/hooks/useNotificationTypes.tsx +++ b/src/hooks/useNotificationTypes.tsx @@ -1,15 +1,19 @@ -import { useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useSelector, shallowEqual, useDispatch } from 'react-redux'; import { groupBy } from 'lodash'; import { AbacusOrderStatus, ORDER_SIDES, ORDER_STATUS_STRINGS } from '@/constants/abacus'; import { DialogTypes } from '@/constants/dialogs'; import { STRING_KEYS } from '@/constants/localization'; -import { type NotificationTypeConfig, type TransferNotifcation, NotificationType } from '@/constants/notifications'; +import { + type NotificationTypeConfig, + type TransferNotifcation, + NotificationType, +} from '@/constants/notifications'; import { ORDER_SIDE_STRINGS, TRADE_TYPE_STRINGS, TradeTypes } from '@/constants/trade'; import { Icon, IconName } from '@/components/Icon'; -import { TransferStatusToast } from '@/components/TransferStatus'; +import { TransferStatusToast } from '@/views/TransferStatus'; import { getSquidTransfers, @@ -21,113 +25,126 @@ import { openDialog } from '@/state/dialogs'; import { OrderStatusIcon } from '@/views/OrderStatusIcon'; import { useStringGetter } from './useStringGetter'; +import { TransferStatusSteps } from '@/views/TransferStatusSteps'; +import { TESTNET_CHAIN_ID } from '@dydxprotocol/v4-client'; -export const notificationTypes = ( - transferNotifications: TransferNotifcation[] -) => [ - { - type: NotificationType.OrderStatusChanged, +export const notificationTypes = (transferNotifications: TransferNotifcation[]) => + [ + { + type: NotificationType.OrderStatusChanged, - useTrigger: ({ trigger, lastUpdated }) => { - const stringGetter = useStringGetter(); + useTrigger: ({ trigger, lastUpdated }) => { + const stringGetter = useStringGetter(); - const orders = useSelector(getSubaccountOrders, shallowEqual) || []; - const ordersByOrderId = Object.fromEntries(orders.map((order) => [order.id, order])); + const orders = useSelector(getSubaccountOrders, shallowEqual) || []; + const ordersByOrderId = Object.fromEntries(orders.map((order) => [order.id, order])); - const fills = useSelector(getSubaccountFills, shallowEqual) || []; - const fillsByOrderId = groupBy(fills, (fill) => fill.orderId); + const fills = useSelector(getSubaccountFills, shallowEqual) || []; + const fillsByOrderId = groupBy(fills, (fill) => fill.orderId); - const orderIds = useMemo( - () => [...Object.keys(ordersByOrderId), ...Object.keys(fillsByOrderId)], - [orders, fills] - ); - - useEffect(() => { - for (const orderId of orderIds) { - const fills = fillsByOrderId[orderId]; - - const order = - ordersByOrderId[orderId] ?? - (fills?.length - ? { - ...fills[fills.length - 1], - id: orderId, - createdAtMilliseconds: Math.max( - ...fills.map((fill) => fill.createdAtMilliseconds) - ), - status: AbacusOrderStatus.filled, - } - : undefined); - - if (order) - trigger( - order.id, - { - icon: ( - - ), - title: `${stringGetter({ - key: TRADE_TYPE_STRINGS[order.type.rawValue as TradeTypes].tradeTypeKey, - })} ${ - order.status === AbacusOrderStatus.open && order.totalFilled > 0 - ? stringGetter({ key: STRING_KEYS.PARTIALLY_FILLED }) - : stringGetter({ key: ORDER_STATUS_STRINGS[order.status.name] }) - }`, - description: `${stringGetter({ - key: ORDER_SIDE_STRINGS[ORDER_SIDES[order.side.name]], - })} ${order.size} ${order.marketId} @ $${order.price}`, - actionDescription: 'View Order', - actionAltText: 'View this order in the Orders tab or the Notifications menu.', - toastSensitivity: - order.status === AbacusOrderStatus.pending ? 'foreground' : 'background', - toastDuration: 5000, - }, - [order.status.name, order.size], - !order.createdAtMilliseconds || order.createdAtMilliseconds > lastUpdated - ); - } - }, [orderIds]); - }, - - useNotificationAction: () => { - const dispatch = useDispatch(); - - return (orderId) => { - dispatch( - openDialog({ - type: DialogTypes.OrderDetails, - dialogProps: { orderId }, - }) + const orderIds = useMemo( + () => [...Object.keys(ordersByOrderId), ...Object.keys(fillsByOrderId)], + [orders, fills] ); - }; - }, - } as NotificationTypeConfig, - { - type: NotificationType.SquidTransfer, - useTrigger: ({ trigger, lastUpdated }) => { - useEffect(() => { - for (const transfer of transferNotifications) { - const transferHash = transfer.txHash; - trigger( - transferHash, - { - icon: , - title: `Deposit in progress...`, - description: ( - - ), - toastSensitivity: 'foreground', - }, - [], + + useEffect(() => { + for (const orderId of orderIds) { + const fills = fillsByOrderId[orderId]; + + const order = + ordersByOrderId[orderId] ?? + (fills?.length + ? { + ...fills[fills.length - 1], + id: orderId, + createdAtMilliseconds: Math.max( + ...fills.map((fill) => fill.createdAtMilliseconds) + ), + status: AbacusOrderStatus.filled, + } + : undefined); + + if (order) + trigger( + order.id, + { + icon: ( + + ), + title: `${stringGetter({ + key: TRADE_TYPE_STRINGS[order.type.rawValue as TradeTypes].tradeTypeKey, + })} ${ + order.status === AbacusOrderStatus.open && order.totalFilled > 0 + ? stringGetter({ key: STRING_KEYS.PARTIALLY_FILLED }) + : stringGetter({ key: ORDER_STATUS_STRINGS[order.status.name] }) + }`, + description: `${stringGetter({ + key: ORDER_SIDE_STRINGS[ORDER_SIDES[order.side.name]], + })} ${order.size} ${order.marketId} @ $${order.price}`, + actionDescription: 'View Order', + actionAltText: 'View this order in the Orders tab or the Notifications menu.', + toastSensitivity: + order.status === AbacusOrderStatus.pending ? 'foreground' : 'background', + toastDuration: 5000, + }, + [order.status.name, order.size], + !order.createdAtMilliseconds || order.createdAtMilliseconds > lastUpdated + ); + } + }, [orderIds]); + }, + + useNotificationAction: () => { + const dispatch = useDispatch(); + + return (orderId) => { + dispatch( + openDialog({ + type: DialogTypes.OrderDetails, + dialogProps: { orderId }, + }) ); - } - }, [transferNotifications]); + }; + }, + } as NotificationTypeConfig, + { + type: NotificationType.SquidTransfer, + useTrigger: ({ trigger, lastUpdated }) => { + const stringGetter = useStringGetter(); + + const getTitleStringKey = useCallback((type: 'deposit' | 'withdraw', finished: boolean) => { + if (type === 'deposit' && !finished) return STRING_KEYS.DEPOSIT_IN_PROGRESS; + if (type === 'deposit' && finished) return STRING_KEYS.DEPOSIT; + if (type === 'withdraw' && !finished) return STRING_KEYS.WITHDRAW_IN_PROGRESS; + return STRING_KEYS.WITHDRAW; + }, []); + + useEffect(() => { + for (const transfer of transferNotifications) { + const { toChainId, status, txHash, toAmount } = transfer; + const finished = Boolean(status) && status?.squidTransactionStatus !== 'ongoing'; + const type = toChainId === TESTNET_CHAIN_ID ? 'deposit' : 'withdraw'; + + trigger( + txHash, + { + icon: , + title: stringGetter({ key: getTitleStringKey(type, finished) }), + description: type === 'deposit' ? `Deposit of $${toAmount}` : `Withdraw of ${toAmount}`, + customContent: ( + + ), + customMenuContent: !finished && , + toastSensitivity: 'foreground', + }, + [] + ); + } + }, [transferNotifications]); + }, }, - }, -] satisfies NotificationTypeConfig[]; + ] satisfies NotificationTypeConfig[]; diff --git a/src/hooks/useNotifications.tsx b/src/hooks/useNotifications.tsx index 77c755b..c4677f6 100644 --- a/src/hooks/useNotifications.tsx +++ b/src/hooks/useNotifications.tsx @@ -1,4 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useQuery } from 'react-query'; import { LocalStorageKey } from '@/constants/localStorage'; import { @@ -9,11 +10,12 @@ import { NotificationStatus, } from '@/constants/notifications'; +import { useSquid } from '@/hooks/useSquid'; import { useLocalStorage } from './useLocalStorage'; import { notificationTypes as notificationTypesFactory } from './useNotificationTypes'; import { renderSvgToDataUrl } from '../lib/renderSvgToDataUrl'; -import { set } from 'lodash'; +import { StatusResponse } from '@0xsquid/sdk'; const NotificationsContext = createContext | undefined>( undefined @@ -39,8 +41,11 @@ const useNotificationsContext = () => { defaultValue: Date.now(), }); - // Front-end only notifications - const [transferNotifications, setTransferNotifications] = useState([]); + // transfer notifications + const [transferNotifications, setTransferNotifications] = useLocalStorage({ + key: LocalStorageKey.TransferNotifications, + defaultValue: [], + }); const addTransferNotification = useCallback( (notification: TransferNotifcation) => @@ -53,6 +58,41 @@ const useNotificationsContext = () => { [transferNotifications] ); + const squid = useSquid(); + + const { data: transferStatuses } = useQuery({ + queryKey: ['getTransactionStatus', transferNotifications], + queryFn: async () => { + const statuses: { [key: string]: StatusResponse } = {}; + for (const { + txHash, + toChainId, + fromChainId, + status: currentStatus, + } of transferNotifications) { + if (currentStatus && currentStatus?.squidTransactionStatus !== 'ongoing') continue; + + const status = await squid?.getStatus({ transactionId: txHash, toChainId, fromChainId }); + if (status) statuses[txHash] = status; + } + return statuses; + }, + refetchInterval: 10_000, + }); + + useEffect(() => { + if (!transferStatuses) return; + const newTransferNotifications = transferNotifications.map((notification) => { + const status = transferStatuses[notification.txHash]; + if (!status) return notification; + return { + ...notification, + status, + }; + }); + setTransferNotifications(newTransferNotifications); + }, [transferStatuses]); + useEffect(() => { setNotificationsLastUpdated(Date.now()); }, [notifications]); diff --git a/src/layout/NotificationsToastArea.tsx b/src/layout/NotificationsToastArea.tsx index 69e7e61..d86ab60 100644 --- a/src/layout/NotificationsToastArea.tsx +++ b/src/layout/NotificationsToastArea.tsx @@ -41,6 +41,7 @@ export const NotificationsToastArea = ({ className }: StyleProps) => { slotIcon={displayData.icon} slotTitle={displayData.title} slotDescription={displayData.description} + slotCustomContent={displayData.customContent} slotAction={