[SDK] Feature: Account components (#5388)

cnct-2137

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new `Account` component suite to the `thirdweb` library, enhancing user account management with features like account name, address, avatar, and balance display. It also marks existing `NFT` components as `@beta`.

### Detailed summary
- Added `Account` components: `AccountName`, `AccountAddress`, `AccountAvatar`, `AccountBalance`, and `AccountBlobbie`.
- Updated existing `NFT` components to `@beta`.
- Introduced tests for new `Account` components.
- Enhanced `AccountProvider` for context management.
- Updated documentation for new components and props.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
This commit is contained in:
kien-ngo
2024-11-20 05:37:00 +00:00
parent 3253a532f8
commit 901c3a102b
21 changed files with 1059 additions and 8 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"thirdweb": minor
---
Add headless UI component: Account (Name, Image, Address, Balance)
+16
View File
@@ -109,6 +109,22 @@ export const sidebar: SideBar = {
href: `${slug}/${name}`,
icon: <CodeIcon />,
})),
{
name: "Account",
isCollapsible: true,
links: [
"AccountProvider",
"AccountAddress",
"AccountAvatar",
"AccountName",
"AccountBlobbie",
"AccountBalance",
].map((name) => ({
name,
href: `${slug}/${name}`,
icon: <CodeIcon />,
})),
},
],
},
{
@@ -41,6 +41,7 @@ const tagsToGroup = {
"@social": "Social API",
"@modules": "Modules",
"@client": "Client",
"@account": "Account Components",
} as const;
type TagKey = keyof typeof tagsToGroup;
@@ -79,6 +80,7 @@ const sidebarGroupOrder: TagKey[] = [
"@theme",
"@utils",
"@others",
"@account",
];
function findTag(
+23
View File
@@ -212,3 +212,26 @@ export type {
// Site Embed and Linking
export { SiteEmbed } from "../react/web/ui/SiteEmbed.js";
export { SiteLink } from "../react/web/ui/SiteLink.js";
// Account
export {
AccountAddress,
type AccountAddressProps,
} from "../react/web/ui/prebuilt/Account/address.js";
export {
AccountBalance,
type AccountBalanceProps,
} from "../react/web/ui/prebuilt/Account/balance.js";
export {
AccountName,
type AccountNameProps,
} from "../react/web/ui/prebuilt/Account/name.js";
export { AccountBlobbie } from "../react/web/ui/prebuilt/Account/blobbie.js";
export {
AccountProvider,
type AccountProviderProps,
} from "../react/web/ui/prebuilt/Account/provider.js";
export {
AccountAvatar,
type AccountAvatarProps,
} from "../react/web/ui/prebuilt/Account/avatar.js";
@@ -21,18 +21,34 @@ const COLOR_OPTIONS = [
["#fda4af", "#be123c"],
];
/**
* Props for the Blobbie component
* @component
*/
export type BlobbieProps = {
address: Address;
style?: Omit<React.CSSProperties, "backgroundImage">;
className?: string;
size?: number;
};
/**
* A unique gradient avatar based on the provided address.
* @param props The component props.
* @param props.address The address to generate the gradient with.
* @param props.size The size of each side of the square avatar (in pixels)
* @param props.style The CSS style for the component - excluding `backgroundImage`
* @param props.className The className for the component
* @param props.size The size of each side of the square avatar (in pixels). This prop will override the `width` and `height` attributes from the `style` prop.
* @component
* @wallet
* @example
* ```tsx
* <Blobbie address="0x...." size={24} />
* import { Blobbie } from "thirdweb/react";
*
* <Blobbie address="0x...." className="w-10 h-10" />
* ```
* @wallet
*/
export function Blobbie(props: { address: Address; size: number }) {
export function Blobbie(props: BlobbieProps) {
const id = useId();
const colors = useMemo(
() =>
@@ -46,10 +62,16 @@ export function Blobbie(props: { address: Address; size: number }) {
<div
id={id}
style={{
width: `${props.size}px`,
height: `${props.size}px`,
...props.style,
backgroundImage: `radial-gradient(ellipse at left bottom, ${colors[0]}, ${colors[1]})`,
...(props.size
? {
width: `${props.size}px`,
height: `${props.size}px`,
}
: undefined),
}}
className={props.className}
/>
);
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { render, screen, waitFor } from "~test/react-render.js";
import { TEST_CLIENT } from "~test/test-clients.js";
import { shortenAddress } from "../../../../../utils/address.js";
import { AccountAddress } from "./address.js";
import { AccountProvider } from "./provider.js";
describe.runIf(process.env.TW_SECRET_KEY)("AccountAddress component", () => {
it("should format the address properly", () => {
render(
<AccountProvider
address="0x12345674b599ce99958242b3D3741e7b01841DF3"
client={TEST_CLIENT}
>
<AccountAddress formatFn={shortenAddress} />
</AccountProvider>,
);
waitFor(() =>
expect(
screen.getByText("0x1234...1DF3", {
exact: true,
selector: "span",
}),
).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,64 @@
"use client";
import { useAccountContext } from "./provider.js";
/**
* @component
* @account
*/
export interface AccountAddressProps
extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
/**
* The function used to transform (format) the wallet address
* Specifically useful for shortening the wallet.
*
* This function should take in a string and output a string
*/
formatFn?: (str: string) => string;
}
/**
*
* @returns a <span> containing the full wallet address of the account
*
* @example
* ### Basic usage
* ```tsx
* import { AccountProvider, AccountAddress } from "thirdweb/react";
*
* <AccountProvider address="0x12345674b599ce99958242b3D3741e7b01841DF3" client={TW_CLIENT}>
* <AccountAddress />
* </AccountProvider>
* ```
* Result:
* ```html
* <span>0x12345674b599ce99958242b3D3741e7b01841DF3</span>
* ```
*
*
* ### Shorten the address
* ```tsx
* import { AccountProvider, AccountAddress } from "thirdweb/react";
* import { shortenAddress } from "thirdweb/utils";
*
* <AccountProvider address="0x12345674b599ce99958242b3D3741e7b01841DF3" client={TW_CLIENT}>
* <AccountAddress formatFn={shortenAddress} />
* </AccountProvider>
* ```
* Result:
* ```html
* <span>0x1234...1DF3</span>
* ```
*
* @component
* @account
* @beta
*/
export function AccountAddress({
formatFn,
...restProps
}: AccountAddressProps) {
const { address } = useAccountContext();
const value = formatFn ? formatFn(address) : address;
return <span {...restProps}>{value}</span>;
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { render, screen, waitFor } from "~test/react-render.js";
import { TEST_CLIENT } from "~test/test-clients.js";
import { TEST_ACCOUNT_A } from "~test/test-wallets.js";
import { AccountAvatar } from "./avatar.js";
import { AccountProvider } from "./provider.js";
describe.runIf(process.env.TW_SECRET_KEY)("AccountAvatar component", () => {
it("should render an image", () => {
render(
<AccountProvider
address={"0x12345674b599ce99958242b3D3741e7b01841DF3"}
client={TEST_CLIENT}
>
<AccountAvatar />
</AccountProvider>,
);
waitFor(() => expect(screen.getByRole("img")).toBeInTheDocument());
});
it("should fallback properly if failed to load", () => {
render(
<AccountProvider address={TEST_ACCOUNT_A.address} client={TEST_CLIENT}>
<AccountAvatar fallbackComponent={<span>oops</span>} />
</AccountProvider>,
);
waitFor(() =>
expect(
screen.getByText("oops", {
exact: true,
selector: "span",
}),
).toBeInTheDocument(),
);
});
it("should NOT render anything if fail to resolve avatar", () => {
render(
<AccountProvider address={"invalid-wallet-address"} client={TEST_CLIENT}>
<AccountAvatar />
</AccountProvider>,
);
waitFor(() => expect(screen.getByRole("img")).not.toBeInTheDocument());
});
});
@@ -0,0 +1,221 @@
"use client";
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
import type React from "react";
import type { JSX } from "react";
import { resolveAvatar } from "../../../../../extensions/ens/resolve-avatar.js";
import {
type ResolveNameOptions,
resolveName,
} from "../../../../../extensions/ens/resolve-name.js";
import { getSocialProfiles } from "../../../../../social/profiles.js";
import type { SocialProfile } from "../../../../../social/types.js";
import { parseAvatarRecord } from "../../../../../utils/ens/avatar.js";
import { useAccountContext } from "./provider.js";
/**
* Props for the AccountAvatar component
* @component
* @account
*/
export interface AccountAvatarProps
extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src">,
Omit<ResolveNameOptions, "client" | "address"> {
/**
* Use this prop to prioritize the social profile that you want to display
* This is useful for a wallet containing multiple social profiles.
* This component inherits all attributes of a HTML's <img />, so you can interact with it just like a normal <img />
*
* @example
* If you have ENS, Lens and Farcaster profiles linked to your wallet
* you can prioritize showing the image for Lens by:
* ```tsx
* <AccountAvatar
* socialType="lens" // Choose between: "farcaster" | "lens" | "ens"
* />
* ```
*/
socialType?: SocialProfile["type"];
/**
* This component will be shown while the avatar of the account is being fetched
* If not passed, the component will return `null`.
*
* You can pass a loading sign or spinner to this prop.
* @example
* ```tsx
* <AccountAvatar loadingComponent={<Spinner />} />
* ```
*/
loadingComponent?: JSX.Element;
/**
* This component will be shown if the request for fetching the avatar is done
* but could not retreive any result.
* You can pass a dummy avatar/image to this prop.
*
* If not passed, the component will return `null`
*
* @example
* ```tsx
* <AccountAvatar fallbackComponent={<DummyImage />} />
* ```
*/
fallbackComponent?: JSX.Element;
/**
* Optional query options for `useQuery`
*/
queryOptions?: Omit<UseQueryOptions<string>, "queryFn" | "queryKey">;
}
/**
* The component for showing the avatar of the account.
* If fetches all the social profiles linked to your wallet, including: Farcaster, ENS, Lens (more to be added)
* You can choose which social profile you want to display. Defaults to the first item in the list.
*
* @example
* ### Basic usage
* ```tsx
* import { AccountProvider, AccountAvatar } from "thirdweb/react";
*
* <AccountProvider address="0x...">
* <AccountAvatar />
* </AccountProvider>
* ```
* Result: An <img /> component, if the avatar is resolved successfully
* ```html
* <img alt="" src="resolved-url-for-the-avatar" />
* ```
*
* ### Show a loading sign when the avatar is being resolved
* ```tsx
* import { AccountProvider, AccountAvatar } from "thirdweb/react";
*
* <AccountProvider address="0x...">
* <AccountAvatar
* loadingComponent={<YourLoadingComponent />}
* />
* </AccountProvider>
* ```
*
* ### Fallback to something when the avatar fails to resolve
* ```tsx
* import { AccountProvider, AccountAvatar } from "thirdweb/react";
*
* <AccountProvider address="0x...">
* <AccountAvatar
* fallbackComponent={<DummyImage />}
* />
* </AccountProvider>
* ```
*
* ### Select a social profile to display
* If you wallet associates with more than one social profiles (Lens, Farcaster, ENS, etc.)
* You can specify which service you want to prioritize using the `socialType` props
* ```tsx
* import { AccountProvider, AccountAvatar } from "thirdweb/react";
*
* <AccountProvider address="0x...">
* <AccountAvatar
* // Choose between: "farcaster" | "lens" | "ens"
* socialType={"ens"}
* />
* </AccountProvider>
* ```
*
* ### Custom ENS resolver chain
* This component shares the same props with the ENS extension `resolveAvatar`
* ```tsx
* import { AccountProvider, AccountAvatar } from "thirdweb/react";
* import { base } from "thirdweb/chains";
*
* <AccountProvider address="0x...">
* <AccountAvatar
* resolverAddress={"0x..."}
* resolverChain={base}
* />
* </AccountProvider>
* ```
*
* ### Custom query options for useQuery
* This component uses `@tanstack-query`'s useQuery internally.
* You can use the `queryOptions` prop for more fine-grained control
* ```tsx
* <AccountAvatar
* queryOptions={{
* enabled: isEnabled,
* retry: 3,
* }}
* />
* ```
* @returns An <img /> if the avatar is resolved successfully
* @component
* @account
* @beta
*/
export function AccountAvatar({
socialType,
resolverAddress,
resolverChain,
loadingComponent,
fallbackComponent,
queryOptions,
...restProps
}: AccountAvatarProps) {
const { address, client } = useAccountContext();
const avatarQuery = useQuery({
queryKey: ["account-avatar", address],
queryFn: async (): Promise<string> => {
const [socialData, ensName] = await Promise.all([
getSocialProfiles({ address, client }),
resolveName({
client,
address: address || "",
resolverAddress,
resolverChain,
}),
]);
const uri = socialData?.filter(
(p) => p.avatar && (socialType ? p.type === socialType : true),
)[0]?.avatar;
const [resolvedSocialAvatar, resolvedENSAvatar] = await Promise.all([
uri ? parseAvatarRecord({ client, uri }) : undefined,
ensName
? resolveAvatar({
client,
name: ensName,
})
: undefined,
]);
// If no social image + ens name found -> exit and show <Blobbie />
if (!resolvedSocialAvatar && !resolvedENSAvatar) {
throw new Error("Failed to resolve social + ens avatar");
}
// else, prioritize the social image first
if (resolvedSocialAvatar) {
return resolvedSocialAvatar;
}
if (resolvedENSAvatar) {
return resolvedENSAvatar;
}
throw new Error("Failed to resolve social + ens avatar");
},
...queryOptions,
});
if (avatarQuery.isLoading) {
return loadingComponent || null;
}
if (!avatarQuery.data) {
return fallbackComponent || null;
}
return <img src={avatarQuery.data} {...restProps} alt={restProps.alt} />;
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { ANVIL_CHAIN } from "~test/chains.js";
import { render, screen, waitFor } from "~test/react-render.js";
import { TEST_CLIENT } from "~test/test-clients.js";
import { TEST_ACCOUNT_A } from "~test/test-wallets.js";
import { getWalletBalance } from "../../../../../wallets/utils/getWalletBalance.js";
import { AccountBalance } from "./balance.js";
import { AccountProvider } from "./provider.js";
describe.runIf(process.env.TW_SECRET_KEY)("AccountBalance component", () => {
it("format the balance properly", async () => {
const roundTo1Decimal = (num: number): number => Math.round(num * 10) / 10;
const balance = await getWalletBalance({
chain: ANVIL_CHAIN,
client: TEST_CLIENT,
address: TEST_ACCOUNT_A.address,
});
render(
<AccountProvider address={TEST_ACCOUNT_A.address} client={TEST_CLIENT}>
<AccountBalance chain={ANVIL_CHAIN} formatFn={roundTo1Decimal} />
</AccountProvider>,
);
waitFor(() =>
expect(
screen.getByText(roundTo1Decimal(Number(balance.displayValue)), {
exact: true,
selector: "span",
}),
).toBeInTheDocument(),
);
});
it("should fallback properly if failed to load", () => {
render(
<AccountProvider address={TEST_ACCOUNT_A.address} client={TEST_CLIENT}>
<AccountBalance
chain={undefined}
fallbackComponent={<span>oops</span>}
/>
</AccountProvider>,
);
waitFor(() =>
expect(
screen.getByText("oops", {
exact: true,
selector: "span",
}),
).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,202 @@
"use client";
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
import type React from "react";
import type { JSX } from "react";
import type { Chain } from "../../../../../chains/types.js";
import { useActiveWalletChain } from "../../../../../react/core/hooks/wallets/useActiveWalletChain.js";
import {
type GetWalletBalanceResult,
getWalletBalance,
} from "../../../../../wallets/utils/getWalletBalance.js";
import { useAccountContext } from "./provider.js";
/**
* Props for the AccountBalance component
* @component
* @account
*/
export interface AccountBalanceProps
extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
/**
* The network to fetch balance on
* If not passed, the component will use the current chain that the wallet is connected to (`useActiveWalletChain()`)
*/
chain?: Chain;
/**
* By default this component will fetch the balance for the native token on a given chain
* If you want to fetch balance for an ERC20 token, use the `tokenAddress` props
*/
tokenAddress?: string;
/**
* A function to format the balance's display value
* use this function to transform the balance display value like round up the number
* Particularly useful to avoid overflowing-UI issues
*/
formatFn?: (num: number) => number;
/**
* This component will be shown while the balance of the account is being fetched
* If not passed, the component will return `null`.
*
* You can/should pass a loading sign or spinner to this prop.
* @example
* ```tsx
* <AccountBalance
* chain={ethereum}
* loadingComponent={<Spinner />}
* />
* ```
*/
loadingComponent?: JSX.Element;
/**
* This component will be shown if the balance fails to be retreived
* If not passed, the component will return `null`.
*
* You can/should pass a descriptive text/component to this prop, indicating that the
* balance was not fetched succesfully
* @example
* ```tsx
* <AccountBalance
* chain={nonExistentChain}
* fallbackComponent={"Failed to load"}
* />
* ```
*/
fallbackComponent?: JSX.Element;
/**
* Optional `useQuery` params
*/
queryOptions?: Omit<
UseQueryOptions<GetWalletBalanceResult>,
"queryFn" | "queryKey"
>;
}
/**
* This component fetches and shows the balance of the wallet address on a given chain.
* It inherits all the attributes of a HTML <span> component, hence you can style it just like how you would style a normal <span>
*
*
* @example
* ### Basic usage
* ```tsx
* import { AccountProvider, AccountBalance } from "thirdweb/react";
* import { ethereum } from "thirdweb/chains";
*
* <AccountProvider address="0x...">
* <AccountBalance chain={ethereum} />
* </AccountProvider>
* ```
* Result:
* ```html
* <span>1.091435 ETH</span>
* ```
*
*
* ### Format the balance (round up, shorten etc.)
* The AccountBalance component accepts a `formatFn` which takes in a number and outputs a number
* The function is used to modify the display value of the wallet balance
*
* ```tsx
* const roundTo1Decimal = (num: number):number => Math.round(num * 10) / 10;
*
* <AccountBalance formatFn={roundTo1Decimal} />
* ```
*
* Result:
* ```html
* <span>1.1 ETH</span>
* ```
*
* ### Show a loading sign when the balance is being fetched
* ```tsx
* import { AccountProvider, AccountBalance } from "thirdweb/react";
*
* <AccountProvider address="0x...">
* <AccountBalance
* chain={ethereum}
* loadingComponent={<Spinner />}
* />
* </AccountProvider>
* ```
*
* ### Fallback to something when the balance fails to resolve
* ```tsx
* <AccountProvider address="0x...">
* <AccountBalance
* chain={nonExistentChain}
* fallbackComponent={"Failed to load"}
* />
* </AccountProvider>
* ```
*
* ### Custom query options for useQuery
* This component uses `@tanstack-query`'s useQuery internally.
* You can use the `queryOptions` prop for more fine-grained control
* ```tsx
* <AccountBalance
* queryOptions={{
* enabled: isEnabled,
* retry: 4,
* }}
* />
* ```
*
* @component
* @account
* @beta
*/
export function AccountBalance({
chain,
tokenAddress,
formatFn,
loadingComponent,
fallbackComponent,
queryOptions,
...restProps
}: AccountBalanceProps) {
const { address, client } = useAccountContext();
const walletChain = useActiveWalletChain();
const chainToLoad = chain || walletChain;
const balanceQuery = useQuery({
queryKey: [
"walletBalance",
chainToLoad?.id || -1,
address || "0x0",
{ tokenAddress },
] as const,
queryFn: async () => {
if (!chainToLoad) {
throw new Error("chain is required");
}
if (!client) {
throw new Error("client is required");
}
return getWalletBalance({
chain: chainToLoad,
client,
address,
tokenAddress,
});
},
...queryOptions,
});
if (balanceQuery.isLoading) {
return loadingComponent || null;
}
if (!balanceQuery.data) {
return fallbackComponent || null;
}
const displayValue = formatFn
? formatFn(Number(balanceQuery.data.displayValue))
: balanceQuery.data.displayValue;
return (
<span {...restProps}>
{displayValue} {balanceQuery.data.symbol}
</span>
);
}
@@ -0,0 +1,12 @@
import { Blobbie, type BlobbieProps } from "../../ConnectWallet/Blobbie.js";
import { useAccountContext } from "./provider.js";
/**
* A wrapper for the Blobbie component
* @param props BlobbieProps
* @beta
*/
export function AccountBlobbie(props: Omit<BlobbieProps, "address">) {
const { address } = useAccountContext();
return <Blobbie {...props} address={address} />;
}
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { render, screen, waitFor } from "~test/react-render.js";
import { TEST_CLIENT } from "~test/test-clients.js";
import { AccountName } from "./name.js";
import { AccountProvider } from "./provider.js";
describe.runIf(process.env.TW_SECRET_KEY)("AccountName component", () => {
it("should return the correct social name", () => {
render(
<AccountProvider
address="0x12345674b599ce99958242b3D3741e7b01841DF3"
client={TEST_CLIENT}
>
<AccountName />
</AccountProvider>,
);
waitFor(() =>
expect(
screen.getByText("kien-ngo", {
exact: true,
selector: "span",
}),
).toBeInTheDocument(),
);
});
it("should return the correct FORMATTED social name", () => {
render(
<AccountProvider
address="0x12345674b599ce99958242b3D3741e7b01841DF3"
client={TEST_CLIENT}
>
<AccountName formatFn={(str: string) => `${str}-formatted`} />
</AccountProvider>,
);
waitFor(() =>
expect(
screen.getByText("kien-ngo-formatted", {
exact: true,
selector: "span",
}),
).toBeInTheDocument(),
);
});
it("should fallback properly when fail to resolve social name", () => {
render(
<AccountProvider address="invalid-wallet-address" client={TEST_CLIENT}>
<AccountName fallbackComponent={<span>oops</span>} />
</AccountProvider>,
);
waitFor(() =>
expect(
screen.getByText("oops", {
exact: true,
selector: "span",
}),
).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,175 @@
"use client";
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
import type React from "react";
import type { JSX } from "react";
import {
type ResolveNameOptions,
resolveName,
} from "../../../../../extensions/ens/resolve-name.js";
import { getSocialProfiles } from "../../../../../social/profiles.js";
import type { SocialProfile } from "../../../../../social/types.js";
import { useAccountContext } from "./provider.js";
/**
* Props for the AccountName component
* @component
* @account
*/
export interface AccountNameProps
extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children">,
Omit<ResolveNameOptions, "client" | "address"> {
/**
* A function used to transform (format) the name of the account.
* it should take in a string and output a string.
*
* This function is particularly useful
*/
formatFn?: (str: string) => string;
/**
* Use this prop to prioritize the social profile that you want to display
* This is useful for a wallet containing multiple social profiles
*/
socialType?: SocialProfile["type"];
/**
* This component will be shown while the name of the account is being fetched
* If not passed, the component will return `null`.
*
* You can pass a loading sign or spinner to this prop.
* @example
* ```tsx
* <AccountName loadingComponent={<Spinner />} />
* ```
*/
loadingComponent?: JSX.Element;
/**
* This component will be shown if the request for fetching the name is done but could not retreive any result.
* You can pass the wallet address as the fallback option if that's the case.
*
* If not passed, the component will return `null`
*
* @example
* ```tsx
* <AccountName fallbackComponent={"0x1234....3f3f"} />
* ```
*/
fallbackComponent?: JSX.Element;
/**
* Optional `useQuery` params
*/
queryOptions?: Omit<UseQueryOptions<string>, "queryKey" | "queryFn">;
}
/**
* This component is used to display the name of the account.
* A "name" in this context is the username, or account of the social profiles that the wallet may have.
* In case a name is not found or failed to resolve, you can always fallback to displaying the wallet address instead by using the `fallbackComponent` prop.
*
* This component inherits all attribute of a native HTML <span> element, so you can style it just like how you would style a <span>.
*
* @param props
* @returns A `<span>` containing the name of the account
* ```html
* <span>{name}</span>
* ```
*
* @example
* ### Basic usage
* ```tsx
* import { AccountProvider, AccountName } from "thirdweb/react";
*
* <AccountProvider address="0x1234...3f3f" client={client}>
* <AccountName />
* </AccountProvider>
* ```
*
* ### Show wallet address while social name is being loaded
* ```tsx
* <AccountName
* loadingComponent={<AccountAddress />}
* />
* ```
*
*
* ### Fallback to showing wallet address if fail to resolve social name
* ```tsx
* <AccountName
* fallbackComponent={<AccountAddress />}
* />
* ```
*
* ### Transform the account name using `formatFn` prop
* ```tsx
* import { isAddress, shortenAddress } from "thirdweb/utils";
* import { AccountProvider, AccountName } from "thirdweb/react";
*
* // Let's say we want the name to be capitalized without using CSS
* const formatName = (name: string) => name.toUpperCase();
*
* return <AccountName formatFn={formatName} />
* ```
*
*
* ### Custom query options for useQuery
* This component uses `@tanstack-query`'s useQuery internally.
* You can use the `queryOptions` prop for more fine-grained control
* ```tsx
* <AccountName
* queryOptions={{
* enabled: isEnabled,
* retry: 3
* }}
* />
* ```
*
* @component
* @account
* @beta
*/
export function AccountName({
resolverAddress,
resolverChain,
socialType,
formatFn,
queryOptions,
loadingComponent,
fallbackComponent,
...restProps
}: AccountNameProps) {
const { address, client } = useAccountContext();
const nameQuery = useQuery({
queryKey: ["account-name", address],
queryFn: async () => {
const [socialData, ensName] = await Promise.all([
getSocialProfiles({ address, client }),
resolveName({
client,
address,
resolverAddress,
resolverChain,
}),
]);
const name =
socialData?.filter(
(p) => p.name && (socialType ? p.type === socialType : true),
)[0]?.name || ensName;
if (!name) {
throw new Error("Failed to resolve account name");
}
return formatFn ? formatFn(name) : name;
},
...queryOptions,
});
if (nameQuery.isLoading) {
return loadingComponent || null;
}
if (!nameQuery.data) {
return fallbackComponent || null;
}
return <span {...restProps}>{nameQuery.data}</span>;
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "~test/react-render.js";
import { TEST_CLIENT } from "~test/test-clients.js";
import { AccountAddress } from "./address.js";
import { AccountProvider } from "./provider.js";
describe.runIf(process.env.TW_SECRET_KEY)("AccountProvider component", () => {
it("should render children correctly", () => {
render(
<AccountProvider
address="0x12345674b599ce99958242b3D3741e7b01841DF3"
client={TEST_CLIENT}
>
<div>Child Component</div>
</AccountProvider>,
);
expect(screen.getByText("Child Component")).toBeInTheDocument();
});
it("should pass the address correctly to the children props", () => {
render(
<AccountProvider
address="0x12345674b599ce99958242b3D3741e7b01841DF3"
client={TEST_CLIENT}
>
<AccountAddress />
</AccountProvider>,
);
expect(
screen.getByText("0x12345674b599ce99958242b3D3741e7b01841DF3", {
exact: true,
selector: "span",
}),
).toBeInTheDocument();
});
});
@@ -0,0 +1,69 @@
"use client";
import type { Address } from "abitype";
import type React from "react";
import { createContext, useContext } from "react";
import type { ThirdwebClient } from "../../../../../client/client.js";
/**
* Props for the <AccountProvider /> component
* @component
* @account
*/
export type AccountProviderProps = {
/**
* The user's wallet address
*/
address: Address;
/**
* thirdweb Client
*/
client: ThirdwebClient;
};
const AccountProviderContext = /* @__PURE__ */ createContext<
AccountProviderProps | undefined
>(undefined);
/**
* A React context provider component that supplies Account-related data to its child components.
*
* This component serves as a wrapper around the `AccountProviderContext.Provider` and passes
* the provided account data down to all of its child components through the context API.
*
* @example
* ```tsx
* import { AccountProvider, AccountAvatar, AccountName, AccountAddress } from "thirdweb/react";
*
* <AccountProvider>
* <AccountAvatar />
* <AccountName />
* <AccountAddress />
* </AccountProvider>
* ```
*
* @component
* @account
* @beta
*/
export function AccountProvider(
props: React.PropsWithChildren<AccountProviderProps>,
) {
return (
<AccountProviderContext.Provider value={props}>
{props.children}
</AccountProviderContext.Provider>
);
}
/**
* @internal
*/
export function useAccountContext() {
const ctx = useContext(AccountProviderContext);
if (!ctx) {
throw new Error(
"AccountProviderContext not found. Make sure you are using AccountName, AccountAvatar, etc. inside an <AccountProvider /> component",
);
}
return ctx;
}
@@ -60,6 +60,7 @@ export interface NFTDescriptionProps
*
* @component
* @nft
* @beta
*/
export function NFTDescription({
loadingComponent,
@@ -77,6 +77,7 @@ export type NFTMediaProps = Omit<
* <NFTMedia style={{ borderRadius: "8px" }} className="mx-auto" />
* ```
* @nft
* @beta
*/
export function NFTMedia({
loadingComponent,
@@ -58,6 +58,7 @@ export interface NFTNameProps
* ```
*
* @nft
* @beta
*/
export function NFTName({
loadingComponent,
@@ -43,7 +43,6 @@ export function useNFTContext() {
* the provided NFT data down to all of its child components through the context API.
*
*
* @component
* @param {React.PropsWithChildren<NFTProviderProps>} props - The props for the NFT provider
*
* @example
@@ -63,7 +62,9 @@ export function useNFTContext() {
* <NFTName />
* </NFTProvider>
* ```
* @component
* @nft
* @beta
*/
export function NFTProvider(props: React.PropsWithChildren<NFTProviderProps>) {
return (
+7 -1
View File
@@ -84,6 +84,10 @@
{
"tagName": "@nft",
"syntaxKind": "block"
},
{
"tagName": "@account",
"syntaxKind": "block"
}
],
"supportForTags": {
@@ -107,6 +111,8 @@
"@modules": true,
"@social": true,
"@client": true,
"@nft": true
"@nft": true,
"@account": true,
"@beta": true
}
}