diff --git a/.changeset/serious-bananas-boil.md b/.changeset/serious-bananas-boil.md new file mode 100644 index 00000000000..333184d2c6b --- /dev/null +++ b/.changeset/serious-bananas-boil.md @@ -0,0 +1,9 @@ +--- +"thirdweb": minor +--- + +Improve NFT Components +- Add custom resolver methods to NFTMedia, NFTName and NFTDescription +- Add caching for the NFT-info-getter method to improve performance +- Small fix to handle falsy values for NFT media src, name and description +- Improve test coverage by extracting internal logics and testing them \ No newline at end of file diff --git a/packages/thirdweb/src/exports/react.ts b/packages/thirdweb/src/exports/react.ts index 63c172b8762..b4bac2d22fc 100644 --- a/packages/thirdweb/src/exports/react.ts +++ b/packages/thirdweb/src/exports/react.ts @@ -189,6 +189,7 @@ export { export { NFTMedia, type NFTMediaProps, + type NFTMediaInfo, } from "../react/web/ui/prebuilt/NFT/media.js"; export { useConnectionManager } from "../react/core/providers/connection-manager.js"; diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/description.test.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/description.test.tsx new file mode 100644 index 00000000000..d31bb4460ad --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/description.test.tsx @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + DOODLES_CONTRACT, + DROP1155_CONTRACT, + UNISWAPV3_FACTORY_CONTRACT, +} from "~test/test-contracts.js"; +import { fetchNftDescription } from "./description.js"; + +describe.runIf(process.env.TW_SECRET_KEY)("NFTDescription", () => { + it("fetchNftDescription should work with ERC721", async () => { + const desc = await fetchNftDescription({ + contract: DOODLES_CONTRACT, + tokenId: 0n, + }); + expect(desc).toBe( + "A community-driven collectibles project featuring art by Burnt Toast. Doodles come in a joyful range of colors, traits and sizes with a collection size of 10,000. Each Doodle allows its owner to vote for experiences and activations paid for by the Doodles Community Treasury. Burnt Toast is the working alias for Scott Martin, a Canadian–based illustrator, designer, animator and muralist.", + ); + }); + + it("fetchNftDescription should work with ERC1155", async () => { + const desc = await fetchNftDescription({ + contract: DROP1155_CONTRACT, + tokenId: 0n, + }); + expect(desc).toBe(""); + }); + + it("fetchNftDescription should respect descriptionResolver as a string", async () => { + const desc = await fetchNftDescription({ + contract: DOODLES_CONTRACT, + tokenId: 0n, + descriptionResolver: "string", + }); + expect(desc).toBe("string"); + }); + + it("fetchNftDescription should respect descriptionResolver as a non-async function", async () => { + const desc = await fetchNftDescription({ + contract: DOODLES_CONTRACT, + tokenId: 0n, + descriptionResolver: () => "non-async", + }); + expect(desc).toBe("non-async"); + }); + + it("fetchNftDescription should respect descriptionResolver as a async function", async () => { + const desc = await fetchNftDescription({ + contract: DOODLES_CONTRACT, + tokenId: 0n, + descriptionResolver: async () => "async", + }); + expect(desc).toBe("async"); + }); + + it("fetchNftDescription should throw error if failed to resolve nft info", async () => { + await expect(() => + fetchNftDescription({ + contract: UNISWAPV3_FACTORY_CONTRACT, + tokenId: 0n, + }), + ).rejects.toThrowError("Failed to resolve NFT info"); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/description.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/description.tsx index be7e8f3b87e..7f2b245cfe0 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/description.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/description.tsx @@ -1,10 +1,10 @@ "use client"; -import type { UseQueryOptions } from "@tanstack/react-query"; +import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; import type { JSX } from "react"; -import type { NFT } from "../../../../../utils/nft/parseNft.js"; -import { useNftInfo } from "./hooks.js"; +import type { ThirdwebContract } from "../../../../../contract/contract.js"; import { useNFTContext } from "./provider.js"; +import { getNFTInfo } from "./utils.js"; export interface NFTDescriptionProps extends Omit, "children"> { @@ -13,7 +13,12 @@ export interface NFTDescriptionProps /** * Optional `useQuery` params */ - queryOptions?: Omit, "queryFn" | "queryKey">; + queryOptions?: Omit, "queryFn" | "queryKey">; + /** + * This prop can be a string or a (async) function that resolves to a string, representing the description of the NFT + * This is particularly useful if you already have a way to fetch the data. + */ + descriptionResolver?: string | (() => string) | (() => Promise); } /** @@ -58,6 +63,21 @@ export interface NFTDescriptionProps * * ``` * + * ### Override the description with the `descriptionResolver` prop + * If you already have the url, you can skip the network requests and pass it directly to the NFTDescription + * ```tsx + * + * ``` + * + * You can also pass in your own custom (async) function that retrieves the description + * ```tsx + * const getDescription = async () => { + * // ... + * return description; + * }; + * + * + * ``` * @component * @nft * @beta @@ -66,22 +86,61 @@ export function NFTDescription({ loadingComponent, fallbackComponent, queryOptions, + descriptionResolver, ...restProps }: NFTDescriptionProps) { const { contract, tokenId } = useNFTContext(); - const nftQuery = useNftInfo({ - contract, - tokenId, - queryOptions, + const descQuery = useQuery({ + queryKey: [ + "_internal_nft_description_", + contract.chain.id, + tokenId.toString(), + { + resolver: + typeof descriptionResolver === "string" + ? descriptionResolver + : typeof descriptionResolver === "function" + ? descriptionResolver.toString() + : undefined, + }, + ], + queryFn: async (): Promise => + fetchNftDescription({ descriptionResolver, contract, tokenId }), + ...queryOptions, }); - if (nftQuery.isLoading) { + if (descQuery.isLoading) { return loadingComponent || null; } - if (!nftQuery.data?.metadata?.description) { + if (!descQuery.data) { return fallbackComponent || null; } - return {nftQuery.data.metadata.description}; + return {descQuery.data}; +} + +/** + * @internal Exported for tests + */ +export async function fetchNftDescription(props: { + descriptionResolver?: string | (() => string) | (() => Promise); + contract: ThirdwebContract; + tokenId: bigint; +}): Promise { + const { descriptionResolver, contract, tokenId } = props; + if (typeof descriptionResolver === "string") { + return descriptionResolver; + } + if (typeof descriptionResolver === "function") { + return descriptionResolver(); + } + const nft = await getNFTInfo({ contract, tokenId }).catch(() => undefined); + if (!nft) { + throw new Error("Failed to resolve NFT info"); + } + if (typeof nft.metadata.description !== "string") { + throw new Error("Failed to resolve NFT description"); + } + return nft.metadata.description; } diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/hooks.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/hooks.tsx deleted file mode 100644 index 6d6766930c4..00000000000 --- a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/hooks.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; -import { getNFT as getNFT721 } from "../../../../../extensions/erc721/read/getNFT.js"; -import { getNFT as getNFT1155 } from "../../../../../extensions/erc1155/read/getNFT.js"; -import type { NFT } from "../../../../../utils/nft/parseNft.js"; -import type { NFTProviderProps } from "./provider.js"; - -/** - * @internal Only used for the NFT prebuilt components - */ -export function useNftInfo( - props: NFTProviderProps & { - queryOptions?: Omit, "queryFn" | "queryKey">; - }, -) { - return useQuery({ - queryKey: [ - "__nft_component_internal__", - props.contract.chain.id, - props.contract.address, - props.tokenId.toString(), - ], - queryFn: () => - getNFTInfo({ contract: props.contract, tokenId: props.tokenId }), - ...props.queryOptions, - }); -} - -/** - * @internal - */ -export async function getNFTInfo(options: NFTProviderProps): Promise { - const nft = await Promise.allSettled([ - getNFT721(options), - getNFT1155(options), - ]).then(([possibleNFT721, possibleNFT1155]) => { - // getNFT extension always return an NFT object - // so we need to check if the tokenURI exists - if ( - possibleNFT721.status === "fulfilled" && - possibleNFT721.value.tokenURI - ) { - return possibleNFT721.value; - } - if ( - possibleNFT1155.status === "fulfilled" && - possibleNFT1155.value.tokenURI - ) { - return possibleNFT1155.value; - } - throw new Error("Failed to load NFT metadata"); - }); - return nft; -} diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/media.test.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/media.test.tsx new file mode 100644 index 00000000000..b71766c7725 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/media.test.tsx @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + DOODLES_CONTRACT, + DROP1155_CONTRACT, + UNISWAPV3_FACTORY_CONTRACT, +} from "~test/test-contracts.js"; +import { fetchNftMedia } from "./media.js"; + +describe.runIf(process.env.TW_SECRET_KEY)("NFTMedia", () => { + it("fetchNftMedia should work with ERC721", async () => { + const desc = await fetchNftMedia({ + contract: DOODLES_CONTRACT, + tokenId: 0n, + }); + expect(desc).toStrictEqual({ + src: "ipfs://QmUEfFfwAh4wyB5UfHCVPUxis4j4Q4kJXtm5x5p3g1fVUn", + poster: undefined, + }); + }); + + it("fetchNftMedia should work with ERC1155", async () => { + const desc = await fetchNftMedia({ + contract: DROP1155_CONTRACT, + tokenId: 0n, + }); + expect(desc).toStrictEqual({ + src: "ipfs://QmeGCqV1mSHTZrvuFzW1XZdCRRGXB6AmSotTqHoxA2xfDo/1.mp4", + poster: "ipfs://QmeGCqV1mSHTZrvuFzW1XZdCRRGXB6AmSotTqHoxA2xfDo/0.png", + }); + }); + + it("fetchNftMedia should respect mediaResolver as a string", async () => { + const desc = await fetchNftMedia({ + contract: DOODLES_CONTRACT, + tokenId: 0n, + mediaResolver: { + src: "string", + poster: undefined, + }, + }); + expect(desc).toStrictEqual({ src: "string", poster: undefined }); + }); + + it("fetchNftMedia should respect mediaResolver as a non-async function", async () => { + const desc = await fetchNftMedia({ + contract: DOODLES_CONTRACT, + tokenId: 0n, + mediaResolver: () => ({ + src: "non-async", + poster: undefined, + }), + }); + expect(desc).toStrictEqual({ src: "non-async", poster: undefined }); + }); + + it("fetchNftMedia should respect mediaResolver as a async function", async () => { + const desc = await fetchNftMedia({ + contract: DOODLES_CONTRACT, + tokenId: 0n, + mediaResolver: async () => + await { + src: "async", + poster: undefined, + }, + }); + expect(desc).toStrictEqual({ src: "async", poster: undefined }); + }); + + it("fetchNftMedia should throw error if failed to resolve nft info", async () => { + await expect(() => + fetchNftMedia({ + contract: UNISWAPV3_FACTORY_CONTRACT, + tokenId: 0n, + }), + ).rejects.toThrowError("Failed to resolve NFT info"); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/media.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/media.tsx index cea3f294901..814de3b124c 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/media.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/media.tsx @@ -1,13 +1,25 @@ -import type { UseQueryOptions } from "@tanstack/react-query"; +import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; import type { JSX } from "react"; -import type { NFT } from "../../../../../utils/nft/parseNft.js"; +import type { ThirdwebContract } from "../../../../../contract/contract.js"; import { MediaRenderer } from "../../MediaRenderer/MediaRenderer.js"; import type { MediaRendererProps } from "../../MediaRenderer/types.js"; -import { useNftInfo } from "./hooks.js"; import { useNFTContext } from "./provider.js"; +import { getNFTInfo } from "./utils.js"; /** * @component + * @beta + * @wallet + */ +export type NFTMediaInfo = { + src: string; + poster: string | undefined; +}; + +/** + * @component + * @beta + * @wallet * The props for the component * It is similar to the [`MediaRendererProps`](https://portal.thirdweb.com/references/typescript/v5/MediaRendererProps) * (excluding `src`, `poster` and `client`) that you can @@ -22,7 +34,16 @@ export type NFTMediaProps = Omit< /** * Optional `useQuery` params */ - queryOptions?: Omit, "queryFn" | "queryKey">; + queryOptions?: Omit, "queryFn" | "queryKey">; + /** + * This prop can be a string or a (async) function that resolves to a string, representing the media url of the NFT + * This is particularly useful if you already have a way to fetch the image. + * In case of function, the function must resolve to an object of type `NFTMediaInfo` + */ + mediaResolver?: + | NFTMediaInfo + | (() => NFTMediaInfo) + | (() => Promise); }; /** @@ -76,6 +97,26 @@ export type NFTMediaProps = Omit< * ```tsx * * ``` + * + * ### Override the media with the `mediaResolver` prop + * If you already have the url, you can skip the network requests and pass it directly to the NFTMedia + * ```tsx + * + * ``` + * + * You can also pass in your own custom (async) function that retrieves the media url + * ```tsx + * const getMedia = async () => { + * const url = getNFTMedia(props); + * return url; + * }; + * + * + * ``` * @nft * @beta */ @@ -83,37 +124,82 @@ export function NFTMedia({ loadingComponent, fallbackComponent, queryOptions, + mediaResolver, ...mediaRendererProps }: NFTMediaProps) { const { contract, tokenId } = useNFTContext(); - const nftQuery = useNftInfo({ - contract, - tokenId, - queryOptions, + const mediaQuery = useQuery({ + queryKey: [ + "_internal_nft_media_", + contract.chain.id, + tokenId.toString(), + { + resolver: + typeof mediaResolver === "object" + ? mediaResolver + : typeof mediaResolver === "function" + ? mediaResolver.toString() + : undefined, + }, + ], + queryFn: async (): Promise => + fetchNftMedia({ mediaResolver, contract, tokenId }), + ...queryOptions, }); - if (nftQuery.isLoading) { + if (mediaQuery.isLoading) { return loadingComponent || null; } - if (!nftQuery.data) { - return fallbackComponent || null; - } - - const animation_url = nftQuery.data.metadata.animation_url; - const image = - nftQuery.data.metadata.image || nftQuery.data.metadata.image_url; - - if (!animation_url && !image) { + if (!mediaQuery.data) { return fallbackComponent || null; } return ( ); } + +/** + * @internal Exported for tests only + */ +export async function fetchNftMedia(props: { + mediaResolver?: + | NFTMediaInfo + | (() => NFTMediaInfo) + | (() => Promise); + contract: ThirdwebContract; + tokenId: bigint; +}): Promise<{ src: string; poster: string | undefined }> { + const { mediaResolver, contract, tokenId } = props; + if (typeof mediaResolver === "object") { + return mediaResolver; + } + if (typeof mediaResolver === "function") { + return mediaResolver(); + } + const nft = await getNFTInfo({ contract, tokenId }).catch(() => undefined); + if (!nft) { + throw new Error("Failed to resolve NFT info"); + } + const animation_url = nft.metadata.animation_url; + const image = nft.metadata.image || nft.metadata.image_url; + if (animation_url) { + return { + src: animation_url, + poster: image || undefined, + }; + } + if (image) { + return { + src: image, + poster: undefined, + }; + } + throw new Error("Failed to resolve NFT media"); +} diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/name.test.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/name.test.tsx new file mode 100644 index 00000000000..ebbd364c208 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/name.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + DOODLES_CONTRACT, + DROP1155_CONTRACT, + UNISWAPV3_FACTORY_CONTRACT, +} from "~test/test-contracts.js"; +import { fetchNftName } from "./name.js"; + +describe.runIf(process.env.TW_SECRET_KEY)("NFTName", () => { + it("fetchNftName should work with ERC721", async () => { + const desc = await fetchNftName({ + contract: DOODLES_CONTRACT, + tokenId: 0n, + }); + expect(desc).toBe("Doodle #0"); + }); + + it("fetchNftName should work with ERC1155", async () => { + const desc = await fetchNftName({ + contract: DROP1155_CONTRACT, + tokenId: 0n, + }); + expect(desc).toBe("Aura OG"); + }); + + it("fetchNftName should respect nameResolver as a string", async () => { + const desc = await fetchNftName({ + contract: DOODLES_CONTRACT, + tokenId: 0n, + nameResolver: "string", + }); + expect(desc).toBe("string"); + }); + + it("fetchNftName should respect nameResolver as a non-async function", async () => { + const desc = await fetchNftName({ + contract: DOODLES_CONTRACT, + tokenId: 0n, + nameResolver: () => "non-async", + }); + expect(desc).toBe("non-async"); + }); + + it("fetchNftName should respect nameResolver as a async function", async () => { + const desc = await fetchNftName({ + contract: DOODLES_CONTRACT, + tokenId: 0n, + nameResolver: async () => "async", + }); + expect(desc).toBe("async"); + }); + + it("fetchNftName should throw error if failed to resolve nft info", async () => { + await expect(() => + fetchNftName({ + contract: UNISWAPV3_FACTORY_CONTRACT, + tokenId: 0n, + }), + ).rejects.toThrowError("Failed to resolve NFT info"); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/name.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/name.tsx index 855b3230657..8d5da597a20 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/name.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/name.tsx @@ -1,8 +1,8 @@ -import type { UseQueryOptions } from "@tanstack/react-query"; +import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; import type { JSX } from "react"; -import type { NFT } from "../../../../../utils/nft/parseNft.js"; -import { useNftInfo } from "./hooks.js"; +import type { ThirdwebContract } from "../../../../../contract/contract.js"; import { useNFTContext } from "./provider.js"; +import { getNFTInfo } from "./utils.js"; export interface NFTNameProps extends Omit, "children"> { @@ -11,7 +11,12 @@ export interface NFTNameProps /** * Optional `useQuery` params */ - queryOptions?: Omit, "queryFn" | "queryKey">; + queryOptions?: Omit, "queryFn" | "queryKey">; + /** + * This prop can be a string or a (async) function that resolves to a string, representing the name of the NFT + * This is particularly useful if you already have a way to fetch the name of the NFT. + */ + nameResolver?: string | (() => string) | (() => Promise); } /** @@ -56,6 +61,22 @@ export interface NFTNameProps * * ``` * + * ### Override the name with the `nameResolver` prop + * If you already have the name, you can skip the network requests and pass it directly to the NFTName + * ```tsx + * + * ``` + * + * You can also pass in your own custom (async) function that retrieves the name + * ```tsx + * const getName = async () => { + * // ... + * return name; + * }; + * + * + * ``` + * * @nft * @component * @beta @@ -64,22 +85,61 @@ export function NFTName({ loadingComponent, fallbackComponent, queryOptions, + nameResolver, ...restProps }: NFTNameProps) { const { contract, tokenId } = useNFTContext(); - const nftQuery = useNftInfo({ - contract, - tokenId, - queryOptions, + const nameQuery = useQuery({ + queryKey: [ + "_internal_nft_name_", + contract.chain.id, + tokenId.toString(), + { + resolver: + typeof nameResolver === "string" + ? nameResolver + : typeof nameResolver === "function" + ? nameResolver.toString() + : undefined, + }, + ], + queryFn: async (): Promise => + fetchNftName({ nameResolver, contract, tokenId }), + ...queryOptions, }); - if (nftQuery.isLoading) { + if (nameQuery.isLoading) { return loadingComponent || null; } - if (!nftQuery.data?.metadata?.name) { + if (!nameQuery.data) { return fallbackComponent || null; } - return {nftQuery.data.metadata.name}; + return {nameQuery.data}; +} + +/** + * @internal Exported for tests + */ +export async function fetchNftName(props: { + nameResolver?: string | (() => string) | (() => Promise); + contract: ThirdwebContract; + tokenId: bigint; +}): Promise { + const { nameResolver, contract, tokenId } = props; + if (typeof nameResolver === "string") { + return nameResolver; + } + if (typeof nameResolver === "function") { + return nameResolver(); + } + const nft = await getNFTInfo({ contract, tokenId }).catch(() => undefined); + if (!nft) { + throw new Error("Failed to resolve NFT info"); + } + if (typeof nft.metadata.name !== "string") { + throw new Error("Failed to resolve NFT name"); + } + return nft.metadata.name; } diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/NFT.test.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/provider.test.tsx similarity index 55% rename from packages/thirdweb/src/react/web/ui/prebuilt/NFT/NFT.test.tsx rename to packages/thirdweb/src/react/web/ui/prebuilt/NFT/provider.test.tsx index c9db001af71..cbe7004a291 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/NFT.test.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/provider.test.tsx @@ -1,48 +1,13 @@ -import { useContext } from "react"; -import { describe, expect, it } from "vitest"; -import { render, screen, waitFor } from "~test/react-render.js"; +import { type FC, useContext } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { render, renderHook, screen, waitFor } from "~test/react-render.js"; import { DOODLES_CONTRACT } from "~test/test-contracts.js"; -import { getNFTInfo } from "./hooks.js"; +import { NFTDescription } from "./description.js"; import { NFTMedia } from "./media.js"; import { NFTName } from "./name.js"; -import { NFTProvider, NFTProviderContext } from "./provider.js"; - -describe.runIf(process.env.TW_SECRET_KEY)("NFT prebuilt component", () => { - it("should fetch the NFT metadata", async () => { - const nft = await getNFTInfo({ - contract: DOODLES_CONTRACT, - tokenId: 1n, - }); - expect(nft.metadata).toStrictEqual({ - attributes: [ - { - trait_type: "face", - value: "holographic beard", - }, - { - trait_type: "hair", - value: "white bucket cap", - }, - { - trait_type: "body", - value: "purple sweater with satchel", - }, - { - trait_type: "background", - value: "grey", - }, - { - trait_type: "head", - value: "gradient 2", - }, - ], - description: - "A community-driven collectibles project featuring art by Burnt Toast. Doodles come in a joyful range of colors, traits and sizes with a collection size of 10,000. Each Doodle allows its owner to vote for experiences and activations paid for by the Doodles Community Treasury. Burnt Toast is the working alias for Scott Martin, a Canadian–based illustrator, designer, animator and muralist.", - image: "ipfs://QmTDxnzcvj2p3xBrKcGv1wxoyhAn2yzCQnZZ9LmFjReuH9", - name: "Doodle #1", - }); - }); +import { NFTProvider, NFTProviderContext, useNFTContext } from "./provider.js"; +describe.runIf(process.env.TW_SECRET_KEY)("NFTProvider", () => { it("should render children correctly", () => { render( @@ -99,7 +64,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("NFT prebuilt component", () => { it("should render the NFT description", () => { render( - + , ); @@ -111,4 +76,31 @@ describe.runIf(process.env.TW_SECRET_KEY)("NFT prebuilt component", () => { ).toBeInTheDocument(), ); }); + + it("useNFTContext should return the context value when used within NFTProvider", () => { + const wrapper: FC = ({ children }: React.PropsWithChildren) => ( + + {children} + + ); + + const { result } = renderHook(() => useNFTContext(), { wrapper }); + + expect(result.current.contract).toStrictEqual(DOODLES_CONTRACT); + expect(result.current.tokenId).toBe(0n); + }); + + it("useNFTContext should throw an error when used outside of NFTProvider", () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + expect(() => { + renderHook(() => useNFTContext()); + }).toThrow( + "NFTProviderContext not found. Make sure you are using NFTMedia, NFTDescription, etc. inside a component", + ); + + consoleErrorSpy.mockRestore(); + }); }); diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/utils.test.ts b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/utils.test.ts new file mode 100644 index 00000000000..4cfb08762ed --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/utils.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { + DOODLES_CONTRACT, + DROP1155_CONTRACT, + UNISWAPV3_FACTORY_CONTRACT, +} from "~test/test-contracts.js"; +import { getNFTInfo } from "./utils.js"; + +describe.runIf(process.env.TW_SECRET_KEY)("getNFTInfo", () => { + it("should work with ERC721", async () => { + const nft = await getNFTInfo({ + contract: DOODLES_CONTRACT, + tokenId: 0n, + }); + expect(nft).toStrictEqual({ + id: 0n, + metadata: { + attributes: [ + { + trait_type: "face", + value: "mustache", + }, + { + trait_type: "hair", + value: "purple long", + }, + { + trait_type: "body", + value: "blue and yellow jacket", + }, + { + trait_type: "background", + value: "green", + }, + { + trait_type: "head", + value: "tan", + }, + ], + description: + "A community-driven collectibles project featuring art by Burnt Toast. Doodles come in a joyful range of colors, traits and sizes with a collection size of 10,000. Each Doodle allows its owner to vote for experiences and activations paid for by the Doodles Community Treasury. Burnt Toast is the working alias for Scott Martin, a Canadian–based illustrator, designer, animator and muralist.", + image: "ipfs://QmUEfFfwAh4wyB5UfHCVPUxis4j4Q4kJXtm5x5p3g1fVUn", + name: "Doodle #0", + }, + owner: null, + tokenURI: "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/0", + type: "ERC721", + }); + }); + + it("should work with ERC1155", async () => { + const nft = await getNFTInfo({ + contract: DROP1155_CONTRACT, + tokenId: 0n, + }); + expect(nft).toStrictEqual({ + id: 0n, + metadata: { + animation_url: + "ipfs://QmeGCqV1mSHTZrvuFzW1XZdCRRGXB6AmSotTqHoxA2xfDo/1.mp4", + attributes: [ + { + trait_type: "Revenue Share", + value: "40%", + }, + { + trait_type: "Max Supply", + value: "50", + }, + { + trait_type: "Max Per Wallet", + value: "1", + }, + ], + background_color: "", + description: "", + external_url: "https://auraexchange.org", + image: "ipfs://QmeGCqV1mSHTZrvuFzW1XZdCRRGXB6AmSotTqHoxA2xfDo/0.png", + name: "Aura OG", + }, + owner: null, + supply: 33n, + tokenURI: "ipfs://QmNgevzVNwJWJdErFY2B7KsuKdJz3gVuBraNKaSxPktLh5/0", + type: "ERC1155", + }); + }); + + it("should throw error if failed to load nft info", async () => { + await expect(() => + getNFTInfo({ contract: UNISWAPV3_FACTORY_CONTRACT, tokenId: 0n }), + ).rejects.toThrowError("Failed to load NFT metadata"); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/utils.ts b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/utils.ts new file mode 100644 index 00000000000..efb7efefe38 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/utils.ts @@ -0,0 +1,41 @@ +import {} from "@tanstack/react-query"; +import { getNFT as getNFT721 } from "../../../../../extensions/erc721/read/getNFT.js"; +import { getNFT as getNFT1155 } from "../../../../../extensions/erc1155/read/getNFT.js"; +import type { NFT } from "../../../../../utils/nft/parseNft.js"; +import { withCache } from "../../../../../utils/promise/withCache.js"; +import type { NFTProviderProps } from "./provider.js"; + +/** + * @internal + */ +export async function getNFTInfo(options: NFTProviderProps): Promise { + return withCache( + async () => { + const nft = await Promise.allSettled([ + getNFT721(options), + getNFT1155(options), + ]).then(([possibleNFT721, possibleNFT1155]) => { + // getNFT extension always return an NFT object + // so we need to check if the tokenURI exists + if ( + possibleNFT721.status === "fulfilled" && + possibleNFT721.value.tokenURI + ) { + return possibleNFT721.value; + } + if ( + possibleNFT1155.status === "fulfilled" && + possibleNFT1155.value.tokenURI + ) { + return possibleNFT1155.value; + } + throw new Error("Failed to load NFT metadata"); + }); + return nft; + }, + { + cacheKey: `nft_info:${options.contract.chain.id}:${options.contract.address}:${options.tokenId.toString()}`, + cacheTime: 15 * 60 * 1000, + }, + ); +}