Commit b92c8007 authored by cartcrom's avatar cartcrom Committed by GitHub

feat: explore chain switching (#4710)

* initial commit
* replaced isUserAddedToken with chain-switching friendly version of hook
* reverted useTopTokens()
* addressed first round of PR comments
parent 029f3acb
import { Currency } from '@uniswap/sdk-core'
import useCurrencyLogoURIs from 'lib/hooks/useCurrencyLogoURIs'
import React from 'react'
import React, { useMemo } from 'react'
import styled from 'styled-components/macro'
import Logo from '../Logo'
......@@ -27,17 +27,21 @@ export default function CurrencyLogo({
symbol,
size = '24px',
style,
src,
...rest
}: {
currency?: Currency | null
symbol?: string | null
size?: string
style?: React.CSSProperties
src?: string | null
}) {
const logoURIs = useCurrencyLogoURIs(currency)
const srcs = useMemo(() => (src ? [src] : logoURIs), [src, logoURIs])
const props = {
alt: `${currency?.symbol ?? 'token'} logo`,
size,
srcs: useCurrencyLogoURIs(currency),
srcs,
symbol: symbol ?? currency?.symbol,
style,
...rest,
......
import { Trans } from '@lingui/macro'
import Web3Status from 'components/Web3Status'
import { NftVariant, useNftFlag } from 'featureFlags/flags/nft'
import { useGlobalChainName } from 'graphql/data/util'
import { Box } from 'nft/components/Box'
import { Row } from 'nft/components/Flex'
import { UniIcon } from 'nft/components/icons'
......@@ -36,6 +37,7 @@ const MenuItem = ({ href, id, isActive, children }: MenuItemProps) => {
const PageTabs = () => {
const { pathname } = useLocation()
const nftFlag = useNftFlag()
const chainName = useGlobalChainName()
const isPoolActive =
pathname.startsWith('/pool') ||
......@@ -49,7 +51,7 @@ const PageTabs = () => {
<MenuItem href="/swap" isActive={pathname.startsWith('/swap')}>
<Trans>Swap</Trans>
</MenuItem>
<MenuItem href="/tokens" isActive={pathname.startsWith('/tokens')}>
<MenuItem href={`/tokens/${chainName.toLowerCase()}`} isActive={pathname.startsWith('/tokens')}>
<Trans>Tokens</Trans>
</MenuItem>
{nftFlag === NftVariant.Enabled && (
......
import { Trans } from '@lingui/macro'
import { Token } from '@uniswap/sdk-core'
import { NativeCurrency, Token } from '@uniswap/sdk-core'
import { ParentSize } from '@visx/responsive'
import { useWeb3React } from '@web3-react/core'
import CurrencyLogo from 'components/CurrencyLogo'
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 { FavoriteTokensVariant, useFavoriteTokensFlag } from 'featureFlags/flags/favoriteTokens'
import { SingleTokenData } from 'graphql/data/Token'
import { useCurrency } from 'hooks/Tokens'
import { SingleTokenData, useTokenPricesCached } from 'graphql/data/Token'
import { TopToken } from 'graphql/data/TopTokens'
import { CHAIN_NAME_TO_CHAIN_ID } from 'graphql/data/util'
import useCurrencyLogoURIs, { getTokenLogoURI } from 'lib/hooks/useCurrencyLogoURIs'
import styled from 'styled-components/macro'
import { isAddress } from 'utils'
import { useIsFavorited, useToggleFavorite } from '../state'
import { ClickFavorited, FavoriteIcon } from '../TokenTable/TokenRow'
import { ClickFavorited, FavoriteIcon, L2NetworkLogo, LogoContainer } from '../TokenTable/TokenRow'
import PriceChart from './PriceChart'
import ShareButton from './ShareButton'
......@@ -51,55 +52,52 @@ const TokenActions = styled.div`
gap: 16px;
color: ${({ theme }) => theme.textSecondary};
`
const NetworkBadge = styled.div<{ networkColor?: string; backgroundColor?: string }>`
border-radius: 5px;
padding: 4px 8px;
font-weight: 600;
font-size: 12px;
line-height: 12px;
color: ${({ theme, networkColor }) => networkColor ?? theme.textPrimary};
background-color: ${({ theme, backgroundColor }) => backgroundColor ?? theme.backgroundSurface};
`
export default function ChartSection({ token, tokenData }: { token: Token; tokenData: SingleTokenData | undefined }) {
const { chainId: connectedChainId } = useWeb3React()
export function useTokenLogoURI(
token: NonNullable<SingleTokenData> | NonNullable<TopToken>,
nativeCurrency?: Token | NativeCurrency
) {
const checksummedAddress = isAddress(token.address)
const chainId = CHAIN_NAME_TO_CHAIN_ID[token.chain]
return (
useCurrencyLogoURIs(nativeCurrency)[0] ??
(checksummedAddress && getTokenLogoURI(checksummedAddress, chainId)) ??
token.project?.logoUrl
)
}
export default function ChartSection({
token,
nativeCurrency,
}: {
token: NonNullable<SingleTokenData>
nativeCurrency?: Token | NativeCurrency
}) {
const isFavorited = useIsFavorited(token.address)
const toggleFavorite = useToggleFavorite(token.address)
const chainInfo = getChainInfo(token?.chainId)
const networkLabel = chainInfo?.label
const networkBadgebackgroundColor = chainInfo?.backgroundColor
const warning = checkWarning(token.address)
let currency = useCurrency(token.address)
const chainId = CHAIN_NAME_TO_CHAIN_ID[token.chain]
const L2Icon = getChainInfo(chainId).circleLogoUrl
const warning = checkWarning(token.address ?? '')
if (connectedChainId) {
const wrappedNativeCurrency = WRAPPED_NATIVE_CURRENCY[connectedChainId]
const isWrappedNativeToken = wrappedNativeCurrency?.address === token?.address
if (isWrappedNativeToken) {
currency = nativeOnChain(connectedChainId)
}
}
const { prices } = useTokenPricesCached(token)
const tokenName = tokenData?.name ?? token?.name
const tokenSymbol = tokenData?.tokens?.[0]?.symbol ?? token?.symbol
const logoSrc = useTokenLogoURI(token, nativeCurrency)
return (
<ChartHeader>
<TokenInfoContainer>
<TokenNameCell>
<CurrencyLogo currency={currency} size={'32px'} symbol={tokenSymbol} />
{tokenName ?? <Trans>Name not found</Trans>}
<TokenSymbol>{tokenSymbol ?? <Trans>Symbol not found</Trans>}</TokenSymbol>
<LogoContainer>
<CurrencyLogo src={logoSrc} size={'32px'} symbol={nativeCurrency?.symbol ?? token.symbol} />
<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>
{!warning && <VerifiedIcon size="20px" />}
{networkBadgebackgroundColor && (
<NetworkBadge networkColor={chainInfo?.color} backgroundColor={networkBadgebackgroundColor}>
{networkLabel}
</NetworkBadge>
)}
</TokenNameCell>
<TokenActions>
{tokenName && tokenSymbol && (
<ShareButton tokenName={tokenName} tokenSymbol={tokenSymbol} tokenAddress={token.address} />
{token.name && token.symbol && token.address && (
<ShareButton tokenName={token.name} tokenSymbol={token.symbol} tokenAddress={token.address} />
)}
{useFavoriteTokensFlag() === FavoriteTokensVariant.Enabled && (
<ClickFavorited onClick={toggleFavorite}>
......@@ -110,9 +108,7 @@ export default function ChartSection({ token, tokenData }: { token: Token; token
</TokenInfoContainer>
<ChartContainer>
<ParentSize>
{({ width, height }) => (
<PriceChart tokenAddress={token.address} width={width} height={height} priceDataFragmentRef={null} />
)}
{({ width, height }) => prices && <PriceChart prices={prices} width={width} height={height} />}
</ParentSize>
</ChartContainer>
</ChartHeader>
......
......@@ -15,8 +15,6 @@ import {
timeMonth,
timeTicks,
} from 'd3'
import { TokenPrices$key } from 'graphql/data/__generated__/TokenPrices.graphql'
import { useTokenPricesCached } from 'graphql/data/Token'
import { PricePoint } from 'graphql/data/Token'
import { TimePeriod } from 'graphql/data/util'
import { useActiveLocale } from 'hooks/useActiveLocale'
......@@ -37,8 +35,6 @@ import {
import LineChart from '../../Charts/LineChart'
import { DISPLAYS, ORDERED_TIMES } from '../TokenTable/TimeSelector'
// TODO: This should be combined with the logic in TimeSelector.
export const DATA_EMPTY = { value: 0, timestamp: 0 }
export function getPriceBounds(pricePoints: PricePoint[]): [number, number] {
......@@ -131,18 +127,14 @@ const timeOptionsHeight = 44
interface PriceChartProps {
width: number
height: number
tokenAddress: string
priceDataFragmentRef?: TokenPrices$key | null
prices: PricePoint[] | undefined
}
export function PriceChart({ width, height, tokenAddress, priceDataFragmentRef }: PriceChartProps) {
export function PriceChart({ width, height, prices }: PriceChartProps) {
const [timePeriod, setTimePeriod] = useAtom(filterTimeAtom)
const locale = useActiveLocale()
const theme = useTheme()
const { priceMap } = useTokenPricesCached(priceDataFragmentRef, tokenAddress, 'ETHEREUM', timePeriod)
const prices = priceMap.get(timePeriod)
// first price point on the x-axis of the current time period's chart
const startingPrice = prices?.[0] ?? DATA_EMPTY
// last price point on the x-axis of the current time period's chart
......
import { getChainInfo } from 'constants/chainInfo'
import { SupportedChainId } from 'constants/chains'
import { BACKEND_CHAIN_NAMES, CHAIN_NAME_TO_CHAIN_ID, validateUrlChainParam } from 'graphql/data/util'
import { useOnClickOutside } from 'hooks/useOnClickOutside'
import { useAtom } from 'jotai'
import { useRef } from 'react'
import { Check, ChevronDown, ChevronUp } from 'react-feather'
import { useNavigate, useParams } from 'react-router-dom'
import { useModalIsOpen, useToggleModal } from 'state/application/hooks'
import { ApplicationModal } from 'state/application/reducer'
import styled, { useTheme } from 'styled-components/macro'
import { MEDIUM_MEDIA_BREAKPOINT } from '../constants'
import { filterNetworkAtom } from '../state'
import FilterOption from './FilterOption'
const NETWORKS = [
SupportedChainId.MAINNET,
SupportedChainId.ARBITRUM_ONE,
SupportedChainId.POLYGON,
SupportedChainId.OPTIMISM,
]
const InternalMenuItem = styled.div`
flex: 1;
padding: 12px 8px;
......@@ -99,15 +91,18 @@ const CheckContainer = styled.div`
flex-direction: flex-end;
`
// TODO: change this to reflect data pipeline
export default function NetworkFilter() {
const theme = useTheme()
const node = useRef<HTMLDivElement | null>(null)
const open = useModalIsOpen(ApplicationModal.NETWORK_FILTER)
const toggleMenu = useToggleModal(ApplicationModal.NETWORK_FILTER)
useOnClickOutside(node, open ? toggleMenu : undefined)
const [activeNetwork, setNetwork] = useAtom(filterNetworkAtom)
const { label, circleLogoUrl, logoUrl } = getChainInfo(activeNetwork)
const navigate = useNavigate()
const { chainName } = useParams<{ chainName?: string }>()
const currentChainName = validateUrlChainParam(chainName)
const { label, circleLogoUrl, logoUrl } = getChainInfo(CHAIN_NAME_TO_CHAIN_ID[currentChainName])
return (
<StyledMenu ref={node}>
......@@ -127,25 +122,28 @@ export default function NetworkFilter() {
</FilterOption>
{open && (
<MenuTimeFlyout>
{NETWORKS.map((network) => (
{BACKEND_CHAIN_NAMES.map((network) => {
const chainInfo = getChainInfo(CHAIN_NAME_TO_CHAIN_ID[network])
return (
<InternalLinkMenuItem
key={network}
onClick={() => {
setNetwork(network)
navigate(`/tokens/${network.toLowerCase()}`)
toggleMenu()
}}
>
<NetworkLabel>
<Logo src={getChainInfo(network).circleLogoUrl ?? getChainInfo(network).logoUrl} />
{getChainInfo(network).label}
<Logo src={chainInfo.circleLogoUrl ?? chainInfo.logoUrl} />
{chainInfo.label}
</NetworkLabel>
{network === activeNetwork && (
{network === currentChainName && (
<CheckContainer>
<Check size={16} color={theme.accentAction} />
</CheckContainer>
)}
</InternalLinkMenuItem>
))}
)
})}
</MenuTimeFlyout>
)}
</StyledMenu>
......
......@@ -7,13 +7,12 @@ import CurrencyLogo from 'components/CurrencyLogo'
import { getChainInfo } from 'constants/chainInfo'
import { FavoriteTokensVariant, useFavoriteTokensFlag } from 'featureFlags/flags/favoriteTokens'
import { TokenSortMethod, TopToken } from 'graphql/data/TopTokens'
import { TimePeriod } from 'graphql/data/util'
import { useCurrency } from 'hooks/Tokens'
import { CHAIN_NAME_TO_CHAIN_ID, TimePeriod } from 'graphql/data/util'
import { useAtomValue } from 'jotai/utils'
import { ForwardedRef, forwardRef } from 'react'
import { CSSProperties, HTMLProps, ReactHTMLElement, ReactNode } from 'react'
import { CSSProperties, ReactNode } from 'react'
import { ArrowDown, ArrowUp, Heart } from 'react-feather'
import { Link } from 'react-router-dom'
import { Link, useParams } from 'react-router-dom'
import styled, { css, useTheme } from 'styled-components/macro'
import { ClickableStyle } from 'theme'
import { formatDollarAmount } from 'utils/formatDollarAmt'
......@@ -26,7 +25,6 @@ import {
} from '../constants'
import { LoadingBubble } from '../loading'
import {
filterNetworkAtom,
filterStringAtom,
filterTimeAtom,
sortAscendingAtom,
......@@ -35,6 +33,7 @@ import {
useSetSortMethod,
useToggleFavorite,
} from '../state'
import { useTokenLogoURI } from '../TokenDetails/ChartSection'
import { formatDelta, getDeltaArrow } from '../TokenDetails/PriceChart'
import { DISPLAYS } from './TimeSelector'
......@@ -312,18 +311,18 @@ const SparkLineLoadingBubble = styled(LongLoadingBubble)`
height: 4px;
`
const L2NetworkLogo = styled.div<{ networkUrl?: string }>`
height: 12px;
width: 12px;
export const L2NetworkLogo = styled.div<{ networkUrl?: string; size?: string }>`
height: ${({ size }) => size ?? '12px'};
width: ${({ size }) => size ?? '12px'};
position: absolute;
left: 50%;
top: 50%;
background: url(${({ networkUrl }) => networkUrl});
background-repeat: no-repeat;
background-size: 12px 12px;
background-size: ${({ size }) => (size ? `${size} ${size}` : '12px 12px')};
display: ${({ networkUrl }) => !networkUrl && 'none'};
`
const LogoContainer = styled.div`
export const LogoContainer = styled.div`
position: relative;
align-items: center;
display: flex;
......@@ -465,29 +464,31 @@ export function LoadingRow() {
)
}
interface LoadedRowProps extends HTMLProps<ReactHTMLElement<HTMLElement>> {
interface LoadedRowProps {
tokenListIndex: number
tokenListLength: number
token: TopToken
token: NonNullable<TopToken>
}
/* Loaded State: row component with token information */
export const LoadedRow = forwardRef((props: LoadedRowProps, ref: ForwardedRef<HTMLDivElement>) => {
const { tokenListIndex, tokenListLength, token } = props
const tokenAddress = token?.address
const currency = useCurrency(tokenAddress)
const tokenName = token?.name
const tokenSymbol = token?.symbol
const tokenAddress = token.address
const tokenName = token.name
const tokenSymbol = token.symbol
const isFavorited = useIsFavorited(tokenAddress)
const toggleFavorite = useToggleFavorite(tokenAddress)
const filterString = useAtomValue(filterStringAtom)
const filterNetwork = useAtomValue(filterNetworkAtom)
const L2Icon = getChainInfo(filterNetwork).circleLogoUrl
const lowercaseChainName = useParams<{ chainName?: string }>().chainName?.toUpperCase() ?? 'ethereum'
const filterNetwork = lowercaseChainName.toUpperCase()
const L2Icon = getChainInfo(CHAIN_NAME_TO_CHAIN_ID[filterNetwork]).circleLogoUrl
const timePeriod = useAtomValue(filterTimeAtom)
const delta = token?.market?.pricePercentChange?.value
const delta = token.market?.pricePercentChange?.value
const arrow = delta ? getDeltaArrow(delta) : null
const formattedDelta = delta ? formatDelta(delta) : null
const sortAscending = useAtomValue(sortAscendingAtom)
const { chainName } = useParams<{ chainName?: string }>()
const exploreTokenSelectedEventProperties = {
chain_id: filterNetwork,
......@@ -503,7 +504,7 @@ export const LoadedRow = forwardRef((props: LoadedRowProps, ref: ForwardedRef<HT
return (
<div ref={ref}>
<StyledLink
to={`/tokens/${tokenAddress}`}
to={`/tokens/${chainName}/${tokenAddress}`}
onClick={() => sendAnalyticsEvent(EventName.EXPLORE_TOKEN_ROW_CLICKED, exploreTokenSelectedEventProperties)}
>
<TokenRow
......@@ -522,7 +523,7 @@ export const LoadedRow = forwardRef((props: LoadedRowProps, ref: ForwardedRef<HT
tokenInfo={
<ClickableName>
<LogoContainer>
<CurrencyLogo currency={currency} symbol={tokenSymbol} />
<CurrencyLogo src={useTokenLogoURI(token)} symbol={tokenSymbol} />
<L2NetworkLogo networkUrl={L2Icon} />
</LogoContainer>
<TokenInfoCell>
......@@ -534,7 +535,7 @@ export const LoadedRow = forwardRef((props: LoadedRowProps, ref: ForwardedRef<HT
price={
<ClickableContent>
<PriceInfoCell>
{token?.market?.price?.value ? formatDollarAmount(token.market.price.value) : '-'}
{token.market?.price?.value ? formatDollarAmount(token.market.price.value) : '-'}
<PercentChangeInfoCell>
{formattedDelta}
{arrow}
......@@ -550,12 +551,12 @@ export const LoadedRow = forwardRef((props: LoadedRowProps, ref: ForwardedRef<HT
}
marketCap={
<ClickableContent>
{token?.market?.totalValueLocked?.value ? formatDollarAmount(token.market.totalValueLocked.value) : '-'}
{token.market?.totalValueLocked?.value ? formatDollarAmount(token.market.totalValueLocked.value) : '-'}
</ClickableContent>
}
volume={
<ClickableContent>
{token?.market?.volume?.value ? formatDollarAmount(token.market.volume.value) : '-'}
{token.market?.volume?.value ? formatDollarAmount(token.market.volume.value) : '-'}
</ClickableContent>
}
sparkLine={
......@@ -566,7 +567,7 @@ export const LoadedRow = forwardRef((props: LoadedRowProps, ref: ForwardedRef<HT
width={width}
height={height}
tokenData={token}
pricePercentChange={token?.market?.pricePercentChange?.value}
pricePercentChange={token.market?.pricePercentChange?.value}
timePeriod={timePeriod}
/>
)}
......
import { Trans } from '@lingui/macro'
import { showFavoritesAtom } from 'components/Tokens/state'
import { PAGE_SIZE, useTopTokens } from 'graphql/data/TopTokens'
import { validateUrlChainParam } from 'graphql/data/util'
import { useAtomValue } from 'jotai/utils'
import { ReactNode, useCallback, useRef } from 'react'
import { AlertTriangle } from 'react-feather'
import { useParams } from 'react-router-dom'
import styled from 'styled-components/macro'
import { MAX_WIDTH_MEDIA_BREAKPOINT } from '../constants'
......@@ -69,7 +71,8 @@ export default function TokenTable() {
const showFavorites = useAtomValue<boolean>(showFavoritesAtom)
// TODO: consider moving prefetched call into app.tsx and passing it here, use a preloaded call & updated on interval every 60s
const { loading, tokens, tokensWithoutPriceHistoryCount, hasMore, loadMoreTokens } = useTopTokens()
const chainName = validateUrlChainParam(useParams<{ chainName?: string }>().chainName)
const { loading, tokens, tokensWithoutPriceHistoryCount, hasMore, loadMoreTokens } = useTopTokens(chainName)
const showMoreLoadingRows = Boolean(loading && hasMore)
const observer = useRef<IntersectionObserver>()
......@@ -114,7 +117,9 @@ export default function TokenTable() {
<GridContainer>
<HeaderRow />
<TokenDataContainer>
{tokens.map((token, index) => (
{tokens.map(
(token, index) =>
token && (
<LoadedRow
key={token?.address}
tokenListIndex={index}
......@@ -122,7 +127,8 @@ export default function TokenTable() {
token={token}
ref={index + 1 === tokens.length ? lastTokenRef : undefined}
/>
))}
)
)}
{showMoreLoadingRows && LoadingMoreRows}
</TokenDataContainer>
</GridContainer>
......
import { SupportedChainId } from 'constants/chains'
import { TokenSortMethod } from 'graphql/data/TopTokens'
import { TimePeriod } from 'graphql/data/util'
import { atom, useAtom } from 'jotai'
......@@ -8,7 +7,6 @@ import { useCallback, useMemo } from 'react'
export const favoritesAtom = atomWithStorage<string[]>('favorites', [])
export const showFavoritesAtom = atomWithStorage<boolean>('showFavorites', false)
export const filterStringAtom = atomWithReset<string>('')
export const filterNetworkAtom = atom<SupportedChainId>(SupportedChainId.MAINNET)
export const filterTimeAtom = atom<TimePeriod>(TimePeriod.DAY)
export const sortMethodAtom = atom<TokenSortMethod>(TokenSortMethod.TOTAL_VALUE_LOCKED)
export const sortAscendingAtom = atom<boolean>(false)
......
import graphql from 'babel-plugin-relay/macro'
import { filterTimeAtom } from 'components/Tokens/state'
import { useAtomValue } from 'jotai/utils'
import { useCallback, useEffect, useState } from 'react'
import { fetchQuery, useFragment, useLazyLoadQuery, useRelayEnvironment } from 'react-relay'
......@@ -34,78 +36,39 @@ The difference between Token and TokenProject:
TokenProjectMarket is aggregated market data (aggregated over multiple dexes and centralized exchanges) that we get from coingecko.
*/
const tokenQuery = graphql`
query TokenQuery($contract: ContractInput!, $duration: HistoryDuration!, $skip: Boolean = false) {
tokenProjects(contracts: [$contract]) @skip(if: $skip) {
description
homepageUrl
twitterName
query TokenQuery($contract: ContractInput!, $duration: HistoryDuration!) {
tokens(contracts: [$contract]) {
id
name
tokens {
chain
address
symbol
market {
market(currency: USD) {
totalValueLocked {
value
currency
}
}
}
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) {
priceHistory(duration: $duration) {
timestamp
value
currency
}
pricePercentChange24h {
currency
price {
value
}
pricePercentChange1W: pricePercentChange(duration: WEEK) {
currency
value
}
pricePercentChange1M: pricePercentChange(duration: MONTH) {
currency
volume24H: volume(duration: DAY) {
value
}
pricePercentChange1Y: pricePercentChange(duration: YEAR) {
currency
value
}
priceHigh52W: priceHighLow(duration: YEAR, highLow: HIGH) {
value
currency
}
priceLow52W: priceHighLow(duration: YEAR, highLow: LOW) {
value
currency
project {
description
homepageUrl
twitterName
logoUrl
tokens {
chain
address
}
}
}
......@@ -114,9 +77,8 @@ const tokenQuery = graphql`
export function useTokenQuery(address: string, chain: Chain, timePeriod: TimePeriod) {
const data = useLazyLoadQuery<TokenQuery>(tokenQuery, {
contract: { address, chain },
contract: { address: address.toLowerCase(), chain },
duration: toHistoryDuration(timePeriod),
skip: false,
})
return data
......@@ -132,8 +94,8 @@ const tokenPriceQuery = graphql`
$skip1Y: Boolean!
$skipMax: Boolean!
) {
tokenProjects(contracts: [$contract]) {
markets(currencies: [USD]) {
tokens(contracts: [$contract]) {
market(currency: USD) {
priceHistory1H: priceHistory(duration: HOUR) @skip(if: $skip1H) {
timestamp
value
......@@ -172,19 +134,12 @@ export function useTokenPricesFromFragment(key: TokenPrices$key | null | undefin
return filterPrices(fetchedTokenPrices)
}
export function useTokenPricesCached(
priceDataFragmentRef: TokenPrices$key | null | undefined,
address: string,
chain: Chain,
timePeriod: TimePeriod
) {
export function useTokenPricesCached(token: SingleTokenData) {
// Attempt to use token prices already provided by TokenDetails / TopToken queries
const environment = useRelayEnvironment()
const fetchedTokenPrices = useFragment(tokenPricesFragment, priceDataFragmentRef ?? null)?.priceHistory
const timePeriod = useAtomValue(filterTimeAtom)
const [priceMap, setPriceMap] = useState<Map<TimePeriod, PricePoint[] | undefined>>(
new Map([[timePeriod, filterPrices(fetchedTokenPrices)]])
)
const [priceMap, setPriceMap] = useState<Map<TimePeriod, PricePoint[] | undefined>>(new Map())
const updatePrices = useCallback(
(key: TimePeriod, data?: PricePoint[]) => {
......@@ -195,9 +150,12 @@ export function useTokenPricesCached(
// Fetch the other timePeriods after first render
useEffect(() => {
const fetchedTokenPrices = token?.market?.priceHistory
updatePrices(timePeriod, filterPrices(fetchedTokenPrices))
// Fetch all time periods except the one already populated
if (token?.chain && token?.address) {
fetchQuery<TokenPriceQuery>(environment, tokenPriceQuery, {
contract: { address, chain },
contract: { address: token.address, chain: token.chain },
skip1H: timePeriod === TimePeriod.HOUR && !!fetchedTokenPrices,
skip1D: timePeriod === TimePeriod.DAY && !!fetchedTokenPrices,
skip1W: timePeriod === TimePeriod.WEEK && !!fetchedTokenPrices,
......@@ -206,21 +164,22 @@ export function useTokenPricesCached(
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))
const market = data.tokens?.[0]?.market
if (market) {
market.priceHistory1H && updatePrices(TimePeriod.HOUR, filterPrices(market.priceHistory1H))
market.priceHistory1D && updatePrices(TimePeriod.DAY, filterPrices(market.priceHistory1D))
market.priceHistory1W && updatePrices(TimePeriod.WEEK, filterPrices(market.priceHistory1W))
market.priceHistory1M && updatePrices(TimePeriod.MONTH, filterPrices(market.priceHistory1M))
market.priceHistory1Y && updatePrices(TimePeriod.YEAR, filterPrices(market.priceHistory1Y))
market.priceHistoryMAX && updatePrices(TimePeriod.ALL, filterPrices(market.priceHistoryMAX))
}
},
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [token?.chain, token?.address])
return { priceMap }
return { prices: priceMap.get(timePeriod) }
}
export type SingleTokenData = NonNullable<TokenQuery$data['tokenProjects']>[number]
export type SingleTokenData = NonNullable<TokenQuery$data['tokens']>[number]
......@@ -11,13 +11,16 @@ import { useAtomValue } from 'jotai/utils'
import { useCallback, useLayoutEffect, useMemo, useState } from 'react'
import { fetchQuery, useLazyLoadQuery, useRelayEnvironment } from 'react-relay'
import { ContractInput, HistoryDuration, TopTokens_TokensQuery } from './__generated__/TopTokens_TokensQuery.graphql'
import {
Chain,
ContractInput,
HistoryDuration,
TopTokens_TokensQuery,
} from './__generated__/TopTokens_TokensQuery.graphql'
import type { TopTokens100Query } from './__generated__/TopTokens100Query.graphql'
import { toHistoryDuration } from './util'
import { useCurrentChainName } from './util'
export function usePrefetchTopTokens(duration: HistoryDuration) {
const chain = useCurrentChainName()
export function usePrefetchTopTokens(duration: HistoryDuration, chain: Chain) {
return useLazyLoadQuery<TopTokens100Query>(topTokens100Query, { duration, chain })
}
......@@ -160,12 +163,12 @@ interface UseTopTokensReturnValue {
hasMore: boolean
loadMoreTokens: () => void
}
export function useTopTokens(): UseTopTokensReturnValue {
export function useTopTokens(chain: Chain): UseTopTokensReturnValue {
const duration = toHistoryDuration(useAtomValue(filterTimeAtom))
const [loading, setLoading] = useState(true)
const [tokens, setTokens] = useState<TopToken[]>()
const [page, setPage] = useState(0)
const prefetchedData = usePrefetchTopTokens(duration)
const prefetchedData = usePrefetchTopTokens(duration, chain)
const prefetchedSelectedTokensWithoutPriceHistory = useFilteredTokens(useSortedTokens(prefetchedData.topTokens))
const hasMore = !tokens || tokens.length < prefetchedSelectedTokensWithoutPriceHistory.length
......@@ -269,6 +272,9 @@ export const tokensQuery = graphql`
value
}
}
project {
logoUrl
}
}
}
`
import { useWeb3React } from '@web3-react/core'
import { SupportedChainId } from 'constants/chains'
import { useAppSelector } from 'state/hooks'
import { Chain, HistoryDuration } from './__generated__/TokenQuery.graphql'
......@@ -42,8 +42,37 @@ export const CHAIN_IDS_TO_BACKEND_NAME: { [key: number]: Chain } = {
[SupportedChainId.OPTIMISTIC_KOVAN]: 'OPTIMISM',
}
export function useCurrentChainName() {
const { chainId } = useWeb3React()
export function useGlobalChainName() {
const chainId = useAppSelector((state) => state.application.chainId)
return chainId && CHAIN_IDS_TO_BACKEND_NAME[chainId] ? CHAIN_IDS_TO_BACKEND_NAME[chainId] : 'ETHEREUM'
}
export const URL_CHAIN_PARAM_TO_BACKEND: { [key: string]: Chain } = {
ethereum: 'ETHEREUM',
polygon: 'POLYGON',
celo: 'CELO',
arbitrum: 'ARBITRUM',
optimism: 'OPTIMISM',
}
export function validateUrlChainParam(chainName: string | undefined) {
return chainName && URL_CHAIN_PARAM_TO_BACKEND[chainName] ? URL_CHAIN_PARAM_TO_BACKEND[chainName] : 'ETHEREUM'
}
export const CHAIN_NAME_TO_CHAIN_ID: { [key: string]: SupportedChainId } = {
ETHEREUM: SupportedChainId.MAINNET,
POLYGON: SupportedChainId.POLYGON,
CELO: SupportedChainId.CELO,
ARBITRUM: SupportedChainId.ARBITRUM_ONE,
OPTIMISM: SupportedChainId.OPTIMISM,
}
export const BACKEND_CHAIN_NAMES: Chain[] = ['ARBITRUM', 'CELO', 'ETHEREUM', 'OPTIMISM', 'POLYGON']
export function isValidBackendChainName(chainName: string | undefined): chainName is Chain {
if (!chainName) return false
for (let i = 0; i < BACKEND_CHAIN_NAMES.length; i++) {
if (chainName === BACKEND_CHAIN_NAMES[i]) return true
}
return false
}
......@@ -9,7 +9,7 @@ import { isL2ChainId } from 'utils/chains'
import { useAllLists, useCombinedActiveList, useInactiveListUrls } from '../state/lists/hooks'
import { WrappedTokenInfo } from '../state/lists/wrappedTokenInfo'
import { useUserAddedTokens } from '../state/user/hooks'
import { useUserAddedTokens, useUserAddedTokensOnChain } from '../state/user/hooks'
import { TokenAddressMap, useUnsupportedTokenList } from './../state/lists/hooks'
// reduce token map into standard address <-> Token mapping, optionally include user added tokens
......@@ -160,6 +160,20 @@ export function useIsUserAddedToken(currency: Currency | undefined | null): bool
return !!userAddedTokens.find((token) => currency.equals(token))
}
// Check if currency on specific chain is included in custom list from user storage
export function useIsUserAddedTokenOnChain(
address: string | undefined | null,
chain: number | undefined | null
): boolean {
const userAddedTokens = useUserAddedTokensOnChain(chain)
if (!address || !chain) {
return false
}
return !!userAddedTokens.find((token) => token.address === address)
}
// undefined if invalid or does not exist
// null if loading or null was passed
// otherwise returns the token
......
import { Chain } from 'graphql/data/__generated__/TokenQuery.graphql'
import { useGlobalChainName } from 'graphql/data/util'
import { useEffect, useRef } from 'react'
export const useOnGlobalChainSwitch = (callback: (chain: Chain) => void) => {
const globalChainName = useGlobalChainName()
const prevGlobalChainRef = useRef(globalChainName)
useEffect(() => {
if (prevGlobalChainRef.current !== globalChainName) {
callback(globalChainName)
}
prevGlobalChainRef.current = globalChainName
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [globalChainName])
}
......@@ -39,7 +39,7 @@ export function getNativeLogoURI(chainId: SupportedChainId = SupportedChainId.MA
}
}
function getTokenLogoURI(address: string, chainId: SupportedChainId = SupportedChainId.MAINNET): string | void {
export function getTokenLogoURI(address: string, chainId: SupportedChainId = SupportedChainId.MAINNET): string | void {
const networkName = chainIdToNetworkName(chainId)
const networksWithUrls = [SupportedChainId.ARBITRUM_ONE, SupportedChainId.MAINNET, SupportedChainId.OPTIMISM]
if (networksWithUrls.includes(chainId)) {
......
......@@ -165,7 +165,7 @@ export default function App() {
{tokensFlag === TokensVariant.Enabled && (
<>
<Route
path="/tokens"
path="/tokens/:chainName"
element={
<Suspense fallback={<LoadingTokens />}>
<Tokens />
......@@ -173,7 +173,7 @@ export default function App() {
}
/>
<Route
path="/tokens/:tokenAddress"
path="/tokens/:chainName/:tokenAddress"
element={
<Suspense fallback={<LoadingTokenDetails />}>
<TokenDetails />
......
import { Token } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import { formatToDecimal } from 'analytics/utils'
import {
......@@ -20,9 +21,13 @@ import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal'
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 { isCelo, nativeOnChain, WRAPPED_NATIVE_CURRENCY } from 'constants/tokens'
import { checkWarning } from 'constants/tokenSafety'
import { Chain } from 'graphql/data/__generated__/TokenQuery.graphql'
import { useTokenQuery } from 'graphql/data/Token'
import { useIsUserAddedToken, useToken } from 'hooks/Tokens'
import { CHAIN_NAME_TO_CHAIN_ID, validateUrlChainParam } from 'graphql/data/util'
import { useIsUserAddedTokenOnChain } from 'hooks/Tokens'
import { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch'
import { useNetworkTokenBalances } from 'hooks/useNetworkTokenBalances'
import { useStablecoinValue } from 'hooks/useStablecoinPrice'
import { useAtomValue } from 'jotai/utils'
......@@ -90,14 +95,40 @@ function NetworkBalances(tokenAddress: string | undefined) {
}
export default function TokenDetails() {
const { tokenAddress } = useParams<{ tokenAddress?: string }>()
const token = useToken(tokenAddress)
const { tokenAddress: tokenAddressParam, chainName } = useParams<{ tokenAddress?: string; chainName?: string }>()
const chainId = CHAIN_NAME_TO_CHAIN_ID[validateUrlChainParam(chainName)]
let tokenAddress = tokenAddressParam
let nativeCurrency
if (tokenAddressParam === 'NATIVE') {
nativeCurrency = nativeOnChain(chainId)
tokenAddress = WRAPPED_NATIVE_CURRENCY[chainId]?.address?.toLowerCase()
}
const tokenWarning = tokenAddress ? checkWarning(tokenAddress) : null
const isBlockedToken = tokenWarning?.canProceed === false
const timePeriod = useAtomValue(filterTimeAtom)
const currentChainName = validateUrlChainParam(chainName)
const token = useTokenQuery(tokenAddress ?? '', currentChainName, timePeriod).tokens?.[0]
const navigate = useNavigate()
const switchChains = (newChain: Chain) => {
if (tokenAddressParam === 'NATIVE') {
navigate(`/tokens/${newChain.toLowerCase()}/NATIVE`)
} else {
token?.project?.tokens?.forEach((token) => {
if (token.chain === newChain && token.address) {
navigate(`/tokens/${newChain.toLowerCase()}/${token.address}`)
}
})
}
}
useOnGlobalChainSwitch(switchChains)
const [continueSwap, setContinueSwap] = useState<{ resolve: (value: boolean | PromiseLike<boolean>) => void }>()
const shouldShowSpeedbump = !useIsUserAddedToken(token) && tokenWarning !== null
// TODO: Make useToken() work on non-mainenet chains or replace this logic
const shouldShowSpeedbump = !useIsUserAddedTokenOnChain(tokenAddress, chainId) && tokenWarning !== null
// Show token safety modal if Swap-reviewing a warning token, at all times if the current token is blocked
const onReviewSwap = useCallback(() => {
return new Promise<boolean>((resolve) => {
......@@ -114,10 +145,11 @@ export default function TokenDetails() {
)
/* network balance handling */
const { data: networkData } = NetworkBalances(token?.address)
const { data: networkData } = NetworkBalances(tokenAddress)
const { chainId: connectedChainId, account } = useWeb3React()
const balanceValue = useTokenBalance(account, token ?? undefined)
// TODO: consider updating useTokenBalance to work with just address/chain to avoid using Token data structure here
const balanceValue = useTokenBalance(account, new Token(chainId, tokenAddress ?? '', 18))
const balance = balanceValue ? formatToDecimal(balanceValue, Math.min(balanceValue.currency.decimals, 6)) : undefined
const balanceUsdValue = useStablecoinValue(balanceValue)?.toFixed(2)
const balanceUsd = balanceUsdValue ? parseFloat(balanceUsdValue) : undefined
......@@ -131,9 +163,6 @@ 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]
......@@ -147,7 +176,7 @@ export default function TokenDetails() {
key={chainId}
logoUrl={chainInfo.logoUrl}
balance={'1'}
tokenSymbol={token?.symbol}
tokenSymbol={token.symbol}
fiatValue={fiatValue.toSignificant(2)}
label={chainInfo.label}
networkColor={networkColor}
......@@ -156,9 +185,11 @@ export default function TokenDetails() {
})
: null
const tokenProject = query.tokenProjects?.[0]
const tokenProjectMarket = tokenProject?.markets?.[0]
const tokenMarket = tokenProject?.tokens?.[0]?.market
const defaultWidgetToken =
nativeCurrency ??
(token?.address && token.symbol && token.name
? new Token(CHAIN_NAME_TO_CHAIN_ID[currentChainName], token.address, 18, token.symbol, token.name)
: undefined)
// TODO: Fix this logic to not automatically redirect on refresh, yet still catch invalid addresses
//const location = useLocation()
......@@ -171,32 +202,33 @@ export default function TokenDetails() {
{token && (
<>
<LeftPanel>
<BreadcrumbNavLink to="/tokens">
<BreadcrumbNavLink to={`/tokens/${chainName}`}>
<ArrowLeft size={14} /> Tokens
</BreadcrumbNavLink>
<ChartSection token={token} tokenData={tokenProject} />
<ChartSection token={token} nativeCurrency={nativeCurrency} />
<StatsSection
TVL={tokenMarket?.totalValueLocked?.value}
volume24H={tokenProjectMarket?.volume1D?.value}
priceHigh52W={tokenProjectMarket?.priceHigh52W?.value}
priceLow52W={tokenProjectMarket?.priceLow52W?.value}
TVL={token.market?.totalValueLocked?.value}
volume24H={token.market?.volume24H?.value}
// TODO: Reenable these values once they're available in schema
// priceHigh52W={token.market?.priceHigh52W?.value}
// priceLow52W={token.market?.priceLow52W?.value}
/>
<AboutSection
address={token.address}
description={tokenProject?.description}
homepageUrl={tokenProject?.homepageUrl}
twitterName={tokenProject?.twitterName}
address={token.address ?? ''}
description={token.project?.description}
homepageUrl={token.project?.homepageUrl}
twitterName={token.project?.twitterName}
/>
<AddressSection address={token.address} />
<AddressSection address={token.address ?? ''} />
</LeftPanel>
<RightPanel>
<Widget defaultToken={token ?? undefined} onReviewSwapClick={onReviewSwap} />
{tokenWarning && <TokenSafetyMessage tokenAddress={token.address} warning={tokenWarning} />}
<BalanceSummary address={token.address} balance={balance} balanceUsd={balanceUsd} />
<Widget defaultToken={!isCelo(chainId) ? defaultWidgetToken : undefined} onReviewSwapClick={onReviewSwap} />
{tokenWarning && <TokenSafetyMessage tokenAddress={token.address ?? ''} warning={tokenWarning} />}
<BalanceSummary address={token.address ?? ''} balance={balance} balanceUsd={balanceUsd} />
</RightPanel>
<Footer>
<FooterBalanceSummary
address={token.address}
address={token.address ?? ''}
networkBalances={balancesByNetwork}
balance={balance}
balanceUsd={balanceUsd}
......
......@@ -9,9 +9,11 @@ import SearchBar from 'components/Tokens/TokenTable/SearchBar'
import TimeSelector from 'components/Tokens/TokenTable/TimeSelector'
import TokenTable, { LoadingTokenTable } from 'components/Tokens/TokenTable/TokenTable'
import { FavoriteTokensVariant, useFavoriteTokensFlag } from 'featureFlags/flags/favoriteTokens'
import { isValidBackendChainName } from 'graphql/data/util'
import { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch'
import { useResetAtom } from 'jotai/utils'
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components/macro'
import { ThemedText } from 'theme'
......@@ -74,6 +76,11 @@ const Tokens = () => {
resetFilterString()
}, [location, resetFilterString])
const navigate = useNavigate()
useOnGlobalChainSwitch((chain) => {
if (isValidBackendChainName(chain)) navigate(`/tokens/${chain.toLowerCase()}`)
})
return (
<Trace page={PageName.TOKENS_PAGE} shouldLogImpression>
<ExploreContainer>
......
......@@ -245,8 +245,7 @@ export function useRemoveUserAddedToken(): (chainId: number, address: string) =>
)
}
export function useUserAddedTokens(): Token[] {
const { chainId } = useWeb3React()
export function useUserAddedTokensOnChain(chainId: number | undefined | null): Token[] {
const serializedTokensMap = useAppSelector(({ user: { tokens } }) => tokens)
return useMemo(() => {
......@@ -258,6 +257,10 @@ export function useUserAddedTokens(): Token[] {
}, [serializedTokensMap, chainId])
}
export function useUserAddedTokens(): Token[] {
return useUserAddedTokensOnChain(useWeb3React().chainId)
}
function serializePair(pair: Pair): SerializedPair {
return {
token0: serializeToken(pair.token0),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment