Commit 5557c1e1 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Improvements for NFT media (#2643)

* Show NFT image for token transfers

Fixes #2547

* rewrite logic of loading NFT media

* make amends to NftEntity

* show original media size only in the modal

* immutable envs

* tests

* fix fallback condition

* fixes

* fix one more test

* Removing blockscout-ci-cd from helm repo

---------
Co-authored-by: default avatarNick Zenchik <n.zenchik@gmail.com>
parent 7c4792d0
......@@ -21,6 +21,7 @@ on:
- eth_sepolia
- eth_goerli
- filecoin
- immutable
- neon_devnet
- optimism
- optimism_celestia
......
......@@ -21,6 +21,7 @@ on:
- eth_sepolia
- eth_goerli
- filecoin
- immutable
- mekong
- neon_devnet
- optimism
......
......@@ -365,6 +365,7 @@
"celo_alfajores",
"garnet",
"gnosis",
"immutable",
"eth",
"eth_goerli",
"eth_sepolia",
......
# Set of ENVs for Immutable network explorer
# https://explorer.immutable.com
# This is an auto-generated file. To update all values, run "yarn dev:preset:sync --name=immutable"
# Local ENVs
NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_APP_PORT=3000
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
# Instance ENVs
NEXT_PUBLIC_AD_BANNER_PROVIDER=none
NEXT_PUBLIC_AD_TEXT_PROVIDER=none
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_API_BASE_PATH=/
NEXT_PUBLIC_API_HOST=immutable-mainnet.blockscout.com
NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
NEXT_PUBLIC_DEX_POOLS_ENABLED=true
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/immutable-mainnet.json
NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/immutable.json
NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/sherblockHolmesBadge
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0x6166cece570f4731ccc94c2d17d854ce88496cd3b48e03b537959992ab6685c8
NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS=true
NEXT_PUBLIC_HELIA_VERIFIED_FETCH_ENABLED=false
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['no-repeat center/100% 100% url(https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-skins/immutable.jpg)'],'text_color':['rgba(19, 19, 19, 1)']}
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_LOGOUT_URL=https://blockscout-immutable.us.auth0.com/v2/logout
NEXT_PUBLIC_MARKETPLACE_ENABLED=false
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES=['/pools']
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=IMX
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=IMX
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'GeckoTerminal','logo':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/explorer-logos/geckoterminal.png','baseUrl':'https://www.geckoterminal.com/','paths':{'token':'/immutable-zkevm/pools'}}]
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/immutable-short.svg
NEXT_PUBLIC_NETWORK_ID=13371
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/immutable.svg
NEXT_PUBLIC_NETWORK_NAME=Immutable
NEXT_PUBLIC_NETWORK_RPC_URL=https://rpc.immutable.com/
NEXT_PUBLIC_NETWORK_SHORT_NAME=Immutable
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/immutable.png
NEXT_PUBLIC_STATS_API_HOST=https://stats-immutable-mainnet.k8s.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS=["miner"]
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES=[{'name':'Rarible','collection_url':'https://rarible.com/collection/immutablex/{hash}/items','instance_url':'https://rarible.com/token/immutablex/{hash}:{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/rarible.png'}]
NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
\ No newline at end of file
import type { TokenInfo } from 'types/api/token';
import type { TokenTransfer, TokenTransferResponse } from 'types/api/tokenTransfer';
import * as tokenInstanceMock from './tokenInstance';
export const erc20: TokenTransfer = {
from: {
hash: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859',
......@@ -86,6 +88,7 @@ export const erc721: TokenTransfer = {
},
total: {
token_id: '875879856',
token_instance: tokenInstanceMock.base,
},
transaction_hash: '0xf13bc7afe5e02b494dd2f22078381d36a4800ef94a0ccc147431db56c301e6cc',
type: 'token_transfer',
......@@ -135,6 +138,7 @@ export const erc1155A: TokenTransfer = {
token_id: '123',
value: '42',
decimals: null,
token_instance: null,
},
transaction_hash: '0x05d6589367633c032d757a69c5fb16c0e33e3994b0d9d1483f82aeee1f05d746',
type: 'token_minting',
......@@ -151,7 +155,7 @@ export const erc1155B: TokenTransfer = {
name: 'SastanaNFT',
symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY',
},
total: { token_id: '12345678', value: '100000000000000000000', decimals: null },
total: { token_id: '12345678', value: '100000000000000000000', decimals: null, token_instance: null },
};
export const erc1155C: TokenTransfer = {
......@@ -161,7 +165,7 @@ export const erc1155C: TokenTransfer = {
name: 'SastanaNFT',
symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY',
},
total: { token_id: '483200961027732618117991942553110860267520', value: '200000000000000000000', decimals: null },
total: { token_id: '483200961027732618117991942553110860267520', value: '200000000000000000000', decimals: null, token_instance: null },
};
export const erc1155D: TokenTransfer = {
......@@ -171,7 +175,7 @@ export const erc1155D: TokenTransfer = {
name: 'SastanaNFT',
symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY',
},
total: { token_id: '456', value: '42', decimals: null },
total: { token_id: '456', value: '42', decimals: null, token_instance: null },
};
export const erc404A: TokenTransfer = {
......@@ -213,6 +217,7 @@ export const erc404A: TokenTransfer = {
value: '42000000000000000000000000',
decimals: '18',
token_id: null,
token_instance: null,
},
transaction_hash: '0x05d6589367633c032d757a69c5fb16c0e33e3994b0d9d1483f82aeee1f05d746',
type: 'token_transfer',
......@@ -230,7 +235,7 @@ export const erc404B: TokenTransfer = {
name: 'SastanaNFT',
symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY',
},
total: { token_id: '4625304364899952' },
total: { token_id: '4625304364899952', token_instance: null },
};
export const mixTokens: TokenTransferResponse = {
......
......@@ -19,6 +19,7 @@ export const mintToken: TxStateChange = {
direction: 'from',
total: {
token_id: '15077554365819457090226168288698582604878106156134383525616269766016907608065',
token_instance: null,
},
},
],
......@@ -57,6 +58,7 @@ export const receiveMintedToken: TxStateChange = {
direction: 'to',
total: {
token_id: '15077554365819457090226168288698582604878106156134383525616269766016907608065',
token_instance: null,
},
},
],
......
......@@ -110,6 +110,7 @@ export const TOKEN_TRANSFER_ERC_721: TokenTransfer = {
...TOKEN_TRANSFER_ERC_20,
total: {
token_id: '35870',
token_instance: null,
},
token: TOKEN_INFO_ERC_721,
};
......@@ -120,6 +121,7 @@ export const TOKEN_TRANSFER_ERC_1155: TokenTransfer = {
token_id: '35870',
value: '123',
decimals: '18',
token_instance: null,
},
token: TOKEN_INFO_ERC_1155,
};
......@@ -130,6 +132,7 @@ export const TOKEN_TRANSFER_ERC_404: TokenTransfer = {
token_id: '35870',
value: '123',
decimals: '18',
token_instance: null,
},
token: TOKEN_INFO_ERC_404,
};
......
......@@ -32,6 +32,7 @@ export const STATE_CHANGE_TOKEN: TxStateChange = {
direction: 'to',
total: {
token_id: '1621395',
token_instance: null,
},
},
],
......
......@@ -11,9 +11,10 @@ const PRESETS = {
eth: 'https://eth.blockscout.com',
eth_goerli: 'https://eth-goerli.blockscout.com',
eth_sepolia: 'https://eth-sepolia.blockscout.com',
garnet: 'https://explorer.garnetchain.com',
filecoin: 'https://filecoin.blockscout.com',
garnet: 'https://explorer.garnetchain.com',
gnosis: 'https://gnosis.blockscout.com',
immutable: 'https://explorer.immutable.com',
mekong: 'https://mekong.blockscout.com',
neon_devnet: 'https://neon-devnet.blockscout.com',
optimism: 'https://optimism.blockscout.com',
......
......@@ -59,10 +59,12 @@ export interface TokenInstance {
holder_address_hash: string | null;
image_url: string | null;
animation_url: string | null;
media_url?: string | null;
media_type?: string | null;
external_app_url: string | null;
metadata: Record<string, unknown> | null;
owner: AddressParam | null;
thumbnails: Partial<Record<ThumbnailSize, string>> | null;
thumbnails: ({ original: string } & Partial<Record<Exclude<ThumbnailSize, 'original'>, string>>) | null;
}
export interface TokenInstanceMetadataSocketMessage {
......
import type { AddressParam } from './addressParams';
import type { TokenInfo, TokenType } from './token';
import type { TokenInfo, TokenInstance, TokenType } from './token';
export type Erc20TotalPayload = {
decimals: string | null;
......@@ -8,20 +8,24 @@ export type Erc20TotalPayload = {
export type Erc721TotalPayload = {
token_id: string | null;
token_instance: TokenInstance | null;
};
export type Erc1155TotalPayload = {
decimals: string | null;
value: string;
token_id: string | null;
token_instance: TokenInstance | null;
};
export type Erc404TotalPayload = {
decimals: string;
value: string;
token_id: null;
token_instance: TokenInstance | null;
} | {
token_id: string;
token_instance: TokenInstance | null;
};
export type TokenTransfer = (
......
......@@ -31,6 +31,7 @@ const NFTItem = ({ token, value, isLoading, withTokenLink, ...tokenInstance }: P
<NftMedia
mb="18px"
data={ tokenInstance }
size="md"
isLoading={ isLoading }
autoplayVideo={ false }
/>
......
......@@ -97,7 +97,15 @@ const TokenInstanceContent = () => {
{
id: 'token_transfers',
title: 'Token transfers',
component: <TokenTransfer transfersQuery={ transfersQuery } tokenId={ id } tokenQuery={ tokenQuery } shouldRender={ !isLoading }/>,
component: (
<TokenTransfer
transfersQuery={ transfersQuery }
tokenId={ id }
tokenQuery={ tokenQuery }
tokenInstance={ tokenInstanceQuery.data }
shouldRender={ !isLoading }
/>
),
},
shouldFetchHolders ?
{ id: 'holders', title: 'Holders', component: <TokenHolders holdersQuery={ holdersQuery } token={ tokenQuery.data } shouldRender={ !isLoading }/> } :
......
import { Box } from '@chakra-ui/react';
import React from 'react';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import { mixTokens } from 'mocks/tokens/tokenTransfer';
import { test, expect } from 'playwright/lib';
import TokenTransfers from './TokenTransfers';
test('base view +@mobile', async({ render, mockTextAd, mockApiResponse }) => {
test('base view +@mobile', async({ render, mockTextAd, mockApiResponse, mockAssetResponse }) => {
await mockAssetResponse(tokenInstanceMock.base.image_url as string, './playwright/mocks/image_s.jpg');
await mockTextAd();
await mockApiResponse('token_transfers_all', mixTokens, { queryParams: { type: [] } });
const component = await render(<Box pt={{ base: '106px', lg: 0 }}> <TokenTransfers/> </Box>);
......
import { Box } from '@chakra-ui/react';
import React from 'react';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import * as tokenTransferMock from 'mocks/tokens/tokenTransfer';
import { test, expect, devices } from 'playwright/lib';
......@@ -23,7 +24,8 @@ const data = [
tokenTransferMock.erc1155D,
];
test('without tx info', async({ render }) => {
test('without tx info', async({ render, mockAssetResponse }) => {
await mockAssetResponse(tokenInstanceMock.base.image_url as string, './playwright/mocks/image_s.jpg');
const component = await render(
<Box pt={{ base: '134px', lg: 6 }}>
<TokenTransferList
......@@ -36,7 +38,8 @@ test('without tx info', async({ render }) => {
await expect(component).toHaveScreenshot();
});
test('with tx info', async({ render }) => {
test('with tx info', async({ render, mockAssetResponse }) => {
await mockAssetResponse(tokenInstanceMock.base.image_url as string, './playwright/mocks/image_s.jpg');
const component = await render(
<Box pt={{ base: '134px', lg: 6 }}>
<TokenTransferList
......
......@@ -68,7 +68,7 @@ const TokenTransferListItem = ({
) }
</Flex>
{ total && 'token_id' in total && total.token_id !== null && token && (
<NftEntity hash={ token.address } id={ total.token_id } isLoading={ isLoading }/>
<NftEntity hash={ token.address } id={ total.token_id } instance={ total.token_instance } isLoading={ isLoading }/>
) }
{ showTxInfo && txHash && (
<Flex justifyContent="space-between" alignItems="center" lineHeight="24px" width="100%">
......
import { Box } from '@chakra-ui/react';
import React from 'react';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import * as tokenTransferMock from 'mocks/tokens/tokenTransfer';
import { test, expect } from 'playwright/lib';
import TokenTransferTable from './TokenTransferTable';
test('without tx info', async({ render }) => {
test('without tx info', async({ render, mockAssetResponse }) => {
await mockAssetResponse(tokenInstanceMock.base.image_url as string, './playwright/mocks/image_s.jpg');
const component = await render(
<Box pt={{ base: '134px', lg: 6 }}>
<TokenTransferTable
......@@ -20,7 +22,8 @@ test('without tx info', async({ render }) => {
await expect(component).toHaveScreenshot();
});
test('with tx info', async({ render }) => {
test('with tx info', async({ render, mockAssetResponse }) => {
await mockAssetResponse(tokenInstanceMock.base.image_url as string, './playwright/mocks/image_s.jpg');
const component = await render(
<Box pt={{ base: '134px', lg: 6 }}>
<TokenTransferTable
......
......@@ -75,6 +75,7 @@ const TokenTransferTableItem = ({
<NftEntity
hash={ token.address }
id={ total.token_id }
instance={ total.token_instance }
isLoading={ isLoading }
/>
) }
......
......@@ -44,6 +44,7 @@ const TokenTransferSnippet = ({ data, isLoading, noAddressIcons = true }: Props)
<TokenTransferSnippetNft
token={ data.token }
tokenId={ total.token_id }
instance={ total.token_instance }
value="1"
/>
);
......@@ -56,6 +57,7 @@ const TokenTransferSnippet = ({ data, isLoading, noAddressIcons = true }: Props)
key={ total.token_id }
token={ data.token }
tokenId={ total.token_id }
instance={ total.token_instance }
value={ total.value }
/>
);
......
import { chakra } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfo } from 'types/api/token';
import type { TokenInfo, TokenInstance } from 'types/api/token';
import NftEntity from 'ui/shared/entities/nft/NftEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
......@@ -10,9 +10,10 @@ interface Props {
token: TokenInfo;
value: string;
tokenId: string | null;
instance?: TokenInstance | null;
}
const NftTokenTransferSnippet = ({ value, token, tokenId }: Props) => {
const NftTokenTransferSnippet = ({ value, token, tokenId, instance }: Props) => {
const num = value === '1' ? '' : value;
const tokenIdContent = (() => {
......@@ -28,6 +29,7 @@ const NftTokenTransferSnippet = ({ value, token, tokenId }: Props) => {
<NftEntity
hash={ token.address }
id={ tokenId }
instance={ instance }
fontWeight={ 600 }
icon={{ size: 'md' }}
maxW={{ base: '100%', lg: '150px' }}
......
......@@ -2,20 +2,55 @@ import type { As } from '@chakra-ui/react';
import { chakra } from '@chakra-ui/react';
import React from 'react';
import type { TokenInstance } from 'types/api/token';
import { route } from 'nextjs-routes';
import * as EntityBase from 'ui/shared/entities/base/components';
import NftMedia from 'ui/shared/nft/NftMedia';
import TruncatedValue from 'ui/shared/TruncatedValue';
import { distributeEntityProps } from '../base/utils';
import { distributeEntityProps, getIconProps } from '../base/utils';
const Container = EntityBase.Container;
const Icon = (props: EntityBase.IconBaseProps) => {
type IconProps = EntityBase.IconBaseProps & {
instance?: TokenInstance | null;
};
const ICON_MEDIA_TYPES = [ 'image' as const ];
const Icon = (props: IconProps) => {
if (props.noIcon) {
return null;
}
if (props.instance) {
const styles = getIconProps(props.size ?? 'lg');
const fallback = (
<EntityBase.Icon
{ ...props }
size={ props.size ?? 'lg' }
name={ props.name ?? 'nft_shield' }
marginRight={ 0 }
/>
);
return (
<NftMedia
data={ props.instance }
isLoading={ props.isLoading }
boxSize={ styles.boxSize }
size="sm"
allowedTypes={ ICON_MEDIA_TYPES }
borderRadius="sm"
flexShrink={ 0 }
mr={ 2 }
fallback={ fallback }
/>
);
}
return (
<EntityBase.Icon
{ ...props }
......@@ -54,6 +89,7 @@ const Content = chakra((props: ContentProps) => {
export interface EntityProps extends EntityBase.EntityBaseProps {
hash: string;
id: string;
instance?: TokenInstance | null;
}
const NftEntity = (props: EntityProps) => {
......
import { chakra, LinkOverlay } from '@chakra-ui/react';
import React from 'react';
import { mediaStyleProps } from './utils';
import type { MediaElementProps } from './utils';
interface Props {
src: string;
onLoad: () => void;
onError: () => void;
onClick?: () => void;
}
interface Props extends MediaElementProps<'a'> {}
const NftHtml = ({ src, transport, onLoad, onError, onClick, ...rest }: Props) => {
const ref = React.useRef<HTMLIFrameElement>(null);
const [ isLoaded, setIsLoaded ] = React.useState(false);
const handleLoad = React.useCallback(() => {
setIsLoaded(true);
onLoad?.();
}, [ onLoad ]);
const loadViaHttp = React.useCallback(async() => {
if (!ref.current) {
return;
}
ref.current.src = src;
ref.current.onload = handleLoad;
onError && (ref.current.onerror = onError);
}, [ src, handleLoad, onError ]);
React.useEffect(() => {
switch (transport) {
case 'ipfs': {
// Currently we don't support IPFS video loading
onError?.();
break;
}
case 'http':
loadViaHttp();
break;
}
}, [ loadViaHttp, onError, transport ]);
const NftHtml = ({ src, onLoad, onError, onClick }: Props) => {
return (
<LinkOverlay
onClick={ onClick }
{ ...mediaStyleProps }
{ ...rest }
>
<chakra.iframe
src={ src }
ref={ ref }
h="100%"
w="100%"
sandbox="allow-scripts"
onLoad={ onLoad }
onError={ onError }
opacity={ isLoaded ? 1 : 0 }
/>
</LinkOverlay>
);
......
import { chakra } from '@chakra-ui/react';
import React from 'react';
import NftMediaFullscreenModal from './NftMediaFullscreenModal';
interface Props {
src: string;
isOpen: boolean;
onClose: () => void;
}
const NftHtmlFullscreen = ({ src, isOpen, onClose }: Props) => {
return (
<NftMediaFullscreenModal isOpen={ isOpen } onClose={ onClose }>
<chakra.iframe
w="90vw"
h="90vh"
src={ src }
sandbox="allow-scripts"
/>
</NftMediaFullscreenModal>
);
};
export default NftHtmlFullscreen;
import { Image } from '@chakra-ui/react';
import React from 'react';
import { mediaStyleProps } from './utils';
import useLoadImageViaIpfs from './useLoadImageViaIpfs';
import type { MediaElementProps } from './utils';
interface Props {
src: string;
srcSet?: string;
onLoad: () => void;
onError: () => void;
onClick?: () => void;
}
interface Props extends MediaElementProps<'img'> {}
const NftImage = ({ src, srcSet, onLoad, onError, transport, onClick, ...rest }: Props) => {
const ref = React.useRef<HTMLImageElement>(null);
const [ isLoaded, setIsLoaded ] = React.useState(false);
const handleLoad = React.useCallback(() => {
setIsLoaded(true);
onLoad?.();
}, [ onLoad ]);
const loadImageViaIpfs = useLoadImageViaIpfs();
const loadViaHttp = React.useCallback(async() => {
if (!ref.current) {
return;
}
ref.current.src = src;
srcSet && (ref.current.srcset = srcSet);
ref.current.onload = handleLoad;
onError && (ref.current.onerror = onError);
}, [ src, srcSet, handleLoad, onError ]);
const loadViaIpfs = React.useCallback(() => {
loadImageViaIpfs(src)
.then((src) => {
if (src && ref.current) {
ref.current.src = src;
handleLoad();
}
})
.catch(onError);
}, [ handleLoad, loadImageViaIpfs, onError, src ]);
React.useEffect(() => {
switch (transport) {
case 'ipfs':
loadViaIpfs();
break;
case 'http':
loadViaHttp();
break;
}
}, [ loadViaHttp, loadViaIpfs, transport ]);
const NftImage = ({ src, srcSet, onLoad, onError, onClick }: Props) => {
return (
<Image
ref={ ref }
w="100%"
h="100%"
src={ src }
srcSet={ srcSet }
opacity={ isLoaded ? 1 : 0 }
alt="Token instance image"
onError={ onError }
onLoad={ onLoad }
onClick={ onClick }
{ ...mediaStyleProps }
{ ...rest }
/>
);
};
export default NftImage;
export default React.memo(NftImage);
import {
Image,
} from '@chakra-ui/react';
import React from 'react';
import NftMediaFullscreenModal from './NftMediaFullscreenModal';
interface Props {
src: string;
isOpen: boolean;
onClose: () => void;
}
const NftImageFullscreen = ({ src, isOpen, onClose }: Props) => {
const imgRef = React.useRef<HTMLImageElement>(null);
const [ hasDimensions, setHasDimensions ] = React.useState<boolean>(true);
const checkWidth = React.useCallback(() => {
if (imgRef.current?.getBoundingClientRect().width === 0) {
setHasDimensions(false);
}
}, [ ]);
return (
<NftMediaFullscreenModal isOpen={ isOpen } onClose={ onClose }>
<Image
src={ src }
alt="Token instance image"
maxH="90vh"
maxW="90vw"
ref={ imgRef }
onLoad={ checkWidth }
{ ...(hasDimensions ? {} : { width: '90vw', height: '90vh' }) }
/>
</NftMediaFullscreenModal>
);
};
export default NftImageFullscreen;
......@@ -66,7 +66,7 @@ test.describe('image', () => {
} as TokenInstance;
await render(
<Box boxSize="250px">
<NftMedia data={ data }/>
<NftMedia data={ data } size="md"/>
</Box>,
);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } });
......@@ -84,7 +84,7 @@ test.describe('image', () => {
await mockAssetResponse(THUMBNAIL_URL, './playwright/mocks/image_md.jpg');
await render(
<Box boxSize="250px">
<NftMedia data={ data }/>
<NftMedia data={ data } size="md"/>
</Box>,
);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } });
......@@ -95,7 +95,7 @@ test.describe('image', () => {
animation_url: MEDIA_URL,
image_url: null,
} as TokenInstance;
const component = await render(<NftMedia data={ data } w="250px"/>);
const component = await render(<NftMedia data={ data } w="250px" size="md"/>);
await component.getByAltText('Token instance image').hover();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } });
});
......
......@@ -8,45 +8,64 @@ import Skeleton from 'ui/shared/chakra/Skeleton';
import NftFallback from './NftFallback';
import NftHtml from './NftHtml';
import NftHtmlFullscreen from './NftHtmlFullscreen';
import NftImage from './NftImage';
import NftImageFullscreen from './NftImageFullscreen';
import NftMediaFullscreenModal from './NftMediaFullscreenModal';
import NftVideo from './NftVideo';
import NftVideoFullscreen from './NftVideoFullscreen';
import useNftMediaInfo from './useNftMediaInfo';
import type { MediaType, Size } from './utils';
import { mediaStyleProps } from './utils';
interface Props {
data: TokenInstance;
size?: Size;
allowedTypes?: Array<MediaType>;
className?: string;
isLoading?: boolean;
withFullscreen?: boolean;
autoplayVideo?: boolean;
fallback?: React.ReactNode;
}
const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }: Props) => {
const NftMedia = ({ data, size = 'original', allowedTypes, className, isLoading, withFullscreen, autoplayVideo, fallback }: Props) => {
const [ isMediaLoading, setIsMediaLoading ] = React.useState(true);
const [ isLoadingError, setIsLoadingError ] = React.useState(false);
const [ isMediaLoadingError, setIsMediaLoadingError ] = React.useState(false);
const [ mediaInfoIndex, setMediaInfoIndex ] = React.useState(0);
const [ mediaInfoField, setMediaInfoField ] = React.useState<'animation_url' | 'image_url'>('animation_url');
const { ref, inView } = useInView({ triggerOnce: true });
const mediaInfo = useNftMediaInfo({ data, isEnabled: !isLoading && inView });
const mediaInfoQuery = useNftMediaInfo({ data, size, allowedTypes, field: mediaInfoField, isEnabled: !isLoading && inView });
React.useEffect(() => {
if (!isLoading && !mediaInfo) {
if (!mediaInfoQuery.isPending && !mediaInfoQuery.data) {
if (mediaInfoField === 'animation_url') {
setMediaInfoField('image_url');
} else {
setIsMediaLoadingError(true);
setIsMediaLoading(false);
setIsLoadingError(true);
}
}, [ isLoading, mediaInfo ]);
}
}, [ mediaInfoQuery.isPending, mediaInfoQuery.data, mediaInfoField ]);
const handleMediaLoaded = React.useCallback(() => {
setIsMediaLoading(false);
}, []);
const handleMediaLoadError = React.useCallback(() => {
if (mediaInfoQuery.data) {
if (mediaInfoIndex < mediaInfoQuery.data.length - 1) {
setMediaInfoIndex(mediaInfoIndex + 1);
return;
} else if (mediaInfoField === 'animation_url') {
setMediaInfoField('image_url');
setMediaInfoIndex(0);
return;
}
}
setIsMediaLoading(false);
setIsLoadingError(true);
}, []);
setIsMediaLoadingError(true);
}, [ mediaInfoField, mediaInfoIndex, mediaInfoQuery.data ]);
const { isOpen, onOpen, onClose } = useDisclosure();
......@@ -55,57 +74,27 @@ const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }:
return null;
}
if (!mediaInfo || isLoadingError) {
if (isMediaLoadingError) {
const styleProps = withFullscreen ? {} : mediaStyleProps;
return <NftFallback { ...styleProps }/>;
return fallback ?? <NftFallback { ...styleProps }/>;
}
const mediaInfo = mediaInfoQuery.data?.[mediaInfoIndex];
const props = {
onLoad: handleMediaLoaded,
onError: handleMediaLoadError,
...(withFullscreen ? { onClick: onOpen } : {}),
...(size !== 'sm' ? mediaStyleProps : {}),
};
switch (mediaInfo.mediaType) {
switch (mediaInfo?.mediaType) {
case 'video': {
return <NftVideo { ...props } src={ mediaInfo.src } autoPlay={ autoplayVideo } instance={ data }/>;
}
case 'html':
return <NftHtml { ...props } src={ mediaInfo.src }/>;
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 <NftImage { ...props } src={ src } srcSet={ srcSet }/>;
}
}
return <NftImage { ...props } src={ mediaInfo.src }/>;
}
default:
return null;
}
})();
const modal = (() => {
if (!mediaInfo || !withFullscreen || isLoading) {
return null;
return <NftVideo { ...props } src={ mediaInfo.src } transport={ mediaInfo.transport } autoPlay={ autoplayVideo } instance={ data }/>;
}
const props = {
isOpen,
onClose,
};
switch (mediaInfo.mediaType) {
case 'video':
return <NftVideoFullscreen { ...props } src={ mediaInfo.src }/>;
case 'html':
return <NftHtmlFullscreen { ...props } src={ mediaInfo.src }/>;
return <NftHtml { ...props } src={ mediaInfo.src } transport={ mediaInfo.transport }/>;
case 'image': {
const src = mediaInfo.srcType === 'url' && data.thumbnails?.original ? data.thumbnails.original : mediaInfo.src;
return <NftImageFullscreen { ...props } src={ src }/>;
return <NftImage { ...props } src={ mediaInfo.src } srcSet={ mediaInfo.srcSet } transport={ mediaInfo.transport }/>;
}
default:
return null;
......@@ -113,6 +102,7 @@ const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }:
})();
return (
<>
<AspectRatio
ref={ ref }
className={ className }
......@@ -129,11 +119,14 @@ const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }:
>
<>
{ content }
{ modal }
{ isMediaLoading && <Skeleton position="absolute" left={ 0 } top={ 0 } w="100%" h="100%" zIndex="1"/> }
</>
</AspectRatio>
{ isOpen && (
<NftMediaFullscreenModal isOpen={ isOpen } onClose={ onClose } data={ data } allowedTypes={ allowedTypes } field={ mediaInfoField }/>
) }
</>
);
};
export default chakra(NftMedia);
export default chakra(React.memo(NftMedia));
......@@ -6,19 +6,86 @@ import {
} from '@chakra-ui/react';
import React from 'react';
import type { TokenInstance } from 'types/api/token';
import NftHtml from './NftHtml';
import NftImage from './NftImage';
import NftVideo from './NftVideo';
import useNftMediaInfo from './useNftMediaInfo';
import type { MediaType } from './utils';
interface Props {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
data: TokenInstance;
allowedTypes?: Array<MediaType>;
field: 'animation_url' | 'image_url';
}
const NftMediaFullscreenModal = ({ isOpen, onClose, children }: Props) => {
const NftMediaFullscreenModal = ({ isOpen, onClose, data, allowedTypes, field }: Props) => {
const [ mediaInfoIndex, setMediaInfoIndex ] = React.useState(0);
const mediaInfoQuery = useNftMediaInfo({ data, size: 'original', allowedTypes, field, isEnabled: true });
const handleMediaLoadError = React.useCallback(() => {
if (mediaInfoQuery.data) {
if (mediaInfoIndex < mediaInfoQuery.data.length - 1) {
setMediaInfoIndex(mediaInfoIndex + 1);
return;
}
}
// FIXME: maybe we should display something if the media is not loaded
}, [ mediaInfoIndex, mediaInfoQuery.data ]);
const content = (() => {
const mediaInfo = mediaInfoQuery.data?.[mediaInfoIndex];
switch (mediaInfo?.mediaType) {
case 'video':
return (
<NftVideo
src={ mediaInfo.src }
transport={ mediaInfo.transport }
onError={ handleMediaLoadError }
maxW="90vw"
maxH="90vh"
objectFit="contain"
autoPlay
instance={ data }
/>
);
case 'html':
return (
<NftHtml
src={ mediaInfo.src }
transport={ mediaInfo.transport }
onError={ handleMediaLoadError }
w="90vw"
h="90vh"
/>
);
case 'image':
return (
<NftImage
src={ mediaInfo.src }
srcSet={ mediaInfo.srcSet }
transport={ mediaInfo.transport }
onError={ handleMediaLoadError }
maxW="90vw"
maxH="90vh"
objectFit="contain"/>
);
default:
return null;
}
})();
return (
<Modal isOpen={ isOpen } onClose={ onClose } motionPreset="none">
<ModalOverlay/>
<ModalContent w="unset" maxW="100vw" p={ 0 } background="none" boxShadow="none">
<ModalCloseButton position="fixed" top={{ base: 2.5, lg: 8 }} right={{ base: 2.5, lg: 8 }} color="whiteAlpha.800"/>
{ children }
{ content }
</ModalContent>
</Modal>
);
......
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 useLoadImageViaIpfs from './useLoadImageViaIpfs';
import useNftMediaInfo from './useNftMediaInfo';
import type { MediaElementProps, Size } from './utils';
import { videoPlayProps } from './utils';
import { mediaStyleProps, videoPlayProps } from './utils';
interface Props {
src: string;
interface Props extends MediaElementProps<'video'> {
instance: TokenInstance;
autoPlay?: boolean;
onLoad: () => void;
onError: () => void;
onClick?: () => void;
size?: Size;
}
const NftVideo = ({ src, instance, autoPlay = true, onLoad, onError, onClick }: Props) => {
const POSTER_ALLOWED_TYPES = [ 'image' as const ];
const NftVideo = ({ src, transport, instance, autoPlay = true, onLoad, size = 'original', onError, onClick, ...rest }: Props) => {
const ref = React.useRef<HTMLVideoElement>(null);
const controller = React.useRef<AbortController | null>(null);
const fetchVideoPoster = React.useCallback(async() => {
const mediaInfoQuery = useNftMediaInfo({ data: instance, size, field: 'image_url', isEnabled: true, allowedTypes: POSTER_ALLOWED_TYPES });
const loadImageViaIpfs = useLoadImageViaIpfs();
const handleMouseEnter = React.useCallback(() => {
!autoPlay && ref.current?.play();
}, [ autoPlay ]);
const handleMouseLeave = React.useCallback(() => {
!autoPlay && ref.current?.pause();
}, [ autoPlay ]);
const loadViaHttp = React.useCallback(async() => {
if (!ref.current) {
return;
}
try {
if (!config.UI.views.nft.verifiedFetch.isEnabled) {
throw new Error('Helia verified fetch is disabled');
ref.current.src = src;
onLoad && (ref.current.oncanplaythrough = onLoad);
onError && (ref.current.onerror = onError);
}, [ src, onLoad, onError ]);
React.useEffect(() => {
switch (transport) {
case 'ipfs': {
// Currently we don't support IPFS video loading
onError?.();
break;
}
const imageUrl = typeof instance.metadata?.image === 'string' ? instance.metadata.image : undefined;
if (!imageUrl) {
throw new Error('No image URL found');
case 'http':
loadViaHttp();
break;
}
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;
}, [ loadViaHttp, onError, transport ]);
// 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) {
const src = instance.thumbnails?.['500x500'] || instance.thumbnails?.original || instance.image_url;
if (src) {
ref.current.poster = src;
const loadPosterViaHttp = React.useCallback(async(src: string) => {
if (!ref.current || !ref.current.poster) {
return;
}
const poster = new Image();
poster.src = 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
const poster = new Image();
poster.src = ref.current.poster;
poster.onload = onLoad;
}
onLoad && (poster.onload = onLoad);
ref.current.poster = poster.src;
}, [ onLoad ]);
const loadPosterViaIpfs = React.useCallback((url: string) => {
loadImageViaIpfs(url)
.then((src) => {
if (src && ref.current) {
ref.current.poster = src;
onLoad?.();
}
}, [ instance.image_url, instance.metadata?.image, instance.thumbnails, onLoad ]);
})
.catch(onError);
}, [ loadImageViaIpfs, onError, onLoad ]);
React.useEffect(() => {
!autoPlay && fetchVideoPoster();
return () => {
controller.current?.abort();
};
// run only on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (autoPlay) {
return;
}
const handleMouseEnter = React.useCallback(() => {
!autoPlay && ref.current?.play();
}, [ autoPlay ]);
if (!mediaInfoQuery.isPending && mediaInfoQuery.data) {
const mediaInfo = mediaInfoQuery.data[0];
switch (mediaInfo.transport) {
case 'ipfs':
loadPosterViaIpfs(mediaInfo.src);
break;
case 'http':
loadPosterViaHttp(mediaInfo.src);
break;
}
}
const handleMouseLeave = React.useCallback(() => {
!autoPlay && ref.current?.pause();
}, [ autoPlay ]);
}, [ autoPlay, loadPosterViaHttp, loadPosterViaIpfs, mediaInfoQuery.data, mediaInfoQuery.isPending ]);
return (
<chakra.video
ref={ ref }
{ ...videoPlayProps }
autoPlay={ autoPlay }
src={ src }
onCanPlayThrough={ onLoad }
onError={ onError }
borderRadius="md"
onClick={ onClick }
onMouseEnter={ handleMouseEnter }
onMouseLeave={ handleMouseLeave }
{ ...mediaStyleProps }
{ ...rest }
/>
);
};
......
import { chakra } from '@chakra-ui/react';
import React from 'react';
import NftMediaFullscreenModal from './NftMediaFullscreenModal';
import { videoPlayProps } from './utils';
interface Props {
src: string;
isOpen: boolean;
onClose: () => void;
}
const NftVideoFullscreen = ({ src, isOpen, onClose }: Props) => {
return (
<NftMediaFullscreenModal isOpen={ isOpen } onClose={ onClose }>
<chakra.video
{ ...videoPlayProps }
src={ src }
maxH="90vh"
maxW="90vw"
autoPlay={ true }
/>
</NftMediaFullscreenModal>
);
};
export default NftVideoFullscreen;
import { verifiedFetch } from '@helia/verified-fetch';
import React from 'react';
export default function useLoadImageViaIpfs() {
return React.useCallback(async(url: string) => {
const response = await verifiedFetch(url);
if (response.status !== 200) {
throw new Error('Failed to load image');
}
const blob = await response.blob();
const src = URL.createObjectURL(blob);
return src;
}, [ ]);
}
import { verifiedFetch } from '@helia/verified-fetch';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { TokenInstance } from 'types/api/token';
......@@ -8,151 +7,169 @@ 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';
import type { MediaType, SrcType } from './utils';
import type { MediaType, Size, TransportType } from './utils';
import { getPreliminaryMediaType } from './utils';
interface Params {
data: TokenInstance;
size: Size;
allowedTypes?: Array<MediaType>;
field: 'animation_url' | 'image_url';
isEnabled: boolean;
}
interface AssetsData {
imageUrl: string | undefined;
animationUrl: string | undefined;
}
type TransportType = 'http' | 'ipfs';
type ReturnType =
{
interface MediaInfo {
src: string;
srcSet?: string;
mediaType: MediaType;
srcType: SrcType;
} |
{
mediaType: 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?.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?.mediaType,
isEnabled && (httpSecondaryQuery.data === null || Boolean(httpSecondaryQuery.data?.mediaType)),
);
return React.useMemo(() => {
return ipfsPrimaryQuery || httpPrimaryQuery.data || ipfsSecondaryQuery || httpSecondaryQuery.data || null;
}, [ httpPrimaryQuery.data, httpSecondaryQuery.data, ipfsPrimaryQuery, ipfsSecondaryQuery ]);
}
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,
},
};
transport: TransportType;
}
// 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, mediaType: MediaType | undefined, isEnabled: boolean): ReturnType | null {
const [ result, setResult ] = React.useState<ReturnType | null>({ mediaType: undefined });
const controller = React.useRef<AbortController | null>(null);
export default function useNftMediaInfo({ data, size, allowedTypes, field, isEnabled }: Params): UseQueryResult<Array<MediaInfo> | null> {
const url = data[field];
const query = useQuery({
queryKey: [ 'nft-media-info', data.id, url, size, ...(allowedTypes ? allowedTypes : []) ],
queryFn: async() => {
const metadataField = field === 'animation_url' ? 'animation_url' : 'image';
const mediaType = await getMediaType(data, field);
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({ mediaType: 'image', src, srcType: 'blob' });
return;
if (!mediaType || (allowedTypes ? !allowedTypes.includes(mediaType) : false)) {
return null;
}
} catch (error) {}
setResult(null);
}, []);
React.useEffect(() => {
if (isEnabled) {
if (config.UI.views.nft.verifiedFetch.isEnabled && mediaType === 'image' && url && url.includes('ipfs')) {
fetchAsset(url);
} else {
setResult(null);
}
} else {
setResult({ mediaType: undefined });
}
}, [ fetchAsset, url, mediaType, isEnabled ]);
const cdnData = getCdnData(data, size, mediaType);
const ipfsData = getIpfsData(data.metadata?.[metadataField], mediaType);
React.useEffect(() => {
return () => {
controller.current?.abort();
};
}, []);
return [
cdnData,
ipfsData,
url ? { src: url, mediaType, transport: 'http' as const } : undefined,
].filter(Boolean);
},
enabled: isEnabled,
});
return result;
return query;
}
function useNftMediaTypeQuery(url: string | undefined, enabled: boolean) {
const fetch = useFetch();
async function getMediaType(data: TokenInstance, field: Params['field']): Promise<MediaType | undefined> {
const url = data[field];
return useQuery<ReturnType | null, ResourceError<unknown>, ReturnType | null>({
queryKey: [ 'nft-media-type', url ],
queryFn: async() => {
if (!url) {
return null;
return;
}
// media could be either image, gif, video or html-page
// so we pre-fetch the resources in order to get its content type
// have to do it via Node.js due to strict CSP for connect-src
// but in order not to abuse our server firstly we check file url extension
// and if it is valid we will trust it and display corresponding media component
// If the media_url is the same as the url, we can use the media_type field to determine the media type.
if (url === data.media_url) {
const mediaType = castMimeTypeToMediaType(data.media_type || undefined);
if (mediaType) {
return mediaType;
}
}
// Media can be an image, video, or HTML page.
// We pre-fetch the resources to determine their content type.
// We must do this via Node.js due to strict CSP for connect-src.
// To avoid overloading our server, we first check the file URL extension.
// If it is valid, we will trust it and display the corresponding media component.
const preliminaryType = getPreliminaryMediaType(url);
if (preliminaryType) {
return { mediaType: preliminaryType, src: url, srcType: 'url' };
return preliminaryType;
}
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' });
const response = await fetch(mediaTypeResourceUrl);
const payload = await response.json() as { type: MediaType | undefined };
return 'type' in response ? response.type : undefined;
return payload.type;
} catch (error) {
return;
}
})();
}
if (!mediaType) {
return null;
function castMimeTypeToMediaType(mimeType: string | undefined): MediaType | undefined {
if (!mimeType) {
return;
}
return { mediaType, src: url, srcType: 'url' };
},
enabled,
placeholderData: { mediaType: undefined },
staleTime: Infinity,
});
if (mimeType.startsWith('image/')) {
return 'image';
}
if (mimeType.startsWith('video/')) {
return 'video';
}
}
function getCdnData(data: TokenInstance, size: Size, mediaType: MediaType): MediaInfo | undefined {
// CDN is only used for images
if (mediaType !== 'image') {
return;
}
if (!data.thumbnails) {
return;
}
switch (size) {
case 'sm': {
return {
src: data.thumbnails['60x60'] || data.thumbnails['250x250'] || data.thumbnails['500x500'] || data.thumbnails['original'],
// the smallest thumbnail is already greater than sm size by two times
// so there is no need to pass srcSet
srcSet: undefined,
mediaType: 'image',
transport: 'http',
};
}
case 'md': {
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;
return {
src,
srcSet,
mediaType: 'image',
transport: 'http',
};
}
default: {
if (data.thumbnails.original) {
return {
src: data.thumbnails.original,
mediaType: 'image',
transport: 'http',
};
}
}
}
}
function getIpfsData(url: unknown, mediaType: MediaType): MediaInfo | undefined {
if (!config.UI.views.nft.verifiedFetch.isEnabled) {
return;
}
// Currently we only load images via IPFS
if (mediaType !== 'image') {
return;
}
if (typeof url !== 'string') {
return;
}
if (!url.includes('ipfs')) {
return;
}
return {
src: url,
mediaType,
transport: 'ipfs',
};
}
import type { HTMLChakraProps } from '@chakra-ui/react';
import type React from 'react';
export type MediaType = 'image' | 'video' | 'html';
export type TransportType = 'http' | 'ipfs';
// Currently we have only 3 sizes:
// sm = max-width<=30px
// md = max-width<=250px
// original
export type Size = 'sm' | 'md' | 'original';
export type SrcType = 'url' | 'blob';
export type MediaElementProps<As extends React.ElementType> = HTMLChakraProps<As> & {
src: string;
srcSet?: string;
transport: TransportType;
onLoad?: () => void;
onError?: () => void;
onClick?: () => void;
};
// https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/Image_types
const IMAGE_EXTENSIONS = [
'.jpg', 'jpeg',
'.png',
'.jpg', 'jpeg', '.jfif', '.pjpeg', '.pjp',
'.png', '.apng',
'.avif',
'.gif',
'.svg',
'.webp',
];
const VIDEO_EXTENSIONS = [
......
......@@ -24,6 +24,7 @@ const TokenInventoryItem = ({ item, token, isLoading }: Props) => {
data={ item }
isLoading={ isLoading }
autoplayVideo={ false }
size="md"
/>
);
......
......@@ -2,6 +2,7 @@ import { Box } from '@chakra-ui/react';
import React from 'react';
import { tokenInfoERC20a, tokenInfoERC721a, tokenInfoERC1155a } from 'mocks/tokens/tokenInfo';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import * as tokenTransferMock from 'mocks/tokens/tokenTransfer';
import { test, expect } from 'playwright/lib';
......@@ -33,7 +34,8 @@ test('erc20 +@mobile', async({ render }) => {
await expect(component).toHaveScreenshot();
});
test('erc721 +@mobile', async({ render }) => {
test('erc721 +@mobile', async({ render, mockAssetResponse }) => {
await mockAssetResponse(tokenInstanceMock.base.image_url as string, './playwright/mocks/image_s.jpg');
const component = await render(
<Box pt={{ base: '134px', lg: '100px' }}>
<TokenTransfer
......
......@@ -4,7 +4,7 @@ import { useRouter } from 'next/router';
import React from 'react';
import type { SocketMessage } from 'lib/socket/types';
import type { TokenInfo } from 'types/api/token';
import type { TokenInfo, TokenInstance } from 'types/api/token';
import type { ResourceError } from 'lib/api/resources';
import useGradualIncrement from 'lib/hooks/useGradualIncrement';
......@@ -25,11 +25,12 @@ const TABS_HEIGHT = 88;
type Props = {
transfersQuery: QueryWithPagesResult<'token_transfers'> | QueryWithPagesResult<'token_instance_transfers'>;
tokenId?: string;
tokenInstance?: TokenInstance;
tokenQuery: UseQueryResult<TokenInfo, ResourceError<unknown>>;
shouldRender?: boolean;
};
const TokenTransfer = ({ transfersQuery, tokenId, tokenQuery, shouldRender = true }: Props) => {
const TokenTransfer = ({ transfersQuery, tokenId, tokenQuery, tokenInstance, shouldRender = true }: Props) => {
const isMobile = useIsMobile();
const isMounted = useIsMounted();
const router = useRouter();
......@@ -80,6 +81,7 @@ const TokenTransfer = ({ transfersQuery, tokenId, tokenQuery, shouldRender = tru
socketInfoNum={ newItemsCount }
tokenId={ tokenId }
token={ token }
instance={ tokenInstance }
isLoading={ isLoading }
/>
</Box>
......@@ -93,7 +95,7 @@ const TokenTransfer = ({ transfersQuery, tokenId, tokenQuery, shouldRender = tru
isLoading={ isLoading }
/>
) }
<TokenTransferList data={ data?.items } tokenId={ tokenId } isLoading={ isLoading }/>
<TokenTransferList data={ data?.items } tokenId={ tokenId } instance={ tokenInstance } isLoading={ isLoading }/>
</Box>
</>
) : null;
......
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { TokenInstance } from 'types/api/token';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import TokenTransferListItem from 'ui/token/TokenTransfer/TokenTransferListItem';
......@@ -8,10 +9,11 @@ import TokenTransferListItem from 'ui/token/TokenTransfer/TokenTransferListItem'
interface Props {
data: Array<TokenTransfer>;
tokenId?: string;
instance?: TokenInstance;
isLoading?: boolean;
}
const TokenTransferList = ({ data, tokenId, isLoading }: Props) => {
const TokenTransferList = ({ data, tokenId, instance, isLoading }: Props) => {
return (
<Box>
{ data.map((item, index) => (
......@@ -19,6 +21,7 @@ const TokenTransferList = ({ data, tokenId, isLoading }: Props) => {
key={ item.transaction_hash + item.block_hash + item.log_index + '_' + index }
{ ...item }
tokenId={ tokenId }
instance={ instance }
isLoading={ isLoading }
/>
)) }
......
import { Grid, Flex } from '@chakra-ui/react';
import React from 'react';
import type { TokenInstance } from 'types/api/token';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import getCurrencyValue from 'lib/getCurrencyValue';
......@@ -14,7 +15,7 @@ import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import TruncatedValue from 'ui/shared/TruncatedValue';
type Props = TokenTransfer & { tokenId?: string; isLoading?: boolean };
type Props = TokenTransfer & { tokenId?: string; isLoading?: boolean; instance?: TokenInstance };
const TokenTransferListItem = ({
token,
......@@ -26,6 +27,7 @@ const TokenTransferListItem = ({
timestamp,
tokenId,
isLoading,
instance,
}: Props) => {
const { usd, valueStr } = total && 'value' in total && total.value !== null ? getCurrencyValue({
value: total.value,
......@@ -94,6 +96,7 @@ const TokenTransferListItem = ({
<NftEntity
hash={ token.address }
id={ total.token_id }
instance={ instance || total.token_instance }
noLink={ Boolean(tokenId && tokenId === total.token_id) }
isLoading={ isLoading }
/>
......
import { Table, Tbody, Tr, Th } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfo } from 'types/api/token';
import type { TokenInfo, TokenInstance } from 'types/api/token';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
......@@ -20,9 +20,10 @@ interface Props {
tokenId?: string;
isLoading?: boolean;
token: TokenInfo;
instance?: TokenInstance;
}
const TokenTransferTable = ({ data, top, showSocketInfo, socketInfoAlert, socketInfoNum, tokenId, isLoading, token }: Props) => {
const TokenTransferTable = ({ data, top, showSocketInfo, socketInfoAlert, socketInfoNum, tokenId, isLoading, token, instance }: Props) => {
const tokenType = token.type;
......@@ -59,6 +60,7 @@ const TokenTransferTable = ({ data, top, showSocketInfo, socketInfoAlert, socket
key={ item.transaction_hash + item.block_hash + item.log_index + '_' + index }
{ ...item }
tokenId={ tokenId }
instance={ instance }
isLoading={ isLoading }
/>
)) }
......
import { Tr, Td, Flex, Box } from '@chakra-ui/react';
import React from 'react';
import type { TokenInstance } from 'types/api/token';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import getCurrencyValue from 'lib/getCurrencyValue';
......@@ -12,7 +13,7 @@ import NftEntity from 'ui/shared/entities/nft/NftEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = TokenTransfer & { tokenId?: string; isLoading?: boolean };
type Props = TokenTransfer & { tokenId?: string; isLoading?: boolean; instance?: TokenInstance };
const TokenTransferTableItem = ({
token,
......@@ -24,6 +25,7 @@ const TokenTransferTableItem = ({
timestamp,
tokenId,
isLoading,
instance,
}: Props) => {
const { usd, valueStr } = total && 'value' in total && total.value !== null ? getCurrencyValue({
value: total.value,
......@@ -78,6 +80,7 @@ const TokenTransferTableItem = ({
<NftEntity
hash={ token.address }
id={ total.token_id }
instance={ instance || total.token_instance }
noLink={ Boolean(tokenId && tokenId === total.token_id) }
isLoading={ isLoading }
/>
......
......@@ -113,6 +113,7 @@ const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => {
<NftMedia
data={ data }
isLoading={ isLoading }
size="md"
withFullscreen
w="250px"
flexShrink={ 0 }
......
......@@ -76,6 +76,7 @@ const TokenTransfersListItem = ({ item, isLoading }: Props) => {
<NftEntity
hash={ item.token.address }
id={ item.total.token_id }
instance={ item.total.token_instance }
isLoading={ isLoading }
noIcon
/>
......
......@@ -67,6 +67,7 @@ const TokenTransferTableItem = ({ item, isLoading }: Props) => {
<NftEntity
hash={ item.token.address }
id={ item.total.token_id }
instance={ item.total.token_instance }
isLoading={ isLoading }
maxW="140px"
/>
......
import React from 'react';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import * as txMock from 'mocks/txs/tx';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
......@@ -27,7 +28,8 @@ test('creating contact', async({ render, page }) => {
});
});
test('with token transfer +@mobile', async({ render, page }) => {
test('with token transfer +@mobile', async({ render, page, mockAssetResponse }) => {
await mockAssetResponse(tokenInstanceMock.base.image_url as string, './playwright/mocks/image_s.jpg');
const component = await render(<TxInfo data={ txMock.withTokenTransfer } isLoading={ false }/>);
await expect(component).toHaveScreenshot({
......
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