Notifications Refresh (#135)

* Remove X button from tablet/mobile Notification menu

* Toast styling

* missed in merge

* .env.example

* Update abacus@1.0.9

* Add notif strings, and abaucs notif state

* Update Notification hooks, add Notification Preference controls

* Consolidate Notification into single component

* withLabel -> withTrigger prop

* nits

* OrderStatusChanged -> AbacusGenerated

* Notification OnClick

* Move Notification -> Components

* withAnimation -> $withAnimation

* Notification styling and click handle

* load state, onClick, localStorage

* add error count

* Rez localStorage Notifications

* Update TransferStatus UI

* Use Notification component for other abacusNotifs

* fix uselocalnotif query

* Default to Notification from NotificationMenu

---------

Co-authored-by: Bill He <bill@dydx.exchange>
This commit is contained in:
Jared Vu
2023-11-10 20:55:34 -08:00
committed by GitHub
co-authored by Bill He
parent 876f620ada
commit 99e708a984
38 changed files with 1516 additions and 802 deletions
+1 -1
View File
@@ -111,7 +111,7 @@
"styled-components": "^5.3.11",
"use-latest": "^1.2.1",
"viem": "^1.12.2",
"wagmi": "^1.3.7"
"wagmi": "^1.4.5"
},
"devDependencies": {
"@babel/core": "^7.22.5",
+68 -69
View File
@@ -239,8 +239,8 @@ dependencies:
specifier: ^1.12.2
version: 1.12.2(typescript@5.1.3)
wagmi:
specifier: ^1.3.7
version: 1.3.7(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2)
specifier: ^1.4.5
version: 1.4.5(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2)
devDependencies:
'@babel/core':
@@ -5975,26 +5975,12 @@ packages:
pretty-format: 27.5.1
dev: true
/@wagmi/chains@1.4.0(typescript@5.1.3):
resolution: {integrity: sha512-9HwJrhcZ1TxyrCbE10y7s1eSiSiyfGam7AHIOLYExaOX+vpOZ+MNTt4orFEDbEpz1fxwJDPPI38lanAUix1OSA==}
/@wagmi/connectors@3.1.3(@types/react@18.2.14)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2):
resolution: {integrity: sha512-UgwsQKQDFObJVJMf9pDfFoXTv710o4zrTHyhIWKBTMMkLpCMsMxN5+ZaDhBYt/BgoRinfRYQo8uwuwLhxE6Log==}
peerDependencies:
typescript: '>=5.0.4'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
typescript: 5.1.3
dev: false
/@wagmi/connectors@2.6.5(@wagmi/chains@1.4.0)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2):
resolution: {integrity: sha512-klF31togMDd0qQqEcLl5cCGxjMbL0RRXQ8I1vxmEa3KdGzw6Z3ICVzX7/bDfnNEZcOW7BKyAnZDq7rCt5jTOiw==}
peerDependencies:
'@wagmi/chains': '>=1.3.0'
typescript: '>=5.0.4'
viem: '>=0.3.35'
peerDependenciesMeta:
'@wagmi/chains':
optional: true
typescript:
optional: true
dependencies:
@@ -6002,17 +5988,17 @@ packages:
'@ledgerhq/connect-kit-loader': 1.1.2
'@safe-global/safe-apps-provider': 0.17.1(typescript@5.1.3)
'@safe-global/safe-apps-sdk': 8.1.0(typescript@5.1.3)
'@wagmi/chains': 1.4.0(typescript@5.1.3)
'@walletconnect/ethereum-provider': 2.8.6(@walletconnect/modal@2.5.9)
'@walletconnect/ethereum-provider': 2.10.2(@walletconnect/modal@2.6.2)
'@walletconnect/legacy-provider': 2.0.0
'@walletconnect/modal': 2.5.9(react@18.2.0)
'@walletconnect/utils': 2.8.6
'@walletconnect/modal': 2.6.2(@types/react@18.2.14)(react@18.2.0)
'@walletconnect/utils': 2.10.2
abitype: 0.8.7(typescript@5.1.3)
eventemitter3: 4.0.7
typescript: 5.1.3
viem: 1.12.2(typescript@5.1.3)
transitivePeerDependencies:
- '@react-native-async-storage/async-storage'
- '@types/react'
- bufferutil
- encoding
- lokijs
@@ -6022,8 +6008,8 @@ packages:
- zod
dev: false
/@wagmi/core@1.3.6(@types/react@18.2.14)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2):
resolution: {integrity: sha512-TXv9ZlRR5aySfERFuWMuo+lKXC/CoqtxVJZVHPqhK1jY+nldMx3AvrWzzF4CccRaMYcVdvPFepvmxzq2A2VvWg==}
/@wagmi/core@1.4.5(@types/react@18.2.14)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2):
resolution: {integrity: sha512-N9luRb1Uk4tBN9kaYcQSWKE9AsRt/rvZaFt5IZech4JPzNN2sQlfhKd9GEjOXYRDqEPHdDvos7qyBKiDNTz4GA==}
peerDependencies:
typescript: '>=5.0.4'
viem: '>=0.3.35'
@@ -6031,8 +6017,7 @@ packages:
typescript:
optional: true
dependencies:
'@wagmi/chains': 1.4.0(typescript@5.1.3)
'@wagmi/connectors': 2.6.5(@wagmi/chains@1.4.0)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2)
'@wagmi/connectors': 3.1.3(@types/react@18.2.14)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2)
abitype: 0.8.7(typescript@5.1.3)
eventemitter3: 4.0.7
typescript: 5.1.3
@@ -6077,8 +6062,8 @@ packages:
- utf-8-validate
dev: false
/@walletconnect/core@2.8.6:
resolution: {integrity: sha512-rnSqm1KJLcww/v6+UH8JeibQkJ3EKgyUDPfEK0stSEkrIUIcXaFlq3Et8S+vgV8bPhI0MVUhAhFL5OJZ3t2ryg==}
/@walletconnect/core@2.10.2:
resolution: {integrity: sha512-JQz/xp3SLEpTeRQctdck2ugSBVEpMxoSE+lFi2voJkZop1hv6P+uqr6E4PzjFluAjeAnKlT1xvra0aFWjPWVcw==}
dependencies:
'@walletconnect/heartbeat': 1.2.1
'@walletconnect/jsonrpc-provider': 1.0.13
@@ -6091,8 +6076,8 @@ packages:
'@walletconnect/relay-auth': 1.0.4
'@walletconnect/safe-json': 1.0.2
'@walletconnect/time': 1.0.2
'@walletconnect/types': 2.8.6
'@walletconnect/utils': 2.8.6
'@walletconnect/types': 2.10.2
'@walletconnect/utils': 2.10.2
events: 3.3.0
lodash.isequal: 4.5.0
uint8arrays: 3.1.1
@@ -6128,8 +6113,8 @@ packages:
tslib: 1.14.1
dev: false
/@walletconnect/ethereum-provider@2.8.6(@walletconnect/modal@2.5.9):
resolution: {integrity: sha512-wUvJEsXTLmMihrOhQxAs1k9hrWEOT03QBn54P9r9GpJbJ1zEfIjQaXFfi8uup6gldhH+vN38PsbOiLyv/6d3qQ==}
/@walletconnect/ethereum-provider@2.10.2(@walletconnect/modal@2.6.2):
resolution: {integrity: sha512-QMYFZ6+rVq2CJLdIPdKK0j1Qm66UA27oQU5V2SrL8EVwl7wFfm0Bq7fnL+qAWeDpn612dNeNErpk/ROa1zWlWg==}
peerDependencies:
'@walletconnect/modal': '>=2'
peerDependenciesMeta:
@@ -6140,11 +6125,11 @@ packages:
'@walletconnect/jsonrpc-provider': 1.0.13
'@walletconnect/jsonrpc-types': 1.0.3
'@walletconnect/jsonrpc-utils': 1.0.8
'@walletconnect/modal': 2.5.9(react@18.2.0)
'@walletconnect/sign-client': 2.8.6
'@walletconnect/types': 2.8.6
'@walletconnect/universal-provider': 2.8.6
'@walletconnect/utils': 2.8.6
'@walletconnect/modal': 2.6.2(@types/react@18.2.14)(react@18.2.0)
'@walletconnect/sign-client': 2.10.2
'@walletconnect/types': 2.10.2
'@walletconnect/universal-provider': 2.10.2
'@walletconnect/utils': 2.10.2
events: 3.3.0
transitivePeerDependencies:
- '@react-native-async-storage/async-storage'
@@ -6295,32 +6280,34 @@ packages:
tslib: 1.14.1
dev: false
/@walletconnect/modal-core@2.5.9(react@18.2.0):
resolution: {integrity: sha512-isIebwF9hOknGouhS/Ob4YJ9Sa/tqNYG2v6Ua9EkCqIoLimepkG5eC53tslUWW29SLSfQ9qqBNG2+iE7yQXqgw==}
/@walletconnect/modal-core@2.6.2(@types/react@18.2.14)(react@18.2.0):
resolution: {integrity: sha512-cv8ibvdOJQv2B+nyxP9IIFdxvQznMz8OOr/oR/AaUZym4hjXNL/l1a2UlSQBXrVjo3xxbouMxLb3kBsHoYP2CA==}
dependencies:
buffer: 6.0.3
valtio: 1.10.6(react@18.2.0)
valtio: 1.11.2(@types/react@18.2.14)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
- react
dev: false
/@walletconnect/modal-ui@2.5.9(react@18.2.0):
resolution: {integrity: sha512-nfBaAT9Ls7RZTBBgAq+Nt/3AoUcinIJ9bcq5UHXTV3lOPu/qCKmUC/0HY3GvUK8ykabUAsjr0OAGmcqkB91qug==}
/@walletconnect/modal-ui@2.6.2(@types/react@18.2.14)(react@18.2.0):
resolution: {integrity: sha512-rbdstM1HPGvr7jprQkyPggX7rP4XiCG85ZA+zWBEX0dVQg8PpAgRUqpeub4xQKDgY7pY/xLRXSiCVdWGqvG2HA==}
dependencies:
'@walletconnect/modal-core': 2.5.9(react@18.2.0)
lit: 2.7.5
'@walletconnect/modal-core': 2.6.2(@types/react@18.2.14)(react@18.2.0)
lit: 2.8.0
motion: 10.16.2
qrcode: 1.5.3
transitivePeerDependencies:
- '@types/react'
- react
dev: false
/@walletconnect/modal@2.5.9(react@18.2.0):
resolution: {integrity: sha512-Zs2RvPwbBNRdBhb50FuJCxi3FJltt1KSpI7odjU/x9GTpTOcSOkmR66PBCy2JvNA0+ztnS1Xs0LVEr3lu7/Jzw==}
/@walletconnect/modal@2.6.2(@types/react@18.2.14)(react@18.2.0):
resolution: {integrity: sha512-eFopgKi8AjKf/0U4SemvcYw9zlLpx9njVN8sf6DAkowC2Md0gPU/UNEbH1Wwj407pEKnEds98pKWib1NN1ACoA==}
dependencies:
'@walletconnect/modal-core': 2.5.9(react@18.2.0)
'@walletconnect/modal-ui': 2.5.9(react@18.2.0)
'@walletconnect/modal-core': 2.6.2(@types/react@18.2.14)(react@18.2.0)
'@walletconnect/modal-ui': 2.6.2(@types/react@18.2.14)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
- react
dev: false
@@ -6376,17 +6363,17 @@ packages:
- utf-8-validate
dev: false
/@walletconnect/sign-client@2.8.6:
resolution: {integrity: sha512-rOFTKTHP7oJfXgYHX7+SdB8VbcsEE3ZFG/bMdmZboWaBim1mrY3vUyDdKrNr0VgI3AwBiEQezQDfKxBX0pMSQQ==}
/@walletconnect/sign-client@2.10.2:
resolution: {integrity: sha512-vviSLV3f92I0bReX+OLr1HmbH0uIzYEQQFd1MzIfDk9PkfFT/LLAHhUnDaIAMkIdippqDcJia+5QEtT4JihL3Q==}
dependencies:
'@walletconnect/core': 2.8.6
'@walletconnect/core': 2.10.2
'@walletconnect/events': 1.0.1
'@walletconnect/heartbeat': 1.2.1
'@walletconnect/jsonrpc-utils': 1.0.8
'@walletconnect/logger': 2.0.1
'@walletconnect/time': 1.0.2
'@walletconnect/types': 2.8.6
'@walletconnect/utils': 2.8.6
'@walletconnect/types': 2.10.2
'@walletconnect/utils': 2.10.2
events: 3.3.0
transitivePeerDependencies:
- '@react-native-async-storage/async-storage'
@@ -6415,8 +6402,8 @@ packages:
- lokijs
dev: false
/@walletconnect/types@2.8.6:
resolution: {integrity: sha512-Z/PFa3W1XdxeTcCtdR6lUsFgZfU/69wWJBPyclPwn7cu1+eriuCr6XZXQpJjib3flU+HnwHiXeUuqZaheehPxw==}
/@walletconnect/types@2.10.2:
resolution: {integrity: sha512-luNV+07Wdla4STi9AejseCQY31tzWKQ5a7C3zZZaRK/di+rFaAAb7YW04OP4klE7tw/mJRGPTlekZElmHxO8kQ==}
dependencies:
'@walletconnect/events': 1.0.1
'@walletconnect/heartbeat': 1.2.1
@@ -6429,17 +6416,17 @@ packages:
- lokijs
dev: false
/@walletconnect/universal-provider@2.8.6:
resolution: {integrity: sha512-ln1RVv8+oHu9enOJ/oVkjiarneB+4vJCk16znOklIN2JtDHwB8iObDHlQH3UE6ynNTw1iRvaGuPR4g+YdIfB6w==}
/@walletconnect/universal-provider@2.10.2:
resolution: {integrity: sha512-wFgI0LbQ3D56sgaUMsgOHCM5m8WLxiC71BGuCKQfApgsbNMVKugYVy2zWHyUyi8sqTQHI+uSaVpDev4UHq9LEw==}
dependencies:
'@walletconnect/jsonrpc-http-connection': 1.0.7
'@walletconnect/jsonrpc-provider': 1.0.13
'@walletconnect/jsonrpc-types': 1.0.3
'@walletconnect/jsonrpc-utils': 1.0.8
'@walletconnect/logger': 2.0.1
'@walletconnect/sign-client': 2.8.6
'@walletconnect/types': 2.8.6
'@walletconnect/utils': 2.8.6
'@walletconnect/sign-client': 2.10.2
'@walletconnect/types': 2.10.2
'@walletconnect/utils': 2.10.2
events: 3.3.0
transitivePeerDependencies:
- '@react-native-async-storage/async-storage'
@@ -6471,8 +6458,8 @@ packages:
- lokijs
dev: false
/@walletconnect/utils@2.8.6:
resolution: {integrity: sha512-wcy6e5+COYo7tfNnW8YqidnATdJDIW6vDiWWE7A1F78Sl/VflkaevB9cIgyn8eLdxC1SxXgGoeC2oLP90nnHJg==}
/@walletconnect/utils@2.10.2:
resolution: {integrity: sha512-syxXRpc2yhSknMu3IfiBGobxOY7fLfLTJuw+ppKaeO6WUdZpIit3wfuGOcc0Ms3ZPFCrGfyGOoZsCvgdXtptRg==}
dependencies:
'@stablelib/chacha20poly1305': 1.0.1
'@stablelib/hkdf': 1.0.1
@@ -6482,7 +6469,7 @@ packages:
'@walletconnect/relay-api': 1.0.9
'@walletconnect/safe-json': 1.0.2
'@walletconnect/time': 1.0.2
'@walletconnect/types': 2.8.6
'@walletconnect/types': 2.10.2
'@walletconnect/window-getters': 1.0.1
'@walletconnect/window-metadata': 1.0.1
detect-browser: 5.3.0
@@ -10411,6 +10398,14 @@ packages:
lit-html: 2.8.0
dev: false
/lit@2.8.0:
resolution: {integrity: sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==}
dependencies:
'@lit/reactive-element': 1.6.3
lit-element: 3.3.3
lit-html: 2.8.0
dev: false
/loader-runner@4.3.0:
resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==}
engines: {node: '>=6.11.5'}
@@ -13544,15 +13539,19 @@ packages:
use-sync-external-store: 1.2.0(react@18.2.0)
dev: false
/valtio@1.10.6(react@18.2.0):
resolution: {integrity: sha512-SxN1bHUmdhW6V8qsQTpCgJEwp7uHbntuH0S9cdLQtiohuevwBksbpXjwj5uDMA7bLwg1WKyq9sEpZrx3TIMrkA==}
/valtio@1.11.2(@types/react@18.2.14)(react@18.2.0):
resolution: {integrity: sha512-1XfIxnUXzyswPAPXo1P3Pdx2mq/pIqZICkWN60Hby0d9Iqb+MEIpqgYVlbflvHdrp2YR/q3jyKWRPJJ100yxaw==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=16.8'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
react:
optional: true
dependencies:
'@types/react': 18.2.14
proxy-compare: 2.5.1
react: 18.2.0
use-sync-external-store: 1.2.0(react@18.2.0)
@@ -13775,8 +13774,8 @@ packages:
- supports-color
dev: true
/wagmi@1.3.7(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2):
resolution: {integrity: sha512-n4Qwby0QB8/JSGjc0gGn4MWZ0BT229agEg6saPGS6VswmPjJLdkLq9qxKn1JptXxPREmb/ZvwHxU++3Z+gQJVA==}
/wagmi@1.4.5(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2):
resolution: {integrity: sha512-Ph62E6cO5n2Z8Z5LTyZrkaNprxTsbC4w0qZJT4OJdXrEELziI8z/b4FO6amVFXdu2rDp/wpvF56e4mhKC8/Kdw==}
peerDependencies:
react: '>=17.0.0'
typescript: '>=5.0.4'
@@ -13788,7 +13787,7 @@ packages:
'@tanstack/query-sync-storage-persister': 4.32.6
'@tanstack/react-query': 4.32.6(react-dom@18.2.0)(react@18.2.0)
'@tanstack/react-query-persist-client': 4.32.6(@tanstack/react-query@4.32.6)
'@wagmi/core': 1.3.6(@types/react@18.2.14)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2)
'@wagmi/core': 1.4.5(@types/react@18.2.14)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2)
abitype: 0.8.7(typescript@5.1.3)
react: 18.2.0
typescript: 5.1.3
+5 -6
View File
@@ -1,16 +1,13 @@
import type { Story } from '@ladle/react';
import { Collapsible } from '@/components/Collapsible';
import { Collapsible, type CollapsibleProps } from '@/components/Collapsible';
import { StoryWrapper } from '.ladle/components';
import { IconName } from './Icon';
export const CollapsibleStory: Story<Parameters<typeof Collapsible>> = (args) => (
export const CollapsibleStory: Story<CollapsibleProps> = (args) => (
<StoryWrapper>
<Collapsible
label="Collapsible List of Items"
{...args}
>
<Collapsible {...args}>
<ul>
<li>Collapsible Item 1</li>
<li>Collapsible Item 2</li>
@@ -23,6 +20,8 @@ export const CollapsibleStory: Story<Parameters<typeof Collapsible>> = (args) =>
CollapsibleStory.args = {
disabled: false,
withTrigger: true,
label: 'Collapsible List of Items',
};
CollapsibleStory.argTypes = {
+24 -18
View File
@@ -14,6 +14,7 @@ type ElementProps = {
label: React.ReactNode;
triggerIcon?: IconName;
children: React.ReactNode;
withTrigger?: boolean;
};
type StyleProps = {
@@ -23,6 +24,8 @@ type StyleProps = {
fullWidth?: boolean;
};
export type CollapsibleProps = ElementProps & StyleProps;
export const Collapsible = ({
defaultOpen = false,
disabled,
@@ -35,26 +38,29 @@ export const Collapsible = ({
triggerIconSide = 'left',
fullWidth,
className,
}: ElementProps & StyleProps) => (
withTrigger = true,
}: CollapsibleProps) => (
<Styled.Root defaultOpen={defaultOpen} open={open} onOpenChange={onOpenChange}>
<Styled.Trigger className={className} disabled={disabled}>
{triggerIconSide === 'right' && (
<>
{label}
{fullWidth && <HorizontalSeparatorFiller />}
</>
)}
{withTrigger && (
<Styled.Trigger className={className} disabled={disabled}>
{triggerIconSide === 'right' && (
<>
{label}
{fullWidth && <HorizontalSeparatorFiller />}
</>
)}
<Styled.TriggerIcon>
<Icon iconName={triggerIcon} />
</Styled.TriggerIcon>
{triggerIconSide === 'left' && (
<>
{fullWidth && <HorizontalSeparatorFiller />}
{label}
</>
)}
</Styled.Trigger>
<Styled.TriggerIcon>
<Icon iconName={triggerIcon} />
</Styled.TriggerIcon>
{triggerIconSide === 'left' && (
<>
{fullWidth && <HorizontalSeparatorFiller />}
{label}
</>
)}
</Styled.Trigger>
)}
<Styled.Content $transitionDuration={transitionDuration}>{children}</Styled.Content>
</Styled.Root>
);
+12 -2
View File
@@ -22,7 +22,12 @@ type PickComboxMenuProps<
MenuGroupValue extends string | number
> = Pick<
ComboboxMenuProps<MenuItemValue, MenuGroupValue>,
'inputPlaceholder' | 'onItemSelected' | 'slotEmpty' | 'withSearch' | 'withStickyLayout'
| 'inputPlaceholder'
| 'onItemSelected'
| 'slotEmpty'
| 'withItemBorders'
| 'withSearch'
| 'withStickyLayout'
>;
type PickDialogProps = Pick<
@@ -53,6 +58,7 @@ export const ComboboxDialogMenu = <
onItemSelected,
inputPlaceholder,
slotEmpty,
withItemBorders,
withSearch,
withStickyLayout = true,
children,
@@ -83,6 +89,7 @@ export const ComboboxDialogMenu = <
title={title}
inputPlaceholder={inputPlaceholder}
slotEmpty={slotEmpty}
withItemBorders={withItemBorders}
withSearch={withSearch}
withStickyLayout={withStickyLayout}
/>
@@ -95,6 +102,8 @@ const Styled: Record<string, AnyStyledComponent> = {};
Styled.Dialog = styled(Dialog)`
/* Params */
--comboboxDialogMenu-backgroundColor: var(--color-layer-2);
--comboboxDialogMenu-item-gap: 0.5rem;
--comboboxDialogMenu-item-padding: 0.5rem 1rem;
/* Overrides */
& {
@@ -118,5 +127,6 @@ Styled.Dialog = styled(Dialog)`
Styled.ComboboxMenu = styled(ComboboxMenu)`
--comboboxMenu-backgroundColor: var(--comboboxDialogMenu-backgroundColor);
--comboboxMenu-item-gap: var(--comboxDialogMenu-item-gap, 0.5rem);
--comboboxMenu-item-gap: var(--comboboxDialogMenu-item-gap);
--comboboxMenu-item-padding: var(--comboboxDialogMenu-item-padding);
`;
+50 -17
View File
@@ -2,7 +2,7 @@ import { Fragment, type ReactNode, useState } from 'react';
import styled, { type AnyStyledComponent, css } from 'styled-components';
import { Command } from 'cmdk';
import { type MenuConfig } from '@/constants/menus';
import { MenuItem, type MenuConfig } from '@/constants/menus';
import { popoverMixins } from '@/styles/popoverMixins';
import { layoutMixins } from '@/styles/layoutMixins';
@@ -20,6 +20,7 @@ type ElementProps<MenuItemValue extends string | number, MenuGroupValue extends
type StyleProps = {
className?: string;
withItemBorders?: boolean;
withStickyLayout?: boolean;
};
@@ -38,6 +39,7 @@ export const ComboboxMenu = <MenuItemValue extends string, MenuGroupValue extend
withSearch = true,
className,
withItemBorders,
withStickyLayout,
}: ComboboxMenuProps<MenuItemValue, MenuGroupValue>) => {
const [highlightedCommand, setHighlightedCommand] = useState<MenuItemValue>();
@@ -80,6 +82,7 @@ export const ComboboxMenu = <MenuItemValue extends string, MenuGroupValue extend
<Styled.Group
key={group.group}
heading={group.groupLabel}
$withItemBorders={withItemBorders}
$withStickyLayout={withStickyLayout}
>
{group.items.map((item) => (
@@ -99,22 +102,31 @@ export const ComboboxMenu = <MenuItemValue extends string, MenuGroupValue extend
}
}}
disabled={item.disabled}
$withItemBorders={withItemBorders}
>
{item.slotBefore}
<Styled.ItemLabel>
<span>
{`${item.label}${item.subitems?.length ? '…' : ''}`}
{item.tag && (
<>
{' '}
<Tag>{item.tag}</Tag>
</>
{
<>
{item.slotBefore}
{item.slotCustomContent ?? (
<Styled.ItemLabel>
<span>
{typeof item.label === 'string'
? `${item.label}${item.subitems?.length ? '…' : ''}`
: item.label}
{item.tag && (
<>
{' '}
<Tag>{item.tag}</Tag>
</>
)}
</span>
{item.description && <span>{item.description}</span>}
</Styled.ItemLabel>
)}
</span>
{item.description && <span>{item.description}</span>}
</Styled.ItemLabel>
{item.slotAfter}
{item.subitems && '→'}
{item.slotAfter}
{item.subitems && '→'}
</>
}
</Styled.Item>
{searchValue &&
@@ -137,6 +149,7 @@ export const ComboboxMenu = <MenuItemValue extends string, MenuGroupValue extend
onItemSelected?.();
}}
disabled={subitem.disabled}
$withItemBorders={withItemBorders}
>
{subitem.slotBefore}
<Styled.ItemLabel>
@@ -169,14 +182,17 @@ const Styled: Record<string, AnyStyledComponent> = {};
Styled.Command = styled(Command)<{ $withStickyLayout?: boolean }>`
--comboboxMenu-backgroundColor: var(--color-layer-2);
--comboboxMenu-input-backgroundColor: var(--color-layer-3);
--comboboxMenu-input-height: 2.5rem;
--comboboxMenu-item-checked-backgroundColor: ;
--comboboxMenu-item-checked-textColor: ;
--comboboxMenu-item-highlighted-backgroundColor: var(--color-layer-3);
--comboboxMenu-item-highlighted-textColor: var(--color-text-1);
--comboboxMenu-item-backgroundColor: ;
--comboboxMenu-item-gap: 0.5rem;
--comboboxMenu-item-padding: 0.5em 1em;
display: grid;
align-content: start;
@@ -225,7 +241,7 @@ Styled.Input = styled(Command.Input)`
gap: 0.5rem;
`;
Styled.Group = styled(Command.Group)<{ $withStickyLayout?: boolean }>`
Styled.Group = styled(Command.Group)<{ $withItemBorders?: boolean; $withStickyLayout?: boolean }>`
color: var(--color-text-0);
> [cmdk-group-heading] {
@@ -248,6 +264,14 @@ Styled.Group = styled(Command.Group)<{ $withStickyLayout?: boolean }>`
${layoutMixins.stickyArea3}
}
`}
${({ $withItemBorders }) =>
$withItemBorders &&
css`
> [cmdk-group-items] {
padding: var(--border-width) 0;
}
`}
`;
Styled.List = styled(Command.List)<{ $withStickyLayout?: boolean }>`
@@ -274,13 +298,14 @@ Styled.List = styled(Command.List)<{ $withStickyLayout?: boolean }>`
`}
`;
Styled.Item = styled(Command.Item)`
Styled.Item = styled(Command.Item)<{ $withItemBorders?: boolean }>`
${layoutMixins.scrollSnapItem}
${popoverMixins.item}
--item-checked-backgroundColor: var(--comboboxMenu-item-checked-backgroundColor);
--item-checked-textColor: var(--comboboxMenu-item-checked-textColor);
--item-highlighted-textColor: var(--comboboxMenu-item-highlighted-textColor);
--item-gap: var(--comboboxMenu-item-gap);
--item-padding: var(--comboboxMenu-item-padding);
background-color: var(--comboboxMenu-backgroundColor, inherit);
@@ -291,6 +316,12 @@ Styled.Item = styled(Command.Item)`
opacity: 0.75;
cursor: not-allowed;
}
${({ $withItemBorders }) =>
$withItemBorders &&
css`
${layoutMixins.withOuterBorder}
`}
`;
Styled.ItemLabel = styled.div`
@@ -309,6 +340,8 @@ Styled.ItemLabel = styled.div`
opacity: 0.8;
}
}
min-width: 0;
`;
Styled.Empty = styled(Command.Empty)`
+38 -28
View File
@@ -38,6 +38,7 @@ type ElementProps = {
slotTrigger?: React.ReactNode;
slotHeaderInner?: React.ReactNode;
slotFooter?: React.ReactNode;
withClose?: boolean;
};
type StyleProps = {
@@ -46,6 +47,7 @@ type StyleProps = {
hasHeaderBorder?: boolean;
children?: React.ReactNode;
className?: string;
withAnimation?: boolean;
};
export type DialogProps = ElementProps & StyleProps;
@@ -79,9 +81,11 @@ export const Dialog = ({
slotTrigger,
slotHeaderInner,
slotFooter,
withClose = true,
placement = DialogPlacement.Default,
portalContainer,
hasHeaderBorder = false,
withAnimation = false,
children,
className,
}: DialogProps) => {
@@ -105,6 +109,7 @@ export const Dialog = ({
e.preventDefault();
}
}}
$withAnimation={withAnimation}
>
<Styled.Header $withBorder={hasHeaderBorder}>
<Styled.HeaderTopRow>
@@ -114,7 +119,7 @@ export const Dialog = ({
{title && <Styled.Title>{title}</Styled.Title>}
{!preventClose && (
{!preventClose && withClose && (
<Styled.Close ref={closeButtonRef}>
<Icon iconName={IconName.Close} />
</Styled.Close>
@@ -168,7 +173,7 @@ Styled.Overlay = styled(Overlay)`
}
`;
Styled.Container = styled(Content)<{ placement: DialogPlacement }>`
Styled.Container = styled(Content)<{ placement: DialogPlacement; $withAnimation?: boolean }>`
/* Params */
--dialog-inset: 1rem;
--dialog-width: 30rem;
@@ -224,7 +229,7 @@ Styled.Container = styled(Content)<{ placement: DialogPlacement }>`
outline: none;
${({ placement }) =>
${({ placement, $withAnimation }) =>
({
[DialogPlacement.Default]: css`
inset: var(--dialog-inset);
@@ -260,9 +265,11 @@ Styled.Container = styled(Content)<{ placement: DialogPlacement }>`
padding-bottom: var(--dialog-radius); */
}
@media (prefers-reduced-motion: no-preference) {
&[data-state='open'] {
animation: ${keyframes`
${$withAnimation &&
css`
@media (prefers-reduced-motion: no-preference) {
&[data-state='open'] {
animation: ${keyframes`
from {
opacity: 0;
}
@@ -270,18 +277,19 @@ Styled.Container = styled(Content)<{ placement: DialogPlacement }>`
max-height: 0;
}
`} 0.15s var(--ease-out-expo);
}
}
&[data-state='closed'] {
animation: ${keyframes`
&[data-state='closed'] {
animation: ${keyframes`
to {
opacity: 0;
scale: 0.9;
max-height: 0;
}
`} 0.15s;
}
}
}
`}
`,
[DialogPlacement.Sidebar]: css`
--dialog-width: var(--sidebar-width);
@@ -291,50 +299,52 @@ Styled.Container = styled(Content)<{ placement: DialogPlacement }>`
margin-left: auto;
}
@media (prefers-reduced-motion: no-preference) {
&[data-state='open'] {
animation: ${keyframes`
${$withAnimation &&
css`
@media (prefers-reduced-motion: no-preference) {
&[data-state='open'] {
animation: ${keyframes`
from {
translate: 100% 0;
opacity: 0;
}
`} 0.15s var(--ease-out-expo);
}
}
&[data-state='closed'] {
animation: ${keyframes`
&[data-state='closed'] {
animation: ${keyframes`
to {
translate: 100% 0;
opacity: 0;
}
`} 0.15s var(--ease-out-expo);
}
}
}
`}
`,
[DialogPlacement.Inline]: css`
@media (prefers-reduced-motion: no-preference) {
&[data-state='open'] {
animation: ${keyframes`
${$withAnimation &&
css`
@media (prefers-reduced-motion: no-preference) {
&[data-state='open'] {
animation: ${keyframes`
from {
scale: 0.99;
opacity: 0;
/* filter: blur(2px); */
/* backdrop-filter: none; */
}
`} 0.15s var(--ease-out-expo);
}
}
&[data-state='closed'] {
animation: ${keyframes`
&[data-state='closed'] {
animation: ${keyframes`
to {
scale: 0.99;
opacity: 0;
/* filter: blur(2px); */
/* backdrop-filter: none; */
}
`} 0.15s var(--ease-out-expo);
}
}
}
`}
`,
[DialogPlacement.FullScreen]: css`
--dialog-width: 100vw;
+201
View File
@@ -0,0 +1,201 @@
import React, { type MouseEvent } from 'react';
import styled, { css } from 'styled-components';
import { ButtonShape, ButtonSize } from '@/constants/buttons';
import {
NotificationStatus,
type Notification as NotificationDataType,
} from '@/constants/notifications';
import { useNotifications } from '@/hooks/useNotifications';
import { popoverMixins } from '@/styles/popoverMixins';
import { layoutMixins } from '@/styles/layoutMixins';
import { IconButton } from '@/components/IconButton';
import { IconName } from '@/components/Icon';
import { Output, OutputType } from '@/components/Output';
type ElementProps = {
notification: NotificationDataType;
onClick?: () => void;
slotIcon?: React.ReactNode;
slotTitle?: React.ReactNode;
slotTitleLeft?: React.ReactNode;
slotTitleRight?: React.ReactNode;
slotDescription?: React.ReactNode;
slotCustomContent?: React.ReactNode;
slotAction?: React.ReactNode;
withClose?: boolean;
};
type StyleProps = {
className?: string;
isToast?: boolean;
};
export type NotificationProps = ElementProps & StyleProps;
export const Notification = ({
className,
isToast,
notification,
onClick,
slotIcon,
slotTitle,
slotTitleLeft,
slotTitleRight,
slotDescription,
slotCustomContent,
slotAction,
withClose = !isToast,
}: NotificationProps) => {
const { markCleared, markSeen } = useNotifications();
return (
<$Container className={className} isToast={isToast} onClick={onClick}>
<$Header>
{slotTitleLeft ?? (slotIcon && <$Icon>{slotIcon}</$Icon>)}
<$Title>{slotTitle}</$Title>
{slotTitleRight}
{withClose && !isToast && (
<$ActionItems>
<$Output
type={OutputType.RelativeTime}
value={notification.timestamps[NotificationStatus.Triggered]}
/>
{notification.status < NotificationStatus.Seen ? <$UnreadIndicator /> : null}
{notification.status < NotificationStatus.Cleared ? (
<$IconButton
iconName={IconName.Close}
shape={ButtonShape.Square}
size={ButtonSize.XSmall}
onClick={(e: MouseEvent) => {
e.stopPropagation();
if (notification.status < NotificationStatus.Seen) {
markSeen(notification);
} else if (notification.status < NotificationStatus.Cleared) {
markCleared(notification);
}
}}
/>
) : null}
</$ActionItems>
)}
</$Header>
<$Description>{slotCustomContent ?? slotDescription}</$Description>
{slotAction && <$Action>{slotAction}</$Action>}
</$Container>
);
};
const $Container = styled.div<{ isToast?: boolean }>`
// Params
--toast-icon-size: 1.75em;
// Rules
${popoverMixins.popover}
overflow: visible;
padding: 1rem 1.25rem;
${({ isToast }) =>
isToast
? css`
padding: 1rem;
box-shadow: 0 0 0 var(--border-width) var(--color-border),
// border
0 0 0.5rem 0.1rem var(--color-layer-2); // shadow
`
: css`
flex: 1;
background-color: transparent;
border-radius: 0;
backdrop-filter: none;
`}
`;
const $Header = styled.header`
${layoutMixins.row}
position: relative;
`;
const $Icon = styled.div`
${layoutMixins.row}
float: left;
margin-right: 0.5rem;
line-height: 1;
> svg,
img {
width: 1.5rem;
height: 1.5rem;
}
`;
const $Title = styled.div`
flex: 1;
font: var(--font-base-medium);
color: var(--color-text-2);
overflow: hidden;
text-overflow: ellipsis;
`;
const $Description = styled.div`
margin-top: 0.5rem;
color: var(--color-text-0);
font: var(--font-small-book);
`;
const $Action = styled.div`
margin-top: 0.5rem;
font: var(--font-small-book);
`;
const $ActionItems = styled.div`
min-width: fit-content;
padding-left: 0.5rem;
height: 100%;
${layoutMixins.row};
justify-content: end;
gap: 0.5rem;
position: absolute;
right: 0;
top: 0;
background-color: var(--color-layer-2);
font: var(--font-mini-book);
display: none;
z-index: 1;
${$Container}:hover & {
display: flex;
}
`;
const $UnreadIndicator = styled.div`
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: var(--color-accent);
border: 1px solid var(--color-layer-2);
`;
const $Output = styled(Output)`
color: var(--color-text-0);
`;
const $IconButton = styled(IconButton)`
--button-border: none;
--button-textColor: var(--color-text-0);
--button-hover-textColor: var(--color-text-1);
`;
+72 -78
View File
@@ -1,24 +1,18 @@
import React, { useEffect, useRef, useState } from 'react';
import { Root, Title, Description, Action, Close } from '@radix-ui/react-toast';
import { type MouseEvent, useEffect, useRef, useState } from 'react';
import styled, { keyframes } from 'styled-components';
import { Root, Action, Close } from '@radix-ui/react-toast';
import { ButtonShape, ButtonSize } from '@/constants/buttons';
import { popoverMixins } from '@/styles/popoverMixins';
import { Notification, type NotificationProps } from '@/components/Notification';
import { IconButton } from './IconButton';
import { CloseIcon } from '@/icons';
import styled, { keyframes } from 'styled-components';
import { popoverMixins } from '@/styles/popoverMixins';
import { layoutMixins } from '@/styles/layoutMixins';
import { IconName } from './Icon';
type ElementProps = {
isOpen?: boolean;
setIsOpen?: (isOpen: boolean, isClosedFromTimeout?: boolean) => void;
slotIcon?: React.ReactNode;
slotTitle?: React.ReactNode;
slotDescription?: React.ReactNode;
slotCustomContent?: React.ReactNode;
slotAction?: React.ReactNode;
actionDescription?: string;
actionAltText?: string;
sensitivity?: 'foreground' | 'background';
@@ -26,13 +20,22 @@ type ElementProps = {
lastUpdated?: number;
};
type StyleProps = {};
type StyleProps = {
className?: string;
};
type ToastProps = NotificationProps & ElementProps & StyleProps;
export const Toast = ({
className,
isOpen = true,
notification,
onClick,
setIsOpen,
slotIcon,
slotTitle,
slotTitleLeft,
slotTitleRight,
slotDescription,
slotCustomContent,
slotAction,
@@ -41,7 +44,7 @@ export const Toast = ({
sensitivity = 'background',
duration = Infinity,
lastUpdated,
}: ElementProps & StyleProps) => {
}: ToastProps) => {
// Timeout
const timeout = useRef<number>();
const [isPaused, setIsPaused] = useState(false);
@@ -58,37 +61,45 @@ export const Toast = ({
return (
<$Root
className={className}
type={sensitivity}
duration={Infinity}
open={isOpen}
onOpenChange={setIsOpen}
onPause={() => setIsPaused(true)}
onResume={() => setIsPaused(false)}
onClick={() => {
setIsOpen?.(false, false);
onClick?.();
}}
>
<div>
<$Container>
<$Header>
{slotIcon && <$Icon>{slotIcon}</$Icon>}
<Close asChild>
<$CloseButton
iconName={IconName.Close}
shape={ButtonShape.Square}
size={ButtonSize.XSmall}
onClick={(e: MouseEvent) => e.stopPropagation()}
/>
</Close>
<Close asChild>
<$CloseButton
iconComponent={CloseIcon}
shape={ButtonShape.Square}
size={ButtonSize.XSmall}
/>
</Close>
<$Title>{slotTitle}</$Title>
</$Header>
{!slotCustomContent && <$Description>{slotDescription}</$Description>}
{slotCustomContent}
{actionDescription && (
<$Action asChild altText={actionAltText}>
{slotAction}
</$Action>
)}
</$Container>
</div>
{slotCustomContent ?? (
<$Notification
isToast
notification={notification}
slotIcon={slotIcon}
slotTitle={slotTitle}
slotTitleLeft={slotTitleLeft}
slotTitleRight={slotTitleRight}
slotDescription={slotDescription}
slotAction={
actionDescription && (
<$Action asChild altText={actionAltText}>
{slotAction}
</$Action>
)
}
/>
)}
</$Root>
);
};
@@ -114,7 +125,7 @@ const $Root = styled(Root)`
translate: var(--x) var(--y);
will-change: translate, margin-top;
margin-bottom: var(--toasts-gap);
/* margin-bottom: var(--toasts-gap); */
&[data-swipe-direction='right'] {
&[data-state='open'] {
@@ -163,6 +174,10 @@ const $Root = styled(Root)`
cursor: n-resize;
}
&:focus:not([data-swipe='end']) & {
outline: var(--color-accent) 1px solid;
}
&[data-swipe='move'] {
transition-property: opacity;
opacity: 0.98;
@@ -171,6 +186,10 @@ const $Root = styled(Root)`
* {
cursor: inherit;
}
& > * {
opacity: 0.5;
}
}
&[data-state='closed'],
@@ -198,69 +217,44 @@ const $Root = styled(Root)`
}
`;
const $Container = styled.div`
const $Notification = styled(Notification)`
// Params
--toast-icon-size: 1.75em;
// Rules
${popoverMixins.popover}
overflow: visible;
padding: 1rem;
box-shadow: 0 0 0 var(--border-width) var(--color-border),
// border
0 0 0.5rem 0.1rem var(--color-layer-2); // shadow
${$Root}:focus:not([data-swipe='end']) & {
outline: var(--color-accent) 1px solid;
}
> * {
transition: opacity 0.2s;
}
${$Root}[data-swipe='move'] & > * {
opacity: 0.5;
}
`;
const $Header = styled.header`
display: block;
`;
const $Icon = styled.div`
${layoutMixins.row}
float: left;
width: 1em;
height: 1em;
margin-right: 0.4em;
line-height: 1;
`;
const $CloseButton = styled(IconButton)`
--button-textColor: var(--color-text-0);
--button-border: none;
--button-icon-size: 0.85em;
display: none;
float: right;
/* float: right; */
margin: -0.42rem -0.42rem -0.42rem 0.42rem;
`;
const $Title = styled(Title)`
flex: 1;
// Absolute
position: absolute;
top: 0;
right: 0;
font: var(--font-base-medium);
color: var(--color-text-2);
border: solid var(--border-width) var(--color-border);
border-radius: 50%;
overflow: hidden;
text-overflow: ellipsis;
`;
const $Description = styled(Description)`
margin-top: 0.5rem;
color: var(--color-text-0);
font: var(--font-small-book);
${$Root}:hover & {
display: block;
z-index: 2;
}
`;
const $Action = styled(Action)`
+2
View File
@@ -34,6 +34,7 @@ const $ToastArea = styled.aside`
> * {
display: contents;
pointer-events: none;
position: relative;
> ol {
/* display: block; */
@@ -43,6 +44,7 @@ const $ToastArea = styled.aside`
// Desired display order: latest to earliest (¯\_(ツ)_/¯)
display: flex;
flex-direction: column-reverse;
position: relative;
pointer-events: none;
+34 -1
View File
@@ -124,6 +124,8 @@ export type SubaccountPosition = Abacus.exchange.dydx.abacus.output.SubaccountPo
export type SubaccountOrder = Abacus.exchange.dydx.abacus.output.SubaccountOrder;
export type OrderStatus = Abacus.exchange.dydx.abacus.output.input.OrderStatus;
export const AbacusOrderStatus = Abacus.exchange.dydx.abacus.output.input.OrderStatus;
const abacusOrderStatuses = [...AbacusOrderStatus.values()] as const;
export type AbacusOrderStatuses = (typeof abacusOrderStatuses)[number];
export type SubaccountFills = Abacus.exchange.dydx.abacus.output.SubaccountFill[];
export type SubaccountFill = Abacus.exchange.dydx.abacus.output.SubaccountFill;
export type SubaccountFundingPayment = Abacus.exchange.dydx.abacus.output.SubaccountFundingPayment;
@@ -209,11 +211,13 @@ type ReadonlyKeysOf<T> = {
[K in keyof T]: IfEquals<{ [_ in K]: T[K] }, { readonly [_ in K]: T[K] }, K, never>;
}[keyof T];
type KotlinIrEnumValues<T> = Exclude<ReadonlyKeysOf<T>, 'Companion' | '$serializer'>;
export type KotlinIrEnumValues<T> = Exclude<ReadonlyKeysOf<T>, 'Companion' | '$serializer'>;
export const ORDER_SIDES: Record<KotlinIrEnumValues<typeof AbacusOrderSide>, OrderSide> = {
[AbacusOrderSide.buy.name]: OrderSide.BUY,
[AbacusOrderSide.sell.name]: OrderSide.SELL,
[AbacusOrderSide.buy.rawValue]: OrderSide.BUY,
[AbacusOrderSide.sell.rawValue]: OrderSide.SELL,
};
export const POSITION_SIDES: Record<KotlinIrEnumValues<typeof AbacusPositionSide>, PositionSide> = {
@@ -234,12 +238,25 @@ export const HISTORICAL_PNL_PERIODS: Record<
export const ORDER_STATUS_STRINGS: Record<KotlinIrEnumValues<typeof AbacusOrderStatus>, string> = {
[AbacusOrderStatus.open.name]: STRING_KEYS.OPEN_STATUS,
[AbacusOrderStatus.open.rawValue]: STRING_KEYS.OPEN_STATUS,
[AbacusOrderStatus.partiallyFilled.name]: STRING_KEYS.PARTIALLY_FILLED,
[AbacusOrderStatus.partiallyFilled.rawValue]: STRING_KEYS.PARTIALLY_FILLED,
[AbacusOrderStatus.filled.name]: STRING_KEYS.ORDER_FILLED,
[AbacusOrderStatus.filled.rawValue]: STRING_KEYS.ORDER_FILLED,
[AbacusOrderStatus.cancelled.name]: STRING_KEYS.CANCELED,
[AbacusOrderStatus.cancelled.rawValue]: STRING_KEYS.CANCELED,
[AbacusOrderStatus.canceling.name]: STRING_KEYS.CANCELING,
[AbacusOrderStatus.canceling.rawValue]: STRING_KEYS.CANCELING,
[AbacusOrderStatus.pending.name]: STRING_KEYS.PENDING,
[AbacusOrderStatus.pending.rawValue]: STRING_KEYS.PENDING,
[AbacusOrderStatus.untriggered.name]: STRING_KEYS.UNTRIGGERED,
[AbacusOrderStatus.untriggered.rawValue]: STRING_KEYS.UNTRIGGERED,
};
export const TRADE_TYPES: Record<
@@ -247,15 +264,31 @@ export const TRADE_TYPES: Record<
Nullable<TradeTypes>
> = {
[AbacusOrderType.limit.name]: TradeTypes.LIMIT,
[AbacusOrderType.limit.rawValue]: TradeTypes.LIMIT,
[AbacusOrderType.market.name]: TradeTypes.MARKET,
[AbacusOrderType.market.rawValue]: TradeTypes.MARKET,
[AbacusOrderType.stopLimit.name]: TradeTypes.STOP_LIMIT,
[AbacusOrderType.stopLimit.rawValue]: TradeTypes.STOP_LIMIT,
[AbacusOrderType.stopMarket.name]: TradeTypes.STOP_MARKET,
[AbacusOrderType.stopMarket.rawValue]: TradeTypes.STOP_MARKET,
[AbacusOrderType.takeProfitLimit.name]: TradeTypes.TAKE_PROFIT,
[AbacusOrderType.takeProfitLimit.rawValue]: TradeTypes.TAKE_PROFIT,
[AbacusOrderType.takeProfitMarket.name]: TradeTypes.TAKE_PROFIT_MARKET,
[AbacusOrderType.takeProfitMarket.rawValue]: TradeTypes.TAKE_PROFIT_MARKET,
[AbacusOrderType.liquidated.name]: null,
[AbacusOrderType.liquidation.name]: null,
[AbacusOrderType.liquidation.name]: null,
[AbacusOrderType.liquidation.rawValue]: null,
[AbacusOrderType.trailingStop.name]: null,
[AbacusOrderType.trailingStop.rawValue]: null,
};
// Custom types involving Abacus
+1
View File
@@ -10,6 +10,7 @@ export enum DialogTypes {
MobileSignIn = 'MobileSignIn',
Onboarding = 'Onboarding',
OrderDetails = 'OrderDetails',
Preferences = 'Preferences',
RateLimit = 'RateLimit',
Receive = 'Receive',
RestrictedGeo = 'RestrictedGeo',
+4
View File
@@ -13,6 +13,7 @@ export enum LocalStorageKey {
PushNotificationsEnabled = 'dydx.PushNotificationsEnabled',
PushNotificationsLastUpdated = 'dydx.PushNotificationsLastUpdated',
TransferNotifications = 'dydx.TransferNotifications',
NotificationPreferences = 'dydx.NotificationPreferences',
// UI State
LastViewedMarket = 'dydx.LastViewedMarket',
@@ -25,5 +26,8 @@ export enum LocalStorageKey {
export const LOCAL_STORAGE_VERSIONS = {
[LocalStorageKey.EvmDerivedAddresses]: 'v2',
[LocalStorageKey.NotificationPreferences]: 'v1',
[LocalStorageKey.TransferNotifications]: 'v1',
[LocalStorageKey.Notifications]: 'v1',
// TODO: version all localStorage keys
};
+8
View File
@@ -6,6 +6,8 @@ import {
LOCALE_DATA,
TOOLTIPS,
WARNINGS_STRING_KEYS,
NOTIFICATIONS_STRING_KEYS,
NOTIFICATIONS,
} from '@dydxprotocol/v4-localization';
import { type LinksConfigs } from '@/hooks/useURLConfigs';
@@ -27,6 +29,7 @@ export enum SupportedLocales {
export const EN_LOCALE_DATA = {
...LOCALE_DATA[SupportedLocales.EN],
...NOTIFICATIONS[SupportedLocales.EN],
TOOLTIPS: TOOLTIPS[SupportedLocales.EN],
};
@@ -34,8 +37,13 @@ export const STRING_KEYS = {
...APP_STRING_KEYS,
...ERRORS_STRING_KEYS,
...WARNINGS_STRING_KEYS,
...NOTIFICATIONS_STRING_KEYS,
};
export const STRING_KEY_VALUES = Object.fromEntries(
Object.values(STRING_KEYS).map((key) => [key, key])
);
export type StringKey = keyof typeof STRING_KEYS;
export type LocaleData = typeof EN_LOCALE_DATA;
+1
View File
@@ -26,6 +26,7 @@ export type MenuItem<MenuItemValue, MenuItemTypes = string> = {
tag?: React.ReactNode;
slotAfter?: React.ReactNode;
description?: string;
slotCustomContent?: React.ReactNode;
href?: string;
onSelect?: (key: MenuItemValue) => void;
+24 -8
View File
@@ -2,7 +2,7 @@ import { StatusResponse } from '@0xsquid/sdk';
/** implemented in useNotificationTypes */
export enum NotificationType {
OrderStatusChanged = 'OrderStatusChanged',
AbacusGenerated = 'AbacusGenerated',
SquidTransfer = 'SquidTransfer',
}
@@ -59,7 +59,7 @@ export enum NotificationStatus {
/** Toast or NotificationsMenu item interacted with or dismissed. "Seen" in NotificationsMenu. */
Seen,
/** Notification marked for deletion. "Archived" in NotificationsMenu. */
/** Notification marked for deletion. Hidden in NotificationsMenu. */
Cleared,
}
@@ -80,14 +80,20 @@ export type Notifications = Record<NotificationId, Notification<any>>;
/** Notification display data derived from app state at runtime. */
export type NotificationDisplayData = {
icon?: React.ReactNode;
title: string; // Title for Toast, Notification, and Push Notification
body?: string; // Description body for Toast, Notification, and Push Notification
title?: string;
slotTitleLeft?: React.ReactNode;
slotTitleRight?: React.ReactNode;
description?: React.ReactNode;
customContent?: React.ReactNode;
customMenuContent?: React.ReactNode;
// Overrides title/body for Notification in NotificationMenu
renderCustomBody?: ({
isToast,
notification,
}: {
isToast?: boolean;
notification: Notification;
}) => React.ReactNode; // Custom Notification
actionDescription?: string;
@@ -122,5 +128,15 @@ export type TransferNotifcation = {
fromChainId?: string;
toAmount?: number;
triggeredAt?: number;
errorCount?: number;
status?: StatusResponse;
};
/**
* @description Struct to store whether a NotificationType should be triggered
*/
export type NotificationPreferences = {
[key in NotificationType]: boolean;
} & { version: string };
export const DEFAULT_TOAST_AUTO_CLOSE_MS = 5000;
+68 -47
View File
@@ -2,11 +2,11 @@ import { createContext, useContext, useCallback, useEffect, useMemo } from 'reac
import { useQuery } from 'react-query';
import type { StatusResponse } from '@0xsquid/sdk';
import { LocalStorageKey } from '@/constants/localStorage';
import { LOCAL_STORAGE_VERSIONS, LocalStorageKey } from '@/constants/localStorage';
import { type TransferNotifcation } from '@/constants/notifications';
import { useAccounts } from '@/hooks/useAccounts';
import { STATUS_ERROR_GRACE_PERIOD, useSquid } from '@/hooks/useSquid';
import { useLocalStorage } from './useLocalStorage';
const LocalNotificationsContext = createContext<
@@ -22,16 +22,32 @@ export const LocalNotificationsProvider = ({ ...props }) => (
export const useLocalNotifications = () => useContext(LocalNotificationsContext)!;
const TRANSFER_STATUS_FETCH_INTERVAL = 10_000;
const ERROR_COUNT_THRESHOLD = 3;
const useLocalNotificationsContext = () => {
// transfer notifications
const [allTransferNotifications, setAllTransferNotifications] = useLocalStorage<{
[key: `dydx${string}`]: TransferNotifcation[];
version: string;
}>({
key: LocalStorageKey.TransferNotifications,
defaultValue: {},
defaultValue: {
version: LOCAL_STORAGE_VERSIONS[LocalStorageKey.TransferNotifications],
},
});
// Ensure version matches, otherwise wipe
useEffect(() => {
if (
allTransferNotifications?.version !==
LOCAL_STORAGE_VERSIONS[LocalStorageKey.TransferNotifications]
) {
setAllTransferNotifications({
version: LOCAL_STORAGE_VERSIONS[LocalStorageKey.TransferNotifications],
});
}
}, [allTransferNotifications]);
const { dydxAddress } = useAccounts();
const transferNotifications = dydxAddress ? allTransferNotifications[dydxAddress] || [] : [];
@@ -54,56 +70,61 @@ const useLocalNotificationsContext = () => {
const squid = useSquid();
const { data: transferStatuses } = useQuery({
queryKey: ['getTransactionStatus', transferNotifications],
useQuery({
queryKey: 'getTransactionStatus',
queryFn: async () => {
const statuses: { [key: string]: StatusResponse } = {};
for (const {
txHash,
toChainId,
fromChainId,
triggeredAt,
status: currentStatus,
} of transferNotifications) {
try {
// skip if error is returned or if the transaction is not ongoing
if (
// @ts-ignore status.errors is not in the type definition but can be returned
currentStatus?.errors ||
currentStatus?.error ||
(currentStatus?.squidTransactionStatus &&
currentStatus?.squidTransactionStatus !== 'ongoing')
) {
continue;
}
const processTransferNotifications = async (transferNotifications: TransferNotifcation[]) => {
const newTransferNotifications = await Promise.all(
transferNotifications.map(async (transferNotification) => {
const {
txHash,
toChainId,
fromChainId,
triggeredAt,
errorCount,
status: currentStatus,
} = transferNotification;
const status = await squid?.getStatus({ transactionId: txHash, toChainId, fromChainId });
if (status) statuses[txHash] = status;
} catch (error) {
// ignore errors for the first 120s since the status might not be available yet
if (!triggeredAt || Date.now() - triggeredAt > STATUS_ERROR_GRACE_PERIOD) {
statuses[txHash] = error;
}
}
}
return statuses;
if (
// @ts-ignore status.errors is not in the type definition but can be returned
!currentStatus?.errors &&
!currentStatus?.error &&
(!currentStatus?.squidTransactionStatus ||
currentStatus?.squidTransactionStatus === 'ongoing')
) {
try {
const status = await squid?.getStatus({
transactionId: txHash,
toChainId,
fromChainId,
});
if (status) {
transferNotification.status = status;
}
} catch (error) {
if (!triggeredAt || Date.now() - triggeredAt > STATUS_ERROR_GRACE_PERIOD) {
if (errorCount && errorCount > ERROR_COUNT_THRESHOLD) {
transferNotification.status = error;
} else {
transferNotification.errorCount = errorCount ? errorCount + 1 : 1;
}
}
}
}
return transferNotification;
})
);
return newTransferNotifications;
};
const newTransferNotifications = await processTransferNotifications(transferNotifications);
setTransferNotifications(newTransferNotifications);
},
refetchInterval: TRANSFER_STATUS_FETCH_INTERVAL,
});
useEffect(() => {
if (!transferStatuses) return;
const newTransferNotifications = transferNotifications.map((notification) => {
const status = transferStatuses[notification.txHash];
if (!status) return notification;
return {
...notification,
status,
};
});
setTransferNotifications(newTransferNotifications);
}, [transferStatuses]);
return {
transferNotifications,
addTransferNotification,
+162 -156
View File
@@ -1,125 +1,147 @@
import { useCallback, useEffect, useMemo } from 'react';
import styled, { type AnyStyledComponent } from 'styled-components';
import { useSelector, shallowEqual, useDispatch } from 'react-redux';
import { groupBy } from 'lodash';
import { type ReactNode, useEffect } from 'react';
import styled from 'styled-components';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { isEqual, groupBy } from 'lodash';
import { useNavigate } from 'react-router-dom';
import { AlertType } from '@/constants/alerts';
import { AbacusOrderStatus, ORDER_SIDES } from '@/constants/abacus';
import { DialogTypes } from '@/constants/dialogs';
import { STRING_KEYS, StringKey } from '@/constants/localization';
import { ENVIRONMENT_CONFIG_MAP } from '@/constants/networks';
import { type NotificationTypeConfig, NotificationType } from '@/constants/notifications';
import { ORDER_SIDE_STRINGS } from '@/constants/trade';
import { AppRoute } from '@/constants/routes';
import { DydxChainAsset } from '@/constants/wallets';
import { useSelectedNetwork } from '@/hooks';
import {
STRING_KEYS,
STRING_KEY_VALUES,
type StringGetterFunction,
type StringKey,
} from '@/constants/localization';
import {
type NotificationTypeConfig,
NotificationType,
DEFAULT_TOAST_AUTO_CLOSE_MS,
} from '@/constants/notifications';
import { useSelectedNetwork, useStringGetter } from '@/hooks';
import { useLocalNotifications } from '@/hooks/useLocalNotifications';
import { Icon, IconName } from '@/components/Icon';
import { Output, OutputType } from '@/components/Output';
import { TransferStatusToast } from '@/views/TransferStatus';
import { TradeNotification } from '@/views/notifications/TradeNotification';
import { TransferStatusNotification } from '@/views/notifications/TransferStatusNotification';
import { getSubaccountFills, getSubaccountOrders } from '@/state/accountSelectors';
import { openDialog } from '@/state/dialogs';
import { getAbacusNotifications } from '@/state/notificationsSelectors';
import { getMarketIds } from '@/state/perpetualsSelectors';
import { OrderStatusIcon } from '@/views/OrderStatusIcon';
import { formatSeconds } from '@/lib/timeUtils';
import { useStringGetter } from './useStringGetter';
import { TransferStatusSteps } from '@/views/TransferStatusSteps';
const parseStringParamsForNotification = ({
stringGetter,
value,
}: {
stringGetter: StringGetterFunction;
value: unknown;
}): ReactNode => {
if (STRING_KEY_VALUES[value as StringKey]) {
return stringGetter({ key: value as StringKey });
}
export const notificationTypes = [
return value as ReactNode;
};
export const notificationTypes: NotificationTypeConfig[] = [
{
type: NotificationType.OrderStatusChanged,
useTrigger: ({ trigger, lastUpdated }) => {
type: NotificationType.AbacusGenerated,
useTrigger: ({ trigger }) => {
const stringGetter = useStringGetter();
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 orderIds = useMemo(
() => [...Object.keys(ordersByOrderId), ...Object.keys(fillsByOrderId)],
[orders, fills]
);
const abacusNotifications = useSelector(getAbacusNotifications, isEqual);
useEffect(() => {
for (const orderId of orderIds) {
const fills = fillsByOrderId[orderId];
for (const abacusNotif of abacusNotifications) {
const [abacusNotificationType = '', id = ''] = abacusNotif.id.split(':');
const parsedData = abacusNotif.data ? JSON.parse(abacusNotif.data) : {};
const order =
ordersByOrderId[orderId] ??
(fills?.length
? {
...fills[fills.length - 1],
id: orderId,
createdAtMilliseconds: Math.max(
...fills.map((fill) => fill.createdAtMilliseconds)
const params = Object.fromEntries(
Object.entries(parsedData).map(([key, value]) => {
return [key, parseStringParamsForNotification({ stringGetter, value })];
})
);
switch (abacusNotificationType) {
case 'order': {
trigger(
abacusNotif.id,
{
icon: abacusNotif.image && <$Icon src={abacusNotif.image} alt="" />,
title: stringGetter({ key: abacusNotif.title }),
body: abacusNotif.text ? stringGetter({ key: abacusNotif.text, params }) : '',
toastDuration: DEFAULT_TOAST_AUTO_CLOSE_MS,
toastSensitivity: 'foreground',
renderCustomBody: ({ isToast, notification }) => (
<TradeNotification
isToast={isToast}
data={parsedData}
notification={notification}
/>
),
status: AbacusOrderStatus.filled,
}
: undefined);
if (order)
trigger(
order.id,
{
icon: (
<OrderStatusIcon status={order.status} totalFilled={order.totalFilled ?? 0} />
),
title: `${stringGetter({
key: order.resources.typeStringKey as StringKey,
})} ${
order.status === AbacusOrderStatus.open && (order?.totalFilled ?? 0) > 0
? stringGetter({ key: STRING_KEYS.PARTIALLY_FILLED })
: stringGetter({ key: order.resources.statusStringKey as StringKey })
}`,
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
);
},
[abacusNotif.updateTimeInMilliseconds, abacusNotif.data],
true
);
break;
}
default:
trigger(
abacusNotif.id,
{
icon: abacusNotif.image && <$Icon src={abacusNotif.image} alt="" />,
title: stringGetter({ key: abacusNotif.title }),
body: abacusNotif.text ? stringGetter({ key: abacusNotif.text, params }) : '',
toastDuration: DEFAULT_TOAST_AUTO_CLOSE_MS,
toastSensitivity: 'foreground',
},
[abacusNotif.updateTimeInMilliseconds, abacusNotif.data]
);
break;
}
}
}, [orderIds]);
}, [abacusNotifications, stringGetter]);
},
useNotificationAction: () => {
const dispatch = useDispatch();
const orders = useSelector(getSubaccountOrders, shallowEqual) || [];
const ordersById = groupBy(orders, 'id');
const fills = useSelector(getSubaccountFills, shallowEqual) || [];
const fillsById = groupBy(fills, 'id');
const marketIds = useSelector(getMarketIds, shallowEqual);
const navigate = useNavigate();
// useNotificationAction: () => {
// const dispatch = useDispatch();
// const orders = useSelector(getSubaccountOrders, shallowEqual) || [];
// const ordersByOrderId = Object.fromEntries(orders.map((order) => [order.id, order]));
return (notificationId: string) => {
const [abacusNotificationType = '', id = ''] = notificationId.split(':');
// const fills = useSelector(getSubaccountFills, shallowEqual) || [];
// const fillsByOrderId = groupBy(fills, (fill) => fill.orderId);
// return (id) => {
// if (ordersByOrderId[id]) {
// dispatch(
// openDialog({
// type: DialogTypes.OrderDetails,
// dialogProps: { orderId: id },
// })
// );
// } else if (fillsByOrderId[id]) {
// const fillId = fillsByOrderId[id][0].id;
// dispatch(
// openDialog({
// type: DialogTypes.FillDetails,
// dialogProps: { fillId },
// })
// );
// }
// };
// },
} as NotificationTypeConfig<string, [string, number]>,
if (ordersById[id]) {
dispatch(
openDialog({
type: DialogTypes.OrderDetails,
dialogProps: { orderId: id },
})
);
} else if (fillsById[id]) {
dispatch(
openDialog({
type: DialogTypes.FillDetails,
dialogProps: { fillId: id },
})
);
} else if (marketIds.includes(id)) {
navigate(`${AppRoute.Trade}/${id}`, {
replace: true,
});
}
};
},
},
{
type: NotificationType.SquidTransfer,
useTrigger: ({ trigger }) => {
@@ -127,81 +149,65 @@ export const notificationTypes = [
const { transferNotifications } = useLocalNotifications();
const { selectedNetwork } = useSelectedNetwork();
const getTitleStringKey = useCallback((type: 'deposit' | 'withdrawal', finished: boolean) => {
if (type === 'deposit' && !finished) return STRING_KEYS.DEPOSIT_IN_PROGRESS;
if (type === 'deposit' && finished) return STRING_KEYS.DEPOSIT;
if (type === 'withdrawal' && !finished) return STRING_KEYS.WITHDRAW_IN_PROGRESS;
return STRING_KEYS.WITHDRAW;
}, []);
useEffect(() => {
for (const transfer of transferNotifications) {
const { fromChainId, status, txHash, toAmount } = transfer;
const finished = Boolean(status) && status?.squidTransactionStatus !== 'ongoing';
const type = fromChainId === ENVIRONMENT_CONFIG_MAP[selectedNetwork].dydxChainId ? 'withdrawal' : 'deposit';
// @ts-ignore status.errors is not in the type definition but can be returned
const error = status?.errors?.length ? status?.errors[0] : status?.error;
const isFinished = Boolean(status) && status?.squidTransactionStatus !== 'ongoing';
const icon = <Icon iconName={isFinished ? IconName.Transfer : IconName.Clock} />;
// TODO: confirm with design what the description should be
const description = (
<div>
<Styled.TransferText>
{type === 'deposit' ? 'Deposit of ' : 'Withdraw of '}
<Output type={OutputType.Fiat} value={toAmount} />
</Styled.TransferText>
const type =
fromChainId === ENVIRONMENT_CONFIG_MAP[selectedNetwork].dydxChainId
? 'withdrawal'
: 'deposit';
{error && (
<Styled.ErrorMessage type={AlertType.Error}>
{stringGetter({
key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE,
params: {
ERROR_MESSAGE:
error.message || stringGetter({ key: STRING_KEYS.UNKNOWN_ERROR }),
},
})}
</Styled.ErrorMessage>
)}
</div>
);
const title = stringGetter({
key: {
deposit: isFinished ? STRING_KEYS.DEPOSIT : STRING_KEYS.DEPOSIT_IN_PROGRESS,
withdrawal: isFinished ? STRING_KEYS.WITHDRAW : STRING_KEYS.WITHDRAW_IN_PROGRESS,
}[type],
});
const toChainEta = status?.toChain?.chainData?.estimatedRouteDuration || 0;
const estimatedDuration = formatSeconds(Math.max(toChainEta, 0));
const body = stringGetter({
key: STRING_KEYS.DEPOSIT_STATUS,
params: {
AMOUNT_USD: `${toAmount} ${DydxChainAsset.USDC.toUpperCase()}`,
ESTIMATED_DURATION: estimatedDuration,
},
});
trigger(
txHash,
{
icon: <Icon iconName={finished ? IconName.Transfer : IconName.Clock} />,
title: stringGetter({ key: getTitleStringKey(type, finished) }),
description: description,
customContent: (
<TransferStatusToast
icon,
title,
body,
renderCustomBody: ({ isToast, notification }) => (
<TransferStatusNotification
isToast={isToast}
slotIcon={icon}
slotTitle={title}
transfer={transfer}
type={type}
toAmount={transfer.toAmount}
triggeredAt={transfer.triggeredAt}
status={transfer.status}
notification={notification}
/>
),
customMenuContent: !finished && (
<div>
{description}
<TransferStatusSteps status={transfer.status} type={type} />
</div>
),
toastSensitivity: 'foreground',
},
[]
[isFinished]
);
}
}, [transferNotifications]);
}, [transferNotifications, stringGetter]);
},
useNotificationAction: () => {
return () => {};
},
},
] satisfies NotificationTypeConfig[];
];
const Styled: Record<string, AnyStyledComponent> = {};
Styled.TransferText = styled.span`
display: inline-flex;
align-items: center;
gap: 0.5ch;
`;
Styled.ErrorMessage = styled.div`
max-width: 13rem;
const $Icon = styled.img`
height: 1.5rem;
width: 1.5rem;
`;
+81 -36
View File
@@ -1,11 +1,13 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { LocalStorageKey } from '@/constants/localStorage';
import { LOCAL_STORAGE_VERSIONS, LocalStorageKey } from '@/constants/localStorage';
import {
type Notification,
type NotificationDisplayData,
type NotificationPreferences,
type Notifications,
NotificationStatus,
NotificationType,
} from '@/constants/notifications';
import { useLocalStorage } from './useLocalStorage';
@@ -13,8 +15,10 @@ import { notificationTypes } from './useNotificationTypes';
import { renderSvgToDataUrl } from '../lib/renderSvgToDataUrl';
const NotificationsContext = createContext<ReturnType<typeof useNotificationsContext> | undefined>(
undefined
type NotificationsContextType = ReturnType<typeof useNotificationsContext>;
const NotificationsContext = createContext<NotificationsContextType>(
{} as NotificationsContextType
);
NotificationsContext.displayName = 'Notifications';
@@ -27,6 +31,7 @@ export const useNotifications = () => useContext(NotificationsContext)!;
const useNotificationsContext = () => {
// Local storage
// const [notifications, setNotifications] = useState<Notifications>({});
const [notifications, setNotifications] = useLocalStorage<Notifications>({
key: LocalStorageKey.Notifications,
defaultValue: {},
@@ -37,12 +42,25 @@ const useNotificationsContext = () => {
defaultValue: Date.now(),
});
const [notificationPreferences, setNotificationPreferences] =
useLocalStorage<NotificationPreferences>({
key: LocalStorageKey.NotificationPreferences,
defaultValue: {
[NotificationType.AbacusGenerated]: true,
[NotificationType.SquidTransfer]: true,
version: LOCAL_STORAGE_VERSIONS[LocalStorageKey.NotificationPreferences],
},
});
useEffect(() => {
setNotificationsLastUpdated(Date.now());
}, [notifications]);
const getKey = <T extends string | number>(notification: Pick<Notification<T>, 'type' | 'id'>) =>
`${notification.type}/${notification.id}`;
const getKey = useCallback(
<T extends string | number>(notification: Pick<Notification<T>, 'type' | 'id'>) =>
`${notification.type}/${notification.id}`,
[]
);
// Display data
const [notificationsDisplayData, setNotificationsDisplayData] = useState(
@@ -54,29 +72,46 @@ const useNotificationsContext = () => {
[notificationsDisplayData]
);
// Check for version changes
useEffect(() => {
if (
notificationPreferences.version !==
LOCAL_STORAGE_VERSIONS[LocalStorageKey.NotificationPreferences]
) {
setNotificationPreferences({
[NotificationType.AbacusGenerated]: true,
[NotificationType.SquidTransfer]: true,
version: LOCAL_STORAGE_VERSIONS[LocalStorageKey.NotificationPreferences],
});
}
}, []);
// Status changes
const updateStatus = useCallback(
(notification: Notification, status: NotificationStatus) => {
notification.status = status;
notification.timestamps[notification.status] = Date.now();
setNotifications({ ...notifications });
setNotifications({ ...notifications, [getKey(notification)]: notification });
},
[notifications]
[notifications, getKey]
);
const { markUnseen, markSeen, markCleared } = useMemo(
() => ({
markUnseen: (notification: Notification) => {
if (notification.status < NotificationStatus.Unseen)
if (notification.status < NotificationStatus.Unseen) {
updateStatus(notification, NotificationStatus.Unseen);
}
},
markSeen: (notification: Notification) => {
if (notification.status < NotificationStatus.Seen)
if (notification.status < NotificationStatus.Seen) {
updateStatus(notification, NotificationStatus.Seen);
}
},
markCleared: (notification: Notification) => {
if (notification.status < NotificationStatus.Cleared)
if (notification.status < NotificationStatus.Cleared) {
updateStatus(notification, NotificationStatus.Cleared);
}
},
}),
[updateStatus]
@@ -97,33 +132,37 @@ const useNotificationsContext = () => {
const notification = notifications[key];
// New unique key - create new notification
if (!notification) {
const notification = (notifications[key] = {
id,
type,
timestamps: {},
updateKey,
} as Notification);
// Filter out notifications that are not enabled
if (notificationPreferences[type] !== false) {
// New unique key - create new notification
if (!notification) {
const notification = (notifications[key] = {
id,
type,
timestamps: {},
updateKey,
} as Notification);
updateStatus(
notification,
isNew ? NotificationStatus.Triggered : NotificationStatus.Cleared
);
} else if (JSON.stringify(updateKey) !== JSON.stringify(notification.updateKey)) {
// updateKey changed - update existing notification
updateStatus(
notification,
isNew ? NotificationStatus.Triggered : NotificationStatus.Cleared
);
}
const notification = notifications[key];
// updateKey changed - update existing notification
else if (JSON.stringify(updateKey) !== JSON.stringify(notification.updateKey)) {
const notification = notifications[key];
notification.updateKey = updateKey;
updateStatus(notification, NotificationStatus.Updated);
notification.updateKey = updateKey;
updateStatus(notification, NotificationStatus.Updated);
}
} else {
// Notification is disabled - remove it
delete notifications[key];
}
notificationsDisplayData[key] = displayData;
setNotificationsDisplayData({ ...notificationsDisplayData });
},
[notifications, updateStatus]
[notifications, updateStatus, notificationPreferences[type]]
),
lastUpdated: notificationsLastUpdated,
@@ -136,10 +175,8 @@ const useNotificationsContext = () => {
)
);
const onNotificationAction = useCallback(
async (notification: Notification) => await actions[notification.type]?.(notification.id),
[actions]
);
const onNotificationAction = async (notification: Notification) =>
await actions[notification.type]?.(notification.id);
// Push notifications
const [hasEnabledPush, setHasEnabledPush] = useLocalStorage({
@@ -186,7 +223,7 @@ const useNotificationsContext = () => {
renotify: true,
tag: getKey(notification),
data: notification,
body: displayData.description,
description: displayData.body,
icon: iconUrl ?? '/favicon.svg',
badge: iconUrl ?? '/favicon.svg',
image: iconUrl ?? '/favicon.svg',
@@ -208,7 +245,7 @@ const useNotificationsContext = () => {
setPushNotificationsLastUpdated(Date.now());
})();
}, [hasEnabledPush, notifications, onNotificationAction, markSeen]);
}, [hasEnabledPush, notifications, markSeen]);
// Menu state
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -239,5 +276,13 @@ const useNotificationsContext = () => {
// Menu state
isMenuOpen,
setIsMenuOpen,
// Notification Preferences
notificationPreferences,
setNotificationPreferences,
getNotificationPreferenceForType: useCallback(
(type: NotificationType) => notificationPreferences[type],
[notificationPreferences]
),
};
};
+2
View File
@@ -15,6 +15,7 @@ import { ExternalNavKeplrDialog } from '@/views/dialogs/ExternalNavKeplrDialog';
import { MnemonicExportDialog } from '@/views/dialogs/MnemonicExportDialog';
import { MobileSignInDialog } from '@/views/dialogs/MobileSignInDialog';
import { OnboardingDialog } from '@/views/dialogs/OnboardingDialog';
import { PreferencesDialog } from '@/views/dialogs/PreferencesDialog';
import { RateLimitDialog } from '@/views/dialogs/RateLimitDialog';
import { ReceiveDialog } from '@/views/dialogs/ReceiveDialog';
import { RestrictedGeoDialog } from '@/views/dialogs/RestrictedGeoDialog';
@@ -56,6 +57,7 @@ export const DialogManager = () => {
[DialogTypes.MobileSignIn]: <MobileSignInDialog {...modalProps} />,
[DialogTypes.Onboarding]: <OnboardingDialog {...modalProps} />,
[DialogTypes.OrderDetails]: <OrderDetailsDialog {...modalProps} />,
[DialogTypes.Preferences]: <PreferencesDialog {...modalProps} />,
[DialogTypes.RateLimit]: <RateLimitDialog {...modalProps} />,
[DialogTypes.Receive]: <ReceiveDialog {...modalProps} />,
[DialogTypes.RestrictedGeo]: <RestrictedGeoDialog {...modalProps} />,
+64 -37
View File
@@ -1,19 +1,22 @@
import { useMemo } from 'react';
import styled, { css } from 'styled-components';
import { NotificationStatus } from '@/constants/notifications';
import { ButtonSize } from '@/constants/buttons';
import { useNotifications } from '@/hooks/useNotifications';
import { useBreakpoints } from '@/hooks/useBreakpoints';
import { ToastArea } from '@/components/ToastArea';
import { Toast } from '@/components/Toast';
import { Button } from '@/components/Button';
import styled from 'styled-components';
import { Toast } from '@/components/Toast';
import { ToastArea } from '@/components/ToastArea';
type StyleProps = {
className?: string;
};
const MAX_TOASTS = 10;
export const NotificationsToastArea = ({ className }: StyleProps) => {
const {
notifications,
@@ -26,10 +29,9 @@ export const NotificationsToastArea = ({ className }: StyleProps) => {
} = useNotifications();
const { isMobile } = useBreakpoints();
return (
<StyledToastArea swipeDirection={isMobile ? 'up' : 'right'} className={className}>
{Object.values(notifications)
const notificationMap = useMemo(() => {
return (
Object.values(notifications)
// Sort by time of first trigger
.sort(
(n1, n2) =>
@@ -42,44 +44,69 @@ export const NotificationsToastArea = ({ className }: StyleProps) => {
displayData: getDisplayData(notification),
}))
.filter(({ displayData }) => displayData)
.map(({ notification, key, displayData }) => (
<Toast
key={key}
isOpen={notification.status < NotificationStatus.Unseen}
slotIcon={displayData.icon}
slotTitle={displayData.title}
slotDescription={displayData.description}
slotCustomContent={displayData.customContent}
slotAction={
<Button size={ButtonSize.Small} onClick={() => onNotificationAction(notification)}>
{displayData.actionDescription}
</Button>
}
actionDescription={displayData.actionDescription}
actionAltText={displayData.actionAltText}
duration={isMenuOpen ? Infinity : displayData.toastDuration}
sensitivity={displayData.toastSensitivity}
setIsOpen={(isOpen, isClosedFromTimeout) => {
if (!isOpen)
if (isClosedFromTimeout)
// Toast timer expired without user interaction
markUnseen(notification);
// Toast interacted with or dismissed
else markSeen(notification);
}}
lastUpdated={notification.timestamps[notification.status]}
/>
))}
.slice(-MAX_TOASTS)
);
}, [notifications, getKey, getDisplayData]);
if (isMenuOpen) return null;
return (
<StyledToastArea swipeDirection={isMobile ? 'up' : 'right'} className={className}>
{notificationMap.map(({ notification, key, displayData }, idx) => (
<StyledToast
key={key}
layer={notificationMap.length - 1 - idx}
isOpen={notification.status < NotificationStatus.Unseen}
notification={notification}
slotIcon={displayData.icon}
slotTitle={displayData.title}
slotTitleLeft={displayData.slotTitleLeft}
slotTitleRight={displayData.slotTitleRight}
slotDescription={displayData.body}
slotCustomContent={displayData.renderCustomBody?.({ isToast: true, notification })}
onClick={() => onNotificationAction(notification)}
slotAction={
<Button size={ButtonSize.Small} onClick={() => onNotificationAction(notification)}>
{displayData.actionDescription}
</Button>
}
actionDescription={displayData.actionDescription}
actionAltText={displayData.actionAltText}
duration={displayData.toastDuration ?? Infinity}
sensitivity={displayData.toastSensitivity}
setIsOpen={(isOpen, isClosedFromTimeout) => {
if (!isOpen)
if (isClosedFromTimeout)
// Toast timer expired without user interaction
markUnseen(notification);
// Toast interacted with or dismissed
else markSeen(notification);
}}
lastUpdated={notification.timestamps[notification.status]}
/>
))}
</StyledToastArea>
);
};
const StyledToastArea = styled(ToastArea)`
position: absolute;
width: min(16rem, 100%);
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);
`;
const StyledToast = styled(Toast)<{ layer: number }>`
// Stacked toast
position: absolute;
top: 0;
left: 0;
right: 0;
${({ layer }) => css`
right: calc(${layer} * -2px);
top: calc(${layer} * 2px);
`}
`;
+12 -7
View File
@@ -1,6 +1,7 @@
import Abacus, { type Nullable } from '@dydxprotocol/v4-abacus';
import Long from 'long';
import type { IndexedTx } from '@cosmjs/stargate';
import { encodeJson } from '@dydxprotocol/v4-client-js';
import {
CompositeClient,
@@ -195,8 +196,9 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol {
throw new StatefulOrderError('Stateful order has failed to commit.', tx);
}
const parsedTx = this.parseToPrimitives(tx);
const hash = parsedTx?.hash;
const encodedTx = encodeJson(tx);
const parsedTx = JSON.parse(encodedTx);
const hash = parsedTx.hash.toUpperCase();
if (isTestnet) {
console.log(
@@ -206,7 +208,7 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol {
);
} else console.log(`txHash: ${hash}`);
return JSON.stringify(parsedTx);
return encodedTx;
} catch (error) {
if (error?.name !== 'BroadcastError') {
log('DydxChainTransactions/placeOrderTransaction', error);
@@ -236,9 +238,14 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol {
goodTilBlockTime || undefined
);
const parsedTx = this.parseToPrimitives(tx);
const encodedTx = encodeJson(tx);
return JSON.stringify(parsedTx);
if (import.meta.env.MODE === 'development') {
const parsedTx = JSON.parse(encodedTx);
console.log(parsedTx, parsedTx.hash.toUpperCase());
}
return encodedTx;
} catch (error) {
log('DydxChainTransactions/cancelOrderTransaction', error);
@@ -434,8 +441,6 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol {
const parseDelegations = this.parseToPrimitives(delegations);
callback(JSON.stringify(parseDelegations));
break;
// Do not implement Transfers (yet)
case QueryType.Transfers:
default:
break;
}
+2
View File
@@ -35,6 +35,7 @@ import { setApiState } from '@/state/app';
import { setAssets } from '@/state/assets';
import { setConfigs } from '@/state/configs';
import { setInputs } from '@/state/inputs';
import { updateNotifications } from '@/state/notifications';
import { setHistoricalFundings, setLiveTrades, setMarkets, setOrderbook } from '@/state/perpetuals';
import { isTruthy } from '../isTruthy';
@@ -50,6 +51,7 @@ class AbacusStateNotifier implements AbacusStateNotificationProtocol {
}
notificationsChanged(notifications: kollections.List<AbacusNotification>): void {
this.store?.dispatch(updateNotifications(notifications.toArray()));
return;
}
+3
View File
@@ -1,6 +1,7 @@
import { configureStore } from '@reduxjs/toolkit';
import abacusStateManager from '@/lib/abacus';
import appMiddleware from './appMiddleware';
import localizationMiddleware from './localizationMiddleware';
@@ -13,6 +14,7 @@ import { inputsSlice } from './inputs';
import { layoutSlice } from './layout';
import { localizationSlice } from './localization';
import { perpetualsSlice } from './perpetuals';
import { notificationsSlice } from './notifications';
export const commandMenuSlices = [layoutSlice, localizationSlice];
@@ -26,6 +28,7 @@ export const store = configureStore({
inputs: inputsSlice.reducer,
layout: layoutSlice.reducer,
localization: localizationSlice.reducer,
notifications: notificationsSlice.reducer,
perpetuals: perpetualsSlice.reducer,
},
+7 -1
View File
@@ -1,5 +1,10 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { LOCALE_DATA, SupportedLocale, TOOLTIPS } from '@dydxprotocol/v4-localization';
import {
LOCALE_DATA,
NOTIFICATIONS,
SupportedLocale,
TOOLTIPS,
} from '@dydxprotocol/v4-localization';
import {
type LocaleData,
@@ -26,6 +31,7 @@ const getNewLocaleData = ({
const newLocaleData = {
...LOCALE_DATA[locale],
...NOTIFICATIONS[locale],
TOOLTIPS: TOOLTIPS[locale],
};
+24
View File
@@ -0,0 +1,24 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import { AbacusNotification } from '@/constants/abacus';
export interface NotificationsState {
abacusNotifications: AbacusNotification[];
}
const initialState: NotificationsState = {
abacusNotifications: [],
};
export const notificationsSlice = createSlice({
name: 'Notifications',
initialState,
reducers: {
updateNotifications: (state, action: PayloadAction<AbacusNotification[]>) => {
state.abacusNotifications = action.payload;
},
},
});
export const { updateNotifications } = notificationsSlice.actions;
+3
View File
@@ -0,0 +1,3 @@
import { RootState } from './_store';
export const getAbacusNotifications = (state: RootState) => state.notifications.abacusNotifications;
+2 -2
View File
@@ -12,8 +12,8 @@ export const headerMixins = {
--trigger-open-textColor: var(--color-text-2);
&:hover:not(:disabled) {
background-color: var(--trigger-hover-backgroundColor);
color: var(--trigger-hover-textColor);
--trigger-backgroundColor: var(--trigger-hover-backgroundColor);
--trigger-textColor: var(--trigger-hover-textColor);
}
`,
+49 -40
View File
@@ -1,6 +1,7 @@
import { type OrderStatus, AbacusOrderStatus } from '@/constants/abacus';
import styled from 'styled-components';
import { AbacusOrderStatus } from '@/constants/abacus';
import { Icon } from '@/components/Icon';
import {
OrderCanceledIcon,
OrderFilledIcon,
@@ -9,49 +10,57 @@ import {
OrderPendingIcon,
} from '@/icons';
import styled from 'styled-components';
import { Icon } from '@/components/Icon';
type ElementProps = {
status: OrderStatus;
status: string;
totalFilled: number;
};
export const OrderStatusIcon = ({ status, totalFilled }: ElementProps) => (
<$Icon
{...{
[AbacusOrderStatus.open.name]:
totalFilled > 0
? {
iconComponent: OrderPartiallyFilledIcon,
color: 'var(--color-warning)',
}
: {
iconComponent: OrderOpenIcon,
color: 'var(--color-text-2)',
},
[AbacusOrderStatus.filled.name]: {
iconComponent: OrderFilledIcon,
color: 'var(--color-success)',
},
[AbacusOrderStatus.cancelled.name]: {
iconComponent: OrderCanceledIcon,
color: 'var(--color-error)',
},
[AbacusOrderStatus.canceling.name]: {
iconComponent: OrderPendingIcon,
color: 'var(--color-error)',
},
[AbacusOrderStatus.pending.name]: {
iconComponent: OrderPendingIcon,
color: 'var(--color-text-2)',
},
[AbacusOrderStatus.untriggered.name]: {
iconComponent: OrderPendingIcon,
color: 'var(--color-text-2)',
},
}[status.name]}
/>
);
type StyleProps = {
className?: string;
};
export const OrderStatusIcon = ({ className, status, totalFilled }: ElementProps & StyleProps) => {
const { iconComponent, color } = {
[AbacusOrderStatus.open.rawValue]:
totalFilled > 0
? {
iconComponent: OrderPartiallyFilledIcon,
color: 'var(--color-warning)',
}
: {
iconComponent: OrderOpenIcon,
color: 'var(--color-text-2)',
},
[AbacusOrderStatus.partiallyFilled.rawValue]: {
iconComponent: OrderPartiallyFilledIcon,
color: 'var(--color-warning)',
},
[AbacusOrderStatus.filled.rawValue]: {
iconComponent: OrderFilledIcon,
color: 'var(--color-success)',
},
[AbacusOrderStatus.cancelled.rawValue]: {
iconComponent: OrderCanceledIcon,
color: 'var(--color-error)',
},
[AbacusOrderStatus.canceling.rawValue]: {
iconComponent: OrderPendingIcon,
color: 'var(--color-error)',
},
[AbacusOrderStatus.pending.rawValue]: {
iconComponent: OrderPendingIcon,
color: 'var(--color-text-2)',
},
[AbacusOrderStatus.untriggered.rawValue]: {
iconComponent: OrderPendingIcon,
color: 'var(--color-text-2)',
},
}[status];
return <$Icon className={className} iconComponent={iconComponent} color={color} />;
};
const $Icon = styled(Icon)<{ color: string }>`
color: ${({ color }) => color};
-185
View File
@@ -1,185 +0,0 @@
import { useCallback, useState, useMemo } from 'react';
import styled, { type AnyStyledComponent } from 'styled-components';
import { Root, Trigger, Content } from '@radix-ui/react-collapsible';
import { StatusResponse } from '@0xsquid/sdk';
import { useInterval, useStringGetter } from '@/hooks';
import { STRING_KEYS } from '@/constants/localization';
import { AlertType } from '@/constants/alerts';
import { formatSeconds } from '@/lib/timeUtils';
import { AlertMessage } from '@/components/AlertMessage';
import { Output, OutputType } from '@/components/Output';
import { WithReceipt } from '@/components/WithReceipt';
import { Icon, IconName } from '@/components/Icon';
import { TransferStatusSteps } from '@/views/TransferStatusSteps';
import { LoadingDots } from '@/components/Loading/LoadingDots';
import { layoutMixins } from '@/styles/layoutMixins';
type ElementProps = {
type: 'withdrawal' | 'deposit';
toAmount?: number;
triggeredAt?: number;
status?: StatusResponse;
};
export const TransferStatusToast = ({
type,
toAmount,
triggeredAt = Date.now(),
status,
}: ElementProps) => {
const stringGetter = useStringGetter();
const [open, setOpen] = useState<boolean>(false);
const [secondsLeft, setSecondsLeft] = useState<number>(0);
// @ts-ignore status.errors is not in the type definition but can be returned
const error = status?.errors?.length ? status?.errors[0] : status?.error;
const updateSecondsLeft = useCallback(() => {
const fromChainEta = (status?.fromChain?.chainData?.estimatedRouteDuration || 0) * 1000;
const toChainEta = (status?.toChain?.chainData?.estimatedRouteDuration || 0) * 1000;
setSecondsLeft(Math.floor((triggeredAt + fromChainEta + toChainEta - Date.now()) / 1000));
}, [status]);
useInterval({ callback: updateSecondsLeft });
if (!status) return <LoadingDots size={3} />;
const inProgressStatusString =
type === 'deposit'
? secondsLeft > 0
? STRING_KEYS.DEPOSIT_STATUS
: STRING_KEYS.DEPOSIT_STATUS_SHORTLY
: secondsLeft > 0
? STRING_KEYS.WITHDRAW_STATUS
: STRING_KEYS.WITHDRAW_STATUS_SHORTLY;
const statusString =
type === 'deposit'
? status?.squidTransactionStatus === 'success'
? STRING_KEYS.DEPOSIT_COMPLETE
: inProgressStatusString
: status?.squidTransactionStatus === 'success'
? STRING_KEYS.WITHDRAW_COMPLETE
: inProgressStatusString;
return (
<Styled.Root open={open} onOpenChange={setOpen}>
<WithReceipt
hideReceipt={!open}
side="bottom"
slotReceipt={
<Styled.Receipt>
<TransferStatusSteps status={status} type={type} />
</Styled.Receipt>
}
>
<Styled.BridgingStatus>
<Styled.Status>
{stringGetter({
key: statusString,
params: {
AMOUNT_USD: <Styled.InlineOutput type={OutputType.Fiat} value={toAmount} />,
ESTIMATED_DURATION: (
<Styled.InlineOutput
type={OutputType.Text}
value={formatSeconds(Math.max(secondsLeft || 0, 0))}
/>
),
},
})}
</Styled.Status>
{error && (
<AlertMessage type={AlertType.Error}>
{stringGetter({
key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE,
params: {
ERROR_MESSAGE: error.message || stringGetter({ key: STRING_KEYS.UNKNOWN_ERROR }),
},
})}
</AlertMessage>
)}
<Styled.Trigger>
<Styled.TriggerIcon>
<Icon iconName={IconName.Caret} />
</Styled.TriggerIcon>
{stringGetter({ key: open ? STRING_KEYS.HIDE_DETAILS : STRING_KEYS.VIEW_DETAILS })}
</Styled.Trigger>
</Styled.BridgingStatus>
</WithReceipt>
</Styled.Root>
);
};
const Styled: Record<string, AnyStyledComponent> = {};
Styled.Root = styled(Root)`
margin: 0.5rem -1rem -1rem -1rem;
color: var(--color-text-0);
font: var(--font-small-book);
`;
Styled.Receipt = styled.div`
padding: 0 1rem;
`;
Styled.BridgingStatus = styled.div`
${layoutMixins.flexColumn};
background-color: var(--color-layer-3);
gap: 0.5rem;
padding: 0 1rem 1rem 1rem;
border-radius: 0.5rem;
`;
Styled.Status = styled.div`
color: var(--color-text-0);
font-size: 0.875rem;
`;
Styled.InlineOutput = styled(Output)`
display: inline-block;
color: var(--color-text-1);
`;
Styled.Content = styled(Content)`
${layoutMixins.flexColumn};
gap: 0.5rem;
padding: 1rem;
`;
Styled.Step = styled.div`
${layoutMixins.row};
gap: 0.5rem;
`;
Styled.Trigger = styled(Trigger)`
display: flex;
align-items: center;
gap: 0.5em;
color: var(--color-accent);
user-select: none;
&:focus {
outline: none;
}
`;
Styled.TriggerIcon = styled.span`
width: 0.75em;
display: inline-flex;
transition: rotate 0.3s var(--ease-out-expo);
${Styled.Trigger}[data-state='open'] & {
rotate: -0.5turn;
}
`;
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useMemo, useState } from 'react';
import styled, { AnyStyledComponent } from 'styled-components';
import { STRING_KEYS } from '@/constants/localization';
import { NotificationType } from '@/constants/notifications';
import { useStringGetter } from '@/hooks';
import { useNotifications } from '@/hooks/useNotifications';
import { ComboboxDialogMenu } from '@/components/ComboboxDialogMenu';
import { Switch } from '@/components/Switch';
export const usePreferenceMenu = () => {
const stringGetter = useStringGetter();
// Notifications
const { notificationPreferences, setNotificationPreferences } = useNotifications();
const [enabledNotifs, setEnabledNotifs] = useState(notificationPreferences);
const toggleNotifPreference = (type: NotificationType) =>
setEnabledNotifs((prev) => ({ ...prev, [type]: !prev[type] }));
useEffect(() => {
setNotificationPreferences(enabledNotifs);
}, [enabledNotifs]);
const notificationSection = useMemo(
() => ({
group: 'Notifications',
groupLabel: stringGetter({ key: STRING_KEYS.NOTIFICATIONS }),
items: [
{
value: NotificationType.AbacusGenerated,
label: stringGetter({ key: STRING_KEYS.TRADING }),
slotAfter: (
<Switch
name={NotificationType.AbacusGenerated}
checked={enabledNotifs[NotificationType.AbacusGenerated]}
onCheckedChange={(enabled: boolean) => null}
/>
),
onSelect: () => toggleNotifPreference(NotificationType.AbacusGenerated),
},
{
value: NotificationType.SquidTransfer,
label: stringGetter({ key: STRING_KEYS.TRANSFERS }),
slotAfter: (
<Switch
name={NotificationType.SquidTransfer}
checked={enabledNotifs[NotificationType.SquidTransfer]}
onCheckedChange={(enabled: boolean) => null}
/>
),
onSelect: () => toggleNotifPreference(NotificationType.SquidTransfer),
},
],
}),
[stringGetter, enabledNotifs]
);
return [notificationSection];
};
type ElementProps = {
setIsOpen: (open: boolean) => void;
};
export const PreferencesDialog = ({ setIsOpen }: ElementProps) => {
const stringGetter = useStringGetter();
const preferenceItems = usePreferenceMenu();
return (
<Styled.ComboboxDialogMenu
isOpen
title={stringGetter({ key: STRING_KEYS.PREFERENCES })}
items={preferenceItems}
setIsOpen={setIsOpen}
/>
);
};
const Styled: Record<string, AnyStyledComponent> = {};
Styled.ComboboxDialogMenu = styled(ComboboxDialogMenu)`
--dialog-content-paddingBottom: 0.5rem;
`;
@@ -10,6 +10,7 @@ import { AlertType } from '@/constants/alerts';
import { ButtonSize } from '@/constants/buttons';
import { STRING_KEYS } from '@/constants/localization';
import { ENVIRONMENT_CONFIG_MAP } from '@/constants/networks';
import { NotificationStatus } from '@/constants/notifications';
import { NumberSign } from '@/constants/numbers';
import type { EvmAddress } from '@/constants/wallets';
@@ -244,6 +245,7 @@ export const DepositForm = ({ onDeposit, onError }: DepositFormProps) => {
fromChainId: chainIdStr || undefined,
toAmount: summary?.usdcSize || undefined,
triggeredAt: Date.now(),
notificationStatus: NotificationStatus.Triggered,
});
abacusStateManager.clearTransferInputValues();
setFromAmount('');
@@ -10,6 +10,7 @@ import { AlertType } from '@/constants/alerts';
import { ButtonSize } from '@/constants/buttons';
import { STRING_KEYS } from '@/constants/localization';
import { ENVIRONMENT_CONFIG_MAP } from '@/constants/networks';
import { NotificationStatus } from '@/constants/notifications';
import { NumberSign } from '@/constants/numbers';
import {
@@ -171,6 +172,7 @@ export const WithdrawForm = () => {
toChainId: chainIdStr || undefined,
toAmount: debouncedAmountBN.toNumber(),
triggeredAt: Date.now(),
notificationStatus: NotificationStatus.Triggered,
});
abacusStateManager.clearTransferInputValues();
setWithdrawAmount('');
+6
View File
@@ -184,6 +184,12 @@ export const AccountMenu = () => {
},
].filter(isTruthy)
: []),
{
value: 'Preferences',
icon: <Icon iconName={IconName.Gear} />,
label: stringGetter({ key: STRING_KEYS.PREFERENCES }),
onSelect: () => dispatch(openDialog({ type: DialogTypes.Preferences })),
},
{
value: 'Disconnect',
icon: <Icon iconName={IconName.BoxClose} />,
+37 -60
View File
@@ -3,18 +3,17 @@ import styled from 'styled-components';
import { layoutMixins } from '@/styles/layoutMixins';
import { groupBy } from 'lodash';
import { ButtonAction, ButtonSize } from '@/constants/buttons';
import { STRING_KEYS } from '@/constants/localization';
import { type Notification, NotificationStatus } from '@/constants/notifications';
import { useBreakpoints } from '@/hooks';
import { useBreakpoints, useStringGetter } from '@/hooks';
import { useNotifications } from '@/hooks/useNotifications';
import { CloseIcon } from '@/icons';
import { Button } from '@/components/Button';
import { ButtonAction, ButtonSize } from '@/constants/buttons';
import { ComboboxDialogMenu } from '@/components/ComboboxDialogMenu';
import { DialogPlacement } from '@/components/Dialog';
import { Output, OutputType } from '@/components/Output';
import { IconButton } from '@/components/IconButton';
import { Notification as NotificationCard } from '@/components/Notification';
import { Toolbar } from '@/components/Toolbar';
type ElementProps = {
@@ -27,13 +26,14 @@ export const NotificationsMenu = ({
placement = DialogPlacement.Sidebar,
}: ElementProps) => {
const { isTablet } = useBreakpoints();
const stringGetter = useStringGetter();
const {
notifications,
getDisplayData,
getKey,
markSeen,
markCleared,
markAllCleared,
onNotificationAction,
@@ -65,14 +65,12 @@ export const NotificationsMenu = ({
const items: Parameters<typeof ComboboxDialogMenu>[0]['items'] = useMemo(
() =>
(Object.entries(notificationsByStatus) as unknown as [NotificationStatus, Notification[]][])
// .filter(([status]) => status !== NotificationStatus.Cleared)
.filter(([status]) => status < NotificationStatus.Cleared)
.map(([status, notifications]) => ({
group: status,
groupLabel: {
[NotificationStatus.Triggered]: 'New',
// [NotificationStatus.Updated]: 'Updates',
[NotificationStatus.Triggered]: stringGetter({ key: STRING_KEYS.NEW }),
[NotificationStatus.Seen]: 'Seen',
[NotificationStatus.Cleared]: 'Archived',
}[status as number],
items: notifications
@@ -83,59 +81,40 @@ export const NotificationsMenu = ({
)
.map((notification) => ({
notification,
key: getKey(notification),
displayData: getDisplayData(notification),
key: getKey?.(notification),
displayData: getDisplayData?.(notification),
}))
.map(({ notification, key, displayData }) => ({
value: key,
label: displayData.title ?? '',
description: displayData.customMenuContent || displayData.description,
slotBefore: !displayData.customMenuContent && displayData.icon,
slotAfter: !displayData.customMenuContent && (
<>
<$Output
type={OutputType.RelativeTime}
value={
notification.timestamps[NotificationStatus.Updated] ||
notification.timestamps[NotificationStatus.Triggered]
}
/>
{notification.status < NotificationStatus.Seen ? <$UnreadIndicator /> : null}
{notification.status < NotificationStatus.Cleared ? (
<$IconButton
iconComponent={CloseIcon}
onClick={(e) => {
e.stopPropagation();
/*if (notification.status < NotificationStatus.Seen) {
markSeen(notification);
} else*/ if (notification.status < NotificationStatus.Cleared) {
markCleared(notification);
}
}}
/>
) : null}
</>
label: displayData.title,
description: displayData.body,
slotCustomContent: displayData.renderCustomBody?.({ notification }) ?? (
<NotificationCard
isToast={false}
slotIcon={displayData.icon}
slotTitle={displayData.title}
slotDescription={displayData.body}
notification={notification}
/>
),
disabled: notification.status === NotificationStatus.Cleared,
onSelect: () => {
onNotificationAction(notification);
markSeen(notification);
onNotificationAction?.(notification);
markSeen?.(notification);
},
})),
}))
.filter(({ items }) => items.length),
[notificationsByStatus, getDisplayData, onNotificationAction, markSeen]
[notificationsByStatus, getDisplayData, onNotificationAction, markSeen, stringGetter]
);
return (
<ComboboxDialogMenu
<$ComboboxDialogMenu
withItemBorders
isOpen={isMenuOpen || placement === DialogPlacement.Inline}
setIsOpen={setIsMenuOpen}
items={items}
title="Notifications"
title={stringGetter({ key: STRING_KEYS.NOTIFICATIONS })}
slotTrigger={
<$TriggerContainer>
{slotTrigger}
@@ -150,11 +129,11 @@ export const NotificationsMenu = ({
state={{ isLoading: isEnablingPush }}
onClick={enablePush}
>
Enable Push Notifications
{stringGetter({ key: STRING_KEYS.ENABLE_PUSH_NOTIFICATIONS })}
</Button>
) : (
<Button size={ButtonSize.Small} action={ButtonAction.Secondary} onClick={disablePush}>
Disable Push Notifications
{stringGetter({ key: STRING_KEYS.DISABLE_PUSH_NOTIFICATIONS })}
</Button>
)}
@@ -168,7 +147,7 @@ export const NotificationsMenu = ({
),
}}
>
Clear All
{stringGetter({ key: STRING_KEYS.CLEAR_ALL })}
</Button>
</$FooterToolbar>
}
@@ -178,6 +157,14 @@ export const NotificationsMenu = ({
);
};
const $ComboboxDialogMenu = styled(ComboboxDialogMenu)`
--comboboxDialogMenu-item-padding: 0;
[cmdk-list] > [cmdk-list-sizer] > * {
box-shadow: none;
}
`;
const $UnreadIndicator = styled.div`
width: 0.5rem;
height: 0.5rem;
@@ -198,16 +185,6 @@ const $TriggerUnreadIndicator = styled($UnreadIndicator)`
top: -0.325rem;
`;
const $Output = styled(Output)`
color: var(--color-text-0);
`;
const $IconButton = styled(IconButton)`
--button-border: none;
--button-textColor: var(--color-text-0);
--button-hover-textColor: var(--color-text-1);
`;
const $FooterToolbar = styled(Toolbar)`
display: flex;
flex-wrap: wrap;
@@ -0,0 +1,127 @@
import styled, { type AnyStyledComponent } from 'styled-components';
import { OrderSide } from '@dydxprotocol/v4-client-js';
import { shallowEqual, useSelector } from 'react-redux';
import {
AbacusOrderStatus,
AbacusOrderType,
KotlinIrEnumValues,
ORDER_STATUS_STRINGS,
TRADE_TYPES,
} from '@/constants/abacus';
import { STRING_KEYS } from '@/constants/localization';
import { TRADE_TYPE_STRINGS, TradeTypes } from '@/constants/trade';
import { useStringGetter } from '@/hooks';
import { layoutMixins } from '@/styles/layoutMixins';
import { AssetIcon } from '@/components/AssetIcon';
import { Details } from '@/components/Details';
import { Notification, NotificationProps } from '@/components/Notification';
import { OrderSideTag } from '@/components/OrderSideTag';
import { OrderStatusIcon } from '@/views/OrderStatusIcon';
import { Output, OutputType } from '@/components/Output';
import { getMarketData } from '@/state/perpetualsSelectors';
type ElementProps = {
data: {
AMOUNT: string;
ASSET: string;
AVERAGE_PRICE: string;
FILLED_AMOUNT: string;
MARKET: string;
ORDER_STATUS: string;
ORDER_TYPE: string;
ORDER_TYPE_TEXT: string;
PRICE: string;
SIDE: string;
};
};
export type TradeNotificationProps = NotificationProps & ElementProps;
export const TradeNotification = ({ isToast, data, notification }: TradeNotificationProps) => {
const stringGetter = useStringGetter();
const { AVERAGE_PRICE, FILLED_AMOUNT, MARKET, ORDER_TYPE, ORDER_STATUS, SIDE } = data;
const marketData = useSelector(getMarketData(MARKET), shallowEqual);
const { assetId } = marketData ?? {};
const orderType = ORDER_TYPE as KotlinIrEnumValues<typeof AbacusOrderType>;
const tradeType = TRADE_TYPES[orderType];
const titleKey = tradeType && TRADE_TYPE_STRINGS[tradeType]?.tradeTypeKey;
const orderStatus = ORDER_STATUS as KotlinIrEnumValues<typeof AbacusOrderStatus>;
return (
<Notification
isToast={isToast}
notification={notification}
slotIcon={<AssetIcon symbol={assetId} />}
slotTitle={titleKey && stringGetter({ key: titleKey })}
slotTitleRight={
<Styled.OrderStatus>
{stringGetter({ key: ORDER_STATUS_STRINGS[orderStatus] })}
<Styled.OrderStatusIcon status={orderStatus} totalFilled={parseFloat(FILLED_AMOUNT)} />
</Styled.OrderStatus>
}
slotCustomContent={
<Styled.Details
items={[
{
key: 'size',
label: (
<Styled.Label>
{stringGetter({ key: STRING_KEYS.SIZE })}
<OrderSideTag
orderSide={SIDE === STRING_KEYS.BUY ? OrderSide.BUY : OrderSide.SELL}
/>
</Styled.Label>
),
value: (
<Output type={OutputType.Asset} value={FILLED_AMOUNT} tag={marketData?.assetId} />
),
},
{
key: 'price',
label: stringGetter({ key: STRING_KEYS.PRICE }),
value:
ORDER_TYPE === TradeTypes.MARKET ? (
<span>{stringGetter({ key: STRING_KEYS.MARKET_ORDER_SHORT })}</span>
) : (
<Output
type={OutputType.Fiat}
value={AVERAGE_PRICE}
fractionDigits={marketData?.configs?.displayTickSizeDecimals}
/>
),
},
]}
/>
}
/>
);
};
const Styled: Record<string, AnyStyledComponent> = {};
Styled.Label = styled.span`
${layoutMixins.row}
gap: 0.5ch;
`;
Styled.OrderStatus = styled(Styled.Label)`
color: var(--color-text-0);
font: var(--font-small-book);
`;
Styled.OrderStatusIcon = styled(OrderStatusIcon)`
width: 0.9375rem;
height: 0.9375rem;
`;
Styled.Details = styled(Details)`
--details-item-height: 1.5rem;
dd {
color: var(--color-text-2);
}
`;
@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import styled, { css, keyframes, type AnyStyledComponent } from 'styled-components';
import styled, { css, type AnyStyledComponent } from 'styled-components';
import { StatusResponse } from '@0xsquid/sdk';
import { useStringGetter, useSelectedNetwork, useURLConfigs } from '@/hooks';
@@ -18,6 +18,10 @@ type ElementProps = {
type: 'withdrawal' | 'deposit';
};
type StyleProps = {
className?: string;
};
enum TransferStatusStep {
FromChain,
Bridge,
@@ -25,7 +29,7 @@ enum TransferStatusStep {
Complete,
}
export const TransferStatusSteps = ({ status, type }: ElementProps) => {
export const TransferStatusSteps = ({ className, status, type }: ElementProps & StyleProps) => {
const stringGetter = useStringGetter();
const { selectedNetwork } = useSelectedNetwork();
const { mintscan: mintscanTxUrl } = useURLConfigs();
@@ -101,7 +105,7 @@ export const TransferStatusSteps = ({ status, type }: ElementProps) => {
if (!status) return <LoadingDots size={3} />;
return (
<Styled.BridgingStatus>
<Styled.BridgingStatus className={className}>
{steps.map((step) => (
<Styled.Step key={step.step}>
<Styled.row>
@@ -0,0 +1,225 @@
import { useCallback, useState, useMemo, MouseEvent } from 'react';
import styled, { css, type AnyStyledComponent } from 'styled-components';
import { useInterval, useStringGetter } from '@/hooks';
import { AlertType } from '@/constants/alerts';
import { STRING_KEYS } from '@/constants/localization';
import { TransferNotifcation } from '@/constants/notifications';
import { formatSeconds } from '@/lib/timeUtils';
import { AlertMessage } from '@/components/AlertMessage';
import { Collapsible } from '@/components/Collapsible';
import { Icon, IconName } from '@/components/Icon';
import { LoadingDots } from '@/components/Loading/LoadingDots';
import { Notification, NotificationProps } from '@/components/Notification';
import { Output, OutputType } from '@/components/Output';
import { WithReceipt } from '@/components/WithReceipt';
import { layoutMixins } from '@/styles/layoutMixins';
import { TransferStatusSteps } from './TransferStatusSteps';
type ElementProps = {
type: 'withdrawal' | 'deposit';
transfer: TransferNotifcation;
triggeredAt?: number;
};
export const TransferStatusNotification = ({
isToast,
notification,
slotIcon,
slotTitle,
slotDescription,
transfer,
type,
triggeredAt = Date.now(),
}: ElementProps & NotificationProps) => {
const stringGetter = useStringGetter();
const [open, setOpen] = useState<boolean>(false);
const [secondsLeft, setSecondsLeft] = useState<number>(0);
const { fromChainId, status, txHash, toAmount } = transfer;
// @ts-ignore status.errors is not in the type definition but can be returned
const error = status?.errors?.length ? status?.errors[0] : status?.error;
const updateSecondsLeft = useCallback(() => {
const fromChainEta = (status?.fromChain?.chainData?.estimatedRouteDuration || 0) * 1000;
const toChainEta = (status?.toChain?.chainData?.estimatedRouteDuration || 0) * 1000;
setSecondsLeft(Math.floor((triggeredAt + fromChainEta + toChainEta - Date.now()) / 1000));
}, [status]);
useInterval({ callback: updateSecondsLeft });
const inProgressStatusString =
type === 'deposit'
? secondsLeft > 0
? STRING_KEYS.DEPOSIT_STATUS
: STRING_KEYS.DEPOSIT_STATUS_SHORTLY
: secondsLeft > 0
? STRING_KEYS.WITHDRAW_STATUS
: STRING_KEYS.WITHDRAW_STATUS_SHORTLY;
const statusString =
type === 'deposit'
? status?.squidTransactionStatus === 'success'
? STRING_KEYS.DEPOSIT_COMPLETE
: inProgressStatusString
: status?.squidTransactionStatus === 'success'
? STRING_KEYS.WITHDRAW_COMPLETE
: inProgressStatusString;
const content = (
<>
<Styled.Status>
{stringGetter({
key: statusString,
params: {
AMOUNT_USD: <Styled.InlineOutput type={OutputType.Fiat} value={toAmount} />,
ESTIMATED_DURATION: (
<Styled.InlineOutput
type={OutputType.Text}
value={formatSeconds(Math.max(secondsLeft || 0, 0))}
/>
),
},
})}
</Styled.Status>
{error && (
<AlertMessage type={AlertType.Error}>
{stringGetter({
key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE,
params: {
ERROR_MESSAGE: error.message || stringGetter({ key: STRING_KEYS.UNKNOWN_ERROR }),
},
})}
</AlertMessage>
)}
</>
);
const transferNotif = (
<Notification
isToast={isToast}
notification={notification}
slotIcon={isToast && slotIcon}
slotTitle={slotTitle}
slotCustomContent={
!status ? (
<LoadingDots size={3} />
) : (
<Styled.BridgingStatus>
{content}
{!isToast && status?.squidTransactionStatus !== 'success' && (
<Styled.TransferStatusSteps status={status} type={type} />
)}
</Styled.BridgingStatus>
)
}
slotAction={
isToast &&
status &&
!error && (
<Styled.Trigger
isOpen={open}
onClick={(e: MouseEvent) => {
e.stopPropagation();
setOpen(!open);
}}
>
<Icon iconName={IconName.Caret} />{' '}
{stringGetter({
key: open ? STRING_KEYS.HIDE_DETAILS : STRING_KEYS.VIEW_DETAILS,
})}
</Styled.Trigger>
)
}
withClose={false}
/>
);
return isToast ? (
<WithReceipt
hideReceipt={!open}
side="bottom"
slotReceipt={
<Collapsible open={open} onOpenChange={setOpen} label="" withTrigger={false}>
<Styled.Receipt>
<TransferStatusSteps status={status} type={type} />
</Styled.Receipt>
</Collapsible>
}
>
{transferNotif}
</WithReceipt>
) : (
transferNotif
);
};
const Styled: Record<string, AnyStyledComponent> = {};
Styled.BridgingStatus = styled.div`
${layoutMixins.flexColumn};
gap: 0.5rem;
`;
Styled.Status = styled.div<{ withMarginBottom?: boolean }>`
color: var(--color-text-0);
font-size: 0.875rem;
${({ withMarginBottom }) =>
withMarginBottom &&
css`
margin-bottom: 0.5rem;
`}
`;
Styled.InlineOutput = styled(Output)`
display: inline-block;
color: var(--color-text-1);
`;
Styled.Step = styled.div`
${layoutMixins.row};
gap: 0.5rem;
`;
Styled.TransferStatusSteps = styled(TransferStatusSteps)`
padding: 0.5rem 0 0;
`;
Styled.Trigger = styled.button<{ isOpen?: boolean }>`
display: flex;
align-items: center;
gap: 0.5em;
color: var(--color-accent);
user-select: none;
cursor: pointer;
&:focus-visible {
outline: none;
text-decoration: underline;
}
svg {
transition: rotate 0.3s var(--ease-out-expo);
}
${({ isOpen }) =>
isOpen &&
css`
& > svg {
rotate: -0.5turn;
}
`}
`;
Styled.Receipt = styled.div`
padding: 0 1rem;
`;