Skip to content

Commit

Permalink
prototype
Browse files Browse the repository at this point in the history
  • Loading branch information
tom2drum committed Nov 5, 2024
1 parent 8c6d740 commit 94f8add
Show file tree
Hide file tree
Showing 11 changed files with 1,296 additions and 63 deletions.
4 changes: 4 additions & 0 deletions nextjs/csp/policies/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ export function app(): CspDev.DirectiveDescriptor {

// github (spec for api-docs page)
'raw.githubusercontent.com',

'https://delegated-ipfs.dev',
'https://trustless-gateway.link',
].filter(Boolean),

'script-src': [
Expand Down Expand Up @@ -123,6 +126,7 @@ export function app(): CspDev.DirectiveDescriptor {
],

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

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
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();
});
});
24 changes: 13 additions & 11 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,22 +58,23 @@ const NftMedia = ({ imageUrl, animationUrl, className, isLoading, withFullscreen
return <NftFallback { ...styleProps }/>;
}

const { type, url } = mediaInfo;
const { type, src } = mediaInfo;

if (!url) {
if (!src) {
return null;
}

const props = {
src: url,
src,
onLoad: handleMediaLoaded,
onError: handleMediaLoadError,
...(withFullscreen ? { onClick: onOpen } : {}),
};

switch (type) {
case 'video':
return <NftVideo { ...props } autoPlay={ autoplayVideo } poster={ imageUrl || undefined }/>;
// TODO @tom2drum add poster src from ipfs
return <NftVideo { ...props } autoPlay={ autoplayVideo } poster={ data.image_url || undefined }/>;
case 'html':
return <NftHtml { ...props }/>;
case 'image':
Expand All @@ -87,14 +89,14 @@ const NftMedia = ({ imageUrl, animationUrl, className, isLoading, withFullscreen
return null;
}

const { type, url } = mediaInfo;
const { type, src } = mediaInfo;

if (!url) {
if (!src) {
return null;
}

const props = {
src: url,
src,
isOpen,
onClose,
};
Expand Down
113 changes: 85 additions & 28 deletions ui/shared/nft/useNftMediaInfo.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { createVerifiedFetch } from '@helia/verified-fetch';
import { useQuery } from '@tanstack/react-query';
import filetype from 'magic-bytes.js';
import React from 'react';

import type { TokenInstance } from 'types/api/token';

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

Expand All @@ -11,49 +15,101 @@ import type { MediaType } from './utils';
import { getPreliminaryMediaType } from './utils';

interface Params {
imageUrl: string | null;
animationUrl: string | null;
data: TokenInstance;
isEnabled: boolean;
}

interface AssetsData {
imageUrl: string | undefined;
animationUrl: string | undefined;
}

type TransportType = 'http' | 'ipfs';

interface ReturnType {
type: MediaType | undefined;
url: string | null;
src: string | undefined;
}

export default function useNftMediaInfo({ imageUrl, animationUrl, isEnabled }: Params): ReturnType | null {
export default function useNftMediaInfo({ data, isEnabled }: Params): ReturnType | null {

const primaryQuery = useNftMediaTypeQuery(animationUrl, isEnabled);
const secondaryQuery = useNftMediaTypeQuery(imageUrl, isEnabled && !primaryQuery.isPending && !primaryQuery.data);
const assetsData = composeAssetsData(data);
const ipfsPrimaryQuery = useFetchViaIpfs(assetsData.ipfs.animationUrl, isEnabled);
const ipfsSecondaryQuery = useFetchViaIpfs(assetsData.ipfs.imageUrl, isEnabled && !ipfsPrimaryQuery);
const httpPrimaryQuery = useNftMediaTypeQuery(assetsData.http.animationUrl, isEnabled && !ipfsSecondaryQuery);
const httpSecondaryQuery = useNftMediaTypeQuery(assetsData.http.imageUrl, isEnabled && !httpPrimaryQuery.data);

return React.useMemo(() => {
if (primaryQuery.isPending) {
return {
type: undefined,
url: animationUrl,
};
}
return ipfsPrimaryQuery || ipfsSecondaryQuery || httpPrimaryQuery.data || httpSecondaryQuery.data || null;
}, [ httpPrimaryQuery.data, httpSecondaryQuery.data, ipfsPrimaryQuery, ipfsSecondaryQuery ]);
}

if (primaryQuery.data) {
return primaryQuery.data;
}
function composeAssetsData(data: TokenInstance): Record<TransportType, AssetsData> {
return {
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 (secondaryQuery.isPending) {
return {
type: undefined,
url: imageUrl,
};
}
async function ipfsFetch() {
return createVerifiedFetch(undefined, {
contentTypeParser: async(bytes) => {
const result = filetype(bytes);
return result[0]?.mime;
},
});
}

function mapContentTypeToMediaType(contentType: string | null) {
if (!contentType) {
return;
}

if (secondaryQuery.data) {
return secondaryQuery.data;
if (contentType.includes('image')) {
return 'image';
}

if (contentType.includes('video')) {
return 'video';
}
}

function useFetchViaIpfs(url: string | undefined, isEnabled: boolean): ReturnType | null {
const [ result, setResult ] = React.useState<ReturnType | null>({ src: url, type: undefined });

const fetchAsset = React.useCallback(async(url: string) => {
try {
const response = await (await ipfsFetch())(url);
const contentType = response.headers.get('content-type');
const mediaType = mapContentTypeToMediaType(contentType);
if (mediaType) {
const blob = await response.blob();
const src = URL.createObjectURL(blob);
setResult({ type: mediaType, src });
return;
}
} catch (error) {}
setResult(null);
}, []);

React.useEffect(() => {
if (isEnabled) {
url && url.includes('ipfs') ? fetchAsset(url) : setResult(null);
} else {
setResult({ src: url, type: undefined });
}
}, [ fetchAsset, url, isEnabled ]);

return null;
}, [ animationUrl, imageUrl, primaryQuery.data, primaryQuery.isPending, secondaryQuery.data, secondaryQuery.isPending ]);
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>({
Expand All @@ -72,7 +128,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() => {
Expand All @@ -90,9 +146,10 @@ function useNftMediaTypeQuery(url: string | null, enabled: boolean) {
return null;
}

return { type, url };
return { type, src: url };
},
enabled,
placeholderData: { type: undefined, src: url },
staleTime: Infinity,
});
}
Loading

0 comments on commit 94f8add

Please sign in to comment.