diff --git a/mocks/tokens/tokenInstance.ts b/mocks/tokens/tokenInstance.ts index 712c466b7d..1dc1b96493 100644 --- a/mocks/tokens/tokenInstance.ts +++ b/mocks/tokens/tokenInstance.ts @@ -73,6 +73,7 @@ export const base: TokenInstance = { name: 'GENESIS #188848, 22a5f8bbb1602995. Blockchain pixel PFP NFT + "on music video" trait inspired by God', }, owner: addressMock.withName, + thumbnails: null, }; export const withRichMetadata: TokenInstance = { diff --git a/stubs/token.ts b/stubs/token.ts index 9f489b1113..c301ff7cd2 100644 --- a/stubs/token.ts +++ b/stubs/token.ts @@ -175,4 +175,5 @@ export const TOKEN_INSTANCE: TokenInstance = { }, owner: ADDRESS_PARAMS, holder_address_hash: ADDRESS_HASH, + thumbnails: null, }; diff --git a/types/api/token.ts b/types/api/token.ts index 98feb08bf4..c223d70a03 100644 --- a/types/api/token.ts +++ b/types/api/token.ts @@ -51,6 +51,8 @@ export type TokenHoldersPagination = { value: string; }; +export type ThumbnailSize = '60x60' | '250x250' | '500x500' | 'original'; + export interface TokenInstance { is_unique: boolean; id: string; @@ -60,6 +62,7 @@ export interface TokenInstance { external_app_url: string | null; metadata: Record | null; owner: AddressParam | null; + thumbnails: Partial> | null; } export interface TokenInstanceMetadataSocketMessage { diff --git a/ui/shared/nft/NftImage.tsx b/ui/shared/nft/NftImage.tsx index a728c93d65..4ec4e9c746 100644 --- a/ui/shared/nft/NftImage.tsx +++ b/ui/shared/nft/NftImage.tsx @@ -5,17 +5,19 @@ import { mediaStyleProps } from './utils'; interface Props { src: string; + srcSet?: string; onLoad: () => void; onError: () => void; onClick?: () => void; } -const NftImage = ({ src, onLoad, onError, onClick }: Props) => { +const NftImage = ({ src, srcSet, onLoad, onError, onClick }: Props) => { return ( Token instance image { await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } }); }); + test('preview with thumbnails', async({ render, page, mockAssetResponse }) => { + const THUMBNAIL_URL = 'https://localhost:3000/my-image-250.jpg'; + const data = { + animation_url: MEDIA_URL, + image_url: null, + thumbnails: { + '500x500': THUMBNAIL_URL, + }, + } as TokenInstance; + await mockAssetResponse(THUMBNAIL_URL, './playwright/mocks/image_md.jpg'); + await render( + + + , + ); + await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } }); + }); + test('preview hover', async({ render, page }) => { const data = { animation_url: MEDIA_URL, diff --git a/ui/shared/nft/NftMedia.tsx b/ui/shared/nft/NftMedia.tsx index 894bbb284f..23ce3a19fd 100644 --- a/ui/shared/nft/NftMedia.tsx +++ b/ui/shared/nft/NftMedia.tsx @@ -64,14 +64,23 @@ const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }: ...(withFullscreen ? { onClick: onOpen } : {}), }; - switch (mediaInfo.type) { + switch (mediaInfo.mediaType) { case 'video': { return ; } case 'html': return ; - case 'image': + case 'image': { + if (mediaInfo.srcType === 'url' && data.thumbnails) { + const srcSet = data.thumbnails['250x250'] && data.thumbnails['500x500'] ? `${ data.thumbnails['500x500'] } 2x` : undefined; + const src = (srcSet ? data.thumbnails['250x250'] : undefined) || data.thumbnails['500x500'] || data.thumbnails.original; + if (src) { + return ; + } + } + return ; + } default: return null; } @@ -87,13 +96,15 @@ const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }: onClose, }; - switch (mediaInfo.type) { + switch (mediaInfo.mediaType) { case 'video': return ; case 'html': return ; - case 'image': - return ; + case 'image': { + const src = mediaInfo.srcType === 'url' && data.thumbnails?.original ? data.thumbnails.original : mediaInfo.src; + return ; + } default: return null; } diff --git a/ui/shared/nft/NftVideo.tsx b/ui/shared/nft/NftVideo.tsx index 1df7565edd..8b352a9271 100644 --- a/ui/shared/nft/NftVideo.tsx +++ b/ui/shared/nft/NftVideo.tsx @@ -44,8 +44,9 @@ const NftVideo = ({ src, instance, autoPlay = true, onLoad, onError, onClick }: // 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; + const src = instance.thumbnails?.['500x500'] || instance.thumbnails?.original || instance.image_url; + if (src) { + 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 @@ -54,10 +55,10 @@ const NftVideo = ({ src, instance, autoPlay = true, onLoad, onError, onClick }: poster.onload = onLoad; } } - }, [ instance.image_url, instance.metadata?.image, onLoad ]); + }, [ instance.image_url, instance.metadata?.image, instance.thumbnails, onLoad ]); React.useEffect(() => { - fetchVideoPoster(); + !autoPlay && fetchVideoPoster(); return () => { controller.current?.abort(); }; diff --git a/ui/shared/nft/__screenshots__/NftMedia.pw.tsx_default_image-preview-with-thumbnails-1.png b/ui/shared/nft/__screenshots__/NftMedia.pw.tsx_default_image-preview-with-thumbnails-1.png new file mode 100644 index 0000000000..e10f86e0c8 Binary files /dev/null and b/ui/shared/nft/__screenshots__/NftMedia.pw.tsx_default_image-preview-with-thumbnails-1.png differ diff --git a/ui/shared/nft/useNftMediaInfo.tsx b/ui/shared/nft/useNftMediaInfo.tsx index b8f426f714..d8fd174262 100644 --- a/ui/shared/nft/useNftMediaInfo.tsx +++ b/ui/shared/nft/useNftMediaInfo.tsx @@ -11,7 +11,7 @@ import config from 'configs/app'; import type { ResourceError } from 'lib/api/resources'; import useFetch from 'lib/hooks/useFetch'; -import type { MediaType } from './utils'; +import type { MediaType, SrcType } from './utils'; import { getPreliminaryMediaType } from './utils'; interface Params { @@ -28,11 +28,12 @@ type TransportType = 'http' | 'ipfs'; type ReturnType = { - type: MediaType; src: string; + mediaType: MediaType; + srcType: SrcType; } | { - type: undefined; + mediaType: undefined; } | null; @@ -42,14 +43,14 @@ export default function useNftMediaInfo({ data, isEnabled }: Params): ReturnType const httpPrimaryQuery = useNftMediaTypeQuery(assetsData.http.animationUrl, isEnabled); const ipfsPrimaryQuery = useFetchAssetViaIpfs( assetsData.ipfs.animationUrl, - httpPrimaryQuery.data?.type, - isEnabled && (httpPrimaryQuery.data === null || Boolean(httpPrimaryQuery.data?.type)), + httpPrimaryQuery.data?.mediaType, + isEnabled && (httpPrimaryQuery.data === null || Boolean(httpPrimaryQuery.data?.mediaType)), ); 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)), + httpSecondaryQuery.data?.mediaType, + isEnabled && (httpSecondaryQuery.data === null || Boolean(httpSecondaryQuery.data?.mediaType)), ); return React.useMemo(() => { @@ -72,8 +73,8 @@ function composeAssetsData(data: TokenInstance): Record({ type: undefined }); +function useFetchAssetViaIpfs(url: string | undefined, mediaType: MediaType | undefined, isEnabled: boolean): ReturnType | null { + const [ result, setResult ] = React.useState({ mediaType: undefined }); const controller = React.useRef(null); const fetchAsset = React.useCallback(async(url: string) => { @@ -83,7 +84,7 @@ function useFetchAssetViaIpfs(url: string | undefined, type: MediaType | undefin if (response.status === 200) { const blob = await response.blob(); const src = URL.createObjectURL(blob); - setResult({ type: 'image', src }); + setResult({ mediaType: 'image', src, srcType: 'blob' }); return; } } catch (error) {} @@ -92,15 +93,15 @@ function useFetchAssetViaIpfs(url: string | undefined, type: MediaType | undefin React.useEffect(() => { if (isEnabled) { - if (config.UI.views.nft.verifiedFetch.isEnabled && type === 'image' && url && url.includes('ipfs')) { + if (config.UI.views.nft.verifiedFetch.isEnabled && mediaType === 'image' && url && url.includes('ipfs')) { fetchAsset(url); } else { setResult(null); } } else { - setResult({ type: undefined }); + setResult({ mediaType: undefined }); } - }, [ fetchAsset, url, type, isEnabled ]); + }, [ fetchAsset, url, mediaType, isEnabled ]); React.useEffect(() => { return () => { @@ -114,7 +115,7 @@ function useFetchAssetViaIpfs(url: string | undefined, type: MediaType | undefin function useNftMediaTypeQuery(url: string | undefined, enabled: boolean) { const fetch = useFetch(); - return useQuery, ReturnType | null>({ + return useQuery, ReturnType | null>({ queryKey: [ 'nft-media-type', url ], queryFn: async() => { if (!url) { @@ -130,10 +131,10 @@ function useNftMediaTypeQuery(url: string | undefined, enabled: boolean) { const preliminaryType = getPreliminaryMediaType(url); if (preliminaryType) { - return { type: preliminaryType, src: url }; + return { mediaType: preliminaryType, src: url, srcType: 'url' }; } - const type = await (async() => { + const mediaType = await (async() => { try { const mediaTypeResourceUrl = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url } }); const response = await fetch<{ type: MediaType | undefined }, ResourceError>(mediaTypeResourceUrl, undefined, { resource: 'media-type' }); @@ -144,14 +145,14 @@ function useNftMediaTypeQuery(url: string | undefined, enabled: boolean) { } })(); - if (!type) { + if (!mediaType) { return null; } - return { type, src: url }; + return { mediaType, src: url, srcType: 'url' }; }, enabled, - placeholderData: { type: undefined }, + placeholderData: { mediaType: undefined }, staleTime: Infinity, }); } diff --git a/ui/shared/nft/utils.ts b/ui/shared/nft/utils.ts index 3c0ce90bac..d788cdb757 100644 --- a/ui/shared/nft/utils.ts +++ b/ui/shared/nft/utils.ts @@ -1,5 +1,7 @@ export type MediaType = 'image' | 'video' | 'html'; +export type SrcType = 'url' | 'blob'; + const IMAGE_EXTENSIONS = [ '.jpg', 'jpeg', '.png',