From 3b7d3ea96339e182b58ffca73325baa4563481d4 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Mon, 26 Oct 2026 15:12:52 -0700 Subject: [PATCH] perf(coins): load resized coin logo variants for badges and icons Coin badges are drawn at 14-24px but load the full logo_uri, which averages 500 KB (up to 2.2 MB) across prod coins. Add getCoinLogoUrl, which rewrites validator node logo URLs (/content/) to the smallest square variant that is sharp at 3x for the display size. Other URLs (Irys, static assets) pass through unchanged. Prod logos are all on Irys today, so this takes effect as logo_uri moves to validator nodes via the Irys -> Audius migration (pedalboard#90 for new coins, migrate-logos-to-audius for existing). Co-Authored-By: Claude Opus 5.5 --- packages/common/src/utils/coinLogo.test.ts | 35 +++++++++++++++++++ packages/common/src/utils/coinLogo.ts | 33 +++++++++++++++++ packages/common/src/utils/index.ts | 1 + .../mobile/src/components/core/TokenIcon.tsx | 3 +- .../details-tile/DetailsTileHasAccess.tsx | 3 +- .../web/src/components/CoinSuccessModal.tsx | 4 +-- .../components/buy-sell-modal/TokenIcon.tsx | 7 +++- .../PriceAndAudienceField.tsx | 10 ++++-- .../hover-card/FanClubHoverCard.tsx | 4 +-- .../FanClubTextPostNotification.tsx | 9 +++-- .../src/components/user-badges/UserBadges.tsx | 6 ++-- .../components/FanClubInfoSection.tsx | 3 +- 12 files changed, 104 insertions(+), 14 deletions(-) create mode 100644 packages/common/src/utils/coinLogo.test.ts create mode 100644 packages/common/src/utils/coinLogo.ts diff --git a/packages/common/src/utils/coinLogo.test.ts b/packages/common/src/utils/coinLogo.test.ts new file mode 100644 index 00000000000..71b95ecd189 --- /dev/null +++ b/packages/common/src/utils/coinLogo.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { getCoinLogoUrl } from './coinLogo' + +const CID = 'baeaaaiqsecwikb3rxnjr4txcptwvtlpdj6et6bkkz7vo5giyy35xnl5vxxbxk' +const NODE_URL = `https://creatornode.audius.co/content/${CID}` + +describe('getCoinLogoUrl', () => { + it('requests a variant sized for the display at 3x', () => { + expect(getCoinLogoUrl(NODE_URL, 16)).toBe(`${NODE_URL}/150x150.jpg`) + expect(getCoinLogoUrl(NODE_URL, 50)).toBe(`${NODE_URL}/150x150.jpg`) + expect(getCoinLogoUrl(NODE_URL, 64)).toBe(`${NODE_URL}/480x480.jpg`) + expect(getCoinLogoUrl(NODE_URL, 200)).toBe(`${NODE_URL}/1000x1000.jpg`) + }) + + it('tolerates a trailing slash', () => { + expect(getCoinLogoUrl(`${NODE_URL}/`, 24)).toBe(`${NODE_URL}/150x150.jpg`) + }) + + it('leaves URLs it cannot resize unchanged', () => { + const irys = + 'https://gateway.irys.xyz/FRdFgDGxVZC9HsdRkWQ3CKCyi6icxvxT1ENWgnk1By4y' + const asset = 'https://audius.co/assets/TokenAUDIO-B9fxmOIn.png' + const variant = `${NODE_URL}/480x480.jpg` + expect(getCoinLogoUrl(irys, 16)).toBe(irys) + expect(getCoinLogoUrl(asset, 16)).toBe(asset) + expect(getCoinLogoUrl(variant, 16)).toBe(variant) + }) + + it('passes through empty values', () => { + expect(getCoinLogoUrl(undefined, 16)).toBeUndefined() + expect(getCoinLogoUrl(null, 16)).toBeNull() + expect(getCoinLogoUrl('', 16)).toBe('') + }) +}) diff --git a/packages/common/src/utils/coinLogo.ts b/packages/common/src/utils/coinLogo.ts new file mode 100644 index 00000000000..92d6d1f6d03 --- /dev/null +++ b/packages/common/src/utils/coinLogo.ts @@ -0,0 +1,33 @@ +import { SquareSizes } from '~/models/ImageSizes' + +// A bare validator node content URL, e.g. https://creatornode.audius.co/content/. +// Coin logos launched after the Irys -> Audius migration (and existing logos +// once they are re-hosted) are stored this way, and the node resizes any image +// it stores on demand at /content//.jpg. +const CONTENT_NODE_URL_REGEX = /^(https?:\/\/[^/?#]+\/content\/[^/?#]+)\/?$/ + +// Smallest variant that stays sharp at 3x pixel density +const getSquareSizeForDisplay = (displaySize: number) => { + const pixels = displaySize * 3 + if (pixels <= 150) return SquareSizes.SIZE_150_BY_150 + if (pixels <= 480) return SquareSizes.SIZE_480_BY_480 + return SquareSizes.SIZE_1000_BY_1000 +} + +/** + * Returns a resized variant of a coin's `logo_uri` for drawing it at + * `displaySize` CSS pixels. Coin logos are uploaded at up to 1000px and often + * weigh 0.5-2 MB, so badges should never load the original. + * + * Only validator node URLs can be resized. Other URLs (Irys/Arweave, static + * assets) are returned unchanged. + */ +export const getCoinLogoUrl = ( + logoUri: T, + displaySize: number +): T | string => { + if (!logoUri) return logoUri + const match = CONTENT_NODE_URL_REGEX.exec(logoUri) + if (!match) return logoUri + return `${match[1]}/${getSquareSizeForDisplay(displaySize)}.jpg` +} diff --git a/packages/common/src/utils/index.ts b/packages/common/src/utils/index.ts index 2ee43601ea8..26ae2d8a184 100644 --- a/packages/common/src/utils/index.ts +++ b/packages/common/src/utils/index.ts @@ -55,6 +55,7 @@ export * from './handle' export * from './comments' export * from './quickSearch' export * from './coinMetrics' +export * from './coinLogo' export * from './convertHexToRGBA' export * from './socialLinks' export * from './videoUtils' diff --git a/packages/mobile/src/components/core/TokenIcon.tsx b/packages/mobile/src/components/core/TokenIcon.tsx index 4a776123d25..9a91a9ce43f 100644 --- a/packages/mobile/src/components/core/TokenIcon.tsx +++ b/packages/mobile/src/components/core/TokenIcon.tsx @@ -1,3 +1,4 @@ +import { getCoinLogoUrl } from '@audius/common/utils' import { Image } from 'react-native' import { HexagonalIcon, useTheme } from '@audius/harmony-native' @@ -19,7 +20,7 @@ export const TokenIcon = ({ logoURI, size = 's' }: TokenIconProps) => { return ( diff --git a/packages/web/src/components/CoinSuccessModal.tsx b/packages/web/src/components/CoinSuccessModal.tsx index 328a60698c7..1d04e721139 100644 --- a/packages/web/src/components/CoinSuccessModal.tsx +++ b/packages/web/src/components/CoinSuccessModal.tsx @@ -5,7 +5,7 @@ import { TrackMetadataForUpload, useCoinSuccessModal } from '@audius/common/store' -import { route } from '@audius/common/utils' +import { getCoinLogoUrl, route } from '@audius/common/utils' import { Artwork, Button, @@ -107,7 +107,7 @@ export const CoinSuccessModal = () => { > { { label: messages.coinGated, startAdornment: ( - + ) } ] diff --git a/packages/web/src/components/hover-card/FanClubHoverCard.tsx b/packages/web/src/components/hover-card/FanClubHoverCard.tsx index e8cb24a5447..7100769f986 100644 --- a/packages/web/src/components/hover-card/FanClubHoverCard.tsx +++ b/packages/web/src/components/hover-card/FanClubHoverCard.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from 'react' import { useFanClub, useCoinBalance, useUser } from '@audius/common/api' import { ID } from '@audius/common/models' -import { formatCount, route } from '@audius/common/utils' +import { formatCount, getCoinLogoUrl, route } from '@audius/common/utils' import { Artwork, Flex, @@ -143,7 +143,7 @@ export const FanClubHoverCard = ({ {/* Token Icon */} {fanClubBadge?.logo_uri ? ( {user.fan_club_badge?.logo_uri ? ( - + ) : null} diff --git a/packages/web/src/components/user-badges/UserBadges.tsx b/packages/web/src/components/user-badges/UserBadges.tsx index 1377a897070..71c3708bb42 100644 --- a/packages/web/src/components/user-badges/UserBadges.tsx +++ b/packages/web/src/components/user-badges/UserBadges.tsx @@ -9,7 +9,7 @@ import { import { useUser } from '@audius/common/api' import { BadgeTier, ID } from '@audius/common/models' import { useTierAndVerifiedForUser } from '@audius/common/store' -import { Nullable } from '@audius/common/utils' +import { getCoinLogoUrl, Nullable } from '@audius/common/utils' import { Artwork, Box, @@ -199,7 +199,9 @@ const UserBadges = ({ }} > { > {coin.logoUri ? (