diff --git a/package.json b/package.json index 06c5f6e..2403e67 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd5bd27..5302312 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/components/Collapsible.stories.tsx b/src/components/Collapsible.stories.tsx index 80e8323..f47a2f5 100644 --- a/src/components/Collapsible.stories.tsx +++ b/src/components/Collapsible.stories.tsx @@ -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> = (args) => ( +export const CollapsibleStory: Story = (args) => ( - +
  • Collapsible Item 1
  • Collapsible Item 2
  • @@ -23,6 +20,8 @@ export const CollapsibleStory: Story> = (args) => CollapsibleStory.args = { disabled: false, + withTrigger: true, + label: 'Collapsible List of Items', }; CollapsibleStory.argTypes = { diff --git a/src/components/Collapsible.tsx b/src/components/Collapsible.tsx index 84c3c13..5148314 100644 --- a/src/components/Collapsible.tsx +++ b/src/components/Collapsible.tsx @@ -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) => ( - - {triggerIconSide === 'right' && ( - <> - {label} - {fullWidth && } - - )} + {withTrigger && ( + + {triggerIconSide === 'right' && ( + <> + {label} + {fullWidth && } + + )} - - - - {triggerIconSide === 'left' && ( - <> - {fullWidth && } - {label} - - )} - + + + + {triggerIconSide === 'left' && ( + <> + {fullWidth && } + {label} + + )} + + )} {children} ); diff --git a/src/components/ComboboxDialogMenu.tsx b/src/components/ComboboxDialogMenu.tsx index b7f6bb6..53834f1 100644 --- a/src/components/ComboboxDialogMenu.tsx +++ b/src/components/ComboboxDialogMenu.tsx @@ -22,7 +22,12 @@ type PickComboxMenuProps< MenuGroupValue extends string | number > = Pick< ComboboxMenuProps, - '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 = {}; 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); `; diff --git a/src/components/ComboboxMenu.tsx b/src/components/ComboboxMenu.tsx index 9f5ce78..af25aea 100644 --- a/src/components/ComboboxMenu.tsx +++ b/src/components/ComboboxMenu.tsx @@ -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) => { const [highlightedCommand, setHighlightedCommand] = useState(); @@ -80,6 +82,7 @@ export const ComboboxMenu = {group.items.map((item) => ( @@ -99,22 +102,31 @@ export const ComboboxMenu = - {item.slotBefore} - - - {`${item.label}${item.subitems?.length ? '…' : ''}`} - {item.tag && ( - <> - {' '} - {item.tag} - + { + <> + {item.slotBefore} + {item.slotCustomContent ?? ( + + + {typeof item.label === 'string' + ? `${item.label}${item.subitems?.length ? '…' : ''}` + : item.label} + {item.tag && ( + <> + {' '} + {item.tag} + + )} + + {item.description && {item.description}} + )} - - {item.description && {item.description}} - - {item.slotAfter} - {item.subitems && '→'} + {item.slotAfter} + {item.subitems && '→'} + + } {searchValue && @@ -137,6 +149,7 @@ export const ComboboxMenu = {subitem.slotBefore} @@ -169,14 +182,17 @@ const Styled: Record = {}; 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)` diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index f64d89f..aead810 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -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} > @@ -114,7 +119,7 @@ export const Dialog = ({ {title && {title}} - {!preventClose && ( + {!preventClose && withClose && ( @@ -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; diff --git a/src/components/Notification.tsx b/src/components/Notification.tsx new file mode 100644 index 0000000..bca9dd6 --- /dev/null +++ b/src/components/Notification.tsx @@ -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})} + <$Title>{slotTitle} + {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} + + )} + + <$Description>{slotCustomContent ?? slotDescription} + {slotAction && <$Action>{slotAction}} + + ); +}; + +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); +`; diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 9c85fbf..0afbeed 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -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(); 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?.(); + }} > -
    - <$Container> - <$Header> - {slotIcon && <$Icon>{slotIcon}} + + <$CloseButton + iconName={IconName.Close} + shape={ButtonShape.Square} + size={ButtonSize.XSmall} + onClick={(e: MouseEvent) => e.stopPropagation()} + /> + - - <$CloseButton - iconComponent={CloseIcon} - shape={ButtonShape.Square} - size={ButtonSize.XSmall} - /> - - - <$Title>{slotTitle} - - {!slotCustomContent && <$Description>{slotDescription}} - {slotCustomContent} - {actionDescription && ( - <$Action asChild altText={actionAltText}> - {slotAction} - - )} - -
    + {slotCustomContent ?? ( + <$Notification + isToast + notification={notification} + slotIcon={slotIcon} + slotTitle={slotTitle} + slotTitleLeft={slotTitleLeft} + slotTitleRight={slotTitleRight} + slotDescription={slotDescription} + slotAction={ + actionDescription && ( + <$Action asChild altText={actionAltText}> + {slotAction} + + ) + } + /> + )} ); }; @@ -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)` diff --git a/src/components/ToastArea.tsx b/src/components/ToastArea.tsx index eee1551..5f552a9 100644 --- a/src/components/ToastArea.tsx +++ b/src/components/ToastArea.tsx @@ -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; diff --git a/src/constants/abacus.ts b/src/constants/abacus.ts index 4e761e3..f9e7b69 100644 --- a/src/constants/abacus.ts +++ b/src/constants/abacus.ts @@ -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 = { [K in keyof T]: IfEquals<{ [_ in K]: T[K] }, { readonly [_ in K]: T[K] }, K, never>; }[keyof T]; -type KotlinIrEnumValues = Exclude, 'Companion' | '$serializer'>; +export type KotlinIrEnumValues = Exclude, 'Companion' | '$serializer'>; export const ORDER_SIDES: Record, 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, PositionSide> = { @@ -234,12 +238,25 @@ export const HISTORICAL_PNL_PERIODS: Record< export const ORDER_STATUS_STRINGS: Record, 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 > = { [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 diff --git a/src/constants/dialogs.ts b/src/constants/dialogs.ts index ac5d5b5..0faa6e4 100644 --- a/src/constants/dialogs.ts +++ b/src/constants/dialogs.ts @@ -10,6 +10,7 @@ export enum DialogTypes { MobileSignIn = 'MobileSignIn', Onboarding = 'Onboarding', OrderDetails = 'OrderDetails', + Preferences = 'Preferences', RateLimit = 'RateLimit', Receive = 'Receive', RestrictedGeo = 'RestrictedGeo', diff --git a/src/constants/localStorage.ts b/src/constants/localStorage.ts index da1b177..411d917 100644 --- a/src/constants/localStorage.ts +++ b/src/constants/localStorage.ts @@ -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 }; diff --git a/src/constants/localization.ts b/src/constants/localization.ts index 696e947..5ff02b2 100644 --- a/src/constants/localization.ts +++ b/src/constants/localization.ts @@ -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; diff --git a/src/constants/menus.ts b/src/constants/menus.ts index da72dd6..39eaf51 100644 --- a/src/constants/menus.ts +++ b/src/constants/menus.ts @@ -26,6 +26,7 @@ export type MenuItem = { tag?: React.ReactNode; slotAfter?: React.ReactNode; description?: string; + slotCustomContent?: React.ReactNode; href?: string; onSelect?: (key: MenuItemValue) => void; diff --git a/src/constants/notifications.ts b/src/constants/notifications.ts index a550546..333a0a4 100644 --- a/src/constants/notifications.ts +++ b/src/constants/notifications.ts @@ -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>; /** 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; diff --git a/src/hooks/useLocalNotifications.tsx b/src/hooks/useLocalNotifications.tsx index 7774e5f..ac7c6a9 100644 --- a/src/hooks/useLocalNotifications.tsx +++ b/src/hooks/useLocalNotifications.tsx @@ -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, diff --git a/src/hooks/useNotificationTypes.tsx b/src/hooks/useNotificationTypes.tsx index 07b213d..c5a340b 100644 --- a/src/hooks/useNotificationTypes.tsx +++ b/src/hooks/useNotificationTypes.tsx @@ -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 }) => ( + ), - status: AbacusOrderStatus.filled, - } - : undefined); - - if (order) - trigger( - order.id, - { - icon: ( - - ), - 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, + 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 = ; - // TODO: confirm with design what the description should be - const description = ( -
    - - {type === 'deposit' ? 'Deposit of ' : 'Withdraw of '} - - + const type = + fromChainId === ENVIRONMENT_CONFIG_MAP[selectedNetwork].dydxChainId + ? 'withdrawal' + : 'deposit'; - {error && ( - - {stringGetter({ - key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE, - params: { - ERROR_MESSAGE: - error.message || stringGetter({ key: STRING_KEYS.UNKNOWN_ERROR }), - }, - })} - - )} -
    - ); + 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: , - title: stringGetter({ key: getTitleStringKey(type, finished) }), - description: description, - customContent: ( - ( + ), - customMenuContent: !finished && ( -
    - {description} - -
    - ), toastSensitivity: 'foreground', }, - [] + [isFinished] ); } - }, [transferNotifications]); + }, [transferNotifications, stringGetter]); + }, + useNotificationAction: () => { + return () => {}; }, }, -] satisfies NotificationTypeConfig[]; +]; -const Styled: Record = {}; - -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; `; diff --git a/src/hooks/useNotifications.tsx b/src/hooks/useNotifications.tsx index 6c97009..3c12b8b 100644 --- a/src/hooks/useNotifications.tsx +++ b/src/hooks/useNotifications.tsx @@ -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 | undefined>( - undefined +type NotificationsContextType = ReturnType; + +const NotificationsContext = createContext( + {} as NotificationsContextType ); NotificationsContext.displayName = 'Notifications'; @@ -27,6 +31,7 @@ export const useNotifications = () => useContext(NotificationsContext)!; const useNotificationsContext = () => { // Local storage + // const [notifications, setNotifications] = useState({}); const [notifications, setNotifications] = useLocalStorage({ key: LocalStorageKey.Notifications, defaultValue: {}, @@ -37,12 +42,25 @@ const useNotificationsContext = () => { defaultValue: Date.now(), }); + const [notificationPreferences, setNotificationPreferences] = + useLocalStorage({ + key: LocalStorageKey.NotificationPreferences, + defaultValue: { + [NotificationType.AbacusGenerated]: true, + [NotificationType.SquidTransfer]: true, + version: LOCAL_STORAGE_VERSIONS[LocalStorageKey.NotificationPreferences], + }, + }); + useEffect(() => { setNotificationsLastUpdated(Date.now()); }, [notifications]); - const getKey = (notification: Pick, 'type' | 'id'>) => - `${notification.type}/${notification.id}`; + const getKey = useCallback( + (notification: Pick, '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] + ), }; }; diff --git a/src/layout/DialogManager.tsx b/src/layout/DialogManager.tsx index 0a653b5..b3956d2 100644 --- a/src/layout/DialogManager.tsx +++ b/src/layout/DialogManager.tsx @@ -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]: , [DialogTypes.Onboarding]: , [DialogTypes.OrderDetails]: , + [DialogTypes.Preferences]: , [DialogTypes.RateLimit]: , [DialogTypes.Receive]: , [DialogTypes.RestrictedGeo]: , diff --git a/src/layout/NotificationsToastArea.tsx b/src/layout/NotificationsToastArea.tsx index 7e36cf1..b48c69e 100644 --- a/src/layout/NotificationsToastArea.tsx +++ b/src/layout/NotificationsToastArea.tsx @@ -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 ( - - {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 }) => ( - onNotificationAction(notification)}> - {displayData.actionDescription} - - } - 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 ( + + {notificationMap.map(({ notification, key, displayData }, idx) => ( + onNotificationAction(notification)} + slotAction={ + + } + 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]} + /> + ))} ); }; 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); + `} +`; diff --git a/src/lib/abacus/dydxChainTransactions.ts b/src/lib/abacus/dydxChainTransactions.ts index e8d3d36..9940389 100644 --- a/src/lib/abacus/dydxChainTransactions.ts +++ b/src/lib/abacus/dydxChainTransactions.ts @@ -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; } diff --git a/src/lib/abacus/stateNotification.ts b/src/lib/abacus/stateNotification.ts index de275da..216b7b7 100644 --- a/src/lib/abacus/stateNotification.ts +++ b/src/lib/abacus/stateNotification.ts @@ -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): void { + this.store?.dispatch(updateNotifications(notifications.toArray())); return; } diff --git a/src/state/_store.ts b/src/state/_store.ts index 5d5588d..2e2b93a 100644 --- a/src/state/_store.ts +++ b/src/state/_store.ts @@ -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, }, diff --git a/src/state/localizationMiddleware.ts b/src/state/localizationMiddleware.ts index c723d1c..40049ff 100644 --- a/src/state/localizationMiddleware.ts +++ b/src/state/localizationMiddleware.ts @@ -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], }; diff --git a/src/state/notifications.ts b/src/state/notifications.ts new file mode 100644 index 0000000..3f2ba4c --- /dev/null +++ b/src/state/notifications.ts @@ -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) => { + state.abacusNotifications = action.payload; + }, + }, +}); + +export const { updateNotifications } = notificationsSlice.actions; diff --git a/src/state/notificationsSelectors.ts b/src/state/notificationsSelectors.ts new file mode 100644 index 0000000..7feee85 --- /dev/null +++ b/src/state/notificationsSelectors.ts @@ -0,0 +1,3 @@ +import { RootState } from './_store'; + +export const getAbacusNotifications = (state: RootState) => state.notifications.abacusNotifications; diff --git a/src/styles/headerMixins.ts b/src/styles/headerMixins.ts index 4a8da3d..c2baa8a 100644 --- a/src/styles/headerMixins.ts +++ b/src/styles/headerMixins.ts @@ -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); } `, diff --git a/src/views/OrderStatusIcon.tsx b/src/views/OrderStatusIcon.tsx index 8aa3190..2dbac70 100644 --- a/src/views/OrderStatusIcon.tsx +++ b/src/views/OrderStatusIcon.tsx @@ -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}; diff --git a/src/views/TransferStatus.tsx b/src/views/TransferStatus.tsx deleted file mode 100644 index 0b0c9ad..0000000 --- a/src/views/TransferStatus.tsx +++ /dev/null @@ -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(false); - const [secondsLeft, setSecondsLeft] = useState(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 ; - - 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 ( - - - - - } - > - - - {stringGetter({ - key: statusString, - params: { - AMOUNT_USD: , - ESTIMATED_DURATION: ( - - ), - }, - })} - - {error && ( - - {stringGetter({ - key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE, - params: { - ERROR_MESSAGE: error.message || stringGetter({ key: STRING_KEYS.UNKNOWN_ERROR }), - }, - })} - - )} - - - - - {stringGetter({ key: open ? STRING_KEYS.HIDE_DETAILS : STRING_KEYS.VIEW_DETAILS })} - - - - - ); -}; - -const Styled: Record = {}; - -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; - } -`; diff --git a/src/views/dialogs/PreferencesDialog.tsx b/src/views/dialogs/PreferencesDialog.tsx new file mode 100644 index 0000000..1d06f7b --- /dev/null +++ b/src/views/dialogs/PreferencesDialog.tsx @@ -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: ( + null} + /> + ), + onSelect: () => toggleNotifPreference(NotificationType.AbacusGenerated), + }, + { + value: NotificationType.SquidTransfer, + label: stringGetter({ key: STRING_KEYS.TRANSFERS }), + slotAfter: ( + 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 ( + + ); +}; + +const Styled: Record = {}; + +Styled.ComboboxDialogMenu = styled(ComboboxDialogMenu)` + --dialog-content-paddingBottom: 0.5rem; +`; diff --git a/src/views/forms/AccountManagementForms/DepositForm.tsx b/src/views/forms/AccountManagementForms/DepositForm.tsx index 6797fcf..849aa7b 100644 --- a/src/views/forms/AccountManagementForms/DepositForm.tsx +++ b/src/views/forms/AccountManagementForms/DepositForm.tsx @@ -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(''); diff --git a/src/views/forms/AccountManagementForms/WithdrawForm.tsx b/src/views/forms/AccountManagementForms/WithdrawForm.tsx index 70730db..0bd2b0b 100644 --- a/src/views/forms/AccountManagementForms/WithdrawForm.tsx +++ b/src/views/forms/AccountManagementForms/WithdrawForm.tsx @@ -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(''); diff --git a/src/views/menus/AccountMenu.tsx b/src/views/menus/AccountMenu.tsx index d902cf3..e5514a7 100644 --- a/src/views/menus/AccountMenu.tsx +++ b/src/views/menus/AccountMenu.tsx @@ -184,6 +184,12 @@ export const AccountMenu = () => { }, ].filter(isTruthy) : []), + { + value: 'Preferences', + icon: , + label: stringGetter({ key: STRING_KEYS.PREFERENCES }), + onSelect: () => dispatch(openDialog({ type: DialogTypes.Preferences })), + }, { value: 'Disconnect', icon: , diff --git a/src/views/menus/NotificationsMenu.tsx b/src/views/menus/NotificationsMenu.tsx index 08b3117..e07a9c1 100644 --- a/src/views/menus/NotificationsMenu.tsx +++ b/src/views/menus/NotificationsMenu.tsx @@ -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[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 }) ?? ( + ), 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 ( - {slotTrigger} @@ -150,11 +129,11 @@ export const NotificationsMenu = ({ state={{ isLoading: isEnablingPush }} onClick={enablePush} > - Enable Push Notifications + {stringGetter({ key: STRING_KEYS.ENABLE_PUSH_NOTIFICATIONS })} ) : ( )} @@ -168,7 +147,7 @@ export const NotificationsMenu = ({ ), }} > - Clear All + {stringGetter({ key: STRING_KEYS.CLEAR_ALL })} } @@ -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; diff --git a/src/views/notifications/TradeNotification/index.tsx b/src/views/notifications/TradeNotification/index.tsx new file mode 100644 index 0000000..d628b2d --- /dev/null +++ b/src/views/notifications/TradeNotification/index.tsx @@ -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; + const tradeType = TRADE_TYPES[orderType]; + const titleKey = tradeType && TRADE_TYPE_STRINGS[tradeType]?.tradeTypeKey; + const orderStatus = ORDER_STATUS as KotlinIrEnumValues; + + return ( + } + slotTitle={titleKey && stringGetter({ key: titleKey })} + slotTitleRight={ + + {stringGetter({ key: ORDER_STATUS_STRINGS[orderStatus] })} + + + } + slotCustomContent={ + + {stringGetter({ key: STRING_KEYS.SIZE })} + + + ), + value: ( + + ), + }, + { + key: 'price', + label: stringGetter({ key: STRING_KEYS.PRICE }), + value: + ORDER_TYPE === TradeTypes.MARKET ? ( + {stringGetter({ key: STRING_KEYS.MARKET_ORDER_SHORT })} + ) : ( + + ), + }, + ]} + /> + } + /> + ); +}; + +const Styled: Record = {}; + +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); + } +`; diff --git a/src/views/TransferStatusSteps.tsx b/src/views/notifications/TransferStatusNotification/TransferStatusSteps.tsx similarity index 95% rename from src/views/TransferStatusSteps.tsx rename to src/views/notifications/TransferStatusNotification/TransferStatusSteps.tsx index 1bbf5c8..758c60b 100644 --- a/src/views/TransferStatusSteps.tsx +++ b/src/views/notifications/TransferStatusNotification/TransferStatusSteps.tsx @@ -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 ; return ( - + {steps.map((step) => ( diff --git a/src/views/notifications/TransferStatusNotification/index.tsx b/src/views/notifications/TransferStatusNotification/index.tsx new file mode 100644 index 0000000..e94f90b --- /dev/null +++ b/src/views/notifications/TransferStatusNotification/index.tsx @@ -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(false); + const [secondsLeft, setSecondsLeft] = useState(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 = ( + <> + + {stringGetter({ + key: statusString, + params: { + AMOUNT_USD: , + ESTIMATED_DURATION: ( + + ), + }, + })} + + {error && ( + + {stringGetter({ + key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE, + params: { + ERROR_MESSAGE: error.message || stringGetter({ key: STRING_KEYS.UNKNOWN_ERROR }), + }, + })} + + )} + + ); + + const transferNotif = ( + + ) : ( + + {content} + {!isToast && status?.squidTransactionStatus !== 'success' && ( + + )} + + ) + } + slotAction={ + isToast && + status && + !error && ( + { + e.stopPropagation(); + setOpen(!open); + }} + > + {' '} + {stringGetter({ + key: open ? STRING_KEYS.HIDE_DETAILS : STRING_KEYS.VIEW_DETAILS, + })} + + ) + } + withClose={false} + /> + ); + + return isToast ? ( + + + + + + } + > + {transferNotif} + + ) : ( + transferNotif + ); +}; + +const Styled: Record = {}; + +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; +`;