diff --git a/app/css/index.css b/app/css/index.css index 7ca640e1..d5a1642c 100644 --- a/app/css/index.css +++ b/app/css/index.css @@ -439,6 +439,13 @@ video { display: none; } +input[type='text']:autofill, +input[type='password']:autofill { + -webkit-background-clip: text; + -webkit-text-fill-color: #ffffff; + box-shadow: inset 0 0 20px 20px black; +} + .transform { --tw-translate-x: 0; --tw-translate-y: 0; @@ -460,6 +467,7 @@ video { --tw-shadow-colored: 0 0 #0000; } +.blur, .filter { --tw-blur: ; --tw-brightness: ; @@ -472,7 +480,9 @@ video { --tw-drop-shadow: ; } -.backdrop-blur-sm { +.backdrop-blur-\[2px\], +.backdrop-blur-sm, +.backdrop\:backdrop-blur-\[2px\]::backdrop { --tw-backdrop-blur: ; --tw-backdrop-brightness: ; --tw-backdrop-contrast: ; @@ -488,6 +498,10 @@ video { pointer-events: none; } +.pointer-events-auto { + pointer-events: auto; +} + .static { position: static; } @@ -512,6 +526,10 @@ video { inset: 0px; } +.left-4 { + left: 1rem; +} + .left-auto { left: auto; } @@ -520,16 +538,16 @@ video { right: 0px; } -.right-4 { - right: 1rem; +.right-2 { + right: 0.5rem; } .top-0 { top: 0px; } -.top-4 { - top: 1rem; +.top-2 { + top: 0.5rem; } .z-10 { @@ -540,6 +558,18 @@ video { z-index: 50; } +.col-span-full { + grid-column: 1 / -1; +} + +.row-start-2 { + grid-row-start: 2; +} + +.clear-none { + clear: none; +} + .mx-2 { margin-left: 0.5rem; margin-right: 0.5rem; @@ -550,11 +580,6 @@ video { margin-right: auto; } -.my-2 { - margin-top: 0.5rem; - margin-bottom: 0.5rem; -} - .my-4 { margin-top: 1rem; margin-bottom: 1rem; @@ -573,6 +598,10 @@ video { margin-bottom: 0.5rem; } +.mb-3 { + margin-bottom: 0.75rem; +} + .mb-4 { margin-bottom: 1rem; } @@ -625,10 +654,18 @@ video { display: none; } +.aspect-\[16\/9\] { + aspect-ratio: 16/9; +} + .aspect-square { aspect-ratio: 1 / 1; } +.h-0 { + height: 0px; +} + .h-10 { height: 2.5rem; } @@ -677,6 +714,14 @@ video { height: 100%; } +.max-h-full { + max-height: 100%; +} + +.w-0 { + width: 0px; +} + .w-10 { width: 2.5rem; } @@ -689,12 +734,8 @@ video { width: 3rem; } -.w-20 { - width: 5rem; -} - -.w-4 { - width: 1rem; +.w-16 { + width: 4rem; } .w-8 { @@ -709,32 +750,16 @@ video { min-width: 0px; } -.max-w-4xl { - max-width: 56rem; -} - -.max-w-5xl { - max-width: 64rem; -} - -.max-w-6xl { - max-width: 72rem; -} - .max-w-fit { max-width: fit-content; } -.max-w-lg { - max-width: 32rem; -} - -.max-w-md { - max-width: 28rem; +.max-w-full { + max-width: 100%; } -.max-w-sm { - max-width: 24rem; +.max-w-lg { + max-width: 32rem; } .shrink { @@ -769,6 +794,10 @@ video { animation: spin 1s linear infinite; } +.cursor-pointer { + cursor: pointer; +} + .snap-x { scroll-snap-type: x var(--tw-scroll-snap-strictness); } @@ -785,12 +814,16 @@ video { appearance: none; } -.grid-cols-1 { - grid-template-columns: repeat(1, minmax(0, 1fr)); +.grid-flow-col { + grid-auto-flow: column; +} + +.grid-flow-col-dense { + grid-auto-flow: column dense; } -.grid-cols-2 { - grid-template-columns: repeat(2, minmax(0, 1fr)); +.grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); } .grid-cols-3 { @@ -801,6 +834,10 @@ video { grid-template-columns: 1fr auto; } +.grid-cols-\[1fr\2c min-content\] { + grid-template-columns: 1fr min-content; +} + .grid-cols-\[1fr_auto\] { grid-template-columns: 1fr auto; } @@ -821,10 +858,22 @@ video { grid-template-columns: auto minmax(0, 1fr); } +.grid-cols-\[min-content\2c 1fr\] { + grid-template-columns: min-content 1fr; +} + +.grid-cols-\[min-content\2c min-content\] { + grid-template-columns: min-content min-content; +} + .grid-cols-\[min-content\2c minmax\(0\2c 1fr\)\2c min-content\] { grid-template-columns: min-content minmax(0, 1fr) min-content; } +.grid-rows-\[1fr\2c min-content\] { + grid-template-rows: 1fr min-content; +} + .flex-col { flex-direction: column; } @@ -845,10 +894,6 @@ video { align-items: flex-start; } -.items-end { - align-items: flex-end; -} - .items-center { align-items: center; } @@ -885,6 +930,26 @@ video { gap: 1.5rem; } +.gap-x-1 { + column-gap: 0.25rem; +} + +.gap-x-2 { + column-gap: 0.5rem; +} + +.gap-x-3 { + column-gap: 0.75rem; +} + +.gap-y-1 { + row-gap: 0.25rem; +} + +.gap-y-3 { + row-gap: 0.75rem; +} + .overflow-hidden { overflow: hidden; } @@ -917,10 +982,6 @@ video { white-space: nowrap; } -.break-all { - word-break: break-all; -} - .rounded { border-radius: 0.25rem; } @@ -945,10 +1006,6 @@ video { border-color: rgb(163 230 53 / 0.4); } -.border-orange-400\/50 { - border-color: rgb(251 146 60 / 0.5); -} - .border-red-400 { --tw-border-opacity: 1; border-color: rgb(248 113 113 / var(--tw-border-opacity)); @@ -958,14 +1015,15 @@ video { border-color: rgb(248 113 113 / 0.2); } -.border-red-400\/50 { - border-color: rgb(248 113 113 / 0.5); -} - .border-transparent { border-color: transparent; } +.border-white { + --tw-border-opacity: 1; + border-color: rgb(255 255 255 / var(--tw-border-opacity)); +} + .border-white\/10 { border-color: rgb(255 255 255 / 0.1); } @@ -995,10 +1053,6 @@ video { background-color: rgb(0 0 0 / 0.1); } -.bg-black\/20 { - background-color: rgb(0 0 0 / 0.2); -} - .bg-black\/50 { background-color: rgb(0 0 0 / 0.5); } @@ -1007,17 +1061,13 @@ video { background-color: rgb(0 0 0 / 0.6); } -.bg-black\/80 { - background-color: rgb(0 0 0 / 0.8); -} - .bg-lime-400\/5 { background-color: rgb(163 230 53 / 0.05); } -.bg-neutral-800 { +.bg-neutral-600 { --tw-bg-opacity: 1; - background-color: rgb(38 38 38 / var(--tw-bg-opacity)); + background-color: rgb(82 82 82 / var(--tw-bg-opacity)); } .bg-neutral-900 { @@ -1025,14 +1075,6 @@ video { background-color: rgb(23 23 23 / var(--tw-bg-opacity)); } -.bg-orange-400\/10 { - background-color: rgb(251 146 60 / 0.1); -} - -.bg-red-200\/10 { - background-color: rgb(254 202 202 / 0.1); -} - .bg-red-400\/5 { background-color: rgb(248 113 113 / 0.05); } @@ -1082,6 +1124,15 @@ video { padding: 1rem; } +.p-6 { + padding: 1.5rem; +} + +.px-1 { + padding-left: 0.25rem; + padding-right: 0.25rem; +} + .px-2 { padding-left: 0.5rem; padding-right: 0.5rem; @@ -1127,11 +1178,6 @@ video { padding-bottom: 1rem; } -.py-6 { - padding-top: 1.5rem; - padding-bottom: 1.5rem; -} - .pb-4 { padding-bottom: 1rem; } @@ -1140,6 +1186,14 @@ video { padding-left: 1rem; } +.pt-4 { + padding-top: 1rem; +} + +.pt-5 { + padding-top: 1.25rem; +} + .pt-6 { padding-top: 1.5rem; } @@ -1191,6 +1245,10 @@ video { font-weight: 700; } +.font-semibold { + font-weight: 600; +} + .uppercase { text-transform: uppercase; } @@ -1212,11 +1270,6 @@ video { color: rgb(17 24 39 / var(--tw-text-opacity)); } -.text-red-600 { - --tw-text-opacity: 1; - color: rgb(220 38 38 / var(--tw-text-opacity)); -} - .text-white { --tw-text-opacity: 1; color: rgb(255 255 255 / var(--tw-text-opacity)); @@ -1230,10 +1283,6 @@ video { color: rgb(255 255 255 / 0.5); } -.text-white\/75 { - color: rgb(255 255 255 / 0.75); -} - .text-white\/80 { color: rgb(255 255 255 / 0.8); } @@ -1242,22 +1291,10 @@ video { opacity: 0; } -.opacity-25 { - opacity: 0.25; -} - .opacity-50 { opacity: 0.5; } -.opacity-70 { - opacity: 0.7; -} - -.opacity-75 { - opacity: 0.75; -} - .shadow-lg { --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); @@ -1281,10 +1318,20 @@ video { outline-color: rgb(255 255 255 / 0.2); } +.blur { + --tw-blur: blur(8px); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + .filter { filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); } +.backdrop-blur-\[2px\] { + --tw-backdrop-blur: blur(2px); + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); +} + .backdrop-blur-sm { --tw-backdrop-blur: blur(4px); backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); @@ -1368,6 +1415,25 @@ video { color: transparent; } +.clear-none::-ms-clear { + display: none; + width: 0; + height: 0; +} + +.clear-none::-ms-reveal { + display: none; + width: 0; + height: 0; +} + +.clear-none::-webkit-search-decoration, +.clear-none::-webkit-search-cancel-button, +.clear-none::-webkit-search-results-button, +.clear-none::-webkit-search-results-decoration { + display: none; +} + .placeholder\:text-white\/20::placeholder { color: rgb(255 255 255 / 0.2); } @@ -1376,9 +1442,13 @@ video { color: rgb(255 255 255 / 0.3); } -.invalid\:text-red-600:invalid { - --tw-text-opacity: 1; - color: rgb(220 38 38 / var(--tw-text-opacity)); +.backdrop\:bg-black\/80::backdrop { + background-color: rgb(0 0 0 / 0.8); +} + +.backdrop\:backdrop-blur-\[2px\]::backdrop { + --tw-backdrop-blur: blur(2px); + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); } .focus-within\:border-red-400:focus-within { @@ -1408,6 +1478,11 @@ video { border-color: rgb(255 255 255 / var(--tw-border-opacity)); } +.hover\:bg-neutral-800:hover { + --tw-bg-opacity: 1; + background-color: rgb(38 38 38 / var(--tw-bg-opacity)); +} + .hover\:bg-white\/10:hover { background-color: rgb(255 255 255 / 0.1); } @@ -1429,15 +1504,15 @@ video { text-decoration-line: underline; } +.hover\:opacity-100:hover { + opacity: 1; +} + .focus\:border-white:focus { --tw-border-opacity: 1; border-color: rgb(255 255 255 / var(--tw-border-opacity)); } -.focus\:border-white\/90:focus { - border-color: rgb(255 255 255 / 0.9); -} - .focus\:bg-white\/10:focus { background-color: rgb(255 255 255 / 0.1); } @@ -1451,9 +1526,24 @@ video { color: rgb(255 255 255 / var(--tw-text-opacity)); } -.focus\:outline-none:focus { - outline: 2px solid transparent; - outline-offset: 2px; +.focus\:opacity-100:focus { + opacity: 1; +} + +.disabled\:border-white\/30:disabled { + border-color: rgb(255 255 255 / 0.3); +} + +.disabled\:border-white\/50:disabled { + border-color: rgb(255 255 255 / 0.5); +} + +.disabled\:bg-white\/10:disabled { + background-color: rgb(255 255 255 / 0.1); +} + +.disabled\:text-white\/30:disabled { + color: rgb(255 255 255 / 0.3); } .disabled\:text-white\/50:disabled { @@ -1472,25 +1562,108 @@ video { background-color: transparent; } -.group:hover .group-hover\:block { - display: block; +.peer:checked ~ .peer-checked\:hidden { + display: none; } -.group:focus .group-focus\:block { - display: block; +.peer:checked ~ .peer-checked\:border-white\/50 { + border-color: rgb(255 255 255 / 0.5); } -.peer:invalid ~ .peer-invalid\:block { - display: block; +.peer:checked ~ .peer-checked\:opacity-100 { + opacity: 1; } -@media (min-width: 640px) { - .sm\:grid-cols-3 { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } +.peer:placeholder-shown ~ .peer-placeholder-shown\:hidden { + display: none; +} - .sm\:grid-cols-4 { - grid-template-columns: repeat(4, minmax(0, 1fr)); +.peer:checked:focus ~ .peer-checked\:peer-focus\:border-white { + --tw-border-opacity: 1; + border-color: rgb(255 255 255 / var(--tw-border-opacity)); +} + +.peer:disabled ~ .peer-disabled\:hidden { + display: none; +} + +.modified\:enabled\:invalid\:border-red-400:invalid:not(:disabled):not([data-pristine]) { + --tw-border-opacity: 1; + border-color: rgb(248 113 113 / var(--tw-border-opacity)); +} + +.group:not([data-pristine]) .group-modified\:enabled\:invalid\:text-red-400:invalid:not(:disabled) { + --tw-text-opacity: 1; + color: rgb(248 113 113 / var(--tw-text-opacity)); +} + +.focus\|hover\:border:focus { + border-width: 1px; +} + +.focus\|hover\:border-white:focus { + --tw-border-opacity: 1; + border-color: rgb(255 255 255 / var(--tw-border-opacity)); +} + +.focus\|hover\:bg-neutral-800:focus { + --tw-bg-opacity: 1; + background-color: rgb(38 38 38 / var(--tw-bg-opacity)); +} + +.focus\|hover\:bg-white\/10:focus { + background-color: rgb(255 255 255 / 0.1); +} + +.focus\|hover\:text-white:focus { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.focus\|hover\:enabled\:border-white:not(:disabled):focus { + --tw-border-opacity: 1; + border-color: rgb(255 255 255 / var(--tw-border-opacity)); +} + +.focus\|hover\:enabled\:bg-white\/20:not(:disabled):focus { + background-color: rgb(255 255 255 / 0.2); +} + +.focus\|hover\:border:hover { + border-width: 1px; +} + +.focus\|hover\:border-white:hover { + --tw-border-opacity: 1; + border-color: rgb(255 255 255 / var(--tw-border-opacity)); +} + +.focus\|hover\:bg-neutral-800:hover { + --tw-bg-opacity: 1; + background-color: rgb(38 38 38 / var(--tw-bg-opacity)); +} + +.focus\|hover\:bg-white\/10:hover { + background-color: rgb(255 255 255 / 0.1); +} + +.focus\|hover\:text-white:hover { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.focus\|hover\:enabled\:border-white:not(:disabled):hover { + --tw-border-opacity: 1; + border-color: rgb(255 255 255 / var(--tw-border-opacity)); +} + +.focus\|hover\:enabled\:bg-white\/20:not(:disabled):hover { + background-color: rgb(255 255 255 / 0.2); +} + +@media (min-width: 640px) { + .sm\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); } .sm\:grid-cols-\[40vw\2c 100vw\] { @@ -1499,10 +1672,30 @@ video { } @media (min-width: 768px) { + .md\:aspect-\[4\/5\] { + aspect-ratio: 4/5; + } + + .md\:max-h-\[calc\(100vh-3rem\)\] { + max-height: calc(100vh - 3rem); + } + + .md\:min-w-\[14em\] { + min-width: 14em; + } + + .md\:max-w-fit { + max-width: fit-content; + } + .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .md\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .md\:grid-cols-\[35vw\2c 100vw\] { grid-template-columns: 35vw 100vw; } @@ -1517,6 +1710,10 @@ video { display: none; } + .lg\:w-\[32rem\] { + width: 32rem; + } + .lg\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } diff --git a/app/ts/components/AmountField.tsx b/app/ts/components/AmountField.tsx deleted file mode 100644 index 337b3475..00000000 --- a/app/ts/components/AmountField.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useComputed, useSignal, useSignalEffect } from '@preact/signals' -import { useRef } from 'preact/hooks' -import { JSX } from 'preact/jsx-runtime' -import { formatUnits } from 'ethers' -import { removeNonStringsAndTrim } from '../library/utilities.js' -import { useAccount } from '../store/account.js' -import { TokenMeta, useTokenBalance } from '../store/tokens.js' -import * as Icon from './Icon/index.js' - -type Props = { - label: string - placeholder?: string - value: string - onInput: (amount: string) => void - onClear: () => void - disabled?: boolean - token?: TokenMeta -} - -export const AmountField = (props: Props) => { - const { address } = useAccount() - const inputRef = useRef(null) - const isValid = useSignal(true) - const { tokenBalance, getTokenBalance } = useTokenBalance() - - const handleClear = () => { - props.onClear?.() - inputRef.current?.focus() - } - - const handleInput = (e: JSX.TargetedEvent) => { - const inputField = inputRef.current - const value = e.currentTarget.value - - if (inputField === null) return - - // clear errors and update field value - inputField.setCustomValidity('') - props.onInput(value) - - isValid.value = inputField.validity.valid - if (inputField.validity.patternMismatch) { - inputField.setCustomValidity('Amount value accepts only numbers') - } - } - - const isCalculatingMax = useComputed(() => tokenBalance.value.state === 'pending') - - const setTokenBalanceAsAmount = () => { - if (props.token === undefined) return - if (address.value.state !== 'resolved') return - getTokenBalance(address.value.value, props.token.address) - } - - useSignalEffect(() => { - if (tokenBalance.value.state !== 'resolved') return - const balance = formatUnits(tokenBalance.value.value, props.token?.decimals) - props.onInput(balance) - inputRef.current?.focus() - }) - - return ( -
-
-
- - -
- - -
-
- ) -} - -const baseClasses = { - root: 'border bg-transparent focus-within:bg-white/5', - field: 'h-6 bg-transparent outline-none placeholder:text-white/20', -} - -type MaxButtonProps = { - show?: boolean - isBusy?: boolean - onClick: () => void -} - -const MaxButton = (props: MaxButtonProps) => { - if (props.show !== true) return <> - if (props.isBusy) - return ( -
- -
- ) - - return ( - - ) -} - -type ClearButtonProps = { - show?: boolean - onClick: () => void - disabled?: boolean -} - -const ClearButton = (props: ClearButtonProps) => { - if (!props.show) return <> - return ( - - ) -} diff --git a/app/ts/components/App.tsx b/app/ts/components/App.tsx index cf37c4ee..b459ad90 100644 --- a/app/ts/components/App.tsx +++ b/app/ts/components/App.tsx @@ -1,26 +1,29 @@ import { Route, Router } from './HashRouter.js' -import { TransferPage } from './TransferPage/index.js' import { Notices } from './Notice.js' import { SplashScreen } from './SplashScreen.js' import { TransactionPage } from './TransactionPage/index.js' import { ErrorAlert } from './ErrorAlert.js' +import { TransferPage } from './TransferPage/index.js' +import { WalletProvider } from '../context/Wallet.js' +import { AccountProvider } from '../context/Account.js' export function App() { return ( - - - - - - - - - - - - - + + + + + + + + + + + + + + ) } diff --git a/app/ts/components/ConnectAccount.tsx b/app/ts/components/ConnectAccount.tsx index d4b90d1c..c840e0e6 100644 --- a/app/ts/components/ConnectAccount.tsx +++ b/app/ts/components/ConnectAccount.tsx @@ -1,12 +1,38 @@ -import { useAccount } from '../store/account.js' -import { useNetwork } from '../store/network.js' +import { useSignalEffect } from '@preact/signals' +import { useAsyncState } from '../library/preact-utilities.js' +import { EthereumAddress } from '../schema.js' +import { useWallet } from '../context/Wallet.js' +import { useAccount } from '../context/Account.js' +import { useNotice } from '../store/notice.js' import { AsyncText } from './AsyncText.js' import SVGBlockie from './SVGBlockie.js' export const ConnectAccount = () => { - const { address, connect } = useAccount() + const { browserProvider } = useWallet() + const { account } = useAccount() + const { value: query, waitFor } = useAsyncState() + const { notify } = useNotice() - switch (address.value.state) { + const connect = () => { + if (!browserProvider) { + notify({ message: 'No compatible web3 wallet detected.', title: 'Failed to connect' }) + return + } + waitFor(async () => { + const signer = await browserProvider.getSigner() + return EthereumAddress.parse(signer.address) + }) + } + + const listenForQueryChanges = () => { + // do not reset shared state for other instances of this hooks + if (query.value.state === 'inactive') return + account.value = query.value + } + + useSignalEffect(listenForQueryChanges) + + switch (account.value.state) { case 'inactive': case 'rejected': return ( @@ -35,16 +61,17 @@ export const ConnectAccount = () => { } const AccountAddress = () => { - const { address } = useAccount() + const { account } = useAccount() - switch (address.value.state) { + switch (account.value.state) { case 'inactive': + return <> case 'rejected': return
Not connected
case 'pending': return case 'resolved': - return
{address.value.value}
+ return
{account.value.value}
} } @@ -55,9 +82,9 @@ const NetworkIcon = () => ( ) const AccountAvatar = () => { - const { address } = useAccount() + const { account } = useAccount() - switch (address.value.state) { + switch (account.value.state) { case 'inactive': case 'rejected': return
@@ -66,16 +93,16 @@ const AccountAvatar = () => { case 'resolved': return (
- +
) } } const WalletNetwork = () => { - const { address } = useAccount() + const { account } = useAccount() - switch (address.value.state) { + switch (account.value.state) { case 'inactive': case 'rejected': return <> @@ -92,7 +119,7 @@ const WalletNetwork = () => { } const NetworkName = () => { - const { network } = useNetwork() + const { network } = useWallet() switch (network.value.state) { case 'inactive': diff --git a/app/ts/components/ErrorAlert.tsx b/app/ts/components/ErrorAlert.tsx index 4752da65..8f590ef7 100644 --- a/app/ts/components/ErrorAlert.tsx +++ b/app/ts/components/ErrorAlert.tsx @@ -21,9 +21,9 @@ export const ErrorAlert = () => { {latestError.value && ( <> - +
Application Error
{latestError.value.message}
diff --git a/app/ts/components/QueryToken.tsx b/app/ts/components/QueryToken.tsx deleted file mode 100644 index b3270baf..00000000 --- a/app/ts/components/QueryToken.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { JSX } from 'preact/jsx-runtime' -import { ComponentChildren } from 'preact' -import { Signal, useComputed } from '@preact/signals' -import { TokenMeta, useManagedTokens, useTokenQuery } from '../store/tokens.js' -import { AsyncProperty } from '../library/preact-utilities.js' - -type QueryTokenProps = { - onSave: (token: TokenMeta) => void -} - -export const QueryToken = ({ onSave }: QueryTokenProps) => { - const { query, tokenAddress } = useTokenQuery() - - return ( -
- - -
- ) -} - -type HelperProps = { - query: Signal> - onTokenSave: (token: TokenMeta) => void -} - -const Helper = ({ query, onTokenSave }: HelperProps) => { - switch (query.value.state) { - case 'inactive': - return <> - - case 'pending': - return ( - -
- - - - -
Retrieving token information from the network.
-
-
- ) - - case 'rejected': - return ( - -
-
×
-
The active network failed to retrieve token information.
-
-
- ) - - case 'resolved': - return ( - - - - ) - } -} - -const Boxed = ({ children }: { children: ComponentChildren }) => { - return
{children}
-} - -type TokenAddressFieldProps = { - address: Signal -} - -const TokenAddressField = ({ address }: TokenAddressFieldProps) => { - const handleChange = (e: JSX.TargetedEvent) => { - const target = e.currentTarget - target.checkValidity() - address.value = target.value.trim() - } - - return ( -
- - -
- ) -} - -type SaveTokenProps = { - token: TokenMeta - onSuccess: (token: TokenMeta) => void -} - -const SaveToken = ({ token, onSuccess }: SaveTokenProps) => { - const { tokens } = useManagedTokens() - - const accountTokenExists = useComputed(() => Boolean(tokens.value.find(userToken => userToken.address === token.address))) - - const handleTokenSave = () => { - tokens.value = tokens.peek().concat(token) - onSuccess(token) - } - - return ( -
-
- {token.name} ({token.symbol}) -
- {accountTokenExists.value ? ( -
Saved
- ) : ( - - )} -
- ) -} diff --git a/app/ts/components/RecentTransfers.tsx b/app/ts/components/RecentTransfers.tsx deleted file mode 100644 index ba3566cb..00000000 --- a/app/ts/components/RecentTransfers.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useComputed } from '@preact/signals' -import { useAccount } from '../store/account.js' -import { useTransfers } from '../store/transfer.js' -import { TimeAgo } from './TimeAgo.js' - -export const RecentTransfers = () => { - const allTransfers = useTransfers() - const { address } = useAccount() - - const connectedAddress = useComputed(() => address.value.state !== 'resolved' ? undefined : address.value.value) - - const getRecentTransferFromConnecteAddress = () => allTransfers.value.data - // select only transfers from connected address - .filter((transfer) => transfer.from === connectedAddress.value) - // sort by recent transfer date - .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() - ) - - const connectedAddressTransfers = useComputed(getRecentTransferFromConnecteAddress) - - if (connectedAddressTransfers.value.length < 1) return <> - - return ( -
-
Recent Transfers
-
- {connectedAddressTransfers.value.map(transfer => { - const timeStamp = new Date(transfer.date).getTime() - return ( - -
- Sent {transfer.amount} {transfer.token?.name || 'ETH'} to {transfer.to} -
-
- -
-
- ) - })} -
-
- ) -} diff --git a/app/ts/components/SVGBlockie.tsx b/app/ts/components/SVGBlockie.tsx index 931b41e5..3419615f 100644 --- a/app/ts/components/SVGBlockie.tsx +++ b/app/ts/components/SVGBlockie.tsx @@ -1,6 +1,6 @@ // ported directly from https://github.com/DarkFlorist/TheInterceptor -import { useMemo } from "preact/hooks"; +import { useMemo } from 'preact/hooks' function generateIdenticon(options: { seed: string; size?: number }) { // NOTE -- Majority of this code is referenced from: https://github.com/alexvandesande/blockies diff --git a/app/ts/components/SetupTransfer.tsx b/app/ts/components/SetupTransfer.tsx new file mode 100644 index 00000000..c433ef9d --- /dev/null +++ b/app/ts/components/SetupTransfer.tsx @@ -0,0 +1,83 @@ +import { useSignalEffect } from '@preact/signals' +import { Contract, TransactionResponse } from 'ethers' +import { ComponentChildren } from 'preact' +import { useTransfer } from '../context/Transfer.js' +import { ERC20ABI } from '../library/ERC20ABI.js' +import { useAsyncState } from '../library/preact-utilities.js' +import { TransferAddressField } from './TransferAddressField.js' +import { TransferAmountField } from './TransferAmountField.js' +import { TransferRecorder } from './TransferRecorder.js' +import { TransferButton } from './TransferButton.js' +import { TransferTokenSelectField } from './TransferTokenField.js' +import { useWallet } from '../context/Wallet.js' +import { useNotice } from '../store/notice.js' +import { TokenPicker } from './TokenPicker.js' +import { TokenAdd } from './TokenAdd.js' + +export function SetupTransfer() { + return ( + +
+
+ + +
+ + + + + +
+
+ ) +} + +const TransferForm = ({ children }: { children: ComponentChildren }) => { + const { browserProvider, network } = useWallet() + const { input, transaction, safeParse } = useTransfer() + const { value: transactionQuery, waitFor } = useAsyncState() + const { notify } = useNotice() + + const sendTransferRequest = (e: Event) => { + e.preventDefault() + + if (!browserProvider) { + notify({ message: 'No compatible web3 wallet detected.', title: 'Failed to connect' }) + return + } + + if (!safeParse.value.success) return + const transferInput = safeParse.value.value + + waitFor(async () => { + const signer = await browserProvider.getSigner() + + // Ether transfer + if (transferInput.token === undefined) { + return await signer.sendTransaction({ to: transferInput.to, value: transferInput.amount }) + } + + // Token transfer + const tokenMetadata = transferInput.token + const contract = new Contract(tokenMetadata.address, ERC20ABI, signer) + return await contract.transfer(transferInput.to, transferInput.amount) + }) + } + + const listenForQueryChanges = () => { + // do not reset shared state for other instances of this hooks + if (transactionQuery.value.state === 'inactive') return + transaction.value = transactionQuery.value + } + + const listenForWalletsChainChange = () => { + if (network.value.state !== 'resolved') return + // reset token input as it may not exist on the active network + input.value = { ...input.peek(), token: undefined } + } + + useSignalEffect(listenForWalletsChainChange) + useSignalEffect(listenForQueryChanges) + + return
{children}
+} diff --git a/app/ts/components/TokenAdd.tsx b/app/ts/components/TokenAdd.tsx new file mode 100644 index 00000000..9789ce6f --- /dev/null +++ b/app/ts/components/TokenAdd.tsx @@ -0,0 +1,245 @@ +import { batch, Signal, useComputed, useSignal, useSignalEffect } from '@preact/signals' +import { Contract } from 'ethers' +import { Result } from 'funtypes' +import { ComponentChildren } from 'preact' +import { useTokenManager } from '../context/TokenManager.js' +import { useTransfer } from '../context/Transfer.js' +import { useWallet } from '../context/Wallet.js' +import { ERC20ABI } from '../library/ERC20ABI.js' +import { useAsyncState, useSignalRef } from '../library/preact-utilities.js' +import { ERC20Token, EthereumAddress, serialize } from '../schema.js' +import { useNotice } from '../store/notice.js' +import * as Icon from './Icon/index.js' + +export const TokenAdd = () => { + const queryResult = useSignal | undefined>(undefined) + + return ( + +
+
Add a token
+
Enter the token's contract address to retrieve details
+
+
+ + +
+
+
+
+ ) +} + +const AddTokenDialog = ({ children }: { children: ComponentChildren }) => { + const { ref, signal: dialogRef } = useSignalRef(null) + const { stage } = useTokenManager() + + const closeDialogOnBackdropClick = (e: Event) => { + const isClickWithinDialog = e.type === 'click' && e.target !== dialogRef?.value + if (isClickWithinDialog) return + dialogRef.value?.close() + } + + const unsetStageIfClosedIntentionally = () => { + stage.value = stage.value === 'add' ? undefined : stage.value + } + + const showOrHideDialog = () => { + const dialogElement = dialogRef.value + const isDialogOpen = stage.value === 'add' + if (!dialogElement) return + dialogElement.onclose = unsetStageIfClosedIntentionally + isDialogOpen ? dialogElement.showModal() : dialogElement.close() + } + + const setClickListenerForDialog = () => { + if (stage.value !== 'add') return + document.addEventListener('click', closeDialogOnBackdropClick) + return () => document.removeEventListener('click', closeDialogOnBackdropClick) + } + + useSignalEffect(showOrHideDialog) + useSignalEffect(setClickListenerForDialog) + + return ( + + {children} + + ) +} + +const QueryAddressField = ({ result }: { result: Signal | undefined> }) => { + const query = useSignal('') + const isPristine = useSignal(true) + const { ref, signal: inputRef } = useSignalRef(null) + + const parsedAddress = useComputed(() => EthereumAddress.safeParse(query.value)) + + const normalizeAndUpdateValue = (newValue: string) => { + batch(() => { + isPristine.value = undefined + query.value = newValue.trim() + }) + } + + const clearValue = () => { + if (inputRef.value) { + inputRef.value.value = '' + const inputEvent = new InputEvent('input') + inputRef.value.dispatchEvent(inputEvent) + inputRef.value.focus() + } + } + + const validationMessage = useComputed(() => { + if (parsedAddress.value.success) return undefined + return 'Invalid ERC20 contract address.' + }) + + const validateField = () => { + if (inputRef.value === null) return + if (validationMessage.value === undefined) { + inputRef.value.setCustomValidity('') + return + } + + inputRef.value.setCustomValidity(validationMessage.value) + inputRef.value.reportValidity() + } + + useSignalEffect(() => { + result.value = parsedAddress.value + }) + useSignalEffect(validateField) + + return ( +
+ + normalizeAndUpdateValue(e.currentTarget.value)} required placeholder='0x0123...' class='peer outline-none pt-4 bg-transparent text-ellipsis disabled:text-white/30 placeholder:text-white/20 group-modified:enabled:invalid:text-red-400' /> + +
+ ) +} + +const QueryResult = ({ result }: { result: Signal | undefined> }) => { + const { notify } = useNotice() + const { value: query, waitFor, reset } = useAsyncState() + const { browserProvider, network } = useWallet() + + const getTokenMetadata = () => { + if (!result.value?.success) { + reset() + return + } + + if (!browserProvider) { + notify({ message: 'No compatible web3 wallet detected.', title: 'Failed to connect' }) + return + } + + if (network.value.state !== 'resolved') { + notify({ message: 'Your wallet may not connected to the chain.', title: 'Network unavailable' }) + return + } + + const tokenAddress = result.value.value + const activeChainId = network.value.value.chainId + + waitFor(async () => { + const contract = new Contract(tokenAddress, ERC20ABI, browserProvider) + const namePromise = contract.name() + const symbolPromise = contract.symbol() + const decimalsPromise = contract.decimals() + const name = await namePromise + const symbol = await symbolPromise + const decimals = await decimalsPromise + const chainId = activeChainId + return { chainId, name, symbol, decimals, address: tokenAddress } + }) + } + + useSignalEffect(getTokenMetadata) + + switch (query.value.state) { + case 'inactive': + return <> + case 'pending': + return ( +
+ + Retrieving token information from the network... +
+ ) + case 'rejected': + return ( +
+
+ +
+
+
No token contract matches the provided address
+
Make sure the address and network is correctly set in your connected wallet.
+
+
+ ) + case 'resolved': + const token = serialize(ERC20Token, query.value.value) + const parsedToken = ERC20Token.parse(token) + + return ( +
+
+ {token.name} ({token.symbol}) +
+ +
+ ) + } +} + +const UseTokenButton = ({ token }: { token: ERC20Token }) => { + const { cache, stage } = useTokenManager() + const { input } = useTransfer() + + const tokenExistsInCache = useComputed(() => cache.value.data.some(t => t.address === token.address)) + + const saveNewToken = () => { + cache.value = Object.assign({}, cache.peek(), { data: cache.peek().data.concat([token]) }) + } + + const useToken = () => { + batch(() => { + if (!tokenExistsInCache.value) saveNewToken() + input.value = Object.assign({}, input.peek(), { token }) + stage.value = undefined + }) + } + + return ( + + ) +} + +const ClearButton = ({ onClick }: { onClick: () => void }) => { + return ( + + ) +} + +const PlusIcon = () => ( + + + +) + +const EmptyIcon = () => ( + + + + +) diff --git a/app/ts/components/TokenManager/EtherBalance.tsx b/app/ts/components/TokenManager/EtherBalance.tsx deleted file mode 100644 index 283d761e..00000000 --- a/app/ts/components/TokenManager/EtherBalance.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useSignalEffect } from '@preact/signals' -import { formatEther } from 'ethers' -import { useAsyncState } from '../../library/preact-utilities.js' -import { useAccount } from '../../store/account.js' -import { useNetwork } from '../../store/network.js' -import { useProviders } from '../../store/provider.js' -import { AsyncText } from '../AsyncText.js' -import * as Icon from '../Icon/index.js' - -export const EtherBalance = () => { - const { network } = useNetwork() - const { address } = useAccount() - const providers = useProviders() - const { value: query, waitFor } = useAsyncState() - - const getBalance = () => { - if (address.value.state !== 'resolved') return - const accountAddress = address.value.value - waitFor(async () => { - return await providers.browserProvider.getBalance(accountAddress) - }) - } - - useSignalEffect(() => { - if (network.value.state !== 'resolved' || address.value.state !== 'resolved') return - getBalance() - }) - - switch (query.value.state) { - case 'inactive': - return <> - case 'rejected': - return ( -
- -
- ) - case 'pending': - return ( -
- -
- ) - case 'resolved': - const balance = formatEther(query.value.value) - return
{balance} ETH
- } -} diff --git a/app/ts/components/TokenManager/SearchField.tsx b/app/ts/components/TokenManager/SearchField.tsx deleted file mode 100644 index ffc5aa02..00000000 --- a/app/ts/components/TokenManager/SearchField.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Signal } from '@preact/signals' -import { useEffect, useRef } from 'preact/hooks' -import * as Icon from '../Icon/index.js' - -type Props = { - value: Signal -} - -export const SearchField = ({ value }: Props) => { - const inputRef = useRef(null) - - const handleClear = () => { - value.value = '' - inputRef.current?.focus() - } - - useEffect(() => { - inputRef.current?.focus() - }, []) - - return ( -
- (value.value = event.currentTarget.value)} /> - {value.value !== '' && } -
- ) -} - -const ClearButton = ({ onClick }: { onClick: () => void }) => { - return ( - - ) -} diff --git a/app/ts/components/TokenManager/TokenBalance.tsx b/app/ts/components/TokenManager/TokenBalance.tsx deleted file mode 100644 index db4dc6c2..00000000 --- a/app/ts/components/TokenManager/TokenBalance.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useSignalEffect } from '@preact/signals' -import { formatUnits } from 'ethers' -import { useAccount } from '../../store/account.js' -import { TokenMeta, useTokenBalance } from '../../store/tokens.js' -import { AsyncText } from '../AsyncText.js' - -type Props = { - token: TokenMeta -} - -export const TokenBalance = ({ token }: Props) => { - const { tokenBalance, getTokenBalance } = useTokenBalance() - const { address } = useAccount() - - useSignalEffect(() => { - if (address.value.state !== 'resolved') return - getTokenBalance(address.value.value, token.address) - }) - - if (address.value.state !== 'resolved') return <> - - switch (tokenBalance.value.state) { - case 'inactive': - return <> - case 'pending': - return ( -
- -
- ) - case 'rejected': - return ( -
- -
- ) - case 'resolved': - const balance = formatUnits(tokenBalance.value.value, token.decimals) - return ( -
- {balance} {token.symbol} -
- ) - } -} diff --git a/app/ts/components/TokenManager/index.tsx b/app/ts/components/TokenManager/index.tsx deleted file mode 100644 index c93df1cf..00000000 --- a/app/ts/components/TokenManager/index.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { useComputed, useSignal, useSignalEffect } from '@preact/signals' -import { ComponentChild, ComponentChildren } from 'preact' -import { EtherBalance } from './EtherBalance.js' -import { SearchField } from './SearchField.js' -import { TokenBalance } from './TokenBalance.js' -import { MANAGED_TOKENS_CACHE_KEY } from '../../library/constants.js' -import { useAccount } from '../../store/account.js' -import { useNetwork } from '../../store/network.js' -import { TokenMeta, useManagedTokens } from '../../store/tokens.js' - -export type TokenManagerProps = { - show: boolean - onClose: () => void - onSelect: (token?: TokenMeta) => void - onAddToken: () => void -} - -export const TokenManager = (props: TokenManagerProps) => { - const { tokens, cacheKey } = useManagedTokens() - const { address } = useAccount() - const { network } = useNetwork() - const query = useSignal('') - - const chainId = useComputed(() => (network.value.state === 'resolved' ? network.value.value.chainId : 1n)) - const tokensMatchingQuery = (token: TokenMeta) => token.name.toLowerCase().includes(query.value) - const tokensInChain = (token: TokenMeta) => token.chainId === chainId.value - const tokenList = useComputed(() => tokens.value.filter(tokensInChain).filter(tokensMatchingQuery)) - - if (props.show === false) return <> - - const handleAddToken = () => { - props.onClose() - props.onAddToken() - } - - useSignalEffect(() => { - cacheKey.value = address.value.state !== 'resolved' ? MANAGED_TOKENS_CACHE_KEY : `${MANAGED_TOKENS_CACHE_KEY}:${address.value.value}` - }) - - return ( -
-
props.onClose()} /> - - -
-
Tokens
- -
- - {query.value === '' && props.onSelect(undefined)} />} - {tokenList.value.map(token => ( - props.onClose()} /> - ))} - {query.value === '' && } - -
- - -
- ) -} - -type TokenGridProps = { - count: number - children: ComponentChild -} - -const TokenGrid = (props: TokenGridProps) => { - switch (props.count) { - case 1: - return
{props.children}
- case 2: - return
{props.children}
- case 3: - return
{props.children}
- case 4: - return
{props.children}
- default: - return
{props.children}
- } -} - -type ContainerProps = { - count: number - children: ComponentChildren -} - -const Dialog = (props: ContainerProps) => { - switch (props.count) { - case 1: - return
{props.children}
- case 2: - return
{props.children}
- case 3: - return
{props.children}
- case 4: - return
{props.children}
- default: - return
{props.children}
- } -} - -export type TokenCardProps = { - token: TokenMeta - onSelect: (token: TokenMeta) => void - onClose: () => void -} - -const TokenCard = ({ token, onSelect }: TokenCardProps) => { - const removal = useSignal(false) - const { tokens } = useManagedTokens() - - const removeTokenFromList = (tokenAddress: string) => { - tokens.value = tokens.peek().filter(token => token.address !== tokenAddress) - removal.value = false - } - - return ( -
-
- -
{token.name.substring(0, 2)}
-
-
{token.name}
- -
- -
- ) -} - -const AddTokenCard = ({ onClick }: { onClick: () => void }) => { - const { address } = useAccount() - const cardTitle = useComputed(() => { - return address.value.state !== 'resolved' ? `Connect Wallet` : `Add Token` - }) - - return ( -
-
-
- - - -
-
{cardTitle}
-
-
- ) -} - -const NativeCard = ({ onSelect }: { onSelect: () => void }) => { - return ( -
-
- -
ETH
- -
- -
- ) -} diff --git a/app/ts/components/TokenPicker.tsx b/app/ts/components/TokenPicker.tsx new file mode 100644 index 00000000..b6e3667d --- /dev/null +++ b/app/ts/components/TokenPicker.tsx @@ -0,0 +1,270 @@ +import { batch, Signal, useComputed, useSignal, useSignalEffect } from '@preact/signals' +import { Contract, formatUnits } from 'ethers' +import { useEffect, useRef } from 'preact/hooks' +import { useAccount } from '../context/Account.js' +import { useTokenManager } from '../context/TokenManager.js' +import { useTransfer } from '../context/Transfer.js' +import { useWallet } from '../context/Wallet.js' +import { ERC20ABI } from '../library/ERC20ABI.js' +import { useAsyncState, useSignalRef } from '../library/preact-utilities.js' +import { removeNonStringsAndTrim, stringIncludes } from '../library/utilities.js' +import { ERC20Token } from '../schema.js' +import { AsyncText } from './AsyncText.js' +import * as Icon from './Icon/index.js' + +export const TokenPicker = () => { + const { ref, signal: dialogRef } = useSignalRef(null) + const { query, stage } = useTokenManager() + + const closeDialogOnBackdropClick = (e: Event) => { + const isClickWithinDialog = e.type === 'click' && e.target !== dialogRef?.value + if (isClickWithinDialog) return + dialogRef.value?.close() + } + + const unsetStageIfClosedIntentionally = () => { + stage.value = stage.value === 'select' ? undefined : stage.value + } + + const showOrHideDialog = () => { + const dialogElement = dialogRef.value + const isDialogOpen = stage.value === 'select' + if (!dialogElement) return + dialogElement.onclose = unsetStageIfClosedIntentionally + dialogElement[isDialogOpen ? 'showModal' : 'close']() + } + + const setClickListenerForDialog = () => { + if (stage.value !== 'select') return + document.addEventListener('click', closeDialogOnBackdropClick) + return () => document.removeEventListener('click', closeDialogOnBackdropClick) + } + + useSignalEffect(showOrHideDialog) + useSignalEffect(setClickListenerForDialog) + + return ( + +
Select Asset
+
+
+ +
+ + +
+ ) +} + +const AssetCardList = () => { + const { cache } = useTokenManager() + const { input } = useTransfer() + const { query } = useTokenManager() + const { network } = useWallet() + + const activeChainId = useComputed(() => (network.value.state === 'resolved' ? network.value.value.chainId : 1n)) + + const matchTokensInChain = (token: ERC20Token) => token.chainId === activeChainId.value + const matchQueriedTokens = (token: ERC20Token) => stringIncludes(token.name, query.value) || stringIncludes(token.symbol, query.value) + + const tokensList = useComputed(() => { + const tokensInChain = cache.value.data.filter(matchTokensInChain) + return tokensInChain.filter(matchQueriedTokens) + }) + + const gridStyles = useComputed(() => { + let classNames = 'grid-cols-1' + const length = tokensList.value.length + 2 + if (length > 1) classNames += ' sm:grid-cols-2' + if (length > 2) classNames += ' md:grid-cols-3' + if (length > 3) classNames += ' lg:grid-cols-4' + if (length > 4) classNames += ' xl:grid-cols-5' + return classNames + }) + + useSignalEffect(() => { + input.value = { ...input.peek(), token: query.value !== '' ? tokensList.value.at(0) : undefined } + }) + + return ( +
+ {query.value === '' || stringIncludes('ethers', query.value) ? : <>} + {tokensList.value.map(token => ( + + ))} + +
+ ) +} + +const AssetCard = ({ token }: { token?: ERC20Token }) => { + const radioRef = useRef(null) + const { stage } = useTokenManager() + const { input } = useTransfer() + const iconPath = token ? `/img/${token.address.toLowerCase()}.svg` : `/img/ethereum.svg` + + const setId = 'transfer_asset' + const uniqueId = token?.address || 'ether' + + const isSelected = useComputed(() => input.value.token?.address === token?.address) + + const inputEventHandler = (e: Event) => { + if (e instanceof FocusEvent) { + input.value = { ...input.peek(), token } + return + } + + if (e instanceof KeyboardEvent && e.key === 'Enter') { + stage.value = undefined + return + } + } + + const selectAssetAndExitManager = () => + batch(() => { + input.value = { ...input.peek(), token } + stage.value = undefined + }) + + return ( +
+ + + {token ? : <>} +
+ ) +} + +const TokenBalance = ({ token }: { token?: ERC20Token }) => { + const { browserProvider} = useWallet() + const { account } = useAccount() + const { value: query, waitFor } = useAsyncState() + + if (!browserProvider || !token) return <> + + const getTokenBalance = async () => { + if (account.value.state !== 'resolved') return + console.log('getTokenBalance', account.value.state) + const accountAddress = account.value.value + const contract = new Contract(token.address, ERC20ABI, browserProvider) + waitFor(async () => await contract.balanceOf(accountAddress)) + } + + useEffect(() => { + getTokenBalance() + }, [token]) + + switch (query.value.state) { + case 'inactive': + return <> + case 'pending': + return + case 'rejected': + return
error
+ case 'resolved': + const displayValue = formatUnits(query.value.value, token.decimals) + return <>{displayValue} {token.symbol} + } + +} + +const RemoveAssetDialog = ({ token }: { token: ERC20Token }) => { + const { cache } = useTokenManager() + const isRemoving = useSignal(false) + + const confirmRemove = () => { + cache.value = Object.assign({}, cache.peek(), { data: cache.peek().data.filter(t => t.address !== token.address) }) + isRemoving.value = false + } + + const rejectRemove = () => { + isRemoving.value = false + } + + return ( +
+ + {isRemoving.value ? ( +
+
+
This will remove the contract address for
+
{token.name}
+
Continue?
+
+ + +
+
+
+ ) : ( + <> + )} +
+ ) +} + +const AddTokenCard = () => { + const { stage } = useTokenManager() + const openAddTokenDialog = () => { + stage.value = 'add' + } + + return ( +
+ +
+ ) +} + +const SearchField = ({ query }: { query: Signal }) => { + const searchInputRef = useRef(null) + + const clearSearchQuery = () => { + query.value = '' + searchInputRef.current?.focus() + } + + return ( +
+ (query.value = e.currentTarget.value)} tabIndex={2} /> + +
+ ) +} + +const TrashIcon = () => ( + + + +) diff --git a/app/ts/components/TransactionPage/index.tsx b/app/ts/components/TransactionPage/index.tsx index fa48a0cd..f358a900 100644 --- a/app/ts/components/TransactionPage/index.tsx +++ b/app/ts/components/TransactionPage/index.tsx @@ -1,12 +1,13 @@ import { Header, HeaderNav, Main, Navigation, Root, usePanels } from '../DefaultLayout/index.js' import { ConnectAccount } from '../ConnectAccount.js' -import { RecentTransfers } from '../RecentTransfers.js' +import { TransferHistory } from '../TransferHistory.js' import { DiscordInvite } from '../DiscordInvite.js' import { TransactionDetails } from './TransactionDetails.js' import { Favorites } from '../Favorites.js' import { MainFooter } from '../MainFooter.js' import { useAccount } from '../../store/account.js' import { useEffect } from 'preact/hooks' +import { TransferHistoryProvider } from '../../context/TransferHistory.js' const SCROLL_OPTIONS = { inline: 'start', behavior: 'smooth' } as const @@ -20,8 +21,10 @@ export const TransactionPage = () => { return (
- - + + + +
) @@ -84,7 +87,7 @@ const LeftPanel = () => {
- + diff --git a/app/ts/components/TransferAddressField.tsx b/app/ts/components/TransferAddressField.tsx new file mode 100644 index 00000000..71e399ca --- /dev/null +++ b/app/ts/components/TransferAddressField.tsx @@ -0,0 +1,60 @@ +import { batch, useComputed, useSignal, useSignalEffect } from '@preact/signals' +import { useTransfer } from '../context/Transfer.js' +import { useSignalRef } from '../library/preact-utilities.js' +import * as Icon from './Icon/index.js' + +export const TransferAddressField = () => { + const transfer = useTransfer() + const isPristine = useSignal(true) + const { ref, signal: inputRef } = useSignalRef(null) + + const normalizeAndUpdateValue = (newValue: string) => { + batch(() => { + isPristine.value = undefined + transfer.input.value = { ...transfer.input.value, to: newValue.trim() } + }) + } + + const clearValue = () => { + if (inputRef.value) { + inputRef.value.value = '' + const inputEvent = new InputEvent('input') + inputRef.value.dispatchEvent(inputEvent) + inputRef.value.focus() + } + } + + const validationMessage = useComputed(() => { + const safeParsedInput = transfer.safeParse.value + if (safeParsedInput.success || safeParsedInput.key !== 'to') return undefined + return 'Requires a valid address' + }) + + const validateField = () => { + if (inputRef.value === null) return + if (validationMessage.value === undefined) { + inputRef.value.setCustomValidity('') + return + } + + inputRef.value.setCustomValidity(validationMessage.value) + } + + useSignalEffect(validateField) + + return ( +
+ + normalizeAndUpdateValue(e.currentTarget.value)} required placeholder='0x0123...' class='peer outline-none pt-4 bg-transparent text-ellipsis disabled:text-white/30 placeholder:text-white/20 group-modified:enabled:invalid:text-red-400' /> + +
+ ) +} + +const ClearButton = ({ onClick }: { onClick: () => void }) => { + return ( + + ) +} diff --git a/app/ts/components/TransferAmountField.tsx b/app/ts/components/TransferAmountField.tsx new file mode 100644 index 00000000..992777f5 --- /dev/null +++ b/app/ts/components/TransferAmountField.tsx @@ -0,0 +1,119 @@ +import { batch, useComputed, useSignal, useSignalEffect } from '@preact/signals' +import { Contract, formatUnits, parseUnits } from 'ethers' +import { useAccount } from '../context/Account.js' +import { useTransfer } from '../context/Transfer.js' +import { useWallet } from '../context/Wallet.js' +import { ERC20ABI } from '../library/ERC20ABI.js' +import { useAsyncState, useSignalRef } from '../library/preact-utilities.js' +import * as Icon from './Icon/index.js' + +export const TransferAmountField = () => { + const { input, safeParse } = useTransfer() + const isPristine = useSignal(true) + const { ref, signal: inputRef } = useSignalRef(null) + + const normalizeAndUpdateValue = (newValue: string) => { + batch(() => { + isPristine.value = undefined + input.value = { ...input.value, amount: newValue.trim() } + }) + } + + const clearValue = () => { + if (inputRef.value) { + inputRef.value.value = '' + const inputEvent = new InputEvent('input') + inputRef.value.dispatchEvent(inputEvent) + inputRef.value.focus() + } + } + + const validationMessage = useComputed(() => { + const safeParsedInput = safeParse.value + if (safeParsedInput.success || safeParsedInput.key !== 'amount') return undefined + return 'Amount should be a number.' + }) + + const validateField = () => { + if (inputRef.value === null) return + if (validationMessage.value === undefined) { + inputRef.value.setCustomValidity('') + return + } + + inputRef.value.setCustomValidity(validationMessage.value) + } + + useSignalEffect(validateField) + + return ( +
+ + normalizeAndUpdateValue(e.currentTarget.value)} required placeholder='1.00' class='peer outline-none pt-4 bg-transparent text-ellipsis disabled:text-white/30 placeholder:text-white/20' /> + + +
+ ) +} + +const MaxButton = () => { + const { browserProvider } = useWallet() + const { value: tokenBalance, waitFor } = useAsyncState() + const { input } = useTransfer() + const { account } = useAccount() + + const accountAddress = useComputed(() => (account.value.state === 'resolved' ? account.value.value : undefined)) + const tokenAddress = useComputed(() => input.value.token?.address) + const currentTokenBalance = useComputed(() => (tokenBalance.value.state === 'resolved' ? tokenBalance.value.value : 0n)) + + const getTokenBalance = () => { + waitFor(async () => { + if (!accountAddress.value || !tokenAddress.value) return + const contract = new Contract(tokenAddress.value, ERC20ABI, browserProvider) + return await contract.balanceOf(accountAddress.value) + }) + } + + const setMaxAmount = async () => { + const amount = formatUnits(currentTokenBalance.value, input.value.token?.decimals) + input.value = Object.assign({}, input.peek(), { amount }) + } + + const isInputAmountAtMax = useComputed(() => { + try { + const amount = parseUnits(input.value.amount, input.value.token?.decimals) + return currentTokenBalance.value === amount + } catch { + return false + } + }) + + useSignalEffect(() => { + if (!tokenAddress.value || !accountAddress.value) return + getTokenBalance() + }) + + if (!input.value.token || isInputAmountAtMax.value) return <> + + switch (tokenBalance.value.state) { + case 'inactive': + case 'rejected': + return <> + case 'pending': + return + case 'resolved': + return ( + + ) + } +} + +const ClearButton = ({ onClick }: { onClick: () => void }) => { + return ( + + ) +} diff --git a/app/ts/components/TransferButton.tsx b/app/ts/components/TransferButton.tsx new file mode 100644 index 00000000..edaf144a --- /dev/null +++ b/app/ts/components/TransferButton.tsx @@ -0,0 +1,100 @@ +import { useComputed, useSignalEffect } from '@preact/signals' +import { JSX } from 'preact/jsx-runtime' +import { removeNonStringsAndTrim } from '../library/utilities.js' +import { useTransfer } from '../context/Transfer.js' +import * as Icon from './Icon/index.js' +import { useWallet } from '../context/Wallet.js' +import { useAsyncState } from '../library/preact-utilities.js' +import { EthereumAddress } from '../schema.js' +import { useNotice } from '../store/notice.js' +import { useAccount } from '../context/Account.js' + +export const TransferButton = () => { + const { transaction } = useTransfer() + + const isTransferring = useComputed(() => transaction.value.state === 'pending') + + if (isTransferring.value) + return ( + + ) + + return +} + +const ConnectOrTransferButton = () => { + const { value: query, waitFor } = useAsyncState() + const { browserProvider } = useWallet() + const { account } = useAccount() + const { notify } = useNotice() + + const connect = () => { + if (!browserProvider) { + notify({ message: 'No compatible web3 wallet detected.', title: 'Failed to connect' }) + return + } + waitFor(async () => { + const signer = await browserProvider.getSigner() + return EthereumAddress.parse(signer.address) + }) + } + + const listenForQueryChanges = () => { + // do not reset shared state for other instances of this hooks + if (query.value.state === 'inactive') return + account.value = query.value + } + + useSignalEffect(listenForQueryChanges) + + switch (account.value.state) { + case 'inactive': + case 'rejected': + return ( + + ) + case 'pending': + return ( + + ) + case 'resolved': + return ( + + ) + } +} + +const Button = ({ class: className, children, ...props }: JSX.HTMLAttributes) => { + return ( + + ) +} + +const ConnectIcon = () => ( + + + +) + +const TransferIcon = () => ( + + + + + + +) diff --git a/app/ts/components/TransferHistory.tsx b/app/ts/components/TransferHistory.tsx new file mode 100644 index 00000000..b832e248 --- /dev/null +++ b/app/ts/components/TransferHistory.tsx @@ -0,0 +1,55 @@ +import { useComputed } from '@preact/signals' +import { formatUnits } from 'ethers' +import { useTransferHistory } from '../context/TransferHistory.js' +import { Transfer } from '../schema.js' +import { useAccount } from '../store/account.js' +import { TimeAgo } from './TimeAgo.js' + +export const TransferHistory = () => { + const history = useTransferHistory() + const { address } = useAccount() + + const connectedAddress = useComputed(() => (address.value.state !== 'resolved' ? undefined : address.value.value)) + + const getTransfersFromConnectedAddress = () => { + return ( + history.value.data + // select only history from connected address + .filter(transfer => transfer.from === connectedAddress.value) + // sort by recent transfer date + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + ) + } + + const connectedAddressTransfers = useComputed(getTransfersFromConnectedAddress) + + return ( +
+
Recent Transfers
+ +
+ ) +} + +const HistoryList = ({ transfers }: { transfers: Transfer[] }) => { + if (transfers.length < 1) return
A history of transfers will be stored locally on the current browser.
+ + return ( +
+ {transfers.map(transfer => { + const timeStamp = new Date(transfer.date).getTime() + const amount = formatUnits(transfer.amount, transfer.token?.decimals) + return ( + +
+ Sent {amount} {transfer.token?.name || 'ETH'} to {transfer.to} +
+
+ +
+
+ ) + })} +
+ ) +} diff --git a/app/ts/components/TransferPage/AddTokenDialog.tsx b/app/ts/components/TransferPage/AddTokenDialog.tsx deleted file mode 100644 index 784b6e7a..00000000 --- a/app/ts/components/TransferPage/AddTokenDialog.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Signal } from '@preact/signals' -import { QueryToken } from '../QueryToken.js' -import { TokenMeta } from '../../store/tokens.js' - -type Props = { - show: Signal<'select' | 'add' | undefined> - onSave: (token: TokenMeta) => void -} - -export const AddTokenDialog = ({ show, onSave }: Props) => { - if (show.value !== 'add') return <> - - const handleCancel = () => { - show.value = undefined - } - - return ( -
-
- -
-
- -
-
-
Add Token
-
Enter the token's contract address to retrieve details
- -
-
-
- ) -} diff --git a/app/ts/components/TransferPage/Validation.tsx b/app/ts/components/TransferPage/Validation.tsx deleted file mode 100644 index f2720445..00000000 --- a/app/ts/components/TransferPage/Validation.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Signal, useComputed } from '@preact/signals' -import { useAccount } from '../../store/account.js' -import { useAccountTokens } from '../../store/tokens.js' -import { TransferData } from '../../store/transfer.js' -import * as Icon from '../Icon/index.js' - -type Props = { - data: Signal -} - -export const TransferValidation = (props: Props) => { - const { address } = useAccount() - const { tokens } = useAccountTokens() - - const isRecipientAToken = useComputed(() => tokens.value.some(token => token.address.toLowerCase() === props.data.value.recipientAddress.toLowerCase())) - - const isRecipientOwn = useComputed(() => { - if (address.value.state !== 'resolved') return false - return address.value.value.toLowerCase() === props.data.value.recipientAddress.toLowerCase() - }) - - if (isRecipientAToken.value) - return ( -
- -
-
- Warning: Recipient is a Token address -
-
The recipient address provided is a token contract address and will probably result in a loss of funds.
- - -
-
- ) - - if (isRecipientOwn.value) { - return ( -
- -
-
- Warning: The recipient is the same as the sender -
-
This transactions sends funds to itself and will almost certainly result wasting money on transaction fees.
- -
-
- ) - } - return <> -} diff --git a/app/ts/components/TransferPage/index.tsx b/app/ts/components/TransferPage/index.tsx index c7129e41..f3acebe7 100644 --- a/app/ts/components/TransferPage/index.tsx +++ b/app/ts/components/TransferPage/index.tsx @@ -1,105 +1,71 @@ -import { Signal, useComputed, useSignal, useSignalEffect } from '@preact/signals' -import { TokenMeta } from '../../store/tokens.js' +import { useSignalEffect } from '@preact/signals' import { Header, HeaderNav, Main, Navigation, Root, usePanels } from '../DefaultLayout/index.js' -import { TokenManager } from '../TokenManager/index.js' import { ConnectAccount } from '../ConnectAccount.js' -import { AmountField } from '../AmountField.js' -import { AddressField } from '../AddressField.js' -import { useTransfer } from '../../store/transfer.js' -import { removeNonStringsAndTrim } from '../../library/utilities.js' -import { useAccount } from '../../store/account.js' -import { AsyncProperty } from '../../library/preact-utilities.js' -import { TransactionResponse } from 'ethers' -import { RecentTransfers } from '../RecentTransfers.js' +import { TransferHistory } from '../TransferHistory.js' import { DiscordInvite } from '../DiscordInvite.js' -import { AddTokenDialog } from './AddTokenDialog.js' -import { TransferValidation } from './Validation.js' import { Favorites } from '../Favorites.js' -import { useRouter } from '../HashRouter.js' -import { useFavorites } from '../../store/favorites.js' import { MainFooter } from '../MainFooter.js' import { useEffect } from 'preact/hooks' +import { TransferProvider, useTransfer } from '../../context/Transfer.js' +import { TransferHistoryProvider } from '../../context/TransferHistory.js' +import { SetupTransfer } from '../SetupTransfer.js' +import { TokenManagerProvider } from '../../context/TokenManager.js' +import { useWallet } from '../../context/Wallet.js' +import { useAsyncState } from '../../library/preact-utilities.js' +import { EthereumAddress } from '../../schema.js' +import { useAccount } from '../../context/Account.js' const SCROLL_OPTIONS = { inline: 'start', behavior: 'smooth' } as const export const TransferPage = () => { - const { attemptToConnect } = useAccount() - const router = useRouter<{ index: string }>() - const transferStore = useTransfer() - const { favorites } = useFavorites() - - const transferFormData = useComputed(() => { - if (router.value.params.index === undefined) return - return favorites.value[parseInt(router.value.params.index)] - }) + const { browserProvider } = useWallet() + const { account } = useAccount() + const { value: query, waitFor } = useAsyncState() + + const attemptToConnect = () => { + if (browserProvider === undefined) return + waitFor(async () => { + const [signer] = await browserProvider.listAccounts() + return EthereumAddress.parse(signer.address) + }) + } - useSignalEffect(() => { - if (transferFormData.value === undefined) return - transferStore.data.value = transferFormData.value - }) + const listenForQueryChanges = () => { + // do not reset shared state for other instances of this hooks + if (query.value.state === 'inactive') return + account.value = query.value + } - useEffect(() => { - attemptToConnect() - }, []) + useSignalEffect(listenForQueryChanges) + useEffect(attemptToConnect, []) return (
- - + + + + + + + +
) } -type MainPanelProps = { - transferStore: ReturnType -} - -const MainPanel = ({ transferStore }: MainPanelProps) => { - const { transaction, data, send, clearData } = transferStore - const tokenManager = useSignal<'select' | 'add' | undefined>(undefined) +const MainPanel = () => { + const { transaction } = useTransfer() const { nav, main } = usePanels() - const { address, connect } = useAccount() - const setUserSelectedToken = (token?: TokenMeta) => { - data.value = { ...data.value, token, amount: '' } - tokenManager.value = undefined + const redirectOnSuccess = (path: string) => { + window.location.hash = path } - const handleAddressChange = (address?: string) => { - const recipientAddress = address ? address.trim() : '' - data.value = { ...data.value, recipientAddress } - } - - const handleSubmit = (e: Event) => { - e.preventDefault() - send() - } - - const handleSuccess = (transactionResponse: TransactionResponse) => { - clearData() - window.location.hash = `#tx/${transactionResponse.hash}` - } - - const selectTokenAndCloseManager = (token: TokenMeta) => { - data.value = { ...data.value, token } - tokenManager.value = undefined - } - - const showAddTokenDialogOrConnect = () => { - if (address.value.state !== 'resolved') { - connect() - return - } - tokenManager.value = 'add' - } - - const isFormSubmitting = useComputed(() => transaction.value.state === 'pending') - useSignalEffect(() => { if (transaction.value.state !== 'resolved') return - handleSuccess(transaction.value.value) + redirectOnSuccess(`#tx/${transaction.value.value.hash}`) }) return ( @@ -119,21 +85,8 @@ const MainPanel = ({ transferStore }: MainPanelProps) => {
-
-
-
- (tokenManager.value = 'select')} disabled={isFormSubmitting.value} /> - (data.value = { ...data.value, amount: value })} onClear={() => (data.value = { ...data.value, amount: '' })} disabled={isFormSubmitting.value} token={data.value.token} /> -
- - - - -
-
+
- (tokenManager.value = undefined)} onSelect={setUserSelectedToken} onAddToken={showAddTokenDialogOrConnect} /> - ) @@ -171,7 +124,7 @@ const LeftPanel = () => {
- + @@ -180,100 +133,6 @@ const LeftPanel = () => { ) } -type TokenFieldProps = { - token?: TokenMeta - onClick: () => void - disabled?: boolean -} - -const TokenField = (props: TokenFieldProps) => { - const handleChange = (e: KeyboardEvent) => { - const shoudSkipKey = ['Tab', 'Meta', 'Alt', 'Shift', 'Escape'].includes(e.key) - if (shoudSkipKey) return - e.preventDefault() - props.onClick() - } - - return ( -
-
-
-
Asset
- -
-
- - - -
-
-
- ) -} - -const SubmitButton = (props: { disabled?: boolean }) => { - const { address, connect } = useAccount() - - switch (address.value.state) { - case 'inactive': - case 'rejected': - return ( - - ) - case 'resolved': - return ( - - ) - case 'pending': - return ( - - ) - } -} - -const TransferStatus = ({ transaction }: { transaction: Signal> }) => { - switch (transaction.value.state) { - case 'inactive': - return <> - case 'pending': - return ( -
- - - - - - -
Confirming transaction...
-
- ) - case 'resolved': - return <> - case 'rejected': - return ( -
-
Failed to complete transfer
-
Error:
-
{transaction.value.error.message}
-
- ) - } -} - const MenuIcon = () => ( diff --git a/app/ts/components/TransferRecorder.tsx b/app/ts/components/TransferRecorder.tsx new file mode 100644 index 00000000..d68f5638 --- /dev/null +++ b/app/ts/components/TransferRecorder.tsx @@ -0,0 +1,39 @@ +import { useComputed, useSignalEffect } from '@preact/signals' +import { useTransferHistory } from '../context/TransferHistory.js' +import { useTransfer } from '../context/Transfer.js' +import { EthereumAddress, Transfer } from '../schema.js' + +export const TransferRecorder = () => { + const history = useTransferHistory() + const { safeParse, transaction } = useTransfer() + + const addTransferToHistory = (transfer: Transfer) => { + history.value = { ...history.peek(), data: history.peek().data.concat([transfer]) } + } + + const successfulTransfer = useComputed(() => { + if (transaction.value.state !== 'resolved') return + if (!safeParse.value.success) return + + const inputs = safeParse.value.value + const currentTransaction = transaction.value.value + + return { + from: EthereumAddress.parse(currentTransaction.from), + to: inputs.to, + amount: inputs.amount, + date: Date.now(), + hash: currentTransaction.hash, + token: inputs.token, + } + }) + + const listenAndRecordTransfers = () => { + if (successfulTransfer.value === undefined) return + addTransferToHistory(successfulTransfer.value) + } + + useSignalEffect(listenAndRecordTransfers) + + return <> +} diff --git a/app/ts/components/TransferTokenField.tsx b/app/ts/components/TransferTokenField.tsx new file mode 100644 index 00000000..a1c6b064 --- /dev/null +++ b/app/ts/components/TransferTokenField.tsx @@ -0,0 +1,57 @@ +import { useTransfer } from '../context/Transfer.js' +import { useTokenManager } from '../context/TokenManager.js' +import { removeNonStringsAndTrim } from '../library/utilities.js' +import { useComputed, useSignalEffect } from '@preact/signals' +import { useSignalRef } from '../library/preact-utilities.js' + +export const TransferTokenSelectField = () => { + const { ref, signal: buttonRef } = useSignalRef(null) + const { isBusy } = useTransfer() + const { stage } = useTokenManager() + const { input } = useTransfer() + + const activateOnKeypress = (e: KeyboardEvent) => { + switch (e.key) { + case '': + case 'Enter': + case 'ArrowUp': + case 'ArrowDown': + e.preventDefault() + stage.value = 'select' + break + default: + return + } + } + + const selectedAsset = useComputed(() => input.value.token) + + const focusButtonOnClearStage = () => { + if (!buttonRef.value) return + if (document.activeElement === buttonRef.value) return + if (stage.value === undefined) buttonRef.value.focus() + } + + useSignalEffect(focusButtonOnClearStage) + + return ( +
+
+
Asset
+
{selectedAsset.value?.name || 'Ether'}
+
+
+
{selectedAsset.value?.symbol || 'ETH'}
+ +
+
+ + ) +} + +const SwitchIcon = () => ( + + + +) diff --git a/app/ts/context/Account.tsx b/app/ts/context/Account.tsx new file mode 100644 index 00000000..5f46f6e4 --- /dev/null +++ b/app/ts/context/Account.tsx @@ -0,0 +1,56 @@ +import { Signal, useSignal, useSignalEffect } from '@preact/signals' +import { ComponentChildren, createContext } from 'preact' +import { useContext } from 'preact/hooks' +import { DEFAULT_TOKENS, SETTINGS_CACHE_KEY } from '../library/constants.js' +import { assertsEthereumObservable, assertsWithEthereum } from '../library/ethereum.js' +import { persistSignalEffect } from '../library/persistent-signal.js' +import { AsyncProperty } from '../library/preact-utilities.js' +import { SettingsCacheSchema, createCacheParser, SettingsCache, EthereumAddress } from '../schema.js' + +export type AccountContext = { + settings: Signal + account: Signal> +} + +export const AccountContext = createContext(undefined) +export const AccountProvider = ({ children }: { children: ComponentChildren }) => { + const settings = useSignal({ data: [], version: '1.0.0' }) + const account = useSignal>({ state: 'inactive' }) + + const updateAsyncAccount = ([newAddress]: (string | undefined)[]) => { + account.value = newAddress ? { state: 'resolved', value: EthereumAddress.parse(newAddress) } : { state: 'inactive' } + } + + const initializeSettings = () => { + if (account.value.state !== 'resolved') return + const accountAddress = account.value.value + const accountSettings = settings.value.data.find(setting => setting.address === accountAddress) + + if (accountSettings && accountSettings.tokens.length) return + const newSettings = { address: accountAddress, tokens: DEFAULT_TOKENS.map(token => token.address) } + settings.value = Object.assign({}, settings.peek(), { data: settings.peek().data.concat([newSettings]) }) + } + + const listenToWalletsAccountChange = () => { + assertsWithEthereum(window) + assertsEthereumObservable(window.ethereum) + window.ethereum.on('accountsChanged', updateAsyncAccount) + } + + const listenToAccountChange = () => { + if (account.value.state !== 'resolved') return + listenToWalletsAccountChange() + } + + useSignalEffect(listenToAccountChange) + useSignalEffect(initializeSettings) + persistSignalEffect(SETTINGS_CACHE_KEY, settings, createCacheParser(SettingsCacheSchema)) + + return {children} +} + +export function useAccount() { + const context = useContext(AccountContext) + if (!context) throw new Error('useSettings can only be used within children of SettingsProvider') + return context +} diff --git a/app/ts/context/TokenManager.tsx b/app/ts/context/TokenManager.tsx new file mode 100644 index 00000000..0db9bf8a --- /dev/null +++ b/app/ts/context/TokenManager.tsx @@ -0,0 +1,48 @@ +import { Signal, useSignal } from '@preact/signals' +import { ComponentChildren, createContext } from 'preact' +import { useContext } from 'preact/hooks' +import { Contract } from 'ethers' +import { createCacheParser, TokensCache, TokensCacheSchema } from '../schema.js' +import { DEFAULT_TOKENS, MANAGED_TOKENS_CACHE_KEY } from '../library/constants.js' +import { persistSignalEffect } from '../library/persistent-signal.js' +import { useAsyncState } from '../library/preact-utilities.js' +import { ERC20ABI } from '../library/ERC20ABI.js' +import { useWallet } from './Wallet.js' + +export type TokenManagerContext = { + cache: Signal + query: Signal + stage: Signal<'select' | 'add' | undefined> +} + +export const TokenManagerContext = createContext(undefined) + +export const TokenManagerProvider = ({ children }: { children: ComponentChildren }) => { + const query = useSignal('') + const stage = useSignal(undefined) + const cache = useSignal({ data: DEFAULT_TOKENS, version: '1.0.0' }) + + persistSignalEffect(MANAGED_TOKENS_CACHE_KEY, cache, createCacheParser(TokensCacheSchema)) + + return {children} +} + +export function useTokenManager() { + const context = useContext(TokenManagerContext) + if (context === undefined) throw new Error('useTokenManager can only be used within children of TokenManagerProvider') + return context +} + +export function useTokenBalance() { + const { browserProvider } = useWallet() + const { value: tokenBalance, waitFor } = useAsyncState() + + const getTokenBalance = (accountAddress: string, tokenAddress: string) => { + waitFor(async () => { + const contract = new Contract(tokenAddress, ERC20ABI, browserProvider) + return await contract.balanceOf(accountAddress) + }) + } + + return { tokenBalance, getTokenBalance } +} diff --git a/app/ts/context/Transfer.tsx b/app/ts/context/Transfer.tsx new file mode 100644 index 00000000..53134209 --- /dev/null +++ b/app/ts/context/Transfer.tsx @@ -0,0 +1,50 @@ +import { Signal, ReadonlySignal, useComputed, useSignal } from '@preact/signals' +import { TransactionResponse } from 'ethers' +import * as funtypes from 'funtypes' +import { ComponentChildren, createContext } from 'preact' +import { useContext } from 'preact/hooks' +import { AsyncProperty } from '../library/preact-utilities.js' +import { createUnitParser, safeSerialize, ERC20Token, TransferRequestInput } from '../schema.js' + +type PartialInput = { to: string; amount: string; token: ERC20Token | undefined } + +type TransferContext = { + input: Signal + safeParse: ReadonlySignal> + transaction: Signal> + isBusy: Signal +} + +export const TransferContext = createContext(undefined) + +export const TransferProvider = ({ children }: { children: ComponentChildren }) => { + const transaction = useSignal>({ state: 'inactive' }) + const input = useSignal({ to: '', amount: '', token: undefined }) + const isBusy = useSignal(false) + + const parsedAmount = useComputed(() => { + const HexUnit = funtypes.String.withParser(createUnitParser(input.value.token?.decimals)) + return HexUnit.safeParse(input.value.amount) + }) + + const serializedToken = useComputed(() => { + if (input.value.token === undefined) return { success: true, value: undefined } satisfies funtypes.Success + return safeSerialize(ERC20Token, input.value.token) + }) + + const safeParse = useComputed(() => { + if (!parsedAmount.value.success) return { ...parsedAmount.value, key: 'amount' } + if (!serializedToken.value.success) return { ...serializedToken.value, key: 'token' } + const amount = parsedAmount.value.value + const token = serializedToken.value.value + return TransferRequestInput.safeParse({ ...input.value, amount, token }) + }) + + return {children} +} + +export function useTransfer() { + const context = useContext(TransferContext) + if (!context) throw new Error('useTransfer can only be used within children of TransferProvider') + return context +} diff --git a/app/ts/context/TransferHistory.tsx b/app/ts/context/TransferHistory.tsx new file mode 100644 index 00000000..35c29197 --- /dev/null +++ b/app/ts/context/TransferHistory.tsx @@ -0,0 +1,32 @@ +import { Signal, useSignal } from '@preact/signals' +import * as funtypes from 'funtypes' +import { ComponentChildren, createContext } from 'preact' +import { useContext } from 'preact/hooks' +import { RECENT_TRANSFERS_CACHE_KEY } from '../library/constants.js' +import { persistSignalEffect } from '../library/persistent-signal.js' +import { createCacheParser, TransferSchema } from '../schema.js' + +export const TransferHistoryCacheSchema = funtypes.Union( + funtypes.Object({ + data: funtypes.Array(TransferSchema), + version: funtypes.Literal('1.0.0'), + }), +) + +export type TransferHistory = funtypes.Static + +const TransferHistoryContext = createContext | undefined>(undefined) + +export const TransferHistoryProvider = ({ children }: { children: ComponentChildren }) => { + const transfers = useSignal({ data: [], version: '1.0.0' }) + + persistSignalEffect(RECENT_TRANSFERS_CACHE_KEY, transfers, createCacheParser(TransferHistoryCacheSchema)) + + return {children} +} + +export function useTransferHistory() { + const context = useContext(TransferHistoryContext) + if (context === undefined) throw new Error('useTransferHistory can only be used within children of TransferHistoryProvider') + return context +} diff --git a/app/ts/context/Wallet.tsx b/app/ts/context/Wallet.tsx new file mode 100644 index 00000000..6fc115f0 --- /dev/null +++ b/app/ts/context/Wallet.tsx @@ -0,0 +1,53 @@ +import { Signal, useSignal, useSignalEffect } from '@preact/signals' +import { BrowserProvider, Network } from 'ethers' +import { ComponentChildren, createContext } from 'preact' +import { useContext, useEffect } from 'preact/hooks' +import { assertsEthereumObservable, assertsWithEthereum } from '../library/ethereum.js' +import { AsyncProperty } from '../library/preact-utilities.js' +import { BigIntHex, HexString } from '../schema.js' + +type WalletContext = { + browserProvider: BrowserProvider | undefined + network: Signal> +} + +export const WalletContext = createContext(undefined) +export const WalletProvider = ({ children }: { children: ComponentChildren }) => { + const provider = useSignal(undefined) + const network = useSignal>({ state: 'inactive' }) + + const updateBrowserProvider = async (chainIdHex?: HexString) => { + assertsWithEthereum(window) + const chainId = chainIdHex ? BigIntHex.parse(chainIdHex) : undefined + provider.value = new BrowserProvider(window.ethereum, chainId) + const newNetwork = await provider.value.getNetwork() + network.value = { state: 'resolved', value: newNetwork } + } + + const listenToWalletsChainChange = () => { + assertsWithEthereum(window) + assertsEthereumObservable(window.ethereum) + window.ethereum.on('chainChanged', updateBrowserProvider) + } + + const listenToBrowserProviderChange = () => { + if (provider.value === undefined) return + listenToWalletsChainChange() + } + + const context = { + browserProvider: provider.value, + network, + } + + useSignalEffect(listenToBrowserProviderChange) + useEffect(() => void updateBrowserProvider(), []) + + return {children} +} + +export function useWallet() { + const context = useContext(WalletContext) + if (context === undefined) throw new Error('useWallet can only be used within children of WalletProvider') + return context +} diff --git a/app/ts/library/ERC20ABI.ts b/app/ts/library/ERC20ABI.ts index 7a8521dc..c74b3de4 100644 --- a/app/ts/library/ERC20ABI.ts +++ b/app/ts/library/ERC20ABI.ts @@ -1,11 +1 @@ -export const ERC20ABI = [ - 'function name() view returns (string)', - 'function symbol() view returns (string)', - 'function decimals() view returns (uint8)', - 'function balanceOf(address account) view returns (uint256)', - 'function transfer(address recipient, uint256 amount) returns (bool)', - 'function approve(address spender, uint256 amount) returns (bool)', - 'function allowance(address owner, address spender) view returns (uint256)', - 'event Transfer(address indexed from, address indexed to, uint256 value)', - 'event Approval(address indexed owner, address indexed spender, uint256 value)' -] +export const ERC20ABI = ['function name() view returns (string)', 'function symbol() view returns (string)', 'function decimals() view returns (uint8)', 'function balanceOf(address account) view returns (uint256)', 'function transfer(address recipient, uint256 amount) returns (bool)', 'function approve(address spender, uint256 amount) returns (bool)', 'function allowance(address owner, address spender) view returns (uint256)', 'event Transfer(address indexed from, address indexed to, uint256 value)', 'event Approval(address indexed owner, address indexed spender, uint256 value)'] diff --git a/app/ts/library/constants.ts b/app/ts/library/constants.ts index 86816c92..dd50f009 100644 --- a/app/ts/library/constants.ts +++ b/app/ts/library/constants.ts @@ -1,6 +1,6 @@ -import { TokenMeta } from '../store/tokens' +import { ERC20Token } from '../schema' -export const DEFAULT_TOKENS: TokenMeta[] = [ +export const DEFAULT_TOKENS: ERC20Token[] = [ { chainId: 1n, address: '0x4c9BBFc1FbD93dFB509E718400978fbEedf590E9', @@ -53,5 +53,6 @@ export const DEFAULT_TOKENS: TokenMeta[] = [ ] export const STORAGE_KEY_RECENTS = 'txns' -export const MANAGED_TOKENS_CACHE_KEY = 'managed_tokens-v1' +export const MANAGED_TOKENS_CACHE_KEY = 'managed_tokens' +export const SETTINGS_CACHE_KEY = 'settings' export const RECENT_TRANSFERS_CACHE_KEY = 'transfers' diff --git a/app/ts/library/persistent-signal.ts b/app/ts/library/persistent-signal.ts index 471cb784..60119362 100644 --- a/app/ts/library/persistent-signal.ts +++ b/app/ts/library/persistent-signal.ts @@ -1,9 +1,9 @@ import { Signal, useSignal, useSignalEffect } from '@preact/signals' import * as funtypes from 'funtypes' -import { ParsedValueConfig } from 'funtypes/lib/types/ParsedValue' import { useEffect } from 'preact/hooks' +import { safeSerialize } from '../schema.js' -export function persistSignalEffect, R>(cacheKey: string, derivedSignal: Signal, funTypeParser: T, storage?: Storage) { +export function persistSignalEffect['config'], R>(cacheKey: string, derivedSignal: Signal, funTypeParser: T, storage?: Storage) { const cacheStorage = storage ?? localStorage const error = useSignal(undefined) @@ -17,19 +17,13 @@ export function persistSignalEffect { - const serializedStore = funtypes.String.withParser(funTypeParser).safeSerialize(derivedSignal.value) + const serializedStore = safeSerialize(funtypes.String.withParser(funTypeParser), derivedSignal.value) if (!serializedStore.success) { error.value = serializedStore.message return } - const stringCache = funtypes.String.safeParse(serializedStore.value) - if (!stringCache.success) { - error.value = stringCache.message - return - } - - cacheStorage.setItem(cacheKey, stringCache.value) + cacheStorage.setItem(cacheKey, serializedStore.value) error.value = undefined } diff --git a/app/ts/library/preact-utilities.ts b/app/ts/library/preact-utilities.ts index 84e38e78..c4c02c1d 100644 --- a/app/ts/library/preact-utilities.ts +++ b/app/ts/library/preact-utilities.ts @@ -1,5 +1,5 @@ import { Signal, useSignal } from '@preact/signals' -import { useEffect, useRef } from 'preact/hooks'; +import { useEffect, useRef } from 'preact/hooks' export type Inactive = { state: 'inactive' } export type Pending = { state: 'pending' } export type Resolved = { state: 'resolved'; value: T } @@ -49,7 +49,6 @@ export function useAsyncState(): AsyncState { return { value: result, waitFor: resolver => activate(resolver), reset } } - // Creates a signal-observable state of an element ref // enables listening to ref change from within a signal effect such as useSignalEffect and useComputed export function useSignalRef(value: T) { diff --git a/app/ts/library/utilities.ts b/app/ts/library/utilities.ts index 030d6049..40297f74 100644 --- a/app/ts/library/utilities.ts +++ b/app/ts/library/utilities.ts @@ -1,3 +1,5 @@ +import { JSX } from 'preact/jsx-runtime' + export async function sleep(milliseconds: number) { await new Promise(resolve => setTimeout(resolve, milliseconds)) } @@ -43,3 +45,16 @@ export function JSONParse(jsonString: string) { }) } +/** + * + * Checks if a search string can be found within the source string + * + */ +export function stringIncludes(source: string, search: string, caseSensitive?: boolean) { + if (caseSensitive) return source.includes(search) + return source.toLowerCase().includes(search.toLowerCase()) +} + +export function preventFocus(e: JSX.TargetedEvent) { + e.currentTarget.blur() +} diff --git a/app/ts/schema.ts b/app/ts/schema.ts index bc60f8fa..dd4e61cf 100644 --- a/app/ts/schema.ts +++ b/app/ts/schema.ts @@ -1,9 +1,8 @@ -import { isHexString, isAddress } from 'ethers' +import { getAddress, isAddress, isHexString, parseUnits } from 'ethers' import * as funtypes from 'funtypes' -import { ParsedValueConfig } from 'funtypes/lib/types/ParsedValue' export function createCacheParser(funType: funtypes.Codec) { - const config: ParsedValueConfig = { + const config: funtypes.ParsedValue['config'] = { parse(value) { const jsonParsed = JSON.parse(value) return funType.safeParse(jsonParsed) @@ -18,12 +17,9 @@ export function createCacheParser(funType: funtypes.Codec) { return config } -export const AddressSchema = funtypes.String.withGuard(isHexString).withConstraint(isAddress, { name: 'Address' }) -export type Address = funtypes.Static - -export const BigIntParser: ParsedValueConfig = { +export const BigIntParser: funtypes.ParsedValue['config'] = { parse(value) { - if (!isHexString(value)) return { success: false, message: `${value} is not a hex string encoded number.` } + if (!/^0x([a-fA-F0-9]{1,64})$/.test(value)) return { success: false, message: `${value} is not a hex string encoded number.` } return { success: true, value: BigInt(value) } }, serialize(value) { @@ -32,25 +28,124 @@ export const BigIntParser: ParsedValueConfig = { }, } -export const BigIntHexSchema = funtypes.String.withParser(BigIntParser) +export const BigIntHex = funtypes.String.withParser(BigIntParser) + +export const HexString = funtypes.String.withGuard(isHexString) +export type HexString = funtypes.Static + +export function createUnitParser(decimals?: bigint): funtypes.ParsedValue['config'] { + return { + parse(value) { + try { + const bigIntAmount = parseUnits(value, decimals) + const maybeHexAmount = BigIntHex.serialize(bigIntAmount) + return HexString.safeParse(maybeHexAmount) + } catch { + return { success: false, message: `${value} is not a number.` } + } + }, + serialize: funtypes.String.safeParse, + } +} -export const TokenSchema = funtypes.Object({ - chainId: BigIntHexSchema, +export const AddressParser: funtypes.ParsedValue['config'] = { + parse: value => { + const addressString = value.toLowerCase() + if (!isAddress(addressString)) return { success: false, message: `${value} is not a valid address string.` } + else return { success: true, value: getAddress(addressString) } + }, + serialize: funtypes.String.safeParse, +} + +export const EthereumAddress = funtypes.String.withParser(AddressParser).withGuard(isHexString) +export type EthereumAddress = funtypes.Static + +export const ERC20Token = funtypes.Object({ + chainId: BigIntHex, name: funtypes.String, - address: funtypes.String, + address: EthereumAddress, symbol: funtypes.String, - decimals: BigIntHexSchema, + decimals: BigIntHex, }) -export type Token = funtypes.Static +export type ERC20Token = funtypes.Static export const TransferSchema = funtypes.Object({ hash: funtypes.String, - from: AddressSchema, - to: AddressSchema, - amount: funtypes.String, - token: funtypes.Union(TokenSchema, funtypes.Undefined), + from: EthereumAddress, + to: EthereumAddress, + amount: BigIntHex, + token: ERC20Token.Or(funtypes.Undefined), date: funtypes.Number, }) export type Transfer = funtypes.Static + +export const TransferRequestInput = funtypes.Object({ + to: EthereumAddress, + amount: BigIntHex, + token: ERC20Token.Or(funtypes.Undefined), +}) + +export type TransferRequestInput = funtypes.Static + +export const TokensCacheSchema = funtypes.Union( + funtypes.Object({ + data: funtypes.Array(ERC20Token), + version: funtypes.Literal('1.0.0'), + }) +) + +export type TokensCache = funtypes.Static + +const AccountSettings = funtypes.Object({ + address: EthereumAddress, + tokens: funtypes.Array(EthereumAddress), +}) + +export const SettingsCacheSchema = funtypes.Union( + funtypes.Object({ + data: funtypes.Array(AccountSettings), + version: funtypes.Literal('1.0.0'), + }) +) + +export type SettingsCache = funtypes.Static + +export function serialize>(funType: U, value: T) { + return funType.serialize(value) as ToWireType +} + +export function safeSerialize>(funType: U, value: T) { + return funType.safeSerialize(value) as funtypes.Result> +} + +export type UnionToIntersection = (T extends unknown ? (k: T) => void : never) extends (k: infer I) => void ? I : never + +export type ToWireType = T extends funtypes.Intersect + ? UnionToIntersection<{ [I in keyof U]: ToWireType }[number]> + : T extends funtypes.Union + ? { [I in keyof U]: ToWireType }[number] + : T extends funtypes.Record + ? Record, ToWireType> + : T extends funtypes.Partial + ? V extends true + ? { readonly [K in keyof U]?: ToWireType } + : { [K in keyof U]?: ToWireType } + : T extends funtypes.Object + ? V extends true + ? { readonly [K in keyof U]: ToWireType } + : { [K in keyof U]: ToWireType } + : T extends funtypes.Readonly> + ? { readonly [P in keyof U]: ToWireType } + : T extends funtypes.Tuple + ? { [P in keyof U]: ToWireType } + : T extends funtypes.ReadonlyArray + ? readonly ToWireType[] + : T extends funtypes.Array + ? ToWireType[] + : T extends funtypes.ParsedValue + ? ToWireType + : T extends funtypes.Codec + ? U + : never diff --git a/app/ts/store/account.ts b/app/ts/store/account.ts index b9c57bf2..513d81cd 100644 --- a/app/ts/store/account.ts +++ b/app/ts/store/account.ts @@ -4,21 +4,20 @@ import { ApplicationError } from './errors.js' import { useNotice } from './notice.js' import { useProviders } from './provider.js' import { effect, signal, useSignalEffect } from '@preact/signals' -import { Address, AddressSchema } from '../schema.js' -import { getAddress } from 'ethers' +import { EthereumAddress } from '../schema.js' -const address = signal>({ state: 'inactive' }) +const address = signal>({ state: 'inactive' }) export function useAccount() { const { notify } = useNotice() const provider = useProviders() - const { value: query, waitFor } = useAsyncState
() + const { value: query, waitFor } = useAsyncState() const connect = () => { waitFor(async () => { try { const signer = await provider.browserProvider.getSigner() - return AddressSchema.parse(getAddress(signer.address)) + return EthereumAddress.parse(signer.address) } catch (error) { let errorMessage = 'An unknown error occurred.' if (error instanceof ApplicationError) errorMessage = error.message @@ -31,7 +30,7 @@ export function useAccount() { const attemptToConnect = () => { waitFor(async () => { const [signer] = await provider.browserProvider.listAccounts() - return AddressSchema.parse(getAddress(signer.address)) + return EthereumAddress.parse(signer.address) }) } @@ -53,7 +52,7 @@ const handleAccountChanged = ([newAddress]: string[]) => { address.value = { state: 'inactive' } return } - address.value = { ...address.value, value: AddressSchema.parse(getAddress(newAddress)) } + address.value = { ...address.value, value: EthereumAddress.parse(newAddress) } } const removeAccountChangedListener = effect(() => { diff --git a/app/ts/store/errors.ts b/app/ts/store/errors.ts index 732dab21..1c97f653 100644 --- a/app/ts/store/errors.ts +++ b/app/ts/store/errors.ts @@ -37,6 +37,6 @@ export class ApplicationError extends Error { // TODO: create a map of possible errors export const ErrorsDictionary = { - 'UNKNOWN': 'An unknown error has occurred.', - 'WALLET_MISSING': 'No web 3 compatible wallet detected.' + UNKNOWN: 'An unknown error has occurred.', + WALLET_MISSING: 'No web 3 compatible wallet detected.', } diff --git a/app/ts/store/favorites.ts b/app/ts/store/favorites.ts index 84d249ef..3485f05f 100644 --- a/app/ts/store/favorites.ts +++ b/app/ts/store/favorites.ts @@ -1,13 +1,13 @@ import { effect, signal } from '@preact/signals' import { useEffect } from 'preact/hooks' import { JSONStringify } from '../library/utilities.js' -import { TokenMeta } from './tokens.js' +import { ERC20Token } from '../schema.js' export type FavoriteModel = { label?: string source: string recipientAddress: string - token?: TokenMeta + token?: ERC20Token amount: string } diff --git a/app/ts/store/notice.ts b/app/ts/store/notice.ts index 3078f7a5..de35b162 100644 --- a/app/ts/store/notice.ts +++ b/app/ts/store/notice.ts @@ -7,7 +7,7 @@ export type Notice = { } const notices = signal([]) -const nextId = computed(() => notices.value.length+ 1) +const nextId = computed(() => notices.value.length + 1) export function useNotice() { const notify = (notice: Omit) => { diff --git a/app/ts/store/tokens.ts b/app/ts/store/tokens.ts index f3038a1d..612960d5 100644 --- a/app/ts/store/tokens.ts +++ b/app/ts/store/tokens.ts @@ -1,60 +1,16 @@ -import { signal, useComputed, useSignal, useSignalEffect } from '@preact/signals' +import { signal, useSignal, useSignalEffect } from '@preact/signals' import * as funtypes from 'funtypes' -import { useAccount } from './account.js' import { useAsyncState } from '../library/preact-utilities.js' import { useProviders } from './provider.js' import { useNetwork } from './network.js' import { Contract, isAddress } from 'ethers' import { ERC20ABI } from '../library/ERC20ABI.js' -import { JSONParse, JSONStringify } from '../library/utilities.js' import { DEFAULT_TOKENS, MANAGED_TOKENS_CACHE_KEY } from '../library/constants.js' import { persistSignalEffect } from '../library/persistent-signal.js' -import { createCacheParser, Token, TokenSchema } from '../schema.js' - -const CACHEID_PREFIX = '_ut' - -const tokens = signal([]) - -export function useAccountTokens() { - const { address } = useAccount() - - const cacheKey = useComputed(() => { - if (address.value.state !== 'resolved') return `${CACHEID_PREFIX}:default` - return `${CACHEID_PREFIX}:${address.value.value}` - }) - - const addToken = (token: TokenMeta) => { - tokens.value = [...tokens.value, token] - } - - const removeToken = (address: TokenMeta['address']) => { - tokens.value = [...tokens.value.filter(token => token.address !== address)] - } - - const listenForCacheKeyChange = () => { - const tokensCache = useTokensCache(cacheKey.value) - if (tokensCache.error !== undefined) throw new Error('Cache data could not be read.') - tokens.value = tokensCache.data - } - - const listenForTokensChange = () => { - if (cacheKey.value === `${CACHEID_PREFIX}:default`) return - const newCache = JSONStringify(tokens.value) - localStorage.setItem(cacheKey.value, newCache) - } - - useSignalEffect(listenForCacheKeyChange) - useSignalEffect(listenForTokensChange) - - return { - tokens, - addToken, - removeToken, - } -} +import { createCacheParser, EthereumAddress, ERC20Token } from '../schema.js' export function useTokenQuery() { - const { value: query, waitFor, reset } = useAsyncState() + const { value: query, waitFor, reset } = useAsyncState() const providers = useProviders() const { network } = useNetwork() const tokenAddress = useSignal('') @@ -77,7 +33,8 @@ export function useTokenQuery() { const name = await contract.name() const symbol = await contract.symbol() const decimals = await contract.decimals() - return { chainId, name, symbol, decimals, address: tokenAddress.value } as const + const address = EthereumAddress.parse(tokenAddress.value) + return { chainId, name, symbol, decimals, address } as const } catch (unknownError) { throw new Error('Contract call failed') } @@ -89,45 +46,6 @@ export function useTokenQuery() { return { query, tokenAddress } } -// Move to constants later -export type TokenMeta = { - chainId: bigint - name: string - address: string - symbol: string - decimals: bigint -} - -function isTokenMeta(meta: object): meta is TokenMeta { - return 'chainId' in meta && typeof meta.chainId === 'bigint' && 'address' in meta && typeof meta.address === 'string' && 'name' in meta && typeof meta.name === 'string' && 'symbol' in meta && typeof meta.symbol === 'string' && 'decimals' in meta && typeof meta.decimals === 'bigint' -} - -export function useTokensCache(cacheKey: string) { - const tokensCache = localStorage.getItem(cacheKey) - - if (tokensCache === null) { - return { data: DEFAULT_TOKENS } - } - - try { - let tokens = [] - const parsed = JSONParse(tokensCache) - if (!Array.isArray(parsed)) throw new Error() - - for (const item of parsed) { - if (!isTokenMeta(item)) continue - tokens.push(item) - } - - return { data: tokens } - } catch (unknownError) { - let error = new Error('An unknown error has occured.') - if (unknownError instanceof Error) error = unknownError - if (typeof unknownError === 'string') error = new Error(unknownError) - return { error } - } -} - export function useTokenBalance() { const providers = useProviders() const { value: tokenBalance, waitFor } = useAsyncState() @@ -142,8 +60,8 @@ export function useTokenBalance() { return { tokenBalance, getTokenBalance } } -const ManagedTokensSchema = funtypes.Array(TokenSchema) -const managedTokens = signal(DEFAULT_TOKENS) +const ManagedTokensSchema = funtypes.Array(ERC20Token) +const managedTokens = signal(DEFAULT_TOKENS) const managedTokensCacheKey = signal(MANAGED_TOKENS_CACHE_KEY) export function useManagedTokens() { diff --git a/app/ts/store/transfer.ts b/app/ts/store/transfer.ts deleted file mode 100644 index 03ee27eb..00000000 --- a/app/ts/store/transfer.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { signal, useSignal, useSignalEffect } from '@preact/signals' -import * as funtypes from 'funtypes' -import { TransactionResponse, parseEther, Contract, parseUnits } from 'ethers' -import { ERC20ABI } from '../library/ERC20ABI.js' -import { AsyncProperty, useAsyncState } from '../library/preact-utilities.js' -import { useProviders } from './provider.js' -import { TokenMeta } from './tokens.js' -import { AddressSchema, createCacheParser, Transfer, TransferSchema } from '../schema.js' -import { RECENT_TRANSFERS_CACHE_KEY } from '../library/constants.js' -import { persistSignalEffect } from '../library/persistent-signal.js' - -export type TransferData = { - recipientAddress: string - amount: string - token?: TokenMeta -} - -const transferDataDefaults: TransferData = { recipientAddress: '', amount: '', token: undefined } - -export function useTransfer() { - const transfers = useTransfers() - const providers = useProviders() - const transaction = useSignal>({ state: 'inactive' }) - const data = useSignal(transferDataDefaults) - const { value: query, waitFor } = useAsyncState() - - const addToRecentTransfers = (transfer: Transfer) => { - transfers.value = { ...transfers.peek(), data: transfers.peek().data.concat([transfer]) } - } - - const send = () => { - waitFor(async () => { - const signer = await providers.browserProvider.getSigner() - const to = AddressSchema.parse(data.value.recipientAddress) - const from = AddressSchema.parse(signer.address) - - // Ether transfer - if (data.value.token === undefined) { - const value = parseEther(data.value.amount) - const response = await signer.sendTransaction({ to, value }) - const newTransfer = { from, to, hash: response.hash, date: Date.now(), amount: data.value.amount, token: undefined } - addToRecentTransfers(newTransfer) - return response - } - - // Token transfer - const tokenMetadata = data.value.token - const contract = new Contract(tokenMetadata.address, ERC20ABI, signer) - const value = parseUnits(data.value.amount, tokenMetadata.decimals) - const response = await contract.transfer(to, value) - const newTransfer = { from, to, hash: response.hash, date: Date.now(), amount: data.value.amount, token: tokenMetadata } - addToRecentTransfers(newTransfer) - return response - }) - } - - const clearData = () => { - data.value = transferDataDefaults - } - - const listenForQueryChanges = () => { - // do not reset shared state for other instances of this hooks - if (query.value.state === 'inactive') return - transaction.value = query.value - } - - useSignalEffect(listenForQueryChanges) - - return { transaction, data, send, clearData } -} - -const RecentTransfersCacheSchema = funtypes.Union( - funtypes.Object({ - data: funtypes.Array(TransferSchema), - version: funtypes.Literal('1.0.0') - }) -) - -type RecentTransfers = funtypes.Static -const recentTransfers = signal({ data: [], version: '1.0.0' }) - -export function useTransfers() { - persistSignalEffect(RECENT_TRANSFERS_CACHE_KEY, recentTransfers, createCacheParser(RecentTransfersCacheSchema)) - return recentTransfers -} diff --git a/twcss/tailwind.config.js b/twcss/tailwind.config.js index d21757f9..5a17cbf3 100644 --- a/twcss/tailwind.config.js +++ b/twcss/tailwind.config.js @@ -1,7 +1,16 @@ +const plugin = require('tailwindcss/plugin') + module.exports = { content: ['../app/ts/**/*.(ts|tsx)'], theme: {}, - plugins: [], + plugins: [ + plugin(function({ addVariant }) { + addVariant('enabled', '&:not(:disabled)') + addVariant('modified', '&:not([data-pristine])') + addVariant('group-modified',':merge(.group):not([data-pristine]) &') + addVariant('focus|hover', ['&:focus', '&:hover']) + }) + ], experimental: { optimizeUniversalDefaults: true, } diff --git a/twcss/tailwind.globals.css b/twcss/tailwind.globals.css index b67d573b..34857952 100644 --- a/twcss/tailwind.globals.css +++ b/twcss/tailwind.globals.css @@ -2,6 +2,14 @@ @tailwind components; @tailwind utilities; +@layer base { + input[type=text]:autofill, input[type=password]:autofill { + -webkit-background-clip: text; + -webkit-text-fill-color: #ffffff; + box-shadow: inset 0 0 20px 20px black; + } +} + @layer utilities { .scrollbar-hidden { -ms-overflow-style: none; @@ -70,10 +78,15 @@ background: linear-gradient(271.22deg, rgba(255, 255, 255, 0.1) 1.03%, rgba(255, 255, 255, 0) 100%); } - .text-empty:empty:before { content: attr(data-placeholder); @apply bg-white/20 text-transparent animate-pulse; } -} + .clear-none::-ms-clear { display: none; width : 0; height: 0; } + .clear-none::-ms-reveal { display: none; width : 0; height: 0; } + .clear-none::-webkit-search-decoration, + .clear-none::-webkit-search-cancel-button, + .clear-none::-webkit-search-results-button, + .clear-none::-webkit-search-results-decoration { display: none; } +}