From 2d7aaa40b3e4a9720036cb2a9282eefe9e4d3aa0 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 2 Oct 2025 13:49:28 +0100 Subject: [PATCH 1/4] feat: useAvatarUrl with fallback --- apps/ensadmin/src/components/ens-avatar.tsx | 6 +- .../use-ens-metadata-service-avatar-url.ts | 3 +- packages/ensnode-react/package.json | 3 +- .../ensnode-react/src/hooks/useAvatarUrl.ts | 55 +++++++++++++++---- packages/ensnode-react/src/index.ts | 1 + .../src/utils/ensMetadataService.ts | 35 ++++++++++++ pnpm-lock.yaml | 3 + 7 files changed, 86 insertions(+), 20 deletions(-) create mode 100644 packages/ensnode-react/src/utils/ensMetadataService.ts diff --git a/apps/ensadmin/src/components/ens-avatar.tsx b/apps/ensadmin/src/components/ens-avatar.tsx index 40ed84c4d..877d5f996 100644 --- a/apps/ensadmin/src/components/ens-avatar.tsx +++ b/apps/ensadmin/src/components/ens-avatar.tsx @@ -1,7 +1,6 @@ "use client"; import { Avatar, AvatarImage } from "@/components/ui/avatar"; -import { buildEnsMetadataServiceAvatarUrl } from "@/lib/namespace-utils"; import { ENSNamespaceId } from "@ensnode/datasources"; import { useAvatarUrl } from "@ensnode/ensnode-react"; import { Name } from "@ensnode/ensnode-sdk"; @@ -23,10 +22,7 @@ export const EnsAvatar = ({ name, namespaceId, className }: EnsAvatarProps) => { const { data: avatarUrl } = useAvatarUrl({ name, - fallback: async (name) => { - const url = buildEnsMetadataServiceAvatarUrl(name, namespaceId); - return url?.toString() ?? null; - }, + namespaceId, }); if (avatarUrl === null || avatarUrl === undefined) { diff --git a/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts b/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts index d71a9f966..879771dfe 100644 --- a/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts +++ b/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts @@ -1,10 +1,9 @@ "use client"; +import { buildEnsMetadataServiceAvatarUrl } from "@ensnode/ensnode-react"; import { Name } from "@ensnode/ensnode-sdk"; import { useQuery } from "@tanstack/react-query"; -import { buildEnsMetadataServiceAvatarUrl } from "@/lib/namespace-utils"; - import { useNamespace } from "./use-namespace"; export interface UseEnsMetadataServiceAvatarUrlParameters { diff --git a/packages/ensnode-react/package.json b/packages/ensnode-react/package.json index 416deab5a..1cbd76f94 100644 --- a/packages/ensnode-react/package.json +++ b/packages/ensnode-react/package.json @@ -60,6 +60,7 @@ "vitest": "catalog:" }, "dependencies": { - "@ensnode/ensnode-sdk": "workspace:*" + "@ensnode/ensnode-sdk": "workspace:*", + "@ensnode/datasources": "workspace:*" } } diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index abb0fb4ce..087383ecd 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -1,9 +1,12 @@ "use client"; +import type { ENSNamespaceId } from "@ensnode/datasources"; import type { Name } from "@ensnode/ensnode-sdk"; import { useQuery } from "@tanstack/react-query"; import type { ConfigParameter, QueryParameter } from "../types"; +import { buildEnsMetadataServiceAvatarUrl } from "../utils/ensMetadataService"; +import { useENSIndexerConfig } from "./useENSIndexerConfig"; import { useENSNodeConfig } from "./useENSNodeConfig"; import { useRecords } from "./useRecords"; @@ -15,11 +18,15 @@ import { useRecords } from "./useRecords"; export interface UseAvatarUrlParameters extends QueryParameter, ConfigParameter { name: Name | null; /** - * Optional fallback function to get avatar URL when the avatar text record + * The ENS namespace ID for the name. Optional - if not provided, it will be automatically + * fetched from the ENSNode config. This is used for the default ENS Metadata Service fallback. + */ + namespaceId?: ENSNamespaceId | null; + /** + * Optional custom fallback function to get avatar URL when the avatar text record * uses a complex protocol (not http/https). * - * This allows consumers to provide their own fallback strategy, such as - * using the ENS Metadata Service or other avatar resolution services. + * If not provided, defaults to using the ENS Metadata Service. * * @param name - The ENS name to get the avatar URL for * @returns Promise resolving to the avatar URL, or null if unavailable @@ -63,7 +70,7 @@ function normalizeWebsiteUrl(url: string | null | undefined): URL | null { * 1. Fetching the avatar text record using useRecords * 2. Normalizing the avatar text record as a URL * 3. Returning the URL if it uses http or https protocol - * 4. Falling back to a custom fallback function if provided for other protocols + * 4. Falling back to the ENS Metadata Service (default) or custom fallback for other protocols * * @param parameters - Configuration for the avatar URL resolution * @returns Query result with the avatar URL, loading state, and error handling @@ -71,10 +78,12 @@ function normalizeWebsiteUrl(url: string | null | undefined): URL | null { * @example * ```typescript * import { useAvatarUrl } from "@ensnode/ensnode-react"; + * import { ENSNamespaceIds } from "@ensnode/datasources"; * * function ProfileAvatar() { * const { data: avatarUrl, isLoading, error } = useAvatarUrl({ - * name: "vitalik.eth" + * name: "vitalik.eth", + * namespaceId: ENSNamespaceIds.Mainnet * }); * * if (isLoading) return
Loading...
; @@ -87,7 +96,7 @@ function normalizeWebsiteUrl(url: string | null | undefined): URL | null { * * @example * ```typescript - * // With ENS Metadata Service fallback + * // With custom fallback * import { useAvatarUrl } from "@ensnode/ensnode-react"; * * function ProfileAvatar() { @@ -95,7 +104,7 @@ function normalizeWebsiteUrl(url: string | null | undefined): URL | null { * name: "vitalik.eth", * fallback: async (name) => { * // Custom fallback logic for IPFS, NFT URIs, etc. - * return `https://metadata.ens.domains/mainnet/avatar/${name}`; + * return `https://custom-resolver.example.com/${name}`; * } * }); * @@ -104,7 +113,13 @@ function normalizeWebsiteUrl(url: string | null | undefined): URL | null { * ``` */ export function useAvatarUrl(parameters: UseAvatarUrlParameters) { - const { name, config, query: queryOptions, fallback } = parameters; + const { + name, + config, + query: queryOptions, + fallback, + namespaceId: providedNamespaceId, + } = parameters; const _config = useENSNodeConfig(config); const canEnable = name !== null; @@ -117,9 +132,25 @@ export function useAvatarUrl(parameters: UseAvatarUrlParameters) { query: { enabled: canEnable }, }); + // Get namespace from config if not provided + const configQuery = useENSIndexerConfig({ config: _config }); + const namespaceId = providedNamespaceId ?? configQuery.data?.namespace ?? null; + + // Create default fallback using ENS Metadata Service if namespaceId is available + const defaultFallback = + namespaceId !== null && namespaceId !== undefined + ? async (name: Name) => { + const url = buildEnsMetadataServiceAvatarUrl(name, namespaceId); + return url?.toString() ?? null; + } + : undefined; + + // Use custom fallback if provided, otherwise use default + const activeFallback = fallback ?? defaultFallback; + // Then process the avatar URL return useQuery({ - queryKey: ["avatarUrl", name, _config.client.url.href, !!fallback], + queryKey: ["avatarUrl", name, _config.client.url.href, namespaceId, !!fallback], queryFn: async (): Promise => { if (!name || !recordsQuery.data) return null; @@ -144,10 +175,10 @@ export function useAvatarUrl(parameters: UseAvatarUrlParameters) { return normalizedUrl.toString(); } - // For other protocols (ipfs, data, NFT URIs, etc.), use fallback if provided - if (fallback) { + // For other protocols (ipfs, data, NFT URIs, etc.), use fallback if available + if (activeFallback) { try { - return await fallback(name); + return await activeFallback(name); } catch { return null; } diff --git a/packages/ensnode-react/src/index.ts b/packages/ensnode-react/src/index.ts index e6dad6465..246d03266 100644 --- a/packages/ensnode-react/src/index.ts +++ b/packages/ensnode-react/src/index.ts @@ -6,3 +6,4 @@ export * from "./provider"; export * from "./context"; export * from "./hooks"; export * from "./types"; +export * from "./utils/ensMetadataService"; diff --git a/packages/ensnode-react/src/utils/ensMetadataService.ts b/packages/ensnode-react/src/utils/ensMetadataService.ts new file mode 100644 index 000000000..3206f5f8d --- /dev/null +++ b/packages/ensnode-react/src/utils/ensMetadataService.ts @@ -0,0 +1,35 @@ +import { ENSNamespaceId, ENSNamespaceIds } from "@ensnode/datasources"; +import type { Name } from "@ensnode/ensnode-sdk"; + +/** + * Build the avatar image URL for a name on the given ENS Namespace that (once fetched) would + * load the avatar image for the given name from the ENS Metadata Service + * (https://metadata.ens.domains/docs). + * + * The returned URL is dynamically built based on the provided ENS namespace. Not all ENS + * namespaces are supported by the ENS Metadata Service. Therefore, the returned URL may + * be null. + * + * @param {Name} name - ENS name to build the avatar image URL for + * @param {ENSNamespaceId} namespaceId - ENS Namespace identifier + * @returns avatar image URL for the name on the given ENS Namespace, or null if the given + * ENS namespace is not supported by the ENS Metadata Service + */ +export function buildEnsMetadataServiceAvatarUrl( + name: Name, + namespaceId: ENSNamespaceId, +): URL | null { + switch (namespaceId) { + case ENSNamespaceIds.Mainnet: + return new URL(name, `https://metadata.ens.domains/mainnet/avatar/`); + case ENSNamespaceIds.Sepolia: + return new URL(name, `https://metadata.ens.domains/sepolia/avatar/`); + case ENSNamespaceIds.Holesky: + // metadata.ens.domains doesn't currently support holesky + return null; + case ENSNamespaceIds.EnsTestEnv: + // ens-test-env runs on a local chain and is not supported by metadata.ens.domains + // TODO: Above comment is not true. Details at https://github.com/namehash/ensnode/issues/1078 + return null; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94745158d..0c33f3234 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -601,6 +601,9 @@ importers: packages/ensnode-react: dependencies: + '@ensnode/datasources': + specifier: workspace:* + version: link:../datasources '@ensnode/ensnode-sdk': specifier: workspace:* version: link:../ensnode-sdk From 31ddc0ee88e9fd417ed2a4caf85d3c6991d6b03c Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 2 Oct 2025 14:12:28 +0100 Subject: [PATCH 2/4] utils barrel --- packages/ensnode-react/src/index.ts | 2 +- packages/ensnode-react/src/utils/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 packages/ensnode-react/src/utils/index.ts diff --git a/packages/ensnode-react/src/index.ts b/packages/ensnode-react/src/index.ts index 246d03266..59757061a 100644 --- a/packages/ensnode-react/src/index.ts +++ b/packages/ensnode-react/src/index.ts @@ -6,4 +6,4 @@ export * from "./provider"; export * from "./context"; export * from "./hooks"; export * from "./types"; -export * from "./utils/ensMetadataService"; +export * from "./utils"; diff --git a/packages/ensnode-react/src/utils/index.ts b/packages/ensnode-react/src/utils/index.ts new file mode 100644 index 000000000..622688823 --- /dev/null +++ b/packages/ensnode-react/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./ensMetadataService"; From 4a94605d4cef4a0cc5c09c547e79c27dabe728bc Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 2 Oct 2025 16:50:37 +0100 Subject: [PATCH 3/4] apply feedback --- .../name/[name]/_components/ProfileHeader.tsx | 6 +--- apps/ensadmin/src/components/ens-avatar.tsx | 5 +-- .../src/components/identity/index.tsx | 2 +- apps/ensadmin/src/lib/namespace-utils.ts | 33 ------------------- .../ensnode-react/src/hooks/useAvatarUrl.ts | 25 +++----------- packages/ensnode-react/src/utils/index.ts | 1 - packages/ensnode-sdk/src/ens/index.ts | 1 + .../src/ens/metadata-service.ts} | 6 ++-- 8 files changed, 13 insertions(+), 66 deletions(-) delete mode 100644 packages/ensnode-react/src/utils/index.ts rename packages/{ensnode-react/src/utils/ensMetadataService.ts => ensnode-sdk/src/ens/metadata-service.ts} (90%) diff --git a/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx b/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx index d80797304..47cd6e8d1 100644 --- a/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx +++ b/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx @@ -68,11 +68,7 @@ export function ProfileHeader({ name, headerImage, websiteUrl }: ProfileHeaderPr
- +

diff --git a/apps/ensadmin/src/components/ens-avatar.tsx b/apps/ensadmin/src/components/ens-avatar.tsx index 877d5f996..aacab9881 100644 --- a/apps/ensadmin/src/components/ens-avatar.tsx +++ b/apps/ensadmin/src/components/ens-avatar.tsx @@ -1,7 +1,6 @@ "use client"; import { Avatar, AvatarImage } from "@/components/ui/avatar"; -import { ENSNamespaceId } from "@ensnode/datasources"; import { useAvatarUrl } from "@ensnode/ensnode-react"; import { Name } from "@ensnode/ensnode-sdk"; import BoringAvatar from "boring-avatars"; @@ -9,7 +8,6 @@ import * as React from "react"; interface EnsAvatarProps { name: Name; - namespaceId: ENSNamespaceId; className?: string; } @@ -17,12 +15,11 @@ type ImageLoadingStatus = Parameters< NonNullable["onLoadingStatusChange"]> >[0]; -export const EnsAvatar = ({ name, namespaceId, className }: EnsAvatarProps) => { +export const EnsAvatar = ({ name, className }: EnsAvatarProps) => { const [loadingStatus, setLoadingStatus] = React.useState("idle"); const { data: avatarUrl } = useAvatarUrl({ name, - namespaceId, }); if (avatarUrl === null || avatarUrl === undefined) { diff --git a/apps/ensadmin/src/components/identity/index.tsx b/apps/ensadmin/src/components/identity/index.tsx index 4b50547d9..e379bf787 100644 --- a/apps/ensadmin/src/components/identity/index.tsx +++ b/apps/ensadmin/src/components/identity/index.tsx @@ -71,7 +71,7 @@ export function Identity({ name={ensName} className="inline-flex items-center gap-2 text-blue-600 hover:underline" > - {showAvatar && } + {showAvatar && } ); diff --git a/apps/ensadmin/src/lib/namespace-utils.ts b/apps/ensadmin/src/lib/namespace-utils.ts index fd6f4bbe7..8e72c440f 100644 --- a/apps/ensadmin/src/lib/namespace-utils.ts +++ b/apps/ensadmin/src/lib/namespace-utils.ts @@ -91,39 +91,6 @@ export function getEnsManagerAppUrl(namespaceId: ENSNamespaceId): URL | null { } } -/** - * Build the avatar image URL for a name on the given ENS Namespace that (once fetched) would - * load the avatar image for the given name from the ENS Metadata Service - * (https://metadata.ens.domains/docs). - * - * The returned URL is dynamically built based on the provided ENS namespace. Not all ENS - * namespaces are supported by the ENS Metadata Service. Therefore, the returned URL may - * be null. - * - * @param {Name} name - ENS name to build the avatar image URL for - * @param {ENSNamespaceId} namespaceId - ENS Namespace identifier - * @returns avatar image URL for the name on the given ENS Namespace, or null if the given - * ENS namespace is not supported by the ENS Metadata Service - */ -export function buildEnsMetadataServiceAvatarUrl( - name: Name, - namespaceId: ENSNamespaceId, -): URL | null { - switch (namespaceId) { - case ENSNamespaceIds.Mainnet: - return new URL(name, `https://metadata.ens.domains/mainnet/avatar/`); - case ENSNamespaceIds.Sepolia: - return new URL(name, `https://metadata.ens.domains/sepolia/avatar/`); - case ENSNamespaceIds.Holesky: - // metadata.ens.domains doesn't currently support holesky - return null; - case ENSNamespaceIds.EnsTestEnv: - // ens-test-env runs on a local chain and is not supported by metadata.ens.domains - // TODO: Above comment is not true. Details at https://github.com/namehash/ensnode/issues/1078 - return null; - } -} - /** * Builds the URL of the external ENS Manager App Profile page for a given name and ENS Namespace. * diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 087383ecd..afbecacda 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -1,11 +1,9 @@ "use client"; -import type { ENSNamespaceId } from "@ensnode/datasources"; -import type { Name } from "@ensnode/ensnode-sdk"; +import { type Name, buildEnsMetadataServiceAvatarUrl } from "@ensnode/ensnode-sdk"; import { useQuery } from "@tanstack/react-query"; import type { ConfigParameter, QueryParameter } from "../types"; -import { buildEnsMetadataServiceAvatarUrl } from "../utils/ensMetadataService"; import { useENSIndexerConfig } from "./useENSIndexerConfig"; import { useENSNodeConfig } from "./useENSNodeConfig"; import { useRecords } from "./useRecords"; @@ -17,11 +15,6 @@ import { useRecords } from "./useRecords"; */ export interface UseAvatarUrlParameters extends QueryParameter, ConfigParameter { name: Name | null; - /** - * The ENS namespace ID for the name. Optional - if not provided, it will be automatically - * fetched from the ENSNode config. This is used for the default ENS Metadata Service fallback. - */ - namespaceId?: ENSNamespaceId | null; /** * Optional custom fallback function to get avatar URL when the avatar text record * uses a complex protocol (not http/https). @@ -78,12 +71,10 @@ function normalizeWebsiteUrl(url: string | null | undefined): URL | null { * @example * ```typescript * import { useAvatarUrl } from "@ensnode/ensnode-react"; - * import { ENSNamespaceIds } from "@ensnode/datasources"; * * function ProfileAvatar() { * const { data: avatarUrl, isLoading, error } = useAvatarUrl({ - * name: "vitalik.eth", - * namespaceId: ENSNamespaceIds.Mainnet + * name: "vitalik.eth" * }); * * if (isLoading) return
Loading...
; @@ -113,13 +104,7 @@ function normalizeWebsiteUrl(url: string | null | undefined): URL | null { * ``` */ export function useAvatarUrl(parameters: UseAvatarUrlParameters) { - const { - name, - config, - query: queryOptions, - fallback, - namespaceId: providedNamespaceId, - } = parameters; + const { name, config, query: queryOptions, fallback } = parameters; const _config = useENSNodeConfig(config); const canEnable = name !== null; @@ -132,9 +117,9 @@ export function useAvatarUrl(parameters: UseAvatarUrlParameters) { query: { enabled: canEnable }, }); - // Get namespace from config if not provided + // Get namespace from config const configQuery = useENSIndexerConfig({ config: _config }); - const namespaceId = providedNamespaceId ?? configQuery.data?.namespace ?? null; + const namespaceId = configQuery.data?.namespace ?? null; // Create default fallback using ENS Metadata Service if namespaceId is available const defaultFallback = diff --git a/packages/ensnode-react/src/utils/index.ts b/packages/ensnode-react/src/utils/index.ts deleted file mode 100644 index 622688823..000000000 --- a/packages/ensnode-react/src/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./ensMetadataService"; diff --git a/packages/ensnode-sdk/src/ens/index.ts b/packages/ensnode-sdk/src/ens/index.ts index ebf78beaa..eb69069de 100644 --- a/packages/ensnode-sdk/src/ens/index.ts +++ b/packages/ensnode-sdk/src/ens/index.ts @@ -8,3 +8,4 @@ export * from "./parse-reverse-name"; export * from "./is-normalized"; export * from "./encode-labelhash"; export * from "./dns-encoded-name"; +export * from "./metadata-service"; diff --git a/packages/ensnode-react/src/utils/ensMetadataService.ts b/packages/ensnode-sdk/src/ens/metadata-service.ts similarity index 90% rename from packages/ensnode-react/src/utils/ensMetadataService.ts rename to packages/ensnode-sdk/src/ens/metadata-service.ts index 3206f5f8d..8830f1393 100644 --- a/packages/ensnode-react/src/utils/ensMetadataService.ts +++ b/packages/ensnode-sdk/src/ens/metadata-service.ts @@ -1,5 +1,7 @@ -import { ENSNamespaceId, ENSNamespaceIds } from "@ensnode/datasources"; -import type { Name } from "@ensnode/ensnode-sdk"; +import type { ENSNamespaceId } from "@ensnode/datasources"; +import { ENSNamespaceIds } from "@ensnode/datasources"; + +import type { Name } from "./types"; /** * Build the avatar image URL for a name on the given ENS Namespace that (once fetched) would From b2a8b192e3b7d4354af49e696f0ab33de87141cc Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 2 Oct 2025 16:54:51 +0100 Subject: [PATCH 4/4] fix: remove invalid path; --- packages/ensnode-react/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ensnode-react/src/index.ts b/packages/ensnode-react/src/index.ts index 59757061a..e6dad6465 100644 --- a/packages/ensnode-react/src/index.ts +++ b/packages/ensnode-react/src/index.ts @@ -6,4 +6,3 @@ export * from "./provider"; export * from "./context"; export * from "./hooks"; export * from "./types"; -export * from "./utils";