Commit 8e6a099d authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge pull request #1670 from blockscout/hotfix/nft-image

Add fallback to 'image_url' if 'animation_url' is invalid for NFT instance
parents d423fb88 0d3890df
......@@ -22,11 +22,13 @@ export default async function mediaTypeHandler(req: NextApiRequest, res: NextApi
return 'video';
}
if (contentType?.startsWith('image')) {
return 'image';
}
if (contentType?.startsWith('text/html')) {
return 'html';
}
return 'image';
})();
res.status(200).json({ type: mediaType });
} catch (error) {
......
......@@ -26,7 +26,8 @@ const NFTItem = ({ token, value, isLoading, withTokenLink, ...tokenInstance }: P
<Link href={ isLoading ? undefined : tokenInstanceLink }>
<NftMedia
mb="18px"
url={ tokenInstance?.animation_url || tokenInstance?.image_url || null }
animationUrl={ tokenInstance?.animation_url ?? null }
imageUrl={ tokenInstance?.image_url ?? null }
isLoading={ isLoading }
/>
</Link>
......
......@@ -10,7 +10,54 @@ test.describe('no url', () => {
test('preview +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<NftMedia url={ null }/>
<NftMedia animationUrl={ null } imageUrl={ null }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('with fallback', async({ mount, page }) => {
const IMAGE_URL = 'https://localhost:3000/my-image.jpg';
await page.route(IMAGE_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/mocks/image_long.jpg',
});
});
const component = await mount(
<TestApp>
<NftMedia animationUrl={ null } imageUrl={ IMAGE_URL }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('non-media url and fallback', async({ mount, page }) => {
const ANIMATION_URL = 'https://localhost:3000/my-animation.m3u8';
const ANIMATION_MEDIA_TYPE_API_URL = `/node-api/media-type?url=${ encodeURIComponent(ANIMATION_URL) }`;
const IMAGE_URL = 'https://localhost:3000/my-image.jpg';
await page.route(ANIMATION_MEDIA_TYPE_API_URL, (route) => {
return route.fulfill({
status: 200,
body: JSON.stringify({ type: undefined }),
});
});
await page.route(IMAGE_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/mocks/image_long.jpg',
});
});
const component = await mount(
<TestApp>
<NftMedia animationUrl={ ANIMATION_URL } imageUrl={ IMAGE_URL }/>
</TestApp>,
);
......@@ -35,7 +82,7 @@ test.describe('image', () => {
test('preview +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<NftMedia url={ MEDIA_URL }/>
<NftMedia animationUrl={ MEDIA_URL } imageUrl={ null }/>
</TestApp>,
);
......@@ -55,7 +102,7 @@ test('image preview hover', async({ mount, page }) => {
const component = await mount(
<TestApp>
<NftMedia url={ MEDIA_URL } w="250px"/>
<NftMedia animationUrl={ MEDIA_URL } imageUrl={ null } w="250px"/>
</TestApp>,
);
......@@ -75,7 +122,7 @@ test('image fullscreen +@dark-mode +@mobile', async({ mount, page }) => {
});
const component = await mount(
<TestApp>
<NftMedia url={ MEDIA_URL } withFullscreen w="250px"/>
<NftMedia animationUrl={ MEDIA_URL } imageUrl={ null } withFullscreen w="250px"/>
</TestApp>,
);
......@@ -107,7 +154,7 @@ test.describe('page', () => {
test('preview +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<NftMedia url={ MEDIA_URL }/>
<NftMedia animationUrl={ MEDIA_URL } imageUrl={ null }/>
</TestApp>,
);
......
......@@ -9,29 +9,31 @@ import NftImage from './NftImage';
import NftImageFullscreen from './NftImageFullscreen';
import NftVideo from './NftVideo';
import NftVideoFullscreen from './NftVideoFullscreen';
import useNftMediaType from './useNftMediaType';
import useNftMediaInfo from './useNftMediaInfo';
import { mediaStyleProps } from './utils';
interface Props {
url: string | null;
imageUrl: string | null;
animationUrl: string | null;
className?: string;
isLoading?: boolean;
withFullscreen?: boolean;
}
const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => {
const NftMedia = ({ imageUrl, animationUrl, className, isLoading, withFullscreen }: Props) => {
const [ isMediaLoading, setIsMediaLoading ] = React.useState(true);
const [ isLoadingError, setIsLoadingError ] = React.useState(false);
const { ref, inView } = useInView({ triggerOnce: true });
const type = useNftMediaType(url, !isLoading && inView);
const mediaInfo = useNftMediaInfo({ imageUrl, animationUrl, isEnabled: !isLoading && inView });
React.useEffect(() => {
if (!isLoading) {
setIsMediaLoading(Boolean(url));
if (!isLoading && !mediaInfo) {
setIsMediaLoading(false);
setIsLoadingError(true);
}
}, [ isLoading, url ]);
}, [ isLoading, mediaInfo ]);
const handleMediaLoaded = React.useCallback(() => {
setIsMediaLoading(false);
......@@ -45,11 +47,17 @@ const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const content = (() => {
if (!url || isLoadingError) {
if (!mediaInfo || isLoadingError) {
const styleProps = withFullscreen ? {} : mediaStyleProps;
return <NftFallback { ...styleProps }/>;
}
const { type, url } = mediaInfo;
if (!url) {
return null;
}
const props = {
src: url,
onLoad: handleMediaLoaded,
......@@ -70,7 +78,13 @@ const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => {
})();
const modal = (() => {
if (!url || !withFullscreen) {
if (!mediaInfo || !withFullscreen) {
return null;
}
const { type, url } = mediaInfo;
if (!url) {
return null;
}
......
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { StaticRoute } from 'nextjs-routes';
import { route } from 'nextjs-routes';
......@@ -9,15 +10,57 @@ import useFetch from 'lib/hooks/useFetch';
import type { MediaType } from './utils';
import { getPreliminaryMediaType } from './utils';
export default function useNftMediaType(url: string | null, isEnabled: boolean) {
interface Params {
imageUrl: string | null;
animationUrl: string | null;
isEnabled: boolean;
}
interface ReturnType {
type: MediaType | undefined;
url: string | null;
}
export default function useNftMediaInfo({ imageUrl, animationUrl, isEnabled }: Params): ReturnType | null {
const primaryQuery = useNftMediaTypeQuery(animationUrl, isEnabled);
const secondaryQuery = useNftMediaTypeQuery(imageUrl, !primaryQuery.isPending && !primaryQuery.data);
return React.useMemo(() => {
if (primaryQuery.isPending) {
return {
type: undefined,
url: animationUrl,
};
}
if (primaryQuery.data) {
return primaryQuery.data;
}
if (secondaryQuery.isPending) {
return {
type: undefined,
url: imageUrl,
};
}
if (secondaryQuery.data) {
return secondaryQuery.data;
}
return null;
}, [ animationUrl, imageUrl, primaryQuery.data, primaryQuery.isPending, secondaryQuery.data, secondaryQuery.isPending ]);
}
function useNftMediaTypeQuery(url: string | null, enabled: boolean) {
const fetch = useFetch();
const { data } = useQuery<unknown, ResourceError<unknown>, MediaType>({
return useQuery<unknown, ResourceError<unknown>, ReturnType | null>({
queryKey: [ 'nft-media-type', url ],
queryFn: async() => {
if (!url) {
return 'image';
return null;
}
// media could be either image, gif, video or html-page
......@@ -29,21 +72,27 @@ export default function useNftMediaType(url: string | null, isEnabled: boolean)
const preliminaryType = getPreliminaryMediaType(url);
if (preliminaryType) {
return preliminaryType;
return { type: preliminaryType, url };
}
const type = await (async() => {
try {
const mediaTypeResourceUrl = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url } });
const response = await fetch<{ type: MediaType | undefined }, ResourceError>(mediaTypeResourceUrl, undefined, { resource: 'media-type' });
return 'type' in response ? response.type ?? 'image' : 'image';
return 'type' in response ? response.type : undefined;
} catch (error) {
return 'image';
return;
}
})();
if (!type) {
return null;
}
return { type, url };
},
enabled: isEnabled && Boolean(url),
enabled,
staleTime: Infinity,
});
return data;
}
......@@ -20,7 +20,8 @@ const TokenInventoryItem = ({ item, token, isLoading }: Props) => {
const mediaElement = (
<NftMedia
mb="18px"
url={ item.animation_url || item.image_url }
animationUrl={ item.animation_url }
imageUrl={ item.image_url }
isLoading={ isLoading }
/>
);
......
......@@ -74,7 +74,8 @@ const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => {
<TokenNftMarketplaces isLoading={ isLoading } hash={ token.address } id={ data.id }/>
</Grid>
<NftMedia
url={ data.animation_url || data.image_url }
animationUrl={ data.animation_url }
imageUrl={ data.image_url }
w="250px"
flexShrink={ 0 }
alignSelf={{ base: 'center', lg: 'flex-start' }}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment