From 7973d6450c41d1d7919c00b4f439d6ad728f95f1 Mon Sep 17 00:00:00 2001 From: kien-ngo Date: Wed, 20 Nov 2024 06:40:01 +0000 Subject: [PATCH] [React] Feature: Headless UI Token components (#5433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem solved Short description of the bug fixed or feature added --- ## PR-Codex overview This PR introduces new components related to token management in a React application, including `TokenProvider`, `TokenName`, `TokenSymbol`, and `TokenIcon`. These components allow users to fetch and display token-related data such as name, symbol, and icon. ### Detailed summary - Added `Token` section in the sidebar with links to token components. - Created `TokenProvider` for managing token data context. - Implemented `TokenName`, `TokenSymbol`, and `TokenIcon` components for fetching and displaying token information. - Exported new token-related types and components from `react.ts`. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- apps/portal/src/app/react/v5/sidebar.tsx | 14 ++ packages/thirdweb/src/exports/react.ts | 18 ++ .../src/react/web/ui/prebuilt/Token/icon.tsx | 164 ++++++++++++++ .../src/react/web/ui/prebuilt/Token/name.tsx | 201 ++++++++++++++++++ .../react/web/ui/prebuilt/Token/provider.tsx | 86 ++++++++ .../react/web/ui/prebuilt/Token/symbol.tsx | 201 ++++++++++++++++++ 6 files changed, 684 insertions(+) create mode 100644 packages/thirdweb/src/react/web/ui/prebuilt/Token/icon.tsx create mode 100644 packages/thirdweb/src/react/web/ui/prebuilt/Token/name.tsx create mode 100644 packages/thirdweb/src/react/web/ui/prebuilt/Token/provider.tsx create mode 100644 packages/thirdweb/src/react/web/ui/prebuilt/Token/symbol.tsx diff --git a/apps/portal/src/app/react/v5/sidebar.tsx b/apps/portal/src/app/react/v5/sidebar.tsx index e5ac08463fc..9499171acf3 100644 --- a/apps/portal/src/app/react/v5/sidebar.tsx +++ b/apps/portal/src/app/react/v5/sidebar.tsx @@ -358,6 +358,20 @@ export const sidebar: SideBar = { icon: , })), }, + { + name: "Token", + isCollapsible: true, + links: [ + "TokenProvider", + "TokenName", + "TokenSymbol", + "TokenIcon", + ].map((name) => ({ + name, + href: `${slug}/${name}`, + icon: , + })), + }, ], }, { diff --git a/packages/thirdweb/src/exports/react.ts b/packages/thirdweb/src/exports/react.ts index 2dab3c98dca..c41928f6f42 100644 --- a/packages/thirdweb/src/exports/react.ts +++ b/packages/thirdweb/src/exports/react.ts @@ -235,3 +235,21 @@ export { AccountAvatar, type AccountAvatarProps, } from "../react/web/ui/prebuilt/Account/avatar.js"; + +// Token +export { + TokenProvider, + type TokenProviderProps, +} from "../react/web/ui/prebuilt/Token/provider.js"; +export { + TokenName, + type TokenNameProps, +} from "../react/web/ui/prebuilt/Token/name.js"; +export { + TokenSymbol, + type TokenSymbolProps, +} from "../react/web/ui/prebuilt/Token/symbol.js"; +export { + TokenIcon, + type TokenIconProps, +} from "../react/web/ui/prebuilt/Token/icon.js"; diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Token/icon.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Token/icon.tsx new file mode 100644 index 00000000000..70094abe3ce --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Token/icon.tsx @@ -0,0 +1,164 @@ +import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; +import type { JSX } from "react"; +import { getChainMetadata } from "../../../../../chains/utils.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; +import { getContract } from "../../../../../contract/contract.js"; +import { getContractMetadata } from "../../../../../extensions/common/read/getContractMetadata.js"; +import { resolveScheme } from "../../../../../utils/ipfs.js"; +import { useTokenContext } from "./provider.js"; + +export interface TokenIconProps + extends Omit, "src"> { + /** + * This prop can be a string or a (async) function that resolves to a string, representing the icon url of the token + * This is particularly useful if you already have a way to fetch the token icon. + */ + iconResolver?: string | (() => string) | (() => Promise); + /** + * This component will be shown while the avatar of the icon is being fetched + * If not passed, the component will return `null`. + * + * You can pass a loading sign or spinner to this prop. + * @example + * ```tsx + * } /> + * ``` + */ + 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 + * } /> + * ``` + */ + fallbackComponent?: JSX.Element; + + /** + * Optional query options for `useQuery` + */ + queryOptions?: Omit, "queryFn" | "queryKey">; +} + +/** + * This component tries to resolve the icon of a given token, then return an image. + * @returns an with the src of the token icon + * + * @example + * ### Basic usage + * ```tsx + * import { TokenProvider, TokenIcon } from "thirdweb/react"; + * + * + * + * + * ``` + * + * Result: An component with the src of the icon + * ```html + * + * ``` + * + * ### Override the icon with the `iconResolver` prop + * If you already have the icon url, you can skip the network requests and pass it directly to the TokenIcon + * ```tsx + * + * ``` + * + * You can also pass in your own custom (async) function that retrieves the icon url + * ```tsx + * const getIcon = async () => { + * const icon = getIconFromCoinMarketCap(tokenAddress, etc); + * return icon; + * }; + * + * + * ``` + * + * ### Show a loading sign while the icon is being loaded + * ```tsx + * } /> + * ``` + * + * ### Fallback to a dummy image if the token icon fails to resolve + * ```tsx + * } /> + * ``` + * + * ### Usage with queryOptions + * TokenIcon 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 + * + * ``` + * + * @component + * @token + * @beta + */ +export function TokenIcon({ + iconResolver, + loadingComponent, + fallbackComponent, + queryOptions, + ...restProps +}: TokenIconProps) { + const { address, client, chain } = useTokenContext(); + const iconQuery = useQuery({ + queryKey: ["_internal_token_icon_", chain.id, address] as const, + queryFn: async () => { + if (typeof iconResolver === "string") { + return iconResolver; + } + if (typeof iconResolver === "function") { + return iconResolver(); + } + if (address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase()) { + const possibleUrl = await getChainMetadata(chain).then( + (data) => data.icon?.url, + ); + if (!possibleUrl) { + throw new Error("Failed to resolve icon for native token"); + } + return resolveScheme({ uri: possibleUrl, client }); + } + + // Try to get the icon from the contractURI + const contractMetadata = await getContractMetadata({ + contract: getContract({ + address, + chain, + client, + }), + }); + + if ( + !contractMetadata.image || + typeof contractMetadata.image !== "string" + ) { + throw new Error("Failed to resolve token icon from contract metadata"); + } + + return resolveScheme({ + uri: contractMetadata.image, + client, + }); + }, + ...queryOptions, + }); + + if (iconQuery.isLoading) { + return loadingComponent || null; + } + + if (!iconQuery.data) { + return fallbackComponent || null; + } + + return {restProps.alt}; +} diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Token/name.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Token/name.tsx new file mode 100644 index 00000000000..806fcba3537 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Token/name.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; +import type React from "react"; +import type { JSX } from "react"; +import { getChainMetadata } from "../../../../../chains/utils.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; +import { getContract } from "../../../../../contract/contract.js"; +import { getContractMetadata } from "../../../../../extensions/common/read/getContractMetadata.js"; +import { name } from "../../../../../extensions/common/read/name.js"; +import { useTokenContext } from "./provider.js"; + +/** + * Props for the TokenName component + * @component + * @token + */ +export interface TokenNameProps + extends Omit, "children"> { + /** + * This prop can be a string or a (async) function that resolves to a string, representing the name of the token + * This is particularly useful if you already have a way to fetch the token name. + */ + nameResolver?: string | (() => string) | (() => Promise); + /** + * A function to format the name's display value + * Particularly useful to avoid overflowing-UI issues + * + * ```tsx + * doSomething()} /> + * ``` + */ + formatFn?: (str: string) => string; + /** + * This component will be shown while the name of the token 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 + * } /> + * ``` + */ + loadingComponent?: JSX.Element; + /** + * This component will be shown if the name 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 + * name was not fetched succesfully + * @example + * ```tsx + * + * ``` + */ + fallbackComponent?: JSX.Element; + /** + * Optional `useQuery` params + */ + queryOptions?: Omit, "queryFn" | "queryKey">; +} + +/** + * This component fetches then shows the name of a token. For ERC20 tokens, it calls the `name` function in the ERC20 contract. + * It inherits all the attributes of a HTML component, hence you can style it just like how you would style a normal + * + * + * @example + * ### Basic usage + * ```tsx + * import { TokenProvider, TokenName } from "thirdweb/react"; + * import { ethereum } from "thirdweb/chains"; + * + * + * + * + * ``` + * Result: + * ```html + * Ether + * ``` + * + * ### Custom name resolver + * By default TokenName will call the `name` method of the token contract. + * However if you have a different way to fetch the name, you can pass the function to the `nameResolver` prop. + * Note: nameResolver should either be a string or a function (async) that returns a string. + * ```tsx + * async function fetchNameMethod() { + * // your own fetching logic + * return "the token name"; + * } + * + * + * ``` + * + * Alternatively you can also pass in a string directly: + * ```tsx + * + * ``` + * + * + * ### Format the name (capitalize, truncate, etc.) + * The TokenName component accepts a `formatFn` which takes in a string and outputs a string + * The function is used to modify the name of the token + * + * ```tsx + * const concatStr = (str: string):string => str + "Token" + * + * + * ``` + * + * Result: + * ```html + * Ether Token + * ``` + * + * ### Show a loading sign when the name is being fetched + * ```tsx + * import { TokenProvider, TokenName } from "thirdweb/react"; + * + * + * } /> + * + * ``` + * + * ### Fallback to something when the name fails to resolve + * ```tsx + * + * + * + * ``` + * + * ### 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 + * + * ``` + * + * @component + * @token + * @beta + */ +export function TokenName({ + nameResolver, + formatFn, + loadingComponent, + fallbackComponent, + queryOptions, + ...restProps +}: TokenNameProps) { + const { address, client, chain } = useTokenContext(); + const nameQuery = useQuery({ + queryKey: ["_internal_token_name_", chain.id, address] as const, + queryFn: async () => { + if (typeof nameResolver === "string") { + return nameResolver; + } + if (typeof nameResolver === "function") { + return nameResolver(); + } + if (address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase()) { + // Don't wanna use `getChainNativeCurrencyName` because it has some side effect (it catches error and defaults to "ETH") + return getChainMetadata(chain).then((data) => data.nativeCurrency.name); + } + // Try to fetch the name from both the `name()` function and the contract metadata + // then prioritize the `name()` + const contract = getContract({ address, client, chain }); + const [_name, contractMetadata] = await Promise.all([ + name({ contract }), + getContractMetadata({ contract }), + ]); + if (!_name && !contractMetadata.name) { + throw new Error( + "Failed to resolve name from both name() and contract metadata", + ); + } + + return _name || contractMetadata.name; + }, + ...queryOptions, + }); + + if (nameQuery.isLoading) { + return loadingComponent || null; + } + + if (!nameQuery.data) { + return fallbackComponent || null; + } + + const displayValue = formatFn ? formatFn(nameQuery.data) : nameQuery.data; + + return {displayValue}; +} diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Token/provider.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Token/provider.tsx new file mode 100644 index 00000000000..15eedafbf93 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Token/provider.tsx @@ -0,0 +1,86 @@ +"use client"; + +import type { Address } from "abitype"; +import type React from "react"; +import { createContext, useContext } from "react"; +import type { Chain } from "../../../../../chains/types.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; + +/** + * Props for the component + * @component + * @token + */ +export type TokenProviderProps = { + /** + * The token (ERC20) contract address + */ + address: Address; + /** + * thirdweb Client + */ + client: ThirdwebClient; + /** + * The chain (network) that the token is on + */ + chain: Chain; +}; + +const TokenProviderContext = /* @__PURE__ */ createContext< + TokenProviderProps | undefined +>(undefined); + +/** + * A React context provider component that supplies Token-related data to its child components. + * + * This component serves as a wrapper around the `TokenProviderContext.Provider` and passes + * the provided token data down to all of its child components through the context API. + * + * @example + * ### Basic usage + * ```tsx + * import { TokenProvider, TokenIcon, TokenName } from "thirdweb/react"; + * import { ethereum } from "thirdweb/chains"; + * + * + * + * + * + * ``` + * + * ### This component also works with native token! + * ```tsx + * import { NATIVE_TOKEN_ADDRESS} from "thirdweb"; + * import { ethereum } from "thirdweb/chains"; + * + * + * // "ETH" + * + * ``` + * + * @component + * @token + * @beta + */ +export function TokenProvider( + props: React.PropsWithChildren, +) { + return ( + + {props.children} + + ); +} + +/** + * @internal + */ +export function useTokenContext() { + const ctx = useContext(TokenProviderContext); + if (!ctx) { + throw new Error( + "TokenProviderContext not found. Make sure you are using TokenName, TokenIcon, TokenSymbol etc. inside a component", + ); + } + return ctx; +} diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Token/symbol.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Token/symbol.tsx new file mode 100644 index 00000000000..f770a600401 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Token/symbol.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; +import type React from "react"; +import type { JSX } from "react"; +import { getChainMetadata } from "../../../../../chains/utils.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; +import { getContract } from "../../../../../contract/contract.js"; +import { getContractMetadata } from "../../../../../extensions/common/read/getContractMetadata.js"; +import { symbol } from "../../../../../extensions/common/read/symbol.js"; +import { useTokenContext } from "./provider.js"; + +/** + * Props for the TokenSymbol component + * @component + * @token + */ +export interface TokenSymbolProps + extends Omit, "children"> { + /** + * This prop can be a string or a (async) function that resolves to a string, representing the symbol of the token + * This is particularly useful if you already have a way to fetch the token symbol. + */ + symbolResolver?: string | (() => string) | (() => Promise); + /** + * A function to format the symbol's value + * Particularly useful to avoid overflowing-UI issues + * + * ```tsx + * doSomething()} /> + * ``` + */ + formatFn?: (str: string) => string; + /** + * This component will be shown while the symbol of the token 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 + * } /> + * ``` + */ + loadingComponent?: JSX.Element; + /** + * This component will be shown if the symbol 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 + * symbol was not fetched succesfully + * @example + * ```tsx + * + * ``` + */ + fallbackComponent?: JSX.Element; + /** + * Optional `useQuery` params + */ + queryOptions?: Omit, "queryFn" | "queryKey">; +} + +/** + * This component fetches then shows the symbol of a token. For ERC20 tokens, it calls the `symbol` function in the ERC20 contract. + * It inherits all the attributes of a HTML component, hence you can style it just like how you would style a normal + * + * + * @example + * ### Basic usage + * ```tsx + * import { TokenProvider, TokenSymbol } from "thirdweb/react"; + * import { ethereum } from "thirdweb/chains"; + * + * + * + * + * ``` + * Result: + * ```html + * ETH + * ``` + * + * ### Custom symbol resolver + * By default, TokenSymbol calls the `symbol` function of your contract, + * however, if your token as an unconventional way to fetch the symbol, you can pass the custom logic to the `symbolResolver` prop. + * It can either be a string or a function (async) that returns or resolves to a string. + * ```tsx + * async function getSymbol() { + * // your own fetching logic + * return "the symbol"; + * } + * + * + * ``` + * Alternatively, you can pass in a string directly: + * ```tsx + * + * ``` + * + * ### Format the symbol (capitalize, truncate, etc.) + * The TokenSymbol component accepts a `formatFn` which takes in a string and outputs a string + * The function is used to modify the symbol of the token + * + * ```tsx + * const concatStr = (str: string):string => str + "Token" + * + * + * ``` + * + * Result: + * ```html + * Ether Token + * ``` + * + * ### Show a loading sign when the symbol is being fetched + * ```tsx + * import { TokenProvider, TokenSymbol } from "thirdweb/react"; + * + * + * } /> + * + * ``` + * + * ### Fallback to something when the symbol fails to resolve + * ```tsx + * + * + * + * ``` + * + * ### 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 + * + * ``` + * + * @component + * @token + * @beta + */ +export function TokenSymbol({ + symbolResolver, + formatFn, + loadingComponent, + fallbackComponent, + queryOptions, + ...restProps +}: TokenSymbolProps) { + const { address, client, chain } = useTokenContext(); + const symbolQuery = useQuery({ + queryKey: ["_internal_token_symbol_", chain.id, address] as const, + queryFn: async () => { + if (typeof symbolResolver === "string") { + return symbolResolver; + } + if (typeof symbolResolver === "function") { + return symbolResolver(); + } + if (address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase()) { + // Don't wanna use `getChainSymbol` because it has some side effect (it catches error and defaults to "ETH") + return getChainMetadata(chain).then( + (data) => data.nativeCurrency.symbol, + ); + } + + // Try to fetch the symbol from both the `symbol` function and the contract metadata + // then prioritize the `symbol()` + const contract = getContract({ address, client, chain }); + const [_symbol, contractMetadata] = await Promise.all([ + symbol({ contract }), + getContractMetadata({ contract }), + ]); + if (!_symbol && !contractMetadata.symbol) { + throw new Error( + "Failed to resolve symbol from both symbol() and contract metadata", + ); + } + + return _symbol || contractMetadata.symbol; + }, + ...queryOptions, + }); + + if (symbolQuery.isLoading) { + return loadingComponent || null; + } + + if (!symbolQuery.data) { + return fallbackComponent || null; + } + + const displayValue = formatFn ? formatFn(symbolQuery.data) : symbolQuery.data; + + return {displayValue}; +}