Commit a60ea703 authored by Zach Pomerantz's avatar Zach Pomerantz Committed by GitHub

fix: token select ux (#3321)

parent ae664dc2
...@@ -8,7 +8,7 @@ import useSyncSwapDefaults from 'lib/hooks/swap/useSyncSwapDefaults' ...@@ -8,7 +8,7 @@ import useSyncSwapDefaults from 'lib/hooks/swap/useSyncSwapDefaults'
import { usePendingTransactions } from 'lib/hooks/transactions' import { usePendingTransactions } from 'lib/hooks/transactions'
import useActiveWeb3React from 'lib/hooks/useActiveWeb3React' import useActiveWeb3React from 'lib/hooks/useActiveWeb3React'
import useHasFocus from 'lib/hooks/useHasFocus' 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 { displayTxHashAtom } from 'lib/state/swap'
import { SwapTransactionInfo, Transaction, TransactionType } from 'lib/state/transactions' import { SwapTransactionInfo, Transaction, TransactionType } from 'lib/state/transactions'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
...@@ -50,7 +50,7 @@ export interface SwapProps { ...@@ -50,7 +50,7 @@ export interface SwapProps {
} }
export default function Swap(props: SwapProps) { export default function Swap(props: SwapProps) {
const list = useTokenList(props.tokenList) useSyncTokenList(props.tokenList)
useSyncSwapDefaults(props) useSyncSwapDefaults(props)
useSyncConvenienceFee(props) useSyncConvenienceFee(props)
...@@ -61,16 +61,17 @@ export default function Swap(props: SwapProps) { ...@@ -61,16 +61,17 @@ export default function Swap(props: SwapProps) {
const pendingTxs = usePendingTransactions() const pendingTxs = usePendingTransactions()
const displayTx = getSwapTx(pendingTxs, displayTxHash) const displayTx = getSwapTx(pendingTxs, displayTxHash)
const onSupportedChain = useMemo( const tokenList = useTokenList()
() => chainId && ALL_SUPPORTED_CHAIN_IDS.includes(chainId) && list.some((token) => token.chainId === chainId), const isSwapSupported = useMemo(
[chainId, list] () => Boolean(chainId && ALL_SUPPORTED_CHAIN_IDS.includes(chainId) && tokenList?.length),
[chainId, tokenList]
) )
const focused = useHasFocus(wrapper) const focused = useHasFocus(wrapper)
return ( return (
<SwapPropValidator {...props}> <SwapPropValidator {...props}>
{onSupportedChain && <SwapInfoUpdater />} {isSwapSupported && <SwapInfoUpdater />}
<Header title={<Trans>Swap</Trans>}> <Header title={<Trans>Swap</Trans>}>
{active && <Wallet disabled={!account} onClick={props.onConnectWallet} />} {active && <Wallet disabled={!account} onClick={props.onConnectWallet} />}
<Settings disabled={!active} /> <Settings disabled={!active} />
......
import DEFAULT_TOKEN_LIST from '@uniswap/default-token-list' 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 { Modal } from './Dialog'
import { TokenSelectDialog } from './TokenSelect' import { TokenSelectDialog } from './TokenSelect'
export default function Fixture() { export default function Fixture() {
useTokenList(DEFAULT_TOKEN_LIST.tokens) useSyncTokenList(DEFAULT_TOKEN_LIST.tokens)
return ( return (
<Modal color="module"> <Modal color="module">
......
...@@ -69,6 +69,13 @@ interface BubbledEvent extends SyntheticEvent { ...@@ -69,6 +69,13 @@ interface BubbledEvent extends SyntheticEvent {
ref?: HTMLButtonElement 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) { function TokenOption({ index, value, style }: TokenOptionProps) {
const { i18n } = useLingui() const { i18n } = useLingui()
const ref = useRef<HTMLButtonElement>(null) const ref = useRef<HTMLButtonElement>(null)
...@@ -103,7 +110,9 @@ function TokenOption({ index, value, style }: TokenOptionProps) { ...@@ -103,7 +110,9 @@ function TokenOption({ index, value, style }: TokenOptionProps) {
<ThemedText.Caption color="secondary">{value.name}</ThemedText.Caption> <ThemedText.Caption color="secondary">{value.name}</ThemedText.Caption>
</Column> </Column>
</Row> </Row>
<TokenBalance isLoading={Boolean(account) && !balance}>
{balance?.greaterThan(0) && formatCurrencyAmount(balance, 2, i18n.locale)} {balance?.greaterThan(0) && formatCurrencyAmount(balance, 2, i18n.locale)}
</TokenBalance>
</Row> </Row>
</ThemedText.Body1> </ThemedText.Body1>
</TokenButton> </TokenButton>
......
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 (
<TokenRow>
<ThemedText.Body1>
<Row>
<Row gap={0.5}>
<Img />
<Column flex gap={0.125} align="flex-start" justify="flex-center">
<ThemedText.Subhead1 style={{ display: 'flex' }}>
<Symbol />
</ThemedText.Subhead1>
<ThemedText.Caption style={{ display: 'flex' }}>
<Name />
</ThemedText.Caption>
</Column>
</Row>
<Balance />
</Row>
</ThemedText.Body1>
</TokenRow>
)
}
export default function TokenOptionsSkeleton() {
return (
<Column>
<TokenOption />
<TokenOption />
<TokenOption />
<TokenOption />
<TokenOption />
</Column>
)
}
import { t, Trans } from '@lingui/macro' import { t, Trans } from '@lingui/macro'
import { Currency } from '@uniswap/sdk-core' 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 styled, { ThemedText } from 'lib/theme'
import { ElementRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ElementRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { currencyId } from 'utils/currencyId' import { currencyId } from 'utils/currencyId'
...@@ -13,11 +16,29 @@ import Rule from '../Rule' ...@@ -13,11 +16,29 @@ import Rule from '../Rule'
import TokenBase from './TokenBase' import TokenBase from './TokenBase'
import TokenButton from './TokenButton' import TokenButton from './TokenButton'
import TokenOptions from './TokenOptions' import TokenOptions from './TokenOptions'
import TokenOptionsSkeleton from './TokenOptionsSkeleton'
const SearchInput = styled(StringInput)` const SearchInput = styled(StringInput)`
${inputCss} ${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 { interface TokenSelectDialogProps {
value?: Currency value?: Currency
onSelect: (token: Currency) => void onSelect: (token: Currency) => void
...@@ -25,8 +46,24 @@ interface TokenSelectDialogProps { ...@@ -25,8 +46,24 @@ interface TokenSelectDialogProps {
export function TokenSelectDialog({ value, onSelect }: TokenSelectDialogProps) { export function TokenSelectDialog({ value, onSelect }: TokenSelectDialogProps) {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const queriedTokens = useQueryTokenList(query) const queriedTokens = useQueryCurrencies(query)
const tokens = useMemo(() => queriedTokens.filter((token) => token !== value), [queriedTokens, value]) 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 const baseTokens: Currency[] = [] // TODO(zzmp): Add base tokens to token list functionality
...@@ -60,7 +97,7 @@ export function TokenSelectDialog({ value, onSelect }: TokenSelectDialogProps) { ...@@ -60,7 +97,7 @@ export function TokenSelectDialog({ value, onSelect }: TokenSelectDialogProps) {
)} )}
<Rule padded /> <Rule padded />
</Column> </Column>
<TokenOptions tokens={tokens} onSelect={onSelect} ref={setOptions} /> {isLoaded ? <TokenOptions tokens={tokens} onSelect={onSelect} ref={setOptions} /> : <TokenOptionsSkeleton />}
</> </>
) )
} }
...@@ -73,6 +110,8 @@ interface TokenSelectProps { ...@@ -73,6 +110,8 @@ interface TokenSelectProps {
} }
export default function TokenSelect({ value, collapsed, disabled, onSelect }: TokenSelectProps) { export default function TokenSelect({ value, collapsed, disabled, onSelect }: TokenSelectProps) {
usePrefetchBalances()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const selectAndClose = useCallback( const selectAndClose = useCallback(
(value: Currency) => { (value: Currency) => {
......
import { Token } from '@uniswap/sdk-core' import { NativeCurrency, Token } from '@uniswap/sdk-core'
import { TokenInfo, TokenList } from '@uniswap/token-lists' import { TokenInfo, TokenList } from '@uniswap/token-lists'
import { atom, useAtom } from 'jotai' import { atom } from 'jotai'
import { useAtomValue } from 'jotai/utils' import { useAtomValue, useUpdateAtom } from 'jotai/utils'
import useActiveWeb3React from 'lib/hooks/useActiveWeb3React' import useActiveWeb3React from 'lib/hooks/useActiveWeb3React'
import resolveENSContentHash from 'lib/utils/resolveENSContentHash' 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 { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo'
import fetchTokenList from './fetchTokenList' import fetchTokenList from './fetchTokenList'
...@@ -12,38 +12,70 @@ import { useQueryTokens } from './querying' ...@@ -12,38 +12,70 @@ import { useQueryTokens } from './querying'
import { ChainTokenMap, tokensToChainTokenMap } from './utils' import { ChainTokenMap, tokensToChainTokenMap } from './utils'
import { validateTokens } from './validateTokenList' 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<ChainTokenMap>({}) const chainTokenMapAtom = atom<ChainTokenMap | undefined>(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 { 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 // Error boundaries will not catch (non-rendering) async errors, but it should still be shown
const [error, setError] = useState<Error>() const [error, setError] = useState<Error>()
if (error) throw error if (error) throw error
useEffect(() => { const resolver = useCallback(
if (list !== undefined) { (ensName: string) => {
let tokens: Promise<TokenList | TokenInfo[]>
if (typeof list === 'string') {
tokens = fetchTokenList(list, (ensName: string) => {
if (library && chainId === 1) { if (library && chainId === 1) {
// TODO(zzmp): Use network resolver when wallet is not on chainId === 1.
return resolveENSContentHash(ensName, library) return resolveENSContentHash(ensName, library)
} }
throw new Error('Could not construct mainnet ENS resolver') throw new Error('Could not construct mainnet ENS resolver')
}) },
[chainId, library]
)
useEffect(() => {
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 { } else {
tokens = validateTokens(list) 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 useMemo(() => {
return Object.values((chainId && chainTokenMap[chainId]) || {}).map(({ token }) => token) if (!tokenMap) return []
}, [chainId, chainTokenMap]) return Object.values(tokenMap).map(({ token }) => token)
}, [tokenMap])
} }
export type TokenMap = { [address: string]: Token } export type TokenMap = { [address: string]: Token }
...@@ -51,14 +83,16 @@ export type TokenMap = { [address: string]: Token } ...@@ -51,14 +83,16 @@ export type TokenMap = { [address: string]: Token }
export function useTokenMap(): TokenMap { export function useTokenMap(): TokenMap {
const { chainId } = useActiveWeb3React() const { chainId } = useActiveWeb3React()
const chainTokenMap = useAtomValue(chainTokenMapAtom) const chainTokenMap = useAtomValue(chainTokenMapAtom)
const tokenMap = chainId && chainTokenMap?.[chainId]
return useMemo(() => { 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 map[address] = token
return map return map
}, {} as TokenMap) }, {} as TokenMap)
}, [chainId, chainTokenMap]) }, [tokenMap])
} }
export function useQueryTokenList(query: string) { export function useQueryCurrencies(query = ''): (WrappedTokenInfo | NativeCurrency)[] {
return useQueryTokens(query, useTokenList()) return useQueryTokens(query, useTokenList())
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment