Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 53 additions & 18 deletions src/components/icons/TokenIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,47 @@
import { IToken } from '@hyperlane-xyz/sdk';
import { isHttpsUrl, isRelativeUrl } from '@hyperlane-xyz/utils';
import { isHttpsUrl } from '@hyperlane-xyz/utils';
import { Circle } from '@hyperlane-xyz/widgets';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { links } from '../../consts/links';
import { useChainMetadata } from '../../features/chains/hooks';

interface Props {
token?: IToken | null;
size?: number;
}

export function TokenIcon({ token, size = 32 }: Props) {
const chainMetadata = useChainMetadata(token?.chainName);
const chainId = typeof chainMetadata?.chainId === 'number' ? chainMetadata.chainId : undefined;

const candidates = useMemo(
() => buildCandidates(token, chainId, size),
[token, chainId, size],
);

const tokenKey = token ? `${token.chainName}::${token.addressOrDenom}` : '';
const [attempt, setAttempt] = useState(0);
const [activeKey, setActiveKey] = useState(tokenKey);
if (activeKey !== tokenKey) {
setActiveKey(tokenKey);
setAttempt(0);
}

const src = candidates[attempt];
const title = token?.symbol || '';
const character = title ? title.charAt(0).toUpperCase() : '';
const fontSize = Math.floor(size / 2);

const [fallbackToText, setFallbackToText] = useState(false);
const imageSrc = getImageSrc(token);
const bgColorSeed =
token && (!imageSrc || fallbackToText)
? (Buffer.from(token.addressOrDenom).at(0) || 0) % 5
: undefined;
token && !src ? (Buffer.from(token.addressOrDenom).at(0) || 0) % 5 : undefined;

return (
<Circle size={size} bgColorSeed={bgColorSeed} title={title}>
{imageSrc && !fallbackToText ? (
{src ? (
<img
src={imageSrc}
key={src}
src={src}
className="h-full w-full p-0.5"
onError={() => setFallbackToText(true)}
onError={() => setAttempt((i) => i + 1)}
loading="lazy"
/>
) : (
Expand All @@ -37,11 +51,32 @@ export function TokenIcon({ token, size = 32 }: Props) {
);
}

function getImageSrc(token?: IToken | null) {
if (!token?.logoURI) return null;
// If it's a valid, direct URL, return it
if (isHttpsUrl(token.logoURI)) return token.logoURI;
// Otherwise assume it's a relative URL to the registry base
if (isRelativeUrl(token.logoURI)) return `${links.imgPath}${token.logoURI}`;
return null;
function buildCandidates(
token: IToken | null | undefined,
chainId: number | undefined,
size: number,
): string[] {
if (!token) return [];
const out: string[] = [];
const logo = token.logoURI;

if (logo) {
if (isHttpsUrl(logo)) {
out.push(logo);
} else if (logo.startsWith('/')) {
out.push(logo);
} else {
out.push(`${links.imgPath}/${logo.replace(/^\.?\/?/, '')}`);
}
}

const addr = token.addressOrDenom;
if (chainId && addr && /^0x[0-9a-fA-F]{40}$/.test(addr)) {
const px = Math.max(48, Math.round(size * 2));
out.push(
`https://token-icons.llamao.fi/icons/tokens/${chainId}/${addr.toLowerCase()}?h=${px}&w=${px}`,
);
}

return out;
}
14 changes: 12 additions & 2 deletions src/features/tokens/TokenChainIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { IToken } from '@hyperlane-xyz/sdk';
import { ChainLogo } from '../../components/icons/ChainLogo';
import { TokenIcon } from '../../components/icons/TokenIcon';

interface Props {
token: IToken;
size?: number;
}

export function TokenChainIcon({ token }: Props) {
export function TokenChainIcon({ token, size = 36 }: Props) {
const badgeSize = Math.max(14, Math.round(size * 0.45));
return (
<ChainLogo chainName={token.chainName} size={50} />
<div className="relative shrink-0" style={{ width: size, height: size }}>
<TokenIcon token={token} size={size} />
<div
className="absolute -bottom-0.5 -right-0.5 flex items-center justify-center overflow-hidden rounded-full bg-dark2 ring-2 ring-dark2"
style={{ width: badgeSize, height: badgeSize }}
>
<ChainLogo chainName={token.chainName} size={badgeSize} />
</div>
</div>
);
}
12 changes: 5 additions & 7 deletions src/features/tokens/TokenSelectField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,11 @@ function TokenButton({
disabled={disabled}
>
{token ? (
<div className={`flex items-center justify-between sm:gap-4 gap-2.5 ${childClass}`}>
<div className="sm:size-[50px] size-5">
<TokenChainIcon token={token} size={36} />
</div>
<div className="flex flex-col items-start gap-1.5">
<span className="sm:text-sm text-[10px] font-medium text-gray-400 leading-none">{token.symbol}</span>
<span className="sm:text-lg text-xs font-medium text-contentBody leading-none">{chainDisplayName}</span>
<div className={`flex items-center sm:gap-3 gap-2 ${childClass}`}>
<TokenChainIcon token={token} size={36} />
<div className="flex flex-col items-start gap-0.5">
<span className="sm:text-base text-sm font-semibold text-contentBody leading-tight">{token.symbol}</span>
<span className="sm:text-xs text-[10px] font-normal text-content-gray leading-tight">{chainDisplayName}</span>
</div>
</div>
) : (
Expand Down
Loading