explorer: Added an Error Image Placeholder and resolved a fetching issue for NFT assets (#20608)

* Fixed an issue where NFT assets were continually fetched if we failed to get the asset from a fetch.
Cleaned up/removed un-used NFT asset code.
Added an error image when we failed to fetch the asset.

* Corrected some fetching logic and added a error placeholder timeout since onError/onLoad are never fired for <img /> if the src is undefined
This commit is contained in:
Will Roeder 2021-10-12 16:19:59 -07:00 committed by GitHub
parent 7631011d8c
commit e6776effb9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 115 additions and 97 deletions

View File

@ -18,7 +18,7 @@ export function NFTHeader({
return (
<div className="row align-items-begin">
<div className="col-auto ml-2">
<ArtContent metadata={metadata} pubkey={address} preview={false} />
<ArtContent metadata={metadata} pubkey={address} />
</div>
<div className="col mb-3 ml-n3 ml-md-n2 mt-3">

View File

@ -0,0 +1,15 @@
<svg width="96" height="84" viewBox="0 0 96 84" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.368 64.052C17.669 63.7209 18.0359 63.4563 18.445 63.2752C18.8541 63.094 19.2966 63.0003 19.744 63L93.056 63.06C93.3682 63.0606 93.6735 63.1519 93.9349 63.3228C94.1962 63.4936 94.4023 63.7367 94.5281 64.0224C94.6539 64.3081 94.694 64.6242 94.6436 64.9323C94.5932 65.2404 94.4544 65.5273 94.244 65.758L78.632 82.948C78.3308 83.2793 77.9637 83.5441 77.5542 83.7252C77.1447 83.9064 76.7018 84 76.254 84L2.94405 83.94C2.63185 83.9394 2.32654 83.8481 2.06523 83.6772C1.80391 83.5064 1.59783 83.2634 1.47202 82.9776C1.3462 82.6919 1.30607 82.3758 1.35649 82.0677C1.40691 81.7596 1.54572 81.4727 1.75605 81.242L17.368 64.052ZM94.244 49.742C94.4544 49.9727 94.5932 50.2596 94.6436 50.5677C94.694 50.8758 94.6539 51.1919 94.5281 51.4776C94.4023 51.7634 94.1962 52.0064 93.9349 52.1772C93.6735 52.3481 93.3682 52.4394 93.056 52.44L19.746 52.5C19.2983 52.5 18.8554 52.4064 18.4459 52.2252C18.0364 52.0441 17.6693 51.7793 17.368 51.448L1.75605 34.248C1.54572 34.0173 1.40691 33.7304 1.35649 33.4223C1.30607 33.1142 1.3462 32.7981 1.47202 32.5124C1.59783 32.2266 1.80391 31.9836 2.06523 31.8128C2.32654 31.6419 2.63185 31.5506 2.94405 31.55L76.256 31.49C76.7035 31.4903 77.146 31.584 77.5551 31.7652C77.9642 31.9463 78.3311 32.2109 78.632 32.542L94.244 49.742ZM17.368 1.052C17.669 0.720916 18.0359 0.456328 18.445 0.275176C18.8541 0.0940234 19.2966 0.000298083 19.744 0L93.056 0.06C93.3682 0.0606347 93.6735 0.151917 93.9349 0.322758C94.1962 0.493599 94.4023 0.736647 94.5281 1.02238C94.6539 1.30811 94.694 1.62423 94.6436 1.93234C94.5932 2.24044 94.4544 2.52728 94.244 2.758L78.632 19.948C78.3308 20.2793 77.9637 20.5441 77.5542 20.7252C77.1447 20.9064 76.7018 21 76.254 21L2.94405 20.94C2.63185 20.9394 2.32654 20.8481 2.06523 20.6772C1.80391 20.5064 1.59783 20.2634 1.47202 19.9776C1.3462 19.6919 1.30607 19.3758 1.35649 19.0677C1.40691 18.7596 1.54572 18.4727 1.75605 18.242L17.368 1.052Z" fill="url(#paint0_linear)"/>
</g>
<defs>
<linearGradient id="paint0_linear" x1="4.16805" y1="85.832" x2="91.8321" y2="-1.832" gradientUnits="userSpaceOnUse">
<stop stop-color="#9945FF"/>
<stop offset="0.2" stop-color="#7962E7"/>
<stop offset="1" stop-color="#00D18C"/>
</linearGradient>
<clipPath id="clip0">
<rect width="96" height="84" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -1,4 +1,4 @@
import React, { Ref, useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { MetadataCategory, MetadataFile } from "../types";
import { pubkeyToString } from "../utils";
import { useCachedImage, useExtendedArt } from "./useArt";
@ -7,8 +7,11 @@ import { PublicKey } from "@solana/web3.js";
import { getLast } from "../utils";
import { Metadata } from "metaplex/classes";
import ContentLoader from "react-content-loader";
import ErrorLogo from "img/logos-solana/dark-solana-logo.svg";
const Placeholder = () => (
const MAX_TIME_LOADING_IMAGE = 5000; /* 5 seconds */
const LoadingPlaceholder = () => (
<ContentLoader
viewBox="0 0 212 200"
height={150}
@ -21,50 +24,72 @@ const Placeholder = () => (
</ContentLoader>
);
const CachedImageContent = ({
uri,
}: {
uri?: string;
className?: string;
preview?: boolean;
style?: React.CSSProperties;
}) => {
const [loaded, setLoaded] = useState<boolean>(false);
const ErrorPlaceHolder = () => (
<img src={ErrorLogo} width="120" height="120" alt="Solana Logo" />
);
const CachedImageContent = ({ uri }: { uri?: string }) => {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [showError, setShowError] = useState<boolean>(false);
const [timeout, setTimeout] = useState<NodeJS.Timeout | undefined>(undefined);
useEffect(() => {
// Set the timeout if we don't have a valid uri
if (!uri && !timeout) {
setTimeout(setInterval(() => setShowError(true), MAX_TIME_LOADING_IMAGE));
}
// We have a uri - clear the timeout
if (uri && timeout) {
clearInterval(timeout);
}
return () => {
if (timeout) {
clearInterval(timeout);
}
};
}, [uri, setShowError, timeout, setTimeout]);
const { cachedBlob } = useCachedImage(uri || "");
return (
<>
{!loaded && <Placeholder />}
<img
className={`rounded mx-auto ${loaded ? "d-block" : "d-none"}`}
src={cachedBlob}
loading="lazy"
alt={"nft"}
style={{
width: 150,
height: "auto",
}}
onLoad={() => {
setLoaded(true);
}}
onError={() => {
setLoaded(true);
}}
/>
{showError ? (
<div className={"art-error-image-placeholder"}>
<ErrorPlaceHolder />
<h6 className={"header-pretitle mt-2"}>Error Loading Image</h6>
</div>
) : (
<>
{isLoading && <LoadingPlaceholder />}
<img
className={`rounded mx-auto ${isLoading ? "d-none" : "d-block"}`}
src={cachedBlob}
alt={"nft"}
style={{
width: 150,
height: "auto",
}}
onLoad={() => {
setIsLoading(false);
}}
onError={() => {
setShowError(true);
}}
/>
</>
)}
</>
);
};
const VideoArtContent = ({
className,
style,
files,
uri,
animationURL,
active,
}: {
className?: string;
style?: React.CSSProperties;
files?: (MetadataFile | string)[];
uri?: string;
animationURL?: string;
@ -97,7 +122,7 @@ const VideoArtContent = ({
const content =
likelyVideo &&
likelyVideo.startsWith("https://watch.videodelivery.net/") ? (
<div className={`${className} square`}>
<div className={"square"}>
<Stream
streamRef={(e: any) => playerRef(e)}
src={likelyVideo.replace("https://watch.videodelivery.net/", "")}
@ -116,26 +141,21 @@ const VideoArtContent = ({
</div>
) : (
<video
className={className}
playsInline={true}
autoPlay={true}
muted={true}
controls={true}
controlsList="nodownload"
style={{ borderRadius: 12, ...style }}
style={{ borderRadius: 12 }}
loop={true}
poster={uri}
>
{likelyVideo && (
<source src={likelyVideo} type="video/mp4" style={style} />
)}
{animationURL && (
<source src={animationURL} type="video/mp4" style={style} />
)}
{likelyVideo && <source src={likelyVideo} type="video/mp4" />}
{animationURL && <source src={animationURL} type="video/mp4" />}
{files
?.filter((f) => typeof f !== "string")
.map((f: any) => (
<source src={f.uri} type={f.type} style={style} />
<source src={f.uri} type={f.type} />
))}
</video>
);
@ -145,13 +165,9 @@ const VideoArtContent = ({
const HTMLContent = ({
animationUrl,
className,
style,
files,
}: {
animationUrl?: string;
className?: string;
style?: React.CSSProperties;
files?: (MetadataFile | string)[];
}) => {
const [loaded, setLoaded] = useState<boolean>(false);
@ -162,15 +178,15 @@ const HTMLContent = ({
return (
<>
{!loaded && <Placeholder />}
{!loaded && <LoadingPlaceholder />}
<iframe
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
title={"html-content"}
sandbox="allow-scripts"
frameBorder="0"
src={htmlURL}
className={`${className} ${loaded ? "d-block" : "d-none"}`}
style={{ width: 150, borderRadius: 12, ...style }}
className={`${loaded ? "d-block" : "d-none"}`}
style={{ width: 150, borderRadius: 12 }}
onLoad={() => {
setLoaded(true);
}}
@ -185,9 +201,6 @@ const HTMLContent = ({
export const ArtContent = ({
metadata,
category,
className,
preview,
style,
active,
pubkey,
uri,
@ -196,12 +209,6 @@ export const ArtContent = ({
}: {
metadata: Metadata;
category?: MetadataCategory;
className?: string;
preview?: boolean;
style?: React.CSSProperties;
width?: number;
height?: number;
ref?: Ref<HTMLDivElement>;
active?: boolean;
pubkey?: PublicKey | string;
uri?: string;
@ -231,27 +238,15 @@ export const ArtContent = ({
const content =
category === "video" ? (
<VideoArtContent
className={className}
style={style}
files={files}
uri={uri}
animationURL={animationURL}
active={active}
/>
) : category === "html" || animationUrlExt === "html" ? (
<HTMLContent
animationUrl={animationURL}
className={className}
style={style}
files={files}
/>
<HTMLContent animationUrl={animationURL} files={files} />
) : (
<CachedImageContent
uri={uri}
className={className}
preview={preview}
style={style}
/>
<CachedImageContent uri={uri} />
);
return (

View File

@ -2,25 +2,39 @@ import { IMetadataExtension, Metadata } from "metaplex/classes";
import { StringPublicKey } from "metaplex/types";
import { useEffect, useState } from "react";
enum ArtFetchStatus {
ReadyToFetch,
Fetching,
FetchFailed,
FetchSucceeded,
}
const cachedImages = new Map<string, string>();
export const useCachedImage = (uri: string) => {
const [cachedBlob, setCachedBlob] = useState<string | undefined>(undefined);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [fetchStatus, setFetchStatus] = useState<ArtFetchStatus>(
ArtFetchStatus.ReadyToFetch
);
useEffect(() => {
if (!uri) {
return;
}
if (fetchStatus === ArtFetchStatus.FetchFailed) {
setCachedBlob(uri);
return;
}
const result = cachedImages.get(uri);
if (result) {
setCachedBlob(result);
return;
}
if (!isLoading) {
if (fetchStatus === ArtFetchStatus.ReadyToFetch) {
(async () => {
setIsLoading(true);
setFetchStatus(ArtFetchStatus.Fetching);
let response: Response;
try {
response = await fetch(uri, { cache: "force-cache" });
@ -28,11 +42,10 @@ export const useCachedImage = (uri: string) => {
try {
response = await fetch(uri, { cache: "reload" });
} catch {
// If external URL, just use the uri
if (uri?.startsWith("http")) {
setCachedBlob(uri);
}
setIsLoading(false);
setFetchStatus(ArtFetchStatus.FetchFailed);
return;
}
}
@ -41,12 +54,12 @@ export const useCachedImage = (uri: string) => {
const blobURI = URL.createObjectURL(blob);
cachedImages.set(uri, blobURI);
setCachedBlob(blobURI);
setIsLoading(false);
setFetchStatus(ArtFetchStatus.FetchSucceeded);
})();
}
}, [uri, setCachedBlob, isLoading, setIsLoading]);
}, [uri, setCachedBlob, fetchStatus, setFetchStatus]);
return { cachedBlob, isLoading };
return { cachedBlob };
};
export const useExtendedArt = (id: StringPublicKey, metadata: Metadata) => {
@ -54,21 +67,8 @@ export const useExtendedArt = (id: StringPublicKey, metadata: Metadata) => {
useEffect(() => {
if (id && !data) {
const USE_CDN = false;
const routeCDN = (uri: string) => {
let result = uri;
if (USE_CDN) {
result = uri.replace(
"https://arweave.net/",
"https://coldcdn.com/api/cdn/bronil/"
);
}
return result;
};
if (metadata.data.uri) {
const uri = routeCDN(metadata.data.uri);
const uri = metadata.data.uri;
const processJson = (extended: any) => {
if (!extended || extended?.properties?.files?.length === 0) {
@ -76,10 +76,9 @@ export const useExtendedArt = (id: StringPublicKey, metadata: Metadata) => {
}
if (extended?.image) {
const file = extended.image.startsWith("http")
extended.image = extended.image.startsWith("http")
? extended.image
: `${metadata.data.uri}/${extended.image}`;
extended.image = routeCDN(file);
}
return extended;

View File

@ -420,3 +420,12 @@ p.updated-time {
overflow: hidden;
text-overflow: ellipsis;
}
.art-error-image-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 150px;
margin-top: 20px;
}