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 ...@@ -22,11 +22,13 @@ export default async function mediaTypeHandler(req: NextApiRequest, res: NextApi
return 'video'; return 'video';
} }
if (contentType?.startsWith('image')) {
return 'image';
}
if (contentType?.startsWith('text/html')) { if (contentType?.startsWith('text/html')) {
return 'html'; return 'html';
} }
return 'image';
})(); })();
res.status(200).json({ type: mediaType }); res.status(200).json({ type: mediaType });
} catch (error) { } catch (error) {
......
...@@ -26,7 +26,8 @@ const NFTItem = ({ token, value, isLoading, withTokenLink, ...tokenInstance }: P ...@@ -26,7 +26,8 @@ const NFTItem = ({ token, value, isLoading, withTokenLink, ...tokenInstance }: P
<Link href={ isLoading ? undefined : tokenInstanceLink }> <Link href={ isLoading ? undefined : tokenInstanceLink }>
<NftMedia <NftMedia
mb="18px" mb="18px"
url={ tokenInstance?.animation_url || tokenInstance?.image_url || null } animationUrl={ tokenInstance?.animation_url ?? null }
imageUrl={ tokenInstance?.image_url ?? null }
isLoading={ isLoading } isLoading={ isLoading }
/> />
</Link> </Link>
......
...@@ -10,7 +10,54 @@ test.describe('no url', () => { ...@@ -10,7 +10,54 @@ test.describe('no url', () => {
test('preview +@dark-mode', async({ mount }) => { test('preview +@dark-mode', async({ mount }) => {
const component = await mount( const component = await mount(
<TestApp> <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>, </TestApp>,
); );
...@@ -35,7 +82,7 @@ test.describe('image', () => { ...@@ -35,7 +82,7 @@ test.describe('image', () => {
test('preview +@dark-mode', async({ mount }) => { test('preview +@dark-mode', async({ mount }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<NftMedia url={ MEDIA_URL }/> <NftMedia animationUrl={ MEDIA_URL } imageUrl={ null }/>
</TestApp>, </TestApp>,
); );
...@@ -55,7 +102,7 @@ test('image preview hover', async({ mount, page }) => { ...@@ -55,7 +102,7 @@ test('image preview hover', async({ mount, page }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<NftMedia url={ MEDIA_URL } w="250px"/> <NftMedia animationUrl={ MEDIA_URL } imageUrl={ null } w="250px"/>
</TestApp>, </TestApp>,
); );
...@@ -75,7 +122,7 @@ test('image fullscreen +@dark-mode +@mobile', async({ mount, page }) => { ...@@ -75,7 +122,7 @@ test('image fullscreen +@dark-mode +@mobile', async({ mount, page }) => {
}); });
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<NftMedia url={ MEDIA_URL } withFullscreen w="250px"/> <NftMedia animationUrl={ MEDIA_URL } imageUrl={ null } withFullscreen w="250px"/>
</TestApp>, </TestApp>,
); );
...@@ -107,7 +154,7 @@ test.describe('page', () => { ...@@ -107,7 +154,7 @@ test.describe('page', () => {
test('preview +@dark-mode', async({ mount }) => { test('preview +@dark-mode', async({ mount }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<NftMedia url={ MEDIA_URL }/> <NftMedia animationUrl={ MEDIA_URL } imageUrl={ null }/>
</TestApp>, </TestApp>,
); );
......
...@@ -9,29 +9,31 @@ import NftImage from './NftImage'; ...@@ -9,29 +9,31 @@ import NftImage from './NftImage';
import NftImageFullscreen from './NftImageFullscreen'; import NftImageFullscreen from './NftImageFullscreen';
import NftVideo from './NftVideo'; import NftVideo from './NftVideo';
import NftVideoFullscreen from './NftVideoFullscreen'; import NftVideoFullscreen from './NftVideoFullscreen';
import useNftMediaType from './useNftMediaType'; import useNftMediaInfo from './useNftMediaInfo';
import { mediaStyleProps } from './utils'; import { mediaStyleProps } from './utils';
interface Props { interface Props {
url: string | null; imageUrl: string | null;
animationUrl: string | null;
className?: string; className?: string;
isLoading?: boolean; isLoading?: boolean;
withFullscreen?: 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 [ isMediaLoading, setIsMediaLoading ] = React.useState(true);
const [ isLoadingError, setIsLoadingError ] = React.useState(false); const [ isLoadingError, setIsLoadingError ] = React.useState(false);
const { ref, inView } = useInView({ triggerOnce: true }); const { ref, inView } = useInView({ triggerOnce: true });
const type = useNftMediaType(url, !isLoading && inView); const mediaInfo = useNftMediaInfo({ imageUrl, animationUrl, isEnabled: !isLoading && inView });
React.useEffect(() => { React.useEffect(() => {
if (!isLoading) { if (!isLoading && !mediaInfo) {
setIsMediaLoading(Boolean(url)); setIsMediaLoading(false);
setIsLoadingError(true);
} }
}, [ isLoading, url ]); }, [ isLoading, mediaInfo ]);
const handleMediaLoaded = React.useCallback(() => { const handleMediaLoaded = React.useCallback(() => {
setIsMediaLoading(false); setIsMediaLoading(false);
...@@ -45,11 +47,17 @@ const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => { ...@@ -45,11 +47,17 @@ const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const content = (() => { const content = (() => {
if (!url || isLoadingError) { if (!mediaInfo || isLoadingError) {
const styleProps = withFullscreen ? {} : mediaStyleProps; const styleProps = withFullscreen ? {} : mediaStyleProps;
return <NftFallback { ...styleProps }/>; return <NftFallback { ...styleProps }/>;
} }
const { type, url } = mediaInfo;
if (!url) {
return null;
}
const props = { const props = {
src: url, src: url,
onLoad: handleMediaLoaded, onLoad: handleMediaLoaded,
...@@ -70,7 +78,13 @@ const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => { ...@@ -70,7 +78,13 @@ const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => {
})(); })();
const modal = (() => { const modal = (() => {
if (!url || !withFullscreen) { if (!mediaInfo || !withFullscreen) {
return null;
}
const { type, url } = mediaInfo;
if (!url) {
return null; return null;
} }
......
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { StaticRoute } from 'nextjs-routes'; import type { StaticRoute } from 'nextjs-routes';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
...@@ -9,15 +10,57 @@ import useFetch from 'lib/hooks/useFetch'; ...@@ -9,15 +10,57 @@ import useFetch from 'lib/hooks/useFetch';
import type { MediaType } from './utils'; import type { MediaType } from './utils';
import { getPreliminaryMediaType } 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 fetch = useFetch();
const { data } = useQuery<unknown, ResourceError<unknown>, MediaType>({ return useQuery<unknown, ResourceError<unknown>, ReturnType | null>({
queryKey: [ 'nft-media-type', url ], queryKey: [ 'nft-media-type', url ],
queryFn: async() => { queryFn: async() => {
if (!url) { if (!url) {
return 'image'; return null;
} }
// media could be either image, gif, video or html-page // media could be either image, gif, video or html-page
...@@ -29,21 +72,27 @@ export default function useNftMediaType(url: string | null, isEnabled: boolean) ...@@ -29,21 +72,27 @@ export default function useNftMediaType(url: string | null, isEnabled: boolean)
const preliminaryType = getPreliminaryMediaType(url); const preliminaryType = getPreliminaryMediaType(url);
if (preliminaryType) { if (preliminaryType) {
return preliminaryType; return { type: preliminaryType, url };
} }
try { const type = await (async() => {
const mediaTypeResourceUrl = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url } }); try {
const response = await fetch<{ type: MediaType | undefined }, ResourceError>(mediaTypeResourceUrl, undefined, { resource: 'media-type' }); 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) { } catch (error) {
return 'image'; return;
}
})();
if (!type) {
return null;
} }
return { type, url };
}, },
enabled: isEnabled && Boolean(url), enabled,
staleTime: Infinity, staleTime: Infinity,
}); });
return data;
} }
...@@ -20,7 +20,8 @@ const TokenInventoryItem = ({ item, token, isLoading }: Props) => { ...@@ -20,7 +20,8 @@ const TokenInventoryItem = ({ item, token, isLoading }: Props) => {
const mediaElement = ( const mediaElement = (
<NftMedia <NftMedia
mb="18px" mb="18px"
url={ item.animation_url || item.image_url } animationUrl={ item.animation_url }
imageUrl={ item.image_url }
isLoading={ isLoading } isLoading={ isLoading }
/> />
); );
......
...@@ -74,7 +74,8 @@ const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => { ...@@ -74,7 +74,8 @@ const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => {
<TokenNftMarketplaces isLoading={ isLoading } hash={ token.address } id={ data.id }/> <TokenNftMarketplaces isLoading={ isLoading } hash={ token.address } id={ data.id }/>
</Grid> </Grid>
<NftMedia <NftMedia
url={ data.animation_url || data.image_url } animationUrl={ data.animation_url }
imageUrl={ data.image_url }
w="250px" w="250px"
flexShrink={ 0 } flexShrink={ 0 }
alignSelf={{ base: 'center', lg: 'flex-start' }} 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