From 58f4e13ace3026c7cd99563919155375dcaee126 Mon Sep 17 00:00:00 2001 From: vinnyhoward Date: Wed, 15 Jul 2026 16:13:34 -0600 Subject: [PATCH] fix(activity): show account name and avatar for owned accounts in activity list The redesigned activity list row subtitle rendered the send/receive counterparty as a short hex address only. Sends between the user's own accounts now resolve the counterparty against the account tree and render "To: " (and "From: ..." on receives), matching the name resolution the transaction details screen already does. - useActivityListItemRowContent resolves the counterparty via useAccountNames and returns a structured subtitleAccount - useSubtitleAccountParts builds the AvatarAccount subtitle node shared by the resolved and pending row variants (queued prefix preserved) - Unowned addresses keep the existing short-address subtitle --- .../ActivityListItemRow.styles.ts | 9 ++ .../ActivityListItemRow.test.tsx | 93 +++++++++++++++++++ .../ActivityListItemRow.tsx | 7 ++ .../ActivityListItemRow.types.ts | 1 + .../ActivityListItemRowLayout.tsx | 27 +++++- .../PendingActivityListItemRow.tsx | 15 +++ .../useActivityListItemRowContent.ts | 41 +++++++- .../useSubtitleAccountParts.tsx | 47 ++++++++++ 8 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 app/components/UI/ActivityListItemRow/useSubtitleAccountParts.tsx diff --git a/app/components/UI/ActivityListItemRow/ActivityListItemRow.styles.ts b/app/components/UI/ActivityListItemRow/ActivityListItemRow.styles.ts index f577ff61e32..93583cecf92 100644 --- a/app/components/UI/ActivityListItemRow/ActivityListItemRow.styles.ts +++ b/app/components/UI/ActivityListItemRow/ActivityListItemRow.styles.ts @@ -130,6 +130,15 @@ export const createStyles = ( alignItems: 'center', marginTop: 0, }, + subtitleRowSpaced: { + marginTop: 4, + }, + subtitleAccountAvatar: { + marginRight: 4, + }, + subtitleAccountName: { + flexShrink: 1, + } as TextStyle, subtitleLeadingIcon: { height: 16, justifyContent: 'center', diff --git a/app/components/UI/ActivityListItemRow/ActivityListItemRow.test.tsx b/app/components/UI/ActivityListItemRow/ActivityListItemRow.test.tsx index f7c470f08bb..1956ce058ed 100644 --- a/app/components/UI/ActivityListItemRow/ActivityListItemRow.test.tsx +++ b/app/components/UI/ActivityListItemRow/ActivityListItemRow.test.tsx @@ -26,6 +26,7 @@ import { useTokensData } from '../../hooks/useTokensData/useTokensData'; const LINEA_MUSD_ADDRESS = '0xaca92e438df0b2401ff60da7e4337b687a2435da'; const LINEA_MUSD_CHECKSUM_ADDRESS = '0xacA92E438df0B2401fF60dA7E4337B687a2435DA'; +const OWNED_ACCOUNT_ADDRESS = '0xAa60919dd0d0964B76620dAaF08bF357e1c9DD73'; const mockState = { user: { @@ -61,6 +62,35 @@ const mockState = { }, }, }, + AccountsController: { + internalAccounts: { + selectedAccount: 'owned-account-1', + accounts: { + 'owned-account-1': { + id: 'owned-account-1', + address: OWNED_ACCOUNT_ADDRESS, + metadata: { name: 'Account 2' }, + }, + }, + }, + }, + AccountTreeController: { + accountTree: { + wallets: { + 'entropy:wallet-1': { + id: 'entropy:wallet-1', + metadata: { name: 'Wallet 1' }, + groups: { + 'entropy:wallet-1/1': { + id: 'entropy:wallet-1/1', + metadata: { name: 'ETH DeFi' }, + accounts: ['owned-account-1'], + }, + }, + }, + }, + }, + }, }, }, }; @@ -222,6 +252,11 @@ jest.mock('@metamask/design-system-react-native', () => { severity, }); + const AvatarAccount = ({ address }: { address?: string }) => + ReactActual.createElement(View, { + testID: `avatar-account-${address ?? 'unknown'}`, + }); + const BadgeNetwork = ({ src }: { src?: unknown }) => ReactActual.createElement(View, { src }); @@ -249,6 +284,13 @@ jest.mock('@metamask/design-system-react-native', () => { AvatarIcon, AvatarIconSeverity: { Neutral: 'neutral', Danger: 'danger' }, AvatarIconSize: { Xs: 'xs', Sm: 'sm', Md: 'md', Lg: 'lg', Xl: 'xl' }, + AvatarAccount, + AvatarAccountVariant: { + Jazzicon: 'Jazzicon', + Blockies: 'Blockies', + Maskicon: 'Maskicon', + }, + AvatarBaseSize: { Xs: 'xs', Sm: 'sm', Md: 'md', Lg: 'lg', Xl: 'xl' }, BadgeNetwork, BadgeWrapper, BadgeWrapperPosition: { BottomRight: 'BottomRight' }, @@ -455,6 +497,57 @@ describe('ActivityListItemRow — row content', () => { expect(getByTestId('avatar-token-USDC')).toBeOnTheScreen(); }); + it('renders the account name in the subtitle when sending to an owned account', () => { + const item = makeItem({ + type: 'send', + status: 'success', + // Lowercased on purpose: the owned-account match is case-insensitive. + to: OWNED_ACCOUNT_ADDRESS.toLowerCase(), + token: { + amount: '10', + symbol: 'USDC', + direction: 'out', + }, + }); + const { getByTestId } = render( + , + ); + + expect(getByTestId('activity-subtitle-0xabc').props.children).toBe('To: '); + expect( + getByTestId('activity-subtitle-account-name-0xabc').props.children, + ).toBe('ETH DeFi'); + expect( + getByTestId('activity-subtitle-account-avatar-0xabc'), + ).toBeOnTheScreen(); + }); + + it('renders the account name in the subtitle when receiving from an owned account', () => { + const item = makeItem({ + type: 'receive', + status: 'success', + from: OWNED_ACCOUNT_ADDRESS, + token: { + amount: '10', + symbol: 'USDC', + direction: 'in', + }, + }); + const { getByTestId } = render( + , + ); + + expect(getByTestId('activity-subtitle-0xabc').props.children).toBe( + 'From: ', + ); + expect( + getByTestId('activity-subtitle-account-name-0xabc').props.children, + ).toBe('ETH DeFi'); + expect( + getByTestId('activity-subtitle-account-avatar-0xabc'), + ).toBeOnTheScreen(); + }); + it('shows "Send cancelled" and hides the amount for a cancelled send', () => { const item = makeItem({ type: 'send', diff --git a/app/components/UI/ActivityListItemRow/ActivityListItemRow.tsx b/app/components/UI/ActivityListItemRow/ActivityListItemRow.tsx index 58688302ec2..4306408be8b 100644 --- a/app/components/UI/ActivityListItemRow/ActivityListItemRow.tsx +++ b/app/components/UI/ActivityListItemRow/ActivityListItemRow.tsx @@ -12,6 +12,7 @@ import { PendingActivityListItemRow } from './PendingActivityListItemRow'; import { resolveTransactionIconName } from './resolveIconType'; import { useActivityListItemRowContent } from './useActivityListItemRowContent'; import { useNftActivityImage } from './useNftActivityImage'; +import { useSubtitleAccountParts } from './useSubtitleAccountParts'; import type { ActivityListItemRowProps } from './ActivityListItemRow.types'; import { isPerpsOrderKind, @@ -68,6 +69,11 @@ function ResolvedActivityListItemRow({ const nftImageUrl = useNftActivityImage(item); const styles = createStyles(colors, typography); + const subtitleParts = useSubtitleAccountParts( + content, + styles, + item.hash ?? index, + ); const isFailed = item.status === 'failed'; const fallbackIconName = resolveTransactionIconName(item.type); const networkImageSource = isSingleNetworkDomainKind(item.type) @@ -105,6 +111,7 @@ function ResolvedActivityListItemRow({ secondaryAmount={content.secondaryAmount} styles={styles} subtitle={content.subtitle} + subtitleParts={subtitleParts} title={titleOverride ?? content.title} /> ); diff --git a/app/components/UI/ActivityListItemRow/ActivityListItemRow.types.ts b/app/components/UI/ActivityListItemRow/ActivityListItemRow.types.ts index 41300cbf062..f1d6b1b094e 100644 --- a/app/components/UI/ActivityListItemRow/ActivityListItemRow.types.ts +++ b/app/components/UI/ActivityListItemRow/ActivityListItemRow.types.ts @@ -36,6 +36,7 @@ export interface ActivityListItemRowProps export interface ActivityListItemRowContent { title: string; subtitle?: string; + subtitleAccount?: { prefix: string; address: string; name: string }; primaryToken?: TokenAmount; secondaryToken?: TokenAmount; primaryAmount?: string; diff --git a/app/components/UI/ActivityListItemRow/ActivityListItemRowLayout.tsx b/app/components/UI/ActivityListItemRow/ActivityListItemRowLayout.tsx index cd9b3205b69..99bb588f3cc 100644 --- a/app/components/UI/ActivityListItemRow/ActivityListItemRowLayout.tsx +++ b/app/components/UI/ActivityListItemRow/ActivityListItemRowLayout.tsx @@ -23,6 +23,7 @@ export function ActivityListItemRowLayout({ styles, subtitle, subtitleLeadingAccessory, + subtitleParts, title, titleAccessory, }: { @@ -41,6 +42,7 @@ export function ActivityListItemRowLayout({ styles: ActivityListItemRowStyles; subtitle?: string; subtitleLeadingAccessory?: React.ReactNode; + subtitleParts?: { pre: string; avatar: React.ReactNode; name: string }; title: string; titleAccessory?: React.ReactNode; }) { @@ -68,7 +70,30 @@ export function ActivityListItemRowLayout({ ) : ( titleText ); - const subtitleNode = subtitle ? ( + const subtitleNode = subtitleParts ? ( + + {subtitleLeadingAccessory} + + {subtitleParts.pre} + + {subtitleParts.avatar} + + {subtitleParts.name} + + + ) : subtitle ? ( subtitleLeadingAccessory ? ( {subtitleLeadingAccessory} diff --git a/app/components/UI/ActivityListItemRow/PendingActivityListItemRow.tsx b/app/components/UI/ActivityListItemRow/PendingActivityListItemRow.tsx index 2de76e34c12..4f5502a95e1 100644 --- a/app/components/UI/ActivityListItemRow/PendingActivityListItemRow.tsx +++ b/app/components/UI/ActivityListItemRow/PendingActivityListItemRow.tsx @@ -20,6 +20,7 @@ import { ActivityListItemRowIcon } from './ActivityListItemRowIcon'; import { ActivityListItemRowLayout } from './ActivityListItemRowLayout'; import { resolveTransactionIconName } from './resolveIconType'; import { useActivityListItemRowContent } from './useActivityListItemRowContent'; +import { useSubtitleAccountParts } from './useSubtitleAccountParts'; import type { ActivityListItemRowProps } from './ActivityListItemRow.types'; export function PendingActivityListItemRow({ @@ -53,6 +54,19 @@ export function PendingActivityListItemRow({ : content.subtitle : undefined; + const subtitleAccountParts = useSubtitleAccountParts( + content, + styles, + testIdSuffix, + ); + const subtitleParts = + subtitleAccountParts && isQueued + ? { + ...subtitleAccountParts, + pre: `${strings('transaction.queued')} • ${subtitleAccountParts.pre}`, + } + : subtitleAccountParts; + const titleAccessory = isQueued ? undefined : ( @@ -89,6 +103,7 @@ export function PendingActivityListItemRow({ styles={styles} subtitle={subtitle} subtitleLeadingAccessory={subtitleLeadingAccessory} + subtitleParts={subtitleParts} title={titleOverride ?? content.title} titleAccessory={titleAccessory} /> diff --git a/app/components/UI/ActivityListItemRow/useActivityListItemRowContent.ts b/app/components/UI/ActivityListItemRow/useActivityListItemRowContent.ts index d9c25c3b6bf..700a0d70eb7 100644 --- a/app/components/UI/ActivityListItemRow/useActivityListItemRowContent.ts +++ b/app/components/UI/ActivityListItemRow/useActivityListItemRowContent.ts @@ -44,6 +44,8 @@ import { } from '../../../util/number/bigint'; // eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog import { getPerpsDisplaySymbol } from '@metamask/perps-controller'; +import { useAccountNames } from '../../hooks/DisplayName/useAccountNames'; +import { NameType } from '../Name/Name.types'; import type { ActivityListItemRowContent } from './ActivityListItemRow.types'; import { ACTIVITY_FALLBACK_TITLE_RESOLVERS, @@ -466,6 +468,7 @@ function isNamelessNftToken(token: TokenAmount | undefined): boolean { function resolveCoreContent( item: ActivityListItem, bridgeHistoryItem?: BridgeHistoryItem, + counterpartyName?: string, ): Omit< ActivityListItemRowContent, 'avatarTokens' | 'primaryAmount' | 'secondaryAmount' @@ -483,6 +486,10 @@ function resolveCoreContent( const cancelledLabel = item.type === 'receive' ? 'Receive cancelled' : 'Send cancelled'; const subtitlePrefix = item.type === 'receive' ? 'From' : 'To'; + const counterpartyLabel = + counterpartyName || + shortAddress(address) || + strings('transactions.unavailable'); return { title: statusTitle(item, { @@ -491,7 +498,16 @@ function resolveCoreContent( failed: failedLabel, cancelled: cancelledLabel, }), - subtitle: `${subtitlePrefix}: ${shortAddress(address) ?? strings('transactions.unavailable')}`, + subtitle: `${subtitlePrefix}: ${counterpartyLabel}`, + ...(counterpartyName && address + ? { + subtitleAccount: { + prefix: subtitlePrefix, + address, + name: counterpartyName, + }, + } + : {}), primaryToken: token, }; } @@ -1154,7 +1170,28 @@ export function useActivityListItemRowContent( } const lendingTokenData = useTokensData(lendingAssetIds); - const content = resolveCoreContent(item, bridgeHistoryItem); + // The send/receive subtitle names the counterparty. Resolve it against the + // user's own accounts so transfers between their accounts read as + // "To: " instead of a hex address. + const counterpartyAddress = + item.type === 'send' || item.type === 'receive' + ? item.type === 'receive' + ? item.data.from + : item.data.to + : undefined; + const counterpartyName = useAccountNames( + counterpartyAddress + ? [ + { + value: counterpartyAddress, + variation: networkChainId ?? '', + type: NameType.EthereumAddress, + }, + ] + : [], + )[0]; + + const content = resolveCoreContent(item, bridgeHistoryItem, counterpartyName); let basePrimaryToken: TokenAmount | undefined; if (isSpendingCap) { diff --git a/app/components/UI/ActivityListItemRow/useSubtitleAccountParts.tsx b/app/components/UI/ActivityListItemRow/useSubtitleAccountParts.tsx new file mode 100644 index 00000000000..b9bacef5df6 --- /dev/null +++ b/app/components/UI/ActivityListItemRow/useSubtitleAccountParts.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { View } from 'react-native'; +import { useSelector } from 'react-redux'; +import { + AvatarAccount, + AvatarBaseSize, +} from '@metamask/design-system-react-native'; +import { getAvatarAccountVariant } from '../../../component-library/components-temp/MultichainAccounts/avatarAccountVariant'; +import { selectAvatarAccountType } from '../../../selectors/settings'; +import type { ActivityListItemRowContent } from './ActivityListItemRow.types'; +import type { ActivityListItemRowStyles } from './ActivityListItemRow.styles'; + +/** + * Builds the structured "To: Name" subtitle for rows whose + * counterparty resolved to one of the user's own accounts, using the same + * account avatar the Accounts list shows. + */ +export function useSubtitleAccountParts( + content: ActivityListItemRowContent, + styles: ActivityListItemRowStyles, + testIdSuffix: string | number | undefined, +): { pre: string; avatar: React.ReactNode; name: string } | undefined { + const avatarVariant = getAvatarAccountVariant( + useSelector(selectAvatarAccountType), + ); + const account = content.subtitleAccount; + if (!account) { + return undefined; + } + + return { + pre: `${account.prefix}: `, + avatar: ( + + + + ), + name: account.name, + }; +}