feat: [info] Add Token Description Component (#7400)

* working token details section

* update decription styling

* different chain explorers

* remove wrap check for color extraction

* move token description to its own component, add copy, make simple project query

* rename styled components and add tests

* remove old comment

* await test fragment

* fix: update description truncation from TokenDescription (#7413)

* fix: update description truncation from TokenDescription

* fix: use better name

* fix: test if description is hidden or not (#7422)

---------

Co-authored-by: Charles Bachmeier <charles@bachmeier.io>

* make darker or lighter

* showCopy default false

* update test

* remove unused styles

---------

Co-authored-by: eddie <66155195+just-toby@users.noreply.github.com>
This commit is contained in:
Charles Bachmeier 2023-10-11 10:32:33 -07:00 committed by GitHub
parent 1882b14690
commit 82a194987a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 2090 additions and 49 deletions

@ -0,0 +1,22 @@
import { ComponentProps } from 'react'
export const EtherscanLogo = (props: ComponentProps<'svg'>) => (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="transparent"
{...props}
>
<path
d="M5.08042 8.66148C5.08043 8.58693 5.09517 8.51313 5.12378 8.44429C5.1524 8.37546 5.19432 8.31297 5.24716 8.26038C5.30001 8.2078 5.3627 8.16617 5.43167 8.13788C5.50064 8.1096 5.57452 8.09522 5.64907 8.09557L6.59187 8.09865C6.74218 8.09865 6.88635 8.15836 6.99263 8.26465C7.09893 8.37094 7.15865 8.5151 7.15865 8.66543V12.2303C7.26478 12.1988 7.4011 12.1652 7.55026 12.1301C7.65387 12.1058 7.74621 12.0471 7.8123 11.9637C7.87839 11.8803 7.91434 11.777 7.91432 11.6705V7.24848C7.91432 7.09814 7.97403 6.95397 8.08032 6.84766C8.1866 6.74135 8.33077 6.68162 8.4811 6.68158H9.42577C9.57609 6.68162 9.72026 6.74135 9.82655 6.84766C9.93284 6.95397 9.99255 7.09814 9.99255 7.24848V11.3526C9.99255 11.3526 10.2291 11.2569 10.4595 11.1596C10.545 11.1234 10.6181 11.0629 10.6694 10.9854C10.7208 10.908 10.7482 10.8172 10.7483 10.7242V5.83152C10.7483 5.68122 10.808 5.53707 10.9143 5.43078C11.0206 5.32449 11.1647 5.26478 11.315 5.26474H12.2597C12.41 5.26474 12.5542 5.32445 12.6604 5.43075C12.7667 5.53704 12.8265 5.6812 12.8265 5.83152V9.86056C13.6455 9.267 14.4754 8.55315 15.1341 7.69474C15.2297 7.57015 15.2929 7.42383 15.3181 7.26887C15.3434 7.1139 15.3299 6.95509 15.2788 6.8066C14.9739 5.9294 14.4894 5.12551 13.856 4.44636C13.2226 3.76722 12.4544 3.22777 11.6005 2.86256C10.7467 2.49734 9.82602 2.31439 8.89742 2.32542C7.96882 2.33645 7.05275 2.54121 6.20783 2.9266C5.36291 3.31199 4.60774 3.86952 3.99066 4.56352C3.37358 5.25751 2.90817 6.07269 2.62422 6.95689C2.34027 7.84107 2.24403 8.7748 2.34166 9.69832C2.43929 10.6218 2.72863 11.5148 3.19118 12.3201C3.27176 12.459 3.39031 12.572 3.53289 12.6459C3.67548 12.7198 3.83618 12.7514 3.99614 12.7372C4.17482 12.7215 4.3973 12.6992 4.66181 12.6681C4.77695 12.655 4.88326 12.6001 4.96048 12.5137C5.0377 12.4273 5.08043 12.3155 5.08053 12.1996L5.08042 8.66148Z"
fill={props.fill ?? '#607BEE'}
/>
<path
d="M5.05957 14.3792C6.05531 15.1036 7.23206 15.5384 8.45961 15.6356C9.68716 15.7326 10.9176 15.4883 12.0149 14.9294C13.1122 14.3705 14.0334 13.519 14.6768 12.4691C15.3201 11.4191 15.6605 10.2116 15.6601 8.98024C15.6601 8.82658 15.653 8.67457 15.6428 8.52344C13.2041 12.1605 8.70139 13.8609 5.05978 14.3786"
fill={props.fill ?? '#607BEE'}
/>
</svg>
)

@ -0,0 +1,18 @@
import { ComponentProps } from 'react'
export const Globe = (props: ComponentProps<'svg'>) => (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="transparent"
{...props}
>
<path
d="M5.12245 9.5625C5.23495 11.8725 6.01495 14.2275 7.37245 16.32C4.19245 15.615 1.76996 12.8925 1.52246 9.5625H5.12245ZM7.37245 1.67999C4.19245 2.38499 1.76996 5.1075 1.52246 8.4375H5.12245C5.23495 6.1275 6.01495 3.77249 7.37245 1.67999ZM9.14997 1.5H8.84995L8.62496 1.82249C7.19996 3.84749 6.36745 6.1725 6.24745 8.4375H11.7525C11.6325 6.1725 10.8 3.84749 9.37496 1.82249L9.14997 1.5ZM6.24745 9.5625C6.36745 11.8275 7.19996 14.1525 8.62496 16.1775L8.84995 16.5H9.14997L9.37496 16.1775C10.8 14.1525 11.6325 11.8275 11.7525 9.5625H6.24745ZM12.8775 9.5625C12.765 11.8725 11.985 14.2275 10.6275 16.32C13.8075 15.615 16.23 12.8925 16.4775 9.5625H12.8775ZM16.4775 8.4375C16.23 5.1075 13.8075 2.38499 10.6275 1.67999C11.985 3.77249 12.765 6.1275 12.8775 8.4375H16.4775Z"
fill={props.fill ?? '#607BEE'}
/>
</svg>
)

@ -0,0 +1,18 @@
import { ComponentProps } from 'react'
export const TwitterXLogo = (props: ComponentProps<'svg'>) => (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="transparent"
{...props}
>
<path
d="M12.8761 3H14.9451L10.4251 8.16609L15.7425 15.196H11.579L8.31797 10.9324L4.58662 15.196H2.51644L7.35104 9.67026L2.25 3H6.51922L9.46689 6.89708L12.8761 3ZM12.15 13.9576H13.2964L5.89628 4.17332H4.66605L12.15 13.9576Z"
fill={props.fill ?? '#607BEE'}
/>
</svg>
)

@ -1,19 +1,14 @@
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import { ChainId } from '@uniswap/sdk-core' import { ChainId } from '@uniswap/sdk-core'
import { getChainInfo } from 'constants/chainInfo' import { getChainInfo } from 'constants/chainInfo'
import { darken } from 'polished'
import { useState } from 'react' import { useState } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import { ThemedText } from 'theme/components' import { ThemedText } from 'theme/components'
import { textFadeIn } from 'theme/styles' import { textFadeIn } from 'theme/styles'
import Resource from './Resource' import Resource from './Resource'
import { NoInfoAvailable, TRUNCATE_CHARACTER_COUNT, truncateDescription, TruncateDescriptionButton } from './shared'
const NoInfoAvailable = styled.span`
color: ${({ theme }) => theme.neutral3};
font-weight: 485;
font-size: 16px;
`
const TokenDescriptionContainer = styled.div` const TokenDescriptionContainer = styled.div`
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -24,32 +19,6 @@ const TokenDescriptionContainer = styled.div`
white-space: pre-wrap; white-space: pre-wrap;
` `
const TruncateDescriptionButton = styled.div`
color: ${({ theme }) => theme.neutral2};
font-weight: 485;
font-size: 0.85em;
padding-top: 0.5em;
&:hover,
&:focus {
color: ${({ theme }) => darken(0.1, theme.neutral2)};
cursor: pointer;
}
`
const truncateDescription = (desc: string) => {
//trim the string to the maximum length
let tokenDescriptionTruncated = desc.slice(0, TRUNCATE_CHARACTER_COUNT)
//re-trim if we are in the middle of a word
tokenDescriptionTruncated = `${tokenDescriptionTruncated.slice(
0,
Math.min(tokenDescriptionTruncated.length, tokenDescriptionTruncated.lastIndexOf(' '))
)}...`
return tokenDescriptionTruncated
}
const TRUNCATE_CHARACTER_COUNT = 400
export const AboutContainer = styled.div` export const AboutContainer = styled.div`
gap: 16px; gap: 16px;
padding: 24px 0px; padding: 24px 0px;

@ -0,0 +1,85 @@
import { QueryResult } from '@apollo/client'
import userEvent from '@testing-library/user-event'
import { USDC_MAINNET } from 'constants/tokens'
import { Chain, Exact, TokenProjectQuery, useTokenProjectQuery } from 'graphql/data/__generated__/types-and-hooks'
import { useCurrency } from 'hooks/Tokens'
import { mocked } from 'test-utils/mocked'
import { validTokenProjectResponse, validUSDCCurrency } from 'test-utils/pools/fixtures'
import { act, render, screen } from 'test-utils/render'
import { TokenDescription } from './TokenDescription'
jest.mock('graphql/data/__generated__/types-and-hooks', () => {
const originalModule = jest.requireActual('graphql/data/__generated__/types-and-hooks')
return {
...originalModule,
useTokenProjectQuery: jest.fn(),
}
})
jest.mock('hooks/Tokens', () => {
const originalModule = jest.requireActual('hooks/Tokens')
return {
...originalModule,
useCurrency: jest.fn(),
}
})
const tokenAddress = USDC_MAINNET.address
describe('TokenDescription', () => {
beforeEach(() => {
mocked(useTokenProjectQuery).mockReturnValue(validTokenProjectResponse)
mocked(useCurrency).mockReturnValue(validUSDCCurrency)
})
it('renders token information correctly with defaults', () => {
const { asFragment } = render(<TokenDescription tokenAddress={tokenAddress} showCopy />)
expect(asFragment()).toMatchSnapshot()
expect(screen.getByText('USDC')).toBeVisible()
expect(screen.getByText('USDCoin')).toBeVisible()
expect(screen.getByText('Website')).toBeVisible()
expect(screen.getByText('Twitter')).toBeVisible()
expect(screen.getByText('Etherscan')).toBeVisible()
expect(screen.getByText('0xA0b8...eB48')).toBeVisible()
})
it('truncates description and shows more', async () => {
const { asFragment } = render(<TokenDescription tokenAddress={tokenAddress} showCopy />)
expect(asFragment()).toMatchSnapshot()
const truncatedDescription = screen.getByTestId('token-description-truncated')
const fullDescription = screen.getByTestId('token-description-full')
expect(truncatedDescription).toHaveStyleRule('display', 'inline')
expect(fullDescription).toHaveStyleRule('display', 'none')
await act(() => userEvent.click(screen.getByText('Show more')))
expect(truncatedDescription).toHaveStyleRule('display', 'none')
expect(fullDescription).toHaveStyleRule('display', 'inline')
expect(screen.getByText('Hide')).toBeVisible()
})
it('copy address button hidden when flagged', async () => {
const { asFragment } = render(<TokenDescription tokenAddress={tokenAddress} />)
expect(asFragment()).toMatchSnapshot()
expect(screen.queryByText('0xA0b8...eB48')).toBeNull()
})
it('no description or social buttons shown when not available', async () => {
mocked(useTokenProjectQuery).mockReturnValue({ data: undefined } as unknown as QueryResult<
TokenProjectQuery,
Exact<{ chain: Chain; address?: string }>
>)
const { asFragment } = render(<TokenDescription tokenAddress={tokenAddress} showCopy />)
expect(asFragment()).toMatchSnapshot()
expect(screen.getByText('No token information available')).toBeVisible()
expect(screen.queryByText('Website')).toBeNull()
expect(screen.queryByText('Twitter')).toBeNull()
expect(screen.getByText('Etherscan')).toBeVisible()
expect(screen.getByText('0xA0b8...eB48')).toBeVisible()
})
})

@ -0,0 +1,170 @@
import { Trans } from '@lingui/macro'
import { ChainId } from '@uniswap/sdk-core'
import Column from 'components/Column'
import { EtherscanLogo } from 'components/Icons/Etherscan'
import { Globe } from 'components/Icons/Globe'
import { TwitterXLogo } from 'components/Icons/TwitterX'
import CurrencyLogo from 'components/Logo/CurrencyLogo'
import Row from 'components/Row'
import { NoInfoAvailable, truncateDescription, TruncateDescriptionButton } from 'components/Tokens/TokenDetails/shared'
import { useTokenProjectQuery } from 'graphql/data/__generated__/types-and-hooks'
import { chainIdToBackendName } from 'graphql/data/util'
import { useCurrency } from 'hooks/Tokens'
import { useColor } from 'hooks/useColor'
import useCopyClipboard from 'hooks/useCopyClipboard'
import { useCallback, useReducer } from 'react'
import { Copy } from 'react-feather'
import styled, { useTheme } from 'styled-components'
import { BREAKPOINTS } from 'theme'
import { ClickableStyle, EllipsisStyle, ExternalLink, ThemedText } from 'theme/components'
import { opacify } from 'theme/utils'
import { shortenAddress } from 'utils'
import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink'
const TokenInfoSection = styled(Column)`
gap: 12px;
width: 100%;
@media (max-width: ${BREAKPOINTS.lg - 1}px) and (min-width: ${BREAKPOINTS.sm}px) {
max-width: 45%;
}
`
const TokenNameRow = styled(Row)`
gap: 8px;
width: 100%;
`
const TokenName = styled(ThemedText.BodyPrimary)`
${EllipsisStyle}
`
const TokenButtonRow = styled(TokenNameRow)`
flex-wrap: wrap;
`
const TokenInfoButton = styled(Row)<{ tokenColor: string }>`
gap: 8px;
padding: 8px 12px;
border-radius: 20px;
color: ${({ tokenColor }) => tokenColor};
background-color: ${({ tokenColor }) => opacify(12, tokenColor)};
font-size: 14px;
font-weight: 535;
line-height: 16px;
width: max-content;
${ClickableStyle}
`
const TokenDescriptionContainer = styled(ThemedText.BodyPrimary)`
${EllipsisStyle}
max-width: 100%;
// max-height: fit-content;
line-height: 24px;
white-space: pre-wrap;
`
const DescriptionVisibilityWrapper = styled.span<{ $visible: boolean }>`
display: ${({ $visible }) => ($visible ? 'inline' : 'none')};
`
const TRUNCATE_CHARACTER_COUNT = 75
export function TokenDescription({
tokenAddress,
chainId = ChainId.MAINNET,
showCopy = false,
}: {
tokenAddress: string
chainId?: number
showCopy?: boolean
}) {
const currency = useCurrency(tokenAddress, chainId)
const theme = useTheme()
const color = useColor(currency?.wrapped, theme.surface1, theme.darkMode)
const { data: tokenQuery } = useTokenProjectQuery({
variables: {
address: tokenAddress,
chain: chainIdToBackendName(chainId),
},
errorPolicy: 'all',
})
const tokenProject = tokenQuery?.token?.project
const description = tokenProject?.description
const explorerUrl = getExplorerLink(chainId, tokenAddress, ExplorerDataType.TOKEN)
const [, setCopied] = useCopyClipboard()
const copy = useCallback(() => {
setCopied(tokenAddress)
}, [tokenAddress, setCopied])
const [isDescriptionTruncated, toggleIsDescriptionTruncated] = useReducer((x) => !x, true)
const truncatedDescription = truncateDescription(description ?? '', TRUNCATE_CHARACTER_COUNT)
const shouldTruncate = !!description && description.length > TRUNCATE_CHARACTER_COUNT
const showTruncatedDescription = shouldTruncate && isDescriptionTruncated
return (
<TokenInfoSection>
<TokenNameRow>
<CurrencyLogo currency={currency} size="20px" />
<TokenName>{currency?.name}</TokenName>
<ThemedText.BodySecondary>{currency?.symbol}</ThemedText.BodySecondary>
</TokenNameRow>
<TokenButtonRow>
{showCopy && (
<TokenInfoButton tokenColor={color} onClick={copy}>
<Copy width="18px" height="18px" color={color} />
{shortenAddress(tokenAddress)}
</TokenInfoButton>
)}
<ExternalLink href={explorerUrl}>
<TokenInfoButton tokenColor={color}>
<EtherscanLogo width="18px" height="18px" fill={color} />
{chainId === ChainId.MAINNET ? <Trans>Etherscan</Trans> : <Trans>Explorer</Trans>}
</TokenInfoButton>
</ExternalLink>
{!!tokenProject?.homepageUrl && (
<ExternalLink href={tokenProject.homepageUrl}>
<TokenInfoButton tokenColor={color}>
<Globe width="18px" height="18px" fill={color} />
<Trans>Website</Trans>
</TokenInfoButton>
</ExternalLink>
)}
{!!tokenProject?.twitterName && (
<ExternalLink href={`https://x.com/${tokenProject.twitterName}`}>
<TokenInfoButton tokenColor={color}>
<TwitterXLogo width="18px" height="18px" fill={color} />
<Trans>Twitter</Trans>
</TokenInfoButton>
</ExternalLink>
)}
</TokenButtonRow>
<TokenDescriptionContainer>
{!description && (
<NoInfoAvailable>
<Trans>No token information available</Trans>
</NoInfoAvailable>
)}
{description && (
<>
<DescriptionVisibilityWrapper data-testid="token-description-full" $visible={!showTruncatedDescription}>
{description}
</DescriptionVisibilityWrapper>
<DescriptionVisibilityWrapper data-testid="token-description-truncated" $visible={showTruncatedDescription}>
{truncatedDescription}
</DescriptionVisibilityWrapper>
</>
)}
{shouldTruncate && (
<TruncateDescriptionButton
onClick={toggleIsDescriptionTruncated}
data-testid="token-description-show-more-button"
>
{isDescriptionTruncated ? <Trans>Show more</Trans> : <Trans>Hide</Trans>}
</TruncateDescriptionButton>
)}
</TokenDescriptionContainer>
</TokenInfoSection>
)
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,34 @@
import { darken } from 'polished'
import styled from 'styled-components'
export const NoInfoAvailable = styled.span`
color: ${({ theme }) => theme.neutral3};
font-weight: 485;
font-size: 16px;
`
export const TruncateDescriptionButton = styled.div`
color: ${({ theme }) => theme.neutral2};
font-weight: 485;
font-size: 0.85em;
padding-top: 0.5em;
&:hover,
&:focus {
color: ${({ theme }) => darken(0.1, theme.neutral2)};
cursor: pointer;
}
`
export const truncateDescription = (desc: string, maxCharacterCount = TRUNCATE_CHARACTER_COUNT) => {
//trim the string to the maximum length
let tokenDescriptionTruncated = desc.slice(0, maxCharacterCount)
//re-trim if we are in the middle of a word
tokenDescriptionTruncated = `${tokenDescriptionTruncated.slice(
0,
Math.min(tokenDescriptionTruncated.length, tokenDescriptionTruncated.lastIndexOf(' '))
)}...`
return tokenDescriptionTruncated
}
export const TRUNCATE_CHARACTER_COUNT = 400

