diff --git a/src/components/sharedComponents/TokenLogo.test.tsx b/src/components/sharedComponents/TokenLogo/TokenLogo.test.tsx similarity index 57% rename from src/components/sharedComponents/TokenLogo.test.tsx rename to src/components/sharedComponents/TokenLogo/TokenLogo.test.tsx index b8d4aa93..6a8f12a5 100644 --- a/src/components/sharedComponents/TokenLogo.test.tsx +++ b/src/components/sharedComponents/TokenLogo/TokenLogo.test.tsx @@ -1,10 +1,10 @@ -import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { ChakraProvider } from '@chakra-ui/react' import { fireEvent, render, screen } from '@testing-library/react' +import { zeroAddress } from 'viem' import { describe, expect, it } from 'vitest' +import { system } from '@/src/components/ui/provider' import type { Token } from '@/src/types/token' -import TokenLogo from './TokenLogo' - -const system = createSystem(defaultConfig) +import TokenLogo from '.' const mockToken: Token = { address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', @@ -72,4 +72,46 @@ describe('TokenLogo', () => { const img = screen.getByRole('img') expect(img.getAttribute('src')).toBe('https://ipfs.io/ipfs/QmHash123') }) + + it('renders the chain icon (not the img or placeholder) for a native token on a mapped chain', () => { + const nativeEthToken: Token = { + address: zeroAddress, + chainId: 1, + decimals: 18, + name: 'Ether', + symbol: 'ETH', + } + const { container } = renderTokenLogo(nativeEthToken) + expect(screen.queryByRole('img')).toBeNull() + expect(screen.queryByText('E')).toBeNull() + expect(container.querySelector('svg')).not.toBeNull() + }) + + it('renders the chain icon for the native POL on Polygon (chainId 137)', () => { + const nativePolToken: Token = { + address: zeroAddress, + chainId: 137, + decimals: 18, + name: 'POL', + symbol: 'POL', + } + const { container } = renderTokenLogo(nativePolToken) + expect(screen.queryByRole('img')).toBeNull() + expect(screen.queryByText('P')).toBeNull() + expect(container.querySelector('svg')).not.toBeNull() + }) + + it('falls back to placeholder for a native token on an unmapped chain', () => { + const nativeUnknownToken: Token = { + address: zeroAddress, + chainId: 999999, + decimals: 18, + name: 'Unknown', + symbol: 'XXX', + } + const { container } = renderTokenLogo(nativeUnknownToken) + expect(screen.queryByRole('img')).toBeNull() + expect(container.querySelector('svg')).toBeNull() + expect(screen.getByText('X')).toBeDefined() + }) }) diff --git a/src/components/sharedComponents/TokenLogo.tsx b/src/components/sharedComponents/TokenLogo/index.tsx similarity index 53% rename from src/components/sharedComponents/TokenLogo.tsx rename to src/components/sharedComponents/TokenLogo/index.tsx index de2f6041..0fbcfcdd 100644 --- a/src/components/sharedComponents/TokenLogo.tsx +++ b/src/components/sharedComponents/TokenLogo/index.tsx @@ -1,46 +1,41 @@ import { Flex } from '@chakra-ui/react' -import { type ComponentProps, type FC, useCallback, useEffect, useState } from 'react' +import { type ComponentProps, type FC, useEffect, useMemo, useState } from 'react' + +import { nativeTokenIcons } from '@/src/components/sharedComponents/TokenLogo/nativeTokenIcons' +import type { ChainsIds } from '@/src/lib/networks.config' import type { Token } from '@/src/types/token' +import { isNativeToken } from '@/src/utils/address' interface PlaceholderProps extends ComponentProps<'div'> { size: number symbol: string } -const Placeholder: FC = ({ size, symbol, ...restProps }) => { - const [backgroundColor, setBackgroundColor] = useState('') - - const generateHexColor = useCallback((symbol: string): string => { - // Convert symbol to a hash number - let hash = 0 - for (let i = 0; i < symbol.length; i++) { - hash = symbol.charCodeAt(i) + ((hash << 5) - hash) - } - - // Convert hash to a hexadecimal string and ensure it is 6 characters long - const baseColor = - ((hash >> 24) & 0xff).toString(16).padStart(2, '0') + - ((hash >> 16) & 0xff).toString(16).padStart(2, '0') + - ((hash >> 8) & 0xff).toString(16).padStart(2, '0') - - // Ensure the baseColor is dark-ish by making sure each component is less than 196 - const r = Number.parseInt(baseColor.slice(0, 2), 16) % 196 - const g = Number.parseInt(baseColor.slice(2, 4), 16) % 196 - const b = Number.parseInt(baseColor.slice(4, 6), 16) % 196 - - // Convert back to hex string and pad with leading 6s if necessary and also - // because I love Satan - const color = - r.toString(16).padStart(2, '6') + - g.toString(16).padStart(2, '6') + - b.toString(16).padStart(2, '6') - - return `#${color}` - }, []) +const generateHexColor = (symbol: string): string => { + let hash = 0 + for (let i = 0; i < symbol.length; i++) { + hash = symbol.charCodeAt(i) + ((hash << 5) - hash) + } - useEffect(() => { - setBackgroundColor(generateHexColor(symbol)) - }, [symbol, generateHexColor]) + const baseColor = + ((hash >> 24) & 0xff).toString(16).padStart(2, '0') + + ((hash >> 16) & 0xff).toString(16).padStart(2, '0') + + ((hash >> 8) & 0xff).toString(16).padStart(2, '0') + + const r = Number.parseInt(baseColor.slice(0, 2), 16) % 196 + const g = Number.parseInt(baseColor.slice(2, 4), 16) % 196 + const b = Number.parseInt(baseColor.slice(4, 6), 16) % 196 + + const color = + r.toString(16).padStart(2, '0') + + g.toString(16).padStart(2, '0') + + b.toString(16).padStart(2, '0') + + return `#${color}` +} + +const Placeholder: FC = ({ size, symbol, ...restProps }) => { + const backgroundColor = useMemo(() => generateHexColor(symbol), [symbol]) return ( } [props.restProps] - Additional props for the img element. * * @example * ```tsx @@ -97,6 +97,19 @@ const TokenLogo: FC = ({ size = 24, token }) => { setHasError(false) }, [logoURI]) + const NativeIcon = isNativeToken(token.address) + ? nativeTokenIcons[token.chainId as ChainsIds] + : undefined + + if (NativeIcon) { + return ( + + ) + } + return logoURI && !hasError ? ( {token.name}> = { + 1: NetworkEthereum, + 10: NetworkOptimism, + 137: NetworkPolygon, + 42161: NetworkArbitrumOne, + 11155111: NetworkSepolia, + 11155420: NetworkOptimismSepolia, +} diff --git a/src/components/ui/provider.tsx b/src/components/ui/provider.tsx index e9451067..1d6fa447 100644 --- a/src/components/ui/provider.tsx +++ b/src/components/ui/provider.tsx @@ -3,129 +3,129 @@ import { ChakraProvider, createSystem, defaultConfig, defineConfig } from '@chakra-ui/react' import { ColorModeProvider, type ColorModeProviderProps } from './color-mode' -export function Provider(props: ColorModeProviderProps) { - const customConfig = defineConfig({ - theme: { - // Use tokens for values that don't change with light / dark themes - tokens: { - fonts: { - body: { - value: '"Manrope", "Arial", "Helvetica Neue", "Helvetica", sans-serif', - }, - heading: { - value: '{fonts.body}', - }, - mono: { - value: '"Roboto Mono", "Courier New", monospace', - }, +const customConfig = defineConfig({ + theme: { + // Use tokens for values that don't change with light / dark themes + tokens: { + fonts: { + body: { + value: '"Manrope", "Arial", "Helvetica Neue", "Helvetica", sans-serif', + }, + heading: { + value: '{fonts.body}', + }, + mono: { + value: '"Roboto Mono", "Courier New", monospace', }, }, - // Use semantic tokens for light / dark values - semanticTokens: { - colors: { - bg: { - default: { - value: { - _light: '#f7f7f7', - _dark: '#292B43', - }, - }, - emphasized: { - value: { - _light: '#ccc', - _dark: '#888', - }, + }, + // Use semantic tokens for light / dark values + semanticTokens: { + colors: { + bg: { + default: { + value: { + _light: '#f7f7f7', + _dark: '#292B43', }, }, - primary: { - default: { - value: { - _light: '#692581', - _dark: '#8b46a4', - }, + emphasized: { + value: { + _light: '#ccc', + _dark: '#888', }, }, - text: { - default: { - value: { - _light: '#4b4d60', - _dark: '#e2e0e7', - }, + }, + primary: { + default: { + value: { + _light: '#692581', + _dark: '#8b46a4', }, }, - danger: { - default: { - value: { - _light: '#800', - _dark: '#ff6666', - }, + }, + text: { + default: { + value: { + _light: '#4b4d60', + _dark: '#e2e0e7', }, }, - ok: { - default: { - value: { - _light: '#006600', - _dark: '#66ee66', - }, + }, + danger: { + default: { + value: { + _light: '#800', + _dark: '#ff6666', }, }, - warning: { - default: { - value: { - _light: '#996600', - _dark: '#e6b800', - }, + }, + ok: { + default: { + value: { + _light: '#006600', + _dark: '#66ee66', }, }, }, - }, - // Some custom animations - keyframes: { - rotateSwitch: { - from: { - transform: 'rotate(0)', - }, - to: { - transform: 'rotate(360deg)', + warning: { + default: { + value: { + _light: '#996600', + _dark: '#e6b800', + }, }, }, }, }, - globalCss: { - ////////////////////////////////////////////////// - // Just some basic stuff, don't add too much here. - ////////////////////////////////////////////////// - html: { - scrollBehavior: 'smooth', - overflowX: 'hidden', - }, - body: { - '--moz-osx-font-smoothing': 'grayscale', - '--webkit-font-smoothing': 'antialiased', - background: '{colors.bg.default}', - backgroundPosition: '100% 0', - backgroundRepeat: 'no-repeat', - color: '{colors.text.default}', - fontFamily: '{fonts.body}', - lineHeight: 1.5, - outlineColor: '{colors.text.default}', - overflowX: 'hidden', - }, - code: { - fontFamily: '{fonts.mono}', - }, - a: { - color: '{colors.primary.default}', - }, - img: { - display: 'block', - maxInlineSize: '100%', + // Some custom animations + keyframes: { + rotateSwitch: { + from: { + transform: 'rotate(0)', + }, + to: { + transform: 'rotate(360deg)', + }, }, }, - }) + }, + globalCss: { + ////////////////////////////////////////////////// + // Just some basic stuff, don't add too much here. + ////////////////////////////////////////////////// + html: { + scrollBehavior: 'smooth', + overflowX: 'hidden', + }, + body: { + '--moz-osx-font-smoothing': 'grayscale', + '--webkit-font-smoothing': 'antialiased', + background: '{colors.bg.default}', + backgroundPosition: '100% 0', + backgroundRepeat: 'no-repeat', + color: '{colors.text.default}', + fontFamily: '{fonts.body}', + lineHeight: 1.5, + outlineColor: '{colors.text.default}', + overflowX: 'hidden', + }, + code: { + fontFamily: '{fonts.mono}', + }, + a: { + color: '{colors.primary.default}', + }, + img: { + display: 'block', + maxInlineSize: '100%', + }, + }, +}) - const system = createSystem(defaultConfig, customConfig) +export const system = createSystem(defaultConfig, customConfig) +export function Provider(props: ColorModeProviderProps) { return (