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:
parent
1536e18784
commit
9662344e24
4
src/assets/svg/eye.svg
Normal file
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 |
54
src/components/Tokens/TokenDetails/InvalidTokenDetails.tsx
Normal file
54
src/components/Tokens/TokenDetails/InvalidTokenDetails.tsx
Normal file
@ -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'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.
|
||||
useEffect(() => {
|
||||
if (prices) {
|
||||
setDisplayPrice(endingPrice)
|
||||
}
|
||||
setDisplayPrice(endingPrice)
|
||||
}, [prices, endingPrice])
|
||||
const [crosshair, setCrosshair] = useState<number | null>(null)
|
||||
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { Trace } from '@uniswap/analytics'
|
||||
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 { AboutSection } from 'components/Tokens/TokenDetails/About'
|
||||
import AddressSection from 'components/Tokens/TokenDetails/AddressSection'
|
||||
@ -24,21 +25,24 @@ import TokenSafetyMessage from 'components/TokenSafety/TokenSafetyMessage'
|
||||
import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal'
|
||||
import Widget from 'components/Widget'
|
||||
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 { 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 { 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 { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch'
|
||||
import { UNKNOWN_TOKEN_SYMBOL, useTokenFromActiveNetwork } from 'lib/hooks/useCurrency'
|
||||
import { useCallback, useMemo, useState, useTransition } from 'react'
|
||||
import { ArrowLeft } from 'react-feather'
|
||||
import { PreloadedQuery, usePreloadedQuery } from 'react-relay'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components/macro'
|
||||
import { isAddress } from 'utils'
|
||||
|
||||
import { RefetchPricesFunction } from './ChartSection'
|
||||
import InvalidTokenDetails from './InvalidTokenDetails'
|
||||
|
||||
const TokenSymbol = styled.span`
|
||||
text-transform: uppercase;
|
||||
@ -50,56 +54,100 @@ const TokenActions = styled.div`
|
||||
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 = {
|
||||
tokenAddress: string | undefined
|
||||
urlAddress: string | undefined
|
||||
chain: Chain
|
||||
tokenQueryReference: PreloadedQuery<TokenQuery>
|
||||
priceQueryReference: PreloadedQuery<TokenPriceQuery> | null | undefined
|
||||
refetchTokenPrices: RefetchPricesFunction
|
||||
}
|
||||
export default function TokenDetails({
|
||||
tokenAddress,
|
||||
urlAddress,
|
||||
chain,
|
||||
tokenQueryReference,
|
||||
priceQueryReference,
|
||||
refetchTokenPrices,
|
||||
}: TokenDetailsProps) {
|
||||
if (!tokenAddress) {
|
||||
throw new Error(`Invalid token details route: tokenAddress param is undefined`)
|
||||
if (!urlAddress) {
|
||||
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 nativeCurrency = nativeOnChain(pageChainId)
|
||||
const isNative = tokenAddress === NATIVE_CHAIN_ID
|
||||
|
||||
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(() => {
|
||||
if (isNative) return nativeCurrency
|
||||
if (tokenQueryData) return new QueryToken(tokenQueryData)
|
||||
return new Token(pageChainId, tokenAddress, DEFAULT_ERC20_DECIMALS)
|
||||
}, [isNative, nativeCurrency, pageChainId, tokenAddress, tokenQueryData])
|
||||
const tokenWarning = tokenAddress ? checkWarning(tokenAddress) : null
|
||||
const { token, didFetchFromChain } = useRelevantToken(address, pageChainId, tokenQueryData)
|
||||
|
||||
const tokenWarning = address ? checkWarning(address) : null
|
||||
const isBlockedToken = tokenWarning?.canProceed === false
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Wrapping navigate in a transition prevents Suspense from unnecessarily showing fallbacks again.
|
||||
const [isPending, startTokenTransition] = useTransition()
|
||||
const navigateToTokenForChain = useCallback(
|
||||
(chain: 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
|
||||
(update: Chain) => {
|
||||
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)
|
||||
|
||||
const navigateToWidgetSelectedToken = useCallback(
|
||||
(token: Currency) => {
|
||||
const address = token.isNative ? NATIVE_CHAIN_ID : token.address
|
||||
startTokenTransition(() => navigate(`/tokens/${chain.toLowerCase()}/${address}`))
|
||||
startTokenTransition(() => navigate(getTokenDetailsURL(address, chain)))
|
||||
},
|
||||
[chain, navigate]
|
||||
)
|
||||
@ -107,7 +155,7 @@ export default function TokenDetails({
|
||||
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
|
||||
const shouldShowSpeedbump = !useIsUserAddedTokenOnChain(tokenAddress, pageChainId) && tokenWarning !== null
|
||||
const shouldShowSpeedbump = !useIsUserAddedTokenOnChain(address, pageChainId) && tokenWarning !== null
|
||||
const onReviewSwapClick = useCallback(
|
||||
() => new Promise<boolean>((resolve) => (shouldShowSpeedbump ? setContinueSwap({ resolve }) : resolve(true))),
|
||||
[shouldShowSpeedbump]
|
||||
@ -123,10 +171,18 @@ export default function TokenDetails({
|
||||
|
||||
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 (
|
||||
<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>
|
||||
{tokenQueryData && !isPending ? (
|
||||
{token && !isPending ? (
|
||||
<LeftPanel>
|
||||
<BreadcrumbNavLink to={`/tokens/${chain.toLowerCase()}`}>
|
||||
<ArrowLeft size={14} /> Tokens
|
||||
@ -137,32 +193,30 @@ export default function TokenDetails({
|
||||
<CurrencyLogo currency={token} size="32px" />
|
||||
<L2NetworkLogo networkUrl={L2Icon} size="16px" />
|
||||
</LogoContainer>
|
||||
{token?.name ?? <Trans>Name not found</Trans>}
|
||||
<TokenSymbol>{token?.symbol ?? <Trans>Symbol not found</Trans>}</TokenSymbol>
|
||||
{token.name ?? <Trans>Name not found</Trans>}
|
||||
<TokenSymbol>{token.symbol ?? <Trans>Symbol not found</Trans>}</TokenSymbol>
|
||||
</TokenNameCell>
|
||||
<TokenActions>
|
||||
{tokenQueryData?.name && tokenQueryData.symbol && tokenQueryData.address && (
|
||||
<ShareButton currency={token} />
|
||||
)}
|
||||
<ShareButton currency={token} />
|
||||
</TokenActions>
|
||||
</TokenInfoContainer>
|
||||
<ChartSection priceQueryReference={priceQueryReference} refetchTokenPrices={refetchTokenPrices} />
|
||||
<StatsSection
|
||||
TVL={tokenQueryData.market?.totalValueLocked?.value}
|
||||
volume24H={tokenQueryData.market?.volume24H?.value}
|
||||
priceHigh52W={tokenQueryData.market?.priceHigh52W?.value}
|
||||
priceLow52W={tokenQueryData.market?.priceLow52W?.value}
|
||||
TVL={tokenQueryData?.market?.totalValueLocked?.value}
|
||||
volume24H={tokenQueryData?.market?.volume24H?.value}
|
||||
priceHigh52W={tokenQueryData?.market?.priceHigh52W?.value}
|
||||
priceLow52W={tokenQueryData?.market?.priceLow52W?.value}
|
||||
/>
|
||||
{!isNative && (
|
||||
{!token.isNative && (
|
||||
<>
|
||||
<Hr />
|
||||
<AboutSection
|
||||
address={tokenQueryData.address ?? ''}
|
||||
description={tokenQueryData.project?.description}
|
||||
homepageUrl={tokenQueryData.project?.homepageUrl}
|
||||
twitterName={tokenQueryData.project?.twitterName}
|
||||
address={address}
|
||||
description={tokenQueryData?.project?.description}
|
||||
homepageUrl={tokenQueryData?.project?.homepageUrl}
|
||||
twitterName={tokenQueryData?.project?.twitterName}
|
||||
/>
|
||||
<AddressSection address={tokenQueryData.address ?? ''} />
|
||||
<AddressSection address={address} />
|
||||
</>
|
||||
)}
|
||||
</LeftPanel>
|
||||
@ -172,25 +226,23 @@ export default function TokenDetails({
|
||||
|
||||
<RightPanel>
|
||||
<Widget
|
||||
token={token ?? nativeCurrency}
|
||||
token={token ?? undefined}
|
||||
onTokenChange={navigateToWidgetSelectedToken}
|
||||
onReviewSwapClick={onReviewSwapClick}
|
||||
/>
|
||||
{tokenWarning && <TokenSafetyMessage tokenAddress={tokenAddress ?? ''} warning={tokenWarning} />}
|
||||
{tokenWarning && <TokenSafetyMessage tokenAddress={address} warning={tokenWarning} />}
|
||||
{token && <BalanceSummary token={token} />}
|
||||
</RightPanel>
|
||||
{token && <MobileBalanceSummaryFooter token={token} />}
|
||||
|
||||
{tokenAddress && (
|
||||
<TokenSafetyModal
|
||||
isOpen={isBlockedToken || !!continueSwap}
|
||||
tokenAddress={tokenAddress}
|
||||
onContinue={() => onResolveSwap(true)}
|
||||
onBlocked={() => navigate(-1)}
|
||||
onCancel={() => onResolveSwap(false)}
|
||||
showCancel={true}
|
||||
/>
|
||||
)}
|
||||
<TokenSafetyModal
|
||||
isOpen={isBlockedToken || !!continueSwap}
|
||||
tokenAddress={address}
|
||||
onContinue={() => onResolveSwap(true)}
|
||||
onBlocked={() => navigate(-1)}
|
||||
onCancel={() => onResolveSwap(false)}
|
||||
showCancel={true}
|
||||
/>
|
||||
</TokenDetailsLayout>
|
||||
</Trace>
|
||||
)
|
||||
|
@ -48,8 +48,8 @@ export const tokenQuery = graphql`
|
||||
twitterName
|
||||
logoUrl
|
||||
tokens {
|
||||
chain
|
||||
address
|
||||
chain @required(action: LOG)
|
||||
address @required(action: LOG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -25,6 +25,9 @@ function parseStringOrBytes32(str: string | undefined, bytes32: string | undefin
|
||||
: defaultValue
|
||||
}
|
||||
|
||||
export const UNKNOWN_TOKEN_SYMBOL = 'UNKNOWN'
|
||||
export const UNKNOWN_TOKEN_NAME = 'Unknown Token'
|
||||
|
||||
/**
|
||||
* Returns a Token from the tokenAddress.
|
||||
* 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 parsedSymbol = useMemo(
|
||||
() => parseStringOrBytes32(symbol.result?.[0], symbolBytes32.result?.[0], 'UNKNOWN'),
|
||||
() => parseStringOrBytes32(symbol.result?.[0], symbolBytes32.result?.[0], UNKNOWN_TOKEN_SYMBOL),
|
||||
[symbol.result, symbolBytes32.result]
|
||||
)
|
||||
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]
|
||||
)
|
||||
|
||||
|
@ -46,7 +46,7 @@ export default function TokenDetailsPage() {
|
||||
return (
|
||||
<Suspense fallback={<TokenDetailsPageSkeleton />}>
|
||||
<TokenDetails
|
||||
tokenAddress={tokenAddress}
|
||||
urlAddress={tokenAddress}
|
||||
chain={chain}
|
||||
tokenQueryReference={tokenQueryReference}
|
||||
priceQueryReference={priceQueryReference}
|
||||
|
Loading…
Reference in New Issue
Block a user