@ -63,6 +63,32 @@ gql`
} }
` `
gql`
query TokenProject($chain: Chain!, $address: String = null) {
token(chain: $chain, address: $address) {
id
decimals
name
chain
address
symbol
standard
project {
id
description
homepageUrl
twitterName
logoUrl
tokens {
id
chain
address
}
}
}
}
`
export type { Chain, TokenQuery } from './__generated__/types-and-hooks' export type { Chain, TokenQuery } from './__generated__/types-and-hooks'
export type TokenQueryData = TokenQuery['token'] export type TokenQueryData = TokenQuery['token']

@ -1,11 +1,26 @@
import { ChainId, Token } from '@uniswap/sdk-core' import { ChainId, Token } from '@uniswap/sdk-core'
import { DEFAULT_COLOR } from 'constants/tokenColors' import { DEFAULT_COLOR } from 'constants/tokenColors'
import useTokenLogoSource from 'hooks/useAssetLogoSource' import useTokenLogoSource from 'hooks/useAssetLogoSource'
import { rgb } from 'polished' import { darken, lighten, rgb } from 'polished'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo' import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo'
import { useTheme } from 'styled-components' import { useTheme } from 'styled-components'
import { getColor } from 'utils/getColor' import { getColor } from 'utils/getColor'
import { hex } from 'wcag-contrast'
// The WCAG AA standard color contrast threshold
const MIN_COLOR_CONTRAST_THRESHOLD = 3
/**
* Compares a given color against the background color to determine if it passes the minimum contrast threshold.
* @param color The hex value of the extracted color
* @param backgroundColor The hex value of the background color to check contrast against
* @returns either 'sporeWhite' or 'sporeBlack'
*/
function passesContrast(color: string, backgroundColor: string): boolean {
const contrast = hex(color, backgroundColor)
return contrast >= MIN_COLOR_CONTRAST_THRESHOLD
}
function URIForEthToken(address: string) { function URIForEthToken(address: string) {
return `https://raw.githubusercontent.com/uniswap/assets/master/blockchains/ethereum/assets/${address}/logo.png` return `https://raw.githubusercontent.com/uniswap/assets/master/blockchains/ethereum/assets/${address}/logo.png`
@ -20,10 +35,6 @@ function URIForEthToken(address: string) {
* @returns {Promise< | null>} A promise that resolves to a color string or null if color cannot be determined. * @returns {Promise< | null>} A promise that resolves to a color string or null if color cannot be determined.
*/ */
async function getColorFromToken(token: Token, primarySrc?: string): Promise<string | null> { async function getColorFromToken(token: Token, primarySrc?: string): Promise<string | null> {
if (!(token instanceof WrappedTokenInfo)) {
return null
}
const wrappedToken = token as WrappedTokenInfo const wrappedToken = token as WrappedTokenInfo
let color: string | null = null let color: string | null = null
@ -33,7 +44,7 @@ async function getColorFromToken(token: Token, primarySrc?: string): Promise<str
color = colorArray === DEFAULT_COLOR ? null : convertColorArrayToString(colorArray) color = colorArray === DEFAULT_COLOR ? null : convertColorArrayToString(colorArray)
} }
if (!color && wrappedToken.logoURI) { if (!color && wrappedToken?.logoURI) {
const colorArray = await getColor(wrappedToken.logoURI) const colorArray = await getColor(wrappedToken.logoURI)
color = colorArray === DEFAULT_COLOR ? null : convertColorArrayToString(colorArray) color = colorArray === DEFAULT_COLOR ? null : convertColorArrayToString(colorArray)
} }
@ -54,7 +65,7 @@ function convertColorArrayToString([red, green, blue]: number[]): string {
return rgb({ red, green, blue }) return rgb({ red, green, blue })
} }
export function useColor(token?: Token) { export function useColor(token?: Token, backgroundColor?: string, makeLighter?: boolean) {
const theme = useTheme() const theme = useTheme()
const [color, setColor] = useState(theme.accent1) const [color, setColor] = useState(theme.accent1)
const [src] = useTokenLogoSource(token?.address, token?.chainId, token?.isNative) const [src] = useTokenLogoSource(token?.address, token?.chainId, token?.isNative)
@ -65,6 +76,13 @@ export function useColor(token?: Token) {
if (token) { if (token) {
getColorFromToken(token, src).then((tokenColor) => { getColorFromToken(token, src).then((tokenColor) => {
if (!stale && tokenColor !== null) { if (!stale && tokenColor !== null) {
if (backgroundColor) {
let increment = 0.1
while (!passesContrast(tokenColor, backgroundColor)) {
tokenColor = makeLighter ? lighten(increment, tokenColor) : darken(increment, tokenColor)
increment += 0.1
}
}
setColor(tokenColor) setColor(tokenColor)
} }
}) })
@ -74,7 +92,7 @@ export function useColor(token?: Token) {
stale = true stale = true
setColor(theme.accent1) setColor(theme.accent1)
} }
}, [src, theme.accent1, token]) }, [backgroundColor, makeLighter, src, theme.accent1, token])
return color return color
} }

