feat: sort the widget token select (#3114)
* refactor: mv token list utils to lib * refactor: mv balance hooks to lib * feat: interactive token select
This commit is contained in:
parent
8784a761d6
commit
e68e1afd9d
@ -6,12 +6,15 @@ import useDebounce from 'hooks/useDebounce'
|
||||
import { useOnClickOutside } from 'hooks/useOnClickOutside'
|
||||
import useTheme from 'hooks/useTheme'
|
||||
import useToggle from 'hooks/useToggle'
|
||||
import { getTokenFilter } from 'lib/hooks/useTokenList/filtering'
|
||||
import { tokenComparator, useSortTokensByQuery } from 'lib/hooks/useTokenList/sorting'
|
||||
import { KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Edit } from 'react-feather'
|
||||
import ReactGA from 'react-ga'
|
||||
import AutoSizer from 'react-virtualized-auto-sizer'
|
||||
import { FixedSizeList } from 'react-window'
|
||||
import { Text } from 'rebass'
|
||||
import { useAllTokenBalances } from 'state/wallet/hooks'
|
||||
import styled from 'styled-components/macro'
|
||||
|
||||
import {
|
||||
@ -27,9 +30,7 @@ import Column from '../Column'
|
||||
import Row, { RowBetween, RowFixed } from '../Row'
|
||||
import CommonBases from './CommonBases'
|
||||
import CurrencyList from './CurrencyList'
|
||||
import { filterTokens, useSortedTokensByQuery } from './filtering'
|
||||
import ImportRow from './ImportRow'
|
||||
import { useTokenComparator } from './sorting'
|
||||
import { PaddedColumn, SearchInput, Separator } from './styleds'
|
||||
|
||||
const ContentWrapper = styled(Column)`
|
||||
@ -84,8 +85,6 @@ export function CurrencySearch({
|
||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||
const debouncedQuery = useDebounce(searchQuery, 200)
|
||||
|
||||
const [invertSearchOrder] = useState<boolean>(false)
|
||||
|
||||
const allTokens = useAllTokens()
|
||||
|
||||
// if they input an address, use it
|
||||
@ -105,17 +104,16 @@ export function CurrencySearch({
|
||||
}
|
||||
}, [isAddressSearch])
|
||||
|
||||
const tokenComparator = useTokenComparator(invertSearchOrder)
|
||||
|
||||
const filteredTokens: Token[] = useMemo(() => {
|
||||
return filterTokens(Object.values(allTokens), debouncedQuery)
|
||||
return Object.values(allTokens).filter(getTokenFilter(debouncedQuery))
|
||||
}, [allTokens, debouncedQuery])
|
||||
|
||||
const balances = useAllTokenBalances()
|
||||
const sortedTokens: Token[] = useMemo(() => {
|
||||
return filteredTokens.sort(tokenComparator)
|
||||
}, [filteredTokens, tokenComparator])
|
||||
return filteredTokens.sort(tokenComparator.bind(null, balances))
|
||||
}, [balances, filteredTokens])
|
||||
|
||||
const filteredSortedTokens = useSortedTokensByQuery(sortedTokens, debouncedQuery)
|
||||
const filteredSortedTokens = useSortTokensByQuery(debouncedQuery, sortedTokens)
|
||||
|
||||
const native = useNativeCurrency()
|
||||
|
||||
|
@ -1,76 +0,0 @@
|
||||
import { Token } from '@uniswap/sdk-core'
|
||||
import { TokenInfo } from '@uniswap/token-lists'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { isAddress } from '../../utils'
|
||||
|
||||
const alwaysTrue = () => true
|
||||
|
||||
/**
|
||||
* Create a filter function to apply to a token for whether it matches a particular search query
|
||||
* @param search the search query to apply to the token
|
||||
*/
|
||||
export function createTokenFilterFunction<T extends Token | TokenInfo>(search: string): (tokens: T) => boolean {
|
||||
const searchingAddress = isAddress(search)
|
||||
|
||||
if (searchingAddress) {
|
||||
const lower = searchingAddress.toLowerCase()
|
||||
return (t: T) => ('isToken' in t ? searchingAddress === t.address : lower === t.address.toLowerCase())
|
||||
}
|
||||
|
||||
const lowerSearchParts = search
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((s) => s.length > 0)
|
||||
|
||||
if (lowerSearchParts.length === 0) return alwaysTrue
|
||||
|
||||
const matchesSearch = (s: string): boolean => {
|
||||
const sParts = s
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((s) => s.length > 0)
|
||||
|
||||
return lowerSearchParts.every((p) => p.length === 0 || sParts.some((sp) => sp.startsWith(p) || sp.endsWith(p)))
|
||||
}
|
||||
|
||||
return ({ name, symbol }: T): boolean => Boolean((symbol && matchesSearch(symbol)) || (name && matchesSearch(name)))
|
||||
}
|
||||
|
||||
export function filterTokens<T extends Token | TokenInfo>(tokens: T[], search: string): T[] {
|
||||
return tokens.filter(createTokenFilterFunction(search))
|
||||
}
|
||||
|
||||
export function useSortedTokensByQuery(tokens: Token[] | undefined, searchQuery: string): Token[] {
|
||||
return useMemo(() => {
|
||||
if (!tokens) {
|
||||
return []
|
||||
}
|
||||
|
||||
const symbolMatch = searchQuery
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((s) => s.length > 0)
|
||||
|
||||
if (symbolMatch.length > 1) {
|
||||
return tokens
|
||||
}
|
||||
|
||||
const exactMatches: Token[] = []
|
||||
const symbolSubtrings: Token[] = []
|
||||
const rest: Token[] = []
|
||||
|
||||
// sort tokens by exact match -> subtring on symbol match -> rest
|
||||
tokens.map((token) => {
|
||||
if (token.symbol?.toLowerCase() === symbolMatch[0]) {
|
||||
return exactMatches.push(token)
|
||||
} else if (token.symbol?.toLowerCase().startsWith(searchQuery.toLowerCase().trim())) {
|
||||
return symbolSubtrings.push(token)
|
||||
} else {
|
||||
return rest.push(token)
|
||||
}
|
||||
})
|
||||
|
||||
return [...exactMatches, ...symbolSubtrings, ...rest]
|
||||
}, [tokens, searchQuery])
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { useAllTokenBalances } from '../../state/wallet/hooks'
|
||||
|
||||
// compare two token amounts with highest one coming first
|
||||
function balanceComparator(balanceA?: CurrencyAmount<Currency>, balanceB?: CurrencyAmount<Currency>) {
|
||||
if (balanceA && balanceB) {
|
||||
return balanceA.greaterThan(balanceB) ? -1 : balanceA.equalTo(balanceB) ? 0 : 1
|
||||
} else if (balanceA && balanceA.greaterThan('0')) {
|
||||
return -1
|
||||
} else if (balanceB && balanceB.greaterThan('0')) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function getTokenComparator(balances: {
|
||||
[tokenAddress: string]: CurrencyAmount<Currency> | undefined
|
||||
}): (tokenA: Token, tokenB: Token) => number {
|
||||
return function sortTokens(tokenA: Token, tokenB: Token): number {
|
||||
// -1 = a is first
|
||||
// 1 = b is first
|
||||
|
||||
// sort by balances
|
||||
const balanceA = balances[tokenA.address]
|
||||
const balanceB = balances[tokenB.address]
|
||||
|
||||
const balanceComp = balanceComparator(balanceA, balanceB)
|
||||
if (balanceComp !== 0) return balanceComp
|
||||
|
||||
if (tokenA.symbol && tokenB.symbol) {
|
||||
// sort by symbol
|
||||
return tokenA.symbol.toLowerCase() < tokenB.symbol.toLowerCase() ? -1 : 1
|
||||
} else {
|
||||
return tokenA.symbol ? -1 : tokenB.symbol ? -1 : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useTokenComparator(inverted: boolean): (tokenA: Token, tokenB: Token) => number {
|
||||
const balances = useAllTokenBalances()
|
||||
const comparator = useMemo(() => getTokenComparator(balances ?? {}), [balances])
|
||||
return useMemo(() => {
|
||||
if (inverted) {
|
||||
return (tokenA: Token, tokenB: Token) => comparator(tokenA, tokenB) * -1
|
||||
} else {
|
||||
return comparator
|
||||
}
|
||||
}, [inverted, comparator])
|
||||
}
|
@ -5,9 +5,9 @@ import { CHAIN_INFO } from 'constants/chainInfo'
|
||||
import { L2_CHAIN_IDS, SupportedChainId, SupportedL2ChainId } from 'constants/chains'
|
||||
import useActiveWeb3React from 'hooks/useActiveWeb3React'
|
||||
import { NEVER_RELOAD, useSingleCallResult } from 'lib/hooks/multicall'
|
||||
import { getTokenFilter } from 'lib/hooks/useTokenList/filtering'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { createTokenFilterFunction } from '../components/SearchModal/filtering'
|
||||
import { nativeOnChain } from '../constants/tokens'
|
||||
import { useAllLists, useCombinedActiveList, useInactiveListUrls } from '../state/lists/hooks'
|
||||
import { WrappedTokenInfo } from '../state/lists/wrappedTokenInfo'
|
||||
@ -117,7 +117,7 @@ export function useSearchInactiveTokenLists(search: string | undefined, minResul
|
||||
const activeTokens = useAllTokens()
|
||||
return useMemo(() => {
|
||||
if (!search || search.trim().length === 0) return []
|
||||
const tokenFilter = createTokenFilterFunction(search)
|
||||
const tokenFilter = getTokenFilter(search)
|
||||
const result: WrappedTokenInfo[] = []
|
||||
const addressSet: { [address: string]: true } = {}
|
||||
for (const url of inactiveUrls) {
|
||||
|
@ -1,3 +1,5 @@
|
||||
import useActiveWeb3React from 'lib/hooks/useActiveWeb3React'
|
||||
import useCurrencyBalance from 'lib/hooks/useCurrencyBalance'
|
||||
import useNativeEvent from 'lib/hooks/useNativeEvent'
|
||||
import useScrollbar from 'lib/hooks/useScrollbar'
|
||||
import styled, { ThemedText } from 'lib/theme'
|
||||
@ -74,6 +76,10 @@ function TokenOption({ index, value, style }: TokenOptionProps) {
|
||||
e.token = value
|
||||
e.ref = ref.current ?? undefined
|
||||
}
|
||||
|
||||
const { account } = useActiveWeb3React()
|
||||
const balance = useCurrencyBalance(account, value)
|
||||
|
||||
return (
|
||||
<TokenButton
|
||||
data-index={index}
|
||||
@ -94,7 +100,7 @@ function TokenOption({ index, value, style }: TokenOptionProps) {
|
||||
<ThemedText.Caption color="secondary">{value.name}</ThemedText.Caption>
|
||||
</Column>
|
||||
</Row>
|
||||
1.234
|
||||
{balance?.greaterThan(0) && balance?.toFixed(2)}
|
||||
</Row>
|
||||
</ThemedText.Body1>
|
||||
</TokenButton>
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { t, Trans } from '@lingui/macro'
|
||||
import useTokenList from 'lib/hooks/useTokenList'
|
||||
import { useQueryTokenList } from 'lib/hooks/useTokenList'
|
||||
import styled, { ThemedText } from 'lib/theme'
|
||||
import { Token } from 'lib/types'
|
||||
import { ElementRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ElementRef, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import Column from '../Column'
|
||||
import Dialog, { Header } from '../Dialog'
|
||||
@ -18,17 +18,12 @@ const SearchInput = styled(StringInput)`
|
||||
`
|
||||
|
||||
export function TokenSelectDialog({ onSelect }: { onSelect: (token: Token) => void }) {
|
||||
const tokenMap = useTokenList()
|
||||
const tokens = useMemo(() => Object.values(tokenMap).map(({ token }) => token), [tokenMap])
|
||||
const [query, setQuery] = useState('')
|
||||
const tokens = useQueryTokenList(query)
|
||||
|
||||
const baseTokens: Token[] = [] // TODO(zzmp): Add base tokens to token list functionality
|
||||
|
||||
// TODO(zzmp): Load token balances
|
||||
// TODO(zzmp): Sort tokens
|
||||
// TODO(zzmp): Disable already selected tokens
|
||||
// TODO(zzmp): Include native Currency
|
||||
|
||||
// TODO(zzmp): Filter tokens by search
|
||||
const [search, setSearch] = useState('')
|
||||
// TODO(zzmp): Disable already selected tokens (passed as props?)
|
||||
|
||||
const input = useRef<HTMLInputElement>(null)
|
||||
useEffect(() => input.current?.focus(), [input])
|
||||
@ -42,8 +37,8 @@ export function TokenSelectDialog({ onSelect }: { onSelect: (token: Token) => vo
|
||||
<Row pad={0.75} grow>
|
||||
<ThemedText.Body1>
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t`Search by token name or address`}
|
||||
onKeyDown={options?.onKeyDown}
|
||||
onBlur={options?.blur}
|
||||
|
140
src/lib/hooks/useCurrencyBalance.ts
Normal file
140
src/lib/hooks/useCurrencyBalance.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { Interface } from '@ethersproject/abi'
|
||||
import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core'
|
||||
import ERC20ABI from 'abis/erc20.json'
|
||||
import { Erc20Interface } from 'abis/types/Erc20'
|
||||
import useActiveWeb3React from 'hooks/useActiveWeb3React'
|
||||
import JSBI from 'jsbi'
|
||||
import { useMultipleContractSingleData, useSingleContractMultipleData } from 'lib/hooks/multicall'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { nativeOnChain } from '../../constants/tokens'
|
||||
import { useInterfaceMulticall } from '../../hooks/useContract'
|
||||
import { isAddress } from '../../utils'
|
||||
|
||||
/**
|
||||
* Returns a map of the given addresses to their eventually consistent ETH balances.
|
||||
*/
|
||||
export function useNativeCurrencyBalances(uncheckedAddresses?: (string | undefined)[]): {
|
||||
[address: string]: CurrencyAmount<Currency> | undefined
|
||||
} {
|
||||
const { chainId } = useActiveWeb3React()
|
||||
const multicallContract = useInterfaceMulticall()
|
||||
|
||||
const validAddressInputs: [string][] = useMemo(
|
||||
() =>
|
||||
uncheckedAddresses
|
||||
? uncheckedAddresses
|
||||
.map(isAddress)
|
||||
.filter((a): a is string => a !== false)
|
||||
.sort()
|
||||
.map((addr) => [addr])
|
||||
: [],
|
||||
[uncheckedAddresses]
|
||||
)
|
||||
|
||||
const results = useSingleContractMultipleData(multicallContract, 'getEthBalance', validAddressInputs)
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
validAddressInputs.reduce<{ [address: string]: CurrencyAmount<Currency> }>((memo, [address], i) => {
|
||||
const value = results?.[i]?.result?.[0]
|
||||
if (value && chainId)
|
||||
memo[address] = CurrencyAmount.fromRawAmount(nativeOnChain(chainId), JSBI.BigInt(value.toString()))
|
||||
return memo
|
||||
}, {}),
|
||||
[validAddressInputs, chainId, results]
|
||||
)
|
||||
}
|
||||
|
||||
const ERC20Interface = new Interface(ERC20ABI) as Erc20Interface
|
||||
const tokenBalancesGasRequirement = { gasRequired: 125_000 }
|
||||
|
||||
/**
|
||||
* Returns a map of token addresses to their eventually consistent token balances for a single account.
|
||||
*/
|
||||
export function useTokenBalancesWithLoadingIndicator(
|
||||
address?: string,
|
||||
tokens?: (Token | undefined)[]
|
||||
): [{ [tokenAddress: string]: CurrencyAmount<Token> | undefined }, boolean] {
|
||||
const validatedTokens: Token[] = useMemo(
|
||||
() => tokens?.filter((t?: Token): t is Token => isAddress(t?.address) !== false) ?? [],
|
||||
[tokens]
|
||||
)
|
||||
const validatedTokenAddresses = useMemo(() => validatedTokens.map((vt) => vt.address), [validatedTokens])
|
||||
|
||||
const balances = useMultipleContractSingleData(
|
||||
validatedTokenAddresses,
|
||||
ERC20Interface,
|
||||
'balanceOf',
|
||||
useMemo(() => [address], [address]),
|
||||
tokenBalancesGasRequirement
|
||||
)
|
||||
|
||||
const anyLoading: boolean = useMemo(() => balances.some((callState) => callState.loading), [balances])
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
address && validatedTokens.length > 0
|
||||
? validatedTokens.reduce<{ [tokenAddress: string]: CurrencyAmount<Token> | undefined }>((memo, token, i) => {
|
||||
const value = balances?.[i]?.result?.[0]
|
||||
const amount = value ? JSBI.BigInt(value.toString()) : undefined
|
||||
if (amount) {
|
||||
memo[token.address] = CurrencyAmount.fromRawAmount(token, amount)
|
||||
}
|
||||
return memo
|
||||
}, {})
|
||||
: {},
|
||||
anyLoading,
|
||||
],
|
||||
[address, validatedTokens, anyLoading, balances]
|
||||
)
|
||||
}
|
||||
|
||||
export function useTokenBalances(
|
||||
address?: string,
|
||||
tokens?: (Token | undefined)[]
|
||||
): { [tokenAddress: string]: CurrencyAmount<Token> | undefined } {
|
||||
return useTokenBalancesWithLoadingIndicator(address, tokens)[0]
|
||||
}
|
||||
|
||||
// get the balance for a single token/account combo
|
||||
export function useTokenBalance(account?: string, token?: Token): CurrencyAmount<Token> | undefined {
|
||||
const tokenBalances = useTokenBalances(account, [token])
|
||||
if (!token) return undefined
|
||||
return tokenBalances[token.address]
|
||||
}
|
||||
|
||||
export function useCurrencyBalances(
|
||||
account?: string,
|
||||
currencies?: (Currency | undefined)[]
|
||||
): (CurrencyAmount<Currency> | undefined)[] {
|
||||
const tokens = useMemo(
|
||||
() => currencies?.filter((currency): currency is Token => currency?.isToken ?? false) ?? [],
|
||||
[currencies]
|
||||
)
|
||||
|
||||
const tokenBalances = useTokenBalances(account, tokens)
|
||||
const containsETH: boolean = useMemo(() => currencies?.some((currency) => currency?.isNative) ?? false, [currencies])
|
||||
const ethBalance = useNativeCurrencyBalances(containsETH ? [account] : [])
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
currencies?.map((currency) => {
|
||||
if (!account || !currency) return undefined
|
||||
if (currency.isToken) return tokenBalances[currency.address]
|
||||
if (currency.isNative) return ethBalance[account]
|
||||
return undefined
|
||||
}) ?? [],
|
||||
[account, currencies, ethBalance, tokenBalances]
|
||||
)
|
||||
}
|
||||
|
||||
export default function useCurrencyBalance(
|
||||
account?: string,
|
||||
currency?: Currency
|
||||
): CurrencyAmount<Currency> | undefined {
|
||||
return useCurrencyBalances(
|
||||
account,
|
||||
useMemo(() => [currency], [currency])
|
||||
)[0]
|
||||
}
|
34
src/lib/hooks/useTokenList/filtering.ts
Normal file
34
src/lib/hooks/useTokenList/filtering.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Token } from '@uniswap/sdk-core'
|
||||
import { TokenInfo } from '@uniswap/token-lists'
|
||||
|
||||
import { isAddress } from '../../../utils'
|
||||
|
||||
const alwaysTrue = () => true
|
||||
|
||||
/** Creates a filter function that filters tokens that do not match the query. */
|
||||
export function getTokenFilter<T extends Token | TokenInfo>(query: string): (token: T) => boolean {
|
||||
const searchingAddress = isAddress(query)
|
||||
|
||||
if (searchingAddress) {
|
||||
const lower = searchingAddress.toLowerCase()
|
||||
return (t: T) => ('isToken' in t ? searchingAddress === t.address : lower === t.address.toLowerCase())
|
||||
}
|
||||
|
||||
const queryParts = query
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((s) => s.length > 0)
|
||||
|
||||
if (queryParts.length === 0) return alwaysTrue
|
||||
|
||||
const match = (s: string): boolean => {
|
||||
const parts = s
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((s) => s.length > 0)
|
||||
|
||||
return queryParts.every((p) => p.length === 0 || parts.some((sp) => sp.startsWith(p) || sp.endsWith(p)))
|
||||
}
|
||||
|
||||
return ({ name, symbol }: T): boolean => Boolean((symbol && match(symbol)) || (name && match(name)))
|
||||
}
|
@ -3,16 +3,18 @@ import { atom, useAtom } from 'jotai'
|
||||
import useActiveWeb3React from 'lib/hooks/useActiveWeb3React'
|
||||
import resolveENSContentHash from 'lib/utils/resolveENSContentHash'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo'
|
||||
|
||||
import fetchTokenList from './fetchTokenList'
|
||||
import { ChainTokenMap, TokenMap, tokensToChainTokenMap } from './utils'
|
||||
import { useQueryTokens } from './querying'
|
||||
import { ChainTokenMap, tokensToChainTokenMap } from './utils'
|
||||
import { validateTokens } from './validateTokenList'
|
||||
|
||||
export { DEFAULT_TOKEN_LIST } from './fetchTokenList'
|
||||
|
||||
const chainTokenMapAtom = atom<ChainTokenMap>({})
|
||||
|
||||
export default function useTokenList(list?: string | TokenInfo[]): TokenMap {
|
||||
export default function useTokenList(list?: string | TokenInfo[]): WrappedTokenInfo[] {
|
||||
const { chainId, library } = useActiveWeb3React()
|
||||
const [chainTokenMap, setChainTokenMap] = useAtom(chainTokenMapAtom)
|
||||
|
||||
@ -38,6 +40,10 @@ export default function useTokenList(list?: string | TokenInfo[]): TokenMap {
|
||||
}, [chainId, library, list, setChainTokenMap])
|
||||
|
||||
return useMemo(() => {
|
||||
return (chainId && chainTokenMap[chainId]) || {}
|
||||
return Object.values((chainId && chainTokenMap[chainId]) || {}).map(({ token }) => token)
|
||||
}, [chainId, chainTokenMap])
|
||||
}
|
||||
|
||||
export function useQueryTokenList(query: string) {
|
||||
return useQueryTokens(query, useTokenList())
|
||||
}
|
||||
|
22
src/lib/hooks/useTokenList/querying.ts
Normal file
22
src/lib/hooks/useTokenList/querying.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import useDebounce from 'hooks/useDebounce'
|
||||
import useActiveWeb3React from 'lib/hooks/useActiveWeb3React'
|
||||
import { useTokenBalances } from 'lib/hooks/useCurrencyBalance'
|
||||
import { useMemo } from 'react'
|
||||
import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo'
|
||||
|
||||
import { getTokenFilter } from './filtering'
|
||||
import { tokenComparator, useSortTokensByQuery } from './sorting'
|
||||
|
||||
export function useQueryTokens(query: string, tokens: WrappedTokenInfo[]) {
|
||||
const { account } = useActiveWeb3React()
|
||||
const balances = useTokenBalances(account, tokens)
|
||||
const sortedTokens = useMemo(() => [...tokens.sort(tokenComparator.bind(null, balances))], [balances, tokens])
|
||||
|
||||
const debouncedQuery = useDebounce(query, 200)
|
||||
const filteredTokens = useMemo(
|
||||
() => sortedTokens.filter(getTokenFilter(debouncedQuery)),
|
||||
[debouncedQuery, sortedTokens]
|
||||
)
|
||||
|
||||
return useSortTokensByQuery(debouncedQuery, filteredTokens)
|
||||
}
|
65
src/lib/hooks/useTokenList/sorting.ts
Normal file
65
src/lib/hooks/useTokenList/sorting.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core'
|
||||
import { TokenInfo } from '@uniswap/token-lists'
|
||||
import { useMemo } from 'react'
|
||||
import { useTokenBalances } from 'state/wallet/hooks'
|
||||
|
||||
/** Sorts currency amounts (descending). */
|
||||
function balanceComparator(a?: CurrencyAmount<Currency>, b?: CurrencyAmount<Currency>) {
|
||||
if (a && b) {
|
||||
return a.greaterThan(b) ? -1 : a.equalTo(b) ? 0 : 1
|
||||
} else if (a?.greaterThan('0')) {
|
||||
return -1
|
||||
} else if (b?.greaterThan('0')) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Sorts tokens by currency amount (descending), then symbol (ascending). */
|
||||
export function tokenComparator(balances: ReturnType<typeof useTokenBalances>, a: Token, b: Token) {
|
||||
// Sorts by balances
|
||||
const balanceComparison = balanceComparator(balances[a.address], balances[b.address])
|
||||
if (balanceComparison !== 0) return balanceComparison
|
||||
|
||||
// Sorts by symbol
|
||||
if (a.symbol && b.symbol) {
|
||||
return a.symbol.toLowerCase() < b.symbol.toLowerCase() ? -1 : 1
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
/** Sorts tokens by query, giving precedence to exact matches and partial matches. */
|
||||
export function useSortTokensByQuery<T extends Token | TokenInfo>(query: string, tokens?: T[]): T[] {
|
||||
return useMemo(() => {
|
||||
if (!tokens) {
|
||||
return []
|
||||
}
|
||||
|
||||
const matches = query
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((s) => s.length > 0)
|
||||
|
||||
if (matches.length > 1) {
|
||||
return tokens
|
||||
}
|
||||
|
||||
const exactMatches: T[] = []
|
||||
const symbolSubtrings: T[] = []
|
||||
const rest: T[] = []
|
||||
|
||||
// sort tokens by exact match -> subtring on symbol match -> rest
|
||||
tokens.map((token) => {
|
||||
if (token.symbol?.toLowerCase() === matches[0]) {
|
||||
return exactMatches.push(token)
|
||||
} else if (token.symbol?.toLowerCase().startsWith(query.toLowerCase().trim())) {
|
||||
return symbolSubtrings.push(token)
|
||||
} else {
|
||||
return rest.push(token)
|
||||
}
|
||||
})
|
||||
|
||||
return [...exactMatches, ...symbolSubtrings, ...rest]
|
||||
}, [tokens, query])
|
||||
}
|
@ -1,142 +1,22 @@
|
||||
import { Interface } from '@ethersproject/abi'
|
||||
import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core'
|
||||
import ERC20ABI from 'abis/erc20.json'
|
||||
import { Erc20Interface } from 'abis/types/Erc20'
|
||||
import { CurrencyAmount, Token } from '@uniswap/sdk-core'
|
||||
import useActiveWeb3React from 'hooks/useActiveWeb3React'
|
||||
import JSBI from 'jsbi'
|
||||
import { useMultipleContractSingleData, useSingleContractMultipleData } from 'lib/hooks/multicall'
|
||||
import { useTokenBalance, useTokenBalances } from 'lib/hooks/useCurrencyBalance'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { nativeOnChain, UNI } from '../../constants/tokens'
|
||||
import { UNI } from '../../constants/tokens'
|
||||
import { useAllTokens } from '../../hooks/Tokens'
|
||||
import { useInterfaceMulticall } from '../../hooks/useContract'
|
||||
import { isAddress } from '../../utils'
|
||||
import { useUserUnclaimedAmount } from '../claim/hooks'
|
||||
import { useTotalUniEarned } from '../stake/hooks'
|
||||
/**
|
||||
* Returns a map of the given addresses to their eventually consistent ETH balances.
|
||||
*/
|
||||
export function useNativeCurrencyBalances(uncheckedAddresses?: (string | undefined)[]): {
|
||||
[address: string]: CurrencyAmount<Currency> | undefined
|
||||
} {
|
||||
const { chainId } = useActiveWeb3React()
|
||||
const multicallContract = useInterfaceMulticall()
|
||||
|
||||
const validAddressInputs: [string][] = useMemo(
|
||||
() =>
|
||||
uncheckedAddresses
|
||||
? uncheckedAddresses
|
||||
.map(isAddress)
|
||||
.filter((a): a is string => a !== false)
|
||||
.sort()
|
||||
.map((addr) => [addr])
|
||||
: [],
|
||||
[uncheckedAddresses]
|
||||
)
|
||||
|
||||
const results = useSingleContractMultipleData(multicallContract, 'getEthBalance', validAddressInputs)
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
validAddressInputs.reduce<{ [address: string]: CurrencyAmount<Currency> }>((memo, [address], i) => {
|
||||
const value = results?.[i]?.result?.[0]
|
||||
if (value && chainId)
|
||||
memo[address] = CurrencyAmount.fromRawAmount(nativeOnChain(chainId), JSBI.BigInt(value.toString()))
|
||||
return memo
|
||||
}, {}),
|
||||
[validAddressInputs, chainId, results]
|
||||
)
|
||||
}
|
||||
|
||||
const ERC20Interface = new Interface(ERC20ABI) as Erc20Interface
|
||||
const tokenBalancesGasRequirement = { gasRequired: 125_000 }
|
||||
|
||||
/**
|
||||
* Returns a map of token addresses to their eventually consistent token balances for a single account.
|
||||
*/
|
||||
export function useTokenBalancesWithLoadingIndicator(
|
||||
address?: string,
|
||||
tokens?: (Token | undefined)[]
|
||||
): [{ [tokenAddress: string]: CurrencyAmount<Token> | undefined }, boolean] {
|
||||
const validatedTokens: Token[] = useMemo(
|
||||
() => tokens?.filter((t?: Token): t is Token => isAddress(t?.address) !== false) ?? [],
|
||||
[tokens]
|
||||
)
|
||||
const validatedTokenAddresses = useMemo(() => validatedTokens.map((vt) => vt.address), [validatedTokens])
|
||||
|
||||
const balances = useMultipleContractSingleData(
|
||||
validatedTokenAddresses,
|
||||
ERC20Interface,
|
||||
'balanceOf',
|
||||
useMemo(() => [address], [address]),
|
||||
tokenBalancesGasRequirement
|
||||
)
|
||||
|
||||
const anyLoading: boolean = useMemo(() => balances.some((callState) => callState.loading), [balances])
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
address && validatedTokens.length > 0
|
||||
? validatedTokens.reduce<{ [tokenAddress: string]: CurrencyAmount<Token> | undefined }>((memo, token, i) => {
|
||||
const value = balances?.[i]?.result?.[0]
|
||||
const amount = value ? JSBI.BigInt(value.toString()) : undefined
|
||||
if (amount) {
|
||||
memo[token.address] = CurrencyAmount.fromRawAmount(token, amount)
|
||||
}
|
||||
return memo
|
||||
}, {})
|
||||
: {},
|
||||
anyLoading,
|
||||
],
|
||||
[address, validatedTokens, anyLoading, balances]
|
||||
)
|
||||
}
|
||||
|
||||
export function useTokenBalances(
|
||||
address?: string,
|
||||
tokens?: (Token | undefined)[]
|
||||
): { [tokenAddress: string]: CurrencyAmount<Token> | undefined } {
|
||||
return useTokenBalancesWithLoadingIndicator(address, tokens)[0]
|
||||
}
|
||||
|
||||
// get the balance for a single token/account combo
|
||||
export function useTokenBalance(account?: string, token?: Token): CurrencyAmount<Token> | undefined {
|
||||
const tokenBalances = useTokenBalances(account, [token])
|
||||
if (!token) return undefined
|
||||
return tokenBalances[token.address]
|
||||
}
|
||||
|
||||
export function useCurrencyBalances(
|
||||
account?: string,
|
||||
currencies?: (Currency | undefined)[]
|
||||
): (CurrencyAmount<Currency> | undefined)[] {
|
||||
const tokens = useMemo(
|
||||
() => currencies?.filter((currency): currency is Token => currency?.isToken ?? false) ?? [],
|
||||
[currencies]
|
||||
)
|
||||
|
||||
const tokenBalances = useTokenBalances(account, tokens)
|
||||
const containsETH: boolean = useMemo(() => currencies?.some((currency) => currency?.isNative) ?? false, [currencies])
|
||||
const ethBalance = useNativeCurrencyBalances(containsETH ? [account] : [])
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
currencies?.map((currency) => {
|
||||
if (!account || !currency) return undefined
|
||||
if (currency.isToken) return tokenBalances[currency.address]
|
||||
if (currency.isNative) return ethBalance[account]
|
||||
return undefined
|
||||
}) ?? [],
|
||||
[account, currencies, ethBalance, tokenBalances]
|
||||
)
|
||||
}
|
||||
|
||||
export function useCurrencyBalance(account?: string, currency?: Currency): CurrencyAmount<Currency> | undefined {
|
||||
return useCurrencyBalances(
|
||||
account,
|
||||
useMemo(() => [currency], [currency])
|
||||
)[0]
|
||||
}
|
||||
export {
|
||||
default as useCurrencyBalance,
|
||||
useCurrencyBalances,
|
||||
useNativeCurrencyBalances,
|
||||
useTokenBalance,
|
||||
useTokenBalances,
|
||||
useTokenBalancesWithLoadingIndicator,
|
||||
} from 'lib/hooks/useCurrencyBalance'
|
||||
|
||||
// mimics useAllBalances
|
||||
export function useAllTokenBalances(): { [tokenAddress: string]: CurrencyAmount<Token> | undefined } {
|
||||
|
Loading…
Reference in New Issue
Block a user