diff --git a/__tests__/components/nft-image/utils/animation-source.test.ts b/__tests__/components/nft-image/utils/animation-source.test.ts index 499f2f26be..f5f4292ab8 100644 --- a/__tests__/components/nft-image/utils/animation-source.test.ts +++ b/__tests__/components/nft-image/utils/animation-source.test.ts @@ -88,6 +88,33 @@ describe("getResolvedAnimationSrc", () => { ); }); + it("uses metadata animation fields for HTML, not top-level animation", () => { + const nft = createMockNFT({ + animation: "https://example.com/processed-or-proxy.html", + metadata: { + animation_details: { format: "html" }, + animation_url: "https://example.com/original.html", + }, + }); + + expect(getResolvedAnimationSrc(nft)).toBe( + "https://example.com/original.html" + ); + }); + + it("returns undefined for HTML format when metadata animation fields are empty", () => { + const nft = createMockNFT({ + animation: "https://example.com/processed-or-proxy.html", + metadata: { + animation_details: { format: "html" }, + animation: "", + animation_url: undefined, + }, + }); + + expect(getResolvedAnimationSrc(nft)).toBeUndefined(); + }); + it("returns undefined when every candidate is empty or invalid", () => { const nft = createMockNFT({ animation: " ", diff --git a/components/nft-image/NFTModel.tsx b/components/nft-image/NFTModel.tsx index 64d38af947..16ff434445 100644 --- a/components/nft-image/NFTModel.tsx +++ b/components/nft-image/NFTModel.tsx @@ -1,13 +1,48 @@ import "@google/model-viewer"; +import { useEffect, useMemo, useRef } from "react"; +import type { SyntheticEvent } from "react"; import type { BaseNFT } from "@/entities/INFT"; import { getResolvedAnimationSrc } from "./utils/animation-source"; +import { withArweaveFallback } from "./utils/arweave-fallback"; + +type ModelViewerElement = HTMLElement & { + src: string; +}; export default function NFTModel( props: Readonly<{ nft: BaseNFT; id?: string | undefined }> ) { + const modelRef = useRef(null); + const handleArweaveError = useMemo(() => withArweaveFallback(), []); + + useEffect(() => { + const modelElement = modelRef.current; + if (!modelElement) { + return; + } + + const nativeErrorHandler = (event: Event) => { + const currentTarget = event.currentTarget as ModelViewerElement | null; + if (!currentTarget) { + return; + } + + handleArweaveError({ + currentTarget, + } as SyntheticEvent); + }; + + modelElement.addEventListener("error", nativeErrorHandler); + + return () => { + modelElement.removeEventListener("error", nativeErrorHandler); + }; + }, [handleArweaveError]); + return ( // @ts-ignore ) { const src = getSrc(props.nft); const animationClassName = styles["nftAnimation"] ?? ""; + const urls = useMemo( + () => (src ? getArweaveGatewayFallbackUrls(src) : []), + [src] + ); + const [activeIndex, setActiveIndex] = useState(0); + const [didLoadCurrentUrl, setDidLoadCurrentUrl] = useState(false); + const activeUrl = urls[activeIndex]; + + useEffect(() => { + if (urls.length === 0) { + setActiveIndex(0); + setDidLoadCurrentUrl(false); + return; + } + setActiveIndex(0); + setDidLoadCurrentUrl(false); + }, [urls]); + + useEffect(() => { + setDidLoadCurrentUrl(false); + }, [activeUrl]); + + useEffect(() => { + if (!activeUrl || didLoadCurrentUrl || activeIndex + 1 >= urls.length) { + return; + } + + const timeoutId = globalThis.setTimeout(() => { + setActiveIndex((current) => + current === activeIndex && current + 1 < urls.length + ? current + 1 + : current + ); + }, IFRAME_FALLBACK_TIMEOUT_MS); + + return () => { + globalThis.clearTimeout(timeoutId); + }; + }, [activeIndex, activeUrl, didLoadCurrentUrl, urls.length]); + + const advanceToNextUrl = () => { + setActiveIndex((current) => + current + 1 < urls.length ? current + 1 : current + ); + }; return ( ) { height={props.height} /> )} -