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
@@ -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_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_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_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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { AddressMetadataTag } from 'types/api/addressMetadata';
|
||||
import type { AddressMetadataTagFormatted } from 'types/client/addressMetadata';
|
||||
|
||||
type MetaParsed = NonNullable<AddressMetadataTagFormatted['meta']>;
|
||||
|
||||
export default function parseMetaPayload(meta: AddressMetadataTag['meta']): AddressMetadataTagFormatted['meta'] {
|
||||
try {
|
||||
const parsedMeta = JSON.parse(meta || '');
|
||||
@@ -11,16 +13,20 @@ export default function parseMetaPayload(meta: AddressMetadataTag['meta']): Addr
|
||||
|
||||
const result: AddressMetadataTagFormatted['meta'] = {};
|
||||
|
||||
if ('textColor' in parsedMeta && typeof parsedMeta.textColor === 'string') {
|
||||
result.textColor = parsedMeta.textColor;
|
||||
}
|
||||
const stringFields: Array<keyof MetaParsed> = [
|
||||
'textColor',
|
||||
'bgColor',
|
||||
'tagUrl',
|
||||
'tooltipIcon',
|
||||
'tooltipTitle',
|
||||
'tooltipDescription',
|
||||
'tooltipUrl',
|
||||
];
|
||||
|
||||
if ('bgColor' in parsedMeta && typeof parsedMeta.bgColor === 'string') {
|
||||
result.bgColor = parsedMeta.bgColor;
|
||||
}
|
||||
|
||||
if ('actionURL' in parsedMeta && typeof parsedMeta.actionURL === 'string') {
|
||||
result.actionURL = parsedMeta.actionURL;
|
||||
for (const stringField of stringFields) {
|
||||
if (stringField in parsedMeta && typeof parsedMeta[stringField as keyof typeof parsedMeta] === 'string') {
|
||||
result[stringField] = parsedMeta[stringField as keyof typeof parsedMeta];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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 ? (
|
||||
} | {
|
||||
'Type': 'Security score';
|
||||
'Source': 'Analyzed contracts popup';
|
||||
} | {
|
||||
'Type': 'Address tag';
|
||||
'Info': string;
|
||||
'URL': string;
|
||||
}
|
||||
) :
|
||||
Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? {
|
||||
|
||||
@@ -30,6 +30,24 @@ export const withEns: AddressParam = {
|
||||
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 = {
|
||||
hash: hash,
|
||||
implementation_name: null,
|
||||
|
||||
@@ -1,36 +1,63 @@
|
||||
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 nameTag1: AddressMetadataTag = {
|
||||
slug: 'ethermineru',
|
||||
name: 'Ethermine.ru',
|
||||
export const nameTag: AddressMetadataTagApi = {
|
||||
slug: 'quack-quack',
|
||||
name: 'Quack quack',
|
||||
tagType: 'name',
|
||||
ordinal: 0,
|
||||
ordinal: 99,
|
||||
meta: null,
|
||||
};
|
||||
|
||||
export const genericTag1: AddressMetadataTag = {
|
||||
slug: 'ethermine.ru',
|
||||
name: 'Ethermine.ru',
|
||||
export const customNameTag: AddressMetadataTagApi = {
|
||||
slug: 'unicorn-uproar',
|
||||
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',
|
||||
ordinal: 0,
|
||||
meta: null,
|
||||
ordinal: 55,
|
||||
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',
|
||||
name: 'Aerodrome',
|
||||
tagType: 'protocol',
|
||||
ordinal: 0,
|
||||
meta: null,
|
||||
};
|
||||
|
||||
export const baseInfo: AddressMetadataInfo = {
|
||||
addresses: {
|
||||
[hash]: {
|
||||
tags: [ nameTag1, genericTag1, protocolTag1 ],
|
||||
reputation: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface AddressMetadataInfo {
|
||||
|
||||
export type AddressMetadataTagType = 'name' | 'generic' | 'classifier' | 'information' | 'note' | 'protocol';
|
||||
|
||||
// Response model from Metadata microservice API
|
||||
export interface AddressMetadataTag {
|
||||
slug: string;
|
||||
name: string;
|
||||
@@ -14,3 +15,16 @@ export interface AddressMetadataTag {
|
||||
ordinal: number;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AddressMetadataTagApi } from './addressMetadata';
|
||||
|
||||
export interface AddressTag {
|
||||
label: string;
|
||||
display_name: string;
|
||||
@@ -22,6 +24,10 @@ export type AddressParamBasic = {
|
||||
is_contract: boolean;
|
||||
is_verified: boolean | null;
|
||||
ens_domain_name: string | null;
|
||||
metadata?: {
|
||||
reputation: number | null;
|
||||
tags: Array<AddressMetadataTagApi>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type AddressParam = UserTags & AddressParamBasic;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AddressMetadataTagType } from 'types/api/addressMetadata';
|
||||
import type { AddressMetadataTagApi } from 'types/api/addressMetadata';
|
||||
|
||||
export interface AddressMetadataInfoFormatted {
|
||||
addresses: Record<string, {
|
||||
@@ -7,14 +7,4 @@ export interface AddressMetadataInfoFormatted {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface AddressMetadataTagFormatted {
|
||||
slug: string;
|
||||
name: string;
|
||||
tagType: AddressMetadataTagType;
|
||||
ordinal: number;
|
||||
meta: {
|
||||
textColor?: string;
|
||||
bgColor?: string;
|
||||
actionURL?: string;
|
||||
} | null;
|
||||
}
|
||||
export type AddressMetadataTagFormatted = AddressMetadataTagApi;
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Box, Flex, HStack, useColorModeValue } from '@chakra-ui/react';
|
||||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
|
||||
import type { EntityTag } from 'ui/shared/EntityTags/types';
|
||||
import type { RoutedTab } from 'ui/shared/Tabs/types';
|
||||
|
||||
import config from 'configs/app';
|
||||
import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery';
|
||||
import useApiQuery from 'lib/api/useApiQuery';
|
||||
import { useAppContext } from 'lib/contexts/app';
|
||||
import useContractTabs from 'lib/hooks/useContractTabs';
|
||||
@@ -36,7 +38,9 @@ import TextAd from 'ui/shared/ad/TextAd';
|
||||
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
|
||||
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
|
||||
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 NetworkExplorers from 'ui/shared/NetworkExplorers';
|
||||
import PageTitle from 'ui/shared/Page/PageTitle';
|
||||
@@ -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 isTabsLoading = isLoading || addressTabsCountersQuery.isPlaceholderData;
|
||||
|
||||
@@ -185,18 +192,27 @@ const AddressPageContent = () => {
|
||||
].filter(Boolean);
|
||||
}, [ 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
|
||||
data={ addressQuery.data }
|
||||
isLoading={ isLoading }
|
||||
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,
|
||||
] }
|
||||
tags={ tags }
|
||||
isLoading={ isLoading || (config.features.addressMetadata.isEnabled && addressMetadataQuery.isPending) }
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -261,7 +277,7 @@ const AddressPageContent = () => {
|
||||
<PageTitle
|
||||
title={ `${ addressQuery.data?.is_contract ? 'Contract' : 'Address' } details` }
|
||||
backLink={ backLink }
|
||||
contentAfter={ tags }
|
||||
contentAfter={ titleContentAfter }
|
||||
secondRow={ titleSecondRow }
|
||||
isLoading={ isLoading }
|
||||
/>
|
||||
|
||||
@@ -247,7 +247,7 @@ const TokenPageContent = () => {
|
||||
<>
|
||||
<TextAd mb={ 6 }/>
|
||||
|
||||
<TokenPageTitle tokenQuery={ tokenQuery } addressQuery={ addressQuery }/>
|
||||
<TokenPageTitle tokenQuery={ tokenQuery } addressQuery={ addressQuery } hash={ hashString }/>
|
||||
|
||||
<TokenDetails tokenQuery={ tokenQuery }/>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import getQueryParamString from 'lib/router/getQueryParamString';
|
||||
import { publicClient } from 'lib/web3/client';
|
||||
import TextAd from 'ui/shared/ad/TextAd';
|
||||
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 RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
|
||||
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
|
||||
@@ -77,7 +77,7 @@ const TransactionPageContent = () => {
|
||||
const tags = (
|
||||
<EntityTags
|
||||
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 } ] : [] }
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,124 +0,0 @@
|
||||
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));
|
||||
@@ -0,0 +1,37 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
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);
|
||||
@@ -0,0 +1,70 @@
|
||||
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);
|
||||
@@ -0,0 +1,61 @@
|
||||
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);
|
||||
@@ -0,0 +1,69 @@
|
||||
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));
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,9 @@
|
||||
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 })),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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'];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
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 React from 'react';
|
||||
|
||||
@@ -10,9 +10,11 @@ interface Props {
|
||||
children: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
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 styleProps: ChakraProps = (() => {
|
||||
@@ -57,9 +59,9 @@ const LinkExternal = ({ href, children, className, isLoading, variant }: Props)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link className={ className } { ...styleProps } target="_blank" href={ href }>
|
||||
<Link className={ className } { ...styleProps } target="_blank" href={ href } onClick={ onClick }>
|
||||
{ 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.7 KiB |
@@ -5,7 +5,7 @@ import type { TokenInfo } from 'types/api/token';
|
||||
import * as addressMock from 'mocks/address/address';
|
||||
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
|
||||
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 NetworkExplorers from 'ui/shared/NetworkExplorers';
|
||||
|
||||
@@ -34,8 +34,8 @@ const DefaultView = () => {
|
||||
<>
|
||||
<IconSvg name="verified_token" color="green.500" boxSize={ 6 } cursor="pointer"/>
|
||||
<EntityTags
|
||||
tagsBefore={ [
|
||||
{ label: 'example', display_name: 'Example label' },
|
||||
tags={ [
|
||||
{ slug: 'example', name: 'Example label', tagType: 'custom' },
|
||||
] }
|
||||
flexGrow={ 1 }
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { TokenInfo } from 'types/api/token';
|
||||
|
||||
import { publicTag, privateTag, watchlistName } from 'mocks/address/tag';
|
||||
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 NetworkExplorers from 'ui/shared/NetworkExplorers';
|
||||
|
||||
@@ -29,21 +30,19 @@ const LongNameAndManyTags = () => {
|
||||
<>
|
||||
<IconSvg name="verified_token" color="green.500" boxSize={ 6 } cursor="pointer" flexShrink={ 0 }/>
|
||||
<EntityTags
|
||||
data={{
|
||||
private_tags: [ privateTag ],
|
||||
public_tags: [ publicTag ],
|
||||
watchlist_names: [ watchlistName ],
|
||||
}}
|
||||
tagsBefore={ [
|
||||
{ label: 'example', display_name: 'Example with long name' },
|
||||
tags={ [
|
||||
{ slug: 'example', name: 'Example with long name', tagType: 'custom' },
|
||||
...formatUserTags({
|
||||
private_tags: [ privateTag ],
|
||||
public_tags: [ publicTag ],
|
||||
watchlist_names: [ watchlistName ],
|
||||
}),
|
||||
{ slug: 'after_1', name: 'Another tag', tagType: 'custom' },
|
||||
{ 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 }
|
||||
/>
|
||||
<NetworkExplorers type="token" pathParam="token-hash" ml="auto"/>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PlacementWithLogical } from '@chakra-ui/react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import React from 'react';
|
||||
@@ -8,9 +9,10 @@ import { BODY_TYPEFACE } from 'theme/foundations/typography';
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
label: string;
|
||||
placement?: PlacementWithLogical;
|
||||
}
|
||||
|
||||
const TruncatedTextTooltip = ({ children, label }: Props) => {
|
||||
const TruncatedTextTooltip = ({ children, label, placement }: Props) => {
|
||||
const childRef = React.useRef<HTMLElement>(null);
|
||||
const [ isTruncated, setTruncated ] = React.useState(false);
|
||||
|
||||
@@ -60,7 +62,7 @@ const TruncatedTextTooltip = ({ children, label }: Props) => {
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PlacementWithLogical } from '@chakra-ui/react';
|
||||
import { Skeleton, chakra } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -7,11 +8,12 @@ interface Props {
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
value: string;
|
||||
tooltipPlacement?: PlacementWithLogical;
|
||||
}
|
||||
|
||||
const TruncatedValue = ({ className, isLoading, value }: Props) => {
|
||||
const TruncatedValue = ({ className, isLoading, value, tooltipPlacement }: Props) => {
|
||||
return (
|
||||
<TruncatedTextTooltip label={ value }>
|
||||
<TruncatedTextTooltip label={ value } placement={ tooltipPlacement }>
|
||||
<Skeleton
|
||||
className={ className }
|
||||
isLoaded={ !isLoading }
|
||||
|
||||
@@ -4,7 +4,7 @@ import React from 'react';
|
||||
|
||||
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
|
||||
|
||||
interface Props extends TagProps {
|
||||
export interface Props extends TagProps {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,18 @@ test('with ENS', async({ mount }) => {
|
||||
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 }) => {
|
||||
const component = await mount(
|
||||
<TestApp>
|
||||
|
||||
@@ -100,11 +100,13 @@ const Icon = (props: IconProps) => {
|
||||
type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'address'>;
|
||||
|
||||
const Content = chakra((props: ContentProps) => {
|
||||
if (props.address.name || props.address.ens_domain_name) {
|
||||
const text = props.address.ens_domain_name || props.address.name;
|
||||
const nameTag = props.address.metadata?.tags.find(tag => tag.tagType === 'name')?.name;
|
||||
const nameText = nameTag || props.address.ens_domain_name || props.address.name;
|
||||
|
||||
if (nameText) {
|
||||
const label = (
|
||||
<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>
|
||||
</VStack>
|
||||
);
|
||||
@@ -112,7 +114,7 @@ const Content = chakra((props: ContentProps) => {
|
||||
return (
|
||||
<Tooltip label={ label } maxW={{ base: '100vw', lg: '400px' }}>
|
||||
<Skeleton isLoaded={ !props.isLoading } overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap" as="span">
|
||||
{ text }
|
||||
{ nameText }
|
||||
</Skeleton>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -140,7 +142,7 @@ const Copy = (props: CopyProps) => {
|
||||
const Container = EntityBase.Container;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -1,11 +1,13 @@
|
||||
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 React from 'react';
|
||||
|
||||
import type { Address } from 'types/api/address';
|
||||
import type { TokenInfo } from 'types/api/token';
|
||||
import type { EntityTag } from 'ui/shared/EntityTags/types';
|
||||
|
||||
import config from 'configs/app';
|
||||
import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery';
|
||||
import type { ResourceError } from 'lib/api/resources';
|
||||
import useApiQuery from 'lib/api/useApiQuery';
|
||||
import { useAppContext } from 'lib/contexts/app';
|
||||
@@ -14,7 +16,9 @@ import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu'
|
||||
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
|
||||
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
|
||||
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 NetworkExplorers from 'ui/shared/NetworkExplorers';
|
||||
import PageTitle from 'ui/shared/Page/PageTitle';
|
||||
@@ -24,9 +28,10 @@ import TokenVerifiedInfo from './TokenVerifiedInfo';
|
||||
interface Props {
|
||||
tokenQuery: UseQueryResult<TokenInfo, ResourceError<unknown>>;
|
||||
addressQuery: UseQueryResult<Address, ResourceError<unknown>>;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => {
|
||||
const TokenPageTitle = ({ tokenQuery, addressQuery, hash }: Props) => {
|
||||
const appProps = useAppContext();
|
||||
const addressHash = !tokenQuery.isPlaceholderData ? (tokenQuery.data?.address || '') : '';
|
||||
|
||||
@@ -35,9 +40,12 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => {
|
||||
queryOptions: { enabled: Boolean(tokenQuery.data) && !tokenQuery.isPlaceholderData && config.features.verifiedTokens.isEnabled },
|
||||
});
|
||||
|
||||
const isLoading = tokenQuery.isPlaceholderData || addressQuery.isPlaceholderData || (
|
||||
config.features.verifiedTokens.isEnabled ? verifiedInfoQuery.isPending : false
|
||||
);
|
||||
const addressesForMetadataQuery = React.useMemo(() => ([ hash ].filter(Boolean)), [ hash ]);
|
||||
const addressMetadataQuery = useAddressMetadataInfoQuery(addressesForMetadataQuery);
|
||||
|
||||
const isLoading = tokenQuery.isPlaceholderData ||
|
||||
addressQuery.isPlaceholderData ||
|
||||
(config.features.verifiedTokens.isEnabled && verifiedInfoQuery.isPending);
|
||||
|
||||
const tokenSymbolText = tokenQuery.data?.symbol ? ` (${ tokenQuery.data.symbol })` : '';
|
||||
|
||||
@@ -54,6 +62,37 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => {
|
||||
};
|
||||
}, [ 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 = (
|
||||
<>
|
||||
{ verifiedInfoQuery.data?.tokenAddress && (
|
||||
@@ -64,19 +103,8 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => {
|
||||
</Tooltip>
|
||||
) }
|
||||
<EntityTags
|
||||
data={ addressQuery.data }
|
||||
isLoading={ isLoading }
|
||||
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
|
||||
}
|
||||
isLoading={ isLoading || (config.features.addressMetadata.isEnabled && addressMetadataQuery.isPending) }
|
||||
tags={ tags }
|
||||
flexGrow={ 1 }
|
||||
/>
|
||||
</>
|
||||
|
||||