diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index f4013ece4f07..4a44303aba46 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -3788,6 +3788,9 @@ "multichainAddEthereumChainConfirmationDescription": { "message": "You're adding this network to MetaMask and giving this site permission to use it." }, + "multichainAddressViewAll": { + "message": "View all" + }, "multichainQuoteCardRateExplanation": { "message": "The best rate we found from providers, including provider fees and a $1% MetaMask fee." }, @@ -3939,13 +3942,6 @@ "network": { "message": "Network" }, - "networkAddress": { - "message": "1 network address" - }, - "networkAddresses": { - "message": "$1 network addresses", - "description": "$1 is the number of accounts" - }, "networkChanged": { "message": "Network changed" }, @@ -3992,6 +3988,9 @@ "networkNameBitcoin": { "message": "Bitcoin" }, + "networkNameBitcoinSegwit": { + "message": "Bitcoin Native SegWit" + }, "networkNameDefinition": { "message": "The name associated with this network." }, diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index f4013ece4f07..4a44303aba46 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -3788,6 +3788,9 @@ "multichainAddEthereumChainConfirmationDescription": { "message": "You're adding this network to MetaMask and giving this site permission to use it." }, + "multichainAddressViewAll": { + "message": "View all" + }, "multichainQuoteCardRateExplanation": { "message": "The best rate we found from providers, including provider fees and a $1% MetaMask fee." }, @@ -3939,13 +3942,6 @@ "network": { "message": "Network" }, - "networkAddress": { - "message": "1 network address" - }, - "networkAddresses": { - "message": "$1 network addresses", - "description": "$1 is the number of accounts" - }, "networkChanged": { "message": "Network changed" }, @@ -3992,6 +3988,9 @@ "networkNameBitcoin": { "message": "Bitcoin" }, + "networkNameBitcoinSegwit": { + "message": "Bitcoin Native SegWit" + }, "networkNameDefinition": { "message": "The name associated with this network." }, diff --git a/app/_locales/ga/messages.json b/app/_locales/ga/messages.json index 97ef17706694..850695c3ef65 100644 --- a/app/_locales/ga/messages.json +++ b/app/_locales/ga/messages.json @@ -3701,13 +3701,6 @@ "network": { "message": "Líonra" }, - "networkAddress": { - "message": "1 seoladh líonra" - }, - "networkAddresses": { - "message": "Seoltaí líonra $1", - "description": "$1 is the number of accounts" - }, "networkChanged": { "message": "Athraíodh an líonra" }, diff --git a/ui/components/multichain-accounts/multichain-account-network-group/index.ts b/ui/components/multichain-accounts/multichain-account-network-group/index.ts new file mode 100644 index 000000000000..a1d684839a82 --- /dev/null +++ b/ui/components/multichain-accounts/multichain-account-network-group/index.ts @@ -0,0 +1,2 @@ +export { MultichainAccountNetworkGroup } from './multichain-account-network-group'; +export type { MultichainAccountNetworkGroupProps } from './multichain-account-network-group'; diff --git a/ui/components/multichain-accounts/multichain-account-network-group/multichain-account-network-group.tsx b/ui/components/multichain-accounts/multichain-account-network-group/multichain-account-network-group.tsx new file mode 100644 index 000000000000..150c79cd8811 --- /dev/null +++ b/ui/components/multichain-accounts/multichain-account-network-group/multichain-account-network-group.tsx @@ -0,0 +1,162 @@ +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { AccountGroupId } from '@metamask/account-api'; +import { Box } from '@metamask/design-system-react'; +import { AvatarGroup } from '../../multichain/avatar-group'; +import { AvatarType } from '../../multichain/avatar-group/avatar-group.types'; +import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../shared/constants/network'; +import { convertCaipToHexChainId } from '../../../../shared/modules/network.utils'; +import { getInternalAccountListSpreadByScopesByGroupId } from '../../../selectors/multichain-accounts/account-tree'; + +export type MultichainAccountNetworkGroupProps = { + /** + * The account group ID to fetch networks for + */ + groupId?: AccountGroupId; + /** + * Array of specific chain IDs to display + * - If provided with groupId: shows only chains that exist in both the group and this list + * - If provided without groupId: shows only these specific chains + */ + chainIds?: string[]; + /** + * Whether to exclude test networks (default: true) + */ + excludeTestNetworks?: boolean; + /** + * Maximum number of avatars to display before showing "+X" + */ + limit?: number; + /** + * Optional className for additional styling + */ + className?: string; +}; + +/** + * A reusable component that displays a group of network avatars. + * Can fetch networks based on account group ID or accept explicit chain IDs. + * Handles conversion from CAIP chain IDs to hex format for EVM chains. + * + * @param props - The component props + * @param props.groupId - The account group ID to fetch networks for. When provided, fetches chain IDs from the account group. + * @param props.chainIds - Array of specific chain IDs to display. Behavior depends on groupId: + * - If provided with groupId: shows only chains that exist in both the group and this list (intersection) + * - If provided without groupId: shows only these specific chains + * @param props.excludeTestNetworks - Whether to exclude test networks from display. Defaults to true. + * @param props.limit - Maximum number of avatars to display before showing "+X" indicator. Defaults to 4. + * @param props.className - Optional CSS class name for additional styling + * @returns A React component displaying network avatars in a group + */ +export const MultichainAccountNetworkGroup: React.FC< + MultichainAccountNetworkGroupProps +> = ({ + groupId, + chainIds, + excludeTestNetworks = true, + limit = 4, + className, +}) => { + // Fetch chain IDs from account group if groupId is provided + const accountGroupScopes = useSelector((state) => + groupId + ? getInternalAccountListSpreadByScopesByGroupId(state, groupId) + : [], + ); + + const filteredChainIds = useMemo(() => { + // If only filterChainIds is provided (no groupId), show those chains + if (chainIds && !groupId) { + return chainIds; + } + + // If groupId is provided + if (groupId && accountGroupScopes.length > 0) { + // Extract unique chain IDs from account group scopes + const groupChainIds = new Set(); + accountGroupScopes.forEach((item) => { + groupChainIds.add(item.scope); + }); + + // If filterChainIds is also provided, show intersection + if (chainIds) { + const filterSet = new Set(chainIds); + return Array.from(groupChainIds).filter((chainId) => + filterSet.has(chainId), + ); + } + + // Otherwise, show all chains from the group + return Array.from(groupChainIds); + } + + return []; + }, [chainIds, groupId, accountGroupScopes]); + + const networkData = useMemo(() => { + if (excludeTestNetworks) { + // TODO: Add test network filtering logic here + // For now, we'll keep all networks + } + + // Define chain priority - these chains will appear first in this order + const chainPriority: Record = { + // Ethereum mainnet + 'eip155:1': 1, + '0x1': 1, + // Linea mainnet + 'eip155:59144': 2, + '0xe708': 2, + // Solana mainnet + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': 3, + // Bitcoin mainnet + 'bip122:000000000019d6689c085ae165831e93': 4, + }; + + // Sort chainIds based on priority + const sortedChainIds = [...filteredChainIds].sort((a, b) => { + const priorityA = chainPriority[a] || 999; + const priorityB = chainPriority[b] || 999; + return priorityA - priorityB; + }); + + return sortedChainIds + .map((chain) => { + let hexChainId = chain; + // Convert CAIP chain ID to hex format for EVM chains + if (chain.startsWith('eip155:')) { + try { + hexChainId = convertCaipToHexChainId( + chain as `${string}:${string}`, + ); + } catch { + // If conversion fails, fall back to using the original chain ID + hexChainId = chain; + } + } + return { + avatarValue: + CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[ + hexChainId as keyof typeof CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP + ], + }; + }) + .filter((network) => network.avatarValue); // Only include networks with valid avatar images + }, [filteredChainIds, excludeTestNetworks]); + + return ( + + + + ); +}; diff --git a/ui/components/multichain-accounts/multichain-accounts.scss b/ui/components/multichain-accounts/multichain-accounts.scss index e59accf9ef1f..b9885cc78c94 100644 --- a/ui/components/multichain-accounts/multichain-accounts.scss +++ b/ui/components/multichain-accounts/multichain-accounts.scss @@ -3,3 +3,4 @@ @import "multichain-account-menu-items"; @import "account-details-row"; @import "add-multichain-account"; +@import "multichain-address-rows-hovered-list"; diff --git a/ui/components/multichain-accounts/multichain-address-row/multichain-address-row.tsx b/ui/components/multichain-accounts/multichain-address-row/multichain-address-row.tsx index 198e9b050639..c306206c7f93 100644 --- a/ui/components/multichain-accounts/multichain-address-row/multichain-address-row.tsx +++ b/ui/components/multichain-accounts/multichain-address-row/multichain-address-row.tsx @@ -27,7 +27,7 @@ import { getImageForChainId } from '../../../selectors/multichain'; import { convertCaipToHexChainId } from '../../../../shared/modules/network.utils'; import { useI18nContext } from '../../../hooks/useI18nContext'; -type CopyParams = { +export type CopyParams = { /** * Message to display when the copy callback is executed */ diff --git a/ui/components/multichain-accounts/multichain-address-rows-hovered-list/index.scss b/ui/components/multichain-accounts/multichain-address-rows-hovered-list/index.scss new file mode 100644 index 000000000000..8cb14ff1e1dd --- /dev/null +++ b/ui/components/multichain-accounts/multichain-address-rows-hovered-list/index.scss @@ -0,0 +1,4 @@ +.multichain-address-row { + transition: background-color 0.3s ease-in-out; +} + diff --git a/ui/components/multichain-accounts/multichain-address-rows-hovered-list/index.ts b/ui/components/multichain-accounts/multichain-address-rows-hovered-list/index.ts new file mode 100644 index 000000000000..6724f62bede5 --- /dev/null +++ b/ui/components/multichain-accounts/multichain-address-rows-hovered-list/index.ts @@ -0,0 +1,2 @@ +export { MultichainAggregatedAddressListRow } from './multichain-aggregated-list-row'; +export { MultichainHoveredAddressRowsList } from './multichain-hovered-address-rows-hovered-list'; diff --git a/ui/components/multichain-accounts/multichain-address-rows-hovered-list/multichain-aggregated-list-row.stories.tsx b/ui/components/multichain-accounts/multichain-address-rows-hovered-list/multichain-aggregated-list-row.stories.tsx new file mode 100644 index 000000000000..6cf1d76cc9f4 --- /dev/null +++ b/ui/components/multichain-accounts/multichain-address-rows-hovered-list/multichain-aggregated-list-row.stories.tsx @@ -0,0 +1,222 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import { InternalAccount } from '@metamask/keyring-internal-api'; +import mockState from '../../../../test/data/mock-state.json'; +import { + MOCK_ACCOUNT_EOA, + MOCK_ACCOUNT_BIP122_P2WPKH, + MOCK_ACCOUNT_SOLANA_MAINNET, +} from '../../../../test/data/mock-accounts'; +import { MultichainNetworks } from '../../../../shared/constants/multichain/networks'; +import { MultichainAggregatedAddressListRow } from './multichain-aggregated-list-row'; + +const mockStore = configureStore([]); + +const accounts: Record = { + ethereum: { ...MOCK_ACCOUNT_EOA, scopes: ['eip155:1'] }, + polygon: { + ...MOCK_ACCOUNT_EOA, + id: '2', + address: '0xabcdef1234567890abcdef1234567890abcdef12', + scopes: ['eip155:137'], + }, + solana: { + ...MOCK_ACCOUNT_SOLANA_MAINNET, + scopes: [MultichainNetworks.SOLANA], + metadata: { + ...MOCK_ACCOUNT_SOLANA_MAINNET.metadata, + snap: { + enabled: true, + name: 'Solana Snap', + id: 'npm:@consensys/solana-snap', + }, + }, + }, + solanaTestnet: { + ...MOCK_ACCOUNT_SOLANA_MAINNET, + id: 'solana-testnet-account', + address: '9A4AptCThfbuknsbteHgGKXczfJpfjuVA9SLTSGaaLGD', + scopes: [MultichainNetworks.SOLANA_TESTNET], + metadata: { + ...MOCK_ACCOUNT_SOLANA_MAINNET.metadata, + name: 'Solana Testnet Account', + snap: { + enabled: true, + name: 'Solana Snap', + id: 'npm:@consensys/solana-snap', + }, + }, + }, + bitcoin: { + ...MOCK_ACCOUNT_BIP122_P2WPKH, + scopes: ['bip122:000000000019d6689c085ae165831e93'], + }, +}; + +const createMockState = () => ({ + ...mockState, + metamask: { + ...mockState.metamask, + remoteFeatureFlags: { + ...mockState.metamask.remoteFeatureFlags, + solanaAccounts: { enabled: true, minimumVersion: '13.6.0' }, + bitcoinAccounts: { enabled: true, minimumVersion: '13.6.0' }, + }, + // Override the EVM network configurations to have proper names + networkConfigurationsByChainId: { + '0x1': { + ...mockState.metamask.networkConfigurationsByChainId['0x1'], + name: 'Ethereum Mainnet', + }, + '0x89': { + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + { + networkClientId: 'polygon', + type: 'custom', + url: 'https://polygon-rpc.com', + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://polygonscan.com'], + defaultBlockExplorerUrlIndex: 0, + }, + '0xa4b1': { + chainId: '0xa4b1', + name: 'Arbitrum One', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'arbitrum', + type: 'custom', + url: 'https://arb1.arbitrum.io/rpc', + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://arbiscan.io'], + defaultBlockExplorerUrlIndex: 0, + }, + ...Object.fromEntries( + Object.entries( + mockState.metamask.networkConfigurationsByChainId, + ).filter(([chainId]) => !['0x1'].includes(chainId)), + ), + }, + multichainNetworkConfigurationsByChainId: { + [MultichainNetworks.SOLANA]: { + chainId: MultichainNetworks.SOLANA, + name: 'Solana Mainnet', + nativeCurrency: 'SOL', + isEvm: false, + }, + [MultichainNetworks.SOLANA_TESTNET]: { + chainId: MultichainNetworks.SOLANA_TESTNET, + name: 'Solana Testnet', + nativeCurrency: 'SOL', + isEvm: false, + }, + }, + internalAccounts: { + selectedAccount: accounts.ethereum.id, + accounts: Object.fromEntries( + Object.values(accounts).map((acc) => [acc.id, acc]), + ), + }, + }, +}); + +const meta: Meta = { + title: 'Components/MultichainAccounts/MultichainAggregatedAddressListRow', + component: MultichainAggregatedAddressListRow, + parameters: { + docs: { + description: { + component: + 'A component that displays an aggregated list row with multiple network avatars, truncated address, and a copy action. The group name is automatically derived from the chain IDs - "Ethereum" for EVM chains or the network name for non-EVM chains.', + }, + }, + }, + argTypes: { + chainIds: { + control: 'array', + description: 'List of chain ids associated with an address', + }, + address: { + control: 'text', + description: 'Address string to display (will be truncated)', + }, + copyActionParams: { + control: 'object', + description: + 'Copy parameters for the address, including message and callback function', + }, + className: { + control: 'text', + description: 'Optional className for additional styling', + }, + }, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const DefaultEthereum: Story = { + args: { + chainIds: ['0x1'], + address: '0x1234567890abcdef1234567890abcdef12345678', + copyActionParams: { + message: 'Address copied!', + callback: () => { + navigator.clipboard.writeText( + '0x1234567890abcdef1234567890abcdef12345678', + ); + console.log('Address copied to clipboard'); + }, + }, + }, +}; + +export const ManyNetworks: Story = { + args: { + chainIds: ['0x1', '0x89', '0xa4b1', '0xa', '0x2105', '0x8274f'], + address: '0x1234567890abcdef1234567890abcdef12345678', + copyActionParams: { + message: 'Address copied!', + callback: () => { + navigator.clipboard.writeText( + '0x1234567890abcdef1234567890abcdef12345678', + ); + console.log('Address copied to clipboard'); + }, + }, + }, +}; + +export const NonEvmNetwork: Story = { + args: { + chainIds: [MultichainNetworks.SOLANA], + address: 'DfGj1XfVTbfM7VZvqLkVNvDhFb4Nt8xBpGpH5f2r3Dqq', + copyActionParams: { + message: 'Address copied!', + callback: () => { + navigator.clipboard.writeText( + 'DfGj1XfVTbfM7VZvqLkVNvDhFb4Nt8xBpGpH5f2r3Dqq', + ); + console.log('Solana address copied to clipboard'); + }, + }, + }, +}; diff --git a/ui/components/multichain-accounts/multichain-address-rows-hovered-list/multichain-aggregated-list-row.test.tsx b/ui/components/multichain-accounts/multichain-address-rows-hovered-list/multichain-aggregated-list-row.test.tsx new file mode 100644 index 000000000000..c18af97d549b --- /dev/null +++ b/ui/components/multichain-accounts/multichain-address-rows-hovered-list/multichain-aggregated-list-row.test.tsx @@ -0,0 +1,451 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import { CopyParams } from '../multichain-address-row/multichain-address-row'; +import { MultichainAggregatedAddressListRow } from './multichain-aggregated-list-row'; + +jest.mock('../../../hooks/useI18nContext', () => ({ + useI18nContext: () => (key: string) => { + if (key === 'networkNameEthereum') { + return 'Ethereum'; + } + return key; + }, +})); + +const mockStore = configureStore([]); + +const TEST_STRINGS = { + FULL_ADDRESS: '0x1234567890abcdef1234567890abcdef12345678', + TRUNCATED_ADDRESS: '0x12345...45678', + ALT_FULL_ADDRESS: '0xabcdef1234567890abcdef1234567890abcdef12', + ALT_TRUNCATED_ADDRESS: '0xabCDE...DeF12', + COPY_MESSAGE: 'Copied!', + EMPTY_STRING: '', + ETHEREUM_GROUP_NAME: 'Ethereum', + SOLANA_NETWORK_NAME: 'Solana Mainnet', +} as const; + +const TEST_CHAIN_IDS = { + ETHEREUM: '0x1', + POLYGON: '0x89', + ARBITRUM: '0xa4b1', + FANTOM: '0xfa', + MOONRIVER: '0x2105', + OPTIMISM: '0xa', + UNKNOWN: 'unknown-chain-id', + HEX_123: '0x123', + ETHEREUM_CAIP: 'eip155:1', + POLYGON_CAIP: 'eip155:137', + ARBITRUM_CAIP: 'eip155:42161', + SOLANA_CAIP: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', +} as const; + +const TEST_IDS = { + MULTICHAIN_ADDRESS_ROW: 'multichain-address-row', + AVATAR_GROUP: 'avatar-group', +} as const; + +const CSS_CLASSES = { + MULTICHAIN_ADDRESS_ROW: 'multichain-address-row', + CUSTOM_CLASS: 'custom-class', +} as const; + +const IMAGE_SOURCES = { + ETH_LOGO: './images/eth_logo.svg', + POL_TOKEN: './images/pol-token.svg', + ARBITRUM: './images/arbitrum.svg', +} as const; + +const ALT_TEXTS = { + NETWORK_LOGO: 'network logo', +} as const; + +const createTestProps = ( + overrides = {}, +): { + chainIds: string[]; + address: string; + copyActionParams: CopyParams; + className?: string; +} => ({ + chainIds: ['eip155:1', 'eip155:137'], + address: TEST_STRINGS.FULL_ADDRESS, + copyActionParams: { + callback: jest.fn(), + message: TEST_STRINGS.COPY_MESSAGE, + }, + ...overrides, +}); + +const createMockState = () => ({ + metamask: { + useBlockie: false, + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'mainnet', + url: 'https://mainnet.infura.io/v3', + type: 'infura', + }, + ], + defaultRpcEndpointIndex: 0, + }, + '0x89': { + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + { + networkClientId: 'polygon-mainnet', + url: 'https://polygon-rpc.com', + type: 'custom', + }, + ], + defaultRpcEndpointIndex: 0, + }, + '0xa4b1': { + chainId: '0xa4b1', + name: 'Arbitrum One', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'arbitrum-mainnet', + url: 'https://arb1.arbitrum.io/rpc', + type: 'custom', + }, + ], + defaultRpcEndpointIndex: 0, + }, + }, + multichainNetworkConfigurationsByChainId: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + name: 'Solana Mainnet', + nativeCurrency: 'SOL', + isEvm: false, + }, + }, + internalAccounts: { + accounts: { + 'test-account-1': { + id: 'test-account-1', + address: '0x1234567890abcdef1234567890abcdef12345678', + scopes: ['eip155:1', 'eip155:137', 'eip155:42161'], + metadata: { + name: 'Test Account 1', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + lastSelected: Date.now(), + }, + }, + 'test-account-2': { + id: 'test-account-2', + address: 'DfGj1XfVTbfM7VZvqLkVNvDhFb4Nt8xBpGpH5f2r3Dqq', + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + metadata: { + name: 'Test Account 2', + keyring: { type: 'Snap' }, + importTime: Date.now(), + lastSelected: Date.now(), + snap: { + enabled: true, + }, + }, + }, + }, + }, + }, +}); + +describe('MultichainAggregatedAddressListRow', () => { + let store: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.useFakeTimers(); + store = mockStore(createMockState()); + }); + + afterEach(() => { + act(() => { + jest.runOnlyPendingTimers(); + }); + jest.useRealTimers(); + jest.resetAllMocks(); + }); + + describe('Component Rendering', () => { + it('renders with all provided props', () => { + const props = createTestProps(); + + render( + + + , + ); + + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROW), + ).toBeInTheDocument(); + expect( + screen.getByText(TEST_STRINGS.ETHEREUM_GROUP_NAME), + ).toBeInTheDocument(); + expect( + screen.getByText(TEST_STRINGS.TRUNCATED_ADDRESS), + ).toBeInTheDocument(); + }); + + it('renders with custom className when provided', () => { + const props = createTestProps({ className: CSS_CLASSES.CUSTOM_CLASS }); + + render( + + + , + ); + + const row = screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROW); + expect(row).toHaveClass(CSS_CLASSES.MULTICHAIN_ADDRESS_ROW); + expect(row).toHaveClass(CSS_CLASSES.CUSTOM_CLASS); + }); + + it('displays avatar group with network images', () => { + const chainIds = ['eip155:1', 'eip155:137', 'eip155:42161']; + const props = createTestProps({ chainIds }); + + render( + + + , + ); + + const row = screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROW); + expect(row).toBeInTheDocument(); + + // Verify avatar group is rendered + const avatarGroup = screen.getByTestId(TEST_IDS.AVATAR_GROUP); + expect(avatarGroup).toBeInTheDocument(); + + // Verify network avatars are displayed + // Note: Only networks with valid images will be rendered + const networkAvatars = screen.getAllByAltText(ALT_TEXTS.NETWORK_LOGO); + expect(networkAvatars.length).toBeGreaterThanOrEqual(1); + expect(networkAvatars.length).toBeLessThanOrEqual(chainIds.length); + + // Verify at least one expected image is present + const avatarSources = networkAvatars.map((avatar) => + avatar.getAttribute('src'), + ); + const expectedSources = [ + IMAGE_SOURCES.ETH_LOGO, + IMAGE_SOURCES.POL_TOKEN, + IMAGE_SOURCES.ARBITRUM, + ]; + expect( + avatarSources.some( + (src) => + src && + expectedSources.includes(src as (typeof expectedSources)[number]), + ), + ).toBe(true); + }); + + it('truncates address correctly', () => { + const address = TEST_STRINGS.ALT_FULL_ADDRESS; + const props = createTestProps({ address }); + + render( + + + , + ); + + expect( + screen.getByText(TEST_STRINGS.ALT_TRUNCATED_ADDRESS), + ).toBeInTheDocument(); + }); + }); + + describe('Copy Functionality', () => { + it('executes copy callback when copy button is clicked', () => { + const mockCallback = jest.fn(); + const props = createTestProps({ + copyActionParams: { + callback: mockCallback, + message: TEST_STRINGS.COPY_MESSAGE, + }, + }); + + render( + + + , + ); + + const copyButton = screen.getByRole('button'); + fireEvent.click(copyButton); + + expect(mockCallback).toHaveBeenCalled(); + }); + + it('shows copy message after clicking copy button', () => { + const props = createTestProps(); + + render( + + + , + ); + + const copyButton = screen.getByRole('button'); + + // Initially should show the truncated address + expect( + screen.getByText(TEST_STRINGS.TRUNCATED_ADDRESS), + ).toBeInTheDocument(); + + // Click copy button + fireEvent.click(copyButton); + + // Should show the copy message + expect(screen.getByText(TEST_STRINGS.COPY_MESSAGE)).toBeInTheDocument(); + expect(props.copyActionParams.callback).toHaveBeenCalled(); + }); + + it('reverts icon to copy state after 1 second', async () => { + const props = createTestProps(); + + render( + + + , + ); + + const copyButton = screen.getByRole('button'); + fireEvent.click(copyButton); + + expect(props.copyActionParams.callback).toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect( + screen.getByText(TEST_STRINGS.TRUNCATED_ADDRESS), + ).toBeInTheDocument(); + }); + + it('changes background color to success state when address is copied', () => { + const props = createTestProps(); + + render( + + + , + ); + const row = screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROW); + + expect(row).toHaveClass(CSS_CLASSES.MULTICHAIN_ADDRESS_ROW); + + const copyButton = screen.getByRole('button'); + fireEvent.click(copyButton); + + expect(props.copyActionParams.callback).toHaveBeenCalled(); + }); + + it('resets state after 1 second', () => { + const props = createTestProps(); + + render( + + + , + ); + + const copyButton = screen.getByRole('button'); + fireEvent.click(copyButton); + + expect(props.copyActionParams.callback).toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + fireEvent.click(copyButton); + expect(props.copyActionParams.callback).toHaveBeenCalled(); + }); + }); + + describe('Group Name Derivation', () => { + it('displays "Ethereum" for EVM chain IDs', () => { + const props = createTestProps({ + chainIds: ['eip155:1', 'eip155:137'], + }); + + render( + + + , + ); + + expect( + screen.getByText(TEST_STRINGS.ETHEREUM_GROUP_NAME), + ).toBeInTheDocument(); + }); + + it('displays "Ethereum" for CAIP-format EVM chain IDs', () => { + const props = createTestProps({ + chainIds: ['eip155:1', 'eip155:137'], + }); + + render( + + + , + ); + + expect( + screen.getByText(TEST_STRINGS.ETHEREUM_GROUP_NAME), + ).toBeInTheDocument(); + }); + + it('displays network name for non-EVM chain IDs', () => { + const props = createTestProps({ + chainIds: [TEST_CHAIN_IDS.SOLANA_CAIP], + }); + + render( + + + , + ); + + expect( + screen.getByText(TEST_STRINGS.SOLANA_NETWORK_NAME), + ).toBeInTheDocument(); + }); + + it('displays "Ethereum" for mixed EVM and non-EVM chains with at least one EVM chain', () => { + const props = createTestProps({ + chainIds: [TEST_CHAIN_IDS.ETHEREUM_CAIP, TEST_CHAIN_IDS.SOLANA_CAIP], + }); + + render( + + + , + ); + + expect( + screen.getByText(TEST_STRINGS.ETHEREUM_GROUP_NAME), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/components/multichain-accounts/multichain-address-rows-hovered-list/multichain-aggregated-list-row.tsx b/ui/components/multichain-accounts/multichain-address-rows-hovered-list/multichain-aggregated-list-row.tsx new file mode 100644 index 000000000000..1df009ced3a6 --- /dev/null +++ b/ui/components/multichain-accounts/multichain-address-rows-hovered-list/multichain-aggregated-list-row.tsx @@ -0,0 +1,195 @@ +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { + Box, + BoxAlignItems, + BoxBackgroundColor, + BoxFlexDirection, + BoxJustifyContent, + ButtonIconSize, + FontWeight, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react'; +import { useSelector } from 'react-redux'; +import { shortenAddress } from '../../../helpers/utils/util'; + +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { CopyParams } from '../multichain-address-row/multichain-address-row'; +import { getNetworksByScopes } from '../../../../shared/modules/selectors/networks'; +import { ButtonIcon, IconName } from '../../component-library'; +import { IconColor } from '../../../helpers/constants/design-system'; +import { MultichainAccountNetworkGroup } from '../multichain-account-network-group'; +// eslint-disable-next-line import/no-restricted-paths +import { normalizeSafeAddress } from '../../../../app/scripts/lib/multichain/address'; + +type MultichainAggregatedAddressListRowProps = { + /** + * List of chain ids associated with an address + */ + chainIds: string[]; + /** + * Address string to display (will be truncated) + */ + address: string; + /** + * Copy parameters for the address + */ + copyActionParams: CopyParams; + /** + * Optional className for additional styling + */ + className?: string; +}; + +export const MultichainAggregatedAddressListRow = ({ + chainIds, + address, + copyActionParams, + className = '', +}: MultichainAggregatedAddressListRowProps) => { + const t = useI18nContext(); + + const truncatedAddress = shortenAddress(normalizeSafeAddress(address)); // Shorten address for display + const [displayText, setDisplayText] = useState(truncatedAddress); // Text to display (address or copy message) + const [copyIcon, setCopyIcon] = useState(IconName.Copy); // Default copy icon state + const [addressCopied, setAddressCopied] = useState(false); + const [isHovered, setIsHovered] = useState(false); + + // Track timeout ID for managing `setTimeout` + const timeoutRef = useRef(null); + + // Update `displayText` when the address prop changes + useEffect(() => { + setDisplayText(truncatedAddress); + }, [address, truncatedAddress]); + + // Cleanup timeout when component unmounts + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, []); + + const networks = useSelector((state) => getNetworksByScopes(state, chainIds)); + + const groupName = useMemo(() => { + if (networks[0]?.name === 'Bitcoin') { + return t('networkNameBitcoinSegwit'); + } + + return chainIds.some((chain) => chain.startsWith('eip155:')) + ? t('networkNameEthereum') + : networks[0]?.name; + }, [chainIds, t, networks]); + + // Helper function to get text color based on state + const getTextColor = () => { + if (addressCopied) { + return TextColor.SuccessDefault; + } + if (isHovered) { + return TextColor.PrimaryDefaultHover; + } + return TextColor.TextAlternative; + }; + + // Helper function to get icon color based on state + const getIconColor = () => { + if (addressCopied) { + return IconColor.successDefault; + } + if (isHovered) { + return IconColor.primaryDefault; + } + return IconColor.iconAlternative; + }; + + const getBackgroundColor = useMemo(() => { + if (addressCopied) { + return BoxBackgroundColor.SuccessMuted; + } + if (isHovered) { + return BoxBackgroundColor.BackgroundMuted; + } + return BoxBackgroundColor.BackgroundDefault; + }, [addressCopied, isHovered]); + + // Handle "Copy" button click events + const handleCopyClick = (e: React.MouseEvent) => { + e.stopPropagation(); + // Clear existing timeout if clicking multiple times in rapid succession + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + setAddressCopied(true); + + // Trigger copy callback and update UI state + copyActionParams.callback(); + setDisplayText(copyActionParams.message); + setCopyIcon(IconName.CopySuccess); + + // Reset state after 1 second and track the new timeout + timeoutRef.current = setTimeout(() => { + setDisplayText(truncatedAddress); + setCopyIcon(IconName.Copy); + timeoutRef.current = null; // Clear the reference after timeout resolves + setAddressCopied(false); + }, 1000); + }; + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + + + {groupName} + + + + + {displayText} + + + + + ); +}; diff --git a/ui/components/multichain-accounts/multichain-address-rows-hovered-list/multichain-hovered-address-rows-hovered-list.stories.tsx b/ui/components/multichain-accounts/multichain-address-rows-hovered-list/multichain-hovered-address-rows-hovered-list.stories.tsx new file mode 100644 index 000000000000..dfba1c1f5a17 --- /dev/null +++ b/ui/components/multichain-accounts/multichain-address-rows-hovered-list/multichain-hovered-address-rows-hovered-list.stories.tsx @@ -0,0 +1,499 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import { InternalAccount } from '@metamask/keyring-internal-api'; +import { CaipChainId } from '@metamask/utils'; +import { + AccountGroupId, + AccountWalletType, + toAccountWalletId, +} from '@metamask/account-api'; +import mockState from '../../../../test/data/mock-state.json'; +import { + MOCK_ACCOUNT_EOA, + MOCK_ACCOUNT_BIP122_P2WPKH, + MOCK_ACCOUNT_SOLANA_MAINNET, +} from '../../../../test/data/mock-accounts'; +import { MultichainNetworks } from '../../../../shared/constants/multichain/networks'; +import { MultichainHoveredAddressRowsList } from './multichain-hovered-address-rows-hovered-list'; + +const mockStore = configureStore([]); + +const mockWalletEntropySource = '01234567890ABCDEFGHIJKLMNOP'; +const WALLET_ID = toAccountWalletId( + AccountWalletType.Entropy, + mockWalletEntropySource, +); +const GROUP_ID = `${WALLET_ID}/0` as AccountGroupId; + +const accounts: Record = { + ethereum: { + ...MOCK_ACCOUNT_EOA, + id: 'ethereum-account', + address: '0x1234567890abcdef1234567890abcdef12345678', + type: 'eip155:eoa', + scopes: ['eip155:*'], + metadata: { + ...MOCK_ACCOUNT_EOA.metadata, + name: 'Ethereum Account', + }, + }, + polygon: { + ...MOCK_ACCOUNT_EOA, + id: 'polygon-account', + address: '0xabcdef1234567890abcdef1234567890abcdef12', + type: 'eip155:eoa', + scopes: ['eip155:137'], + metadata: { + ...MOCK_ACCOUNT_EOA.metadata, + name: 'Polygon Account', + }, + }, + solana: { + ...MOCK_ACCOUNT_SOLANA_MAINNET, + id: 'solana-account', + address: '9A4AptCThfbuknsbteHgGKXczfJpfjuVA9SLTSGaaLGD', + scopes: ['solana:*'], + metadata: { + ...MOCK_ACCOUNT_SOLANA_MAINNET.metadata, + name: 'Solana Account', + }, + }, + solanaTestnet: { + ...MOCK_ACCOUNT_SOLANA_MAINNET, + id: 'solana-testnet-account', + address: '9A4AptCThfbuknsbteHgGKXczfJpfjuVA9SLTSGaaLGD', + scopes: [MultichainNetworks.SOLANA_TESTNET], + metadata: { + ...MOCK_ACCOUNT_SOLANA_MAINNET.metadata, + name: 'Solana Testnet Account', + }, + }, + bitcoin: { + ...MOCK_ACCOUNT_BIP122_P2WPKH, + id: 'bitcoin-account', + address: 'bc1q4v2dstzcpvkhz29l75kz5gxspvpxgxkdmhjaq8', + scopes: ['bip122:*'], + metadata: { + ...MOCK_ACCOUNT_BIP122_P2WPKH.metadata, + name: 'Bitcoin Account', + }, + }, + multiChainAccount: { + ...MOCK_ACCOUNT_EOA, + id: 'multi-chain-account', + address: '0x9876543210fedcba9876543210fedcba98765432', + type: 'eip155:eoa', + scopes: ['eip155:*'], + metadata: { + ...MOCK_ACCOUNT_EOA.metadata, + name: 'Multi-Chain Account', + }, + }, +}; + +const createMockState = () => ({ + ...mockState, + metamask: { + ...mockState.metamask, + remoteFeatureFlags: { + ...mockState.metamask.remoteFeatureFlags, + solanaAccounts: { enabled: true, minimumVersion: '13.6.0' }, + bitcoinAccounts: { enabled: true, minimumVersion: '13.6.0' }, + }, + accountTree: { + wallets: { + [WALLET_ID]: { + type: 'entropy', + id: WALLET_ID, + metadata: {}, + groups: { + [GROUP_ID]: { + type: 'multichain-account', + id: GROUP_ID, + metadata: { + name: 'Storybook Account Group', + entropy: { + groupIndex: 0, + }, + pinned: false, + hidden: false, + }, + accounts: Object.keys(accounts).map( + (key) => accounts[key as keyof typeof accounts].id, + ), + }, + }, + }, + }, + }, + networkConfigurationsByChainId: { + '0x1': { + ...mockState.metamask.networkConfigurationsByChainId['0x1'], + name: 'Ethereum Mainnet', + }, + '0x89': { + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + { + networkClientId: 'polygon', + type: 'custom', + url: 'https://polygon-rpc.com', + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://polygonscan.com'], + defaultBlockExplorerUrlIndex: 0, + }, + '0xa4b1': { + chainId: '0xa4b1', + name: 'Arbitrum One', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'arbitrum', + type: 'custom', + url: 'https://arb1.arbitrum.io/rpc', + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://arbiscan.io'], + defaultBlockExplorerUrlIndex: 0, + }, + '0xa': { + chainId: '0xa', + name: 'Optimism', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'optimism', + type: 'custom', + url: 'https://mainnet.optimism.io', + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://optimistic.etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + }, + '0xe708': { + chainId: '0xe708', + name: 'Linea', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'linea', + type: 'custom', + url: 'https://rpc.linea.build', + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://lineascan.build'], + defaultBlockExplorerUrlIndex: 0, + }, + ...Object.fromEntries( + Object.entries( + mockState.metamask.networkConfigurationsByChainId, + ).filter( + ([chainId]) => + !['0x1', '0x89', '0xa4b1', '0xa', '0xe708'].includes(chainId), + ), + ), + }, + multichainNetworkConfigurationsByChainId: { + 'eip155:1': { + chainId: 'eip155:1', + name: 'Ethereum', + isEvm: true, + nativeCurrency: 'ETH', + }, + 'eip155:137': { + chainId: 'eip155:137', + name: 'Polygon', + isEvm: true, + nativeCurrency: 'MATIC', + }, + 'eip155:42161': { + chainId: 'eip155:42161', + name: 'Arbitrum', + isEvm: true, + nativeCurrency: 'ETH', + }, + 'eip155:10': { + chainId: 'eip155:10', + name: 'Optimism', + isEvm: true, + nativeCurrency: 'ETH', + }, + 'eip155:59144': { + chainId: 'eip155:59144', + name: 'Linea', + isEvm: true, + nativeCurrency: 'ETH', + }, + [MultichainNetworks.SOLANA]: { + chainId: MultichainNetworks.SOLANA, + name: 'Solana with a really long name', + nativeCurrency: 'SOL', + isEvm: false, + }, + [MultichainNetworks.SOLANA_TESTNET]: { + chainId: MultichainNetworks.SOLANA_TESTNET, + name: 'Solana Testnet', + nativeCurrency: 'SOL', + isEvm: false, + }, + 'bip122:000000000019d6689c085ae165831e93': { + chainId: 'bip122:000000000019d6689c085ae165831e93', + name: 'Bitcoin Mainnet', + nativeCurrency: 'BTC', + isEvm: false, + }, + 'tron:0x2b6653dc': { + chainId: 'tron:0x2b6653dc', + name: 'Tron Mainnet', + nativeCurrency: 'TRX', + isEvm: false, + }, + }, + internalAccounts: { + selectedAccount: accounts.ethereum.id, + accounts: Object.fromEntries( + Object.values(accounts).map((acc) => [acc.id, acc]), + ), + }, + balances: { + totalBalanceInUserCurrency: 1234.56, + userCurrency: 'USD', + wallets: { + [WALLET_ID]: { + walletId: WALLET_ID, + totalBalanceInUserCurrency: 1234.56, + userCurrency: 'USD', + groups: { + [GROUP_ID]: { + totalBalanceInUserCurrency: 1234.56, + userCurrency: 'USD', + walletId: WALLET_ID, + groupId: GROUP_ID, + }, + }, + }, + }, + }, + }, +}); + +const meta: Meta = { + title: 'Components/MultichainAccounts/MultichainHoveredAddressRowsList', + component: MultichainHoveredAddressRowsList, + parameters: { + docs: { + description: { + component: + 'A component that displays a list of multichain addresses grouped by account and sorted by priority networks. It renders MultichainAggregatedAddressListRow components for each network group.', + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const MultipleDifferentAccounts: Story = { + args: { + groupId: GROUP_ID, + children: , + }, + decorators: [ + (Story) => { + const state = createMockState(); + state.metamask.accountTree.wallets[WALLET_ID].groups[GROUP_ID].accounts = + [ + accounts.ethereum.id, + accounts.polygon.id, + accounts.solana.id, + accounts.bitcoin.id, + ]; + return ( + +
+ +
+
+ ); + }, + ], +}; + +export const SingleEthereumAccount: Story = { + args: { + groupId: GROUP_ID, + children: , + }, + decorators: [ + (Story) => { + const state = createMockState(); + state.metamask.accountTree.wallets[WALLET_ID].groups[GROUP_ID].accounts = + [accounts.ethereum.id]; + return ( + +
+ +
+
+ ); + }, + ], +}; + +export const SpecificNetworkAccount: Story = { + args: { + groupId: GROUP_ID, + children: , + }, + decorators: [ + (Story) => { + const state = createMockState(); + state.metamask.accountTree.wallets[WALLET_ID].groups[GROUP_ID].accounts = + [accounts.polygon.id]; + return ( + +
+ +
+
+ ); + }, + ], +}; + +export const SolanaOnly: Story = { + args: { + groupId: GROUP_ID, + children: , + }, + decorators: [ + (Story) => { + const state = createMockState(); + state.metamask.accountTree.wallets[WALLET_ID].groups[GROUP_ID].accounts = + [accounts.solana.id, accounts.solanaTestnet.id]; + return ( + +
+ +
+
+ ); + }, + ], +}; + +export const MultiChainSingleAccount: Story = { + args: { + groupId: GROUP_ID, + children: , + }, + parameters: { + docs: { + description: { + story: + 'Shows a single account that has multiple EVM chains aggregated into one row', + }, + }, + }, + decorators: [ + (Story) => { + const state = createMockState(); + state.metamask.accountTree.wallets[WALLET_ID].groups[GROUP_ID].accounts = + [accounts.multiChainAccount.id]; + return ( + +
+ +
+
+ ); + }, + ], +}; + +export const NonEvmOnly: Story = { + args: { + groupId: GROUP_ID, + children: , + }, + parameters: { + docs: { + description: { + story: 'Shows only non-EVM networks (Bitcoin and Solana)', + }, + }, + }, + decorators: [ + (Story) => { + const state = createMockState(); + state.metamask.accountTree.wallets[WALLET_ID].groups[GROUP_ID].accounts = + [accounts.bitcoin.id, accounts.solana.id]; + return ( + +
+ +
+
+ ); + }, + ], +}; + +export const EmptyState: Story = { + args: { + groupId: GROUP_ID, + children: , + }, + decorators: [ + (Story) => { + const state = createMockState(); + state.metamask.accountTree.wallets[WALLET_ID].groups[GROUP_ID].accounts = + []; + return ( + +
+ +
+
+ ); + }, + ], +}; + +export const AllAccounts: Story = { + args: { + groupId: GROUP_ID, + children: , + }, + parameters: { + docs: { + description: { + story: + 'Shows all available accounts with various network configurations', + }, + }, + }, + decorators: [ + (Story) => { + const state = createMockState(); + state.metamask.accountTree.wallets[WALLET_ID].groups[GROUP_ID].accounts = + Object.values(accounts).map((acc) => acc.id); + return ( + +
+ +
+
+ ); + }, + ], +}; diff --git a/ui/components/multichain-accounts/multichain-address-rows-hovered-list/multichain-hovered-address-rows-hovered-list.test.tsx b/ui/components/multichain-accounts/multichain-address-rows-hovered-list/multichain-hovered-address-rows-hovered-list.test.tsx new file mode 100644 index 000000000000..d4def5c5883e --- /dev/null +++ b/ui/components/multichain-accounts/multichain-address-rows-hovered-list/multichain-hovered-address-rows-hovered-list.test.tsx @@ -0,0 +1,915 @@ +import React from 'react'; +import { + render, + screen, + fireEvent, + waitFor, + act, +} from '@testing-library/react'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import { InternalAccount } from '@metamask/keyring-internal-api'; +import { + AccountGroupId, + AccountGroupType, + AccountWalletType, + toAccountWalletId, +} from '@metamask/account-api'; +import { CaipChainId } from '@metamask/utils'; +import { MULTICHAIN_ACCOUNT_ADDRESS_LIST_PAGE_ROUTE } from '../../../helpers/constants/routes'; +import { + getInternalAccountListSpreadByScopesByGroupId, + getAllAccountGroups, +} from '../../../selectors/multichain-accounts/account-tree'; +import { getNetworksByScopes } from '../../../../shared/modules/selectors/networks'; +import { selectBalanceForAllWallets } from '../../../selectors/assets'; +import { MultichainHoveredAddressRowsList } from './multichain-hovered-address-rows-hovered-list'; + +const mockStore = configureStore([]); +const mockPush = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: mockPush, + }), +})); + +jest.mock('../../../selectors/multichain-accounts/account-tree', () => ({ + ...jest.requireActual('../../../selectors/multichain-accounts/account-tree'), + getInternalAccountListSpreadByScopesByGroupId: jest.fn(), + getAllAccountGroups: jest.fn(), +})); + +jest.mock('../../../../shared/modules/selectors/networks', () => ({ + ...jest.requireActual('../../../../shared/modules/selectors/networks'), + getNetworksByScopes: jest.fn(), +})); + +jest.mock('../../../selectors/assets', () => ({ + ...jest.requireActual('../../../selectors/assets'), + selectBalanceForAllWallets: jest.fn(), +})); + +jest.mock('../../../hooks/useFormatters', () => ({ + useFormatters: () => ({ + formatCurrencyWithMinThreshold: jest.fn( + (value, currency) => `${currency}${value}`, + ), + }), +})); + +jest.mock('../../../hooks/useI18nContext', () => ({ + useI18nContext: () => (key: string) => key, +})); + +jest.mock('../../../../app/scripts/lib/multichain/address', () => ({ + normalizeSafeAddress: jest.fn((address: string) => address), +})); + +const mockHandleCopy = jest.fn(); +jest.mock('../../../hooks/useCopyToClipboard', () => ({ + useCopyToClipboard: () => [false, mockHandleCopy], +})); + +// Test constants +const TEST_STRINGS = { + VIEW_ALL_TEXT: 'multichainAddressViewAll', + EVM_NETWORKS: 'networkNameEthereum', + BITCOIN_NETWORK: 'networkNameBitcoinSegwit', + SOLANA_NETWORK: 'Solana', + TRON_NETWORK: 'Tron', +} as const; + +const TEST_IDS = { + MULTICHAIN_ADDRESS_ROWS_LIST: 'multichain-address-rows-list', + MULTICHAIN_ADDRESS_ROW: 'multichain-address-row', + AVATAR_GROUP: 'avatar-group', +} as const; + +const CSS_CLASSES = { + FONT_BOLD: 'font-bold', + ARROW_RIGHT: 'arrow-right', + AVATAR_NETWORK: 'avatar-network', +} as const; + +const mockWalletEntropySource = '01K437Z7EJ0VCMFDE9TQKRV60A'; +const WALLET_ID_MOCK = toAccountWalletId( + AccountWalletType.Entropy, + mockWalletEntropySource, +); +const GROUP_ID_MOCK = `${WALLET_ID_MOCK}/0` as AccountGroupId; +const SPECIAL_GROUP_ID = `${WALLET_ID_MOCK}/special-0` as AccountGroupId; +const ACCOUNT_EVM_ID_MOCK = + 'entropy:01K437Z7EJ0VCMFDE9TQKRV60A:multichain-account:01K437Z7EJ0VCMFDE9TQKRV60A:eoa:0x1234567890123456789012345678901234567890'; +const ACCOUNT_BITCOIN_ID_MOCK = + 'bitcoin:mainnet:4e445ed5a8c09d4d3be8e7fbf7dc3314'; +const ACCOUNT_SOLANA_ID_MOCK = + 'solana:mainnet:5e445ed5a8c09d4d3be8e7fbf7dc3314'; +const ACCOUNT_TRON_ID_MOCK = 'tron:mainnet:6e445ed5a8c09d4d3be8e7fbf7dc3314'; + +const INTERNAL_ACCOUNTS_MOCK: Record = { + [ACCOUNT_EVM_ID_MOCK]: { + id: ACCOUNT_EVM_ID_MOCK, + address: '0x1234567890123456789012345678901234567890', + metadata: { + name: 'EVM Account', + importTime: Date.now(), + keyring: { type: 'HD Key Tree' }, + }, + options: {}, + methods: [], + type: 'eip155:eoa', + scopes: [ + 'eip155:1', + 'eip155:137', + 'eip155:42161', + 'eip155:11155111', + 'eip155:59144', + ], + }, + [ACCOUNT_BITCOIN_ID_MOCK]: { + id: ACCOUNT_BITCOIN_ID_MOCK, + address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', + metadata: { + name: 'Bitcoin Account', + importTime: Date.now(), + keyring: { type: 'Snap Keyring' }, + }, + options: {}, + methods: [], + type: 'bip122:p2wpkh', + scopes: ['bip122:000000000019d6689c085ae165831e93'], + }, + [ACCOUNT_SOLANA_ID_MOCK]: { + id: ACCOUNT_SOLANA_ID_MOCK, + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + metadata: { + name: 'Solana Account', + importTime: Date.now(), + keyring: { type: 'Snap Keyring' }, + }, + options: {}, + methods: [], + type: 'solana:data-account', + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + }, + [ACCOUNT_TRON_ID_MOCK]: { + id: ACCOUNT_TRON_ID_MOCK, + address: 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9', + metadata: { + name: 'Tron Account', + importTime: Date.now(), + keyring: { type: 'Snap Keyring' }, + }, + options: {}, + methods: [], + type: 'tron:eoa', + scopes: ['tron:0x2b6653dc'], + }, +}; + +const ACCOUNT_TREE_MOCK = { + wallets: { + [WALLET_ID_MOCK]: { + type: 'entropy', + id: WALLET_ID_MOCK, + metadata: {}, + groups: { + [GROUP_ID_MOCK]: { + type: 'multichain-account', + id: GROUP_ID_MOCK, + metadata: {}, + accounts: [ + ACCOUNT_EVM_ID_MOCK, + ACCOUNT_BITCOIN_ID_MOCK, + ACCOUNT_SOLANA_ID_MOCK, + ACCOUNT_TRON_ID_MOCK, + ], + }, + }, + }, + }, +}; + +const createMockState = () => ({ + metamask: { + completedOnboarding: true, + internalAccounts: { + accounts: INTERNAL_ACCOUNTS_MOCK, + selectedAccount: ACCOUNT_EVM_ID_MOCK, + }, + accountTree: ACCOUNT_TREE_MOCK, + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Ethereum', + nativeCurrency: 'ETH', + }, + '0x89': { + chainId: '0x89', + name: 'Polygon', + nativeCurrency: 'MATIC', + }, + '0xa4b1': { + chainId: '0xa4b1', + name: 'Arbitrum', + nativeCurrency: 'ETH', + }, + '0xaa36a7': { + chainId: '0xaa36a7', + name: 'Sepolia', + nativeCurrency: 'ETH', + }, + '0xe708': { + chainId: '0xe708', + name: 'Linea', + nativeCurrency: 'ETH', + }, + }, + multichainNetworkConfigurationsByChainId: { + 'eip155:1': { + chainId: 'eip155:1', + name: 'Ethereum', + isEvm: true, + nativeCurrency: 'ETH', + }, + 'eip155:137': { + chainId: 'eip155:137', + name: 'Polygon', + isEvm: true, + nativeCurrency: 'MATIC', + }, + 'eip155:42161': { + chainId: 'eip155:42161', + name: 'Arbitrum', + isEvm: true, + nativeCurrency: 'ETH', + }, + 'eip155:11155111': { + chainId: 'eip155:11155111', + name: 'Sepolia', + isEvm: true, + nativeCurrency: 'ETH', + }, + 'eip155:59144': { + chainId: 'eip155:59144', + name: 'Linea', + isEvm: true, + nativeCurrency: 'ETH', + }, + 'bip122:000000000019d6689c085ae165831e93': { + chainId: 'bip122:000000000019d6689c085ae165831e93', + name: 'Bitcoin', + isEvm: false, + nativeCurrency: 'BTC', + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + name: 'Solana', + isEvm: false, + nativeCurrency: 'SOL', + }, + 'tron:0x2b6653dc': { + chainId: 'tron:0x2b6653dc', + name: 'Tron', + isEvm: false, + nativeCurrency: 'TRX', + }, + }, + }, +}); + +const createMockAccountGroup = ( + groupId: AccountGroupId, + walletId: string, + name: string, + groupIndex: number, + accounts: string[], +) => ({ + id: groupId, + walletId, + type: AccountGroupType.MultichainAccount, + metadata: { + name, + entropy: { + groupIndex, + }, + pinned: false, + hidden: false, + }, + accounts, +}); + +const createMockBalance = ( + walletId: string, + groupId: AccountGroupId, + totalBalance: number, + currency: string, +) => ({ + totalBalanceInUserCurrency: totalBalance, + userCurrency: currency, + wallets: { + [walletId]: { + walletId, + totalBalanceInUserCurrency: totalBalance, + userCurrency: currency, + groups: { + [groupId]: { + totalBalanceInUserCurrency: totalBalance, + userCurrency: currency, + walletId, + groupId, + }, + }, + }, + }, +}); + +const renderComponent = (groupId: AccountGroupId = GROUP_ID_MOCK) => { + const store = mockStore(createMockState()); + return render( + + +
Hover Me
+
+
, + ); +}; + +const mockedGetInternalAccountListSpreadByScopesByGroupId = + getInternalAccountListSpreadByScopesByGroupId as jest.MockedFunction< + typeof getInternalAccountListSpreadByScopesByGroupId + >; +const mockedGetNetworksByScopes = getNetworksByScopes as jest.MockedFunction< + typeof getNetworksByScopes +>; +const mockedGetAllAccountGroups = getAllAccountGroups as jest.MockedFunction< + typeof getAllAccountGroups +>; +const mockedSelectBalanceForAllWallets = + selectBalanceForAllWallets as jest.MockedFunction< + typeof selectBalanceForAllWallets + >; + +describe('MultichainHoveredAddressRowsList', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockedGetAllAccountGroups.mockReturnValue([ + createMockAccountGroup( + GROUP_ID_MOCK, + WALLET_ID_MOCK, + 'Test Account Group', + 0, + [ACCOUNT_EVM_ID_MOCK], + ), + ] as never); + + mockedSelectBalanceForAllWallets.mockReturnValue( + createMockBalance(WALLET_ID_MOCK, GROUP_ID_MOCK, 100.5, 'USD') as never, + ); + + mockedGetNetworksByScopes.mockImplementation((_, scopes) => { + const networkMap: Record = { + 'bip122:000000000019d6689c085ae165831e93': { + name: 'Bitcoin', + chainId: 'bip122:000000000019d6689c085ae165831e93', + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + name: 'Solana', + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + }, + 'tron:0x2b6653dc': { name: 'Tron', chainId: 'tron:0x2b6653dc' }, + 'eip155:1': { name: 'Ethereum', chainId: 'eip155:1' }, + 'eip155:137': { name: 'Polygon', chainId: 'eip155:137' }, + 'eip155:42161': { name: 'Arbitrum', chainId: 'eip155:42161' }, + 'eip155:59144': { name: 'Linea', chainId: 'eip155:59144' }, + }; + + return (scopes as string[]) + .map((scope) => networkMap[scope]) + .filter(Boolean); + }); + + mockedGetInternalAccountListSpreadByScopesByGroupId.mockReturnValue([ + { + account: INTERNAL_ACCOUNTS_MOCK[ACCOUNT_EVM_ID_MOCK], + scope: 'eip155:1' as CaipChainId, + networkName: 'Ethereum', + }, + { + account: INTERNAL_ACCOUNTS_MOCK[ACCOUNT_EVM_ID_MOCK], + scope: 'eip155:137' as CaipChainId, + networkName: 'Polygon', + }, + { + account: INTERNAL_ACCOUNTS_MOCK[ACCOUNT_EVM_ID_MOCK], + scope: 'eip155:42161' as CaipChainId, + networkName: 'Arbitrum', + }, + { + account: INTERNAL_ACCOUNTS_MOCK[ACCOUNT_EVM_ID_MOCK], + scope: 'eip155:59144' as CaipChainId, + networkName: 'Linea', + }, + { + account: INTERNAL_ACCOUNTS_MOCK[ACCOUNT_BITCOIN_ID_MOCK], + scope: 'bip122:000000000019d6689c085ae165831e93' as CaipChainId, + networkName: 'Bitcoin', + }, + { + account: INTERNAL_ACCOUNTS_MOCK[ACCOUNT_SOLANA_ID_MOCK], + scope: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId, + networkName: 'Solana', + }, + { + account: INTERNAL_ACCOUNTS_MOCK[ACCOUNT_TRON_ID_MOCK], + scope: 'tron:0x2b6653dc' as CaipChainId, + networkName: 'Tron', + }, + ]); + }); + + it('renders the component with aggregated rows', async () => { + renderComponent(); + + const triggerElement = screen.getByTestId('hover-trigger'); + fireEvent.mouseEnter(triggerElement.parentElement as HTMLElement); + await waitFor(() => { + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + }); + + const addressRows = screen.getAllByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROW); + expect(addressRows.length).toBeGreaterThan(0); + }); + + it('groups all eip155 scopes together', async () => { + renderComponent(); + + const triggerElement = screen.getByTestId('hover-trigger'); + fireEvent.mouseEnter(triggerElement.parentElement as HTMLElement); + await waitFor(() => { + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + }); + + const evmRow = screen + .getByText(TEST_STRINGS.EVM_NETWORKS) + .closest(`[data-testid="${TEST_IDS.MULTICHAIN_ADDRESS_ROW}"]`); + expect(evmRow).toBeInTheDocument(); + + const avatarGroup = evmRow?.querySelector( + `[data-testid="${TEST_IDS.AVATAR_GROUP}"]`, + ); + expect(avatarGroup).toBeInTheDocument(); + }); + + it('displays separate rows for non-eip155 accounts', async () => { + renderComponent(); + + const triggerElement = screen.getByTestId('hover-trigger'); + fireEvent.mouseEnter(triggerElement.parentElement as HTMLElement); + await waitFor(() => { + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + }); + + expect(screen.getByText(TEST_STRINGS.BITCOIN_NETWORK)).toBeInTheDocument(); + expect(screen.getByText(TEST_STRINGS.SOLANA_NETWORK)).toBeInTheDocument(); + expect(screen.getByText(TEST_STRINGS.TRON_NETWORK)).toBeInTheDocument(); + }); + + it('applies priority sorting with grouped eip155 first', async () => { + renderComponent(); + + const triggerElement = screen.getByTestId('hover-trigger'); + fireEvent.mouseEnter(triggerElement.parentElement as HTMLElement); + await waitFor(() => { + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + }); + + const addressRows = screen.getAllByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROW); + const rowTexts = addressRows.map((row) => row.textContent); + + expect(rowTexts[0]).toContain(TEST_STRINGS.EVM_NETWORKS); + expect(rowTexts[1]).toContain(TEST_STRINGS.BITCOIN_NETWORK); + expect(rowTexts[2]).toContain(TEST_STRINGS.SOLANA_NETWORK); + expect(rowTexts[3]).toContain(TEST_STRINGS.TRON_NETWORK); + }); + + it('handles copy functionality for aggregated rows', async () => { + renderComponent(); + + const triggerElement = screen.getByTestId('hover-trigger'); + fireEvent.mouseEnter(triggerElement.parentElement as HTMLElement); + await waitFor(() => { + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + }); + + const evmRow = screen + .getByText(TEST_STRINGS.EVM_NETWORKS) + .closest(`[data-testid="${TEST_IDS.MULTICHAIN_ADDRESS_ROW}"]`); + const copyButton = evmRow?.querySelector('[aria-label*="copy"]'); + + expect(copyButton).toBeInTheDocument(); + + if (copyButton) { + fireEvent.click(copyButton); + expect(mockHandleCopy).toHaveBeenCalledWith( + '0x1234567890123456789012345678901234567890', + ); + } + }); + + it('displays truncated addresses', async () => { + renderComponent(); + + const triggerElement = screen.getByTestId('hover-trigger'); + fireEvent.mouseEnter(triggerElement.parentElement as HTMLElement); + await waitFor(() => { + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + }); + + const addressElements = screen.getAllByText(/0x\w+\.\.\.\w+/u); + expect(addressElements.length).toBeGreaterThan(0); + }); + + it('handles invalid group id gracefully', async () => { + mockedGetInternalAccountListSpreadByScopesByGroupId.mockReturnValue([]); + renderComponent('invalid-group-id' as AccountGroupId); + + const triggerElement = screen.getByTestId('hover-trigger'); + fireEvent.mouseEnter(triggerElement.parentElement as HTMLElement); + await waitFor(() => { + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + }); + + expect( + screen.queryAllByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROW), + ).toHaveLength(0); + }); + + it('groups eip155 scopes together for each account', async () => { + renderComponent(); + + const triggerElement = screen.getByTestId('hover-trigger'); + fireEvent.mouseEnter(triggerElement.parentElement as HTMLElement); + await waitFor(() => { + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + }); + + const evmRow = screen + .getByText(TEST_STRINGS.EVM_NETWORKS) + .closest(`[data-testid="${TEST_IDS.MULTICHAIN_ADDRESS_ROW}"]`); + expect(evmRow).toBeInTheDocument(); + + const avatarGroup = evmRow?.querySelector( + `[data-testid="${TEST_IDS.AVATAR_GROUP}"]`, + ); + expect(avatarGroup).toBeInTheDocument(); + + const avatars = avatarGroup?.querySelectorAll( + `[class*="${CSS_CLASSES.AVATAR_NETWORK}"]`, + ); + expect(avatars?.length).toBeGreaterThan(1); + + expect(screen.getByText(TEST_STRINGS.BITCOIN_NETWORK)).toBeInTheDocument(); + expect(screen.getByText(TEST_STRINGS.SOLANA_NETWORK)).toBeInTheDocument(); + expect(screen.getByText(TEST_STRINGS.TRON_NETWORK)).toBeInTheDocument(); + }); + + it('respects priority order when multiple accounts have priority chains', async () => { + renderComponent(); + + const triggerElement = screen.getByTestId('hover-trigger'); + fireEvent.mouseEnter(triggerElement.parentElement as HTMLElement); + await waitFor(() => { + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + }); + + const addressRows = screen.getAllByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROW); + + expect(addressRows.length).toBe(4); + + const groupNames = addressRows.map((row) => { + const nameElement = row.querySelector( + `p[class*="${CSS_CLASSES.FONT_BOLD}"]`, + ); + return nameElement?.textContent || ''; + }); + + expect(groupNames).toEqual([ + TEST_STRINGS.EVM_NETWORKS, + TEST_STRINGS.BITCOIN_NETWORK, + TEST_STRINGS.SOLANA_NETWORK, + TEST_STRINGS.TRON_NETWORK, + ]); + }); + + describe('Copy Functionality', () => { + beforeEach(() => { + mockHandleCopy.mockClear(); + }); + + it('copies address when clicking copy button', async () => { + renderComponent(); + + const triggerElement = screen.getByTestId('hover-trigger'); + fireEvent.mouseEnter(triggerElement.parentElement as HTMLElement); + await waitFor(() => { + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + }); + + const evmRow = screen + .getByText(TEST_STRINGS.EVM_NETWORKS) + .closest(`[data-testid="${TEST_IDS.MULTICHAIN_ADDRESS_ROW}"]`); + const copyButton = evmRow?.querySelector( + '[aria-label*="copy"]', + ) as HTMLElement; + + fireEvent.click(copyButton); + + expect(mockHandleCopy).toHaveBeenCalledTimes(1); + expect(mockHandleCopy).toHaveBeenCalledWith( + '0x1234567890123456789012345678901234567890', + ); + }); + + it('copies address when clicking on the row (not button)', async () => { + renderComponent(); + + const triggerElement = screen.getByTestId('hover-trigger'); + fireEvent.mouseEnter(triggerElement.parentElement as HTMLElement); + await waitFor(() => { + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + }); + + const networkNameElement = screen.getByText(TEST_STRINGS.EVM_NETWORKS); + + fireEvent.click(networkNameElement); + + expect(mockHandleCopy).toHaveBeenCalledTimes(1); + expect(mockHandleCopy).toHaveBeenCalledWith( + '0x1234567890123456789012345678901234567890', + ); + }); + }); + + describe('View All Button', () => { + it('renders the View All button', async () => { + renderComponent(); + + const triggerElement = screen.getByTestId('hover-trigger'); + fireEvent.mouseEnter(triggerElement.parentElement as HTMLElement); + await waitFor(() => { + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + }); + + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBeGreaterThan(0); + const viewAllButton = buttons[buttons.length - 1]; + expect(viewAllButton).toBeInTheDocument(); + }); + + it('navigates to the correct route when clicked', async () => { + renderComponent(); + + const triggerElement = screen.getByTestId('hover-trigger'); + fireEvent.mouseEnter(triggerElement.parentElement as HTMLElement); + await waitFor(() => { + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + }); + + const buttons = screen.getAllByRole('button'); + const viewAllButton = buttons[buttons.length - 1]; + + fireEvent.click(viewAllButton); + + expect(mockPush).toHaveBeenCalledWith( + `${MULTICHAIN_ACCOUNT_ADDRESS_LIST_PAGE_ROUTE}/${encodeURIComponent(GROUP_ID_MOCK)}`, + ); + }); + + it('navigates with properly encoded group ID', async () => { + mockedGetAllAccountGroups.mockReturnValue([ + createMockAccountGroup( + SPECIAL_GROUP_ID, + WALLET_ID_MOCK, + 'Special Group', + 1, + [ACCOUNT_EVM_ID_MOCK], + ), + ] as never); + + mockedSelectBalanceForAllWallets.mockReturnValue( + createMockBalance( + WALLET_ID_MOCK, + SPECIAL_GROUP_ID, + 200, + 'USD', + ) as never, + ); + + renderComponent(SPECIAL_GROUP_ID); + + const triggerElement = screen.getByTestId('hover-trigger'); + fireEvent.mouseEnter(triggerElement.parentElement as HTMLElement); + await waitFor(() => { + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + }); + + const buttons = screen.getAllByRole('button'); + const viewAllButton = buttons[buttons.length - 1]; + + fireEvent.click(viewAllButton); + + expect(mockPush).toHaveBeenCalledWith( + `${MULTICHAIN_ACCOUNT_ADDRESS_LIST_PAGE_ROUTE}/${encodeURIComponent(SPECIAL_GROUP_ID)}`, + ); + }); + }); + + describe('Hover Functionality', () => { + it('renders children element correctly', () => { + renderComponent(); + + expect(screen.getByTestId('hover-trigger')).toBeInTheDocument(); + expect(screen.getByText('Hover Me')).toBeInTheDocument(); + }); + + it('shows address list on hover', async () => { + renderComponent(); + + const triggerElement = screen.getByTestId('hover-trigger'); + + // Initially, the address list should not be visible + expect( + screen.queryByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).not.toBeInTheDocument(); + + // Hover over the trigger element + fireEvent.mouseEnter(triggerElement.parentElement as HTMLElement); + + // Wait for the popover to appear + await waitFor(() => { + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + }); + + // Verify content is displayed + expect(screen.getByText(TEST_STRINGS.EVM_NETWORKS)).toBeInTheDocument(); + expect( + screen.getByText(TEST_STRINGS.BITCOIN_NETWORK), + ).toBeInTheDocument(); + }); + + it('hides address list on mouse leave with delay', async () => { + jest.useFakeTimers(); + renderComponent(); + + const triggerElement = screen.getByTestId('hover-trigger'); + + // Show the popover + fireEvent.mouseEnter(triggerElement.parentElement as HTMLElement); + + await waitFor(() => { + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + }); + + // Leave hover + fireEvent.mouseLeave(triggerElement.parentElement as HTMLElement); + + // Popover should still be visible immediately after mouse leave + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + + // Fast forward timers to trigger the hide + await act(async () => { + jest.advanceTimersByTime(300); + }); + + await waitFor(() => { + expect( + screen.queryByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).not.toBeInTheDocument(); + }); + + jest.useRealTimers(); + }); + + it('keeps popover open when hovering over it', async () => { + jest.useFakeTimers(); + renderComponent(); + + const triggerElement = screen.getByTestId('hover-trigger'); + + // Show the popover + fireEvent.mouseEnter(triggerElement.parentElement as HTMLElement); + + await waitFor(() => { + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + }); + + // Leave trigger element + fireEvent.mouseLeave(triggerElement.parentElement as HTMLElement); + + // Immediately hover over the popover + const popoverContent = screen.getByTestId( + TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST, + ); + fireEvent.mouseEnter(popoverContent); + + // Fast forward timers + await act(async () => { + jest.advanceTimersByTime(300); + }); + + // Popover should still be visible + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + + jest.useRealTimers(); + }); + + it('applies hover styles to trigger element', async () => { + jest.useFakeTimers(); + renderComponent(); + + const triggerElement = screen.getByTestId('hover-trigger'); + const containerElement = triggerElement.parentElement as HTMLElement; + + // Initially the popover should not be visible + expect( + screen.queryByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).not.toBeInTheDocument(); + + // Hover over the trigger + fireEvent.mouseEnter(containerElement); + + // Wait for popover to appear, which indicates hover state is active + await waitFor(() => { + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + }); + + // The component should have the hover state active + // Since we can't directly check the inline styles in test environment, + // we verify the behavior by checking that the popover is visible + + // Leave hover + fireEvent.mouseLeave(containerElement); + + // Popover should still be visible immediately after mouse leave + expect( + screen.getByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).toBeInTheDocument(); + + // Fast forward time to trigger the hide + await act(async () => { + jest.advanceTimersByTime(300); + }); + + // Popover should be hidden after delay + expect( + screen.queryByTestId(TEST_IDS.MULTICHAIN_ADDRESS_ROWS_LIST), + ).not.toBeInTheDocument(); + + jest.useRealTimers(); + }); + }); +}); diff --git a/ui/components/multichain-accounts/multichain-address-rows-hovered-list/multichain-hovered-address-rows-hovered-list.tsx b/ui/components/multichain-accounts/multichain-address-rows-hovered-list/multichain-hovered-address-rows-hovered-list.tsx new file mode 100644 index 000000000000..79faba2e23d8 --- /dev/null +++ b/ui/components/multichain-accounts/multichain-address-rows-hovered-list/multichain-hovered-address-rows-hovered-list.tsx @@ -0,0 +1,345 @@ +import React, { + useMemo, + useCallback, + useRef, + useState, + useEffect, +} from 'react'; +import { useSelector } from 'react-redux'; +import { type AccountGroupId } from '@metamask/account-api'; +import { CaipChainId } from '@metamask/utils'; +import { InternalAccount } from '@metamask/keyring-internal-api'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + Button, + ButtonVariant, + FontWeight, + Icon, + IconName, + IconSize, + Text, + TextVariant, +} from '@metamask/design-system-react'; +import { useHistory } from 'react-router-dom'; +import { BackgroundColor } from '../../../helpers/constants/design-system'; +import { Popover, PopoverPosition } from '../../component-library'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard'; +import { + getAllAccountGroups, + getInternalAccountListSpreadByScopesByGroupId, +} from '../../../selectors/multichain-accounts/account-tree'; +import { MULTICHAIN_ACCOUNT_ADDRESS_LIST_PAGE_ROUTE } from '../../../helpers/constants/routes'; +import { selectBalanceForAllWallets } from '../../../selectors/assets'; +import { useFormatters } from '../../../hooks/useFormatters'; +import { MultichainAggregatedAddressListRow } from './multichain-aggregated-list-row'; + +// Priority networks that should appear first (using CAIP chain IDs) +const PRIORITY_CHAIN_IDS = new Map([ + ['eip155:1' as CaipChainId, 0], // Ethereum mainnet + ['bip122:000000000019d6689c085ae165831e93' as CaipChainId, 1], // Bitcoin mainnet + ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId, 2], // Solana mainnet + ['tron:0x2b6653dc' as CaipChainId, 3], // Tron mainnet +]); + +const MAX_NETWORK_AVATARS = 4; + +export type MultichainAddressRowsListProps = { + /** + * The account group ID. + */ + groupId: AccountGroupId; + /** + * The child element that triggers the popover on hover. + */ + children: React.ReactNode; + /** + * Whether to show the account header and balance. + */ + showAccountHeaderAndBalance?: boolean; + /** + * The delay of the hover. + */ + hoverCloseDelay?: number; +}; + +export const MultichainHoveredAddressRowsList = ({ + groupId, + children, + showAccountHeaderAndBalance = true, + hoverCloseDelay = 100, +}: MultichainAddressRowsListProps) => { + const t = useI18nContext(); + const [, handleCopy] = useCopyToClipboard(); + const history = useHistory(); + const [isHoverOpen, setIsHoverOpen] = useState(false); + const [referenceElement, setReferenceElement] = useState( + null, + ); + const [dynamicPosition, setDynamicPosition] = useState( + PopoverPosition.BottomStart, + ); + const hoverTimeoutRef = useRef(null); + const allAccountGroups = useSelector(getAllAccountGroups); + + const allBalances = useSelector(selectBalanceForAllWallets); + const { balance, currency, accountGroup } = useMemo(() => { + const group = allAccountGroups.find((g) => g.id === groupId); + const account = allBalances?.wallets?.[group?.walletId]?.groups?.[groupId]; + const bal = account?.totalBalanceInUserCurrency ?? 0; + const curr = account?.userCurrency ?? ''; + return { balance: bal, currency: curr, accountGroup: group }; + }, [allBalances, groupId, allAccountGroups]); + + const { formatCurrencyWithMinThreshold } = useFormatters(); + + const getAccountsSpreadByNetworkByGroupId = useSelector((state) => + getInternalAccountListSpreadByScopesByGroupId(state, groupId), + ); + + // Calculate whether popover should show above or below + const calculatePopoverPosition = useCallback(() => { + if (!referenceElement) { + return PopoverPosition.BottomStart; + } + + const rect = referenceElement.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const popoverEstimatedHeight = 400; // Based on the maxHeight set on the popover + const spaceBelow = viewportHeight - rect.bottom; + + // If there's not enough space below, use TopStart + if (spaceBelow < popoverEstimatedHeight) { + return PopoverPosition.TopStart; + } + + // Default to BottomStart + return PopoverPosition.BottomStart; + }, [referenceElement]); + + const handleMouseEnter = useCallback(() => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + setDynamicPosition(calculatePopoverPosition()); + setIsHoverOpen(true); + }, [calculatePopoverPosition]); + + const handleMouseLeave = useCallback(() => { + hoverTimeoutRef.current = setTimeout(() => { + setIsHoverOpen(false); + }, hoverCloseDelay); + }, [hoverCloseDelay]); + + useEffect(() => { + return () => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } + }; + }, []); + + const sortByPriorityNetworks = useCallback( + (items: typeof getAccountsSpreadByNetworkByGroupId) => { + const accountGroups = items.reduce( + (groups, item) => { + const accountKey = item.account.address; + if (!groups[accountKey]) { + groups[accountKey] = { + account: item.account, + scopes: [], + }; + } + groups[accountKey].scopes.push(item.scope); + return groups; + }, + {} as Record< + string, + { account: InternalAccount; scopes: CaipChainId[] } + >, + ); + + // Create items: one for grouped eip155 scopes (if any) and one for each other scope + const groupedItems: { + scopes: CaipChainId[]; + account: InternalAccount; + }[] = []; + + // Transform grouped data and separate eip155 scopes + Object.values(accountGroups).forEach(({ account, scopes }) => { + // Separate eip155 scopes from others + const eip155Scopes = scopes.filter((scope) => + scope.startsWith('eip155:'), + ); + const otherScopes = scopes.filter( + (scope) => !scope.startsWith('eip155:'), + ); + + if (eip155Scopes.length > 0) { + groupedItems.push({ + scopes: eip155Scopes, + account, + }); + } + + otherScopes.forEach((scope) => { + groupedItems.push({ + scopes: [scope], + account, + }); + }); + }); + + const priorityItems: { + scopes: CaipChainId[]; + account: InternalAccount; + }[] = []; + const otherItems: typeof priorityItems = []; + + groupedItems.forEach((item) => { + // Check if any of the scopes are in priority list + let priorityIndex = -1; + + // Check each scope for priority chain membership + for (const scope of item.scopes) { + const index = PRIORITY_CHAIN_IDS.get(scope); + if (index !== undefined) { + priorityIndex = index; + break; + } + } + + if (priorityIndex > -1) { + // Store with priority index for proper ordering + if (priorityItems[priorityIndex] === undefined) { + priorityItems[priorityIndex] = item; + } else { + // If slot is already taken, add to other items + otherItems.push(item); + } + } else { + otherItems.push(item); + } + }); + // Filter out undefined entries and maintain priority order + return [...priorityItems.filter(Boolean), ...otherItems]; + }, + [], + ); + + const renderAddressItem = useCallback( + ( + item: { + scopes: CaipChainId[]; + account: InternalAccount; + }, + index: number, + ): React.JSX.Element => { + const handleCopyClick = () => { + handleCopy(item.account.address); + }; + + return ( + + ); + }, + [handleCopy, t], + ); + + const handleViewAllClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + history.push( + `${MULTICHAIN_ACCOUNT_ADDRESS_LIST_PAGE_ROUTE}/${encodeURIComponent(groupId)}`, + ); + }, + [groupId, history], + ); + + const renderedRows = useMemo(() => { + const rows = sortByPriorityNetworks(getAccountsSpreadByNetworkByGroupId); + return rows.map((item, index) => renderAddressItem(item, index)); + }, [ + getAccountsSpreadByNetworkByGroupId, + renderAddressItem, + sortByPriorityNetworks, + ]); + + return ( + <> + + {children} + + + + {showAccountHeaderAndBalance && ( + + + {accountGroup?.metadata.name} + + + {formatCurrencyWithMinThreshold(balance, currency)} + + + )} + {renderedRows} + + + + + ); +}; + +export default MultichainHoveredAddressRowsList; diff --git a/ui/components/multichain/app-header/app-header-unlocked-content.tsx b/ui/components/multichain/app-header/app-header-unlocked-content.tsx index e79d1fff91a4..824696bb15e6 100644 --- a/ui/components/multichain/app-header/app-header-unlocked-content.tsx +++ b/ui/components/multichain/app-header/app-header-unlocked-content.tsx @@ -10,13 +10,6 @@ import browser from 'webextension-polyfill'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; -import { Link } from 'react-router-dom-v5-compat'; -import { - Icon, - IconName as IconNameDesignSystem, - IconSize as IconSizeDesignSystem, - IconColor as IconColorDesignSystem, -} from '@metamask/design-system-react'; import { AlignItems, BackgroundColor, @@ -39,6 +32,7 @@ import { IconSize, Text, } from '../../component-library'; +import { MultichainHoveredAddressRowsList } from '../../multichain-accounts/multichain-address-rows-hovered-list'; import { MetaMetricsEventName, MetaMetricsEventCategory, @@ -73,7 +67,6 @@ import { NotificationsTagCounter } from '../notifications-tag-counter'; import { ACCOUNT_LIST_PAGE_ROUTE, REVIEW_PERMISSIONS, - MULTICHAIN_ACCOUNT_ADDRESS_LIST_PAGE_ROUTE, } from '../../../helpers/constants/routes'; import VisitSupportDataConsentModal from '../../app/modals/visit-support-data-consent-modal'; import { @@ -85,8 +78,8 @@ import { AccountIconTour } from '../../app/account-icon-tour/account-icon-tour'; import { getMultichainAccountGroupById, getSelectedAccountGroup, - getNetworkAddressCount, } from '../../../selectors/multichain-accounts/account-tree'; +import { MultichainAccountNetworkGroup } from '../../multichain-accounts/multichain-account-network-group'; type AppHeaderUnlockedContentProps = { disableAccountPicker: boolean; @@ -111,9 +104,6 @@ export const AppHeaderUnlockedContent = ({ const selectedMultichainAccount = useSelector((state) => getMultichainAccountGroupById(state, selectedMultichainAccountId), ); - const numberOfAccountsInGroup = useSelector((state) => - getNetworkAddressCount(state, selectedMultichainAccountId), - ); // Used for account picker const internalAccount = useSelector(getSelectedInternalAccount); @@ -221,11 +211,6 @@ export const AppHeaderUnlockedContent = ({ ); const multichainAccountAppContent = useMemo(() => { - const networksLabel = - numberOfAccountsInGroup === 1 - ? t('networkAddress') - : t('networkAddresses', [numberOfAccountsInGroup]); - return ( {/* Prevent overflow of account picker by long account names */} @@ -257,31 +242,23 @@ export const AppHeaderUnlockedContent = ({ <>{!isMultichainAccountsState2Enabled && CopyButton} {selectedMultichainAccountId && ( - - - - {networksLabel} - - - - + + )} ); @@ -292,8 +269,6 @@ export const AppHeaderUnlockedContent = ({ selectedMultichainAccountId, history, isMultichainAccountsState2Enabled, - numberOfAccountsInGroup, - t, trackEvent, ]); diff --git a/ui/components/multichain/app-header/index.scss b/ui/components/multichain/app-header/index.scss index 699ecc878410..43f320c96579 100644 --- a/ui/components/multichain/app-header/index.scss +++ b/ui/components/multichain/app-header/index.scss @@ -115,3 +115,12 @@ .networks-subtitle:hover { background-color: var(--color-background-default-hover); } + +.networks-label-text { + text-decoration: none; +} + +.networks-label-text:hover { + text-decoration: underline; + text-decoration-style: dotted; +}