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
This commit is contained in:
cartcrom 2022-11-10 11:00:32 -05:00 committed by GitHub
parent 44163f54b1
commit 12eb337444
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 473 additions and 413 deletions

@ -1,7 +1,7 @@
import { SparkLineLoadingBubble } from 'components/Tokens/TokenTable/TokenRow'
import { curveCardinal, scaleLinear } from 'd3'
import { PricePoint } from 'graphql/data/TokenPrice'
import { SparklineMap, TopToken } from 'graphql/data/TopTokens'
import { PricePoint } from 'graphql/data/util'
import { TimePeriod } from 'graphql/data/util'
import { memo } from 'react'
import styled, { useTheme } from 'styled-components/macro'

@ -1,112 +1,77 @@
import { Trans } from '@lingui/macro'
import { Currency, NativeCurrency, Token } from '@uniswap/sdk-core'
import { ParentSize } from '@visx/responsive'
import CurrencyLogo from 'components/CurrencyLogo'
import { getChainInfo } from 'constants/chainInfo'
import { TokenQueryData } from 'graphql/data/Token'
import { PriceDurations } from 'graphql/data/TokenPrice'
import { TopToken } from 'graphql/data/TopTokens'
import { CHAIN_NAME_TO_CHAIN_ID } from 'graphql/data/util'
import { ChartContainer, LoadingChart } from 'components/Tokens/TokenDetails/Skeleton'
import { TokenPriceQuery, tokenPriceQuery } from 'graphql/data/TokenPrice'
import { isPricePoint, PricePoint } from 'graphql/data/util'
import { TimePeriod } from 'graphql/data/util'
import { useAtomValue } from 'jotai/utils'
import useCurrencyLogoURIs from 'lib/hooks/useCurrencyLogoURIs'
import styled from 'styled-components/macro'
import { textFadeIn } from 'theme/animations'
import { startTransition, Suspense, useMemo, useState } from 'react'
import { PreloadedQuery, usePreloadedQuery } from 'react-relay'
import { filterTimeAtom } from '../state'
import { L2NetworkLogo, LogoContainer } from '../TokenTable/TokenRow'
import PriceChart from './PriceChart'
import ShareButton from './ShareButton'
import TimePeriodSelector from './TimeSelector'
export const ChartHeader = styled.div`
width: 100%;
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};
`
function usePreloadedTokenPriceQuery(priceQueryReference: PreloadedQuery<TokenPriceQuery>): PricePoint[] | undefined {
const queryData = usePreloadedQuery(tokenPriceQuery, priceQueryReference)
export function useTokenLogoURI(
token: NonNullable<TokenQueryData> | NonNullable<TopToken>,
nativeCurrency?: Token | NativeCurrency
) {
const chainId = CHAIN_NAME_TO_CHAIN_ID[token.chain]
return [
...useCurrencyLogoURIs(nativeCurrency),
...useCurrencyLogoURIs({ ...token, chainId }),
token.project?.logoUrl,
][0]
// Appends the current price to the end of the priceHistory array
const priceHistory = useMemo(() => {
const market = queryData.tokens?.[0]?.market
const priceHistory = market?.priceHistory?.filter(isPricePoint)
const currentPrice = market?.price?.value
if (Array.isArray(priceHistory) && currentPrice !== undefined) {
const timestamp = Date.now() / 1000
return [...priceHistory, { timestamp, value: currentPrice }]
}
return priceHistory
}, [queryData])
return priceHistory
}
export default function ChartSection({
token,
currency,
nativeCurrency,
prices,
priceQueryReference,
refetchTokenPrices,
}: {
token: NonNullable<TokenQueryData>
currency?: Currency | null
nativeCurrency?: Token | NativeCurrency
prices?: PriceDurations
priceQueryReference: PreloadedQuery<TokenPriceQuery> | null | undefined
refetchTokenPrices: RefetchPricesFunction
}) {
const chainId = CHAIN_NAME_TO_CHAIN_ID[token.chain]
const L2Icon = getChainInfo(chainId)?.circleLogoUrl
const timePeriod = useAtomValue(filterTimeAtom)
const logoSrc = useTokenLogoURI(token, nativeCurrency)
if (!priceQueryReference) {
return <LoadingChart />
}
return (
<ChartHeader>
<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>
<Suspense fallback={<LoadingChart />}>
<ChartContainer>
<ParentSize>
{({ width }) => <PriceChart prices={prices ? prices?.[timePeriod] : null} width={width} height={436} />}
</ParentSize>
<Chart priceQueryReference={priceQueryReference} refetchTokenPrices={refetchTokenPrices} />
</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'
import { GlyphCircle } from '@visx/glyph'
import { Line } from '@visx/shape'
import AnimatedInLineChart from 'components/Charts/AnimatedInLineChart'
import { filterTimeAtom } from 'components/Tokens/state'
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 { useActiveLocale } from 'hooks/useActiveLocale'
import { useAtom } from 'jotai'
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
import { ArrowDownRight, ArrowUpRight, TrendingUp } from 'react-feather'
import styled, { useTheme } from 'styled-components/macro'
@ -24,9 +22,6 @@ import {
} from 'utils/formatChartTimes'
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 function getPriceBounds(pricePoints: PricePoint[]): [number, number] {
@ -86,46 +81,6 @@ const ArrowCell = styled.div`
padding-left: 2px;
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 timeOptionsHeight = 44
@ -134,10 +89,10 @@ interface PriceChartProps {
width: number
height: number
prices: PricePoint[] | undefined | null
timePeriod: TimePeriod
}
export function PriceChart({ width, height, prices }: PriceChartProps) {
const [timePeriod, setTimePeriod] = useAtom(filterTimeAtom)
export function PriceChart({ width, height, prices, timePeriod }: PriceChartProps) {
const locale = useActiveLocale()
const theme = useTheme()
@ -282,9 +237,7 @@ export function PriceChart({ width, height, prices }: PriceChartProps) {
width={width}
height={graphHeight}
message={
prices === null ? (
<Trans>Loading chart data</Trans>
) : prices?.length === 0 ? (
prices?.length === 0 ? (
<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>
@ -375,21 +328,6 @@ export function PriceChart({ width, height, prices }: PriceChartProps) {
/>
</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'
import { ArrowLeft } from 'react-feather'
import { useParams } from 'react-router-dom'
import styled, { useTheme } from 'styled-components/macro'
import { textFadeIn } from 'theme/animations'
import { LoadingBubble } from '../loading'
import { LogoContainer } from '../TokenTable/TokenRow'
import { AboutContainer, AboutHeader } from './About'
import { BreadcrumbNavLink } from './BreadcrumbNavLink'
import { ChartContainer, ChartHeader, TokenInfoContainer, TokenNameCell } from './ChartSection'
import { DeltaContainer, TokenPrice } from './PriceChart'
import { StatPair, StatsWrapper, StatWrapper } from './StatsSection'
@ -49,12 +50,38 @@ export const RightPanel = styled.div`
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};
height: 313px; // save 1px for the border-bottom (ie y-axis)
height: 100%;
margin-bottom: 44px;
padding-bottom: 66px;
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 */
const DetailBubble = styled(LoadingBubble)`
height: 16px;
@ -73,10 +100,13 @@ const TitleBubble = styled(DetailBubble)`
width: 140px;
`
const PriceBubble = styled(SquaredBubble)`
height: 40px;
margin-top: 2px;
height: 38px;
`
const DeltaBubble = styled(DetailBubble)`
margin-top: 6px;
width: 96px;
height: 20px;
`
const SectionBubble = styled(SquaredBubble)`
width: 96px;
@ -105,6 +135,7 @@ const ChartAnimation = styled.div`
animation: wave 8s cubic-bezier(0.36, 0.45, 0.63, 0.53) infinite;
display: flex;
overflow: hidden;
margin-top: 90px;
@keyframes wave {
0% {
@ -128,15 +159,9 @@ function Wave() {
)
}
function LoadingChart() {
export function LoadingChart() {
return (
<ChartHeader>
<TokenInfoContainer>
<TokenNameCell>
<TokenLogoBubble />
<TitleBubble />
</TokenNameCell>
</TokenInfoContainer>
<ChartContainer>
<TokenPrice>
<PriceBubble />
</TokenPrice>
@ -155,7 +180,7 @@ function LoadingChart() {
</ChartAnimation>
</div>
</LoadingChartContainer>
</ChartHeader>
</ChartContainer>
)
}
@ -197,8 +222,17 @@ export default function TokenDetailsSkeleton() {
<BreadcrumbNavLink to={{ chainName } ? `/tokens/${chainName}` : `/explore`}>
<ArrowLeft size={14} /> Tokens
</BreadcrumbNavLink>
<TokenInfoContainer>
<TokenNameCell>
<LogoContainer>
<TokenLogoBubble />
</LogoContainer>
<TitleBubble />
</TokenNameCell>
</TokenInfoContainer>
<LoadingChart />
<Space heightSize={45} />
<Space heightSize={4} />
<LoadingStats />
<Hr />
<AboutContainer>

@ -0,0 +1,76 @@
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>
)
}

@ -0,0 +1,215 @@
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 {
TokenSortMethod,
useSetSortMethod,
} from '../state'
import { useTokenLogoURI } from '../TokenDetails/ChartSection'
import { useTokenLogoURI } from '../TokenDetails'
import InfoTip from '../TokenDetails/InfoTip'
import { formatDelta, getDeltaArrow } from '../TokenDetails/PriceChart'

@ -1,11 +1,8 @@
import graphql from 'babel-plugin-relay/macro'
import { DEFAULT_ERC20_DECIMALS } from 'constants/tokens'
import { useMemo } from 'react'
import { useLazyLoadQuery } from 'react-relay'
import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo'
import { Chain } from './__generated__/TokenPriceQuery.graphql'
import { TokenQuery, TokenQuery$data } from './__generated__/TokenQuery.graphql'
import { TokenQuery$data } from './__generated__/TokenQuery.graphql'
import { CHAIN_NAME_TO_CHAIN_ID } from './util'
/*
@ -16,7 +13,7 @@ The difference between Token and TokenProject:
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.
*/
const tokenQuery = graphql`
export const tokenQuery = graphql`
query TokenQuery($contract: ContractInput!) {
tokens(contracts: [$contract]) {
id @required(action: LOG)
@ -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 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.
export class QueryToken extends WrappedTokenInfo {
constructor(data: NonNullable<TokenQueryData>) {

@ -1,84 +1,19 @@
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'
import environment from './RelayEnvironment'
import { TimePeriod } from './util'
const tokenPriceQuery = graphql`
query TokenPriceQuery($contract: ContractInput!) {
// TODO: Implemnt this as a refetchable fragment on tokenQuery when backend adds support
export const tokenPriceQuery = graphql`
query TokenPriceQuery($contract: ContractInput!, $duration: HistoryDuration!) {
tokens(contracts: [$contract]) {
market(currency: USD) {
priceHistory1H: priceHistory(duration: HOUR) {
timestamp
value
market(currency: USD) @required(action: LOG) {
price {
value @required(action: LOG)
}
priceHistory1D: priceHistory(duration: DAY) {
timestamp
value
}
priceHistory1W: priceHistory(duration: WEEK) {
timestamp
value
}
priceHistory1M: priceHistory(duration: MONTH) {
timestamp
value
}
priceHistory1Y: priceHistory(duration: YEAR) {
timestamp
value
priceHistory(duration: $duration) {
timestamp @required(action: LOG)
value @required(action: LOG)
}
}
}
}
`
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
}
export type { TokenPriceQuery } from './__generated__/TokenPriceQuery.graphql'

@ -12,7 +12,7 @@ import { fetchQuery, useLazyLoadQuery, useRelayEnvironment } from 'react-relay'
import type { Chain, TopTokens100Query } from './__generated__/TopTokens100Query.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'
const topTokens100Query = graphql`
@ -54,8 +54,8 @@ const tokenSparklineQuery = graphql`
address
market(currency: USD) {
priceHistory(duration: $duration) {
timestamp
value
timestamp @required(action: LOG)
value @required(action: LOG)
}
}
}

@ -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 } = {
[SupportedChainId.MAINNET]: 'ETHEREUM',
[SupportedChainId.GOERLI]: 'ETHEREUM_GOERLI',

@ -1,5 +1,5 @@
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 { useEffect, useRef } from 'react'

@ -22,7 +22,6 @@ import ErrorBoundary from '../components/ErrorBoundary'
import NavBar from '../components/NavBar'
import Polling from '../components/Polling'
import Popups from '../components/Popups'
import { TokenDetailsPageSkeleton } from '../components/Tokens/TokenDetails/Skeleton'
import { useIsExpertMode } from '../state/user/hooks'
import DarkModeQueryParamReader from '../theme/DarkModeQueryParamReader'
import AddLiquidity from './AddLiquidity'
@ -183,14 +182,7 @@ export default function App() {
<Route path="tokens" element={<Tokens />}>
<Route path=":chainName" />
</Route>
<Route
path="tokens/:chainName/:tokenAddress"
element={
<Suspense fallback={<TokenDetailsPageSkeleton />}>
<TokenDetails />
</Suspense>
}
/>
<Route path="tokens/:chainName/:tokenAddress" element={<TokenDetails />} />
<Route
path="vote/*"
element={

@ -1,150 +1,57 @@
import { Currency, Token } from '@uniswap/sdk-core'
import { PageName } from 'analytics/constants'
import { Trace } from 'analytics/Trace'
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 TokenDetailsSkeleton, {
Hr,
LeftPanel,
RightPanel,
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'
import { filterTimeAtom } from 'components/Tokens/state'
import TokenDetails from 'components/Tokens/TokenDetails'
import { TokenDetailsPageSkeleton } from 'components/Tokens/TokenDetails/Skeleton'
import { NATIVE_CHAIN_ID, nativeOnChain } from 'constants/tokens'
import { TokenQuery, tokenQuery } from 'graphql/data/Token'
import { TokenPriceQuery, tokenPriceQuery } from 'graphql/data/TokenPrice'
import { CHAIN_NAME_TO_CHAIN_ID, TimePeriod, toHistoryDuration, validateUrlChainParam } from 'graphql/data/util'
import { useAtomValue } from 'jotai/utils'
import { Suspense, useCallback, useEffect, useMemo } from 'react'
import { useQueryLoader } from 'react-relay'
import { useParams } from 'react-router-dom'
export default function TokenDetails() {
export default function TokenDetailsPage() {
const { tokenAddress, chainName } = useParams<{ tokenAddress?: string; chainName?: string }>()
const chain = validateUrlChainParam(chainName)
const pageChainId = CHAIN_NAME_TO_CHAIN_ID[chain]
const nativeCurrency = nativeOnChain(pageChainId)
const isNative = tokenAddress === NATIVE_CHAIN_ID
const tokenQueryData = useTokenQuery(isNative ? nativeCurrency.wrapped.address : tokenAddress ?? '', chain)
const prices = useTokenPriceQuery(isNative ? nativeCurrency.wrapped.address : tokenAddress ?? '', chain)
const token = useMemo(() => {
if (!tokenAddress) return undefined
if (isNative) return nativeCurrency
if (tokenQueryData) return new QueryToken(tokenQueryData)
return new Token(pageChainId, tokenAddress, DEFAULT_ERC20_DECIMALS)
}, [isNative, nativeCurrency, pageChainId, tokenAddress, tokenQueryData])
const timePeriod = useAtomValue(filterTimeAtom)
const [contract, duration] = useMemo(
() => [
{ address: isNative ? nativeOnChain(pageChainId).wrapped.address : tokenAddress ?? '', chain },
toHistoryDuration(timePeriod),
],
[chain, isNative, pageChainId, timePeriod, tokenAddress]
)
const tokenWarning = tokenAddress ? checkWarning(tokenAddress) : null
const isBlockedToken = tokenWarning?.canProceed === false
const [tokenQueryReference, loadTokenQuery] = useQueryLoader<TokenQuery>(tokenQuery)
const [priceQueryReference, loadPriceQuery] = useQueryLoader<TokenPriceQuery>(tokenPriceQuery)
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}`))
useEffect(() => {
loadTokenQuery({ contract })
loadPriceQuery({ contract, duration })
}, [contract, duration, loadPriceQuery, loadTokenQuery, timePeriod])
const refetchTokenPrices = useCallback(
(t: TimePeriod) => {
loadPriceQuery({ contract, duration: toHistoryDuration(t) })
},
[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]
[contract, loadPriceQuery]
)
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]
)
if (!tokenQueryReference) {
return <TokenDetailsPageSkeleton />
}
return (
<Trace page={PageName.TOKEN_DETAILS_PAGE} properties={{ tokenAddress, tokenName: chainName }} shouldLogImpression>
<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>
<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>
<Suspense fallback={<TokenDetailsPageSkeleton />}>
<TokenDetails
tokenAddress={tokenAddress}
chain={chain}
tokenQueryReference={tokenQueryReference}
priceQueryReference={priceQueryReference}
refetchTokenPrices={refetchTokenPrices}
/>
</Suspense>
)
}