Skip to content

Commit

Permalink
[SDK] Headless Wallet components | CNCT-2620 (#5691)
Browse files Browse the repository at this point in the history
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 -->
  • Loading branch information
kien-ngo committed Dec 14, 2024
1 parent 34165cb commit 5be197b
Show file tree
Hide file tree
Showing 12 changed files with 576 additions and 14 deletions.
5 changes: 5 additions & 0 deletions .changeset/hip-houses-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"thirdweb": minor
---

Add headless components for Wallets: WalletProvider, WalletIcon and WalletName
23 changes: 23 additions & 0 deletions apps/portal/src/app/react/v5/components/onchain/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,27 @@ Build your own UI and interact with onchain data using headless components.
description="Component to display the name of a chain"
/>

### Wallets

<ArticleIconCard
title="WalletProvider"
icon={ReactIcon}
href="/references/typescript/v5/WalletProvider"
description="Component to provide the Wallet context to your app"
/>

<ArticleIconCard
title="WalletIcon"
icon={ReactIcon}
href="/references/typescript/v5/WalletIcon"
description="Component to display the icon of a wallet"
/>

<ArticleIconCard
title="WalletName"
icon={ReactIcon}
href="/references/typescript/v5/WalletName"
description="Component to display the name of a wallet"
/>

</Stack>
14 changes: 14 additions & 0 deletions packages/thirdweb/src/exports/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,3 +273,17 @@ export {

// Utils
export { getLastAuthProvider } from "../react/web/utils/storage.js";

// Wallet
export {
WalletProvider,
type WalletProviderProps,
} from "../react/web/ui/prebuilt/Wallet/provider.js";
export {
WalletIcon,
type WalletIconProps,
} from "../react/web/ui/prebuilt/Wallet/icon.js";
export {
WalletName,
type WalletNameProps,
} from "../react/web/ui/prebuilt/Wallet/name.js";
20 changes: 19 additions & 1 deletion packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";
import { render, screen, waitFor } from "~test/react-render.js";
import { ethereum } from "../../../../../chains/chain-definitions/ethereum.js";
import { defineChain } from "../../../../../chains/utils.js";
import { ChainName, fetchChainName } from "./name.js";
import { getFunctionId } from "../../../../../utils/function-id.js";
import { ChainName, fetchChainName, getQueryKeys } from "./name.js";
import { ChainProvider } from "./provider.js";

describe.runIf(process.env.TW_SECRET_KEY)("ChainName component", () => {
Expand Down Expand Up @@ -97,4 +98,21 @@ describe.runIf(process.env.TW_SECRET_KEY)("ChainName component", () => {
});
expect(res).toBe("eth_mainnet");
});

it("getQueryKeys should work without nameResolver", () => {
expect(getQueryKeys({ chainId: 1 })).toStrictEqual([
"_internal_chain_name_",
1,
]);
});

it("getQueryKeys should work WITH nameResolver", () => {
const nameResolver = () => "tw";
const fnId = getFunctionId(nameResolver);
expect(getQueryKeys({ chainId: 1, nameResolver })).toStrictEqual([
"_internal_chain_name_",
1,
{ resolver: fnId },
]);
});
});
32 changes: 19 additions & 13 deletions packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export interface ChainNameProps
* name was not fetched succesfully
* @example
* ```tsx
* <ChainName fallbackComponent={"Failed to load"}
* <ChainName fallbackComponent={<span>Failed to load</span>}
* />
* ```
*/
Expand Down Expand Up @@ -157,18 +157,7 @@ export function ChainName({
}: ChainNameProps) {
const { chain } = useChainContext();
const nameQuery = useQuery({
queryKey: [
"_internal_chain_name_",
chain.id,
{
resolver:
typeof nameResolver === "string"
? nameResolver
: typeof nameResolver === "function"
? getFunctionId(nameResolver)
: undefined,
},
] as const,
queryKey: getQueryKeys({ chainId: chain.id, nameResolver }),
queryFn: async () => fetchChainName({ chain, nameResolver }),
...queryOptions,
});
Expand Down Expand Up @@ -205,3 +194,20 @@ export async function fetchChainName(props: {
}
return getChainMetadata(chain).then((data) => data.name);
}

/**
* @internal Exported for tests
*/
export function getQueryKeys(props: {
chainId: number;
nameResolver?: string | (() => string) | (() => Promise<string>);
}) {
if (typeof props.nameResolver === "function") {
return [
"_internal_chain_name_",
props.chainId,
{ resolver: getFunctionId(props.nameResolver) },
] as const;
}
return ["_internal_chain_name_", props.chainId] as const;
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const ChainProviderContext = /* @__PURE__ */ createContext<
* ```
* @component
* @chain
* @beta
*/
export function ChainProvider(
props: React.PropsWithChildren<ChainProviderProps>,
Expand Down
30 changes: 30 additions & 0 deletions packages/thirdweb/src/react/web/ui/prebuilt/Wallet/icon.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { render, waitFor } from "~test/react-render.js";
import { WalletIcon, fetchWalletImage } from "./icon.js";
import { WalletProvider } from "./provider.js";

describe("WalletIcon", () => {
it("should fetch wallet image", async () => {
expect(await fetchWalletImage({ id: "io.metamask" })).toBe(
"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",
);
});

it("should throw error if WalletId is not supported", async () => {
await expect(() =>
// @ts-ignore For test
fetchWalletImage({ id: "__undefined__" }),
).rejects.toThrowError("Wallet with id __undefined__ not found");
});

it("should render an image", async () => {
const { container } = render(
<WalletProvider id="io.cosmostation">
<WalletIcon />
</WalletProvider>,
);
await waitFor(() => {
expect(container.querySelector("img")).not.toBe(null);
});
});
});
120 changes: 120 additions & 0 deletions packages/thirdweb/src/react/web/ui/prebuilt/Wallet/icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"use client";

import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
import type { JSX } from "react";
import { getWalletInfo } from "../../../../../wallets/__generated__/getWalletInfo.js";
import type { WalletId } from "../../../../../wallets/wallet-types.js";
import { useWalletContext } from "./provider.js";

export interface WalletIconProps
extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src"> {
/**
* This component will be shown while the icon of the wallet 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
* <WalletIcon loadingComponent={<Spinner />} />
* ```
*/
loadingComponent?: JSX.Element;
/**
* This component will be shown if the icon 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
* icon was not fetched succesfully
* @example
* ```tsx
* <WalletIcon fallbackComponent={<span>Failed to load</span>}
* />
* ```
*/
fallbackComponent?: JSX.Element;
/**
* Optional `useQuery` params
*/
queryOptions?: Omit<UseQueryOptions<string>, "queryFn" | "queryKey">;
}

/**
* This component tries to resolve the icon of a given wallet, then return an image.
* @returns an <img /> with the src of the wallet icon
*
* @example
* ### Basic usage
* ```tsx
* import { WalletProvider, WalletIcon } from "thirdweb/react";
*
* <WalletProvider id="io.metamask">
* <WalletIcon />
* </WalletProvider>
* ```
*
* Result: An <img /> component with the src of the icon
* ```html
* <img src="metamask-icon.png" />
* ```
*
* ### Show a loading sign while the icon is being loaded
* ```tsx
* <WalletIcon loadingComponent={<Spinner />} />
* ```
*
* ### Fallback to a dummy image if the wallet icon fails to resolve
* ```tsx
* <WalletIcon fallbackComponent={<img src="blank-image.png" />} />
* ```
*
* ### Usage with queryOptions
* WalletIcon uses useQuery() from tanstack query internally.
* It allows you to pass a custom queryOptions of your choice for more control of the internal fetching logic
* ```tsx
* <WalletIcon queryOptions={{ enabled: someLogic, retry: 3, }} />
* ```
*
* @component
* @wallet
* @beta
*/
export function WalletIcon({
loadingComponent,
fallbackComponent,
queryOptions,
...restProps
}: WalletIconProps) {
const imageQuery = useWalletIcon({ queryOptions });
if (imageQuery.isLoading) {
return loadingComponent || null;
}
if (!imageQuery.data) {
return fallbackComponent || null;

Check warning on line 92 in packages/thirdweb/src/react/web/ui/prebuilt/Wallet/icon.tsx

View check run for this annotation

Codecov / codecov/patch

packages/thirdweb/src/react/web/ui/prebuilt/Wallet/icon.tsx#L92

Added line #L92 was not covered by tests
}
return <img src={imageQuery.data} {...restProps} alt={restProps.alt} />;
}

/**
* @internal
*/
function useWalletIcon(props: {
queryOptions?: Omit<UseQueryOptions<string>, "queryFn" | "queryKey">;
}) {
const { id } = useWalletContext();
const imageQuery = useQuery({
queryKey: ["walletIcon", id],
queryFn: async () => fetchWalletImage({ id }),
...props.queryOptions,
});
return imageQuery;
}

/**
* @internal Exported for tests only
*/
export async function fetchWalletImage(props: {
id: WalletId;
}) {
const image_src = await getWalletInfo(props.id, true);
return image_src;
}
55 changes: 55 additions & 0 deletions packages/thirdweb/src/react/web/ui/prebuilt/Wallet/name.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { render, waitFor } from "~test/react-render.js";
import { getFunctionId } from "../../../../../utils/function-id.js";
import { WalletName, fetchWalletName, getQueryKeys } from "./name.js";
import { WalletProvider } from "./provider.js";

describe.runIf(process.env.TW_SECRET_KEY)("WalletName", () => {
it("fetchWalletName: should fetch wallet name from id", async () => {
const name = await fetchWalletName({ id: "io.metamask" });
expect(name).toBe("MetaMask");
});

it("fetchWalletName should throw error if failed to get name", async () => {
// @ts-ignore for test
await expect(() => fetchWalletName({ id: "test___" })).rejects.toThrowError(
"Wallet with id test___ not found",
);
});

it("fetchWalletName should work with formatFn", async () => {
const formatFn = (str: string) => `${str} Wallet`;
expect(await fetchWalletName({ id: "io.metamask", formatFn })).toBe(
"MetaMask Wallet",
);
});

it("getQueryKeys should work without a formatFn", () => {
expect(getQueryKeys({ id: "ai.hacken" })).toStrictEqual([
"walletName",
"ai.hacken",
]);
});

it("getQueryKeys should work WITH a formatFn", () => {
const fn = (str: string) => `test:${str}`;
const fnId = getFunctionId(fn);
expect(getQueryKeys({ id: "ai.hacken", formatFn: fn })).toStrictEqual([
"walletName",
"ai.hacken",
{ resolver: fnId },
]);
});

it("should render a span", async () => {
const { container } = render(
<WalletProvider id="io.metamask">
<WalletName />
</WalletProvider>,
);

await waitFor(() => {
expect(container.querySelector("span")).not.toBe(null);
});
});
});
Loading

0 comments on commit 5be197b

Please sign in to comment.