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
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,7 @@ export function ProfileHeader({ name, headerImage, websiteUrl }: ProfileHeaderPr
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<EnsAvatar
className="-mt-16 h-20 w-20 ring-4 ring-white"
name={name}
namespaceId={namespace}
/>
<EnsAvatar className="-mt-16 h-20 w-20 ring-4 ring-white" name={name} />
<div className="flex-1">
<h1>
<NameDisplay className="text-3xl font-bold" name={name} />
Expand Down
9 changes: 1 addition & 8 deletions apps/ensadmin/src/components/ens-avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,25 @@
"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";
import BoringAvatar from "boring-avatars";
import * as React from "react";

interface EnsAvatarProps {
name: Name;
namespaceId: ENSNamespaceId;
className?: string;
}

type ImageLoadingStatus = Parameters<
NonNullable<React.ComponentProps<typeof AvatarImage>["onLoadingStatusChange"]>
>[0];

export const EnsAvatar = ({ name, namespaceId, className }: EnsAvatarProps) => {
export const EnsAvatar = ({ name, className }: EnsAvatarProps) => {
const [loadingStatus, setLoadingStatus] = React.useState<ImageLoadingStatus>("idle");

const { data: avatarUrl } = useAvatarUrl({
name,
fallback: async (name) => {
const url = buildEnsMetadataServiceAvatarUrl(name, namespaceId);
return url?.toString() ?? null;
},
});

if (avatarUrl === null || avatarUrl === undefined) {
Expand Down
2 changes: 1 addition & 1 deletion apps/ensadmin/src/components/identity/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function Identity({
name={ensName}
className="inline-flex items-center gap-2 text-blue-600 hover:underline"
>
{showAvatar && <EnsAvatar name={ensName} namespaceId={namespaceId} className="h-6 w-6" />}
{showAvatar && <EnsAvatar name={ensName} className="h-6 w-6" />}
<NameDisplay name={ensName} />
</NameLink>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
33 changes: 0 additions & 33 deletions apps/ensadmin/src/lib/namespace-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
3 changes: 2 additions & 1 deletion packages/ensnode-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"vitest": "catalog:"
},
"dependencies": {
"@ensnode/ensnode-sdk": "workspace:*"
"@ensnode/ensnode-sdk": "workspace:*",
"@ensnode/datasources": "workspace:*"
}
}
38 changes: 27 additions & 11 deletions packages/ensnode-react/src/hooks/useAvatarUrl.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"use client";

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 { useENSIndexerConfig } from "./useENSIndexerConfig";
import { useENSNodeConfig } from "./useENSNodeConfig";
import { useRecords } from "./useRecords";

Expand All @@ -15,11 +16,10 @@ import { useRecords } from "./useRecords";
export interface UseAvatarUrlParameters extends QueryParameter<string | null>, ConfigParameter {
name: Name | null;
/**
* Optional fallback function to get avatar URL when the avatar text record
* 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 proxy 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
Expand Down Expand Up @@ -63,7 +63,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
Expand All @@ -87,15 +87,15 @@ 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() {
* const { data: avatarUrl } = useAvatarUrl({
* 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}`;
* }
* });
*
Expand All @@ -117,9 +117,25 @@ export function useAvatarUrl(parameters: UseAvatarUrlParameters) {
query: { enabled: canEnable },
});

// Get namespace from config
const configQuery = useENSIndexerConfig({ config: _config });
const namespaceId = 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<string | null> => {
if (!name || !recordsQuery.data) return null;

Expand All @@ -144,10 +160,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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/ensnode-sdk/src/ens/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
37 changes: 37 additions & 0 deletions packages/ensnode-sdk/src/ens/metadata-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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
* 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;
}
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading