Skip to content

Commit 2822f61

Browse files
committed
update
1 parent b30177c commit 2822f61

File tree

11 files changed

+826
-4
lines changed

11 files changed

+826
-4
lines changed

.changeset/metal-cows-hear.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"thirdweb": minor
3+
---
4+
5+
Add headless UI component: Account (Name, Image, Address, Balance)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"use client";
2+
3+
import { Button } from "@/components/ui/button";
4+
import { THIRDWEB_CLIENT } from "@/lib/client";
5+
import { sepolia } from "thirdweb/chains";
6+
import { Account, ThirdwebProvider } from "thirdweb/react";
7+
import { shortenAddress } from "thirdweb/utils";
8+
9+
export default function Page() {
10+
return (
11+
<ThirdwebProvider>
12+
<Account.Provider
13+
address="0x12345674b599ce99958242b3D3741e7b01841DF3"
14+
client={THIRDWEB_CLIENT}
15+
>
16+
<div className="flex h-full w-full flex-col items-center gap-3">
17+
<div className="my-3 flex flex-col gap-3 border p-5">
18+
<div>
19+
Let&apos;s rebuild a basic ConnectButton-details component
20+
</div>
21+
<BasicConnectedButton />
22+
</div>
23+
</div>
24+
</Account.Provider>
25+
</ThirdwebProvider>
26+
);
27+
}
28+
29+
const BasicConnectedButton = () => {
30+
const roundUpBalance = (num: number) => Math.round(num * 10) / 10;
31+
return (
32+
<Button className="flex h-12 w-40 flex-row justify-start gap-3 px-2">
33+
<Account.Avatar
34+
className="h-10 w-10 rounded-full"
35+
loadingComponent={
36+
<Account.Blobbie className="h-10 w-10 rounded-full" />
37+
}
38+
fallbackComponent={
39+
<Account.Blobbie className="h-10 w-10 rounded-full" />
40+
}
41+
/>
42+
<div className="flex flex-col items-start">
43+
<Account.Name
44+
loadingComponent={<Account.Address formatFn={shortenAddress} />}
45+
fallbackComponent={<Account.Address formatFn={shortenAddress} />}
46+
/>
47+
<Account.Balance
48+
chain={sepolia}
49+
formatFn={roundUpBalance}
50+
loadingComponent={"Loading..."}
51+
fallbackComponent={"N/A"}
52+
/>
53+
</div>
54+
</Button>
55+
);
56+
};

