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: ...@@ -21,6 +21,7 @@ on:
- eth_sepolia - eth_sepolia
- eth_goerli - eth_goerli
- filecoin - filecoin
- immutable
- neon_devnet - neon_devnet
- optimism - optimism
- optimism_celestia - optimism_celestia
......
...@@ -21,6 +21,7 @@ on: ...@@ -21,6 +21,7 @@ on:
- eth_sepolia - eth_sepolia
- eth_goerli - eth_goerli
- filecoin - filecoin
- immutable
- mekong - mekong
- neon_devnet - neon_devnet
- optimism - optimism
......
...@@ -365,6 +365,7 @@ ...@@ -365,6 +365,7 @@
"celo_alfajores", "celo_alfajores",
"garnet", "garnet",
"gnosis", "gnosis",
"immutable",
"eth", "eth",
"eth_goerli", "eth_goerli",
"eth_sepolia", "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 { TokenInfo } from 'types/api/token';
import type { TokenTransfer, TokenTransferResponse } from 'types/api/tokenTransfer'; import type { TokenTransfer, TokenTransferResponse } from 'types/api/tokenTransfer';
import * as tokenInstanceMock from './tokenInstance';
export const erc20: TokenTransfer = { export const erc20: TokenTransfer = {
from: { from: {
hash: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859', hash: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859',
...@@ -86,6 +88,7 @@ export const erc721: TokenTransfer = { ...@@ -86,6 +88,7 @@ export const erc721: TokenTransfer = {
}, },
total: { total: {
token_id: '875879856', token_id: '875879856',
token_instance: tokenInstanceMock.base,
}, },
transaction_hash: '0xf13bc7afe5e02b494dd2f22078381d36a4800ef94a0ccc147431db56c301e6cc', transaction_hash: '0xf13bc7afe5e02b494dd2f22078381d36a4800ef94a0ccc147431db56c301e6cc',
type: 'token_transfer', type: 'token_transfer',
...@@ -135,6 +138,7 @@ export const erc1155A: TokenTransfer = { ...@@ -135,6 +138,7 @@ export const erc1155A: TokenTransfer = {
token_id: '123', token_id: '123',
value: '42', value: '42',
decimals: null, decimals: null,
token_instance: null,
}, },
transaction_hash: '0x05d6589367633c032d757a69c5fb16c0e33e3994b0d9d1483f82aeee1f05d746', transaction_hash: '0x05d6589367633c032d757a69c5fb16c0e33e3994b0d9d1483f82aeee1f05d746',
type: 'token_minting', type: 'token_minting',
...@@ -151,7 +155,7 @@ export const erc1155B: TokenTransfer = { ...@@ -151,7 +155,7 @@ export const erc1155B: TokenTransfer = {
name: 'SastanaNFT', name: 'SastanaNFT',
symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY', 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 = { export const erc1155C: TokenTransfer = {
...@@ -161,7 +165,7 @@ export const erc1155C: TokenTransfer = { ...@@ -161,7 +165,7 @@ export const erc1155C: TokenTransfer = {
name: 'SastanaNFT', name: 'SastanaNFT',
symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY', 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 = { export const erc1155D: TokenTransfer = {
...@@ -171,7 +175,7 @@ export const erc1155D: TokenTransfer = { ...@@ -171,7 +175,7 @@ export const erc1155D: TokenTransfer = {
name: 'SastanaNFT', name: 'SastanaNFT',
symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY', 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 = { export const erc404A: TokenTransfer = {
...@@ -213,6 +217,7 @@ export const erc404A: TokenTransfer = { ...@@ -213,6 +217,7 @@ export const erc404A: TokenTransfer = {
value: '42000000000000000000000000', value: '42000000000000000000000000',
decimals: '18', decimals: '18',
token_id: null, token_id: null,
token_instance: null,
}, },
transaction_hash: '0x05d6589367633c032d757a69c5fb16c0e33e3994b0d9d1483f82aeee1f05d746', transaction_hash: '0x05d6589367633c032d757a69c5fb16c0e33e3994b0d9d1483f82aeee1f05d746',
type: 'token_transfer', type: 'token_transfer',
...@@ -230,7 +235,7 @@ export const erc404B: TokenTransfer = { ...@@ -230,7 +235,7 @@ export const erc404B: TokenTransfer = {
name: 'SastanaNFT', name: 'SastanaNFT',
symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY', symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY',
}, },
total: { token_id: '4625304364899952' }, total: { token_id: '4625304364899952', token_instance: null },
}; };
export const mixTokens: TokenTransferResponse = { export const mixTokens: TokenTransferResponse = {
......
...@@ -19,6 +19,7 @@ export const mintToken: TxStateChange = { ...@@ -19,6 +19,7 @@ export const mintToken: TxStateChange = {
direction: 'from', direction: 'from',
total: { total: {
token_id: '15077554365819457090226168288698582604878106156134383525616269766016907608065', token_id: '15077554365819457090226168288698582604878106156134383525616269766016907608065',
token_instance: null,
}, },
}, },
], ],
...@@ -57,6 +58,7 @@ export const receiveMintedToken: TxStateChange = { ...@@ -57,6 +58,7 @@ export const receiveMintedToken: TxStateChange = {
direction: 'to', direction: 'to',
total: { total: {
token_id: '15077554365819457090226168288698582604878106156134383525616269766016907608065', token_id: '15077554365819457090226168288698582604878106156134383525616269766016907608065',
token_instance: null,
}, },
}, },
], ],
......
...@@ -110,6 +110,7 @@ export const TOKEN_TRANSFER_ERC_721: TokenTransfer = { ...@@ -110,6 +110,7 @@ export const TOKEN_TRANSFER_ERC_721: TokenTransfer = {
...TOKEN_TRANSFER_ERC_20, ...TOKEN_TRANSFER_ERC_20,
total: { total: {
token_id: '35870', token_id: '35870',
token_instance: null,
}, },
token: TOKEN_INFO_ERC_721, token: TOKEN_INFO_ERC_721,
}; };
...@@ -120,6 +121,7 @@ export const TOKEN_TRANSFER_ERC_1155: TokenTransfer = { ...@@ -120,6 +121,7 @@ export const TOKEN_TRANSFER_ERC_1155: TokenTransfer = {
token_id: '35870', token_id: '35870',
value: '123', value: '123',
decimals: '18', decimals: '18',
token_instance: null,
}, },
token: TOKEN_INFO_ERC_1155, token: TOKEN_INFO_ERC_1155,
}; };
...@@ -130,6 +132,7 @@ export const TOKEN_TRANSFER_ERC_404: TokenTransfer = { ...@@ -130,6 +132,7 @@ export const TOKEN_TRANSFER_ERC_404: TokenTransfer = {
token_id: '35870', token_id: '35870',
value: '123', value: '123',
decimals: '18', decimals: '18',
token_instance: null,
}, },
token: TOKEN_INFO_ERC_404, token: TOKEN_INFO_ERC_404,
}; };
......
...@@ -32,6 +32,7 @@ export const STATE_CHANGE_TOKEN: TxStateChange = { ...@@ -32,6 +32,7 @@ export const STATE_CHANGE_TOKEN: TxStateChange = {
direction: 'to', direction: 'to',
total: { total: {
token_id: '1621395', token_id: '1621395',
token_instance: null,
}, },
}, },
], ],
......
...@@ -11,9 +11,10 @@ const PRESETS = { ...@@ -11,9 +11,10 @@ const PRESETS = {
eth: 'https://eth.blockscout.com', eth: 'https://eth.blockscout.com',
eth_goerli: 'https://eth-goerli.blockscout.com', eth_goerli: 'https://eth-goerli.blockscout.com',
eth_sepolia: 'https://eth-sepolia.blockscout.com', eth_sepolia: 'https://eth-sepolia.blockscout.com',
garnet: 'https://explorer.garnetchain.com',
filecoin: 'https://filecoin.blockscout.com', filecoin: 'https://filecoin.blockscout.com',
garnet: 'https://explorer.garnetchain.com',
gnosis: 'https://gnosis.blockscout.com', gnosis: 'https://gnosis.blockscout.com',
immutable: 'https://explorer.immutable.com',
mekong: 'https://mekong.blockscout.com', mekong: 'https://mekong.blockscout.com',
neon_devnet: 'https://neon-devnet.blockscout.com', neon_devnet: 'https://neon-devnet.blockscout.com',
optimism: 'https://optimism.blockscout.com', optimism: 'https://optimism.blockscout.com',
......
...@@ -59,10 +59,12 @@ export interface TokenInstance { ...@@ -59,10 +59,12 @@ export interface TokenInstance {
holder_address_hash: string | null; holder_address_hash: string | null;
image_url: string | null; image_url: string | null;
animation_url: string | null; animation_url: string | null;
media_url?: string | null;
media_type?: string | null;
external_app_url: string | null; external_app_url: string | null;
metadata: Record<string, unknown> | null; metadata: Record<string, unknown> | null;
owner: AddressParam | null; owner: AddressParam | null;
thumbnails: Partial<Record<ThumbnailSize, string>> | null; thumbnails: ({ original: string } & Partial<Record<Exclude<ThumbnailSize, 'original'>, string>>) | null;
} }
export interface TokenInstanceMetadataSocketMessage { export interface TokenInstanceMetadataSocketMessage {
......
import type { AddressParam } from './addressParams'; import type { AddressParam } from './addressParams';
import type { TokenInfo, TokenType } from './token'; import type { TokenInfo, TokenInstance, TokenType } from './token';
export type Erc20TotalPayload = { export type Erc20TotalPayload = {
decimals: string | null; decimals: string | null;
...@@ -8,20 +8,24 @@ export type Erc20TotalPayload = { ...@@ -8,20 +8,24 @@ export type Erc20TotalPayload = {
export type Erc721TotalPayload = { export type Erc721TotalPayload = {
token_id: string | null; token_id: string | null;
token_instance: TokenInstance | null;
}; };
export type Erc1155TotalPayload = { export type Erc1155TotalPayload = {
decimals: string | null; decimals: string | null;
value: string; value: string;
token_id: string | null; token_id: string | null;
token_instance: TokenInstance | null;
}; };
export type Erc404TotalPayload = { export type Erc404TotalPayload = {
decimals: string; decimals: string;
value: string; value: string;
token_id: null; token_id: null;
token_instance: TokenInstance | null;
} | { } | {
token_id: string; token_id: string;
token_instance: TokenInstance | null;
}; };
export type TokenTransfer = ( export type TokenTransfer = (
......
...@@ -31,6 +31,7 @@ const NFTItem = ({ token, value, isLoading, withTokenLink, ...tokenInstance }: P ...@@ -31,6 +31,7 @@ const NFTItem = ({ token, value, isLoading, withTokenLink, ...tokenInstance }: P
<NftMedia <NftMedia
mb="18px" mb="18px"
data={ tokenInstance } data={ tokenInstance }
size="md"
isLoading={ isLoading } isLoading={ isLoading }
autoplayVideo={ false } autoplayVideo={ false }
/> />
......
...@@ -97,7 +97,15 @@ const TokenInstanceContent = () => { ...@@ -97,7 +97,15 @@ const TokenInstanceContent = () => {
{ {
id: 'token_transfers', id: 'token_transfers',
title: '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 ? shouldFetchHolders ?
{ id: 'holders', title: 'Holders', component: <TokenHolders holdersQuery={ holdersQuery } token={ tokenQuery.data } shouldRender={ !isLoading }/> } : { id: 'holders', title: 'Holders', component: <TokenHolders holdersQuery={ holdersQuery } token={ tokenQuery.data } shouldRender={ !isLoading }/> } :
......
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import { mixTokens } from 'mocks/tokens/tokenTransfer'; import { mixTokens } from 'mocks/tokens/tokenTransfer';
import { test, expect } from 'playwright/lib'; import { test, expect } from 'playwright/lib';
import TokenTransfers from './TokenTransfers'; 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 mockTextAd();
await mockApiResponse('token_transfers_all', mixTokens, { queryParams: { type: [] } }); await mockApiResponse('token_transfers_all', mixTokens, { queryParams: { type: [] } });
const component = await render(<Box pt={{ base: '106px', lg: 0 }}> <TokenTransfers/> </Box>); const component = await render(<Box pt={{ base: '106px', lg: 0 }}> <TokenTransfers/> </Box>);
......
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import * as tokenTransferMock from 'mocks/tokens/tokenTransfer'; import * as tokenTransferMock from 'mocks/tokens/tokenTransfer';
import { test, expect, devices } from 'playwright/lib'; import { test, expect, devices } from 'playwright/lib';
...@@ -23,7 +24,8 @@ const data = [ ...@@ -23,7 +24,8 @@ const data = [
tokenTransferMock.erc1155D, 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( const component = await render(
<Box pt={{ base: '134px', lg: 6 }}> <Box pt={{ base: '134px', lg: 6 }}>
<TokenTransferList <TokenTransferList
...@@ -36,7 +38,8 @@ test('without tx info', async({ render }) => { ...@@ -36,7 +38,8 @@ test('without tx info', async({ render }) => {
await expect(component).toHaveScreenshot(); 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( const component = await render(
<Box pt={{ base: '134px', lg: 6 }}> <Box pt={{ base: '134px', lg: 6 }}>
<TokenTransferList <TokenTransferList
......
...@@ -68,7 +68,7 @@ const TokenTransferListItem = ({ ...@@ -68,7 +68,7 @@ const TokenTransferListItem = ({
) } ) }
</Flex> </Flex>
{ total && 'token_id' in total && total.token_id !== null && token && ( { 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 && ( { showTxInfo && txHash && (
<Flex justifyContent="space-between" alignItems="center" lineHeight="24px" width="100%"> <Flex justifyContent="space-between" alignItems="center" lineHeight="24px" width="100%">
......
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import * as tokenTransferMock from 'mocks/tokens/tokenTransfer'; import * as tokenTransferMock from 'mocks/tokens/tokenTransfer';
import { test, expect } from 'playwright/lib'; import { test, expect } from 'playwright/lib';
import TokenTransferTable from './TokenTransferTable'; 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( const component = await render(
<Box pt={{ base: '134px', lg: 6 }}> <Box pt={{ base: '134px', lg: 6 }}>
<TokenTransferTable <TokenTransferTable
...@@ -20,7 +22,8 @@ test('without tx info', async({ render }) => { ...@@ -20,7 +22,8 @@ test('without tx info', async({ render }) => {
await expect(component).toHaveScreenshot(); 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( const component = await render(
<Box pt={{ base: '134px', lg: 6 }}> <Box pt={{ base: '134px', lg: 6 }}>
<TokenTransferTable <TokenTransferTable
......
...@@ -75,6 +75,7 @@ const TokenTransferTableItem = ({ ...@@ -75,6 +75,7 @@ const TokenTransferTableItem = ({
<NftEntity <NftEntity
hash={ token.address } hash={ token.address }
id={ total.token_id } id={ total.token_id }
instance={ total.token_instance }
isLoading={ isLoading } isLoading={ isLoading }
/> />
) } ) }
......
...@@ -44,6 +44,7 @@ const TokenTransferSnippet = ({ data, isLoading, noAddressIcons = true }: Props) ...@@ -44,6 +44,7 @@ const TokenTransferSnippet = ({ data, isLoading, noAddressIcons = true }: Props)
<TokenTransferSnippetNft <TokenTransferSnippetNft
token={ data.token } token={ data.token }
tokenId={ total.token_id } tokenId={ total.token_id }
instance={ total.token_instance }
value="1" value="1"
/> />
); );
...@@ -56,6 +57,7 @@ const TokenTransferSnippet = ({ data, isLoading, noAddressIcons = true }: Props) ...@@ -56,6 +57,7 @@ const TokenTransferSnippet = ({ data, isLoading, noAddressIcons = true }: Props)
key={ total.token_id } key={ total.token_id }
token={ data.token } token={ data.token }
tokenId={ total.token_id } tokenId={ total.token_id }
instance={ total.token_instance }
value={ total.value } value={ total.value }
/> />
); );
......
import { chakra } from '@chakra-ui/react'; import { chakra } from '@chakra-ui/react';
import React from '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 NftEntity from 'ui/shared/entities/nft/NftEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity'; import TokenEntity from 'ui/shared/entities/token/TokenEntity';
...@@ -10,9 +10,10 @@ interface Props { ...@@ -10,9 +10,10 @@ interface Props {
token: TokenInfo; token: TokenInfo;
value: string; value: string;
tokenId: string | null; 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 num = value === '1' ? '' : value;
const tokenIdContent = (() => { const tokenIdContent = (() => {
...@@ -28,6 +29,7 @@ const NftTokenTransferSnippet = ({ value, token, tokenId }: Props) => { ...@@ -28,6 +29,7 @@ const NftTokenTransferSnippet = ({ value, token, tokenId }: Props) => {
<NftEntity <NftEntity
hash={ token.address } hash={ token.address }
id={ tokenId } id={ tokenId }
instance={ instance }
fontWeight={ 600 } fontWeight={ 600 }
icon={{ size: 'md' }} icon={{ size: 'md' }}
maxW={{ base: '100%', lg: '150px' }} maxW={{ base: '100%', lg: '150px' }}
......
...@@ -2,20 +2,55 @@ import type { As } from '@chakra-ui/react'; ...@@ -2,20 +2,55 @@ import type { As } from '@chakra-ui/react';
import { chakra } from '@chakra-ui/react'; import { chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenInstance } from 'types/api/token';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import * as EntityBase from 'ui/shared/entities/base/components'; import * as EntityBase from 'ui/shared/entities/base/components';
import NftMedia from 'ui/shared/nft/NftMedia';
import TruncatedValue from 'ui/shared/TruncatedValue'; import TruncatedValue from 'ui/shared/TruncatedValue';
import { distributeEntityProps } from '../base/utils'; import { distributeEntityProps, getIconProps } from '../base/utils';
const Container = EntityBase.Container; 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) { if (props.noIcon) {
return null; 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 ( return (
<EntityBase.Icon <EntityBase.Icon
{ ...props } { ...props }
...@@ -54,6 +89,7 @@ const Content = chakra((props: ContentProps) => { ...@@ -54,6 +89,7 @@ const Content = chakra((props: ContentProps) => {
export interface EntityProps extends EntityBase.EntityBaseProps { export interface EntityProps extends EntityBase.EntityBaseProps {
hash: string; hash: string;
id: string; id: string;
instance?: TokenInstance | null;
} }
const NftEntity = (props: EntityProps) => { const NftEntity = (props: EntityProps) => {
......
import { chakra, LinkOverlay } from '@chakra-ui/react'; import { chakra, LinkOverlay } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import { mediaStyleProps } from './utils'; import type { MediaElementProps } from './utils';
interface Props { interface Props extends MediaElementProps<'a'> {}
src: string;
onLoad: () => void; const NftHtml = ({ src, transport, onLoad, onError, onClick, ...rest }: Props) => {
onError: () => void; const ref = React.useRef<HTMLIFrameElement>(null);
onClick?: () => void;
} 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 ( return (
<LinkOverlay <LinkOverlay
onClick={ onClick } onClick={ onClick }
{ ...mediaStyleProps } { ...rest }
> >
<chakra.iframe <chakra.iframe
src={ src } ref={ ref }
h="100%" h="100%"
w="100%" w="100%"
sandbox="allow-scripts" sandbox="allow-scripts"
onLoad={ onLoad } opacity={ isLoaded ? 1 : 0 }
onError={ onError }
/> />
</LinkOverlay> </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 { Image } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import { mediaStyleProps } from './utils'; import useLoadImageViaIpfs from './useLoadImageViaIpfs';
import type { MediaElementProps } from './utils';
interface Props { interface Props extends MediaElementProps<'img'> {}
src: string;
srcSet?: string; const NftImage = ({ src, srcSet, onLoad, onError, transport, onClick, ...rest }: Props) => {
onLoad: () => void; const ref = React.useRef<HTMLImageElement>(null);
onError: () => void; const [ isLoaded, setIsLoaded ] = React.useState(false);
onClick?: () => void;
} 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 ( return (
<Image <Image
ref={ ref }
w="100%" w="100%"
h="100%" h="100%"
src={ src } opacity={ isLoaded ? 1 : 0 }
srcSet={ srcSet }
alt="Token instance image" alt="Token instance image"
onError={ onError }
onLoad={ onLoad }
onClick={ onClick } 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', () => { ...@@ -66,7 +66,7 @@ test.describe('image', () => {
} as TokenInstance; } as TokenInstance;
await render( await render(
<Box boxSize="250px"> <Box boxSize="250px">
<NftMedia data={ data }/> <NftMedia data={ data } size="md"/>
</Box>, </Box>,
); );
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } });
...@@ -84,7 +84,7 @@ test.describe('image', () => { ...@@ -84,7 +84,7 @@ test.describe('image', () => {
await mockAssetResponse(THUMBNAIL_URL, './playwright/mocks/image_md.jpg'); await mockAssetResponse(THUMBNAIL_URL, './playwright/mocks/image_md.jpg');
await render( await render(
<Box boxSize="250px"> <Box boxSize="250px">
<NftMedia data={ data }/> <NftMedia data={ data } size="md"/>
</Box>, </Box>,
); );
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } });
...@@ -95,7 +95,7 @@ test.describe('image', () => { ...@@ -95,7 +95,7 @@ test.describe('image', () => {
animation_url: MEDIA_URL, animation_url: MEDIA_URL,
image_url: null, image_url: null,
} as TokenInstance; } 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 component.getByAltText('Token instance image').hover();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } });
}); });
......
...@@ -8,45 +8,64 @@ import Skeleton from 'ui/shared/chakra/Skeleton'; ...@@ -8,45 +8,64 @@ import Skeleton from 'ui/shared/chakra/Skeleton';
import NftFallback from './NftFallback'; import NftFallback from './NftFallback';
import NftHtml from './NftHtml'; import NftHtml from './NftHtml';
import NftHtmlFullscreen from './NftHtmlFullscreen';
import NftImage from './NftImage'; import NftImage from './NftImage';
import NftImageFullscreen from './NftImageFullscreen'; import NftMediaFullscreenModal from './NftMediaFullscreenModal';
import NftVideo from './NftVideo'; import NftVideo from './NftVideo';
import NftVideoFullscreen from './NftVideoFullscreen';
import useNftMediaInfo from './useNftMediaInfo'; import useNftMediaInfo from './useNftMediaInfo';
import type { MediaType, Size } from './utils';
import { mediaStyleProps } from './utils'; import { mediaStyleProps } from './utils';
interface Props { interface Props {
data: TokenInstance; data: TokenInstance;
size?: Size;
allowedTypes?: Array<MediaType>;
className?: string; className?: string;
isLoading?: boolean; isLoading?: boolean;
withFullscreen?: boolean; withFullscreen?: boolean;
autoplayVideo?: 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 [ 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 { 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(() => { React.useEffect(() => {
if (!isLoading && !mediaInfo) { if (!mediaInfoQuery.isPending && !mediaInfoQuery.data) {
if (mediaInfoField === 'animation_url') {
setMediaInfoField('image_url');
} else {
setIsMediaLoadingError(true);
setIsMediaLoading(false); setIsMediaLoading(false);
setIsLoadingError(true);
} }
}, [ isLoading, mediaInfo ]); }
}, [ mediaInfoQuery.isPending, mediaInfoQuery.data, mediaInfoField ]);
const handleMediaLoaded = React.useCallback(() => { const handleMediaLoaded = React.useCallback(() => {
setIsMediaLoading(false); setIsMediaLoading(false);
}, []); }, []);
const handleMediaLoadError = React.useCallback(() => { 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); setIsMediaLoading(false);
setIsLoadingError(true); setIsMediaLoadingError(true);
}, []); }, [ mediaInfoField, mediaInfoIndex, mediaInfoQuery.data ]);
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
...@@ -55,57 +74,27 @@ const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }: ...@@ -55,57 +74,27 @@ const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }:
return null; return null;
} }
if (!mediaInfo || isLoadingError) { if (isMediaLoadingError) {
const styleProps = withFullscreen ? {} : mediaStyleProps; const styleProps = withFullscreen ? {} : mediaStyleProps;
return <NftFallback { ...styleProps }/>; return fallback ?? <NftFallback { ...styleProps }/>;
} }
const mediaInfo = mediaInfoQuery.data?.[mediaInfoIndex];
const props = { const props = {
onLoad: handleMediaLoaded, onLoad: handleMediaLoaded,
onError: handleMediaLoadError, onError: handleMediaLoadError,
...(withFullscreen ? { onClick: onOpen } : {}), ...(withFullscreen ? { onClick: onOpen } : {}),
...(size !== 'sm' ? mediaStyleProps : {}),
}; };
switch (mediaInfo.mediaType) { switch (mediaInfo?.mediaType) {
case 'video': { case 'video': {
return <NftVideo { ...props } src={ mediaInfo.src } autoPlay={ autoplayVideo } instance={ data }/>; return <NftVideo { ...props } src={ mediaInfo.src } transport={ mediaInfo.transport } 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;
} }
const props = {
isOpen,
onClose,
};
switch (mediaInfo.mediaType) {
case 'video':
return <NftVideoFullscreen { ...props } src={ mediaInfo.src }/>;
case 'html': case 'html':
return <NftHtmlFullscreen { ...props } src={ mediaInfo.src }/>; return <NftHtml { ...props } src={ mediaInfo.src } transport={ mediaInfo.transport }/>;
case 'image': { case 'image': {
const src = mediaInfo.srcType === 'url' && data.thumbnails?.original ? data.thumbnails.original : mediaInfo.src; return <NftImage { ...props } src={ mediaInfo.src } srcSet={ mediaInfo.srcSet } transport={ mediaInfo.transport }/>;
return <NftImageFullscreen { ...props } src={ src }/>;
} }
default: default:
return null; return null;
...@@ -113,6 +102,7 @@ const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }: ...@@ -113,6 +102,7 @@ const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }:
})(); })();
return ( return (
<>
<AspectRatio <AspectRatio
ref={ ref } ref={ ref }
className={ className } className={ className }
...@@ -129,11 +119,14 @@ const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }: ...@@ -129,11 +119,14 @@ const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }:
> >
<> <>
{ content } { content }
{ modal }
{ isMediaLoading && <Skeleton position="absolute" left={ 0 } top={ 0 } w="100%" h="100%" zIndex="1"/> } { isMediaLoading && <Skeleton position="absolute" left={ 0 } top={ 0 } w="100%" h="100%" zIndex="1"/> }
</> </>
</AspectRatio> </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 { ...@@ -6,19 +6,86 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React from '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 { interface Props {
isOpen: boolean; isOpen: boolean;
onClose: () => void; 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 ( return (
<Modal isOpen={ isOpen } onClose={ onClose } motionPreset="none"> <Modal isOpen={ isOpen } onClose={ onClose } motionPreset="none">
<ModalOverlay/> <ModalOverlay/>
<ModalContent w="unset" maxW="100vw" p={ 0 } background="none" boxShadow="none"> <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"/> <ModalCloseButton position="fixed" top={{ base: 2.5, lg: 8 }} right={{ base: 2.5, lg: 8 }} color="whiteAlpha.800"/>
{ children } { content }
</ModalContent> </ModalContent>
</Modal> </Modal>
); );
......
import { chakra } from '@chakra-ui/react'; import { chakra } from '@chakra-ui/react';
import { verifiedFetch } from '@helia/verified-fetch';
import React from 'react'; import React from 'react';
import type { TokenInstance } from 'types/api/token'; 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 extends MediaElementProps<'video'> {
interface Props {
src: string;
instance: TokenInstance; instance: TokenInstance;
autoPlay?: boolean; autoPlay?: boolean;
onLoad: () => void; size?: Size;
onError: () => void;
onClick?: () => void;
} }
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 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) { if (!ref.current) {
return; return;
} }
try { ref.current.src = src;
if (!config.UI.views.nft.verifiedFetch.isEnabled) { onLoad && (ref.current.oncanplaythrough = onLoad);
throw new Error('Helia verified fetch is disabled'); 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; case 'http':
if (!imageUrl) { loadViaHttp();
throw new Error('No image URL found'); break;
} }
controller.current = new AbortController(); }, [ loadViaHttp, onError, transport ]);
const response = await verifiedFetch(imageUrl, { signal: controller.current.signal });
const blob = await response.blob();
const src = URL.createObjectURL(blob);
ref.current.poster = src;
// we want to call onLoad right after the poster is loaded const loadPosterViaHttp = React.useCallback(async(src: string) => {
// otherwise, the skeleton will be shown underneath the element until the video is loaded if (!ref.current || !ref.current.poster) {
onLoad(); return;
} catch (error) { }
const src = instance.thumbnails?.['500x500'] || instance.thumbnails?.original || instance.image_url;
if (src) {
ref.current.poster = src;
const poster = new Image();
poster.src = src;
// we want to call onLoad right after the poster is loaded // 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 // otherwise, the skeleton will be shown underneath the element until the video is loaded
const poster = new Image(); onLoad && (poster.onload = onLoad);
poster.src = ref.current.poster;
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(() => { React.useEffect(() => {
!autoPlay && fetchVideoPoster(); if (autoPlay) {
return () => { return;
controller.current?.abort(); }
};
// run only on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleMouseEnter = React.useCallback(() => { if (!mediaInfoQuery.isPending && mediaInfoQuery.data) {
!autoPlay && ref.current?.play(); const mediaInfo = mediaInfoQuery.data[0];
}, [ autoPlay ]); switch (mediaInfo.transport) {
case 'ipfs':
loadPosterViaIpfs(mediaInfo.src);
break;
case 'http':
loadPosterViaHttp(mediaInfo.src);
break;
}
}
const handleMouseLeave = React.useCallback(() => { }, [ autoPlay, loadPosterViaHttp, loadPosterViaIpfs, mediaInfoQuery.data, mediaInfoQuery.isPending ]);
!autoPlay && ref.current?.pause();
}, [ autoPlay ]);
return ( return (
<chakra.video <chakra.video
ref={ ref } ref={ ref }
{ ...videoPlayProps } { ...videoPlayProps }
autoPlay={ autoPlay } autoPlay={ autoPlay }
src={ src }
onCanPlayThrough={ onLoad }
onError={ onError }
borderRadius="md"
onClick={ onClick } onClick={ onClick }
onMouseEnter={ handleMouseEnter } onMouseEnter={ handleMouseEnter }
onMouseLeave={ handleMouseLeave } 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 { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { TokenInstance } from 'types/api/token'; import type { TokenInstance } from 'types/api/token';
...@@ -8,151 +7,169 @@ import type { StaticRoute } from 'nextjs-routes'; ...@@ -8,151 +7,169 @@ import type { StaticRoute } from 'nextjs-routes';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app'; 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'; import { getPreliminaryMediaType } from './utils';
interface Params { interface Params {
data: TokenInstance; data: TokenInstance;
size: Size;
allowedTypes?: Array<MediaType>;
field: 'animation_url' | 'image_url';
isEnabled: boolean; isEnabled: boolean;
} }
interface AssetsData { interface MediaInfo {
imageUrl: string | undefined;
animationUrl: string | undefined;
}
type TransportType = 'http' | 'ipfs';
type ReturnType =
{
src: string; src: string;
srcSet?: string;
mediaType: MediaType; mediaType: MediaType;
srcType: SrcType; transport: TransportType;
} |
{
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,
},
};
} }
// As of now we fetch only images via IPFS because video streaming has performance issues export default function useNftMediaInfo({ data, size, allowedTypes, field, isEnabled }: Params): UseQueryResult<Array<MediaInfo> | null> {
// Also, we don't want to store the entire file content in the ReactQuery cache, so we don't use useQuery hook here const url = data[field];
function useFetchAssetViaIpfs(url: string | undefined, mediaType: MediaType | undefined, isEnabled: boolean): ReturnType | null { const query = useQuery({
const [ result, setResult ] = React.useState<ReturnType | null>({ mediaType: undefined }); queryKey: [ 'nft-media-info', data.id, url, size, ...(allowedTypes ? allowedTypes : []) ],
const controller = React.useRef<AbortController | null>(null); queryFn: async() => {
const metadataField = field === 'animation_url' ? 'animation_url' : 'image';
const mediaType = await getMediaType(data, field);
const fetchAsset = React.useCallback(async(url: string) => { if (!mediaType || (allowedTypes ? !allowedTypes.includes(mediaType) : false)) {
try { return null;
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;
} }
} catch (error) {}
setResult(null);
}, []);
React.useEffect(() => { const cdnData = getCdnData(data, size, mediaType);
if (isEnabled) { const ipfsData = getIpfsData(data.metadata?.[metadataField], mediaType);
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 ]);
React.useEffect(() => { return [
return () => { cdnData,
controller.current?.abort(); 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) { async function getMediaType(data: TokenInstance, field: Params['field']): Promise<MediaType | undefined> {
const fetch = useFetch(); const url = data[field];
return useQuery<ReturnType | null, ResourceError<unknown>, ReturnType | null>({
queryKey: [ 'nft-media-type', url ],
queryFn: async() => {
if (!url) { if (!url) {
return null; return;
} }
// media could be either image, gif, video or html-page // If the media_url is the same as the url, we can use the media_type field to determine the media type.
// so we pre-fetch the resources in order to get its content type if (url === data.media_url) {
// have to do it via Node.js due to strict CSP for connect-src const mediaType = castMimeTypeToMediaType(data.media_type || undefined);
// but in order not to abuse our server firstly we check file url extension if (mediaType) {
// and if it is valid we will trust it and display corresponding media component 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); const preliminaryType = getPreliminaryMediaType(url);
if (preliminaryType) { if (preliminaryType) {
return { mediaType: preliminaryType, src: url, srcType: 'url' }; return preliminaryType;
} }
const mediaType = await (async() => {
try { try {
const mediaTypeResourceUrl = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url } }); 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) { } catch (error) {
return; return;
} }
})(); }
if (!mediaType) { function castMimeTypeToMediaType(mimeType: string | undefined): MediaType | undefined {
return null; if (!mimeType) {
return;
} }
return { mediaType, src: url, srcType: 'url' }; if (mimeType.startsWith('image/')) {
}, return 'image';
enabled, }
placeholderData: { mediaType: undefined },
staleTime: Infinity, 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 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 = [ const IMAGE_EXTENSIONS = [
'.jpg', 'jpeg', '.jpg', 'jpeg', '.jfif', '.pjpeg', '.pjp',
'.png', '.png', '.apng',
'.avif',
'.gif', '.gif',
'.svg', '.svg',
'.webp',
]; ];
const VIDEO_EXTENSIONS = [ const VIDEO_EXTENSIONS = [
......
...@@ -24,6 +24,7 @@ const TokenInventoryItem = ({ item, token, isLoading }: Props) => { ...@@ -24,6 +24,7 @@ const TokenInventoryItem = ({ item, token, isLoading }: Props) => {
data={ item } data={ item }
isLoading={ isLoading } isLoading={ isLoading }
autoplayVideo={ false } autoplayVideo={ false }
size="md"
/> />
); );
......
...@@ -2,6 +2,7 @@ import { Box } from '@chakra-ui/react'; ...@@ -2,6 +2,7 @@ import { Box } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import { tokenInfoERC20a, tokenInfoERC721a, tokenInfoERC1155a } from 'mocks/tokens/tokenInfo'; import { tokenInfoERC20a, tokenInfoERC721a, tokenInfoERC1155a } from 'mocks/tokens/tokenInfo';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import * as tokenTransferMock from 'mocks/tokens/tokenTransfer'; import * as tokenTransferMock from 'mocks/tokens/tokenTransfer';
import { test, expect } from 'playwright/lib'; import { test, expect } from 'playwright/lib';
...@@ -33,7 +34,8 @@ test('erc20 +@mobile', async({ render }) => { ...@@ -33,7 +34,8 @@ test('erc20 +@mobile', async({ render }) => {
await expect(component).toHaveScreenshot(); 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( const component = await render(
<Box pt={{ base: '134px', lg: '100px' }}> <Box pt={{ base: '134px', lg: '100px' }}>
<TokenTransfer <TokenTransfer
......
...@@ -4,7 +4,7 @@ import { useRouter } from 'next/router'; ...@@ -4,7 +4,7 @@ import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { SocketMessage } from 'lib/socket/types'; 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 type { ResourceError } from 'lib/api/resources';
import useGradualIncrement from 'lib/hooks/useGradualIncrement'; import useGradualIncrement from 'lib/hooks/useGradualIncrement';
...@@ -25,11 +25,12 @@ const TABS_HEIGHT = 88; ...@@ -25,11 +25,12 @@ const TABS_HEIGHT = 88;
type Props = { type Props = {
transfersQuery: QueryWithPagesResult<'token_transfers'> | QueryWithPagesResult<'token_instance_transfers'>; transfersQuery: QueryWithPagesResult<'token_transfers'> | QueryWithPagesResult<'token_instance_transfers'>;
tokenId?: string; tokenId?: string;
tokenInstance?: TokenInstance;
tokenQuery: UseQueryResult<TokenInfo, ResourceError<unknown>>; tokenQuery: UseQueryResult<TokenInfo, ResourceError<unknown>>;
shouldRender?: boolean; shouldRender?: boolean;
}; };
const TokenTransfer = ({ transfersQuery, tokenId, tokenQuery, shouldRender = true }: Props) => { const TokenTransfer = ({ transfersQuery, tokenId, tokenQuery, tokenInstance, shouldRender = true }: Props) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isMounted = useIsMounted(); const isMounted = useIsMounted();
const router = useRouter(); const router = useRouter();
...@@ -80,6 +81,7 @@ const TokenTransfer = ({ transfersQuery, tokenId, tokenQuery, shouldRender = tru ...@@ -80,6 +81,7 @@ const TokenTransfer = ({ transfersQuery, tokenId, tokenQuery, shouldRender = tru
socketInfoNum={ newItemsCount } socketInfoNum={ newItemsCount }
tokenId={ tokenId } tokenId={ tokenId }
token={ token } token={ token }
instance={ tokenInstance }
isLoading={ isLoading } isLoading={ isLoading }
/> />
</Box> </Box>
...@@ -93,7 +95,7 @@ const TokenTransfer = ({ transfersQuery, tokenId, tokenQuery, shouldRender = tru ...@@ -93,7 +95,7 @@ const TokenTransfer = ({ transfersQuery, tokenId, tokenQuery, shouldRender = tru
isLoading={ isLoading } isLoading={ isLoading }
/> />
) } ) }
<TokenTransferList data={ data?.items } tokenId={ tokenId } isLoading={ isLoading }/> <TokenTransferList data={ data?.items } tokenId={ tokenId } instance={ tokenInstance } isLoading={ isLoading }/>
</Box> </Box>
</> </>
) : null; ) : null;
......
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenInstance } from 'types/api/token';
import type { TokenTransfer } from 'types/api/tokenTransfer'; import type { TokenTransfer } from 'types/api/tokenTransfer';
import TokenTransferListItem from 'ui/token/TokenTransfer/TokenTransferListItem'; import TokenTransferListItem from 'ui/token/TokenTransfer/TokenTransferListItem';
...@@ -8,10 +9,11 @@ import TokenTransferListItem from 'ui/token/TokenTransfer/TokenTransferListItem' ...@@ -8,10 +9,11 @@ import TokenTransferListItem from 'ui/token/TokenTransfer/TokenTransferListItem'
interface Props { interface Props {
data: Array<TokenTransfer>; data: Array<TokenTransfer>;
tokenId?: string; tokenId?: string;
instance?: TokenInstance;
isLoading?: boolean; isLoading?: boolean;
} }
const TokenTransferList = ({ data, tokenId, isLoading }: Props) => { const TokenTransferList = ({ data, tokenId, instance, isLoading }: Props) => {
return ( return (
<Box> <Box>
{ data.map((item, index) => ( { data.map((item, index) => (
...@@ -19,6 +21,7 @@ const TokenTransferList = ({ data, tokenId, isLoading }: Props) => { ...@@ -19,6 +21,7 @@ const TokenTransferList = ({ data, tokenId, isLoading }: Props) => {
key={ item.transaction_hash + item.block_hash + item.log_index + '_' + index } key={ item.transaction_hash + item.block_hash + item.log_index + '_' + index }
{ ...item } { ...item }
tokenId={ tokenId } tokenId={ tokenId }
instance={ instance }
isLoading={ isLoading } isLoading={ isLoading }
/> />
)) } )) }
......
import { Grid, Flex } from '@chakra-ui/react'; import { Grid, Flex } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenInstance } from 'types/api/token';
import type { TokenTransfer } from 'types/api/tokenTransfer'; import type { TokenTransfer } from 'types/api/tokenTransfer';
import getCurrencyValue from 'lib/getCurrencyValue'; import getCurrencyValue from 'lib/getCurrencyValue';
...@@ -14,7 +15,7 @@ import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; ...@@ -14,7 +15,7 @@ import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import TruncatedValue from 'ui/shared/TruncatedValue'; import TruncatedValue from 'ui/shared/TruncatedValue';
type Props = TokenTransfer & { tokenId?: string; isLoading?: boolean }; type Props = TokenTransfer & { tokenId?: string; isLoading?: boolean; instance?: TokenInstance };
const TokenTransferListItem = ({ const TokenTransferListItem = ({
token, token,
...@@ -26,6 +27,7 @@ const TokenTransferListItem = ({ ...@@ -26,6 +27,7 @@ const TokenTransferListItem = ({
timestamp, timestamp,
tokenId, tokenId,
isLoading, isLoading,
instance,
}: Props) => { }: Props) => {
const { usd, valueStr } = total && 'value' in total && total.value !== null ? getCurrencyValue({ const { usd, valueStr } = total && 'value' in total && total.value !== null ? getCurrencyValue({
value: total.value, value: total.value,
...@@ -94,6 +96,7 @@ const TokenTransferListItem = ({ ...@@ -94,6 +96,7 @@ const TokenTransferListItem = ({
<NftEntity <NftEntity
hash={ token.address } hash={ token.address }
id={ total.token_id } id={ total.token_id }
instance={ instance || total.token_instance }
noLink={ Boolean(tokenId && tokenId === total.token_id) } noLink={ Boolean(tokenId && tokenId === total.token_id) }
isLoading={ isLoading } isLoading={ isLoading }
/> />
......
import { Table, Tbody, Tr, Th } from '@chakra-ui/react'; import { Table, Tbody, Tr, Th } from '@chakra-ui/react';
import React from '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 type { TokenTransfer } from 'types/api/tokenTransfer';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight'; import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
...@@ -20,9 +20,10 @@ interface Props { ...@@ -20,9 +20,10 @@ interface Props {
tokenId?: string; tokenId?: string;
isLoading?: boolean; isLoading?: boolean;
token: TokenInfo; 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; const tokenType = token.type;
...@@ -59,6 +60,7 @@ const TokenTransferTable = ({ data, top, showSocketInfo, socketInfoAlert, socket ...@@ -59,6 +60,7 @@ const TokenTransferTable = ({ data, top, showSocketInfo, socketInfoAlert, socket
key={ item.transaction_hash + item.block_hash + item.log_index + '_' + index } key={ item.transaction_hash + item.block_hash + item.log_index + '_' + index }
{ ...item } { ...item }
tokenId={ tokenId } tokenId={ tokenId }
instance={ instance }
isLoading={ isLoading } isLoading={ isLoading }
/> />
)) } )) }
......
import { Tr, Td, Flex, Box } from '@chakra-ui/react'; import { Tr, Td, Flex, Box } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenInstance } from 'types/api/token';
import type { TokenTransfer } from 'types/api/tokenTransfer'; import type { TokenTransfer } from 'types/api/tokenTransfer';
import getCurrencyValue from 'lib/getCurrencyValue'; import getCurrencyValue from 'lib/getCurrencyValue';
...@@ -12,7 +13,7 @@ import NftEntity from 'ui/shared/entities/nft/NftEntity'; ...@@ -12,7 +13,7 @@ import NftEntity from 'ui/shared/entities/nft/NftEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = TokenTransfer & { tokenId?: string; isLoading?: boolean }; type Props = TokenTransfer & { tokenId?: string; isLoading?: boolean; instance?: TokenInstance };
const TokenTransferTableItem = ({ const TokenTransferTableItem = ({
token, token,
...@@ -24,6 +25,7 @@ const TokenTransferTableItem = ({ ...@@ -24,6 +25,7 @@ const TokenTransferTableItem = ({
timestamp, timestamp,
tokenId, tokenId,
isLoading, isLoading,
instance,
}: Props) => { }: Props) => {
const { usd, valueStr } = total && 'value' in total && total.value !== null ? getCurrencyValue({ const { usd, valueStr } = total && 'value' in total && total.value !== null ? getCurrencyValue({
value: total.value, value: total.value,
...@@ -78,6 +80,7 @@ const TokenTransferTableItem = ({ ...@@ -78,6 +80,7 @@ const TokenTransferTableItem = ({
<NftEntity <NftEntity
hash={ token.address } hash={ token.address }
id={ total.token_id } id={ total.token_id }
instance={ instance || total.token_instance }
noLink={ Boolean(tokenId && tokenId === total.token_id) } noLink={ Boolean(tokenId && tokenId === total.token_id) }
isLoading={ isLoading } isLoading={ isLoading }
/> />
......
...@@ -113,6 +113,7 @@ const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => { ...@@ -113,6 +113,7 @@ const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => {
<NftMedia <NftMedia
data={ data } data={ data }
isLoading={ isLoading } isLoading={ isLoading }
size="md"
withFullscreen withFullscreen
w="250px" w="250px"
flexShrink={ 0 } flexShrink={ 0 }
......
...@@ -76,6 +76,7 @@ const TokenTransfersListItem = ({ item, isLoading }: Props) => { ...@@ -76,6 +76,7 @@ const TokenTransfersListItem = ({ item, isLoading }: Props) => {
<NftEntity <NftEntity
hash={ item.token.address } hash={ item.token.address }
id={ item.total.token_id } id={ item.total.token_id }
instance={ item.total.token_instance }
isLoading={ isLoading } isLoading={ isLoading }
noIcon noIcon
/> />
......
...@@ -67,6 +67,7 @@ const TokenTransferTableItem = ({ item, isLoading }: Props) => { ...@@ -67,6 +67,7 @@ const TokenTransferTableItem = ({ item, isLoading }: Props) => {
<NftEntity <NftEntity
hash={ item.token.address } hash={ item.token.address }
id={ item.total.token_id } id={ item.total.token_id }
instance={ item.total.token_instance }
isLoading={ isLoading } isLoading={ isLoading }
maxW="140px" maxW="140px"
/> />
......
import React from 'react'; import React from 'react';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import * as txMock from 'mocks/txs/tx'; import * as txMock from 'mocks/txs/tx';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib'; import { test, expect } from 'playwright/lib';
...@@ -27,7 +28,8 @@ test('creating contact', async({ render, page }) => { ...@@ -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 }/>); const component = await render(<TxInfo data={ txMock.withTokenTransfer } isLoading={ false }/>);
await expect(component).toHaveScreenshot({ 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