fix: render invalid address warnings on token details (#5260)

* initial commit
* polishing loading state
* small refactors
* polished on chain token network switching
* fixed commited merge conflict
* small refactor
* fixed merge conflicts
* PR comments and refactors
* fix unintented trace property change
* second round of comments
This commit is contained in:
cartcrom 2022-11-22 11:22:07 -05:00 committed by GitHub
parent 1536e18784
commit 9662344e24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 173 additions and 62 deletions

4
src/assets/svg/eye.svg Normal file

@ -0,0 +1,4 @@
<svg width="54" height="40" viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.3335 19.9997C1.3335 19.9997 10.6668 1.33301 27.0002 1.33301C43.3335 1.33301 52.6668 19.9997 52.6668 19.9997C52.6668 19.9997 43.3335 38.6663 27.0002 38.6663C10.6668 38.6663 1.3335 19.9997 1.3335 19.9997Z" stroke="#98A1C0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M27.0002 26.9997C30.8662 26.9997 34.0002 23.8657 34.0002 19.9997C34.0002 16.1337 30.8662 12.9997 27.0002 12.9997C23.1342 12.9997 20.0002 16.1337 20.0002 19.9997C20.0002 23.8657 23.1342 26.9997 27.0002 26.9997Z" stroke="#98A1C0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 705 B

@ -0,0 +1,54 @@
import { Trans } from '@lingui/macro'
import { useNavigate } from 'react-router-dom'
import styled from 'styled-components/macro'
import { ReactComponent as EyeIcon } from '../../../assets/svg/eye.svg'
const InvalidDetailsContainer = styled.div`
padding-top: 128px;
display: flex;
flex-direction: column;
align-items: center;
`
const InvalidDetailsText = styled.span`
margin-top: 28px;
margin-bottom: 20px;
text-align: center;
color: ${({ theme }) => theme.textSecondary};
font-size: 20px;
font-weight: 500;
line-height: 28px;
`
const TokenExploreButton = styled.button`
border: none;
border-radius: 12px;
background-color: ${({ theme }) => theme.accentAction};
padding: 12px 16px;
color: ${({ theme }) => theme.textPrimary};
font-size: 16px;
font-weight: 600;
`
export default function InvalidTokenDetails({ chainName }: { chainName?: string }) {
const navigate = useNavigate()
return (
<InvalidDetailsContainer>
<EyeIcon />
<InvalidDetailsText>
{chainName ? (
<Trans>{`This token doesn't exist on ${chainName}`}</Trans>
) : (
<Trans>This token doesn&apos;t exist</Trans>
)}
</InvalidDetailsText>
<TokenExploreButton onClick={() => navigate('/tokens')}>
<Trans>Explore tokens</Trans>
</TokenExploreButton>
</InvalidDetailsContainer>
)
}

@ -114,9 +114,7 @@ export function PriceChart({ width, height, prices, timePeriod }: PriceChartProp
// set display price to ending price when prices have changed. // set display price to ending price when prices have changed.
useEffect(() => { useEffect(() => {
if (prices) {
setDisplayPrice(endingPrice) setDisplayPrice(endingPrice)
}
}, [prices, endingPrice]) }, [prices, endingPrice])
const [crosshair, setCrosshair] = useState<number | null>(null) const [crosshair, setCrosshair] = useState<number | null>(null)

@ -1,7 +1,8 @@
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import { Trace } from '@uniswap/analytics' import { Trace } from '@uniswap/analytics'
import { PageName } from '@uniswap/analytics-events' import { PageName } from '@uniswap/analytics-events'
import { Currency, Token } from '@uniswap/sdk-core' import { Currency } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import CurrencyLogo from 'components/Logo/CurrencyLogo' import CurrencyLogo from 'components/Logo/CurrencyLogo'
import { AboutSection } from 'components/Tokens/TokenDetails/About' import { AboutSection } from 'components/Tokens/TokenDetails/About'
import AddressSection from 'components/Tokens/TokenDetails/AddressSection' import AddressSection from 'components/Tokens/TokenDetails/AddressSection'
@ -24,21 +25,24 @@ import TokenSafetyMessage from 'components/TokenSafety/TokenSafetyMessage'
import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal' import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal'
import Widget from 'components/Widget' import Widget from 'components/Widget'
import { getChainInfo } from 'constants/chainInfo' import { getChainInfo } from 'constants/chainInfo'
import { DEFAULT_ERC20_DECIMALS, NATIVE_CHAIN_ID, nativeOnChain } from 'constants/tokens' import { NATIVE_CHAIN_ID, nativeOnChain } from 'constants/tokens'
import { checkWarning } from 'constants/tokenSafety' import { checkWarning } from 'constants/tokenSafety'
import { TokenPriceQuery } from 'graphql/data/__generated__/TokenPriceQuery.graphql' import { TokenPriceQuery } from 'graphql/data/__generated__/TokenPriceQuery.graphql'
import { Chain, TokenQuery } from 'graphql/data/Token' import { Chain, TokenQuery, TokenQueryData } from 'graphql/data/Token'
import { QueryToken, tokenQuery } from 'graphql/data/Token' import { QueryToken, tokenQuery } from 'graphql/data/Token'
import { CHAIN_NAME_TO_CHAIN_ID } from 'graphql/data/util' import { CHAIN_NAME_TO_CHAIN_ID, getTokenDetailsURL } from 'graphql/data/util'
import { useIsUserAddedTokenOnChain } from 'hooks/Tokens' import { useIsUserAddedTokenOnChain } from 'hooks/Tokens'
import { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch' import { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch'
import { UNKNOWN_TOKEN_SYMBOL, useTokenFromActiveNetwork } from 'lib/hooks/useCurrency'
import { useCallback, useMemo, useState, useTransition } from 'react' import { useCallback, useMemo, useState, useTransition } from 'react'
import { ArrowLeft } from 'react-feather' import { ArrowLeft } from 'react-feather'
import { PreloadedQuery, usePreloadedQuery } from 'react-relay' import { PreloadedQuery, usePreloadedQuery } from 'react-relay'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
import { isAddress } from 'utils'
import { RefetchPricesFunction } from './ChartSection' import { RefetchPricesFunction } from './ChartSection'
import InvalidTokenDetails from './InvalidTokenDetails'
const TokenSymbol = styled.span` const TokenSymbol = styled.span`
text-transform: uppercase; text-transform: uppercase;
@ -50,56 +54,100 @@ const TokenActions = styled.div`
color: ${({ theme }) => theme.textSecondary}; color: ${({ theme }) => theme.textSecondary};
` `
function useOnChainToken(address: string | undefined, skip: boolean) {
const token = useTokenFromActiveNetwork(skip || !address ? undefined : address)
if (skip || !address || (token && token?.symbol === UNKNOWN_TOKEN_SYMBOL)) {
return undefined
} else {
return token
}
}
// Selects most relevant token based on data available, preferring native > query > on-chain
// Token will be null if still loading from on-chain, and undefined if unavailable
function useRelevantToken(
address: string | undefined,
pageChainId: number,
tokenQueryData: TokenQueryData | undefined
) {
const { chainId: activeChainId } = useWeb3React()
const queryToken = useMemo(() => {
if (!address) return undefined
if (address === NATIVE_CHAIN_ID) return nativeOnChain(pageChainId)
if (tokenQueryData) return new QueryToken(tokenQueryData)
return undefined
}, [pageChainId, address, tokenQueryData])
// fetches on-chain token if query data is missing and page chain matches global chain (else fetch won't work)
const skipOnChainFetch = Boolean(queryToken) || pageChainId !== activeChainId
const onChainToken = useOnChainToken(address, skipOnChainFetch)
return useMemo(
() => ({ token: queryToken ?? onChainToken, didFetchFromChain: !queryToken }),
[onChainToken, queryToken]
)
}
type TokenDetailsProps = { type TokenDetailsProps = {
tokenAddress: string | undefined urlAddress: string | undefined
chain: Chain chain: Chain
tokenQueryReference: PreloadedQuery<TokenQuery> tokenQueryReference: PreloadedQuery<TokenQuery>
priceQueryReference: PreloadedQuery<TokenPriceQuery> | null | undefined priceQueryReference: PreloadedQuery<TokenPriceQuery> | null | undefined
refetchTokenPrices: RefetchPricesFunction refetchTokenPrices: RefetchPricesFunction
} }
export default function TokenDetails({ export default function TokenDetails({
tokenAddress, urlAddress,
chain, chain,
tokenQueryReference, tokenQueryReference,
priceQueryReference, priceQueryReference,
refetchTokenPrices, refetchTokenPrices,
}: TokenDetailsProps) { }: TokenDetailsProps) {
if (!tokenAddress) { if (!urlAddress) {
throw new Error(`Invalid token details route: tokenAddress param is undefined`) throw new Error('Invalid token details route: tokenAddress param is undefined')
} }
const address = useMemo(
() => (urlAddress === NATIVE_CHAIN_ID ? urlAddress : isAddress(urlAddress) || undefined),
[urlAddress]
)
const pageChainId = CHAIN_NAME_TO_CHAIN_ID[chain] const pageChainId = CHAIN_NAME_TO_CHAIN_ID[chain]
const nativeCurrency = nativeOnChain(pageChainId)
const isNative = tokenAddress === NATIVE_CHAIN_ID
const tokenQueryData = usePreloadedQuery(tokenQuery, tokenQueryReference).tokens?.[0] const tokenQueryData = usePreloadedQuery(tokenQuery, tokenQueryReference).tokens?.[0]
const crossChainMap = useMemo(
() =>
tokenQueryData?.project?.tokens.reduce((map, current) => {
if (current) map[current.chain] = current.address
return map
}, {} as { [key: string]: string | undefined }) ?? {},
[tokenQueryData]
)
const token = useMemo(() => { const { token, didFetchFromChain } = useRelevantToken(address, pageChainId, tokenQueryData)
if (isNative) return nativeCurrency
if (tokenQueryData) return new QueryToken(tokenQueryData) const tokenWarning = address ? checkWarning(address) : null
return new Token(pageChainId, tokenAddress, DEFAULT_ERC20_DECIMALS)
}, [isNative, nativeCurrency, pageChainId, tokenAddress, tokenQueryData])
const tokenWarning = tokenAddress ? checkWarning(tokenAddress) : null
const isBlockedToken = tokenWarning?.canProceed === false const isBlockedToken = tokenWarning?.canProceed === false
const navigate = useNavigate() const navigate = useNavigate()
// Wrapping navigate in a transition prevents Suspense from unnecessarily showing fallbacks again. // Wrapping navigate in a transition prevents Suspense from unnecessarily showing fallbacks again.
const [isPending, startTokenTransition] = useTransition() const [isPending, startTokenTransition] = useTransition()
const navigateToTokenForChain = useCallback( const navigateToTokenForChain = useCallback(
(chain: Chain) => { (update: Chain) => {
const chainName = chain.toLowerCase()
const token = tokenQueryData?.project?.tokens.find((token) => token.chain === chain && token.address)
const address = isNative ? NATIVE_CHAIN_ID : token?.address
if (!address) return if (!address) return
startTokenTransition(() => navigate(`/tokens/${chainName}/${address}`)) const bridgedAddress = crossChainMap[update]
if (bridgedAddress) {
startTokenTransition(() => navigate(getTokenDetailsURL(bridgedAddress, update)))
} else if (didFetchFromChain || token?.isNative) {
startTokenTransition(() => navigate(getTokenDetailsURL(address, update)))
}
}, },
[isNative, navigate, startTokenTransition, tokenQueryData?.project?.tokens] [address, crossChainMap, didFetchFromChain, navigate, token?.isNative]
) )
useOnGlobalChainSwitch(navigateToTokenForChain) useOnGlobalChainSwitch(navigateToTokenForChain)
const navigateToWidgetSelectedToken = useCallback( const navigateToWidgetSelectedToken = useCallback(
(token: Currency) => { (token: Currency) => {
const address = token.isNative ? NATIVE_CHAIN_ID : token.address const address = token.isNative ? NATIVE_CHAIN_ID : token.address
startTokenTransition(() => navigate(`/tokens/${chain.toLowerCase()}/${address}`)) startTokenTransition(() => navigate(getTokenDetailsURL(address, chain)))
}, },
[chain, navigate] [chain, navigate]
) )
@ -107,7 +155,7 @@ export default function TokenDetails({
const [continueSwap, setContinueSwap] = useState<{ resolve: (value: boolean | PromiseLike<boolean>) => void }>() const [continueSwap, setContinueSwap] = useState<{ resolve: (value: boolean | PromiseLike<boolean>) => void }>()
// Show token safety modal if Swap-reviewing a warning token, at all times if the current token is blocked // Show token safety modal if Swap-reviewing a warning token, at all times if the current token is blocked
const shouldShowSpeedbump = !useIsUserAddedTokenOnChain(tokenAddress, pageChainId) && tokenWarning !== null const shouldShowSpeedbump = !useIsUserAddedTokenOnChain(address, pageChainId) && tokenWarning !== null
const onReviewSwapClick = useCallback( const onReviewSwapClick = useCallback(
() => new Promise<boolean>((resolve) => (shouldShowSpeedbump ? setContinueSwap({ resolve }) : resolve(true))), () => new Promise<boolean>((resolve) => (shouldShowSpeedbump ? setContinueSwap({ resolve }) : resolve(true))),
[shouldShowSpeedbump] [shouldShowSpeedbump]
@ -123,10 +171,18 @@ export default function TokenDetails({
const L2Icon = getChainInfo(pageChainId)?.circleLogoUrl const L2Icon = getChainInfo(pageChainId)?.circleLogoUrl
// address will never be undefined if token is defined; address is checked here to appease typechecker
if (token === undefined || !address) {
return <InvalidTokenDetails chainName={address && getChainInfo(pageChainId)?.label} />
}
return ( return (
<Trace page={PageName.TOKEN_DETAILS_PAGE} properties={{ tokenAddress, tokenName: token?.name }} shouldLogImpression> <Trace
page={PageName.TOKEN_DETAILS_PAGE}
properties={{ tokenAddress: address, tokenName: token?.name }}
shouldLogImpression
>
<TokenDetailsLayout> <TokenDetailsLayout>
{tokenQueryData && !isPending ? ( {token && !isPending ? (
<LeftPanel> <LeftPanel>
<BreadcrumbNavLink to={`/tokens/${chain.toLowerCase()}`}> <BreadcrumbNavLink to={`/tokens/${chain.toLowerCase()}`}>
<ArrowLeft size={14} /> Tokens <ArrowLeft size={14} /> Tokens
@ -137,32 +193,30 @@ export default function TokenDetails({
<CurrencyLogo currency={token} size="32px" /> <CurrencyLogo currency={token} size="32px" />
<L2NetworkLogo networkUrl={L2Icon} size="16px" /> <L2NetworkLogo networkUrl={L2Icon} size="16px" />
</LogoContainer> </LogoContainer>
{token?.name ?? <Trans>Name not found</Trans>} {token.name ?? <Trans>Name not found</Trans>}
<TokenSymbol>{token?.symbol ?? <Trans>Symbol not found</Trans>}</TokenSymbol> <TokenSymbol>{token.symbol ?? <Trans>Symbol not found</Trans>}</TokenSymbol>
</TokenNameCell> </TokenNameCell>
<TokenActions> <TokenActions>
{tokenQueryData?.name && tokenQueryData.symbol && tokenQueryData.address && (
<ShareButton currency={token} /> <ShareButton currency={token} />
)}
</TokenActions> </TokenActions>
</TokenInfoContainer> </TokenInfoContainer>
<ChartSection priceQueryReference={priceQueryReference} refetchTokenPrices={refetchTokenPrices} /> <ChartSection priceQueryReference={priceQueryReference} refetchTokenPrices={refetchTokenPrices} />
<StatsSection <StatsSection
TVL={tokenQueryData.market?.totalValueLocked?.value} TVL={tokenQueryData?.market?.totalValueLocked?.value}
volume24H={tokenQueryData.market?.volume24H?.value} volume24H={tokenQueryData?.market?.volume24H?.value}
priceHigh52W={tokenQueryData.market?.priceHigh52W?.value} priceHigh52W={tokenQueryData?.market?.priceHigh52W?.value}
priceLow52W={tokenQueryData.market?.priceLow52W?.value} priceLow52W={tokenQueryData?.market?.priceLow52W?.value}
/> />
{!isNative && ( {!token.isNative && (
<> <>
<Hr /> <Hr />
<AboutSection <AboutSection
address={tokenQueryData.address ?? ''} address={address}
description={tokenQueryData.project?.description} description={tokenQueryData?.project?.description}
homepageUrl={tokenQueryData.project?.homepageUrl} homepageUrl={tokenQueryData?.project?.homepageUrl}
twitterName={tokenQueryData.project?.twitterName} twitterName={tokenQueryData?.project?.twitterName}
/> />
<AddressSection address={tokenQueryData.address ?? ''} /> <AddressSection address={address} />
</> </>
)} )}
</LeftPanel> </LeftPanel>
@ -172,25 +226,23 @@ export default function TokenDetails({
<RightPanel> <RightPanel>
<Widget <Widget
token={token ?? nativeCurrency} token={token ?? undefined}
onTokenChange={navigateToWidgetSelectedToken} onTokenChange={navigateToWidgetSelectedToken}
onReviewSwapClick={onReviewSwapClick} onReviewSwapClick={onReviewSwapClick}
/> />
{tokenWarning && <TokenSafetyMessage tokenAddress={tokenAddress ?? ''} warning={tokenWarning} />} {tokenWarning && <TokenSafetyMessage tokenAddress={address} warning={tokenWarning} />}
{token && <BalanceSummary token={token} />} {token && <BalanceSummary token={token} />}
</RightPanel> </RightPanel>
{token && <MobileBalanceSummaryFooter token={token} />} {token && <MobileBalanceSummaryFooter token={token} />}
{tokenAddress && (
<TokenSafetyModal <TokenSafetyModal
isOpen={isBlockedToken || !!continueSwap} isOpen={isBlockedToken || !!continueSwap}
tokenAddress={tokenAddress} tokenAddress={address}
onContinue={() => onResolveSwap(true)} onContinue={() => onResolveSwap(true)}
onBlocked={() => navigate(-1)} onBlocked={() => navigate(-1)}
onCancel={() => onResolveSwap(false)} onCancel={() => onResolveSwap(false)}
showCancel={true} showCancel={true}
/> />
)}
</TokenDetailsLayout> </TokenDetailsLayout>
</Trace> </Trace>
) )

@ -48,8 +48,8 @@ export const tokenQuery = graphql`
twitterName twitterName
logoUrl logoUrl
tokens { tokens {
chain chain @required(action: LOG)
address address @required(action: LOG)
} }
} }
} }

