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,
+ };
+}