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:
parent
44163f54b1
commit
12eb337444
@ -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't have chart data because it hasn'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>
|
||||
|
76
src/components/Tokens/TokenDetails/TimeSelector.tsx
Normal file
76
src/components/Tokens/TokenDetails/TimeSelector.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
215
src/components/Tokens/TokenDetails/index.tsx
Normal file
215
src/components/Tokens/TokenDetails/index.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user