@ -25,6 +25,9 @@ function parseStringOrBytes32(str: string | undefined, bytes32: string | undefin
: defaultValue : defaultValue
} }
export const UNKNOWN_TOKEN_SYMBOL = 'UNKNOWN'
export const UNKNOWN_TOKEN_NAME = 'Unknown Token'
/** /**
* Returns a Token from the tokenAddress. * Returns a Token from the tokenAddress.
* Returns null if token is loading or null was passed. * Returns null if token is loading or null was passed.
@ -51,11 +54,11 @@ export function useTokenFromActiveNetwork(tokenAddress: string | undefined): Tok
const parsedDecimals = useMemo(() => decimals?.result?.[0] ?? DEFAULT_ERC20_DECIMALS, [decimals.result]) const parsedDecimals = useMemo(() => decimals?.result?.[0] ?? DEFAULT_ERC20_DECIMALS, [decimals.result])
const parsedSymbol = useMemo( const parsedSymbol = useMemo(
() => parseStringOrBytes32(symbol.result?.[0], symbolBytes32.result?.[0], 'UNKNOWN'), () => parseStringOrBytes32(symbol.result?.[0], symbolBytes32.result?.[0], UNKNOWN_TOKEN_SYMBOL),
[symbol.result, symbolBytes32.result] [symbol.result, symbolBytes32.result]
) )
const parsedName = useMemo( const parsedName = useMemo(
() => parseStringOrBytes32(tokenName.result?.[0], tokenNameBytes32.result?.[0], 'Unknown Token'), () => parseStringOrBytes32(tokenName.result?.[0], tokenNameBytes32.result?.[0], UNKNOWN_TOKEN_NAME),
[tokenName.result, tokenNameBytes32.result] [tokenName.result, tokenNameBytes32.result]
) )

@ -46,7 +46,7 @@ export default function TokenDetailsPage() {
return ( return (
<Suspense fallback={<TokenDetailsPageSkeleton />}> <Suspense fallback={<TokenDetailsPageSkeleton />}>
<TokenDetails <TokenDetails
tokenAddress={tokenAddress} urlAddress={tokenAddress}
chain={chain} chain={chain}
tokenQueryReference={tokenQueryReference} tokenQueryReference={tokenQueryReference}
priceQueryReference={priceQueryReference} priceQueryReference={priceQueryReference}