diff --git a/src/lib/components/Swap/index.tsx b/src/lib/components/Swap/index.tsx index 9bc1237aed..f179647837 100644 --- a/src/lib/components/Swap/index.tsx +++ b/src/lib/components/Swap/index.tsx @@ -8,7 +8,7 @@ import useSyncSwapDefaults from 'lib/hooks/swap/useSyncSwapDefaults' import { usePendingTransactions } from 'lib/hooks/transactions' import useActiveWeb3React from 'lib/hooks/useActiveWeb3React' import useHasFocus from 'lib/hooks/useHasFocus' -import useTokenList from 'lib/hooks/useTokenList' +import useTokenList, { useSyncTokenList } from 'lib/hooks/useTokenList' import { displayTxHashAtom } from 'lib/state/swap' import { SwapTransactionInfo, Transaction, TransactionType } from 'lib/state/transactions' import { useMemo, useState } from 'react' @@ -50,7 +50,7 @@ export interface SwapProps { } export default function Swap(props: SwapProps) { - const list = useTokenList(props.tokenList) + useSyncTokenList(props.tokenList) useSyncSwapDefaults(props) useSyncConvenienceFee(props) @@ -61,16 +61,17 @@ export default function Swap(props: SwapProps) { const pendingTxs = usePendingTransactions() const displayTx = getSwapTx(pendingTxs, displayTxHash) - const onSupportedChain = useMemo( - () => chainId && ALL_SUPPORTED_CHAIN_IDS.includes(chainId) && list.some((token) => token.chainId === chainId), - [chainId, list] + const tokenList = useTokenList() + const isSwapSupported = useMemo( + () => Boolean(chainId && ALL_SUPPORTED_CHAIN_IDS.includes(chainId) && tokenList?.length), + [chainId, tokenList] ) const focused = useHasFocus(wrapper) return ( - {onSupportedChain && } + {isSwapSupported && }
Swap}> {active && } diff --git a/src/lib/components/TokenSelect.fixture.tsx b/src/lib/components/TokenSelect.fixture.tsx index d7bc7c4a3c..f09da76ef0 100644 --- a/src/lib/components/TokenSelect.fixture.tsx +++ b/src/lib/components/TokenSelect.fixture.tsx @@ -1,11 +1,11 @@ import DEFAULT_TOKEN_LIST from '@uniswap/default-token-list' -import useTokenList from 'lib/hooks/useTokenList' +import { useSyncTokenList } from 'lib/hooks/useTokenList' import { Modal } from './Dialog' import { TokenSelectDialog } from './TokenSelect' export default function Fixture() { - useTokenList(DEFAULT_TOKEN_LIST.tokens) + useSyncTokenList(DEFAULT_TOKEN_LIST.tokens) return ( diff --git a/src/lib/components/TokenSelect/TokenOptions.tsx b/src/lib/components/TokenSelect/TokenOptions.tsx index 62f7be5fd6..fc51868b1f 100644 --- a/src/lib/components/TokenSelect/TokenOptions.tsx +++ b/src/lib/components/TokenSelect/TokenOptions.tsx @@ -69,6 +69,13 @@ interface BubbledEvent extends SyntheticEvent { ref?: HTMLButtonElement } +const TokenBalance = styled.div<{ isLoading: boolean }>` + background-color: ${({ theme, isLoading }) => isLoading && theme.secondary}; + border-radius: 0.25em; + padding: 0.375em 0; + width: 1.5em; +` + function TokenOption({ index, value, style }: TokenOptionProps) { const { i18n } = useLingui() const ref = useRef(null) @@ -103,7 +110,9 @@ function TokenOption({ index, value, style }: TokenOptionProps) { {value.name} - {balance?.greaterThan(0) && formatCurrencyAmount(balance, 2, i18n.locale)} + + {balance?.greaterThan(0) && formatCurrencyAmount(balance, 2, i18n.locale)} + diff --git a/src/lib/components/TokenSelect/TokenOptionsSkeleton.tsx b/src/lib/components/TokenSelect/TokenOptionsSkeleton.tsx new file mode 100644 index 0000000000..01b05675de --- /dev/null +++ b/src/lib/components/TokenSelect/TokenOptionsSkeleton.tsx @@ -0,0 +1,66 @@ +import styled, { ThemedText } from 'lib/theme' + +import Column from '../Column' +import Row from '../Row' + +const Img = styled.div` + clip-path: circle(50%); + height: 1.5em; + width: 1.5em; +` +const Symbol = styled.div` + height: 0.75em; + width: 7em; +` +const Name = styled.div` + height: 0.5em; + width: 5.5em; +` +const Balance = styled.div` + padding: 0.375em 0; + width: 1.5em; +` +const TokenRow = styled.div` + outline: none; + padding: 0.6875em 0.75em; + + ${Img}, ${Symbol}, ${Name}, ${Balance} { + background-color: ${({ theme }) => theme.secondary}; + border-radius: 0.25em; + } +` + +function TokenOption() { + return ( + + + + + + + + + + + + + + + + + + + ) +} + +export default function TokenOptionsSkeleton() { + return ( + + + + + + + + ) +} diff --git a/src/lib/components/TokenSelect/index.tsx b/src/lib/components/TokenSelect/index.tsx index 9d9c5dfe40..1a639409f7 100644 --- a/src/lib/components/TokenSelect/index.tsx +++ b/src/lib/components/TokenSelect/index.tsx @@ -1,6 +1,9 @@ import { t, Trans } from '@lingui/macro' import { Currency } from '@uniswap/sdk-core' -import { useQueryTokenList } from 'lib/hooks/useTokenList' +import useActiveWeb3React from 'lib/hooks/useActiveWeb3React' +import { useCurrencyBalances } from 'lib/hooks/useCurrencyBalance' +import useNativeCurrency from 'lib/hooks/useNativeCurrency' +import useTokenList, { useIsTokenListLoaded, useQueryCurrencies } from 'lib/hooks/useTokenList' import styled, { ThemedText } from 'lib/theme' import { ElementRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { currencyId } from 'utils/currencyId' @@ -13,11 +16,29 @@ import Rule from '../Rule' import TokenBase from './TokenBase' import TokenButton from './TokenButton' import TokenOptions from './TokenOptions' +import TokenOptionsSkeleton from './TokenOptionsSkeleton' const SearchInput = styled(StringInput)` ${inputCss} ` +function usePrefetchBalances() { + const { account } = useActiveWeb3React() + const tokenList = useTokenList() + const [prefetchedTokenList, setPrefetchedTokenList] = useState(tokenList) + useEffect(() => setPrefetchedTokenList(tokenList), [tokenList]) + useCurrencyBalances(account, tokenList !== prefetchedTokenList ? tokenList : undefined) +} + +function useAreBalancesLoaded(): boolean { + const { account } = useActiveWeb3React() + const tokens = useTokenList() + const native = useNativeCurrency() + const currencies = useMemo(() => [native, ...tokens], [native, tokens]) + const balances = useCurrencyBalances(account, currencies).filter(Boolean) + return !account || currencies.length === balances.length +} + interface TokenSelectDialogProps { value?: Currency onSelect: (token: Currency) => void @@ -25,8 +46,24 @@ interface TokenSelectDialogProps { export function TokenSelectDialog({ value, onSelect }: TokenSelectDialogProps) { const [query, setQuery] = useState('') - const queriedTokens = useQueryTokenList(query) - const tokens = useMemo(() => queriedTokens.filter((token) => token !== value), [queriedTokens, value]) + const queriedTokens = useQueryCurrencies(query) + const tokens = useMemo(() => queriedTokens?.filter((token) => token !== value), [queriedTokens, value]) + + const isTokenListLoaded = useIsTokenListLoaded() + const areBalancesLoaded = useAreBalancesLoaded() + const [isLoaded, setIsLoaded] = useState(isTokenListLoaded && areBalancesLoaded) + // Give the balance-less tokens a small block period to avoid layout thrashing from re-sorting. + useEffect(() => { + if (!isLoaded) { + const timeout = setTimeout(() => setIsLoaded(true), 1500) + return () => clearTimeout(timeout) + } + return + }, [isLoaded]) + useEffect( + () => setIsLoaded(Boolean(query) || (isTokenListLoaded && areBalancesLoaded)), + [query, areBalancesLoaded, isTokenListLoaded] + ) const baseTokens: Currency[] = [] // TODO(zzmp): Add base tokens to token list functionality @@ -60,7 +97,7 @@ export function TokenSelectDialog({ value, onSelect }: TokenSelectDialogProps) { )} - + {isLoaded ? : } ) } @@ -73,6 +110,8 @@ interface TokenSelectProps { } export default function TokenSelect({ value, collapsed, disabled, onSelect }: TokenSelectProps) { + usePrefetchBalances() + const [open, setOpen] = useState(false) const selectAndClose = useCallback( (value: Currency) => { diff --git a/src/lib/hooks/useTokenList/index.ts b/src/lib/hooks/useTokenList/index.ts index 27d590c569..a2e9bfe038 100644 --- a/src/lib/hooks/useTokenList/index.ts +++ b/src/lib/hooks/useTokenList/index.ts @@ -1,10 +1,10 @@ -import { Token } from '@uniswap/sdk-core' +import { NativeCurrency, Token } from '@uniswap/sdk-core' import { TokenInfo, TokenList } from '@uniswap/token-lists' -import { atom, useAtom } from 'jotai' -import { useAtomValue } from 'jotai/utils' +import { atom } from 'jotai' +import { useAtomValue, useUpdateAtom } from 'jotai/utils' import useActiveWeb3React from 'lib/hooks/useActiveWeb3React' import resolveENSContentHash from 'lib/utils/resolveENSContentHash' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo' import fetchTokenList from './fetchTokenList' @@ -12,38 +12,70 @@ import { useQueryTokens } from './querying' import { ChainTokenMap, tokensToChainTokenMap } from './utils' import { validateTokens } from './validateTokenList' -export { DEFAULT_TOKEN_LIST } from './fetchTokenList' +export const DEFAULT_TOKEN_LIST = 'https://gateway.ipfs.io/ipns/tokens.uniswap.org' -const chainTokenMapAtom = atom({}) +const chainTokenMapAtom = atom(undefined) -export default function useTokenList(list?: string | TokenInfo[]): WrappedTokenInfo[] { +export function useIsTokenListLoaded() { + return Boolean(useAtomValue(chainTokenMapAtom)) +} + +export function useSyncTokenList(list: string | TokenInfo[] = DEFAULT_TOKEN_LIST): void { const { chainId, library } = useActiveWeb3React() - const [chainTokenMap, setChainTokenMap] = useAtom(chainTokenMapAtom) + const setChainTokenMap = useUpdateAtom(chainTokenMapAtom) // Error boundaries will not catch (non-rendering) async errors, but it should still be shown const [error, setError] = useState() if (error) throw error + const resolver = useCallback( + (ensName: string) => { + if (library && chainId === 1) { + // TODO(zzmp): Use network resolver when wallet is not on chainId === 1. + return resolveENSContentHash(ensName, library) + } + throw new Error('Could not construct mainnet ENS resolver') + }, + [chainId, library] + ) useEffect(() => { - if (list !== undefined) { - let tokens: Promise - if (typeof list === 'string') { - tokens = fetchTokenList(list, (ensName: string) => { - if (library && chainId === 1) { - return resolveENSContentHash(ensName, library) - } - throw new Error('Could not construct mainnet ENS resolver') - }) - } else { - tokens = validateTokens(list) + let stale = false + activateList(list) + return () => { + stale = true + } + + async function activateList(list: string | TokenInfo[]) { + try { + let tokens: TokenList | TokenInfo[] + if (typeof list === 'string') { + tokens = await fetchTokenList(list, resolver) + } else { + tokens = await validateTokens(list) + } + const tokenMap = tokensToChainTokenMap(tokens) // also caches the fetched tokens, so it is invoked even if stale + if (!stale) { + setChainTokenMap(tokenMap) + setError(undefined) + } + } catch (e: unknown) { + if (!stale) { + setChainTokenMap(undefined) + setError(e as Error) + } } - tokens.then(tokensToChainTokenMap).then(setChainTokenMap).catch(setError) } - }, [chainId, library, list, setChainTokenMap]) + }, [list, resolver, setChainTokenMap]) +} +export default function useTokenList(): WrappedTokenInfo[] { + const { chainId } = useActiveWeb3React() + const chainTokenMap = useAtomValue(chainTokenMapAtom) + const tokenMap = chainId && chainTokenMap?.[chainId] return useMemo(() => { - return Object.values((chainId && chainTokenMap[chainId]) || {}).map(({ token }) => token) - }, [chainId, chainTokenMap]) + if (!tokenMap) return [] + return Object.values(tokenMap).map(({ token }) => token) + }, [tokenMap]) } export type TokenMap = { [address: string]: Token } @@ -51,14 +83,16 @@ export type TokenMap = { [address: string]: Token } export function useTokenMap(): TokenMap { const { chainId } = useActiveWeb3React() const chainTokenMap = useAtomValue(chainTokenMapAtom) + const tokenMap = chainId && chainTokenMap?.[chainId] return useMemo(() => { - return Object.entries((chainId && chainTokenMap[chainId]) || {}).reduce((map, [address, { token }]) => { + if (!tokenMap) return {} + return Object.entries(tokenMap).reduce((map, [address, { token }]) => { map[address] = token return map }, {} as TokenMap) - }, [chainId, chainTokenMap]) + }, [tokenMap]) } -export function useQueryTokenList(query: string) { +export function useQueryCurrencies(query = ''): (WrappedTokenInfo | NativeCurrency)[] { return useQueryTokens(query, useTokenList()) }