diff --git a/apps/ensadmin/src/app/name/_components/AdditionalRecords.tsx b/apps/ensadmin/src/app/name/_components/AdditionalRecords.tsx index 41c7a0abf..2fc6b7f03 100644 --- a/apps/ensadmin/src/app/name/_components/AdditionalRecords.tsx +++ b/apps/ensadmin/src/app/name/_components/AdditionalRecords.tsx @@ -1,34 +1,14 @@ "use client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { ENSAdminProfile } from "@/hooks/use-ensadmin-profile"; interface AdditionalRecordsProps { - texts: Record | null | undefined; + profile: ENSAdminProfile; } -const RECORDS_ALREADY_DISPLAYED_ELSEWHERE = [ - "description", - "url", - "email", - "com.twitter", - "com.github", - "com.farcaster", - "org.telegram", - "com.linkedin", - "com.reddit", - "avatar", - "header", - "name", -]; - -export function AdditionalRecords({ texts }: AdditionalRecordsProps) { - if (!texts) return null; - - const records = Object.entries(texts).filter( - ([key]) => !RECORDS_ALREADY_DISPLAYED_ELSEWHERE.includes(key), - ); - - if (records.length === 0) return null; +export function AdditionalRecords({ profile }: AdditionalRecordsProps) { + if (profile.additionalTextRecords.length === 0) return null; return ( @@ -36,10 +16,10 @@ export function AdditionalRecords({ texts }: AdditionalRecordsProps) { Additional Records - {records.map(([key, value]) => ( + {profile.additionalTextRecords.map(({ key, value }) => (
{key} - {String(value)} + {value}
))}
diff --git a/apps/ensadmin/src/app/name/_components/Addresses.tsx b/apps/ensadmin/src/app/name/_components/Addresses.tsx index 5a5c13b5f..b30315e1e 100644 --- a/apps/ensadmin/src/app/name/_components/Addresses.tsx +++ b/apps/ensadmin/src/app/name/_components/Addresses.tsx @@ -1,13 +1,14 @@ "use client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { ENSAdminProfile } from "@/hooks/use-ensadmin-profile"; interface AddressesProps { - addresses: Record | null | undefined; + profile: ENSAdminProfile; } -export function Addresses({ addresses }: AddressesProps) { - if (!addresses || Object.keys(addresses).length === 0) { +export function Addresses({ profile }: AddressesProps) { + if (profile.addresses.length === 0) { return null; } @@ -17,12 +18,10 @@ export function Addresses({ addresses }: AddressesProps) { Addresses - {Object.entries(addresses).map(([coinType, address]) => ( + {profile.addresses.map(({ coinType, address }) => (
Coin Type {coinType} - - {String(address)} - + {address}
))}
diff --git a/apps/ensadmin/src/app/name/_components/NameDetailPageContent.tsx b/apps/ensadmin/src/app/name/_components/NameDetailPageContent.tsx index b0b1340ab..d83d05cdd 100644 --- a/apps/ensadmin/src/app/name/_components/NameDetailPageContent.tsx +++ b/apps/ensadmin/src/app/name/_components/NameDetailPageContent.tsx @@ -1,11 +1,9 @@ "use client"; -import { ASSUME_IMMUTABLE_QUERY, useRecords } from "@ensnode/ensnode-react"; -import { type Name, type ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; +import { type Name } from "@ensnode/ensnode-sdk"; import { Card, CardContent } from "@/components/ui/card"; -import { useActiveNamespace } from "@/hooks/active/use-active-namespace"; -import { getCommonCoinTypes } from "@/lib/default-records-selection"; +import { useENSAdminProfile } from "@/hooks/use-ensadmin-profile"; import { AdditionalRecords } from "./AdditionalRecords"; import { Addresses } from "./Addresses"; @@ -14,58 +12,12 @@ import { ProfileHeader } from "./ProfileHeader"; import { ProfileInformation } from "./ProfileInformation"; import { SocialLinks } from "./SocialLinks"; -const HeaderPanelTextRecords = ["url", "avatar", "header"]; -const ProfilePanelTextRecords = ["description", "email"]; -const SocialLinksTextRecords = [ - "com.twitter", - "com.github", - "com.farcaster", - "org.telegram", - "com.linkedin", - "com.reddit", -]; -// TODO: Instead of explicitly listing AdditionalTextRecords, we should update -// `useRecords` so that we can ask it to return not only all the records we -// explicitly requested, but also any other records that were found onchain, -// no matter what their text record keys are. Below are two examples of -// additional text records set for lightwalker.eth on mainnet as an example. -// see: https://github.com/namehash/ensnode/issues/1083 -const AdditionalTextRecords = ["status", "eth.ens.delegate"]; -const AllRequestedTextRecords = [ - ...HeaderPanelTextRecords, - ...ProfilePanelTextRecords, - ...SocialLinksTextRecords, - ...AdditionalTextRecords, -]; - interface NameDetailPageContentProps { name: Name; } export function NameDetailPageContent({ name }: NameDetailPageContentProps) { - const namespace = useActiveNamespace(); - - const selection = { - addresses: getCommonCoinTypes(namespace), - texts: AllRequestedTextRecords, - } as const satisfies ResolverRecordsSelection; - - // TODO: Each app (including ENSAdmin) should define their own "wrapper" data model around - // their `useRecords` queries that is specific to their use case. For example, ENSAdmin should - // define a nicely designed data model such as `ENSProfile` (based on the subjective definition - // of what an ENS profile is within the context of ENSAdmin). Then, a hook such as `useENSProfile` - // should be defined that internally calls `useRecords` and then performs the data transformations - // that might be required to return the nice, clean, and specialized `ENSProfile` data model. - // The code in `ProfileHeader`, `ProfileInformation`, `SocialLinks`, `Addresses`, and `AdditionalRecords` - // should then be updated so that it takes as input only the nice and clean `ENSProfile` data model. - // These UI components should not need to consider the nuances or complexities of the raw `useRecords` - // data model. All those nuances and complexities should be mananaged in a single place (ex: `useENSProfile`). - // see: https://github.com/namehash/ensnode/issues/1082 - const { data, status } = useRecords({ - name, - selection, - query: ASSUME_IMMUTABLE_QUERY, - }); + const { data: profile, status } = useENSAdminProfile({ name }); if (status === "pending") return ; @@ -78,25 +30,17 @@ export function NameDetailPageContent({ name }: NameDetailPageContentProps) {
); + // TODO: Design and Implement Profile not found page + if (!profile) return null; + return (
- +
- - - - - - - + + + +
); diff --git a/apps/ensadmin/src/app/name/_components/ProfileHeader.tsx b/apps/ensadmin/src/app/name/_components/ProfileHeader.tsx index 4f0575eb7..0fce7d46a 100644 --- a/apps/ensadmin/src/app/name/_components/ProfileHeader.tsx +++ b/apps/ensadmin/src/app/name/_components/ProfileHeader.tsx @@ -1,25 +1,21 @@ "use client"; -import type { ENSNamespaceId, Name } from "@ensnode/ensnode-sdk"; - import { EnsAvatar } from "@/components/ens-avatar"; import { NameDisplay } from "@/components/identity/utils"; import { ExternalLinkWithIcon } from "@/components/link"; import { Card, CardContent } from "@/components/ui/card"; +import type { ENSAdminProfile } from "@/hooks/use-ensadmin-profile"; import { beautifyUrl } from "@/lib/beautify-url"; interface ProfileHeaderProps { - name: Name; - namespaceId: ENSNamespaceId; - headerImage?: string | null; - websiteUrl?: string | null; + profile: ENSAdminProfile; } -export function ProfileHeader({ name, namespaceId, headerImage, websiteUrl }: ProfileHeaderProps) { +export function ProfileHeader({ profile }: ProfileHeaderProps) { // Parse header image URI and only use it if it's HTTP/HTTPS // TODO: Add support for more URI types as defined in ENSIP-12 // See: https://docs.ens.domains/ensip/12#uri-types - const getValidHeaderImageUrl = (headerImage: string | null | undefined): string | null => { + const getValidHeaderImageUrl = (headerImage: string | null): string | null => { if (!headerImage) return null; let url: URL; @@ -35,7 +31,7 @@ export function ProfileHeader({ name, namespaceId, headerImage, websiteUrl }: Pr return null; }; - const normalizeWebsiteUrl = (url: string | null | undefined): URL | null => { + const normalizeWebsiteUrl = (url: string | null): URL | null => { if (!url) return null; try { @@ -49,8 +45,8 @@ export function ProfileHeader({ name, namespaceId, headerImage, websiteUrl }: Pr } }; - const validHeaderImageUrl = getValidHeaderImageUrl(headerImage); - const normalizedWebsiteUrl = normalizeWebsiteUrl(websiteUrl); + const validHeaderImageUrl = getValidHeaderImageUrl(profile.header.headerImageUrl); + const normalizedWebsiteUrl = normalizeWebsiteUrl(profile.header.websiteUrl); return ( @@ -67,10 +63,10 @@ export function ProfileHeader({ name, namespaceId, headerImage, websiteUrl }: Pr
- +

- +

{normalizedWebsiteUrl && ( diff --git a/apps/ensadmin/src/app/name/_components/ProfileInformation.tsx b/apps/ensadmin/src/app/name/_components/ProfileInformation.tsx index f27a46e3b..ce81d82d0 100644 --- a/apps/ensadmin/src/app/name/_components/ProfileInformation.tsx +++ b/apps/ensadmin/src/app/name/_components/ProfileInformation.tsx @@ -3,13 +3,15 @@ import { Mail } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { ENSAdminProfile } from "@/hooks/use-ensadmin-profile"; interface ProfileInformationProps { - description?: string | null; - email?: string | null; + profile: ENSAdminProfile; } -export function ProfileInformation({ description, email }: ProfileInformationProps) { +export function ProfileInformation({ profile }: ProfileInformationProps) { + const { description, email } = profile.information; + if (!description && !email) { return null; } diff --git a/apps/ensadmin/src/app/name/_components/SocialLinks.tsx b/apps/ensadmin/src/app/name/_components/SocialLinks.tsx index 220289558..75ea23d8b 100644 --- a/apps/ensadmin/src/app/name/_components/SocialLinks.tsx +++ b/apps/ensadmin/src/app/name/_components/SocialLinks.tsx @@ -1,30 +1,18 @@ "use client"; import { SiFarcaster, SiGithub, SiReddit, SiTelegram, SiX } from "@icons-pack/react-simple-icons"; -import { useMemo } from "react"; import { LinkedInIcon } from "@/components/icons/LinkedInIcon"; import { ExternalLinkWithIcon } from "@/components/link"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { ENSAdminProfile, ENSAdminSocialLinkKey } from "@/hooks/use-ensadmin-profile"; -const SOCIAL_LINK_KEYS = [ - "com.twitter", - "com.farcaster", - "com.github", - "org.telegram", - "com.linkedin", - "com.reddit", -] as const; - -type SocialLinkKey = (typeof SOCIAL_LINK_KEYS)[number]; -type SocialLinkValue = string; +interface SocialLinksProps { + profile: ENSAdminProfile; +} -export function SocialLinks({ - links, -}: { - links: { key: SocialLinkKey; value: SocialLinkValue }[]; -}) { - if (links.length === 0) return null; +export function SocialLinks({ profile }: SocialLinksProps) { + if (profile.socialLinks.length === 0) return null; return ( @@ -32,8 +20,8 @@ export function SocialLinks({ Social Links - {links.map(({ key, value }) => { - switch (key) { + {profile.socialLinks.map(({ key, value }) => { + switch (key as ENSAdminSocialLinkKey) { case "com.twitter": { return (
@@ -106,24 +94,3 @@ export function SocialLinks({ ); } - -SocialLinks.Texts = function SocialLinksTexts({ - texts, -}: { - texts: Record; -}) { - const links = useMemo( - () => - SOCIAL_LINK_KEYS - // map social keys to a set of links - .map((key) => ({ key, value: texts[key] })) - // filter those links by those that exist - .filter( - (link): link is { key: SocialLinkKey; value: SocialLinkValue } => - typeof link.value === "string", - ), - [texts], - ); - - return ; -}; diff --git a/apps/ensadmin/src/hooks/use-ensadmin-profile.ts b/apps/ensadmin/src/hooks/use-ensadmin-profile.ts new file mode 100644 index 000000000..3e387d31f --- /dev/null +++ b/apps/ensadmin/src/hooks/use-ensadmin-profile.ts @@ -0,0 +1,277 @@ +"use client"; + +import { useMemo } from "react"; + +import { useProfile } from "@ensnode/ensnode-react"; +import type { Name, ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; + +import { useActiveNamespace } from "@/hooks/active/use-active-namespace"; +import { getCommonCoinTypes } from "@/lib/default-records-selection"; + +/** + * Strongly-typed social link identifiers supported by ENSAdmin profiles. + */ +export const ENSADMIN_SOCIAL_LINK_KEYS = [ + "com.twitter", + "com.github", + "com.farcaster", + "org.telegram", + "com.linkedin", + "com.reddit", +] as const; + +/** + * A social link key recognized by ENSAdmin. + */ +export type ENSAdminSocialLinkKey = (typeof ENSADMIN_SOCIAL_LINK_KEYS)[number]; + +/** + * A structured social link with its platform identifier and username/handle value. + */ +export type ENSAdminSocialLink = { + readonly key: ENSAdminSocialLinkKey; + readonly value: string; +}; + +/** + * A structured address record with its coin type identifier and address value. + */ +export type ENSAdminAddressRecord = { + readonly coinType: string; + readonly address: string; +}; + +/** + * A structured additional text record with its key and value. + * Excludes records already displayed in header, profile, or social sections. + */ +export type ENSAdminAdditionalTextRecord = { + readonly key: string; + readonly value: string; +}; + +/** + * The specialized ENS Profile data model optimized for ENSAdmin. + * + * This model abstracts away the complexities of raw `useRecords` responses + * and provides a clean, purpose-built structure for ENSAdmin's profile UI. + * + * @example + * ```typescript + * const profile: ENSAdminProfile = { + * name: "vitalik.eth", + * header: { + * avatarUrl: "https://...", + * headerImageUrl: "https://...", + * websiteUrl: "https://vitalik.ca", + * }, + * information: { + * description: "Ethereum co-founder", + * email: "v@ethereum.org", + * }, + * socialLinks: [ + * { key: "com.twitter", value: "VitalikButerin" }, + * ], + * addresses: [ + * { coinType: "60", address: "0x..." }, + * ], + * additionalTextRecords: [ + * { key: "status", value: "Building" }, + * ], + * }; + * ``` + */ +export type ENSAdminProfile = { + /** + * The ENS name being displayed. + */ + readonly name: Name; + + /** + * Header section data including avatar, banner image, and website URL. + */ + readonly header: { + readonly avatarUrl: string | null; + readonly headerImageUrl: string | null; + readonly websiteUrl: string | null; + }; + + /** + * Profile information section data. + */ + readonly information: { + readonly description: string | null; + readonly email: string | null; + }; + + /** + * Structured list of social platform links with non-null values. + */ + readonly socialLinks: readonly ENSAdminSocialLink[]; + + /** + * Structured list of blockchain addresses with non-null values. + */ + readonly addresses: readonly ENSAdminAddressRecord[]; + + /** + * Additional text records not displayed elsewhere, with non-null values. + */ + readonly additionalTextRecords: readonly ENSAdminAdditionalTextRecord[]; +}; + +/** + * Text record keys that are displayed in specialized UI sections. + * Used to filter out these keys from the additional records section. + */ +const DISPLAYED_TEXT_RECORD_KEYS = [ + "url", + "avatar", + "header", + "description", + "email", + "name", + ...ENSADMIN_SOCIAL_LINK_KEYS, +] as const; + +/** + * Parameters for the useENSAdminProfile hook. + */ +export type UseENSAdminProfileParameters = { + /** + * The ENS name to resolve profile data for. + */ + name: Name; +}; + +/** + * Hook for fetching and transforming ENS profile data optimized for ENSAdmin. + * + * Internally calls `useProfile` with ENSAdmin-specific record selection and + * transformation logic to produce a clean, structured `ENSAdminProfile` data + * model tailored for ENSAdmin's UI components. + * + * @param parameters - Configuration specifying which name to resolve + * @returns Query result containing the transformed ENSAdminProfile data model + * + * @example + * ```typescript + * import { useENSAdminProfile } from "@/hooks/use-ensadmin-profile"; + * + * function ProfilePage() { + * const { data: profile, status } = useENSAdminProfile({ + * name: "vitalik.eth" + * }); + * + * if (status === "pending") return ; + * if (status === "error") return ; + * + * return ( + * <> + * + * + * + * + * + * + * ); + * } + * ``` + */ +export function useENSAdminProfile({ name }: UseENSAdminProfileParameters) { + const activeNamespace = useActiveNamespace(); + + const recordsSelection = useMemo( + () => + ({ + addresses: getCommonCoinTypes(activeNamespace), + texts: [ + // Header section texts + "url", + "avatar", + "header", + // Profile information section texts + "description", + "email", + // Social links + ...ENSADMIN_SOCIAL_LINK_KEYS, + // Additional known records + // TODO: Extend to dynamically discover all text records set onchain + // See: https://github.com/namehash/ensnode/issues/1083 + "status", + "eth.ens.delegate", + ], + }) as const satisfies ResolverRecordsSelection, + [activeNamespace], + ); + + const transformRecordsToENSAdminProfile = useMemo( + () => + ( + profileName: Name, + recordsResponse: { + records: { + texts: Record; + addresses: Record; + }; + }, + ): ENSAdminProfile => { + // Extract and structure social links with non-null values + const socialLinksWithValues = ENSADMIN_SOCIAL_LINK_KEYS.map((socialLinkKey) => ({ + key: socialLinkKey, + value: recordsResponse.records.texts[socialLinkKey], + })).filter( + (socialLink): socialLink is ENSAdminSocialLink => + socialLink.value !== null && socialLink.value !== undefined, + ); + + // Extract and structure addresses with non-null values + const addressesWithValues = Object.entries(recordsResponse.records.addresses) + .map(([coinTypeString, addressValue]) => ({ + coinType: coinTypeString, + address: addressValue as string, + })) + .filter( + (addressRecord): addressRecord is ENSAdminAddressRecord => + addressRecord.address !== null && addressRecord.address !== undefined, + ); + + // Extract additional text records not displayed elsewhere + const additionalTextRecordsWithValues = Object.entries(recordsResponse.records.texts) + .filter(([textKey]) => !DISPLAYED_TEXT_RECORD_KEYS.includes(textKey as any)) + .map(([textKey, textValue]) => ({ + key: textKey, + value: textValue as string, + })) + .filter( + (textRecord): textRecord is ENSAdminAdditionalTextRecord => + textRecord.value !== null && textRecord.value !== undefined, + ); + + return { + name: profileName, + header: { + avatarUrl: recordsResponse.records.texts.avatar ?? null, + headerImageUrl: recordsResponse.records.texts.header ?? null, + websiteUrl: recordsResponse.records.texts.url ?? null, + }, + information: { + description: recordsResponse.records.texts.description ?? null, + email: recordsResponse.records.texts.email ?? null, + }, + socialLinks: socialLinksWithValues, + addresses: addressesWithValues, + additionalTextRecords: additionalTextRecordsWithValues, + }; + }, + [], + ); + + return useProfile( + { + name, + selection: recordsSelection, + }, + transformRecordsToENSAdminProfile, + ); +} diff --git a/packages/ensnode-react/src/hooks/index.ts b/packages/ensnode-react/src/hooks/index.ts index 46583a1b2..d7b395051 100644 --- a/packages/ensnode-react/src/hooks/index.ts +++ b/packages/ensnode-react/src/hooks/index.ts @@ -1,6 +1,7 @@ // Re-export BrowserSupportedAssetUrl for convenience export type { BrowserSupportedAssetUrl } from "@ensnode/ensnode-sdk"; +export * from "./useAvatarUrl"; export * from "./useAvatarUrl"; export * from "./useENSNodeConfig"; export * from "./useENSNodeSDKConfig"; @@ -8,5 +9,6 @@ export * from "./useIndexingStatus"; export * from "./useIndexingStatus"; export * from "./usePrimaryName"; export * from "./usePrimaryNames"; +export * from "./useProfile"; export * from "./useRecords"; export * from "./useResolvedIdentity"; diff --git a/packages/ensnode-react/src/hooks/useProfile.ts b/packages/ensnode-react/src/hooks/useProfile.ts new file mode 100644 index 000000000..4382324fc --- /dev/null +++ b/packages/ensnode-react/src/hooks/useProfile.ts @@ -0,0 +1,224 @@ +"use client"; + +import type { UseQueryResult } from "@tanstack/react-query"; +import { useMemo } from "react"; + +import type { Name, ResolveRecordsResponse, ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; + +import type { QueryParameter, WithSDKConfigParameter } from "../types"; +import { useRecords } from "./useRecords"; + +/** + * Parameters for the useProfile hook. + * + * @template RECORDS_SELECTION - The resolver records selection type + */ +export type UseProfileParameters = { + /** + * The ENS name to resolve profile data for. + * If null, the query will not be executed. + */ + name: Name | null; + + /** + * The selection of records to fetch. + */ + selection: RECORDS_SELECTION; +} & WithSDKConfigParameter & + QueryParameter>; + +/** + * A transformation function that converts raw resolver records into a custom profile data model. + * + * This function is called with the ENS name and the raw records response from the resolver, + * and should return a transformed profile object optimized for your application's use case. + * + * Invariant: This function is only called when records data is successfully fetched. + * It will not be called with null or undefined data. + * + * @template RECORDS_SELECTION - The resolver records selection type + * @template TRANSFORMED_PROFILE - The custom profile data model type + * + * @param name - The ENS name being resolved + * @param recordsResponse - The raw records response from the resolver + * @returns The transformed profile data model + * + * @example + * ```typescript + * const transformToMyProfile = (name, recordsResponse) => ({ + * displayName: name, + * avatar: recordsResponse.records.texts.avatar ?? null, + * ethAddress: recordsResponse.records.addresses["60"] ?? null, + * }); + * ``` + */ +export type ProfileTransformFunction< + RECORDS_SELECTION extends ResolverRecordsSelection, + TRANSFORMED_PROFILE, +> = (name: Name, recordsResponse: ResolveRecordsResponse) => TRANSFORMED_PROFILE; + +/** + * Hook for fetching ENS records with custom transformation logic. + * + * This hook wraps `useRecords` and allows applications to define their own + * data transformation logic to convert raw resolver records into a custom + * profile data model optimized for their specific use case. + * + * The transformation function is memoized and only re-runs when the underlying + * records data changes, ensuring efficient re-renders. + * + * @template RECORDS_SELECTION - The resolver records selection type + * @template TRANSFORMED_PROFILE - The custom profile data model type + * + * @param parameters - Configuration for fetching and transforming profile data + * @param transform - Pure function to transform records into profile model + * @returns Query result containing the transformed profile data + * + * @example + * ```typescript + * import { useProfile } from "@ensnode/ensnode-react"; + * import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; + * + * const selection = { + * addresses: [60] as const, + * texts: ["avatar", "description", "com.twitter"] as const, + * } satisfies ResolverRecordsSelection; + * + * type MyProfile = { + * name: string; + * avatar: string | null; + * description: string | null; + * ethAddress: string | null; + * twitter: string | null; + * }; + * + * function MyComponent() { + * const { data: profile, isLoading, error } = useProfile( + * { + * name: "vitalik.eth", + * selection, + * }, + * (name, recordsResponse) => ({ + * name, + * avatar: recordsResponse.records.texts.avatar ?? null, + * description: recordsResponse.records.texts.description ?? null, + * ethAddress: recordsResponse.records.addresses["60"] ?? null, + * twitter: recordsResponse.records.texts["com.twitter"] ?? null, + * }) + * ); + * + * if (isLoading) return
Loading...
; + * if (error) return
Error: {error.message}
; + * if (!profile) return
No profile found
; + * + * return ( + *
+ *

{profile.name}

+ * {profile.avatar && Avatar} + *

{profile.description}

+ *

ETH: {profile.ethAddress}

+ * {profile.twitter &&

Twitter: @{profile.twitter}

} + *
+ * ); + * } + * ``` + * + * @example + * ```typescript + * // Building a reusable profile hook for your application + * import { useProfile } from "@ensnode/ensnode-react"; + * import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; + * + * const MY_APP_SELECTION = { + * addresses: [60, 0] as const, // ETH and BTC + * texts: ["avatar", "description", "url", "com.twitter", "com.github"] as const, + * } satisfies ResolverRecordsSelection; + * + * type MyAppProfile = { + * name: string; + * avatar: string | null; + * description: string | null; + * website: string | null; + * social: { + * twitter: string | null; + * github: string | null; + * }; + * crypto: { + * eth: string | null; + * btc: string | null; + * }; + * }; + * + * export function useMyAppProfile(name: string | null) { + * return useProfile( + * { + * name, + * selection: MY_APP_SELECTION, + * }, + * (name, recordsResponse) => ({ + * name, + * avatar: recordsResponse.records.texts.avatar ?? null, + * description: recordsResponse.records.texts.description ?? null, + * website: recordsResponse.records.texts.url ?? null, + * social: { + * twitter: recordsResponse.records.texts["com.twitter"] ?? null, + * github: recordsResponse.records.texts["com.github"] ?? null, + * }, + * crypto: { + * eth: recordsResponse.records.addresses["60"] ?? null, + * btc: recordsResponse.records.addresses["0"] ?? null, + * }, + * }) + * ); + * } + * + * // Usage in components is now simple: + * function ProfileCard({ name }: { name: string }) { + * const { data: profile, isLoading } = useMyAppProfile(name); + * + * if (isLoading) return
Loading...
; + * if (!profile) return null; + * + * return ( + *
+ *

{profile.name}

+ *

{profile.description}

+ * Website + *

Twitter: {profile.social.twitter}

+ *

ETH: {profile.crypto.eth}

+ *
+ * ); + * } + * ``` + */ +export function useProfile( + parameters: UseProfileParameters, + transform: ProfileTransformFunction, +): UseQueryResult { + const { name, selection, config, query } = parameters; + + const recordsQueryResult = useRecords({ + name, + selection, + config, + query, + }); + + // Memoize the transformed profile data + // Only recompute when records data or transform function changes + const transformedProfileData = useMemo(() => { + // Invariant: Only transform when we have successful data + if (!recordsQueryResult.data || !name) { + return undefined; + } + + return transform(name, recordsQueryResult.data); + }, [recordsQueryResult.data, name, transform]); + + // Return the query result with transformed data + // Preserve all other query states from useRecords + return { + ...recordsQueryResult, + data: transformedProfileData, + } as UseQueryResult; +}