Commit 6c20266e authored by tom goriunov's avatar tom goriunov Committed by GitHub

Public tags: display name tags for addresses (#1877)

* refactor EntityTags component to work with metadata API format

* hook for API metadata info query

* make EntityTag component

* make EntityTag component

* display custom tag colors and sort tags by ordinal field

* add tag popover

* refactoring

* display name tag in the lists

* add mixpanel event and disable link for protocol and generic tags

* adjust demo ENVs

* tests

* fix tests

* change actionURL to tagUrl
parent cf10ac75
...@@ -54,7 +54,7 @@ frontend: ...@@ -54,7 +54,7 @@ frontend:
NEXT_PUBLIC_NETWORK_LOGO: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/sepolia.svg NEXT_PUBLIC_NETWORK_LOGO: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/sepolia.svg
NEXT_PUBLIC_NETWORK_ICON: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/sepolia.png NEXT_PUBLIC_NETWORK_ICON: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/sepolia.png
NEXT_PUBLIC_API_HOST: eth-sepolia.k8s-dev.blockscout.com NEXT_PUBLIC_API_HOST: eth-sepolia.k8s-dev.blockscout.com
NEXT_PUBLIC_STATS_API_HOST: https://stats-goerli.k8s-dev.blockscout.com/ NEXT_PUBLIC_STATS_API_HOST: https://stats-sepolia.k8s-dev.blockscout.com/
NEXT_PUBLIC_VISUALIZE_API_HOST: http://visualizer-svc.visualizer-testing.svc.cluster.local/ NEXT_PUBLIC_VISUALIZE_API_HOST: http://visualizer-svc.visualizer-testing.svc.cluster.local/
NEXT_PUBLIC_CONTRACT_INFO_API_HOST: https://contracts-info-test.k8s-dev.blockscout.com NEXT_PUBLIC_CONTRACT_INFO_API_HOST: https://contracts-info-test.k8s-dev.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: https://admin-rs-test.k8s-dev.blockscout.com NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: https://admin-rs-test.k8s-dev.blockscout.com
......
import type { AddressMetadataTag } from 'types/api/addressMetadata'; import type { AddressMetadataTag } from 'types/api/addressMetadata';
import type { AddressMetadataTagFormatted } from 'types/client/addressMetadata'; import type { AddressMetadataTagFormatted } from 'types/client/addressMetadata';
type MetaParsed = NonNullable<AddressMetadataTagFormatted['meta']>;
export default function parseMetaPayload(meta: AddressMetadataTag['meta']): AddressMetadataTagFormatted['meta'] { export default function parseMetaPayload(meta: AddressMetadataTag['meta']): AddressMetadataTagFormatted['meta'] {
try { try {
const parsedMeta = JSON.parse(meta || ''); const parsedMeta = JSON.parse(meta || '');
...@@ -11,16 +13,20 @@ export default function parseMetaPayload(meta: AddressMetadataTag['meta']): Addr ...@@ -11,16 +13,20 @@ export default function parseMetaPayload(meta: AddressMetadataTag['meta']): Addr
const result: AddressMetadataTagFormatted['meta'] = {}; const result: AddressMetadataTagFormatted['meta'] = {};
if ('textColor' in parsedMeta && typeof parsedMeta.textColor === 'string') { const stringFields: Array<keyof MetaParsed> = [
result.textColor = parsedMeta.textColor; 'textColor',
} 'bgColor',
'tagUrl',
'tooltipIcon',
'tooltipTitle',
'tooltipDescription',
'tooltipUrl',
];
if ('bgColor' in parsedMeta && typeof parsedMeta.bgColor === 'string') { for (const stringField of stringFields) {
result.bgColor = parsedMeta.bgColor; if (stringField in parsedMeta && typeof parsedMeta[stringField as keyof typeof parsedMeta] === 'string') {
result[stringField] = parsedMeta[stringField as keyof typeof parsedMeta];
} }
if ('actionURL' in parsedMeta && typeof parsedMeta.actionURL === 'string') {
result.actionURL = parsedMeta.actionURL;
} }
return result; return result;
......
export default function makePrettyLink(url: string | undefined): { url: string; domain: string } | undefined {
try {
const urlObj = new URL(url ?? '');
return {
url: urlObj.href,
domain: urlObj.hostname,
};
} catch (error) {}
}
...@@ -105,6 +105,10 @@ Type extends EventTypes.PAGE_WIDGET ? ( ...@@ -105,6 +105,10 @@ Type extends EventTypes.PAGE_WIDGET ? (
} | { } | {
'Type': 'Security score'; 'Type': 'Security score';
'Source': 'Analyzed contracts popup'; 'Source': 'Analyzed contracts popup';
} | {
'Type': 'Address tag';
'Info': string;
'URL': string;
} }
) : ) :
Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? { Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? {
......
...@@ -30,6 +30,24 @@ export const withEns: AddressParam = { ...@@ -30,6 +30,24 @@ export const withEns: AddressParam = {
ens_domain_name: 'kitty.kitty.kitty.cat.eth', ens_domain_name: 'kitty.kitty.kitty.cat.eth',
}; };
export const withNameTag: AddressParam = {
hash: hash,
implementation_name: null,
is_contract: false,
is_verified: null,
name: 'ArianeeStore',
private_tags: [],
watchlist_names: [],
public_tags: [],
ens_domain_name: 'kitty.kitty.kitty.cat.eth',
metadata: {
reputation: null,
tags: [
{ tagType: 'name', name: 'Mrs. Duckie', slug: 'mrs-duckie', ordinal: 0, meta: null },
],
},
};
export const withoutName: AddressParam = { export const withoutName: AddressParam = {
hash: hash, hash: hash,
implementation_name: null, implementation_name: null,
......
import type { AddressMetadataInfo, AddressMetadataTag } from 'types/api/addressMetadata'; /* eslint-disable max-len */
import type { AddressMetadataTagApi } from 'types/api/addressMetadata';
import { hash } from '../address/address'; export const nameTag: AddressMetadataTagApi = {
slug: 'quack-quack',
export const nameTag1: AddressMetadataTag = { name: 'Quack quack',
slug: 'ethermineru',
name: 'Ethermine.ru',
tagType: 'name', tagType: 'name',
ordinal: 0, ordinal: 99,
meta: null, meta: null,
}; };
export const genericTag1: AddressMetadataTag = { export const customNameTag: AddressMetadataTagApi = {
slug: 'ethermine.ru', slug: 'unicorn-uproar',
name: 'Ethermine.ru', name: 'Unicorn Uproar',
tagType: 'name',
ordinal: 777,
meta: {
tagUrl: 'https://example.com',
bgColor: 'linear-gradient(45deg, deeppink, deepskyblue)',
textColor: '#FFFFFF',
},
};
export const genericTag: AddressMetadataTagApi = {
slug: 'duck-owner',
name: 'duck owner 🦆',
tagType: 'generic', tagType: 'generic',
ordinal: 0, ordinal: 55,
meta: null, meta: {
bgColor: 'rgba(255,243,12,90%)',
},
}; };
export const protocolTag1: AddressMetadataTag = { export const infoTagWithLink: AddressMetadataTagApi = {
slug: 'goosegang',
name: 'GooseGanG GooseGanG GooseGanG GooseGanG GooseGanG GooseGanG GooseGanG',
tagType: 'classifier',
ordinal: 11,
meta: {
tagUrl: 'https://example.com',
},
};
export const tagWithTooltip: AddressMetadataTagApi = {
slug: 'blockscout-heroes',
name: 'BlockscoutHeroes',
tagType: 'classifier',
ordinal: 42,
meta: {
tooltipDescription: 'The Blockscout team, EVM blockchain aficionados, illuminate Ethereum networks with unparalleled insight and prowess, leading the way in blockchain exploration! 🚀🔎',
tooltipIcon: 'https://localhost:3100/icon.svg',
tooltipTitle: 'Blockscout team member',
tooltipUrl: 'https://blockscout.com',
},
};
export const protocolTag: AddressMetadataTagApi = {
slug: 'aerodrome', slug: 'aerodrome',
name: 'Aerodrome', name: 'Aerodrome',
tagType: 'protocol', tagType: 'protocol',
ordinal: 0, ordinal: 0,
meta: null, meta: null,
}; };
export const baseInfo: AddressMetadataInfo = {
addresses: {
[hash]: {
tags: [ nameTag1, genericTag1, protocolTag1 ],
reputation: null,
},
},
};
...@@ -7,6 +7,7 @@ export interface AddressMetadataInfo { ...@@ -7,6 +7,7 @@ export interface AddressMetadataInfo {
export type AddressMetadataTagType = 'name' | 'generic' | 'classifier' | 'information' | 'note' | 'protocol'; export type AddressMetadataTagType = 'name' | 'generic' | 'classifier' | 'information' | 'note' | 'protocol';
// Response model from Metadata microservice API
export interface AddressMetadataTag { export interface AddressMetadataTag {
slug: string; slug: string;
name: string; name: string;
...@@ -14,3 +15,16 @@ export interface AddressMetadataTag { ...@@ -14,3 +15,16 @@ export interface AddressMetadataTag {
ordinal: number; ordinal: number;
meta: string | null; meta: string | null;
} }
// Response model from Blockscout API with parsed meta field
export interface AddressMetadataTagApi extends Omit<AddressMetadataTag, 'meta'> {
meta: {
textColor?: string;
bgColor?: string;
tagUrl?: string;
tooltipIcon?: string;
tooltipTitle?: string;
tooltipDescription?: string;
tooltipUrl?: string;
} | null;
}
import type { AddressMetadataTagApi } from './addressMetadata';
export interface AddressTag { export interface AddressTag {
label: string; label: string;
display_name: string; display_name: string;
...@@ -22,6 +24,10 @@ export type AddressParamBasic = { ...@@ -22,6 +24,10 @@ export type AddressParamBasic = {
is_contract: boolean; is_contract: boolean;
is_verified: boolean | null; is_verified: boolean | null;
ens_domain_name: string | null; ens_domain_name: string | null;
metadata?: {
reputation: number | null;
tags: Array<AddressMetadataTagApi>;
} | null;
} }
export type AddressParam = UserTags & AddressParamBasic; export type AddressParam = UserTags & AddressParamBasic;
import type { AddressMetadataTagType } from 'types/api/addressMetadata'; import type { AddressMetadataTagApi } from 'types/api/addressMetadata';
export interface AddressMetadataInfoFormatted { export interface AddressMetadataInfoFormatted {
addresses: Record<string, { addresses: Record<string, {
...@@ -7,14 +7,4 @@ export interface AddressMetadataInfoFormatted { ...@@ -7,14 +7,4 @@ export interface AddressMetadataInfoFormatted {
}>; }>;
} }
export interface AddressMetadataTagFormatted { export type AddressMetadataTagFormatted = AddressMetadataTagApi;
slug: string;
name: string;
tagType: AddressMetadataTagType;
ordinal: number;
meta: {
textColor?: string;
bgColor?: string;
actionURL?: string;
} | null;
}
...@@ -2,9 +2,11 @@ import { Box, Flex, HStack, useColorModeValue } from '@chakra-ui/react'; ...@@ -2,9 +2,11 @@ import { Box, Flex, HStack, useColorModeValue } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { EntityTag } from 'ui/shared/EntityTags/types';
import type { RoutedTab } from 'ui/shared/Tabs/types'; import type { RoutedTab } from 'ui/shared/Tabs/types';
import config from 'configs/app'; import config from 'configs/app';
import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
import useContractTabs from 'lib/hooks/useContractTabs'; import useContractTabs from 'lib/hooks/useContractTabs';
...@@ -36,7 +38,9 @@ import TextAd from 'ui/shared/ad/TextAd'; ...@@ -36,7 +38,9 @@ import TextAd from 'ui/shared/ad/TextAd';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet'; import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import EnsEntity from 'ui/shared/entities/ens/EnsEntity'; import EnsEntity from 'ui/shared/entities/ens/EnsEntity';
import EntityTags from 'ui/shared/EntityTags'; import EntityTags from 'ui/shared/EntityTags/EntityTags';
import formatUserTags from 'ui/shared/EntityTags/formatUserTags';
import sortEntityTags from 'ui/shared/EntityTags/sortEntityTags';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import NetworkExplorers from 'ui/shared/NetworkExplorers'; import NetworkExplorers from 'ui/shared/NetworkExplorers';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
...@@ -71,6 +75,9 @@ const AddressPageContent = () => { ...@@ -71,6 +75,9 @@ const AddressPageContent = () => {
}, },
}); });
const addressesForMetadataQuery = React.useMemo(() => ([ hash ].filter(Boolean)), [ hash ]);
const addressMetadataQuery = useAddressMetadataInfoQuery(addressesForMetadataQuery);
const isLoading = addressQuery.isPlaceholderData || (config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData); const isLoading = addressQuery.isPlaceholderData || (config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData);
const isTabsLoading = isLoading || addressTabsCountersQuery.isPlaceholderData; const isTabsLoading = isLoading || addressTabsCountersQuery.isPlaceholderData;
...@@ -185,18 +192,27 @@ const AddressPageContent = () => { ...@@ -185,18 +192,27 @@ const AddressPageContent = () => {
].filter(Boolean); ].filter(Boolean);
}, [ addressQuery.data, contractTabs, addressTabsCountersQuery.data, userOpsAccountQuery.data, isTabsLoading ]); }, [ addressQuery.data, contractTabs, addressTabsCountersQuery.data, userOpsAccountQuery.data, isTabsLoading ]);
const tags = ( const tags: Array<EntityTag> = React.useMemo(() => {
return [
!addressQuery.data?.is_contract ? { slug: 'eoa', name: 'EOA', tagType: 'custom' as const, ordinal: -1 } : undefined,
config.features.validators.isEnabled && addressQuery.data?.has_validated_blocks ?
{ slug: 'validator', name: 'Validator', tagType: 'custom' as const, ordinal: 10 } :
undefined,
addressQuery.data?.implementation_address ? { slug: 'proxy', name: 'Proxy', tagType: 'custom' as const, ordinal: -1 } : undefined,
addressQuery.data?.token ? { slug: 'token', name: 'Token', tagType: 'custom' as const, ordinal: -1 } : undefined,
isSafeAddress ? { slug: 'safe', name: 'Multisig: Safe', tagType: 'custom' as const, ordinal: -10 } : undefined,
config.features.userOps.isEnabled && userOpsAccountQuery.data ?
{ slug: 'user_ops_acc', name: 'Smart contract wallet', tagType: 'custom' as const, ordinal: -10 } :
undefined,
...formatUserTags(addressQuery.data),
...(addressMetadataQuery.data?.addresses?.[hash.toLowerCase()]?.tags || []),
].filter(Boolean).sort(sortEntityTags);
}, [ addressMetadataQuery.data, addressQuery.data, hash, isSafeAddress, userOpsAccountQuery.data ]);
const titleContentAfter = (
<EntityTags <EntityTags
data={ addressQuery.data } tags={ tags }
isLoading={ isLoading } isLoading={ isLoading || (config.features.addressMetadata.isEnabled && addressMetadataQuery.isPending) }
tagsBefore={ [
!addressQuery.data?.is_contract ? { label: 'eoa', display_name: 'EOA' } : undefined,
config.features.validators.isEnabled && addressQuery.data?.has_validated_blocks ? { label: 'validator', display_name: 'Validator' } : undefined,
addressQuery.data?.implementation_address ? { label: 'proxy', display_name: 'Proxy' } : undefined,
addressQuery.data?.token ? { label: 'token', display_name: 'Token' } : undefined,
isSafeAddress ? { label: 'safe', display_name: 'Multisig: Safe' } : undefined,
config.features.userOps.isEnabled && userOpsAccountQuery.data ? { label: 'user_ops_acc', display_name: 'Smart contract wallet' } : undefined,
] }
/> />
); );
...@@ -261,7 +277,7 @@ const AddressPageContent = () => { ...@@ -261,7 +277,7 @@ const AddressPageContent = () => {
<PageTitle <PageTitle
title={ `${ addressQuery.data?.is_contract ? 'Contract' : 'Address' } details` } title={ `${ addressQuery.data?.is_contract ? 'Contract' : 'Address' } details` }
backLink={ backLink } backLink={ backLink }
contentAfter={ tags } contentAfter={ titleContentAfter }
secondRow={ titleSecondRow } secondRow={ titleSecondRow }
isLoading={ isLoading } isLoading={ isLoading }
/> />
......
...@@ -247,7 +247,7 @@ const TokenPageContent = () => { ...@@ -247,7 +247,7 @@ const TokenPageContent = () => {
<> <>
<TextAd mb={ 6 }/> <TextAd mb={ 6 }/>
<TokenPageTitle tokenQuery={ tokenQuery } addressQuery={ addressQuery }/> <TokenPageTitle tokenQuery={ tokenQuery } addressQuery={ addressQuery } hash={ hashString }/>
<TokenDetails tokenQuery={ tokenQuery }/> <TokenDetails tokenQuery={ tokenQuery }/>
......
...@@ -10,7 +10,7 @@ import getQueryParamString from 'lib/router/getQueryParamString'; ...@@ -10,7 +10,7 @@ import getQueryParamString from 'lib/router/getQueryParamString';
import { publicClient } from 'lib/web3/client'; import { publicClient } from 'lib/web3/client';
import TextAd from 'ui/shared/ad/TextAd'; import TextAd from 'ui/shared/ad/TextAd';
import isCustomAppError from 'ui/shared/AppError/isCustomAppError'; import isCustomAppError from 'ui/shared/AppError/isCustomAppError';
import EntityTags from 'ui/shared/EntityTags'; import EntityTags from 'ui/shared/EntityTags/EntityTags';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton'; import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
...@@ -77,7 +77,7 @@ const TransactionPageContent = () => { ...@@ -77,7 +77,7 @@ const TransactionPageContent = () => {
const tags = ( const tags = (
<EntityTags <EntityTags
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
tagsBefore={ [ data?.tx_tag ? { label: data.tx_tag, display_name: data.tx_tag } : undefined ] } tags={ data?.tx_tag ? [ { slug: data.tx_tag, name: data.tx_tag, tagType: 'private_tag' as const } ] : [] }
/> />
); );
......
import type { ThemingProps } from '@chakra-ui/react';
import { Flex, chakra, useDisclosure, Popover, PopoverTrigger, PopoverContent, PopoverBody, Box } from '@chakra-ui/react';
import React from 'react';
import type { UserTags } from 'types/api/addressParams';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import Tag from 'ui/shared/chakra/Tag';
interface TagData {
label: string;
display_name: string;
colorScheme?: ThemingProps<'Tag'>['colorScheme'];
variant?: ThemingProps<'Tag'>['variant'];
}
interface Props {
className?: string;
data?: UserTags;
isLoading?: boolean;
tagsBefore?: Array<TagData | undefined>;
tagsAfter?: Array<TagData | undefined>;
contentAfter?: React.ReactNode;
}
const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoading, contentAfter }: Props) => {
const isMobile = useIsMobile();
const { isOpen, onToggle, onClose } = useDisclosure();
const tags: Array<TagData> = [
...tagsBefore,
...(data?.private_tags || []),
...(data?.public_tags || []),
...(data?.watchlist_names || []),
...tagsAfter,
]
.filter(Boolean);
const metaSuitesPlaceholder = config.features.metasuites.isEnabled ?
<Box display="none" id="meta-suites__address-tag" data-ready={ !isLoading }/> :
null;
if (tags.length === 0 && !contentAfter) {
return metaSuitesPlaceholder;
}
const content = (() => {
if (isMobile && tags.length > 2) {
return (
<>
{
tags
.slice(0, 2)
.map((tag) => (
<Tag
key={ tag.label }
isLoading={ isLoading }
isTruncated
maxW={{ base: '115px', lg: 'initial' }}
colorScheme={ 'colorScheme' in tag ? tag.colorScheme : 'gray' }
variant={ 'variant' in tag ? tag.variant : 'subtle' }
>
{ tag.display_name }
</Tag>
))
}
{ metaSuitesPlaceholder }
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<Tag isLoading={ isLoading }onClick={ onToggle }>+{ tags.length - 1 }</Tag>
</PopoverTrigger>
<PopoverContent w="240px">
<PopoverBody >
<Flex columnGap={ 2 } rowGap={ 2 } flexWrap="wrap">
{
tags
.slice(2)
.map((tag) => (
<Tag
key={ tag.label }
colorScheme={ 'colorScheme' in tag ? tag.colorScheme : 'gray' }
variant={ 'variant' in tag ? tag.variant : 'subtle' }
>
{ tag.display_name }
</Tag>
))
}
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
</>
);
}
return (
<>
{ tags.map((tag) => (
<Tag
key={ tag.label }
isLoading={ isLoading }
isTruncated
maxW={{ base: '115px', lg: 'initial' }}
colorScheme={ 'colorScheme' in tag ? tag.colorScheme : 'gray' }
variant={ 'variant' in tag ? tag.variant : 'subtle' }
>
{ tag.display_name }
</Tag>
)) }
{ metaSuitesPlaceholder }
</>
);
})();
return (
<Flex className={ className } columnGap={ 2 } rowGap={ 2 } flexWrap="wrap" alignItems="center" flexGrow={ 1 }>
{ content }
{ contentAfter }
</Flex>
);
};
export default React.memo(chakra(EntityTags));
import { Box } from '@chakra-ui/react';
import React from 'react';
import * as addressMetadataMock from 'mocks/metadata/address';
import { test, expect } from 'playwright/lib';
import EntityTag from './EntityTag';
test.use({ viewport: { width: 400, height: 300 } });
test('custom name tag +@dark-mode', async({ render }) => {
const component = await render(<Box w="200px"><EntityTag data={ addressMetadataMock.customNameTag }/></Box>);
await expect(component).toHaveScreenshot();
});
test('generic tag +@dark-mode', async({ render }) => {
const component = await render(<Box w="200px"><EntityTag data={ addressMetadataMock.genericTag }/></Box>);
await expect(component).toHaveScreenshot();
});
test('protocol tag +@dark-mode', async({ render }) => {
const component = await render(<Box w="200px"><EntityTag data={ addressMetadataMock.protocolTag }/></Box>);
await expect(component).toHaveScreenshot();
});
test('tag with link and long name +@dark-mode', async({ render }) => {
const component = await render(<EntityTag data={ addressMetadataMock.infoTagWithLink } truncate/>);
await expect(component).toHaveScreenshot();
});
test('tag with tooltip +@dark-mode', async({ render, page, mockAssetResponse }) => {
await mockAssetResponse(addressMetadataMock.tagWithTooltip.meta?.tooltipIcon as string, './playwright/mocks/image_s.jpg');
const component = await render(<EntityTag data={ addressMetadataMock.tagWithTooltip }/>);
await component.getByText('BlockscoutHeroes').hover();
await page.getByText('Blockscout team member').waitFor({ state: 'visible' });
await expect(page).toHaveScreenshot();
});
import { chakra, Skeleton, Tag } from '@chakra-ui/react';
import React from 'react';
import type { EntityTag as TEntityTag } from './types';
import IconSvg from 'ui/shared/IconSvg';
import TruncatedValue from 'ui/shared/TruncatedValue';
import EntityTagLink from './EntityTagLink';
import EntityTagPopover from './EntityTagPopover';
interface Props {
data: TEntityTag;
isLoading?: boolean;
truncate?: boolean;
}
const EntityTag = ({ data, isLoading, truncate }: Props) => {
if (isLoading) {
return <Skeleton borderRadius="sm" w="100px" h="24px"/>;
}
// const hasLink = Boolean(data.meta?.tagUrl || data.tagType === 'generic' || data.tagType === 'protocol');
// Change the condition when "Tag search" page is ready - issue #1869
const hasLink = Boolean(data.meta?.tagUrl);
const iconColor = data.meta?.textColor ?? 'gray.400';
return (
<EntityTagPopover data={ data }>
<Tag
display="flex"
alignItems="center"
minW={ 0 }
maxW={ truncate ? { base: '125px', lg: '300px' } : undefined }
bg={ data.meta?.bgColor }
color={ data.meta?.textColor }
colorScheme={ hasLink ? 'gray-blue' : 'gray' }
_hover={ hasLink ? { opacity: 0.76 } : undefined }
>
<EntityTagLink data={ data }>
{ data.tagType === 'name' && <IconSvg name="publictags_slim" boxSize={ 3 } mr={ 1 } flexShrink={ 0 } color={ iconColor }/> }
{ (data.tagType === 'protocol' || data.tagType === 'generic') && <chakra.span color={ iconColor } whiteSpace="pre"># </chakra.span> }
<TruncatedValue value={ data.name } tooltipPlacement="top"/>
</EntityTagLink>
</Tag>
</EntityTagPopover>
);
};
export default React.memo(EntityTag);
import type { LinkProps } from '@chakra-ui/react';
import React from 'react';
import type { EntityTag } from './types';
import * as mixpanel from 'lib/mixpanel/index';
import LinkExternal from 'ui/shared/LinkExternal';
// import { route } from 'nextjs-routes';
// import LinkInternal from 'ui/shared/LinkInternal';
interface Props {
data: EntityTag;
children: React.ReactNode;
}
const EntityTagLink = ({ data, children }: Props) => {
const handleLinkClick = React.useCallback(() => {
if (!data.meta?.tagUrl) {
return;
}
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, {
Type: 'Address tag',
Info: data.slug,
URL: data.meta.tagUrl,
});
}, [ data.meta?.tagUrl, data.slug ]);
const linkProps: LinkProps = {
color: 'inherit',
display: 'inline-flex',
overflow: 'hidden',
_hover: { textDecor: 'none', color: 'inherit' },
onClick: handleLinkClick,
};
// Uncomment this block when "Tag search" page is ready - issue #1869
// switch (data.tagType) {
// case 'generic':
// case 'protocol': {
// return (
// <LinkInternal
// { ...linkProps }
// href={ route({ pathname: '/' }) }
// >
// { children }
// </LinkInternal>
// );
// }
// }
if (data.meta?.tagUrl) {
return (
<LinkExternal
{ ...linkProps }
href={ data.meta.tagUrl }
iconColor={ data.meta.textColor }
>
{ children }
</LinkExternal>
);
}
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{ children }</>;
};
export default React.memo(EntityTagLink);
import { chakra, Image, Flex, Popover, PopoverArrow, PopoverBody, PopoverContent, PopoverTrigger, useColorModeValue, DarkMode } from '@chakra-ui/react';
import React from 'react';
import type { EntityTag } from './types';
import makePrettyLink from 'lib/makePrettyLink';
import * as mixpanel from 'lib/mixpanel/index';
import LinkExternal from 'ui/shared/LinkExternal';
interface Props {
data: EntityTag;
children: React.ReactNode;
}
const EntityTagPopover = ({ data, children }: Props) => {
const bgColor = useColorModeValue('gray.700', 'gray.900');
const link = makePrettyLink(data.meta?.tooltipUrl);
const hasPopover = Boolean(data.meta?.tooltipIcon || data.meta?.tooltipTitle || data.meta?.tooltipDescription || data.meta?.tooltipUrl);
const handleLinkClick = React.useCallback(() => {
if (!data.meta?.tooltipUrl) {
return;
}
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, {
Type: 'Address tag',
Info: data.slug,
URL: data.meta.tooltipUrl,
});
}, [ data.meta?.tooltipUrl, data.slug ]);
if (!hasPopover) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{ children }</>;
}
return (
<Popover trigger="hover" isLazy>
<PopoverTrigger>
{ children }
</PopoverTrigger>
<PopoverContent bgColor={ bgColor } borderRadius="sm">
<PopoverArrow bgColor={ bgColor }/>
<DarkMode>
<PopoverBody color="white" p={ 2 } fontSize="sm" display="flex" flexDir="column" rowGap={ 2 }>
{ (data.meta?.tooltipIcon || data.meta?.tooltipTitle) && (
<Flex columnGap={ 3 } alignItems="center">
{ data.meta?.tooltipIcon && <Image src={ data.meta.tooltipIcon } boxSize="30px" alt={ `${ data.name } tag logo` }/> }
{ data.meta?.tooltipTitle && <chakra.span fontWeight="600">{ data.meta.tooltipTitle }</chakra.span> }
</Flex>
) }
{ data.meta?.tooltipDescription && <chakra.span>{ data.meta.tooltipDescription }</chakra.span> }
{ link && <LinkExternal href={ link.url } onClick={ handleLinkClick }>{ link.domain }</LinkExternal> }
</PopoverBody>
</DarkMode>
</PopoverContent>
</Popover>
);
};
export default React.memo(EntityTagPopover);
import { Box, Flex, Popover, PopoverBody, PopoverContent, PopoverTrigger, chakra } from '@chakra-ui/react';
import React from 'react';
import type { EntityTag as TEntityTag } from './types';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import Tag from 'ui/shared/chakra/Tag';
import EntityTag from './EntityTag';
interface Props {
className?: string;
tags: Array<TEntityTag>;
isLoading?: boolean;
}
const EntityTags = ({ tags, className, isLoading }: Props) => {
const isMobile = useIsMobile();
const visibleNum = isMobile ? 2 : 3;
const metaSuitesPlaceholder = config.features.metasuites.isEnabled ?
<Box display="none" id="meta-suites__address-tag" data-ready={ !isLoading }/> :
null;
if (tags.length === 0) {
return metaSuitesPlaceholder;
}
const content = (() => {
if (tags.length > visibleNum) {
return (
<>
{ tags.slice(0, visibleNum).map((tag) => <EntityTag key={ tag.slug } data={ tag } isLoading={ isLoading } truncate/>) }
{ metaSuitesPlaceholder }
<Popover trigger="click" placement="bottom-start" isLazy>
<PopoverTrigger>
<Tag isLoading={ isLoading } cursor="pointer" as="button" _hover={{ color: 'link_hovered' }}>
+{ tags.length - visibleNum }
</Tag>
</PopoverTrigger>
<PopoverContent w="300px">
<PopoverBody >
<Flex columnGap={ 2 } rowGap={ 2 } flexWrap="wrap">
{ tags.slice(visibleNum).map((tag) => <EntityTag key={ tag.slug } data={ tag }/>) }
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
</>
);
}
return (
<>
{ tags.map((tag) => <EntityTag key={ tag.slug } data={ tag } isLoading={ isLoading } truncate/>) }
{ metaSuitesPlaceholder }
</>
);
})();
return (
<Flex className={ className } columnGap={ 2 } rowGap={ 2 } flexWrap="wrap" alignItems="center" flexGrow={ 1 }>
{ content }
</Flex>
);
};
export default React.memo(chakra(EntityTags));
import type { EntityTag } from './types';
import type { UserTags } from 'types/api/addressParams';
export default function formatUserTags(data: UserTags | undefined): Array<EntityTag> {
return [
...(data?.private_tags || []).map((tag) => ({ slug: tag.label, name: tag.display_name, tagType: 'private_tag' as const, ordinal: 1_000 })),
...(data?.watchlist_names || []).map((tag) => ({ slug: tag.label, name: tag.display_name, tagType: 'watchlist' as const, ordinal: 1_000 })),
];
}
import type { EntityTag } from './types';
export default function sortEntityTags(tagA: EntityTag, tagB: EntityTag): number {
if (tagA.ordinal < tagB.ordinal) {
return 1;
}
if (tagA.ordinal > tagB.ordinal) {
return -1;
}
return 0;
}
import type { AddressMetadataTagType } from 'types/api/addressMetadata';
import type { AddressMetadataTagFormatted } from 'types/client/addressMetadata';
export type EntityTagType = AddressMetadataTagType | 'custom' | 'watchlist' | 'private_tag';
export interface EntityTag extends Pick<AddressMetadataTagFormatted, 'slug' | 'name' | 'ordinal'> {
tagType: EntityTagType;
meta?: AddressMetadataTagFormatted['meta'];
}
import type { ChakraProps } from '@chakra-ui/react'; import type { ChakraProps, LinkProps } from '@chakra-ui/react';
import { Link, chakra, Box, Skeleton, useColorModeValue } from '@chakra-ui/react'; import { Link, chakra, Box, Skeleton, useColorModeValue } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
...@@ -10,9 +10,11 @@ interface Props { ...@@ -10,9 +10,11 @@ interface Props {
children: React.ReactNode; children: React.ReactNode;
isLoading?: boolean; isLoading?: boolean;
variant?: 'subtle'; variant?: 'subtle';
iconColor?: LinkProps['color'];
onClick?: LinkProps['onClick'];
} }
const LinkExternal = ({ href, children, className, isLoading, variant }: Props) => { const LinkExternal = ({ href, children, className, isLoading, variant, iconColor, onClick }: Props) => {
const subtleLinkBg = useColorModeValue('gray.100', 'gray.700'); const subtleLinkBg = useColorModeValue('gray.100', 'gray.700');
const styleProps: ChakraProps = (() => { const styleProps: ChakraProps = (() => {
...@@ -57,9 +59,9 @@ const LinkExternal = ({ href, children, className, isLoading, variant }: Props) ...@@ -57,9 +59,9 @@ const LinkExternal = ({ href, children, className, isLoading, variant }: Props)
} }
return ( return (
<Link className={ className } { ...styleProps } target="_blank" href={ href }> <Link className={ className } { ...styleProps } target="_blank" href={ href } onClick={ onClick }>
{ children } { children }
<IconSvg name="arrows/north-east" boxSize={ 4 } verticalAlign="middle" color="gray.400" flexShrink={ 0 }/> <IconSvg name="arrows/north-east" boxSize={ 4 } verticalAlign="middle" color={ iconColor ?? 'gray.400' } flexShrink={ 0 }/>
</Link> </Link>
); );
}; };
......
...@@ -5,7 +5,7 @@ import type { TokenInfo } from 'types/api/token'; ...@@ -5,7 +5,7 @@ import type { TokenInfo } from 'types/api/token';
import * as addressMock from 'mocks/address/address'; import * as addressMock from 'mocks/address/address';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import EntityTags from 'ui/shared/EntityTags'; import EntityTags from 'ui/shared/EntityTags/EntityTags';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import NetworkExplorers from 'ui/shared/NetworkExplorers'; import NetworkExplorers from 'ui/shared/NetworkExplorers';
...@@ -34,8 +34,8 @@ const DefaultView = () => { ...@@ -34,8 +34,8 @@ const DefaultView = () => {
<> <>
<IconSvg name="verified_token" color="green.500" boxSize={ 6 } cursor="pointer"/> <IconSvg name="verified_token" color="green.500" boxSize={ 6 } cursor="pointer"/>
<EntityTags <EntityTags
tagsBefore={ [ tags={ [
{ label: 'example', display_name: 'Example label' }, { slug: 'example', name: 'Example label', tagType: 'custom' },
] } ] }
flexGrow={ 1 } flexGrow={ 1 }
/> />
......
...@@ -5,7 +5,8 @@ import type { TokenInfo } from 'types/api/token'; ...@@ -5,7 +5,8 @@ import type { TokenInfo } from 'types/api/token';
import { publicTag, privateTag, watchlistName } from 'mocks/address/tag'; import { publicTag, privateTag, watchlistName } from 'mocks/address/tag';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import EntityTags from 'ui/shared/EntityTags'; import EntityTags from 'ui/shared/EntityTags/EntityTags';
import formatUserTags from 'ui/shared/EntityTags/formatUserTags';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import NetworkExplorers from 'ui/shared/NetworkExplorers'; import NetworkExplorers from 'ui/shared/NetworkExplorers';
...@@ -29,21 +30,19 @@ const LongNameAndManyTags = () => { ...@@ -29,21 +30,19 @@ const LongNameAndManyTags = () => {
<> <>
<IconSvg name="verified_token" color="green.500" boxSize={ 6 } cursor="pointer" flexShrink={ 0 }/> <IconSvg name="verified_token" color="green.500" boxSize={ 6 } cursor="pointer" flexShrink={ 0 }/>
<EntityTags <EntityTags
data={{ tags={ [
{ slug: 'example', name: 'Example with long name', tagType: 'custom' },
...formatUserTags({
private_tags: [ privateTag ], private_tags: [ privateTag ],
public_tags: [ publicTag ], public_tags: [ publicTag ],
watchlist_names: [ watchlistName ], watchlist_names: [ watchlistName ],
}} }),
tagsBefore={ [ { slug: 'after_1', name: 'Another tag', tagType: 'custom' },
{ label: 'example', display_name: 'Example with long name' }, { slug: 'after_2', name: 'And yet more', tagType: 'custom' },
] } ] }
tagsAfter={ [
{ label: 'after_1', display_name: 'Another tag' },
{ label: 'after_2', display_name: 'And yet more' },
] }
contentAfter={ <NetworkExplorers type="token" pathParam="token-hash" ml="auto"/> }
flexGrow={ 1 } flexGrow={ 1 }
/> />
<NetworkExplorers type="token" pathParam="token-hash" ml="auto"/>
</> </>
); );
......
import type { PlacementWithLogical } from '@chakra-ui/react';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import React from 'react'; import React from 'react';
...@@ -8,9 +9,10 @@ import { BODY_TYPEFACE } from 'theme/foundations/typography'; ...@@ -8,9 +9,10 @@ import { BODY_TYPEFACE } from 'theme/foundations/typography';
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;
label: string; label: string;
placement?: PlacementWithLogical;
} }
const TruncatedTextTooltip = ({ children, label }: Props) => { const TruncatedTextTooltip = ({ children, label, placement }: Props) => {
const childRef = React.useRef<HTMLElement>(null); const childRef = React.useRef<HTMLElement>(null);
const [ isTruncated, setTruncated ] = React.useState(false); const [ isTruncated, setTruncated ] = React.useState(false);
...@@ -60,7 +62,7 @@ const TruncatedTextTooltip = ({ children, label }: Props) => { ...@@ -60,7 +62,7 @@ const TruncatedTextTooltip = ({ children, label }: Props) => {
); );
if (isTruncated) { if (isTruncated) {
return <Tooltip label={ label } maxW={{ base: '100vw', lg: '400px' }}>{ modifiedChildren }</Tooltip>; return <Tooltip label={ label } maxW={{ base: '100vw', lg: '400px' }} placement={ placement }>{ modifiedChildren }</Tooltip>;
} }
return modifiedChildren; return modifiedChildren;
......
import type { PlacementWithLogical } from '@chakra-ui/react';
import { Skeleton, chakra } from '@chakra-ui/react'; import { Skeleton, chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
...@@ -7,11 +8,12 @@ interface Props { ...@@ -7,11 +8,12 @@ interface Props {
className?: string; className?: string;
isLoading?: boolean; isLoading?: boolean;
value: string; value: string;
tooltipPlacement?: PlacementWithLogical;
} }
const TruncatedValue = ({ className, isLoading, value }: Props) => { const TruncatedValue = ({ className, isLoading, value, tooltipPlacement }: Props) => {
return ( return (
<TruncatedTextTooltip label={ value }> <TruncatedTextTooltip label={ value } placement={ tooltipPlacement }>
<Skeleton <Skeleton
className={ className } className={ className }
isLoaded={ !isLoading } isLoaded={ !isLoading }
......
...@@ -4,7 +4,7 @@ import React from 'react'; ...@@ -4,7 +4,7 @@ import React from 'react';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip'; import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
interface Props extends TagProps { export interface Props extends TagProps {
isLoading?: boolean; isLoading?: boolean;
} }
......
...@@ -97,6 +97,18 @@ test('with ENS', async({ mount }) => { ...@@ -97,6 +97,18 @@ test('with ENS', async({ mount }) => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('with name tag', async({ mount }) => {
const component = await mount(
<TestApp>
<AddressEntity
address={ addressMock.withNameTag }
/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('external link', async({ mount }) => { test('external link', async({ mount }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
......
...@@ -100,11 +100,13 @@ const Icon = (props: IconProps) => { ...@@ -100,11 +100,13 @@ const Icon = (props: IconProps) => {
type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'address'>; type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'address'>;
const Content = chakra((props: ContentProps) => { const Content = chakra((props: ContentProps) => {
if (props.address.name || props.address.ens_domain_name) { const nameTag = props.address.metadata?.tags.find(tag => tag.tagType === 'name')?.name;
const text = props.address.ens_domain_name || props.address.name; const nameText = nameTag || props.address.ens_domain_name || props.address.name;
if (nameText) {
const label = ( const label = (
<VStack gap={ 0 } py={ 1 } color="inherit"> <VStack gap={ 0 } py={ 1 } color="inherit">
<Box fontWeight={ 600 } whiteSpace="pre-wrap" wordBreak="break-word">{ text }</Box> <Box fontWeight={ 600 } whiteSpace="pre-wrap" wordBreak="break-word">{ nameText }</Box>
<Box whiteSpace="pre-wrap" wordBreak="break-word">{ props.address.hash }</Box> <Box whiteSpace="pre-wrap" wordBreak="break-word">{ props.address.hash }</Box>
</VStack> </VStack>
); );
...@@ -112,7 +114,7 @@ const Content = chakra((props: ContentProps) => { ...@@ -112,7 +114,7 @@ const Content = chakra((props: ContentProps) => {
return ( return (
<Tooltip label={ label } maxW={{ base: '100vw', lg: '400px' }}> <Tooltip label={ label } maxW={{ base: '100vw', lg: '400px' }}>
<Skeleton isLoaded={ !props.isLoading } overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap" as="span"> <Skeleton isLoaded={ !props.isLoading } overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap" as="span">
{ text } { nameText }
</Skeleton> </Skeleton>
</Tooltip> </Tooltip>
); );
...@@ -140,7 +142,7 @@ const Copy = (props: CopyProps) => { ...@@ -140,7 +142,7 @@ const Copy = (props: CopyProps) => {
const Container = EntityBase.Container; const Container = EntityBase.Container;
export interface EntityProps extends EntityBase.EntityBaseProps { export interface EntityProps extends EntityBase.EntityBaseProps {
address: Pick<AddressParam, 'hash' | 'name' | 'is_contract' | 'is_verified' | 'implementation_name' | 'ens_domain_name'>; address: Pick<AddressParam, 'hash' | 'name' | 'is_contract' | 'is_verified' | 'implementation_name' | 'ens_domain_name' | 'metadata'>;
isSafeAddress?: boolean; isSafeAddress?: boolean;
} }
......
import { Box, Flex, Tooltip } from '@chakra-ui/react'; import { Box, Flex, Tooltip, useToken } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query'; import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react'; import React from 'react';
import type { Address } from 'types/api/address'; import type { Address } from 'types/api/address';
import type { TokenInfo } from 'types/api/token'; import type { TokenInfo } from 'types/api/token';
import type { EntityTag } from 'ui/shared/EntityTags/types';
import config from 'configs/app'; import config from 'configs/app';
import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
...@@ -14,7 +16,9 @@ import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu' ...@@ -14,7 +16,9 @@ import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu'
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet'; import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import EntityTags from 'ui/shared/EntityTags'; import EntityTags from 'ui/shared/EntityTags/EntityTags';
import formatUserTags from 'ui/shared/EntityTags/formatUserTags';
import sortEntityTags from 'ui/shared/EntityTags/sortEntityTags';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import NetworkExplorers from 'ui/shared/NetworkExplorers'; import NetworkExplorers from 'ui/shared/NetworkExplorers';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
...@@ -24,9 +28,10 @@ import TokenVerifiedInfo from './TokenVerifiedInfo'; ...@@ -24,9 +28,10 @@ import TokenVerifiedInfo from './TokenVerifiedInfo';
interface Props { interface Props {
tokenQuery: UseQueryResult<TokenInfo, ResourceError<unknown>>; tokenQuery: UseQueryResult<TokenInfo, ResourceError<unknown>>;
addressQuery: UseQueryResult<Address, ResourceError<unknown>>; addressQuery: UseQueryResult<Address, ResourceError<unknown>>;
hash: string;
} }
const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => { const TokenPageTitle = ({ tokenQuery, addressQuery, hash }: Props) => {
const appProps = useAppContext(); const appProps = useAppContext();
const addressHash = !tokenQuery.isPlaceholderData ? (tokenQuery.data?.address || '') : ''; const addressHash = !tokenQuery.isPlaceholderData ? (tokenQuery.data?.address || '') : '';
...@@ -35,9 +40,12 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => { ...@@ -35,9 +40,12 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => {
queryOptions: { enabled: Boolean(tokenQuery.data) && !tokenQuery.isPlaceholderData && config.features.verifiedTokens.isEnabled }, queryOptions: { enabled: Boolean(tokenQuery.data) && !tokenQuery.isPlaceholderData && config.features.verifiedTokens.isEnabled },
}); });
const isLoading = tokenQuery.isPlaceholderData || addressQuery.isPlaceholderData || ( const addressesForMetadataQuery = React.useMemo(() => ([ hash ].filter(Boolean)), [ hash ]);
config.features.verifiedTokens.isEnabled ? verifiedInfoQuery.isPending : false const addressMetadataQuery = useAddressMetadataInfoQuery(addressesForMetadataQuery);
);
const isLoading = tokenQuery.isPlaceholderData ||
addressQuery.isPlaceholderData ||
(config.features.verifiedTokens.isEnabled && verifiedInfoQuery.isPending);
const tokenSymbolText = tokenQuery.data?.symbol ? ` (${ tokenQuery.data.symbol })` : ''; const tokenSymbolText = tokenQuery.data?.symbol ? ` (${ tokenQuery.data.symbol })` : '';
...@@ -54,6 +62,37 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => { ...@@ -54,6 +62,37 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => {
}; };
}, [ appProps.referrer ]); }, [ appProps.referrer ]);
const bridgedTokenTagBgColor = useToken('colors', 'blue.500');
const bridgedTokenTagTextColor = useToken('colors', 'white');
const tags: Array<EntityTag> = React.useMemo(() => {
return [
tokenQuery.data ? { slug: tokenQuery.data?.type, name: tokenQuery.data?.type, tagType: 'custom' as const, ordinal: -20 } : undefined,
config.features.bridgedTokens.isEnabled && tokenQuery.data?.is_bridged ?
{
slug: 'bridged',
name: 'Bridged',
tagType: 'custom' as const,
ordinal: -10,
meta: { bgColor: bridgedTokenTagBgColor, textColor: bridgedTokenTagTextColor },
} :
undefined,
...formatUserTags(addressQuery.data),
verifiedInfoQuery.data?.projectSector ?
{ slug: verifiedInfoQuery.data.projectSector, name: verifiedInfoQuery.data.projectSector, tagType: 'custom' as const, ordinal: -30 } :
undefined,
...(addressMetadataQuery.data?.addresses?.[hash.toLowerCase()]?.tags || []),
].filter(Boolean).sort(sortEntityTags);
}, [
addressMetadataQuery.data?.addresses,
addressQuery.data,
bridgedTokenTagBgColor,
bridgedTokenTagTextColor,
tokenQuery.data,
verifiedInfoQuery.data?.projectSector,
hash,
]);
const contentAfter = ( const contentAfter = (
<> <>
{ verifiedInfoQuery.data?.tokenAddress && ( { verifiedInfoQuery.data?.tokenAddress && (
...@@ -64,19 +103,8 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => { ...@@ -64,19 +103,8 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => {
</Tooltip> </Tooltip>
) } ) }
<EntityTags <EntityTags
data={ addressQuery.data } isLoading={ isLoading || (config.features.addressMetadata.isEnabled && addressMetadataQuery.isPending) }
isLoading={ isLoading } tags={ tags }
tagsBefore={ [
tokenQuery.data ? { label: tokenQuery.data?.type, display_name: tokenQuery.data?.type } : undefined,
config.features.bridgedTokens.isEnabled && tokenQuery.data?.is_bridged ?
{ label: 'bridged', display_name: 'Bridged', colorScheme: 'blue', variant: 'solid' } :
undefined,
] }
tagsAfter={
verifiedInfoQuery.data?.projectSector ?
[ { label: verifiedInfoQuery.data.projectSector, display_name: verifiedInfoQuery.data.projectSector } ] :
undefined
}
flexGrow={ 1 } flexGrow={ 1 }
/> />
</> </>
......
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