Skip to content

Commit 5be197b

Browse files
committed
[SDK] Headless Wallet components | CNCT-2620 (#5691)
CNCT-2620 <!-- start pr-codex --> --- ## PR-Codex overview This PR introduces new headless components for wallet management in the `thirdweb` library, enhancing user interface options for wallet context, icons, and names. ### Detailed summary - Added `WalletProvider`, `WalletIcon`, and `WalletName` components. - Updated `ChainProvider` to mark it as `@beta`. - Introduced utility functions `getQueryKeys` for both chain and wallet components. - Updated documentation and tests for new wallet components. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent 34165cb commit 5be197b

File tree

12 files changed

+576
-14
lines changed

12 files changed

+576
-14
lines changed

.changeset/hip-houses-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 components for Wallets: WalletProvider, WalletIcon and WalletName

apps/portal/src/app/react/v5/components/onchain/page.mdx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,27 @@ Build your own UI and interact with onchain data using headless components.
136136
description="Component to display the name of a chain"
137137
/>
138138

139+
### Wallets
140+
141+
<ArticleIconCard
142+
title="WalletProvider"
143+
icon={ReactIcon}
144+
href="/references/typescript/v5/WalletProvider"
145+
description="Component to provide the Wallet context to your app"
146+
/>
147+
148+
<ArticleIconCard
149+
title="WalletIcon"
150+
icon={ReactIcon}
151+
href="/references/typescript/v5/WalletIcon"
152+
description="Component to display the icon of a wallet"
153+
/>
154+
155+
<ArticleIconCard
156+
title="WalletName"
157+
icon={ReactIcon}
158+
href="/references/typescript/v5/WalletName"
159+
description="Component to display the name of a wallet"
160+
/>
161+
139162
</Stack>

packages/thirdweb/src/exports/react.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,3 +273,17 @@ export {
273273

274274
// Utils
275275
export { getLastAuthProvider } from "../react/web/utils/storage.js";
276+
277+
// Wallet
278+
export {
279+
WalletProvider,
280+
type WalletProviderProps,
281+
} from "../react/web/ui/prebuilt/Wallet/provider.js";
282+
export {
283+
WalletIcon,
284+
type WalletIconProps,
285+
} from "../react/web/ui/prebuilt/Wallet/icon.js";
286+
export {
287+
WalletName,
288+
type WalletNameProps,
289+
} from "../react/web/ui/prebuilt/Wallet/name.js";

packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.test.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";
22
import { render, screen, waitFor } from "~test/react-render.js";
33
import { ethereum } from "../../../../../chains/chain-definitions/ethereum.js";
44
import { defineChain } from "../../../../../chains/utils.js";
5-
import { ChainName, fetchChainName } from "./name.js";
5+
import { getFunctionId } from "../../../../../utils/function-id.js";
6+
import { ChainName, fetchChainName, getQueryKeys } from "./name.js";
67
import { ChainProvider } from "./provider.js";
78

89
describe.runIf(process.env.TW_SECRET_KEY)("ChainName component", () => {
@@ -97,4 +98,21 @@ describe.runIf(process.env.TW_SECRET_KEY)("ChainName component", () => {
9798
});
9899
expect(res).toBe("eth_mainnet");
99100
});
101+
102+
it("getQueryKeys should work without nameResolver", () => {
103+
expect(getQueryKeys({ chainId: 1 })).toStrictEqual([
104+
"_internal_chain_name_",
105+
1,
106+
]);
107+
});
108+
109+
it("getQueryKeys should work WITH nameResolver", () => {
110+
const nameResolver = () => "tw";
111+
const fnId = getFunctionId(nameResolver);
112+
expect(getQueryKeys({ chainId: 1, nameResolver })).toStrictEqual([
113+
"_internal_chain_name_",
114+
1,
115+
{ resolver: fnId },
116+
]);
117+
});
100118
});

packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.tsx

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export interface ChainNameProps
4848
* name was not fetched succesfully
4949
* @example
5050
* ```tsx
51-
* <ChainName fallbackComponent={"Failed to load"}
51+
* <ChainName fallbackComponent={<span>Failed to load</span>}
5252
* />
5353
* ```
5454
*/
@@ -157,18 +157,7 @@ export function ChainName({
157157
}: ChainNameProps) {
158158
const { chain } = useChainContext();
159159
const nameQuery = useQuery({
160-
queryKey: [
161-
"_internal_chain_name_",
162-
chain.id,
163-
{
164-
resolver:
165-
typeof nameResolver === "string"
166-
? nameResolver
167-
: typeof nameResolver === "function"
168-
? getFunctionId(nameResolver)
169-
: undefined,
170-
},
171-
] as const,
160+
queryKey: getQueryKeys({ chainId: chain.id, nameResolver }),
172161
queryFn: async () => fetchChainName({ chain, nameResolver }),
173162
...queryOptions,
174163
});
@@ -205,3 +194,20 @@ export async function fetchChainName(props: {
205194
}
206195
return getChainMetadata(chain).then((data) => data.name);
207196
}
197+
198+
/**
199+
* @internal Exported for tests
200+
*/
201+
export function getQueryKeys(props: {
202+
chainId: number;
203+
nameResolver?: string | (() => string) | (() => Promise<string>);
204+
}) {
205+
if (typeof props.nameResolver === "function") {
206+
return [
207+
"_internal_chain_name_",
208+
props.chainId,
209+
{ resolver: getFunctionId(props.nameResolver) },
210+
] as const;
211+
}
212+
return ["_internal_chain_name_", props.chainId] as const;
213+
}

packages/thirdweb/src/react/web/ui/prebuilt/Chain/provider.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const ChainProviderContext = /* @__PURE__ */ createContext<
4848
* ```
4949
* @component
5050
* @chain
51+
* @beta
5152
*/
5253
export function ChainProvider(
5354
props: React.PropsWithChildren<ChainProviderProps>,
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, it } from "vitest";
2+
import { render, waitFor } from "~test/react-render.js";
3+
import { WalletIcon, fetchWalletImage } from "./icon.js";
4+
import { WalletProvider } from "./provider.js";
5+
6+
describe("WalletIcon", () => {
7+
it("should fetch wallet image", async () => {
8+
expect(await fetchWalletImage({ id: "io.metamask" })).toBe(
9+
"data:image/webp;base64,UklGRsgFAABXRUJQVlA4ILwFAAAwHgCdASqAAIAAPm0ylEWkIyIXTGzMQAbEoAzQSPDffG8qhx7LpfeDUfnDee/tP6gP2u9Yf0a/7L0/+pc3nivc0JlTXBvMVYW1Wt7JbdRJ1a5UhpH9+sdhAvGug20WzG+wRD+P/7/raQ1NqZ5EfcOwosQsP7NRVflPNArBLCbFJWE3EvTLNOmMfR2f+r+E2y6y1lWanc3n9RtF4lYbcW38t3UG/W2jXng4loXFOy3Vjm/r/1KHwXlpAuhAIzK/XF+ChayRFhBD+lbRrgQ3CSVjdmXw6agYPoMQ+tpjPUN/3qdGcvnN1sKWt57OGozQVSjMu0aA58MPKVWiAP77l9VfptKhZ7in3+r1DqZZQEYqO1p+MENah/WS/QQDvPuew8P0OXfahcjbCg7h4QEEEjDyfoFUbQHdw+meAsydryxVs/Ij+eB619Uj/sMgtwIifIyTieCT/hdsDmpLqGD+vOIArZfzSkordRkngojxVgvWRht84IaCHCs5Dn208UHFmyQE4KCxHef1iLYeAEPxnIAovGHIl7rCjiYE12obAM3ZCnt+RFqcT3q2rorCswc4/8f8TOhCSo63/dszpMkNQonGwtyhAFnm1EKqLPDcy99ggKQL95VYePoHcv8sHOG5zJ7lircX7VPpxkloNVWnlp/drJjQrp0h5BsOnqYn756+wcFw5qapAOnYCKUHujsoSz2BlLs1702rFpvi1iVczo2aO9GN1TaM3zBqMX0NN0YEU8pz/3+xwkK5M5q+Qb4FNJeugjdOp2kcYoOCbyAg/0OyGswYHPwDN/opVaDHWj0FcKzLq43uEjUZMG8t7O6BkoK0FmpYOeTQrinuF5F6W6ENly78daVmGPYTU22R9No5+xr8F2ESYTaJzOR3UoouY15xHrsxhDCukfebOiHljS6jUjF0TWaIAb58k57DZ3gdjkpxnJDyEmCsCOlASNWMn1ay3G8PDriQOdwsL7bgx/jZXN6XZHVx2hst/6Qcljnnn4um8eD1NcP1rTV7HMZzAb/d7ntki9zU1IFl695zPs3YT+h0PS4JuQYLzYoxpmMUQ35sVx/3KkL4Pm/TlvuNpnopM0+l2b/0OS6+5GwJYEMpzM4peYZS3qZkz/kxWNPTLv3UcCguDqpGxZ39l/VQJhSImKPZBa1RWCtSj29VCzCEZlX/eAdAKgZwhMKR4C5osznEbxjfRdHHPxjZwstCwugFa5w/WykOTFGAzk0sLSuvxO9kw/f++0WvG5La11GQaqbDDJ/ks7bcELqEGtvLHiJgxOXW9PqCk5Ap9EYelPTmdhHZgzWxtz4idN+JX+DGyYtJYjtW+Ay4kJ4Hol+Iavfje1f/22S69z1XSZ8OOcrDHKQdO1JlGaTkKQmCg5Tr05Qy9NQSfh67Vpui6OyMu38BnbG+cfWPRx/MjGQITY8w8sb1GUGd5hD9eVqycVtz8yFlYrXYQSBkAxMYgkBaiWe6kk24SoOCgt74nNs3pyTWUERw9ENESB6PyO9HjcsUZWIh97RKlf6thPtqxdlfr9i3McON9zvI7M2TtYcneXhOnqN8V4wqqII2J3DQ6/DZFNj5eNkCp2ijt7UWuKduhEmbZlLahfxD8eqBHAZR/H5rulzc4oVlyXr6qPXf9LCEuDRSDE8VNFY4NuTcaRTZO33RrWmWzbXAwpEKeH/Xf78XMoeynLBSyB+pS6y8Fqh7ExdmnvtW2gW3pwNrLc5lXlZJW8VcBzaSpy1Dmqj6Xll9BS8RyWqNx0O3fY8NJpf1exbNYMWA8juddzn2d9lHypEbiym0ASxI/jgGkbis5fecZ60QtJusJgdC8HAJEh64A9EuOCAnnISc5CElwpsvJTdEZhYv3t7MtLDvHp20lAtylt5l9yxqfCy/sqC6qZ/8+tsTAHzziGF6NKoaD0FVhZ2CER5AonJdBz5sg0rcD7arFe96uzujFDCQAAAA",
10+
);
11+
});
12+
13+
it("should throw error if WalletId is not supported", async () => {
14+
await expect(() =>
15+
// @ts-ignore For test
16+
fetchWalletImage({ id: "__undefined__" }),
17+
).rejects.toThrowError("Wallet with id __undefined__ not found");
18+
});
19+
20+
it("should render an image", async () => {
21+
const { container } = render(
22+
<WalletProvider id="io.cosmostation">
23+
<WalletIcon />
24+
</WalletProvider>,
25+
);
26+
await waitFor(() => {
27+
expect(container.querySelector("img")).not.toBe(null);
28+
});
29+
});
30+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"use client";
2+
3+
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
4+
import type { JSX } from "react";
5+
import { getWalletInfo } from "../../../../../wallets/__generated__/getWalletInfo.js";
6+
import type { WalletId } from "../../../../../wallets/wallet-types.js";
7+
import { useWalletContext } from "./provider.js";
8+
9+
export interface WalletIconProps
10+
extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src"> {
11+
/**
12+
* This component will be shown while the icon of the wallet is being fetched
13+
* If not passed, the component will return `null`.
14+
*
15+
* You can/should pass a loading sign or spinner to this prop.
16+
* @example
17+
* ```tsx
18+
* <WalletIcon loadingComponent={<Spinner />} />
19+
* ```
20+
*/
21+
loadingComponent?: JSX.Element;
22+
/**
23+
* This component will be shown if the icon fails to be retreived
24+
* If not passed, the component will return `null`.
25+
*
26+
* You can/should pass a descriptive text/component to this prop, indicating that the
27+
* icon was not fetched succesfully
28+
* @example
29+
* ```tsx
30+
* <WalletIcon fallbackComponent={<span>Failed to load</span>}
31+
* />
32+
* ```
33+
*/
34+
fallbackComponent?: JSX.Element;
35+
/**
36+
* Optional `useQuery` params
37+
*/
38+
queryOptions?: Omit<UseQueryOptions<string>, "queryFn" | "queryKey">;
39+
}
40+
41+
/**
42+
* This component tries to resolve the icon of a given wallet, then return an image.
43+
* @returns an <img /> with the src of the wallet icon
44+
*
45+
* @example
46+
* ### Basic usage
47+
* ```tsx
48+
* import { WalletProvider, WalletIcon } from "thirdweb/react";
49+
*
50+
* <WalletProvider id="io.metamask">
51+
* <WalletIcon />
52+
* </WalletProvider>
53+
* ```
54+
*
55+
* Result: An <img /> component with the src of the icon
56+
* ```html
57+
* <img src="metamask-icon.png" />
58+
* ```
59+
*
60+
* ### Show a loading sign while the icon is being loaded
61+
* ```tsx
62+
* <WalletIcon loadingComponent={<Spinner />} />
63+
* ```
64+
*
65+
* ### Fallback to a dummy image if the wallet icon fails to resolve
66+
* ```tsx
67+
* <WalletIcon fallbackComponent={<img src="blank-image.png" />} />
68+
* ```
69+
*
70+
* ### Usage with queryOptions
71+
* WalletIcon uses useQuery() from tanstack query internally.
72+
* It allows you to pass a custom queryOptions of your choice for more control of the internal fetching logic
73+
* ```tsx
74+
* <WalletIcon queryOptions={{ enabled: someLogic, retry: 3, }} />
75+
* ```
76+
*
77+
* @component
78+
* @wallet
79+
* @beta
80+
*/
81+
export function WalletIcon({
82+
loadingComponent,
83+
fallbackComponent,
84+
queryOptions,
85+
...restProps
86+
}: WalletIconProps) {
87+
const imageQuery = useWalletIcon({ queryOptions });
88+
if (imageQuery.isLoading) {
89+
return loadingComponent || null;
90+
}
91+
if (!imageQuery.data) {
92+
return fallbackComponent || null;
93+
}
94+
return <img src={imageQuery.data} {...restProps} alt={restProps.alt} />;
95+
}
96+
97+
/**
98+
* @internal
99+
*/
100+
function useWalletIcon(props: {
101+
queryOptions?: Omit<UseQueryOptions<string>, "queryFn" | "queryKey">;
102+
}) {
103+
const { id } = useWalletContext();
104+
const imageQuery = useQuery({
105+
queryKey: ["walletIcon", id],
106+
queryFn: async () => fetchWalletImage({ id }),
107+
...props.queryOptions,
108+
});
109+
return imageQuery;
110+
}
111+
112+
/**
113+
* @internal Exported for tests only
114+
*/
115+
export async function fetchWalletImage(props: {
116+
id: WalletId;
117+
}) {
118+
const image_src = await getWalletInfo(props.id, true);
119+
return image_src;
120+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, expect, it } from "vitest";
2+
import { render, waitFor } from "~test/react-render.js";
3+
import { getFunctionId } from "../../../../../utils/function-id.js";
4+
import { WalletName, fetchWalletName, getQueryKeys } from "./name.js";
5+
import { WalletProvider } from "./provider.js";
6+
7+
describe.runIf(process.env.TW_SECRET_KEY)("WalletName", () => {
8+
it("fetchWalletName: should fetch wallet name from id", async () => {
9+
const name = await fetchWalletName({ id: "io.metamask" });
10+
expect(name).toBe("MetaMask");
11+
});
12+
13+
it("fetchWalletName should throw error if failed to get name", async () => {
14+
// @ts-ignore for test
15+
await expect(() => fetchWalletName({ id: "test___" })).rejects.toThrowError(
16+
"Wallet with id test___ not found",
17+
);
18+
});
19+
20+
it("fetchWalletName should work with formatFn", async () => {
21+
const formatFn = (str: string) => `${str} Wallet`;
22+
expect(await fetchWalletName({ id: "io.metamask", formatFn })).toBe(
23+
"MetaMask Wallet",
24+
);
25+
});
26+
27+
it("getQueryKeys should work without a formatFn", () => {
28+
expect(getQueryKeys({ id: "ai.hacken" })).toStrictEqual([
29+
"walletName",
30+
"ai.hacken",
31+
]);
32+
});
33+
34+
it("getQueryKeys should work WITH a formatFn", () => {
35+
const fn = (str: string) => `test:${str}`;
36+
const fnId = getFunctionId(fn);
37+
expect(getQueryKeys({ id: "ai.hacken", formatFn: fn })).toStrictEqual([
38+
"walletName",
39+
"ai.hacken",
40+
{ resolver: fnId },
41+
]);
42+
});
43+
44+
it("should render a span", async () => {
45+
const { container } = render(
46+
<WalletProvider id="io.metamask">
47+
<WalletName />
48+
</WalletProvider>,
49+
);
50+
51+
await waitFor(() => {
52+
expect(container.querySelector("span")).not.toBe(null);
53+
});
54+
});
55+
});

0 commit comments

Comments
 (0)