@ -1,5 +1,6 @@
import { enableNetConnect } from 'nock'
import { validPoolDataResponse } from 'test-utils/pools/fixtures' import { validPoolDataResponse } from 'test-utils/pools/fixtures'
import { render, screen } from 'test-utils/render' import { act, render, screen } from 'test-utils/render'
import { BREAKPOINTS } from 'theme' import { BREAKPOINTS } from 'theme'
import { PoolDetailsStats } from './PoolDetailsStats' import { PoolDetailsStats } from './PoolDetailsStats'
@ -11,8 +12,18 @@ describe('PoolDetailsStats', () => {
chainId: 1, chainId: 1,
} }
it('renders stats text correctly', () => { beforeEach(() => {
// Enable network connections for retrieving token logos
enableNetConnect()
})
it('renders stats text correctly', async () => {
const { asFragment } = render(<PoolDetailsStats {...mockProps} />) const { asFragment } = render(<PoolDetailsStats {...mockProps} />)
// After the first render, the extracted color is updated to an a11y compliant color
// This is why we need to wrap the fragment in act(...)
await act(async () => {
await asFragment
})
expect(asFragment()).toMatchSnapshot() expect(asFragment()).toMatchSnapshot()
expect(screen.getByText(/Stats/i)).toBeInTheDocument() expect(screen.getByText(/Stats/i)).toBeInTheDocument()
@ -23,13 +34,16 @@ describe('PoolDetailsStats', () => {
expect(screen.getByTestId('pool-balance-chart')).toBeInTheDocument() expect(screen.getByTestId('pool-balance-chart')).toBeInTheDocument()
}) })
it('pool balance chart not visible on mobile', () => { it('pool balance chart not visible on mobile', async () => {
Object.defineProperty(window, 'innerWidth', { Object.defineProperty(window, 'innerWidth', {
writable: true, writable: true,
configurable: true, configurable: true,
value: BREAKPOINTS.md, value: BREAKPOINTS.md,
}) })
const { asFragment } = render(<PoolDetailsStats {...mockProps} />) const { asFragment } = render(<PoolDetailsStats {...mockProps} />)
await act(async () => {
await asFragment
})
expect(asFragment()).toMatchSnapshot() expect(asFragment()).toMatchSnapshot()
expect(screen.queryByTestId('pool-balance-chart')).toBeNull() expect(screen.queryByTestId('pool-balance-chart')).toBeNull()

@ -105,8 +105,8 @@ export function PoolDetailsStats({ poolData, isReversed, chainId }: PoolDetailsS
const currency0 = useCurrency(poolData?.token0?.id, chainId) ?? undefined const currency0 = useCurrency(poolData?.token0?.id, chainId) ?? undefined
const currency1 = useCurrency(poolData?.token1?.id, chainId) ?? undefined const currency1 = useCurrency(poolData?.token1?.id, chainId) ?? undefined
const color0 = useColor(currency0?.wrapped) const color0 = useColor(currency0?.wrapped, theme.surface2, theme.darkMode)
let color1 = useColor(currency1?.wrapped) let color1 = useColor(currency1?.wrapped, theme.surface2, theme.darkMode)
if (color0 === color1 && color0 === theme.accent1) { if (color0 === color1 && color0 === theme.accent1) {
color1 = colors.blue400 color1 = colors.blue400
} }

@ -506,7 +506,7 @@ exports[`PoolDetailsStats renders stats text correctly 1`] = `
.c9 { .c9 {
height: 8px; height: 8px;
width: 40.698463777008904%; width: 40.698463777008904%;
background: #FC72FF; background: #0066d9;
border-top-left-radius: 5px; border-top-left-radius: 5px;
border-bottom-left-radius: 5px; border-bottom-left-radius: 5px;
border-right: 1px solid #F9F9F9; border-right: 1px solid #F9F9F9;
@ -515,7 +515,7 @@ exports[`PoolDetailsStats renders stats text correctly 1`] = `
.c10 { .c10 {
height: 8px; height: 8px;
width: 59.3015362229911%; width: 59.3015362229911%;
background: #4C82FB; background: #FC72FF;
border-top-right-radius: 5px; border-top-right-radius: 5px;
border-bottom-right-radius: 5px; border-bottom-right-radius: 5px;
border-left: 1px solid #F9F9F9; border-left: 1px solid #F9F9F9;

@ -110,6 +110,25 @@ exports[`PoolDetailsPage pool header is displayed when data is received from the
color: #222222; color: #222222;
} }
.c43 {
-webkit-text-decoration: none;
text-decoration: none;
cursor: pointer;
-webkit-transition-duration: 125ms;
transition-duration: 125ms;
color: #FC72FF;
stroke: #FC72FF;
font-weight: 500;
}
.c43:hover {
opacity: 0.6;
}
.c43:active {
opacity: 0.4;
}
.c3 { .c3 {
display: -webkit-box; display: -webkit-box;
display: -webkit-flex; display: -webkit-flex;
@ -124,6 +143,97 @@ exports[`PoolDetailsPage pool header is displayed when data is received from the
justify-content: flex-start; justify-content: flex-start;
} }
.c40 {
opacity: 0;
-webkit-transition: opacity 250ms ease-in;
transition: opacity 250ms ease-in;
width: 20px;
height: 20px;
border-radius: 50%;
}
.c39 {
width: 20px;
height: 20px;
background: #22222212;
-webkit-transition: background-color 250ms ease-in;
transition: background-color 250ms ease-in;
box-shadow: 0 0 1px white;
border-radius: 50%;
}
.c38 {
position: relative;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
}
.c46 {
color: #CECECE;
font-weight: 485;
font-size: 16px;
}
.c36 {
gap: 12px;
width: 100%;
}
.c37 {
gap: 8px;
width: 100%;
}
.c41 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.c42 {
-webkit-flex-wrap: wrap;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
}
.c44 {
gap: 8px;
padding: 8px 12px;
border-radius: 20px;
color: #FC72FF;
background-color: #FC72FF1f;
font-size: 14px;
font-weight: 535;
line-height: 16px;
width: -webkit-max-content;
width: -moz-max-content;
width: max-content;
-webkit-text-decoration: none;
text-decoration: none;
cursor: pointer;
-webkit-transition-duration: 125ms;
transition-duration: 125ms;
}
.c44:hover {
opacity: 0.6;
}
.c44:active {
opacity: 0.4;
}
.c45 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
line-height: 24px;
white-space: pre-wrap;
}
.c21 { .c21 {
background-color: transparent; background-color: transparent;
bottom: 0; bottom: 0;
@ -381,6 +491,24 @@ exports[`PoolDetailsPage pool header is displayed when data is received from the
min-width: 360px; min-width: 360px;
} }
.c34 {
gap: 24px;
padding: 20px;
}
.c35 {
width: 100%;
font-size: 24px;
font-weight: 485;
line-height: 32px;
}
@media (max-width:1023px) and (min-width:640px) {
.c36 {
max-width: 45%;
}
}
@media (max-width:1023px) { @media (max-width:1023px) {
.c23 { .c23 {
width: 100%; width: 100%;
@ -474,6 +602,24 @@ exports[`PoolDetailsPage pool header is displayed when data is received from the
} }
} }
@media (max-width:1023px) and (min-width:640px) {
.c34 {
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-flex-wrap: wrap;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
padding: unset;
}
}
@media (max-width:639px) {
.c34 {
padding: unset;
}
}
<div <div
class="c0 c1 c2" class="c0 c1 c2"
> >
@ -741,6 +887,163 @@ exports[`PoolDetailsPage pool header is displayed when data is received from the
</div> </div>
</div> </div>
</div> </div>
<div
class="c3 c34"
>
<div
class="c35 css-vurnku"
>
Info
</div>
<div
class="c3 c36"
>
<div
class="c0 c1 c37"
>
<div
class="c38"
style="height: 20px; width: 20px;"
>
<div
class="c39"
>
<img
alt="UNKNOWN logo"
class="c40"
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png"
/>
</div>
</div>
<div
class="c7 c41 css-1urox24"
>
Unknown Token
</div>
<div
class="c6 css-1urox24"
>
UNKNOWN
</div>
</div>
<div
class="c0 c1 c37 c42"
>
<a
class="c43"
href="https://etherscan.io/token/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
rel="noopener noreferrer"
target="_blank"
>
<div
class="c0 c1 c44"
>
<svg
fill="#FC72FF"
height="18px"
stroke="transparent"
viewBox="0 0 18 18"
width="18px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.08042 8.66148C5.08043 8.58693 5.09517 8.51313 5.12378 8.44429C5.1524 8.37546 5.19432 8.31297 5.24716 8.26038C5.30001 8.2078 5.3627 8.16617 5.43167 8.13788C5.50064 8.1096 5.57452 8.09522 5.64907 8.09557L6.59187 8.09865C6.74218 8.09865 6.88635 8.15836 6.99263 8.26465C7.09893 8.37094 7.15865 8.5151 7.15865 8.66543V12.2303C7.26478 12.1988 7.4011 12.1652 7.55026 12.1301C7.65387 12.1058 7.74621 12.0471 7.8123 11.9637C7.87839 11.8803 7.91434 11.777 7.91432 11.6705V7.24848C7.91432 7.09814 7.97403 6.95397 8.08032 6.84766C8.1866 6.74135 8.33077 6.68162 8.4811 6.68158H9.42577C9.57609 6.68162 9.72026 6.74135 9.82655 6.84766C9.93284 6.95397 9.99255 7.09814 9.99255 7.24848V11.3526C9.99255 11.3526 10.2291 11.2569 10.4595 11.1596C10.545 11.1234 10.6181 11.0629 10.6694 10.9854C10.7208 10.908 10.7482 10.8172 10.7483 10.7242V5.83152C10.7483 5.68122 10.808 5.53707 10.9143 5.43078C11.0206 5.32449 11.1647 5.26478 11.315 5.26474H12.2597C12.41 5.26474 12.5542 5.32445 12.6604 5.43075C12.7667 5.53704 12.8265 5.6812 12.8265 5.83152V9.86056C13.6455 9.267 14.4754 8.55315 15.1341 7.69474C15.2297 7.57015 15.2929 7.42383 15.3181 7.26887C15.3434 7.1139 15.3299 6.95509 15.2788 6.8066C14.9739 5.9294 14.4894 5.12551 13.856 4.44636C13.2226 3.76722 12.4544 3.22777 11.6005 2.86256C10.7467 2.49734 9.82602 2.31439 8.89742 2.32542C7.96882 2.33645 7.05275 2.54121 6.20783 2.9266C5.36291 3.31199 4.60774 3.86952 3.99066 4.56352C3.37358 5.25751 2.90817 6.07269 2.62422 6.95689C2.34027 7.84107 2.24403 8.7748 2.34166 9.69832C2.43929 10.6218 2.72863 11.5148 3.19118 12.3201C3.27176 12.459 3.39031 12.572 3.53289 12.6459C3.67548 12.7198 3.83618 12.7514 3.99614 12.7372C4.17482 12.7215 4.3973 12.6992 4.66181 12.6681C4.77695 12.655 4.88326 12.6001 4.96048 12.5137C5.0377 12.4273 5.08043 12.3155 5.08053 12.1996L5.08042 8.66148Z"
fill="#FC72FF"
/>
<path
d="M5.05957 14.3792C6.05531 15.1036 7.23206 15.5384 8.45961 15.6356C9.68716 15.7326 10.9176 15.4883 12.0149 14.9294C13.1122 14.3705 14.0334 13.519 14.6768 12.4691C15.3201 11.4191 15.6605 10.2116 15.6601 8.98024C15.6601 8.82658 15.653 8.67457 15.6428 8.52344C13.2041 12.1605 8.70139 13.8609 5.05978 14.3786"
fill="#FC72FF"
/>
</svg>
Etherscan
</div>
</a>
</div>
<div
class="c7 c45 css-1urox24"
>
<span
class="c46"
>
No token information available
</span>
</div>
</div>
<div
class="c3 c36"
>
<div
class="c0 c1 c37"
>
<div
class="c38"
style="height: 20px; width: 20px;"
>
<div
class="c39"
>
<img
alt="WETH logo"
class="c40"
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png"
/>
</div>
</div>
<div
class="c7 c41 css-1urox24"
>
Wrapped Ether
</div>
<div
class="c6 css-1urox24"
>
WETH
</div>
</div>
<div
class="c0 c1 c37 c42"
>
<a
class="c43"
href="https://etherscan.io/token/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
rel="noopener noreferrer"
target="_blank"
>
<div
class="c0 c1 c44"
>
<svg
fill="#FC72FF"
height="18px"
stroke="transparent"
viewBox="0 0 18 18"
width="18px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.08042 8.66148C5.08043 8.58693 5.09517 8.51313 5.12378 8.44429C5.1524 8.37546 5.19432 8.31297 5.24716 8.26038C5.30001 8.2078 5.3627 8.16617 5.43167 8.13788C5.50064 8.1096 5.57452 8.09522 5.64907 8.09557L6.59187 8.09865C6.74218 8.09865 6.88635 8.15836 6.99263 8.26465C7.09893 8.37094 7.15865 8.5151 7.15865 8.66543V12.2303C7.26478 12.1988 7.4011 12.1652 7.55026 12.1301C7.65387 12.1058 7.74621 12.0471 7.8123 11.9637C7.87839 11.8803 7.91434 11.777 7.91432 11.6705V7.24848C7.91432 7.09814 7.97403 6.95397 8.08032 6.84766C8.1866 6.74135 8.33077 6.68162 8.4811 6.68158H9.42577C9.57609 6.68162 9.72026 6.74135 9.82655 6.84766C9.93284 6.95397 9.99255 7.09814 9.99255 7.24848V11.3526C9.99255 11.3526 10.2291 11.2569 10.4595 11.1596C10.545 11.1234 10.6181 11.0629 10.6694 10.9854C10.7208 10.908 10.7482 10.8172 10.7483 10.7242V5.83152C10.7483 5.68122 10.808 5.53707 10.9143 5.43078C11.0206 5.32449 11.1647 5.26478 11.315 5.26474H12.2597C12.41 5.26474 12.5542 5.32445 12.6604 5.43075C12.7667 5.53704 12.8265 5.6812 12.8265 5.83152V9.86056C13.6455 9.267 14.4754 8.55315 15.1341 7.69474C15.2297 7.57015 15.2929 7.42383 15.3181 7.26887C15.3434 7.1139 15.3299 6.95509 15.2788 6.8066C14.9739 5.9294 14.4894 5.12551 13.856 4.44636C13.2226 3.76722 12.4544 3.22777 11.6005 2.86256C10.7467 2.49734 9.82602 2.31439 8.89742 2.32542C7.96882 2.33645 7.05275 2.54121 6.20783 2.9266C5.36291 3.31199 4.60774 3.86952 3.99066 4.56352C3.37358 5.25751 2.90817 6.07269 2.62422 6.95689C2.34027 7.84107 2.24403 8.7748 2.34166 9.69832C2.43929 10.6218 2.72863 11.5148 3.19118 12.3201C3.27176 12.459 3.39031 12.572 3.53289 12.6459C3.67548 12.7198 3.83618 12.7514 3.99614 12.7372C4.17482 12.7215 4.3973 12.6992 4.66181 12.6681C4.77695 12.655 4.88326 12.6001 4.96048 12.5137C5.0377 12.4273 5.08043 12.3155 5.08053 12.1996L5.08042 8.66148Z"
fill="#FC72FF"
/>
<path
d="M5.05957 14.3792C6.05531 15.1036 7.23206 15.5384 8.45961 15.6356C9.68716 15.7326 10.9176 15.4883 12.0149 14.9294C13.1122 14.3705 14.0334 13.519 14.6768 12.4691C15.3201 11.4191 15.6605 10.2116 15.6601 8.98024C15.6601 8.82658 15.653 8.67457 15.6428 8.52344C13.2041 12.1605 8.70139 13.8609 5.05978 14.3786"
fill="#FC72FF"
/>
</svg>
Etherscan
</div>
</a>
</div>
<div
class="c7 c45 css-1urox24"
>
<span
class="c46"
>
No token information available
</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
</DocumentFragment> </DocumentFragment>

@ -1,10 +1,13 @@
import { Trans } from '@lingui/macro'
import Column from 'components/Column' import Column from 'components/Column'
import Row from 'components/Row' import Row from 'components/Row'
import { TokenDescription } from 'components/Tokens/TokenDetails/TokenDescription'
import { getValidUrlChainName, supportedChainIdFromGQLChain } from 'graphql/data/util' import { getValidUrlChainName, supportedChainIdFromGQLChain } from 'graphql/data/util'
import { usePoolData } from 'graphql/thegraph/PoolData' import { usePoolData } from 'graphql/thegraph/PoolData'
import NotFound from 'pages/NotFound' import NotFound from 'pages/NotFound'
import { useReducer } from 'react' import { useReducer } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { Text } from 'rebass'
import styled from 'styled-components' import styled from 'styled-components'
import { BREAKPOINTS } from 'theme' import { BREAKPOINTS } from 'theme'
import { isAddress } from 'utils' import { isAddress } from 'utils'
@ -40,6 +43,28 @@ const RightColumn = styled(Column)`
} }
` `
const TokenDetailsWrapper = styled(Column)`
gap: 24px;
padding: 20px;
@media (max-width: ${BREAKPOINTS.lg - 1}px) and (min-width: ${BREAKPOINTS.sm}px) {
flex-direction: row;
flex-wrap: wrap;
padding: unset;
}
@media (max-width: ${BREAKPOINTS.sm - 1}px) {
padding: unset;
}
`
const TokenDetailsHeader = styled(Text)`
width: 100%;
font-size: 24px;
font-weight: 485;
line-height: 32px;
`
export default function PoolDetailsPage() { export default function PoolDetailsPage() {
const { poolAddress, chainName } = useParams<{ const { poolAddress, chainName } = useParams<{
poolAddress: string poolAddress: string
@ -70,6 +95,15 @@ export default function PoolDetailsPage() {
<RightColumn> <RightColumn>
<PoolDetailsStatsButtons chainId={chainId} token0={token0} token1={token1} feeTier={poolData?.feeTier} /> <PoolDetailsStatsButtons chainId={chainId} token0={token0} token1={token1} feeTier={poolData?.feeTier} />
{poolData && <PoolDetailsStats poolData={poolData} isReversed={isReversed} chainId={chainId} />} {poolData && <PoolDetailsStats poolData={poolData} isReversed={isReversed} chainId={chainId} />}
{(token0 || token1) && (
<TokenDetailsWrapper>
<TokenDetailsHeader>
<Trans>Info</Trans>
</TokenDetailsHeader>
{token0 && <TokenDescription tokenAddress={token0.id} chainId={chainId} />}
{token1 && <TokenDescription tokenAddress={token1.id} chainId={chainId} />}
</TokenDetailsWrapper>
)}
</RightColumn> </RightColumn>
</PageWrapper> </PageWrapper>
) )

@ -1,8 +1,11 @@
import { QueryResult } from '@apollo/client'
import { BigNumber } from '@ethersproject/bignumber' import { BigNumber } from '@ethersproject/bignumber'
import { ChainId, WETH9 } from '@uniswap/sdk-core' import { ChainId, Currency, WETH9 } from '@uniswap/sdk-core'
import { FeeAmount, Pool, Position } from '@uniswap/v3-sdk' import { FeeAmount, Pool, Position } from '@uniswap/v3-sdk'
import { USDC_MAINNET } from 'constants/tokens' import { USDC_MAINNET } from 'constants/tokens'
import { Chain, Exact, TokenProjectQuery } from 'graphql/data/__generated__/types-and-hooks'
import { Token } from 'graphql/thegraph/__generated__/types-and-hooks' import { Token } from 'graphql/thegraph/__generated__/types-and-hooks'
import { PoolData } from 'graphql/thegraph/PoolData'
export const validParams = { poolAddress: '0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640', chainName: 'ethereum' } export const validParams = { poolAddress: '0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640', chainName: 'ethereum' }
@ -15,6 +18,21 @@ export const validPoolToken0 = {
__typename: 'Token', __typename: 'Token',
} as Token } as Token
export const validUSDCCurrency = {
isNative: false,
isToken: true,
name: 'USDCoin',
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
symbol: 'USDC',
decimals: 6,
chainId: 1,
logoURI:
'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png',
_checksummedAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
_tags: null,
wrapped: validPoolToken0,
} as unknown as Currency
export const validPoolToken1 = { export const validPoolToken1 = {
id: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', id: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
symbol: 'WETH', symbol: 'WETH',
@ -97,7 +115,32 @@ export const validPoolDataResponse = {
tvlUSDChange: -0.3657085465786977, tvlUSDChange: -0.3657085465786977,
tvlToken0: 90930713.7356909, tvlToken0: 90930713.7356909,
tvlToken1: 82526.48678530742, tvlToken1: 82526.48678530742,
}, } as PoolData,
loading: false, loading: false,
error: false, error: false,
} }
export const validTokenProjectResponse = {
data: {
token: {
id: 'VG9rZW46RVRIRVJFVU1fMHhBMGI4Njk5MWM2MjE4YjM2YzFkMTlENGEyZTlFYjBjRTM2MDZlQjQ4',
decimals: 6,
name: 'USD Coin',
chain: 'ETHEREUM',
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
symbol: 'USDC',
standard: 'ERC20',
project: {
id: 'VG9rZW5Qcm9qZWN0OkVUSEVSRVVNXzB4YTBiODY5OTFjNjIxOGIzNmMxZDE5ZDRhMmU5ZWIwY2UzNjA2ZWI0OF9VU0RD',
description:
'USDC is a fully collateralized US dollar stablecoin. USDC is the bridge between dollars and trading on cryptocurrency exchanges. The technology behind CENTRE makes it possible to exchange value between people, businesses and financial institutions just like email between mail services and texts between SMS providers. We believe by removing artificial economic borders, we can create a more inclusive global economy.',
homepageUrl: 'https://www.circle.com/en/usdc',
twitterName: 'circle',
logoUrl:
'https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png',
__typename: 'TokenProject',
},
__typename: 'Token',
},
},
} as unknown as QueryResult<TokenProjectQuery, Exact<{ chain: Chain; address?: string }>>