diff --git a/deploy/values/review/values.yaml.gotmpl b/deploy/values/review/values.yaml.gotmpl index 0fc85885e2..e738ae3e63 100644 --- a/deploy/values/review/values.yaml.gotmpl +++ b/deploy/values/review/values.yaml.gotmpl @@ -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 diff --git a/lib/address/parseMetaPayload.ts b/lib/address/parseMetaPayload.ts index 80fc7fd8d1..ad5e8d401a 100644 --- a/lib/address/parseMetaPayload.ts +++ b/lib/address/parseMetaPayload.ts @@ -1,6 +1,8 @@ import type { AddressMetadataTag } from 'types/api/addressMetadata'; import type { AddressMetadataTagFormatted } from 'types/client/addressMetadata'; +type MetaParsed = NonNullable; + 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 = [ + '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; diff --git a/lib/makePrettyLink.ts b/lib/makePrettyLink.ts new file mode 100644 index 0000000000..9e05a2d660 --- /dev/null +++ b/lib/makePrettyLink.ts @@ -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) {} +} diff --git a/lib/mixpanel/utils.ts b/lib/mixpanel/utils.ts index 7abb8b5ee0..b901d7cc5e 100644 --- a/lib/mixpanel/utils.ts +++ b/lib/mixpanel/utils.ts @@ -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 ? { diff --git a/mocks/address/address.ts b/mocks/address/address.ts index 85eb1c5032..8ed50dca22 100644 --- a/mocks/address/address.ts +++ b/mocks/address/address.ts @@ -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, diff --git a/mocks/metadata/address.ts b/mocks/metadata/address.ts index f629272ed4..4e7849bb50 100644 --- a/mocks/metadata/address.ts +++ b/mocks/metadata/address.ts @@ -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, - }, - }, -}; diff --git a/types/api/addressMetadata.ts b/types/api/addressMetadata.ts index e6f8c0dac4..18579bdead 100644 --- a/types/api/addressMetadata.ts +++ b/types/api/addressMetadata.ts @@ -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 { + meta: { + textColor?: string; + bgColor?: string; + tagUrl?: string; + tooltipIcon?: string; + tooltipTitle?: string; + tooltipDescription?: string; + tooltipUrl?: string; + } | null; +} diff --git a/types/api/addressParams.ts b/types/api/addressParams.ts index fe8cb3d90c..8432062c4f 100644 --- a/types/api/addressParams.ts +++ b/types/api/addressParams.ts @@ -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; + } | null; } export type AddressParam = UserTags & AddressParamBasic; diff --git a/types/client/addressMetadata.ts b/types/client/addressMetadata.ts index fd6b36dc02..8281e1cf1b 100644 --- a/types/client/addressMetadata.ts +++ b/types/client/addressMetadata.ts @@ -1,4 +1,4 @@ -import type { AddressMetadataTagType } from 'types/api/addressMetadata'; +import type { AddressMetadataTagApi } from 'types/api/addressMetadata'; export interface AddressMetadataInfoFormatted { addresses: Record; } -export interface AddressMetadataTagFormatted { - slug: string; - name: string; - tagType: AddressMetadataTagType; - ordinal: number; - meta: { - textColor?: string; - bgColor?: string; - actionURL?: string; - } | null; -} +export type AddressMetadataTagFormatted = AddressMetadataTagApi; diff --git a/ui/pages/Address.tsx b/ui/pages/Address.tsx index c21ae2d0e1..86b0783d20 100644 --- a/ui/pages/Address.tsx +++ b/ui/pages/Address.tsx @@ -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 = 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 = ( ); @@ -261,7 +277,7 @@ const AddressPageContent = () => { diff --git a/ui/pages/Token.tsx b/ui/pages/Token.tsx index 3226a382c9..84998a0f9d 100644 --- a/ui/pages/Token.tsx +++ b/ui/pages/Token.tsx @@ -247,7 +247,7 @@ const TokenPageContent = () => { <> - + diff --git a/ui/pages/Transaction.tsx b/ui/pages/Transaction.tsx index e93fafb237..b9086c2fa9 100644 --- a/ui/pages/Transaction.tsx +++ b/ui/pages/Transaction.tsx @@ -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 = ( ); diff --git a/ui/pages/__screenshots__/Token.pw.tsx_default_bridged-token-1.png b/ui/pages/__screenshots__/Token.pw.tsx_default_bridged-token-1.png index f04e9f2426..6a3328be1c 100644 Binary files a/ui/pages/__screenshots__/Token.pw.tsx_default_bridged-token-1.png and b/ui/pages/__screenshots__/Token.pw.tsx_default_bridged-token-1.png differ diff --git a/ui/shared/EntityTags.tsx b/ui/shared/EntityTags.tsx deleted file mode 100644 index 40245f7984..0000000000 --- a/ui/shared/EntityTags.tsx +++ /dev/null @@ -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; - tagsAfter?: Array; - contentAfter?: React.ReactNode; -} - -const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoading, contentAfter }: Props) => { - const isMobile = useIsMobile(); - const { isOpen, onToggle, onClose } = useDisclosure(); - - const tags: Array = [ - ...tagsBefore, - ...(data?.private_tags || []), - ...(data?.public_tags || []), - ...(data?.watchlist_names || []), - ...tagsAfter, - ] - .filter(Boolean); - - const metaSuitesPlaceholder = config.features.metasuites.isEnabled ? - : - null; - - if (tags.length === 0 && !contentAfter) { - return metaSuitesPlaceholder; - } - - const content = (() => { - if (isMobile && tags.length > 2) { - return ( - <> - { - tags - .slice(0, 2) - .map((tag) => ( - - { tag.display_name } - - )) - } - { metaSuitesPlaceholder } - - - +{ tags.length - 1 } - - - - - { - tags - .slice(2) - .map((tag) => ( - - { tag.display_name } - - )) - } - - - - - - ); - } - - return ( - <> - { tags.map((tag) => ( - - { tag.display_name } - - )) } - { metaSuitesPlaceholder } - - ); - })(); - - return ( - - { content } - { contentAfter } - - ); -}; - -export default React.memo(chakra(EntityTags)); diff --git a/ui/shared/EntityTags/EntityTag.pw.tsx b/ui/shared/EntityTags/EntityTag.pw.tsx new file mode 100644 index 0000000000..299c48cf31 --- /dev/null +++ b/ui/shared/EntityTags/EntityTag.pw.tsx @@ -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(); + await expect(component).toHaveScreenshot(); +}); + +test('generic tag +@dark-mode', async({ render }) => { + const component = await render(); + await expect(component).toHaveScreenshot(); +}); + +test('protocol tag +@dark-mode', async({ render }) => { + const component = await render(); + await expect(component).toHaveScreenshot(); +}); + +test('tag with link and long name +@dark-mode', async({ render }) => { + const component = await render(); + 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(); + await component.getByText('BlockscoutHeroes').hover(); + await page.getByText('Blockscout team member').waitFor({ state: 'visible' }); + await expect(page).toHaveScreenshot(); +}); diff --git a/ui/shared/EntityTags/EntityTag.tsx b/ui/shared/EntityTags/EntityTag.tsx new file mode 100644 index 0000000000..5a83b3d4b4 --- /dev/null +++ b/ui/shared/EntityTags/EntityTag.tsx @@ -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 ; + } + + // 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 ( + + + + { data.tagType === 'name' && } + { (data.tagType === 'protocol' || data.tagType === 'generic') && # } + + + + + ); +}; + +export default React.memo(EntityTag); diff --git a/ui/shared/EntityTags/EntityTagLink.tsx b/ui/shared/EntityTags/EntityTagLink.tsx new file mode 100644 index 0000000000..00f6b23115 --- /dev/null +++ b/ui/shared/EntityTags/EntityTagLink.tsx @@ -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 ( + // + // { children } + // + // ); + // } + // } + + if (data.meta?.tagUrl) { + return ( + + { children } + + ); + } + + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>{ children }; +}; + +export default React.memo(EntityTagLink); diff --git a/ui/shared/EntityTags/EntityTagPopover.tsx b/ui/shared/EntityTags/EntityTagPopover.tsx new file mode 100644 index 0000000000..1451918ce8 --- /dev/null +++ b/ui/shared/EntityTags/EntityTagPopover.tsx @@ -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 ( + + + { children } + + + + + + { (data.meta?.tooltipIcon || data.meta?.tooltipTitle) && ( + + { data.meta?.tooltipIcon && { } + { data.meta?.tooltipTitle && { data.meta.tooltipTitle } } + + ) } + { data.meta?.tooltipDescription && { data.meta.tooltipDescription } } + { link && { link.domain } } + + + + + ); +}; + +export default React.memo(EntityTagPopover); diff --git a/ui/shared/EntityTags/EntityTags.tsx b/ui/shared/EntityTags/EntityTags.tsx new file mode 100644 index 0000000000..26698b8be2 --- /dev/null +++ b/ui/shared/EntityTags/EntityTags.tsx @@ -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; + isLoading?: boolean; +} + +const EntityTags = ({ tags, className, isLoading }: Props) => { + const isMobile = useIsMobile(); + const visibleNum = isMobile ? 2 : 3; + + const metaSuitesPlaceholder = config.features.metasuites.isEnabled ? + : + null; + + if (tags.length === 0) { + return metaSuitesPlaceholder; + } + + const content = (() => { + if (tags.length > visibleNum) { + return ( + <> + { tags.slice(0, visibleNum).map((tag) => ) } + { metaSuitesPlaceholder } + + + + +{ tags.length - visibleNum } + + + + + + { tags.slice(visibleNum).map((tag) => ) } + + + + + + ); + } + + return ( + <> + { tags.map((tag) => ) } + { metaSuitesPlaceholder } + + ); + })(); + + return ( + + { content } + + ); +}; + +export default React.memo(chakra(EntityTags)); diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_custom-name-tag-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_custom-name-tag-dark-mode-1.png new file mode 100644 index 0000000000..932254450b Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_custom-name-tag-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_generic-tag-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_generic-tag-dark-mode-1.png new file mode 100644 index 0000000000..2381600421 Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_generic-tag-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_protocol-tag-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_protocol-tag-dark-mode-1.png new file mode 100644 index 0000000000..41836e3717 Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_protocol-tag-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_tag-with-link-and-long-name-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_tag-with-link-and-long-name-dark-mode-1.png new file mode 100644 index 0000000000..dc42e698ef Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_tag-with-link-and-long-name-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_tag-with-tooltip-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_tag-with-tooltip-dark-mode-1.png new file mode 100644 index 0000000000..f86bb76d63 Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_tag-with-tooltip-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_custom-name-tag-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_custom-name-tag-dark-mode-1.png new file mode 100644 index 0000000000..2fa5802c36 Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_custom-name-tag-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_generic-tag-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_generic-tag-dark-mode-1.png new file mode 100644 index 0000000000..9e12c057a6 Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_generic-tag-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_protocol-tag-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_protocol-tag-dark-mode-1.png new file mode 100644 index 0000000000..c5de547a2d Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_protocol-tag-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_tag-with-link-and-long-name-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_tag-with-link-and-long-name-dark-mode-1.png new file mode 100644 index 0000000000..ec1924e091 Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_tag-with-link-and-long-name-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_tag-with-tooltip-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_tag-with-tooltip-dark-mode-1.png new file mode 100644 index 0000000000..244342f1c3 Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_tag-with-tooltip-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/formatUserTags.ts b/ui/shared/EntityTags/formatUserTags.ts new file mode 100644 index 0000000000..f644647ae0 --- /dev/null +++ b/ui/shared/EntityTags/formatUserTags.ts @@ -0,0 +1,9 @@ +import type { EntityTag } from './types'; +import type { UserTags } from 'types/api/addressParams'; + +export default function formatUserTags(data: UserTags | undefined): Array { + 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 })), + ]; +} diff --git a/ui/shared/EntityTags/sortEntityTags.ts b/ui/shared/EntityTags/sortEntityTags.ts new file mode 100644 index 0000000000..8227829f5d --- /dev/null +++ b/ui/shared/EntityTags/sortEntityTags.ts @@ -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; +} diff --git a/ui/shared/EntityTags/types.ts b/ui/shared/EntityTags/types.ts new file mode 100644 index 0000000000..47a444f0ad --- /dev/null +++ b/ui/shared/EntityTags/types.ts @@ -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 { + tagType: EntityTagType; + meta?: AddressMetadataTagFormatted['meta']; +} diff --git a/ui/shared/LinkExternal.tsx b/ui/shared/LinkExternal.tsx index cae2427170..31aaa7593d 100644 --- a/ui/shared/LinkExternal.tsx +++ b/ui/shared/LinkExternal.tsx @@ -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 ( - + { children } - + ); }; diff --git a/ui/shared/Page/__screenshots__/PageTitle.pw.tsx_default_with-long-name-and-many-tags-mobile-1.png b/ui/shared/Page/__screenshots__/PageTitle.pw.tsx_default_with-long-name-and-many-tags-mobile-1.png index 1284ee5f7b..6e08e3c204 100644 Binary files a/ui/shared/Page/__screenshots__/PageTitle.pw.tsx_default_with-long-name-and-many-tags-mobile-1.png and b/ui/shared/Page/__screenshots__/PageTitle.pw.tsx_default_with-long-name-and-many-tags-mobile-1.png differ diff --git a/ui/shared/Page/__screenshots__/PageTitle.pw.tsx_mobile_with-long-name-and-many-tags-mobile-1.png b/ui/shared/Page/__screenshots__/PageTitle.pw.tsx_mobile_with-long-name-and-many-tags-mobile-1.png index d7fd28ebf2..c1904cbeca 100644 Binary files a/ui/shared/Page/__screenshots__/PageTitle.pw.tsx_mobile_with-long-name-and-many-tags-mobile-1.png and b/ui/shared/Page/__screenshots__/PageTitle.pw.tsx_mobile_with-long-name-and-many-tags-mobile-1.png differ diff --git a/ui/shared/Page/specs/DefaultView.tsx b/ui/shared/Page/specs/DefaultView.tsx index 0c924f55e0..0c117d1411 100644 --- a/ui/shared/Page/specs/DefaultView.tsx +++ b/ui/shared/Page/specs/DefaultView.tsx @@ -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 = () => { <> diff --git a/ui/shared/Page/specs/LongNameAndManyTags.tsx b/ui/shared/Page/specs/LongNameAndManyTags.tsx index ae8e190cda..4c690737b9 100644 --- a/ui/shared/Page/specs/LongNameAndManyTags.tsx +++ b/ui/shared/Page/specs/LongNameAndManyTags.tsx @@ -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 = () => { <> } flexGrow={ 1 } /> + ); diff --git a/ui/shared/TruncatedTextTooltip.tsx b/ui/shared/TruncatedTextTooltip.tsx index 1f24937d7d..a929f1b63a 100644 --- a/ui/shared/TruncatedTextTooltip.tsx +++ b/ui/shared/TruncatedTextTooltip.tsx @@ -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(null); const [ isTruncated, setTruncated ] = React.useState(false); @@ -60,7 +62,7 @@ const TruncatedTextTooltip = ({ children, label }: Props) => { ); if (isTruncated) { - return { modifiedChildren }; + return { modifiedChildren }; } return modifiedChildren; diff --git a/ui/shared/TruncatedValue.tsx b/ui/shared/TruncatedValue.tsx index 626039c5c7..f953477b08 100644 --- a/ui/shared/TruncatedValue.tsx +++ b/ui/shared/TruncatedValue.tsx @@ -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 ( - + { await expect(component).toHaveScreenshot(); }); +test('with name tag', async({ mount }) => { + const component = await mount( + + + , + ); + + await expect(component).toHaveScreenshot(); +}); + test('external link', async({ mount }) => { const component = await mount( diff --git a/ui/shared/entities/address/AddressEntity.tsx b/ui/shared/entities/address/AddressEntity.tsx index 08d043ffe4..a5b2bd95d9 100644 --- a/ui/shared/entities/address/AddressEntity.tsx +++ b/ui/shared/entities/address/AddressEntity.tsx @@ -100,11 +100,13 @@ const Icon = (props: IconProps) => { type ContentProps = Omit & Pick; 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 = ( - { text } + { nameText } { props.address.hash } ); @@ -112,7 +114,7 @@ const Content = chakra((props: ContentProps) => { return ( - { text } + { nameText } ); @@ -140,7 +142,7 @@ const Copy = (props: CopyProps) => { const Container = EntityBase.Container; export interface EntityProps extends EntityBase.EntityBaseProps { - address: Pick; + address: Pick; isSafeAddress?: boolean; } diff --git a/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_with-name-tag-1.png b/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_with-name-tag-1.png new file mode 100644 index 0000000000..b3b745e458 Binary files /dev/null and b/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_with-name-tag-1.png differ diff --git a/ui/token/TokenPageTitle.tsx b/ui/token/TokenPageTitle.tsx index c92f1b8d92..cf22c633e7 100644 --- a/ui/token/TokenPageTitle.tsx +++ b/ui/token/TokenPageTitle.tsx @@ -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>; addressQuery: UseQueryResult>; + 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 = 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) => { ) }