packages/thirdweb/src/exports/react.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,6 @@ export type {
199199
// Site Embed and Linking
200200
export { SiteEmbed } from "../react/web/ui/SiteEmbed.js";
201201
export { SiteLink } from "../react/web/ui/SiteLink.js";
202+
203+
// Account
204+
export * as Account from "../react/web/ui/prebuilt/Account/index.js";

packages/thirdweb/src/react/web/ui/ConnectWallet/Blobbie.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,27 @@ const COLOR_OPTIONS = [
2121
["#fda4af", "#be123c"],
2222
];
2323

24+
/**
25+
* Props for the Blobbie component
26+
* @component
27+
*/
28+
export type BlobbieProps = {
29+
address: Address;
30+
style?: Omit<React.CSSProperties, "backgroundImage">;
31+
className?: string;
32+
size?: number;
33+
};
34+
2435
/**
2536
* A unique gradient avatar based on the provided address.
2637
* @param props The component props.
2738
* @param props.address The address to generate the gradient with.
28-
* @param props.size The size of each side of the square avatar (in pixels)
39+
* @param props.style The CSS style for the component - excluding `backgroundImage`
40+
* @param props.className The className for the component
41+
* @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.
42+
* @component
2943
*/
30-
export function Blobbie(props: { address: Address; size: number }) {
44+
export function Blobbie(props: BlobbieProps) {
3145
const id = useId();
3246
const colors = useMemo(
3347
() =>
@@ -41,10 +55,16 @@ export function Blobbie(props: { address: Address; size: number }) {
4155
<div
4256
id={id}
4357
style={{
44-
width: `${props.size}px`,
45-
height: `${props.size}px`,
58+
...props.style,
4659
backgroundImage: `radial-gradient(ellipse at left bottom, ${colors[0]}, ${colors[1]})`,
60+
...(props.size
61+
? {
62+
width: `${props.size}px`,
63+
height: `${props.size}px`,
64+
}
65+
: undefined),
4766
}}
67+
className={props.className}
4868
/>
4969
);
5070
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"use client";
2+
3+
import { useAccountContext } from "./provider.js";
4+
5+
/**
6+
* @component
7+
* @account
8+
*/
9+
export interface AccountAddressProps
10+
extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
11+
/**
12+
* The function used to transform (format) the wallet address
13+
* Specifically useful for shortening the wallet.
14+
*
15+
* This function should take in a string and output a string
16+
*/
17+
formatFn?: (str: string) => string;
18+
}
19+
20+
/**
21+
*
22+
* @returns a <span> containing the full wallet address of the account
23+
*
24+
* @example
25+
* ### Basic usage
26+
* ```tsx
27+
* import { Account } from "thirdweb/react";
28+
*
29+
* <Account address="0x12345674b599ce99958242b3D3741e7b01841DF3" client={TW_CLIENT}>
30+
* <Account.Address />
31+
* </Account>
32+
* ```
33+
* Result:
34+
* ```html
35+
* <span>0x12345674b599ce99958242b3D3741e7b01841DF3</span>
36+
* ```
37+
*
38+
*
39+
* ### Shorten the address
40+
* ```tsx
41+
* import { Account } from "thirdweb/react";
42+
* import { shortenAddress } from "thirdweb/utils";
43+
*
44+
* <Account address="0x12345674b599ce99958242b3D3741e7b01841DF3" client={TW_CLIENT}>
45+
* <Account.Address formatFn={shortenAddress} />
46+
* </Account>
47+
* ```
48+
* Result:
49+
* ```html
50+
* <span>0x1234...1DF3</span>
51+
* ```
52+
*
53+
* @component
54+
* @account
55+
*/
56+
export const Address = ({ formatFn, ...restProps }: AccountAddressProps) => {
57+
const { address } = useAccountContext();
58+
const value = formatFn ? formatFn(address) : address;
59+
return <span {...restProps}>{value}</span>;
60+
};
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
"use client";
2+
3+
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
4+
import type React from "react";
5+
import type { JSX } from "react";
6+
import { resolveAvatar } from "../../../../../extensions/ens/resolve-avatar.js";
7+
import {
8+
type ResolveNameOptions,
9+
resolveName,
10+
} from "../../../../../extensions/ens/resolve-name.js";
11+
import { getSocialProfiles } from "../../../../../social/profiles.js";
12+
import type { SocialProfile } from "../../../../../social/types.js";
13+
import { parseAvatarRecord } from "../../../../../utils/ens/avatar.js";
14+
import { useAccountContext } from "./provider.js";
15+
16+
/**
17+
* Props for the Account.Avatar component
18+
* @component
19+
* @account
20+
*/
21+
export interface AccountAvatar
22+
extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src">,
23+
Omit<ResolveNameOptions, "client" | "address"> {
24+
/**
25+
* Use this prop to prioritize the social profile that you want to display
26+
* This is useful for a wallet containing multiple social profiles.
27+
* This component inherits all attributes of a HTML's <img />, so you can interact with it just like a normal <img />
28+
*
29+
* @example
30+
* If you have ENS, Lens and Farcaster profiles linked to your wallet
31+
* you can prioritize showing the image for Lens by:
32+
* ```tsx
33+
* <Account.Avatar
34+
* socialType="lens" // Choose between: "farcaster" | "lens" | "ens"
35+
* />
36+
* ```
37+
*/
38+
socialType?: SocialProfile["type"];
39+
40+
/**
41+
* This component will be shown while the avatar of the account is being fetched
42+
* If not passed, the component will return `null`.
43+
*
44+
* You can pass a loading sign or spinner to this prop.
45+
* @example
46+
* ```tsx
47+
* <Account.Avatar loadingComponent={<Spinner />} />
48+
* ```
49+
*/
50+
loadingComponent?: JSX.Element;
51+
/**
52+
* This component will be shown if the request for fetching the avatar is done
53+
* but could not retreive any result.
54+
* You can pass a dummy avatar/image to this prop.
55+
*
56+
* If not passed, the component will return `null`
57+
*
58+
* @example
59+
* ```tsx
60+
* <Account.Avatar fallbackComponent={<DummyImage />} />
61+
* ```
62+
*/
63+
fallbackComponent?: JSX.Element;
64+
65+
/**
66+
* Optional query options for `useQuery`
67+
*/
68+
queryOptions?: Omit<UseQueryOptions<string>, "queryFn" | "queryKey">;
69+
}
70+
71+
/**
72+
* The component for showing the avatar of the account.
73+
* If fetches all the social profiles linked to your wallet, including: Farcaster, ENS, Lens (more ot be added)
74+
* You can choose which social profile you want to display. Defaults to the first item in the list.
75+
*
76+
* @example
77+
* ### Basic usage
78+
* ```tsx
79+
* import { Account } from "thirdweb/react";
80+
*
81+
* <Account.Provider address="0x...">
82+
* <Account.Avatar />
83+
* </Account>
84+
* ```
85+
* Result: An <img /> component, if the avatar is resolved successfully
86+
* ```html
87+
* <img alt="" src="resolved-url-for-the-avatar" />
88+
* ```
89+
*
90+
* ### Show a loading sign when the avatar is being resolved
91+
* ```tsx
92+
* import { Account } from "thirdweb/react";
93+
*
94+
* <Account.Provider address="0x...">
95+
* <Account.Avatar
96+
* loadingComponent={<YourLoadingComponent />}
97+
* />
98+
* </Account>
99+
* ```
100+
*
101+
* ### Fallback to something when the avatar fails to resolve
102+
* ```tsx
103+
* import { Account } from "thirdweb/react";
104+
*
105+
* <Account.Provider address="0x...">
106+
* <Account.Avatar
107+
* fallbackComponent={<DummyImage />}
108+
* />
109+
* </Account>
110+
* ```
111+
*
112+
* ### Select a social profile to display
113+
* If you wallet associates with more than one social profiles (Lens, Farcaster, ENS, etc.)
114+
* You can specify which service you want to prioritize using the `socialType` props
115+
* ```tsx
116+
* import { Account } from "thirdweb/react";
117+
*
118+
* <Account.Provider address="0x...">
119+
* <Account.Avatar
120+
* // Choose between: "farcaster" | "lens" | "ens"
121+
* socialType={"ens"}
122+
* />
123+
* </Account>
124+
* ```
125+
*
126+
* ### Custom ENS resolver chain
127+
* This component shares the same props with the ENS extension `resolveAvatar`
128+
* ```tsx
129+
* import { Account } from "thirdweb/react";
130+
* import { base } from "thirdweb/chains";
131+
*
132+
* <Account.Provider address="0x...">
133+
* <Account.Avatar
134+
* resolverAddress={"0x..."}
135+
* resolverChain={base}
136+
* />
137+
* </Account>
138+
* ```
139+
*
140+
* ### Custom query options for useQuery
141+
* This component uses `@tanstack-query`'s useQuery internally.
142+
* You can use the `queryOptions` prop for more fine-grained control
143+
* ```tsx
144+
* <Account.Avatar queryOption={{
145+
* enabled: isEnabled,
146+
* retry: 3,
147+
* }}
148+
* />
149+
* ```
150+
* @returns An <img /> if the avatar is resolved successfully
151+
* @component
152+
* @account
153+
*/
154+
export const Avatar = ({
155+
socialType,
156+
resolverAddress,
157+
resolverChain,
158+
loadingComponent,
159+
fallbackComponent,
160+
queryOptions,
161+
...restProps
162+
}: AccountAvatar) => {
163+
const { address, client } = useAccountContext();
164+
const avatarQuery = useQuery({
165+
queryKey: ["account-avatar", address],
166+
queryFn: async (): Promise<string> => {
167+
const [socialData, ensName] = await Promise.all([
168+
getSocialProfiles({ address, client }),
169+
resolveName({
170+
client,
171+
address: address || "",
172+
resolverAddress,
173+
resolverChain,
174+
}),
175+
]);
176+
177+
const uri = socialData?.filter(
178+
(p) => p.avatar && (socialType ? p.type === socialType : true),
179+
)[0]?.avatar;
180+
181+
const [resolvedSocialAvatar, resolvedENSAvatar] = await Promise.all([
182+
uri ? parseAvatarRecord({ client, uri }) : undefined,
183+
ensName
184+
? resolveAvatar({
185+
client,
186+
name: ensName,
187+
})
188+
: undefined,
189+
]);
190+
191+
// If no social image + ens name found -> exit and show <Blobbie />
192+
if (!resolvedSocialAvatar && !resolvedENSAvatar) {
193+
throw new Error("Failed to resolve social + ens avatar");
194+
}
195+
196+
// else, prioritize the social image first
197+
if (resolvedSocialAvatar) {
198+
return resolvedSocialAvatar;
199+
}
200+
201+
if (resolvedENSAvatar) {
202+
return resolvedENSAvatar;
203+
}
204+
205+
throw new Error("Failed to resolve social + ens avatar");
206+
},
207+
...queryOptions,
208+
});
209+
210+
if (avatarQuery.isLoading) {
211+
return loadingComponent || null;
212+
}
213+
214+
if (!avatarQuery.data) {
215+
return fallbackComponent || null;
216+
}
217+
218+
return <img src={avatarQuery.data} {...restProps} alt={restProps.alt} />;
219+
};

0 commit comments

Comments
 (0)