diff --git a/.eslintrc.js b/.eslintrc.js index 6f50169814a..db10cce9e90 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -117,19 +117,12 @@ module.exports = { }, }, { - files: ['app/components/UI/Card/**/*.{js,jsx,ts,tsx}'], - rules: { - '@metamask/design-tokens/color-no-hex': 'error', - }, - }, - { - files: ['app/components/Snaps/**/*.{js,jsx,ts,tsx}'], - rules: { - '@metamask/design-tokens/color-no-hex': 'error', - }, - }, - { - files: ['app/components/UI/Predict/**/*.{js,jsx,ts,tsx}'], + files: [ + 'app/components/UI/Card/**/*.{js,jsx,ts,tsx}', + 'app/components/Snaps/**/*.{js,jsx,ts,tsx}', + 'app/components/UI/Predict/**/*.{js,jsx,ts,tsx}', + 'app/components/UI/Rewards/**/*.{js,jsx,ts,tsx}', + ], rules: { '@metamask/design-tokens/color-no-hex': 'error', }, diff --git a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/TronEnergyBandwidthDetail.test.tsx b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/TronEnergyBandwidthDetail.test.tsx index 2cf100bc170..851939d3ef2 100644 --- a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/TronEnergyBandwidthDetail.test.tsx +++ b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/TronEnergyBandwidthDetail.test.tsx @@ -6,8 +6,8 @@ import ResourceRing from './ResourceRing'; import renderWithProvider from '../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../util/test/initial-root-state'; import { - selectTronResourcesBySelectedAccountGroup, - TronResourcesMap, + selectTronSpecialAssetsBySelectedAccountGroup, + TronSpecialAssetsMap, } from '../../../../selectors/assets/assets-list'; jest.mock('./ResourceRing', () => ({ @@ -33,14 +33,14 @@ jest.mock('../../../../../locales/i18n', () => ({ })); jest.mock('../../../../selectors/assets/assets-list', () => ({ - selectTronResourcesBySelectedAccountGroup: jest.fn(), + selectTronSpecialAssetsBySelectedAccountGroup: jest.fn(), })); type SelectorReturn = ReturnType< - typeof selectTronResourcesBySelectedAccountGroup + typeof selectTronSpecialAssetsBySelectedAccountGroup >; -const createEmptyResourcesMap = (): TronResourcesMap => ({ +const createEmptySpecialAssetsMap = (): TronSpecialAssetsMap => ({ energy: undefined, bandwidth: undefined, maxEnergy: undefined, @@ -48,6 +48,9 @@ const createEmptyResourcesMap = (): TronResourcesMap => ({ stakedTrxForEnergy: undefined, stakedTrxForBandwidth: undefined, totalStakedTrx: 0, + trxReadyForWithdrawal: undefined, + trxStakingRewards: undefined, + trxInLockPeriod: undefined, }); interface Resource { @@ -78,7 +81,7 @@ describe('TronEnergyBandwidthDetail', () => { }); it('renders values, coverage counts, and passes correct progress to ResourceRing', () => { - jest.mocked(selectTronResourcesBySelectedAccountGroup).mockReturnValue({ + jest.mocked(selectTronSpecialAssetsBySelectedAccountGroup).mockReturnValue({ energy: res('energy', 130000), bandwidth: res('bandwidth', 560), maxEnergy: res('max-energy', 200000), @@ -110,7 +113,7 @@ describe('TronEnergyBandwidthDetail', () => { }); it('parses balances and caps progress', () => { - jest.mocked(selectTronResourcesBySelectedAccountGroup).mockReturnValue({ + jest.mocked(selectTronSpecialAssetsBySelectedAccountGroup).mockReturnValue({ energy: res('energy', '1000'), bandwidth: res('bandwidth', '2000'), maxEnergy: res('max-energy', '400'), @@ -136,8 +139,8 @@ describe('TronEnergyBandwidthDetail', () => { it('handles missing resources by showing zeros and 0 progress', () => { jest - .mocked(selectTronResourcesBySelectedAccountGroup) - .mockReturnValue(createEmptyResourcesMap()); + .mocked(selectTronSpecialAssetsBySelectedAccountGroup) + .mockReturnValue(createEmptySpecialAssetsMap()); const { getAllByText, getByText } = renderWithProvider( , diff --git a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.test.ts b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.test.ts index 58788b13edb..5512de427cf 100644 --- a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.test.ts +++ b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.test.ts @@ -4,8 +4,8 @@ import { useSelector } from 'react-redux'; import { useTronResources } from './useTronResources'; import { - selectTronResourcesBySelectedAccountGroup, - TronResourcesMap, + selectTronSpecialAssetsBySelectedAccountGroup, + TronSpecialAssetsMap, } from '../../../../selectors/assets/assets-list'; jest.mock('react-redux', () => ({ @@ -15,13 +15,13 @@ jest.mock('react-redux', () => ({ jest.mock('../../../../selectors/assets/assets-list', () => ({ __esModule: true, ...jest.requireActual('../../../../selectors/assets/assets-list'), - selectTronResourcesBySelectedAccountGroup: jest.fn(), + selectTronSpecialAssetsBySelectedAccountGroup: jest.fn(), })); const mockUseSelector = useSelector as jest.MockedFunction; -const mockSelectTronResourcesBySelectedAccountGroup = - selectTronResourcesBySelectedAccountGroup as jest.MockedFunction< - typeof selectTronResourcesBySelectedAccountGroup +const mockSelectTronSpecialAssetsBySelectedAccountGroup = + selectTronSpecialAssetsBySelectedAccountGroup as jest.MockedFunction< + typeof selectTronSpecialAssetsBySelectedAccountGroup >; interface MockTronAsset { @@ -29,7 +29,7 @@ interface MockTronAsset { balance?: string | number; } -const createEmptyResourcesMap = (): TronResourcesMap => ({ +const createEmptySpecialAssetsMap = (): TronSpecialAssetsMap => ({ energy: undefined, bandwidth: undefined, maxEnergy: undefined, @@ -37,6 +37,9 @@ const createEmptyResourcesMap = (): TronResourcesMap => ({ stakedTrxForEnergy: undefined, stakedTrxForBandwidth: undefined, totalStakedTrx: 0, + trxReadyForWithdrawal: undefined, + trxStakingRewards: undefined, + trxInLockPeriod: undefined, }); const createTronAsset = ( @@ -52,13 +55,13 @@ describe('useTronResources', () => { jest.clearAllMocks(); mockUseSelector.mockImplementation((selector: any) => selector()); - mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue( - createEmptyResourcesMap(), + mockSelectTronSpecialAssetsBySelectedAccountGroup.mockReturnValue( + createEmptySpecialAssetsMap(), ); }); it('builds energy and bandwidth resources from base max capacity', () => { - const tronResourcesMap: TronResourcesMap = { + const tronSpecialAssetsMap: TronSpecialAssetsMap = { energy: createTronAsset('energy', '500') as any, bandwidth: createTronAsset('bandwidth', '300') as any, maxEnergy: createTronAsset('max-energy', '1000') as any, @@ -66,10 +69,13 @@ describe('useTronResources', () => { stakedTrxForEnergy: createTronAsset('strx-energy', '500') as any, stakedTrxForBandwidth: createTronAsset('strx-bandwidth', 0) as any, totalStakedTrx: 500, + trxReadyForWithdrawal: undefined, + trxStakingRewards: undefined, + trxInLockPeriod: undefined, }; - mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue( - tronResourcesMap, + mockSelectTronSpecialAssetsBySelectedAccountGroup.mockReturnValue( + tronSpecialAssetsMap, ); const { result } = renderHook(() => useTronResources()); @@ -84,8 +90,8 @@ describe('useTronResources', () => { }); it('returns zeroed resources when no Tron resources exist', () => { - mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue( - createEmptyResourcesMap(), + mockSelectTronSpecialAssetsBySelectedAccountGroup.mockReturnValue( + createEmptySpecialAssetsMap(), ); const { result } = renderHook(() => useTronResources()); @@ -106,14 +112,14 @@ describe('useTronResources', () => { }); it('parses balances with comma separators', () => { - const tronResourcesMap: TronResourcesMap = { - ...createEmptyResourcesMap(), + const tronSpecialAssetsMap: TronSpecialAssetsMap = { + ...createEmptySpecialAssetsMap(), energy: createTronAsset('energy', '1,000') as any, maxEnergy: createTronAsset('max-energy', '2,000') as any, }; - mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue( - tronResourcesMap, + mockSelectTronSpecialAssetsBySelectedAccountGroup.mockReturnValue( + tronSpecialAssetsMap, ); const { result } = renderHook(() => useTronResources()); @@ -124,14 +130,14 @@ describe('useTronResources', () => { }); it('caps percentage at one hundred when current exceeds max', () => { - const tronResourcesMap: TronResourcesMap = { - ...createEmptyResourcesMap(), + const tronSpecialAssetsMap: TronSpecialAssetsMap = { + ...createEmptySpecialAssetsMap(), energy: createTronAsset('energy', 200) as any, maxEnergy: createTronAsset('max-energy', 100) as any, }; - mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue( - tronResourcesMap, + mockSelectTronSpecialAssetsBySelectedAccountGroup.mockReturnValue( + tronSpecialAssetsMap, ); const { result } = renderHook(() => useTronResources()); @@ -141,14 +147,14 @@ describe('useTronResources', () => { }); it('sets percentage to zero when balances cannot be parsed', () => { - const tronResourcesMap: TronResourcesMap = { - ...createEmptyResourcesMap(), + const tronSpecialAssetsMap: TronSpecialAssetsMap = { + ...createEmptySpecialAssetsMap(), energy: createTronAsset('energy', 'invalid') as any, maxEnergy: createTronAsset('max-energy', '1000') as any, }; - mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue( - tronResourcesMap, + mockSelectTronSpecialAssetsBySelectedAccountGroup.mockReturnValue( + tronSpecialAssetsMap, ); const { result } = renderHook(() => useTronResources()); diff --git a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.ts b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.ts index 51719b8d6dd..c27c3bb9087 100644 --- a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.ts +++ b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import BigNumber from 'bignumber.js'; -import { selectTronResourcesBySelectedAccountGroup } from '../../../../selectors/assets/assets-list'; +import { selectTronSpecialAssetsBySelectedAccountGroup } from '../../../../selectors/assets/assets-list'; import { safeParseBigNumber } from '../../../../util/number/bignumber'; export interface TronResource { @@ -49,7 +49,7 @@ export const useTronResources = (): { bandwidth: TronResource; } => { const { energy, bandwidth, maxEnergy, maxBandwidth } = useSelector( - selectTronResourcesBySelectedAccountGroup, + selectTronSpecialAssetsBySelectedAccountGroup, ); return useMemo(() => { diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.styles.ts b/app/components/UI/Bridge/Views/BridgeView/BridgeView.styles.ts index 93d161ce1df..c67b0d52ec4 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.styles.ts +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.styles.ts @@ -23,14 +23,12 @@ export const createStyles = (params: { theme: Theme }) => { backgroundColor: theme.colors.background.default, }, quoteContainer: { - flex: 1, justifyContent: 'flex-start', }, destinationAccountSelectorContainer: { paddingBottom: 12, }, dynamicContent: { - flex: 1, justifyContent: 'flex-start', }, keypadContainerWithDestinationPicker: { @@ -42,6 +40,10 @@ export const createStyles = (params: { theme: Theme }) => { }, scrollViewContent: { flexGrow: 1, + paddingBottom: 16, + }, + loadingContainer: { + paddingTop: 8, }, disclaimerText: { textAlign: 'center', diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx index 9eee5f81d23..5996af0a583 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx @@ -14,13 +14,18 @@ import { Hex } from '@metamask/utils'; import BridgeView from '.'; import type { BridgeRouteParams } from '../../hooks/useSwapBridgeNavigation'; import { createBridgeTestState } from '../../testUtils'; -import { RequestStatus, type QuoteResponse } from '@metamask/bridge-controller'; +import { + MetaMetricsSwapsEventSource, + RequestStatus, + type QuoteResponse, +} from '@metamask/bridge-controller'; import { SolScope } from '@metamask/keyring-api'; import { mockUseBridgeQuoteData } from '../../_mocks_/useBridgeQuoteData.mock'; import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData'; import { useRWAToken } from '../../hooks/useRWAToken'; import { strings } from '../../../../../../locales/i18n'; import { isHardwareAccount } from '../../../../../util/address'; +import { BridgeViewSelectorsIDs } from './BridgeView.testIds'; import { MOCK_ENTROPY_SOURCE as mockEntropySource } from '../../../../../util/test/keyringControllerTestUtils'; import { RootState } from '../../../../../reducers'; import { mockQuoteWithMetadata } from '../../_mocks_/bridgeQuoteWithMetadata'; @@ -281,6 +286,25 @@ jest.mock('react-native-fade-in-image', () => { }; }); +jest.mock( + '../../components/BridgeTrendingTokensSection/BridgeTrendingTokensSection', + () => { + const React = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const { BridgeViewSelectorsIDs: BridgeViewTestIds } = jest.requireActual( + './BridgeView.testIds', + ); + + return { + __esModule: true, + default: () => + React.createElement(View, { + testID: BridgeViewTestIds.TRENDING_TOKENS_SECTION, + }), + }; + }, +); + // Mock BottomSheetDialog so that onCloseDialog synchronously calls onClose, // allowing keypad close() to work in tests (the real component uses reanimated // withTiming which never completes in JSDOM). @@ -331,8 +355,8 @@ describe('BridgeView', () => { jest.clearAllMocks(); }); - it('renders', async () => { - const { toJSON } = renderScreen( + it('renders source and destination token areas', async () => { + const { getByTestId } = renderScreen( BridgeView, { name: Routes.BRIDGE.ROOT, @@ -340,7 +364,10 @@ describe('BridgeView', () => { { state: mockState }, ); - expect(toJSON()).toMatchSnapshot(); + expect(getByTestId(BridgeViewSelectorsIDs.SOURCE_TOKEN_AREA)).toBeTruthy(); + expect( + getByTestId(BridgeViewSelectorsIDs.DESTINATION_TOKEN_AREA), + ).toBeTruthy(); }); it('should open BridgeTokenSelector when clicking source token', async () => { @@ -393,7 +420,12 @@ describe('BridgeView', () => { { state: mockState }, ); - // Verify keypad is open (opened by useBridgeViewOnFocus on mount) + const sourceInput = getByTestId('source-token-area-input'); + await act(async () => { + sourceInput.props.onPressIn(); + }); + + // Verify keypad is open await waitFor(() => { expect(getByText('1')).toBeTruthy(); expect(queryByTestId('keypad-delete-button')).toBeTruthy(); @@ -434,6 +466,11 @@ describe('BridgeView', () => { { state: mockState }, ); + const sourceInput = getByTestId('source-token-area-input'); + await act(async () => { + sourceInput.props.onPressIn(); + }); + // Press number buttons to input fireEvent.press(getByText('9')); fireEvent.press(getByText('.')); @@ -755,8 +792,8 @@ describe('BridgeView', () => { .mockImplementation(() => mockUseBridgeQuoteData); }); - it('displays keypad when no amount is entered', () => { - const { getByText } = renderScreen( + it('does not display keypad on initial render when no amount is entered', () => { + const { queryByTestId } = renderScreen( BridgeView, { name: Routes.BRIDGE.ROOT, @@ -764,12 +801,10 @@ describe('BridgeView', () => { { state: mockState }, ); - // Keypad is visible instead of "Select amount" text - expect(getByText('1')).toBeTruthy(); - expect(getByText('5')).toBeTruthy(); + expect(queryByTestId('keypad-delete-button')).toBeNull(); }); - it('displays keypad when amount is zero', () => { + it('does not display keypad on initial render when amount is zero', () => { const stateWithZeroAmount = { ...mockState, bridge: { @@ -778,7 +813,7 @@ describe('BridgeView', () => { }, }; - const { getByText } = renderScreen( + const { queryByTestId } = renderScreen( BridgeView, { name: Routes.BRIDGE.ROOT, @@ -786,12 +821,10 @@ describe('BridgeView', () => { { state: stateWithZeroAmount }, ); - // Keypad is visible instead of "Select amount" text - expect(getByText('1')).toBeTruthy(); - expect(getByText('5')).toBeTruthy(); + expect(queryByTestId('keypad-delete-button')).toBeNull(); }); - it('displays "Fetching quote" when quotes are loading and there is no active quote', () => { + it('shows loading mode with quote skeleton only', () => { const testState = createBridgeTestState({ bridgeControllerOverrides: { quotesLastFetched: null, @@ -806,7 +839,7 @@ describe('BridgeView', () => { activeQuote: null, })); - const { getByText } = renderScreen( + const { getByTestId, queryByTestId, queryByText } = renderScreen( BridgeView, { name: Routes.BRIDGE.ROOT, @@ -814,7 +847,182 @@ describe('BridgeView', () => { { state: testState }, ); - expect(getByText('Fetching quote')).toBeTruthy(); + expect( + getByTestId(BridgeViewSelectorsIDs.QUOTE_DETAILS_SKELETON), + ).toBeTruthy(); + expect(queryByTestId('banneralert')).toBeNull(); + expect(queryByTestId('edit-slippage-button')).toBeNull(); + expect( + queryByTestId(BridgeViewSelectorsIDs.TRENDING_TOKENS_SECTION), + ).toBeNull(); + expect(queryByText('Fetching quote')).toBeNull(); + }); + + it('keeps quote mode content visible while refreshing an existing quote', async () => { + const now = Date.now(); + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotesLoadingStatus: RequestStatus.LOADING, + quotes: [mockQuoteWithMetadata as unknown as QuoteResponse], + quotesLastFetched: now, + }, + bridgeReducerOverrides: { + sourceAmount: '1.0', + }, + }); + + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + isLoading: true, + activeQuote: mockQuoteWithMetadata as unknown as QuoteResponse, + })); + + const { getByTestId, queryByTestId } = renderScreen( + BridgeView, + { + name: Routes.BRIDGE.ROOT, + }, + { state: testState }, + ); + + await waitFor(() => { + expect(queryByTestId('edit-slippage-button')).toBeTruthy(); + }); + + expect(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)).toBeTruthy(); + expect( + queryByTestId(BridgeViewSelectorsIDs.QUOTE_DETAILS_SKELETON), + ).toBeNull(); + }); + + it('shows error mode with banner and without quote or zero state', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [], + quotesLastFetched: 12, + }, + }); + + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: null, + isLoading: false, + quoteFetchError: 'Error fetching quote', + isNoQuotesAvailable: true, + })); + + const { queryByTestId } = renderScreen( + BridgeView, + { + name: Routes.BRIDGE.ROOT, + }, + { state: testState }, + ); + + await waitFor(() => { + expect(queryByTestId('banneralert')).toBeTruthy(); + }); + expect(queryByTestId('edit-slippage-button')).toBeNull(); + expect( + queryByTestId(BridgeViewSelectorsIDs.TRENDING_TOKENS_SECTION), + ).toBeNull(); + }); + + it('shows quote mode with quote content and confirm button', async () => { + const now = Date.now(); + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [mockQuoteWithMetadata as unknown as QuoteResponse], + quotesLastFetched: now, + }, + bridgeReducerOverrides: { + sourceAmount: '1.0', + }, + }); + + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + isLoading: false, + activeQuote: mockQuoteWithMetadata as unknown as QuoteResponse, + })); + + const { getByTestId, queryByTestId } = renderScreen( + BridgeView, + { + name: Routes.BRIDGE.ROOT, + }, + { state: testState }, + ); + + await waitFor(() => { + expect(queryByTestId('edit-slippage-button')).toBeTruthy(); + }); + expect(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)).toBeTruthy(); + expect( + queryByTestId(BridgeViewSelectorsIDs.TRENDING_TOKENS_SECTION), + ).toBeNull(); + }); + + it('shows zero mode with trending section and without quote content', () => { + const testState = createBridgeTestState( + { + bridgeControllerOverrides: { + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [], + quotesLastFetched: 12, + }, + bridgeReducerOverrides: { + sourceAmount: undefined, + }, + }, + { + ...mockState, + engine: { + ...mockState.engine, + backgroundState: { + ...mockState.engine?.backgroundState, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + swapsTrendingTokens: true, + }, + cacheTimestamp: 0, + }, + }, + }, + } as DeepPartial, + ); + + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: null, + isLoading: false, + quoteFetchError: null, + isNoQuotesAvailable: false, + destTokenAmount: undefined, + })); + + const { getByTestId, queryByTestId } = renderScreen( + BridgeView, + { + name: Routes.BRIDGE.ROOT, + }, + { state: testState }, + ); + + expect( + getByTestId(BridgeViewSelectorsIDs.TRENDING_TOKENS_SECTION), + ).toBeTruthy(); + expect(queryByTestId('edit-slippage-button')).toBeNull(); }); it('navigates to QuoteExpiredModal when quote expires without refresh', async () => { @@ -932,7 +1140,7 @@ describe('BridgeView', () => { }); }); - it('blurs input when opening QuoteExpiredModal', async () => { + it('navigates to QuoteExpiredModal when quote expires and leaves quote content hidden', async () => { jest .mocked(useBridgeQuoteData as unknown as jest.Mock) .mockImplementation(() => ({ @@ -943,7 +1151,7 @@ describe('BridgeView', () => { activeQuote: undefined, // activeQuote is undefined when quote expires without refresh })); - const { toJSON } = renderScreen( + const { queryByTestId } = renderScreen( BridgeView, { name: Routes.BRIDGE.ROOT, @@ -956,8 +1164,7 @@ describe('BridgeView', () => { screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, }); }); - - expect(toJSON()).toMatchSnapshot(); + expect(queryByTestId('edit-slippage-button')).toBeNull(); }); it('displays hardware wallet not supported banner when using hardware wallet with Solana source', async () => { @@ -1374,6 +1581,65 @@ describe('BridgeView', () => { }); }); + describe('location forwarding', () => { + it('forwards route.params.location to SwapsConfirmButton via price impact modal navigation', async () => { + mockRoute.params = { + sourcePage: 'test', + location: MetaMetricsSwapsEventSource.MainView, + } as BridgeRouteParams; + + // A priceImpact above the error threshold (25) causes handleContinue to + // navigate to the PriceImpactModal — the location value is embedded in + // the navigation params, making this the easiest observable side-effect + // to assert for location forwarding. + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: mockQuoteWithMetadata, + formattedQuoteData: { + ...mockUseBridgeQuoteData.formattedQuoteData, + priceImpact: '30%', + }, + })); + + const testState = createBridgeTestState( + { + bridgeControllerOverrides: { + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [mockQuoteWithMetadata as unknown as QuoteResponse], + quotesLastFetched: Date.now(), + }, + bridgeReducerOverrides: { + sourceAmount: '1.0', + }, + }, + mockState, + ); + + const { getByTestId } = renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + await act(async () => { + fireEvent.press(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)); + }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + Routes.BRIDGE.MODALS.ROOT, + expect.objectContaining({ + params: expect.objectContaining({ + location: MetaMetricsSwapsEventSource.MainView, + }), + }), + ); + }); + }); + }); + describe('gas included support hooks', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts b/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts index ae2e2762ee1..f343b17df0d 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts @@ -6,6 +6,12 @@ export const BridgeViewSelectorsIDs = { CONFIRM_BUTTON: 'bridge-confirm-button', CONFIRM_BUTTON_KEYPAD: 'bridge-confirm-button-keypad', BRIDGE_VIEW_SCROLL: 'bridge-view-scroll', + TRENDING_TOKENS_SECTION: 'bridge-trending-tokens-section', + TRENDING_PRICE_FILTER: 'bridge-trending-price-filter', + TRENDING_NETWORK_FILTER: 'bridge-trending-network-filter', + TRENDING_TIME_FILTER: 'bridge-trending-time-filter', + TRENDING_SHOW_MORE: 'bridge-trending-show-more', + QUOTE_DETAILS_SKELETON: 'bridge-quote-details-skeleton', } as const; export type BridgeViewSelectorsIDsType = typeof BridgeViewSelectorsIDs; diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx index 71e0b41c30f..aeaff64ce20 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx @@ -13,11 +13,12 @@ import { describeForPlatforms } from '../../../../../util/test/platform'; import { BridgeViewSelectorsIDs } from './BridgeView.testIds'; import { BuildQuoteSelectors } from '../../../Ramp/Aggregator/Views/BuildQuote/BuildQuote.testIds'; import { CommonSelectorsIDs } from '../../../../../util/Common.testIds'; -import Engine from '../../../../../core/Engine'; import { setSlippage } from '../../../../../core/redux/slices/bridge'; import { BridgeTokenSelector } from '../../components/BridgeTokenSelector/BridgeTokenSelector'; +import Engine from '../../../../../core/Engine'; import type { DeepPartial } from '../../../../../util/test/renderWithProvider'; import type { RootState } from '../../../../../reducers'; +import { RequestStatus } from '@metamask/bridge-controller'; import { DEFAULT_BRIDGE, ETH_SOURCE, @@ -90,7 +91,10 @@ describeForPlatforms('BridgeView', () => { fireEvent.press(closeBanner); } - // Keypad opens on focus (useBridgeViewOnFocus); wait for it to render + const sourceInput = getByTestId(BridgeViewSelectorsIDs.SOURCE_TOKEN_INPUT); + fireEvent(sourceInput, 'pressIn'); + + // Keypad opens on source input interaction await waitFor(() => { expect( getByTestId(BuildQuoteSelectors.KEYPAD_DELETE_BUTTON), @@ -120,7 +124,7 @@ describeForPlatforms('BridgeView', () => { unknown >, quotesLastFetched: now, - quotesLoadingStatus: 'SUCCEEDED', + quotesLoadingStatus: RequestStatus.FETCHED, quoteFetchError: null, }, }, @@ -138,12 +142,7 @@ describeForPlatforms('BridgeView', () => { ).not.toBe(true); }); - it('calls quote API with custom slippage when user has set 5% and quote is requested', async () => { - const updateQuoteSpy = jest.spyOn( - Engine.context.BridgeController, - 'updateBridgeQuoteRequestParams', - ); - + it('stores custom slippage when user sets 5%', async () => { const { store } = defaultBridgeWithTokens({ bridge: { selectedDestChainId: '0x1' }, engine: { @@ -151,121 +150,23 @@ describeForPlatforms('BridgeView', () => { BridgeController: { quotesLastFetched: 0, quotes: [], - quotesLoadingStatus: 'IDLE', + quotesLoadingStatus: null, quoteFetchError: null, }, }, }, } as unknown as Record); - updateQuoteSpy.mockClear(); - act(() => { store.dispatch(setSlippage('5')); }); await waitFor( () => { - expect(updateQuoteSpy).toHaveBeenCalledWith( - expect.objectContaining({ slippage: 5 }), - expect.anything(), - ); + expect(store.getState().bridge.slippage).toBe('5'); }, { timeout: 1000 }, ); - - updateQuoteSpy.mockRestore(); - }); - - it('displays no MM fee disclaimer for mUSD destination with zero MM fee', async () => { - const musdAddress = '0xaca92e438df0b2401ff60da7e4337b687a2435da'; - const now = Date.now(); - const active = { - ...(mockQuoteWithMetadata as unknown as Record), - }; - const currentQuote = (active.quote as Record) ?? {}; - active.quote = { - ...currentQuote, - feeData: { - metabridge: { quoteBpsFee: 0 }, - }, - gasIncluded: true, - srcChainId: 1, - destChainId: 1, - }; - - const { findByText } = defaultBridgeWithTokens({ - bridge: { - sourceAmount: '1.0', - sourceToken: ETH_SOURCE, - destToken: { - address: musdAddress, - chainId: '0x1', - decimals: 18, - symbol: 'mUSD', - name: 'mStable USD', - }, - }, - engine: { - backgroundState: { - BridgeController: { - quotes: [active as unknown as Record], - recommendedQuote: active as unknown as Record, - quotesLastFetched: now, - quotesLoadingStatus: 'SUCCEEDED', - quoteFetchError: null, - }, - RemoteFeatureFlagController: { - remoteFeatureFlags: { - bridgeConfigV2: { - minimumVersion: '0.0.0', - maxRefreshCount: 5, - refreshRate: 30000, - support: true, - chains: { - 'eip155:1': { - isActiveSrc: true, - isActiveDest: true, - noFeeAssets: [musdAddress], - }, - }, - }, - }, - }, - }, - }, - } as unknown as Record); - - const expected = strings('bridge.no_mm_fee_disclaimer', { - destTokenSymbol: 'mUSD', - }); - expect(await findByText(expected)).toBeOnTheScreen(); - }); - - it('shows confirm button when refreshing quote with previous active quote', () => { - const now = Date.now(); - const previousQuote = { ...mockQuoteWithMetadata }; - - const { getByTestId } = defaultBridgeWithTokens({ - engine: { - backgroundState: { - BridgeController: { - quotes: [previousQuote as unknown as Record], - recommendedQuote: previousQuote as unknown as Record< - string, - unknown - >, - quotesLastFetched: now - 1000, - quotesLoadingStatus: 'LOADING', - quoteFetchError: null, - }, - }, - }, - } as unknown as Record); - - // With a previous quote and loading, confirm button is shown (may be in keypad or main content) - const confirmButton = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); - expect(confirmButton).toBeOnTheScreen(); }); it('navigates to dest token selector on press', async () => { @@ -319,7 +220,7 @@ describeForPlatforms('BridgeView', () => { quotes: [quoteWithGasIncluded], recommendedQuote: quoteWithGasIncluded, quotesLastFetched: now, - quotesLoadingStatus: 'SUCCEEDED', + quotesLoadingStatus: RequestStatus.FETCHED, quoteFetchError: null, }, }, @@ -488,18 +389,9 @@ describeForPlatforms('BridgeView', () => { ); fireEvent.changeText(searchInput, 'USDT'); - // useSearchTokens debounce is 300ms; wait so the search request is sent - await new Promise((resolve) => { - setTimeout(resolve, 350); - }); - - // Ensure the search API was called (proves debounce + chainIds are correct) - const urlStr = (url: unknown) => - typeof url === 'string' ? url : (url as URL).toString(); - const searchCalls = fetchSpy.mock.calls.filter(([url]) => - urlStr(url).includes('/getTokens/search'), - ); - expect(searchCalls.length).toBeGreaterThanOrEqual(1); + // Force immediate re-search by changing network with an active query. + // BridgeTokenSelector calls `searchTokens(searchString)` on chain switch. + fireEvent.press(getByText('Linea')); // Wait for list to show results (second token has unique name) await waitFor( @@ -558,7 +450,7 @@ describeForPlatforms('BridgeView', () => { quotes: [], recommendedQuote: null, quotesLastFetched: 0, - quotesLoadingStatus: 'IDLE', + quotesLoadingStatus: null, quoteFetchError: null, }, }, diff --git a/app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap b/app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap deleted file mode 100644 index f6843cfa141..00000000000 --- a/app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap +++ /dev/null @@ -1,3639 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`BridgeView Bottom Content blurs input when opening QuoteExpiredModal 1`] = ` - - - - - - - - - - - - - Bridge - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ETH - - - - - - - - - 2 ETH - - - - - - - - - - - - - - - - - - - - - - Swap to - - - - - - - - - - - - - - - - - - - - - - 25% - - - - - 50% - - - - - 75% - - - - - 90% - - - - - - - - - 1 - - - - - - - 2 - - - - - - - 3 - - - - - - - - - 4 - - - - - - - 5 - - - - - - - 6 - - - - - - - - - 7 - - - - - - - 8 - - - - - - - 9 - - - - - - - - - . - - - - - - - 0 - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[`BridgeView renders 1`] = ` - - - - - - - - - - - - - Bridge - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ETH - - - - - - - - - 2 ETH - - - - - - - - - - - - - - - - - - - - - - Swap to - - - - - - - - - - - - - - - - - - - - - - - - 25% - - - - - 50% - - - - - 75% - - - - - 90% - - - - - - - - - 1 - - - - - - - 2 - - - - - - - 3 - - - - - - - - - 4 - - - - - - - 5 - - - - - - - 6 - - - - - - - - - 7 - - - - - - - 8 - - - - - - - 9 - - - - - - - - - . - - - - - - - 0 - - - - - - - - - - - - - - - - - - - - - - - - - -`; diff --git a/app/components/UI/Bridge/Views/BridgeView/index.tsx b/app/components/UI/Bridge/Views/BridgeView/index.tsx index 64a11629c8f..a4c641bb749 100644 --- a/app/components/UI/Bridge/Views/BridgeView/index.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/index.tsx @@ -1,4 +1,10 @@ -import React, { useEffect, useState, useRef, useMemo } from 'react'; +import React, { + useEffect, + useState, + useRef, + useMemo, + useCallback, +} from 'react'; import { useSelector, useDispatch } from 'react-redux'; import ScreenView from '../../../../Base/ScreenView'; import { @@ -44,6 +50,7 @@ import { strings } from '../../../../../../locales/i18n'; import Engine from '../../../../../core/Engine'; import Routes from '../../../../../constants/navigation/Routes'; import QuoteDetailsCard from '../../components/QuoteDetailsCard'; +import QuoteDetailsCardSkeleton from '../../components/QuoteDetailsCard/QuoteDetailsCardSkeleton'; import { useBridgeQuoteRequest } from '../../hooks/useBridgeQuoteRequest'; import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData'; import BannerAlert from '../../../../../component-library/components/Banners/Banner/variants/BannerAlert'; @@ -56,7 +63,11 @@ import { selectSelectedNetworkClientId } from '../../../../../selectors/networkC import { useIsNetworkEnabled } from '../../hooks/useIsNetworkEnabled'; import { BridgeToken } from '../../types'; import { useSwitchTokens } from '../../hooks/useSwitchTokens'; -import { ScrollView } from 'react-native'; +import { + ScrollView, + type NativeSyntheticEvent, + type NativeScrollEvent, +} from 'react-native'; import useIsInsufficientBalance from '../../hooks/useInsufficientBalance'; import { selectSelectedInternalAccountFormattedAddress } from '../../../../../selectors/accountsController'; import { isHardwareAccount } from '../../../../../util/address'; @@ -84,12 +95,24 @@ import { SwapsConfirmButton } from '../../components/SwapsConfirmButton/index.ts import { useBridgeViewOnFocus } from '../../hooks/useBridgeViewOnFocus/index.ts'; import { useRenderQuoteExpireModal } from '../../hooks/useRenderQuoteExpireModal/index.ts'; import { type BridgeRouteParams } from '../../hooks/useSwapBridgeNavigation/index.ts'; +import BridgeTrendingTokensSection from '../../components/BridgeTrendingTokensSection/BridgeTrendingTokensSection'; +import { selectRemoteFeatureFlags } from '../../../../../selectors/featureFlagController'; +import type { RootState } from '../../../../../reducers'; +const SCROLL_NEAR_BOTTOM_PX = 160; import { useTrackSwapPageViewed } from '../../hooks/useTrackSwapPageViewed/index.ts'; const BridgeView = () => { const [isErrorBannerVisible, setIsErrorBannerVisible] = useState(true); + const [isNearBottom, setIsNearBottom] = useState(false); const isSubmittingTx = useSelector(selectIsSubmittingTx); + // Inline selector because this is a temporary feature flag + // TODO: Remove this once trending tokens feature is prod hardened + const isSwapsTrendingTokensEnabled = useSelector( + (state: RootState) => + selectRemoteFeatureFlags(state).swapsTrendingTokens === true, + ); + const { styles } = useStyles(createStyles); const dispatch = useDispatch(); const navigation = useNavigation(); @@ -124,6 +147,9 @@ const BridgeView = () => { const isSolanaSourced = useSelector(selectIsSolanaSourced); const isDestNetworkEnabled = useIsNetworkEnabled(destToken?.chainId); + /** The entry point location for analytics (e.g. Main View, Token View, Trending Explore) */ + const location = route.params?.location; + // inputRef is used to programmatically blur the input field after a delay // This gives users time to type before the keyboard disappears // The ref is typed to only expose the blur method we need @@ -246,6 +272,7 @@ const BridgeView = () => { // Always show quote details when there's an active quote const shouldDisplayQuoteDetails = !!activeQuote; + const isZeroState = !sourceAmount || !(Number(sourceAmount) > 0); // Update quote parameters when relevant state changes useEffect(() => { @@ -326,15 +353,30 @@ const BridgeView = () => { ? strings('bridge.stock_token_error_banner_description') : strings('bridge.error_banner_description'); + const getContentMode = () => { + if (isLoading && !activeQuote) return 'loading'; + if (isError && isErrorBannerVisible) return 'error'; + if (shouldDisplayQuoteDetails) return 'quote'; + if (isZeroState) return 'zero'; + return 'none'; + }; + const contentMode = getContentMode(); + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = + event.nativeEvent; + setIsNearBottom( + contentOffset.y + layoutMeasurement.height >= + contentSize.height - SCROLL_NEAR_BOTTOM_PX, + ); + }, + [], + ); + const renderBottomContent = () => { if (isLoading && !activeQuote) { - return ( - - - {strings('bridge.fetching_quote')} - - - ); + return null; } // Prevent bottom section from rendering when no active @@ -380,7 +422,10 @@ const BridgeView = () => { /> )} - + {hasFee @@ -418,64 +463,70 @@ const BridgeView = () => { keypadRef.current?.close(); }} > - - keypadRef.current?.open()} - onTokenPress={handleSourceTokenPress} - onMaxPress={handleSourceMaxPress} - latestAtomicBalance={latestSourceBalance?.atomicBalance} - isSourceToken - isQuoteSponsored={isQuoteSponsored} - /> - - keypadRef.current?.close()} - onTokenPress={handleDestTokenPress} - isLoading={!destTokenAmount && isLoading} - style={styles.destTokenArea} - isQuoteSponsored={isQuoteSponsored} - /> - - - {/* Scrollable Dynamic Content */} + + keypadRef.current?.open()} + onTokenPress={handleSourceTokenPress} + onMaxPress={handleSourceMaxPress} + latestAtomicBalance={latestSourceBalance?.atomicBalance} + isSourceToken + isQuoteSponsored={isQuoteSponsored} + /> + + keypadRef.current?.close()} + onTokenPress={handleDestTokenPress} + isLoading={!destTokenAmount && isLoading} + style={styles.destTokenArea} + isQuoteSponsored={isQuoteSponsored} + /> + + - {isError && isErrorBannerVisible && ( + {contentMode === 'loading' ? ( + + + + ) : null} + {contentMode === 'error' ? ( { }} /> - )} - {shouldDisplayQuoteDetails && ( + ) : null} + {contentMode === 'quote' ? ( - )} + ) : null} + {contentMode === 'zero' && isSwapsTrendingTokensEnabled ? ( + + ) : null} @@ -509,6 +564,7 @@ const BridgeView = () => { > {sourceAmount && sourceAmount !== '0' ? ( diff --git a/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.test.tsx b/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.test.tsx new file mode 100644 index 00000000000..c1d64a86c58 --- /dev/null +++ b/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.test.tsx @@ -0,0 +1,160 @@ +import { TrendingAsset } from '@metamask/assets-controllers'; +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; +import BridgeTrendingTokensSection from './BridgeTrendingTokensSection'; +import { useTokenListFilters } from '../../../Trending/hooks/useTokenListFilters/useTokenListFilters'; +import { useTrendingRequest } from '../../../Trending/hooks/useTrendingRequest/useTrendingRequest'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(() => ({})), +})); + +jest.mock( + '../../../Trending/hooks/useTokenListFilters/useTokenListFilters', + () => ({ + useTokenListFilters: jest.fn(), + }), +); + +jest.mock( + '../../../Trending/hooks/useTrendingRequest/useTrendingRequest', + () => ({ + useTrendingRequest: jest.fn(), + }), +); + +jest.mock('../../../Trending/utils/sortTrendingTokens', () => ({ + sortTrendingTokens: jest.fn((tokens: TrendingAsset[]) => tokens), +})); + +jest.mock( + '../../../Trending/components/TrendingTokenRowItem/TrendingTokenRowItem', + () => { + const React = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ token }: { token: { assetId: string } }) => + React.createElement(View, { testID: `row-${token.assetId}` }), + }; + }, +); + +jest.mock( + '../../../Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton', + () => { + const React = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => React.createElement(View, { testID: 'skeleton-row' }), + }; + }, +); + +jest.mock('../../../Trending/components/TrendingTokensBottomSheet', () => ({ + TrendingTokenTimeBottomSheet: () => null, + TrendingTokenNetworkBottomSheet: () => null, + TrendingTokenPriceChangeBottomSheet: () => null, + mapTimeOptionToSortBy: jest.fn(() => 'h24_trending'), +})); + +const mockUseTokenListFilters = useTokenListFilters as jest.Mock; +const mockUseTrendingRequest = useTrendingRequest as jest.Mock; + +const createTrendingTokens = (count: number): TrendingAsset[] => + Array.from({ length: count }, (_, index) => ({ + assetId: `eip155:1/erc20:0x${(index + 1).toString(16).padStart(40, '0')}`, + symbol: `T${index + 1}`, + name: `Token ${index + 1}`, + decimals: 18, + price: `${index + 1}`, + aggregatedUsdVolume: index + 1, + marketCap: index + 1, + priceChangePct: { + h24: `${index + 1}`, + h6: `${index + 1}`, + h1: `${index + 1}`, + m5: `${index + 1}`, + }, + })); + +const setupMocks = (tokens: TrendingAsset[], isLoading = false) => { + mockUseTokenListFilters.mockReturnValue({ + selectedTimeOption: '24h', + setSelectedTimeOption: jest.fn(), + selectedNetwork: null, + selectedPriceChangeOption: 'price_change', + priceChangeSortDirection: 'descending', + selectedNetworkName: 'All networks', + priceChangeButtonText: 'Price change', + filterContext: { + timeFilter: '24h', + sortOption: 'price_change', + networkFilter: 'all', + isSearchResult: false, + }, + handlePriceChangeSelect: jest.fn(), + handleNetworkSelect: jest.fn(), + }); + mockUseTrendingRequest.mockReturnValue({ + results: tokens, + isLoading, + error: null, + fetch: jest.fn(), + }); +}; + +describe('BridgeTrendingTokensSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupMocks(createTrendingTokens(30)); + }); + + it('renders 12 tokens initially and shows the show-more button', () => { + const { getAllByTestId, getByTestId } = render( + , + ); + + const rows = getAllByTestId(/^row-/); + expect(rows).toHaveLength(12); + expect(getByTestId('bridge-trending-show-more')).toBeTruthy(); + }); + + it('appends one chunk when isNearBottom becomes true', () => { + const { getAllByTestId, rerender } = render( + , + ); + + rerender(); + + expect(getAllByTestId(/^row-/)).toHaveLength(24); + }); + + it('resets visible token count when dataset changes', () => { + const { getAllByTestId, queryByTestId, rerender } = render( + , + ); + + rerender(); + expect(getAllByTestId(/^row-/)).toHaveLength(24); + + setupMocks(createTrendingTokens(8)); + rerender(); + + expect(getAllByTestId(/^row-/)).toHaveLength(8); + expect(queryByTestId('bridge-trending-show-more')).toBeNull(); + }); + + it('does not append chunk while a bottom sheet is open', () => { + const { getAllByTestId, getByTestId, rerender } = render( + , + ); + + fireEvent.press(getByTestId('bridge-trending-price-filter')); + + rerender(); + + expect(getAllByTestId(/^row-/)).toHaveLength(12); + }); +}); diff --git a/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.tsx b/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.tsx new file mode 100644 index 00000000000..bed9ba7c97b --- /dev/null +++ b/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.tsx @@ -0,0 +1,254 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Modal, Pressable } from 'react-native'; +import { useSelector } from 'react-redux'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxFlexDirection, + Text, + TextVariant, + TextColor, + FontWeight, +} from '@metamask/design-system-react-native'; +import { selectNetworkConfigurationsByCaipChainId } from '../../../../../selectors/networkController'; +import { + TrendingTokenNetworkBottomSheet, + TrendingTokenPriceChangeBottomSheet, + TrendingTokenTimeBottomSheet, + mapTimeOptionToSortBy, +} from '../../../Trending/components/TrendingTokensBottomSheet'; +import { + ALLOWED_BRIDGE_CHAIN_IDS, + formatChainIdToCaip, +} from '@metamask/bridge-controller'; +import TrendingTokensSkeleton from '../../../Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; +import TrendingTokenRowItem from '../../../Trending/components/TrendingTokenRowItem/TrendingTokenRowItem'; +import { useTokenListFilters } from '../../../Trending/hooks/useTokenListFilters/useTokenListFilters'; +import { useTrendingRequest } from '../../../Trending/hooks/useTrendingRequest/useTrendingRequest'; +import { sortTrendingTokens } from '../../../Trending/utils/sortTrendingTokens'; +import { strings } from '../../../../../../locales/i18n'; +import { BridgeViewSelectorsIDs } from '../../Views/BridgeView/BridgeView.testIds'; +import { getNetworkImageSource } from '../../../../../util/networks'; +import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../constants/bridge'; +import type { ProcessedNetwork } from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; +import type { CaipChainId } from '@metamask/utils'; +import { FilterButton } from '../../../Trending/components/FilterBar/FilterBar'; + +const TOKEN_CHUNK_SIZE = 12; + +type ActiveBottomSheet = 'none' | 'time' | 'network' | 'price_change'; + +interface BridgeTrendingTokensSectionProps { + isNearBottom?: boolean; +} + +const BridgeTrendingTokensSection = ({ + isNearBottom, +}: BridgeTrendingTokensSectionProps) => { + const tw = useTailwind(); + const [activeBottomSheet, setActiveBottomSheet] = + useState('none'); + const closeBottomSheet = () => setActiveBottomSheet('none'); + const [visibleTokenCount, setVisibleTokenCount] = useState(TOKEN_CHUNK_SIZE); + + const networkConfigurations = useSelector( + selectNetworkConfigurationsByCaipChainId, + ); + + const { + selectedTimeOption, + setSelectedTimeOption, + selectedNetwork, + selectedPriceChangeOption, + priceChangeSortDirection, + selectedNetworkName, + priceChangeButtonText, + filterContext, + handlePriceChangeSelect, + handleNetworkSelect, + } = useTokenListFilters(); + + const sortBy = useMemo( + () => mapTimeOptionToSortBy(selectedTimeOption), + [selectedTimeOption], + ); + + const { results, isLoading } = useTrendingRequest({ + sortBy, + chainIds: selectedNetwork ?? undefined, + }); + + const trendingTokens = useMemo(() => { + if (results.length === 0 || !selectedPriceChangeOption) { + return results; + } + return sortTrendingTokens( + results, + selectedPriceChangeOption, + priceChangeSortDirection, + selectedTimeOption, + ); + }, [ + results, + selectedPriceChangeOption, + priceChangeSortDirection, + selectedTimeOption, + ]); + + useEffect(() => { + if (isLoading) { + setVisibleTokenCount(TOKEN_CHUNK_SIZE); + return; + } + setVisibleTokenCount(Math.min(TOKEN_CHUNK_SIZE, trendingTokens.length)); + }, [isLoading, trendingTokens]); + + const hasMore = visibleTokenCount < trendingTokens.length; + const bridgeTrendingNetworks = useMemo( + () => + ALLOWED_BRIDGE_CHAIN_IDS.map((allowedChainId) => { + const caipChainId = formatChainIdToCaip(allowedChainId) as CaipChainId; + // Map to network configurations first because network filter dropdown does the same + // Fallback to NETWORK_TO_SHORT_NETWORK_NAME_MAP because some bridge chains are not in network configurations + const networkName = + networkConfigurations[caipChainId]?.name ?? + NETWORK_TO_SHORT_NETWORK_NAME_MAP[allowedChainId] ?? + caipChainId; + + return { + id: caipChainId, + name: networkName, + caipChainId, + isSelected: false, + imageSource: getNetworkImageSource({ chainId: allowedChainId }), + } as ProcessedNetwork; + }), + [networkConfigurations], + ); + + const loadNextChunk = useCallback(() => { + setVisibleTokenCount((currentCount) => + Math.min(currentCount + TOKEN_CHUNK_SIZE, trendingTokens.length), + ); + }, [trendingTokens.length]); + + useEffect(() => { + if (isNearBottom && activeBottomSheet === 'none' && !isLoading && hasMore) { + loadNextChunk(); + } + }, [isNearBottom, activeBottomSheet, isLoading, hasMore, loadNextChunk]); + + return ( + <> + + + {strings('trending.trending_tokens')} + + + setActiveBottomSheet('price_change')} + label={priceChangeButtonText} + twClassName="flex-1" + /> + setActiveBottomSheet('network')} + label={selectedNetworkName} + twClassName="flex-1" + /> + setActiveBottomSheet('time')} + label={selectedTimeOption} + twClassName="w-[72px] shrink-0" + /> + + + {isLoading + ? Array.from({ length: 6 }).map((_, index) => ( + + )) + : trendingTokens + .slice(0, visibleTokenCount) + .map((token, index) => ( + + ))} + {!isLoading && hasMore ? ( + + tw.style('mt-3 py-2 self-center', pressed && 'opacity-70') + } + > + + {strings('rewards.settings.show_more')} + + + ) : null} + + + + {activeBottomSheet === 'time' && ( + + setSelectedTimeOption(timeOption) + } + selectedTime={selectedTimeOption} + /> + )} + {activeBottomSheet === 'network' && ( + + )} + {activeBottomSheet === 'price_change' && ( + + )} + + + ); +}; + +export default BridgeTrendingTokensSection; diff --git a/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactDescription.test.tsx b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactDescription.test.tsx new file mode 100644 index 00000000000..17fc7d2c1ab --- /dev/null +++ b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactDescription.test.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { PriceImpactDescription } from './PriceImpactDescription'; +import { PriceImpactModalType } from './constants'; +import { strings } from '../../../../../../locales/i18n'; + +describe('PriceImpactDescription', () => { + describe('Execution type', () => { + it('renders the execution description with the given priceImpact', () => { + const { getByText } = render( + , + ); + + expect( + getByText( + strings('bridge.price_impact_execution_description', { + priceImpact: '-30%', + }), + ), + ).toBeTruthy(); + }); + + it('renders the execution description with "0" when priceImpact is undefined', () => { + const { getByText } = render( + , + ); + + expect( + getByText( + strings('bridge.price_impact_execution_description', { + priceImpact: '0', + }), + ), + ).toBeTruthy(); + }); + + it('does not render the info description', () => { + const { queryByText } = render( + , + ); + + expect( + queryByText(strings('bridge.price_impact_info_description')), + ).toBeNull(); + }); + }); + + describe('Info type — with priceImpact (warning state)', () => { + it('renders the warning description with the given priceImpact', () => { + const { getByText } = render( + , + ); + + expect( + getByText( + strings('bridge.price_impact_warning_description', { + priceImpact: '-10%', + }), + ), + ).toBeTruthy(); + }); + + it('does not render the info description when priceImpact is provided', () => { + const { queryByText } = render( + , + ); + + expect( + queryByText(strings('bridge.price_impact_info_description')), + ).toBeNull(); + }); + + it('treats the string "0" as a truthy priceImpact and renders the warning description', () => { + const { getByText } = render( + , + ); + + expect( + getByText( + strings('bridge.price_impact_warning_description', { + priceImpact: '0', + }), + ), + ).toBeTruthy(); + }); + }); + + describe('Info type — without priceImpact (info state)', () => { + it('renders the info description when priceImpact is undefined', () => { + const { getByText } = render( + , + ); + + expect( + getByText(strings('bridge.price_impact_info_description')), + ).toBeTruthy(); + }); + + it('renders the info description when priceImpact is an empty string', () => { + const { getByText } = render( + , + ); + + expect( + getByText(strings('bridge.price_impact_info_description')), + ).toBeTruthy(); + }); + + it('does not render the warning description when priceImpact is absent', () => { + const { queryByText } = render( + , + ); + + expect( + queryByText( + strings('bridge.price_impact_warning_description', { + priceImpact: undefined, + }), + ), + ).toBeNull(); + }); + }); + + describe('priority — Execution type takes precedence over warning state', () => { + it('renders the execution description rather than the warning description when type is Execution and priceImpact is provided', () => { + const { getByText, queryByText } = render( + , + ); + + expect( + getByText( + strings('bridge.price_impact_execution_description', { + priceImpact: '-10%', + }), + ), + ).toBeTruthy(); + + expect( + queryByText( + strings('bridge.price_impact_warning_description', { + priceImpact: '-10%', + }), + ), + ).toBeNull(); + }); + }); +}); diff --git a/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactDescription.tsx b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactDescription.tsx new file mode 100644 index 00000000000..db6947603a4 --- /dev/null +++ b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactDescription.tsx @@ -0,0 +1,36 @@ +import React, { useMemo } from 'react'; +import { strings } from '../../../../../../locales/i18n'; +import { Box, Text, TextColor } from '@metamask/design-system-react-native'; +import { PriceImpactModalType } from './constants'; + +interface PriceImpactDescriptionProps { + type: PriceImpactModalType; + priceImpact?: string; +} + +export function PriceImpactDescription({ + type, + priceImpact, +}: PriceImpactDescriptionProps) { + const isWarning = Boolean(priceImpact); + + const body = useMemo(() => { + if (type === PriceImpactModalType.Execution) { + return strings('bridge.price_impact_execution_description', { + priceImpact: priceImpact ?? '0', + }); + } + if (isWarning) { + return strings('bridge.price_impact_warning_description', { + priceImpact: priceImpact ?? '0', + }); + } + return strings('bridge.price_impact_info_description'); + }, [type, priceImpact, isWarning]); + + return ( + + {body} + + ); +} diff --git a/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactFooter.test.tsx b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactFooter.test.tsx new file mode 100644 index 00000000000..63671a34227 --- /dev/null +++ b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactFooter.test.tsx @@ -0,0 +1,258 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import { PriceImpactFooter } from './PriceImpactFooter'; +import { PriceImpactModalType } from './constants'; +import { strings } from '../../../../../../locales/i18n'; + +const onConfirm = jest.fn(); +const onCancel = jest.fn(); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('PriceImpactFooter', () => { + describe('Info type', () => { + it('renders the Got it button', () => { + const { getByText } = render( + , + ); + + expect(getByText(strings('bridge.got_it'))).toBeTruthy(); + }); + + it('does not render the Proceed button', () => { + const { queryByText } = render( + , + ); + + expect(queryByText(strings('bridge.proceed'))).toBeNull(); + }); + + it('does not render the Cancel button', () => { + const { queryByText } = render( + , + ); + + expect(queryByText(strings('bridge.cancel'))).toBeNull(); + }); + + it('calls onConfirm when Got it is pressed', () => { + const { getByText } = render( + , + ); + + fireEvent.press(getByText(strings('bridge.got_it'))); + + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it('does not pass loading or disabled props to the Got it button', () => { + // loading prop is not forwarded for the Info type layout — no isLoading/disabled + // on the button. Rendering with loading=true should not affect the button state. + const { getByText } = render( + , + ); + + fireEvent.press(getByText(strings('bridge.got_it'))); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + }); + + describe('Execution type', () => { + it('renders the Proceed button', () => { + const { getByText } = render( + , + ); + + expect(getByText(strings('bridge.proceed'))).toBeTruthy(); + }); + + it('renders the Cancel button', () => { + const { getByText } = render( + , + ); + + expect(getByText(strings('bridge.cancel'))).toBeTruthy(); + }); + + it('does not render the Got it button', () => { + const { queryByText } = render( + , + ); + + expect(queryByText(strings('bridge.got_it'))).toBeNull(); + }); + + it('calls onCancel when Proceed is pressed', () => { + const { getByText } = render( + , + ); + + fireEvent.press(getByText(strings('bridge.proceed'))); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('calls onConfirm when Cancel is pressed', () => { + const { getByText } = render( + , + ); + + fireEvent.press(getByText(strings('bridge.cancel'))); + + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onCancel).not.toHaveBeenCalled(); + }); + + describe('loading state', () => { + it('disables the Proceed button while loading', () => { + const { getAllByRole } = render( + , + ); + + // Execution layout renders Proceed first, Cancel second + const [proceedButton] = getAllByRole('button'); + expect(proceedButton.props.accessibilityState?.disabled).toBe(true); + }); + + it('disables the Cancel button while loading', () => { + const { getAllByRole } = render( + , + ); + + const [, cancelButton] = getAllByRole('button'); + expect(cancelButton.props.accessibilityState?.disabled).toBe(true); + }); + + it('does not fire onCancel when Proceed is pressed while loading', () => { + const { getByText } = render( + , + ); + + fireEvent.press(getByText(strings('bridge.proceed'))); + + expect(onCancel).not.toHaveBeenCalled(); + }); + + it('does not fire onConfirm when Cancel is pressed while loading', () => { + const { getByText } = render( + , + ); + + fireEvent.press(getByText(strings('bridge.cancel'))); + + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('does not mark buttons as disabled when not loading', () => { + const { getAllByRole } = render( + , + ); + + const [proceedButton, cancelButton] = getAllByRole('button'); + + // ButtonBase only adds `disabled: true` to accessibilityState when + // disabled; the key is absent (undefined) when the button is enabled. + expect(proceedButton.props.accessibilityState?.disabled).not.toBe(true); + expect(cancelButton.props.accessibilityState?.disabled).not.toBe(true); + }); + + it('sets busy accessibilityState on the Proceed button but not on the Cancel button', () => { + const { getAllByRole } = render( + , + ); + + const [proceedButton, cancelButton] = getAllByRole('button'); + + // Proceed (onCancel) shows a loading spinner — accessibilityState.busy = true + expect(proceedButton.props.accessibilityState?.busy).toBe(true); + // Cancel (onConfirm) is disabled but has no loading spinner + expect(cancelButton.props.accessibilityState?.busy).not.toBe(true); + }); + }); + }); +}); diff --git a/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactFooter.tsx b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactFooter.tsx new file mode 100644 index 00000000000..0da2d903ee5 --- /dev/null +++ b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactFooter.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { strings } from '../../../../../../locales/i18n'; +import { + Box, + BoxFlexDirection, + Button, + ButtonSize, + ButtonVariant, +} from '@metamask/design-system-react-native'; +import { PriceImpactModalType } from './constants'; + +export interface PriceImpactFooterProps { + type: PriceImpactModalType; + onConfirm: () => void; + onCancel: () => void; + loading: boolean; +} + +export function PriceImpactFooter({ + type, + onConfirm, + onCancel, + loading, +}: PriceImpactFooterProps) { + if (type === PriceImpactModalType.Execution) { + return ( + + + + + + + + + ); + } + + return ( + + + + ); +} diff --git a/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactHeader.test.tsx b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactHeader.test.tsx new file mode 100644 index 00000000000..9330591264e --- /dev/null +++ b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactHeader.test.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import { PriceImpactHeader } from './PriceImpactHeader'; +import { PriceImpactModalType } from './constants'; +import { strings } from '../../../../../../locales/i18n'; +import { IconName } from '../../../../../component-library/components/Icons/Icon'; + +// Render the warning Icon with a testID derived from the icon name so it can +// be queried in tests without coupling to SVG internals. +jest.mock('../../../../../component-library/components/Icons/Icon', () => { + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ name, testID }: { name: string; testID?: string }) => ( + + ), + IconName: jest.requireActual( + '../../../../../component-library/components/Icons/Icon', + ).IconName, + IconSize: jest.requireActual( + '../../../../../component-library/components/Icons/Icon', + ).IconSize, + }; +}); + +const onClose = jest.fn(); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('PriceImpactHeader', () => { + describe('title', () => { + it('renders "Price impact" for the Info type', () => { + const { getByText } = render( + , + ); + + expect(getByText(strings('bridge.price_impact'))).toBeTruthy(); + }); + + it('renders "High price impact" for the Execution type', () => { + const { getByText } = render( + , + ); + + expect(getByText(strings('bridge.price_impact_high'))).toBeTruthy(); + }); + }); + + describe('close button', () => { + it('renders the close button', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('button-icon')).toBeTruthy(); + }); + + it('calls onClose when the close button is pressed', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('button-icon')); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose on the Execution type as well', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('button-icon')); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); + + describe('warning icon', () => { + it('renders the warning icon when both warningIconName and warningIconColor are provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(`icon-${IconName.Danger}`)).toBeTruthy(); + }); + + it('does not render the warning icon when warningIconName is absent', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(`icon-${IconName.Danger}`)).toBeNull(); + }); + + it('does not render the warning icon when warningIconColor is absent', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(`icon-${IconName.Warning}`)).toBeNull(); + }); + + it('does not render the warning icon when neither prop is provided', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(`icon-${IconName.Danger}`)).toBeNull(); + expect(queryByTestId(`icon-${IconName.Warning}`)).toBeNull(); + }); + + it('renders a Warning icon for the Info type when both props are provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(`icon-${IconName.Warning}`)).toBeTruthy(); + }); + }); +}); diff --git a/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactHeader.tsx b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactHeader.tsx new file mode 100644 index 00000000000..9938b1b44d6 --- /dev/null +++ b/app/components/UI/Bridge/components/PriceImpactModal/PriceImpactHeader.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import Icon, { + IconName, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; +import { strings } from '../../../../../../locales/i18n'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + ButtonIcon, + ButtonIconSize, + FontWeight, + IconName as DSIconName, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import { PriceImpactModalType } from './constants'; + +interface PriceImpactHeaderProps { + type: PriceImpactModalType; + onClose: () => void; + warningIconName?: IconName; + warningIconColor?: string; +} + +export function PriceImpactHeader({ + type, + onClose, + warningIconName, + warningIconColor, +}: PriceImpactHeaderProps) { + const isWarning = Boolean(warningIconName && warningIconColor); + const title = + type === PriceImpactModalType.Execution + ? strings('bridge.price_impact_high') + : strings('bridge.price_impact'); + + return ( + + + + {isWarning && warningIconName && warningIconColor && ( + + )} + + {title} + + + + + + + ); +} diff --git a/app/components/UI/Bridge/components/PriceImpactModal/constants.ts b/app/components/UI/Bridge/components/PriceImpactModal/constants.ts new file mode 100644 index 00000000000..a00a7aaee25 --- /dev/null +++ b/app/components/UI/Bridge/components/PriceImpactModal/constants.ts @@ -0,0 +1,4 @@ +export enum PriceImpactModalType { + Info = 'info', + Execution = 'execution', +} diff --git a/app/components/UI/Bridge/components/PriceImpactModal/index.test.tsx b/app/components/UI/Bridge/components/PriceImpactModal/index.test.tsx new file mode 100644 index 00000000000..443139395de --- /dev/null +++ b/app/components/UI/Bridge/components/PriceImpactModal/index.test.tsx @@ -0,0 +1,416 @@ +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; +import { PriceImpactModal } from './index'; +import { PriceImpactModalType } from './constants'; +import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller'; +import { TextColor } from '../../../../../component-library/components/Texts/Text'; +import { IconName } from '../../../../../component-library/components/Icons/Icon'; + +// Mock BottomSheet +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const ReactModule = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + + return { + __esModule: true, + default: ReactModule.forwardRef( + (props: { children: unknown }, _ref: unknown) => ( + {props.children as React.ReactNode} + ), + ), + }; + }, +); + +// Mock sub-components so we can assert on the props they receive +jest.mock('./PriceImpactHeader', () => ({ + PriceImpactHeader: jest.fn( + ({ type, onClose }: { type: string; onClose: () => void }) => { + const { View, TouchableOpacity, Text } = + jest.requireActual('react-native'); + return ( + + {type} + + Close + + + ); + }, + ), +})); + +jest.mock('./PriceImpactDescription', () => ({ + PriceImpactDescription: jest.fn( + ({ type, priceImpact }: { type: string; priceImpact?: string }) => { + const { View, Text } = jest.requireActual('react-native'); + return ( + + {type} + {priceImpact ? ( + {priceImpact} + ) : null} + + ); + }, + ), +})); + +jest.mock('./PriceImpactFooter', () => ({ + PriceImpactFooter: jest.fn( + ({ + type, + onConfirm, + onCancel, + loading, + }: { + type: string; + onConfirm: () => void; + onCancel: () => Promise; + loading: boolean; + }) => { + const { View, TouchableOpacity, Text } = + jest.requireActual('react-native'); + return ( + + {type} + {String(loading)} + + Confirm + + + Cancel + + + ); + }, + ), +})); + +// Mock hooks +jest.mock('../../../../../util/navigation/navUtils', () => ({ + useParams: jest.fn(), +})); + +jest.mock('../../hooks/useLatestBalance', () => ({ + useLatestBalance: jest.fn(), +})); + +jest.mock('../../hooks/useBridgeConfirm', () => ({ + useBridgeConfirm: jest.fn(), +})); + +jest.mock('../../hooks/useBridgeQuoteData', () => ({ + useBridgeQuoteData: jest.fn(), +})); + +jest.mock('../../hooks/useModalCloseOnQuoteExpiry', () => ({ + useModalCloseOnQuoteExpiry: jest.fn(), +})); + +jest.mock('../../utils/getPriceImpactViewData', () => ({ + getPriceImpactViewData: jest.fn(), +})); + +import { useParams } from '../../../../../util/navigation/navUtils'; +import { useLatestBalance } from '../../hooks/useLatestBalance'; +import { useBridgeConfirm } from '../../hooks/useBridgeConfirm'; +import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData'; +import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry'; +import { getPriceImpactViewData } from '../../utils/getPriceImpactViewData'; +import { PriceImpactHeader } from './PriceImpactHeader'; +import { PriceImpactDescription } from './PriceImpactDescription'; +import { PriceImpactFooter } from './PriceImpactFooter'; + +const mockUseParams = useParams as jest.MockedFunction; +const mockUseLatestBalance = useLatestBalance as jest.MockedFunction< + typeof useLatestBalance +>; +const mockUseBridgeConfirm = useBridgeConfirm as jest.MockedFunction< + typeof useBridgeConfirm +>; +const mockUseBridgeQuoteData = useBridgeQuoteData as jest.MockedFunction< + typeof useBridgeQuoteData +>; +const mockUseModalCloseOnQuoteExpiry = + useModalCloseOnQuoteExpiry as jest.MockedFunction< + typeof useModalCloseOnQuoteExpiry + >; +const mockGetPriceImpactViewData = + getPriceImpactViewData as jest.MockedFunction; +const mockPriceImpactHeader = PriceImpactHeader as jest.MockedFunction< + typeof PriceImpactHeader +>; +const mockPriceImpactDescription = + PriceImpactDescription as jest.MockedFunction; +const mockPriceImpactFooter = PriceImpactFooter as jest.MockedFunction< + typeof PriceImpactFooter +>; + +const mockConfirmBridge = jest.fn(); + +const mockToken = { + address: '0xabc', + decimals: 18, + chainId: '0x1' as `0x${string}`, + symbol: 'ETH', + name: 'Ether', + image: '', +}; + +const defaultParams = { + type: PriceImpactModalType.Info, + token: mockToken, + location: MetaMetricsSwapsEventSource.MainView, +}; + +const defaultViewData = { + textColor: TextColor.Alternative, + icon: undefined, +}; + +describe('PriceImpactModal', () => { + beforeEach(() => { + mockUseParams.mockReturnValue(defaultParams); + mockUseLatestBalance.mockReturnValue(undefined); + mockUseBridgeConfirm.mockReturnValue(mockConfirmBridge); + mockUseBridgeQuoteData.mockReturnValue({ + formattedQuoteData: undefined, + } as ReturnType); + mockGetPriceImpactViewData.mockReturnValue( + defaultViewData as ReturnType, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('useModalCloseOnQuoteExpiry', () => { + it('calls useModalCloseOnQuoteExpiry on render', () => { + render(); + + expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalled(); + }); + + it('calls useModalCloseOnQuoteExpiry exactly once per render', () => { + render(); + + expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalledTimes(1); + }); + }); + + describe('component structure', () => { + it('renders PriceImpactHeader', () => { + const { getByTestId } = render(); + + expect(getByTestId('price-impact-header')).toBeTruthy(); + }); + + it('renders PriceImpactDescription', () => { + const { getByTestId } = render(); + + expect(getByTestId('price-impact-description')).toBeTruthy(); + }); + + it('renders PriceImpactFooter', () => { + const { getByTestId } = render(); + + expect(getByTestId('price-impact-footer')).toBeTruthy(); + }); + }); + + describe('props passed to sub-components', () => { + it('passes type to PriceImpactHeader', () => { + mockUseParams.mockReturnValue({ + ...defaultParams, + type: PriceImpactModalType.Execution, + }); + + render(); + + expect(mockPriceImpactHeader).toHaveBeenCalledWith( + expect.objectContaining({ type: PriceImpactModalType.Execution }), + expect.anything(), + ); + }); + + it('passes type to PriceImpactDescription', () => { + render(); + + expect(mockPriceImpactDescription).toHaveBeenCalledWith( + expect.objectContaining({ type: PriceImpactModalType.Info }), + expect.anything(), + ); + }); + + it('passes type to PriceImpactFooter', () => { + render(); + + expect(mockPriceImpactFooter).toHaveBeenCalledWith( + expect.objectContaining({ type: PriceImpactModalType.Info }), + expect.anything(), + ); + }); + + it('passes priceImpact to PriceImpactDescription when warningIcon is present', () => { + mockGetPriceImpactViewData.mockReturnValue({ + textColor: TextColor.Error, + icon: { name: IconName.Danger, color: TextColor.Error }, + } as ReturnType); + mockUseBridgeQuoteData.mockReturnValue({ + formattedQuoteData: { priceImpact: '5%' }, + } as ReturnType); + + render(); + + expect(mockPriceImpactDescription).toHaveBeenCalledWith( + expect.objectContaining({ priceImpact: '5%' }), + expect.anything(), + ); + }); + + it('passes undefined priceImpact to PriceImpactDescription when warningIcon is absent', () => { + mockGetPriceImpactViewData.mockReturnValue({ + textColor: TextColor.Alternative, + icon: undefined, + } as ReturnType); + mockUseBridgeQuoteData.mockReturnValue({ + formattedQuoteData: { priceImpact: '5%' }, + } as ReturnType); + + render(); + + expect(mockPriceImpactDescription).toHaveBeenCalledWith( + expect.objectContaining({ priceImpact: undefined }), + expect.anything(), + ); + }); + + it('passes warningIconName and warningIconColor to PriceImpactHeader from view data', () => { + mockGetPriceImpactViewData.mockReturnValue({ + textColor: TextColor.Warning, + icon: { name: IconName.Warning, color: TextColor.Warning }, + } as ReturnType); + + render(); + + expect(mockPriceImpactHeader).toHaveBeenCalledWith( + expect.objectContaining({ + warningIconName: IconName.Warning, + warningIconColor: TextColor.Warning, + }), + expect.anything(), + ); + }); + + it('passes undefined warningIconName and warningIconColor when icon is absent', () => { + render(); + + expect(mockPriceImpactHeader).toHaveBeenCalledWith( + expect.objectContaining({ + warningIconName: undefined, + warningIconColor: undefined, + }), + expect.anything(), + ); + }); + + it('starts with loading false', () => { + render(); + + expect(mockPriceImpactFooter).toHaveBeenCalledWith( + expect.objectContaining({ loading: false }), + expect.anything(), + ); + }); + }); + + describe('handleClose', () => { + it('does not call confirmBridge when close is pressed', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('price-impact-header-close')); + + expect(mockConfirmBridge).not.toHaveBeenCalled(); + }); + }); + + describe('handleProceed', () => { + it('calls confirmBridge when the proceed (cancel) button is pressed', async () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('price-impact-footer-cancel')); + + await waitFor(() => { + expect(mockConfirmBridge).toHaveBeenCalledTimes(1); + }); + }); + + it('sets loading to true while proceeding', async () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('price-impact-footer-cancel')); + + await waitFor(() => { + expect(mockPriceImpactFooter).toHaveBeenCalledWith( + expect.objectContaining({ loading: true }), + expect.anything(), + ); + }); + }); + }); + + describe('hook wiring', () => { + it('passes token address, decimals, and chainId to useLatestBalance', () => { + render(); + + expect(mockUseLatestBalance).toHaveBeenCalledWith({ + address: mockToken.address, + decimals: mockToken.decimals, + chainId: mockToken.chainId, + }); + }); + + it('passes location to useBridgeConfirm', () => { + render(); + + expect(mockUseBridgeConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + location: MetaMetricsSwapsEventSource.MainView, + }), + ); + }); + + it('calls getPriceImpactViewData with the priceImpact from formattedQuoteData', () => { + mockUseBridgeQuoteData.mockReturnValue({ + formattedQuoteData: { priceImpact: '12%' }, + } as ReturnType); + + render(); + + expect(mockGetPriceImpactViewData).toHaveBeenCalledWith('12%'); + }); + + it('calls getPriceImpactViewData with undefined when formattedQuoteData is absent', () => { + mockUseBridgeQuoteData.mockReturnValue({ + formattedQuoteData: undefined, + } as ReturnType); + + render(); + + expect(mockGetPriceImpactViewData).toHaveBeenCalledWith(undefined); + }); + }); +}); diff --git a/app/components/UI/Bridge/components/PriceImpactModal/index.tsx b/app/components/UI/Bridge/components/PriceImpactModal/index.tsx new file mode 100644 index 00000000000..30cfebb4749 --- /dev/null +++ b/app/components/UI/Bridge/components/PriceImpactModal/index.tsx @@ -0,0 +1,71 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData'; +import { getPriceImpactViewData } from '../../utils/getPriceImpactViewData'; +import { PriceImpactModalRouterParams } from './types'; +import { useParams } from '../../../../../util/navigation/navUtils'; +import { PriceImpactHeader } from './PriceImpactHeader'; +import { PriceImpactDescription } from './PriceImpactDescription'; +import { PriceImpactFooter } from './PriceImpactFooter'; +import { useLatestBalance } from '../../hooks/useLatestBalance'; +import { useBridgeConfirm } from '../../hooks/useBridgeConfirm'; +import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry'; + +export const PriceImpactModal = () => { + const [loading, setLoading] = useState(false); + const { type, token, location } = useParams(); + const sheetRef = useRef(null); + const tokenBalance = useLatestBalance({ + address: token?.address, + decimals: token?.decimals, + chainId: token?.chainId, + }); + + const confirmBridge = useBridgeConfirm({ + latestSourceBalance: tokenBalance, + location, + }); + + const { formattedQuoteData } = useBridgeQuoteData(); + + const priceImpactViewData = useMemo( + () => getPriceImpactViewData(formattedQuoteData?.priceImpact), + [formattedQuoteData?.priceImpact], + ); + + const handleClose = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + }, []); + + const handleProceed = useCallback(async () => { + setLoading(true); + await confirmBridge(); + }, [confirmBridge]); + + const warningIcon = priceImpactViewData.icon; + + useModalCloseOnQuoteExpiry(); + + return ( + + + + + + ); +}; diff --git a/app/components/UI/Bridge/components/PriceImpactModal/types.ts b/app/components/UI/Bridge/components/PriceImpactModal/types.ts new file mode 100644 index 00000000000..ff5ed695782 --- /dev/null +++ b/app/components/UI/Bridge/components/PriceImpactModal/types.ts @@ -0,0 +1,9 @@ +import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller'; +import { BridgeToken } from '../../types'; +import { PriceImpactModalType } from './constants'; + +export interface PriceImpactModalRouterParams { + type: PriceImpactModalType; + token: BridgeToken; + location: MetaMetricsSwapsEventSource; +} diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx index 9e4c3348eca..d29acb19232 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx @@ -9,6 +9,8 @@ import mockQuotes from '../../_mocks_/mock-quotes-sol-sol.json'; import mockQuotesGasIncluded from '../../_mocks_/mock-quotes-gas-included.json'; import { createBridgeTestState } from '../../testUtils'; import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData'; +import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller'; +import { PriceImpactModalType } from '../PriceImpactModal/constants'; jest.mock( '../../../../../animations/rewards_icon_animations.riv', @@ -263,7 +265,10 @@ const testState = createBridgeTestState({ }); const QuoteDetailsCardTestScreen = () => ( - + ); describe('QuoteDetailsCard', () => { @@ -283,7 +288,7 @@ describe('QuoteDetailsCard', () => { }); it('displays fee amount', () => { - const { getByText } = renderScreen( + const { getByText, getByTestId } = renderScreen( QuoteDetailsCardTestScreen, { name: Routes.BRIDGE.ROOT, @@ -292,10 +297,12 @@ describe('QuoteDetailsCard', () => { ); expect(getByText('0.01')).toBeDefined(); + expect(getByText('Price impact')).toBeTruthy(); + expect(getByTestId('price-impact-info-button')).toBeTruthy(); }); it('displays quote rate', () => { - const { getByText } = renderScreen( + const { getByText, getByTestId } = renderScreen( QuoteDetailsCardTestScreen, { name: Routes.BRIDGE.ROOT, @@ -304,10 +311,12 @@ describe('QuoteDetailsCard', () => { ); expect(getByText('1 ETH = 24.4 USDC')).toBeDefined(); + expect(getByText('Price impact')).toBeTruthy(); + expect(getByTestId('price-impact-info-button')).toBeTruthy(); }); it('navigates to slippage modal on edit press', () => { - const { getByTestId } = renderScreen( + const { getByTestId, getByText } = renderScreen( QuoteDetailsCardTestScreen, { name: Routes.BRIDGE.ROOT, @@ -327,10 +336,12 @@ describe('QuoteDetailsCard', () => { destChainId: 'evm:1', }, }); + expect(getByText('Price impact')).toBeTruthy(); + expect(getByTestId('price-impact-info-button')).toBeTruthy(); }); it('displays slippage value', () => { - const { getByText } = renderScreen( + const { getByText, getByTestId } = renderScreen( QuoteDetailsCardTestScreen, { name: Routes.BRIDGE.ROOT, @@ -340,6 +351,8 @@ describe('QuoteDetailsCard', () => { // Verify slippage value expect(getByText('0.5%')).toBeDefined(); + expect(getByText('Price impact')).toBeTruthy(); + expect(getByTestId('price-impact-info-button')).toBeTruthy(); }); it('displays "Included" fee when gasIncluded7702 is true', () => { @@ -368,7 +381,7 @@ describe('QuoteDetailsCard', () => { }, })); - const { getByText } = renderScreen( + const { getByText, getByTestId } = renderScreen( QuoteDetailsCardTestScreen, { name: Routes.BRIDGE.ROOT, @@ -378,6 +391,8 @@ describe('QuoteDetailsCard', () => { // Verify "Included" text is displayed expect(getByText(strings('bridge.included'))).toBeDefined(); + expect(getByText('Price impact')).toBeTruthy(); + expect(getByTestId('price-impact-info-button')).toBeTruthy(); // Restore original implementation mockModule.useBridgeQuoteData.mockImplementation(originalImpl); @@ -402,7 +417,7 @@ describe('QuoteDetailsCard', () => { }, })); - const { getByText } = renderScreen( + const { getByText, getByTestId } = renderScreen( QuoteDetailsCardTestScreen, { name: Routes.BRIDGE.ROOT, @@ -412,6 +427,8 @@ describe('QuoteDetailsCard', () => { // Verify "Included" text is displayed expect(getByText(strings('bridge.included'))).toBeDefined(); + expect(getByText('Price impact')).toBeTruthy(); + expect(getByTestId('price-impact-info-button')).toBeTruthy(); // Restore original implementation mockModule.useBridgeQuoteData.mockImplementation(originalImpl); @@ -442,7 +459,7 @@ describe('QuoteDetailsCard', () => { }, })); - const { getByText, queryByText } = renderScreen( + const { getByText, getByTestId, queryByText } = renderScreen( QuoteDetailsCardTestScreen, { name: Routes.BRIDGE.ROOT }, { state: testState }, @@ -451,6 +468,8 @@ describe('QuoteDetailsCard', () => { expect(getByText(strings('bridge.network_fee'))).toBeOnTheScreen(); expect(getByText(strings('bridge.gas_fees_sponsored'))).toBeOnTheScreen(); expect(queryByText('0.01')).toBeNull(); + expect(getByText('Price impact')).toBeTruthy(); + expect(getByTestId('price-impact-info-button')).toBeTruthy(); mockModule.useBridgeQuoteData.mockImplementation(originalImpl); }); @@ -480,7 +499,7 @@ describe('QuoteDetailsCard', () => { }, })); - const { getByLabelText } = renderScreen( + const { getByLabelText, getByText, getByTestId } = renderScreen( QuoteDetailsCardTestScreen, { name: Routes.BRIDGE.ROOT }, { state: testState }, @@ -502,6 +521,8 @@ describe('QuoteDetailsCard', () => { }, screen: 'tooltipModal', }); + expect(getByText('Price impact')).toBeTruthy(); + expect(getByTestId('price-impact-info-button')).toBeTruthy(); mockModule.useBridgeQuoteData.mockImplementation(originalImpl); }); @@ -526,7 +547,7 @@ describe('QuoteDetailsCard', () => { expect(queryByTestId('quote-details-card')).toBeNull(); }); - it('handles price impact warning navigation', () => { + it('handles price impact info button navigation', () => { const mockModule = jest.requireMock('../../hooks/useBridgeQuoteData'); mockModule.useBridgeQuoteData.mockImplementationOnce(() => ({ quoteFetchError: null, @@ -550,27 +571,33 @@ describe('QuoteDetailsCard', () => { }, })); - const { getByLabelText } = renderScreen( + const { getByTestId } = renderScreen( QuoteDetailsCardTestScreen, { name: Routes.BRIDGE.ROOT }, { state: testState }, ); - try { - const priceImpactTooltip = getByLabelText( - /Price Impact Warning tooltip/i, - ); - fireEvent.press(priceImpactTooltip); - expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { - params: { isGasIncluded: false }, - }); - } catch { - // Component rendered with high price impact logic - } + const priceImpactInfoButton = getByTestId('price-impact-info-button'); + fireEvent.press(priceImpactInfoButton); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { + screen: Routes.BRIDGE.MODALS.PRICE_IMPACT_MODAL, + params: { + type: PriceImpactModalType.Info, + token: { + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + address: '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + symbol: 'SOL', + decimals: 9, + name: 'Solana', + }, + location: MetaMetricsSwapsEventSource.MainView, + }, + }); }); it('handles quote info navigation', () => { - const { getByLabelText } = renderScreen( + const { getByLabelText, getByText, getByTestId } = renderScreen( QuoteDetailsCardTestScreen, { name: Routes.BRIDGE.ROOT }, { state: testState }, @@ -588,10 +615,11 @@ describe('QuoteDetailsCard', () => { }, screen: 'tooltipModal', }); + expect(getByText('Price impact')).toBeTruthy(); + expect(getByTestId('price-impact-info-button')).toBeTruthy(); }); - it('handles shouldShowPriceImpactWarning false branch', () => { - // Test with low price impact to ensure shouldShowPriceImpactWarning is false + it('renders price impact info button for low price impact values', () => { const mockModule = jest.requireMock('../../hooks/useBridgeQuoteData'); mockModule.useBridgeQuoteData.mockImplementationOnce(() => ({ quoteFetchError: null, @@ -615,18 +643,17 @@ describe('QuoteDetailsCard', () => { }, })); - const { queryByLabelText } = renderScreen( + const { getByTestId, getByText } = renderScreen( QuoteDetailsCardTestScreen, { name: Routes.BRIDGE.ROOT }, { state: testState }, ); - // With low price impact, the warning tooltip should not exist - expect(queryByLabelText(/Price Impact Warning tooltip/i)).toBeNull(); + expect(getByText('Price impact')).toBeTruthy(); + expect(getByTestId('price-impact-info-button')).toBeTruthy(); }); - it('handles shouldShowPriceImpactWarning true branch with color', () => { - // Test with very high price impact to ensure shouldShowPriceImpactWarning is true + it('renders price impact row with info button for high price impact values', () => { const mockModule = jest.requireMock('../../hooks/useBridgeQuoteData'); mockModule.useBridgeQuoteData.mockImplementationOnce(() => ({ quoteFetchError: null, @@ -650,28 +677,15 @@ describe('QuoteDetailsCard', () => { }, })); - const { getByText, queryByLabelText } = renderScreen( + const { getByText, getByTestId } = renderScreen( QuoteDetailsCardTestScreen, { name: Routes.BRIDGE.ROOT }, { state: testState }, ); - // The key is testing the shouldShowPriceImpactWarning conditional branches - // Verify the Price Impact section is visible (this exercises the component logic) expect(getByText('Price impact')).toBeTruthy(); - - // Test the shouldShowPriceImpactWarning branches by checking for tooltip presence - const hasWarningTooltip = - queryByLabelText(/Price Impact Warning tooltip/i) !== null; - - // Either way, we're testing both branches of the conditional - if (hasWarningTooltip) { - // True branch - warning tooltip exists - expect(queryByLabelText(/Price Impact Warning tooltip/i)).toBeTruthy(); - } else { - // False branch - no warning tooltip - expect(queryByLabelText(/Price Impact Warning tooltip/i)).toBeNull(); - } + expect(getByText('25%')).toBeTruthy(); + expect(getByTestId('price-impact-info-button')).toBeTruthy(); }); describe('rewards functionality', () => { diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx index 68f838f74d1..ecbba26db57 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx @@ -2,24 +2,22 @@ import React, { useMemo } from 'react'; import { TouchableOpacity, Platform, UIManager } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { strings } from '../../../../../../locales/i18n'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../component-library/components/Texts/Text'; import { useTheme } from '../../../../../util/theme'; import createStyles from './QuoteDetailsCard.styles'; -import Icon, { - IconColor, - IconName, - IconSize, -} from '../../../../../component-library/components/Icons/Icon'; -import KeyValueRow from '../../../../../component-library/components-temp/KeyValueRow'; +import { IconName as IconNameLegacy } from '../../../../../component-library/components/Icons/Icon'; import { TooltipSizes } from '../../../../../component-library/components-temp/KeyValueRow/KeyValueRow.types'; import { Box, BoxFlexDirection, BoxAlignItems, BoxJustifyContent, + Text, + TextVariant, + TextColor, + Icon, + IconName, + IconSize, + IconColor, } from '@metamask/design-system-react-native'; import Routes from '../../../../../constants/navigation/Routes'; import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData'; @@ -45,6 +43,14 @@ import TagColored, { import { useShouldRenderGasSponsoredBanner } from '../../hooks/useShouldRenderGasSponsoredBanner'; import { isGaslessQuote } from '../../utils/isGaslessQuote'; import { QuoteDetailsCardProps } from './QuoteDetailsCard.types'; +import { getPriceImpactViewData } from '../../utils/getPriceImpactViewData'; +import { + TextVariant as TextVariantLegacy, + TextColor as TextColorLegacy, +} from '../../../../../component-library/components/Texts/Text'; +import KeyValueRow from '../../../../../component-library/components-temp/KeyValueRow'; +import { PriceImpactModalType } from '../PriceImpactModal/constants'; +import { formatPriceImpact } from '../../utils/formatPriceImpact'; if ( Platform.OS === 'android' && @@ -55,6 +61,7 @@ if ( const QuoteDetailsCard: React.FC = ({ hasInsufficientBalance, + location, }) => { const theme = useTheme(); const navigation = useNavigation(); @@ -64,7 +71,6 @@ const QuoteDetailsCard: React.FC = ({ formattedQuoteData, activeQuote, isLoading: isQuoteLoading, - shouldShowPriceImpactWarning, } = useBridgeQuoteData(); const sourceToken = useSelector(selectSourceToken); const destToken = useSelector(selectDestToken); @@ -103,7 +109,28 @@ const QuoteDetailsCard: React.FC = ({ }); }; - // Early return for invalid states + const handlePriceImpactPress = () => { + navigation.navigate(Routes.BRIDGE.MODALS.ROOT, { + screen: Routes.BRIDGE.MODALS.PRICE_IMPACT_MODAL, + params: { + type: PriceImpactModalType.Info, + token: sourceToken, + location, + }, + }); + }; + + const isGasless = isGaslessQuote(activeQuote?.quote); + + const formattedMinToTokenAmount = formatMinimumReceived( + activeQuote?.minToTokenAmount?.amount || '0', + ); + + const priceImactViewData = useMemo( + () => getPriceImpactViewData(formattedQuoteData?.priceImpact), + [formattedQuoteData?.priceImpact], + ); + if ( !sourceToken?.chainId || !destToken?.chainId || @@ -113,14 +140,6 @@ const QuoteDetailsCard: React.FC = ({ return null; } - const { networkFee, rate, priceImpact, slippage } = formattedQuoteData; - - const isGasless = isGaslessQuote(activeQuote?.quote); - - const formattedMinToTokenAmount = formatMinimumReceived( - activeQuote?.minToTokenAmount?.amount || '0', - ); - return ( @@ -133,8 +152,8 @@ const QuoteDetailsCard: React.FC = ({ gap={1} > {strings('bridge.rate')} @@ -145,19 +164,19 @@ const QuoteDetailsCard: React.FC = ({ title: strings('bridge.quote_info_title'), content: strings('bridge.quote_info_content'), size: TooltipSizes.Sm, - iconName: IconName.Info, + iconName: IconNameLegacy.Info, }, }} value={{ label: ( - {rate} + {formattedQuoteData.rate} ), }} @@ -167,7 +186,7 @@ const QuoteDetailsCard: React.FC = ({ field={{ label: { text: strings('bridge.network_fee'), - variant: TextVariant.BodyMDMedium, + variant: TextVariantLegacy.BodyMDMedium, }, tooltip: { title: strings('bridge.network_fee_info_title'), @@ -175,7 +194,7 @@ const QuoteDetailsCard: React.FC = ({ nativeToken: nativeTokenName, }), size: TooltipSizes.Sm, - iconName: IconName.Info, + iconName: IconNameLegacy.Info, }, }} value={{ @@ -183,7 +202,7 @@ const QuoteDetailsCard: React.FC = ({ = ({ alignItems={BoxAlignItems.Center} justifyContent={BoxJustifyContent.Between} > - + {toSentenceCase(strings('bridge.network_fee'))} = ({ gap={2} > - {networkFee} + {formattedQuoteData.networkFee} - + {strings('bridge.included')} @@ -228,21 +253,21 @@ const QuoteDetailsCard: React.FC = ({ field={{ label: { text: toSentenceCase(strings('bridge.network_fee')), - variant: TextVariant.BodyMD, - color: TextColor.Alternative, + variant: TextVariantLegacy.BodyMD, + color: TextColorLegacy.Alternative, }, tooltip: { title: strings('bridge.network_fee_info_title'), content: strings('bridge.network_fee_info_content'), size: TooltipSizes.Sm, - iconName: IconName.Info, + iconName: IconNameLegacy.Info, }, }} value={{ label: { - text: networkFee, - variant: TextVariant.BodyMD, - color: TextColor.Alternative, + text: formattedQuoteData.networkFee, + variant: TextVariantLegacy.BodyMD, + color: TextColorLegacy.Alternative, }, }} /> @@ -252,14 +277,14 @@ const QuoteDetailsCard: React.FC = ({ field={{ label: { text: strings('bridge.slippage'), - variant: TextVariant.BodyMD, - color: TextColor.Alternative, + variant: TextVariantLegacy.BodyMD, + color: TextColorLegacy.Alternative, }, tooltip: { title: strings('bridge.slippage_info_title'), content: strings('bridge.slippage_info_description'), size: TooltipSizes.Sm, - iconName: IconName.Info, + iconName: IconNameLegacy.Info, }, }} value={{ @@ -271,15 +296,15 @@ const QuoteDetailsCard: React.FC = ({ style={styles.slippageButton} > - {slippage} + {formattedQuoteData.slippage} ), @@ -291,54 +316,63 @@ const QuoteDetailsCard: React.FC = ({ field={{ label: { text: toSentenceCase(strings('bridge.minimum_received')), - variant: TextVariant.BodyMD, - color: TextColor.Alternative, + variant: TextVariantLegacy.BodyMD, + color: TextColorLegacy.Alternative, }, tooltip: { title: strings('bridge.minimum_received_tooltip_title'), content: strings('bridge.minimum_received_tooltip_content'), size: TooltipSizes.Sm, - iconName: IconName.Info, + iconName: IconNameLegacy.Info, }, }} value={{ label: { text: `${formattedMinToTokenAmount} ${destToken?.symbol}`, - variant: TextVariant.BodyMD, - color: TextColor.Alternative, + variant: TextVariantLegacy.BodyMD, + color: TextColorLegacy.Alternative, }, }} /> )} - {priceImpact && ( - - )} + + + {toSentenceCase(strings('bridge.price_impact'))} + + + + + + ), + }} + value={{ + icon: priceImactViewData.icon, + label: { + text: formatPriceImpact(formattedQuoteData.priceImpact), + variant: TextVariantLegacy.BodyMD, + color: priceImactViewData.textColor, + }, + }} + /> @@ -349,7 +383,7 @@ const QuoteDetailsCard: React.FC = ({ field={{ label: { text: toSentenceCase(strings('bridge.points')), - variant: TextVariant.BodyMD, + variant: TextVariantLegacy.BodyMD, }, tooltip: { title: strings('bridge.points_tooltip'), @@ -357,7 +391,7 @@ const QuoteDetailsCard: React.FC = ({ 'bridge.points_tooltip_content_1', )}\n\n${strings('bridge.points_tooltip_content_2')}`, size: TooltipSizes.Sm, - iconName: IconName.Info, + iconName: IconNameLegacy.Info, }, }} value={{ @@ -394,7 +428,7 @@ const QuoteDetailsCard: React.FC = ({ title: strings('bridge.points_error'), content: strings('bridge.points_error_content'), size: TooltipSizes.Sm, - iconName: IconName.Info, + iconName: IconNameLegacy.Info, }, }), }} diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.types.ts b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.types.ts index eb576aa6fa6..913f3b5cae7 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.types.ts +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.types.ts @@ -1,3 +1,6 @@ +import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller'; + export interface QuoteDetailsCardProps { hasInsufficientBalance: boolean; + location: MetaMetricsSwapsEventSource; } diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCardSkeleton.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCardSkeleton.tsx new file mode 100644 index 00000000000..e524eccb0fa --- /dev/null +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCardSkeleton.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { + Box, + BoxFlexDirection, + BoxJustifyContent, + BoxAlignItems, +} from '@metamask/design-system-react-native'; +import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { BridgeViewSelectorsIDs } from '../../Views/BridgeView/BridgeView.testIds'; + +const ROWS: readonly (readonly [string, string])[] = [ + ['35%', '42%'], + ['28%', '24%'], + ['30%', '18%'], + ['32%', '22%'], +]; + +const QuoteDetailsCardSkeleton = () => ( + + {ROWS.map(([left, right], i) => ( + + + + + ))} + +); + +export default QuoteDetailsCardSkeleton; diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap b/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap index 93f612e2d8b..994ecb77ca2 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap @@ -395,13 +395,17 @@ exports[`QuoteDetailsCard renders initial state 1`] = ` Rate @@ -502,13 +506,17 @@ exports[`QuoteDetailsCard renders initial state 1`] = ` minimumFontScale={0.8} numberOfLines={1} style={ - { - "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, - "letterSpacing": 0, - "lineHeight": 24, - } + [ + { + "color": "#66676a", + "fontFamily": "Geist-Regular", + "fontSize": 16, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 24, + }, + undefined, + ] } > 1 ETH = 24.4 USDC @@ -785,29 +793,34 @@ exports[`QuoteDetailsCard renders initial state 1`] = ` 0.5% @@ -854,55 +867,58 @@ exports[`QuoteDetailsCard renders initial state 1`] = ` } } > - - Price impact - - - - + > + Price impact + + + + + @@ -944,7 +960,7 @@ exports[`QuoteDetailsCard renders initial state 1`] = ` } testID="label" > - -0.06% + 0% diff --git a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx index a054df0a5e6..6780302c682 100644 --- a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx +++ b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx @@ -140,6 +140,10 @@ jest.mock('../../hooks/useSlippageConfig', () => ({ useSlippageConfig: jest.fn(), })); +jest.mock('../../hooks/useModalCloseOnQuoteExpiry', () => ({ + useModalCloseOnQuoteExpiry: jest.fn(), +})); + jest.mock('../../hooks/useShouldDisableCustomSlippageConfirm', () => ({ useShouldDisableCustomSlippageConfirm: jest.fn(), })); @@ -180,10 +184,15 @@ import { useSlippageStepperDescription } from '../../hooks/useSlippageStepperDes import { useParams } from '../../../../../util/navigation/navUtils'; import { InputStepper } from '../InputStepper'; import Keypad from '../../../../Base/Keypad'; +import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry'; const mockUseSlippageConfig = useSlippageConfig as jest.MockedFunction< typeof useSlippageConfig >; +const mockUseModalCloseOnQuoteExpiry = + useModalCloseOnQuoteExpiry as jest.MockedFunction< + typeof useModalCloseOnQuoteExpiry + >; const mockUseShouldDisableCustomSlippageConfirm = useShouldDisableCustomSlippageConfirm as jest.MockedFunction< typeof useShouldDisableCustomSlippageConfirm @@ -946,6 +955,20 @@ describe('CustomSlippageModal', () => { }); }); + describe('useModalCloseOnQuoteExpiry', () => { + it('calls useModalCloseOnQuoteExpiry on render', () => { + render(); + + expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalled(); + }); + + it('calls useModalCloseOnQuoteExpiry exactly once per render', () => { + render(); + + expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalledTimes(1); + }); + }); + describe('handleClose functionality', () => { it('closes modal via header close button', () => { const { getByLabelText } = render(); diff --git a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx index b94daaa162a..243b1aa1849 100644 --- a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx +++ b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx @@ -23,9 +23,11 @@ import { import { useDispatch, useSelector } from 'react-redux'; import { useSlippageStepperDescription } from '../../hooks/useSlippageStepperDescription'; import { useShouldDisableCustomSlippageConfirm } from '../../hooks/useShouldDisableCustomSlippageConfirm'; +import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry'; export const CustomSlippageModal = () => { const dispatch = useDispatch(); + useModalCloseOnQuoteExpiry(); const sheetRef = useRef(null); const { sourceChainId, destChainId } = useParams(); diff --git a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.test.tsx b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.test.tsx index fc5ba455bef..774e5839ded 100644 --- a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.test.tsx +++ b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.test.tsx @@ -74,6 +74,10 @@ jest.mock('../../hooks/useSlippageConfig', () => ({ useSlippageConfig: jest.fn(), })); +jest.mock('../../hooks/useModalCloseOnQuoteExpiry', () => ({ + useModalCloseOnQuoteExpiry: jest.fn(), +})); + jest.mock('../../../../../util/navigation/navUtils', () => ({ useParams: jest.fn(), })); @@ -115,6 +119,7 @@ import { useGetSlippageOptions } from '../../hooks/useGetSlippageOptions'; import { useSlippageConfig } from '../../hooks/useSlippageConfig'; import { useParams } from '../../../../../util/navigation/navUtils'; import { AUTO_SLIPPAGE_VALUE } from './constants'; +import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry'; const mockUseGetSlippageOptions = useGetSlippageOptions as jest.MockedFunction< typeof useGetSlippageOptions @@ -123,6 +128,10 @@ const mockUseSlippageConfig = useSlippageConfig as jest.MockedFunction< typeof useSlippageConfig >; const mockUseParams = useParams as jest.MockedFunction; +const mockUseModalCloseOnQuoteExpiry = + useModalCloseOnQuoteExpiry as jest.MockedFunction< + typeof useModalCloseOnQuoteExpiry + >; describe('DefaultSlippageModal', () => { const mockSlippageConfig = { @@ -635,6 +644,20 @@ describe('DefaultSlippageModal', () => { }); }); + describe('useModalCloseOnQuoteExpiry', () => { + it('calls useModalCloseOnQuoteExpiry on render', () => { + render(); + + expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalled(); + }); + + it('calls useModalCloseOnQuoteExpiry exactly once per render', () => { + render(); + + expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalledTimes(1); + }); + }); + describe('auto slippage behavior', () => { it('dispatches undefined for auto slippage on submit', () => { mockSelector.mockReturnValue(undefined); diff --git a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx index 2d34b5caa77..9dda2b94e51 100644 --- a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx +++ b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx @@ -26,10 +26,12 @@ import { DefaultSlippageModalParams } from './types'; import { useParams } from '../../../../../util/navigation/navUtils'; import { useSlippageConfig } from '../../hooks/useSlippageConfig'; import { SlippageType } from '../../types'; +import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry'; export const DefaultSlippageModal = () => { const navigation = useNavigation(); const dispatch = useDispatch(); + useModalCloseOnQuoteExpiry(); const sheetRef = useRef(null); const slippage = useSelector(selectSlippage); const [selectedSlippage, setSelectedSlippage] = useState( diff --git a/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx b/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx index c41b8c1c411..9d48dd0ce1f 100644 --- a/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx +++ b/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx @@ -21,6 +21,8 @@ import { MOCK_ENTROPY_SOURCE as mockEntropySource } from '../../../../../util/te import { BigNumber } from 'ethers'; import Engine from '../../../../../core/Engine'; import { setSourceAmount } from '../../../../../core/redux/slices/bridge'; +import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller'; +import { PriceImpactModalType } from '../PriceImpactModal/constants'; // Mock the account-tree-controller file that imports the problematic module jest.mock( @@ -250,7 +252,10 @@ describe('SwapsConfirmButton', () => { describe('Button Label', () => { it('displays "Confirm swap" label by default', () => { const { getByText } = renderWithProvider( - , + , { state: mockState, }, @@ -263,7 +268,10 @@ describe('SwapsConfirmButton', () => { jest.mocked(useIsInsufficientBalance).mockReturnValue(true); const { getByText } = renderWithProvider( - , + , { state: mockState, }, @@ -276,7 +284,10 @@ describe('SwapsConfirmButton', () => { jest.mocked(useHasSufficientGas).mockReturnValue(false); const { getByText } = renderWithProvider( - , + , { state: mockState, }, @@ -295,7 +306,10 @@ describe('SwapsConfirmButton', () => { }; const { queryByText } = renderWithProvider( - , + , { state: submittingState, }, @@ -318,7 +332,10 @@ describe('SwapsConfirmButton', () => { })); const { getByTestId } = renderWithProvider( - , + , { state: mockState, }, @@ -332,7 +349,10 @@ describe('SwapsConfirmButton', () => { jest.mocked(useIsInsufficientBalance).mockReturnValue(true); const { getByTestId } = renderWithProvider( - , + , { state: mockState, }, @@ -352,7 +372,10 @@ describe('SwapsConfirmButton', () => { }; const { getByTestId } = renderWithProvider( - , + , { state: submittingState, }, @@ -381,7 +404,10 @@ describe('SwapsConfirmButton', () => { }; const { getByTestId } = renderWithProvider( - , + , { state: solanaState, }, @@ -400,7 +426,10 @@ describe('SwapsConfirmButton', () => { })); const { getByTestId } = renderWithProvider( - , + , { state: mockState, }, @@ -414,7 +443,10 @@ describe('SwapsConfirmButton', () => { jest.mocked(useHasSufficientGas).mockReturnValue(false); const { getByTestId } = renderWithProvider( - , + , { state: mockState, }, @@ -428,7 +460,10 @@ describe('SwapsConfirmButton', () => { jest.mocked(selectSourceWalletAddress).mockReturnValue(undefined); const { getByTestId } = renderWithProvider( - , + , { state: mockState, }, @@ -450,7 +485,10 @@ describe('SwapsConfirmButton', () => { })); const { queryByText, getByTestId } = renderWithProvider( - , + , { state: mockState, }, @@ -473,7 +511,10 @@ describe('SwapsConfirmButton', () => { }; const { queryByText, getByTestId } = renderWithProvider( - , + , { state: submittingState, }, @@ -495,7 +536,10 @@ describe('SwapsConfirmButton', () => { })); const { queryByText, getByTestId } = renderWithProvider( - , + , { state: mockState, // sourceAmount: '1.0' }, @@ -525,7 +569,10 @@ describe('SwapsConfirmButton', () => { }; const { getByText } = renderWithProvider( - , + , { state: emptyAmountState, }, @@ -555,7 +602,10 @@ describe('SwapsConfirmButton', () => { }; const { getByText } = renderWithProvider( - , + , { state: zeroAmountState, }, @@ -576,7 +626,10 @@ describe('SwapsConfirmButton', () => { })); const { getByText, getByTestId } = renderWithProvider( - , + , { state: mockState, }, @@ -593,7 +646,10 @@ describe('SwapsConfirmButton', () => { jest.mocked(useIsInsufficientBalance).mockReturnValue(true); const { getByText, getByTestId } = renderWithProvider( - , + , { state: mockState, }, @@ -611,7 +667,10 @@ describe('SwapsConfirmButton', () => { jest.mocked(useHasSufficientGas).mockReturnValue(false); const { getByText, getByTestId } = renderWithProvider( - , + , { state: mockState, }, @@ -628,7 +687,10 @@ describe('SwapsConfirmButton', () => { it('shows loading when amount changes to non-zero and quote is stale', () => { // First render with sourceAmount='1.0' — settledAmountRef latches to '1.0' const { queryByText, getByTestId, store } = renderWithProvider( - , + , { state: mockState, }, @@ -649,7 +711,10 @@ describe('SwapsConfirmButton', () => { it('disables button without loading when amount changes to zero and quote is stale', () => { // First render with sourceAmount='1.0' — settledAmountRef latches to '1.0' const { getByText, getByTestId, store } = renderWithProvider( - , + , { state: mockState, }, @@ -671,7 +736,10 @@ describe('SwapsConfirmButton', () => { it('is not stale when amount matches the quote', () => { // sourceAmount='1.0' matches the mock quote's srcTokenAmount const { getByText, getByTestId } = renderWithProvider( - , + , { state: mockState, }, @@ -694,7 +762,10 @@ describe('SwapsConfirmButton', () => { })); const { getByText } = renderWithProvider( - , + , { state: mockState, }, @@ -715,7 +786,10 @@ describe('SwapsConfirmButton', () => { })); const { getByTestId } = renderWithProvider( - , + , { state: mockState, }, @@ -735,7 +809,10 @@ describe('SwapsConfirmButton', () => { })); const { getByTestId } = renderWithProvider( - , + , { state: mockState, }, @@ -763,7 +840,10 @@ describe('SwapsConfirmButton', () => { })); const { getByText, getByTestId } = renderWithProvider( - , + , { state: mockState, }, @@ -789,7 +869,10 @@ describe('SwapsConfirmButton', () => { })); const { queryByText } = renderWithProvider( - , + , { state: mockState, }, @@ -819,7 +902,10 @@ describe('SwapsConfirmButton', () => { }; const { queryByText } = renderWithProvider( - , + , { state: submittingState, }, @@ -835,7 +921,10 @@ describe('SwapsConfirmButton', () => { describe('handleContinue', () => { it('submits transaction and navigates to transactions view', async () => { const { getByTestId } = renderWithProvider( - , + , { state: mockState, }, @@ -895,7 +984,10 @@ describe('SwapsConfirmButton', () => { }; const { getByTestId } = renderWithProvider( - , + , { state: solanaState, }, @@ -926,7 +1018,10 @@ describe('SwapsConfirmButton', () => { mockSubmitBridgeTx.mockRejectedValue(new Error('Network error')); const { getByTestId } = renderWithProvider( - , + , { state: mockState, }, @@ -961,7 +1056,10 @@ describe('SwapsConfirmButton', () => { })); const { getByTestId } = renderWithProvider( - , + , { state: mockState, }, @@ -979,7 +1077,10 @@ describe('SwapsConfirmButton', () => { jest.mocked(selectSourceWalletAddress).mockReturnValue(undefined); const { getByTestId } = renderWithProvider( - , + , { state: mockState, }, @@ -993,4 +1094,231 @@ describe('SwapsConfirmButton', () => { expect(mockSubmitBridgeTx).not.toHaveBeenCalled(); }); }); + + describe('handleContinue — price impact routing', () => { + it('navigates to PriceImpactModal when priceImpact exceeds the error threshold', async () => { + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: mockActiveQuote, + formattedQuoteData: { + ...mockUseBridgeQuoteData.formattedQuoteData, + priceImpact: '30%', // 30 > PRICE_IMPACT_ERROR_THRESHOLD (25) + }, + })); + + const { getByTestId } = renderWithProvider( + , + { state: mockState }, + ); + + await act(async () => { + fireEvent.press(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)); + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { + screen: Routes.BRIDGE.MODALS.PRICE_IMPACT_MODAL, + params: { + type: PriceImpactModalType.Execution, + token: mockState.bridge?.sourceToken, + location: MetaMetricsSwapsEventSource.MainView, + }, + }); + }); + + it('does not submit the transaction when navigating to PriceImpactModal', async () => { + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: mockActiveQuote, + formattedQuoteData: { + ...mockUseBridgeQuoteData.formattedQuoteData, + priceImpact: '30%', + }, + })); + + const { getByTestId } = renderWithProvider( + , + { state: mockState }, + ); + + await act(async () => { + fireEvent.press(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)); + }); + + expect(mockSubmitBridgeTx).not.toHaveBeenCalled(); + }); + + it('navigates to PriceImpactModal when priceImpact is exactly at the threshold', async () => { + // 25 >= 25, so the modal IS shown and the transaction is not submitted + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: mockActiveQuote, + formattedQuoteData: { + ...mockUseBridgeQuoteData.formattedQuoteData, + priceImpact: '25%', + }, + })); + + const { getByTestId } = renderWithProvider( + , + { state: mockState }, + ); + + await act(async () => { + fireEvent.press(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)); + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { + screen: Routes.BRIDGE.MODALS.PRICE_IMPACT_MODAL, + params: { + type: PriceImpactModalType.Execution, + token: mockState.bridge?.sourceToken, + location: MetaMetricsSwapsEventSource.MainView, + }, + }); + expect(mockSubmitBridgeTx).not.toHaveBeenCalled(); + }); + + it('submits the transaction when priceImpact is below the threshold', async () => { + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: mockActiveQuote, + formattedQuoteData: { + ...mockUseBridgeQuoteData.formattedQuoteData, + priceImpact: '10%', + }, + })); + + const { getByTestId } = renderWithProvider( + , + { state: mockState }, + ); + + await act(async () => { + fireEvent.press(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)); + }); + + await waitFor(() => { + expect(mockSubmitBridgeTx).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); + }); + }); + + it('submits the transaction when priceImpact is undefined', async () => { + // Falsy priceImpact defaults to 0, which is not > 25 + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: mockActiveQuote, + formattedQuoteData: { + ...mockUseBridgeQuoteData.formattedQuoteData, + priceImpact: undefined, + }, + })); + + const { getByTestId } = renderWithProvider( + , + { state: mockState }, + ); + + await act(async () => { + fireEvent.press(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)); + }); + + await waitFor(() => { + expect(mockSubmitBridgeTx).toHaveBeenCalledTimes(1); + }); + }); + + it('submits the transaction when priceImpact is not a finite number', async () => { + // Number.parseFloat('NaN%') → NaN → Number.isFinite(NaN) = false → skip modal + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: mockActiveQuote, + formattedQuoteData: { + ...mockUseBridgeQuoteData.formattedQuoteData, + priceImpact: 'NaN%', + }, + })); + + const { getByTestId } = renderWithProvider( + , + { state: mockState }, + ); + + await act(async () => { + fireEvent.press(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)); + }); + + await waitFor(() => { + expect(mockSubmitBridgeTx).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalledWith( + Routes.BRIDGE.MODALS.ROOT, + expect.anything(), + ); + }); + }); + + it('passes the location prop into the PriceImpactModal params', async () => { + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: mockActiveQuote, + formattedQuoteData: { + ...mockUseBridgeQuoteData.formattedQuoteData, + priceImpact: '30%', + }, + })); + + const { getByTestId } = renderWithProvider( + , + { state: mockState }, + ); + + await act(async () => { + fireEvent.press(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)); + }); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.BRIDGE.MODALS.ROOT, + expect.objectContaining({ + params: expect.objectContaining({ + location: MetaMetricsSwapsEventSource.MainView, + }), + }), + ); + }); + }); }); diff --git a/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx b/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx index bd9616ba848..df0c29372ea 100644 --- a/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx +++ b/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx @@ -6,44 +6,48 @@ import Button, { } from '../../../../../component-library/components/Buttons/Button'; import { strings } from '../../../../../../locales/i18n'; import { BridgeViewSelectorsIDs } from '../../Views/BridgeView/BridgeView.testIds'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { selectIsSolanaSourced, selectIsSubmittingTx, selectSourceAmount, selectSourceToken, - setIsSubmittingTx, } from '../../../../../core/redux/slices/bridge'; import useIsInsufficientBalance from '../../hooks/useInsufficientBalance'; import { useLatestBalance } from '../../hooks/useLatestBalance'; import { useHasSufficientGas } from '../../hooks/useHasSufficientGas'; import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData'; import { useBridgeQuoteRequest } from '../../hooks/useBridgeQuoteRequest'; -import useSubmitBridgeTx from '../../../../../util/bridge/hooks/useSubmitBridgeTx'; import { selectSourceWalletAddress } from '../../../../../selectors/bridge'; import { selectSelectedInternalAccountFormattedAddress } from '../../../../../selectors/accountsController'; import { isHardwareAccount } from '../../../../../util/address'; -import { BridgeQuoteResponse } from '../../types'; -import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; -import Routes from '../../../../../constants/navigation/Routes'; import Engine from '../../../../../core/Engine'; -import { BridgeRouteParams } from '../../hooks/useSwapBridgeNavigation'; import { calcTokenValue } from '../../../../../util/transactions'; +import { useBridgeConfirm } from '../../hooks/useBridgeConfirm'; +import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller'; +import Routes from '../../../../../constants/navigation/Routes'; +import { PriceImpactModalType } from '../PriceImpactModal/constants'; +import { useNavigation } from '@react-navigation/native'; +import AppConstants from '../../../../../core/AppConstants'; interface Props { latestSourceBalance: ReturnType; /** Optional testID override (e.g. when rendered inside keypad to avoid duplicate IDs in E2E) */ testID?: string; + location: MetaMetricsSwapsEventSource; } -export const SwapsConfirmButton = ({ latestSourceBalance, testID }: Props) => { - const dispatch = useDispatch(); +export const SwapsConfirmButton = ({ + latestSourceBalance, + testID, + location, +}: Props) => { const navigation = useNavigation(); - const route = useRoute>(); - /** The entry point location for analytics (e.g. Main View, Token View, Trending Explore) */ - const location = route.params?.location; + const handleConfirm = useBridgeConfirm({ + latestSourceBalance, + location, + }); - const { submitBridgeTx } = useSubmitBridgeTx(); const updateQuoteParams = useBridgeQuoteRequest(); const sourceAmount = useSelector(selectSourceAmount); const sourceToken = useSelector(selectSourceToken); @@ -70,6 +74,7 @@ export const SwapsConfirmButton = ({ latestSourceBalance, testID }: Props) => { blockaidError, quoteFetchError, isNoQuotesAvailable, + formattedQuoteData, } = useBridgeQuoteData({ latestSourceAtomicBalance: latestSourceBalance?.atomicBalance, }); @@ -147,27 +152,30 @@ export const SwapsConfirmButton = ({ latestSourceBalance, testID }: Props) => { !walletAddress; const handleContinue = async () => { - try { - if (activeQuote && walletAddress) { - dispatch(setIsSubmittingTx(true)); - - const quoteResponse: BridgeQuoteResponse = { - ...activeQuote, - aggregator: activeQuote.quote.bridgeId, - walletAddress, - }; - - await submitBridgeTx({ - quoteResponse, + const priceImpact = !formattedQuoteData?.priceImpact + ? // Default to zero to bypass swap friction. + // This callback is always called when active quote exists, + // thus this check is not expected to be used, but we introduce + // it regardless as a defensive mechanism. + 0 + : Number.parseFloat(formattedQuoteData.priceImpact.replace('%', '')); + + if ( + Number.isFinite(priceImpact) && + priceImpact >= AppConstants.BRIDGE.PRICE_IMPACT_ERROR_THRESHOLD + ) { + navigation.navigate(Routes.BRIDGE.MODALS.ROOT, { + screen: Routes.BRIDGE.MODALS.PRICE_IMPACT_MODAL, + params: { + type: PriceImpactModalType.Execution, + token: sourceToken, location, - }); - } - } catch (error) { - console.error('Error submitting bridge tx', error); - } finally { - dispatch(setIsSubmittingTx(false)); - navigation.navigate(Routes.TRANSACTIONS_VIEW); + }, + }); + return; } + + await handleConfirm(); }; const handleGetNewQuote = () => { diff --git a/app/components/UI/Bridge/components/TokenInputArea/index.tsx b/app/components/UI/Bridge/components/TokenInputArea/index.tsx index 35b0aef090a..3b7b4402c6c 100644 --- a/app/components/UI/Bridge/components/TokenInputArea/index.tsx +++ b/app/components/UI/Bridge/components/TokenInputArea/index.tsx @@ -286,7 +286,7 @@ export const TokenInputArea = forwardRef< isReadonly={tokenType === TokenInputAreaType.Destination} showSoftInputOnFocus={false} caretHidden={false} - autoFocus + autoFocus={false} placeholder="0" testID={`${testID}-input`} onPressIn={() => { diff --git a/app/components/UI/Bridge/hooks/useBridgeConfirm/index.ts b/app/components/UI/Bridge/hooks/useBridgeConfirm/index.ts new file mode 100644 index 00000000000..d449a326fe4 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useBridgeConfirm/index.ts @@ -0,0 +1,51 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { useBridgeQuoteData } from '../useBridgeQuoteData'; +import { useNavigation } from '@react-navigation/native'; +import { setIsSubmittingTx } from '../../../../../core/redux/slices/bridge'; +import Routes from '../../../../../constants/navigation/Routes'; +import { BridgeQuoteResponse } from '../../types'; +import useSubmitBridgeTx from '../../../../../util/bridge/hooks/useSubmitBridgeTx'; +import { selectSourceWalletAddress } from '../../../../../selectors/bridge'; +import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller'; +import { useLatestBalance } from '../useLatestBalance'; + +interface Params { + location: MetaMetricsSwapsEventSource; + latestSourceBalance: ReturnType; +} + +export const useBridgeConfirm = ({ latestSourceBalance, location }: Params) => { + const dispatch = useDispatch(); + const navigation = useNavigation(); + const { submitBridgeTx } = useSubmitBridgeTx(); + const walletAddress = useSelector(selectSourceWalletAddress); + const { activeQuote } = useBridgeQuoteData({ + latestSourceAtomicBalance: latestSourceBalance?.atomicBalance, + }); + + const handleConfirm = async () => { + try { + if (activeQuote && walletAddress) { + dispatch(setIsSubmittingTx(true)); + + const quoteResponse: BridgeQuoteResponse = { + ...activeQuote, + aggregator: activeQuote.quote.bridgeId, + walletAddress, + }; + + await submitBridgeTx({ + quoteResponse, + location, + }); + } + } catch (error) { + console.error('Error submitting bridge tx', error); + } finally { + dispatch(setIsSubmittingTx(false)); + navigation.navigate(Routes.TRANSACTIONS_VIEW); + } + }; + + return handleConfirm; +}; diff --git a/app/components/UI/Bridge/hooks/useBridgeConfirm/useBridgeConfirm.test.ts b/app/components/UI/Bridge/hooks/useBridgeConfirm/useBridgeConfirm.test.ts new file mode 100644 index 00000000000..23ee2efeea4 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useBridgeConfirm/useBridgeConfirm.test.ts @@ -0,0 +1,280 @@ +import { act, waitFor } from '@testing-library/react-native'; +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { useBridgeConfirm } from './index'; +import { useBridgeQuoteData } from '../useBridgeQuoteData'; +import { selectSourceWalletAddress } from '../../../../../selectors/bridge'; +import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller'; +import { mockQuoteWithMetadata } from '../../_mocks_/bridgeQuoteWithMetadata'; +import { mockUseBridgeQuoteData } from '../../_mocks_/useBridgeQuoteData.mock'; +import Routes from '../../../../../constants/navigation/Routes'; +import { BigNumber } from 'ethers'; + +const WALLET_ADDRESS = '0x1234567890123456789012345678901234567890'; + +const mockLatestSourceBalance = { + displayBalance: '2.0', + atomicBalance: BigNumber.from('2000000000000000000'), +}; + +const defaultParams = { + location: MetaMetricsSwapsEventSource.MainView, + latestSourceBalance: mockLatestSourceBalance, +}; + +// Navigation +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ navigate: mockNavigate }), +})); + +// useBridgeQuoteData +jest.mock('../useBridgeQuoteData', () => ({ + useBridgeQuoteData: jest.fn(), +})); + +// selectSourceWalletAddress +jest.mock('../../../../../selectors/bridge', () => ({ + ...jest.requireActual('../../../../../selectors/bridge'), + selectSourceWalletAddress: jest.fn(), +})); + +// useSubmitBridgeTx +const mockSubmitBridgeTx = jest.fn(); +jest.mock('../../../../../util/bridge/hooks/useSubmitBridgeTx', () => ({ + __esModule: true, + default: () => ({ submitBridgeTx: mockSubmitBridgeTx }), +})); + +// Engine (required by store / other transitive deps) +jest.mock('../../../../../core/Engine', () => ({ + controllerMessenger: { + call: jest.fn(), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + }, + context: { + KeyringController: { state: { keyrings: [] } }, + BridgeController: { resetState: jest.fn() }, + }, +})); + +jest.mock( + '../../../../../multichain-accounts/controllers/account-tree-controller', + () => ({ + accountTreeControllerInit: jest.fn(() => ({ + controller: { state: { accountTree: { wallets: {} } } }, + })), + }), +); + +jest.mock('../../../../../selectors/confirmTransaction'); + +function renderHook( + params: Parameters[0] = defaultParams, +) { + return renderHookWithProvider(() => useBridgeConfirm(params), { state: {} }); +} + +describe('useBridgeConfirm', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useBridgeQuoteData).mockReturnValue({ + ...mockUseBridgeQuoteData, + activeQuote: mockQuoteWithMetadata, + } as ReturnType); + jest.mocked(selectSourceWalletAddress).mockReturnValue(WALLET_ADDRESS); + mockSubmitBridgeTx.mockResolvedValue({ success: true }); + }); + + it('returns a function', () => { + const { result } = renderHook(); + + expect(typeof result.current).toBe('function'); + }); + + describe('successful submission', () => { + it('calls submitBridgeTx with the correct quoteResponse and location', async () => { + const { result } = renderHook(); + + await act(async () => { + await result.current(); + }); + + expect(mockSubmitBridgeTx).toHaveBeenCalledWith({ + quoteResponse: { + ...mockQuoteWithMetadata, + aggregator: mockQuoteWithMetadata.quote.bridgeId, + walletAddress: WALLET_ADDRESS, + }, + location: MetaMetricsSwapsEventSource.MainView, + }); + }); + + it('passes the location prop through to submitBridgeTx', async () => { + const { result } = renderHook({ + ...defaultParams, + location: MetaMetricsSwapsEventSource.MainView, + }); + + await act(async () => { + await result.current(); + }); + + expect(mockSubmitBridgeTx).toHaveBeenCalledWith( + expect.objectContaining({ + location: MetaMetricsSwapsEventSource.MainView, + }), + ); + }); + + it('navigates to TRANSACTIONS_VIEW after submission', async () => { + const { result } = renderHook(); + + await act(async () => { + await result.current(); + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); + }); + + it('resets isSubmittingTx to false after submission', async () => { + const { result, store } = renderHook(); + + await act(async () => { + await result.current(); + }); + + await waitFor(() => { + expect( + (store.getState() as { bridge: { isSubmittingTx: boolean } }).bridge + .isSubmittingTx, + ).toBe(false); + }); + }); + + it('passes latestSourceAtomicBalance to useBridgeQuoteData', () => { + renderHook(); + + expect(jest.mocked(useBridgeQuoteData)).toHaveBeenCalledWith( + expect.objectContaining({ + latestSourceAtomicBalance: mockLatestSourceBalance.atomicBalance, + }), + ); + }); + + it('passes undefined atomicBalance when latestSourceBalance is undefined', () => { + renderHook({ ...defaultParams, latestSourceBalance: undefined }); + + expect(jest.mocked(useBridgeQuoteData)).toHaveBeenCalledWith( + expect.objectContaining({ + latestSourceAtomicBalance: undefined, + }), + ); + }); + }); + + describe('when activeQuote is null', () => { + beforeEach(() => { + jest.mocked(useBridgeQuoteData).mockReturnValue({ + ...mockUseBridgeQuoteData, + activeQuote: null, + } as ReturnType); + }); + + it('does not call submitBridgeTx', async () => { + const { result } = renderHook(); + + await act(async () => { + await result.current(); + }); + + expect(mockSubmitBridgeTx).not.toHaveBeenCalled(); + }); + + it('still navigates to TRANSACTIONS_VIEW', async () => { + const { result } = renderHook(); + + await act(async () => { + await result.current(); + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); + }); + }); + + describe('when walletAddress is missing', () => { + beforeEach(() => { + jest.mocked(selectSourceWalletAddress).mockReturnValue(undefined); + }); + + it('does not call submitBridgeTx', async () => { + const { result } = renderHook(); + + await act(async () => { + await result.current(); + }); + + expect(mockSubmitBridgeTx).not.toHaveBeenCalled(); + }); + + it('still navigates to TRANSACTIONS_VIEW', async () => { + const { result } = renderHook(); + + await act(async () => { + await result.current(); + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); + }); + }); + + describe('when submitBridgeTx throws', () => { + beforeEach(() => { + mockSubmitBridgeTx.mockRejectedValue(new Error('Network error')); + }); + + it('logs the error', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + const { result } = renderHook(); + + await act(async () => { + await result.current(); + }); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Error submitting bridge tx', + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + it('still navigates to TRANSACTIONS_VIEW after the error', async () => { + jest.spyOn(console, 'error').mockImplementation(); + const { result } = renderHook(); + + await act(async () => { + await result.current(); + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); + }); + + it('resets isSubmittingTx to false after the error', async () => { + jest.spyOn(console, 'error').mockImplementation(); + const { result, store } = renderHook(); + + await act(async () => { + await result.current(); + }); + + await waitFor(() => { + expect( + (store.getState() as { bridge: { isSubmittingTx: boolean } }).bridge + .isSubmittingTx, + ).toBe(false); + }); + }); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useBridgeViewOnFocus/index.ts b/app/components/UI/Bridge/hooks/useBridgeViewOnFocus/index.ts index 9228de0e99a..a37c6f83b52 100644 --- a/app/components/UI/Bridge/hooks/useBridgeViewOnFocus/index.ts +++ b/app/components/UI/Bridge/hooks/useBridgeViewOnFocus/index.ts @@ -1,5 +1,5 @@ import { useFocusEffect } from '@react-navigation/native'; -import { RefObject, useCallback, useRef } from 'react'; +import { RefObject, useCallback } from 'react'; import { TokenInputAreaRef } from '../../components/TokenInputArea'; import { SwapsKeypadRef } from '../../components/SwapsKeypad/types'; @@ -9,22 +9,13 @@ interface Params { } export const useBridgeViewOnFocus = ({ inputRef, keypadRef }: Params) => { - // Track whether this is the very first time the screen gains focus - const isFirstFocus = useRef(true); - useFocusEffect( - useCallback(() => { - if (isFirstFocus.current) { - // Always auto-focus and open keypad on initial mount - isFirstFocus.current = false; - inputRef.current?.focus(); - keypadRef.current?.open(); - } - - return () => { + useCallback( + () => () => { inputRef.current?.blur(); keypadRef.current?.close(); - }; - }, [inputRef, keypadRef]), + }, + [inputRef, keypadRef], + ), ); }; diff --git a/app/components/UI/Bridge/hooks/useBridgeViewOnFocus/useBridgeViewOnFocus.test.ts b/app/components/UI/Bridge/hooks/useBridgeViewOnFocus/useBridgeViewOnFocus.test.ts index 58b9e326f84..16f2bfe9a18 100644 --- a/app/components/UI/Bridge/hooks/useBridgeViewOnFocus/useBridgeViewOnFocus.test.ts +++ b/app/components/UI/Bridge/hooks/useBridgeViewOnFocus/useBridgeViewOnFocus.test.ts @@ -33,7 +33,7 @@ describe('useBridgeViewOnFocus', () => { focusCallback = undefined; }); - it('focuses input and opens keypad on initial focus', () => { + it('does not focus input or open keypad on initial focus', () => { // Arrange const inputRef = createMockInputRef(); const keypadRef = createMockKeypadRef(); @@ -44,8 +44,8 @@ describe('useBridgeViewOnFocus', () => { const cleanup = focusCallback?.(); // Assert - expect(inputRef.current.focus).toHaveBeenCalledTimes(1); - expect(keypadRef.current.open).toHaveBeenCalledTimes(1); + expect(inputRef.current.focus).not.toHaveBeenCalled(); + expect(keypadRef.current.open).not.toHaveBeenCalled(); // Cleanup should exist expect(typeof cleanup).toBe('function'); diff --git a/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.test.ts b/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.test.ts new file mode 100644 index 00000000000..271db4145ca --- /dev/null +++ b/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.test.ts @@ -0,0 +1,137 @@ +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { useModalCloseOnQuoteExpiry } from './index'; +import { useBridgeQuoteData } from '../useBridgeQuoteData'; +import Routes from '../../../../../constants/navigation/Routes'; +import { CommonActions } from '@react-navigation/native'; + +jest.mock('../useBridgeQuoteData', () => ({ + useBridgeQuoteData: jest.fn(), +})); + +const mockDispatch = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + dispatch: mockDispatch, + }), +})); + +const mockUseBridgeQuoteData = { + isExpired: false, + willRefresh: false, +}; + +describe('useModalCloseOnQuoteExpiry', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest + .mocked(useBridgeQuoteData) + .mockReturnValue( + mockUseBridgeQuoteData as ReturnType, + ); + }); + + it('dispatches a reset to QuoteExpiredModal when quote is expired and will not refresh', () => { + // Arrange + jest.mocked(useBridgeQuoteData).mockReturnValue({ + ...mockUseBridgeQuoteData, + isExpired: true, + willRefresh: false, + } as ReturnType); + + // Act + renderHookWithProvider(() => useModalCloseOnQuoteExpiry()); + + // Assert + expect(mockDispatch).toHaveBeenCalledWith( + CommonActions.reset({ + index: 0, + routes: [{ name: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL }], + }), + ); + }); + + it('does not dispatch when quote is not expired', () => { + // Arrange + jest.mocked(useBridgeQuoteData).mockReturnValue({ + ...mockUseBridgeQuoteData, + isExpired: false, + willRefresh: false, + } as ReturnType); + + // Act + renderHookWithProvider(() => useModalCloseOnQuoteExpiry()); + + // Assert + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('does not dispatch when quote is expired but will refresh', () => { + // Arrange + jest.mocked(useBridgeQuoteData).mockReturnValue({ + ...mockUseBridgeQuoteData, + isExpired: true, + willRefresh: true, + } as ReturnType); + + // Act + renderHookWithProvider(() => useModalCloseOnQuoteExpiry()); + + // Assert + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('dispatches again when quote transitions from not-expired to expired', () => { + // Arrange – start with not expired + jest.mocked(useBridgeQuoteData).mockReturnValue({ + ...mockUseBridgeQuoteData, + isExpired: false, + willRefresh: false, + } as ReturnType); + + const { rerender } = renderHookWithProvider(() => + useModalCloseOnQuoteExpiry(), + ); + + expect(mockDispatch).not.toHaveBeenCalled(); + + // Quote expires + jest.mocked(useBridgeQuoteData).mockReturnValue({ + ...mockUseBridgeQuoteData, + isExpired: true, + willRefresh: false, + } as ReturnType); + + // Act + rerender({}); + + // Assert + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith( + CommonActions.reset({ + index: 0, + routes: [{ name: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL }], + }), + ); + }); + + it('dispatches reset with index 0 so QuoteExpiredModal is the only route', () => { + // Arrange + jest.mocked(useBridgeQuoteData).mockReturnValue({ + ...mockUseBridgeQuoteData, + isExpired: true, + willRefresh: false, + } as ReturnType); + + // Act + renderHookWithProvider(() => useModalCloseOnQuoteExpiry()); + + // Assert + const dispatchedAction = mockDispatch.mock.calls[0][0]; + expect(dispatchedAction.payload.index).toBe(0); + expect(dispatchedAction.payload.routes).toHaveLength(1); + expect(dispatchedAction.payload.routes[0].name).toBe( + Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, + ); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.ts b/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.ts new file mode 100644 index 00000000000..a5f4b7394c9 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.ts @@ -0,0 +1,28 @@ +import { useEffect } from 'react'; +import { CommonActions, useNavigation } from '@react-navigation/native'; +import { useBridgeQuoteData } from '../useBridgeQuoteData'; +import Routes from '../../../../../constants/navigation/Routes'; + +/** + * Resets the BridgeModalStack to show only QuoteExpiredModal when quotes expire. + * + * Must be called from a screen that lives inside BridgeModalStack so that + * CommonActions.reset targets BridgeModalStack (not the root navigator). + * This prevents the previous modal's BottomSheetOverlay from remaining + * visible behind QuoteExpiredModal. + */ +export const useModalCloseOnQuoteExpiry = () => { + const navigation = useNavigation(); + const { isExpired, willRefresh } = useBridgeQuoteData(); + + useEffect(() => { + if (isExpired && !willRefresh) { + navigation.dispatch( + CommonActions.reset({ + index: 0, + routes: [{ name: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL }], + }), + ); + } + }, [isExpired, willRefresh, navigation]); +}; diff --git a/app/components/UI/Bridge/hooks/useTokenAddress/index.ts b/app/components/UI/Bridge/hooks/useTokenAddress/index.ts index c3009e884c6..0388e066eb7 100644 --- a/app/components/UI/Bridge/hooks/useTokenAddress/index.ts +++ b/app/components/UI/Bridge/hooks/useTokenAddress/index.ts @@ -1,16 +1,7 @@ import { BridgeToken } from '../../types'; -import { zeroAddress } from 'ethereumjs-util'; -import { CHAIN_IDS } from '@metamask/transaction-controller'; -import { POLYGON_NATIVE_TOKEN } from '../../constants/assets'; +import { normalizeTokenAddress } from '../../utils/tokenUtils'; -export const useTokenAddress = (token: BridgeToken | undefined) => { - // Polygon native token address can be 0x0000000000000000000000000000000000001010 - // so we need to use the zero address for the token address - const tokenAddress = - token?.chainId === CHAIN_IDS.POLYGON && - token?.address === POLYGON_NATIVE_TOKEN - ? zeroAddress() - : token?.address; - - return tokenAddress; -}; +export const useTokenAddress = ( + token: BridgeToken | undefined, +): string | undefined => + token ? normalizeTokenAddress(token.address, token.chainId) : undefined; diff --git a/app/components/UI/Bridge/hooks/useTokensWithBalance/index.ts b/app/components/UI/Bridge/hooks/useTokensWithBalance/index.ts index 4ac20afdd88..e0684eb577c 100644 --- a/app/components/UI/Bridge/hooks/useTokensWithBalance/index.ts +++ b/app/components/UI/Bridge/hooks/useTokensWithBalance/index.ts @@ -39,6 +39,7 @@ import { isNonEvmChainId, } from '@metamask/bridge-controller'; import { isTradableToken } from '../../utils/isTradableToken'; +import { normalizeTokenAddress } from '../../utils/tokenUtils'; interface CalculateFiatBalancesParams { assets: TokenI[]; @@ -261,7 +262,7 @@ export const useTokensWithBalance: ({ } return { - address: token.address, + address: normalizeTokenAddress(token.address, chainId), name: token.name, decimals: token.decimals, symbol: token.isETH ? 'ETH' : token.symbol, // TODO: not sure why symbol is ETHEREUM, will also break the token icon for ETH diff --git a/app/components/UI/Bridge/routes.tsx b/app/components/UI/Bridge/routes.tsx index e23b2756d9d..5a6daffdd06 100644 --- a/app/components/UI/Bridge/routes.tsx +++ b/app/components/UI/Bridge/routes.tsx @@ -11,6 +11,7 @@ import MarketClosedBottomSheet from './components/MarketClosedBottomSheets/Marke import { DefaultSlippageModal } from './components/SlippageModal/DefaultSlippageModal'; import { CustomSlippageModal } from './components/SlippageModal/CustomSlippageModal'; import NetworkListModal from './components/BridgeTokenSelector/NetworkListModal'; +import { PriceImpactModal } from './components/PriceImpactModal'; const clearStackNavigatorOptions = { headerShown: false, @@ -79,5 +80,9 @@ export const BridgeModalStack = () => ( name={Routes.BRIDGE.MODALS.NETWORK_LIST_MODAL} component={NetworkListModal} /> + ); diff --git a/app/components/UI/Bridge/utils/formatPriceImpact.test.ts b/app/components/UI/Bridge/utils/formatPriceImpact.test.ts new file mode 100644 index 00000000000..220400deaa0 --- /dev/null +++ b/app/components/UI/Bridge/utils/formatPriceImpact.test.ts @@ -0,0 +1,43 @@ +import { formatPriceImpact } from './formatPriceImpact'; + +describe('formatPriceImpact', () => { + it('returns "0%" when called with undefined', () => { + expect(formatPriceImpact(undefined)).toBe('0%'); + }); + + it('returns "0%" when called with an empty string', () => { + expect(formatPriceImpact('')).toBe('0%'); + }); + + it('returns "0%" when value is zero', () => { + expect(formatPriceImpact('0%')).toBe('0%'); + }); + + it('returns "0%" when value is negative', () => { + expect(formatPriceImpact('-1.5%')).toBe('0%'); + }); + + it('returns "0%" for a negative value without percent sign', () => { + expect(formatPriceImpact('-3')).toBe('0%'); + }); + + it('returns "0%" for a non-numeric string', () => { + expect(formatPriceImpact('abc')).toBe('0%'); + }); + + it('appends "%" when given a positive numeric string without percent sign', () => { + expect(formatPriceImpact('2.5')).toBe('2.5%'); + }); + + it('preserves the value and appends "%" when given a positive value with percent sign', () => { + expect(formatPriceImpact('3.14%')).toBe('3.14%'); + }); + + it('handles whole number positive values', () => { + expect(formatPriceImpact('5%')).toBe('5%'); + }); + + it('handles very small positive values', () => { + expect(formatPriceImpact('0.01%')).toBe('0.01%'); + }); +}); diff --git a/app/components/UI/Bridge/utils/formatPriceImpact.ts b/app/components/UI/Bridge/utils/formatPriceImpact.ts new file mode 100644 index 00000000000..f4f7b0dcafe --- /dev/null +++ b/app/components/UI/Bridge/utils/formatPriceImpact.ts @@ -0,0 +1,13 @@ +export const formatPriceImpact = (priceImpact?: string) => { + if (!priceImpact) { + return '0%'; + } + + const value = Number.parseFloat(priceImpact.replace('%', '')); + + if (!Number.isFinite(value)) { + return '0%'; + } + + return value < 0 ? '0%' : value + '%'; +}; diff --git a/app/components/UI/Bridge/utils/getPriceImpactViewData.test.ts b/app/components/UI/Bridge/utils/getPriceImpactViewData.test.ts new file mode 100644 index 00000000000..b1d39c54bee --- /dev/null +++ b/app/components/UI/Bridge/utils/getPriceImpactViewData.test.ts @@ -0,0 +1,44 @@ +import { IconName } from '../../../../component-library/components/Icons/Icon'; +import { TextColor } from '../../../../component-library/components/Texts/Text'; +import { getPriceImpactViewData } from './getPriceImpactViewData'; + +describe('getPriceImpactViewData', () => { + it.each([ + { priceImpact: undefined }, + { priceImpact: '-0.06%' }, + { priceImpact: '4.99%' }, + { priceImpact: 'invalid' }, + ])( + 'returns alternative text color and no icon for $priceImpact', + ({ priceImpact }) => { + expect(getPriceImpactViewData(priceImpact)).toEqual({ + textColor: TextColor.Alternative, + icon: undefined, + }); + }, + ); + + it.each([ + { priceImpact: '5.00%' }, + { priceImpact: '5.01%' }, + { priceImpact: '24.99%' }, + ])( + 'returns warning text color and warning icon for $priceImpact', + ({ priceImpact }) => { + expect(getPriceImpactViewData(priceImpact)).toEqual({ + textColor: TextColor.Warning, + icon: { name: IconName.Warning, color: TextColor.Warning }, + }); + }, + ); + + it.each([{ priceImpact: '25.00%' }, { priceImpact: '25.01%' }])( + 'returns error text color and danger icon for $priceImpact', + ({ priceImpact }) => { + expect(getPriceImpactViewData(priceImpact)).toEqual({ + textColor: TextColor.Error, + icon: { name: IconName.Danger, color: TextColor.Error }, + }); + }, + ); +}); diff --git a/app/components/UI/Bridge/utils/getPriceImpactViewData.ts b/app/components/UI/Bridge/utils/getPriceImpactViewData.ts new file mode 100644 index 00000000000..30f1214065b --- /dev/null +++ b/app/components/UI/Bridge/utils/getPriceImpactViewData.ts @@ -0,0 +1,46 @@ +import { IconName } from '../../../../component-library/components/Icons/Icon'; +import { TextColor } from '../../../../component-library/components/Texts/Text'; +import AppConstants from '../../../../core/AppConstants'; + +export const getPriceImpactViewData = (priceImpactValue?: string) => { + if (!priceImpactValue) { + return { + textColor: TextColor.Alternative, + icon: undefined, + }; + } + + const priceImpact = Number.parseFloat(priceImpactValue.replace('%', '')); + + if (!Number.isFinite(priceImpact)) { + return { + textColor: TextColor.Alternative, + icon: undefined, + }; + } + + if (priceImpact >= AppConstants.BRIDGE.PRICE_IMPACT_ERROR_THRESHOLD) { + return { + textColor: TextColor.Error, + icon: { + name: IconName.Danger, + color: TextColor.Error, + }, + }; + } + + if (priceImpact >= AppConstants.BRIDGE.PRICE_IMPACT_WARNING_THRESHOLD) { + return { + textColor: TextColor.Warning, + icon: { + name: IconName.Warning, + color: TextColor.Warning, + }, + }; + } + + return { + textColor: TextColor.Alternative, + icon: undefined, + }; +}; diff --git a/app/components/UI/Bridge/utils/isTradableToken/index.test.ts b/app/components/UI/Bridge/utils/isTradableToken/index.test.ts index 0efd75c3f7b..1ba0cd83fa6 100644 --- a/app/components/UI/Bridge/utils/isTradableToken/index.test.ts +++ b/app/components/UI/Bridge/utils/isTradableToken/index.test.ts @@ -238,5 +238,38 @@ describe('isTradableToken', () => { expect(result).toBe(false); }); + + it('returns false for Tron Ready for Withdrawal token', () => { + const token = createTestToken({ + chainId: TrxScope.Mainnet, + symbol: 'TRX-READY-FOR-WITHDRAWAL', + }); + + const result = isTradableToken(token); + + expect(result).toBe(false); + }); + + it('returns false for Tron Staking Rewards token', () => { + const token = createTestToken({ + chainId: TrxScope.Mainnet, + symbol: 'TRX-STAKING-REWARDS', + }); + + const result = isTradableToken(token); + + expect(result).toBe(false); + }); + + it('returns false for Tron In Lock Period token', () => { + const token = createTestToken({ + chainId: TrxScope.Mainnet, + symbol: 'TRX-IN-LOCK-PERIOD', + }); + + const result = isTradableToken(token); + + expect(result).toBe(false); + }); }); }); diff --git a/app/components/UI/Bridge/utils/isTradableToken/index.ts b/app/components/UI/Bridge/utils/isTradableToken/index.ts index 37c5af07686..cad399bebc5 100644 --- a/app/components/UI/Bridge/utils/isTradableToken/index.ts +++ b/app/components/UI/Bridge/utils/isTradableToken/index.ts @@ -1,16 +1,8 @@ import { TrxScope } from '@metamask/keyring-api'; import { BridgeToken } from '../../types'; -import { - TRON_RESOURCE_SYMBOLS, - TronResourceSymbol, -} from '../../../../../core/Multichain/constants'; +import { isTronSpecialAsset } from '../../../../../core/Multichain/utils'; import { TokenI } from '../../../Tokens/types'; -export const isTradableToken = (token: BridgeToken | TokenI) => { - if (token.chainId === TrxScope.Mainnet) { - return !TRON_RESOURCE_SYMBOLS.includes( - token.symbol?.toLowerCase() as TronResourceSymbol, - ); - } - return true; -}; +export const isTradableToken = (token: BridgeToken | TokenI) => + token.chainId !== TrxScope.Mainnet || + !isTronSpecialAsset(token.chainId, token.symbol); diff --git a/app/components/UI/Bridge/utils/tokenUtils.ts b/app/components/UI/Bridge/utils/tokenUtils.ts index 5f7efded794..b4f0269e78c 100644 --- a/app/components/UI/Bridge/utils/tokenUtils.ts +++ b/app/components/UI/Bridge/utils/tokenUtils.ts @@ -5,9 +5,28 @@ import { getNativeAssetForChainId, isNonEvmChainId, } from '@metamask/bridge-controller'; +import { zeroAddress } from 'ethereumjs-util'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; import { BridgeToken } from '../types'; import { DefaultSwapDestTokens } from '../constants/default-swap-dest-tokens'; import { IncludeAsset } from '../hooks/usePopularTokens'; +import { POLYGON_NATIVE_TOKEN } from '../constants/assets'; + +/** + * Normalizes chain-specific native token addresses to the zero address for the bridge flow. + * + * Some chains use a non-zero contract address for their native token + * (e.g. Polygon uses 0x0000000000000000000000000000000000001010), but the bridge API + * expects the zero address for all native assets. + */ +export const normalizeTokenAddress = ( + address: string, + chainId: Hex | CaipChainId, +): string => { + const isPolygonNativeToken = + chainId === CHAIN_IDS.POLYGON && address === POLYGON_NATIVE_TOKEN; + return isPolygonNativeToken ? zeroAddress() : address; +}; /** * Creates a formatted native token object for the given chain ID diff --git a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx index 44015b4697b..34281237673 100644 --- a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx +++ b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx @@ -57,7 +57,9 @@ describeForPlatforms('EarnMusdConversionEducationView', () => { ), ).toBeOnTheScreen(); expect( - getByText(/Convert your stablecoins to mUSD.*receive up to a \d+% bonus/), + getByText( + /Convert your stablecoins to mUSD.*earn up to a \d+% annualized bonus/, + ), ).toBeOnTheScreen(); expect( getByText(strings('earn.musd_conversion.education.primary_button')), @@ -389,7 +391,7 @@ describeForPlatforms('EarnMusdConversionEducationView', () => { // Assert const description = getByText( - /Convert your stablecoins to mUSD.*receive up to a \d+% bonus/, + /Convert your stablecoins to mUSD.*earn up to a \d+% annualized bonus/, ); expect(description).toBeOnTheScreen(); expect(description.props.children[0]).toContain(`${MUSD_CONVERSION_APY}%`); diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.test.tsx b/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.test.tsx index 2ec33887b61..b06f4efd5b9 100644 --- a/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.test.tsx +++ b/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.test.tsx @@ -318,7 +318,7 @@ describe('MusdQuickConvertView', () => { expect( getByText( strings('earn.musd_conversion.quick_convert.title', { - apy: MUSD_CONVERSION_APY, + percentage: MUSD_CONVERSION_APY, }), ), ).toBeOnTheScreen(); diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx b/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx index 52af5bb6083..d13e718d22d 100644 --- a/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx +++ b/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx @@ -265,12 +265,12 @@ const MusdQuickConvertView = () => { {strings('earn.musd_conversion.quick_convert.title', { - apy: MUSD_CONVERSION_APY, + percentage: MUSD_CONVERSION_APY, })} {strings('earn.musd_conversion.quick_convert.subtitle', { - apy: MUSD_CONVERSION_APY, + percentage: MUSD_CONVERSION_APY, })}{' '} ({ ...jest.requireActual('../../../../../selectors/assets/assets-list'), - selectTronResourcesBySelectedAccountGroup: jest.fn(), + selectTronSpecialAssetsBySelectedAccountGroup: jest.fn(), })); jest.mock('../Tron/TronStakingButtons', () => ({ @@ -137,7 +137,7 @@ jest.mock('../../hooks/useTronStakeApy', () => ({ }), })); -const createEmptyResourcesMap = () => ({ +const createEmptySpecialAssetsMap = () => ({ energy: undefined, bandwidth: undefined, maxEnergy: undefined, @@ -145,6 +145,9 @@ const createEmptyResourcesMap = () => ({ stakedTrxForEnergy: undefined, stakedTrxForBandwidth: undefined, totalStakedTrx: 0, + trxReadyForWithdrawal: undefined, + trxStakingRewards: undefined, + trxInLockPeriod: undefined, }); describe('EarnBalance', () => { @@ -152,8 +155,8 @@ describe('EarnBalance', () => { jest.clearAllMocks(); (jest.mocked(selectTrxStakingEnabled) as jest.Mock).mockReturnValue(false); ( - jest.mocked(selectTronResourcesBySelectedAccountGroup) as jest.Mock - ).mockReturnValue(createEmptyResourcesMap()); + jest.mocked(selectTronSpecialAssetsBySelectedAccountGroup) as jest.Mock + ).mockReturnValue(createEmptySpecialAssetsMap()); }); describe('Ethereum Mainnet', () => { @@ -251,7 +254,7 @@ describe('EarnBalance', () => { describe('TRON', () => { const mockFlag = selectTrxStakingEnabled as unknown as jest.Mock; const mockTronResources = - selectTronResourcesBySelectedAccountGroup as unknown as jest.Mock; + selectTronSpecialAssetsBySelectedAccountGroup as unknown as jest.Mock; it('renders TRON stake button with aprText for TRX without staked positions', () => { const trx: Partial = { @@ -261,7 +264,7 @@ describe('EarnBalance', () => { }; mockFlag.mockReturnValue(true); - mockTronResources.mockReturnValue(createEmptyResourcesMap()); + mockTronResources.mockReturnValue(createEmptySpecialAssetsMap()); renderWithProvider(); @@ -283,7 +286,7 @@ describe('EarnBalance', () => { mockFlag.mockReturnValue(true); mockTronResources.mockReturnValue({ - ...createEmptyResourcesMap(), + ...createEmptySpecialAssetsMap(), stakedTrxForEnergy: { symbol: 'strx-energy', balance: '1' }, stakedTrxForBandwidth: { symbol: 'strx-bandwidth', balance: '2' }, totalStakedTrx: 3, diff --git a/app/components/UI/Earn/components/EarnBalance/index.tsx b/app/components/UI/Earn/components/EarnBalance/index.tsx index 575f2632d9a..894f55b7309 100644 --- a/app/components/UI/Earn/components/EarnBalance/index.tsx +++ b/app/components/UI/Earn/components/EarnBalance/index.tsx @@ -8,7 +8,7 @@ import EarnLendingBalance from '../EarnLendingBalance'; import { selectIsStakeableToken } from '../../../Stake/selectors/stakeableTokens'; ///: BEGIN:ONLY_INCLUDE_IF(tron) import TronStakingButtons from '../Tron/TronStakingButtons'; -import { selectTronResourcesBySelectedAccountGroup } from '../../../../../selectors/assets/assets-list'; +import { selectTronSpecialAssetsBySelectedAccountGroup } from '../../../../../selectors/assets/assets-list'; import { selectTrxStakingEnabled } from '../../../../../selectors/featureFlagController/trxStakingEnabled'; import { hasStakedTrxPositions as hasStakedTrxPositionsUtil } from '../../utils/tron'; import useTronStakeApy from '../../hooks/useTronStakeApy'; @@ -47,10 +47,12 @@ const EarnBalance = ({ asset }: EarnBalanceProps) => { const isStakedTrxAsset = isTron && (asset?.ticker === 'sTRX' || asset?.symbol === 'sTRX'); - const tronResources = useSelector(selectTronResourcesBySelectedAccountGroup); + const tronSpecialAssets = useSelector( + selectTronSpecialAssetsBySelectedAccountGroup, + ); const hasStakedTrxPositions = React.useMemo( - () => hasStakedTrxPositionsUtil(tronResources), - [tronResources], + () => hasStakedTrxPositionsUtil(tronSpecialAssets), + [tronSpecialAssets], ); const { apyPercent: tronApyPercent } = useTronStakeApy(); diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.test.tsx index 2d77c92c3a1..e2ade49a6a8 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.test.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.test.tsx @@ -131,7 +131,7 @@ describe('MusdConversionAssetOverviewCta', () => { ).toBeOnTheScreen(); expect( getByText( - `Convert your stablecoins to mUSD and receive up to a ${MUSD_CONVERSION_APY}% bonus.`, + `Convert your stablecoins to mUSD and get a ${MUSD_CONVERSION_APY}% annualized bonus.`, ), ).toBeOnTheScreen(); }); diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx index 8329744547a..3fa6a6945e6 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx @@ -103,7 +103,7 @@ describeForPlatforms('MusdConversionAssetOverviewCta', () => { ).toBeOnTheScreen(); expect( getByText( - `Convert your stablecoins to mUSD and receive up to a ${MUSD_CONVERSION_APY}% bonus.`, + `Convert your stablecoins to mUSD and get a ${MUSD_CONVERSION_APY}% annualized bonus.`, ), ).toBeOnTheScreen(); }); @@ -537,7 +537,7 @@ describeForPlatforms('MusdConversionAssetOverviewCta', () => { // Assert expect( getByText( - `Convert your stablecoins to mUSD and receive up to a ${MUSD_CONVERSION_APY}% bonus.`, + `Convert your stablecoins to mUSD and get a ${MUSD_CONVERSION_APY}% annualized bonus.`, ), ).toBeOnTheScreen(); }); diff --git a/app/components/UI/Earn/components/Tron/StakePreview/TronStakePreview.test.tsx b/app/components/UI/Earn/components/Tron/StakePreview/TronStakePreview.test.tsx index 2fe50e52747..c43ef4cc19c 100644 --- a/app/components/UI/Earn/components/Tron/StakePreview/TronStakePreview.test.tsx +++ b/app/components/UI/Earn/components/Tron/StakePreview/TronStakePreview.test.tsx @@ -4,8 +4,8 @@ import { useSelector } from 'react-redux'; import TronStakePreview from './TronStakePreview'; import { - selectTronResourcesBySelectedAccountGroup, - TronResourcesMap, + selectTronSpecialAssetsBySelectedAccountGroup, + TronSpecialAssetsMap, } from '../../../../../../selectors/assets/assets-list'; import type { ComputeFeeResult } from '../../../utils/tron-staking-snap'; @@ -46,7 +46,9 @@ jest.mock('../../../hooks/useTronStakeApy', () => ({ const mockUseSelector = useSelector as jest.Mock; -const createMockResourcesMap = (totalStakedTrx: number): TronResourcesMap => ({ +const createMockSpecialAssetsMap = ( + totalStakedTrx: number, +): TronSpecialAssetsMap => ({ energy: undefined, bandwidth: undefined, maxEnergy: undefined, @@ -54,6 +56,9 @@ const createMockResourcesMap = (totalStakedTrx: number): TronResourcesMap => ({ stakedTrxForEnergy: undefined, stakedTrxForBandwidth: undefined, totalStakedTrx, + trxReadyForWithdrawal: undefined, + trxStakingRewards: undefined, + trxInLockPeriod: undefined, }); describe('TronStakePreview', () => { @@ -68,10 +73,10 @@ describe('TronStakePreview', () => { }); // Default: 10 + 5 = 15 TRX staked - const mockResourcesMap = createMockResourcesMap(15); + const mockResourcesMap = createMockSpecialAssetsMap(15); mockUseSelector.mockImplementation((selector: unknown) => { - if (selector === selectTronResourcesBySelectedAccountGroup) { + if (selector === selectTronSpecialAssetsBySelectedAccountGroup) { return mockResourcesMap; } return undefined; @@ -91,10 +96,10 @@ describe('TronStakePreview', () => { }); it('calculates annual reward from floating-point balances without precision errors', () => { - const mockResourcesMap = createMockResourcesMap(130.96926); + const mockResourcesMap = createMockSpecialAssetsMap(130.96926); mockUseSelector.mockImplementation((selector: unknown) => { - if (selector === selectTronResourcesBySelectedAccountGroup) { + if (selector === selectTronSpecialAssetsBySelectedAccountGroup) { return mockResourcesMap; } return undefined; @@ -174,8 +179,8 @@ describe('TronStakePreview', () => { it('returns empty reward when total staked balance is zero in stake mode', () => { mockUseSelector.mockImplementation((selector: unknown) => { - if (selector === selectTronResourcesBySelectedAccountGroup) { - return createMockResourcesMap(0); + if (selector === selectTronSpecialAssetsBySelectedAccountGroup) { + return createMockSpecialAssetsMap(0); } return undefined; }); diff --git a/app/components/UI/Earn/components/Tron/StakePreview/TronStakePreview.tsx b/app/components/UI/Earn/components/Tron/StakePreview/TronStakePreview.tsx index 153fb46bf85..ef23240bbad 100644 --- a/app/components/UI/Earn/components/Tron/StakePreview/TronStakePreview.tsx +++ b/app/components/UI/Earn/components/Tron/StakePreview/TronStakePreview.tsx @@ -12,7 +12,7 @@ import { BoxJustifyContent, } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../../locales/i18n'; -import { selectTronResourcesBySelectedAccountGroup } from '../../../../../../selectors/assets/assets-list'; +import { selectTronSpecialAssetsBySelectedAccountGroup } from '../../../../../../selectors/assets/assets-list'; import type { ComputeFeeResult } from '../../../types/tron-staking.types'; import useTronStakeApy from '../../../hooks/useTronStakeApy'; @@ -46,7 +46,7 @@ const TronStakePreview = ({ const tw = useTailwind(); const { totalStakedTrx } = useSelector( - selectTronResourcesBySelectedAccountGroup, + selectTronSpecialAssetsBySelectedAccountGroup, ); const { apyDecimal } = useTronStakeApy(); diff --git a/app/components/UI/Earn/hooks/useEarnToasts.test.tsx b/app/components/UI/Earn/hooks/useEarnToasts.test.tsx index 4a8bfc28bf1..417195c10cd 100644 --- a/app/components/UI/Earn/hooks/useEarnToasts.test.tsx +++ b/app/components/UI/Earn/hooks/useEarnToasts.test.tsx @@ -200,7 +200,7 @@ describe('useEarnToasts', () => { expect(successToast.labelOptions).toBeDefined(); expect(Array.isArray(successToast.labelOptions)).toBe(true); - expect(successToast.labelOptions).toHaveLength(1); + expect(successToast.labelOptions).toHaveLength(3); }); it('includes labelOptions in failed toast', () => { diff --git a/app/components/UI/Earn/hooks/useEarnToasts.tsx b/app/components/UI/Earn/hooks/useEarnToasts.tsx index 3047debb549..978311ff51f 100644 --- a/app/components/UI/Earn/hooks/useEarnToasts.tsx +++ b/app/components/UI/Earn/hooks/useEarnToasts.tsx @@ -14,7 +14,12 @@ import { } from '../../../../component-library/components/Toast/Toast.types'; import { useAppThemeFromContext } from '../../../../util/theme'; import { Spinner } from '@metamask/design-system-react-native/dist/components/temp-components/Spinner/index.cjs'; -import { IconSize as ReactNativeDsIconSize } from '@metamask/design-system-react-native'; +import { + IconSize as ReactNativeDsIconSize, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; export type EarnToastOptions = Omit< Extract, @@ -185,6 +190,14 @@ const useEarnToasts = (): { ...earnBaseToastOptions.success, labelOptions: getEarnToastLabels({ primary: strings('earn.musd_conversion.toasts.delivered'), + secondary: ( + + {strings('earn.musd_conversion.toasts.delivered_description')} + + ), }), closeButtonOptions, }, diff --git a/app/components/UI/Earn/hooks/useTronUnstake.test.ts b/app/components/UI/Earn/hooks/useTronUnstake.test.ts index 190555733b8..d909204e571 100644 --- a/app/components/UI/Earn/hooks/useTronUnstake.test.ts +++ b/app/components/UI/Earn/hooks/useTronUnstake.test.ts @@ -12,7 +12,7 @@ import { TokenI } from '../../Tokens/types'; const mockSelectSelectedInternalAccountByScope = jest.fn(); const mockSelectTrxStakingEnabled = jest.fn(); -const mockSelectTronResourcesBySelectedAccountGroup = jest.fn(); +const mockSelectTronSpecialAssetsBySelectedAccountGroup = jest.fn(); jest.mock('react-redux', () => ({ useSelector: jest.fn((selector) => selector()), @@ -31,8 +31,8 @@ jest.mock( ); jest.mock('../../../../selectors/assets/assets-list', () => ({ - selectTronResourcesBySelectedAccountGroup: () => - mockSelectTronResourcesBySelectedAccountGroup(), + selectTronSpecialAssetsBySelectedAccountGroup: () => + mockSelectTronSpecialAssetsBySelectedAccountGroup(), })); jest.mock('../utils/tron-staking-snap', () => ({ @@ -46,7 +46,7 @@ jest.mock('../../../../core/Multichain/utils', () => ({ })); jest.mock('../utils/tron', () => ({ - getStakedTrxTotalFromResources: jest.fn(() => 100), + getStakedTrxTotalFromSpecialAssets: jest.fn(() => 100), buildTronEarnTokenIfEligible: jest.fn(() => ({ symbol: 'TRX', balance: '100', @@ -102,7 +102,7 @@ describe('useTronUnstake', () => { // Setup default mock values mockSelectSelectedInternalAccountByScope.mockReturnValue(mockAccount); mockSelectTrxStakingEnabled.mockReturnValue(true); - mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue({ + mockSelectTronSpecialAssetsBySelectedAccountGroup.mockReturnValue({ energy: undefined, bandwidth: undefined, maxEnergy: undefined, @@ -110,6 +110,9 @@ describe('useTronUnstake', () => { stakedTrxForEnergy: { symbol: 'strx-energy', balance: '50' }, stakedTrxForBandwidth: { symbol: 'strx-bandwidth', balance: '50' }, totalStakedTrx: 100, + trxReadyForWithdrawal: undefined, + trxStakingRewards: undefined, + trxInLockPeriod: undefined, }); }); diff --git a/app/components/UI/Earn/hooks/useTronUnstake.ts b/app/components/UI/Earn/hooks/useTronUnstake.ts index 70cc40cc145..d2517562475 100644 --- a/app/components/UI/Earn/hooks/useTronUnstake.ts +++ b/app/components/UI/Earn/hooks/useTronUnstake.ts @@ -6,14 +6,14 @@ import { useSelector } from 'react-redux'; import { TronResourceType } from '../../../../core/Multichain/constants'; import Logger from '../../../../util/Logger'; import { isTronChainId } from '../../../../core/Multichain/utils'; -import { selectTronResourcesBySelectedAccountGroup } from '../../../../selectors/assets/assets-list'; +import { selectTronSpecialAssetsBySelectedAccountGroup } from '../../../../selectors/assets/assets-list'; import { selectTrxStakingEnabled } from '../../../../selectors/featureFlagController/trxStakingEnabled'; import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; import { TokenI } from '../../Tokens/types'; import { EarnTokenDetails } from '../types/lending.types'; import { buildTronEarnTokenIfEligible, - getStakedTrxTotalFromResources, + getStakedTrxTotalFromSpecialAssets, } from '../utils/tron'; import { computeStakeFee, @@ -70,7 +70,9 @@ const useTronUnstake = ({ TrxScope.Mainnet, ); const isTrxStakingEnabled = useSelector(selectTrxStakingEnabled); - const tronResources = useSelector(selectTronResourcesBySelectedAccountGroup); + const tronSpecialAssets = useSelector( + selectTronSpecialAssetsBySelectedAccountGroup, + ); // Derive whether token is on Tron chain const isTronAsset = useMemo( @@ -81,10 +83,10 @@ const useTronUnstake = ({ // Tron unstaking is enabled when both flag is on and token is on Tron chain const isTronEnabled = Boolean(isTrxStakingEnabled && isTronAsset); - // Compute staked TRX total from resources const stakedTrxTotal = useMemo( - () => (isTronEnabled ? getStakedTrxTotalFromResources(tronResources) : 0), - [isTronEnabled, tronResources], + () => + isTronEnabled ? getStakedTrxTotalFromSpecialAssets(tronSpecialAssets) : 0, + [isTronEnabled, tronSpecialAssets], ); // Determine the staked balance to use for withdrawal diff --git a/app/components/UI/Earn/utils/tron.test.ts b/app/components/UI/Earn/utils/tron.test.ts index eb31350452f..652c76bbaa6 100644 --- a/app/components/UI/Earn/utils/tron.test.ts +++ b/app/components/UI/Earn/utils/tron.test.ts @@ -3,11 +3,11 @@ import Routes from '../../../../constants/navigation/Routes'; import { EARN_EXPERIENCES } from '../constants/experiences'; import type { EarnTokenDetails } from '../types/lending.types'; import type { TokenI } from '../../Tokens/types'; -import type { TronResourcesMap } from '../../../../selectors/assets/assets-list'; +import type { TronSpecialAssetsMap } from '../../../../selectors/assets/assets-list'; import { buildTronEarnTokenIfEligible, getLocalizedErrorMessage, - getStakedTrxTotalFromResources, + getStakedTrxTotalFromSpecialAssets, handleTronStakingNavigationResult, hasStakedTrxPositions, } from './tron'; @@ -51,22 +51,21 @@ describe('tron utils', () => { }; }); - describe('getStakedTrxTotalFromResources', () => { - it('returns zero when resources are missing', () => { - const total = getStakedTrxTotalFromResources(undefined); + describe('getStakedTrxTotalFromSpecialAssets', () => { + it('returns zero when special assets are missing', () => { + const total = getStakedTrxTotalFromSpecialAssets(undefined); expect(total).toBe(0); }); - it('returns zero when resources are null', () => { - const total = getStakedTrxTotalFromResources(null); + it('returns zero when special assets are null', () => { + const total = getStakedTrxTotalFromSpecialAssets(null); expect(total).toBe(0); }); - it('returns totalStakedTrx from resources', () => { - // totalStakedTrx is now pre-computed in the selector - const resources: TronResourcesMap = { + it('returns totalStakedTrx from special assets', () => { + const specialAssets: TronSpecialAssetsMap = { energy: undefined, bandwidth: undefined, maxEnergy: undefined, @@ -74,26 +73,27 @@ describe('tron utils', () => { stakedTrxForEnergy: undefined, stakedTrxForBandwidth: undefined, totalStakedTrx: 15, + trxReadyForWithdrawal: undefined, + trxStakingRewards: undefined, + trxInLockPeriod: undefined, }; - const total = getStakedTrxTotalFromResources(resources); + const total = getStakedTrxTotalFromSpecialAssets(specialAssets); expect(total).toBe(15); }); it('defaults to zero when totalStakedTrx is undefined at runtime', () => { - // Simulates a malformed object where totalStakedTrx is missing, - // exercising the ?? 0 fallback in getStakedTrxTotalFromResources - const resources = { + const specialAssets = { energy: undefined, bandwidth: undefined, maxEnergy: undefined, maxBandwidth: undefined, stakedTrxForEnergy: undefined, stakedTrxForBandwidth: undefined, - } as unknown as TronResourcesMap; + } as unknown as TronSpecialAssetsMap; - const total = getStakedTrxTotalFromResources(resources); + const total = getStakedTrxTotalFromSpecialAssets(specialAssets); expect(total).toBe(0); }); @@ -101,7 +101,7 @@ describe('tron utils', () => { describe('hasStakedTrxPositions', () => { it('returns false when totalStakedTrx is zero', () => { - const resources: TronResourcesMap = { + const specialAssets: TronSpecialAssetsMap = { energy: undefined, bandwidth: undefined, maxEnergy: undefined, @@ -109,15 +109,18 @@ describe('tron utils', () => { stakedTrxForEnergy: undefined, stakedTrxForBandwidth: undefined, totalStakedTrx: 0, + trxReadyForWithdrawal: undefined, + trxStakingRewards: undefined, + trxInLockPeriod: undefined, }; - const result = hasStakedTrxPositions(resources); + const result = hasStakedTrxPositions(specialAssets); expect(result).toBe(false); }); it('returns true when totalStakedTrx is greater than zero', () => { - const resources: TronResourcesMap = { + const specialAssets: TronSpecialAssetsMap = { energy: undefined, bandwidth: undefined, maxEnergy: undefined, @@ -125,9 +128,12 @@ describe('tron utils', () => { stakedTrxForEnergy: undefined, stakedTrxForBandwidth: undefined, totalStakedTrx: 1, + trxReadyForWithdrawal: undefined, + trxStakingRewards: undefined, + trxInLockPeriod: undefined, }; - const result = hasStakedTrxPositions(resources); + const result = hasStakedTrxPositions(specialAssets); expect(result).toBe(true); }); diff --git a/app/components/UI/Earn/utils/tron.ts b/app/components/UI/Earn/utils/tron.ts index f73a90b550d..cbf4205304c 100644 --- a/app/components/UI/Earn/utils/tron.ts +++ b/app/components/UI/Earn/utils/tron.ts @@ -13,24 +13,24 @@ import { TokenI } from '../../Tokens/types'; import Engine from '../../../../core/Engine'; import Logger from '../../../../util/Logger'; import { safeParseBigNumber } from '../../../../util/number/bignumber'; -import type { TronResourcesMap } from '../../../../selectors/assets/assets-list'; +import type { TronSpecialAssetsMap } from '../../../../selectors/assets/assets-list'; /** - * Returns the total staked TRX (sTRX) amount from TRON resources. + * Returns the total staked TRX (sTRX) amount from Tron special assets. * This is pre-computed in the selector using BigNumber to avoid floating-point precision errors. */ -export function getStakedTrxTotalFromResources( - resources?: TronResourcesMap | null, +export function getStakedTrxTotalFromSpecialAssets( + specialAssets?: TronSpecialAssetsMap | null, ): number { - return resources?.totalStakedTrx ?? 0; + return specialAssets?.totalStakedTrx ?? 0; } /** - * True if the user holds any sTRX according to TRON resources. + * True if the user holds any sTRX according to Tron special assets. */ export const hasStakedTrxPositions = ( - resources?: TronResourcesMap | null, -): boolean => getStakedTrxTotalFromResources(resources) > 0; + specialAssets?: TronSpecialAssetsMap | null, +): boolean => getStakedTrxTotalFromSpecialAssets(specialAssets) > 0; export const buildTronEarnTokenIfEligible = ( token: TokenI, diff --git a/app/components/UI/Perps/components/PerpsCard/PerpsCard.tsx b/app/components/UI/Perps/components/PerpsCard/PerpsCard.tsx index dd9010402ee..2fd709dc42e 100644 --- a/app/components/UI/Perps/components/PerpsCard/PerpsCard.tsx +++ b/app/components/UI/Perps/components/PerpsCard/PerpsCard.tsx @@ -206,4 +206,4 @@ const PerpsCard: React.FC = ({ ); }; -export default PerpsCard; +export default React.memo(PerpsCard); diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx index 33856867418..8ce1d84f2e0 100644 --- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx +++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx @@ -612,4 +612,4 @@ const PerpsPositionCard: React.FC = ({ ); }; -export default PerpsPositionCard; +export default React.memo(PerpsPositionCard); diff --git a/app/components/UI/Perps/hooks/usePerpsMarketListView.ts b/app/components/UI/Perps/hooks/usePerpsMarketListView.ts index f82653d2243..6953520563c 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketListView.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketListView.ts @@ -198,7 +198,7 @@ export const usePerpsMarketListView = ({ // Use sorting hook for sort state and sorting logic const sortingHook = usePerpsSorting({ - initialOptionId: savedSortPreference.optionId, + initialOptionId: savedSortPreference.optionId as SortOptionId, initialDirection: savedSortPreference.direction, }); diff --git a/app/components/UI/Perps/selectors/perpsController/index.ts b/app/components/UI/Perps/selectors/perpsController/index.ts index 7939229f090..2634f317814 100644 --- a/app/components/UI/Perps/selectors/perpsController/index.ts +++ b/app/components/UI/Perps/selectors/perpsController/index.ts @@ -56,19 +56,55 @@ const selectPerpsBalances = createSelector( (perpsControllerState) => perpsControllerState?.perpsBalances || {}, ); +const DEFAULT_MARKET_FILTER_PREFERENCES = { + optionId: 'volume', + direction: 'desc' as const, +}; + +// When PerpsController state is missing or partial (e.g. before Engine init, rehydration, or minimal E2E fixtures), +// avoid calling perps-controller selectors with undefined (they may access .length etc. on nested props). +// Normalize return values (?? []) so we're safe even when the package returns undefined for partial state. const selectIsFirstTimePerpsUser = createSelector( selectPerpsControllerState, - (perpsControllerState) => selectIsFirstTimeUser(perpsControllerState), + (perpsControllerState) => { + try { + return perpsControllerState + ? selectIsFirstTimeUser(perpsControllerState) + : true; + } catch { + return true; + } + }, ); const selectPerpsWatchlistMarkets = createSelector( selectPerpsControllerState, - (perpsControllerState) => selectWatchlistMarkets(perpsControllerState), + (perpsControllerState) => { + try { + return ( + (perpsControllerState + ? selectWatchlistMarkets(perpsControllerState) + : undefined) ?? [] + ); + } catch { + return []; + } + }, ); const selectPerpsMarketFilterPreferences = createSelector( selectPerpsControllerState, - (perpsControllerState) => selectMarketFilterPreferences(perpsControllerState), + (perpsControllerState) => { + try { + return ( + (perpsControllerState + ? selectMarketFilterPreferences(perpsControllerState) + : undefined) ?? DEFAULT_MARKET_FILTER_PREFERENCES + ); + } catch { + return DEFAULT_MARKET_FILTER_PREFERENCES; + } + }, ); /** @@ -102,9 +138,15 @@ const selectPerpsInitializationState = createSelector( // Factory function to create selector for specific market export const createSelectIsWatchlistMarket = (symbol: string) => - createSelector(selectPerpsControllerState, (perpsControllerState) => - selectIsWatchlistMarket(perpsControllerState, symbol), - ); + createSelector(selectPerpsControllerState, (perpsControllerState) => { + try { + return perpsControllerState + ? selectIsWatchlistMarket(perpsControllerState, symbol) + : false; + } catch { + return false; + } + }); export { selectPerpsProvider, diff --git a/app/components/UI/Rewards/RewardsNavigator.test.tsx b/app/components/UI/Rewards/RewardsNavigator.test.tsx index 2dbb8397fb6..af3781616ba 100644 --- a/app/components/UI/Rewards/RewardsNavigator.test.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.test.tsx @@ -100,14 +100,12 @@ jest.mock('../../Views/ErrorBoundary', () => ({ })); // Mock theme -jest.mock('../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - primary: '#000', - background: '#fff', - }, - }), -})); +jest.mock('../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../util/theme'); + return { + useTheme: () => mockTheme, + }; +}); // Mock getNavigationOptionsTitle jest.mock('../Navbar', () => ({ diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx index 3a0f95ef029..b01f9977e0d 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx @@ -106,14 +106,12 @@ const mockSelectSnapshotsRewardsEnabledFlag = >; // Mock theme -jest.mock('../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - primary: '#000', - background: '#fff', - }, - }), -})); +jest.mock('../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../util/theme'); + return { + useTheme: () => mockTheme, + }; +}); // Mock react-native-safe-area-context jest.mock('react-native-safe-area-context', () => { diff --git a/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx b/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx index bd9b873881b..b82bd22f49a 100644 --- a/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx @@ -29,14 +29,12 @@ jest.mock('@react-navigation/native', () => ({ })); // Mock theme -jest.mock('../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - primary: '#000', - background: '#fff', - }, - }), -})); +jest.mock('../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../util/theme'); + return { + useTheme: () => mockTheme, + }; +}); // Mock i18n jest.mock('../../../../../locales/i18n', () => ({ diff --git a/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx b/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx index ded9c90a707..8b0dcf956c2 100644 --- a/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx +++ b/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx @@ -102,15 +102,12 @@ jest.mock('../../hooks/useLineaSeasonOneTokenReward', () => ({ })); // Mock useTheme -jest.mock('../../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - text: { - alternative: '#666666', - }, - }, - }), -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useTheme: () => mockTheme, + }; +}); // Mock i18n jest.mock('../../../../../../locales/i18n', () => ({ diff --git a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingNoActiveSeasonStep.test.tsx b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingNoActiveSeasonStep.test.tsx index 4430b2deece..6b7869d650c 100644 --- a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingNoActiveSeasonStep.test.tsx +++ b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingNoActiveSeasonStep.test.tsx @@ -48,15 +48,12 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ })); // Mock theme -jest.mock('../../../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - background: { - muted: '#f5f5f5', - }, - }, - }), -})); +jest.mock('../../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../../util/theme'); + return { + useTheme: () => mockTheme, + }; +}); // Mock strings jest.mock('../../../../../../../locales/i18n', () => ({ diff --git a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep.test.tsx b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep.test.tsx index 1b35ecd1d7c..45b943cd59e 100644 --- a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep.test.tsx +++ b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep.test.tsx @@ -42,23 +42,12 @@ jest.mock('react-redux', () => ({ })); // Mock theme -jest.mock('../../../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - background: { - muted: '#f5f5f5', - default: '#ffffff', - }, - text: { - primary: '#000000', - alternative: '#666666', - }, - border: { - muted: '#e0e0e0', - }, - }, - }), -})); +jest.mock('../../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../../util/theme'); + return { + useTheme: () => mockTheme, + }; +}); // Mock rewards auth hook const mockOptin = jest.fn(); diff --git a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep1.test.tsx b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep1.test.tsx index 4d2088db4ff..6c39fcdd552 100644 --- a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep1.test.tsx +++ b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep1.test.tsx @@ -29,15 +29,12 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ })); // Mock theme -jest.mock('../../../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - background: { - muted: '#f5f5f5', - }, - }, - }), -})); +jest.mock('../../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../../util/theme'); + return { + useTheme: () => mockTheme, + }; +}); // Mock strings jest.mock('../../../../../../../locales/i18n', () => ({ diff --git a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep2.test.tsx b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep2.test.tsx index 47dab7aa9c4..308af179595 100644 --- a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep2.test.tsx +++ b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep2.test.tsx @@ -29,15 +29,12 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ })); // Mock theme -jest.mock('../../../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - background: { - muted: '#f5f5f5', - }, - }, - }), -})); +jest.mock('../../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../../util/theme'); + return { + useTheme: () => mockTheme, + }; +}); // Mock strings jest.mock('../../../../../../../locales/i18n', () => ({ diff --git a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep3.test.tsx b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep3.test.tsx index 63fe5b219f6..32397d57824 100644 --- a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep3.test.tsx +++ b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep3.test.tsx @@ -29,15 +29,12 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ })); // Mock theme -jest.mock('../../../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - background: { - muted: '#f5f5f5', - }, - }, - }), -})); +jest.mock('../../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../../util/theme'); + return { + useTheme: () => mockTheme, + }; +}); // Mock strings jest.mock('../../../../../../../locales/i18n', () => ({ diff --git a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep4.test.tsx b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep4.test.tsx index c39ab1a0619..2f58d403ec0 100644 --- a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep4.test.tsx +++ b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep4.test.tsx @@ -43,15 +43,12 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ })); // Mock theme -jest.mock('../../../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - background: { - muted: '#f5f5f5', - }, - }, - }), -})); +jest.mock('../../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../../util/theme'); + return { + useTheme: () => mockTheme, + }; +}); // Mock strings jest.mock('../../../../../../../locales/i18n', () => ({ diff --git a/app/components/UI/Rewards/components/Onboarding/testUtils.ts b/app/components/UI/Rewards/components/Onboarding/testUtils.ts index f1dd8b32b49..6a290fcb0ac 100644 --- a/app/components/UI/Rewards/components/Onboarding/testUtils.ts +++ b/app/components/UI/Rewards/components/Onboarding/testUtils.ts @@ -182,20 +182,3 @@ export const mockAccount = { }, }, }; - -// Mock theme -export const mockTheme = { - colors: { - background: { - muted: '#f5f5f5', - default: '#ffffff', - }, - text: { - primary: '#000000', - alternative: '#666666', - }, - border: { - muted: '#e0e0e0', - }, - }, -}; diff --git a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx index 06c06753940..dd0027cdd40 100644 --- a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx +++ b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx @@ -52,19 +52,12 @@ const mockUseUnlockedRewards = useUnlockedRewards as jest.MockedFunction< >; // Mock useTheme -jest.mock('../../../../../util/theme', () => ({ - useTheme: () => ({ - themeAppearance: 'light', - colors: { - background: { - default: '#FFFFFF', - }, - text: { - muted: '#999999', - }, - }, - }), -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useTheme: () => mockTheme, + }; +}); // Mock i18n jest.mock('../../../../../../locales/i18n', () => ({ diff --git a/app/components/UI/Rewards/components/RewardPointsAnimation/RewardPointsAnimation.stories.tsx b/app/components/UI/Rewards/components/RewardPointsAnimation/RewardPointsAnimation.stories.tsx index be716304229..301b58fe67f 100644 --- a/app/components/UI/Rewards/components/RewardPointsAnimation/RewardPointsAnimation.stories.tsx +++ b/app/components/UI/Rewards/components/RewardPointsAnimation/RewardPointsAnimation.stories.tsx @@ -1,10 +1,13 @@ /* eslint-disable react/display-name */ -/* eslint-disable react-native/no-inline-styles */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable react-native/no-color-literals */ import React, { useState, useCallback, useEffect } from 'react'; -import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { View } from 'react-native'; import RewardPointsAnimationComponent, { RewardAnimationState } from './index'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@metamask/design-system-react-native'; /** * Storybook configuration for RewardPointsAnimation component @@ -22,61 +25,13 @@ const RewardPointsAnimationMeta = { control: { type: 'number' }, description: 'Animation duration in milliseconds', }, - variant: { - control: { type: 'select' }, - options: ['BodyMD', 'BodyLG', 'HeadingMd'], - description: 'Text variant for styling', - }, }, }; export default RewardPointsAnimationMeta; -const styles = StyleSheet.create({ - container: { - padding: 24, - }, - buttonContainer: { - marginTop: 20, - gap: 10, - }, - buttonRow: { - flexDirection: 'row', - gap: 10, - }, - primaryButton: { - backgroundColor: '#007AFF', - padding: 10, - borderRadius: 5, - minWidth: 100, - alignItems: 'center', - }, - secondaryButton: { - backgroundColor: '#6C757D', - padding: 10, - borderRadius: 5, - minWidth: 100, - alignItems: 'center', - }, - buttonText: { - color: 'white', - fontWeight: 'bold', - }, - animationContainer: { - padding: 20, - paddingHorizontal: 50, - width: '100%', - alignItems: 'center', - justifyContent: 'center', - alignSelf: 'center', - }, -}); - -const InteractiveStory = (args: { - value: number; - duration: number; - variant?: any; -}) => { +const InteractiveStory = (args: { value: number; duration: number }) => { + const tw = useTailwind(); const [currentValue, setCurrentValue] = useState(0); const [animationState, setAnimationState] = useState( RewardAnimationState.Idle, @@ -126,9 +81,11 @@ const InteractiveStory = (args: { }, [handleIdle]); return ( - + {/* Animation display */} - + {/* Control buttons for state demonstration */} - - - - Loading - - + + + - - - Set random value - + + diff --git a/app/components/UI/Rewards/components/RewardPointsAnimation/index.test.tsx b/app/components/UI/Rewards/components/RewardPointsAnimation/index.test.tsx index 663670b612c..94bcf3e5f9c 100644 --- a/app/components/UI/Rewards/components/RewardPointsAnimation/index.test.tsx +++ b/app/components/UI/Rewards/components/RewardPointsAnimation/index.test.tsx @@ -26,16 +26,12 @@ jest.mock('../../../../../component-library/hooks', () => ({ })), })); -jest.mock('../../../../../util/theme', () => ({ - useTheme: jest.fn(() => ({ - colors: { - text: { - default: '#000000', - alternative: '#666666', - }, - }, - })), -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useTheme: jest.fn(() => mockTheme), + }; +}); jest.mock('rive-react-native', () => ({ __esModule: true, diff --git a/app/components/UI/Rewards/components/RewardsReferralCodeTag/RewardsReferralCodeTag.test.tsx b/app/components/UI/Rewards/components/RewardsReferralCodeTag/RewardsReferralCodeTag.test.tsx index 80f51d71278..8dd2bdde77e 100644 --- a/app/components/UI/Rewards/components/RewardsReferralCodeTag/RewardsReferralCodeTag.test.tsx +++ b/app/components/UI/Rewards/components/RewardsReferralCodeTag/RewardsReferralCodeTag.test.tsx @@ -4,6 +4,8 @@ import RewardsReferralCodeTag from './RewardsReferralCodeTag'; import ClipboardManager from '../../../../../core/ClipboardManager'; import { useStyles } from '../../../../../component-library/hooks'; +const { mockTheme } = jest.requireActual('../../../../../util/theme'); + jest.mock('../../../../../component-library/hooks', () => ({ useStyles: jest.fn((_styleSheet, params) => ({ styles: { @@ -62,7 +64,7 @@ describe('RewardsReferralCodeTag', () => { }); it('applies custom backgroundColor when provided', () => { - const customBackgroundColor = '#FF0000'; + const customBackgroundColor = mockTheme.colors.error.default; render( { }); it('applies custom fontColor when provided', () => { - const customFontColor = '#00FF00'; + const customFontColor = mockTheme.colors.success.default; render( ({ })); // Mock useTheme -jest.mock('../../../../../util/theme', () => ({ - useTheme: jest.fn(() => ({ - colors: { - background: { - alternative: '#f5f5f5', - default: '#ffffff', - section: '#f9f9f9', - }, - text: { - primary: '#000000', - alternative: '#666666', - }, - }, - })), -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useTheme: jest.fn(() => mockTheme), + }; +}); // Mock Tailwind jest.mock('@metamask/design-system-twrnc-preset', () => ({ diff --git a/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.test.tsx b/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.test.tsx index 6287d9926a0..f51bd4df51a 100644 --- a/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.test.tsx +++ b/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.test.tsx @@ -219,15 +219,12 @@ jest.mock('../../hooks/useApplyReferralCode', () => ({ useApplyReferralCode: jest.fn(), })); -jest.mock('../../../../../util/theme', () => ({ - useTheme: jest.fn(() => ({ - colors: { - background: { muted: '#f5f5f5' }, - border: { muted: '#e0e0e0' }, - error: { default: '#ff0000' }, - }, - })), -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useTheme: jest.fn(() => mockTheme), + }; +}); jest.mock('@react-navigation/native', () => ({ useFocusEffect: jest.fn((callback) => callback()), diff --git a/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroup.test.tsx b/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroup.test.tsx index e678426377c..5a70da8ce74 100644 --- a/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroup.test.tsx +++ b/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroup.test.tsx @@ -35,15 +35,12 @@ jest.mock('../../../../../../locales/i18n', () => ({ }, })); -jest.mock('../../../../../util/theme', () => ({ - useTheme: jest.fn(() => ({ - colors: { - icon: { - default: '#000000', - }, - }, - })), -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useTheme: jest.fn(() => mockTheme), + }; +}); jest.mock('lodash', () => ({ isEmpty: jest.fn((value: unknown) => { diff --git a/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.test.tsx b/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.test.tsx index a9bfb9de36d..9cd932415b8 100644 --- a/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.test.tsx +++ b/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.test.tsx @@ -52,18 +52,12 @@ jest.mock('../../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => key), })); -jest.mock('../../../../../util/theme', () => ({ - useTheme: jest.fn(() => ({ - colors: { - primary: { - default: '#037DD6', - }, - background: { - alternative: '#F7F9FA', - }, - }, - })), -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useTheme: jest.fn(() => mockTheme), + }; +}); // Mock FlashList jest.mock('@shopify/flash-list', () => { diff --git a/app/components/UI/Rewards/components/Tabs/LevelsTab/UnlockedRewards.test.tsx b/app/components/UI/Rewards/components/Tabs/LevelsTab/UnlockedRewards.test.tsx index 7b4c1151998..a2867b3f9c7 100644 --- a/app/components/UI/Rewards/components/Tabs/LevelsTab/UnlockedRewards.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/LevelsTab/UnlockedRewards.test.tsx @@ -41,16 +41,12 @@ jest.mock('../../../../../../reducers/rewards/selectors', () => ({ })); // Mock theme -jest.mock('../../../../../../util/theme', () => ({ - useTheme: () => ({ - themeAppearance: 'light', - colors: { - grey: { - 700: '#374151', - }, - }, - }), -})); +jest.mock('../../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../../util/theme'); + return { + useTheme: () => mockTheme, + }; +}); // Mock useTailwind jest.mock('@metamask/design-system-twrnc-preset', () => ({ diff --git a/app/components/UI/Rewards/components/Tabs/LevelsTab/UpcomingRewards.test.tsx b/app/components/UI/Rewards/components/Tabs/LevelsTab/UpcomingRewards.test.tsx index acc54d03f5c..0ac81bd066b 100644 --- a/app/components/UI/Rewards/components/Tabs/LevelsTab/UpcomingRewards.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/LevelsTab/UpcomingRewards.test.tsx @@ -125,12 +125,15 @@ const mockSelectSeasonStartDate = selectSeasonStartDate as jest.MockedFunction< >; // Mock theme -jest.mock('../../../../../../util/theme', () => ({ - useTheme: () => ({ - themeAppearance: 'light', - brandColors: { grey700: '#374151' }, - }), -})); +jest.mock('../../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../../util/theme'); + return { + useTheme: () => ({ + themeAppearance: 'light', + brandColors: mockTheme.brandColors, + }), + }; +}); // Mock i18n jest.mock('../../../../../../../locales/i18n', () => ({ diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.test.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.test.tsx index b6739038729..dd9aedcbd29 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.test.tsx @@ -161,6 +161,14 @@ const mockFormatTimeRemaining = jest.requireMock( '../../../utils/formatUtils', ).formatTimeRemaining; +/* eslint-disable @metamask/design-tokens/color-no-hex -- domain-specific mock API colors */ +const MOCK_BOOST_COLORS = { + swap: '#FF6B35', + seasonLong: '#4A90E2', + noEndDate: '#50C878', +} as const; +/* eslint-enable @metamask/design-tokens/color-no-hex */ + // Mock React Native components jest.mock('react-native', () => { const RN = jest.requireActual('react-native'); @@ -202,7 +210,7 @@ const mockBoost: PointsBoostDto = { seasonLong: false, startDate: '2024-01-01', endDate: '2024-12-31', - backgroundColor: '#FF6B35', + backgroundColor: MOCK_BOOST_COLORS.swap, }; const mockSeasonLongBoost: PointsBoostDto = { @@ -214,7 +222,7 @@ const mockSeasonLongBoost: PointsBoostDto = { }, boostBips: 1000, seasonLong: true, - backgroundColor: '#4A90E2', + backgroundColor: MOCK_BOOST_COLORS.seasonLong, }; const mockBoostWithoutEndDate: PointsBoostDto = { @@ -226,7 +234,7 @@ const mockBoostWithoutEndDate: PointsBoostDto = { }, boostBips: 250, seasonLong: false, - backgroundColor: '#50C878', + backgroundColor: MOCK_BOOST_COLORS.noEndDate, }; describe('ActiveBoosts', () => { diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/BonusCodeBottomSheet.test.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/BonusCodeBottomSheet.test.tsx index 7652d36b09f..0d59b9412af 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/BonusCodeBottomSheet.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/BonusCodeBottomSheet.test.tsx @@ -120,9 +120,12 @@ jest.mock('../../../../hooks/useRewardsToast', () => ({ }), })); -jest.mock('../../../../../../../util/theme', () => ({ - useTheme: () => ({ colors: { icon: { default: '#000000' } } }), -})); +jest.mock('../../../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../../../util/theme'); + return { + useTheme: () => mockTheme, + }; +}); const mockUseValidateBonusCode = useValidateBonusCode as jest.MockedFunction< typeof useValidateBonusCode diff --git a/app/components/UI/Rewards/components/ThemeImageComponent/RewardsThemeImageComponent.test.tsx b/app/components/UI/Rewards/components/ThemeImageComponent/RewardsThemeImageComponent.test.tsx index b18572fe4e6..7332aebbe0b 100644 --- a/app/components/UI/Rewards/components/ThemeImageComponent/RewardsThemeImageComponent.test.tsx +++ b/app/components/UI/Rewards/components/ThemeImageComponent/RewardsThemeImageComponent.test.tsx @@ -22,6 +22,7 @@ jest.mock('../../../../../util/theme', () => ({ })); import RewardsThemeImageComponent from './RewardsThemeImageComponent'; +const { mockTheme } = jest.requireActual('../../../../../util/theme'); // Helper function to render with Redux Provider const renderWithProvider = (component: React.ReactElement) => @@ -56,21 +57,14 @@ describe('RewardsThemeImageComponent', () => { darkModeUrl: 'https://example.com/dark.png', }; - const mockTheme = { - colors: { - primary: { - default: '#037DD6', - }, - }, + const mockRewardsTheme = { + ...mockTheme, themeAppearance: 'light', - brandColors: {}, - typography: {}, - shadows: {}, } as any; beforeEach(() => { jest.clearAllMocks(); - mockUseTheme.mockReturnValue(mockTheme); + mockUseTheme.mockReturnValue(mockRewardsTheme); }); it('renders Image and ActivityIndicator on initial render', () => { diff --git a/app/components/UI/Rewards/hooks/useActivePointsBoosts.test.ts b/app/components/UI/Rewards/hooks/useActivePointsBoosts.test.ts index dc69ae51889..f180e62a9d2 100644 --- a/app/components/UI/Rewards/hooks/useActivePointsBoosts.test.ts +++ b/app/components/UI/Rewards/hooks/useActivePointsBoosts.test.ts @@ -51,6 +51,13 @@ jest.mock('@react-navigation/native', () => ({ useFocusEffect: jest.fn(), })); +/* eslint-disable @metamask/design-tokens/color-no-hex -- domain-specific mock API colors */ +const MOCK_ACTIVE_BOOST_COLORS = { + primary: '#FF0000', + secondary: '#00FF00', +} as const; +/* eslint-enable @metamask/design-tokens/color-no-hex */ + describe('useActivePointsBoosts', () => { const mockDispatch = jest.fn(); const mockUseFocusEffect = useFocusEffect as jest.MockedFunction< @@ -77,7 +84,7 @@ describe('useActivePointsBoosts', () => { }, boostBips: 1000, seasonLong: true, - backgroundColor: '#FF0000', + backgroundColor: MOCK_ACTIVE_BOOST_COLORS.primary, }, { id: 'boost-2', @@ -90,7 +97,7 @@ describe('useActivePointsBoosts', () => { seasonLong: false, startDate: '2024-01-01', endDate: '2024-01-31', - backgroundColor: '#00FF00', + backgroundColor: MOCK_ACTIVE_BOOST_COLORS.secondary, }, ]; diff --git a/app/components/UI/TokenDetails/hooks/useTokenBalance.test.ts b/app/components/UI/TokenDetails/hooks/useTokenBalance.test.ts index c200b5e18e4..9e5a51bf45c 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenBalance.test.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenBalance.test.ts @@ -3,12 +3,12 @@ import { useTokenBalance } from './useTokenBalance'; import { TokenI } from '../../Tokens/types'; import { selectAsset, - selectTronResourcesBySelectedAccountGroup, - TronResourcesMap, + selectTronSpecialAssetsBySelectedAccountGroup, + TronSpecialAssetsMap, } from '../../../../selectors/assets/assets-list'; import { createStakedTrxAsset } from '../../AssetOverview/utils/createStakedTrxAsset'; -const createEmptyResourcesMap = (): TronResourcesMap => ({ +const createEmptySpecialAssetsMap = (): TronSpecialAssetsMap => ({ energy: undefined, bandwidth: undefined, maxEnergy: undefined, @@ -16,12 +16,15 @@ const createEmptyResourcesMap = (): TronResourcesMap => ({ stakedTrxForEnergy: undefined, stakedTrxForBandwidth: undefined, totalStakedTrx: 0, + trxReadyForWithdrawal: undefined, + trxStakingRewards: undefined, + trxInLockPeriod: undefined, }); jest.mock('../../../../selectors/assets/assets-list', () => ({ selectAsset: jest.fn(), - selectTronResourcesBySelectedAccountGroup: jest.fn( - (): TronResourcesMap => ({ + selectTronSpecialAssetsBySelectedAccountGroup: jest.fn( + (): TronSpecialAssetsMap => ({ energy: undefined, bandwidth: undefined, maxEnergy: undefined, @@ -29,6 +32,9 @@ jest.mock('../../../../selectors/assets/assets-list', () => ({ stakedTrxForEnergy: undefined, stakedTrxForBandwidth: undefined, totalStakedTrx: 0, + trxReadyForWithdrawal: undefined, + trxStakingRewards: undefined, + trxInLockPeriod: undefined, }), ), })); @@ -39,14 +45,14 @@ jest.mock('../../AssetOverview/utils/createStakedTrxAsset', () => ({ const mockSelectAsset = jest.mocked(selectAsset); const mockSelectTronResources = jest.mocked( - selectTronResourcesBySelectedAccountGroup, + selectTronSpecialAssetsBySelectedAccountGroup, ); const mockCreateStakedTrxAsset = jest.mocked(createStakedTrxAsset); describe('useTokenBalance', () => { beforeEach(() => { jest.clearAllMocks(); - mockSelectTronResources.mockReturnValue(createEmptyResourcesMap()); + mockSelectTronResources.mockReturnValue(createEmptySpecialAssetsMap()); }); afterEach(() => { @@ -119,10 +125,10 @@ describe('useTokenBalance', () => { } as TokenI); mockSelectTronResources.mockReturnValue({ - ...createEmptyResourcesMap(), + ...createEmptySpecialAssetsMap(), stakedTrxForEnergy: { symbol: 'strx-energy', balance: '100' }, stakedTrxForBandwidth: { symbol: 'strx-bandwidth', balance: '200' }, - } as TronResourcesMap); + } as TronSpecialAssetsMap); mockCreateStakedTrxAsset.mockReturnValue(mockStakedAsset); diff --git a/app/components/UI/TokenDetails/hooks/useTokenBalance.ts b/app/components/UI/TokenDetails/hooks/useTokenBalance.ts index 11fc6171b22..c036b368ab4 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenBalance.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenBalance.ts @@ -5,7 +5,7 @@ import { TokenI } from '../../Tokens/types'; import { selectAsset, ///: BEGIN:ONLY_INCLUDE_IF(tron) - selectTronResourcesBySelectedAccountGroup, + selectTronSpecialAssetsBySelectedAccountGroup, ///: END:ONLY_INCLUDE_IF } from '../../../../selectors/assets/assets-list'; import { toFormattedAddress } from '../../../../util/address'; @@ -34,7 +34,7 @@ export const useTokenBalance = (token: TokenI): UseTokenBalanceResult => { ///: BEGIN:ONLY_INCLUDE_IF(tron) const { stakedTrxForEnergy, stakedTrxForBandwidth } = useSelector( - selectTronResourcesBySelectedAccountGroup, + selectTronSpecialAssetsBySelectedAccountGroup, ); const isTronNative = diff --git a/app/components/UI/Trending/components/FilterBar/FilterBar.tsx b/app/components/UI/Trending/components/FilterBar/FilterBar.tsx index 3ecb916d3a5..7ba1fdcaddb 100644 --- a/app/components/UI/Trending/components/FilterBar/FilterBar.tsx +++ b/app/components/UI/Trending/components/FilterBar/FilterBar.tsx @@ -8,7 +8,7 @@ import Icon, { } from '../../../../../component-library/components/Icons/Icon'; import Text from '../../../../../component-library/components/Texts/Text'; -interface FilterButtonProps { +export interface FilterButtonProps { testID: string; label: string; onPress: () => void; @@ -17,9 +17,11 @@ interface FilterButtonProps { ellipsizeMode?: 'tail' | 'head' | 'middle' | 'clip'; /** Extra horizontal padding (px-3) vs default (p-2) */ wide?: boolean; + /** Optional Tailwind class overrides for layout in custom contexts */ + twClassName?: string; } -const FilterButton: React.FC = ({ +export const FilterButton: React.FC = ({ testID, label, onPress, @@ -27,6 +29,7 @@ const FilterButton: React.FC = ({ numberOfLines, ellipsizeMode, wide = false, + twClassName, }) => { const tw = useTailwind(); @@ -38,6 +41,7 @@ const FilterButton: React.FC = ({ 'min-w-0 shrink items-center rounded-lg bg-muted', wide ? 'py-2 px-3' : 'p-2', disabled && 'opacity-50', + twClassName, )} activeOpacity={0.2} disabled={disabled} diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx index 12564d914ef..731fd11b17c 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx @@ -487,8 +487,8 @@ describe('TrendingTokenNetworkBottomSheet', () => { expect(queryByTestId('bottom-sheet')).toBeNull(); }); - it('calls onOpenBottomSheet when isVisible becomes true', () => { - const { rerender } = renderWithProvider( + it('renders when isVisible becomes true', () => { + const { rerender, queryByTestId } = renderWithProvider( { false, ); - expect(mockOnOpenBottomSheet).not.toHaveBeenCalled(); + expect(queryByTestId('bottom-sheet')).toBeNull(); rerender( { />, ); - expect(mockOnOpenBottomSheet).toHaveBeenCalled(); + expect(queryByTestId('bottom-sheet')).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx index 06eca9eaa0e..7f341f1ea91 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx @@ -55,13 +55,6 @@ const TrendingTokenNetworkBottomSheet: React.FC< } }, [initialSelectedNetwork]); - // Open bottom sheet when isVisible becomes true - useEffect(() => { - if (isVisible) { - sheetRef.current?.onOpenBottomSheet(); - } - }, [isVisible]); - const optionStyles = StyleSheet.create({ optionsList: { paddingBottom: 16, diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.test.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.test.tsx index d2e5499c653..8563bc918dc 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.test.tsx +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.test.tsx @@ -285,21 +285,21 @@ describe('TrendingTokenPriceChangeBottomSheet', () => { expect(getByText('Low to high')).toBeOnTheScreen(); }); - it('calls onOpenBottomSheet when isVisible becomes true', () => { - const { rerender } = render( + it('renders when isVisible becomes true', () => { + const { rerender, queryByTestId } = render( , ); - expect(mockOnOpenBottomSheet).not.toHaveBeenCalled(); + expect(queryByTestId('bottom-sheet')).toBeNull(); rerender( , ); - expect(mockOnOpenBottomSheet).toHaveBeenCalled(); + expect(queryByTestId('bottom-sheet')).toBeOnTheScreen(); }); it('selects MarketCap option when pressed', () => { const { getByText } = render( diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.tsx index aaa4a798e76..f0e2b3d7192 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.tsx +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.tsx @@ -75,13 +75,6 @@ const TrendingTokenPriceChangeBottomSheet: React.FC< } }, [initialSelectedOption, initialSortDirection, isVisible]); - // Open bottom sheet when isVisible becomes true - useEffect(() => { - if (isVisible) { - sheetRef.current?.onOpenBottomSheet(); - } - }, [isVisible]); - const optionStyles = StyleSheet.create({ optionsList: { paddingBottom: 24, diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.test.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.test.tsx index 2316f3c830c..8056c509982 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.test.tsx +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.test.tsx @@ -299,15 +299,15 @@ describe('TrendingTokenTimeBottomSheet', () => { expect(getByTestId('icon-Check')).toBeOnTheScreen(); }); - it('calls onOpenBottomSheet when isVisible becomes true', () => { - const { rerender } = render( + it('renders when isVisible becomes true', () => { + const { rerender, queryByTestId } = render( , ); - expect(mockOnOpenBottomSheet).not.toHaveBeenCalled(); + expect(queryByTestId('bottom-sheet')).toBeNull(); rerender(); - expect(mockOnOpenBottomSheet).toHaveBeenCalled(); + expect(queryByTestId('bottom-sheet')).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.tsx index e71fde2e326..3c8fd0c43df 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.tsx +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.tsx @@ -32,7 +32,7 @@ export interface TrendingTokenTimeBottomSheetProps { /** * Maps TimeOption to SortTrendingBy */ -const mapTimeOptionToSortBy = (option: TimeOption): SortTrendingBy => { +export const mapTimeOptionToSortBy = (option: TimeOption): SortTrendingBy => { switch (option) { case TimeOption.TwentyFourHours: return 'h24_trending' as SortTrendingBy; @@ -89,13 +89,6 @@ const TrendingTokenTimeBottomSheet: React.FC< } }, [initialSelectedTime]); - // Open bottom sheet when isVisible becomes true - useEffect(() => { - if (isVisible) { - sheetRef.current?.onOpenBottomSheet(); - } - }, [isVisible]); - const optionStyles = StyleSheet.create({ optionsList: { paddingBottom: 16, diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/index.ts b/app/components/UI/Trending/components/TrendingTokensBottomSheet/index.ts index 59892b12476..f80c9b072e9 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/index.ts +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/index.ts @@ -2,6 +2,7 @@ export { TrendingTokenTimeBottomSheet, TimeOption, mapSortByToTimeOption, + mapTimeOptionToSortBy, type TrendingTokenTimeBottomSheetProps, } from './TrendingTokenTimeBottomSheet'; diff --git a/app/components/Views/Homepage/Homepage.tsx b/app/components/Views/Homepage/Homepage.tsx index b729a8ee1f4..46136441bc4 100644 --- a/app/components/Views/Homepage/Homepage.tsx +++ b/app/components/Views/Homepage/Homepage.tsx @@ -17,6 +17,7 @@ import { selectPerpsEnabledFlag } from '../../UI/Perps'; import { selectPredictEnabledFlag } from '../../UI/Predict/selectors/featureFlags'; import { selectAssetsDefiPositionsEnabled } from '../../../selectors/featureFlagController/assetsDefiPositions'; import { HomeSectionNames, HomeSectionName } from './hooks/useHomeViewedEvent'; +import useHomeSessionSummary from './hooks/useHomeSessionSummary'; /** * Homepage component - Main view for the redesigned wallet homepage. @@ -53,6 +54,8 @@ const Homepage = forwardRef((_, ref) => { const totalSectionsLoaded = enabledSections.length; + useHomeSessionSummary({ totalSectionsLoaded }); + const getSectionIndex = useCallback( (name: HomeSectionName) => enabledSections.findIndex((s) => s.name === name), diff --git a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx index 9c5ee0a1197..4d4fd194854 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { screen, fireEvent, act } from '@testing-library/react-native'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; -import PerpsSection from './PerpsSection'; +import PerpsSection, { positionDisplayKey } from './PerpsSection'; import Routes from '../../../../../constants/navigation/Routes'; import { MetaMetricsEvents } from '../../../../../core/Analytics/MetaMetrics.events'; import { @@ -241,6 +241,73 @@ jest.mock('../../hooks/useHomeViewedEvent', () => ({ }, })); +describe('positionDisplayKey', () => { + it('returns stable key from position display fields', () => { + const position = makePosition({ + symbol: 'BTC', + entryPrice: '98500', + size: '-0.0015', + unrealizedPnl: '9.4', + takeProfitPrice: undefined, + stopLossPrice: undefined, + }) as Parameters[0]; + expect(positionDisplayKey(position)).toBe('BTC:98500:-0.0015:9.4::'); + }); + + it('uses empty string for undefined optional fields', () => { + const position = makePosition({ + symbol: 'ETH', + entryPrice: undefined, + size: '1', + unrealizedPnl: undefined, + takeProfitPrice: undefined, + stopLossPrice: undefined, + }) as Parameters[0]; + expect(positionDisplayKey(position)).toBe('ETH::1:::'); + }); + + it('includes takeProfitPrice and stopLossPrice when set', () => { + const position = makePosition({ + symbol: 'SOL', + entryPrice: '180', + size: '10', + unrealizedPnl: '-5', + takeProfitPrice: '200', + stopLossPrice: '160', + }) as Parameters[0]; + expect(positionDisplayKey(position)).toBe('SOL:180:10:-5:200:160'); + }); + + it('returns different keys when display-relevant fields differ', () => { + const base = makePosition({ symbol: 'BTC' }) as Parameters< + typeof positionDisplayKey + >[0]; + const withPnl = makePosition({ + symbol: 'BTC', + unrealizedPnl: '100', + }) as Parameters[0]; + expect(positionDisplayKey(base)).not.toBe(positionDisplayKey(withPnl)); + }); + + it('returns same key when only non-display fields differ', () => { + const a = makePosition({ + symbol: 'BTC', + entryPrice: '50000', + size: '1', + unrealizedPnl: '100', + positionValue: '50000', + }) as Parameters[0]; + const b = makePosition({ + symbol: 'BTC', + entryPrice: '50000', + size: '1', + unrealizedPnl: '100', + positionValue: '99999', + }) as Parameters[0]; + expect(positionDisplayKey(a)).toBe(positionDisplayKey(b)); + }); +}); + describe('PerpsSection', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx index 680f000973c..fad25d38c2d 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx +++ b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx @@ -52,6 +52,41 @@ const MAX_ITEMS = 5; const MAX_TRENDING_MARKETS = 5; const HOMEPAGE_THROTTLE_MS = 5000; +/** Key fields that affect position card display; skip re-render if unchanged. Exported for testing. */ +export function positionDisplayKey(p: Position): string { + return `${p.symbol}:${p.entryPrice ?? ''}:${p.size ?? ''}:${p.unrealizedPnl ?? ''}:${p.takeProfitPrice ?? ''}:${p.stopLossPrice ?? ''}`; +} + +/** + * Memoized row so only the position card whose data changed re-renders on stream updates. + */ +const PositionCardItem = React.memo<{ + position: Position; + tpSlLoading: boolean; + onPositionPress: (position: Position) => void; +}>( + ({ position, tpSlLoading, onPositionPress }) => { + const handlePress = useCallback( + () => onPositionPress(position), + [onPositionPress, position], + ); + return ( + + ); + }, + (prev, next) => + prev.tpSlLoading === next.tpSlLoading && + prev.onPositionPress === next.onPositionPress && + positionDisplayKey(prev.position) === positionDisplayKey(next.position), +); + /** * PerpsSection — single "Perpetuals" section on the homepage. * @@ -172,7 +207,7 @@ const PerpsSection = forwardRef( const allCarouselMarkets = useMemo( () => - [...watchlistMarkets, ...trendingMarkets].slice( + [...(watchlistMarkets ?? []), ...(trendingMarkets ?? [])].slice( 0, MAX_TRENDING_MARKETS, ), @@ -180,12 +215,13 @@ const PerpsSection = forwardRef( ); const watchlistSymbolSet = useMemo( - () => new Set(watchlistMarkets.map((m) => m.symbol)), + () => new Set((watchlistMarkets ?? []).map((m) => m.symbol)), [watchlistMarkets], ); const carouselSymbols = useMemo( - () => (showTrending ? allCarouselMarkets.map((m) => m.symbol) : []), + () => + showTrending ? (allCarouselMarkets ?? []).map((m) => m.symbol) : [], [showTrending, allCarouselMarkets], ); const { sparklines, refresh: refreshSparklines } = @@ -298,14 +334,11 @@ const PerpsSection = forwardRef( {displayPositions.map((position) => ( - handlePositionPress(position)} - testID={`perps-position-row-${position.symbol}`} + onPositionPress={handlePositionPress} /> ))} {displayOrders.map((order) => ( @@ -327,7 +360,7 @@ const PerpsSection = forwardRef( testID="homepage-trending-perps-carousel" {...scrollProps} > - {allCarouselMarkets.map((market) => ( + {(allCarouselMarkets ?? []).map((market) => ( { }; }); -jest.mock('../../../../../../UI/Perps/hooks/stream', () => ({ - usePerpsLivePrices: jest.fn(() => ({})), -})); - -const { usePerpsLivePrices } = jest.requireMock( - '../../../../../../UI/Perps/hooks/stream', -); -const mockUsePerpsLivePrices = usePerpsLivePrices as jest.MockedFunction< - typeof usePerpsLivePrices ->; - jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), useSelector: jest.fn(() => false), @@ -120,7 +109,6 @@ describe('PerpsMarketTileCard', () => { beforeEach(() => { jest.clearAllMocks(); - mockUsePerpsLivePrices.mockReturnValue({}); }); it('renders market symbol and leverage', () => { @@ -188,23 +176,7 @@ describe('PerpsMarketTileCard', () => { expect(mockOnPress).toHaveBeenCalledWith(mockMarketData); }); - it('uses live percentage change when available', () => { - mockUsePerpsLivePrices.mockReturnValue({ - BTC: { - price: '55000', - percentChange24h: '5.50', - volume24h: 3000000000, - }, - }); - - render(); - - expect(screen.getByText('+5.50%')).toBeOnTheScreen(); - }); - - it('falls back to market data when no live prices', () => { - mockUsePerpsLivePrices.mockReturnValue({}); - + it('displays market change24hPercent', () => { render(); expect(screen.getByText('+4.00%')).toBeOnTheScreen(); diff --git a/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsMarketTileCard/PerpsMarketTileCard.tsx b/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsMarketTileCard/PerpsMarketTileCard.tsx index 53eb5d43b2e..f600bd69166 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsMarketTileCard/PerpsMarketTileCard.tsx +++ b/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsMarketTileCard/PerpsMarketTileCard.tsx @@ -11,12 +11,7 @@ import { IconColor, } from '@metamask/design-system-react-native'; import { useStyles } from '../../../../../../hooks/useStyles'; -import { - getPerpsDisplaySymbol, - type PriceUpdate, -} from '@metamask/perps-controller'; -import { usePerpsLivePrices } from '../../../../../../UI/Perps/hooks/stream'; -import { formatPercentage } from '../../../../../../UI/Perps/utils/formatUtils'; +import { getPerpsDisplaySymbol } from '@metamask/perps-controller'; import PerpsLeverage from '../../../../../../UI/Perps/components/PerpsLeverage/PerpsLeverage'; import PerpsTokenLogo from '../../../../../../UI/Perps/components/PerpsTokenLogo'; import SparklineChart from '../SparklineChart'; @@ -29,42 +24,29 @@ const SPARKLINE_HEIGHT = 80; const SPARKLINE_STROKE_WIDTH = 2; const TOKEN_LOGO_SIZE = 40; const SHIMMER_PULSE_DURATION = 900; -const LIVE_PRICES_THROTTLE_MS = 3000; - -const EMPTY_PRICES: Record = {}; /** - * Inner tile card that accepts pre-resolved live prices. - * Extracted so it doesn't depend on stream context. + * PerpsMarketTileCard — compact card for horizontal carousels. + * Uses static market data only (no live price subscription). */ -const TileCardInner: React.FC< - PerpsMarketTileCardProps & { livePrices: Record } -> = ({ +const PerpsMarketTileCard: React.FC = ({ market, sparklineData, onPress, cardWidth = DEFAULT_CARD_WIDTH, cardHeight = DEFAULT_CARD_HEIGHT, - livePrices, showFavoriteTag = false, testID = 'perps-market-tile-card', }) => { const { styles, theme } = useStyles(styleSheet, { cardWidth, cardHeight }); - const { changePercent, isPositive } = useMemo(() => { - const livePrice = livePrices[market.symbol]; - - let percent = market.change24hPercent; - if (livePrice?.percentChange24h) { - const changeVal = parseFloat(livePrice.percentChange24h); - percent = formatPercentage(changeVal); - } - - return { - changePercent: percent, - isPositive: !percent.startsWith('-'), - }; - }, [market, livePrices]); + const { changePercent, isPositive } = useMemo( + () => ({ + changePercent: market.change24hPercent, + isPositive: !market.change24hPercent.startsWith('-'), + }), + [market.change24hPercent], + ); const sparklineColor = isPositive ? theme.colors.success.default @@ -197,29 +179,4 @@ const TileCardInner: React.FC< ); }; -/** - * Wrapper that subscribes to live prices via the stream provider. - * Only used when disableLivePrices is false (default). - */ -const TileCardWithLivePrices: React.FC = (props) => { - const livePrices = usePerpsLivePrices({ - symbols: [props.market.symbol], - throttleMs: LIVE_PRICES_THROTTLE_MS, - }); - return ; -}; - -/** - * PerpsMarketTileCard — compact card for horizontal carousels. - * - * When disableLivePrices is true, uses static market data and skips the - * WebSocket stream subscription (safe to use outside PerpsStreamProvider). - */ -const PerpsMarketTileCard: React.FC = (props) => { - if (props.disableLivePrices) { - return ; - } - return ; -}; - export default React.memo(PerpsMarketTileCard); diff --git a/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsMarketTileCard/PerpsMarketTileCard.types.ts b/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsMarketTileCard/PerpsMarketTileCard.types.ts index 6939a678688..e29b59f230c 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsMarketTileCard/PerpsMarketTileCard.types.ts +++ b/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsMarketTileCard/PerpsMarketTileCard.types.ts @@ -11,8 +11,6 @@ export interface PerpsMarketTileCardProps { cardWidth?: number; /** Card height in pixels (default: 180) */ cardHeight?: number; - /** Skip live price WebSocket subscription (use static market data instead) */ - disableLivePrices?: boolean; /** Show a "Favorite" tag */ showFavoriteTag?: boolean; /** Test ID for E2E testing */ diff --git a/app/components/Views/Homepage/Sections/Perpetuals/hooks/useHomepageSparklines.test.ts b/app/components/Views/Homepage/Sections/Perpetuals/hooks/useHomepageSparklines.test.ts index e1c573d4363..4cddbeffd8a 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/hooks/useHomepageSparklines.test.ts +++ b/app/components/Views/Homepage/Sections/Perpetuals/hooks/useHomepageSparklines.test.ts @@ -57,7 +57,7 @@ describe('useHomepageSparklines', () => { ); }); - it('returns downsampled close prices when callback fires', () => { + it('returns downsampled close prices when callback fires', async () => { mockSubscribe.mockImplementation( (params: { callback: (candleData: CandleData) => void }) => { params.callback({ @@ -71,6 +71,9 @@ describe('useHomepageSparklines', () => { const { result } = renderHook(() => useHomepageSparklines(['BTC'])); + // Sparkline updates are batched via queueMicrotask — flush with async act. + await act(async () => null); + expect(result.current.sparklines.BTC).toBeDefined(); expect(result.current.sparklines.BTC.length).toBe(50); }); @@ -109,7 +112,7 @@ describe('useHomepageSparklines', () => { expect(mockSubscribe).not.toHaveBeenCalled(); }); - it('accumulates sparklines from multiple symbol callbacks', () => { + it('accumulates sparklines from multiple symbol callbacks', async () => { const callbacks: Record void> = {}; mockSubscribe.mockImplementation( (params: { @@ -123,7 +126,7 @@ describe('useHomepageSparklines', () => { const { result } = renderHook(() => useHomepageSparklines(['BTC', 'ETH'])); - act(() => { + await act(async () => { callbacks.BTC({ symbol: 'BTC', interval: CandlePeriod.FifteenMinutes, @@ -134,7 +137,7 @@ describe('useHomepageSparklines', () => { expect(result.current.sparklines.BTC).toBeDefined(); expect(result.current.sparklines.ETH).toBeUndefined(); - act(() => { + await act(async () => { callbacks.ETH({ symbol: 'ETH', interval: CandlePeriod.FifteenMinutes, @@ -146,7 +149,7 @@ describe('useHomepageSparklines', () => { expect(result.current.sparklines.ETH).toBeDefined(); }); - it('refresh clears data and resubscribes', () => { + it('refresh clears data and resubscribes', async () => { const callbacks: Record void> = {}; mockSubscribe.mockImplementation( (params: { @@ -160,7 +163,7 @@ describe('useHomepageSparklines', () => { const { result } = renderHook(() => useHomepageSparklines(['BTC'])); - act(() => { + await act(async () => { callbacks.BTC({ symbol: 'BTC', interval: CandlePeriod.FifteenMinutes, @@ -170,7 +173,7 @@ describe('useHomepageSparklines', () => { expect(result.current.sparklines.BTC).toBeDefined(); - act(() => { + await act(async () => { result.current.refresh(); }); diff --git a/app/components/Views/Homepage/Sections/Perpetuals/hooks/useHomepageSparklines.ts b/app/components/Views/Homepage/Sections/Perpetuals/hooks/useHomepageSparklines.ts index 73ea016798e..706ca25ad46 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/hooks/useHomepageSparklines.ts +++ b/app/components/Views/Homepage/Sections/Perpetuals/hooks/useHomepageSparklines.ts @@ -36,36 +36,52 @@ function extractCloses(candleData: CandleData): number[] { * Uses the existing CandleStreamChannel which handles caching, ref-counting, * and reconnection automatically. * + * Candle callbacks arrive independently per symbol. A microtask-based flush + * coalesces rapid-fire arrivals into a single React state update so the + * parent component re-renders once instead of N times (one per symbol). + * * @param symbols - Market symbols to fetch sparklines for */ export function useHomepageSparklines( symbols: string[], ): UseHomepageSparklinesResult { + const safeSymbols = useMemo(() => symbols ?? [], [symbols]); const stream = usePerpsStream(); const [sparklines, setSparklines] = useState>({}); const dataRef = useRef>({}); + const flushScheduledRef = useRef(false); const [refreshKey, setRefreshKey] = useState(0); // Stable string key so the effect doesn't re-run on every render - // when the caller produces a new array reference with the same contents. - const symbolsKey = useMemo(() => symbols.join(','), [symbols]); + const symbolsKey = useMemo(() => safeSymbols.join(','), [safeSymbols]); useEffect(() => { if (!symbolsKey) return undefined; dataRef.current = {}; + flushScheduledRef.current = false; setSparklines({}); + const scheduleFlush = () => { + if (flushScheduledRef.current) return; + flushScheduledRef.current = true; + queueMicrotask(() => { + flushScheduledRef.current = false; + setSparklines({ ...dataRef.current }); + }); + }; + const unsubscribes: (() => void)[] = []; + const syms = symbolsKey.split(',').filter(Boolean); - for (const symbol of symbols) { + for (const symbol of syms) { const unsubscribe = stream.candles.subscribe({ symbol, interval: CandlePeriod.FifteenMinutes, duration: TimeDuration.OneDay, callback: (candleData: CandleData) => { if (dataRef.current[symbol]) return; - if (!candleData || candleData.candles.length < 2) return; + if (!candleData?.candles || candleData.candles.length < 2) return; const closes = extractCloses(candleData); if (closes.length < 2) return; @@ -74,7 +90,7 @@ export function useHomepageSparklines( ...dataRef.current, [symbol]: downsample(closes, SPARKLINE_TARGET_POINTS), }; - setSparklines({ ...dataRef.current }); + scheduleFlush(); }, }); unsubscribes.push(unsubscribe); diff --git a/app/components/Views/Homepage/context/HomepageScrollContext.ts b/app/components/Views/Homepage/context/HomepageScrollContext.ts index 17135e40d5a..294683f9450 100644 --- a/app/components/Views/Homepage/context/HomepageScrollContext.ts +++ b/app/components/Views/Homepage/context/HomepageScrollContext.ts @@ -1,4 +1,5 @@ import { createContext, useContext } from 'react'; +import type { HomeSectionName } from '../hooks/useHomeViewedEvent'; export const HomepageEntryPoints = { APP_OPENED: 'app_opened', @@ -36,6 +37,16 @@ interface HomepageScrollContextValue { * this to reset their "has fired" state and re-fire on every visit. */ visitId: number; + /** + * Called by each section immediately after its section_viewed event fires. + * Used to aggregate the total number of distinct sections viewed this visit. + */ + notifySectionViewed: (sectionName: HomeSectionName) => void; + /** + * Returns the number of distinct sections viewed during the current visit. + * Intended for use in the session_summary event fired on blur. + */ + getViewedSectionCount: () => number; } const noop = () => () => { @@ -48,6 +59,8 @@ const defaultValue: HomepageScrollContextValue = { containerScreenY: 0, entryPoint: HomepageEntryPoints.APP_OPENED, visitId: 0, + notifySectionViewed: () => undefined, + getViewedSectionCount: () => 0, }; export const HomepageScrollContext = diff --git a/app/components/Views/Homepage/hooks/useHomeSessionSummary.test.ts b/app/components/Views/Homepage/hooks/useHomeSessionSummary.test.ts new file mode 100644 index 00000000000..76375cd3171 --- /dev/null +++ b/app/components/Views/Homepage/hooks/useHomeSessionSummary.test.ts @@ -0,0 +1,340 @@ +import { useFocusEffect } from '@react-navigation/native'; +import { renderHook, act } from '@testing-library/react-hooks'; +import useHomeSessionSummary from './useHomeSessionSummary'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; + +// --- @react-navigation/native mock --- +jest.mock('@react-navigation/native', () => ({ + useFocusEffect: jest.fn(), +})); + +const mockUseFocusEffect = useFocusEffect as jest.MockedFunction< + typeof useFocusEffect +>; + +// --- Analytics mock --- +const mockTrackEvent = jest.fn(); +const mockBuild = jest.fn(() => ({ builtEvent: true })); +const mockAddProperties = jest.fn(() => ({ build: mockBuild })); +const mockCreateEventBuilder = jest.fn(() => ({ + addProperties: mockAddProperties, +})); + +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +// --- Scroll context mock --- +const HomepageEntryPoints = { + APP_OPENED: 'app_opened', + HOME_TAB: 'home_tab', + NAVIGATED_BACK: 'navigated_back', +} as const; + +let mockGetViewedSectionCount = jest.fn(() => 3); +let mockNotifySectionViewed = jest.fn(); + +let mockContextValue = { + subscribeToScroll: jest.fn(() => jest.fn()), + viewportHeight: 800, + containerScreenY: 0, + entryPoint: HomepageEntryPoints.APP_OPENED as string, + visitId: 1, + notifySectionViewed: mockNotifySectionViewed, + getViewedSectionCount: mockGetViewedSectionCount, +}; + +jest.mock('../context/HomepageScrollContext', () => ({ + useHomepageScrollContext: () => mockContextValue, + HomepageEntryPoints: { + APP_OPENED: 'app_opened', + HOME_TAB: 'home_tab', + NAVIGATED_BACK: 'navigated_back', + }, +})); + +// --- Helpers --- + +/** Type for addProperties(firstArg) so mock.calls is safely indexable after toHaveBeenCalled(). */ +type AddPropertiesCall = [Record]; + +/** + * Simulate useFocusEffect: call the callback (focus) and return a function + * that invokes the cleanup (blur). + */ +const setupFocusBlur = () => { + let blurCleanup: (() => void) | undefined; + mockUseFocusEffect.mockImplementation((callback) => { + blurCleanup = (callback as () => (() => void) | undefined)(); + }); + return { + simulateBlur: () => blurCleanup?.(), + }; +}; + +describe('useHomeSessionSummary', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetViewedSectionCount = jest.fn(() => 3); + mockNotifySectionViewed = jest.fn(); + mockContextValue = { + subscribeToScroll: jest.fn(() => jest.fn()), + viewportHeight: 800, + containerScreenY: 0, + entryPoint: HomepageEntryPoints.APP_OPENED, + visitId: 1, + notifySectionViewed: mockNotifySectionViewed, + getViewedSectionCount: mockGetViewedSectionCount, + }; + }); + + describe('blur guard — visitId === 0', () => { + it('does not fire when visitId is 0 (pre-focus state)', () => { + mockContextValue = { ...mockContextValue, visitId: 0 }; + const { simulateBlur } = setupFocusBlur(); + + renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 })); + + act(() => { + simulateBlur(); + }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + }); + + describe('fires on blur only', () => { + it('does not fire on focus — only fires when the blur cleanup runs', () => { + // Track whether trackEvent is called during focus (render) phase + let calledDuringFocus = false; + mockUseFocusEffect.mockImplementation((callback) => { + // Call the focus callback and check if track was called before blur + (callback as () => void)(); + calledDuringFocus = mockTrackEvent.mock.calls.length > 0; + }); + + renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 })); + + expect(calledDuringFocus).toBe(false); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('fires exactly once on blur', () => { + const { simulateBlur } = setupFocusBlur(); + + renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 })); + + act(() => { + simulateBlur(); + }); + + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); + }); + + describe('event properties', () => { + it('uses the HOME_VIEWED MetaMetrics event', () => { + const { simulateBlur } = setupFocusBlur(); + + renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 })); + + act(() => { + simulateBlur(); + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HOME_VIEWED, + ); + }); + + it('fires with interaction_type: session_summary', () => { + const { simulateBlur } = setupFocusBlur(); + + renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 })); + + act(() => { + simulateBlur(); + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ interaction_type: 'session_summary' }), + ); + }); + + it('fires with location: home', () => { + const { simulateBlur } = setupFocusBlur(); + + renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 })); + + act(() => { + simulateBlur(); + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ location: 'home' }), + ); + }); + + it('uses getViewedSectionCount() for total_sections_viewed', () => { + mockGetViewedSectionCount = jest.fn(() => 4); + mockContextValue = { + ...mockContextValue, + getViewedSectionCount: mockGetViewedSectionCount, + }; + const { simulateBlur } = setupFocusBlur(); + + renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 })); + + act(() => { + simulateBlur(); + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ total_sections_viewed: 4 }), + ); + }); + + it('uses the totalSectionsLoaded prop for total_sections_loaded', () => { + const { simulateBlur } = setupFocusBlur(); + + renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 3 })); + + act(() => { + simulateBlur(); + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ total_sections_loaded: 3 }), + ); + }); + + it('uses the entry_point from context', () => { + mockContextValue = { + ...mockContextValue, + entryPoint: HomepageEntryPoints.HOME_TAB, + }; + const { simulateBlur } = setupFocusBlur(); + + renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 })); + + act(() => { + simulateBlur(); + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ entry_point: HomepageEntryPoints.HOME_TAB }), + ); + }); + + it('includes a non-negative session_time (in seconds)', () => { + const { simulateBlur } = setupFocusBlur(); + + renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 })); + + act(() => { + simulateBlur(); + }); + + expect(mockAddProperties).toHaveBeenCalled(); + const calls = mockAddProperties.mock + .calls as unknown as AddPropertiesCall[]; + const props = calls[0][0]; + expect(typeof props.session_time).toBe('number'); + expect(props.session_time).toBeGreaterThanOrEqual(0); + }); + + it('passes the built event to trackEvent', () => { + const builtEvent = { builtEvent: true }; + mockBuild.mockReturnValue(builtEvent); + const { simulateBlur } = setupFocusBlur(); + + renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 5 })); + + act(() => { + simulateBlur(); + }); + + expect(mockTrackEvent).toHaveBeenCalledWith(builtEvent); + }); + + it('fires with all required properties', () => { + mockGetViewedSectionCount = jest.fn(() => 2); + mockContextValue = { + ...mockContextValue, + entryPoint: HomepageEntryPoints.NAVIGATED_BACK, + getViewedSectionCount: mockGetViewedSectionCount, + }; + const { simulateBlur } = setupFocusBlur(); + + renderHook(() => useHomeSessionSummary({ totalSectionsLoaded: 4 })); + + act(() => { + simulateBlur(); + }); + + expect(mockAddProperties).toHaveBeenCalled(); + const calls = mockAddProperties.mock + .calls as unknown as AddPropertiesCall[]; + const props = calls[0][0]; + expect(props).toMatchObject({ + interaction_type: 'session_summary', + location: 'home', + total_sections_viewed: 2, + total_sections_loaded: 4, + entry_point: HomepageEntryPoints.NAVIGATED_BACK, + }); + expect(typeof props.session_time).toBe('number'); + }); + }); + + describe('session timer resets on new visit', () => { + it('session_time reflects time since most recent visitId change', () => { + jest.useFakeTimers(); + + const { simulateBlur } = setupFocusBlur(); + let currentVisitId = 1; + mockContextValue = { ...mockContextValue, visitId: currentVisitId }; + + const { rerender } = renderHook(() => + useHomeSessionSummary({ totalSectionsLoaded: 5 }), + ); + + // Advance 10 seconds and blur — first visit + jest.advanceTimersByTime(10_000); + + act(() => { + simulateBlur(); + }); + + const addPropertiesCalls = mockAddProperties.mock + .calls as unknown as AddPropertiesCall[]; + const firstSessionTime = addPropertiesCalls[0][0].session_time as number; + expect(firstSessionTime).toBeGreaterThanOrEqual(10); + + // Simulate new visit: visitId increments + currentVisitId = 2; + mockContextValue = { ...mockContextValue, visitId: currentVisitId }; + + act(() => { + rerender(); + }); + + // Only 2 seconds into the new visit before blur + jest.advanceTimersByTime(2_000); + + act(() => { + simulateBlur(); + }); + + const secondSessionTime = addPropertiesCalls[1][0].session_time as number; + // Second visit was only ~2 s, not ~12 s + expect(secondSessionTime).toBeLessThan(firstSessionTime); + expect(secondSessionTime).toBeGreaterThanOrEqual(2); + + jest.useRealTimers(); + }); + }); +}); diff --git a/app/components/Views/Homepage/hooks/useHomeSessionSummary.ts b/app/components/Views/Homepage/hooks/useHomeSessionSummary.ts new file mode 100644 index 00000000000..24008d7649e --- /dev/null +++ b/app/components/Views/Homepage/hooks/useHomeSessionSummary.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useFocusEffect } from '@react-navigation/native'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { useHomepageScrollContext } from '../context/HomepageScrollContext'; + +interface UseHomeSessionSummaryParams { + totalSectionsLoaded: number; +} + +/** + * Fires a `Home Viewed` Segment event with `interaction_type: 'session_summary'` + * when the user navigates away from the homepage. Captures: + * + * - `total_sections_viewed` — distinct sections that reached ≥50% visibility + * - `total_sections_loaded` — sections enabled via feature flags + * - `entry_point` — how the user arrived (app_opened, home_tab, navigated_back) + * - `session_time` — seconds spent on the homepage this visit + * + * All session state is held in refs — no re-renders occur on scroll or blur. + */ +const useHomeSessionSummary = ({ + totalSectionsLoaded, +}: UseHomeSessionSummaryParams) => { + const { visitId, entryPoint, getViewedSectionCount } = + useHomepageScrollContext(); + const { trackEvent, createEventBuilder } = useAnalytics(); + + const sessionStartRef = useRef(Date.now()); + + // Reset session start time on each new visit (visitId increments on focus). + useEffect(() => { + sessionStartRef.current = Date.now(); + }, [visitId]); + + // Stable refs for the blur callback to avoid stale closure issues. + const visitIdRef = useRef(visitId); + const entryPointRef = useRef(entryPoint); + const totalSectionsLoadedRef = useRef(totalSectionsLoaded); + + useEffect(() => { + visitIdRef.current = visitId; + }, [visitId]); + useEffect(() => { + entryPointRef.current = entryPoint; + }, [entryPoint]); + useEffect(() => { + totalSectionsLoadedRef.current = totalSectionsLoaded; + }, [totalSectionsLoaded]); + + useFocusEffect( + useCallback( + () => () => { + // Blur — user is leaving the homepage. Skip if never actually focused. + if (visitIdRef.current === 0) return; + const sessionTime = Math.round( + (Date.now() - sessionStartRef.current) / 1000, + ); + trackEvent( + createEventBuilder(MetaMetricsEvents.HOME_VIEWED) + .addProperties({ + interaction_type: 'session_summary', + location: 'home', + total_sections_viewed: getViewedSectionCount(), + total_sections_loaded: totalSectionsLoadedRef.current, + entry_point: entryPointRef.current, + session_time: sessionTime, + }) + .build(), + ); + }, + [trackEvent, createEventBuilder, getViewedSectionCount], + ), + ); +}; + +export default useHomeSessionSummary; diff --git a/app/components/Views/Homepage/hooks/useHomeViewedEvent.test.ts b/app/components/Views/Homepage/hooks/useHomeViewedEvent.test.ts index 697cc101dce..caba07e080a 100644 --- a/app/components/Views/Homepage/hooks/useHomeViewedEvent.test.ts +++ b/app/components/Views/Homepage/hooks/useHomeViewedEvent.test.ts @@ -34,12 +34,16 @@ const HomeEntryPointsValues = { NAVIGATED_BACK: 'navigated_back', } as const; +const mockNotifySectionViewed = jest.fn(); + let mockContextValue = { subscribeToScroll: mockSubscribeToScroll, viewportHeight: 800, containerScreenY: 0, entryPoint: HomeEntryPointsValues.APP_OPENED as string, visitId: 0, + notifySectionViewed: mockNotifySectionViewed, + getViewedSectionCount: jest.fn(() => 0), }; jest.mock('../context/HomepageScrollContext', () => ({ @@ -86,6 +90,8 @@ describe('useHomeViewedEvent', () => { containerScreenY: 0, entryPoint: HomeEntryPointsValues.APP_OPENED, visitId: 1, // Use 1 as default so "event fires" tests pass; 0 = pre-focus, no fire + notifySectionViewed: mockNotifySectionViewed, + getViewedSectionCount: jest.fn(() => 0), }; }); diff --git a/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts b/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts index f1eb877d714..25ecaa3ce85 100644 --- a/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts +++ b/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts @@ -63,6 +63,7 @@ const useHomeViewedEvent = ({ containerScreenY, entryPoint, visitId, + notifySectionViewed, } = useHomepageScrollContext(); const { trackEvent, createEventBuilder } = useAnalytics(); @@ -96,6 +97,7 @@ const useHomeViewedEvent = ({ }) .build(), ); + notifySectionViewed(sectionName); }, [ visitId, sectionName, @@ -106,6 +108,7 @@ const useHomeViewedEvent = ({ entryPoint, trackEvent, createEventBuilder, + notifySectionViewed, ]); // Reset on each homepage visit so the event re-fires. diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index 62dff744def..b26d90149b6 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -639,6 +639,8 @@ const Wallet = ({ // Callbacks registered by sections to be notified of scroll events. // Using a ref+Set avoids any React state updates (and re-renders) on scroll. const scrollSubscribersRef = useRef void>>(new Set()); + // Tracks which sections have been viewed this visit (reset on each focus). + const viewedSectionsRef = useRef>(new Set()); // ───────────────────────────────────────────────────────────────────────── const isPerpsFlagEnabled = useSelector(selectPerpsEnabledFlag); @@ -1392,6 +1394,20 @@ const Wallet = ({ return () => scrollSubscribersRef.current.delete(cb); }, []); + // Reset viewed sections on each new visit so session summary starts fresh. + useEffect(() => { + viewedSectionsRef.current.clear(); + }, [visitId]); + + const notifySectionViewed = useCallback((sectionName: string) => { + viewedSectionsRef.current.add(sectionName); + }, []); + + const getViewedSectionCount = useCallback( + () => viewedSectionsRef.current.size, + [], + ); + const homepageScrollContextValue = useMemo( () => ({ subscribeToScroll, @@ -1399,8 +1415,18 @@ const Wallet = ({ containerScreenY, entryPoint, visitId, + notifySectionViewed, + getViewedSectionCount, }), - [subscribeToScroll, viewportHeight, containerScreenY, entryPoint, visitId], + [ + subscribeToScroll, + viewportHeight, + containerScreenY, + entryPoint, + visitId, + notifySectionViewed, + getViewedSectionCount, + ], ); const content = ( diff --git a/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.test.tsx b/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.test.tsx index e7fccf6b817..f425bc42b99 100644 --- a/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.test.tsx +++ b/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.test.tsx @@ -2,9 +2,9 @@ import React from 'react'; import renderWithProvider from '../../../../../../util/test/renderWithProvider'; import { PercentageRow } from './percentage-row'; import { useIsTransactionPayLoading } from '../../../hooks/pay/useTransactionPayData'; +import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; import { strings } from '../../../../../../../locales/i18n'; import { MUSD_CONVERSION_APY } from '../../../../../UI/Earn/constants/musd'; -import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; import { TransactionType } from '@metamask/transaction-controller'; jest.mock('../../../hooks/pay/useTransactionPayData'); @@ -28,7 +28,7 @@ describe('PercentageRow', () => { useIsTransactionPayLoadingMock.mockReturnValue(false); useTransactionMetadataRequestMock.mockReturnValue({ type: TransactionType.musdConversion, - } as never); + } as ReturnType); }); it('renders label, tooltip and APY when not loading', () => { @@ -39,28 +39,6 @@ describe('PercentageRow', () => { expect(getByText(`${MUSD_CONVERSION_APY}%`)).toBeOnTheScreen(); }); - it('renders nothing when tx type is not supported', () => { - useTransactionMetadataRequestMock.mockReturnValue({ - type: TransactionType.contractInteraction, - } as never); - - const { queryByText, queryByTestId } = render(); - - expect(queryByTestId('percentage-row-skeleton')).toBeNull(); - expect(queryByText(strings('earn.claimable_bonus'))).toBeNull(); - expect(queryByText(`${MUSD_CONVERSION_APY}%`)).toBeNull(); - }); - - it('renders nothing when transaction metadata is undefined', () => { - useTransactionMetadataRequestMock.mockReturnValue(undefined as never); - - const { queryByText, queryByTestId } = render(); - - expect(queryByTestId('percentage-row-skeleton')).toBeNull(); - expect(queryByText(strings('earn.claimable_bonus'))).toBeNull(); - expect(queryByText(`${MUSD_CONVERSION_APY}%`)).toBeNull(); - }); - it('renders skeleton when transaction pay is loading', () => { useIsTransactionPayLoadingMock.mockReturnValue(true); @@ -68,4 +46,14 @@ describe('PercentageRow', () => { expect(getByTestId('percentage-row-skeleton')).toBeOnTheScreen(); }); + + it('renders nothing for non-musdConversion transactions', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.simpleSend, + } as ReturnType); + + const { toJSON } = render(); + + expect(toJSON()).toBeNull(); + }); }); diff --git a/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx b/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx index a0232f9d9c5..30c74e0824a 100644 --- a/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx +++ b/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { StyleSheet, Linking } from 'react-native'; import InfoRow from '../../UI/info-row'; import { MUSD_CONVERSION_APY } from '../../../../../UI/Earn/constants/musd'; import Text, { @@ -8,45 +9,47 @@ import Text, { import { useIsTransactionPayLoading } from '../../../hooks/pay/useTransactionPayData'; import { InfoRowSkeleton } from '../../UI/info-row/info-row'; import { strings } from '../../../../../../../locales/i18n'; +import { IconColor } from '../../../../../../component-library/components/Icons/Icon'; +import AppConstants from '../../../../../../core/AppConstants'; import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; import { TransactionType } from '@metamask/transaction-controller'; -import { IconColor } from '../../../../../../component-library/components/Icons/Icon'; +import { hasTransactionType } from '../../../utils/transaction'; -function getTxTypeRowConfig( - transactionType?: TransactionType, -): { label: string; tooltip: string } | undefined { - if (transactionType === TransactionType.musdConversion) { - return { - label: strings('earn.claimable_bonus'), - tooltip: strings('earn.claimable_bonus_tooltip'), - }; - } - - return undefined; -} +const styles = StyleSheet.create({ + termsText: { + textDecorationLine: 'underline', + }, +}); export function PercentageRow() { - const transactionMetadata = useTransactionMetadataRequest(); - const isLoading = useIsTransactionPayLoading(); - const transactionType = transactionMetadata?.type; - const rowConfig = getTxTypeRowConfig(transactionType); + const transactionMetadata = useTransactionMetadataRequest(); - if (!rowConfig) { + if ( + !hasTransactionType(transactionMetadata, [TransactionType.musdConversion]) + ) { return null; } + const redirectToBonusFaq = () => + Linking.openURL(AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE); + if (isLoading) { return ; } - const { label, tooltip } = rowConfig; - return ( + {strings('earn.claimable_bonus_tooltip')}{' '} + + {strings('earn.musd_conversion.education.terms_apply')} + + + } tooltipColor={IconColor.Alternative} > diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 1cb0823d519..4421af0948d 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -283,6 +283,7 @@ const Routes = { RECIPIENT_SELECTOR_MODAL: 'RecipientSelectorModal', MARKET_CLOSED_MODAL: 'MarketClosedModal', NETWORK_LIST_MODAL: 'NetworkListModal', + PRICE_IMPACT_MODAL: 'PriceImpactModal', }, BRIDGE_TRANSACTION_DETAILS: 'BridgeTransactionDetails', }, diff --git a/app/core/AppConstants.ts b/app/core/AppConstants.ts index 00f25c183e8..1a77bae5ef9 100644 --- a/app/core/AppConstants.ts +++ b/app/core/AppConstants.ts @@ -33,6 +33,8 @@ export default { BRIDGE: { ACTIVE: true, URL: `${PORTFOLIO_URL}/bridge`, + PRICE_IMPACT_WARNING_THRESHOLD: 5, + PRICE_IMPACT_ERROR_THRESHOLD: 25, // Check app/components/UI/Bridge/types.ts // for interface definition. SLIPPAGE_CONFIG: { diff --git a/app/core/Multichain/constants.ts b/app/core/Multichain/constants.ts index 1612297e1be..ff6ac441341 100644 --- a/app/core/Multichain/constants.ts +++ b/app/core/Multichain/constants.ts @@ -140,14 +140,21 @@ export const PRICE_API_CURRENCIES = [ 'zar', ]; -// Tron resource asset symbols -export const TRON_RESOURCE = { +/** + * Tron special asset types that should be filtered out from asset selectors. + * These are virtual resources and staking state assets passed from the Tron Snap + * to the extension for informational purposes, not actual tradeable tokens. + */ +export const TRON_SPECIAL_ASSET_SYMBOLS = { ENERGY: 'energy', BANDWIDTH: 'bandwidth', MAX_ENERGY: 'max-energy', MAX_BANDWIDTH: 'max-bandwidth', STRX_ENERGY: 'strx-energy', STRX_BANDWIDTH: 'strx-bandwidth', + TRX_READY_FOR_WITHDRAWAL: 'trx-ready-for-withdrawal', + TRX_STAKING_REWARDS: 'trx-staking-rewards', + TRX_IN_LOCK_PERIOD: 'trx-in-lock-period', } as const; export enum TronResourceType { @@ -155,11 +162,10 @@ export enum TronResourceType { BANDWIDTH = 'BANDWIDTH', } -export type TronResourceSymbol = - (typeof TRON_RESOURCE)[keyof typeof TRON_RESOURCE]; +export type TronSpecialAssetSymbol = + (typeof TRON_SPECIAL_ASSET_SYMBOLS)[keyof typeof TRON_SPECIAL_ASSET_SYMBOLS]; -export const TRON_RESOURCE_SYMBOLS = Object.values( - TRON_RESOURCE, -) as readonly TronResourceSymbol[]; -export const TRON_RESOURCE_SYMBOLS_SET: ReadonlySet = - new Set(TRON_RESOURCE_SYMBOLS); +export const TRON_SPECIAL_ASSET_SYMBOLS_SET: ReadonlySet = + new Set( + Object.values(TRON_SPECIAL_ASSET_SYMBOLS) as TronSpecialAssetSymbol[], + ); diff --git a/app/core/Multichain/utils.ts b/app/core/Multichain/utils.ts index 1ffa1c805db..3959b535417 100644 --- a/app/core/Multichain/utils.ts +++ b/app/core/Multichain/utils.ts @@ -10,7 +10,11 @@ import { isAddress as isSolanaAddress } from '@solana/addresses'; import Engine from '../Engine'; import { CaipChainId, Hex } from '@metamask/utils'; import { validate, Network } from 'bitcoin-address-validation'; -import { MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP } from './constants'; +import { + MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP, + TRON_SPECIAL_ASSET_SYMBOLS_SET, + TronSpecialAssetSymbol, +} from './constants'; import { formatAddress, isEthAddress } from '../../util/address'; import { formatBlockExplorerAddressUrl, @@ -257,3 +261,23 @@ export function shortenTransactionId(txId: string) { // For transactions we use a similar output for now, but shortenTransactionId will be added later. return formatAddress(txId, 'short'); } + +/** + * Checks if a token is a Tron special asset (resources, staking state, etc.) + * that should be filtered out from user-facing asset lists. + * + * @param chainId - The chain ID to check + * @param symbol - The token symbol to check + * @returns true if the token is a Tron special asset + */ +export const isTronSpecialAsset = ( + chainId: string | undefined, + symbol: string | undefined, +): boolean => { + if (!chainId?.startsWith('tron:') || !symbol) { + return false; + } + return TRON_SPECIAL_ASSET_SYMBOLS_SET.has( + symbol.toLowerCase() as TronSpecialAssetSymbol, + ); +}; diff --git a/app/selectors/assets/assets-list.test.ts b/app/selectors/assets/assets-list.test.ts index 478d1cb0ca7..605d39b1c26 100644 --- a/app/selectors/assets/assets-list.test.ts +++ b/app/selectors/assets/assets-list.test.ts @@ -14,7 +14,7 @@ import { selectAsset, selectAssetsBySelectedAccountGroup, selectSortedAssetsBySelectedAccountGroup, - selectTronResourcesBySelectedAccountGroup, + selectTronSpecialAssetsBySelectedAccountGroup, } from './assets-list'; import I18n from '../../../locales/i18n'; @@ -588,7 +588,7 @@ describe('selectSortedAssetsBySelectedAccountGroup', () => { ]); }); - it('filters out Tron Energy and Bandwidth resources from assets', () => { + it('filters out Tron special assets from the sorted asset list', () => { const stateWithTronAssets = { ...mockState(), engine: { @@ -600,6 +600,9 @@ describe('selectSortedAssetsBySelectedAccountGroup', () => { '2d89e6a0-b4e6-45a8-a707-f10cef143b42': [ 'tron:728126428/slip44:energy', 'tron:728126428/slip44:bandwidth', + 'tron:728126428/slip44:195-ready-for-withdrawal', + 'tron:728126428/slip44:195-staking-rewards', + 'tron:728126428/slip44:195-in-lock-period', 'tron:728126428/slip44:195', ], }, @@ -620,6 +623,45 @@ describe('selectSortedAssetsBySelectedAccountGroup', () => { { name: 'Bandwidth', symbol: 'BANDWIDTH', decimals: 0 }, ], }, + 'tron:728126428/slip44:195-ready-for-withdrawal': { + name: 'Ready for Withdrawal', + symbol: 'TRX-READY-FOR-WITHDRAWAL', + fungible: true as const, + iconUrl: 'test-url', + units: [ + { + name: 'Ready for Withdrawal', + symbol: 'TRX-READY-FOR-WITHDRAWAL', + decimals: 6, + }, + ], + }, + 'tron:728126428/slip44:195-staking-rewards': { + name: 'Staking Rewards', + symbol: 'TRX-STAKING-REWARDS', + fungible: true as const, + iconUrl: 'test-url', + units: [ + { + name: 'Staking Rewards', + symbol: 'TRX-STAKING-REWARDS', + decimals: 6, + }, + ], + }, + 'tron:728126428/slip44:195-in-lock-period': { + name: 'In Lock Period', + symbol: 'TRX-IN-LOCK-PERIOD', + fungible: true as const, + iconUrl: 'test-url', + units: [ + { + name: 'In Lock Period', + symbol: 'TRX-IN-LOCK-PERIOD', + decimals: 6, + }, + ], + }, 'tron:728126428/slip44:195': { name: 'TRON', symbol: 'TRX', @@ -641,6 +683,18 @@ describe('selectSortedAssetsBySelectedAccountGroup', () => { amount: '604', unit: 'BANDWIDTH', }, + 'tron:728126428/slip44:195-ready-for-withdrawal': { + amount: '10', + unit: 'TRX-READY-FOR-WITHDRAWAL', + }, + 'tron:728126428/slip44:195-staking-rewards': { + amount: '5', + unit: 'TRX-STAKING-REWARDS', + }, + 'tron:728126428/slip44:195-in-lock-period': { + amount: '20', + unit: 'TRX-IN-LOCK-PERIOD', + }, 'tron:728126428/slip44:195': { amount: '1000', unit: 'TRX' }, }, }, @@ -670,21 +724,34 @@ describe('selectSortedAssetsBySelectedAccountGroup', () => { const tronAssets = result.filter((asset) => asset.chainId?.includes('tron:'), ); - const energyAsset = result.find((asset) => - asset.address?.includes('energy'), + + const trxAsset = tronAssets.find( + (asset) => asset.address === 'tron:728126428/slip44:195', + ); + const energyAsset = tronAssets.find( + (asset) => asset.address === 'tron:728126428/slip44:energy', + ); + const bandwidthAsset = tronAssets.find( + (asset) => asset.address === 'tron:728126428/slip44:bandwidth', ); - const bandwidthAsset = result.find((asset) => - asset.address?.includes('bandwidth'), + const readyForWithdrawalAsset = tronAssets.find( + (asset) => + asset.address === 'tron:728126428/slip44:195-ready-for-withdrawal', ); - const trxAsset = result.find((asset) => - asset.address?.includes('slip44:195'), + const stakingRewardsAsset = tronAssets.find( + (asset) => asset.address === 'tron:728126428/slip44:195-staking-rewards', + ); + const inLockPeriodAsset = tronAssets.find( + (asset) => asset.address === 'tron:728126428/slip44:195-in-lock-period', ); + expect(trxAsset).toBeDefined(); expect(energyAsset).toBeUndefined(); expect(bandwidthAsset).toBeUndefined(); - expect(trxAsset).toBeDefined(); + expect(readyForWithdrawalAsset).toBeUndefined(); + expect(stakingRewardsAsset).toBeUndefined(); + expect(inLockPeriodAsset).toBeUndefined(); - // Only TRX is in the list after filtering expect(tronAssets).toHaveLength(1); }); }); @@ -1027,7 +1094,7 @@ describe('selectAsset', () => { }); }); -describe('selectTronResourcesBySelectedAccountGroup', () => { +describe('selectTronSpecialAssetsBySelectedAccountGroup', () => { it('returns Tron energy and bandwidth resources when Tron network is enabled', () => { const stateWithTronAssets = { ...mockState(), @@ -1105,7 +1172,7 @@ describe('selectTronResourcesBySelectedAccountGroup', () => { } as unknown as RootState; const result = - selectTronResourcesBySelectedAccountGroup(stateWithTronAssets); + selectTronSpecialAssetsBySelectedAccountGroup(stateWithTronAssets); // Verify the object structure with named properties expect(result.energy?.assetId).toBe('tron:728126428/slip44:energy'); @@ -1117,7 +1184,7 @@ describe('selectTronResourcesBySelectedAccountGroup', () => { }); it('maps all resource types and computes totalStakedTrx with BigNumber precision', () => { - const stateWithAllResources = { + const stateWithAllSpecialAssets = { ...mockState(), engine: { ...mockState().engine, @@ -1132,6 +1199,9 @@ describe('selectTronResourcesBySelectedAccountGroup', () => { 'tron:728126428/slip44:max-bandwidth', 'tron:728126428/slip44:strx-energy', 'tron:728126428/slip44:strx-bandwidth', + 'tron:728126428/slip44:195-ready-for-withdrawal', + 'tron:728126428/slip44:195-staking-rewards', + 'tron:728126428/slip44:195-in-lock-period', 'tron:728126428/slip44:195', ], }, @@ -1200,6 +1270,45 @@ describe('selectTronResourcesBySelectedAccountGroup', () => { }, ], }, + 'tron:728126428/slip44:195-ready-for-withdrawal': { + name: 'Ready for Withdrawal', + symbol: 'TRX-READY-FOR-WITHDRAWAL', + fungible: true as const, + iconUrl: 'test-url', + units: [ + { + name: 'Ready for Withdrawal', + symbol: 'TRX-READY-FOR-WITHDRAWAL', + decimals: 6, + }, + ], + }, + 'tron:728126428/slip44:195-staking-rewards': { + name: 'Staking Rewards', + symbol: 'TRX-STAKING-REWARDS', + fungible: true as const, + iconUrl: 'test-url', + units: [ + { + name: 'Staking Rewards', + symbol: 'TRX-STAKING-REWARDS', + decimals: 6, + }, + ], + }, + 'tron:728126428/slip44:195-in-lock-period': { + name: 'In Lock Period', + symbol: 'TRX-IN-LOCK-PERIOD', + fungible: true as const, + iconUrl: 'test-url', + units: [ + { + name: 'In Lock Period', + symbol: 'TRX-IN-LOCK-PERIOD', + decimals: 6, + }, + ], + }, 'tron:728126428/slip44:195': { name: 'TRON', symbol: 'TRX', @@ -1237,6 +1346,18 @@ describe('selectTronResourcesBySelectedAccountGroup', () => { amount: '65.48463', unit: 'STRX-BANDWIDTH', }, + 'tron:728126428/slip44:195-ready-for-withdrawal': { + amount: '25.5', + unit: 'TRX-READY-FOR-WITHDRAWAL', + }, + 'tron:728126428/slip44:195-staking-rewards': { + amount: '12.3', + unit: 'TRX-STAKING-REWARDS', + }, + 'tron:728126428/slip44:195-in-lock-period': { + amount: '50', + unit: 'TRX-IN-LOCK-PERIOD', + }, 'tron:728126428/slip44:195': { amount: '1000', unit: 'TRX', @@ -1263,11 +1384,11 @@ describe('selectTronResourcesBySelectedAccountGroup', () => { }, } as unknown as RootState; - const result = selectTronResourcesBySelectedAccountGroup( - stateWithAllResources, + const result = selectTronSpecialAssetsBySelectedAccountGroup( + stateWithAllSpecialAssets, ); - // All 6 resource types should be mapped + // All 9 special assets should be mapped expect(result.energy?.assetId).toBe('tron:728126428/slip44:energy'); expect(result.bandwidth?.assetId).toBe('tron:728126428/slip44:bandwidth'); expect(result.maxEnergy?.assetId).toBe('tron:728126428/slip44:max-energy'); @@ -1280,6 +1401,15 @@ describe('selectTronResourcesBySelectedAccountGroup', () => { expect(result.stakedTrxForBandwidth?.assetId).toBe( 'tron:728126428/slip44:strx-bandwidth', ); + expect(result.trxReadyForWithdrawal?.assetId).toBe( + 'tron:728126428/slip44:195-ready-for-withdrawal', + ); + expect(result.trxStakingRewards?.assetId).toBe( + 'tron:728126428/slip44:195-staking-rewards', + ); + expect(result.trxInLockPeriod?.assetId).toBe( + 'tron:728126428/slip44:195-in-lock-period', + ); // totalStakedTrx computed via BigNumber avoids floating-point errors // 65.48463 + 65.48463 = 130.96926 (not 130.96926000000002) @@ -1345,7 +1475,7 @@ describe('selectTronResourcesBySelectedAccountGroup', () => { }, } as unknown as RootState; - const result = selectTronResourcesBySelectedAccountGroup( + const result = selectTronSpecialAssetsBySelectedAccountGroup( stateWithTronDisabled, ); @@ -1358,6 +1488,9 @@ describe('selectTronResourcesBySelectedAccountGroup', () => { stakedTrxForEnergy: undefined, stakedTrxForBandwidth: undefined, totalStakedTrx: 0, + trxReadyForWithdrawal: undefined, + trxStakingRewards: undefined, + trxInLockPeriod: undefined, }); }); }); diff --git a/app/selectors/assets/assets-list.ts b/app/selectors/assets/assets-list.ts index 4bda702c7b6..27f14fb1552 100644 --- a/app/selectors/assets/assets-list.ts +++ b/app/selectors/assets/assets-list.ts @@ -27,10 +27,11 @@ import { import { safeParseBigNumber } from '../../util/number/bignumber'; import { selectAccountsByChainId } from '../accountTrackerController'; import { - TRON_RESOURCE, - TRON_RESOURCE_SYMBOLS_SET, - TronResourceSymbol, + TRON_SPECIAL_ASSET_SYMBOLS, + TRON_SPECIAL_ASSET_SYMBOLS_SET, + TronSpecialAssetSymbol, } from '../../core/Multichain/constants'; +import { isTronSpecialAsset } from '../../core/Multichain/utils'; import { sortAssetsWithPriority } from '../../components/UI/Tokens/util/sortAssetsWithPriority'; import { selectAllTokens } from '../tokensController'; import { selectSelectedInternalAccountAddress } from '../accountsController'; @@ -38,10 +39,14 @@ import { selectSelectedInternalAccountByScope } from '../multichainAccounts/acco import { getLocaleLanguageCode } from '../../components/hooks/useFormatters'; /** - * Structured map of Tron resources for efficient access. - * Each property corresponds to a specific Tron resource type. + * Structured map of Tron special assets for efficient access. + * + * Includes network resources (Energy, Bandwidth and their max capacities), + * staking-related assets (staked TRX for Energy/Bandwidth, total staked TRX), + * and additional staking lifecycle assets (Ready for Withdrawal, Staking + * Rewards, In Lock Period). */ -export interface TronResourcesMap { +export interface TronSpecialAssetsMap { /** Current available energy */ energy: Asset | undefined; /** Current available bandwidth */ @@ -56,12 +61,18 @@ export interface TronResourcesMap { stakedTrxForBandwidth: Asset | undefined; /** Total staked TRX (sum of energy + bandwidth staking) */ totalStakedTrx: number; + /** TRX ready for withdrawal (unstaked TRX that has completed the lock period) */ + trxReadyForWithdrawal: Asset | undefined; + /** TRX staking rewards */ + trxStakingRewards: Asset | undefined; + /** TRX in lock period (unstaked but waiting for lock period to end) */ + trxInLockPeriod: Asset | undefined; } /** * Empty constant to avoid creating new objects on each call when no Tron networks are enabled. */ -const EMPTY_TRON_RESOURCES_MAP: TronResourcesMap = Object.freeze({ +const EMPTY_TRON_SPECIAL_ASSETS_MAP: TronSpecialAssetsMap = Object.freeze({ energy: undefined, bandwidth: undefined, maxEnergy: undefined, @@ -69,6 +80,9 @@ const EMPTY_TRON_RESOURCES_MAP: TronResourcesMap = Object.freeze({ stakedTrxForEnergy: undefined, stakedTrxForBandwidth: undefined, totalStakedTrx: 0, + trxReadyForWithdrawal: undefined, + trxStakingRewards: undefined, + trxInLockPeriod: undefined, }); const getStateForAssetSelector = (state: RootState) => { @@ -245,7 +259,11 @@ export const selectSortedAssetsBySelectedAccountGroup = createDeepEqualSelector( (bip44Assets, enabledNetworks, tokenSortConfig, stakedAssets) => { const assets = Object.entries(bip44Assets) .filter(([networkId, _]) => enabledNetworks.includes(networkId)) - .flatMap(([_, chainAssets]) => chainAssets); + .flatMap(([_, chainAssets]) => + chainAssets.filter( + (asset) => !isTronSpecialAsset(asset.chainId, asset.symbol), + ), + ); const stakedAssetsArray = []; for (const asset of assets) { @@ -425,22 +443,26 @@ function assetToToken( } /** - * Selects Tron resources (Energy, Bandwidth, Max values, and staked TRX) for the - * currently selected account group. + * Selects Tron special assets for the currently selected account group. + * + * This includes: + * - **Network resources**: Energy, Bandwidth, and their maximum capacities. + * - **Staking assets**: TRX staked for Energy/Bandwidth and a pre-computed `totalStakedTrx` sum. + * - **Staking lifecycle assets**: TRX Ready for Withdrawal, Staking Rewards, and TRX In Lock Period. * - * Returns a structured object with all resources pre-mapped for efficient access, - * eliminating the need for consumers to iterate/search the array. + * Returns a structured {@link TronSpecialAssetsMap} with all assets pre-mapped by type + * for efficient access, eliminating the need for consumers to iterate or search the array. */ -export const selectTronResourcesBySelectedAccountGroup = +export const selectTronSpecialAssetsBySelectedAccountGroup = createDeepEqualSelector( [getStateForAssetSelector, selectEnabledNetworks], - (assetsState, enabledNetworks): TronResourcesMap => { + (assetsState, enabledNetworks): TronSpecialAssetsMap => { const enabledTronNetworks = enabledNetworks.filter((networkId) => networkId.startsWith('tron:'), ); if (enabledTronNetworks.length === 0) { - return EMPTY_TRON_RESOURCES_MAP; + return EMPTY_TRON_SPECIAL_ASSETS_MAP; } const allAssets = _selectAssetsBySelectedAccountGroup(assetsState, { @@ -449,7 +471,7 @@ export const selectTronResourcesBySelectedAccountGroup = const enabledTronNetworksSet = new Set(enabledTronNetworks); - const resourceMap: TronResourcesMap = { + const specialAssetsMap: TronSpecialAssetsMap = { energy: undefined, bandwidth: undefined, maxEnergy: undefined, @@ -457,33 +479,45 @@ export const selectTronResourcesBySelectedAccountGroup = stakedTrxForEnergy: undefined, stakedTrxForBandwidth: undefined, totalStakedTrx: 0, + trxReadyForWithdrawal: undefined, + trxStakingRewards: undefined, + trxInLockPeriod: undefined, }; for (const [networkId, chainAssets] of Object.entries(allAssets)) { if (!enabledTronNetworksSet.has(networkId)) continue; for (const asset of chainAssets) { - const symbol = asset.symbol?.toLowerCase() as TronResourceSymbol; - if (!TRON_RESOURCE_SYMBOLS_SET.has(symbol)) continue; + const symbol = asset.symbol?.toLowerCase() as TronSpecialAssetSymbol; + if (!TRON_SPECIAL_ASSET_SYMBOLS_SET.has(symbol)) continue; switch (symbol) { - case TRON_RESOURCE.ENERGY: - resourceMap.energy = asset; + case TRON_SPECIAL_ASSET_SYMBOLS.ENERGY: + specialAssetsMap.energy = asset; + break; + case TRON_SPECIAL_ASSET_SYMBOLS.BANDWIDTH: + specialAssetsMap.bandwidth = asset; + break; + case TRON_SPECIAL_ASSET_SYMBOLS.MAX_ENERGY: + specialAssetsMap.maxEnergy = asset; + break; + case TRON_SPECIAL_ASSET_SYMBOLS.MAX_BANDWIDTH: + specialAssetsMap.maxBandwidth = asset; break; - case TRON_RESOURCE.BANDWIDTH: - resourceMap.bandwidth = asset; + case TRON_SPECIAL_ASSET_SYMBOLS.STRX_ENERGY: + specialAssetsMap.stakedTrxForEnergy = asset; break; - case TRON_RESOURCE.MAX_ENERGY: - resourceMap.maxEnergy = asset; + case TRON_SPECIAL_ASSET_SYMBOLS.STRX_BANDWIDTH: + specialAssetsMap.stakedTrxForBandwidth = asset; break; - case TRON_RESOURCE.MAX_BANDWIDTH: - resourceMap.maxBandwidth = asset; + case TRON_SPECIAL_ASSET_SYMBOLS.TRX_READY_FOR_WITHDRAWAL: + specialAssetsMap.trxReadyForWithdrawal = asset; break; - case TRON_RESOURCE.STRX_ENERGY: - resourceMap.stakedTrxForEnergy = asset; + case TRON_SPECIAL_ASSET_SYMBOLS.TRX_STAKING_REWARDS: + specialAssetsMap.trxStakingRewards = asset; break; - case TRON_RESOURCE.STRX_BANDWIDTH: - resourceMap.stakedTrxForBandwidth = asset; + case TRON_SPECIAL_ASSET_SYMBOLS.TRX_IN_LOCK_PERIOD: + specialAssetsMap.trxInLockPeriod = asset; break; } } @@ -493,17 +527,17 @@ export const selectTronResourcesBySelectedAccountGroup = * Compute total staked TRX using BigNumber to avoid floating-point precision errors */ const stakedTrxForEnergyBN = safeParseBigNumber( - resourceMap.stakedTrxForEnergy?.balance, + specialAssetsMap.stakedTrxForEnergy?.balance, ); const stakedTrxForBandwidthBN = safeParseBigNumber( - resourceMap.stakedTrxForBandwidth?.balance, + specialAssetsMap.stakedTrxForBandwidth?.balance, ); const totalStakedTrxBN = stakedTrxForEnergyBN.plus( stakedTrxForBandwidthBN, ); - resourceMap.totalStakedTrx = totalStakedTrxBN.toNumber(); + specialAssetsMap.totalStakedTrx = totalStakedTrxBN.toNumber(); - return resourceMap; + return specialAssetsMap; }, ); diff --git a/app/selectors/multichain/multichain.ts b/app/selectors/multichain/multichain.ts index 141bb524dbe..21b67677f7c 100644 --- a/app/selectors/multichain/multichain.ts +++ b/app/selectors/multichain/multichain.ts @@ -46,11 +46,8 @@ import { TokenI } from '../../components/UI/Tokens/types'; import { createSelector } from 'reselect'; import { selectSelectedAccountGroupInternalAccounts } from '../multichainAccounts/accountTreeController'; import { selectAccountTokensAcrossChains } from '../multichain'; -import { - MULTICHAIN_ACCOUNT_TYPE_TO_MAINNET, - TRON_RESOURCE_SYMBOLS_SET, - TronResourceSymbol, -} from '../../core/Multichain/constants'; +import { MULTICHAIN_ACCOUNT_TYPE_TO_MAINNET } from '../../core/Multichain/constants'; +import { isTronSpecialAsset } from '../../core/Multichain/utils'; export const selectMultichainDefaultToken = createDeepEqualSelector( selectIsEvmNetworkSelected, @@ -366,12 +363,7 @@ export const selectAccountTokensAcrossChainsUnified = createDeepEqualSelector( selectMultichainTokenListForAccountsAnyChain(state, [account]) || []; for (const token of nonEvmTokensForAccount) { - if ( - String(token.chainId).includes('tron:') && - TRON_RESOURCE_SYMBOLS_SET.has( - (token.symbol || '').toLowerCase() as TronResourceSymbol, - ) - ) { + if (isTronSpecialAsset(String(token.chainId), token.symbol)) { continue; } // We just need tron mainnet, at least for now diff --git a/app/store/sagas/index.ts b/app/store/sagas/index.ts index 75e31bf2019..6ce9589bedd 100644 --- a/app/store/sagas/index.ts +++ b/app/store/sagas/index.ts @@ -34,6 +34,7 @@ import { rewardsBulkLinkSaga } from './rewardsBulkLinkAccountGroups'; import Authentication from '../../core/Authentication'; import { AppState, AppStateStatus } from 'react-native'; import trackErrorAsAnalytics from '../../util/metrics/TrackError/trackErrorAsAnalytics'; +import { providerErrors } from '@metamask/rpc-errors'; /** * Creates a channel to listen to app state changes. @@ -109,6 +110,19 @@ export function* appLockStateMachine() { while (true) { yield take(UserActionType.LOCKED_APP); + // Reject any pending confirmations so the user doesn't see a stale confirmation after unlock. + try { + const { ApprovalController } = Engine.context; + if (ApprovalController) { + ApprovalController.clear(providerErrors.userRejectedRequest()); + } + } catch (error) { + Logger.error( + error as Error, + 'Failed to reject pending approvals on app lock', + ); + } + // Navigate to lock screen. NavigationService.navigation?.navigate(Routes.LOCK_SCREEN); diff --git a/app/store/sagas/sagas.test.ts b/app/store/sagas/sagas.test.ts index 9c05603cc4d..76033332988 100644 --- a/app/store/sagas/sagas.test.ts +++ b/app/store/sagas/sagas.test.ts @@ -25,6 +25,7 @@ import WC2Manager from '../../core/WalletConnect/WalletConnectV2'; import Authentication from '../../core/Authentication'; import AppConstants from '../../core/AppConstants'; import trackErrorAsAnalytics from '../../util/metrics/TrackError/trackErrorAsAnalytics'; +import { providerErrors } from '@metamask/rpc-errors'; const mockNavigate = jest.fn(); const mockReset = jest.fn(); @@ -75,6 +76,9 @@ jest.mock('../../core/Engine', () => ({ AccountsController: { updateAccounts: jest.fn(), }, + ApprovalController: { + clear: jest.fn(), + }, RemoteFeatureFlagController: { state: { remoteFeatureFlags: { @@ -344,9 +348,13 @@ describe('appStateListenerTask', () => { }); describe('appLockStateMachine', () => { + const mockApprovalControllerClear = Engine.context.ApprovalController + .clear as jest.Mock; + beforeEach(() => { mockNavigate.mockClear(); mockReset.mockClear(); + mockApprovalControllerClear.mockClear(); }); it('forks appStateListenerTask and navigates to LockScreen when app is locked', async () => { @@ -359,6 +367,29 @@ describe('appLockStateMachine', () => { // Verify navigation to LockScreen expect(mockNavigate).toHaveBeenCalledWith(Routes.LOCK_SCREEN); }); + + it('clears pending approvals via ApprovalController.clear when app is locked', async () => { + await expectSaga(appLockStateMachine) + .dispatch({ type: UserActionType.LOCKED_APP }) + .run(); + + expect(mockApprovalControllerClear).toHaveBeenCalledWith( + providerErrors.userRejectedRequest(), + ); + expect(mockNavigate).toHaveBeenCalledWith(Routes.LOCK_SCREEN); + }); + + it('navigates to LockScreen even when ApprovalController.clear throws', async () => { + mockApprovalControllerClear.mockImplementationOnce(() => { + throw new Error('clear failed'); + }); + + await expectSaga(appLockStateMachine) + .dispatch({ type: UserActionType.LOCKED_APP }) + .run(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.LOCK_SCREEN); + }); }); // TODO: Update all saga tests to use expectSaga (more intuitive and easier to read) diff --git a/locales/languages/en.json b/locales/languages/en.json index 0663ab03edd..1ef43c2e1fa 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -5915,7 +5915,7 @@ "error_description": "Installation of {{snap}} failed." }, "earn": { - "claimable_bonus_tooltip": "An annual bonus claimable daily from your wallet.", + "claimable_bonus_tooltip": "The annualized bonus you’ve earned for holding mUSD. Your bonus is claimable daily on Linea.", "earn_a_percentage_bonus": "Earn a {{percentage}}% bonus", "claimable_bonus": "Claimable bonus", "claim_bonus": "Claim bonus", @@ -6023,12 +6023,13 @@ "toasts": { "converting": "Converting {{token}} → mUSD", "eta": "~{{time}}", - "delivered": "Your mUSD is here!", + "delivered": "mUSD conversion successful", + "delivered_description": "Bonus will be claimable within a day.", "failed": "mUSD conversion failed" }, "education": { "heading": "GET {{percentage}}% ON\nSTABLECOINS", - "description": "Convert your stablecoins to mUSD, MetaMask’s US dollar-backed stablecoin, and receive up to a {{percentage}}% bonus.", + "description": "Convert your stablecoins to mUSD and earn up to a {{percentage}}% annualized bonus that you can claim daily.", "terms_apply": "Terms apply.", "primary_button": "Get Started", "secondary_button": "Not now" @@ -6036,17 +6037,16 @@ "buy_musd": "Buy mUSD", "get_musd": "Get mUSD", "bonus_title": "Get {{percentage}}% on your stablecoins", - "bonus_description": "Convert your stablecoins to mUSD and receive up to a {{percentage}}% bonus.", + "bonus_description": "Convert your stablecoins to mUSD and get a {{percentage}}% annualized bonus.", "powered_by_relay": "Powered by Relay", "max": "Max", "quick_convert_button": "Convert", - "cta_body_earn_apy": "Earn {{apy}} yield automatically for holding mUSD.", "learn_more": "Learn more", "tooltip_title": "Earn yield with mUSD", "tooltip_content": "Convert your USDC, USDT, or DAI for mUSD, MetaMask's dollar-backed stablecoin. Earn {{apy}} yield on every dollar you hold.", "quick_convert": { - "title": "Convert and get {{apy}}%", - "subtitle": "Convert your stablecoins to mUSD and receive up to a {{apy}}% bonus.", + "title": "Convert and get {{percentage}}%", + "subtitle": "Convert your stablecoins to mUSD and receive up to a {{percentage}}% annualized bonus that you can claim daily.", "inline_failed_message": "Conversion failed. Try again.", "confirmation": { "title": "Convert max" @@ -6573,6 +6573,11 @@ "price_impact_info_title": "Price impact", "price_impact_info_description": "Price impact reflects how your swap order affects the market price of the asset. It depends on the trade size and the available liquidity in the pool. MetaMask does not influence or control price impact.", "price_impact_info_gasless_description": "Price impact reflects how your swap order affects the market price of the asset. If you don't hold enough funds for gas, part of your source token is automatically allocated to cover fees, which increases price impact. MetaMask does not influence or control price impact.", + "price_impact_warning_description": "This trade has an estimated {{priceImpact}} price impact, which reflects how much your trade changes the market price. The quote already reflects this.", + "price_impact_high": "High price impact", + "price_impact_execution_description": "You'll lose approximately {{priceImpact}} of your token's value on this swap. Try lowering the amount or choosing a more liquid route.", + "proceed": "Proceed", + "cancel": "Cancel", "slippage_info_title": "Slippage", "slippage_info_description": "The % change in price you're willing to allow before your transaction is canceled.", "blockaid_error_title": "This transaction will be reverted", @@ -6592,14 +6597,14 @@ }, "submit": "Submit", "default_slippage_description": "Your transaction won't go through if the price changes more than the slippage percent.", - "cancel": "Cancel", "confirm": "Confirm", "exceeding_upper_slippage_warning": "High slippage, this may result in a unfavourable swap", "exceeding_lower_slippage_warning": "Low slippage, this may result in a unfavourable swap", "exceeding_lower_slippage_error": "Enter a value greater than {{value}}%", "exceeding_upper_slippage_error": "You cannot enter a value greater than {{value}}%", "custom": "Custom", - "invalid_recipient_address": "Invalid address" + "invalid_recipient_address": "Invalid address", + "got_it": "Got it" }, "quote_expired_modal": { "title": "New quotes are available",