Skip to content

Commit

Permalink
fix: token select ux (#3321)
Browse files Browse the repository at this point in the history
  • Loading branch information
zzmp authored Feb 16, 2022
1 parent ae664dc commit a60ea70
Show file tree
Hide file tree
Showing 6 changed files with 188 additions and 39 deletions.
13 changes: 7 additions & 6 deletions src/lib/components/Swap/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)

Expand All @@ -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 (
<SwapPropValidator {...props}>
{onSupportedChain && <SwapInfoUpdater />}
{isSwapSupported && <SwapInfoUpdater />}
<Header title={<Trans>Swap</Trans>}>
{active && <Wallet disabled={!account} onClick={props.onConnectWallet} />}
<Settings disabled={!active} />
Expand Down
4 changes: 2 additions & 2 deletions src/lib/components/TokenSelect.fixture.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Modal color="module">
Expand Down
11 changes: 10 additions & 1 deletion src/lib/components/TokenSelect/TokenOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>(null)
Expand Down Expand Up @@ -103,7 +110,9 @@ function TokenOption({ index, value, style }: TokenOptionProps) {
<ThemedText.Caption color="secondary">{value.name}</ThemedText.Caption>
</Column>
</Row>
{balance?.greaterThan(0) && formatCurrencyAmount(balance, 2, i18n.locale)}
<TokenBalance isLoading={Boolean(account) && !balance}>
{balance?.greaterThan(0) && formatCurrencyAmount(balance, 2, i18n.locale)}
</TokenBalance>
</Row>
</ThemedText.Body1>
</TokenButton>
Expand Down
66 changes: 66 additions & 0 deletions src/lib/components/TokenSelect/TokenOptionsSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<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>
)
}
47 changes: 43 additions & 4 deletions src/lib/components/TokenSelect/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -13,20 +16,54 @@ 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
}

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

Expand Down Expand Up @@ -60,7 +97,7 @@ export function TokenSelectDialog({ value, onSelect }: TokenSelectDialogProps) {
)}
<Rule padded />
</Column>
<TokenOptions tokens={tokens} onSelect={onSelect} ref={setOptions} />
{isLoaded ? <TokenOptions tokens={tokens} onSelect={onSelect} ref={setOptions} /> : <TokenOptionsSkeleton />}
</>
)
}
Expand All @@ -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) => {
Expand Down
86 changes: 60 additions & 26 deletions src/lib/hooks/useTokenList/index.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,98 @@
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'
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<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 [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<Error>()
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<TokenList | TokenInfo[]>
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 }

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())
}

0 comments on commit a60ea70

Please sign in to comment.