Commit 46724cd8 authored by eddie's avatar eddie Committed by GitHub

feat: preserve input currency on TDP when navigating (#6209)

* fix: NPE when connector is undefined

* feat: retain input token when switching Token Detail Page

* fix: remove logic from string template
parent 09e6d38f
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import { Trace } from '@uniswap/analytics' import { Trace } from '@uniswap/analytics'
import { InterfacePageName } from '@uniswap/analytics-events' import { InterfacePageName } from '@uniswap/analytics-events'
import { Currency } from '@uniswap/sdk-core' import { Currency, Field } from '@uniswap/widgets'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import CurrencyLogo from 'components/Logo/CurrencyLogo' import CurrencyLogo from 'components/Logo/CurrencyLogo'
import { AboutSection } from 'components/Tokens/TokenDetails/About' import { AboutSection } from 'components/Tokens/TokenDetails/About'
...@@ -23,6 +23,7 @@ import StatsSection from 'components/Tokens/TokenDetails/StatsSection' ...@@ -23,6 +23,7 @@ import StatsSection from 'components/Tokens/TokenDetails/StatsSection'
import TokenSafetyMessage from 'components/TokenSafety/TokenSafetyMessage' import TokenSafetyMessage from 'components/TokenSafety/TokenSafetyMessage'
import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal' import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal'
import Widget from 'components/Widget' import Widget from 'components/Widget'
import { SwapTokens } from 'components/Widget/inputs'
import { getChainInfo } from 'constants/chainInfo' import { getChainInfo } from 'constants/chainInfo'
import { NATIVE_CHAIN_ID, nativeOnChain } from 'constants/tokens' import { NATIVE_CHAIN_ID, nativeOnChain } from 'constants/tokens'
import { checkWarning } from 'constants/tokenSafety' import { checkWarning } from 'constants/tokenSafety'
...@@ -33,6 +34,7 @@ import { CHAIN_NAME_TO_CHAIN_ID, getTokenDetailsURL } from 'graphql/data/util' ...@@ -33,6 +34,7 @@ import { CHAIN_NAME_TO_CHAIN_ID, getTokenDetailsURL } from 'graphql/data/util'
import { useIsUserAddedTokenOnChain } from 'hooks/Tokens' import { useIsUserAddedTokenOnChain } from 'hooks/Tokens'
import { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch' import { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch'
import { UNKNOWN_TOKEN_SYMBOL, useTokenFromActiveNetwork } from 'lib/hooks/useCurrency' import { UNKNOWN_TOKEN_SYMBOL, useTokenFromActiveNetwork } from 'lib/hooks/useCurrency'
import { getTokenAddress } from 'lib/utils/analytics'
import { useCallback, useMemo, useState, useTransition } from 'react' import { useCallback, useMemo, useState, useTransition } from 'react'
import { ArrowLeft } from 'react-feather' import { ArrowLeft } from 'react-feather'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
...@@ -88,6 +90,7 @@ function useRelevantToken( ...@@ -88,6 +90,7 @@ function useRelevantToken(
type TokenDetailsProps = { type TokenDetailsProps = {
urlAddress: string | undefined urlAddress: string | undefined
inputTokenAddress?: string
chain: Chain chain: Chain
tokenQuery: TokenQuery tokenQuery: TokenQuery
tokenPriceQuery: TokenPriceQuery | undefined tokenPriceQuery: TokenPriceQuery | undefined
...@@ -95,6 +98,7 @@ type TokenDetailsProps = { ...@@ -95,6 +98,7 @@ type TokenDetailsProps = {
} }
export default function TokenDetails({ export default function TokenDetails({
urlAddress, urlAddress,
inputTokenAddress,
chain, chain,
tokenQuery, tokenQuery,
tokenPriceQuery, tokenPriceQuery,
...@@ -120,7 +124,8 @@ export default function TokenDetails({ ...@@ -120,7 +124,8 @@ export default function TokenDetails({
[tokenQueryData] [tokenQueryData]
) )
const { token, didFetchFromChain } = useRelevantToken(address, pageChainId, tokenQueryData) const { token: detailedToken, didFetchFromChain } = useRelevantToken(address, pageChainId, tokenQueryData)
const { token: inputToken } = useRelevantToken(inputTokenAddress, pageChainId, undefined)
const tokenWarning = address ? checkWarning(address) : null const tokenWarning = address ? checkWarning(address) : null
const isBlockedToken = tokenWarning?.canProceed === false const isBlockedToken = tokenWarning?.canProceed === false
...@@ -134,18 +139,27 @@ export default function TokenDetails({ ...@@ -134,18 +139,27 @@ export default function TokenDetails({
const bridgedAddress = crossChainMap[update] const bridgedAddress = crossChainMap[update]
if (bridgedAddress) { if (bridgedAddress) {
startTokenTransition(() => navigate(getTokenDetailsURL({ address: bridgedAddress, chain }))) startTokenTransition(() => navigate(getTokenDetailsURL({ address: bridgedAddress, chain })))
} else if (didFetchFromChain || token?.isNative) { } else if (didFetchFromChain || detailedToken?.isNative) {
startTokenTransition(() => navigate(getTokenDetailsURL({ address, chain }))) startTokenTransition(() => navigate(getTokenDetailsURL({ address, chain })))
} }
}, },
[address, chain, crossChainMap, didFetchFromChain, navigate, token?.isNative] [address, chain, crossChainMap, didFetchFromChain, navigate, detailedToken?.isNative]
) )
useOnGlobalChainSwitch(navigateToTokenForChain) useOnGlobalChainSwitch(navigateToTokenForChain)
const navigateToWidgetSelectedToken = useCallback( const navigateToWidgetSelectedToken = useCallback(
(token: Currency) => { (tokens: SwapTokens) => {
const address = token.isNative ? NATIVE_CHAIN_ID : token.address const newDefaultToken = tokens[Field.OUTPUT] ?? tokens.default
startTokenTransition(() => navigate(getTokenDetailsURL({ address, chain }))) const address = newDefaultToken?.isNative ? NATIVE_CHAIN_ID : newDefaultToken?.address
startTokenTransition(() =>
navigate(
getTokenDetailsURL({
address,
chain,
inputAddress: tokens[Field.INPUT] ? getTokenAddress(tokens[Field.INPUT] as Currency) : null,
})
)
)
}, },
[chain, navigate] [chain, navigate]
) )
...@@ -170,30 +184,30 @@ export default function TokenDetails({ ...@@ -170,30 +184,30 @@ export default function TokenDetails({
) )
// address will never be undefined if token is defined; address is checked here to appease typechecker // address will never be undefined if token is defined; address is checked here to appease typechecker
if (token === undefined || !address) { if (detailedToken === undefined || !address) {
return <InvalidTokenDetails chainName={address && getChainInfo(pageChainId)?.label} /> return <InvalidTokenDetails chainName={address && getChainInfo(pageChainId)?.label} />
} }
return ( return (
<Trace <Trace
page={InterfacePageName.TOKEN_DETAILS_PAGE} page={InterfacePageName.TOKEN_DETAILS_PAGE}
properties={{ tokenAddress: address, tokenName: token?.name }} properties={{ tokenAddress: address, tokenName: detailedToken?.name }}
shouldLogImpression shouldLogImpression
> >
<TokenDetailsLayout> <TokenDetailsLayout>
{token && !isPending ? ( {detailedToken && !isPending ? (
<LeftPanel> <LeftPanel>
<BreadcrumbNavLink to={`/tokens/${chain.toLowerCase()}`}> <BreadcrumbNavLink to={`/tokens/${chain.toLowerCase()}`}>
<ArrowLeft data-testid="token-details-return-button" size={14} /> Tokens <ArrowLeft data-testid="token-details-return-button" size={14} /> Tokens
</BreadcrumbNavLink> </BreadcrumbNavLink>
<TokenInfoContainer data-testid="token-info-container"> <TokenInfoContainer data-testid="token-info-container">
<TokenNameCell> <TokenNameCell>
<CurrencyLogo currency={token} size="32px" hideL2Icon={false} /> <CurrencyLogo currency={detailedToken} size="32px" hideL2Icon={false} />
{token.name ?? <Trans>Name not found</Trans>} {detailedToken.name ?? <Trans>Name not found</Trans>}
<TokenSymbol>{token.symbol ?? <Trans>Symbol not found</Trans>}</TokenSymbol> <TokenSymbol>{detailedToken.symbol ?? <Trans>Symbol not found</Trans>}</TokenSymbol>
</TokenNameCell> </TokenNameCell>
<TokenActions> <TokenActions>
<ShareButton currency={token} /> <ShareButton currency={detailedToken} />
</TokenActions> </TokenActions>
</TokenInfoContainer> </TokenInfoContainer>
<ChartSection tokenPriceQuery={tokenPriceQuery} onChangeTimePeriod={onChangeTimePeriod} /> <ChartSection tokenPriceQuery={tokenPriceQuery} onChangeTimePeriod={onChangeTimePeriod} />
...@@ -211,7 +225,7 @@ export default function TokenDetails({ ...@@ -211,7 +225,7 @@ export default function TokenDetails({
homepageUrl={tokenQueryData?.project?.homepageUrl} homepageUrl={tokenQueryData?.project?.homepageUrl}
twitterName={tokenQueryData?.project?.twitterName} twitterName={tokenQueryData?.project?.twitterName}
/> />
{!token.isNative && <AddressSection address={address} />} {!detailedToken.isNative && <AddressSection address={address} />}
</LeftPanel> </LeftPanel>
) : ( ) : (
<TokenDetailsSkeleton /> <TokenDetailsSkeleton />
...@@ -221,16 +235,17 @@ export default function TokenDetails({ ...@@ -221,16 +235,17 @@ export default function TokenDetails({
<div style={{ pointerEvents: isBlockedToken ? 'none' : 'auto' }}> <div style={{ pointerEvents: isBlockedToken ? 'none' : 'auto' }}>
<Widget <Widget
defaultTokens={{ defaultTokens={{
default: token ?? undefined, [Field.INPUT]: inputToken ?? undefined,
default: detailedToken ?? undefined,
}} }}
onDefaultTokenChange={navigateToWidgetSelectedToken} onDefaultTokenChange={navigateToWidgetSelectedToken}
onReviewSwapClick={onReviewSwapClick} onReviewSwapClick={onReviewSwapClick}
/> />
</div> </div>
{tokenWarning && <TokenSafetyMessage tokenAddress={address} warning={tokenWarning} />} {tokenWarning && <TokenSafetyMessage tokenAddress={address} warning={tokenWarning} />}
{token && <BalanceSummary token={token} />} {detailedToken && <BalanceSummary token={detailedToken} />}
</RightPanel> </RightPanel>
{token && <MobileBalanceSummaryFooter token={token} />} {detailedToken && <MobileBalanceSummaryFooter token={detailedToken} />}
<TokenSafetyModal <TokenSafetyModal
isOpen={openTokenSafetyModal || !!continueSwap} isOpen={openTokenSafetyModal || !!continueSwap}
......
...@@ -31,7 +31,7 @@ import { useIsDarkMode } from 'state/user/hooks' ...@@ -31,7 +31,7 @@ import { useIsDarkMode } from 'state/user/hooks'
import { computeRealizedPriceImpact } from 'utils/prices' import { computeRealizedPriceImpact } from 'utils/prices'
import { switchChain } from 'utils/switchChain' import { switchChain } from 'utils/switchChain'
import { DefaultTokens, useSyncWidgetInputs } from './inputs' import { DefaultTokens, SwapTokens, useSyncWidgetInputs } from './inputs'
import { useSyncWidgetSettings } from './settings' import { useSyncWidgetSettings } from './settings'
import { DARK_THEME, LIGHT_THEME } from './theme' import { DARK_THEME, LIGHT_THEME } from './theme'
import { useSyncWidgetTransactions } from './transactions' import { useSyncWidgetTransactions } from './transactions'
...@@ -47,7 +47,7 @@ function useWidgetTheme() { ...@@ -47,7 +47,7 @@ function useWidgetTheme() {
interface WidgetProps { interface WidgetProps {
defaultTokens: DefaultTokens defaultTokens: DefaultTokens
width?: number | string width?: number | string
onDefaultTokenChange?: (token: Currency) => void onDefaultTokenChange?: (tokens: SwapTokens) => void
onReviewSwapClick?: OnReviewSwapClick onReviewSwapClick?: OnReviewSwapClick
} }
......
...@@ -10,7 +10,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' ...@@ -10,7 +10,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
const EMPTY_AMOUNT = '' const EMPTY_AMOUNT = ''
type SwapValue = Required<SwapController>['value'] type SwapValue = Required<SwapController>['value']
type SwapTokens = Pick<SwapValue, Field.INPUT | Field.OUTPUT> & { default?: Currency } export type SwapTokens = Pick<SwapValue, Field.INPUT | Field.OUTPUT> & { default?: Currency }
export type DefaultTokens = Partial<SwapTokens> export type DefaultTokens = Partial<SwapTokens>
function missingDefaultToken(tokens: SwapTokens) { function missingDefaultToken(tokens: SwapTokens) {
...@@ -47,7 +47,7 @@ export function useSyncWidgetInputs({ ...@@ -47,7 +47,7 @@ export function useSyncWidgetInputs({
onDefaultTokenChange, onDefaultTokenChange,
}: { }: {
defaultTokens: DefaultTokens defaultTokens: DefaultTokens
onDefaultTokenChange?: (token: Currency) => void onDefaultTokenChange?: (tokens: SwapTokens) => void
}) { }) {
const trace = useTrace({ section: InterfaceSectionName.WIDGET }) const trace = useTrace({ section: InterfaceSectionName.WIDGET })
...@@ -137,7 +137,10 @@ export function useSyncWidgetInputs({ ...@@ -137,7 +137,10 @@ export function useSyncWidgetInputs({
}) })
if (missingDefaultToken(update)) { if (missingDefaultToken(update)) {
onDefaultTokenChange?.(update[Field.OUTPUT] ?? selectingToken) onDefaultTokenChange?.({
...update,
default: update[Field.OUTPUT] ?? selectingToken,
})
return return
} }
setTokens(update) setTokens(update)
......
...@@ -108,8 +108,19 @@ export const CHAIN_NAME_TO_CHAIN_ID: { [key: string]: SupportedChainId } = { ...@@ -108,8 +108,19 @@ export const CHAIN_NAME_TO_CHAIN_ID: { [key: string]: SupportedChainId } = {
export const BACKEND_CHAIN_NAMES: Chain[] = [Chain.Ethereum, Chain.Polygon, Chain.Optimism, Chain.Arbitrum, Chain.Celo] export const BACKEND_CHAIN_NAMES: Chain[] = [Chain.Ethereum, Chain.Polygon, Chain.Optimism, Chain.Arbitrum, Chain.Celo]
export function getTokenDetailsURL({ address, chain }: { address?: string | null; chain: Chain }) { export function getTokenDetailsURL({
return `/tokens/${chain.toLowerCase()}/${address ?? NATIVE_CHAIN_ID}` address,
chain,
inputAddress,
}: {
address?: string | null
chain: Chain
inputAddress?: string | null
}) {
const chainName = chain.toLowerCase()
const tokenAddress = address ?? NATIVE_CHAIN_ID
const inputAddressSuffix = inputAddress ? `?inputCurrency=${inputAddress}` : ''
return `/tokens/${chainName}/${tokenAddress}${inputAddressSuffix}`
} }
export function unwrapToken< export function unwrapToken<
......
...@@ -3,6 +3,7 @@ import { TokenDetailsPageSkeleton } from 'components/Tokens/TokenDetails/Skeleto ...@@ -3,6 +3,7 @@ import { TokenDetailsPageSkeleton } from 'components/Tokens/TokenDetails/Skeleto
import { NATIVE_CHAIN_ID } from 'constants/tokens' import { NATIVE_CHAIN_ID } from 'constants/tokens'
import { useTokenPriceQuery, useTokenQuery } from 'graphql/data/__generated__/types-and-hooks' import { useTokenPriceQuery, useTokenQuery } from 'graphql/data/__generated__/types-and-hooks'
import { TimePeriod, toHistoryDuration, validateUrlChainParam } from 'graphql/data/util' import { TimePeriod, toHistoryDuration, validateUrlChainParam } from 'graphql/data/util'
import useParsedQueryString from 'hooks/useParsedQueryString'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { atomWithStorage } from 'jotai/utils' import { atomWithStorage } from 'jotai/utils'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
...@@ -12,27 +13,36 @@ import { getNativeTokenDBAddress } from 'utils/nativeTokens' ...@@ -12,27 +13,36 @@ import { getNativeTokenDBAddress } from 'utils/nativeTokens'
export const pageTimePeriodAtom = atomWithStorage<TimePeriod>('tokenDetailsTimePeriod', TimePeriod.DAY) export const pageTimePeriodAtom = atomWithStorage<TimePeriod>('tokenDetailsTimePeriod', TimePeriod.DAY)
export default function TokenDetailsPage() { export default function TokenDetailsPage() {
const { tokenAddress, chainName } = useParams<{ tokenAddress: string; chainName?: string }>() const { tokenAddress, chainName } = useParams<{
tokenAddress: string
chainName?: string
}>()
const chain = validateUrlChainParam(chainName) const chain = validateUrlChainParam(chainName)
const isNative = tokenAddress === NATIVE_CHAIN_ID const isNative = tokenAddress === NATIVE_CHAIN_ID
const [timePeriod, setTimePeriod] = useAtom(pageTimePeriodAtom) const [timePeriod, setTimePeriod] = useAtom(pageTimePeriodAtom)
const [address, duration] = useMemo( const [detailedTokenAddress, duration] = useMemo(
/* tokenAddress will always be defined in the path for for this page to render, but useParams will always /* tokenAddress will always be defined in the path for for this page to render, but useParams will always
return optional arguments; nullish coalescing operator is present here to appease typechecker */ return optional arguments; nullish coalescing operator is present here to appease typechecker */
() => [isNative ? getNativeTokenDBAddress(chain) : tokenAddress ?? '', toHistoryDuration(timePeriod)], () => [isNative ? getNativeTokenDBAddress(chain) : tokenAddress ?? '', toHistoryDuration(timePeriod)],
[chain, isNative, timePeriod, tokenAddress] [chain, isNative, timePeriod, tokenAddress]
) )
const parsedQs = useParsedQueryString()
const parsedInputTokenAddress: string | undefined = useMemo(() => {
return typeof parsedQs.inputCurrency === 'string' ? (parsedQs.inputCurrency as string) : undefined
}, [parsedQs])
const { data: tokenQuery } = useTokenQuery({ const { data: tokenQuery } = useTokenQuery({
variables: { variables: {
address, address: detailedTokenAddress,
chain, chain,
}, },
}) })
const { data: tokenPriceQuery } = useTokenPriceQuery({ const { data: tokenPriceQuery } = useTokenPriceQuery({
variables: { variables: {
address, address: detailedTokenAddress,
chain, chain,
duration, duration,
}, },
...@@ -53,6 +63,7 @@ export default function TokenDetailsPage() { ...@@ -53,6 +63,7 @@ export default function TokenDetailsPage() {
tokenQuery={tokenQuery} tokenQuery={tokenQuery}
tokenPriceQuery={currentPriceQuery} tokenPriceQuery={currentPriceQuery}
onChangeTimePeriod={setTimePeriod} onChangeTimePeriod={setTimePeriod}
inputTokenAddress={parsedInputTokenAddress}
/> />
) )
} }
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