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: Nick Zenchik <[email protected]>
@@ -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",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# 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
|
||||
@@ -1,6 +1,8 @@
|
||||
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 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 = (
|
||||
|
||||
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 90 KiB |
@@ -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 }/> } :
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
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>);
|
||||
|
||||
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
@@ -1,6 +1,7 @@
|
||||
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%">
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
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 }
|
||||
/>
|
||||
) }
|
||||
|
||||
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 89 KiB |
@@ -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 }
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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) => {
|
||||
|
||||
@@ -1,28 +1,54 @@
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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;
|
||||
@@ -1,30 +1,66 @@
|
||||
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);
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
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) {
|
||||
setIsMediaLoading(false);
|
||||
setIsLoadingError(true);
|
||||
if (!mediaInfoQuery.isPending && !mediaInfoQuery.data) {
|
||||
if (mediaInfoField === 'animation_url') {
|
||||
setMediaInfoField('image_url');
|
||||
} else {
|
||||
setIsMediaLoadingError(true);
|
||||
setIsMediaLoading(false);
|
||||
}
|
||||
}
|
||||
}, [ 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 }/>;
|
||||
return <NftVideo { ...props } src={ mediaInfo.src } transport={ mediaInfo.transport } autoPlay={ autoplayVideo } instance={ data }/>;
|
||||
}
|
||||
case 'html':
|
||||
return <NftHtml { ...props } src={ mediaInfo.src }/>;
|
||||
return <NftHtml { ...props } src={ mediaInfo.src } transport={ mediaInfo.transport }/>;
|
||||
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':
|
||||
return <NftHtmlFullscreen { ...props } src={ mediaInfo.src }/>;
|
||||
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,27 +102,31 @@ const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }:
|
||||
})();
|
||||
|
||||
return (
|
||||
<AspectRatio
|
||||
ref={ ref }
|
||||
className={ className }
|
||||
ratio={ 1 / 1 }
|
||||
overflow="hidden"
|
||||
borderRadius="md"
|
||||
objectFit="contain"
|
||||
isolation="isolate"
|
||||
sx={{
|
||||
'&>img, &>video': {
|
||||
objectFit: 'contain',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<>
|
||||
{ content }
|
||||
{ modal }
|
||||
{ isMediaLoading && <Skeleton position="absolute" left={ 0 } top={ 0 } w="100%" h="100%" zIndex="1"/> }
|
||||
</>
|
||||
</AspectRatio>
|
||||
<>
|
||||
<AspectRatio
|
||||
ref={ ref }
|
||||
className={ className }
|
||||
ratio={ 1 / 1 }
|
||||
overflow="hidden"
|
||||
borderRadius="md"
|
||||
objectFit="contain"
|
||||
isolation="isolate"
|
||||
sx={{
|
||||
'&>img, &>video': {
|
||||
objectFit: 'contain',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<>
|
||||
{ content }
|
||||
{ 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>
|
||||
);
|
||||
|
||||
@@ -1,70 +1,26 @@
|
||||
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() => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!config.UI.views.nft.verifiedFetch.isEnabled) {
|
||||
throw new Error('Helia verified fetch is disabled');
|
||||
}
|
||||
const imageUrl = typeof instance.metadata?.image === 'string' ? instance.metadata.image : undefined;
|
||||
if (!imageUrl) {
|
||||
throw new Error('No image URL found');
|
||||
}
|
||||
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;
|
||||
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}, [ instance.image_url, instance.metadata?.image, instance.thumbnails, onLoad ]);
|
||||
|
||||
React.useEffect(() => {
|
||||
!autoPlay && fetchVideoPoster();
|
||||
return () => {
|
||||
controller.current?.abort();
|
||||
};
|
||||
// run only on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
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();
|
||||
@@ -74,19 +30,82 @@ const NftVideo = ({ src, instance, autoPlay = true, onLoad, onError, onClick }:
|
||||
!autoPlay && ref.current?.pause();
|
||||
}, [ autoPlay ]);
|
||||
|
||||
const loadViaHttp = React.useCallback(async() => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
case 'http':
|
||||
loadViaHttp();
|
||||
break;
|
||||
}
|
||||
}, [ loadViaHttp, onError, transport ]);
|
||||
|
||||
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
|
||||
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?.();
|
||||
}
|
||||
})
|
||||
.catch(onError);
|
||||
}, [ loadImageViaIpfs, onError, onLoad ]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoPlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}, [ 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 }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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;
|
||||
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
}, [ ]);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
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 ]);
|
||||
transport: TransportType;
|
||||
}
|
||||
|
||||
function composeAssetsData(data: TokenInstance): Record<TransportType, AssetsData> {
|
||||
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);
|
||||
|
||||
if (!mediaType || (allowedTypes ? !allowedTypes.includes(mediaType) : false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cdnData = getCdnData(data, size, mediaType);
|
||||
const ipfsData = getIpfsData(data.metadata?.[metadataField], mediaType);
|
||||
|
||||
return [
|
||||
cdnData,
|
||||
ipfsData,
|
||||
url ? { src: url, mediaType, transport: 'http' as const } : undefined,
|
||||
].filter(Boolean);
|
||||
},
|
||||
enabled: isEnabled,
|
||||
});
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
async function getMediaType(data: TokenInstance, field: Params['field']): Promise<MediaType | undefined> {
|
||||
const url = data[field];
|
||||
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 preliminaryType;
|
||||
}
|
||||
|
||||
try {
|
||||
const mediaTypeResourceUrl = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url } });
|
||||
const response = await fetch(mediaTypeResourceUrl);
|
||||
const payload = await response.json() as { type: MediaType | undefined };
|
||||
|
||||
return payload.type;
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function castMimeTypeToMediaType(mimeType: string | undefined): MediaType | undefined {
|
||||
if (!mimeType) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 {
|
||||
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,
|
||||
},
|
||||
src: url,
|
||||
mediaType,
|
||||
transport: 'ipfs',
|
||||
};
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
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;
|
||||
}
|
||||
} 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 ]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
controller.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function useNftMediaTypeQuery(url: string | undefined, enabled: boolean) {
|
||||
const fetch = useFetch();
|
||||
|
||||
return useQuery<ReturnType | null, ResourceError<unknown>, ReturnType | null>({
|
||||
queryKey: [ 'nft-media-type', url ],
|
||||
queryFn: async() => {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
const preliminaryType = getPreliminaryMediaType(url);
|
||||
|
||||
if (preliminaryType) {
|
||||
return { mediaType: preliminaryType, src: url, srcType: 'url' };
|
||||
}
|
||||
|
||||
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' });
|
||||
|
||||
return 'type' in response ? response.type : undefined;
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!mediaType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { mediaType, src: url, srcType: 'url' };
|
||||
},
|
||||
enabled,
|
||||
placeholderData: { mediaType: undefined },
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
import type { HTMLChakraProps } from '@chakra-ui/react';
|
||||
import type React from 'react';
|
||||
|
||||
export type MediaType = 'image' | 'video' | 'html';
|
||||
export type TransportType = 'http' | 'ipfs';
|
||||
|
||||
export type SrcType = 'url' | 'blob';
|
||||
// Currently we have only 3 sizes:
|
||||
// sm = max-width<=30px
|
||||
// md = max-width<=250px
|
||||
// original
|
||||
export type Size = 'sm' | 'md' | 'original';
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 }
|
||||
/>
|
||||
)) }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 }
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 }
|
||||
/>
|
||||
)) }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 }
|
||||
/>
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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({
|
||||
|
||||
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 229 KiB |