diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx index 229e44bb0731..4bc474be3877 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx @@ -4,7 +4,6 @@ import { SafeAreaView } from 'react-native'; import { useNavigation } from '@react-navigation/native'; // External dependencies. -import BottomSheetHeader from '../../../../component-library/components/BottomSheets/BottomSheetHeader'; import { IconName } from '@metamask/design-system-react-native'; import ActionListItem from '../../ActionListItem'; import { strings } from '../../../../../locales/i18n'; @@ -88,9 +87,6 @@ const MultichainAddWalletActions = ({ return ( - - {strings('multichain_accounts.add_wallet')} - {actionConfigs.map( (config) => config.isVisible && ( diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/__snapshots__/MultichainAddWalletActions.test.tsx.snap b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/__snapshots__/MultichainAddWalletActions.test.tsx.snap index 21a3154e8321..4ec3865a5df3 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/__snapshots__/MultichainAddWalletActions.test.tsx.snap +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/__snapshots__/MultichainAddWalletActions.test.tsx.snap @@ -313,46 +313,6 @@ exports[`MultichainAddWalletActions renders correctly 1`] = ` } > - - - - Add wallet - - - ( ({ + overlayStyle: { + opacity: 0, + }, + }), + detachPreviousScreen: false, + }} /> { expect(screen.getByText('Buy')).toBeOnTheScreen(); expect(screen.getByText(baseItem.marketTitle)).toBeOnTheScreen(); expect(screen.getByText('-$1,234.50')).toBeOnTheScreen(); - expect(screen.getByText('2%')).toBeOnTheScreen(); + expect(screen.getByText('1.5%')).toBeOnTheScreen(); }); it('renders SELL activity with plus-signed amount and negative percent', () => { diff --git a/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.test.tsx b/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.test.tsx index b90fa5d2c82e..1911d2fe563f 100644 --- a/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.test.tsx +++ b/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.test.tsx @@ -199,7 +199,7 @@ describe('PredictActivityDetail', () => { expect(screen.getByText(expectedPricePerShare)).toBeOnTheScreen(); expect(screen.getByText('Price impact')).toBeOnTheScreen(); - expect(screen.getByText('2%')).toBeOnTheScreen(); + expect(screen.getByText('1.5%')).toBeOnTheScreen(); expect(screen.queryByLabelText('USDC')).toBeNull(); }); diff --git a/app/components/UI/Predict/components/PredictBalance/PredictBalance.test.tsx b/app/components/UI/Predict/components/PredictBalance/PredictBalance.test.tsx index 317f9d182c40..f131c537f702 100644 --- a/app/components/UI/Predict/components/PredictBalance/PredictBalance.test.tsx +++ b/app/components/UI/Predict/components/PredictBalance/PredictBalance.test.tsx @@ -169,7 +169,7 @@ describe('PredictBalance', () => { }); // Assert - expect(getByText(/\$123\.45/)).toBeOnTheScreen(); + expect(getByText(/\$123\.46/)).toBeOnTheScreen(); }); it('displays zero balance', () => { @@ -189,7 +189,7 @@ describe('PredictBalance', () => { }); // Assert - expect(getByText(/\$0\.00/)).toBeOnTheScreen(); + expect(getByText(/\$0/)).toBeOnTheScreen(); }); it('displays large balance correctly', () => { @@ -209,7 +209,7 @@ describe('PredictBalance', () => { }); // Assert - expect(getByText(/\$1,234,567\.88/)).toBeOnTheScreen(); + expect(getByText(/\$1,234,567\.89/)).toBeOnTheScreen(); }); it('renders container with correct test ID', () => { diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx index d2a6e7f7759e..778591eb91d7 100644 --- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx +++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx @@ -81,7 +81,7 @@ const PredictMarketMultiple: React.FC = ({ const parsed = outcomePrices; if (Array.isArray(parsed) && parsed.length > 0) { const firstValue = parsed[0]; - return formatPercentage(firstValue * 100); + return formatPercentage(firstValue * 100, { truncate: true }); } } catch (error) { DevLogger.log('PredictMarketMultiple: Failed to parse outcomePrices', { diff --git a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx index 7f3e9b06dbc5..2001f39b3cec 100644 --- a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx +++ b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx @@ -73,7 +73,7 @@ const PredictMarketOutcome: React.FC = ({ const getYesPercentage = (): string => { const prices = getOutcomePrices(); if (prices.length > 0) { - return formatPercentage(prices[0] * 100); + return formatPercentage(prices[0] * 100, { truncate: true }); } return '0%'; }; diff --git a/app/components/UI/Predict/components/PredictPosition/PredictPosition.test.tsx b/app/components/UI/Predict/components/PredictPosition/PredictPosition.test.tsx index 91e2f9a1d31a..c85595a6927a 100644 --- a/app/components/UI/Predict/components/PredictPosition/PredictPosition.test.tsx +++ b/app/components/UI/Predict/components/PredictPosition/PredictPosition.test.tsx @@ -7,6 +7,15 @@ import { } from '../../types'; import { PredictPositionSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, vars?: Record) => { + if (key === 'predict.position_info' && vars) { + return `${vars.initialValue} on ${vars.outcome} to win ${vars.shares}`; + } + return key; + }), +})); + const basePosition: PredictPositionType = { id: 'pos-1', providerId: 'polymarket', @@ -46,17 +55,15 @@ describe('PredictPosition', () => { renderComponent(); expect(screen.getByText(basePosition.title)).toBeOnTheScreen(); - expect( - screen.getByText('$123.45 on Yes · 10 shares at 34¢'), - ).toBeOnTheScreen(); + expect(screen.getByText('$123.45 on Yes to win $10')).toBeOnTheScreen(); expect(screen.getByText('$2,345.67')).toBeOnTheScreen(); - expect(screen.getByText('5%')).toBeOnTheScreen(); + expect(screen.getByText('5.25%')).toBeOnTheScreen(); }); it.each([ - { value: -3.5, expected: '-3%' }, + { value: -3.5, expected: '-3.5%' }, { value: 0, expected: '0%' }, - { value: 7.5, expected: '8%' }, + { value: 7.5, expected: '7.5%' }, ])('formats percentPnl $value as $expected', ({ value, expected }) => { renderComponent({ percentPnl: value }); @@ -71,9 +78,7 @@ describe('PredictPosition', () => { size: 10, }); - expect( - screen.getByText('$50.00 on No · 10 shares at 70¢'), - ).toBeOnTheScreen(); + expect(screen.getByText('$50 on No to win $10')).toBeOnTheScreen(); }); it('displays singular share when size is 1', () => { @@ -84,7 +89,7 @@ describe('PredictPosition', () => { size: 1, }); - expect(screen.getByText('$50.00 on No · 1 share at 70¢')).toBeOnTheScreen(); + expect(screen.getByText('$50 on No to win $1')).toBeOnTheScreen(); }); it('renders icon image with correct URI', () => { @@ -154,33 +159,25 @@ describe('PredictPosition', () => { it('formats avgPrice with 1 decimal precision in cents', () => { renderComponent({ avgPrice: 0.456, size: 5 }); - expect( - screen.getByText('$123.45 on Yes · 5 shares at 45.6¢'), - ).toBeOnTheScreen(); + expect(screen.getByText('$123.45 on Yes to win $5')).toBeOnTheScreen(); }); it('formats avgPrice as whole cents when no decimals needed', () => { renderComponent({ avgPrice: 0.5, size: 2 }); - expect( - screen.getByText('$123.45 on Yes · 2 shares at 50¢'), - ).toBeOnTheScreen(); + expect(screen.getByText('$123.45 on Yes to win $2')).toBeOnTheScreen(); }); it('formats initialValue without decimals when minimumDecimals is 0', () => { renderComponent({ initialValue: 100, size: 3 }); - expect( - screen.getByText('$100.00 on Yes · 3 shares at 34¢'), - ).toBeOnTheScreen(); + expect(screen.getByText('$100 on Yes to win $3')).toBeOnTheScreen(); }); it('formats size with 2 decimal places', () => { renderComponent({ size: 10.5555, initialValue: 200 }); - expect( - screen.getByText('$200.00 on Yes · 10.56 shares at 34¢'), - ).toBeOnTheScreen(); + expect(screen.getByText('$200 on Yes to win $10.56')).toBeOnTheScreen(); }); it('renders all position properties correctly', () => { @@ -209,11 +206,9 @@ describe('PredictPosition', () => { render(); expect(screen.getByText('Test Market Question?')).toBeOnTheScreen(); - expect( - screen.getByText('$75.25 on Maybe · 7.50 shares at 62.5¢'), - ).toBeOnTheScreen(); + expect(screen.getByText('$75.25 on Maybe to win $7.50')).toBeOnTheScreen(); expect(screen.getByText('$100.75')).toBeOnTheScreen(); - expect(screen.getByText('16%')).toBeOnTheScreen(); + expect(screen.getByText('15.75%')).toBeOnTheScreen(); }); describe('optimistic updates UI', () => { @@ -233,15 +228,13 @@ describe('PredictPosition', () => { renderComponent({ optimistic: false }); expect(screen.getByText('$2,345.67')).toBeOnTheScreen(); - expect(screen.getByText('5%')).toBeOnTheScreen(); + expect(screen.getByText('5.25%')).toBeOnTheScreen(); }); it('shows initial value line when optimistic', () => { renderComponent({ optimistic: true, initialValue: 123.45 }); - expect( - screen.getByText('$123.45 on Yes · 10 shares at 34¢'), - ).toBeOnTheScreen(); + expect(screen.getByText('$123.45 on Yes to win $10')).toBeOnTheScreen(); }); }); }); diff --git a/app/components/UI/Predict/components/PredictPosition/PredictPosition.tsx b/app/components/UI/Predict/components/PredictPosition/PredictPosition.tsx index 95a9a52d3d6a..4797299833e2 100644 --- a/app/components/UI/Predict/components/PredictPosition/PredictPosition.tsx +++ b/app/components/UI/Predict/components/PredictPosition/PredictPosition.tsx @@ -6,12 +6,7 @@ import Text, { } from '../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../component-library/hooks'; import { PredictPosition as PredictPositionType } from '../../types'; -import { - formatCents, - formatPercentage, - formatPositionSize, - formatPrice, -} from '../../utils/format'; +import { formatPercentage, formatPrice } from '../../utils/format'; import styleSheet from './PredictPosition.styles'; import { PredictPositionSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; import { strings } from '../../../../../../locales/i18n'; @@ -32,7 +27,6 @@ const PredictPosition: React.FC = ({ initialValue, percentPnl, outcome, - avgPrice, currentValue, size, optimistic, @@ -53,23 +47,15 @@ const PredictPosition: React.FC = ({ {title} - {strings( - size !== 1 - ? 'predict.position_info_plural' - : 'predict.position_info_singular', - { - amount: formatPrice(initialValue, { - minimumDecimals: 0, - maximumDecimals: 2, - }), - outcome, - shares: formatPositionSize(size, { - minimumDecimals: 2, - maximumDecimals: 2, - }), - priceCents: formatCents(avgPrice), - }, - )} + {strings('predict.position_info', { + initialValue: formatPrice(initialValue, { + maximumDecimals: 2, + }), + outcome, + shares: formatPrice(size, { + maximumDecimals: 2, + }), + })} diff --git a/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.test.tsx b/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.test.tsx index de8616aab966..d20652b10313 100644 --- a/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.test.tsx +++ b/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.test.tsx @@ -17,7 +17,7 @@ declare global { } jest.mock('../../../../../../locales/i18n', () => ({ - strings: (key: string, _vars?: Record) => { + strings: (key: string, vars?: Record) => { switch (key) { case 'predict.market_details.won': return 'Won'; @@ -25,6 +25,8 @@ jest.mock('../../../../../../locales/i18n', () => ({ return 'Lost'; case 'predict.cash_out': return 'Cash out'; + case 'predict.position_info': + return `${vars?.initialValue} on ${vars?.outcome} to win ${vars?.shares}`; default: return key; } @@ -187,18 +189,18 @@ describe('PredictPositionDetail', () => { expect(screen.getByText('Group')).toBeOnTheScreen(); expect( - screen.getByText('$123.45 on Yes • 34¢', { exact: false }), + screen.getByText('$123.45 on Yes to win $10', { exact: false }), ).toBeOnTheScreen(); expect(screen.getByText('$2,345.67')).toBeOnTheScreen(); - expect(screen.getByText('5%')).toBeOnTheScreen(); + expect(screen.getByText('5.25%')).toBeOnTheScreen(); expect(screen.getByText('Cash out')).toBeOnTheScreen(); }); it.each([ - { value: -3.5, expected: '-3%' }, + { value: -3.5, expected: '-3.5%' }, { value: 0, expected: '0%' }, - { value: 7.5, expected: '8%' }, + { value: 7.5, expected: '7.5%' }, ])('formats percentPnl %p as %p for open market', ({ value, expected }) => { renderComponent({ percentPnl: value }); @@ -210,7 +212,7 @@ describe('PredictPositionDetail', () => { expect(screen.getByText('Group')).toBeOnTheScreen(); expect( - screen.getByText('$50.00 on No • 70¢', { exact: false }), + screen.getByText('$50 on No to win $10', { exact: false }), ).toBeOnTheScreen(); }); @@ -221,7 +223,7 @@ describe('PredictPositionDetail', () => { PredictMarketStatus.CLOSED, ); - expect(screen.getByText('Won $500.00')).toBeOnTheScreen(); + expect(screen.getByText('Won $500')).toBeOnTheScreen(); expect(screen.queryByText('+12.34%')).toBeNull(); expect(screen.queryByText('Cash out')).toBeNull(); }); @@ -233,7 +235,7 @@ describe('PredictPositionDetail', () => { PredictMarketStatus.CLOSED, ); - expect(screen.getByText('Lost $321.08')).toBeOnTheScreen(); + expect(screen.getByText('Lost $321.09')).toBeOnTheScreen(); expect(screen.queryByText('Cash out')).toBeNull(); }); @@ -268,7 +270,7 @@ describe('PredictPositionDetail', () => { renderComponent({ optimistic: true, initialValue: 123.45 }); expect( - screen.getByText('$123.45 on Yes • 34¢', { exact: false }), + screen.getByText('$123.45 on Yes to win $10', { exact: false }), ).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx b/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx index daf1539a765b..7ec5e379a265 100644 --- a/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx +++ b/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx @@ -1,30 +1,30 @@ +import { Box } from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { NavigationProp, useNavigation } from '@react-navigation/native'; import React from 'react'; import { Image } from 'react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { Box } from '@metamask/design-system-react-native'; +import { PredictMarketDetailsSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; +import { strings } from '../../../../../../locales/i18n'; +import Button, { + ButtonSize, + ButtonVariants, + ButtonWidthTypes, +} from '../../../../../component-library/components/Buttons/Button'; +import { Skeleton } from '../../../../../component-library/components/Skeleton'; import Text, { TextColor, TextVariant, } from '../../../../../component-library/components/Texts/Text'; +import Routes from '../../../../../constants/navigation/Routes'; +import { PredictEventValues } from '../../constants/eventNames'; +import { usePredictActionGuard } from '../../hooks/usePredictActionGuard'; import { - PredictPosition as PredictPositionType, PredictMarket, PredictMarketStatus, + PredictPosition as PredictPositionType, } from '../../types'; -import { formatCents, formatPercentage, formatPrice } from '../../utils/format'; -import Button, { - ButtonVariants, - ButtonSize, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; -import Routes from '../../../../../constants/navigation/Routes'; -import { NavigationProp, useNavigation } from '@react-navigation/native'; import { PredictNavigationParamList } from '../../types/navigation'; -import { PredictEventValues } from '../../constants/eventNames'; -import { strings } from '../../../../../../locales/i18n'; -import { usePredictActionGuard } from '../../hooks/usePredictActionGuard'; -import { PredictMarketDetailsSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { formatPercentage, formatPrice } from '../../utils/format'; interface PredictPositionProps { position: PredictPositionType; @@ -44,10 +44,10 @@ const PredictPosition: React.FC = ({ initialValue, percentPnl, outcome, - avgPrice, currentValue, title, optimistic, + size, } = position; const navigation = useNavigation>(); @@ -133,8 +133,15 @@ const PredictPosition: React.FC = ({ variant={TextVariant.BodySMMedium} color={TextColor.Alternative} > - {formatPrice(initialValue, { maximumDecimals: 2 })} on {outcome} •{' '} - {formatCents(avgPrice)} + {strings('predict.position_info', { + initialValue: formatPrice(initialValue, { + maximumDecimals: 2, + }), + outcome, + shares: formatPrice(size, { + maximumDecimals: 2, + }), + })} diff --git a/app/components/UI/Predict/components/PredictPositionResolved/PredictPositionResolved.test.tsx b/app/components/UI/Predict/components/PredictPositionResolved/PredictPositionResolved.test.tsx index 187f79667477..a15ecfa10b07 100644 --- a/app/components/UI/Predict/components/PredictPositionResolved/PredictPositionResolved.test.tsx +++ b/app/components/UI/Predict/components/PredictPositionResolved/PredictPositionResolved.test.tsx @@ -12,7 +12,7 @@ jest.mock('../../../../../../locales/i18n', () => ({ const translations: Record = { 'predict.market_details.resolved_early': 'Resolved early', 'predict.market_details.ended': 'Ended', - 'predict.market_details.amount_on_outcome': `$${params?.amount} on ${params?.outcome}`, + 'predict.market_details.amount_on_outcome': `${params?.amount} on ${params?.outcome}`, 'predict.market_details.won': 'Won', 'predict.market_details.lost': 'Lost', }; @@ -87,9 +87,9 @@ describe('PredictPositionResolved', () => { percentPnl: -50, }); - expect(screen.getByText(/\$100\.00 on Yes/)).toBeOnTheScreen(); + expect(screen.getByText(/\$100 on Yes/)).toBeOnTheScreen(); expect(screen.getByText(/Ended 2 days ago/)).toBeOnTheScreen(); - expect(screen.getByText(/Lost \$50\.00/)).toBeOnTheScreen(); + expect(screen.getByText(/Lost\s+\$50/)).toBeOnTheScreen(); }); it('renders different outcome text', () => { @@ -106,7 +106,7 @@ describe('PredictPositionResolved', () => { percentPnl: 0, }); - expect(screen.getByText(/Lost \$0\.00/)).toBeOnTheScreen(); + expect(screen.getByText('Lost $0')).toBeOnTheScreen(); }); it('calls onPress when position is tapped', () => { diff --git a/app/components/UI/Predict/components/PredictPositionResolved/PredictPositionResolved.tsx b/app/components/UI/Predict/components/PredictPositionResolved/PredictPositionResolved.tsx index 441d70de07bd..4f4f7d9be56e 100644 --- a/app/components/UI/Predict/components/PredictPositionResolved/PredictPositionResolved.tsx +++ b/app/components/UI/Predict/components/PredictPositionResolved/PredictPositionResolved.tsx @@ -76,7 +76,7 @@ const PredictPositionResolved: React.FC = ({ ellipsizeMode="tail" > {strings('predict.market_details.amount_on_outcome', { - amount: initialValue.toFixed(2), + amount: formatPrice(initialValue, { maximumDecimals: 2 }), outcome, })}{' '} • {formatMarketEndDate(endDate)} diff --git a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx index d98699547861..6ee71454bad8 100644 --- a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx +++ b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx @@ -509,7 +509,7 @@ describe('MarketsWonCard', () => { expect(screen.getByText('Available Balance')).toBeOnTheScreen(); expect(screen.getByText('$100.50')).toBeOnTheScreen(); expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); - expect(screen.getByText('+$8.63 (+4%)')).toBeOnTheScreen(); + expect(screen.getByText('+$8.63 (+3.9%)')).toBeOnTheScreen(); }); it('renders claim button without loading indicator when isLoading is false', () => { setupMarketsWonCardTest({ isLoading: false }); @@ -532,7 +532,7 @@ describe('MarketsWonCard', () => { }, ); - expect(screen.getByText('+$123.46 (+6%)')).toBeOnTheScreen(); + expect(screen.getByText('+$123.46 (+5.67%)')).toBeOnTheScreen(); }); it('formats negative unrealized amount correctly', () => { @@ -547,7 +547,7 @@ describe('MarketsWonCard', () => { }, ); - expect(screen.getByText('-$50.25 (-2%)')).toBeOnTheScreen(); + expect(screen.getByText('-$50.25 (-2.1%)')).toBeOnTheScreen(); }); it('handles zero unrealized amount correctly', () => { @@ -649,7 +649,7 @@ describe('MarketsWonCard', () => { }, ); - expect(screen.getByText('+$999999.99 (+>99%)')).toBeOnTheScreen(); + expect(screen.getByText('+$999999.99 (+999.9%)')).toBeOnTheScreen(); }); it('handles very small unrealized amounts', () => { @@ -664,7 +664,7 @@ describe('MarketsWonCard', () => { }, ); - expect(screen.getByText('+$0.01 (+<1%)')).toBeOnTheScreen(); + expect(screen.getByText('+$0.01 (+0.1%)')).toBeOnTheScreen(); }); it('handles very large available balance', () => { @@ -757,7 +757,7 @@ describe('MarketsWonCard', () => { ); expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); - expect(screen.getByText('-$15.75 (-8%)')).toBeOnTheScreen(); + expect(screen.getByText('-$15.75 (-8.2%)')).toBeOnTheScreen(); }); it('does not show unrealized P&L section when hook returns null data', () => { diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index 8269dc88deea..b6aff6f6e064 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -101,7 +101,7 @@ export type PredictControllerState = { // Account balances balances: { [providerId: string]: { [address: string]: PredictBalance } }; - // Claim management + // Claim management (this should always be ALL claimable positions) claimablePositions: { [address: string]: PredictPosition[] }; // Deposit management diff --git a/app/components/UI/Predict/hooks/usePredictClaimToasts.test.tsx b/app/components/UI/Predict/hooks/usePredictClaimToasts.test.tsx index a5265bdbcc72..aa8e120c4494 100644 --- a/app/components/UI/Predict/hooks/usePredictClaimToasts.test.tsx +++ b/app/components/UI/Predict/hooks/usePredictClaimToasts.test.tsx @@ -29,6 +29,19 @@ jest.mock('./usePredictPositions', () => ({ })), })); +// Mock usePredictBalance +const mockLoadBalance = jest.fn().mockResolvedValue(undefined); +jest.mock('./usePredictBalance', () => ({ + usePredictBalance: jest.fn(() => ({ + balance: 100, + hasNoBalance: false, + isLoading: false, + isRefreshing: false, + error: null, + loadBalance: mockLoadBalance, + })), +})); + // Create a mock toast ref const mockToastRef = { current: { @@ -145,6 +158,8 @@ describe('usePredictClaimToasts', () => { jest.clearAllMocks(); mockToastRef.current.showToast.mockClear(); mockClaim.mockClear(); + mockLoadBalance.mockClear(); + mockLoadPositions.mockClear(); // Capture the subscribe callback mockSubscribeCallback = null; @@ -413,6 +428,66 @@ describe('usePredictClaimToasts', () => { }); }); + describe('onConfirmed callback', () => { + it('calls loadBalance when transaction is confirmed', async () => { + // Arrange + renderHook(() => usePredictClaimToasts(), { wrapper }); + + // Act + await act(async () => { + mockSubscribeCallback?.({ + transactionMeta: { + status: TransactionStatus.confirmed, + nestedTransactions: [{ type: TransactionType.predictClaim }], + }, + }); + }); + + // Assert + expect(mockLoadBalance).toHaveBeenCalled(); + }); + + it('calls loadPositions with isRefresh when transaction is confirmed', async () => { + // Arrange + renderHook(() => usePredictClaimToasts(), { wrapper }); + + // Act + await act(async () => { + mockSubscribeCallback?.({ + transactionMeta: { + status: TransactionStatus.confirmed, + nestedTransactions: [{ type: TransactionType.predictClaim }], + }, + }); + }); + + // Assert + expect(mockLoadPositions).toHaveBeenCalledWith({ isRefresh: true }); + }); + + it('calls confirmClaim on PredictController when transaction is confirmed', async () => { + // Arrange + renderHook(() => usePredictClaimToasts(), { wrapper }); + + // Act + await act(async () => { + mockSubscribeCallback?.({ + transactionMeta: { + status: TransactionStatus.confirmed, + nestedTransactions: [{ type: TransactionType.predictClaim }], + }, + }); + }); + + // Assert + expect( + Engine.context.PredictController.confirmClaim, + ).toHaveBeenCalledWith({ + providerId: 'polymarket', + }); + }); + }); + describe('claimable positions', () => { it('calculates total claimable amount from won positions', async () => { // Arrange diff --git a/app/components/UI/Predict/hooks/usePredictClaimToasts.tsx b/app/components/UI/Predict/hooks/usePredictClaimToasts.tsx index a8a5ddd4ab9e..2cd7ca109964 100644 --- a/app/components/UI/Predict/hooks/usePredictClaimToasts.tsx +++ b/app/components/UI/Predict/hooks/usePredictClaimToasts.tsx @@ -1,5 +1,5 @@ import { TransactionType } from '@metamask/transaction-controller'; -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../locales/i18n'; import { selectPredictWonPositions } from '../selectors/predictController'; @@ -10,6 +10,7 @@ import { usePredictPositions } from './usePredictPositions'; import { usePredictToasts } from './usePredictToasts'; import Engine from '../../../../core/Engine'; import { getEvmAccountFromSelectedAccountGroup } from '../utils/accounts'; +import { usePredictBalance } from './usePredictBalance'; export const usePredictClaimToasts = () => { const { claim } = usePredictClaim(); @@ -17,6 +18,7 @@ export const usePredictClaimToasts = () => { claimable: true, loadOnMount: true, }); + const { loadBalance } = usePredictBalance({ loadOnMount: false }); const evmAccount = getEvmAccountFromSelectedAccountGroup(); const selectedAddress = evmAccount?.address ?? '0x0'; @@ -37,6 +39,18 @@ export const usePredictClaimToasts = () => { maximumDecimals: 2, }); + const handleClaimConfirmed = useCallback(() => { + Engine.context.PredictController.confirmClaim({ + providerId: 'polymarket', + }); + loadPositions({ isRefresh: true }).catch(() => { + // Ignore errors when refreshing positions + }); + loadBalance({ isRefresh: true }).catch(() => { + // Ignore errors when refreshing balance + }); + }, [loadBalance, loadPositions]); + usePredictToasts({ transactionType: TransactionType.predictClaim, pendingToastConfig: { @@ -61,13 +75,6 @@ export const usePredictClaimToasts = () => { retryLabel: strings('predict.claim.toasts.error.try_again'), onRetry: claim, }, - onConfirmed: () => { - Engine.context.PredictController.confirmClaim({ - providerId: 'polymarket', - }); - loadPositions({ isRefresh: true }).catch(() => { - // Ignore errors when refreshing positions - }); - }, + onConfirmed: handleClaimConfirmed, }); }; diff --git a/app/components/UI/Predict/hooks/usePredictPositions.ts b/app/components/UI/Predict/hooks/usePredictPositions.ts index 064f1040ca09..3e5fffa16a6f 100644 --- a/app/components/UI/Predict/hooks/usePredictPositions.ts +++ b/app/components/UI/Predict/hooks/usePredictPositions.ts @@ -1,5 +1,5 @@ import { useFocusEffect } from '@react-navigation/native'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; import Logger from '../../../../util/Logger'; import type { PredictPosition } from '../types'; @@ -32,7 +32,8 @@ interface UsePredictPositionsOptions { marketId?: string; /** - * The parameters to load positions for + * Only load claimable positions. When this is set to true, marketId is ignored when fetching positions. + * However, the positions returned will be filtered to only include the specific market positions. */ claimable?: boolean; /** @@ -70,7 +71,9 @@ export function usePredictPositions( const { getPositions } = usePredictTrading(); const { ensurePolygonNetworkExists } = usePredictNetworkManagement(); + // `positions` state only stores active positions const [positions, setPositions] = useState([]); + const [isLoading, setIsLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); @@ -84,6 +87,13 @@ export function usePredictPositions( }), ); + const filteredClaimablePositions = useMemo(() => { + if (!marketId) return [...claimablePositions]; + return claimablePositions.filter( + (position) => position.marketId === marketId, + ); + }, [claimablePositions, marketId]); + const loadPositions = useCallback( async (loadOptions?: { isRefresh?: boolean }) => { const { isRefresh = false } = loadOptions || {}; @@ -114,11 +124,15 @@ export function usePredictPositions( address: selectedInternalAccountAddress, providerId, claimable, - marketId, + // Always load ALL positions when claimable is true + marketId: claimable ? undefined : marketId, }); const validPositions = positionsData ?? []; - setPositions(validPositions); + if (!claimable) { + // `positions` state only stores active positions + setPositions(validPositions); + } DevLogger.log('usePredictPositions: Loaded positions', { originalCount: validPositions.length, @@ -211,7 +225,7 @@ export function usePredictPositions( // Get claimable positions from controller state if claimable is true. // This will ensure that we can refresh claimable positions when the user // performs a claim operation. - positions: claimable ? [...claimablePositions] : positions, + positions: claimable ? filteredClaimablePositions : positions, isLoading, isRefreshing, error, diff --git a/app/components/UI/Predict/utils/format.test.ts b/app/components/UI/Predict/utils/format.test.ts index 629247f8aaca..61e93c649934 100644 --- a/app/components/UI/Predict/utils/format.test.ts +++ b/app/components/UI/Predict/utils/format.test.ts @@ -40,118 +40,344 @@ describe('format utils', () => { }); describe('formatPercentage', () => { - it('formats positive decimal percentage with no decimals', () => { - // Arrange & Act - const result = formatPercentage(5.25); + describe('default behavior (truncate=false)', () => { + it('formats positive decimal percentage with 2 decimals', () => { + // Arrange & Act + const result = formatPercentage(5.25); - // Assert - expect(result).toBe('5%'); - }); + // Assert + expect(result).toBe('5.25%'); + }); - it('formats large percentage as >99%', () => { - // Arrange & Act - const result = formatPercentage(100); + it('formats large percentage without truncation', () => { + // Arrange & Act + const result = formatPercentage(100); - // Assert - expect(result).toBe('>99%'); - }); + // Assert + expect(result).toBe('100%'); + }); - it('formats negative decimal percentage with no decimals', () => { - // Arrange & Act - const result = formatPercentage(-2.75); + it('formats negative decimal percentage with 2 decimals', () => { + // Arrange & Act + const result = formatPercentage(-2.75); - // Assert - expect(result).toBe('-3%'); - }); + // Assert + expect(result).toBe('-2.75%'); + }); - it('formats negative whole number percentage without decimals', () => { - // Arrange & Act - const result = formatPercentage(-50); + it('formats negative whole number percentage without decimals', () => { + // Arrange & Act + const result = formatPercentage(-50); - // Assert - expect(result).toBe('-50%'); - }); + // Assert + expect(result).toBe('-50%'); + }); - it('formats zero as 0%', () => { - // Arrange & Act - const result = formatPercentage(0); + it('formats zero as 0%', () => { + // Arrange & Act + const result = formatPercentage(0); - // Assert - expect(result).toBe('0%'); - }); + // Assert + expect(result).toBe('0%'); + }); - it('handles string input with decimal value', () => { - // Arrange & Act - const result = formatPercentage('3.14159'); + it('handles string input with decimal value', () => { + // Arrange & Act + const result = formatPercentage('3.14159'); - // Assert - expect(result).toBe('3%'); - }); + // Assert + expect(result).toBe('3.14%'); + }); - it('handles string input with whole number', () => { - // Arrange & Act - const result = formatPercentage('42'); + it('handles string input with whole number', () => { + // Arrange & Act + const result = formatPercentage('42'); - // Assert - expect(result).toBe('42%'); - }); + // Assert + expect(result).toBe('42%'); + }); - it('handles string input with negative value', () => { - // Arrange & Act - const result = formatPercentage('-7.89'); + it('handles string input with negative value', () => { + // Arrange & Act + const result = formatPercentage('-7.89'); - // Assert - expect(result).toBe('-8%'); - }); + // Assert + expect(result).toBe('-7.89%'); + }); - it('returns default value for NaN input', () => { - // Arrange & Act - const result = formatPercentage('not-a-number'); + it('returns default value for NaN input', () => { + // Arrange & Act + const result = formatPercentage('not-a-number'); - // Assert - expect(result).toBe('0%'); - }); + // Assert + expect(result).toBe('0%'); + }); - it('returns default value for invalid string', () => { - // Arrange & Act - const result = formatPercentage('abc'); + it('returns default value for invalid string', () => { + // Arrange & Act + const result = formatPercentage('abc'); - // Assert - expect(result).toBe('0%'); + // Assert + expect(result).toBe('0%'); + }); + + it('returns default value for empty string', () => { + // Arrange & Act + const result = formatPercentage(''); + + // Assert + expect(result).toBe('0%'); + }); + + it.each([ + [0.01, '0.01%'], + [0.001, '0%'], + [0.5, '0.5%'], + [0.9, '0.9%'], + [1.999, '2%'], + [99, '99%'], + [99.999, '100%'], + [100, '100%'], + [-0.01, '-0.01%'], + [-0.001, '0%'], + [-1.999, '-2%'], + ])('formats %f correctly as %s', (input, expected) => { + expect(formatPercentage(input)).toBe(expected); + }); }); - it('returns default value for empty string', () => { - // Arrange & Act - const result = formatPercentage(''); + describe('with truncate=true', () => { + it('formats positive decimal percentage with no decimals', () => { + // Arrange & Act + const result = formatPercentage(5.25, { truncate: true }); - // Assert - expect(result).toBe('0%'); + // Assert + expect(result).toBe('5%'); + }); + + it('formats large percentage as >99%', () => { + // Arrange & Act + const result = formatPercentage(100, { truncate: true }); + + // Assert + expect(result).toBe('>99%'); + }); + + it('formats negative decimal percentage with no decimals', () => { + // Arrange & Act + const result = formatPercentage(-2.75, { truncate: true }); + + // Assert + expect(result).toBe('-3%'); + }); + + it('formats negative whole number percentage without decimals', () => { + // Arrange & Act + const result = formatPercentage(-50, { truncate: true }); + + // Assert + expect(result).toBe('-50%'); + }); + + it('formats zero as 0%', () => { + // Arrange & Act + const result = formatPercentage(0, { truncate: true }); + + // Assert + expect(result).toBe('0%'); + }); + + it('handles string input with decimal value', () => { + // Arrange & Act + const result = formatPercentage('3.14159', { truncate: true }); + + // Assert + expect(result).toBe('3%'); + }); + + it('handles string input with whole number', () => { + // Arrange & Act + const result = formatPercentage('42', { truncate: true }); + + // Assert + expect(result).toBe('42%'); + }); + + it('handles string input with negative value', () => { + // Arrange & Act + const result = formatPercentage('-7.89', { truncate: true }); + + // Assert + expect(result).toBe('-8%'); + }); + + it('returns default value for NaN input', () => { + // Arrange & Act + const result = formatPercentage('not-a-number', { truncate: true }); + + // Assert + expect(result).toBe('0%'); + }); + + it.each([ + [0.01, '<1%'], + [0.001, '<1%'], + [0.5, '<1%'], + [0.9, '<1%'], + [1.999, '2%'], + [99, '>99%'], + [99.999, '>99%'], + [100, '>99%'], + [-0.01, '0%'], + [-0.001, '0%'], + [-1.999, '-2%'], + ])('formats %f correctly as %s', (input, expected) => { + expect(formatPercentage(input, { truncate: true })).toBe(expected); + }); }); - it.each([ - [0.01, '<1%'], - [0.001, '<1%'], - [0.5, '<1%'], - [0.9, '<1%'], - [1.999, '2%'], - [99, '>99%'], - [99.999, '>99%'], - [100, '>99%'], - [-0.01, '0%'], - [-0.001, '0%'], - [-1.999, '-2%'], - ])('formats %f correctly as %s', (input, expected) => { - expect(formatPercentage(input)).toBe(expected); + describe('with truncate=false', () => { + it('displays integer percentage without decimals', () => { + // Arrange & Act + const result = formatPercentage(5, { truncate: false }); + + // Assert + expect(result).toBe('5%'); + }); + + it('displays percentage with 2 decimals when not integer', () => { + // Arrange & Act + const result = formatPercentage(5.25, { truncate: false }); + + // Assert + expect(result).toBe('5.25%'); + }); + + it('displays percentage with 1 decimal when second decimal is zero', () => { + // Arrange & Act + const result = formatPercentage(5.5, { truncate: false }); + + // Assert + expect(result).toBe('5.5%'); + }); + + it('displays values above 99 with actual percentage', () => { + // Arrange & Act + const result = formatPercentage(99.5, { truncate: false }); + + // Assert + expect(result).toBe('99.5%'); + }); + + it('displays values above 100 with actual percentage', () => { + // Arrange & Act + const result = formatPercentage(150, { truncate: false }); + + // Assert + expect(result).toBe('150%'); + }); + + it('displays values below 1 with actual percentage', () => { + // Arrange & Act + const result = formatPercentage(0.5, { truncate: false }); + + // Assert + expect(result).toBe('0.5%'); + }); + + it('displays small decimal values with 2 decimals', () => { + // Arrange & Act + const result = formatPercentage(0.01, { truncate: false }); + + // Assert + expect(result).toBe('0.01%'); + }); + + it('displays negative percentage with decimals', () => { + // Arrange & Act + const result = formatPercentage(-2.75, { truncate: false }); + + // Assert + expect(result).toBe('-2.75%'); + }); + + it('displays negative integer percentage without decimals', () => { + // Arrange & Act + const result = formatPercentage(-50, { truncate: false }); + + // Assert + expect(result).toBe('-50%'); + }); + + it('displays zero without decimals', () => { + // Arrange & Act + const result = formatPercentage(0, { truncate: false }); + + // Assert + expect(result).toBe('0%'); + }); + + it('rounds to 2 decimals when more decimals provided', () => { + // Arrange & Act + const result = formatPercentage(5.256, { truncate: false }); + + // Assert + expect(result).toBe('5.26%'); + }); + + it('handles string input with decimals', () => { + // Arrange & Act + const result = formatPercentage('3.14159', { truncate: false }); + + // Assert + expect(result).toBe('3.14%'); + }); + + it('handles string input with integer', () => { + // Arrange & Act + const result = formatPercentage('42', { truncate: false }); + + // Assert + expect(result).toBe('42%'); + }); + + it('returns default value for NaN input', () => { + // Arrange & Act + const result = formatPercentage('not-a-number', { truncate: false }); + + // Assert + expect(result).toBe('0%'); + }); + + it.each([ + [0.01, '0.01%'], + [0.001, '0%'], + [0.5, '0.5%'], + [0.9, '0.9%'], + [1.999, '2%'], + [99, '99%'], + [99.999, '100%'], + [99.5, '99.5%'], + [100, '100%'], + [150.75, '150.75%'], + [-0.01, '-0.01%'], + [-0.001, '0%'], + [-1.999, '-2%'], + [-50, '-50%'], + [-2.75, '-2.75%'], + ])('formats %f correctly as %s', (input, expected) => { + expect(formatPercentage(input, { truncate: false })).toBe(expected); + }); }); }); describe('formatPrice', () => { - it('formats prices with exactly 2 decimal places (truncated)', () => { + it('formats prices with exactly 2 decimal places (rounded up)', () => { // Arrange & Act const result = formatPrice(1234.5678); // Assert - expect(result).toBe('$1,234.56'); + expect(result).toBe('$1,234.57'); }); it('formats prices ignoring custom minimum decimals option', () => { @@ -159,18 +385,29 @@ describe('format utils', () => { const result = formatPrice(50000, { minimumDecimals: 0 }); // Assert - expect(result).toBe('$50,000.00'); + expect(result).toBe('$50,000'); + }); + + it('formats prices respecting custom minimum decimals option', () => { + // Arrange & Act + const result = formatPrice(1234.5678, { + minimumDecimals: 4, + maximumDecimals: 4, + }); + + // Assert + expect(result).toBe('$1,234.5678'); }); - it('formats prices ignoring custom maximum decimals option', () => { + it('respects minimumDecimals for integer values', () => { // Arrange & Act - const result = formatPrice(1234.5678, { minimumDecimals: 4 }); + const result = formatPrice(100, { minimumDecimals: 2 }); // Assert - expect(result).toBe('$1,234.56'); + expect(result).toBe('$100.00'); }); - it('formats small prices with 2 decimal places (truncated)', () => { + it('formats small prices with 2 decimal places (rounded)', () => { // Arrange & Act const result = formatPrice(0.1234); @@ -178,12 +415,12 @@ describe('format utils', () => { expect(result).toBe('$0.12'); }); - it('formats very small prices as $0.00', () => { + it('formats very small prices rounded', () => { // Arrange & Act const result = formatPrice(0.0001234); // Assert - expect(result).toBe('$0.00'); + expect(result).toBe('$0'); }); it('handles string input with decimal value', () => { @@ -191,7 +428,7 @@ describe('format utils', () => { const result = formatPrice('1234.5678'); // Assert - expect(result).toBe('$1,234.56'); + expect(result).toBe('$1,234.57'); }); it('handles string input with small value', () => { @@ -239,7 +476,7 @@ describe('format utils', () => { const result = formatPrice(1000); // Assert - expect(result).toBe('$1,000.00'); + expect(result).toBe('$1,000'); }); it('formats negative prices correctly', () => { @@ -255,7 +492,7 @@ describe('format utils', () => { const result = formatPrice(0); // Assert - expect(result).toBe('$0.00'); + expect(result).toBe('$0'); }); it('formats very large numbers correctly', () => { @@ -263,23 +500,23 @@ describe('format utils', () => { const result = formatPrice(1000000); // Assert - expect(result).toBe('$1,000,000.00'); + expect(result).toBe('$1,000,000'); }); - it('truncates not rounds - 1234.999 becomes $1,234.99 not $1,235.00', () => { + it('rounds up to next cent - 1234.999 becomes $1,235', () => { // Arrange & Act const result = formatPrice(1234.999); // Assert - expect(result).toBe('$1,234.99'); + expect(result).toBe('$1,235'); }); it.each([ - [999.999, '$999.99'], - [1000, '$1,000.00'], - [1000.001, '$1,000.00'], - [0.9999, '$0.99'], - [0.00009999, '$0.00'], + [999.999, '$1,000'], + [1000, '$1,000'], + [1000.001, '$1,000'], + [0.9999, '$1'], + [0.00009999, '$0'], ])('formats boundary value %f as %s', (input, expected) => { const result = formatPrice(input); expect(result).toBe(expected); @@ -316,13 +553,13 @@ describe('format utils', () => { expect(result).toBe(expected); }); - it('uses absolute value and 2 decimals (truncated) for values >= 1000', () => { + it('uses absolute value and 2 decimals (rounded) for values >= 1000', () => { const result = formatCurrencyValue(-1234.567); - expect(result).toBe('$1,234.56'); + expect(result).toBe('$1,234.57'); }); - it('uses absolute value and 2 decimals (truncated) for values < 1000', () => { + it('uses absolute value and 2 decimals (rounded) for values < 1000', () => { const result = formatCurrencyValue(-0.1234); expect(result).toBe('$0.12'); @@ -1302,7 +1539,7 @@ describe('format utils', () => { expect(result).toBe('6.75'); }); - it('handles very small decimal amounts precisely', () => { + it('handles very small decimal amounts', () => { const params = { totalFiat: '0.001', bridgeFeeFiat: '0.0001', diff --git a/app/components/UI/Predict/utils/format.ts b/app/components/UI/Predict/utils/format.ts index 10e733508c59..f6f2a8f557de 100644 --- a/app/components/UI/Predict/utils/format.ts +++ b/app/components/UI/Predict/utils/format.ts @@ -2,69 +2,107 @@ import { Dimensions } from 'react-native'; import { PredictSeries, Recurrence } from '../types'; /** - * Formats a percentage value with no decimals + * Formats a percentage value * @param value - Raw percentage value (e.g., 5.25 for 5.25%, not 0.0525) - * @returns Format: "X%" with no decimals - * - For values >= 99: ">99%" - * - For values < 1 (but > 0): "<1%" - * - For negative values: rounded normally (e.g., "-3%", "-99%") - * @example formatPercentage(5.25) => "5%" - * @example formatPercentage(99.5) => ">99%" - * @example formatPercentage(0.5) => "<1%" - * @example formatPercentage(-2.75) => "-3%" - * @example formatPercentage(-99.5) => "-100%" - * @example formatPercentage(0) => "0%" + * @param options - Optional formatting options + * @param options.truncate - Whether to truncate values with >99% and <1% (default: false) + * @returns Format depends on truncate option: + * - truncate=false (default): Shows actual percentage with up to 2 decimals, hides decimals for integers + * - truncate=true: ">99%" for values >= 99, "<1%" for values < 1, rounded integer otherwise + * @example formatPercentage(5.25) => "5.25%" + * @example formatPercentage(5.25, { truncate: true }) => "5%" + * @example formatPercentage(99.5) => "99.5%" + * @example formatPercentage(99.5, { truncate: true }) => ">99%" + * @example formatPercentage(0.5) => "0.5%" + * @example formatPercentage(0.5, { truncate: true }) => "<1%" + * @example formatPercentage(5) => "5%" + * @example formatPercentage(-2.75) => "-2.75%" + * @example formatPercentage(-2.75, { truncate: true }) => "-3%" */ -export const formatPercentage = (value: string | number): string => { +export const formatPercentage = ( + value: string | number, + options?: { truncate?: boolean }, +): string => { const num = typeof value === 'string' ? parseFloat(value) : value; + const truncate = options?.truncate ?? false; if (isNaN(num)) { return '0%'; } - // Handle special cases for positive numbers only - if (num >= 99) { - return '>99%'; + // Handle truncation mode (when explicitly enabled) + if (truncate) { + // Handle special cases for positive numbers only + if (num >= 99) { + return '>99%'; + } + + if (num > 0 && num < 1) { + return '<1%'; + } + + // Round to nearest integer + return `${Math.round(num)}%`; } - if (num > 0 && num < 1) { - return '<1%'; + // Non-truncated mode: show up to 2 decimals + // Check if the number is an integer + if (num === Math.floor(num)) { + return `${num}%`; } - // Round to nearest integer - return `${Math.round(num)}%`; + // Format with up to 2 decimals, removing trailing zeros + const formatted = num.toFixed(2).replace(/\.?0+$/, ''); + + // Handle edge case: toFixed can return "-0" for very small negative numbers + if (formatted === '-0') { + return '0%'; + } + + return `${formatted}%`; }; /** - * Formats a price value as USD currency with exactly 2 decimal places (truncated, no rounding) + * Formats a price value as USD currency with rounding up to nearest cent * @param price - Raw numeric price value * @param options - Optional formatting options (kept for backwards compatibility, but not used) - * @returns USD formatted string with exactly 2 decimals (truncated, not rounded) - * @example formatPrice(1234.5678) => "$1,234.56" - * @example formatPrice(0.1234) => "$0.12" - * @example formatPrice(50000) => "$50,000.00" - * @example formatPrice(1234.999) => "$1,234.99" (truncated, not rounded to $1,235.00) + * @returns USD formatted string, hiding .00 for integer values, rounding up to nearest cent for 3+ decimals + * @example formatPrice(1234.5678) => "$1,234.57" (rounds up from .5678) + * @example formatPrice(0.1234) => "$0.13" (rounds up from .1234) + * @example formatPrice(50000) => "$50,000" (no .00 for integers) + * @example formatPrice(1234.999) => "$1,235" (rounds up to next dollar) + * @example formatPrice(0.991) => "$1" (rounds up from .991) */ export const formatPrice = ( price: string | number, _options?: { minimumDecimals?: number; maximumDecimals?: number }, ): string => { const num = typeof price === 'string' ? parseFloat(price) : price; + const maximumDecimals = _options?.maximumDecimals ?? 2; + const minimumDecimals = _options?.minimumDecimals; if (isNaN(num)) { return '$0.00'; } - // Truncate to 2 decimal places (no rounding) - const truncated = Math.floor(num * 100) / 100; + // Round to the specified maximum decimal places + const multiplier = Math.pow(10, maximumDecimals); + const rounded = Math.round(num * multiplier) / multiplier; + + // Check if it's an integer (no decimal part) + const isInteger = rounded === Math.floor(rounded); + + // Format with appropriate decimal places + // If user explicitly set minimumDecimals, use it; otherwise, show no decimals for integers + const minFractionDigits = + minimumDecimals !== undefined ? minimumDecimals : isInteger ? 0 : 2; - // Format with exactly 2 decimal places return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(truncated); + minimumFractionDigits: minFractionDigits, + maximumFractionDigits: maximumDecimals, + }).format(rounded); }; /** diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index 2e08d6ce1706..a18edfd388ad 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -132,7 +132,12 @@ jest.mock('react-native-safe-area-context', () => { }); jest.mock('../../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string) => key), + strings: jest.fn((key: string, vars?: Record) => { + if (key === 'predict.position_info' && vars) { + return `${vars.initialValue} on ${vars.outcome} to win ${vars.shares}`; + } + return key; + }), })); jest.mock('../../../Navbar', () => ({ @@ -166,6 +171,12 @@ jest.mock('../../utils/format', () => ({ } return `${cents.toFixed(1)}¢`; }), + formatPositionSize: jest.fn( + ( + value: number, + options?: { minimumDecimals?: number; maximumDecimals?: number }, + ) => value.toFixed(options?.maximumDecimals || 2), + ), })); jest.mock('../../hooks/usePredictMarket', () => ({ @@ -1527,7 +1538,9 @@ describe('PredictMarketDetails', () => { expect(screen.getByText('predict.cash_out')).toBeOnTheScreen(); expect( - screen.getByText('$65.00 on Yes • 65¢', { exact: false }), + screen.getByText('$65.00 on Yes to win $100.00', { + exact: false, + }), ).toBeOnTheScreen(); expect(screen.getByText('+7.70%')).toBeOnTheScreen(); }); @@ -1705,7 +1718,9 @@ describe('PredictMarketDetails', () => { expect(screen.getByText('Yes Option')).toBeOnTheScreen(); expect( - screen.getByText('$65.00 on Yes • 65¢', { exact: false }), + screen.getByText('$65.00 on Yes to win $100.00', { + exact: false, + }), ).toBeOnTheScreen(); }); @@ -1736,7 +1751,9 @@ describe('PredictMarketDetails', () => { expect(screen.getByText('Yes')).toBeOnTheScreen(); expect( - screen.getByText('$65.00 on Yes • 65¢', { exact: false }), + screen.getByText('$65.00 on Yes to win $100.00', { + exact: false, + }), ).toBeOnTheScreen(); }); diff --git a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.test.tsx b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.test.tsx index 9fd2f40d84bf..a9ad0f5915e5 100644 --- a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.test.tsx +++ b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.test.tsx @@ -130,11 +130,18 @@ jest.mock('react-native-safe-area-context', () => ({ // Mock format utilities const mockFormatPrice = jest.fn(); const mockFormatPercentage = jest.fn(); +const mockFormatPositionSize = jest.fn(); +const mockFormatCents = jest.fn(); jest.mock('../../utils/format', () => ({ formatPrice: (value: number, options?: { maximumDecimals?: number }) => mockFormatPrice(value, options), formatPercentage: (value: number) => mockFormatPercentage(value), + formatPositionSize: ( + value: number, + options?: { minimumDecimals?: number; maximumDecimals?: number }, + ) => mockFormatPositionSize(value, options), + formatCents: (value: number) => mockFormatCents(value), })); // Mock BottomSheetHeader to avoid Icon component issues @@ -361,6 +368,14 @@ describe('PredictSellPreview', () => { return `$${value}`; }); mockFormatPercentage.mockImplementation((value) => `${value}% return`); + mockFormatPositionSize.mockImplementation((value, options) => { + const decimals = options?.maximumDecimals ?? 2; + return value.toFixed(decimals); + }); + mockFormatCents.mockImplementation((value) => { + const cents = value * 100; + return `${cents.toFixed(0)}¢`; + }); }); afterEach(() => { @@ -378,7 +393,7 @@ describe('PredictSellPreview', () => { expect(getAllByText('Cash out').length).toBeGreaterThan(0); expect(getByText('Will Bitcoin reach $150,000?')).toBeOnTheScreen(); - expect(getByText('$50.00 on Yes at 50¢')).toBeOnTheScreen(); + expect(getByText('Selling 50.00 shares at 50¢')).toBeOnTheScreen(); expect( queryByText('Funds will be added to your available balance'), @@ -441,14 +456,14 @@ describe('PredictSellPreview', () => { expect(mockFormatPercentage).toHaveBeenCalledWith(-20); }); - it('uses position price when preview sharePrice is undefined', () => { + it('uses zero when preview sharePrice is zero', () => { mockPreview = { marketId: 'market-1', outcomeId: 'outcome-456', outcomeTokenId: 'outcome-token-789', timestamp: Date.now(), side: 'SELL', - sharePrice: undefined as unknown as number, + sharePrice: 0, maxAmountSpent: 100, minAmountReceived: 60, slippage: 0.005, @@ -461,7 +476,7 @@ describe('PredictSellPreview', () => { state: initialState, }); - expect(getByText('At price: 50¢ per share')).toBeOnTheScreen(); + expect(getByText('Selling 50.00 shares at 0¢')).toBeOnTheScreen(); }); it('renders position icon with correct source', () => { diff --git a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx index 9f8f33225aca..d484cb68c889 100644 --- a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx +++ b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx @@ -1,3 +1,8 @@ +import { + Box, + ButtonSize as ButtonSizeHero, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { NavigationProp, RouteProp, @@ -7,39 +12,40 @@ import { } from '@react-navigation/native'; import React, { useCallback, useEffect, useMemo } from 'react'; import { ActivityIndicator, Image, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { PredictCashOutSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; +import { strings } from '../../../../../../locales/i18n'; +import ButtonHero from '../../../../../component-library/components-temp/Buttons/ButtonHero'; import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; import Button, { ButtonSize, ButtonVariants, ButtonWidthTypes, } from '../../../../../component-library/components/Buttons/Button'; +import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; import Text, { TextColor, TextVariant, } from '../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../component-library/hooks/useStyles'; import Engine from '../../../../../core/Engine'; +import { TraceName } from '../../../../../util/trace'; +import { + PredictEventValues, + PredictTradeStatus, +} from '../../constants/eventNames'; +import { usePredictMeasurement } from '../../hooks/usePredictMeasurement'; import { usePredictOrderPreview } from '../../hooks/usePredictOrderPreview'; import { usePredictPlaceOrder } from '../../hooks/usePredictPlaceOrder'; import { Side } from '../../types'; import { PredictNavigationParamList } from '../../types/navigation'; import { - PredictTradeStatus, - PredictEventValues, -} from '../../constants/eventNames'; -import { formatPercentage, formatPrice } from '../../utils/format'; + formatCents, + formatPercentage, + formatPositionSize, + formatPrice, +} from '../../utils/format'; import styleSheet from './PredictSellPreview.styles'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { PredictCashOutSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; -import { strings } from '../../../../../../locales/i18n'; -import { - Box, - ButtonSize as ButtonSizeHero, -} from '@metamask/design-system-react-native'; -import ButtonHero from '../../../../../component-library/components-temp/Buttons/ButtonHero'; -import { TraceName } from '../../../../../util/trace'; -import { usePredictMeasurement } from '../../hooks/usePredictMeasurement'; const PredictSellPreview = () => { const tw = useTailwind(); @@ -50,8 +56,15 @@ const PredictSellPreview = () => { useRoute>(); const { market, position, outcome, entryPoint } = route.params; - const { icon, title, outcome: outcomeSideText, initialValue } = position; + const { + icon, + title, + outcome: outcomeSideText, + initialValue, + size, + } = position; + const outcomeGroupTitle = outcome?.groupItemTitle ?? ''; const outcomeTitle = title; // Prepare analytics properties for sell/cash-out action @@ -130,10 +143,26 @@ const PredictSellPreview = () => { }, [dispatch, result]); const currentValue = preview?.minAmountReceived ?? 0; - const currentPrice = preview?.sharePrice ?? position?.price; - const { cashPnl, percentPnl, avgPrice } = position; + const currentPrice = preview?.sharePrice ?? 0; + const { avgPrice } = position; + + // Recalculate PnL based on preview data + const cashPnl = useMemo( + () => currentValue - initialValue, + [currentValue, initialValue], + ); + + const percentPnl = useMemo( + () => (initialValue > 0 ? (cashPnl / initialValue) * 100 : 0), + [cashPnl, initialValue], + ); - const signal = useMemo(() => (cashPnl >= 0 ? '+' : '-'), [cashPnl]); + const signal = useMemo(() => { + if (cashPnl === 0) { + return ''; + } + return cashPnl > 0 ? '+' : '-'; + }, [cashPnl]); const onCashOut = useCallback(async () => { if (!preview) return; @@ -200,26 +229,58 @@ const PredictSellPreview = () => { style={styles.container} > - - {formatPrice(currentValue, { maximumDecimals: 2 })} - - - {strings('predict.at_price_per_share', { - price: (currentPrice * 100).toFixed(0), - })} - - 0 ? TextColor.Success : TextColor.Error} - variant={TextVariant.BodyMDMedium} - > - {`${signal}${formatPrice(Math.abs(cashPnl), { - maximumDecimals: 2, - })} (${formatPercentage(percentPnl)})`} - + {!preview ? ( + + + + + + ) : ( + <> + + {formatPrice(currentValue, { maximumDecimals: 2 })} + + + {strings('predict.at_price_per_share', { + size: formatPositionSize(size, { + minimumDecimals: 2, + maximumDecimals: 2, + }), + price: formatCents(currentPrice), + })} + + 0 ? TextColor.Success : TextColor.Error} + variant={TextVariant.BodyMDMedium} + > + {`${signal}${formatPrice(Math.abs(cashPnl), { + maximumDecimals: 4, + })} (${formatPercentage(percentPnl)})`} + + + )} {placeOrderError && ( @@ -243,11 +304,18 @@ const PredictSellPreview = () => { variant={TextVariant.BodySMMedium} color={TextColor.Alternative} > - {strings('predict.cashout_info', { - amount: formatPrice(initialValue, { maximumDecimals: 2 }), - outcome: outcomeSideText, - initialPrice: (avgPrice * 100).toFixed(0), - })} + {outcomeGroupTitle + ? strings('predict.cashout_info_multiple', { + amount: formatPrice(initialValue), + outcomeGroupTitle, + outcome: outcomeSideText, + initialPrice: formatCents(avgPrice), + }) + : strings('predict.cashout_info', { + amount: formatPrice(initialValue), + outcome: outcomeSideText, + initialPrice: formatCents(avgPrice), + })} diff --git a/app/components/Views/AccountSelector/AccountSelector.styles.ts b/app/components/Views/AccountSelector/AccountSelector.styles.ts index 53a81361b9cd..6e8e7b2681d9 100644 --- a/app/components/Views/AccountSelector/AccountSelector.styles.ts +++ b/app/components/Views/AccountSelector/AccountSelector.styles.ts @@ -1,5 +1,6 @@ import { StyleSheet } from 'react-native'; import { Theme } from '../../../util/theme/models'; +import { colors as importedColors } from '../../../styles/common'; const styleSheet = (params: { theme: Theme }) => { const { theme } = params; @@ -10,9 +11,17 @@ const styleSheet = (params: { theme: Theme }) => { marginVertical: 16, marginHorizontal: 16, }, - bottomSheetContent: { + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: colors.overlay.default, + }, + keyboardAvoidingView: { + flex: 1, + backgroundColor: importedColors.transparent, + }, + container: { + flex: 1, backgroundColor: colors.background.default, - display: 'flex', }, }); }; diff --git a/app/components/Views/AccountSelector/AccountSelector.test.tsx b/app/components/Views/AccountSelector/AccountSelector.test.tsx index 1faacd1e0140..a431b6545e37 100644 --- a/app/components/Views/AccountSelector/AccountSelector.test.tsx +++ b/app/components/Views/AccountSelector/AccountSelector.test.tsx @@ -1,10 +1,12 @@ import React from 'react'; -import { screen, fireEvent } from '@testing-library/react-native'; +import { screen, fireEvent, waitFor } from '@testing-library/react-native'; import AccountSelector from './AccountSelector'; import { renderScreen } from '../../../util/test/renderWithProvider'; import { AccountListBottomSheetSelectorsIDs } from '../../../../e2e/selectors/wallet/AccountListBottomSheet.selectors'; import { AddAccountBottomSheetSelectorsIDs } from '../../../../e2e/selectors/wallet/AddAccountBottomSheet.selectors'; +import { CellComponentSelectorsIDs } from '../../../../e2e/selectors/wallet/CellComponent.selectors'; import Routes from '../../../constants/navigation/Routes'; +import Engine from '../../../core/Engine'; import { AccountSelectorParams, AccountSelectorProps, @@ -18,6 +20,15 @@ import { internalSolanaAccount1, } from '../../../util/test/accountsControllerTestUtils'; +jest.mock('../../hooks/useFeatureFlag', () => ({ + useFeatureFlag: jest.fn(() => false), // Default to BottomSheet version for tests + FeatureFlagNames: { + rewardsEnabled: 'rewardsEnabled', + otaUpdatesEnabled: 'otaUpdatesEnabled', + fullPageAccountList: 'fullPageAccountList', + }, +})); + const mockAvatarAccountType = 'Maskicon' as const; const mockAccounts = [ @@ -201,6 +212,7 @@ const AccountSelectorWrapper = () => ; describe('AccountSelector', () => { beforeEach(() => { + jest.useFakeTimers(); jest.clearAllMocks(); // Reset multichain selectors to disabled state by default mockSelectMultichainAccountsState2Enabled.mockReturnValue(false); @@ -214,6 +226,17 @@ describe('AccountSelector', () => { }); }); + afterEach(() => { + // Only flush timers if fake timers are active + try { + jest.runOnlyPendingTimers(); + jest.clearAllTimers(); + } catch (e) { + // Fake timers not active, skip + } + jest.useRealTimers(); + }); + it('should render correctly', () => { const wrapper = renderScreen( AccountSelectorWrapper, @@ -355,6 +378,9 @@ describe('AccountSelector', () => { }); it('handles navigation to add account actions', () => { + // Use real timers for this test to avoid animation timing issues + jest.useRealTimers(); + mockSelectMultichainAccountsState2Enabled.mockReturnValue(true); const routeWithNavigation = { @@ -378,9 +404,15 @@ describe('AccountSelector', () => { ); expect(screen.getAllByText('Import a wallet')).toBeDefined(); + + // Restore fake timers for other tests + jest.useFakeTimers(); }); it('clicks Add wallet button and displays MultichainAddWalletActions bottomsheet', () => { + // Use real timers for this test to avoid animation timing issues + jest.useRealTimers(); + // Enable the multichain accounts state 2 feature flag for this test mockSelectMultichainAccountsState2Enabled.mockReturnValue(true); @@ -408,6 +440,9 @@ describe('AccountSelector', () => { expect( screen.getByTestId(AddAccountBottomSheetSelectorsIDs.IMPORT_SRP_BUTTON), ).toBeDefined(); + + // Restore fake timers for other tests + jest.useFakeTimers(); }); }); @@ -470,6 +505,9 @@ describe('AccountSelector', () => { }); it('shows activity indicator when syncing is in progress', () => { + // Use real timers for this test to avoid animation timing issues + jest.useRealTimers(); + mockUseAccountsOperationsLoadingStates.mockReturnValue({ isAccountSyncingInProgress: true, areAnyOperationsLoading: true, @@ -493,9 +531,15 @@ describe('AccountSelector', () => { ); expect(addButton).toBeDefined(); expect(addButton).toHaveTextContent('Syncing...'); + + // Restore fake timers for other tests + jest.useFakeTimers(); }); it('shows different button text based on multichain feature flag when not syncing', () => { + // Use real timers for this test to avoid animation timing issues + jest.useRealTimers(); + // Test with multichain enabled mockSelectMultichainAccountsState2Enabled.mockReturnValue(true); @@ -514,6 +558,9 @@ describe('AccountSelector', () => { AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ADD_BUTTON_ID, ); expect(addButton).toHaveTextContent('Add wallet'); + + // Restore fake timers for other tests + jest.useFakeTimers(); }); it('shows default button text when multichain is disabled and not syncing', () => { @@ -538,6 +585,9 @@ describe('AccountSelector', () => { }); it('prioritizes syncing message over feature flag text', () => { + // Use real timers for this test to avoid animation timing issues + jest.useRealTimers(); + mockSelectMultichainAccountsState2Enabled.mockReturnValue(true); mockUseAccountsOperationsLoadingStates.mockReturnValue({ isAccountSyncingInProgress: true, @@ -561,6 +611,9 @@ describe('AccountSelector', () => { ); // Should show syncing message, not "Add wallet" expect(addButton).toHaveTextContent('Syncing...'); + + // Restore fake timers for other tests + jest.useFakeTimers(); }); it('enables button when syncing completes', () => { @@ -615,4 +668,243 @@ describe('AccountSelector', () => { expect(addButton).toHaveTextContent('Add account or hardware wallet'); }); }); + + describe('Feature Flag: Full-Page Account List', () => { + let mockUseFeatureFlag: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseFeatureFlag = jest.requireMock( + '../../hooks/useFeatureFlag', + ).useFeatureFlag; + }); + + it('renders BottomSheet when feature flag is disabled', () => { + mockUseFeatureFlag.mockReturnValue(false); + + renderScreen( + AccountSelectorWrapper, + { + name: Routes.SHEET.ACCOUNT_SELECTOR, + }, + { + state: mockInitialState, + }, + mockRoute.params, + ); + + // BottomSheet version renders the sheet header + expect(screen.getByText('Accounts')).toBeDefined(); + // Accounts list is present + expect( + screen.getByTestId(AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ID), + ).toBeDefined(); + }); + + it('renders full-page modal when feature flag is enabled', () => { + mockUseFeatureFlag.mockReturnValue(true); + + renderScreen( + AccountSelectorWrapper, + { + name: Routes.SHEET.ACCOUNT_SELECTOR, + }, + { + state: mockInitialState, + }, + mockRoute.params, + ); + + // Full-page version has sheet header with back button + expect(screen.getByText('Accounts')).toBeDefined(); + // Accounts list is present + expect( + screen.getByTestId(AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ID), + ).toBeDefined(); + }); + + it('renders add button in both modes', () => { + // Arrange: BottomSheet mode + mockUseFeatureFlag.mockReturnValue(false); + + // Act: Render in BottomSheet mode + const { unmount } = renderScreen( + AccountSelectorWrapper, + { + name: Routes.SHEET.ACCOUNT_SELECTOR, + }, + { + state: mockInitialState, + }, + mockRoute.params, + ); + + // Assert: Add button is present + expect( + screen.getByTestId( + AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ADD_BUTTON_ID, + ), + ).toBeDefined(); + + unmount(); + + // Arrange: Full-page mode + jest.useRealTimers(); + mockUseFeatureFlag.mockReturnValue(true); + + // Act: Render in full-page mode + renderScreen( + AccountSelectorWrapper, + { + name: Routes.SHEET.ACCOUNT_SELECTOR, + }, + { + state: mockInitialState, + }, + mockRoute.params, + ); + + // Assert: Add button is present + expect( + screen.getByTestId( + AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ADD_BUTTON_ID, + ), + ).toBeDefined(); + + jest.useFakeTimers(); + }); + + it('switches between multichain screens in full-page mode', () => { + // Arrange + jest.useRealTimers(); + mockUseFeatureFlag.mockReturnValue(true); + mockSelectMultichainAccountsState2Enabled.mockReturnValue(true); + + renderScreen( + AccountSelectorWrapper, + { + name: Routes.SHEET.ACCOUNT_SELECTOR, + }, + { + state: mockInitialState, + }, + mockRoute.params, + ); + + const addButton = screen.getByTestId( + AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ADD_BUTTON_ID, + ); + + // Act + fireEvent.press(addButton); + + // Assert: MultichainAddWalletActions screen is displayed + expect(screen.getByText('Add wallet')).toBeDefined(); + + jest.useFakeTimers(); + }); + + it('closes BottomSheet when account is selected with feature flag disabled', async () => { + // Arrange + mockUseFeatureFlag.mockReturnValue(false); + + const { getAllByTestId } = renderScreen( + AccountSelectorWrapper, + { + name: Routes.SHEET.ACCOUNT_SELECTOR, + }, + { + state: mockInitialState, + }, + mockRoute.params, + ); + + // Wait for account cells to render + await waitFor(() => { + const cells = getAllByTestId( + CellComponentSelectorsIDs.SELECT_WITH_MENU, + ); + expect(cells.length).toBeGreaterThan(0); + }); + + const accountCells = getAllByTestId( + CellComponentSelectorsIDs.SELECT_WITH_MENU, + ); + + // Act + fireEvent.press(accountCells[0]); + + // Assert: Account was selected + expect(Engine.setSelectedAddress).toHaveBeenCalled(); + }); + + it('renders SheetHeader with title in full-page mode', () => { + // Arrange + mockUseFeatureFlag.mockReturnValue(true); + + renderScreen( + AccountSelectorWrapper, + { + name: Routes.SHEET.ACCOUNT_SELECTOR, + }, + { + state: mockInitialState, + }, + mockRoute.params, + ); + + // Assert: SheetHeader with title is present in full-page mode + expect(screen.getByText('Accounts')).toBeDefined(); + // Verify accounts list is also present (confirms we're on the right screen) + expect( + screen.getByTestId(AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ID), + ).toBeDefined(); + }); + + it('closes full-page modal when account is selected with feature flag enabled', async () => { + // Arrange + jest.useRealTimers(); + mockUseFeatureFlag.mockReturnValue(true); + + // Mock the useNavigation hook to prevent navigation warnings + const mockGoBack = jest.fn(); + const useNavigationMock = jest.requireMock('@react-navigation/native'); + useNavigationMock.useNavigation = jest.fn(() => ({ + goBack: mockGoBack, + navigate: jest.fn(), + dispatch: jest.fn(), + })); + + const { getAllByTestId } = renderScreen( + AccountSelectorWrapper, + { + name: Routes.SHEET.ACCOUNT_SELECTOR, + }, + { + state: mockInitialState, + }, + mockRoute.params, + ); + + // Wait for account cells to render + await waitFor(() => { + const cells = getAllByTestId( + CellComponentSelectorsIDs.SELECT_WITH_MENU, + ); + expect(cells.length).toBeGreaterThan(0); + }); + + const accountCells = getAllByTestId( + CellComponentSelectorsIDs.SELECT_WITH_MENU, + ); + + // Act + fireEvent.press(accountCells[0]); + + // Assert: Account was selected + expect(Engine.setSelectedAddress).toHaveBeenCalled(); + + jest.useFakeTimers(); + }); + }); }); diff --git a/app/components/Views/AccountSelector/AccountSelector.tsx b/app/components/Views/AccountSelector/AccountSelector.tsx index 04d720f59913..da16539bf735 100644 --- a/app/components/Views/AccountSelector/AccountSelector.tsx +++ b/app/components/Views/AccountSelector/AccountSelector.tsx @@ -3,10 +3,28 @@ import React, { Fragment, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, } from 'react'; +import { + KeyboardAvoidingView, + Platform, + ActivityIndicator, + useWindowDimensions, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + withSpring, + runOnJS, + useDerivedValue, + interpolate, +} from 'react-native-reanimated'; // External dependencies. import EvmAccountSelectorList from '../../UI/EvmAccountSelectorList'; @@ -15,8 +33,10 @@ import { MultichainAddWalletActions } from '../../../component-library/component import BottomSheet, { BottomSheetRef, } from '../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader'; import SheetHeader from '../../../component-library/components/Sheet/SheetHeader'; import Engine from '../../../core/Engine'; +import { useFeatureFlag, FeatureFlagNames } from '../../hooks/useFeatureFlag'; import { store } from '../../../store'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { strings } from '../../../../locales/i18n'; @@ -57,19 +77,29 @@ import BottomSheetFooter from '../../../component-library/components/BottomSheet import { ButtonProps } from '../../../component-library/components/Buttons/Button/Button.types'; import { useSyncSRPs } from '../../hooks/useSyncSRPs'; import { useAccountsOperationsLoadingStates } from '../../../util/accounts/useAccountsOperationsLoadingStates'; -import { ActivityIndicator } from 'react-native'; import { Box } from '../../UI/Box/Box'; import { AlignItems, FlexDirection, JustifyContent, } from '../../UI/Box/box.types'; +import { AnimationDuration } from '../../../component-library/constants/animation.constants'; const AccountSelector = ({ route }: AccountSelectorProps) => { const { styles } = useStyles(styleSheet, {}); const dispatch = useDispatch(); + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const { height: screenHeight } = useWindowDimensions(); const { trackEvent, createEventBuilder } = useMetrics(); const routeParams = useMemo(() => route?.params, [route?.params]); + + // Feature flag for full-page account list + const isFullPageAccountList = useFeatureFlag( + FeatureFlagNames.fullPageAccountList, + ); + const sheetRef = useRef(null); + const { onSelectAccount, disablePrivacyMode, @@ -85,7 +115,6 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { selectMultichainAccountsState2Enabled, ); const selectedAccountGroup = useSelector(selectSelectedAccountGroup); - const sheetRef = useRef(null); const { isAccountSyncingInProgress, @@ -132,16 +161,64 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { const [keyboardAvoidingViewEnabled, setKeyboardAvoidingViewEnabled] = useState(false); + // Animation using react-native-reanimated - only for full-page version + const translateY = useSharedValue(screenHeight); + + // Backdrop opacity animation - fades in as screen slides up + const backdropOpacity = useDerivedValue(() => + interpolate(translateY.value, [screenHeight, 0], [0, 0.5]), + ); + useEffect(() => { if (reloadAccounts) { dispatch(setReloadAccounts(false)); } }, [dispatch, reloadAccounts]); + useLayoutEffect(() => { + if (!isFullPageAccountList) return; + if (screen !== AccountSelectorScreens.AccountSelector) return; + + const onAnimationComplete = () => { + endTrace({ + name: TraceName.ShowAccountList, + }); + }; + + translateY.value = withSpring( + 0, + { + damping: 20, + stiffness: 500, + mass: 0.3, + }, + () => runOnJS(onAnimationComplete)(), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isFullPageAccountList, screen]); + + const closeModal = useCallback(() => { + if (isFullPageAccountList) { + // Full-page version: animate out then navigate back + const onCloseComplete = () => { + navigation.goBack(); + }; + + translateY.value = withTiming( + screenHeight, + { duration: AnimationDuration.Fast }, + () => runOnJS(onCloseComplete)(), + ); + } else { + // BottomSheet version: close the sheet + sheetRef.current?.onCloseBottomSheet(); + } + }, [isFullPageAccountList, translateY, navigation, screenHeight]); + const _onSelectAccount = useCallback( (address: string) => { Engine.setSelectedAddress(address); - sheetRef.current?.onCloseBottomSheet(); + closeModal(); onSelectAccount?.(address); // Track Event: "Switched Account" @@ -154,7 +231,13 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { .build(), ); }, - [accounts?.length, onSelectAccount, trackEvent, createEventBuilder], + [ + accounts?.length, + onSelectAccount, + trackEvent, + createEventBuilder, + closeModal, + ], ); const _onSelectMultichainAccount = useCallback( @@ -162,7 +245,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { Engine.context.AccountTreeController.setSelectedAccountGroup( accountGroup.id, ); - sheetRef.current?.onCloseBottomSheet(); + closeModal(); trackEvent( createEventBuilder(MetaMetricsEvents.SWITCHED_ACCOUNT) @@ -173,7 +256,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { .build(), ); }, - [accounts?.length, trackEvent, createEventBuilder], + [accounts?.length, trackEvent, createEventBuilder, closeModal], ); const handleAddAccount = useCallback(() => { @@ -207,17 +290,18 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { op: TraceOperation.AccountUi, tags: getTraceTags(store.getState()), }); + // Trace ends in animation callback } }, [isAccountSelector]); - // We want to track the full render of the account list, meaning when the full animation is done, so - // we hook the open animation and end the trace there. - const onOpen = useCallback(() => { - if (isAccountSelector) { + + // End trace when bottom sheet opens (only for non-full-page version) + const onBottomSheetOpen = useCallback(() => { + if (!isFullPageAccountList && isAccountSelector) { endTrace({ name: TraceName.ShowAccountList, }); } - }, [isAccountSelector]); + }, [isFullPageAccountList, isAccountSelector]); const addAccountButtonProps: ButtonProps[] = useMemo( () => [ @@ -260,7 +344,6 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { const renderAccountSelector = useCallback( () => ( - {isMultichainAccountsState2Enabled && selectedAccountGroup ? ( { renderMultichainAddWalletActions, ]); + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateY.value }], + })); + + const backdropStyle = useAnimatedStyle(() => ({ + opacity: backdropOpacity.value, + })); + + if ( + isFullPageAccountList && + screen === AccountSelectorScreens.AccountSelector + ) { + return ( + <> + + + + + {renderAccountScreens()} + + + + ); + } + + // Render BottomSheet version return ( + {screen === AccountSelectorScreens.AccountSelector && ( + + )} + {screen === AccountSelectorScreens.AddAccountActions && ( + + {strings('account_actions.add_account')} + + )} + {screen === AccountSelectorScreens.MultichainAddWalletActions && ( + + {strings('multichain_accounts.add_wallet')} + + )} {renderAccountScreens()} ); diff --git a/app/components/Views/AccountSelector/__snapshots__/AccountSelector.test.tsx.snap b/app/components/Views/AccountSelector/__snapshots__/AccountSelector.test.tsx.snap index fc510949c731..f396c9ec8a68 100644 --- a/app/components/Views/AccountSelector/__snapshots__/AccountSelector.test.tsx.snap +++ b/app/components/Views/AccountSelector/__snapshots__/AccountSelector.test.tsx.snap @@ -388,7 +388,6 @@ exports[`AccountSelector should render correctly 1`] = ` "borderTopLeftRadius": 24, "borderTopRightRadius": 24, "borderWidth": 1, - "display": "flex", "maxHeight": 1334, "overflow": "hidden", "paddingBottom": 0, diff --git a/app/components/Views/AddAccountActions/AddAccountActions.tsx b/app/components/Views/AddAccountActions/AddAccountActions.tsx index 0a0c2ac02d1e..2acb192cc7fe 100644 --- a/app/components/Views/AddAccountActions/AddAccountActions.tsx +++ b/app/components/Views/AddAccountActions/AddAccountActions.tsx @@ -5,7 +5,6 @@ import { useSelector } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; // External dependencies. -import SheetHeader from '../../../component-library/components/Sheet/SheetHeader'; import AccountAction from '../AccountAction/AccountAction'; import { IconName } from '../../../component-library/components/Icons/Icon'; import { strings } from '../../../../locales/i18n'; @@ -137,10 +136,6 @@ const AddAccountActions = ({ onBack }: AddAccountActionsProps) => { return ( - - - - - - - - - Create a new account - - - { expect(getByTestId('confirm-loader-custom-amount')).toBeDefined(); }); + it('displays PredictClaim loader when specified', () => { + useParamsMock.mockReturnValue({ + loader: ConfirmationLoader.PredictClaim, + }); + + const stateWithoutRequest = cloneDeep(typedSignV1ConfirmationState); + stateWithoutRequest.engine.backgroundState.ApprovalController = { + pendingApprovals: {}, + pendingApprovalCount: 0, + approvalFlows: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const { getByTestId } = renderWithProvider(, { + state: stateWithoutRequest, + }); + + expect(getByTestId('confirm-loader-predict-claim')).toBeDefined(); + }); + + it('displays Transfer loader when specified', () => { + useParamsMock.mockReturnValue({ + loader: ConfirmationLoader.Transfer, + }); + + const stateWithoutRequest = cloneDeep(typedSignV1ConfirmationState); + stateWithoutRequest.engine.backgroundState.ApprovalController = { + pendingApprovals: {}, + pendingApprovalCount: 0, + approvalFlows: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const { getByTestId } = renderWithProvider(, { + state: stateWithoutRequest, + }); + + expect(getByTestId('confirm-loader-transfer')).toBeDefined(); + }); + + it('renders InfoLoader with SafeAreaView for CustomAmount loader', () => { + useParamsMock.mockReturnValue({ + loader: ConfirmationLoader.CustomAmount, + }); + + const stateWithoutRequest = cloneDeep(typedSignV1ConfirmationState); + stateWithoutRequest.engine.backgroundState.ApprovalController = { + pendingApprovals: {}, + pendingApprovalCount: 0, + approvalFlows: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const { getByTestId, UNSAFE_queryAllByType } = renderWithProvider( + , + { + state: stateWithoutRequest, + }, + ); + + const loaderContainer = getByTestId('confirm-loader-custom-amount'); + const scrollViews = UNSAFE_queryAllByType(ScrollView); + + expect(loaderContainer).toBeDefined(); + expect(scrollViews.length).toBeGreaterThan(0); + }); + + it('renders InfoLoader with SafeAreaView for PredictClaim loader', () => { + useParamsMock.mockReturnValue({ + loader: ConfirmationLoader.PredictClaim, + }); + + const stateWithoutRequest = cloneDeep(typedSignV1ConfirmationState); + stateWithoutRequest.engine.backgroundState.ApprovalController = { + pendingApprovals: {}, + pendingApprovalCount: 0, + approvalFlows: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const { getByTestId, UNSAFE_queryAllByType } = renderWithProvider( + , + { + state: stateWithoutRequest, + }, + ); + + const loaderContainer = getByTestId('confirm-loader-predict-claim'); + const scrollViews = UNSAFE_queryAllByType(ScrollView); + + expect(loaderContainer).toBeDefined(); + expect(scrollViews.length).toBeGreaterThan(0); + }); + + it('renders InfoLoader with SafeAreaView for Transfer loader', () => { + useParamsMock.mockReturnValue({ + loader: ConfirmationLoader.Transfer, + }); + + const stateWithoutRequest = cloneDeep(typedSignV1ConfirmationState); + stateWithoutRequest.engine.backgroundState.ApprovalController = { + pendingApprovals: {}, + pendingApprovalCount: 0, + approvalFlows: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const { getByTestId, UNSAFE_queryAllByType } = renderWithProvider( + , + { + state: stateWithoutRequest, + }, + ); + + const loaderContainer = getByTestId('confirm-loader-transfer'); + const scrollViews = UNSAFE_queryAllByType(ScrollView); + + expect(loaderContainer).toBeDefined(); + expect(scrollViews.length).toBeGreaterThan(0); + }); + + it('defaults to Default loader when no loader param is provided', () => { + useParamsMock.mockReturnValue({}); + + const stateWithoutRequest = cloneDeep(typedSignV1ConfirmationState); + stateWithoutRequest.engine.backgroundState.ApprovalController = { + pendingApprovals: {}, + pendingApprovalCount: 0, + approvalFlows: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const { getByTestId } = renderWithProvider(, { + state: stateWithoutRequest, + }); + + expect(getByTestId('confirm-loader-default')).toBeDefined(); + }); + + it('defaults to Default loader when loader param is undefined', () => { + useParamsMock.mockReturnValue({ + loader: undefined, + }); + + const stateWithoutRequest = cloneDeep(typedSignV1ConfirmationState); + stateWithoutRequest.engine.backgroundState.ApprovalController = { + pendingApprovals: {}, + pendingApprovalCount: 0, + approvalFlows: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const { getByTestId } = renderWithProvider(, { + state: stateWithoutRequest, + }); + + expect(getByTestId('confirm-loader-default')).toBeDefined(); + }); + it('sets navigation options with header hidden for modal confirmations', () => { renderWithProvider(, { state: typedSignV1ConfirmationState, diff --git a/app/components/Views/confirmations/components/confirm/confirm-component.tsx b/app/components/Views/confirmations/components/confirm/confirm-component.tsx index 2b8e9a855086..30878c0e0db8 100755 --- a/app/components/Views/confirmations/components/confirm/confirm-component.tsx +++ b/app/components/Views/confirmations/components/confirm/confirm-component.tsx @@ -19,7 +19,7 @@ import { ConfirmationAssetPollingProvider } from '../confirmation-asset-polling- import AlertBanner from '../alert-banner'; import Info from '../info-root'; import Title from '../title'; -import { Footer } from '../footer'; +import { Footer, FooterSkeleton } from '../footer'; import { Splash } from '../splash'; import styleSheet from './confirm-component.styles'; import { TransactionType } from '@metamask/transaction-controller'; @@ -30,6 +30,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { useTransactionMetadataRequest } from '../../hooks/transactions/useTransactionMetadataRequest'; import { hasTransactionType } from '../../utils/transaction'; import { PredictClaimInfoSkeleton } from '../info/predict-claim-info'; +import { TransferInfoSkeleton } from '../info/transfer/transfer'; const TRANSACTION_TYPES_DISABLE_SCROLL = [TransactionType.predictClaim]; @@ -43,6 +44,7 @@ export enum ConfirmationLoader { Default = 'default', CustomAmount = 'customAmount', PredictClaim = 'predictClaim', + Transfer = 'transfer', } export interface ConfirmationParams { @@ -172,7 +174,7 @@ function Loader() { if (loader === ConfirmationLoader.CustomAmount) { return ( - + ); @@ -180,12 +182,20 @@ function Loader() { if (loader === ConfirmationLoader.PredictClaim) { return ( - + ); } + if (loader === ConfirmationLoader.Transfer) { + return ( + + + + ); + } + return ( @@ -196,9 +206,11 @@ function Loader() { function InfoLoader({ children, testId, + loader, }: { children: ReactNode; testId?: string; + loader: ConfirmationLoader; }) { const { styles } = useStyles(styleSheet, { isFullScreenConfirmation: true }); @@ -214,6 +226,7 @@ function InfoLoader({ > {children} + {loader === ConfirmationLoader.Transfer && } ); } diff --git a/app/components/Views/confirmations/components/footer/footer.styles.ts b/app/components/Views/confirmations/components/footer/footer.styles.ts index 8a0917039aad..ebd56cfe6428 100644 --- a/app/components/Views/confirmations/components/footer/footer.styles.ts +++ b/app/components/Views/confirmations/components/footer/footer.styles.ts @@ -41,11 +41,15 @@ const styleSheet = (params: { isFullScreenConfirmation, ); + const baseFooterStyle = { + backgroundColor: colors.background.alternative, + paddingHorizontal: 16, + paddingTop: 16, + }; + return StyleSheet.create({ base: { - backgroundColor: colors.background.alternative, - paddingHorizontal: 16, - paddingTop: 16, + ...baseFooterStyle, paddingBottom: basePaddingBottom, }, linkText: { @@ -60,6 +64,16 @@ const styleSheet = (params: { flexDirection: 'row', justifyContent: 'center', }, + footerSkeletonContainer: { + ...baseFooterStyle, + flexDirection: 'row', + paddingBottom: 32, + gap: 16, + }, + footerButtonSkeleton: { + flex: 1, + borderRadius: 99, + }, }); }; diff --git a/app/components/Views/confirmations/components/footer/footer.tsx b/app/components/Views/confirmations/components/footer/footer.tsx index d7bdec6be8a9..12305b4e60b3 100644 --- a/app/components/Views/confirmations/components/footer/footer.tsx +++ b/app/components/Views/confirmations/components/footer/footer.tsx @@ -38,6 +38,7 @@ import { import { hasTransactionType } from '../../utils/transaction'; import { PredictClaimFooter } from '../predict-confirmations/predict-claim-footer/predict-claim-footer'; import { useIsTransactionPayLoading } from '../../hooks/pay/useTransactionPayData'; +import { Skeleton } from '../../../../../component-library/components/Skeleton'; const HIDE_FOOTER_BY_DEFAULT_TYPES = [ TransactionType.perpsDeposit, @@ -246,3 +247,19 @@ export const Footer = () => { ); }; + +export function FooterSkeleton() { + const { isFullScreenConfirmation } = useFullScreenConfirmation(); + const { styles } = useStyles(styleSheet, { + confirmDisabled: false, + isStakingConfirmationBool: false, + isFullScreenConfirmation, + }); + + return ( + + + + + ); +} diff --git a/app/components/Views/confirmations/components/footer/index.ts b/app/components/Views/confirmations/components/footer/index.ts index 4248c0b12e12..c1155ac7505e 100644 --- a/app/components/Views/confirmations/components/footer/index.ts +++ b/app/components/Views/confirmations/components/footer/index.ts @@ -1 +1 @@ -export { Footer } from './footer'; +export { Footer, FooterSkeleton } from './footer'; diff --git a/app/components/Views/confirmations/components/info/transfer/transfer.tsx b/app/components/Views/confirmations/components/info/transfer/transfer.tsx index bdb27c580620..003c0240e862 100644 --- a/app/components/Views/confirmations/components/info/transfer/transfer.tsx +++ b/app/components/Views/confirmations/components/info/transfer/transfer.tsx @@ -12,11 +12,20 @@ import useNavbar from '../../../hooks/ui/useNavbar'; import { useMaxValueRefresher } from '../../../hooks/useMaxValueRefresher'; import { useTokenAmount } from '../../../hooks/useTokenAmount'; import { useTransferAssetType } from '../../../hooks/useTransferAssetType'; -import { HeroRow } from '../../rows/transactions/hero-row'; -import { NetworkAndOriginRow } from '../../rows/transactions/network-and-origin-row'; -import FromToRow from '../../rows/transactions/from-to-row'; -import GasFeesDetailsRow from '../../rows/transactions/gas-fee-details-row'; -import AdvancedDetailsRow from '../../rows/transactions/advanced-details-row'; +import { HeroRow, HeroRowSkeleton } from '../../rows/transactions/hero-row'; +import { + NetworkAndOriginRow, + NetworkAndOriginRowSkeleton, +} from '../../rows/transactions/network-and-origin-row'; +import FromToRow, { + FromToRowSkeleton, +} from '../../rows/transactions/from-to-row'; +import GasFeesDetailsRow, { + GasFeesDetailsRowSkeleton, +} from '../../rows/transactions/gas-fee-details-row'; +import AdvancedDetailsRow, { + AdvancedDetailsRowSkeleton, +} from '../../rows/transactions/advanced-details-row'; const Transfer = () => { // Set navbar as first to prevent Android navigation flickering @@ -55,4 +64,19 @@ const Transfer = () => { ); }; +export function TransferInfoSkeleton() { + // Set navbar for loading state + useNavbar(strings('confirm.review')); + + return ( + + + + + + + + ); +} + export default Transfer; diff --git a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-amount/predict-claim-amount.test.tsx b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-amount/predict-claim-amount.test.tsx index 92a760ae9860..bd0137da58c9 100644 --- a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-amount/predict-claim-amount.test.tsx +++ b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-amount/predict-claim-amount.test.tsx @@ -23,7 +23,7 @@ describe('PredictClaimAmount', () => { const { getByText } = render(); // Then the formatted winnings amount is displayed - expect(getByText('$2,250.00')).toBeDefined(); + expect(getByText('$2,250')).toBeDefined(); }); it('renders formatted change and percentage', () => { @@ -31,6 +31,6 @@ describe('PredictClaimAmount', () => { const { getByText } = render(); // Then the formatted change and percentage is displayed - expect(getByText('+$750.00 (33%)')).toBeDefined(); + expect(getByText('+$750 (33.33%)')).toBeDefined(); }); }); diff --git a/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/advanced-details-row.styles.ts b/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/advanced-details-row.styles.ts index 590e7ec41bcb..875d3ea6fdb0 100644 --- a/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/advanced-details-row.styles.ts +++ b/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/advanced-details-row.styles.ts @@ -26,6 +26,16 @@ const styleSheet = (params: { dataScrollContainer: { height: 200, }, + skeletonContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingBottom: 8, + paddingHorizontal: 8, + }, + skeletonBorderRadius: { + borderRadius: 4, + }, }); }; diff --git a/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/advanced-details-row.tsx b/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/advanced-details-row.tsx index ade47ab5a520..64302ef88f12 100644 --- a/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/advanced-details-row.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/advanced-details-row.tsx @@ -1,4 +1,5 @@ import React, { useCallback } from 'react'; +import { View } from 'react-native'; import { Hex } from '@metamask/utils'; import { useSelector } from 'react-redux'; import { ScrollView } from 'react-native-gesture-handler'; @@ -27,6 +28,7 @@ import InfoRow from '../../../UI/info-row'; import InfoSection from '../../../UI/info-row/info-section'; import NestedTransactionData from '../../../nested-transaction-data/nested-transaction-data'; import SmartContractWithLogo from '../../../smart-contract-with-logo'; +import { Skeleton } from '../../../../../../../component-library/components/Skeleton'; import styleSheet from './advanced-details-row.styles'; const MAX_DATA_LENGTH_FOR_SCROLL = 200; @@ -161,4 +163,19 @@ const AdvancedDetailsRow = () => { ); }; +export function AdvancedDetailsRowSkeleton() { + const { styles } = useStyles(styleSheet, { + isNonceChangeDisabled: false, + }); + + return ( + + + + + + + ); +} + export default AdvancedDetailsRow; diff --git a/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/index.ts b/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/index.ts index 89f5f178831f..557a872b81bc 100644 --- a/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/index.ts +++ b/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/index.ts @@ -1 +1 @@ -export { default } from './advanced-details-row'; +export { default, AdvancedDetailsRowSkeleton } from './advanced-details-row'; diff --git a/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.styles.ts b/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.styles.ts index f8957b1daa6f..6f759856caa7 100644 --- a/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.styles.ts +++ b/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.styles.ts @@ -23,6 +23,12 @@ const styleSheet = () => iconContainer: { paddingHorizontal: 8, }, + skeletonBorderRadiusLarge: { + borderRadius: 18, + }, + skeletonBorderRadiusSmall: { + borderRadius: 4, + }, }); export default styleSheet; diff --git a/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.tsx b/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.tsx index b3ab3735c00d..ec836f59a197 100644 --- a/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.tsx @@ -15,6 +15,7 @@ import { useTransferRecipient } from '../../../../hooks/transactions/useTransfer import { RowAlertKey } from '../../../UI/info-row/alert-row/constants'; import InfoSection from '../../../UI/info-row/info-section'; import AlertRow from '../../../UI/info-row/alert-row'; +import { Skeleton } from '../../../../../../../component-library/components/Skeleton'; import styleSheet from './from-to-row.styles'; const FromToRow = () => { @@ -71,4 +72,36 @@ const FromToRow = () => { ); }; +export function FromToRowSkeleton() { + const { styles } = useStyles(styleSheet, {}); + + return ( + + + + + + + + + + + + + + ); +} + export default FromToRow; diff --git a/app/components/Views/confirmations/components/rows/transactions/from-to-row/index.ts b/app/components/Views/confirmations/components/rows/transactions/from-to-row/index.ts index 525d37a01151..507361c25295 100644 --- a/app/components/Views/confirmations/components/rows/transactions/from-to-row/index.ts +++ b/app/components/Views/confirmations/components/rows/transactions/from-to-row/index.ts @@ -1 +1 @@ -export { default } from './from-to-row'; +export { default, FromToRowSkeleton } from './from-to-row'; diff --git a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.styles.ts b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.styles.ts index 7bbff8b85bfe..c1a842ae6bc8 100644 --- a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.styles.ts +++ b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.styles.ts @@ -42,6 +42,17 @@ const styleSheet = (params: { theme: Theme }) => { textAlign: 'left', flex: 1, }, + skeletonBorderRadius: { + borderRadius: 4, + }, + skeletonRowContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + minHeight: 24, + paddingBottom: 8, + paddingHorizontal: 8, + }, }); }; diff --git a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx index f692f49471f3..446df01237eb 100644 --- a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx @@ -4,7 +4,6 @@ import { } from '@metamask/transaction-controller'; import React, { useState } from 'react'; import { TouchableOpacity, View } from 'react-native'; -import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; import { ConfirmationRowComponentIDs } from '../../../../../../../../e2e/selectors/Confirmation/ConfirmationView.selectors'; import { strings } from '../../../../../../../../locales/i18n'; import Icon, { @@ -35,6 +34,7 @@ import { GasFeeModal } from '../../../modals/gas-fee-modal'; import AlertRow from '../../../UI/info-row/alert-row'; import { RowAlertKey } from '../../../UI/info-row/alert-row/constants'; import InfoSection from '../../../UI/info-row/info-section'; +import { Skeleton } from '../../../../../../../component-library/components/Skeleton'; import styleSheet from './gas-fee-details-row.styles'; const PaidByMetaMask = () => ( @@ -43,16 +43,13 @@ const PaidByMetaMask = () => ( ); -const SkeletonEstimationInfo = () => ( - - - -); +const SkeletonEstimationInfo = () => { + const { styles } = useStyles(styleSheet, {}); + + return ( + + ); +}; const EstimationInfo = ({ hideFiatForTestnet, @@ -331,4 +328,21 @@ const GasFeesDetailsRow = ({ ); }; +export function GasFeesDetailsRowSkeleton() { + const { styles } = useStyles(styleSheet, {}); + + return ( + + + + + + + + + + + ); +} + export default GasFeesDetailsRow; diff --git a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/index.ts b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/index.ts index fe85e110c7c5..a5e61333fde6 100644 --- a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/index.ts +++ b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/index.ts @@ -1 +1 @@ -export { default } from './gas-fee-details-row'; +export { default, GasFeesDetailsRowSkeleton } from './gas-fee-details-row'; diff --git a/app/components/Views/confirmations/components/rows/transactions/hero-row/hero-row.styles.ts b/app/components/Views/confirmations/components/rows/transactions/hero-row/hero-row.styles.ts index 9395b422b175..b53dfe388d0a 100644 --- a/app/components/Views/confirmations/components/rows/transactions/hero-row/hero-row.styles.ts +++ b/app/components/Views/confirmations/components/rows/transactions/hero-row/hero-row.styles.ts @@ -16,6 +16,18 @@ const styleSheet = (params: { theme: Theme }) => { borderRadius: 39, backgroundColor: theme.colors.background.alternativePressed, }, + skeletonBorderRadiusLarge: { + borderRadius: 32, + }, + skeletonBorderRadiusMedium: { + borderRadius: 6, + marginTop: 16, + }, + skeletonBorderRadiusSmall: { + borderRadius: 4, + marginTop: 8, + marginBottom: 14, + }, }); }; diff --git a/app/components/Views/confirmations/components/rows/transactions/hero-row/hero-row.tsx b/app/components/Views/confirmations/components/rows/transactions/hero-row/hero-row.tsx index 1b1af48a3472..6a217cac77f8 100644 --- a/app/components/Views/confirmations/components/rows/transactions/hero-row/hero-row.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/hero-row/hero-row.tsx @@ -6,12 +6,32 @@ import { useIsNft } from '../../../../hooks/nft/useIsNft'; import { HeroNft } from '../../../hero-nft'; import { HeroToken } from '../../../hero-token'; import { useStyles } from '../../../../../../../component-library/hooks'; +import { Skeleton } from '../../../../../../../component-library/components/Skeleton'; import styleSheet from './hero-row.styles'; -const LoadingHeroRow = () => { +export function HeroRowSkeleton() { const { styles } = useStyles(styleSheet, {}); - return ; -}; + + return ( + + + + + + ); +} export const HeroRow = ({ amountWei }: { amountWei?: string }) => { const { isNft, isPending } = useIsNft(); @@ -22,7 +42,7 @@ export const HeroRow = ({ amountWei }: { amountWei?: string }) => { style={styles.wrapper} testID={ConfirmationRowComponentIDs.TOKEN_HERO} > - {isPending && } + {isPending && } {!isPending && (isNft ? : )} diff --git a/app/components/Views/confirmations/components/rows/transactions/hero-row/index.ts b/app/components/Views/confirmations/components/rows/transactions/hero-row/index.ts index eada9c737e63..d4094df4f6d9 100644 --- a/app/components/Views/confirmations/components/rows/transactions/hero-row/index.ts +++ b/app/components/Views/confirmations/components/rows/transactions/hero-row/index.ts @@ -1 +1 @@ -export { HeroRow } from './hero-row'; +export { HeroRow, HeroRowSkeleton } from './hero-row'; diff --git a/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.styles.ts b/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.styles.ts index 0e3a10d23395..492522189a70 100644 --- a/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.styles.ts +++ b/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.styles.ts @@ -12,6 +12,16 @@ const styleSheet = () => avatarNetwork: { marginRight: 4, }, + skeletonContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingBottom: 8, + paddingHorizontal: 8, + }, + skeletonBorderRadius: { + borderRadius: 4, + }, }); export default styleSheet; diff --git a/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.tsx b/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.tsx index 60f6f6cf6e6c..8645fb1507d4 100644 --- a/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.tsx @@ -22,6 +22,7 @@ import { MMM_ORIGIN } from '../../../../constants/confirmations'; import InfoSection from '../../../UI/info-row/info-section'; import InfoRow from '../../../UI/info-row/info-row'; import Address from '../../../UI/info-row/info-value/address'; +import { Skeleton } from '../../../../../../../component-library/components/Skeleton'; import styleSheet from './network-and-origin-row.styles'; import { RowAlertKey } from '../../../UI/info-row/alert-row/constants'; import AlertRow from '../../../UI/info-row/alert-row'; @@ -89,3 +90,16 @@ export const NetworkAndOriginRow = () => { ); }; + +export function NetworkAndOriginRowSkeleton() { + const { styles } = useStyles(styleSheet, {}); + + return ( + + + + + + + ); +} diff --git a/app/components/Views/confirmations/hooks/send/useSendActions.test.ts b/app/components/Views/confirmations/hooks/send/useSendActions.test.ts index e826ceda34ee..41d0ba684e36 100644 --- a/app/components/Views/confirmations/hooks/send/useSendActions.test.ts +++ b/app/components/Views/confirmations/hooks/send/useSendActions.test.ts @@ -73,6 +73,7 @@ describe('useSendActions', () => { result.current.handleSubmitPress(); expect(mockNavigate).toHaveBeenCalledWith('RedesignedConfirmations', { params: { maxValueMode: undefined }, + loader: 'transfer', }); }); diff --git a/app/components/Views/confirmations/hooks/send/useSendActions.ts b/app/components/Views/confirmations/hooks/send/useSendActions.ts index c02257b0e40e..9ebe8531af51 100644 --- a/app/components/Views/confirmations/hooks/send/useSendActions.ts +++ b/app/components/Views/confirmations/hooks/send/useSendActions.ts @@ -11,6 +11,7 @@ import { addLeadingZeroIfNeeded, submitEvmTransaction } from '../../utils/send'; import { useSendContext } from '../../context/send-context'; import { useSendType } from './useSendType'; import { useSendExitMetrics } from './metrics/useSendExitMetrics'; +import { ConfirmationLoader } from '../../components/confirm/confirm-component'; export const useSendActions = () => { const { asset, chainId, fromAccount, from, maxValueMode, to, value } = @@ -41,6 +42,7 @@ export const useSendActions = () => { params: { maxValueMode, }, + loader: ConfirmationLoader.Transfer, }, ); } else { diff --git a/app/components/hooks/useFeatureFlag.ts b/app/components/hooks/useFeatureFlag.ts index 3b0c01547caa..f90c4de296ef 100644 --- a/app/components/hooks/useFeatureFlag.ts +++ b/app/components/hooks/useFeatureFlag.ts @@ -15,6 +15,7 @@ export enum FeatureFlagNames { perpsPerpTradingEnabled = 'perpsPerpTradingEnabled', confirmationsPay = 'confirmations_pay', //remote config carouselBanners = 'carouselBanners', + fullPageAccountList = 'fullPageAccountList', } export const useFeatureFlag = (key: FeatureFlagNames) => { diff --git a/app/core/Engine/messengers/currency-rate-controller-messenger/currency-rate-controller-messenger.ts b/app/core/Engine/messengers/currency-rate-controller-messenger/currency-rate-controller-messenger.ts index be769ea074bb..048d758f6ff1 100644 --- a/app/core/Engine/messengers/currency-rate-controller-messenger/currency-rate-controller-messenger.ts +++ b/app/core/Engine/messengers/currency-rate-controller-messenger/currency-rate-controller-messenger.ts @@ -25,7 +25,10 @@ export function getCurrencyRateControllerMessenger( parent: rootExtendedMessenger, }); rootExtendedMessenger.delegate({ - actions: ['NetworkController:getNetworkClientById'], + actions: [ + 'NetworkController:getNetworkClientById', + 'NetworkController:getState', + ], events: [], messenger, }); diff --git a/locales/languages/en.json b/locales/languages/en.json index c9e04b8b8c59..fd34a2e459b5 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1787,7 +1787,7 @@ "won": "Won", "lost": "Lost", "market_unavailable": "Market unavailable", - "amount_on_outcome": "${{amount}} on {{outcome}}", + "amount_on_outcome": "{{amount}} on {{outcome}}", "outcome_at_price": "{{outcome}} at {{price}}¢", "fee_exemption": "We don't charge any fees on this market.", "ended": "Ended", @@ -1807,10 +1807,10 @@ "sell_position": "Sell Position", "cash_out": "Cash out", "cash_out_info": "Funds will be added to your available balance", - "at_price_per_share": "At price: {{price}}¢ per share", - "cashout_info": "{{amount}} on {{outcome}} at {{initialPrice}}¢", - "position_info_plural": "{{amount}} on {{outcome}} · {{shares}} shares at {{priceCents}}", - "position_info_singular": "{{amount}} on {{outcome}} · {{shares}} share at {{priceCents}}", + "at_price_per_share": "Selling {{size}} shares at {{price}}", + "cashout_info": "{{amount}} on {{outcome}} at {{initialPrice}}", + "cashout_info_multiple": "{{amount}} on {{outcomeGroupTitle}} • {{outcome}} at {{initialPrice}}", + "position_info": "{{initialValue}} on {{outcome}} to win {{shares}}", "buy_yes": "Yes", "buy_no": "No", "outcomes": "outcomes", diff --git a/package.json b/package.json index a9fc058119b7..c2f200849272 100644 --- a/package.json +++ b/package.json @@ -196,11 +196,11 @@ "@metamask/address-book-controller": "^7.0.0", "@metamask/app-metadata-controller": "^2.0.0", "@metamask/approval-controller": "^8.0.0", - "@metamask/assets-controllers": "^88.0.0", + "@metamask/assets-controllers": "^89.0.1", "@metamask/base-controller": "^9.0.0", "@metamask/bitcoin-wallet-snap": "^1.6.0", - "@metamask/bridge-controller": "^60.1.0", - "@metamask/bridge-status-controller": "^60.1.0", + "@metamask/bridge-controller": "^61.0.0", + "@metamask/bridge-status-controller": "^61.0.0", "@metamask/chain-agnostic-permission": "^1.2.2", "@metamask/composable-controller": "^12.0.0", "@metamask/controller-utils": "^11.11.0", diff --git a/yarn.lock b/yarn.lock index 0567bc7df99e..2658abf817d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6950,9 +6950,9 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:^88.0.0": - version: 88.0.0 - resolution: "@metamask/assets-controllers@npm:88.0.0" +"@metamask/assets-controllers@npm:^89.0.1": + version: 89.0.1 + resolution: "@metamask/assets-controllers@npm:89.0.1" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -6988,7 +6988,7 @@ __metadata: "@metamask/account-tree-controller": ^3.0.0 "@metamask/accounts-controller": ^34.0.0 "@metamask/approval-controller": ^8.0.0 - "@metamask/core-backend": ^4.0.0 + "@metamask/core-backend": ^4.1.0 "@metamask/keyring-controller": ^24.0.0 "@metamask/network-controller": ^25.0.0 "@metamask/permission-controller": ^12.0.0 @@ -6998,7 +6998,7 @@ __metadata: "@metamask/snaps-controllers": ^14.0.0 "@metamask/transaction-controller": ^61.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/edb9c932a0fc64c8dd9fd437a38aacb4b7e1a3c0e22619b1ef8fc2d9ef5a8d48830d28c79f7bbb557663bb839bd8dac0b40466777af68bf1993e2bbfa7f283e1 + checksum: 10/ba1652a8ba929b4dde8d2dfb953cf3f680d358a5d3cfda739b956e3e86e3c139131405f3565d04d8e0082801fd6c855efd574e257b2e734f635da89fe15cf41b languageName: node linkType: hard @@ -7071,9 +7071,9 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^60.1.0": - version: 60.1.0 - resolution: "@metamask/bridge-controller@npm:60.1.0" +"@metamask/bridge-controller@npm:^61.0.0": + version: 61.0.0 + resolution: "@metamask/bridge-controller@npm:61.0.0" dependencies: "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" @@ -7094,18 +7094,18 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@metamask/accounts-controller": ^34.0.0 - "@metamask/assets-controllers": ^88.0.0 + "@metamask/assets-controllers": ^89.0.0 "@metamask/network-controller": ^25.0.0 "@metamask/remote-feature-flag-controller": ^2.0.0 "@metamask/snaps-controllers": ^14.0.0 "@metamask/transaction-controller": ^61.0.0 - checksum: 10/f308a325d8c13bf21b5b4cd3823660312a4bc2721f635f533baee64fc6141979a83a5de9cc4e5be99a7735e838b1e3520bc5b26f4759198ca3eff16d8422496a + checksum: 10/f1e8f4e6ec44130711a1ebf2bef082cd13b63b2e5043ca7b3a37eabce694913d2ca512f9178dc9e3925ec6eff28bdd75c7b52e78ba50f2c9b502f2a47a38f6bc languageName: node linkType: hard -"@metamask/bridge-status-controller@npm:^60.1.0": - version: 60.1.0 - resolution: "@metamask/bridge-status-controller@npm:60.1.0" +"@metamask/bridge-status-controller@npm:^61.0.0": + version: 61.0.0 + resolution: "@metamask/bridge-status-controller@npm:61.0.0" dependencies: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.15.0" @@ -7116,12 +7116,12 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@metamask/accounts-controller": ^34.0.0 - "@metamask/bridge-controller": ^60.0.0 + "@metamask/bridge-controller": ^61.0.0 "@metamask/gas-fee-controller": ^25.0.0 "@metamask/network-controller": ^25.0.0 "@metamask/snaps-controllers": ^14.0.0 "@metamask/transaction-controller": ^61.0.0 - checksum: 10/94552840a28eae74465db95a01033fb19d5db190ce58359a1bad0719cd735db3e271581f27682102573dc457a7ce2516b76c0d219313f3a29c57a34674a2246d + checksum: 10/5f84b9c46b57079a3d39dd570d5b9e1a0c475435e05c6ec183fd832e563ed362df927ce83c88f9026dbd3ccd279309686e900e15a2b5f17dcff2ebb39959b7b5 languageName: node linkType: hard @@ -34326,12 +34326,12 @@ __metadata: "@metamask/address-book-controller": "npm:^7.0.0" "@metamask/app-metadata-controller": "npm:^2.0.0" "@metamask/approval-controller": "npm:^8.0.0" - "@metamask/assets-controllers": "npm:^88.0.0" + "@metamask/assets-controllers": "npm:^89.0.1" "@metamask/auto-changelog": "npm:^5.1.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/bitcoin-wallet-snap": "npm:^1.6.0" - "@metamask/bridge-controller": "npm:^60.1.0" - "@metamask/bridge-status-controller": "npm:^60.1.0" + "@metamask/bridge-controller": "npm:^61.0.0" + "@metamask/bridge-status-controller": "npm:^61.0.0" "@metamask/browser-passworder": "npm:^5.0.0" "@metamask/build-utils": "npm:^3.0.0" "@metamask/chain-agnostic-permission": "npm:^1.2.2"