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';
const config = Object.freeze({
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;
......@@ -56,3 +56,4 @@ NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY=xxx
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT=['base16','bech32']
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
.transform(replaceQuotes)
.json()
.of(nftMarketplaceSchema),
NEXT_PUBLIC_HELIA_VERIFIED_FETCH_ENABLED: yup.boolean(),
// e. misc
NEXT_PUBLIC_NETWORK_EXPLORERS: yup
......
......@@ -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_FOOTER_LINKS=https://example.com
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d
NEXT_PUBLIC_HELIA_VERIFIED_FETCH_ENABLED=false
NEXT_PUBLIC_HIDE_INDEXING_ALERT_BLOCKS=false
NEXT_PUBLIC_HIDE_INDEXING_ALERT_INT_TXS=false
NEXT_PUBLIC_MAX_CONTENT_WIDTH_ENABLED=false
......
......@@ -279,7 +279,7 @@ Settings for meta tags, OG tags and SEO
| 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_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
| Variable | Type| Description | Compulsoriness | Default value | Example value |
......
......@@ -184,22 +184,27 @@ const nftInstance = {
value: '11',
};
const nftInstanceWithoutImage = {
...nftInstance,
image_url: null,
};
export const collections: AddressCollectionsResponse = {
items: [
{
token: tokens.tokenInfoERC1155a,
amount: '100',
token_instances: Array(5).fill(nftInstance),
token_instances: Array(5).fill(nftInstanceWithoutImage),
},
{
token: tokens.tokenInfoERC20LongSymbol,
amount: '100',
token_instances: Array(5).fill(nftInstance),
token_instances: Array(5).fill(nftInstanceWithoutImage),
},
{
token: tokens.tokenInfoERC1155WithoutName,
amount: '1',
token_instances: [ nftInstance ],
token_instances: [ nftInstanceWithoutImage ],
},
],
next_page_params: {
......
......@@ -11,6 +11,7 @@ function generateCspPolicy() {
descriptors.googleFonts(),
descriptors.googleReCaptcha(),
descriptors.growthBook(),
descriptors.helia(),
descriptors.marketplace(),
descriptors.mixpanel(),
descriptors.monaco(),
......
......@@ -123,6 +123,7 @@ export function app(): CspDev.DirectiveDescriptor {
],
'media-src': [
KEY_WORDS.BLOB,
'*', // 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';
export { googleFonts } from './googleFonts';
export { googleReCaptcha } from './googleReCaptcha';
export { growthBook } from './growthBook';
export { helia } from './helia';
export { marketplace } from './marketplace';
export { mixpanel } from './mixpanel';
export { monaco } from './monaco';
......
......@@ -29,8 +29,7 @@ const NFTItem = ({ token, value, isLoading, withTokenLink, ...tokenInstance }: P
<Link href={ isLoading ? undefined : tokenInstanceLink }>
<NftMedia
mb="18px"
animationUrl={ tokenInstance?.animation_url ?? null }
imageUrl={ tokenInstance?.image_url ?? null }
data={ tokenInstance }
isLoading={ isLoading }
autoplayVideo={ false }
/>
......
......@@ -117,7 +117,11 @@ const TabsWithScroll = ({
isLoading={ isLoading }
/>
<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>
</Tabs>
);
......
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { TokenInstance } from 'types/api/token';
import { test, expect } from 'playwright/lib';
import NftMedia from './NftMedia';
......@@ -8,14 +10,23 @@ import NftMedia from './NftMedia';
test.describe('no url', () => {
test.use({ viewport: { width: 250, height: 250 } });
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();
});
test('with fallback', async({ render, mockAssetResponse }) => {
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');
const component = await render(<NftMedia animationUrl={ null } imageUrl={ IMAGE_URL }/>);
const component = await render(<NftMedia data={ data }/>);
await expect(component).toHaveScreenshot();
});
......@@ -23,6 +34,10 @@ test.describe('no url', () => {
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';
const data = {
animation_url: ANIMATION_URL,
image_url: IMAGE_URL,
} as TokenInstance;
await page.route(ANIMATION_MEDIA_TYPE_API_URL, (route) => {
return route.fulfill({
......@@ -32,7 +47,7 @@ test.describe('no url', () => {
});
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();
});
});
......@@ -45,22 +60,34 @@ test.describe('image', () => {
});
test('preview +@dark-mode', async({ render, page }) => {
const data = {
animation_url: MEDIA_URL,
image_url: null,
} as TokenInstance;
await render(
<Box boxSize="250px">
<NftMedia animationUrl={ MEDIA_URL } imageUrl={ null }/>
<NftMedia data={ data }/>
</Box>,
);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } });
});
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 expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } });
});
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 expect(page).toHaveScreenshot();
});
......@@ -81,7 +108,12 @@ test.describe('page', () => {
});
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();
});
});
......@@ -2,6 +2,8 @@ import { AspectRatio, chakra, Skeleton, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import { useInView } from 'react-intersection-observer';
import type { TokenInstance } from 'types/api/token';
import NftFallback from './NftFallback';
import NftHtml from './NftHtml';
import NftHtmlFullscreen from './NftHtmlFullscreen';
......@@ -13,21 +15,20 @@ import useNftMediaInfo from './useNftMediaInfo';
import { mediaStyleProps } from './utils';
interface Props {
imageUrl: string | null;
animationUrl: string | null;
data: TokenInstance;
className?: string;
isLoading?: boolean;
withFullscreen?: 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 [ isLoadingError, setIsLoadingError ] = React.useState(false);
const { ref, inView } = useInView({ triggerOnce: true });
const mediaInfo = useNftMediaInfo({ imageUrl, animationUrl, isEnabled: !isLoading && inView });
const mediaInfo = useNftMediaInfo({ data, isEnabled: !isLoading && inView });
React.useEffect(() => {
if (!isLoading && !mediaInfo) {
......@@ -57,26 +58,20 @@ const NftMedia = ({ imageUrl, animationUrl, className, isLoading, withFullscreen
return <NftFallback { ...styleProps }/>;
}
const { type, url } = mediaInfo;
if (!url) {
return null;
}
const props = {
src: url,
onLoad: handleMediaLoaded,
onError: handleMediaLoadError,
...(withFullscreen ? { onClick: onOpen } : {}),
};
switch (type) {
case 'video':
return <NftVideo { ...props } autoPlay={ autoplayVideo } poster={ imageUrl || undefined }/>;
switch (mediaInfo.type) {
case 'video': {
return <NftVideo { ...props } src={ mediaInfo.src } autoPlay={ autoplayVideo } instance={ data }/>;
}
case 'html':
return <NftHtml { ...props }/>;
return <NftHtml { ...props } src={ mediaInfo.src }/>;
case 'image':
return <NftImage { ...props }/>;
return <NftImage { ...props } src={ mediaInfo.src }/>;
default:
return null;
}
......@@ -87,25 +82,18 @@ const NftMedia = ({ imageUrl, animationUrl, className, isLoading, withFullscreen
return null;
}
const { type, url } = mediaInfo;
if (!url) {
return null;
}
const props = {
src: url,
isOpen,
onClose,
};
switch (type) {
switch (mediaInfo.type) {
case 'video':
return <NftVideoFullscreen { ...props }/>;
return <NftVideoFullscreen { ...props } src={ mediaInfo.src }/>;
case 'html':
return <NftHtmlFullscreen { ...props }/>;
return <NftHtmlFullscreen { ...props } src={ mediaInfo.src }/>;
case 'image':
return <NftImageFullscreen { ...props }/>;
return <NftImageFullscreen { ...props } src={ mediaInfo.src }/>;
default:
return null;
}
......
import { chakra } from '@chakra-ui/react';
import { verifiedFetch } from '@helia/verified-fetch';
import React from 'react';
import type { TokenInstance } from 'types/api/token';
import config from 'configs/app';
import { mediaStyleProps, videoPlayProps } from './utils';
interface Props {
src: string;
poster?: string;
instance: TokenInstance;
autoPlay?: boolean;
onLoad: () => void;
onError: () => 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 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(() => {
!autoPlay && ref.current?.play();
......@@ -28,7 +78,6 @@ const NftVideo = ({ src, poster, autoPlay = true, onLoad, onError, onClick }: Pr
ref={ ref }
{ ...videoPlayProps }
autoPlay={ autoPlay }
poster={ poster }
src={ src }
onCanPlayThrough={ onLoad }
onError={ onError }
......
import { verifiedFetch } from '@helia/verified-fetch';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { TokenInstance } from 'types/api/token';
import type { StaticRoute } from 'nextjs-routes';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import useFetch from 'lib/hooks/useFetch';
......@@ -11,49 +15,103 @@ import type { MediaType } from './utils';
import { getPreliminaryMediaType } from './utils';
interface Params {
imageUrl: string | null;
animationUrl: string | null;
data: TokenInstance;
isEnabled: boolean;
}
interface ReturnType {
type: MediaType | undefined;
url: string | null;
interface AssetsData {
imageUrl: string | undefined;
animationUrl: string | undefined;
}
export default function useNftMediaInfo({ imageUrl, animationUrl, isEnabled }: Params): ReturnType | null {
const primaryQuery = useNftMediaTypeQuery(animationUrl, isEnabled);
const secondaryQuery = useNftMediaTypeQuery(imageUrl, isEnabled && !primaryQuery.isPending && !primaryQuery.data);
type TransportType = 'http' | 'ipfs';
type ReturnType =
{
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(() => {
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 {
type: undefined,
url: animationUrl,
http: {
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) {
return primaryQuery.data;
}
// As of now we fetch only images via IPFS because video streaming has performance issues
// 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) {
return {
type: undefined,
url: imageUrl,
};
const fetchAsset = React.useCallback(async(url: string) => {
try {
controller.current = new AbortController();
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;
}
if (secondaryQuery.data) {
return secondaryQuery.data;
} catch (error) {}
setResult(null);
}, []);
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;
}, [ animationUrl, imageUrl, primaryQuery.data, primaryQuery.isPending, secondaryQuery.data, secondaryQuery.isPending ]);
React.useEffect(() => {
return () => {
controller.current?.abort();
};
}, []);
return result;
}
function useNftMediaTypeQuery(url: string | null, enabled: boolean) {
function useNftMediaTypeQuery(url: string | undefined, enabled: boolean) {
const fetch = useFetch();
return useQuery<unknown, ResourceError<unknown>, ReturnType | null>({
......@@ -72,7 +130,7 @@ function useNftMediaTypeQuery(url: string | null, enabled: boolean) {
const preliminaryType = getPreliminaryMediaType(url);
if (preliminaryType) {
return { type: preliminaryType, url };
return { type: preliminaryType, src: url };
}
const type = await (async() => {
......@@ -90,9 +148,10 @@ function useNftMediaTypeQuery(url: string | null, enabled: boolean) {
return null;
}
return { type, url };
return { type, src: url };
},
enabled,
placeholderData: { type: undefined },
staleTime: Infinity,
});
}
......@@ -20,8 +20,7 @@ const TokenInventoryItem = ({ item, token, isLoading }: Props) => {
const mediaElement = (
<NftMedia
mb="18px"
animationUrl={ item.animation_url }
imageUrl={ item.image_url }
data={ item }
isLoading={ isLoading }
autoplayVideo={ false }
/>
......
......@@ -110,13 +110,12 @@ const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => {
) }
</Grid>
<NftMedia
animationUrl={ data.animation_url }
imageUrl={ data.image_url }
data={ data }
isLoading={ isLoading }
withFullscreen
w="250px"
flexShrink={ 0 }
alignSelf={{ base: 'center', lg: 'flex-start' }}
isLoading={ isLoading }
withFullscreen
/>
</Flex>
<Grid
......
......@@ -39,7 +39,7 @@ const MetadataItemPrimitive = ({ name, value, isItem = true, isFlat, level }: Pr
})();
return (
<Component level={ level } isFlat={ isFlat }>
<Component level={ level } { ...(isItem ? { isFlat } : {}) }>
{ name && <MetadataAccordionItemTitle name={ name }/> }
{ content }
</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