From 84070835df022ca16fd94f0e034b436b2b92c10f Mon Sep 17 00:00:00 2001 From: cartcrom <39385577+cartcrom@users.noreply.github.com> Date: Tue, 13 Sep 2022 13:09:12 -0400 Subject: [PATCH] feat: token data cache (#4534) * initial cache construction * switched to relay cache, updated hooks * fixed comments --- .../Tokens/TokenDetails/PriceChart.tsx | 67 ++-- .../Tokens/TokenDetails/TokenDetail.tsx | 42 ++- .../Tokens/TokenTable/TimeSelector.tsx | 2 +- src/components/Tokens/TokenTable/TokenRow.tsx | 31 +- .../Tokens/TokenTable/TokenTable.tsx | 75 ++-- src/components/Tokens/state.ts | 12 +- src/graphql/data/RelayEnvironment.ts | 38 +- src/graphql/data/Token.ts | 347 ++++++++++++++++++ src/graphql/data/TokenDetailQuery.ts | 89 ----- src/graphql/data/TokenPriceQuery.ts | 69 ---- src/graphql/data/TopTokenQuery.ts | 147 -------- src/hooks/useNetworkTokenBalances.ts | 2 - src/pages/TokenDetails/index.tsx | 19 +- src/pages/Tokens/index.tsx | 4 +- 14 files changed, 520 insertions(+), 424 deletions(-) create mode 100644 src/graphql/data/Token.ts delete mode 100644 src/graphql/data/TokenDetailQuery.ts delete mode 100644 src/graphql/data/TokenPriceQuery.ts delete mode 100644 src/graphql/data/TopTokenQuery.ts diff --git a/src/components/Tokens/TokenDetails/PriceChart.tsx b/src/components/Tokens/TokenDetails/PriceChart.tsx index bb2b6bfe3f..38835f694b 100644 --- a/src/components/Tokens/TokenDetails/PriceChart.tsx +++ b/src/components/Tokens/TokenDetails/PriceChart.tsx @@ -1,4 +1,3 @@ -import { Token } from '@uniswap/sdk-core' import { AxisBottom, TickFormatter } from '@visx/axis' import { localPoint } from '@visx/event' import { EventType } from '@visx/event/lib/types' @@ -6,11 +5,12 @@ import { GlyphCircle } from '@visx/glyph' import { Line } from '@visx/shape' import { filterTimeAtom } from 'components/Tokens/state' import { bisect, curveCardinal, NumberValue, scaleLinear, timeDay, timeHour, timeMinute, timeMonth } from 'd3' -import { useTokenPriceQuery } from 'graphql/data/TokenPriceQuery' -import { TimePeriod } from 'graphql/data/TopTokenQuery' +import { TokenPrices$key } from 'graphql/data/__generated__/TokenPrices.graphql' +import { useTokenPricesCached } from 'graphql/data/Token' +import { PricePoint, TimePeriod } from 'graphql/data/Token' import { useActiveLocale } from 'hooks/useActiveLocale' import { useAtom } from 'jotai' -import { useCallback, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { ArrowDownRight, ArrowUpRight } from 'react-feather' import styled, { useTheme } from 'styled-components/macro' import { OPACITY_HOVER } from 'theme' @@ -29,8 +29,6 @@ import { DISPLAYS, ORDERED_TIMES } from '../TokenTable/TimeSelector' // TODO: This should be combined with the logic in TimeSelector. -export type PricePoint = { value: number; timestamp: number } - export const DATA_EMPTY = { value: 0, timestamp: 0 } function getPriceBounds(pricePoints: PricePoint[]): [number, number] { @@ -72,7 +70,6 @@ export function formatDelta(delta: number) { export const ChartHeader = styled.div` position: absolute; ` - export const TokenPrice = styled.span` font-size: 36px; line-height: 44px; @@ -124,37 +121,41 @@ const crosshairDateOverhang = 80 interface PriceChartProps { width: number height: number - token: Token + tokenAddress: string + priceData?: TokenPrices$key | null } -export function PriceChart({ width, height, token }: PriceChartProps) { +export function PriceChart({ width, height, tokenAddress, priceData }: PriceChartProps) { const [timePeriod, setTimePeriod] = useAtom(filterTimeAtom) const locale = useActiveLocale() const theme = useTheme() - // TODO: Add network selector input, consider using backend type instead of current front end selector type - const pricePoints: PricePoint[] = useTokenPriceQuery(token.address, timePeriod, 'ETHEREUM').filter( - (p): p is PricePoint => Boolean(p && p.value) - ) + const { priceMap } = useTokenPricesCached(priceData, tokenAddress, 'ETHEREUM', timePeriod) + const prices = priceMap.get(timePeriod) - const hasData = pricePoints.length !== 0 - - /* TODO: Implement API calls & cache to use here */ - const startingPrice = hasData ? pricePoints[0] : DATA_EMPTY - const endingPrice = hasData ? pricePoints[pricePoints.length - 1] : DATA_EMPTY + const startingPrice = prices?.[0] ?? DATA_EMPTY + const endingPrice = prices?.[prices.length - 1] ?? DATA_EMPTY const [displayPrice, setDisplayPrice] = useState(startingPrice) const [crosshair, setCrosshair] = useState(null) const graphWidth = width + crosshairDateOverhang - // TODO: remove this logic after suspense is properly added const graphHeight = height - timeOptionsHeight > 0 ? height - timeOptionsHeight : 0 const graphInnerHeight = graphHeight - margin.top - margin.bottom > 0 ? graphHeight - margin.top - margin.bottom : 0 // Defining scales // x scale - const timeScale = scaleLinear().domain([startingPrice.timestamp, endingPrice.timestamp]).range([0, width]).nice() + const timeScale = useMemo( + () => scaleLinear().domain([startingPrice.timestamp, endingPrice.timestamp]).range([0, width]).nice(), + [startingPrice, endingPrice, width] + ) // y scale - const rdScale = scaleLinear().domain(getPriceBounds(pricePoints)).range([graphInnerHeight, 0]) + const rdScale = useMemo( + () => + scaleLinear() + .domain(getPriceBounds(prices ?? [])) + .range([graphInnerHeight, 0]), + [prices, graphInnerHeight] + ) function tickFormat( startTimestamp: number, @@ -206,16 +207,18 @@ export function PriceChart({ width, height, token }: PriceChartProps) { const handleHover = useCallback( (event: Element | EventType) => { + if (!prices) return + const { x } = localPoint(event) || { x: 0 } const x0 = timeScale.invert(x) // get timestamp from the scalexw const index = bisect( - pricePoints.map((x) => x.timestamp), + prices.map((x) => x.timestamp), x0, 1 ) - const d0 = pricePoints[index - 1] - const d1 = pricePoints[index] + const d0 = prices[index - 1] + const d1 = prices[index] let pricePoint = d0 const hasPreviousData = d1 && d1.timestamp @@ -226,7 +229,7 @@ export function PriceChart({ width, height, token }: PriceChartProps) { setCrosshair(timeScale(pricePoint.timestamp)) setDisplayPrice(pricePoint) }, - [timeScale, pricePoints] + [timeScale, prices] ) const resetDisplay = useCallback(() => { @@ -234,8 +237,8 @@ export function PriceChart({ width, height, token }: PriceChartProps) { setDisplayPrice(endingPrice) }, [setCrosshair, setDisplayPrice, endingPrice]) - // TODO: connect to loading state - if (!hasData) { + // TODO: Display no data available error + if (!prices) { return null } @@ -264,7 +267,7 @@ export function PriceChart({ width, height, token }: PriceChartProps) { timeScale(p.timestamp)} getY={(p: PricePoint) => rdScale(p.value)} marginTop={margin.top} @@ -335,7 +338,13 @@ export function PriceChart({ width, height, token }: PriceChartProps) { {ORDERED_TIMES.map((time) => ( - setTimePeriod(time)}> + { + setTimePeriod(time) + }} + > {DISPLAYS[time]} ))} diff --git a/src/components/Tokens/TokenDetails/TokenDetail.tsx b/src/components/Tokens/TokenDetails/TokenDetail.tsx index 2b9225a3da..47ebd52b32 100644 --- a/src/components/Tokens/TokenDetails/TokenDetail.tsx +++ b/src/components/Tokens/TokenDetails/TokenDetail.tsx @@ -7,9 +7,8 @@ import { VerifiedIcon } from 'components/TokenSafety/TokenSafetyIcon' import { getChainInfo } from 'constants/chainInfo' import { nativeOnChain, WRAPPED_NATIVE_CURRENCY } from 'constants/tokens' import { checkWarning } from 'constants/tokenSafety' -import { chainIdToChainName, useTokenDetailQuery } from 'graphql/data/TokenDetailQuery' +import { TokenQuery$data } from 'graphql/data/__generated__/TokenQuery.graphql' import { useCurrency, useToken } from 'hooks/Tokens' -import { useAtomValue } from 'jotai/utils' import { darken } from 'polished' import { Suspense } from 'react' import { useState } from 'react' @@ -18,7 +17,7 @@ import styled from 'styled-components/macro' import { CopyContractAddress } from 'theme' import { formatDollarAmount } from 'utils/formatDollarAmt' -import { filterNetworkAtom, useIsFavorited, useToggleFavorite } from '../state' +import { useIsFavorited, useToggleFavorite } from '../state' import { ClickFavorited, FavoriteIcon } from '../TokenTable/TokenRow' import LoadingTokenDetail from './LoadingTokenDetail' import Resource from './Resource' @@ -169,7 +168,7 @@ export function AboutSection({ address, tokenDetailData }: { address: string; to ) } -export default function LoadedTokenDetail({ address }: { address: string }) { +export default function LoadedTokenDetail({ address, query }: { address: string; query: TokenQuery$data }) { const { chainId: connectedChainId } = useWeb3React() const token = useToken(address) let currency = useCurrency(address) @@ -179,13 +178,14 @@ export default function LoadedTokenDetail({ address }: { address: string }) { const chainInfo = getChainInfo(token?.chainId) const networkLabel = chainInfo?.label const networkBadgebackgroundColor = chainInfo?.backgroundColor - const filterNetwork = useAtomValue(filterNetworkAtom) - const tokenDetailData = useTokenDetailQuery(address, chainIdToChainName(filterNetwork)) - const relevantTokenDetailData = (({ description, homepageUrl, twitterName }) => ({ - description, - homepageUrl, - twitterName, - }))(tokenDetailData) + + const tokenData = query.tokenProjects?.[0] + const tokenDetails = tokenData?.markets?.[0] + const relevantTokenDetailData = { + description: tokenData?.description, + homepageUrl: tokenData?.homepageUrl, + twitterName: tokenData?.twitterName, + } if (!token || !token.name || !token.symbol || !connectedChainId) { return @@ -198,10 +198,8 @@ export default function LoadedTokenDetail({ address }: { address: string }) { currency = nativeOnChain(connectedChainId) } - const tokenName = isWrappedNativeToken && currency ? currency.name : tokenDetailData.name - const defaultTokenSymbol = tokenDetailData.tokens?.[0]?.symbol ?? token.symbol - const tokenSymbol = isWrappedNativeToken && currency ? currency.symbol : defaultTokenSymbol - + const tokenName = tokenData?.name ?? token.name + const tokenSymbol = tokenData?.tokens?.[0]?.symbol ?? token.symbol return ( }> @@ -231,7 +229,11 @@ export default function LoadedTokenDetail({ address }: { address: string }) { - {({ width, height }) => } + + {({ width, height }) => ( + + )} + @@ -239,13 +241,13 @@ export default function LoadedTokenDetail({ address }: { address: string }) { Market cap - {tokenDetailData.marketCap?.value ? formatDollarAmount(tokenDetailData.marketCap?.value) : '-'} + {tokenDetails?.marketCap?.value ? formatDollarAmount(tokenDetails.marketCap?.value) : '-'} 24H volume - {tokenDetailData.volume24h?.value ? formatDollarAmount(tokenDetailData.volume24h?.value) : '-'} + {tokenDetails?.volume1D?.value ? formatDollarAmount(tokenDetails.volume1D.value) : '-'} @@ -253,13 +255,13 @@ export default function LoadedTokenDetail({ address }: { address: string }) { 52W low - {tokenDetailData.priceLow52W?.value ? formatDollarAmount(tokenDetailData.priceLow52W?.value) : '-'} + {tokenDetails?.priceLow52W?.value ? formatDollarAmount(tokenDetails.priceLow52W?.value) : '-'} 52W high - {tokenDetailData.priceHigh52W?.value ? formatDollarAmount(tokenDetailData.priceHigh52W?.value) : '-'} + {tokenDetails?.priceHigh52W?.value ? formatDollarAmount(tokenDetails.priceHigh52W?.value) : '-'} diff --git a/src/components/Tokens/TokenTable/TimeSelector.tsx b/src/components/Tokens/TokenTable/TimeSelector.tsx index ccf5e2005b..a79ff6cabd 100644 --- a/src/components/Tokens/TokenTable/TimeSelector.tsx +++ b/src/components/Tokens/TokenTable/TimeSelector.tsx @@ -1,4 +1,4 @@ -import { TimePeriod } from 'graphql/data/TopTokenQuery' +import { TimePeriod } from 'graphql/data/Token' import { useOnClickOutside } from 'hooks/useOnClickOutside' import { useAtom } from 'jotai' import { useRef } from 'react' diff --git a/src/components/Tokens/TokenTable/TokenRow.tsx b/src/components/Tokens/TokenTable/TokenRow.tsx index ef7a43cef1..09e73e3459 100644 --- a/src/components/Tokens/TokenTable/TokenRow.tsx +++ b/src/components/Tokens/TokenTable/TokenRow.tsx @@ -5,7 +5,8 @@ import { EventName } from 'components/AmplitudeAnalytics/constants' import SparklineChart from 'components/Charts/SparklineChart' import CurrencyLogo from 'components/CurrencyLogo' import { getChainInfo } from 'constants/chainInfo' -import { TimePeriod, TokenData } from 'graphql/data/TopTokenQuery' +import { getDurationDetails, SingleTokenData } from 'graphql/data/Token' +import { TimePeriod } from 'graphql/data/Token' import { useCurrency } from 'hooks/Tokens' import { useAtomValue } from 'jotai/utils' import { ReactNode } from 'react' @@ -454,29 +455,29 @@ export function LoadingRow() { /* Loaded State: row component with token information */ export default function LoadedRow({ - tokenAddress, tokenListIndex, tokenListLength, tokenData, timePeriod, }: { - tokenAddress: string tokenListIndex: number tokenListLength: number - tokenData: TokenData + tokenData: SingleTokenData timePeriod: TimePeriod }) { + const tokenAddress = tokenData?.tokens?.[0].address const currency = useCurrency(tokenAddress) - const tokenName = tokenData.name - const tokenSymbol = tokenData.symbol + const tokenName = tokenData?.name + const tokenSymbol = tokenData?.tokens?.[0].symbol const isFavorited = useIsFavorited(tokenAddress) const toggleFavorite = useToggleFavorite(tokenAddress) const filterString = useAtomValue(filterStringAtom) const filterNetwork = useAtomValue(filterNetworkAtom) const L2Icon = getChainInfo(filterNetwork).circleLogoUrl - const delta = tokenData.percentChange?.[timePeriod]?.value - const arrow = delta ? getDeltaArrow(delta) : null - const formattedDelta = delta ? formatDelta(delta) : null + const tokenDetails = tokenData?.markets?.[0] + const { volume, pricePercentChange } = getDurationDetails(tokenData, timePeriod) + const arrow = pricePercentChange ? getDeltaArrow(pricePercentChange) : null + const formattedDelta = pricePercentChange ? formatDelta(pricePercentChange) : null const exploreTokenSelectedEventProperties = { chain_id: filterNetwork, @@ -522,7 +523,7 @@ export default function LoadedRow({ price={ - {tokenData.price?.value ? formatDollarAmount(tokenData.price?.value) : '-'} + {tokenDetails?.price?.value ? formatDollarAmount(tokenDetails?.price?.value) : '-'} {formattedDelta} {arrow} @@ -538,16 +539,10 @@ export default function LoadedRow({ } marketCap={ - {tokenData.marketCap?.value ? formatDollarAmount(tokenData.marketCap?.value) : '-'} - - } - volume={ - - {tokenData.volume?.[timePeriod]?.value - ? formatDollarAmount(tokenData.volume?.[timePeriod]?.value ?? undefined) - : '-'} + {tokenDetails?.marketCap?.value ? formatDollarAmount(tokenDetails?.marketCap?.value) : '-'} } + volume={{volume ? formatDollarAmount(volume ?? undefined) : '-'}} sparkLine={ {({ width, height }) => } diff --git a/src/components/Tokens/TokenTable/TokenTable.tsx b/src/components/Tokens/TokenTable/TokenTable.tsx index 9165de3644..5005495617 100644 --- a/src/components/Tokens/TokenTable/TokenTable.tsx +++ b/src/components/Tokens/TokenTable/TokenTable.tsx @@ -7,7 +7,9 @@ import { sortCategoryAtom, sortDirectionAtom, } from 'components/Tokens/state' -import { TimePeriod, TokenData } from 'graphql/data/TopTokenQuery' +import { TokenTopQuery$data } from 'graphql/data/__generated__/TokenTopQuery.graphql' +import { getDurationDetails, SingleTokenData, useTopTokenQuery } from 'graphql/data/Token' +import { TimePeriod } from 'graphql/data/Token' import { useAtomValue } from 'jotai/utils' import { ReactNode, Suspense, useCallback, useMemo } from 'react' import { AlertTriangle } from 'react-feather' @@ -47,33 +49,37 @@ const TokenRowsContainer = styled.div` width: 100%; ` -function useFilteredTokens(tokens: TokenData[] | undefined) { +function useFilteredTokens(data: TokenTopQuery$data): SingleTokenData[] | undefined { const filterString = useAtomValue(filterStringAtom) - const favoriteTokenAddresses = useAtomValue(favoritesAtom) + const favorites = useAtomValue(favoritesAtom) const showFavorites = useAtomValue(showFavoritesAtom) - const shownTokens = - showFavorites && tokens ? tokens.filter((token) => favoriteTokenAddresses.includes(token.address)) : tokens return useMemo( () => - (shownTokens ?? []).filter((token) => { - if (!token.address) { - return false - } - if (!filterString) { - return true - } - const lowercaseFilterString = filterString.toLowerCase() - const addressIncludesFilterString = token?.address?.toLowerCase().includes(lowercaseFilterString) - const nameIncludesFilterString = token?.name?.toLowerCase().includes(lowercaseFilterString) - const symbolIncludesFilterString = token?.symbol?.toLowerCase().includes(lowercaseFilterString) - return nameIncludesFilterString || symbolIncludesFilterString || addressIncludesFilterString - }), - [shownTokens, filterString] + data.topTokenProjects + ?.filter( + (token) => !showFavorites || (token?.tokens?.[0].address && favorites.includes(token?.tokens?.[0].address)) + ) + .filter((token) => { + const tokenInfo = token?.tokens?.[0] + const address = tokenInfo?.address + if (!address) { + return false + } else if (!filterString) { + return true + } else { + const lowercaseFilterString = filterString.toLowerCase() + const addressIncludesFilterString = address?.toLowerCase().includes(lowercaseFilterString) + const nameIncludesFilterString = token?.name?.toLowerCase().includes(lowercaseFilterString) + const symbolIncludesFilterString = tokenInfo?.symbol?.toLowerCase().includes(lowercaseFilterString) + return nameIncludesFilterString || symbolIncludesFilterString || addressIncludesFilterString + } + }), + [data.topTokenProjects, favorites, filterString, showFavorites] ) } -function useSortedTokens(tokenData: TokenData[] | null) { +function useSortedTokens(tokenData: SingleTokenData[] | undefined) { const sortCategory = useAtomValue(sortCategoryAtom) const sortDirection = useAtomValue(sortDirectionAtom) const timePeriod = useAtomValue(filterTimeAtom) @@ -103,22 +109,25 @@ function useSortedTokens(tokenData: TokenData[] | null) { } let a: number | null | undefined let b: number | null | undefined + + const { volume: aVolume, pricePercentChange: aChange } = getDurationDetails(token1, timePeriod) + const { volume: bVolume, pricePercentChange: bChange } = getDurationDetails(token2, timePeriod) switch (sortCategory) { case Category.marketCap: - a = token1.marketCap?.value - b = token2.marketCap?.value + a = token1.markets?.[0]?.marketCap?.value + b = token2.markets?.[0]?.marketCap?.value break case Category.price: - a = token1.price?.value - b = token2.price?.value + a = token1.markets?.[0]?.price?.value + b = token2.markets?.[0]?.price?.value break case Category.volume: - a = token1.volume?.[timePeriod]?.value - b = token2.volume?.[timePeriod]?.value + a = aVolume + b = bVolume break case Category.percentChange: - a = token1.percentChange?.[timePeriod]?.value - b = token2.percentChange?.[timePeriod]?.value + a = aChange + b = bChange break } return sortFn(a, b) @@ -149,14 +158,15 @@ export function LoadingTokenTable() { ) } -export default function TokenTable({ data }: { data: TokenData[] | undefined }) { +export default function TokenTable() { const showFavorites = useAtomValue(showFavoritesAtom) const timePeriod = useAtomValue(filterTimeAtom) - const filteredTokens = useFilteredTokens(data) + const topTokens = useTopTokenQuery(1, timePeriod) + const filteredTokens = useFilteredTokens(topTokens) const sortedFilteredTokens = useSortedTokens(filteredTokens) /* loading and error state */ - if (data === null) { + if (topTokens === null) { return ( {sortedFilteredTokens?.map((token, index) => ( (Category.marketCap) export const sortDirectionAtom = atom(SortDirection.decreasing) /* for favoriting tokens */ -export function useToggleFavorite(tokenAddress: string) { +export function useToggleFavorite(tokenAddress: string | undefined | null) { const [favoriteTokens, updateFavoriteTokens] = useAtom(favoritesAtom) return useCallback(() => { + if (!tokenAddress) return let updatedFavoriteTokens if (favoriteTokens.includes(tokenAddress.toLocaleLowerCase())) { updatedFavoriteTokens = favoriteTokens.filter((address: string) => { @@ -48,8 +49,11 @@ export function useSetSortCategory(category: Category) { }, [category, sortCategory, setSortCategory, sortDirection, setDirectionCategory]) } -export function useIsFavorited(tokenAddress: string) { +export function useIsFavorited(tokenAddress: string | null | undefined) { const favoritedTokens = useAtomValue(favoritesAtom) - return useMemo(() => favoritedTokens.includes(tokenAddress.toLocaleLowerCase()), [favoritedTokens, tokenAddress]) + return useMemo( + () => (tokenAddress ? favoritedTokens.includes(tokenAddress.toLocaleLowerCase()) : false), + [favoritedTokens, tokenAddress] + ) } diff --git a/src/graphql/data/RelayEnvironment.ts b/src/graphql/data/RelayEnvironment.ts index 29ce7ec28d..e835401d9e 100644 --- a/src/graphql/data/RelayEnvironment.ts +++ b/src/graphql/data/RelayEnvironment.ts @@ -1,9 +1,41 @@ -import { Environment, Network, RecordSource, Store } from 'relay-runtime' +import ms from 'ms.macro' +import { Variables } from 'react-relay' +import { Environment, Network, RecordSource, RequestParameters, Store } from 'relay-runtime' +import RelayQueryResponseCache from 'relay-runtime/lib/network/RelayQueryResponseCache' import fetchGraphQL from './fetchGraphQL' +// max number of request in cache, least-recently updated entries purged first +const size = 250 +// number in milliseconds, how long records stay valid in cache +const ttl = ms`5m` +export const cache = new RelayQueryResponseCache({ size, ttl }) + +const fetchQuery = async function wrappedFetchQuery(params: RequestParameters, variables: Variables) { + const queryID = params.name + const cachedData = cache.get(queryID, variables) + + if (cachedData !== null) return cachedData + + return fetchGraphQL(params, variables).then((data) => { + if (params.operationKind !== 'mutation') { + cache.set(queryID, variables, data) + } + return data + }) +} + +// This property tells Relay to not immediately clear its cache when the user +// navigates around the app. Relay will hold onto the specified number of +// query results, allowing the user to return to recently visited pages +// and reusing cached data if its available/fresh. +const gcReleaseBufferSize = 10 + +const store = new Store(new RecordSource(), { gcReleaseBufferSize }) +const network = Network.create(fetchQuery) + // Export a singleton instance of Relay Environment configured with our network function: export default new Environment({ - network: Network.create(fetchGraphQL), - store: new Store(new RecordSource()), + network, + store, }) diff --git a/src/graphql/data/Token.ts b/src/graphql/data/Token.ts new file mode 100644 index 0000000000..15fb288dcc --- /dev/null +++ b/src/graphql/data/Token.ts @@ -0,0 +1,347 @@ +import graphql from 'babel-plugin-relay/macro' +import { useEffect, useState } from 'react' +import { fetchQuery, useFragment, useLazyLoadQuery, useRelayEnvironment } from 'react-relay' + +import { TokenPriceQuery } from './__generated__/TokenPriceQuery.graphql' +import { TokenPrices$data, TokenPrices$key } from './__generated__/TokenPrices.graphql' +import { Chain, HistoryDuration, TokenQuery, TokenQuery$data } from './__generated__/TokenQuery.graphql' +import { TokenTopQuery, TokenTopQuery$data } from './__generated__/TokenTopQuery.graphql' + +export enum TimePeriod { + HOUR, + DAY, + WEEK, + MONTH, + YEAR, + ALL, +} + +function toHistoryDuration(timePeriod: TimePeriod): HistoryDuration { + switch (timePeriod) { + case TimePeriod.HOUR: + return 'HOUR' + case TimePeriod.DAY: + return 'DAY' + case TimePeriod.WEEK: + return 'WEEK' + case TimePeriod.MONTH: + return 'MONTH' + case TimePeriod.YEAR: + return 'YEAR' + case TimePeriod.ALL: + return 'MAX' + } +} + +export type PricePoint = { value: number; timestamp: number } + +const topTokensQuery = graphql` + query TokenTopQuery($page: Int!, $duration: HistoryDuration!) { + topTokenProjects(orderBy: MARKET_CAP, pageSize: 20, currency: USD, page: $page) { + description + homepageUrl + twitterName + name + tokens { + chain + address + symbol + } + prices: markets(currencies: [USD]) { + ...TokenPrices + } + markets(currencies: [USD]) { + price { + value + currency + } + marketCap { + value + currency + } + fullyDilutedMarketCap { + value + currency + } + volume1D: volume(duration: DAY) { + value + currency + } + volume1W: volume(duration: WEEK) { + value + currency + } + volume1M: volume(duration: MONTH) { + value + currency + } + volume1Y: volume(duration: YEAR) { + value + currency + } + pricePercentChange24h { + currency + value + } + pricePercentChange1W: pricePercentChange(duration: WEEK) { + currency + value + } + pricePercentChange1M: pricePercentChange(duration: MONTH) { + currency + value + } + pricePercentChange1Y: pricePercentChange(duration: YEAR) { + currency + value + } + priceHigh52W: priceHighLow(duration: YEAR, highLow: HIGH) { + value + currency + } + priceLow52W: priceHighLow(duration: YEAR, highLow: LOW) { + value + currency + } + } + } + } +` +const tokenPricesFragment = graphql` + fragment TokenPrices on TokenProjectMarket { + priceHistory(duration: $duration) { + timestamp + value + } + } +` +type CachedTopToken = NonNullable['topTokenProjects']>[number] + +let cachedTopTokens: Record = {} +export function useTopTokenQuery(page: number, timePeriod: TimePeriod) { + const topTokens = useLazyLoadQuery(topTokensQuery, { page, duration: toHistoryDuration(timePeriod) }) + + cachedTopTokens = + topTokens.topTokenProjects?.reduce((acc, current) => { + const address = current?.tokens?.[0].address + if (address) acc[address] = current + return acc + }, {} as Record) ?? {} + console.log(cachedTopTokens) + + return topTokens +} + +const tokenQuery = graphql` + query TokenQuery($contract: ContractInput!, $duration: HistoryDuration!, $skip: Boolean = false) { + tokenProjects(contracts: [$contract]) @skip(if: $skip) { + description + homepageUrl + twitterName + name + tokens { + chain + address + symbol + } + prices: markets(currencies: [USD]) { + ...TokenPrices + } + markets(currencies: [USD]) { + price { + value + currency + } + marketCap { + value + currency + } + fullyDilutedMarketCap { + value + currency + } + volume1D: volume(duration: DAY) { + value + currency + } + volume1W: volume(duration: WEEK) { + value + currency + } + volume1M: volume(duration: MONTH) { + value + currency + } + volume1Y: volume(duration: YEAR) { + value + currency + } + pricePercentChange24h { + currency + value + } + pricePercentChange1W: pricePercentChange(duration: WEEK) { + currency + value + } + pricePercentChange1M: pricePercentChange(duration: MONTH) { + currency + value + } + pricePercentChange1Y: pricePercentChange(duration: YEAR) { + currency + value + } + priceHigh52W: priceHighLow(duration: YEAR, highLow: HIGH) { + value + currency + } + priceLow52W: priceHighLow(duration: YEAR, highLow: LOW) { + value + currency + } + } + } + } +` + +export function useTokenQuery(address: string, chain: Chain, timePeriod: TimePeriod) { + const cachedTopToken = cachedTopTokens[address] + const data = useLazyLoadQuery(tokenQuery, { + contract: { address, chain }, + duration: toHistoryDuration(timePeriod), + skip: !!cachedTopToken, + }) + + return !cachedTopToken ? data : { tokenProjects: [{ ...cachedTopToken }] } +} + +const tokenPriceQuery = graphql` + query TokenPriceQuery( + $contract: ContractInput! + $skip1H: Boolean! + $skip1D: Boolean! + $skip1W: Boolean! + $skip1M: Boolean! + $skip1Y: Boolean! + $skipMax: Boolean! + ) { + tokenProjects(contracts: [$contract]) { + markets(currencies: [USD]) { + priceHistory1H: priceHistory(duration: HOUR) @skip(if: $skip1H) { + timestamp + value + } + priceHistory1D: priceHistory(duration: DAY) @skip(if: $skip1D) { + timestamp + value + } + priceHistory1W: priceHistory(duration: WEEK) @skip(if: $skip1W) { + timestamp + value + } + priceHistory1M: priceHistory(duration: MONTH) @skip(if: $skip1M) { + timestamp + value + } + priceHistory1Y: priceHistory(duration: YEAR) @skip(if: $skip1Y) { + timestamp + value + } + priceHistoryMAX: priceHistory(duration: MAX) @skip(if: $skipMax) { + timestamp + value + } + } + } + } +` + +function filterPrices(prices: TokenPrices$data['priceHistory'] | undefined) { + return prices?.filter((p): p is PricePoint => Boolean(p && p.value)) +} + +export function useTokenPricesCached( + key: TokenPrices$key | null | undefined, + address: string, + chain: Chain, + timePeriod: TimePeriod +) { + // Attempt to use token prices already provided by TokenDetails / TopToken queries + const environment = useRelayEnvironment() + const fetchedTokenPrices = useFragment(tokenPricesFragment, key ?? null)?.priceHistory + + const [priceMap, setPriceMap] = useState( + new Map([[timePeriod, filterPrices(fetchedTokenPrices)]]) + ) + + function updatePrices(key: TimePeriod, data?: PricePoint[]) { + setPriceMap(new Map(priceMap.set(key, data))) + } + + // Fetch the other timePeriods after first render + useEffect(() => { + // Fetch all time periods except the one already populated + fetchQuery(environment, tokenPriceQuery, { + contract: { address, chain }, + skip1H: timePeriod === TimePeriod.HOUR && !!fetchedTokenPrices, + skip1D: timePeriod === TimePeriod.DAY && !!fetchedTokenPrices, + skip1W: timePeriod === TimePeriod.WEEK && !!fetchedTokenPrices, + skip1M: timePeriod === TimePeriod.MONTH && !!fetchedTokenPrices, + skip1Y: timePeriod === TimePeriod.YEAR && !!fetchedTokenPrices, + skipMax: timePeriod === TimePeriod.ALL && !!fetchedTokenPrices, + }).subscribe({ + next: (data) => { + const markets = data.tokenProjects?.[0]?.markets?.[0] + if (markets) { + markets.priceHistory1H && updatePrices(TimePeriod.HOUR, filterPrices(markets.priceHistory1H)) + markets.priceHistory1D && updatePrices(TimePeriod.DAY, filterPrices(markets.priceHistory1D)) + markets.priceHistory1W && updatePrices(TimePeriod.WEEK, filterPrices(markets.priceHistory1W)) + markets.priceHistory1M && updatePrices(TimePeriod.MONTH, filterPrices(markets.priceHistory1M)) + markets.priceHistory1Y && updatePrices(TimePeriod.YEAR, filterPrices(markets.priceHistory1Y)) + markets.priceHistoryMAX && updatePrices(TimePeriod.ALL, filterPrices(markets.priceHistoryMAX)) + } + }, + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return { priceMap } +} + +export type SingleTokenData = NonNullable[number] +export function getDurationDetails(data: SingleTokenData, timePeriod: TimePeriod) { + let volume = null + let pricePercentChange = null + + const markets = data?.markets?.[0] + if (markets) { + switch (timePeriod) { + case TimePeriod.HOUR: + pricePercentChange = null + break + case TimePeriod.DAY: + volume = markets.volume1D?.value + pricePercentChange = markets.pricePercentChange24h?.value + break + case TimePeriod.WEEK: + volume = markets.volume1W?.value + pricePercentChange = markets.pricePercentChange1W?.value + break + case TimePeriod.MONTH: + volume = markets.volume1M?.value + pricePercentChange = markets.pricePercentChange1M?.value + break + case TimePeriod.YEAR: + volume = markets.volume1Y?.value + pricePercentChange = markets.pricePercentChange1Y?.value + break + case TimePeriod.ALL: + volume = null + pricePercentChange = null + break + } + } + + return { volume, pricePercentChange } +} diff --git a/src/graphql/data/TokenDetailQuery.ts b/src/graphql/data/TokenDetailQuery.ts deleted file mode 100644 index 71d8db0e88..0000000000 --- a/src/graphql/data/TokenDetailQuery.ts +++ /dev/null @@ -1,89 +0,0 @@ -import graphql from 'babel-plugin-relay/macro' -import { SupportedChainId } from 'constants/chains' -import { useLazyLoadQuery } from 'react-relay' - -import type { Chain, TokenDetailQuery as TokenDetailQueryType } from './__generated__/TokenDetailQuery.graphql' - -export function chainIdToChainName(networkId: SupportedChainId): Chain { - switch (networkId) { - case SupportedChainId.MAINNET: - return 'ETHEREUM' - case SupportedChainId.ARBITRUM_ONE: - return 'ARBITRUM' - case SupportedChainId.OPTIMISM: - return 'OPTIMISM' - case SupportedChainId.POLYGON: - return 'POLYGON' - default: - return 'ETHEREUM' - } -} - -export function useTokenDetailQuery(address: string, chain: Chain) { - const tokenDetail = useLazyLoadQuery( - graphql` - query TokenDetailQuery($contract: ContractInput!) { - tokenProjects(contracts: [$contract]) { - description - homepageUrl - twitterName - name - markets(currencies: [USD]) { - price { - value - currency - } - marketCap { - value - currency - } - fullyDilutedMarketCap { - value - currency - } - volume24h: volume(duration: DAY) { - value - currency - } - priceHigh52W: priceHighLow(duration: YEAR, highLow: HIGH) { - value - currency - } - priceLow52W: priceHighLow(duration: YEAR, highLow: LOW) { - value - currency - } - } - tokens { - chain - address - symbol - decimals - } - } - } - `, - { - contract: { - address, - chain, - }, - } - ) - const { description, homepageUrl, twitterName, name, markets, tokens } = tokenDetail?.tokenProjects?.[0] ?? {} - const { price, marketCap, fullyDilutedMarketCap, volume24h, priceHigh52W, priceLow52W } = markets?.[0] ?? {} - return { - description, - homepageUrl, - twitterName, - name, - markets, - tokens, - price, - marketCap, - fullyDilutedMarketCap, - volume24h, - priceHigh52W, - priceLow52W, - } -} diff --git a/src/graphql/data/TokenPriceQuery.ts b/src/graphql/data/TokenPriceQuery.ts deleted file mode 100644 index ef59bde366..0000000000 --- a/src/graphql/data/TokenPriceQuery.ts +++ /dev/null @@ -1,69 +0,0 @@ -import graphql from 'babel-plugin-relay/macro' -import { useLazyLoadQuery } from 'react-relay' - -import type { Chain, TokenPriceQuery as TokenPriceQueryType } from './__generated__/TokenPriceQuery.graphql' -import { TimePeriod } from './TopTokenQuery' - -export function useTokenPriceQuery(address: string, timePeriod: TimePeriod, chain: Chain) { - const tokenPrices = useLazyLoadQuery( - graphql` - query TokenPriceQuery($contract: ContractInput!) { - tokenProjects(contracts: [$contract]) { - name - markets(currencies: [USD]) { - priceHistory1H: priceHistory(duration: HOUR) { - timestamp - value - } - priceHistory1D: priceHistory(duration: DAY) { - timestamp - value - } - priceHistory1W: priceHistory(duration: WEEK) { - timestamp - value - } - priceHistory1M: priceHistory(duration: MONTH) { - timestamp - value - } - priceHistory1Y: priceHistory(duration: YEAR) { - timestamp - value - } - } - tokens { - chain - address - symbol - decimals - } - } - } - `, - { - contract: { - address, - chain, - }, - } - ) - const { priceHistory1H, priceHistory1D, priceHistory1W, priceHistory1M, priceHistory1Y } = - tokenPrices.tokenProjects?.[0]?.markets?.[0] ?? {} - - switch (timePeriod) { - case TimePeriod.HOUR: - return priceHistory1H ?? [] - case TimePeriod.DAY: - return priceHistory1D ?? [] - case TimePeriod.WEEK: - return priceHistory1W ?? [] - case TimePeriod.MONTH: - return priceHistory1M ?? [] - case TimePeriod.YEAR: - return priceHistory1Y ?? [] - case TimePeriod.ALL: - //TODO: Add functionality for ALL, without requesting it at same time as rest of data for performance reasons - return priceHistory1Y ?? [] - } -} diff --git a/src/graphql/data/TopTokenQuery.ts b/src/graphql/data/TopTokenQuery.ts deleted file mode 100644 index 810356cfb3..0000000000 --- a/src/graphql/data/TopTokenQuery.ts +++ /dev/null @@ -1,147 +0,0 @@ -import graphql from 'babel-plugin-relay/macro' -import { useLazyLoadQuery } from 'react-relay' - -import type { Chain, Currency, TopTokenQuery as TopTokenQueryType } from './__generated__/TopTokenQuery.graphql' - -export enum TimePeriod { - HOUR, - DAY, - WEEK, - MONTH, - YEAR, - ALL, -} - -interface IAmount { - currency: Currency | null - value: number | null -} - -export type TokenData = { - name: string | null - address: string - chain: Chain | null - symbol: string | null - price: IAmount | null | undefined - marketCap: IAmount | null | undefined - volume: Record - percentChange: Record -} - -export interface UseTopTokensResult { - data: TokenData[] | undefined - error: string | null - loading: boolean -} - -export function useTopTokenQuery(page: number) { - const topTokenData = useLazyLoadQuery( - graphql` - query TopTokenQuery($page: Int!) { - topTokenProjects(orderBy: MARKET_CAP, pageSize: 100, currency: USD, page: $page) { - name - tokens { - chain - address - symbol - } - markets(currencies: [USD]) { - price { - value - currency - } - marketCap { - value - currency - } - fullyDilutedMarketCap { - value - currency - } - volume1H: volume(duration: HOUR) { - value - currency - } - volume1D: volume(duration: DAY) { - value - currency - } - volume1W: volume(duration: WEEK) { - value - currency - } - volume1M: volume(duration: MONTH) { - value - currency - } - volume1Y: volume(duration: YEAR) { - value - currency - } - volumeAll: volume(duration: MAX) { - value - currency - } - pricePercentChange1H: pricePercentChange(duration: HOUR) { - currency - value - } - pricePercentChange24h { - currency - value - } - pricePercentChange1W: pricePercentChange(duration: WEEK) { - currency - value - } - pricePercentChange1M: pricePercentChange(duration: MONTH) { - currency - value - } - pricePercentChange1Y: pricePercentChange(duration: YEAR) { - currency - value - } - pricePercentChangeAll: pricePercentChange(duration: MAX) { - currency - value - } - } - } - } - `, - { - page, - } - ) - - const topTokens: TokenData[] | undefined = topTokenData.topTokenProjects?.map((token) => - token?.tokens?.[0].address - ? { - name: token?.name, - address: token?.tokens?.[0].address, - chain: token?.tokens?.[0].chain, - symbol: token?.tokens?.[0].symbol, - price: token?.markets?.[0]?.price, - marketCap: token?.markets?.[0]?.marketCap, - volume: { - [TimePeriod.HOUR]: token?.markets?.[0]?.volume1H, - [TimePeriod.DAY]: token?.markets?.[0]?.volume1D, - [TimePeriod.WEEK]: token?.markets?.[0]?.volume1W, - [TimePeriod.MONTH]: token?.markets?.[0]?.volume1M, - [TimePeriod.YEAR]: token?.markets?.[0]?.volume1Y, - [TimePeriod.ALL]: token?.markets?.[0]?.volumeAll, - }, - percentChange: { - [TimePeriod.HOUR]: token?.markets?.[0]?.pricePercentChange1H, - [TimePeriod.DAY]: token?.markets?.[0]?.pricePercentChange24h, - [TimePeriod.WEEK]: token?.markets?.[0]?.pricePercentChange1W, - [TimePeriod.MONTH]: token?.markets?.[0]?.pricePercentChange1M, - [TimePeriod.YEAR]: token?.markets?.[0]?.pricePercentChange1Y, - [TimePeriod.ALL]: token?.markets?.[0]?.pricePercentChangeAll, - }, - } - : ({} as TokenData) - ) - return topTokens -} diff --git a/src/hooks/useNetworkTokenBalances.ts b/src/hooks/useNetworkTokenBalances.ts index 3925e646e8..c3a202aa88 100644 --- a/src/hooks/useNetworkTokenBalances.ts +++ b/src/hooks/useNetworkTokenBalances.ts @@ -43,10 +43,8 @@ export function useNetworkTokenBalances({ address }: useNetworkTokenBalancesArgs const waitRandom = (min: number, max: number): Promise => new Promise((resolve) => setTimeout(resolve, min + Math.round(Math.random() * Math.max(0, max - min)))) try { - console.log('useNetworkTokenBalances.fetchNetworkTokenBalances', query) setLoading(true) setError(null) - console.log('useNetworkTokenBalances.fetchNetworkTokenBalances', address) await waitRandom(250, 2000) if (Math.random() < 0.05) { throw new Error('fake error') diff --git a/src/pages/TokenDetails/index.tsx b/src/pages/TokenDetails/index.tsx index 2b61bead98..7ff9f23ffb 100644 --- a/src/pages/TokenDetails/index.tsx +++ b/src/pages/TokenDetails/index.tsx @@ -5,6 +5,7 @@ import { MOBILE_MEDIA_BREAKPOINT, SMALL_MEDIA_BREAKPOINT, } from 'components/Tokens/constants' +import { filterTimeAtom } from 'components/Tokens/state' import BalanceSummary from 'components/Tokens/TokenDetails/BalanceSummary' import FooterBalanceSummary from 'components/Tokens/TokenDetails/FooterBalanceSummary' import LoadingTokenDetail from 'components/Tokens/TokenDetails/LoadingTokenDetail' @@ -16,10 +17,12 @@ import Widget, { WIDGET_WIDTH } from 'components/Widget' import { getChainInfo } from 'constants/chainInfo' import { L1_CHAIN_IDS, L2_CHAIN_IDS, SupportedChainId, TESTNET_CHAIN_IDS } from 'constants/chains' import { checkWarning } from 'constants/tokenSafety' +import { useTokenQuery } from 'graphql/data/Token' import { useIsUserAddedToken, useToken } from 'hooks/Tokens' import { useNetworkTokenBalances } from 'hooks/useNetworkTokenBalances' +import { useAtomValue } from 'jotai/utils' import { useCallback, useMemo, useState } from 'react' -import { Navigate, useLocation, useNavigate, useParams } from 'react-router-dom' +import { useNavigate, useParams } from 'react-router-dom' import styled from 'styled-components/macro' const Footer = styled.div` @@ -77,7 +80,6 @@ function NetworkBalances(tokenAddress: string | undefined) { } export default function TokenDetails() { - const location = useLocation() const { tokenAddress } = useParams<{ tokenAddress?: string }>() const token = useToken(tokenAddress) const tokenWarning = tokenAddress ? checkWarning(tokenAddress) : null @@ -115,6 +117,9 @@ export default function TokenDetails() { return chainIds }, [connectedChainId]) + const timePeriod = useAtomValue(filterTimeAtom) + const query = useTokenQuery(tokenAddress ?? '', 'ETHEREUM', timePeriod) + const balancesByNetwork = networkData ? chainsToList.map((chainId) => { const amount = networkData[chainId] @@ -137,15 +142,17 @@ export default function TokenDetails() { }) : null - if (token === undefined) { - return - } + // TODO: Fix this logic to not automatically redirect on refresh, yet still catch invalid addresses + //const location = useLocation() + //if (token === undefined) { + // return + //} return ( {token && ( <> - + {tokenWarning && } diff --git a/src/pages/Tokens/index.tsx b/src/pages/Tokens/index.tsx index 6fd8743e01..70ddccea32 100644 --- a/src/pages/Tokens/index.tsx +++ b/src/pages/Tokens/index.tsx @@ -9,7 +9,6 @@ import SearchBar from 'components/Tokens/TokenTable/SearchBar' import TimeSelector from 'components/Tokens/TokenTable/TimeSelector' import TokenTable, { LoadingTokenTable } from 'components/Tokens/TokenTable/TokenTable' import { TokensNetworkFilterVariant, useTokensNetworkFilterFlag } from 'featureFlags/flags/tokensNetworkFilter' -import { useTopTokenQuery } from 'graphql/data/TopTokenQuery' import { useResetAtom } from 'jotai/utils' import { useEffect } from 'react' import { useLocation } from 'react-router-dom' @@ -69,7 +68,6 @@ const FiltersWrapper = styled.div` ` const Tokens = () => { - const topTokens = useTopTokenQuery(1) const tokensNetworkFilterFlag = useTokensNetworkFilterFlag() const resetFilterString = useResetAtom(filterStringAtom) const location = useLocation() @@ -98,7 +96,7 @@ const Tokens = () => { - +