Skip to content

Commit

Permalink
Merge pull request #1670 from blockscout/hotfix/nft-image
Browse files Browse the repository at this point in the history
Add fallback to 'image_url' if 'animation_url' is invalid for NFT instance
  • Loading branch information
tom2drum authored Mar 7, 2024
2 parents d423fb8 + 0d3890d commit 8e6a099
Show file tree
Hide file tree
Showing 10 changed files with 183 additions and 68 deletions.
6 changes: 4 additions & 2 deletions pages/api/media-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion ui/address/tokens/NFTItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
57 changes: 52 additions & 5 deletions ui/shared/nft/NftMedia.pw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
);

Expand All @@ -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>,
);

Expand All @@ -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>,
);

Expand All @@ -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>,
);

Expand Down Expand Up @@ -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>,
);

Expand Down
32 changes: 23 additions & 9 deletions ui/shared/nft/NftMedia.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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;
}

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
98 changes: 98 additions & 0 deletions ui/shared/nft/useNftMediaInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { useQuery } from '@tanstack/react-query';
import React from 'react';

import type { StaticRoute } from 'nextjs-routes';
import { route } from 'nextjs-routes';

import type { ResourceError } from 'lib/api/resources';
import useFetch from 'lib/hooks/useFetch';

import type { MediaType } from './utils';
import { getPreliminaryMediaType } from './utils';

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();

return useQuery<unknown, ResourceError<unknown>, ReturnType | null>({
queryKey: [ 'nft-media-type', url ],
queryFn: async() => {
if (!url) {
return null;
}

// media could be either image, gif, video or html-page
// so we pre-fetch the resources in order to get its content type
// have to do it via Node.js due to strict CSP for connect-src
// but in order not to abuse our server firstly we check file url extension
// and if it is valid we will trust it and display corresponding media component

const preliminaryType = getPreliminaryMediaType(url);

if (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 : undefined;
} catch (error) {
return;
}
})();

if (!type) {
return null;
}

return { type, url };
},
enabled,
staleTime: Infinity,
});
}
49 changes: 0 additions & 49 deletions ui/shared/nft/useNftMediaType.tsx

This file was deleted.

3 changes: 2 additions & 1 deletion ui/token/TokenInventoryItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
/>
);
Expand Down
3 changes: 2 additions & 1 deletion ui/tokenInstance/TokenInstanceDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}
Expand Down

0 comments on commit 8e6a099

Please sign in to comment.