Commit dfc01750 authored by tom goriunov's avatar tom goriunov Committed by GitHub

bugfix: media is not shown for NFT (#1095)

* combine imageUrl and animationUrl prop for NftMedia

* add lazy load to NftMedia

* refactor image and video components

* add support for NFT HTML media

* move pw image mocks to separate folder

* tests

* fix loading state
parent c58a3fdf
......@@ -105,7 +105,7 @@ export function app(): CspDev.DirectiveDescriptor {
],
'frame-src': [
// improve: allow only frames from marketplace config
// could be a marketplace app or NFT media (html-page)
'*',
],
......
......@@ -17,7 +17,17 @@ export default async function mediaTypeHandler(req: NextApiRequest, res: NextApi
}
const contentType = response.headers.get('content-type');
const mediaType = contentType?.startsWith('video') ? 'video' : 'image';
const mediaType = (() => {
if (contentType?.startsWith('video')) {
return 'video';
}
if (contentType === 'text/html') {
return 'html';
}
return 'image';
})();
res.status(200).json({ type: mediaType });
} catch (error) {
res.status(200).json({ type: undefined });
......
<html>
<head>
<style>
body {
background-color: lightpink;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
}
</style>
</head>
<body>
this is HTML page
</body>
</html>
\ No newline at end of file
......@@ -29,7 +29,7 @@ const test = base.extend({
await page.route(ASSET_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
await page.route(ADDRESS_API_URL, (route) => route.fulfill({
......@@ -141,7 +141,7 @@ base('long values', async({ mount, page }) => {
await page.route(ASSET_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
await page.route(ADDRESS_API_URL, (route) => route.fulfill({
......
......@@ -30,8 +30,7 @@ const NFTItem = ({ token, token_id: tokenId, token_instance: tokenInstance, isLo
<LinkOverlay href={ isLoading ? undefined : tokenLink }>
<NftMedia
mb="18px"
imageUrl={ tokenInstance?.image_url || null }
animationUrl={ tokenInstance?.animation_url || null }
url={ tokenInstance?.animation_url || tokenInstance?.image_url || null }
isLoading={ isLoading }
/>
</LinkOverlay>
......
......@@ -35,7 +35,7 @@ test.beforeEach(async({ page }) => {
await page.route(textAdMock.duck.ad.thumbnail, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
});
......
......@@ -80,7 +80,7 @@ test.describe('custom hero plate background', () => {
await page.route(IMAGE_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/giant_duck_long.jpg',
path: './playwright/mocks/image_long.jpg',
});
});
......
......@@ -31,7 +31,7 @@ test('search by name +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(searchMock.token1.icon_url as string, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
......@@ -204,13 +204,13 @@ test.describe('with apps', () => {
await page.route(appsMock[0].logo, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
await page.route(appsMock[1].logo as string, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
......
......@@ -80,7 +80,7 @@ test('with verified info', async({ mount, page, createSocket }) => {
await page.route(tokenInfo.icon_url as string, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
......@@ -131,7 +131,7 @@ test.describe('mobile', () => {
await page.route(tokenInfo.icon_url as string, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
......
......@@ -14,7 +14,7 @@ test.beforeEach(async({ context }) => {
await context.route(mocks.TOKEN_INFO_APPLICATION_BASE.iconUrl, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
});
......
......@@ -20,7 +20,7 @@ test.beforeEach(async({ page }) => {
await page.route(textAdMock.duck.ad.thumbnail, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
});
......
......@@ -16,13 +16,13 @@ test.beforeEach(async({ page }) => {
await page.route(textAdMock.duck.ad.thumbnail, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
await page.route('https://example.com/logo.png', (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
});
......
......@@ -71,7 +71,7 @@ test('with logo and long symbol', async({ mount, page }) => {
await page.route(API_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
......
import { Icon, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import nftIcon from 'icons/nft_shield.svg';
const NftFallback = () => {
return (
<Icon
as={ nftIcon }
p="50px"
color={ useColorModeValue('blackAlpha.500', 'whiteAlpha.500') }
/>
);
};
export default NftFallback;
import { chakra } from '@chakra-ui/react';
import React from 'react';
interface Props {
src: string;
onLoad: () => void;
onError: () => void;
}
const NftHtml = ({ src, onLoad, onError }: Props) => {
return (
<chakra.iframe
src={ src }
sandbox="allow-scripts"
onLoad={ onLoad }
onError={ onError }
/>
);
};
export default NftHtml;
import type { ResponsiveValue } from '@chakra-ui/react';
import { Box, AspectRatio, chakra, Icon, Image, shouldForwardProp, Skeleton, useColorModeValue } from '@chakra-ui/react';
import type { Property } from 'csstype';
import { Image } from '@chakra-ui/react';
import React from 'react';
import nftIcon from 'icons/nft_shield.svg';
interface Props {
url: string | null;
className?: string;
fallbackPadding?: string;
objectFit: ResponsiveValue<Property.ObjectFit>;
}
interface FallbackProps {
className?: string;
padding?: string;
url: string;
onLoad: () => void;
onError: () => void;
}
const Fallback = ({ className, padding }: FallbackProps) => {
const NftImage = ({ url, onLoad, onError }: Props) => {
return (
<Icon
className={ className }
as={ nftIcon }
p={ padding ?? '50px' }
color={ useColorModeValue('blackAlpha.500', 'whiteAlpha.500') }
/>
);
};
const NftImage = ({ url, className, fallbackPadding, objectFit }: Props) => {
const [ isLoading, setIsLoading ] = React.useState(true);
const [ isError, setIsError ] = React.useState(false);
const handleLoad = React.useCallback(() => {
setIsLoading(false);
}, []);
const handleLoadError = React.useCallback(() => {
setIsLoading(false);
setIsError(true);
}, []);
const _objectFit = objectFit || 'contain';
const content = (() => {
// as of ChakraUI v2.5.3
// fallback prop of Image component doesn't work well with loading prop lazy strategy
// so we have to render fallback and loader manually
if (isError || !url) {
return <Fallback className={ className } padding={ fallbackPadding }/>;
}
return (
<Box>
{ isLoading && <Skeleton position="absolute" left={ 0 } top={ 0 } w="100%" h="100%" zIndex="1"/> }
<Image
w="100%"
h="100%"
objectFit={ _objectFit }
src={ url }
opacity={ isLoading ? 0 : 1 }
alt="Token instance image"
onError={ handleLoadError }
onLoad={ handleLoad }
loading={ url ? 'lazy' : undefined }
onError={ onError }
onLoad={ onLoad }
/>
</Box>
);
})();
return (
<AspectRatio
className={ className }
ratio={ 1 / 1 }
bgColor={ useColorModeValue('blackAlpha.50', 'whiteAlpha.50') }
overflow="hidden"
borderRadius="md"
sx={{
'&>img': {
objectFit: _objectFit,
},
}}
>
{ content }
</AspectRatio>
);
};
const NftImageChakra = chakra(NftImage, {
shouldForwardProp: (prop) => {
const isChakraProp = !shouldForwardProp(prop);
if (isChakraProp && prop !== 'objectFit') {
return false;
}
return true;
},
});
export default NftImageChakra;
export default NftImage;
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import TestApp from 'playwright/TestApp';
import NftMedia from './NftMedia';
test.use({ viewport: { width: 250, height: 250 } });
test('no url +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<NftMedia url={ null }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('image +@dark-mode', async({ mount, page }) => {
const MEDIA_URL = 'https://localhost:3000/my-image.jpg';
await page.route(MEDIA_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/mocks/image_long.jpg',
});
});
const component = await mount(
<TestApp>
<NftMedia url={ MEDIA_URL }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('page', async({ mount, page }) => {
const MEDIA_URL = 'https://localhost:3000/page.html';
const MEDIA_TYPE_API_URL = `/node-api/media-type?url=${ encodeURIComponent(MEDIA_URL) }`;
await page.route(MEDIA_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/mocks/page.html',
});
});
await page.route(MEDIA_TYPE_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({ type: 'html' }),
}));
const component = await mount(
<TestApp>
<NftMedia url={ MEDIA_URL }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { AspectRatio, chakra, Skeleton } from '@chakra-ui/react';
import { AspectRatio, chakra, Skeleton, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import { useInView } from 'react-intersection-observer';
import type { StaticRoute } from 'nextjs-routes';
import { route } from 'nextjs-routes';
import useFetch from 'lib/hooks/useFetch';
import NftFallback from './NftFallback';
import NftHtml from './NftHtml';
import NftImage from './NftImage';
import NftVideo from './NftVideo';
import type { MediaType } from './utils';
import { getPreliminaryMediaType } from './utils';
interface Props {
imageUrl: string | null;
animationUrl: string | null;
url: string | null;
className?: string;
isLoading?: boolean;
}
const NftMedia = ({ imageUrl, animationUrl, className, isLoading }: Props) => {
const [ type, setType ] = React.useState<MediaType | undefined>(!animationUrl ? 'image' : undefined);
const NftMedia = ({ url, className, isLoading }: Props) => {
const [ type, setType ] = React.useState<MediaType | undefined>();
const [ isMediaLoading, setIsMediaLoading ] = React.useState(Boolean(url));
const [ isLoadingError, setIsLoadingError ] = React.useState(false);
const bgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
const fetch = useFetch();
const { ref, inView } = useInView({ triggerOnce: true });
React.useEffect(() => {
if (!animationUrl || isLoading) {
if (!url || isLoading || !inView) {
return;
}
// media could be either gif or video
// media could be either image, gif or video
// 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(animationUrl);
const preliminaryType = getPreliminaryMediaType(url);
if (preliminaryType) {
setType(preliminaryType);
return;
}
const url = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url: animationUrl } });
fetch(url)
const mediaTypeResourceUrl = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url } });
fetch(mediaTypeResourceUrl)
.then((_data) => {
const data = _data as { type: MediaType | undefined };
setType(data.type || 'image');
......@@ -50,26 +58,55 @@ const NftMedia = ({ imageUrl, animationUrl, className, isLoading }: Props) => {
setType('image');
});
}, [ animationUrl, isLoading, fetch ]);
}, [ url, isLoading, fetch, inView ]);
const handleMediaLoaded = React.useCallback(() => {
setIsMediaLoading(false);
}, []);
const handleMediaLoadError = React.useCallback(() => {
setIsMediaLoading(false);
setIsLoadingError(true);
}, []);
const content = (() => {
if (!url || isLoadingError) {
return <NftFallback/>;
}
switch (type) {
case 'video':
return <NftVideo src={ url } onLoad={ handleMediaLoaded } onError={ handleMediaLoadError }/>;
case 'html':
return <NftHtml src={ url } onLoad={ handleMediaLoaded } onError={ handleMediaLoadError }/>;
case 'image':
return <NftImage url={ url } onLoad={ handleMediaLoaded } onError={ handleMediaLoadError }/>;
default:
return null;
}
})();
if (!type || isLoading) {
return (
<AspectRatio
ref={ ref }
className={ className }
bgColor={ isLoading || isMediaLoading ? 'transparent' : bgColor }
ratio={ 1 / 1 }
overflow="hidden"
borderRadius="md"
objectFit="contain"
sx={{
'&>img, &>video': {
objectFit: 'contain',
},
}}
>
<Skeleton/>
<>
{ content }
{ isMediaLoading && <Skeleton position="absolute" left={ 0 } top={ 0 } w="100%" h="100%" zIndex="1"/> }
</>
</AspectRatio>
);
}
if (animationUrl && type === 'video') {
return <NftVideo className={ className } src={ animationUrl }/>;
}
return <NftImage className={ className } url={ animationUrl || imageUrl }/>;
};
export default chakra(NftMedia);
import { AspectRatio, Skeleton, chakra } from '@chakra-ui/react';
import { chakra } from '@chakra-ui/react';
import React from 'react';
interface Props {
className?: string;
src: string;
onLoad: () => void;
onError: () => void;
}
const NftVideo = ({ className, src }: Props) => {
const [ isLoading, setIsLoading ] = React.useState(true);
const handleCanPlay = React.useCallback(() => {
setIsLoading(false);
}, []);
const NftVideo = ({ src, onLoad, onError }: Props) => {
return (
<AspectRatio
className={ className }
ratio={ 1 / 1 }
overflow="hidden"
borderRadius="md"
>
<>
<chakra.video
src={ src }
autoPlay
......@@ -28,12 +16,10 @@ const NftVideo = ({ className, src }: Props) => {
loop
muted
playsInline
onCanPlayThrough={ handleCanPlay }
onCanPlayThrough={ onLoad }
onError={ onError }
borderRadius="md"
/>
{ isLoading && <Skeleton position="absolute" w="100%" h="100%" left={ 0 } top={ 0 }/> }
</>
</AspectRatio>
);
};
......
export type MediaType = 'image' | 'video';
export type MediaType = 'image' | 'video' | 'html';
const IMAGE_EXTENSIONS = [
'.jpg', 'jpeg',
......
......@@ -37,7 +37,7 @@ test('base view', async({ mount, page }) => {
await page.route(LOGO_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
......@@ -67,7 +67,7 @@ test.describe('dark mode', () => {
await page.route(LOGO_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
......
......@@ -59,13 +59,13 @@ base.describe('custom logo', () => {
await page.route(LOGO_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/network-logo.svg',
path: './playwright/mocks/network-logo.svg',
});
});
await page.route(ICON_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/duck.svg',
path: './playwright/mocks/image_svg.svg',
});
});
......@@ -108,13 +108,13 @@ base.describe('custom logo with dark option -@default +@dark-mode', () => {
await page.route(LOGO_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/giant_duck_long.jpg',
path: './playwright/mocks/image_long.jpg',
});
});
await page.route(ICON_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
......
......@@ -23,7 +23,7 @@ extendedTest('base view +@dark-mode', async({ mount, page }) => {
await page.route(LOGO_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
await page.route(FEATURED_NETWORKS_URL, (route) => {
......
......@@ -43,7 +43,7 @@ test.describe('auth', () => {
await page.route(profileMock.base.avatar, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
......
......@@ -45,7 +45,7 @@ test.describe('auth', () => {
await page.route(profileMock.base.avatar, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
......
......@@ -18,13 +18,13 @@ test.beforeEach(async({ page }) => {
await page.route(textAdMock.duck.ad.thumbnail, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
await page.route(searchMock.token1.icon_url as string, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
});
......@@ -306,13 +306,13 @@ test.describe('with apps', () => {
await page.route(appsMock[0].logo, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
await page.route(appsMock[1].logo as string, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
path: './playwright/mocks/image_s.jpg',
});
});
......
......@@ -18,8 +18,7 @@ const NFTItem = ({ item, isLoading }: Props) => {
const mediaElement = (
<NftMedia
mb="18px"
imageUrl={ item.image_url }
animationUrl={ item.animation_url }
url={ item.animation_url || item.image_url }
isLoading={ isLoading }
/>
);
......
......@@ -13,7 +13,7 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(mocks.TOKEN_INFO_APPLICATION_BASE.iconUrl, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_md.jpg',
path: './playwright/mocks/image_md.jpg',
});
});
......@@ -41,7 +41,7 @@ test('status IN_PROCESS', async({ mount, page }) => {
await page.route(mocks.TOKEN_INFO_APPLICATION_BASE.iconUrl, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_md.jpg',
path: './playwright/mocks/image_md.jpg',
});
});
......
......@@ -82,8 +82,7 @@ const TokenInstanceDetails = ({ data, scrollRef, isLoading }: Props) => {
<TokenInstanceTransfersCount hash={ isLoading ? '' : data.token.address } id={ isLoading ? '' : data.id } onClick={ handleCounterItemClick }/>
</Grid>
<NftMedia
imageUrl={ data.image_url }
animationUrl={ data.animation_url }
url={ data.animation_url || data.image_url }
w="250px"
flexShrink={ 0 }
alignSelf={{ base: 'center', lg: 'flex-start' }}
......
......@@ -10998,6 +10998,11 @@ react-inspector@^6.0.1:
resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-6.0.1.tgz#1a37f0165d9df81ee804d63259eaaeabe841287d"
integrity sha512-cxKSeFTf7jpSSVddm66sKdolG90qURAX3g1roTeaN6x0YEbtWc8JpmFN9+yIqLNH2uEkYerWLtJZIXRIFuBKrg==
react-intersection-observer@^9.5.2:
version "9.5.2"
resolved "https://registry.yarnpkg.com/react-intersection-observer/-/react-intersection-observer-9.5.2.tgz#f68363a1ff292323c0808201b58134307a1626d0"
integrity sha512-EmoV66/yvksJcGa1rdW0nDNc4I1RifDWkT50gXSFnPLYQ4xUptuDD4V7k+Rj1OgVAlww628KLGcxPXFlOkkU/Q==
react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
......
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