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:
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
......
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;
}
if ('bgColor' in parsedMeta && typeof parsedMeta.bgColor === 'string') {
result.bgColor = parsedMeta.bgColor;
}
const stringFields: Array<keyof MetaParsed> = [
'textColor',
'bgColor',
'tagUrl',
'tooltipIcon',
'tooltipTitle',
'tooltipDescription',
'tooltipUrl',
];
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;
......
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,
......
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;
}
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;
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 } ] : [] }
/>
);
......
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 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>
);
};
......
......@@ -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"/>
</>
);
......
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;
......
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;
}
......
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 }
/>
</>
......
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