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

Try `@helia/verified-fetch` for fetching NFT images from IPFS (#2363)

* prototype

* fetch video poster from ipfs

* add a constraint to the asset type

* add ENV variable

* fix tests

* [skip ci] clean up
parent ad1c4b22
...@@ -4,6 +4,9 @@ import { getEnvValue, parseEnvJson } from 'configs/app/utils'; ...@@ -4,6 +4,9 @@ import { getEnvValue, parseEnvJson } from 'configs/app/utils';
const config = Object.freeze({ const config = Object.freeze({
marketplaces: parseEnvJson<Array<NftMarketplaceItem>>(getEnvValue('NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES')) || [], marketplaces: parseEnvJson<Array<NftMarketplaceItem>>(getEnvValue('NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES')) || [],
verifiedFetch: {
isEnabled: getEnvValue('NEXT_PUBLIC_HELIA_VERIFIED_FETCH_ENABLED') === 'false' ? false : true,
},
}); });
export default config; export default config;
...@@ -56,3 +56,4 @@ NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY=xxx ...@@ -56,3 +56,4 @@ NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY=xxx
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT=['base16','bech32'] NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT=['base16','bech32']
NEXT_PUBLIC_VIEWS_ADDRESS_BECH_32_PREFIX=tom NEXT_PUBLIC_VIEWS_ADDRESS_BECH_32_PREFIX=tom
NEXT_PUBLIC_HELIA_VERIFIED_FETCH_ENABLED=false
\ No newline at end of file
...@@ -745,6 +745,7 @@ const schema = yup ...@@ -745,6 +745,7 @@ const schema = yup
.transform(replaceQuotes) .transform(replaceQuotes)
.json() .json()
.of(nftMarketplaceSchema), .of(nftMarketplaceSchema),
NEXT_PUBLIC_HELIA_VERIFIED_FETCH_ENABLED: yup.boolean(),
// e. misc // e. misc
NEXT_PUBLIC_NETWORK_EXPLORERS: yup NEXT_PUBLIC_NETWORK_EXPLORERS: yup
......
...@@ -31,6 +31,7 @@ NEXT_PUBLIC_FONT_FAMILY_HEADING={'name':'Montserrat','url':'https://fonts.google ...@@ -31,6 +31,7 @@ NEXT_PUBLIC_FONT_FAMILY_HEADING={'name':'Montserrat','url':'https://fonts.google
NEXT_PUBLIC_FONT_FAMILY_BODY={'name':'Raleway','url':'https://fonts.googleapis.com/css2?family=Raleway:wght@400;500;600;700&display=swap'} NEXT_PUBLIC_FONT_FAMILY_BODY={'name':'Raleway','url':'https://fonts.googleapis.com/css2?family=Raleway:wght@400;500;600;700&display=swap'}
NEXT_PUBLIC_FOOTER_LINKS=https://example.com NEXT_PUBLIC_FOOTER_LINKS=https://example.com
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d
NEXT_PUBLIC_HELIA_VERIFIED_FETCH_ENABLED=false
NEXT_PUBLIC_HIDE_INDEXING_ALERT_BLOCKS=false NEXT_PUBLIC_HIDE_INDEXING_ALERT_BLOCKS=false
NEXT_PUBLIC_HIDE_INDEXING_ALERT_INT_TXS=false NEXT_PUBLIC_HIDE_INDEXING_ALERT_INT_TXS=false
NEXT_PUBLIC_MAX_CONTENT_WIDTH_ENABLED=false NEXT_PUBLIC_MAX_CONTENT_WIDTH_ENABLED=false
......
...@@ -279,7 +279,7 @@ Settings for meta tags, OG tags and SEO ...@@ -279,7 +279,7 @@ Settings for meta tags, OG tags and SEO
| Variable | Type | Description | Compulsoriness | Default value | Example value | Version | | Variable | Type | Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES | `Array<NftMarketplace>` where `NftMarketplace` can have following [properties](#nft-marketplace-properties) | Used to build up links to NFT collections and NFT instances in external marketplaces. | - | - | `[{'name':'OpenSea','collection_url':'https://opensea.io/assets/ethereum/{hash}','instance_url':'https://opensea.io/assets/ethereum/{hash}/{id}','logo_url':'https://opensea.io/static/images/logos/opensea-logo.svg'}]` | v1.15.0+ | | NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES | `Array<NftMarketplace>` where `NftMarketplace` can have following [properties](#nft-marketplace-properties) | Used to build up links to NFT collections and NFT instances in external marketplaces. | - | - | `[{'name':'OpenSea','collection_url':'https://opensea.io/assets/ethereum/{hash}','instance_url':'https://opensea.io/assets/ethereum/{hash}/{id}','logo_url':'https://opensea.io/static/images/logos/opensea-logo.svg'}]` | v1.15.0+ |
| NEXT_PUBLIC_HELIA_VERIFIED_FETCH_ENABLED | `boolean` | Indicates that the [Helia verified fetch](https://github.com/ipfs/helia-verified-fetch/tree/main/packages/verified-fetch) should be used for retrieving content of NFT assets (currently limited to images) directly from IPFS network using trustless gateways. | - | `true` | `false` | v1.37.0+ |
##### NFT marketplace properties ##### NFT marketplace properties
| Variable | Type| Description | Compulsoriness | Default value | Example value | | Variable | Type| Description | Compulsoriness | Default value | Example value |
......
...@@ -184,22 +184,27 @@ const nftInstance = { ...@@ -184,22 +184,27 @@ const nftInstance = {
value: '11', value: '11',
}; };
const nftInstanceWithoutImage = {
...nftInstance,
image_url: null,
};
export const collections: AddressCollectionsResponse = { export const collections: AddressCollectionsResponse = {
items: [ items: [
{ {
token: tokens.tokenInfoERC1155a, token: tokens.tokenInfoERC1155a,
amount: '100', amount: '100',
token_instances: Array(5).fill(nftInstance), token_instances: Array(5).fill(nftInstanceWithoutImage),
}, },
{ {
token: tokens.tokenInfoERC20LongSymbol, token: tokens.tokenInfoERC20LongSymbol,
amount: '100', amount: '100',
token_instances: Array(5).fill(nftInstance), token_instances: Array(5).fill(nftInstanceWithoutImage),
}, },
{ {
token: tokens.tokenInfoERC1155WithoutName, token: tokens.tokenInfoERC1155WithoutName,
amount: '1', amount: '1',
token_instances: [ nftInstance ], token_instances: [ nftInstanceWithoutImage ],
}, },
], ],
next_page_params: { next_page_params: {
......
...@@ -11,6 +11,7 @@ function generateCspPolicy() { ...@@ -11,6 +11,7 @@ function generateCspPolicy() {
descriptors.googleFonts(), descriptors.googleFonts(),
descriptors.googleReCaptcha(), descriptors.googleReCaptcha(),
descriptors.growthBook(), descriptors.growthBook(),
descriptors.helia(),
descriptors.marketplace(), descriptors.marketplace(),
descriptors.mixpanel(), descriptors.mixpanel(),
descriptors.monaco(), descriptors.monaco(),
......
...@@ -123,6 +123,7 @@ export function app(): CspDev.DirectiveDescriptor { ...@@ -123,6 +123,7 @@ export function app(): CspDev.DirectiveDescriptor {
], ],
'media-src': [ 'media-src': [
KEY_WORDS.BLOB,
'*', // see comment for img-src directive '*', // see comment for img-src directive
], ],
......
import type CspDev from 'csp-dev';
import config from 'configs/app';
export function helia(): CspDev.DirectiveDescriptor {
if (!config.UI.views.nft.verifiedFetch.isEnabled) {
return {};
}
return {
'connect-src': [
'https://delegated-ipfs.dev',
'https://trustless-gateway.link',
],
};
}
...@@ -6,6 +6,7 @@ export { googleAnalytics } from './googleAnalytics'; ...@@ -6,6 +6,7 @@ export { googleAnalytics } from './googleAnalytics';
export { googleFonts } from './googleFonts'; export { googleFonts } from './googleFonts';
export { googleReCaptcha } from './googleReCaptcha'; export { googleReCaptcha } from './googleReCaptcha';
export { growthBook } from './growthBook'; export { growthBook } from './growthBook';
export { helia } from './helia';
export { marketplace } from './marketplace'; export { marketplace } from './marketplace';
export { mixpanel } from './mixpanel'; export { mixpanel } from './mixpanel';
export { monaco } from './monaco'; export { monaco } from './monaco';
......
...@@ -29,8 +29,7 @@ const NFTItem = ({ token, value, isLoading, withTokenLink, ...tokenInstance }: P ...@@ -29,8 +29,7 @@ const NFTItem = ({ token, value, isLoading, withTokenLink, ...tokenInstance }: P
<Link href={ isLoading ? undefined : tokenInstanceLink }> <Link href={ isLoading ? undefined : tokenInstanceLink }>
<NftMedia <NftMedia
mb="18px" mb="18px"
animationUrl={ tokenInstance?.animation_url ?? null } data={ tokenInstance }
imageUrl={ tokenInstance?.image_url ?? null }
isLoading={ isLoading } isLoading={ isLoading }
autoplayVideo={ false } autoplayVideo={ false }
/> />
......
...@@ -117,7 +117,11 @@ const TabsWithScroll = ({ ...@@ -117,7 +117,11 @@ const TabsWithScroll = ({
isLoading={ isLoading } isLoading={ isLoading }
/> />
<TabPanels> <TabPanels>
{ tabsList.map((tab) => <TabPanel padding={ 0 } key={ tab.id?.toString() }>{ tab.component }</TabPanel>) } { tabsList.map((tab) => (
<TabPanel padding={ 0 } key={ tab.id?.toString() || (typeof tab.title === 'string' ? tab.title : undefined) }>
{ tab.component }
</TabPanel>
)) }
</TabPanels> </TabPanels>
</Tabs> </Tabs>
); );
......
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenInstance } from 'types/api/token';
import { test, expect } from 'playwright/lib'; import { test, expect } from 'playwright/lib';
import NftMedia from './NftMedia'; import NftMedia from './NftMedia';
...@@ -8,14 +10,23 @@ import NftMedia from './NftMedia'; ...@@ -8,14 +10,23 @@ import NftMedia from './NftMedia';
test.describe('no url', () => { test.describe('no url', () => {
test.use({ viewport: { width: 250, height: 250 } }); test.use({ viewport: { width: 250, height: 250 } });
test('preview +@dark-mode', async({ render }) => { test('preview +@dark-mode', async({ render }) => {
const component = await render(<NftMedia animationUrl={ null } imageUrl={ null }/>); const data = {
image_url: null,
animation_url: null,
} as TokenInstance;
const component = await render(<NftMedia data={ data }/>);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('with fallback', async({ render, mockAssetResponse }) => { test('with fallback', async({ render, mockAssetResponse }) => {
const IMAGE_URL = 'https://localhost:3000/my-image.jpg'; const IMAGE_URL = 'https://localhost:3000/my-image.jpg';
const data = {
image_url: IMAGE_URL,
animation_url: null,
} as TokenInstance;
await mockAssetResponse(IMAGE_URL, './playwright/mocks/image_long.jpg'); await mockAssetResponse(IMAGE_URL, './playwright/mocks/image_long.jpg');
const component = await render(<NftMedia animationUrl={ null } imageUrl={ IMAGE_URL }/>); const component = await render(<NftMedia data={ data }/>);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
...@@ -23,6 +34,10 @@ test.describe('no url', () => { ...@@ -23,6 +34,10 @@ test.describe('no url', () => {
const ANIMATION_URL = 'https://localhost:3000/my-animation.m3u8'; const ANIMATION_URL = 'https://localhost:3000/my-animation.m3u8';
const ANIMATION_MEDIA_TYPE_API_URL = `/node-api/media-type?url=${ encodeURIComponent(ANIMATION_URL) }`; const ANIMATION_MEDIA_TYPE_API_URL = `/node-api/media-type?url=${ encodeURIComponent(ANIMATION_URL) }`;
const IMAGE_URL = 'https://localhost:3000/my-image.jpg'; const IMAGE_URL = 'https://localhost:3000/my-image.jpg';
const data = {
animation_url: ANIMATION_URL,
image_url: IMAGE_URL,
} as TokenInstance;
await page.route(ANIMATION_MEDIA_TYPE_API_URL, (route) => { await page.route(ANIMATION_MEDIA_TYPE_API_URL, (route) => {
return route.fulfill({ return route.fulfill({
...@@ -32,7 +47,7 @@ test.describe('no url', () => { ...@@ -32,7 +47,7 @@ test.describe('no url', () => {
}); });
await mockAssetResponse(IMAGE_URL, './playwright/mocks/image_long.jpg'); await mockAssetResponse(IMAGE_URL, './playwright/mocks/image_long.jpg');
const component = await render(<NftMedia animationUrl={ ANIMATION_URL } imageUrl={ IMAGE_URL }/>); const component = await render(<NftMedia data={ data }/>);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
}); });
...@@ -45,22 +60,34 @@ test.describe('image', () => { ...@@ -45,22 +60,34 @@ test.describe('image', () => {
}); });
test('preview +@dark-mode', async({ render, page }) => { test('preview +@dark-mode', async({ render, page }) => {
const data = {
animation_url: MEDIA_URL,
image_url: null,
} as TokenInstance;
await render( await render(
<Box boxSize="250px"> <Box boxSize="250px">
<NftMedia animationUrl={ MEDIA_URL } imageUrl={ null }/> <NftMedia data={ data }/>
</Box>, </Box>,
); );
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } });
}); });
test('preview hover', async({ render, page }) => { test('preview hover', async({ render, page }) => {
const component = await render(<NftMedia animationUrl={ MEDIA_URL } imageUrl={ null } w="250px"/>); const data = {
animation_url: MEDIA_URL,
image_url: null,
} as TokenInstance;
const component = await render(<NftMedia data={ data } w="250px"/>);
await component.getByAltText('Token instance image').hover(); await component.getByAltText('Token instance image').hover();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } });
}); });
test('fullscreen +@dark-mode +@mobile', async({ render, page }) => { test('fullscreen +@dark-mode +@mobile', async({ render, page }) => {
const component = await render(<NftMedia animationUrl={ MEDIA_URL } imageUrl={ null } withFullscreen w="250px"/>); const data = {
animation_url: MEDIA_URL,
image_url: null,
} as TokenInstance;
const component = await render(<NftMedia data={ data } withFullscreen w="250px"/>);
await component.getByAltText('Token instance image').click(); await component.getByAltText('Token instance image').click();
await expect(page).toHaveScreenshot(); await expect(page).toHaveScreenshot();
}); });
...@@ -81,7 +108,12 @@ test.describe('page', () => { ...@@ -81,7 +108,12 @@ test.describe('page', () => {
}); });
test('preview +@dark-mode', async({ render }) => { test('preview +@dark-mode', async({ render }) => {
const component = await render(<NftMedia animationUrl={ MEDIA_URL } imageUrl={ null }/>); const data = {
animation_url: MEDIA_URL,
image_url: null,
} as TokenInstance;
const component = await render(<NftMedia data={ data }/>);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
}); });
...@@ -2,6 +2,8 @@ import { AspectRatio, chakra, Skeleton, useDisclosure } from '@chakra-ui/react'; ...@@ -2,6 +2,8 @@ import { AspectRatio, chakra, Skeleton, useDisclosure } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import type { TokenInstance } from 'types/api/token';
import NftFallback from './NftFallback'; import NftFallback from './NftFallback';
import NftHtml from './NftHtml'; import NftHtml from './NftHtml';
import NftHtmlFullscreen from './NftHtmlFullscreen'; import NftHtmlFullscreen from './NftHtmlFullscreen';
...@@ -13,21 +15,20 @@ import useNftMediaInfo from './useNftMediaInfo'; ...@@ -13,21 +15,20 @@ import useNftMediaInfo from './useNftMediaInfo';
import { mediaStyleProps } from './utils'; import { mediaStyleProps } from './utils';
interface Props { interface Props {
imageUrl: string | null; data: TokenInstance;
animationUrl: string | null;
className?: string; className?: string;
isLoading?: boolean; isLoading?: boolean;
withFullscreen?: boolean; withFullscreen?: boolean;
autoplayVideo?: boolean; autoplayVideo?: boolean;
} }
const NftMedia = ({ imageUrl, animationUrl, className, isLoading, withFullscreen, autoplayVideo }: Props) => { const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }: 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 mediaInfo = useNftMediaInfo({ imageUrl, animationUrl, isEnabled: !isLoading && inView }); const mediaInfo = useNftMediaInfo({ data, isEnabled: !isLoading && inView });
React.useEffect(() => { React.useEffect(() => {
if (!isLoading && !mediaInfo) { if (!isLoading && !mediaInfo) {
...@@ -57,26 +58,20 @@ const NftMedia = ({ imageUrl, animationUrl, className, isLoading, withFullscreen ...@@ -57,26 +58,20 @@ const NftMedia = ({ imageUrl, animationUrl, className, isLoading, withFullscreen
return <NftFallback { ...styleProps }/>; return <NftFallback { ...styleProps }/>;
} }
const { type, url } = mediaInfo;
if (!url) {
return null;
}
const props = { const props = {
src: url,
onLoad: handleMediaLoaded, onLoad: handleMediaLoaded,
onError: handleMediaLoadError, onError: handleMediaLoadError,
...(withFullscreen ? { onClick: onOpen } : {}), ...(withFullscreen ? { onClick: onOpen } : {}),
}; };
switch (type) { switch (mediaInfo.type) {
case 'video': case 'video': {
return <NftVideo { ...props } autoPlay={ autoplayVideo } poster={ imageUrl || undefined }/>; return <NftVideo { ...props } src={ mediaInfo.src } autoPlay={ autoplayVideo } instance={ data }/>;
}
case 'html': case 'html':
return <NftHtml { ...props }/>; return <NftHtml { ...props } src={ mediaInfo.src }/>;
case 'image': case 'image':
return <NftImage { ...props }/>; return <NftImage { ...props } src={ mediaInfo.src }/>;
default: default:
return null; return null;
} }
...@@ -87,25 +82,18 @@ const NftMedia = ({ imageUrl, animationUrl, className, isLoading, withFullscreen ...@@ -87,25 +82,18 @@ const NftMedia = ({ imageUrl, animationUrl, className, isLoading, withFullscreen
return null; return null;
} }
const { type, url } = mediaInfo;
if (!url) {
return null;
}
const props = { const props = {
src: url,
isOpen, isOpen,
onClose, onClose,
}; };
switch (type) { switch (mediaInfo.type) {
case 'video': case 'video':
return <NftVideoFullscreen { ...props }/>; return <NftVideoFullscreen { ...props } src={ mediaInfo.src }/>;
case 'html': case 'html':
return <NftHtmlFullscreen { ...props }/>; return <NftHtmlFullscreen { ...props } src={ mediaInfo.src }/>;
case 'image': case 'image':
return <NftImageFullscreen { ...props }/>; return <NftImageFullscreen { ...props } src={ mediaInfo.src }/>;
default: default:
return null; return null;
} }
......
import { chakra } from '@chakra-ui/react'; import { chakra } from '@chakra-ui/react';
import { verifiedFetch } from '@helia/verified-fetch';
import React from 'react'; import React from 'react';
import type { TokenInstance } from 'types/api/token';
import config from 'configs/app';
import { mediaStyleProps, videoPlayProps } from './utils'; import { mediaStyleProps, videoPlayProps } from './utils';
interface Props { interface Props {
src: string; src: string;
poster?: string; instance: TokenInstance;
autoPlay?: boolean; autoPlay?: boolean;
onLoad: () => void; onLoad: () => void;
onError: () => void; onError: () => void;
onClick?: () => void; onClick?: () => void;
} }
const NftVideo = ({ src, poster, autoPlay = true, onLoad, onError, onClick }: Props) => { const NftVideo = ({ src, instance, autoPlay = true, onLoad, onError, onClick }: Props) => {
const ref = React.useRef<HTMLVideoElement>(null); const ref = React.useRef<HTMLVideoElement>(null);
const controller = React.useRef<AbortController | null>(null);
const fetchVideoPoster = React.useCallback(async() => {
if (!ref.current) {
return;
}
try {
if (!config.UI.views.nft.verifiedFetch.isEnabled) {
throw new Error('Helia verified fetch is disabled');
}
const imageUrl = typeof instance.metadata?.image === 'string' ? instance.metadata.image : undefined;
if (!imageUrl) {
throw new Error('No image URL found');
}
controller.current = new AbortController();
const response = await verifiedFetch(imageUrl, { signal: controller.current.signal });
const blob = await response.blob();
const src = URL.createObjectURL(blob);
ref.current.poster = src;
// we want to call onLoad right after the poster is loaded
// otherwise, the skeleton will be shown underneath the element until the video is loaded
onLoad();
} catch (error) {
if (instance.image_url) {
ref.current.poster = instance.image_url;
// we want to call onLoad right after the poster is loaded
// otherwise, the skeleton will be shown underneath the element until the video is loaded
const poster = new Image();
poster.src = ref.current.poster;
poster.onload = onLoad;
}
}
}, [ instance.image_url, instance.metadata?.image, onLoad ]);
React.useEffect(() => {
fetchVideoPoster();
return () => {
controller.current?.abort();
};
// run only on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleMouseEnter = React.useCallback(() => { const handleMouseEnter = React.useCallback(() => {
!autoPlay && ref.current?.play(); !autoPlay && ref.current?.play();
...@@ -28,7 +78,6 @@ const NftVideo = ({ src, poster, autoPlay = true, onLoad, onError, onClick }: Pr ...@@ -28,7 +78,6 @@ const NftVideo = ({ src, poster, autoPlay = true, onLoad, onError, onClick }: Pr
ref={ ref } ref={ ref }
{ ...videoPlayProps } { ...videoPlayProps }
autoPlay={ autoPlay } autoPlay={ autoPlay }
poster={ poster }
src={ src } src={ src }
onCanPlayThrough={ onLoad } onCanPlayThrough={ onLoad }
onError={ onError } onError={ onError }
......
import { verifiedFetch } from '@helia/verified-fetch';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import React from 'react'; import React from 'react';
import type { TokenInstance } from 'types/api/token';
import type { StaticRoute } from 'nextjs-routes'; import type { StaticRoute } from 'nextjs-routes';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import useFetch from 'lib/hooks/useFetch'; import useFetch from 'lib/hooks/useFetch';
...@@ -11,49 +15,103 @@ import type { MediaType } from './utils'; ...@@ -11,49 +15,103 @@ import type { MediaType } from './utils';
import { getPreliminaryMediaType } from './utils'; import { getPreliminaryMediaType } from './utils';
interface Params { interface Params {
imageUrl: string | null; data: TokenInstance;
animationUrl: string | null;
isEnabled: boolean; isEnabled: boolean;
} }
interface ReturnType { interface AssetsData {
type: MediaType | undefined; imageUrl: string | undefined;
url: string | null; animationUrl: string | undefined;
} }
export default function useNftMediaInfo({ imageUrl, animationUrl, isEnabled }: Params): ReturnType | null { type TransportType = 'http' | 'ipfs';
const primaryQuery = useNftMediaTypeQuery(animationUrl, isEnabled); type ReturnType =
const secondaryQuery = useNftMediaTypeQuery(imageUrl, isEnabled && !primaryQuery.isPending && !primaryQuery.data); {
type: MediaType;
src: string;
} |
{
type: undefined;
} |
null;
export default function useNftMediaInfo({ data, isEnabled }: Params): ReturnType | null {
const assetsData = composeAssetsData(data);
const httpPrimaryQuery = useNftMediaTypeQuery(assetsData.http.animationUrl, isEnabled);
const ipfsPrimaryQuery = useFetchAssetViaIpfs(
assetsData.ipfs.animationUrl,
httpPrimaryQuery.data?.type,
isEnabled && (httpPrimaryQuery.data === null || Boolean(httpPrimaryQuery.data?.type)),
);
const httpSecondaryQuery = useNftMediaTypeQuery(assetsData.http.imageUrl, isEnabled && !httpPrimaryQuery.data && !ipfsPrimaryQuery);
const ipfsSecondaryQuery = useFetchAssetViaIpfs(
assetsData.ipfs.imageUrl,
httpSecondaryQuery.data?.type,
isEnabled && (httpSecondaryQuery.data === null || Boolean(httpSecondaryQuery.data?.type)),
);
return React.useMemo(() => { return React.useMemo(() => {
if (primaryQuery.isPending) { return ipfsPrimaryQuery || httpPrimaryQuery.data || ipfsSecondaryQuery || httpSecondaryQuery.data || null;
}, [ httpPrimaryQuery.data, httpSecondaryQuery.data, ipfsPrimaryQuery, ipfsSecondaryQuery ]);
}
function composeAssetsData(data: TokenInstance): Record<TransportType, AssetsData> {
return { return {
type: undefined, http: {
url: animationUrl, imageUrl: data.image_url || undefined,
animationUrl: data.animation_url || undefined,
},
ipfs: {
imageUrl: typeof data.metadata?.image === 'string' ? data.metadata.image : undefined,
animationUrl: typeof data.metadata?.animation_url === 'string' ? data.metadata.animation_url : undefined,
},
}; };
} }
if (primaryQuery.data) { // As of now we fetch only images via IPFS because video streaming has performance issues
return primaryQuery.data; // Also, we don't want to store the entire file content in the ReactQuery cache, so we don't use useQuery hook here
} function useFetchAssetViaIpfs(url: string | undefined, type: MediaType | undefined, isEnabled: boolean): ReturnType | null {
const [ result, setResult ] = React.useState<ReturnType | null>({ type: undefined });
const controller = React.useRef<AbortController | null>(null);
if (secondaryQuery.isPending) { const fetchAsset = React.useCallback(async(url: string) => {
return { try {
type: undefined, controller.current = new AbortController();
url: imageUrl, const response = await verifiedFetch(url, { signal: controller.current.signal });
}; if (response.status === 200) {
const blob = await response.blob();
const src = URL.createObjectURL(blob);
setResult({ type: 'image', src });
return;
} }
} catch (error) {}
if (secondaryQuery.data) { setResult(null);
return secondaryQuery.data; }, []);
React.useEffect(() => {
if (isEnabled) {
if (config.UI.views.nft.verifiedFetch.isEnabled && type === 'image' && url && url.includes('ipfs')) {
fetchAsset(url);
} else {
setResult(null);
} }
} else {
setResult({ type: undefined });
}
}, [ fetchAsset, url, type, isEnabled ]);
return null; React.useEffect(() => {
}, [ animationUrl, imageUrl, primaryQuery.data, primaryQuery.isPending, secondaryQuery.data, secondaryQuery.isPending ]); return () => {
controller.current?.abort();
};
}, []);
return result;
} }
function useNftMediaTypeQuery(url: string | null, enabled: boolean) { function useNftMediaTypeQuery(url: string | undefined, enabled: boolean) {
const fetch = useFetch(); const fetch = useFetch();
return useQuery<unknown, ResourceError<unknown>, ReturnType | null>({ return useQuery<unknown, ResourceError<unknown>, ReturnType | null>({
...@@ -72,7 +130,7 @@ function useNftMediaTypeQuery(url: string | null, enabled: boolean) { ...@@ -72,7 +130,7 @@ function useNftMediaTypeQuery(url: string | null, enabled: boolean) {
const preliminaryType = getPreliminaryMediaType(url); const preliminaryType = getPreliminaryMediaType(url);
if (preliminaryType) { if (preliminaryType) {
return { type: preliminaryType, url }; return { type: preliminaryType, src: url };
} }
const type = await (async() => { const type = await (async() => {
...@@ -90,9 +148,10 @@ function useNftMediaTypeQuery(url: string | null, enabled: boolean) { ...@@ -90,9 +148,10 @@ function useNftMediaTypeQuery(url: string | null, enabled: boolean) {
return null; return null;
} }
return { type, url }; return { type, src: url };
}, },
enabled, enabled,
placeholderData: { type: undefined },
staleTime: Infinity, staleTime: Infinity,
}); });
} }
...@@ -20,8 +20,7 @@ const TokenInventoryItem = ({ item, token, isLoading }: Props) => { ...@@ -20,8 +20,7 @@ const TokenInventoryItem = ({ item, token, isLoading }: Props) => {
const mediaElement = ( const mediaElement = (
<NftMedia <NftMedia
mb="18px" mb="18px"
animationUrl={ item.animation_url } data={ item }
imageUrl={ item.image_url }
isLoading={ isLoading } isLoading={ isLoading }
autoplayVideo={ false } autoplayVideo={ false }
/> />
......
...@@ -110,13 +110,12 @@ const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => { ...@@ -110,13 +110,12 @@ const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => {
) } ) }
</Grid> </Grid>
<NftMedia <NftMedia
animationUrl={ data.animation_url } data={ data }
imageUrl={ data.image_url } isLoading={ isLoading }
withFullscreen
w="250px" w="250px"
flexShrink={ 0 } flexShrink={ 0 }
alignSelf={{ base: 'center', lg: 'flex-start' }} alignSelf={{ base: 'center', lg: 'flex-start' }}
isLoading={ isLoading }
withFullscreen
/> />
</Flex> </Flex>
<Grid <Grid
......
...@@ -39,7 +39,7 @@ const MetadataItemPrimitive = ({ name, value, isItem = true, isFlat, level }: Pr ...@@ -39,7 +39,7 @@ const MetadataItemPrimitive = ({ name, value, isItem = true, isFlat, level }: Pr
})(); })();
return ( return (
<Component level={ level } isFlat={ isFlat }> <Component level={ level } { ...(isItem ? { isFlat } : {}) }>
{ name && <MetadataAccordionItemTitle name={ name }/> } { name && <MetadataAccordionItemTitle name={ name }/> }
{ content } { content }
</Component> </Component>
......
This diff is collapsed.
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