diff --git a/src/components/CurrencyInputPanel/index.tsx b/src/components/CurrencyInputPanel/index.tsx index 524bacfbc4..6de6b8c0de 100644 --- a/src/components/CurrencyInputPanel/index.tsx +++ b/src/components/CurrencyInputPanel/index.tsx @@ -125,7 +125,6 @@ interface CurrencyInputPanelProps { onMax?: () => void showMaxButton: boolean label?: string - urlAddedTokens?: Token[] onTokenSelection?: (tokenAddress: string) => void token?: Token | null disableTokenSelect?: boolean @@ -145,7 +144,6 @@ export default function CurrencyInputPanel({ onMax, showMaxButton, label = 'Input', - urlAddedTokens = [], // used onTokenSelection = null, token = null, disableTokenSelect = false, @@ -246,7 +244,6 @@ export default function CurrencyInputPanel({ setModalOpen(false) }} filterType="tokens" - urlAddedTokens={urlAddedTokens} onTokenSelect={onTokenSelection} showSendWithSwap={showSendWithSwap} hiddenToken={token?.address} diff --git a/src/components/SearchModal/TokenSortButton.tsx b/src/components/SearchModal/TokenSortButton.tsx new file mode 100644 index 0000000000..60244d0404 --- /dev/null +++ b/src/components/SearchModal/TokenSortButton.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import { Text } from 'rebass' +import { FilterWrapper } from './styleds' + +export function TokenSortButton({ + title, + toggleSortOrder, + invertSearchOrder +}: { + title: string + toggleSortOrder: () => void + invertSearchOrder: boolean +}) { + return ( + + + {title} + + + {!invertSearchOrder ? '↓' : '↑'} + + + ) +} diff --git a/src/components/SearchModal/index.tsx b/src/components/SearchModal/index.tsx index dff352658a..533e9f3c85 100644 --- a/src/components/SearchModal/index.tsx +++ b/src/components/SearchModal/index.tsx @@ -1,170 +1,66 @@ -import React, { useState, useRef, useMemo, useEffect, useContext } from 'react' import '@reach/tooltip/styles.css' -import styled, { ThemeContext } from 'styled-components' -import { JSBI, Token, WETH } from '@uniswap/sdk' +import { ChainId, JSBI, Token, WETH } from '@uniswap/sdk' +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react' import { isMobile } from 'react-device-detect' -import { RouteComponentProps, withRouter } from 'react-router-dom' -import { COMMON_BASES } from '../../constants' -import { useAllTokenBalancesTreatingWETHasETH } from '../../state/wallet/hooks' -import { Link as StyledLink } from '../../theme/components' - -import Card from '../../components/Card' -import Modal from '../Modal' -import Circle from '../../assets/images/circle.svg' -import TokenLogo from '../TokenLogo' -import DoubleTokenLogo from '../DoubleLogo' -import Column, { AutoColumn } from '../Column' -import { Text } from 'rebass' -import { CursorPointer } from '../../theme' import { ArrowLeft } from 'react-feather' -import { CloseIcon } from '../../theme/components' -import { ButtonPrimary, ButtonSecondary } from '../../components/Button' -import { Spinner, TYPE } from '../../theme' -import { RowBetween, RowFixed, AutoRow } from '../Row' - -import { isAddress, escapeRegExp } from '../../utils' -import { useActiveWeb3React } from '../../hooks' -import { - useAllDummyPairs, - useFetchTokenByAddress, - useAddUserToken, - useRemoveUserAddedToken, - useUserAddedTokens -} from '../../state/user/hooks' import { useTranslation } from 'react-i18next' -import { useToken, useAllTokens } from '../../hooks/Tokens' +import { RouteComponentProps, withRouter } from 'react-router-dom' +import { Text } from 'rebass' +import { ThemeContext } from 'styled-components' +import Circle from '../../assets/images/circle.svg' +import Card from '../../components/Card' +import { COMMON_BASES } from '../../constants' +import { ALL_TOKENS } from '../../constants/tokens' +import { useActiveWeb3React } from '../../hooks' +import { useAllTokens, useTokenByAddressAndAutomaticallyAdd } from '../../hooks/Tokens' +import { useAllDummyPairs, useRemoveUserAddedToken, useUserAddedTokens } from '../../state/user/hooks' +import { useAllTokenBalancesTreatingWETHasETH } from '../../state/wallet/hooks' +import { CursorPointer, TYPE } from '../../theme' +import { CloseIcon, Link as StyledLink } from '../../theme/components' +import { escapeRegExp, isAddress } from '../../utils' +import { ButtonPrimary, ButtonSecondary } from '../Button' +import Column, { AutoColumn } from '../Column' +import DoubleTokenLogo from '../DoubleLogo' +import Modal from '../Modal' import QuestionHelper from '../Question' +import { AutoRow, RowBetween, RowFixed } from '../Row' +import TokenLogo from '../TokenLogo' +import { useTokenComparator } from './sorting' +import { + BaseWrapper, + FadedSpan, + GreySpan, + Input, + ItemList, + MenuItem, + PaddedColumn, + SpinnerWrapper, + TokenModalInfo +} from './styleds' +import { TokenSortButton } from './TokenSortButton' -const TokenModalInfo = styled.div` - ${({ theme }) => theme.flexRowNoWrap} - align-items: center; - padding: 1rem 1rem; - margin: 0.25rem 0.5rem; - justify-content: center; - user-select: none; - min-height: 200px; -` - -const ItemList = styled.div` - flex-grow: 1; - height: 254px; - overflow-y: scroll; - -webkit-overflow-scrolling: touch; -` - -const FadedSpan = styled(RowFixed)` - color: ${({ theme }) => theme.primary1}; - font-size: 14px; -` - -const GreySpan = styled.span` - color: ${({ theme }) => theme.text3}; - font-weight: 400; -` - -const SpinnerWrapper = styled(Spinner)` - margin: 0 0.25rem 0 0.25rem; - color: ${({ theme }) => theme.text4}; - opacity: 0.6; -` - -const Input = styled.input` - position: relative; - display: flex; - padding: 16px; - align-items: center; - width: 100%; - white-space: nowrap; - background: none; - border: none; - outline: none; - border-radius: 20px; - color: ${({ theme }) => theme.text1}; - border-style: solid; - border: 1px solid ${({ theme }) => theme.bg3}; - -webkit-appearance: none; - - font-size: 18px; - - ::placeholder { - color: ${({ theme }) => theme.text3}; - } -` - -const FilterWrapper = styled(RowFixed)` - padding: 8px; - background-color: ${({ selected, theme }) => selected && theme.bg2}; - color: ${({ selected, theme }) => (selected ? theme.text1 : theme.text2)}; - border-radius: 8px; - user-select: none; - & > * { - user-select: none; - } - :hover { - cursor: pointer; - } -` - -const PaddedColumn = styled(AutoColumn)` - padding: 20px; - padding-bottom: 12px; -` - -const PaddedItem = styled(RowBetween)` - padding: 4px 20px; - height: 56px; -` - -const MenuItem = styled(PaddedItem)` - cursor: ${({ disabled }) => !disabled && 'pointer'}; - pointer-events: ${({ disabled }) => disabled && 'none'}; - :hover { - background-color: ${({ theme, disabled }) => !disabled && theme.bg2}; - } - opacity: ${({ disabled, selected }) => (disabled || selected ? 0.5 : 1)}; -` - -const BaseWrapper = styled(AutoRow)<{ disable?: boolean }>` - border: 1px solid ${({ theme, disable }) => (disable ? 'transparent' : theme.bg3)}; - padding: 0 6px; - border-radius: 10px; - width: 120px; - - :hover { - cursor: ${({ disable }) => !disable && 'pointer'}; - background-color: ${({ theme, disable }) => !disable && theme.bg2}; - } - - background-color: ${({ theme, disable }) => disable && theme.bg3}; - opacity: ${({ disable }) => disable && '0.4'}; -` - -// filters on results -const FILTERS = { - VOLUME: 'VOLUME', - LIQUIDITY: 'LIQUIDITY', - BALANCES: 'BALANCES' -} - -interface SearchModalProps extends RouteComponentProps<{}> { +interface SearchModalProps extends RouteComponentProps { isOpen?: boolean onDismiss?: () => void filterType?: 'tokens' hiddenToken?: string showSendWithSwap?: boolean onTokenSelect?: (address: string) => void - urlAddedTokens?: Token[] otherSelectedTokenAddress?: string otherSelectedText?: string showCommonBases?: boolean } +function isDefaultToken(tokenAddress: string, chainId?: number): boolean { + const address = isAddress(tokenAddress) + return Boolean(chainId && address && ALL_TOKENS[chainId as ChainId]?.[tokenAddress]) +} + function SearchModal({ history, isOpen, onDismiss, onTokenSelect, - urlAddedTokens, filterType, hiddenToken, showSendWithSwap, @@ -184,16 +80,10 @@ function SearchModal({ const [invertSearchOrder, setInvertSearchOrder] = useState(false) const userAddedTokens = useUserAddedTokens() - const fetchTokenByAddress = useFetchTokenByAddress() - const addToken = useAddUserToken() const removeTokenByAddress = useRemoveUserAddedToken() // if the current input is an address, and we don't have the token in context, try to fetch it - const token = useToken(searchQuery) - const [temporaryToken, setTemporaryToken] = useState() - - // filters for ordering - const [activeFilter, setActiveFilter] = useState(FILTERS.BALANCES) + const searchQueryToken = useTokenByAddressAndAutomaticallyAdd(searchQuery) // toggle specific token import view const [showTokenImport, setShowTokenImport] = useState(false) @@ -201,22 +91,6 @@ function SearchModal({ // used to help scanning on results, put token found from input on left const [identifiedToken, setIdentifiedToken] = useState() - useEffect(() => { - const address = isAddress(searchQuery) - if (address && !token) { - let stale = false - fetchTokenByAddress(address).then(token => { - if (!stale) { - setTemporaryToken(token) - } - }) - return () => { - stale = true - setTemporaryToken(null) - } - } - }, [searchQuery, token, fetchTokenByAddress]) - // reset view on close useEffect(() => { if (!isOpen) { @@ -224,45 +98,24 @@ function SearchModal({ } }, [isOpen]) - const tokenList = useMemo(() => { - return Object.keys(allTokens) - .sort((tokenAddressA, tokenAddressB): number => { - // -1 = a is first - // 1 = b is first + const tokenComparator = useTokenComparator(invertSearchOrder) - // sort ETH first - const a = allTokens[tokenAddressA] - const b = allTokens[tokenAddressB] - if (a.equals(WETH[chainId])) return -1 - if (b.equals(WETH[chainId])) return 1 - - // sort by balances - const balanceA = allBalances[account]?.[tokenAddressA] - const balanceB = allBalances[account]?.[tokenAddressB] - if (balanceA?.greaterThan('0') && !balanceB?.greaterThan('0')) return !invertSearchOrder ? -1 : 1 - if (!balanceA?.greaterThan('0') && balanceB?.greaterThan('0')) return !invertSearchOrder ? 1 : -1 - if (balanceA?.greaterThan('0') && balanceB?.greaterThan('0')) { - return balanceA.greaterThan(balanceB) ? (!invertSearchOrder ? -1 : 1) : !invertSearchOrder ? 1 : -1 - } - - // sort by symbol - return a.symbol.toLowerCase() < b.symbol.toLowerCase() ? -1 : 1 - }) - .map(tokenAddress => { - const token = allTokens[tokenAddress] + const sortedTokenList = useMemo(() => { + return Object.values(allTokens) + .sort(tokenComparator) + .map(token => { return { name: token.name, symbol: token.symbol, - address: isAddress(tokenAddress) as string, - balance: allBalances?.[account]?.[tokenAddress] + address: token.address, + balance: allBalances[account]?.[token.address] } }) - }, [allTokens, chainId, allBalances, account, invertSearchOrder]) + }, [allTokens, tokenComparator, allBalances, account]) const filteredTokenList = useMemo(() => { - return tokenList.filter(tokenEntry => { - const urlAdded = urlAddedTokens?.some(token => token.address === tokenEntry.address) - const customAdded = userAddedTokens?.some(token => token.address === tokenEntry.address) && !urlAdded + return sortedTokenList.filter(tokenEntry => { + const customAdded = !isDefaultToken(tokenEntry.address, chainId) // if token import page dont show preset list, else show all const include = !showTokenImport || (showTokenImport && customAdded && searchQuery !== '') @@ -285,7 +138,7 @@ function SearchModal({ }) return regexMatches.some(m => m) }) - }, [tokenList, urlAddedTokens, userAddedTokens, showTokenImport, searchQuery]) + }, [sortedTokenList, chainId, showTokenImport, searchQuery]) function _onTokenSelect(address) { setSearchQuery('') @@ -313,18 +166,14 @@ function SearchModal({ // try to find an exact match by address if (searchQueryIsAddress) { const identifiedTokenByAddress = Object.values(allTokens).filter(token => { - if (searchQueryIsAddress && token.address === isAddress(searchQuery)) { - return true - } - return false + return searchQueryIsAddress && token.address === isAddress(searchQuery) }) if (identifiedTokenByAddress.length > 0) setIdentifiedToken(identifiedTokenByAddress[0]) } // try to find an exact match by symbol else { const identifiedTokenBySymbol = Object.values(allTokens).filter(token => { - if (token.symbol.slice(0, searchQuery.length).toLowerCase() === searchQuery.toLowerCase()) return true - return false + return token.symbol.slice(0, searchQuery.length).toLowerCase() === searchQuery.toLowerCase() }) if (identifiedTokenBySymbol.length > 0) setIdentifiedToken(identifiedTokenBySymbol[0]) } @@ -423,25 +272,22 @@ function SearchModal({ function renderTokenList() { if (filteredTokenList.length === 0) { if (isAddress(searchQuery)) { - if (temporaryToken === undefined) { - return Searching for Token... - } else if (temporaryToken === null) { - return Address is not a valid ERC-20 token. + if (!searchQueryToken) { + return Searching... } else { // a user found a token by search that isn't yet added to localstorage return ( { - addToken(temporaryToken) - _onTokenSelect(temporaryToken.address) + _onTokenSelect(searchQueryToken.address) }} > - + - {temporaryToken.symbol} + {searchQueryToken.symbol} (Found by search) @@ -453,8 +299,7 @@ function SearchModal({ } } else { return filteredTokenList.map(({ address, symbol, balance }) => { - const urlAdded = urlAddedTokens?.some(token => token.address === address) - const customAdded = userAddedTokens?.some(token => token.address === address) && !urlAdded + const customAdded = !isDefaultToken(address, chainId) const zeroBalance = balance && JSBI.equal(JSBI.BigInt(0), balance.raw) @@ -475,10 +320,7 @@ function SearchModal({ {otherSelectedTokenAddress === address && ({otherSelectedText})} - - {urlAdded && 'Added by URL'} - {customAdded && 'Added by user'} - + {customAdded && 'Added by user'} {customAdded && (
{ @@ -522,27 +364,6 @@ function SearchModal({ } } - const Filter = ({ title, filter, filterType }: { title: string; filter: string; filterType: string }) => { - return ( - { - setActiveFilter(filter) - setInvertSearchOrder(invertSearchOrder => !invertSearchOrder) - }} - selected={filter === activeFilter} - > - - {title} - - {filter === activeFilter && filterType === 'tokens' && ( - - {!invertSearchOrder ? '↓' : '↑'} - - )} - - ) - } - return ( - {filterType === 'tokens' ? 'Select A Token' : 'Select A Pool'} + {filterType === 'tokens' ? 'Select a token' : 'Select a pool'} @@ -621,11 +442,13 @@ function SearchModal({ {filterType === 'tokens' ? 'Token Name' : 'Pool Name'} - + {filterType === 'tokens' && ( + setInvertSearchOrder(iso => !iso)} + title={filterType === 'tokens' ? 'Your Balances' : ' '} + /> + )} )} diff --git a/src/components/SearchModal/sorting.ts b/src/components/SearchModal/sorting.ts new file mode 100644 index 0000000000..6015538010 --- /dev/null +++ b/src/components/SearchModal/sorting.ts @@ -0,0 +1,41 @@ +import { Token, TokenAmount, WETH } from '@uniswap/sdk' +import { useMemo } from 'react' +import { useActiveWeb3React } from '../../hooks' +import { useAllTokenBalancesTreatingWETHasETH } from '../../state/wallet/hooks' + +function getTokenComparator( + weth: Token | undefined, + balances: { [tokenAddress: string]: TokenAmount }, + invertSearchOrder: boolean +): (tokenA: Token, tokenB: Token) => number { + return function sortTokens(tokenA: Token, tokenB: Token): number { + // -1 = a is first + // 1 = b is first + + // sort ETH first + if (weth) { + if (tokenA.equals(weth)) return -1 + if (tokenB.equals(weth)) return 1 + } + + // sort by balances + const balanceA = balances[tokenA.address] + const balanceB = balances[tokenB.address] + + if (balanceA?.greaterThan('0') && !balanceB?.greaterThan('0')) return !invertSearchOrder ? -1 : 1 + if (!balanceA?.greaterThan('0') && balanceB?.greaterThan('0')) return !invertSearchOrder ? 1 : -1 + if (balanceA?.greaterThan('0') && balanceB?.greaterThan('0')) { + return balanceA.greaterThan(balanceB) ? (!invertSearchOrder ? -1 : 1) : !invertSearchOrder ? 1 : -1 + } + + // sort by symbol + return tokenA.symbol.toLowerCase() < tokenB.symbol.toLowerCase() ? -1 : 1 + } +} + +export function useTokenComparator(inverted: boolean): (tokenA: Token, tokenB: Token) => number { + const { account, chainId } = useActiveWeb3React() + const weth = WETH[chainId] + const balances = useAllTokenBalancesTreatingWETHasETH() + return useMemo(() => getTokenComparator(weth, balances[account] ?? {}, inverted), [account, balances, inverted, weth]) +} diff --git a/src/components/SearchModal/styleds.tsx b/src/components/SearchModal/styleds.tsx new file mode 100644 index 0000000000..adf69e678e --- /dev/null +++ b/src/components/SearchModal/styleds.tsx @@ -0,0 +1,108 @@ +import styled from 'styled-components' +import { Spinner } from '../../theme' +import { AutoColumn } from '../Column' +import { AutoRow, RowBetween, RowFixed } from '../Row' + +export const TokenModalInfo = styled.div` + ${({ theme }) => theme.flexRowNoWrap} + align-items: center; + padding: 1rem 1rem; + margin: 0.25rem 0.5rem; + justify-content: center; + user-select: none; + min-height: 200px; +` + +export const ItemList = styled.div` + flex-grow: 1; + height: 254px; + overflow-y: scroll; + -webkit-overflow-scrolling: touch; +` + +export const FadedSpan = styled(RowFixed)` + color: ${({ theme }) => theme.primary1}; + font-size: 14px; +` + +export const GreySpan = styled.span` + color: ${({ theme }) => theme.text3}; + font-weight: 400; +` + +export const SpinnerWrapper = styled(Spinner)` + margin: 0 0.25rem 0 0.25rem; + color: ${({ theme }) => theme.text4}; + opacity: 0.6; +` + +export const Input = styled.input` + position: relative; + display: flex; + padding: 16px; + align-items: center; + width: 100%; + white-space: nowrap; + background: none; + border: none; + outline: none; + border-radius: 20px; + color: ${({ theme }) => theme.text1}; + border-style: solid; + border: 1px solid ${({ theme }) => theme.bg3}; + -webkit-appearance: none; + + font-size: 18px; + + ::placeholder { + color: ${({ theme }) => theme.text3}; + } +` + +export const FilterWrapper = styled(RowFixed)` + padding: 8px; + background-color: ${({ selected, theme }) => selected && theme.bg2}; + color: ${({ selected, theme }) => (selected ? theme.text1 : theme.text2)}; + border-radius: 8px; + user-select: none; + & > * { + user-select: none; + } + :hover { + cursor: pointer; + } +` + +export const PaddedColumn = styled(AutoColumn)` + padding: 20px; + padding-bottom: 12px; +` + +const PaddedItem = styled(RowBetween)` + padding: 4px 20px; + height: 56px; +` + +export const MenuItem = styled(PaddedItem)` + cursor: ${({ disabled }) => !disabled && 'pointer'}; + pointer-events: ${({ disabled }) => disabled && 'none'}; + :hover { + background-color: ${({ theme, disabled }) => !disabled && theme.bg2}; + } + opacity: ${({ disabled, selected }) => (disabled || selected ? 0.5 : 1)}; +` + +export const BaseWrapper = styled(AutoRow)<{ disable?: boolean }>` + border: 1px solid ${({ theme, disable }) => (disable ? 'transparent' : theme.bg3)}; + padding: 0 6px; + border-radius: 10px; + width: 120px; + + :hover { + cursor: ${({ disable }) => !disable && 'pointer'}; + background-color: ${({ theme, disable }) => !disable && theme.bg2}; + } + + background-color: ${({ theme, disable }) => disable && theme.bg3}; + opacity: ${({ disable }) => disable && '0.4'}; +`