From 12eb337444ebbc37217834d08035c752d43c68c7 Mon Sep 17 00:00:00 2001 From: cartcrom <39385577+cartcrom@users.noreply.github.com> Date: Thu, 10 Nov 2022 11:00:32 -0500 Subject: [PATCH] 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 --- src/components/Charts/SparklineChart.tsx | 2 +- .../Tokens/TokenDetails/ChartSection.tsx | 155 +++++-------- .../Tokens/TokenDetails/PriceChart.tsx | 70 +----- .../Tokens/TokenDetails/Skeleton.tsx | 64 ++++-- .../Tokens/TokenDetails/TimeSelector.tsx | 76 +++++++ src/components/Tokens/TokenDetails/index.tsx | 215 ++++++++++++++++++ src/components/Tokens/TokenTable/TokenRow.tsx | 2 +- src/graphql/data/Token.ts | 14 +- src/graphql/data/TokenPrice.ts | 85 +------ src/graphql/data/TopTokens.ts | 6 +- src/graphql/data/util.ts | 6 + src/hooks/useGlobalChainSwitch.ts | 2 +- src/pages/App.tsx | 10 +- src/pages/TokenDetails/index.tsx | 179 ++++----------- 14 files changed, 473 insertions(+), 413 deletions(-) create mode 100644 src/components/Tokens/TokenDetails/TimeSelector.tsx create mode 100644 src/components/Tokens/TokenDetails/index.tsx diff --git a/src/components/Charts/SparklineChart.tsx b/src/components/Charts/SparklineChart.tsx index 0bf04c036f..b37b2a9a69 100644 --- a/src/components/Charts/SparklineChart.tsx +++ b/src/components/Charts/SparklineChart.tsx @@ -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' diff --git a/src/components/Tokens/TokenDetails/ChartSection.tsx b/src/components/Tokens/TokenDetails/ChartSection.tsx index b099fb4b35..e420a7b3c1 100644 --- a/src/components/Tokens/TokenDetails/ChartSection.tsx +++ b/src/components/Tokens/TokenDetails/ChartSection.tsx @@ -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): PricePoint[] | undefined { + const queryData = usePreloadedQuery(tokenPriceQuery, priceQueryReference) -export function useTokenLogoURI( - token: NonNullable | NonNullable, - 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 - currency?: Currency | null - nativeCurrency?: Token | NativeCurrency - prices?: PriceDurations + priceQueryReference: PreloadedQuery | 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 + } return ( - - - - - - - - {nativeCurrency?.name ?? token.name ?? Name not found} - {nativeCurrency?.symbol ?? token.symbol ?? Symbol not found} - - - {token.name && token.symbol && token.address && } - - + }> - - {({ width }) => } - + - + + ) +} + +export type RefetchPricesFunction = (t: TimePeriod) => void +function Chart({ + priceQueryReference, + refetchTokenPrices, +}: { + priceQueryReference: PreloadedQuery + 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 ( + + + {({ width }) => } + + { + startTransition(() => refetchTokenPrices(t)) + setTimePeriod(t) + }} + /> + ) } diff --git a/src/components/Tokens/TokenDetails/PriceChart.tsx b/src/components/Tokens/TokenDetails/PriceChart.tsx index 73065cd4d6..9a9d4441d5 100644 --- a/src/components/Tokens/TokenDetails/PriceChart.tsx +++ b/src/components/Tokens/TokenDetails/PriceChart.tsx @@ -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 ? ( - Loading chart data - ) : prices?.length === 0 ? ( + prices?.length === 0 ? ( This token doesn't have chart data because it hasn't been traded on Uniswap v3 ) : ( Missing chart data @@ -375,21 +328,6 @@ export function PriceChart({ width, height, prices }: PriceChartProps) { /> )} - - - {ORDERED_TIMES.map((time) => ( - { - setTimePeriod(time) - }} - > - {DISPLAYS[time]} - - ))} - - ) } diff --git a/src/components/Tokens/TokenDetails/Skeleton.tsx b/src/components/Tokens/TokenDetails/Skeleton.tsx index 58d6f8c6da..713bca2845 100644 --- a/src/components/Tokens/TokenDetails/Skeleton.tsx +++ b/src/components/Tokens/TokenDetails/Skeleton.tsx @@ -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 ( - - - - - - - + @@ -155,7 +180,7 @@ function LoadingChart() { - + ) } @@ -197,8 +222,17 @@ export default function TokenDetailsSkeleton() { Tokens + + + + + + + + - + +
diff --git a/src/components/Tokens/TokenDetails/TimeSelector.tsx b/src/components/Tokens/TokenDetails/TimeSelector.tsx new file mode 100644 index 0000000000..affacc8fcd --- /dev/null +++ b/src/components/Tokens/TokenDetails/TimeSelector.tsx @@ -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 ( + + + {ORDERED_TIMES.map((time) => ( + { + startTransition(() => onTimeChange(time)) + setTimePeriod(time) + }} + > + {DISPLAYS[time]} + + ))} + + + ) +} diff --git a/src/components/Tokens/TokenDetails/index.tsx b/src/components/Tokens/TokenDetails/index.tsx new file mode 100644 index 0000000000..6565e98f1d --- /dev/null +++ b/src/components/Tokens/TokenDetails/index.tsx @@ -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 + priceQueryReference: PreloadedQuery | 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) => 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((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 ( + + + {tokenQueryData && !isPending ? ( + + + Tokens + + + + + + + + {token?.name ?? Name not found} + {token?.symbol ?? Symbol not found} + + + {tokenQueryData?.name && tokenQueryData.symbol && tokenQueryData.address && ( + + )} + + + + + {!isNative && ( + <> +
+ + + + )} +
+ ) : ( + + )} + + + + {tokenWarning && } + {token && } + + {token && } + + {tokenAddress && ( + onResolveSwap(true)} + onBlocked={() => navigate(-1)} + onCancel={() => onResolveSwap(false)} + showCancel={true} + /> + )} +
+
+ ) +} diff --git a/src/components/Tokens/TokenTable/TokenRow.tsx b/src/components/Tokens/TokenTable/TokenRow.tsx index e18894d1a7..239862b0be 100644 --- a/src/components/Tokens/TokenTable/TokenRow.tsx +++ b/src/components/Tokens/TokenTable/TokenRow.tsx @@ -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' diff --git a/src/graphql/data/Token.ts b/src/graphql/data/Token.ts index 2f56d8a930..2e71809baf 100644 --- a/src/graphql/data/Token.ts +++ b/src/graphql/data/Token.ts @@ -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[number] -export function useTokenQuery(address: string, chain: Chain): TokenQueryData | undefined { - const contract = useMemo(() => ({ address: address.toLowerCase(), chain }), [address, chain]) - const token = useLazyLoadQuery(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) { diff --git a/src/graphql/data/TokenPrice.ts b/src/graphql/data/TokenPrice.ts index c8910f1d4c..4c9f93a4df 100644 --- a/src/graphql/data/TokenPrice.ts +++ b/src/graphql/data/TokenPrice.ts @@ -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> - -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() - - useEffect(() => { - const subscription = fetchQuery(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' diff --git a/src/graphql/data/TopTokens.ts b/src/graphql/data/TopTokens.ts index 817a80bd9e..d9809672cd 100644 --- a/src/graphql/data/TopTokens.ts +++ b/src/graphql/data/TopTokens.ts @@ -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) } } } diff --git a/src/graphql/data/util.ts b/src/graphql/data/util.ts index b1b334541b..fcb07e820f 100644 --- a/src/graphql/data/util.ts +++ b/src/graphql/data/util.ts @@ -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', diff --git a/src/hooks/useGlobalChainSwitch.ts b/src/hooks/useGlobalChainSwitch.ts index b02e813beb..1e3f7cca10 100644 --- a/src/hooks/useGlobalChainSwitch.ts +++ b/src/hooks/useGlobalChainSwitch.ts @@ -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' diff --git a/src/pages/App.tsx b/src/pages/App.tsx index 4887ec2f2e..192fa225c6 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -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() { }> - }> - - - } - /> + } /> () 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) + const [priceQueryReference, loadPriceQuery] = useQueryLoader(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) => 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((resolve) => (shouldShowSpeedbump ? setContinueSwap({ resolve }) : resolve(true))), - [shouldShowSpeedbump] - ) - - const onResolveSwap = useCallback( - (value: boolean) => { - continueSwap?.resolve(value) - setContinueSwap(undefined) - }, - [continueSwap, setContinueSwap] - ) + if (!tokenQueryReference) { + return + } return ( - - - {tokenQueryData && !isPending ? ( - - - Tokens - - - - {!isNative && ( - <> -
- - - - )} -
- ) : ( - - )} - - - - {tokenWarning && } - {token && } - - {token && } - - {tokenAddress && ( - onResolveSwap(true)} - onBlocked={() => navigate(-1)} - onCancel={() => onResolveSwap(false)} - showCancel={true} - /> - )} -
-
+ }> + + ) }