Commit 51f593c4 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Implement NFT instance metadata refetch button (#2039)

* refactor token instance page title

* add menu item and modal with recaptcha

* initialize refresh and show loading state

* handle error state

* fix metadata atributes parser

* refactoring

* tests
parent 5a0b5003
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.396 2.285a8.837 8.837 0 0 1 13.173 6.436.9.9 0 1 1-1.783.247A7.037 7.037 0 0 0 4.105 5.826a.1.1 0 0 0 .081.159H5.92a.9.9 0 1 1 0 1.8H2a.9.9 0 0 1-.9-.9v-3.92a.9.9 0 0 1 1.8 0v1.192a.1.1 0 0 0 .176.065 8.836 8.836 0 0 1 2.32-1.937Zm2.563 7.54a1.88 1.88 0 1 1 3.76 0 1.88 1.88 0 0 1-3.76 0Zm-6.083.088a.9.9 0 0 1 1.015.767 7.037 7.037 0 0 0 12.682 3.142.1.1 0 0 0-.082-.158h-1.733a.9.9 0 1 1 0-1.8h3.41a.93.93 0 0 1 .04 0h.47a.9.9 0 0 1 .9.9v3.92a.9.9 0 1 1-1.8 0V15.49a.1.1 0 0 0-.177-.064 8.834 8.834 0 0 1-12.958.564 8.837 8.837 0 0 1-2.534-5.063.9.9 0 0 1 .767-1.015Zm7.983-.088V9.82l-.002-.005a.019.019 0 0 0-.004-.006.019.019 0 0 0-.006-.004h-.002l-.004-.001h-.006l-.005.001a.019.019 0 0 0-.006.004.019.019 0 0 0-.004.006.028.028 0 0 0-.001.005v.003l.04.001Z" fill="currentColor"/>
</svg>
...@@ -545,6 +545,11 @@ export const RESOURCES = { ...@@ -545,6 +545,11 @@ export const RESOURCES = {
pathParams: [ 'hash' as const, 'id' as const ], pathParams: [ 'hash' as const, 'id' as const ],
filterFields: [], filterFields: [],
}, },
token_instance_refresh_metadata: {
path: '/api/v2/tokens/:hash/instances/:id/refetch-metadata',
pathParams: [ 'hash' as const, 'id' as const ],
filterFields: [],
},
// APP STATS // APP STATS
stats: { stats: {
......
...@@ -4,6 +4,7 @@ import type { AddressCoinBalanceHistoryItem, AddressTokensBalancesSocketMessage ...@@ -4,6 +4,7 @@ import type { AddressCoinBalanceHistoryItem, AddressTokensBalancesSocketMessage
import type { NewBlockSocketResponse } from 'types/api/block'; import type { NewBlockSocketResponse } from 'types/api/block';
import type { SmartContractVerificationResponse } from 'types/api/contract'; import type { SmartContractVerificationResponse } from 'types/api/contract';
import type { RawTracesResponse } from 'types/api/rawTrace'; import type { RawTracesResponse } from 'types/api/rawTrace';
import type { TokenInstanceMetadataSocketMessage } from 'types/api/token';
import type { TokenTransfer } from 'types/api/tokenTransfer'; import type { TokenTransfer } from 'types/api/tokenTransfer';
import type { Transaction } from 'types/api/transaction'; import type { Transaction } from 'types/api/transaction';
import type { NewZkEvmBatchSocketResponse } from 'types/api/zkEvmL2'; import type { NewZkEvmBatchSocketResponse } from 'types/api/zkEvmL2';
...@@ -32,6 +33,7 @@ SocketMessage.AddressFetchedBytecode | ...@@ -32,6 +33,7 @@ SocketMessage.AddressFetchedBytecode |
SocketMessage.SmartContractWasVerified | SocketMessage.SmartContractWasVerified |
SocketMessage.TokenTransfers | SocketMessage.TokenTransfers |
SocketMessage.TokenTotalSupply | SocketMessage.TokenTotalSupply |
SocketMessage.TokenInstanceMetadataFetched |
SocketMessage.ContractVerification | SocketMessage.ContractVerification |
SocketMessage.NewZkEvmL2Batch | SocketMessage.NewZkEvmL2Batch |
SocketMessage.Unknown; SocketMessage.Unknown;
...@@ -69,6 +71,7 @@ export namespace SocketMessage { ...@@ -69,6 +71,7 @@ export namespace SocketMessage {
export type SmartContractWasVerified = SocketMessageParamsGeneric<'smart_contract_was_verified', Record<string, never>>; export type SmartContractWasVerified = SocketMessageParamsGeneric<'smart_contract_was_verified', Record<string, never>>;
export type TokenTransfers = SocketMessageParamsGeneric<'token_transfer', {token_transfer: number }>; export type TokenTransfers = SocketMessageParamsGeneric<'token_transfer', {token_transfer: number }>;
export type TokenTotalSupply = SocketMessageParamsGeneric<'total_supply', {total_supply: number }>; export type TokenTotalSupply = SocketMessageParamsGeneric<'total_supply', {total_supply: number }>;
export type TokenInstanceMetadataFetched = SocketMessageParamsGeneric<'fetched_token_instance_metadata', TokenInstanceMetadataSocketMessage>;
export type ContractVerification = SocketMessageParamsGeneric<'verification_result', SmartContractVerificationResponse>; export type ContractVerification = SocketMessageParamsGeneric<'verification_result', SmartContractVerificationResponse>;
export type NewZkEvmL2Batch = SocketMessageParamsGeneric<'new_zkevm_confirmed_batch', NewZkEvmBatchSocketResponse>; export type NewZkEvmL2Batch = SocketMessageParamsGeneric<'new_zkevm_confirmed_batch', NewZkEvmBatchSocketResponse>;
export type Unknown = SocketMessageParamsGeneric<undefined, unknown>; export type Unknown = SocketMessageParamsGeneric<undefined, unknown>;
......
...@@ -48,11 +48,25 @@ export default function attributesParser(attributes: Array<unknown>): Metadata[' ...@@ -48,11 +48,25 @@ export default function attributesParser(attributes: Array<unknown>): Metadata['
return; return;
} }
const value = 'value' in item && (typeof item.value === 'string' || typeof item.value === 'number') ? item.value : undefined; const value = (() => {
if (!('value' in item)) {
return;
}
switch (typeof item.value) {
case 'string':
case 'number':
return item.value;
case 'boolean':
return String(item.value);
case 'object':
return JSON.stringify(item.value);
}
})();
const trait = 'trait_type' in item && typeof item.trait_type === 'string' ? item.trait_type : undefined; const trait = 'trait_type' in item && typeof item.trait_type === 'string' ? item.trait_type : undefined;
const display = 'display_type' in item && typeof item.display_type === 'string' ? item.display_type : undefined; const display = 'display_type' in item && typeof item.display_type === 'string' ? item.display_type : undefined;
if (!value) { if (value === undefined) {
return; return;
} }
......
...@@ -5,9 +5,9 @@ import * as addressMock from '../address/address'; ...@@ -5,9 +5,9 @@ import * as addressMock from '../address/address';
export const base: TokenInstance = { export const base: TokenInstance = {
animation_url: null, animation_url: null,
external_app_url: null, external_app_url: 'https://duck.nft/get-your-duck-today',
id: '32925298983216553915666621415831103694597106215670571463977478984525997408266', id: '32925298983216553915666621415831103694597106215670571463977478984525997408266',
image_url: null, image_url: 'https://example.com/image.jpg',
is_unique: false, is_unique: false,
holder_address_hash: null, holder_address_hash: null,
metadata: { metadata: {
......
...@@ -5,6 +5,7 @@ import { WebSocketServer } from 'ws'; ...@@ -5,6 +5,7 @@ import { WebSocketServer } from 'ws';
import type { AddressCoinBalanceHistoryItem, AddressTokensBalancesSocketMessage } from 'types/api/address'; import type { AddressCoinBalanceHistoryItem, AddressTokensBalancesSocketMessage } from 'types/api/address';
import type { NewBlockSocketResponse } from 'types/api/block'; import type { NewBlockSocketResponse } from 'types/api/block';
import type { SmartContractVerificationResponse } from 'types/api/contract'; import type { SmartContractVerificationResponse } from 'types/api/contract';
import type { TokenInstanceMetadataSocketMessage } from 'types/api/token';
import type { TokenTransfer } from 'types/api/tokenTransfer'; import type { TokenTransfer } from 'types/api/tokenTransfer';
import type { Transaction } from 'types/api/transaction'; import type { Transaction } from 'types/api/transaction';
...@@ -74,6 +75,7 @@ export function sendMessage(socket: WebSocket, channel: Channel, msg: 'changed_b ...@@ -74,6 +75,7 @@ export function sendMessage(socket: WebSocket, channel: Channel, msg: 'changed_b
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'fetched_bytecode', payload: { fetched_bytecode: string }): void; export function sendMessage(socket: WebSocket, channel: Channel, msg: 'fetched_bytecode', payload: { fetched_bytecode: string }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'smart_contract_was_verified', payload: Record<string, never>): void; export function sendMessage(socket: WebSocket, channel: Channel, msg: 'smart_contract_was_verified', payload: Record<string, never>): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'token_transfer', payload: { token_transfers: Array<TokenTransfer> }): void; export function sendMessage(socket: WebSocket, channel: Channel, msg: 'token_transfer', payload: { token_transfers: Array<TokenTransfer> }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'fetched_token_instance_metadata', payload: TokenInstanceMetadataSocketMessage): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: string, payload: unknown): void { export function sendMessage(socket: WebSocket, channel: Channel, msg: string, payload: unknown): void {
socket.send(JSON.stringify([ socket.send(JSON.stringify([
...channel, ...channel,
......
...@@ -98,6 +98,7 @@ ...@@ -98,6 +98,7 @@
| "publictags_slim" | "publictags_slim"
| "publictags" | "publictags"
| "qr_code" | "qr_code"
| "refresh"
| "repeat_arrow" | "repeat_arrow"
| "restAPI" | "restAPI"
| "rocket_xl" | "rocket_xl"
......
...@@ -169,7 +169,7 @@ export const TOKEN_INSTANCE: TokenInstance = { ...@@ -169,7 +169,7 @@ export const TOKEN_INSTANCE: TokenInstance = {
description: '**GENESIS #188882**, **8a77ca1bcaa4036f** :: *844th* generation of *#57806 and #57809* :: **eGenetic Hash Code (eDNA)** = *2822355e953a462d*', description: '**GENESIS #188882**, **8a77ca1bcaa4036f** :: *844th* generation of *#57806 and #57809* :: **eGenetic Hash Code (eDNA)** = *2822355e953a462d*',
external_url: 'https://vipsland.com/nft/collections/genesis/188882', external_url: 'https://vipsland.com/nft/collections/genesis/188882',
image: 'https://ipfs.vipsland.com/nft/collections/genesis/188882.gif', image: 'https://ipfs.vipsland.com/nft/collections/genesis/188882.gif',
name: 'GENESIS #188882, 8a77ca1bcaa4036f. Blockchain pixel PFP NFT + "on music video" trait inspired by God', name: 'GENESIS #188882, 8a77ca1bcaa4036f',
}, },
owner: ADDRESS_PARAMS, owner: ADDRESS_PARAMS,
holder_address_hash: ADDRESS_HASH, holder_address_hash: ADDRESS_HASH,
......
...@@ -61,6 +61,11 @@ export interface TokenInstance { ...@@ -61,6 +61,11 @@ export interface TokenInstance {
owner: AddressParam | null; owner: AddressParam | null;
} }
export interface TokenInstanceMetadataSocketMessage {
token_id: number;
fetched_metadata: TokenInstance['metadata'];
}
export interface TokenInstanceTransfersCount { export interface TokenInstanceTransfersCount {
transfers_count: number; transfers_count: number;
} }
......
import React from 'react';
import config from 'configs/app';
import * as addressMock from 'mocks/address/address';
import * as tokenMock from 'mocks/tokens/tokenInfo';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import * as socketServer from 'playwright/fixtures/socketServer';
import { test, expect } from 'playwright/lib';
import * as pwConfig from 'playwright/utils/config';
import TokenInstance from './TokenInstance';
const hash = tokenMock.tokenInfo.address;
const id = '42';
test.describe.configure({ mode: 'serial' });
test.beforeEach(async({ mockApiResponse, mockAssetResponse, mockTextAd }) => {
await mockApiResponse('token', tokenMock.tokenInfo, { pathParams: { hash } });
await mockApiResponse('address', addressMock.token, { pathParams: { hash } });
await mockApiResponse('token_instance', tokenInstanceMock.unique, { pathParams: { hash, id } });
await mockApiResponse('token_instance_transfers', { items: [], next_page_params: null }, { pathParams: { hash, id } });
await mockApiResponse('token_instance_transfers_count', { transfers_count: 420 }, { pathParams: { hash, id } });
await mockTextAd();
for (const marketplace of config.UI.views.nft.marketplaces) {
await mockAssetResponse(marketplace.logo_url, './playwright/mocks/image_svg.svg');
}
await mockAssetResponse(tokenInstanceMock.base.image_url as string, './playwright/mocks/image_md.jpg');
});
test('metadata update', async({ render, page, createSocket, mockApiResponse, mockAssetResponse }) => {
const hooksConfig = {
router: {
query: { hash, id, tab: 'metadata' },
pathname: '/token/[hash]/instance/[id]',
},
};
const newMetadata = {
attributes: [
{ value: 'yellow', trait_type: 'Color' },
{ value: 'Mrs. Duckie', trait_type: 'Name' },
],
external_url: 'https://yellow-duck.nft',
image_url: 'https://yellow-duck.nft/duck.jpg',
animation_url: null,
status: 'FRESH!!!',
name: 'Carmelo Anthony',
description: 'Updated description',
};
await mockApiResponse('token_instance_refresh_metadata', {} as never, { pathParams: { hash, id } });
await mockAssetResponse(newMetadata.image_url, './playwright/mocks/image_long.jpg');
const component = await render(<TokenInstance/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
// take a screenshot of initial state
await expect(component).toHaveScreenshot({
mask: [ page.locator(pwConfig.adsBannerSelector) ],
maskColor: pwConfig.maskColor,
});
// open the menu, click the button and submit form
await page.getByLabel('Address menu').click();
await page.getByRole('menuitem', { name: 'Refresh metadata' }).click();
await page.evaluate(() => {
const form = document.querySelector('form');
form && (form.style.display = 'block');
});
await page.getByPlaceholder('reCaptcha token').fill('xxx');
await page.getByRole('button', { name: 'Submit' }).click();
// join socket channel
const channel = await socketServer.joinChannel(socket, `token_instances:${ hash.toLowerCase() }`);
// check that button is disabled
await page.getByLabel('Address menu').click();
await expect(page.getByRole('menuitem', { name: 'Refresh metadata' })).toBeDisabled();
await page.getByLabel('Address menu').click();
// take a screenshot of loading state
await expect(component).toHaveScreenshot({
mask: [ page.locator(pwConfig.adsBannerSelector) ],
maskColor: pwConfig.maskColor,
});
// send message in socket but with wrong token id
socketServer.sendMessage(socket, channel, 'fetched_token_instance_metadata', {
token_id: Number(id) + 1,
fetched_metadata: newMetadata,
});
await expect(page.getByText(newMetadata.description)).toBeHidden();
// send message in socket with correct token id
socketServer.sendMessage(socket, channel, 'fetched_token_instance_metadata', {
token_id: Number(id),
fetched_metadata: newMetadata,
});
// take a screenshot of updated state
await expect(component).toHaveScreenshot({
mask: [ page.locator(pwConfig.adsBannerSelector) ],
maskColor: pwConfig.maskColor,
});
});
test('metadata update failed', async({ render, page }) => {
const hooksConfig = {
router: {
query: { hash, id, tab: 'metadata' },
pathname: '/token/[hash]/instance/[id]',
},
};
const component = await render(<TokenInstance/>, { hooksConfig }, { withSocket: true });
// open the menu, click the button and submit form
await page.getByLabel('Address menu').click();
await page.getByRole('menuitem', { name: 'Refresh metadata' }).click();
await page.evaluate(() => {
const form = document.querySelector('form');
form && (form.style.display = 'block');
});
await page.getByPlaceholder('reCaptcha token').fill('xxx');
await page.getByRole('button', { name: 'Submit' }).click();
// check that button is not disabled
await page.getByLabel('Address menu').click();
await expect(page.getByRole('menuitem', { name: 'Refresh metadata' })).toBeEnabled();
await page.getByLabel('Address menu').click();
// take a screenshot of error state
await expect(component).toHaveScreenshot({
mask: [ page.locator(pwConfig.adsBannerSelector) ],
maskColor: pwConfig.maskColor,
});
});
import { Box, Flex } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
...@@ -6,44 +6,37 @@ import type { PaginationParams } from 'ui/shared/pagination/types'; ...@@ -6,44 +6,37 @@ import type { PaginationParams } from 'ui/shared/pagination/types';
import type { RoutedTab } from 'ui/shared/Tabs/types'; import type { RoutedTab } from 'ui/shared/Tabs/types';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import * as metadata from 'lib/metadata'; import * as metadata from 'lib/metadata';
import * as regexp from 'lib/regexp'; import getQueryParamString from 'lib/router/getQueryParamString';
import { getTokenTypeName } from 'lib/token/tokenTypes';
import { import {
TOKEN_INSTANCE, TOKEN_INSTANCE,
TOKEN_INFO_ERC_1155, TOKEN_INFO_ERC_1155,
getTokenInstanceTransfersStub, getTokenInstanceTransfersStub,
getTokenInstanceHoldersStub, getTokenInstanceHoldersStub,
} from 'stubs/token'; } from 'stubs/token';
import AddressQrCode from 'ui/address/details/AddressQrCode';
import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu';
import TextAd from 'ui/shared/ad/TextAd'; import TextAd from 'ui/shared/ad/TextAd';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import Tag from 'ui/shared/chakra/Tag';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import LinkExternal from 'ui/shared/links/LinkExternal';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination'; import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TokenHolders from 'ui/token/TokenHolders/TokenHolders'; import TokenHolders from 'ui/token/TokenHolders/TokenHolders';
import TokenTransfer from 'ui/token/TokenTransfer/TokenTransfer'; import TokenTransfer from 'ui/token/TokenTransfer/TokenTransfer';
import { MetadataUpdateProvider } from 'ui/tokenInstance/contexts/metadataUpdate';
import TokenInstanceDetails from 'ui/tokenInstance/TokenInstanceDetails'; import TokenInstanceDetails from 'ui/tokenInstance/TokenInstanceDetails';
import TokenInstanceMetadata from 'ui/tokenInstance/TokenInstanceMetadata'; import TokenInstanceMetadata from 'ui/tokenInstance/TokenInstanceMetadata';
import TokenInstanceMetadataFetcher from 'ui/tokenInstance/TokenInstanceMetadataFetcher';
import TokenInstancePageTitle from 'ui/tokenInstance/TokenInstancePageTitle';
export type TokenTabs = 'token_transfers' | 'holders' export type TokenTabs = 'token_transfers' | 'holders'
const TokenInstanceContent = () => { const TokenInstanceContent = () => {
const router = useRouter(); const router = useRouter();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const appProps = useAppContext();
const hash = router.query.hash?.toString(); const hash = getQueryParamString(router.query.hash);
const id = router.query.id?.toString(); const id = getQueryParamString(router.query.id);
const tab = router.query.tab?.toString(); const tab = getQueryParamString(router.query.tab);
const scrollRef = React.useRef<HTMLDivElement>(null); const scrollRef = React.useRef<HTMLDivElement>(null);
...@@ -100,19 +93,6 @@ const TokenInstanceContent = () => { ...@@ -100,19 +93,6 @@ const TokenInstanceContent = () => {
} }
}, [ tokenInstanceQuery.data, tokenInstanceQuery.isPlaceholderData, tokenQuery.data, tokenQuery.isPlaceholderData ]); }, [ tokenInstanceQuery.data, tokenInstanceQuery.isPlaceholderData, tokenQuery.data, tokenQuery.isPlaceholderData ]);
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes(`/token/${ hash }`) && !appProps.referrer.includes('instance');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to token page',
url: appProps.referrer,
};
}, [ appProps.referrer, hash ]);
const tabs: Array<RoutedTab> = [ const tabs: Array<RoutedTab> = [
{ {
id: 'token_transfers', id: 'token_transfers',
...@@ -132,40 +112,6 @@ const TokenInstanceContent = () => { ...@@ -132,40 +112,6 @@ const TokenInstanceContent = () => {
throwOnResourceLoadError(tokenInstanceQuery); throwOnResourceLoadError(tokenInstanceQuery);
const tokenTag = tokenQuery.data?.type ? <Tag isLoading={ tokenInstanceQuery.isPlaceholderData }>{ getTokenTypeName(tokenQuery.data?.type) }</Tag> : null;
const address = {
hash: hash || '',
is_contract: true,
implementations: null,
watchlist_names: [],
watchlist_address_id: null,
};
const appLink = (() => {
if (!tokenInstanceQuery.data?.external_app_url) {
return null;
}
try {
const url = regexp.URL_PREFIX.test(tokenInstanceQuery.data.external_app_url) ?
new URL(tokenInstanceQuery.data.external_app_url) :
new URL('https://' + tokenInstanceQuery.data.external_app_url);
return (
<LinkExternal href={ url.toString() } variant="subtle" isLoading={ isLoading } ml={{ base: 0, lg: 'auto' }}>
{ url.hostname || tokenInstanceQuery.data.external_app_url }
</LinkExternal>
);
} catch (error) {
return (
<LinkExternal href={ tokenInstanceQuery.data.external_app_url } isLoading={ isLoading } ml={{ base: 0, lg: 'auto' }}>
View in app
</LinkExternal>
);
}
})();
let pagination: PaginationParams | undefined; let pagination: PaginationParams | undefined;
if (tab === 'token_transfers') { if (tab === 'token_transfers') {
...@@ -174,50 +120,15 @@ const TokenInstanceContent = () => { ...@@ -174,50 +120,15 @@ const TokenInstanceContent = () => {
pagination = holdersQuery.pagination; pagination = holdersQuery.pagination;
} }
const title = (() => {
if (typeof tokenInstanceQuery.data?.metadata?.name === 'string') {
return tokenInstanceQuery.data.metadata.name;
}
if (tokenQuery.data?.symbol) {
return (tokenQuery.data.name || tokenQuery.data.symbol) + ' #' + tokenInstanceQuery.data?.id;
}
return `ID ${ tokenInstanceQuery.data?.id }`;
})();
const titleSecondRow = (
<Flex alignItems="center" w="100%" minW={ 0 } columnGap={ 2 } rowGap={ 2 } flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
{ tokenQuery.data && (
<TokenEntity
token={ tokenQuery.data }
isLoading={ isLoading }
noSymbol
noCopy
jointSymbol
fontFamily="heading"
fontSize="lg"
fontWeight={ 500 }
w="auto"
maxW="700px"
/>
) }
{ !isLoading && tokenInstanceQuery.data && <AddressAddToWallet token={ tokenQuery.data } variant="button"/> }
<AddressQrCode address={ address } isLoading={ isLoading }/>
<AccountActionsMenu isLoading={ isLoading }/>
{ appLink }
</Flex>
);
return ( return (
<> <MetadataUpdateProvider>
<TextAd mb={ 6 }/> <TextAd mb={ 6 }/>
<PageTitle
title={ title } <TokenInstancePageTitle
backLink={ backLink }
contentAfter={ tokenTag }
secondRow={ titleSecondRow }
isLoading={ isLoading } isLoading={ isLoading }
token={ tokenQuery.data }
instance={ tokenInstanceQuery.data }
hash={ hash }
/> />
<TokenInstanceDetails data={ tokenInstanceQuery?.data } isLoading={ isLoading } scrollRef={ scrollRef } token={ tokenQuery.data }/> <TokenInstanceDetails data={ tokenInstanceQuery?.data } isLoading={ isLoading } scrollRef={ scrollRef } token={ tokenQuery.data }/>
...@@ -232,7 +143,9 @@ const TokenInstanceContent = () => { ...@@ -232,7 +143,9 @@ const TokenInstanceContent = () => {
rightSlot={ !isMobile && pagination?.isVisible ? <Pagination { ...pagination }/> : null } rightSlot={ !isMobile && pagination?.isVisible ? <Pagination { ...pagination }/> : null }
stickyEnabled={ !isMobile } stickyEnabled={ !isMobile }
/> />
</>
<TokenInstanceMetadataFetcher hash={ hash } id={ id }/>
</MetadataUpdateProvider>
); );
}; };
......
...@@ -11,6 +11,7 @@ import * as mixpanel from 'lib/mixpanel/index'; ...@@ -11,6 +11,7 @@ import * as mixpanel from 'lib/mixpanel/index';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import MetadataUpdateMenuItem from './items/MetadataUpdateMenuItem';
import PrivateTagMenuItem from './items/PrivateTagMenuItem'; import PrivateTagMenuItem from './items/PrivateTagMenuItem';
import PublicTagMenuItem from './items/PublicTagMenuItem'; import PublicTagMenuItem from './items/PublicTagMenuItem';
import TokenInfoMenuItem from './items/TokenInfoMenuItem'; import TokenInfoMenuItem from './items/TokenInfoMenuItem';
...@@ -18,13 +19,15 @@ import TokenInfoMenuItem from './items/TokenInfoMenuItem'; ...@@ -18,13 +19,15 @@ import TokenInfoMenuItem from './items/TokenInfoMenuItem';
interface Props { interface Props {
isLoading?: boolean; isLoading?: boolean;
className?: string; className?: string;
showUpdateMetadataItem?: boolean;
} }
const AccountActionsMenu = ({ isLoading, className }: Props) => { const AccountActionsMenu = ({ isLoading, className, showUpdateMetadataItem }: Props) => {
const router = useRouter(); const router = useRouter();
const hash = getQueryParamString(router.query.hash); const hash = getQueryParamString(router.query.hash);
const isTokenPage = router.pathname === '/token/[hash]'; const isTokenPage = router.pathname === '/token/[hash]';
const isTokenInstancePage = router.pathname === '/token/[hash]/instance/[id]';
const isTxPage = router.pathname === '/tx/[hash]'; const isTxPage = router.pathname === '/tx/[hash]';
const isAccountActionAllowed = useIsAccountActionAllowed(); const isAccountActionAllowed = useIsAccountActionAllowed();
...@@ -41,6 +44,10 @@ const AccountActionsMenu = ({ isLoading, className }: Props) => { ...@@ -41,6 +44,10 @@ const AccountActionsMenu = ({ isLoading, className }: Props) => {
const userWithoutEmail = userInfoQuery.data && !userInfoQuery.data.email; const userWithoutEmail = userInfoQuery.data && !userInfoQuery.data.email;
const items = [ const items = [
{
render: (props: ItemProps) => <MetadataUpdateMenuItem { ...props }/>,
enabled: isTokenInstancePage && showUpdateMetadataItem,
},
{ {
render: (props: ItemProps) => <TokenInfoMenuItem { ...props }/>, render: (props: ItemProps) => <TokenInfoMenuItem { ...props }/>,
enabled: isTokenPage && config.features.addressVerification.isEnabled && !userWithoutEmail, enabled: isTokenPage && config.features.addressVerification.isEnabled && !userWithoutEmail,
...@@ -82,6 +89,7 @@ const AccountActionsMenu = ({ isLoading, className }: Props) => { ...@@ -82,6 +89,7 @@ const AccountActionsMenu = ({ isLoading, className }: Props) => {
px="7px" px="7px"
onClick={ handleButtonClick } onClick={ handleButtonClick }
icon={ <IconSvg name="dots" boxSize="18px"/> } icon={ <IconSvg name="dots" boxSize="18px"/> }
aria-label="Show address menu"
/> />
<MenuList minWidth="180px" zIndex="popover"> <MenuList minWidth="180px" zIndex="popover">
{ items.map(({ render }, index) => ( { items.map(({ render }, index) => (
......
import React from 'react';
import type { ItemProps } from '../types';
import IconSvg from 'ui/shared/IconSvg';
import { useMetadataUpdateContext } from 'ui/tokenInstance/contexts/metadataUpdate';
import ButtonItem from '../parts/ButtonItem';
import MenuItem from '../parts/MenuItem';
const MetadataUpdateMenuItem = ({ type, className }: ItemProps) => {
const { status, setStatus } = useMetadataUpdateContext() || {};
const handleClick = React.useCallback(() => {
setStatus?.('MODAL_OPENED');
}, [ setStatus ]);
const element = (() => {
switch (type) {
case 'button': {
return (
<ButtonItem
label="Refresh metadata"
icon="refresh"
onClick={ handleClick }
className={ className }
isDisabled={ status === 'WAITING_FOR_RESPONSE' }
/>
);
}
case 'menu_item': {
return (
<MenuItem className={ className } onClick={ handleClick } isDisabled={ status === 'WAITING_FOR_RESPONSE' }>
<IconSvg name="refresh" boxSize={ 5 } mr={ 2 }/>
<span>Refresh metadata</span>
</MenuItem>
);
}
}
})();
return element;
};
export default React.memo(MetadataUpdateMenuItem);
...@@ -3,7 +3,7 @@ import { useQueryClient } from '@tanstack/react-query'; ...@@ -3,7 +3,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { ItemType } from '../types'; import type { ItemProps } from '../types';
import type { Address } from 'types/api/address'; import type { Address } from 'types/api/address';
import type { Transaction } from 'types/api/transaction'; import type { Transaction } from 'types/api/transaction';
...@@ -16,12 +16,8 @@ import IconSvg from 'ui/shared/IconSvg'; ...@@ -16,12 +16,8 @@ import IconSvg from 'ui/shared/IconSvg';
import ButtonItem from '../parts/ButtonItem'; import ButtonItem from '../parts/ButtonItem';
import MenuItem from '../parts/MenuItem'; import MenuItem from '../parts/MenuItem';
interface Props { interface Props extends ItemProps {
className?: string;
hash: string;
onBeforeClick: () => boolean;
entityType?: 'address' | 'tx'; entityType?: 'address' | 'tx';
type: ItemType;
} }
const PrivateTagMenuItem = ({ className, hash, onBeforeClick, entityType = 'address', type }: Props) => { const PrivateTagMenuItem = ({ className, hash, onBeforeClick, entityType = 'address', type }: Props) => {
......
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { ItemType } from '../types'; import type { ItemProps } from '../types';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import ButtonItem from '../parts/ButtonItem'; import ButtonItem from '../parts/ButtonItem';
import MenuItem from '../parts/MenuItem'; import MenuItem from '../parts/MenuItem';
interface Props { const PublicTagMenuItem = ({ className, hash, onBeforeClick, type }: ItemProps) => {
className?: string;
hash: string;
onBeforeClick: () => boolean;
type: ItemType;
}
const PublicTagMenuItem = ({ className, hash, onBeforeClick, type }: Props) => {
const router = useRouter(); const router = useRouter();
const handleClick = React.useCallback(() => { const handleClick = React.useCallback(() => {
......
...@@ -2,9 +2,7 @@ import { chakra, useDisclosure } from '@chakra-ui/react'; ...@@ -2,9 +2,7 @@ import { chakra, useDisclosure } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { ItemType } from '../types'; import type { ItemProps } from '../types';
import type { Route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
...@@ -16,14 +14,7 @@ import IconSvg from 'ui/shared/IconSvg'; ...@@ -16,14 +14,7 @@ import IconSvg from 'ui/shared/IconSvg';
import ButtonItem from '../parts/ButtonItem'; import ButtonItem from '../parts/ButtonItem';
import MenuItem from '../parts/MenuItem'; import MenuItem from '../parts/MenuItem';
interface Props { const TokenInfoMenuItem = ({ className, hash, onBeforeClick, type }: ItemProps) => {
className?: string;
hash: string;
onBeforeClick: (route: Route) => boolean;
type: ItemType;
}
const TokenInfoMenuItem = ({ className, hash, onBeforeClick, type }: Props) => {
const router = useRouter(); const router = useRouter();
const modal = useDisclosure(); const modal = useDisclosure();
const isAuth = useHasAccount(); const isAuth = useHasAccount();
......
...@@ -9,9 +9,10 @@ interface Props { ...@@ -9,9 +9,10 @@ interface Props {
onClick: () => void; onClick: () => void;
label: string; label: string;
icon: IconName | React.ReactElement; icon: IconName | React.ReactElement;
isDisabled?: boolean;
} }
const ButtonItem = ({ className, label, onClick, icon }: Props) => { const ButtonItem = ({ className, label, onClick, icon, isDisabled }: Props) => {
return ( return (
<Tooltip label={ label }> <Tooltip label={ label }>
<IconButton <IconButton
...@@ -19,6 +20,7 @@ const ButtonItem = ({ className, label, onClick, icon }: Props) => { ...@@ -19,6 +20,7 @@ const ButtonItem = ({ className, label, onClick, icon }: Props) => {
className={ className } className={ className }
icon={ typeof icon === 'string' ? <IconSvg name={ icon } boxSize={ 6 }/> : icon } icon={ typeof icon === 'string' ? <IconSvg name={ icon } boxSize={ 6 }/> : icon }
onClick={ onClick } onClick={ onClick }
isDisabled={ isDisabled }
size="sm" size="sm"
variant="outline" variant="outline"
px="4px" px="4px"
......
...@@ -5,11 +5,12 @@ interface Props { ...@@ -5,11 +5,12 @@ interface Props {
className?: string; className?: string;
children: React.ReactNode; children: React.ReactNode;
onClick: () => void; onClick: () => void;
isDisabled?: boolean;
} }
const MenuItem = ({ className, children, onClick }: Props) => { const MenuItem = ({ className, children, onClick, isDisabled }: Props) => {
return ( return (
<MenuItemChakra className={ className } onClick={ onClick } py={ 2 } px={ 4 }> <MenuItemChakra className={ className } onClick={ onClick } py={ 2 } px={ 4 } isDisabled={ isDisabled }>
{ children } { children }
</MenuItemChakra> </MenuItemChakra>
); );
......
export type ItemType = 'button' | 'menu_item'; export type ItemType = 'button' | 'menu_item';
import type { Route } from 'nextjs-routes';
export interface ItemProps { export interface ItemProps {
className?: string;
type: ItemType; type: ItemType;
hash: string; hash: string;
onBeforeClick: () => boolean; onBeforeClick: (route?: Route) => boolean;
} }
...@@ -29,10 +29,21 @@ const BackLink = (props: BackLinkProp & { isLoading?: boolean }) => { ...@@ -29,10 +29,21 @@ const BackLink = (props: BackLinkProp & { isLoading?: boolean }) => {
} }
if (props.isLoading) { if (props.isLoading) {
return <Skeleton boxSize={ 6 } display="inline-block" borderRadius="base" mr={ 3 } my={ 2 } verticalAlign="text-bottom" isLoaded={ !props.isLoading }/>; return (
<Skeleton
boxSize={ 6 }
display="inline-block"
flexShrink={ 0 }
borderRadius="base"
mr={ 3 }
my={ 2 }
verticalAlign="text-bottom"
isLoaded={ !props.isLoading }
/>
);
} }
const icon = <IconSvg name="arrows/east" boxSize={ 6 } transform="rotate(180deg)" margin="auto" color="gray.400"/>; const icon = <IconSvg name="arrows/east" boxSize={ 6 } transform="rotate(180deg)" margin="auto" color="gray.400" flexShrink={ 0 }/>;
if ('url' in props) { if ('url' in props) {
return ( return (
......
...@@ -10,6 +10,7 @@ import * as tokenInstanceMock from 'mocks/tokens/tokenInstance'; ...@@ -10,6 +10,7 @@ import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
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';
import * as pwConfig from 'playwright/utils/config'; import * as pwConfig from 'playwright/utils/config';
import { MetadataUpdateProvider } from 'ui/tokenInstance/contexts/metadataUpdate';
import TokenInstanceDetails from './TokenInstanceDetails'; import TokenInstanceDetails from './TokenInstanceDetails';
...@@ -41,7 +42,11 @@ test.beforeEach(async({ mockApiResponse, mockAssetResponse }) => { ...@@ -41,7 +42,11 @@ test.beforeEach(async({ mockApiResponse, mockAssetResponse }) => {
}); });
test('base view +@dark-mode +@mobile', async({ render, page }) => { test('base view +@dark-mode +@mobile', async({ render, page }) => {
const component = await render(<TokenInstanceDetails data={ tokenInstanceMock.unique } token={ tokenInfoERC721a }/>); const component = await render(
<MetadataUpdateProvider>
<TokenInstanceDetails data={ tokenInstanceMock.unique } token={ tokenInfoERC721a }/>
</MetadataUpdateProvider>,
);
await expect(component).toHaveScreenshot({ await expect(component).toHaveScreenshot({
mask: [ page.locator(pwConfig.adsBannerSelector) ], mask: [ page.locator(pwConfig.adsBannerSelector) ],
maskColor: pwConfig.maskColor, maskColor: pwConfig.maskColor,
...@@ -57,7 +62,11 @@ test.describe('action button', () => { ...@@ -57,7 +62,11 @@ test.describe('action button', () => {
}); });
test('base view +@dark-mode +@mobile', async({ render, page }) => { test('base view +@dark-mode +@mobile', async({ render, page }) => {
const component = await render(<TokenInstanceDetails data={ tokenInstanceMock.unique } token={ tokenInfoERC721a }/>); const component = await render(
<MetadataUpdateProvider>
<TokenInstanceDetails data={ tokenInstanceMock.unique } token={ tokenInfoERC721a }/>
</MetadataUpdateProvider>,
);
await expect(component).toHaveScreenshot({ await expect(component).toHaveScreenshot({
mask: [ page.locator(pwConfig.adsBannerSelector) ], mask: [ page.locator(pwConfig.adsBannerSelector) ],
maskColor: pwConfig.maskColor, maskColor: pwConfig.maskColor,
...@@ -66,7 +75,11 @@ test.describe('action button', () => { ...@@ -66,7 +75,11 @@ test.describe('action button', () => {
test('without marketplaces +@dark-mode +@mobile', async({ render, page, mockEnvs }) => { test('without marketplaces +@dark-mode +@mobile', async({ render, page, mockEnvs }) => {
mockEnvs(ENVS_MAP.noNftMarketplaces); mockEnvs(ENVS_MAP.noNftMarketplaces);
const component = await render(<TokenInstanceDetails data={ tokenInstanceMock.unique } token={ tokenInfoERC721a }/>); const component = await render(
<MetadataUpdateProvider>
<TokenInstanceDetails data={ tokenInstanceMock.unique } token={ tokenInfoERC721a }/>
</MetadataUpdateProvider>,
);
await expect(component).toHaveScreenshot({ await expect(component).toHaveScreenshot({
mask: [ page.locator(pwConfig.adsBannerSelector) ], mask: [ page.locator(pwConfig.adsBannerSelector) ],
maskColor: pwConfig.maskColor, maskColor: pwConfig.maskColor,
......
import { Box, Flex, Select, chakra } from '@chakra-ui/react'; import { Alert, Box, Flex, Select, chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenInstance } from 'types/api/token'; import type { TokenInstance } from 'types/api/token';
...@@ -7,6 +7,7 @@ import ContentLoader from 'ui/shared/ContentLoader'; ...@@ -7,6 +7,7 @@ import ContentLoader from 'ui/shared/ContentLoader';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import RawDataSnippet from 'ui/shared/RawDataSnippet'; import RawDataSnippet from 'ui/shared/RawDataSnippet';
import { useMetadataUpdateContext } from './contexts/metadataUpdate';
import MetadataAccordion from './metadata/MetadataAccordion'; import MetadataAccordion from './metadata/MetadataAccordion';
type Format = 'JSON' | 'Table' type Format = 'JSON' | 'Table'
...@@ -19,11 +20,13 @@ interface Props { ...@@ -19,11 +20,13 @@ interface Props {
const TokenInstanceMetadata = ({ data, isPlaceholderData }: Props) => { const TokenInstanceMetadata = ({ data, isPlaceholderData }: Props) => {
const [ format, setFormat ] = React.useState<Format>('Table'); const [ format, setFormat ] = React.useState<Format>('Table');
const { status: refetchStatus } = useMetadataUpdateContext() || {};
const handleSelectChange = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => { const handleSelectChange = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => {
setFormat(event.target.value as Format); setFormat(event.target.value as Format);
}, []); }, []);
if (isPlaceholderData) { if (isPlaceholderData || refetchStatus === 'WAITING_FOR_RESPONSE') {
return <ContentLoader/>; return <ContentLoader/>;
} }
...@@ -37,6 +40,12 @@ const TokenInstanceMetadata = ({ data, isPlaceholderData }: Props) => { ...@@ -37,6 +40,12 @@ const TokenInstanceMetadata = ({ data, isPlaceholderData }: Props) => {
return ( return (
<Box> <Box>
{ refetchStatus === 'ERROR' && (
<Alert status="warning" display="flow" mb={ 6 }>
<chakra.span fontWeight={ 600 }>Ooops! </chakra.span>
<span>We { `couldn't` } refresh metadata. Please try again now or later.</span>
</Alert>
) }
<Flex alignItems="center" mb={ 6 }> <Flex alignItems="center" mb={ 6 }>
<chakra.span fontWeight={ 500 }>Metadata</chakra.span> <chakra.span fontWeight={ 500 }>Metadata</chakra.span>
<Select size="xs" borderRadius="base" value={ format } onChange={ handleSelectChange } w="auto" ml={ 5 }> <Select size="xs" borderRadius="base" value={ format } onChange={ handleSelectChange } w="auto" ml={ 5 }>
......
import { chakra, Alert, Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import ReCaptcha from 'react-google-recaptcha';
import type { SocketMessage } from 'lib/socket/types';
import type { TokenInstance } from 'types/api/token';
import config from 'configs/app';
import useApiFetch from 'lib/api/useApiFetch';
import { getResourceKey } from 'lib/api/useApiQuery';
import { MINUTE } from 'lib/consts';
import useToast from 'lib/hooks/useToast';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import { useMetadataUpdateContext } from './contexts/metadataUpdate';
interface Props {
hash: string;
id: string;
}
const TokenInstanceMetadataFetcher = ({ hash, id }: Props) => {
const timeoutId = React.useRef<number>();
const { status, setStatus } = useMetadataUpdateContext() || {};
const apiFetch = useApiFetch();
const toast = useToast();
const queryClient = useQueryClient();
const handleRefreshError = React.useCallback(() => {
setStatus?.('ERROR');
toast.closeAll();
toast({
title: 'Error',
description: 'The refreshing process has failed. Please try again.',
status: 'warning',
variant: 'subtle',
});
}, [ setStatus, toast ]);
const initializeUpdate = React.useCallback((reCaptchaToken: string) => {
apiFetch<'token_instance_refresh_metadata', unknown, unknown>('token_instance_refresh_metadata', {
pathParams: { hash, id },
fetchParams: {
method: 'PATCH',
body: { recaptcha_response: reCaptchaToken },
},
})
.then(() => {
toast({
title: 'Please wait',
description: 'Refetching metadata request sent',
status: 'warning',
variant: 'subtle',
});
setStatus?.('WAITING_FOR_RESPONSE');
timeoutId.current = window.setTimeout(handleRefreshError, 2 * MINUTE);
})
.catch(() => {
toast({
title: 'Error',
description: 'Unable to initialize metadata update',
status: 'warning',
variant: 'subtle',
});
setStatus?.('ERROR');
});
}, [ apiFetch, handleRefreshError, hash, id, setStatus, toast ]);
const handleModalClose = React.useCallback(() => {
setStatus?.('INITIAL');
}, [ setStatus ]);
const handleReCaptchaChange = React.useCallback((token: string | null) => {
if (token) {
initializeUpdate(token);
}
}, [ initializeUpdate ]);
const handleFormSubmit: React.FormEventHandler<HTMLFormElement> = React.useCallback((event) => {
event.preventDefault();
const data = new FormData(event.target as HTMLFormElement);
const token = data.get('recaptcha_token');
typeof token === 'string' && initializeUpdate(token);
}, [ initializeUpdate ]);
const handleSocketMessage: SocketMessage.TokenInstanceMetadataFetched['handler'] = React.useCallback((payload) => {
if (String(payload.token_id) !== id) {
return;
}
const queryKey = getResourceKey('token_instance', { queryParams: { hash, id } });
queryClient.setQueryData(queryKey, (prevData: TokenInstance | undefined): TokenInstance | undefined => {
if (!prevData) {
return;
}
const castToString = (value: unknown) => typeof value === 'string' ? value : undefined;
return {
...prevData,
external_app_url: castToString(payload.fetched_metadata?.external_url) ?? null,
animation_url: castToString(payload.fetched_metadata?.animation_url) ?? null,
image_url: castToString(
payload.fetched_metadata?.image ||
payload.fetched_metadata?.image_url ||
payload.fetched_metadata?.animation_url,
) ?? null,
metadata: payload.fetched_metadata,
};
});
toast.closeAll();
toast({
title: 'Success!',
description: 'Metadata has been refreshed',
status: 'success',
variant: 'subtle',
});
setStatus?.('SUCCESS');
window.clearTimeout(timeoutId.current);
}, [ hash, id, queryClient, setStatus, toast ]);
const channel = useSocketChannel({
topic: `token_instances:${ hash.toLowerCase() }`,
onSocketClose: handleRefreshError,
onSocketError: handleRefreshError,
isDisabled: status !== 'WAITING_FOR_RESPONSE',
});
useSocketMessage({
channel,
event: 'fetched_token_instance_metadata',
handler: handleSocketMessage,
});
return (
<Modal isOpen={ status === 'MODAL_OPENED' } onClose={ handleModalClose } size={{ base: 'full', lg: 'sm' }}>
<ModalOverlay/>
<ModalContent>
<ModalHeader fontWeight="500" textStyle="h3" mb={ 4 }>Solve captcha to refresh metadata</ModalHeader>
<ModalCloseButton/>
<ModalBody mb={ 0 } minH="78px">
{ config.services.reCaptcha.siteKey ? (
<ReCaptcha
className="recaptcha"
sitekey={ config.services.reCaptcha.siteKey }
onChange={ handleReCaptchaChange }
/>
) : (
<Alert status="error">
Metadata refresh is not available at the moment since reCaptcha is not configured for this application.
Please contact the service maintainer to make necessary changes in the service configuration.
</Alert>
) }
{ /* ONLY FOR TEST PURPOSES */ }
<chakra.form noValidate onSubmit={ handleFormSubmit } display="none">
<chakra.input
name="recaptcha_token"
placeholder="reCaptcha token"
/>
<chakra.button type="submit">Submit</chakra.button>
</chakra.form>
</ModalBody>
</ModalContent>
</Modal>
);
};
export default React.memo(TokenInstanceMetadataFetcher);
import { Flex } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfo, TokenInstance } from 'types/api/token';
import { useAppContext } from 'lib/contexts/app';
import * as regexp from 'lib/regexp';
import { getTokenTypeName } from 'lib/token/tokenTypes';
import AddressQrCode from 'ui/address/details/AddressQrCode';
import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import Tag from 'ui/shared/chakra/Tag';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import LinkExternal from 'ui/shared/links/LinkExternal';
import PageTitle from 'ui/shared/Page/PageTitle';
interface Props {
isLoading: boolean;
token: TokenInfo | undefined;
instance: TokenInstance | undefined;
hash: string | undefined;
}
const TokenInstancePageTitle = ({ isLoading, token, instance, hash }: Props) => {
const appProps = useAppContext();
const title = (() => {
if (typeof instance?.metadata?.name === 'string') {
return instance.metadata.name;
}
if (!instance) {
return `Unknown token instance`;
}
if (token?.name || token?.symbol) {
return (token.name || token.symbol) + ' #' + instance.id;
}
return `ID ${ instance.id }`;
})();
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes(`/token/${ hash }`) && !appProps.referrer.includes('instance');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to token page',
url: appProps.referrer,
};
}, [ appProps.referrer, hash ]);
const tokenTag = token ? <Tag isLoading={ isLoading }>{ getTokenTypeName(token.type) }</Tag> : null;
const appLink = (() => {
if (!instance?.external_app_url) {
return null;
}
try {
const url = regexp.URL_PREFIX.test(instance.external_app_url) ?
new URL(instance.external_app_url) :
new URL('https://' + instance.external_app_url);
return (
<LinkExternal href={ url.toString() } variant="subtle" isLoading={ isLoading } ml={{ base: 0, lg: 'auto' }}>
{ url.hostname || instance.external_app_url }
</LinkExternal>
);
} catch (error) {
return (
<LinkExternal href={ instance.external_app_url } isLoading={ isLoading } ml={{ base: 0, lg: 'auto' }}>
View in app
</LinkExternal>
);
}
})();
const address = {
hash: hash || '',
is_contract: true,
implementations: null,
watchlist_names: [],
watchlist_address_id: null,
};
const titleSecondRow = (
<Flex alignItems="center" w="100%" minW={ 0 } columnGap={ 2 } rowGap={ 2 } flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
<TokenEntity
token={ token }
isLoading={ isLoading }
noSymbol
noCopy
jointSymbol
fontFamily="heading"
fontSize="lg"
fontWeight={ 500 }
w="auto"
maxW="700px"
/>
{ !isLoading && <AddressAddToWallet token={ token } variant="button"/> }
<AddressQrCode address={ address } isLoading={ isLoading }/>
<AccountActionsMenu isLoading={ isLoading } showUpdateMetadataItem={ Boolean(instance?.metadata) }/>
{ appLink }
</Flex>
);
return (
<PageTitle
title={ title }
backLink={ backLink }
contentAfter={ tokenTag }
secondRow={ titleSecondRow }
isLoading={ isLoading }
/>
);
};
export default React.memo(TokenInstancePageTitle);
import React from 'react';
interface MetadataUpdateProviderProps {
children: React.ReactNode;
}
interface TMetadataUpdateContext {
status: Status;
setStatus: (status: Status) => void;
}
export const MetadataUpdateContext = React.createContext<TMetadataUpdateContext | null>(null);
type Status = 'INITIAL' | 'MODAL_OPENED' | 'WAITING_FOR_RESPONSE' | 'SUCCESS' | 'ERROR';
export function MetadataUpdateProvider({ children }: MetadataUpdateProviderProps) {
const [ status, setStatus ] = React.useState<Status>('INITIAL');
const value = React.useMemo(() => {
return {
status,
setStatus,
};
}, [ status ]);
return (
<MetadataUpdateContext.Provider value={ value }>
{ children }
</MetadataUpdateContext.Provider>
);
}
export function useMetadataUpdateContext() {
const context = React.useContext(MetadataUpdateContext);
if (context === undefined) {
return null;
}
return context;
}
...@@ -10,6 +10,8 @@ import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider'; ...@@ -10,6 +10,8 @@ import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import LinkExternal from 'ui/shared/links/LinkExternal'; import LinkExternal from 'ui/shared/links/LinkExternal';
import TruncatedValue from 'ui/shared/TruncatedValue'; import TruncatedValue from 'ui/shared/TruncatedValue';
import { useMetadataUpdateContext } from '../contexts/metadataUpdate';
interface Props { interface Props {
data?: TokenInstance; data?: TokenInstance;
isLoading?: boolean; isLoading?: boolean;
...@@ -35,8 +37,9 @@ const Item = ({ data, isLoading }: ItemProps) => { ...@@ -35,8 +37,9 @@ const Item = ({ data, isLoading }: ItemProps) => {
href={ data.value } href={ data.value }
fontSize="sm" fontSize="sm"
lineHeight={ 5 } lineHeight={ 5 }
isLoading={ isLoading }
> >
<TruncatedValue value={ data.value } w="calc(100% - 16px)"/> <TruncatedValue value={ data.value } w="calc(100% - 16px)" isLoading={ isLoading }/>
</LinkExternal> </LinkExternal>
); );
} }
...@@ -62,14 +65,18 @@ const Item = ({ data, isLoading }: ItemProps) => { ...@@ -62,14 +65,18 @@ const Item = ({ data, isLoading }: ItemProps) => {
); );
}; };
const TokenInstanceMetadataInfo = ({ data, isLoading }: Props) => { const TokenInstanceMetadataInfo = ({ data, isLoading: isLoadingProp }: Props) => {
const { status: refetchStatus } = useMetadataUpdateContext() || {};
const metadata = React.useMemo(() => parseMetadata(data?.metadata), [ data ]); const metadata = React.useMemo(() => parseMetadata(data?.metadata), [ data ]);
const hasMetadata = metadata && Boolean((metadata.name || metadata.description || metadata.attributes));
const hasMetadata = metadata && Boolean((metadata.name || metadata.description || metadata.attributes));
if (!hasMetadata) { if (!hasMetadata) {
return null; return null;
} }
const isLoading = isLoadingProp || refetchStatus === 'WAITING_FOR_RESPONSE';
return ( return (
<> <>
<DetailsInfoItemDivider/> <DetailsInfoItemDivider/>
......
...@@ -13761,7 +13761,7 @@ rc@^1.2.7: ...@@ -13761,7 +13761,7 @@ rc@^1.2.7:
minimist "^1.2.0" minimist "^1.2.0"
strip-json-comments "~2.0.1" strip-json-comments "~2.0.1"
react-async-script@^1.1.1: react-async-script@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/react-async-script/-/react-async-script-1.2.0.tgz#ab9412a26f0b83f5e2e00de1d2befc9400834b21" resolved "https://registry.yarnpkg.com/react-async-script/-/react-async-script-1.2.0.tgz#ab9412a26f0b83f5e2e00de1d2befc9400834b21"
integrity sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q== integrity sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==
...@@ -13824,13 +13824,13 @@ react-focus-lock@^2.5.2, react-focus-lock@^2.9.4: ...@@ -13824,13 +13824,13 @@ react-focus-lock@^2.5.2, react-focus-lock@^2.9.4:
use-callback-ref "^1.3.0" use-callback-ref "^1.3.0"
use-sidecar "^1.1.2" use-sidecar "^1.1.2"
react-google-recaptcha@^2.1.0: react-google-recaptcha@^3.1.0:
version "2.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/react-google-recaptcha/-/react-google-recaptcha-2.1.0.tgz#9f6f4954ce49c1dedabc2c532347321d892d0a16" resolved "https://registry.yarnpkg.com/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz#44aaab834495d922b9d93d7d7a7fb2326315b4ab"
integrity sha512-K9jr7e0CWFigi8KxC3WPvNqZZ47df2RrMAta6KmRoE4RUi7Ys6NmNjytpXpg4HI/svmQJLKR+PncEPaNJ98DqQ== integrity sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==
dependencies: dependencies:
prop-types "^15.5.0" prop-types "^15.5.0"
react-async-script "^1.1.1" react-async-script "^1.2.0"
react-hook-form@^7.33.1: react-hook-form@^7.33.1:
version "7.37.0" version "7.37.0"
......
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