Skip to content

Commit

Permalink
Try @helia/verified-fetch for fetching NFT images from IPFS (#2363)
Browse files Browse the repository at this point in the history
* prototype

* fetch video poster from ipfs

* add a constraint to the asset type

* add ENV variable

* fix tests

* [skip ci] clean up
  • Loading branch information
tom2drum authored Nov 25, 2024
1 parent ad1c4b2 commit c435980
Show file tree
Hide file tree
Showing 25 changed files with 1,374 additions and 89 deletions.
3 changes: 3 additions & 0 deletions configs/app/ui/views/nft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
1 change: 1 addition & 0 deletions configs/envs/.env.pw
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions deploy/tools/envs-validator/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions deploy/tools/envs-validator/test/.env.base
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/ENVS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
11 changes: 8 additions & 3 deletions mocks/address/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions nextjs/csp/generateCspPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ function generateCspPolicy() {
descriptors.googleFonts(),
descriptors.googleReCaptcha(),
descriptors.growthBook(),
descriptors.helia(),
descriptors.marketplace(),
descriptors.mixpanel(),
descriptors.monaco(),
Expand Down
1 change: 1 addition & 0 deletions nextjs/csp/policies/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export function app(): CspDev.DirectiveDescriptor {
],

'media-src': [
KEY_WORDS.BLOB,
'*', // see comment for img-src directive
],

Expand Down
16 changes: 16 additions & 0 deletions nextjs/csp/policies/helia.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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',
],
};
}
1 change: 1 addition & 0 deletions nextjs/csp/policies/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@emotion/react": "^11.10.4",
"@emotion/styled": "^11.10.4",
"@growthbook/growthbook-react": "0.21.0",
"@helia/verified-fetch": "2.0.1",
"@hypelab/sdk-react": "^1.0.0",
"@metamask/post-message-stream": "^7.0.0",
"@metamask/providers": "^10.2.1",
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.
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.
3 changes: 1 addition & 2 deletions ui/address/tokens/NFTItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
/>
Expand Down
6 changes: 5 additions & 1 deletion ui/shared/Tabs/TabsWithScroll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
);
Expand Down
46 changes: 39 additions & 7 deletions ui/shared/nft/NftMedia.pw.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,43 @@
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';

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

test('non-media url and fallback', async({ render, page, mockAssetResponse }) => {
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({
Expand All @@ -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();
});
});
Expand All @@ -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();
});
Expand All @@ -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();
});
});
42 changes: 15 additions & 27 deletions ui/shared/nft/NftMedia.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down
Loading

0 comments on commit c435980

Please sign in to comment.