Commit 12eb3374 authored by cartcrom's avatar cartcrom Committed by GitHub

fix: one point price charts + added suspense (#5030)

* Used suspense for graph queries
* cleaned up unused code
* updated skeleton
* fixed zach's pr comments
* removed console.log
* throw error on missing token details address
parent 44163f54
import { SparkLineLoadingBubble } from 'components/Tokens/TokenTable/TokenRow' import { SparkLineLoadingBubble } from 'components/Tokens/TokenTable/TokenRow'
import { curveCardinal, scaleLinear } from 'd3' import { curveCardinal, scaleLinear } from 'd3'
import { PricePoint } from 'graphql/data/TokenPrice'
import { SparklineMap, TopToken } from 'graphql/data/TopTokens' import { SparklineMap, TopToken } from 'graphql/data/TopTokens'
import { PricePoint } from 'graphql/data/util'
import { TimePeriod } from 'graphql/data/util' import { TimePeriod } from 'graphql/data/util'
import { memo } from 'react' import { memo } from 'react'
import styled, { useTheme } from 'styled-components/macro' import styled, { useTheme } from 'styled-components/macro'
......
import { Trans } from '@lingui/macro'
import { Currency, NativeCurrency, Token } from '@uniswap/sdk-core'
import { ParentSize } from '@visx/responsive' import { ParentSize } from '@visx/responsive'
import CurrencyLogo from 'components/CurrencyLogo' import { ChartContainer, LoadingChart } from 'components/Tokens/TokenDetails/Skeleton'
import { getChainInfo } from 'constants/chainInfo' import { TokenPriceQuery, tokenPriceQuery } from 'graphql/data/TokenPrice'
import { TokenQueryData } from 'graphql/data/Token' import { isPricePoint, PricePoint } from 'graphql/data/util'
import { PriceDurations } from 'graphql/data/TokenPrice' import { TimePeriod } from 'graphql/data/util'
import { TopToken } from 'graphql/data/TopTokens'
import { CHAIN_NAME_TO_CHAIN_ID } from 'graphql/data/util'
import { useAtomValue } from 'jotai/utils' import { useAtomValue } from 'jotai/utils'
import useCurrencyLogoURIs from 'lib/hooks/useCurrencyLogoURIs' import { startTransition, Suspense, useMemo, useState } from 'react'
import styled from 'styled-components/macro' import { PreloadedQuery, usePreloadedQuery } from 'react-relay'
import { textFadeIn } from 'theme/animations'
import { filterTimeAtom } from '../state' import { filterTimeAtom } from '../state'
import { L2NetworkLogo, LogoContainer } from '../TokenTable/TokenRow'
import PriceChart from './PriceChart' import PriceChart from './PriceChart'
import ShareButton from './ShareButton' import TimePeriodSelector from './TimeSelector'
export const ChartHeader = styled.div` function usePreloadedTokenPriceQuery(priceQueryReference: PreloadedQuery<TokenPriceQuery>): PricePoint[] | undefined {
width: 100%; const queryData = usePreloadedQuery(tokenPriceQuery, priceQueryReference)
display: flex;
flex-direction: column;
color: ${({ theme }) => theme.textPrimary};
gap: 4px;
margin-bottom: 24px;
`
export const TokenInfoContainer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`
export const ChartContainer = styled.div`
display: flex;
height: 436px;
align-items: center;
`
export const TokenNameCell = styled.div`
display: flex;
gap: 8px;
font-size: 20px;
line-height: 28px;
align-items: center;
${textFadeIn}
`
const TokenSymbol = styled.span`
text-transform: uppercase;
color: ${({ theme }) => theme.textSecondary};
`
const TokenActions = styled.div`
display: flex;
gap: 16px;
color: ${({ theme }) => theme.textSecondary};
`
export function useTokenLogoURI( // Appends the current price to the end of the priceHistory array
token: NonNullable<TokenQueryData> | NonNullable<TopToken>, const priceHistory = useMemo(() => {
nativeCurrency?: Token | NativeCurrency const market = queryData.tokens?.[0]?.market
) { const priceHistory = market?.priceHistory?.filter(isPricePoint)
const chainId = CHAIN_NAME_TO_CHAIN_ID[token.chain] const currentPrice = market?.price?.value
return [ if (Array.isArray(priceHistory) && currentPrice !== undefined) {
...useCurrencyLogoURIs(nativeCurrency), const timestamp = Date.now() / 1000
...useCurrencyLogoURIs({ ...token, chainId }), return [...priceHistory, { timestamp, value: currentPrice }]
token.project?.logoUrl, }
][0] return priceHistory
} }, [queryData])
return priceHistory
}
export default function ChartSection({ export default function ChartSection({
token, priceQueryReference,
currency, refetchTokenPrices,
nativeCurrency,
prices,
}: { }: {
token: NonNullable<TokenQueryData> priceQueryReference: PreloadedQuery<TokenPriceQuery> | null | undefined
currency?: Currency | null refetchTokenPrices: RefetchPricesFunction
nativeCurrency?: Token | NativeCurrency
prices?: PriceDurations
}) { }) {
const chainId = CHAIN_NAME_TO_CHAIN_ID[token.chain] if (!priceQueryReference) {
const L2Icon = getChainInfo(chainId)?.circleLogoUrl return <LoadingChart />
const timePeriod = useAtomValue(filterTimeAtom) }
const logoSrc = useTokenLogoURI(token, nativeCurrency)
return ( return (
<ChartHeader> <Suspense fallback={<LoadingChart />}>
<TokenInfoContainer>
<TokenNameCell>
<LogoContainer>
<CurrencyLogo
src={logoSrc}
size={'32px'}
symbol={nativeCurrency?.symbol ?? token.symbol}
currency={nativeCurrency ? undefined : currency}
/>
<L2NetworkLogo networkUrl={L2Icon} size={'16px'} />
</LogoContainer>
{nativeCurrency?.name ?? token.name ?? <Trans>Name not found</Trans>}
<TokenSymbol>{nativeCurrency?.symbol ?? token.symbol ?? <Trans>Symbol not found</Trans>}</TokenSymbol>
</TokenNameCell>
<TokenActions>
{token.name && token.symbol && token.address && <ShareButton token={token} isNative={!!nativeCurrency} />}
</TokenActions>
</TokenInfoContainer>
<ChartContainer> <ChartContainer>
<ParentSize> <Chart priceQueryReference={priceQueryReference} refetchTokenPrices={refetchTokenPrices} />
{({ width }) => <PriceChart prices={prices ? prices?.[timePeriod] : null} width={width} height={436} />}
</ParentSize>
</ChartContainer> </ChartContainer>
</ChartHeader> </Suspense>
)
}
export type RefetchPricesFunction = (t: TimePeriod) => void
function Chart({
priceQueryReference,
refetchTokenPrices,
}: {
priceQueryReference: PreloadedQuery<TokenPriceQuery>
refetchTokenPrices: RefetchPricesFunction
}) {
const prices = usePreloadedTokenPriceQuery(priceQueryReference)
// Initializes time period to global & maintain separate time period for subsequent changes
const [timePeriod, setTimePeriod] = useState(useAtomValue(filterTimeAtom))
return (
<ChartContainer>
<ParentSize>
{({ width }) => <PriceChart prices={prices ?? null} width={width} height={436} timePeriod={timePeriod} />}
</ParentSize>
<TimePeriodSelector
currentTimePeriod={timePeriod}
onTimeChange={(t: TimePeriod) => {
startTransition(() => refetchTokenPrices(t))
setTimePeriod(t)
}}
/>
</ChartContainer>
) )
} }
...@@ -5,12 +5,10 @@ import { EventType } from '@visx/event/lib/types' ...@@ -5,12 +5,10 @@ import { EventType } from '@visx/event/lib/types'
import { GlyphCircle } from '@visx/glyph' import { GlyphCircle } from '@visx/glyph'
import { Line } from '@visx/shape' import { Line } from '@visx/shape'
import AnimatedInLineChart from 'components/Charts/AnimatedInLineChart' import AnimatedInLineChart from 'components/Charts/AnimatedInLineChart'
import { filterTimeAtom } from 'components/Tokens/state'
import { bisect, curveCardinal, NumberValue, scaleLinear, timeDay, timeHour, timeMinute, timeMonth } from 'd3' import { bisect, curveCardinal, NumberValue, scaleLinear, timeDay, timeHour, timeMinute, timeMonth } from 'd3'
import { PricePoint } from 'graphql/data/TokenPrice' import { PricePoint } from 'graphql/data/util'
import { TimePeriod } from 'graphql/data/util' import { TimePeriod } from 'graphql/data/util'
import { useActiveLocale } from 'hooks/useActiveLocale' import { useActiveLocale } from 'hooks/useActiveLocale'
import { useAtom } from 'jotai'
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react' import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
import { ArrowDownRight, ArrowUpRight, TrendingUp } from 'react-feather' import { ArrowDownRight, ArrowUpRight, TrendingUp } from 'react-feather'
import styled, { useTheme } from 'styled-components/macro' import styled, { useTheme } from 'styled-components/macro'
...@@ -24,9 +22,6 @@ import { ...@@ -24,9 +22,6 @@ import {
} from 'utils/formatChartTimes' } from 'utils/formatChartTimes'
import { formatDollar } from 'utils/formatNumbers' import { formatDollar } from 'utils/formatNumbers'
import { MEDIUM_MEDIA_BREAKPOINT } from '../constants'
import { DISPLAYS, ORDERED_TIMES } from '../TokenTable/TimeSelector'
export const DATA_EMPTY = { value: 0, timestamp: 0 } export const DATA_EMPTY = { value: 0, timestamp: 0 }
export function getPriceBounds(pricePoints: PricePoint[]): [number, number] { export function getPriceBounds(pricePoints: PricePoint[]): [number, number] {
...@@ -86,46 +81,6 @@ const ArrowCell = styled.div` ...@@ -86,46 +81,6 @@ const ArrowCell = styled.div`
padding-left: 2px; padding-left: 2px;
display: flex; display: flex;
` `
export const TimeOptionsWrapper = styled.div`
display: flex;
justify-content: flex-end;
`
export const TimeOptionsContainer = styled.div`
display: flex;
justify-content: flex-end;
margin-top: 4px;
gap: 4px;
border: 1px solid ${({ theme }) => theme.backgroundOutline};
border-radius: 16px;
height: 40px;
padding: 4px;
width: fit-content;
@media only screen and (max-width: ${MEDIUM_MEDIA_BREAKPOINT}) {
width: 100%;
justify-content: space-between;
border: none;
}
`
const TimeButton = styled.button<{ active: boolean }>`
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: ${({ theme, active }) => (active ? theme.backgroundInteractive : 'transparent')};
font-weight: 600;
font-size: 16px;
padding: 6px 12px;
border-radius: 12px;
line-height: 20px;
border: none;
cursor: pointer;
color: ${({ theme, active }) => (active ? theme.textPrimary : theme.textSecondary)};
transition-duration: ${({ theme }) => theme.transition.duration.fast};
:hover {
${({ active, theme }) => !active && `opacity: ${theme.opacity.hover};`}
}
`
const margin = { top: 100, bottom: 48, crosshair: 72 } const margin = { top: 100, bottom: 48, crosshair: 72 }
const timeOptionsHeight = 44 const timeOptionsHeight = 44
...@@ -134,10 +89,10 @@ interface PriceChartProps { ...@@ -134,10 +89,10 @@ interface PriceChartProps {
width: number width: number
height: number height: number
prices: PricePoint[] | undefined | null prices: PricePoint[] | undefined | null
timePeriod: TimePeriod
} }
export function PriceChart({ width, height, prices }: PriceChartProps) { export function PriceChart({ width, height, prices, timePeriod }: PriceChartProps) {
const [timePeriod, setTimePeriod] = useAtom(filterTimeAtom)
const locale = useActiveLocale() const locale = useActiveLocale()
const theme = useTheme() const theme = useTheme()
...@@ -282,9 +237,7 @@ export function PriceChart({ width, height, prices }: PriceChartProps) { ...@@ -282,9 +237,7 @@ export function PriceChart({ width, height, prices }: PriceChartProps) {
width={width} width={width}
height={graphHeight} height={graphHeight}
message={ message={
prices === null ? ( prices?.length === 0 ? (
<Trans>Loading chart data</Trans>
) : prices?.length === 0 ? (
<Trans>This token doesn&apos;t have chart data because it hasn&apos;t been traded on Uniswap v3</Trans> <Trans>This token doesn&apos;t have chart data because it hasn&apos;t been traded on Uniswap v3</Trans>
) : ( ) : (
<Trans>Missing chart data</Trans> <Trans>Missing chart data</Trans>
...@@ -375,21 +328,6 @@ export function PriceChart({ width, height, prices }: PriceChartProps) { ...@@ -375,21 +328,6 @@ export function PriceChart({ width, height, prices }: PriceChartProps) {
/> />
</svg> </svg>
)} )}
<TimeOptionsWrapper>
<TimeOptionsContainer>
{ORDERED_TIMES.map((time) => (
<TimeButton
key={DISPLAYS[time]}
active={timePeriod === time}
onClick={() => {
setTimePeriod(time)
}}
>
{DISPLAYS[time]}
</TimeButton>
))}
</TimeOptionsContainer>
</TimeOptionsWrapper>
</> </>
) )
} }
......
...@@ -3,11 +3,12 @@ import { WIDGET_WIDTH } from 'components/Widget' ...@@ -3,11 +3,12 @@ import { WIDGET_WIDTH } from 'components/Widget'
import { ArrowLeft } from 'react-feather' import { ArrowLeft } from 'react-feather'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import styled, { useTheme } from 'styled-components/macro' import styled, { useTheme } from 'styled-components/macro'
import { textFadeIn } from 'theme/animations'
import { LoadingBubble } from '../loading' import { LoadingBubble } from '../loading'
import { LogoContainer } from '../TokenTable/TokenRow'
import { AboutContainer, AboutHeader } from './About' import { AboutContainer, AboutHeader } from './About'
import { BreadcrumbNavLink } from './BreadcrumbNavLink' import { BreadcrumbNavLink } from './BreadcrumbNavLink'
import { ChartContainer, ChartHeader, TokenInfoContainer, TokenNameCell } from './ChartSection'
import { DeltaContainer, TokenPrice } from './PriceChart' import { DeltaContainer, TokenPrice } from './PriceChart'
import { StatPair, StatsWrapper, StatWrapper } from './StatsSection' import { StatPair, StatsWrapper, StatWrapper } from './StatsSection'
...@@ -49,12 +50,38 @@ export const RightPanel = styled.div` ...@@ -49,12 +50,38 @@ export const RightPanel = styled.div`
display: flex; display: flex;
} }
` `
const LoadingChartContainer = styled(ChartContainer)` export const ChartContainer = styled.div`
display: flex;
flex-direction: column;
height: 436px;
margin-bottom: 24px;
align-items: flex-start;
width: 100%;
`
const LoadingChartContainer = styled.div`
display: flex;
flex-direction: row;
align-items: flex-end;
border-bottom: 1px solid ${({ theme }) => theme.backgroundOutline}; border-bottom: 1px solid ${({ theme }) => theme.backgroundOutline};
height: 313px; // save 1px for the border-bottom (ie y-axis) height: 100%;
margin-bottom: 44px;
padding-bottom: 66px;
overflow: hidden; overflow: hidden;
` `
export const TokenInfoContainer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
`
export const TokenNameCell = styled.div`
display: flex;
gap: 8px;
font-size: 20px;
line-height: 28px;
align-items: center;
${textFadeIn}
`
/* Loading state bubbles */ /* Loading state bubbles */
const DetailBubble = styled(LoadingBubble)` const DetailBubble = styled(LoadingBubble)`
height: 16px; height: 16px;
...@@ -73,10 +100,13 @@ const TitleBubble = styled(DetailBubble)` ...@@ -73,10 +100,13 @@ const TitleBubble = styled(DetailBubble)`
width: 140px; width: 140px;
` `
const PriceBubble = styled(SquaredBubble)` const PriceBubble = styled(SquaredBubble)`
height: 40px; margin-top: 2px;
height: 38px;
` `
const DeltaBubble = styled(DetailBubble)` const DeltaBubble = styled(DetailBubble)`
margin-top: 6px;
width: 96px; width: 96px;
height: 20px;
` `
const SectionBubble = styled(SquaredBubble)` const SectionBubble = styled(SquaredBubble)`
width: 96px; width: 96px;
...@@ -105,6 +135,7 @@ const ChartAnimation = styled.div` ...@@ -105,6 +135,7 @@ const ChartAnimation = styled.div`
animation: wave 8s cubic-bezier(0.36, 0.45, 0.63, 0.53) infinite; animation: wave 8s cubic-bezier(0.36, 0.45, 0.63, 0.53) infinite;
display: flex; display: flex;
overflow: hidden; overflow: hidden;
margin-top: 90px;
@keyframes wave { @keyframes wave {
0% { 0% {
...@@ -128,15 +159,9 @@ function Wave() { ...@@ -128,15 +159,9 @@ function Wave() {
) )
} }
function LoadingChart() { export function LoadingChart() {
return ( return (
<ChartHeader> <ChartContainer>
<TokenInfoContainer>
<TokenNameCell>
<TokenLogoBubble />
<TitleBubble />
</TokenNameCell>
</TokenInfoContainer>
<TokenPrice> <TokenPrice>
<PriceBubble /> <PriceBubble />
</TokenPrice> </TokenPrice>
...@@ -155,7 +180,7 @@ function LoadingChart() { ...@@ -155,7 +180,7 @@ function LoadingChart() {
</ChartAnimation> </ChartAnimation>
</div> </div>
</LoadingChartContainer> </LoadingChartContainer>
</ChartHeader> </ChartContainer>
) )
} }
...@@ -197,8 +222,17 @@ export default function TokenDetailsSkeleton() { ...@@ -197,8 +222,17 @@ export default function TokenDetailsSkeleton() {
<BreadcrumbNavLink to={{ chainName } ? `/tokens/${chainName}` : `/explore`}> <BreadcrumbNavLink to={{ chainName } ? `/tokens/${chainName}` : `/explore`}>
<ArrowLeft size={14} /> Tokens <ArrowLeft size={14} /> Tokens
</BreadcrumbNavLink> </BreadcrumbNavLink>
<TokenInfoContainer>
<TokenNameCell>
<LogoContainer>
<TokenLogoBubble />
</LogoContainer>
<TitleBubble />
</TokenNameCell>
</TokenInfoContainer>
<LoadingChart /> <LoadingChart />
<Space heightSize={45} />
<Space heightSize={4} />
<LoadingStats /> <LoadingStats />
<Hr /> <Hr />
<AboutContainer> <AboutContainer>
......
import { TimePeriod } from 'graphql/data/util'
import { startTransition, useState } from 'react'
import styled from 'styled-components/macro'
import { MEDIUM_MEDIA_BREAKPOINT } from '../constants'
import { DISPLAYS, ORDERED_TIMES } from '../TokenTable/TimeSelector'
export const TimeOptionsWrapper = styled.div`
display: flex;
width: 100%;
justify-content: flex-end;
`
export const TimeOptionsContainer = styled.div`
display: flex;
justify-content: flex-end;
margin-top: 4px;
gap: 4px;
border: 1px solid ${({ theme }) => theme.backgroundOutline};
border-radius: 16px;
height: 40px;
padding: 4px;
width: fit-content;
@media only screen and (max-width: ${MEDIUM_MEDIA_BREAKPOINT}) {
width: 100%;
justify-content: space-between;
border: none;
}
`
const TimeButton = styled.button<{ active: boolean }>`
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: ${({ theme, active }) => (active ? theme.backgroundInteractive : 'transparent')};
font-weight: 600;
font-size: 16px;
padding: 6px 12px;
border-radius: 12px;
line-height: 20px;
border: none;
cursor: pointer;
color: ${({ theme, active }) => (active ? theme.textPrimary : theme.textSecondary)};
transition-duration: ${({ theme }) => theme.transition.duration.fast};
:hover {
${({ active, theme }) => !active && `opacity: ${theme.opacity.hover};`}
}
`
export default function TimePeriodSelector({
currentTimePeriod,
onTimeChange,
}: {
currentTimePeriod: TimePeriod
onTimeChange: (t: TimePeriod) => void
}) {
const [timePeriod, setTimePeriod] = useState(currentTimePeriod)
return (
<TimeOptionsWrapper>
<TimeOptionsContainer>
{ORDERED_TIMES.map((time) => (
<TimeButton
key={DISPLAYS[time]}
active={timePeriod === time}
onClick={() => {
startTransition(() => onTimeChange(time))
setTimePeriod(time)
}}
>
{DISPLAYS[time]}
</TimeButton>
))}
</TimeOptionsContainer>
</TimeOptionsWrapper>
)
}
import { Trans } from '@lingui/macro'
import { Currency, NativeCurrency, Token } from '@uniswap/sdk-core'
import { PageName } from 'analytics/constants'
import { Trace } from 'analytics/Trace'
import CurrencyLogo from 'components/CurrencyLogo'
import { AboutSection } from 'components/Tokens/TokenDetails/About'
import AddressSection from 'components/Tokens/TokenDetails/AddressSection'
import BalanceSummary from 'components/Tokens/TokenDetails/BalanceSummary'
import { BreadcrumbNavLink } from 'components/Tokens/TokenDetails/BreadcrumbNavLink'
import ChartSection from 'components/Tokens/TokenDetails/ChartSection'
import MobileBalanceSummaryFooter from 'components/Tokens/TokenDetails/MobileBalanceSummaryFooter'
import ShareButton from 'components/Tokens/TokenDetails/ShareButton'
import TokenDetailsSkeleton, {
Hr,
LeftPanel,
RightPanel,
TokenDetailsLayout,
TokenInfoContainer,
TokenNameCell,
} from 'components/Tokens/TokenDetails/Skeleton'
import StatsSection from 'components/Tokens/TokenDetails/StatsSection'
import { L2NetworkLogo, LogoContainer } from 'components/Tokens/TokenTable/TokenRow'
import TokenSafetyMessage from 'components/TokenSafety/TokenSafetyMessage'
import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal'
import Widget from 'components/Widget'
import { getChainInfo } from 'constants/chainInfo'
import { SupportedChainId } from 'constants/chains'
import { DEFAULT_ERC20_DECIMALS, 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 { QueryToken, tokenQuery, TokenQueryData } from 'graphql/data/Token'
import { TopToken } from 'graphql/data/TopTokens'
import { CHAIN_NAME_TO_CHAIN_ID } from 'graphql/data/util'
import { useIsUserAddedTokenOnChain } from 'hooks/Tokens'
import { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch'
import useCurrencyLogoURIs from 'lib/hooks/useCurrencyLogoURIs'
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 { RefetchPricesFunction } from './ChartSection'
const TokenSymbol = styled.span`
text-transform: uppercase;
color: ${({ theme }) => theme.textSecondary};
`
const TokenActions = styled.div`
display: flex;
gap: 16px;
color: ${({ theme }) => theme.textSecondary};
`
export function useTokenLogoURI(token?: TokenQueryData | TopToken, nativeCurrency?: Token | NativeCurrency) {
const chainId = token ? CHAIN_NAME_TO_CHAIN_ID[token.chain] : SupportedChainId.MAINNET
return [
...useCurrencyLogoURIs(nativeCurrency),
...useCurrencyLogoURIs({ ...token, chainId }),
token?.project?.logoUrl,
][0]
}
type TokenDetailsProps = {
tokenAddress: string | undefined
chain: Chain
tokenQueryReference: PreloadedQuery<TokenQuery>
priceQueryReference: PreloadedQuery<TokenPriceQuery> | null | undefined
refetchTokenPrices: RefetchPricesFunction
}
export default function TokenDetails({
tokenAddress,
chain,
tokenQueryReference,
priceQueryReference,
refetchTokenPrices,
}: TokenDetailsProps) {
if (!tokenAddress) {
throw new Error(`Invalid token details route: tokenAddress param is undefined`)
}
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 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 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
if (!address) return
startTokenTransition(() => navigate(`/tokens/${chainName}/${address}`))
},
[isNative, navigate, startTokenTransition, tokenQueryData?.project?.tokens]
)
useOnGlobalChainSwitch(navigateToTokenForChain)
const navigateToWidgetSelectedToken = useCallback(
(token: Currency) => {
const address = token.isNative ? NATIVE_CHAIN_ID : token.address
startTokenTransition(() => navigate(`/tokens/${chain.toLowerCase()}/${address}`))
},
[chain, navigate]
)
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 onReviewSwapClick = useCallback(
() => new Promise<boolean>((resolve) => (shouldShowSpeedbump ? setContinueSwap({ resolve }) : resolve(true))),
[shouldShowSpeedbump]
)
const onResolveSwap = useCallback(
(value: boolean) => {
continueSwap?.resolve(value)
setContinueSwap(undefined)
},
[continueSwap, setContinueSwap]
)
const logoSrc = useTokenLogoURI(tokenQueryData, isNative ? nativeCurrency : undefined)
const L2Icon = getChainInfo(pageChainId)?.circleLogoUrl
return (
<Trace page={PageName.TOKEN_DETAILS_PAGE} properties={{ tokenAddress, tokenName: token?.name }} shouldLogImpression>
<TokenDetailsLayout>
{tokenQueryData && !isPending ? (
<LeftPanel>
<BreadcrumbNavLink to={`/tokens/${chain.toLowerCase()}`}>
<ArrowLeft size={14} /> Tokens
</BreadcrumbNavLink>
<TokenInfoContainer>
<TokenNameCell>
<LogoContainer>
<CurrencyLogo
src={logoSrc}
size={'32px'}
symbol={isNative ? nativeCurrency?.symbol : token?.symbol}
currency={isNative ? nativeCurrency : token}
/>
<L2NetworkLogo networkUrl={L2Icon} size={'16px'} />
</LogoContainer>
{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 token={tokenQueryData} isNative={!!nativeCurrency} />
)}
</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}
/>
{!isNative && (
<>
<Hr />
<AboutSection
address={tokenQueryData.address ?? ''}
description={tokenQueryData.project?.description}
homepageUrl={tokenQueryData.project?.homepageUrl}
twitterName={tokenQueryData.project?.twitterName}
/>
<AddressSection address={tokenQueryData.address ?? ''} />
</>
)}
</LeftPanel>
) : (
<TokenDetailsSkeleton />
)}
<RightPanel>
<Widget
token={token ?? nativeCurrency}
onTokenChange={navigateToWidgetSelectedToken}
onReviewSwapClick={onReviewSwapClick}
/>
{tokenWarning && <TokenSafetyMessage tokenAddress={tokenAddress ?? ''} 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}
/>
)}
</TokenDetailsLayout>
</Trace>
)
}
...@@ -31,7 +31,7 @@ import { ...@@ -31,7 +31,7 @@ import {
TokenSortMethod, TokenSortMethod,
useSetSortMethod, useSetSortMethod,
} from '../state' } from '../state'
import { useTokenLogoURI } from '../TokenDetails/ChartSection' import { useTokenLogoURI } from '../TokenDetails'
import InfoTip from '../TokenDetails/InfoTip' import InfoTip from '../TokenDetails/InfoTip'
import { formatDelta, getDeltaArrow } from '../TokenDetails/PriceChart' import { formatDelta, getDeltaArrow } from '../TokenDetails/PriceChart'
......
import graphql from 'babel-plugin-relay/macro' import graphql from 'babel-plugin-relay/macro'
import { DEFAULT_ERC20_DECIMALS } from 'constants/tokens' import { DEFAULT_ERC20_DECIMALS } from 'constants/tokens'
import { useMemo } from 'react'
import { useLazyLoadQuery } from 'react-relay'
import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo' import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo'
import { Chain } from './__generated__/TokenPriceQuery.graphql' import { TokenQuery$data } from './__generated__/TokenQuery.graphql'
import { TokenQuery, TokenQuery$data } from './__generated__/TokenQuery.graphql'
import { CHAIN_NAME_TO_CHAIN_ID } from './util' import { CHAIN_NAME_TO_CHAIN_ID } from './util'
/* /*
...@@ -16,7 +13,7 @@ The difference between Token and TokenProject: ...@@ -16,7 +13,7 @@ The difference between Token and TokenProject:
TokenMarket is per-chain market data for contracts pulled from the graph. TokenMarket is per-chain market data for contracts pulled from the graph.
TokenProjectMarket is aggregated market data (aggregated over multiple dexes and centralized exchanges) that we get from coingecko. TokenProjectMarket is aggregated market data (aggregated over multiple dexes and centralized exchanges) that we get from coingecko.
*/ */
const tokenQuery = graphql` export const tokenQuery = graphql`
query TokenQuery($contract: ContractInput!) { query TokenQuery($contract: ContractInput!) {
tokens(contracts: [$contract]) { tokens(contracts: [$contract]) {
id @required(action: LOG) id @required(action: LOG)
...@@ -58,15 +55,10 @@ const tokenQuery = graphql` ...@@ -58,15 +55,10 @@ const tokenQuery = graphql`
} }
} }
` `
export type { Chain, ContractInput, TokenQuery } from './__generated__/TokenQuery.graphql'
export type TokenQueryData = NonNullable<TokenQuery$data['tokens']>[number] export type TokenQueryData = NonNullable<TokenQuery$data['tokens']>[number]
export function useTokenQuery(address: string, chain: Chain): TokenQueryData | undefined {
const contract = useMemo(() => ({ address: address.toLowerCase(), chain }), [address, chain])
const token = useLazyLoadQuery<TokenQuery>(tokenQuery, { contract }).tokens?.[0]
return token
}
// TODO: Return a QueryToken from useTokenQuery instead of TokenQueryData to make it more usable in Currency-centric interfaces. // TODO: Return a QueryToken from useTokenQuery instead of TokenQueryData to make it more usable in Currency-centric interfaces.
export class QueryToken extends WrappedTokenInfo { export class QueryToken extends WrappedTokenInfo {
constructor(data: NonNullable<TokenQueryData>) { constructor(data: NonNullable<TokenQueryData>) {
......
import graphql from 'babel-plugin-relay/macro' import graphql from 'babel-plugin-relay/macro'
import { useEffect, useMemo, useState } from 'react'
import { fetchQuery } from 'react-relay'
import { Chain, TokenPriceQuery } from './__generated__/TokenPriceQuery.graphql' // TODO: Implemnt this as a refetchable fragment on tokenQuery when backend adds support
import environment from './RelayEnvironment' export const tokenPriceQuery = graphql`
import { TimePeriod } from './util' query TokenPriceQuery($contract: ContractInput!, $duration: HistoryDuration!) {
const tokenPriceQuery = graphql`
query TokenPriceQuery($contract: ContractInput!) {
tokens(contracts: [$contract]) { tokens(contracts: [$contract]) {
market(currency: USD) { market(currency: USD) @required(action: LOG) {
priceHistory1H: priceHistory(duration: HOUR) { price {
timestamp value @required(action: LOG)
value
}
priceHistory1D: priceHistory(duration: DAY) {
timestamp
value
}
priceHistory1W: priceHistory(duration: WEEK) {
timestamp
value
}
priceHistory1M: priceHistory(duration: MONTH) {
timestamp
value
} }
priceHistory1Y: priceHistory(duration: YEAR) { priceHistory(duration: $duration) {
timestamp timestamp @required(action: LOG)
value value @required(action: LOG)
} }
} }
} }
} }
` `
export type { TokenPriceQuery } from './__generated__/TokenPriceQuery.graphql'
export type PricePoint = { timestamp: number; value: number }
export type PriceDurations = Partial<Record<TimePeriod, PricePoint[]>>
export function isPricePoint(p: { timestamp: number; value: number | null } | null): p is PricePoint {
return Boolean(p && p.value)
}
export function useTokenPriceQuery(address: string, chain: Chain): PriceDurations | undefined {
const contract = useMemo(() => ({ address: address.toLowerCase(), chain }), [address, chain])
const [prices, setPrices] = useState<PriceDurations>()
useEffect(() => {
const subscription = fetchQuery<TokenPriceQuery>(environment, tokenPriceQuery, { contract }).subscribe({
next: (response: TokenPriceQuery['response']) => {
const priceData = response.tokens?.[0]?.market
const prices = {
[TimePeriod.HOUR]: priceData?.priceHistory1H?.filter(isPricePoint),
[TimePeriod.DAY]: priceData?.priceHistory1D?.filter(isPricePoint),
[TimePeriod.WEEK]: priceData?.priceHistory1W?.filter(isPricePoint),
[TimePeriod.MONTH]: priceData?.priceHistory1M?.filter(isPricePoint),
[TimePeriod.YEAR]: priceData?.priceHistory1Y?.filter(isPricePoint),
}
// Ensure the latest price available is available for every TimePeriod.
const latests = Object.values(prices)
.map((prices) => prices?.slice(-1)?.[0] ?? null)
.filter(isPricePoint)
if (latests.length) {
const latest = latests.reduce((latest, pricePoint) =>
latest.timestamp > pricePoint.timestamp ? latest : pricePoint
)
Object.values(prices)
.filter((prices) => prices && prices.slice(-1)[0] !== latest)
.forEach((prices) => prices?.push(latest))
}
setPrices(prices)
},
})
return () => {
setPrices(undefined)
subscription.unsubscribe()
}
}, [contract])
return prices
}
...@@ -12,7 +12,7 @@ import { fetchQuery, useLazyLoadQuery, useRelayEnvironment } from 'react-relay' ...@@ -12,7 +12,7 @@ import { fetchQuery, useLazyLoadQuery, useRelayEnvironment } from 'react-relay'
import type { Chain, TopTokens100Query } from './__generated__/TopTokens100Query.graphql' import type { Chain, TopTokens100Query } from './__generated__/TopTokens100Query.graphql'
import { TopTokensSparklineQuery } from './__generated__/TopTokensSparklineQuery.graphql' import { TopTokensSparklineQuery } from './__generated__/TopTokensSparklineQuery.graphql'
import { isPricePoint, PricePoint } from './TokenPrice' import { isPricePoint, PricePoint } from './util'
import { CHAIN_NAME_TO_CHAIN_ID, toHistoryDuration, unwrapToken } from './util' import { CHAIN_NAME_TO_CHAIN_ID, toHistoryDuration, unwrapToken } from './util'
const topTokens100Query = graphql` const topTokens100Query = graphql`
...@@ -54,8 +54,8 @@ const tokenSparklineQuery = graphql` ...@@ -54,8 +54,8 @@ const tokenSparklineQuery = graphql`
address address
market(currency: USD) { market(currency: USD) {
priceHistory(duration: $duration) { priceHistory(duration: $duration) {
timestamp timestamp @required(action: LOG)
value value @required(action: LOG)
} }
} }
} }
......
...@@ -27,6 +27,12 @@ export function toHistoryDuration(timePeriod: TimePeriod): HistoryDuration { ...@@ -27,6 +27,12 @@ export function toHistoryDuration(timePeriod: TimePeriod): HistoryDuration {
} }
} }
export type PricePoint = { timestamp: number; value: number }
export function isPricePoint(p: PricePoint | null): p is PricePoint {
return p !== null
}
export const CHAIN_ID_TO_BACKEND_NAME: { [key: number]: Chain } = { export const CHAIN_ID_TO_BACKEND_NAME: { [key: number]: Chain } = {
[SupportedChainId.MAINNET]: 'ETHEREUM', [SupportedChainId.MAINNET]: 'ETHEREUM',
[SupportedChainId.GOERLI]: 'ETHEREUM_GOERLI', [SupportedChainId.GOERLI]: 'ETHEREUM_GOERLI',
......
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { Chain } from 'graphql/data/__generated__/TokenQuery.graphql' import { Chain } from 'graphql/data/Token'
import { chainIdToBackendName } from 'graphql/data/util' import { chainIdToBackendName } from 'graphql/data/util'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
......
...@@ -22,7 +22,6 @@ import ErrorBoundary from '../components/ErrorBoundary' ...@@ -22,7 +22,6 @@ import ErrorBoundary from '../components/ErrorBoundary'
import NavBar from '../components/NavBar' import NavBar from '../components/NavBar'
import Polling from '../components/Polling' import Polling from '../components/Polling'
import Popups from '../components/Popups' import Popups from '../components/Popups'
import { TokenDetailsPageSkeleton } from '../components/Tokens/TokenDetails/Skeleton'
import { useIsExpertMode } from '../state/user/hooks' import { useIsExpertMode } from '../state/user/hooks'
import DarkModeQueryParamReader from '../theme/DarkModeQueryParamReader' import DarkModeQueryParamReader from '../theme/DarkModeQueryParamReader'
import AddLiquidity from './AddLiquidity' import AddLiquidity from './AddLiquidity'
...@@ -183,14 +182,7 @@ export default function App() { ...@@ -183,14 +182,7 @@ export default function App() {
<Route path="tokens" element={<Tokens />}> <Route path="tokens" element={<Tokens />}>
<Route path=":chainName" /> <Route path=":chainName" />
</Route> </Route>
<Route <Route path="tokens/:chainName/:tokenAddress" element={<TokenDetails />} />
path="tokens/:chainName/:tokenAddress"
element={
<Suspense fallback={<TokenDetailsPageSkeleton />}>
<TokenDetails />
</Suspense>
}
/>
<Route <Route
path="vote/*" path="vote/*"
element={ element={
......
import { Currency, Token } from '@uniswap/sdk-core' import { filterTimeAtom } from 'components/Tokens/state'
import { PageName } from 'analytics/constants' import TokenDetails from 'components/Tokens/TokenDetails'
import { Trace } from 'analytics/Trace' import { TokenDetailsPageSkeleton } from 'components/Tokens/TokenDetails/Skeleton'
import { AboutSection } from 'components/Tokens/TokenDetails/About' import { NATIVE_CHAIN_ID, nativeOnChain } from 'constants/tokens'
import AddressSection from 'components/Tokens/TokenDetails/AddressSection' import { TokenQuery, tokenQuery } from 'graphql/data/Token'
import BalanceSummary from 'components/Tokens/TokenDetails/BalanceSummary' import { TokenPriceQuery, tokenPriceQuery } from 'graphql/data/TokenPrice'
import { BreadcrumbNavLink } from 'components/Tokens/TokenDetails/BreadcrumbNavLink' import { CHAIN_NAME_TO_CHAIN_ID, TimePeriod, toHistoryDuration, validateUrlChainParam } from 'graphql/data/util'
import ChartSection from 'components/Tokens/TokenDetails/ChartSection' import { useAtomValue } from 'jotai/utils'
import MobileBalanceSummaryFooter from 'components/Tokens/TokenDetails/MobileBalanceSummaryFooter' import { Suspense, useCallback, useEffect, useMemo } from 'react'
import TokenDetailsSkeleton, { import { useQueryLoader } from 'react-relay'
Hr, import { useParams } from 'react-router-dom'
LeftPanel,
RightPanel, export default function TokenDetailsPage() {
TokenDetailsLayout,
} from 'components/Tokens/TokenDetails/Skeleton'
import StatsSection from 'components/Tokens/TokenDetails/StatsSection'
import TokenSafetyMessage from 'components/TokenSafety/TokenSafetyMessage'
import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal'
import Widget from 'components/Widget'
import { DEFAULT_ERC20_DECIMALS, NATIVE_CHAIN_ID, nativeOnChain } from 'constants/tokens'
import { checkWarning } from 'constants/tokenSafety'
import { Chain } from 'graphql/data/__generated__/TokenQuery.graphql'
import { QueryToken, useTokenQuery } from 'graphql/data/Token'
import { useTokenPriceQuery } from 'graphql/data/TokenPrice'
import { CHAIN_NAME_TO_CHAIN_ID, validateUrlChainParam } from 'graphql/data/util'
import { useIsUserAddedTokenOnChain } from 'hooks/Tokens'
import { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch'
import { useCallback, useMemo, useState, useTransition } from 'react'
import { ArrowLeft } from 'react-feather'
import { useNavigate, useParams } from 'react-router-dom'
export default function TokenDetails() {
const { tokenAddress, chainName } = useParams<{ tokenAddress?: string; chainName?: string }>() const { tokenAddress, chainName } = useParams<{ tokenAddress?: string; chainName?: string }>()
const chain = validateUrlChainParam(chainName) const chain = validateUrlChainParam(chainName)
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 isNative = tokenAddress === NATIVE_CHAIN_ID
const tokenQueryData = useTokenQuery(isNative ? nativeCurrency.wrapped.address : tokenAddress ?? '', chain) const timePeriod = useAtomValue(filterTimeAtom)
const prices = useTokenPriceQuery(isNative ? nativeCurrency.wrapped.address : tokenAddress ?? '', chain) const [contract, duration] = useMemo(
const token = useMemo(() => { () => [
if (!tokenAddress) return undefined { address: isNative ? nativeOnChain(pageChainId).wrapped.address : tokenAddress ?? '', chain },
if (isNative) return nativeCurrency toHistoryDuration(timePeriod),
if (tokenQueryData) return new QueryToken(tokenQueryData) ],
return new Token(pageChainId, tokenAddress, DEFAULT_ERC20_DECIMALS) [chain, isNative, pageChainId, timePeriod, tokenAddress]
}, [isNative, nativeCurrency, pageChainId, tokenAddress, tokenQueryData])
const tokenWarning = tokenAddress ? checkWarning(tokenAddress) : null
const isBlockedToken = tokenWarning?.canProceed === false
const navigate = useNavigate()
// Wrapping navigate in a transition prevents Suspense from unnecessarily showing fallbacks again.
const [isPending, startTransition] = 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
if (!address) return
startTransition(() => navigate(`/tokens/${chainName}/${address}`))
},
[isNative, navigate, tokenQueryData?.project?.tokens]
)
useOnGlobalChainSwitch(navigateToTokenForChain)
const navigateToWidgetSelectedToken = useCallback(
(token: Currency) => {
const address = token.isNative ? NATIVE_CHAIN_ID : token.address
startTransition(() => navigate(`/tokens/${chainName}/${address}`))
},
[chainName, navigate]
) )
const [continueSwap, setContinueSwap] = useState<{ resolve: (value: boolean | PromiseLike<boolean>) => void }>() const [tokenQueryReference, loadTokenQuery] = useQueryLoader<TokenQuery>(tokenQuery)
const [priceQueryReference, loadPriceQuery] = useQueryLoader<TokenPriceQuery>(tokenPriceQuery)
// Show token safety modal if Swap-reviewing a warning token, at all times if the current token is blocked useEffect(() => {
const shouldShowSpeedbump = !useIsUserAddedTokenOnChain(tokenAddress, pageChainId) && tokenWarning !== null loadTokenQuery({ contract })
const onReviewSwapClick = useCallback( loadPriceQuery({ contract, duration })
() => new Promise<boolean>((resolve) => (shouldShowSpeedbump ? setContinueSwap({ resolve }) : resolve(true))), }, [contract, duration, loadPriceQuery, loadTokenQuery, timePeriod])
[shouldShowSpeedbump]
)
const onResolveSwap = useCallback( const refetchTokenPrices = useCallback(
(value: boolean) => { (t: TimePeriod) => {
continueSwap?.resolve(value) loadPriceQuery({ contract, duration: toHistoryDuration(t) })
setContinueSwap(undefined)
}, },
[continueSwap, setContinueSwap] [contract, loadPriceQuery]
) )
return ( if (!tokenQueryReference) {
<Trace page={PageName.TOKEN_DETAILS_PAGE} properties={{ tokenAddress, tokenName: chainName }} shouldLogImpression> return <TokenDetailsPageSkeleton />
<TokenDetailsLayout> }
{tokenQueryData && !isPending ? (
<LeftPanel>
<BreadcrumbNavLink to={`/tokens/${chainName}`}>
<ArrowLeft size={14} /> Tokens
</BreadcrumbNavLink>
<ChartSection
token={tokenQueryData}
currency={token}
nativeCurrency={isNative ? nativeCurrency : undefined}
prices={prices}
/>
<StatsSection
TVL={tokenQueryData.market?.totalValueLocked?.value}
volume24H={tokenQueryData.market?.volume24H?.value}
priceHigh52W={tokenQueryData.market?.priceHigh52W?.value}
priceLow52W={tokenQueryData.market?.priceLow52W?.value}
/>
{!isNative && (
<>
<Hr />
<AboutSection
address={tokenQueryData.address ?? ''}
description={tokenQueryData.project?.description}
homepageUrl={tokenQueryData.project?.homepageUrl}
twitterName={tokenQueryData.project?.twitterName}
/>
<AddressSection address={tokenQueryData.address ?? ''} />
</>
)}
</LeftPanel>
) : (
<TokenDetailsSkeleton />
)}
<RightPanel> return (
<Widget <Suspense fallback={<TokenDetailsPageSkeleton />}>
token={token ?? nativeCurrency} <TokenDetails
onTokenChange={navigateToWidgetSelectedToken} tokenAddress={tokenAddress}
onReviewSwapClick={onReviewSwapClick} chain={chain}
/> tokenQueryReference={tokenQueryReference}
{tokenWarning && <TokenSafetyMessage tokenAddress={tokenAddress ?? ''} warning={tokenWarning} />} priceQueryReference={priceQueryReference}
{token && <BalanceSummary token={token} />} refetchTokenPrices={refetchTokenPrices}
</RightPanel> />
{token && <MobileBalanceSummaryFooter token={token} />} </Suspense>
{tokenAddress && (
<TokenSafetyModal
isOpen={isBlockedToken || !!continueSwap}
tokenAddress={tokenAddress}
onContinue={() => onResolveSwap(true)}
onBlocked={() => navigate(-1)}
onCancel={() => onResolveSwap(false)}
showCancel={true}
/>
)}
</TokenDetailsLayout>
</Trace>
) )
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment