diff --git a/.eslintrc.js b/.eslintrc.js index 5e5c90cfa8c..6f50169814a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -128,6 +128,12 @@ module.exports = { '@metamask/design-tokens/color-no-hex': 'error', }, }, + { + files: ['app/components/UI/Predict/**/*.{js,jsx,ts,tsx}'], + rules: { + '@metamask/design-tokens/color-no-hex': 'error', + }, + }, { files: [ 'app/components/UI/Name/**/*.{js,ts,tsx}', diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bb14c88fabc..912fdf92b37 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -110,7 +110,6 @@ app/core/SnapKeyring @MetaMask/accounts-e # Co-owned by accounts and mobile-core-ux app/components/Views/AccountSelector @MetaMask/accounts-engineers @MetaMask/mobile-core-ux -app/components/UI/EvmAccountSelectorList @MetaMask/accounts-engineers @MetaMask/mobile-core-ux # Multichain Accounts **/multichain-accounts/** @MetaMask/accounts-engineers diff --git a/android/app/build.gradle b/android/app/build.gradle index 087807f6a14..35041984aeb 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -187,7 +187,7 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionName "7.69.0" + versionName "7.70.0" versionCode 3607 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/component-library/components-temp/ActionListItem/ActionListItem.tsx b/app/component-library/components-temp/ActionListItem/ActionListItem.tsx index d046d57ec42..40cfd75a19d 100644 --- a/app/component-library/components-temp/ActionListItem/ActionListItem.tsx +++ b/app/component-library/components-temp/ActionListItem/ActionListItem.tsx @@ -20,6 +20,11 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset'; // Internal dependencies import { ActionListItemProps } from './ActionListItem.types'; +/** + * @deprecated Please update your code to use `ActionListItem` from `@metamask/design-system-react-native`. + * The API may have changed - compare props before migrating. + * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/src/components/ActionListItem/README.md} + */ const ActionListItem: React.FC = ({ label, description, diff --git a/app/component-library/components-temp/Buttons/ButtonHero/ButtonHero.tsx b/app/component-library/components-temp/Buttons/ButtonHero/ButtonHero.tsx index 3b58bce9c3e..218f2187e02 100644 --- a/app/component-library/components-temp/Buttons/ButtonHero/ButtonHero.tsx +++ b/app/component-library/components-temp/Buttons/ButtonHero/ButtonHero.tsx @@ -56,6 +56,11 @@ const ButtonHeroInner = ({ * The useTailwind hook needs to be called inside the ThemeProvider context to get the locked theme. * By splitting into two components, we ensure the hook gets the correct theme context for all color calculations. */ +/** + * @deprecated Please update your code to use `ButtonHero` from `@metamask/design-system-react-native`. + * The API may have changed - compare props before migrating. + * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/src/components/ButtonHero/README.md} + */ const ButtonHero = (props: ButtonBaseProps) => ( diff --git a/app/component-library/components-temp/Buttons/ButtonSemantic/ButtonSemantic.tsx b/app/component-library/components-temp/Buttons/ButtonSemantic/ButtonSemantic.tsx index a59fad34469..8b47c2d3369 100644 --- a/app/component-library/components-temp/Buttons/ButtonSemantic/ButtonSemantic.tsx +++ b/app/component-library/components-temp/Buttons/ButtonSemantic/ButtonSemantic.tsx @@ -9,6 +9,11 @@ import { ButtonSemanticSeverity, } from './ButtonSemantic.types'; +/** + * @deprecated Please update your code to use `ButtonSemantic` from `@metamask/design-system-react-native`. + * The API may have changed - compare props before migrating. + * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/src/components/ButtonSemantic/README.md} + */ const ButtonSemantic: React.FC = ({ severity, size = ButtonSize.Lg, diff --git a/app/component-library/components/BottomSheets/BottomSheetHeader/BottomSheetHeader.tsx b/app/component-library/components/BottomSheets/BottomSheetHeader/BottomSheetHeader.tsx index 9bd7af609e5..94f535bed0e 100644 --- a/app/component-library/components/BottomSheets/BottomSheetHeader/BottomSheetHeader.tsx +++ b/app/component-library/components/BottomSheets/BottomSheetHeader/BottomSheetHeader.tsx @@ -19,6 +19,11 @@ import { BottomSheetHeaderVariant, } from './BottomSheetHeader.types'; +/** + * @deprecated Please update your code to use `BottomSheetHeader` from `@metamask/design-system-react-native`. + * The API may have changed - compare props before migrating. + * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/src/components/BottomSheetHeader/README.md} + */ const BottomSheetHeader: React.FC = ({ style, children, diff --git a/app/component-library/components/Buttons/Button/foundation/ButtonBase/ButtonBase.tsx b/app/component-library/components/Buttons/Button/foundation/ButtonBase/ButtonBase.tsx index a26439eb824..448c5a4bd61 100644 --- a/app/component-library/components/Buttons/Button/foundation/ButtonBase/ButtonBase.tsx +++ b/app/component-library/components/Buttons/Button/foundation/ButtonBase/ButtonBase.tsx @@ -20,6 +20,11 @@ import { DEFAULT_BUTTONBASE_LABEL_TEXTVARIANT, } from './ButtonBase.constants'; +/** + * @deprecated Please update your code to use `ButtonBase` from `@metamask/design-system-react-native`. + * The API may have changed - compare props before migrating. + * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/src/components/ButtonBase/README.md} + */ const ButtonBase = ({ label, labelColor = DEFAULT_BUTTONBASE_LABEL_COLOR, diff --git a/app/component-library/components/Form/TextField/TextField.tsx b/app/component-library/components/Form/TextField/TextField.tsx index fdce3b35778..31b20303476 100644 --- a/app/component-library/components/Form/TextField/TextField.tsx +++ b/app/component-library/components/Form/TextField/TextField.tsx @@ -27,6 +27,7 @@ import { * @deprecated Please update your code to use `TextField` from `@metamask/design-system-react-native`. * The API may have changed — compare props before migrating. * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/src/components/TextField/README.md} + * @since @metamask/design-system-react-native@0.9.0 */ const TextField = React.forwardRef( ( diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index ba45eb7a763..931ff274193 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -117,7 +117,6 @@ import { useAccountMenuEnabled } from '../../../selectors/featureFlagController/ import PerpsPositionTransactionView from '../../UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView'; import PerpsOrderTransactionView from '../../UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView'; import PerpsFundingTransactionView from '../../UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView'; -import TurnOnBackupAndSync from '../../Views/Identity/TurnOnBackupAndSync/TurnOnBackupAndSync'; import DeFiProtocolPositionDetails from '../../UI/DeFiPositions/DeFiProtocolPositionDetails'; import UnmountOnBlur from '../../Views/UnmountOnBlur'; ///: BEGIN:ONLY_INCLUDE_IF(sample-feature) @@ -1207,11 +1206,6 @@ const MainNavigator = () => { component={NotificationsOptInStack} options={NotificationsOptInStack.navigationOptions} /> - - - - ({ jest.mock('../../selectors/musdConversionStatus', () => ({ ...jest.requireActual('../../selectors/musdConversionStatus'), selectHasInFlightMusdConversion: jest.fn(), + selectHasUnapprovedMusdConversion: jest.fn(), selectMusdConversionStatuses: jest.fn(), })); const mockGetStakingNavbar = jest.fn(() => ({})); @@ -90,6 +92,10 @@ const mockUseMusdConversion = useMusdConversion as jest.MockedFunction< const mockUseMusdBalance = useMusdBalance as jest.MockedFunction< typeof useMusdBalance >; +const mockSelectHasUnapprovedMusdConversion = + selectHasUnapprovedMusdConversion as jest.MockedFunction< + typeof selectHasUnapprovedMusdConversion + >; const mockSelectHasInFlightMusdConversion = selectHasInFlightMusdConversion as jest.MockedFunction< typeof selectHasInFlightMusdConversion @@ -171,6 +177,7 @@ describe('MusdQuickConvertView', () => { fiatBalanceAggregatedFormatted: '$0.00', }); mockSelectHasInFlightMusdConversion.mockReturnValue(false); + mockSelectHasUnapprovedMusdConversion.mockReturnValue(false); mockSelectMusdConversionStatuses.mockReturnValue({}); }); @@ -461,8 +468,8 @@ describe('MusdQuickConvertView', () => { }); }); - describe('in-flight conversion', () => { - it('does not call initiateMaxConversion or initiateCustomConversion when Max or Edit is pressed and hasInFlightMusdConversion is true', async () => { + describe('unapproved conversion', () => { + it('does not call initiateMaxConversion or initiateCustomConversion when Max or Edit is pressed and hasUnapprovedMusdConversion is true', async () => { const token = createMockToken(); mockUseMusdConversionTokens.mockReturnValue({ tokens: [token], @@ -471,7 +478,7 @@ describe('MusdQuickConvertView', () => { isMusdSupportedOnChain: jest.fn(), hasConvertibleTokensByChainId: jest.fn(), }); - mockSelectHasInFlightMusdConversion.mockReturnValue(true); + mockSelectHasUnapprovedMusdConversion.mockReturnValue(true); const { getAllByTestId } = renderWithProvider(, { state: initialRootState, diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/MusdBalanceCard.test.tsx b/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/MusdBalanceCard.test.tsx index ba7d3c63fab..45d598b8906 100644 --- a/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/MusdBalanceCard.test.tsx +++ b/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/MusdBalanceCard.test.tsx @@ -54,7 +54,7 @@ describe('MusdBalanceCard', () => { expect(getByText(MUSD_TOKEN.symbol)).toBeOnTheScreen(); }); - it('displays percentage boost text from localization', () => { + it('displays percentage bonus text from localization', () => { const { getByText } = renderWithProvider( , { @@ -62,7 +62,7 @@ describe('MusdBalanceCard', () => { }, ); - expect(getByText('3% boost')).toBeOnTheScreen(); + expect(getByText('3% bonus')).toBeOnTheScreen(); }); }); }); diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/MusdBalanceCard.tsx b/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/MusdBalanceCard.tsx index ec95ee759a2..a0876b88b3e 100644 --- a/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/MusdBalanceCard.tsx +++ b/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/MusdBalanceCard.tsx @@ -71,7 +71,7 @@ const MusdBalanceCard = ({ chainId, balance }: MusdBalanceCardProps) => { - {strings('earn.musd_conversion.percentage_boost', { + {strings('earn.musd_conversion.percentage_bonus', { percentage: MUSD_CONVERSION_APY, })} diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx b/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx index 54025cada2f..52af5bb6083 100644 --- a/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx +++ b/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx @@ -18,6 +18,7 @@ import { selectMusdQuickConvertEnabledFlag } from '../../selectors/featureFlags' import { createTokenChainKey, selectHasInFlightMusdConversion, + selectHasUnapprovedMusdConversion, selectMusdConversionStatuses, } from '../../selectors/musdConversionStatus'; import ConvertTokenRow from '../../components/Musd/ConvertTokenRow'; @@ -81,6 +82,9 @@ const MusdQuickConvertView = () => { // Get convertible tokens const { tokens: conversionTokens } = useMusdConversionTokens(); + const hasUnapprovedMusdConversion = useSelector( + selectHasUnapprovedMusdConversion, + ); const hasInFlightMusdConversion = useSelector( selectHasInFlightMusdConversion, ); @@ -200,7 +204,9 @@ const MusdQuickConvertView = () => { token={item} onMaxPress={handleMaxPress} onEditPress={handleEditPress} - areActionsDisabled={hasInFlightMusdConversion} + areActionsDisabled={ + hasUnapprovedMusdConversion || hasInFlightMusdConversion + } isConversionPending={Boolean(txStatusInfo?.isPending)} /> ); @@ -210,6 +216,7 @@ const MusdQuickConvertView = () => { handleEditPress, handleMaxPress, hasInFlightMusdConversion, + hasUnapprovedMusdConversion, ], ); diff --git a/app/components/UI/Earn/components/EarnTransactionMonitor.test.tsx b/app/components/UI/Earn/components/EarnTransactionMonitor.test.tsx index dc653ff9952..2eff9f25ee6 100644 --- a/app/components/UI/Earn/components/EarnTransactionMonitor.test.tsx +++ b/app/components/UI/Earn/components/EarnTransactionMonitor.test.tsx @@ -2,13 +2,18 @@ import React from 'react'; import { render } from '@testing-library/react-native'; import EarnTransactionMonitor from './EarnTransactionMonitor'; import { useMusdConversionStatus } from '../hooks/useMusdConversionStatus'; +import { useMusdConversionStaleApprovalCleanup } from '../hooks/useMusdConversionStaleApprovalCleanup'; import { useMerklClaimStatus } from '../hooks/useMerklClaimStatus'; jest.mock('../hooks/useMusdConversionStatus'); +jest.mock('../hooks/useMusdConversionStaleApprovalCleanup'); jest.mock('../hooks/useMerklClaimStatus'); describe('EarnTransactionMonitor', () => { const mockUseMusdConversionStatus = jest.mocked(useMusdConversionStatus); + const mockUseMusdConversionStaleApprovalCleanup = jest.mocked( + useMusdConversionStaleApprovalCleanup, + ); const mockUseMerklClaimStatus = jest.mocked(useMerklClaimStatus); beforeEach(() => { @@ -31,6 +36,12 @@ describe('EarnTransactionMonitor', () => { expect(mockUseMusdConversionStatus).toHaveBeenCalledTimes(1); }); + it('calls useMusdConversionStaleApprovalCleanup hook', () => { + render(); + + expect(mockUseMusdConversionStaleApprovalCleanup).toHaveBeenCalledTimes(1); + }); + it('calls useMerklClaimStatus hook', () => { render(); diff --git a/app/components/UI/Earn/components/EarnTransactionMonitor.tsx b/app/components/UI/Earn/components/EarnTransactionMonitor.tsx index ffd4001d531..322cc6e03fd 100644 --- a/app/components/UI/Earn/components/EarnTransactionMonitor.tsx +++ b/app/components/UI/Earn/components/EarnTransactionMonitor.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useMusdConversionStatus } from '../hooks/useMusdConversionStatus'; +import { useMusdConversionStaleApprovalCleanup } from '../hooks/useMusdConversionStaleApprovalCleanup'; import { useMerklClaimStatus } from '../hooks/useMerklClaimStatus'; /** @@ -9,6 +10,12 @@ import { useMerklClaimStatus } from '../hooks/useMerklClaimStatus'; * allowing them to remain active even when navigating away from Earn screens. */ const EarnTransactionMonitor: React.FC = () => { + /** + * Reject stale mUSD pending approvals on app foreground. + * For example, resuming via notification or deeplink can bypass + * the normal confirmation rejection path. + */ + useMusdConversionStaleApprovalCleanup(); // Enable mUSD conversion status monitoring and toasts useMusdConversionStatus(); // Enable Merkl bonus claim status monitoring and toasts 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 f042fbe0ed8..2d77c92c3a1 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.test.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.test.tsx @@ -435,7 +435,7 @@ describe('MusdConversionAssetOverviewCta', () => { }); // Assert - const expectedCtaText = strings('earn.musd_conversion.boost_title', { + const expectedCtaText = strings('earn.musd_conversion.bonus_title', { percentage: MUSD_CONVERSION_APY, }); @@ -484,7 +484,7 @@ describe('MusdConversionAssetOverviewCta', () => { }); // Assert - const expectedCtaText = strings('earn.musd_conversion.boost_title', { + const expectedCtaText = strings('earn.musd_conversion.bonus_title', { percentage: MUSD_CONVERSION_APY, }); @@ -534,7 +534,7 @@ describe('MusdConversionAssetOverviewCta', () => { }); // Assert - const expectedCtaText = strings('earn.musd_conversion.boost_title', { + const expectedCtaText = strings('earn.musd_conversion.bonus_title', { percentage: MUSD_CONVERSION_APY, }); 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 817e079bc2b..8329744547a 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 @@ -474,7 +474,7 @@ describeForPlatforms('MusdConversionAssetOverviewCta', () => { ).toBeOnTheScreen(); }); - it('renders CTA with correct boost title text', () => { + it('renders CTA with correct bonus title text', () => { // Arrange const state = initialStateWallet() .withMinimalMultichainAssets() @@ -507,7 +507,7 @@ describeForPlatforms('MusdConversionAssetOverviewCta', () => { ).toBeOnTheScreen(); }); - it('renders CTA with correct boost description text', () => { + it('renders CTA with correct bonus description text', () => { // Arrange const state = initialStateWallet() .withMinimalMultichainAssets() diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/index.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/index.tsx index 9de6fe0c7a5..2d066511d1e 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/index.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/index.tsx @@ -53,7 +53,7 @@ const MusdConversionAssetOverviewCta = ({ const submitCtaPressedEvent = () => { const { EVENT_LOCATIONS, MUSD_CTA_TYPES } = MUSD_EVENTS_CONSTANTS; - const ctaText = strings('earn.musd_conversion.boost_title', { + const ctaText = strings('earn.musd_conversion.bonus_title', { percentage: MUSD_CONVERSION_APY, }); @@ -116,12 +116,12 @@ const MusdConversionAssetOverviewCta = ({ {/* Text content in the center */} - {strings('earn.musd_conversion.boost_title', { + {strings('earn.musd_conversion.bonus_title', { percentage: MUSD_CONVERSION_APY, })} - {strings('earn.musd_conversion.boost_description', { + {strings('earn.musd_conversion.bonus_description', { percentage: MUSD_CONVERSION_APY, })} diff --git a/app/components/UI/Earn/hooks/useEarnToasts.tsx b/app/components/UI/Earn/hooks/useEarnToasts.tsx index aadacf310d1..3047debb549 100644 --- a/app/components/UI/Earn/hooks/useEarnToasts.tsx +++ b/app/components/UI/Earn/hooks/useEarnToasts.tsx @@ -150,7 +150,7 @@ const useEarnToasts = (): { ), diff --git a/app/components/UI/Earn/hooks/useMusdConfirmNavigation.test.ts b/app/components/UI/Earn/hooks/useMusdConfirmNavigation.test.ts new file mode 100644 index 00000000000..dcbd37f5276 --- /dev/null +++ b/app/components/UI/Earn/hooks/useMusdConfirmNavigation.test.ts @@ -0,0 +1,72 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import Routes from '../../../../constants/navigation/Routes'; +import { useMusdConfirmNavigation } from './useMusdConfirmNavigation'; + +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); +const mockCanGoBack = jest.fn(); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: mockNavigate, + goBack: mockGoBack, + canGoBack: mockCanGoBack, + }), +})); + +describe('useMusdConfirmNavigation', () => { + const useSelectorMock = useSelector as jest.Mock; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('goes back when quick convert is enabled and navigation can go back', () => { + useSelectorMock.mockReturnValue(true); + mockCanGoBack.mockReturnValue(true); + + const { result } = renderHook(() => useMusdConfirmNavigation()); + + act(() => { + result.current.navigateOnConfirm(); + }); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('navigates to wallet view when quick convert is enabled and cannot go back', () => { + useSelectorMock.mockReturnValue(true); + mockCanGoBack.mockReturnValue(false); + + const { result } = renderHook(() => useMusdConfirmNavigation()); + + act(() => { + result.current.navigateOnConfirm(); + }); + + expect(mockGoBack).not.toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET_VIEW); + }); + + it('navigates to wallet view when quick convert is disabled', () => { + useSelectorMock.mockReturnValue(false); + + const { result } = renderHook(() => useMusdConfirmNavigation()); + + act(() => { + result.current.navigateOnConfirm(); + }); + + expect(mockCanGoBack).not.toHaveBeenCalled(); + expect(mockGoBack).not.toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET_VIEW); + }); +}); diff --git a/app/components/UI/Earn/hooks/useMusdConfirmNavigation.ts b/app/components/UI/Earn/hooks/useMusdConfirmNavigation.ts new file mode 100644 index 00000000000..e32feaf4cee --- /dev/null +++ b/app/components/UI/Earn/hooks/useMusdConfirmNavigation.ts @@ -0,0 +1,25 @@ +import { useCallback } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import Routes from '../../../../constants/navigation/Routes'; +import { selectMusdQuickConvertEnabledFlag } from '../selectors/featureFlags'; + +export const useMusdConfirmNavigation = () => { + const navigation = useNavigation(); + const isMusdQuickConvertEnabled = useSelector( + selectMusdQuickConvertEnabledFlag, + ); + + const navigateOnConfirm = useCallback(() => { + if (isMusdQuickConvertEnabled && navigation.canGoBack()) { + navigation.goBack(); + return; + } + + navigation.navigate(Routes.WALLET_VIEW); + }, [isMusdQuickConvertEnabled, navigation]); + + return { + navigateOnConfirm, + }; +}; diff --git a/app/components/UI/Earn/hooks/useMusdConversionStaleApprovalCleanup.test.ts b/app/components/UI/Earn/hooks/useMusdConversionStaleApprovalCleanup.test.ts new file mode 100644 index 00000000000..71824d17bbb --- /dev/null +++ b/app/components/UI/Earn/hooks/useMusdConversionStaleApprovalCleanup.test.ts @@ -0,0 +1,258 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { AppState, AppStateStatus } from 'react-native'; +import { useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import Logger from '../../../../util/Logger'; +import NavigationService from '../../../../core/NavigationService'; +import Routes from '../../../../constants/navigation/Routes'; +import { useMusdConversionStaleApprovalCleanup } from './useMusdConversionStaleApprovalCleanup'; +import { selectUnapprovedMusdConversions } from '../selectors/musdConversionStatus'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + __esModule: true, + default: { + rejectPendingApproval: jest.fn(), + }, +})); + +jest.mock('../../../../util/Logger', () => ({ + __esModule: true, + default: { + log: jest.fn(), + }, +})); + +jest.mock('../../../../core/NavigationService', () => ({ + __esModule: true, + default: { + navigation: { + getCurrentRoute: jest.fn(), + goBack: jest.fn(), + }, + }, +})); + +jest.mock('../selectors/musdConversionStatus', () => ({ + selectUnapprovedMusdConversions: jest.fn(), +})); + +describe('useMusdConversionStaleApprovalCleanup', () => { + const mockUseSelector = useSelector as jest.MockedFunction< + typeof useSelector + >; + const mockRejectPendingApproval = jest.mocked(Engine.rejectPendingApproval); + const mockLoggerLog = jest.mocked(Logger.log); + const mockSelectUnapprovedMusdConversions = jest.mocked( + selectUnapprovedMusdConversions, + ); + const mockGetCurrentRoute = jest.mocked( + NavigationService.navigation.getCurrentRoute, + ); + const mockGoBack = jest.mocked(NavigationService.navigation.goBack); + + let appStateHandler: ((nextAppState: AppStateStatus) => void) | undefined; + let removeSubscriptionMock: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + mockSelectUnapprovedMusdConversions.mockReturnValue([]); + + removeSubscriptionMock = jest.fn(); + appStateHandler = undefined; + + (AppState as unknown as { currentState: AppStateStatus }).currentState = + 'active'; + + jest + .spyOn(AppState, 'addEventListener') + .mockImplementation((_, handler) => { + appStateHandler = handler as (nextAppState: AppStateStatus) => void; + return { + remove: removeSubscriptionMock, + } as unknown as ReturnType; + }); + + mockGetCurrentRoute.mockReturnValue(undefined as never); + + mockUseSelector.mockImplementation((selector) => { + if (selector === selectUnapprovedMusdConversions) { + return mockSelectUnapprovedMusdConversions({} as never); + } + return undefined as ReturnType; + }); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('registers app state listener and removes it on unmount', () => { + const { unmount } = renderHook(() => + useMusdConversionStaleApprovalCleanup(), + ); + + expect(AppState.addEventListener).toHaveBeenCalledWith( + 'change', + expect.any(Function), + ); + + unmount(); + + expect(removeSubscriptionMock).toHaveBeenCalledTimes(1); + }); + + it('rejects stale pending approvals when app returns to active', () => { + mockSelectUnapprovedMusdConversions.mockReturnValue([ + { id: 'tx-1' } as never, + ]); + + renderHook(() => useMusdConversionStaleApprovalCleanup()); + + act(() => { + appStateHandler?.('background'); + appStateHandler?.('active'); + }); + + expect(mockLoggerLog).toHaveBeenCalledWith( + '[mUSD Conversion] Rejecting stale pending approvals on foreground', + { + count: 1, + transactionIds: ['tx-1'], + }, + ); + expect(mockRejectPendingApproval).toHaveBeenCalledTimes(1); + expect(mockRejectPendingApproval).toHaveBeenCalledWith( + 'tx-1', + expect.objectContaining({ + data: expect.objectContaining({ + cause: 'useMusdConversionStaleApprovalCleanup', + transactionId: 'tx-1', + }), + }), + { + ignoreMissing: true, + logErrors: false, + }, + ); + }); + + it('does not reject approvals on inactive to active transition', () => { + mockSelectUnapprovedMusdConversions.mockReturnValue([ + { id: 'tx-1' } as never, + ]); + + renderHook(() => useMusdConversionStaleApprovalCleanup()); + + act(() => { + appStateHandler?.('inactive'); + appStateHandler?.('active'); + }); + + expect(mockLoggerLog).not.toHaveBeenCalled(); + expect(mockRejectPendingApproval).not.toHaveBeenCalled(); + }); + + it('does not reject approvals when there are no stale pending approvals', () => { + mockSelectUnapprovedMusdConversions.mockReturnValue([]); + + renderHook(() => useMusdConversionStaleApprovalCleanup()); + + act(() => { + appStateHandler?.('background'); + appStateHandler?.('active'); + }); + + expect(mockLoggerLog).not.toHaveBeenCalled(); + expect(mockRejectPendingApproval).not.toHaveBeenCalled(); + }); + + it('uses latest pending approvals after rerender', () => { + mockSelectUnapprovedMusdConversions + .mockReturnValueOnce([]) + .mockReturnValue([{ id: 'tx-latest' } as never]); + + const { rerender } = renderHook(() => + useMusdConversionStaleApprovalCleanup(), + ); + + rerender({}); + + act(() => { + appStateHandler?.('background'); + appStateHandler?.('active'); + }); + + expect(mockRejectPendingApproval).toHaveBeenCalledWith( + 'tx-latest', + expect.any(Object), + { + ignoreMissing: true, + logErrors: false, + }, + ); + }); + + it('navigates back when confirmation screen is focused after rejecting stale approvals', () => { + mockSelectUnapprovedMusdConversions.mockReturnValue([ + { id: 'tx-1' } as never, + ]); + mockGetCurrentRoute.mockReturnValue({ + name: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + } as never); + + renderHook(() => useMusdConversionStaleApprovalCleanup()); + + act(() => { + appStateHandler?.('background'); + appStateHandler?.('active'); + }); + + jest.runAllTimers(); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('does not navigate back when a different screen is focused', () => { + mockSelectUnapprovedMusdConversions.mockReturnValue([ + { id: 'tx-1' } as never, + ]); + mockGetCurrentRoute.mockReturnValue({ + name: 'SomeOtherScreen', + } as never); + + renderHook(() => useMusdConversionStaleApprovalCleanup()); + + act(() => { + appStateHandler?.('background'); + appStateHandler?.('active'); + }); + + jest.runAllTimers(); + + expect(mockGoBack).not.toHaveBeenCalled(); + }); + + it('does not navigate back when no stale approvals exist', () => { + mockSelectUnapprovedMusdConversions.mockReturnValue([]); + mockGetCurrentRoute.mockReturnValue({ + name: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + } as never); + + renderHook(() => useMusdConversionStaleApprovalCleanup()); + + act(() => { + appStateHandler?.('background'); + appStateHandler?.('active'); + }); + + jest.runAllTimers(); + + expect(mockGoBack).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/Earn/hooks/useMusdConversionStaleApprovalCleanup.ts b/app/components/UI/Earn/hooks/useMusdConversionStaleApprovalCleanup.ts new file mode 100644 index 00000000000..0d82761ce0b --- /dev/null +++ b/app/components/UI/Earn/hooks/useMusdConversionStaleApprovalCleanup.ts @@ -0,0 +1,102 @@ +import { providerErrors } from '@metamask/rpc-errors'; +import { useEffect, useMemo } from 'react'; +import { AppState, AppStateStatus } from 'react-native'; +import { useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import Logger from '../../../../util/Logger'; +import NavigationService from '../../../../core/NavigationService'; +import Routes from '../../../../constants/navigation/Routes'; +import { selectUnapprovedMusdConversions } from '../selectors/musdConversionStatus'; + +/** + * Rejects stale mUSD conversion pending approvals on app foreground. + * + * If the app backgrounds while a mUSD conversion is pending approval, some + * flows can stay disabled because the pending approval remains unresolved. + * This hook rejects those stale unapproved mUSD approvals when the app returns + * to active state. + */ +export const useMusdConversionStaleApprovalCleanup = () => { + const pendingUnapprovedMusdConversions = useSelector( + selectUnapprovedMusdConversions, + ); + + const pendingMusdUnapprovedTransactionIds = useMemo( + () => + pendingUnapprovedMusdConversions.map( + (transactionMeta) => transactionMeta.id, + ), + [pendingUnapprovedMusdConversions], + ); + + useEffect(() => { + let previousAppState = AppState.currentState; + + const handleAppStateChange = (nextAppState: AppStateStatus) => { + // Only treat a true background return as stale approval cleanup signal. + // iOS transient system overlays (Notification/Control Center) can emit + // active -> inactive -> active and should not clear pending approvals. + const shouldRejectStaleApprovals = + previousAppState === 'background' && nextAppState === 'active'; + + if (!shouldRejectStaleApprovals) { + previousAppState = nextAppState; + return; + } + + if (pendingMusdUnapprovedTransactionIds.length === 0) { + previousAppState = nextAppState; + return; + } + + Logger.log( + '[mUSD Conversion] Rejecting stale pending approvals on foreground', + { + count: pendingMusdUnapprovedTransactionIds.length, + transactionIds: pendingMusdUnapprovedTransactionIds, + }, + ); + + for (const transactionId of pendingMusdUnapprovedTransactionIds) { + Engine.rejectPendingApproval( + transactionId, + providerErrors.userRejectedRequest({ + message: + 'Automatically rejected stale mUSD conversion pending approval on app foreground', + data: { + cause: 'useMusdConversionStaleApprovalCleanup', + transactionId, + }, + }), + { + ignoreMissing: true, + logErrors: false, + }, + ); + } + + previousAppState = nextAppState; + + // Pop the orphaned confirmation screen on the next frame so React + // finishes processing the approval-rejection state updates first. + requestAnimationFrame(() => { + const currentRoute = NavigationService.navigation.getCurrentRoute(); + if ( + currentRoute?.name === + Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS + ) { + NavigationService.navigation.goBack(); + } + }); + }; + + const appStateListener = AppState.addEventListener( + 'change', + handleAppStateChange, + ); + + return () => { + appStateListener.remove(); + }; + }, [pendingMusdUnapprovedTransactionIds]); +}; diff --git a/app/components/UI/Earn/selectors/musdConversionStatus.test.ts b/app/components/UI/Earn/selectors/musdConversionStatus.test.ts index 5063ff7a7ff..d2081f4bdb9 100644 --- a/app/components/UI/Earn/selectors/musdConversionStatus.test.ts +++ b/app/components/UI/Earn/selectors/musdConversionStatus.test.ts @@ -5,18 +5,25 @@ import { import { RootState } from '../../../../reducers'; import { selectMusdConversions, - selectHasInFlightMusdConversion, + selectHasUnapprovedMusdConversion, createTokenChainKey, selectMusdConversionStatuses, + selectHasInFlightMusdConversion, } from './musdConversionStatus'; -const createState = (transactions: unknown[]): RootState => +const createState = ( + transactions: unknown[], + pendingApprovals: Record = {}, +): RootState => ({ engine: { backgroundState: { TransactionController: { transactions, }, + ApprovalController: { + pendingApprovals, + }, }, }, }) as unknown as RootState; @@ -65,7 +72,7 @@ describe('musdConversionStatus selectors', () => { }); }); - describe('selectHasInFlightMusdConversion', () => { + describe('selectHasUnapprovedMusdConversion', () => { it('returns true when at least one conversion has unapproved status', () => { const transactions = [ { @@ -74,14 +81,14 @@ describe('musdConversionStatus selectors', () => { status: TransactionStatus.unapproved, }, ]; - const state = createState(transactions); + const state = createState(transactions, { '1': {} }); - const result = selectHasInFlightMusdConversion(state); + const result = selectHasUnapprovedMusdConversion(state); expect(result).toBe(true); }); - it('returns true when at least one conversion has submitted status', () => { + it('returns false when conversion status is submitted', () => { const transactions = [ { id: '1', @@ -91,37 +98,7 @@ describe('musdConversionStatus selectors', () => { ]; const state = createState(transactions); - const result = selectHasInFlightMusdConversion(state); - - expect(result).toBe(true); - }); - - it('returns false when all conversions are confirmed', () => { - const transactions = [ - { - id: '1', - type: TransactionType.musdConversion, - status: TransactionStatus.confirmed, - }, - ]; - const state = createState(transactions); - - const result = selectHasInFlightMusdConversion(state); - - expect(result).toBe(false); - }); - - it('returns false when all conversions are failed', () => { - const transactions = [ - { - id: '1', - type: TransactionType.musdConversion, - status: TransactionStatus.failed, - }, - ]; - const state = createState(transactions); - - const result = selectHasInFlightMusdConversion(state); + const result = selectHasUnapprovedMusdConversion(state); expect(result).toBe(false); }); @@ -130,7 +107,7 @@ describe('musdConversionStatus selectors', () => { const transactions = [{ id: '1', type: TransactionType.simpleSend }]; const state = createState(transactions); - const result = selectHasInFlightMusdConversion(state); + const result = selectHasUnapprovedMusdConversion(state); expect(result).toBe(false); }); @@ -250,7 +227,6 @@ describe('musdConversionStatus selectors', () => { it('sets isPending true for in-flight statuses', () => { const inFlightStatuses = [ - TransactionStatus.unapproved, TransactionStatus.approved, TransactionStatus.signed, TransactionStatus.submitted, @@ -393,4 +369,75 @@ describe('musdConversionStatus selectors', () => { expect(result['0xtokena-0x1'].txId).toBe('tx-1'); }); }); + + describe('selectHasInFlightMusdConversion', () => { + it('returns true when at least one conversion is in-flight', () => { + const transactions = [ + { + id: 'tx-1', + type: TransactionType.musdConversion, + status: TransactionStatus.confirmed, + time: 1000, + metamaskPay: { + tokenAddress: '0xTokenA', + chainId: '0x1', + }, + }, + { + id: 'tx-2', + type: TransactionType.musdConversion, + status: TransactionStatus.submitted, + time: 2000, + metamaskPay: { + tokenAddress: '0xTokenB', + chainId: '0x1', + }, + }, + ]; + const state = createState(transactions); + + const result = selectHasInFlightMusdConversion(state); + + expect(result).toBe(true); + }); + + it('returns false when conversions exist but none are in-flight', () => { + const transactions = [ + { + id: 'tx-1', + type: TransactionType.musdConversion, + status: TransactionStatus.confirmed, + time: 1000, + metamaskPay: { + tokenAddress: '0xTokenA', + chainId: '0x1', + }, + }, + { + id: 'tx-2', + type: TransactionType.musdConversion, + status: TransactionStatus.failed, + time: 2000, + metamaskPay: { + tokenAddress: '0xTokenB', + chainId: '0x1', + }, + }, + ]; + const state = createState(transactions); + + const result = selectHasInFlightMusdConversion(state); + + expect(result).toBe(false); + }); + + it('returns false when there are no mUSD conversions', () => { + const transactions = [{ id: 'tx-1', type: TransactionType.simpleSend }]; + const state = createState(transactions); + + const result = selectHasInFlightMusdConversion(state); + + expect(result).toBe(false); + }); + }); }); diff --git a/app/components/UI/Earn/selectors/musdConversionStatus.ts b/app/components/UI/Earn/selectors/musdConversionStatus.ts index bb3f10c708a..c2ee0932bb8 100644 --- a/app/components/UI/Earn/selectors/musdConversionStatus.ts +++ b/app/components/UI/Earn/selectors/musdConversionStatus.ts @@ -22,7 +22,6 @@ interface ConversionStatusInfo { * Transaction statuses that indicate an in-flight conversion. */ const IN_FLIGHT_STATUSES: TransactionStatus[] = [ - TransactionStatus.unapproved, TransactionStatus.approved, TransactionStatus.signed, TransactionStatus.submitted, @@ -38,24 +37,25 @@ export const selectMusdConversions = createSelector( ); /** - * Selects in-flight mUSD conversions (for loading states). - * These are conversions that have been submitted/approved and not yet terminal. + * Selects unapproved mUSD conversion transactions. */ -const selectInFlightMusdConversions = createSelector( +export const selectUnapprovedMusdConversions = createSelector( [selectMusdConversions], (conversions): TransactionMeta[] => - conversions.filter((tx) => - IN_FLIGHT_STATUSES.includes(tx.status as TransactionStatus), + conversions.filter( + (transactionMeta) => + transactionMeta.status === TransactionStatus.unapproved, ), ); /** - * True when any in-flight mUSD conversion exists. - * Used to globally disable quick-convert actions. + * True when any mUSD conversion is awaiting user approval. + * Used to disable quick-convert actions while approval is pending. */ -export const selectHasInFlightMusdConversion = createSelector( - [selectInFlightMusdConversions], - (inFlight): boolean => inFlight.length > 0, +export const selectHasUnapprovedMusdConversion = createSelector( + [selectUnapprovedMusdConversions], + (pendingUnapprovedConversions): boolean => + pendingUnapprovedConversions.length > 0, ); /** @@ -114,3 +114,15 @@ export const selectMusdConversionStatuses = createSelector( return statusMap; }, ); + +/** + * True when any mUSD conversion is currently in-flight. + * Used to guard conversion entry points while a conversion is active. + */ +export const selectHasInFlightMusdConversion = createSelector( + [selectMusdConversionStatuses], + (conversionStatuses): boolean => + Object.values(conversionStatuses).some( + (conversionStatus) => conversionStatus.isPending, + ), +); diff --git a/app/components/UI/EvmAccountSelectorList/EvmAccountSelectorList.constants.ts b/app/components/UI/EvmAccountSelectorList/EvmAccountSelectorList.constants.ts deleted file mode 100644 index 1d0e5522d64..00000000000 --- a/app/components/UI/EvmAccountSelectorList/EvmAccountSelectorList.constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const ACCOUNT_SELECTOR_LIST_TESTID = 'account-selector-list'; diff --git a/app/components/UI/EvmAccountSelectorList/EvmAccountSelectorList.styles.ts b/app/components/UI/EvmAccountSelectorList/EvmAccountSelectorList.styles.ts deleted file mode 100644 index c383a42a1ed..00000000000 --- a/app/components/UI/EvmAccountSelectorList/EvmAccountSelectorList.styles.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Third party dependencies. -import { StyleSheet } from 'react-native'; -import { Theme } from '../../../util/theme/models'; - -/** - * Style sheet function for AvatarIcon component. - * - * @param params Style sheet params. - * @param params.theme App theme from ThemeContext. - * @param params.vars Inputs that the style sheet depends on. - * @returns StyleSheet object. - */ -const styleSheet = ({ theme }: { theme: Theme }) => - StyleSheet.create({ - balancesContainer: { - alignItems: 'flex-end', - flexDirection: 'column', - }, - balanceLabel: { textAlign: 'right', fontWeight: '500' }, - titleText: { fontWeight: '500' }, - sectionHeader: { - paddingHorizontal: 16, - paddingVertical: 8, - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - backgroundColor: theme.colors.background.default, - }, - sectionDetailsLink: { - color: theme.colors.primary.default, - }, - sectionSeparator: { - height: 1, - backgroundColor: theme.colors.border.default, - opacity: 0.4, - marginVertical: 8, - }, - listContainer: { - flexGrow: 1, - flexShrink: 1, - flexDirection: 'row', - }, - }); - -export default styleSheet; diff --git a/app/components/UI/EvmAccountSelectorList/EvmAccountSelectorList.test.tsx b/app/components/UI/EvmAccountSelectorList/EvmAccountSelectorList.test.tsx deleted file mode 100644 index 150a8820227..00000000000 --- a/app/components/UI/EvmAccountSelectorList/EvmAccountSelectorList.test.tsx +++ /dev/null @@ -1,1763 +0,0 @@ -import React from 'react'; -// eslint-disable-next-line @typescript-eslint/no-shadow -import { waitFor, within, fireEvent } from '@testing-library/react-native'; -import { Alert, AlertButton, View } from 'react-native'; -import renderWithProvider from '../../../util/test/renderWithProvider'; -import EvmAccountSelectorList from './EvmAccountSelectorList'; -import { useAccounts, Account } from '../../hooks/useAccounts'; -import { AccountListBottomSheetSelectorsIDs } from '../../Views/AccountSelector/AccountListBottomSheet.testIds'; -import { backgroundState } from '../../../util/test/initial-root-state'; -import { regex } from '../../../util/regex'; -import { - createMockAccountsControllerState, - createMockAccountsControllerStateWithSnap, - MOCK_ADDRESS_1, - MOCK_ADDRESS_2, -} from '../../../util/test/accountsControllerTestUtils'; -import { mockNetworkState } from '../../../util/test/network'; -import { CHAIN_IDS } from '@metamask/transaction-controller'; -import { EvmAccountSelectorListProps } from './EvmAccountSelectorList.types'; -import Engine from '../../../core/Engine'; -import { CellComponentSelectorsIDs } from '../../../component-library/components/Cells/Cell/CellComponent.testIds'; -import { KeyringTypes } from '@metamask/keyring-controller'; -import { ACCOUNT_SELECTOR_LIST_TESTID } from './EvmAccountSelectorList.constants'; -import { AVATARGROUP_CONTAINER_TESTID } from '../../../component-library/components/Avatars/AvatarGroup/AvatarGroup.constants'; -import { KnownCaipNamespace } from '@metamask/utils'; -import Routes from '../../../constants/navigation/Routes'; -import { WalletViewSelectorsIDs } from '../../Views/Wallet/WalletView.testIds'; - -const BUSINESS_ACCOUNT = '0xC4955C0d639D99699Bfd7Ec54d9FaFEe40e4D272'; -const PERSONAL_ACCOUNT = '0xd018538C87232FF95acbCe4870629b75640a78E7'; - -// Those ID have been generated with createMockUuidFromAddress, but we hardcode them here, otherwise jest -// will complain. -const BUSINESS_ACCOUNT_ID = '30786334-3935-4563-b064-363339643939'; -const PERSONAL_ACCOUNT_ID = '30786430-3138-4533-b863-383732333266'; - -const MOCK_ACCOUNTS_CONTROLLER_STATE = createMockAccountsControllerState([ - BUSINESS_ACCOUNT, - PERSONAL_ACCOUNT, -]); - -// Mock InteractionManager to run callbacks immediately in tests -const { InteractionManager } = jest.requireActual('react-native'); - -InteractionManager.runAfterInteractions = jest.fn(async (callback) => - callback(), -); - -jest.mock('../../../util/address', () => { - const actual = jest.requireActual('../../../util/address'); - return { - ...actual, - getLabelTextByInternalAccount: jest.fn(), - }; -}); - -// Mock isDefaultAccountName from ENSUtils -jest.mock('../../../util/ENSUtils', () => ({ - ...jest.requireActual('../../../util/ENSUtils'), - isDefaultAccountName: jest.fn(), -})); - -const mockNavigate = jest.fn(); - -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - navigate: mockNavigate, - }), -})); - -// Mock whenEngineReady to prevent Engine access after Jest teardown -jest.mock('../../../core/Analytics/whenEngineReady', () => ({ - whenEngineReady: jest.fn().mockResolvedValue(undefined), -})); - -// Mock analytics module -jest.mock('../../../util/analytics/analytics', () => ({ - analytics: { - isEnabled: jest.fn(() => false), - trackEvent: jest.fn(), - optIn: jest.fn().mockResolvedValue(undefined), - optOut: jest.fn().mockResolvedValue(undefined), - getAnalyticsId: jest.fn().mockResolvedValue('test-analytics-id'), - identify: jest.fn(), - trackView: jest.fn(), - isOptedIn: jest.fn().mockResolvedValue(false), - }, -})); - -// Mock useAccounts -jest.mock('../../hooks/useAccounts', () => { - const useAccountsMock = jest.fn(() => ({ - accounts: [ - { - id: BUSINESS_ACCOUNT_ID, - name: 'Account 1', - address: BUSINESS_ACCOUNT, - assets: { - fiatBalance: '$3200.00\n1 ETH', - tokens: [], - }, - type: 'HD Key Tree', - yOffset: 0, - isSelected: true, - balanceError: undefined, - caipAccountId: 'eip155:0:0xC4955C0d639D99699Bfd7Ec54d9FaFEe40e4D272', - }, - { - id: PERSONAL_ACCOUNT_ID, - name: 'Account 2', - address: PERSONAL_ACCOUNT, - assets: { - fiatBalance: '$6400.00\n2 ETH', - tokens: [], - }, - type: 'HD Key Tree', - yOffset: 78, - isSelected: false, - balanceError: undefined, - caipAccountId: 'eip155:0:0xd018538C87232FF95acbCe4870629b75640a78E7', - }, - ], - evmAccounts: [], - ensByAccountAddress: {}, - })); - return { - useAccounts: useAccountsMock, - Account: Object, // Mock for the Account type - }; -}); - -// Mock Engine -jest.mock('../../../core/Engine', () => ({ - context: { - KeyringController: { - removeAccount: jest.fn(), - }, - AccountsController: { - getAccountByAddress: jest.fn().mockImplementation((address) => ({ - address, - name: - address === '0xC4955C0d639D99699Bfd7Ec54d9FaFEe40e4D272' - ? 'Account 1' - : 'Account 2', - })), - }, - PermissionController: { - state: { - subjects: {}, - }, - }, - }, - getTotalEvmFiatAccountBalance: jest.fn().mockReturnValue('0'), -})); - -const MOCK_NETWORKS_WITH_TRANSACTION_ACTIVITY = { - [BUSINESS_ACCOUNT.toLowerCase()]: { - namespace: 'eip155:0', - activeChains: ['1', '56'], - }, - [PERSONAL_ACCOUNT.toLowerCase()]: { - namespace: 'eip155:0', - activeChains: ['1', '137'], - }, -}; - -const initialState = { - engine: { - backgroundState: { - ...backgroundState, - NetworkController: { - ...mockNetworkState({ - id: 'mainnet', - nickname: 'Ethereum Mainnet', - ticker: 'ETH', - chainId: CHAIN_IDS.MAINNET, - }), - }, - AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, - AccountTreeController: { - accountTrees: { - roots: { - 'default-group': { - metadata: { - name: 'Default Group', - }, - groups: { - 'hd-accounts': { - accounts: [BUSINESS_ACCOUNT_ID, PERSONAL_ACCOUNT_ID], - }, - }, - }, - }, - }, - }, - AccountTrackerController: { - accountsByChainId: { - '0x1': { - [BUSINESS_ACCOUNT]: { balance: '0xDE0B6B3A7640000' }, - [PERSONAL_ACCOUNT]: { balance: '0x1BC16D674EC80000' }, - }, - }, - }, - PreferencesController: { - isMultiAccountBalancesEnabled: true, - selectedAddress: BUSINESS_ACCOUNT, - }, - CurrencyRateController: { - currentCurrency: 'usd', - currencyRates: { - ETH: { - conversionRate: 3200, - }, - }, - }, - MultichainNetworkController: { - networksWithTransactionActivity: - MOCK_NETWORKS_WITH_TRANSACTION_ACTIVITY, - }, - RemoteFeatureFlagController: { - remoteFeatureFlags: { - enableMultichainAccounts: { - enabled: false, - featureVersion: null, - minimumVersion: null, - }, - enableMultichainAccountsState2: { - enabled: false, - featureVersion: null, - minimumVersion: null, - }, - }, - }, - }, - }, - settings: { - primaryCurrency: 'ETH', - }, -}; - -const onSelectAccount = jest.fn(); -const onRemoveImportedAccount = jest.fn(); - -// Helper function to set mock implementation for useAccounts -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const setAccountsMock = (mockAccounts: any[], mockEnsByAccountAddress = {}) => { - const mockAccountData = { - accounts: mockAccounts, - evmAccounts: [], - ensByAccountAddress: mockEnsByAccountAddress, - }; - (useAccounts as jest.Mock).mockImplementation(() => mockAccountData); - return mockAccountData; -}; - -const defaultAccountsMock = [ - { - id: BUSINESS_ACCOUNT_ID, - name: 'Account 1', - address: BUSINESS_ACCOUNT, - assets: { - fiatBalance: '$3200.00\n1 ETH', - tokens: [], - }, - type: 'HD Key Tree', - yOffset: 0, - isSelected: true, - balanceError: undefined, - caipAccountId: `eip155:0:${BUSINESS_ACCOUNT}`, - isLoadingAccount: false, - scopes: [], - }, - { - id: PERSONAL_ACCOUNT_ID, - name: 'Account 2', - address: PERSONAL_ACCOUNT, - assets: { - fiatBalance: '$6400.00\n2 ETH', - tokens: [], - }, - type: 'HD Key Tree', - yOffset: 78, - isSelected: false, - balanceError: undefined, - caipAccountId: `eip155:0:${PERSONAL_ACCOUNT}`, - isLoadingAccount: false, - scopes: [], - }, -]; - -const EvmAccountSelectorListUseAccounts: React.FC< - EvmAccountSelectorListProps -> = ({ privacyMode = false }) => { - // Set the mock implementation for this specific component render - if (privacyMode) { - setAccountsMock(defaultAccountsMock); - } - const { accounts, ensByAccountAddress } = useAccounts(); - return ( - - ); -}; - -const RIGHT_ACCESSORY_TEST_ID = 'right-accessory'; - -const EvmAccountSelectorListRightAccessoryUseAccounts = () => { - const { accounts, ensByAccountAddress } = useAccounts(); - return ( - ( - {`${address} - ${name}`} - )} - isSelectionDisabled - selectedAddresses={[]} - accounts={accounts} - ensByAccountAddress={ensByAccountAddress} - /> - ); -}; - -const renderComponent = ( - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - state: any = {}, - EvmAccountSelectorListTest = EvmAccountSelectorListUseAccounts, -) => renderWithProvider(, { state }); - -describe('EvmAccountSelectorList', () => { - beforeEach(() => { - // Reset all mocks before each test - jest.clearAllMocks(); - - // Set default mock implementation - setAccountsMock(defaultAccountsMock); - - // Reset function mocks - onSelectAccount.mockClear(); - onRemoveImportedAccount.mockClear(); - mockNavigate.mockClear(); - (Engine.context.KeyringController.removeAccount as jest.Mock).mockClear(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('renders correctly', async () => { - const { toJSON } = renderComponent(initialState); - await waitFor(() => expect(toJSON()).toMatchSnapshot()); - }); - - it('renders all accounts with balances', async () => { - const { queryByTestId, getAllByTestId, toJSON } = - renderComponent(initialState); - - await waitFor(async () => { - const businessAccountItem = await queryByTestId( - `${AccountListBottomSheetSelectorsIDs.ACCOUNT_BALANCE_BY_ADDRESS_TEST_ID}-${BUSINESS_ACCOUNT}`, - ); - const personalAccountItem = await queryByTestId( - `${AccountListBottomSheetSelectorsIDs.ACCOUNT_BALANCE_BY_ADDRESS_TEST_ID}-${PERSONAL_ACCOUNT}`, - ); - - if (!businessAccountItem || !personalAccountItem) { - throw new Error('Account items not found'); - } - - expect( - within(businessAccountItem).getByText(regex.usd(3200)), - ).toBeDefined(); - - expect( - within(personalAccountItem).getByText(regex.usd(6400)), - ).toBeDefined(); - - const accounts = getAllByTestId(regex.accountBalance); - expect(accounts.length).toBe(2); - - expect(toJSON()).toMatchSnapshot(); - }); - }); - - it('renders all accounts with right accessory', async () => { - const { getAllByTestId, toJSON } = renderComponent( - initialState, - EvmAccountSelectorListRightAccessoryUseAccounts, - ); - - await waitFor(() => { - const rightAccessories = getAllByTestId(RIGHT_ACCESSORY_TEST_ID); - expect(rightAccessories.length).toBe(2); - - // Check that each right accessory contains the expected content - expect(rightAccessories[0].props.children).toContain(BUSINESS_ACCOUNT); - expect(rightAccessories[1].props.children).toContain(PERSONAL_ACCOUNT); - - expect(toJSON()).toMatchSnapshot(); - }); - }); - it('renders correct account names', async () => { - const { getAllByTestId } = renderComponent(initialState); - - await waitFor(() => { - const accountNameItems = getAllByTestId( - CellComponentSelectorsIDs.BASE_TITLE, - ); - expect(within(accountNameItems[0]).getByText('Account 1')).toBeDefined(); - expect(within(accountNameItems[1]).getByText('Account 2')).toBeDefined(); - }); - }); - it('renders the snap name tag for Snap accounts', async () => { - // Setup mock with snap accounts - const mockSnapAccounts = [ - { - name: 'Snap Account 1', - address: MOCK_ADDRESS_1, - assets: { - fiatBalance: '$3200.00\n1 ETH', - tokens: [], - }, - type: KeyringTypes.snap, - yOffset: 0, - isSelected: true, - balanceError: undefined, - caipAccountId: `eip155:0:${MOCK_ADDRESS_1}`, - }, - ]; - - setAccountsMock(mockSnapAccounts); - - const mockAccountsWithSnap = createMockAccountsControllerStateWithSnap( - [MOCK_ADDRESS_1, MOCK_ADDRESS_2], - 'MetaMask Simple Snap Keyring', - ); - - const stateWithSnapAccount = { - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - AccountsController: mockAccountsWithSnap, - }, - }, - }; - - const { queryByText } = renderComponent(stateWithSnapAccount); - - await waitFor(async () => { - const snapTag = await queryByText('MetaMask Simple Snap Keyring'); - expect(snapTag).toBeDefined(); - }); - }); - it('Text is hidden when privacy mode is on', async () => { - const state = { - ...initialState, - privacyMode: true, - }; - - const { queryByTestId } = renderComponent(state); - - await waitFor(() => { - const businessAccountItem = queryByTestId( - `${AccountListBottomSheetSelectorsIDs.ACCOUNT_BALANCE_BY_ADDRESS_TEST_ID}-${BUSINESS_ACCOUNT}`, - ); - - if (!businessAccountItem) { - throw new Error('Business account item not found'); - } - - expect( - within(businessAccountItem).queryByText(regex.usd(3200)), - ).toBeNull(); - - expect( - within(businessAccountItem).getByText('••••••••••••'), - ).toBeDefined(); - }); - }); - it('allows account removal for simple keyring type', async () => { - const mockAlert = jest.spyOn(Alert, 'alert'); - mockAlert.mockReset(); - mockAlert.mockImplementation( - (_title, _message, buttons?: AlertButton[]) => { - // Simulate user clicking "Yes, remove it" - buttons?.[1]?.onPress?.(); - }, - ); - - // Setup mock with removable account - const mockSimpleAccount = [ - { - name: 'Account 1', - address: BUSINESS_ACCOUNT, - assets: { - fiatBalance: '$3200.00\n1 ETH', - tokens: [], - }, - type: KeyringTypes.simple, // Important: must be simple type for removal - yOffset: 0, - isSelected: true, - balanceError: undefined, - caipAccountId: `eip155:0:${BUSINESS_ACCOUNT}`, - }, - ]; - - setAccountsMock(mockSimpleAccount); - - // Create a state with a simple keyring account - const mockAccountsWithSimple = createMockAccountsControllerState([ - BUSINESS_ACCOUNT, - ]); - const accountUuid = Object.keys( - mockAccountsWithSimple.internalAccounts.accounts, - )[0]; - mockAccountsWithSimple.internalAccounts.accounts[accountUuid].metadata = { - ...mockAccountsWithSimple.internalAccounts.accounts[accountUuid].metadata, - keyring: { - type: KeyringTypes.simple, - }, - }; - - const stateWithSimpleAccount = { - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - AccountsController: mockAccountsWithSimple, - }, - }, - }; - - const { getAllByTestId } = renderComponent(stateWithSimpleAccount); - - // Find all cell elements with the select-with-menu test ID - const cells = getAllByTestId(CellComponentSelectorsIDs.SELECT_WITH_MENU); - // Trigger long press on the first cell (since we only have one account in this test) - cells[0].props.onLongPress(); - - await waitFor(() => { - // Verify Alert was shown with correct text - expect(mockAlert).toHaveBeenCalledWith( - 'Account removal', - 'Do you really want to remove this account?', - expect.any(Array), - { cancelable: false }, - ); - }); - - // Verify onRemoveImportedAccount was called with correct parameters - expect(onRemoveImportedAccount).toHaveBeenCalledWith({ - removedAddress: BUSINESS_ACCOUNT, - }); - - // Verify KeyringController.removeAccount was called - expect(Engine.context.KeyringController.removeAccount).toHaveBeenCalledWith( - BUSINESS_ACCOUNT, - ); - - mockAlert.mockRestore(); - }); - - it('allows account removal for snap keyring type', async () => { - const mockAlert = jest.spyOn(Alert, 'alert'); - mockAlert.mockReset(); - mockAlert.mockImplementation( - (_title, _message, buttons?: AlertButton[]) => { - // Simulate user clicking "Yes, remove it" - buttons?.[1]?.onPress?.(); - }, - ); - - // Setup mock with removable snap accounts - const mockSnapAccounts = [ - { - name: 'Snap Account 1', - address: MOCK_ADDRESS_1, - assets: { - fiatBalance: '$3200.00\n1 ETH', - tokens: [], - }, - type: KeyringTypes.snap, // Important: must be snap type for removal - yOffset: 0, - isSelected: true, - balanceError: undefined, - caipAccountId: `eip155:0:${MOCK_ADDRESS_1}`, - }, - { - name: 'Snap Account 2', - address: MOCK_ADDRESS_2, - assets: { - fiatBalance: '$6400.00\n2 ETH', - tokens: [], - }, - type: KeyringTypes.snap, // Important: must be snap type for removal - yOffset: 78, - isSelected: false, - balanceError: undefined, - caipAccountId: `eip155:0:${MOCK_ADDRESS_2}`, - }, - ]; - - setAccountsMock(mockSnapAccounts); - - const mockAccountsWithSnap = createMockAccountsControllerStateWithSnap( - [MOCK_ADDRESS_1, MOCK_ADDRESS_2], - 'MetaMask Simple Snap Keyring', - ); - - const stateWithSnapAccount = { - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - AccountsController: mockAccountsWithSnap, - }, - }, - }; - - const { getAllByTestId } = renderComponent(stateWithSnapAccount); - - // Find all cell elements with the select-with-menu test ID - const cells = getAllByTestId(CellComponentSelectorsIDs.SELECT_WITH_MENU); - // Trigger long press on the first cell that should correspond to MOCK_ADDRESS_1 - cells[0].props.onLongPress(); - - // Need to wait for the Alert to be called - await waitFor(() => { - // Verify Alert was shown with correct text - expect(mockAlert).toHaveBeenCalledWith( - 'Account removal', - 'Do you really want to remove this account?', - expect.any(Array), - { cancelable: false }, - ); - }); - - // Verify onRemoveImportedAccount was called with correct parameters - expect(onRemoveImportedAccount).toHaveBeenCalledWith({ - removedAddress: MOCK_ADDRESS_1, - nextActiveAddress: MOCK_ADDRESS_2, - }); - - // Verify KeyringController.removeAccount was called - expect(Engine.context.KeyringController.removeAccount).toHaveBeenCalledWith( - MOCK_ADDRESS_1, - ); - - mockAlert.mockRestore(); - }); - - it('renders accounts with balance error', async () => { - // Create a mock account with balance error - const mockAccount = { - name: 'Account 1', - address: BUSINESS_ACCOUNT, - assets: { - fiatBalance: '$3200.00\n1 ETH', - }, - type: 'HD Key Tree', - yOffset: 0, - isSelected: true, - balanceError: 'Balance error message', - caipAccountId: `eip155:0:${BUSINESS_ACCOUNT}`, - }; - - setAccountsMock([mockAccount]); - - // Create a component that explicitly verifies the account data - let testAccounts: Account[] = []; - const EvmAccountSelectorListBalanceErrorTest = () => { - const { accounts } = useAccounts(); - // Store for verification - testAccounts = accounts; - - return ( - - ); - }; - - renderComponent(initialState, EvmAccountSelectorListBalanceErrorTest); - - // Verify the account data has the balance error - expect(testAccounts[0].balanceError).toBe('Balance error message'); - }); - - it('renders in multi-select mode', () => { - setAccountsMock([ - { - name: 'Account 1', - address: BUSINESS_ACCOUNT, - assets: { - fiatBalance: '$3200.00\n1 ETH', - }, - type: 'HD Key Tree', - yOffset: 0, - isSelected: true, - balanceError: undefined, - caipAccountId: `eip155:0:${BUSINESS_ACCOUNT}`, - }, - ]); - - // Create a test component with multi-select mode - const EvmAccountSelectorListMultiSelectTest = () => { - const { accounts, ensByAccountAddress } = useAccounts(); - return ( - - ); - }; - - // Use initialState to ensure proper AccountTreeController structure - const { getByTestId } = renderComponent( - initialState, - EvmAccountSelectorListMultiSelectTest, - ); - - // Simply check if the component renders - expect(getByTestId(ACCOUNT_SELECTOR_LIST_TESTID)).toBeDefined(); - }); - - it('renders in select-without-menu mode', async () => { - const EvmAccountSelectorListSelectWithoutMenuTest: React.FC = () => { - const { accounts, ensByAccountAddress } = useAccounts(); - return ( - - ); - }; - - const { getAllByTestId } = renderComponent( - initialState, - EvmAccountSelectorListSelectWithoutMenuTest, - ); - - await waitFor(() => { - // Find all select-without-menu cells - const cells = getAllByTestId(CellComponentSelectorsIDs.SELECT); - expect(cells.length).toBe(2); - }); - }); - - it('disables account selection when isSelectionDisabled is true', async () => { - // Clear any previous calls - onSelectAccount.mockClear(); - - const EvmAccountSelectorListDisabledSelectionTest: React.FC = () => { - const { accounts, ensByAccountAddress } = useAccounts(); - return ( - - ); - }; - - const { getAllByTestId } = renderComponent( - initialState, - EvmAccountSelectorListDisabledSelectionTest, - ); - - const cells = getAllByTestId(CellComponentSelectorsIDs.SELECT_WITH_MENU); - - // Check that all cells have the disabled prop set to true - cells.forEach((cell) => { - expect(cell.props.disabled).toBe(true); - }); - - // Since we're mocking React Native components, we can't directly test - // that clicks don't work when disabled. Instead, we'll verify the component - // has the disabled prop set, which is a better indicator of disabled state. - // Calling onPress manually here would bypass the disabled check in the real component. - }); - - it('navigates to account actions when menu button is clicked', async () => { - // Clear mocks - mockNavigate.mockClear(); - - const { getAllByTestId } = renderComponent(initialState); - - // Find buttons with the correct test ID - const actionButtons = getAllByTestId( - WalletViewSelectorsIDs.ACCOUNT_ACTIONS, - ); - expect(actionButtons.length).toBe(2); - - // Click the first account's action button - actionButtons[0].props.onPress(); - - // Verify navigation was triggered - expect(mockNavigate).toHaveBeenCalled(); - // The actual values may be different based on the component's implementation - // So we're just checking that navigation occurred, not the specific values - expect(mockNavigate).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - screen: expect.any(String), - params: expect.anything(), - }), - ); - }); - - it('should not allow account removal when long-pressed for HD Key Tree account type', async () => { - const mockAlert = jest.spyOn(Alert, 'alert'); - mockAlert.mockReset(); - - // Clear previous calls to removeAccount - (Engine.context.KeyringController.removeAccount as jest.Mock).mockClear(); - - // Mock account data that is not removable (HD Key Tree) - setAccountsMock([ - { - name: 'Account 1', - address: BUSINESS_ACCOUNT, - assets: { - fiatBalance: '$3200.00\n1 ETH', - }, - type: 'HD Key Tree', // Not a simple or snap keyring type - yOffset: 0, - isSelected: true, - balanceError: undefined, - caipAccountId: `eip155:0:${BUSINESS_ACCOUNT}`, - }, - ]); - - const { getAllByTestId } = renderComponent(initialState); - - await waitFor(() => { - const cells = getAllByTestId(CellComponentSelectorsIDs.SELECT_WITH_MENU); - expect(cells.length).toBeGreaterThan(0); - - cells[0].props.onLongPress(); - - expect(mockAlert).not.toHaveBeenCalled(); - - expect( - Engine.context.KeyringController.removeAccount, - ).not.toHaveBeenCalled(); - }); - - mockAlert.mockRestore(); - }); - - it('should not allow account removal when isRemoveAccountEnabled is false', async () => { - const mockAlert = jest.spyOn(Alert, 'alert'); - mockAlert.mockReset(); - - // Clear previous calls to removeAccount - (Engine.context.KeyringController.removeAccount as jest.Mock).mockClear(); - - // Mock account data for a simple keyring account (normally removable) - setAccountsMock([ - { - name: 'Account 1', - address: BUSINESS_ACCOUNT, - assets: { - fiatBalance: '$3200.00\n1 ETH', - }, - type: KeyringTypes.simple, // Normally removable type - yOffset: 0, - isSelected: true, - balanceError: undefined, - caipAccountId: `eip155:0:${BUSINESS_ACCOUNT}`, - }, - ]); - - const EvmAccountSelectorListNoRemoveTest: React.FC = () => { - const { accounts, ensByAccountAddress } = useAccounts(); - return ( - - ); - }; - - const { getAllByTestId } = renderComponent( - initialState, - EvmAccountSelectorListNoRemoveTest, - ); - - await waitFor(() => { - const cells = getAllByTestId(CellComponentSelectorsIDs.SELECT_WITH_MENU); - expect(cells.length).toBeGreaterThan(0); - - cells[0].props.onLongPress(); - - expect(mockAlert).not.toHaveBeenCalled(); - - expect( - Engine.context.KeyringController.removeAccount, - ).not.toHaveBeenCalled(); - }); - - mockAlert.mockRestore(); - }); - - it('should auto-scroll to selected account when isAutoScrollEnabled is true', () => { - // Create a mock for the FlatList's scrollToOffset method - const mockScrollToOffset = jest.fn(); - - // Override the scrollToOffset method by mocking the React Native FlatList - jest.mock('react-native-gesture-handler', () => { - const actual = jest.requireActual('react-native-gesture-handler'); - const FlatList = ({ - onContentSizeChange, - ...props - }: { - onContentSizeChange?: () => void; - }) => { - // Simulate the ref by providing a scrollToOffset method - setTimeout(() => { - if (onContentSizeChange) { - onContentSizeChange(); - } - }, 0); - return actual.FlatList(props); - }; - FlatList.prototype.scrollToOffset = mockScrollToOffset; - return { - ...actual, - FlatList, - }; - }); - - // Mock the account data with an account that has a selected flag - (useAccounts as jest.Mock).mockReturnValue({ - accounts: [ - { - id: 'mock-account-id-1', - name: 'Account 1', - address: BUSINESS_ACCOUNT, - assets: { fiatBalance: '$3200.00\n1 ETH' }, - type: 'HD Key Tree', - yOffset: 150, - isSelected: true, - balanceError: undefined, - caipAccountId: `eip155:0:${BUSINESS_ACCOUNT}`, - }, - ], - evmAccounts: [], - ensByAccountAddress: {}, - }); - - renderComponent(initialState); - - // Skip actually testing the scrollToOffset call since we can't - // reliably mock and test the FlatList's methods in this environment - expect(true).toBe(true); - }); - - // TODO: fix this test - it('should not auto-scroll when isAutoScrollEnabled is false', async () => { - const mockScrollToOffset = jest.fn(); - - // Create test component with auto-scroll disabled - const EvmAccountSelectorListNoAutoScrollTest: React.FC = () => { - const { accounts, ensByAccountAddress } = useAccounts(); - return ( - - ); - }; - - const { getByTestId } = renderComponent( - initialState, - EvmAccountSelectorListNoAutoScrollTest, - ); - - // Get the FlatList and trigger content size change - const flatList = getByTestId(ACCOUNT_SELECTOR_LIST_TESTID); - flatList.props.onContentSizeChange(); - - // Verify that scrollToOffset was not called - expect(mockScrollToOffset).not.toHaveBeenCalled(); - }); - - it('should display ENS name instead of account name when available', async () => { - // Access the mocked function directly from the jest mock - const mockENSUtils = jest.requireMock('../../../util/ENSUtils'); - mockENSUtils.isDefaultAccountName.mockReturnValue(true); - - // Mock accounts with ENS names - setAccountsMock( - [ - { - name: 'Account 1', // Default account name - address: BUSINESS_ACCOUNT, - assets: { - fiatBalance: '$3200.00\n1 ETH', - }, - type: 'HD Key Tree', - yOffset: 0, - isSelected: true, - balanceError: undefined, - caipAccountId: `eip155:0:${BUSINESS_ACCOUNT}`, - }, - ], - { - [BUSINESS_ACCOUNT]: 'vitalik.eth', // ENS name for the account - }, - ); - - const { getByText } = renderComponent(initialState); - - await waitFor(() => { - expect(getByText('vitalik.eth')).toBeDefined(); - }); - mockENSUtils.isDefaultAccountName.mockRestore(); - }); - - it('should use account name when ENS name is available but not a default account name', async () => { - // Access the mocked function directly from the jest mock - const mockENSUtils = jest.requireMock('../../../util/ENSUtils'); - mockENSUtils.isDefaultAccountName.mockReturnValue(false); - - // Mock accounts with a custom name - setAccountsMock( - [ - { - name: 'My Custom Account Name', - address: BUSINESS_ACCOUNT, - assets: { - fiatBalance: '$3200.00\n1 ETH', - }, - type: 'HD Key Tree', - yOffset: 0, - isSelected: true, - balanceError: undefined, - caipAccountId: `eip155:0:${BUSINESS_ACCOUNT}`, - }, - ], - { - [BUSINESS_ACCOUNT]: 'vitalik.eth', // ENS name is available - }, - ); - - const { getByText } = renderComponent(initialState); - - await waitFor(() => { - expect(getByText('My Custom Account Name')).toBeDefined(); - }); - mockENSUtils.isDefaultAccountName.mockRestore(); - }); - - it('selects an account when tapped', async () => { - // Setup with multiple accounts using the same addresses as in initialState - const mockMultipleAccounts = [ - { - name: 'Account 1', - address: BUSINESS_ACCOUNT, - assets: { - fiatBalance: '$3200.00\n1 ETH', - tokens: [], - }, - type: KeyringTypes.simple, - yOffset: 0, - isSelected: true, - balanceError: undefined, - caipAccountId: `eip155:0:${BUSINESS_ACCOUNT}`, - }, - { - name: 'Account 2', - address: PERSONAL_ACCOUNT, // Use PERSONAL_ACCOUNT instead of MOCK_ADDRESS_1 - assets: { - fiatBalance: '$6400.00\n2 ETH', - tokens: [], - }, - type: KeyringTypes.simple, - yOffset: 78, - isSelected: false, - balanceError: undefined, - caipAccountId: `eip155:0:${PERSONAL_ACCOUNT}`, - }, - ]; - - setAccountsMock(mockMultipleAccounts); - - const { getAllByTestId } = renderComponent(initialState); - - // Wait for exactly 2 cells to render (one for each account) - await waitFor(() => { - const cells = getAllByTestId(CellComponentSelectorsIDs.SELECT_WITH_MENU); - expect(cells.length).toBe(2); - }); - - const cells = getAllByTestId(CellComponentSelectorsIDs.SELECT_WITH_MENU); - - expect(cells).toHaveLength(2); - expect(cells[0]).toBeDefined(); - expect(cells[1]).toBeDefined(); - - fireEvent.press(cells[1]); - - expect(onSelectAccount).toHaveBeenCalledWith(PERSONAL_ACCOUNT, false); - }); - - it('navigates to account details when a balance error is tapped', () => { - // Setup account with balance error - const mockAccountWithError = [ - { - name: 'Error Account', - address: BUSINESS_ACCOUNT, - assets: { - fiatBalance: '$0.00\n0 ETH', - tokens: [], - }, - type: KeyringTypes.simple, - yOffset: 0, - isSelected: true, - balanceError: true, // Account has balance error - caipAccountId: `eip155:0:${BUSINESS_ACCOUNT}`, - }, - ]; - - setAccountsMock(mockAccountWithError); - - // Render the component to ensure it handles accounts with balance errors - const { getByTestId } = renderComponent(initialState); - - // Verify the component renders successfully even with balance errors - expect(getByTestId(ACCOUNT_SELECTOR_LIST_TESTID)).toBeDefined(); - }); - - it('correctly handles auto-scrolling when an account is marked with autoscroll', async () => { - // Setup accounts with one marked for auto-scroll - const mockAccountsForScroll = [ - { - name: 'Account 1', - address: BUSINESS_ACCOUNT, - assets: { - fiatBalance: '$3200.00\n1 ETH', - tokens: [], - }, - type: KeyringTypes.simple, - yOffset: 0, - isSelected: false, - balanceError: undefined, - caipAccountId: `eip155:0:${BUSINESS_ACCOUNT}`, - }, - { - name: 'Account 2', - address: MOCK_ADDRESS_1, - assets: { - fiatBalance: '$6400.00\n2 ETH', - tokens: [], - }, - type: KeyringTypes.simple, - yOffset: 78, // This is the yOffset that should be used for scrolling - isSelected: true, - balanceError: undefined, - autoScroll: true, // This account should be auto-scrolled to - caipAccountId: `eip155:0:${MOCK_ADDRESS_1}`, - }, - ]; - - setAccountsMock(mockAccountsForScroll); - - renderComponent(initialState); - - // Since we can't properly test SectionList scrolling in this environment, - // we'll just verify the component renders correctly - expect(true).toBe(true); - }); - - it('should call onSelectAccount when an account is pressed', async () => { - const { getAllByTestId } = renderComponent(initialState); - - await waitFor(() => { - const cells = getAllByTestId(CellComponentSelectorsIDs.SELECT_WITH_MENU); - expect(cells.length).toBeGreaterThan(0); - }); - - // Find all cell elements - const cells = getAllByTestId(CellComponentSelectorsIDs.SELECT_WITH_MENU); - // Select the second account - cells[1].props.onPress(); - - // Verify onSelectAccount was called with correct parameters - expect(onSelectAccount).toHaveBeenCalledWith(PERSONAL_ACCOUNT, false); - }); - - it('renders network icons for accounts with transaction activity', () => { - const { toJSON } = renderComponent(initialState); - expect(toJSON()).toMatchSnapshot(); - }); - - it('render the correct amount of network icons for accounts with transaction activity', () => { - const stateWithNetworkActivity = { - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - MultichainNetworkController: { - ...initialState.engine.backgroundState.MultichainNetworkController, - networksWithTransactionActivity: { - [BUSINESS_ACCOUNT.toLowerCase()]: { - namespace: KnownCaipNamespace.Eip155, - activeChains: ['1', '137'], - }, - [PERSONAL_ACCOUNT.toLowerCase()]: { - namespace: KnownCaipNamespace.Eip155, - activeChains: ['1'], - }, - }, - }, - }, - }, - }; - const { getAllByTestId } = renderComponent(stateWithNetworkActivity); - - const avatarGroups = getAllByTestId(AVATARGROUP_CONTAINER_TESTID); - expect(avatarGroups).toHaveLength(2); - }); - - it('does not render network icons when account has no transaction activity', () => { - const stateWithNoNetworkActivity = { - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - MultichainNetworkController: { - ...initialState.engine.backgroundState.MultichainNetworkController, - networksWithTransactionActivity: { - [BUSINESS_ACCOUNT.toLowerCase()]: { - namespace: KnownCaipNamespace.Eip155, - activeChains: [], - }, - [PERSONAL_ACCOUNT.toLowerCase()]: { - namespace: KnownCaipNamespace.Eip155, - activeChains: [], - }, - }, - }, - }, - }, - }; - - const { queryAllByTestId } = renderComponent(stateWithNoNetworkActivity); - - const avatarGroups = queryAllByTestId('network-avatar-group-container'); - expect(avatarGroups).toHaveLength(0); - }); - - // Helper to create state with multichain accounts enabled - const getMultichainState = (overrides = {}) => ({ - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - AccountTreeController: { - accountTree: { - wallets: { - wallet1: { - id: 'test-wallet-id-123', - metadata: { - name: 'HD Accounts', - }, - groups: { - group1: { - accounts: [ - Object.keys( - initialState.engine.backgroundState.AccountsController - .internalAccounts.accounts, - )[0], - Object.keys( - initialState.engine.backgroundState.AccountsController - .internalAccounts.accounts, - )[1], - ], - }, - }, - }, - }, - }, - }, - RemoteFeatureFlagController: { - remoteFeatureFlags: { - enableMultichainAccounts: { - enabled: true, - featureVersion: '1', - minimumVersion: '1.0.0', - }, - }, - }, - ...overrides, - }, - }, - }); - - it('renders sections based on AccountTreeController when multichain accounts enabled', () => { - const multichainState = getMultichainState(); - const { getByText, getAllByText } = renderComponent(multichainState); - - expect(getByText('HD Accounts')).toBeDefined(); - - expect(getAllByText(/^Account/)).toHaveLength(2); - expect(getByText('Account 1')).toBeDefined(); - expect(getByText('Account 2')).toBeDefined(); - }); - - it('navigates to multichain account details when multichain accounts enabled', () => { - const multichainState = getMultichainState(); - const { getAllByTestId } = renderComponent(multichainState); - - const accountActionsButton = getAllByTestId( - WalletViewSelectorsIDs.ACCOUNT_ACTIONS, - )[0]; - - fireEvent.press(accountActionsButton); - - const expectedAccount = - multichainState.engine.backgroundState.AccountsController.internalAccounts - .accounts[BUSINESS_ACCOUNT_ID]; - expect(mockNavigate).toHaveBeenCalledWith( - Routes.MULTICHAIN_ACCOUNTS.ACCOUNT_DETAILS, - { - account: expectedAccount, - }, - ); - }); - - it('does not render tag labels when multichain accounts enabled', () => { - jest - .requireMock('../../../util/address') - .getLabelTextByInternalAccount.mockReturnValue('Imported'); - - const multichainState = getMultichainState(); - const { queryByText } = renderComponent(multichainState); - - // Tag labels should not be rendered when multichain is enabled - // Even though getLabelTextByInternalAccount might be called, its result shouldn't be displayed - expect(queryByText('Imported')).toBeNull(); - }); - - it('renders section headers when multichain accounts enabled', () => { - const multichainState = getMultichainState(); - const { getByText } = renderComponent(multichainState); - - expect(getByText('HD Accounts')).toBeDefined(); - expect(getByText('Details')).toBeDefined(); - }); - - it('creates flattened data structure correctly for multichain accounts', () => { - const multichainState = getMultichainState(); - const { getByTestId, getByText, getAllByTestId } = - renderComponent(multichainState); - - // Verify the list is rendered - const flatList = getByTestId(ACCOUNT_SELECTOR_LIST_TESTID); - expect(flatList).toBeDefined(); - - // Verify section header is rendered - expect(getByText('HD Accounts')).toBeDefined(); - - // Verify accounts are rendered - expect(getByText('Account 1')).toBeDefined(); - expect(getByText('Account 2')).toBeDefined(); - - // Verify account cells are rendered - const accountCells = getAllByTestId( - CellComponentSelectorsIDs.SELECT_WITH_MENU, - ); - expect(accountCells.length).toBe(2); - }); - - it('renders different item types correctly', () => { - const multichainState = getMultichainState(); - const { getByText, getAllByTestId } = renderComponent(multichainState); - - // Verify header is rendered (section title) - expect(getByText('HD Accounts')).toBeDefined(); - - // Verify accounts are rendered - const accountCells = getAllByTestId( - CellComponentSelectorsIDs.SELECT_WITH_MENU, - ); - expect(accountCells.length).toBeGreaterThan(0); - - // Verify details link is rendered in header - expect(getByText('Details')).toBeDefined(); - }); - - it('handles onContentSizeChange callback correctly', () => { - // Mock accounts with selected account - setAccountsMock([ - { - name: 'Account 1', - address: BUSINESS_ACCOUNT, - assets: { fiatBalance: '$3200.00\n1 ETH' }, - type: 'HD Key Tree', - yOffset: 150, - isSelected: true, - balanceError: undefined, - caipAccountId: `eip155:0:${BUSINESS_ACCOUNT}`, - }, - ]); - - const { getByTestId } = renderComponent(initialState); - - const flatList = getByTestId(ACCOUNT_SELECTOR_LIST_TESTID); - - // Verify the component renders with onContentSizeChange prop - expect(flatList.props.onContentSizeChange).toBeDefined(); - expect(typeof flatList.props.onContentSizeChange).toBe('function'); - }); - - it('handles keyExtractor function with proper item structure', () => { - const { getByTestId } = renderComponent(initialState); - - const flatList = getByTestId(ACCOUNT_SELECTOR_LIST_TESTID); - const keyExtractor = flatList.props.keyExtractor; - - // Test that keyExtractor function exists - expect(typeof keyExtractor).toBe('function'); - - // Test key extraction for account item - const accountItem = { - type: 'account', - data: { - name: 'Test Account', - address: '0x123', - assets: { fiatBalance: '$100' }, - type: 'HD Key Tree', - yOffset: 0, - isSelected: false, - balanceError: undefined, - caipAccountId: 'eip155:0:0x123', - }, - sectionIndex: 0, - accountIndex: 0, - }; - - const key = keyExtractor(accountItem); - expect(key).toBe('0x123'); - }); - - it('creates footer items when there are multiple sections', () => { - const accountIds = Object.keys( - initialState.engine.backgroundState.AccountsController.internalAccounts - .accounts, - ); - // Create a state with multiple sections to trigger footer creation - const multiSectionState = { - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - AccountTreeController: { - accountTree: { - wallets: { - wallet1: { - metadata: { - name: 'HD Accounts', - }, - groups: { - group1: { - accounts: [accountIds[0]], - }, - group2: { - accounts: [accountIds[1]], - }, - }, - }, - wallet2: { - metadata: { - name: 'Imported Accounts', - }, - groups: { - group3: { - accounts: [], - }, - }, - }, - }, - }, - }, - RemoteFeatureFlagController: { - remoteFeatureFlags: { - enableMultichainAccounts: { - enabled: true, - featureVersion: '1', - minimumVersion: '1.0.0', - }, - }, - }, - }, - }, - }; - - const { getByTestId, getByText, queryAllByText } = - renderComponent(multiSectionState); - - // Verify the list is rendered - const flatList = getByTestId(ACCOUNT_SELECTOR_LIST_TESTID); - expect(flatList).toBeDefined(); - - // Verify multiple sections are rendered - expect(getByText('HD Accounts')).toBeDefined(); - expect(getByText('Imported Accounts')).toBeDefined(); - - // Verify multiple "Details" links for each section - const detailsLinks = queryAllByText('Details'); - expect(detailsLinks.length).toBeGreaterThan(1); - }); - - it('navigates to wallet details when section header details link is pressed', () => { - const multichainState = getMultichainState(); - const { getByText } = renderComponent(multichainState); - - const detailsLink = getByText('Details'); - fireEvent.press(detailsLink); - - expect(mockNavigate).toHaveBeenCalledWith( - Routes.MULTICHAIN_ACCOUNTS.WALLET_DETAILS, - { - walletId: 'test-wallet-id-123', - }, - ); - }); - - describe('onContentSizeChange callback logic', () => { - it('should handle scroll logic for different scenarios', () => { - const testCases = [ - { - name: 'with selectedAddresses provided', - accounts: [ - { - name: 'Account 1', - address: BUSINESS_ACCOUNT, - assets: { fiatBalance: '$3200.00\n1 ETH' }, - type: KeyringTypes.hd, - yOffset: 150, - isSelected: false, - balanceError: undefined, - caipAccountId: `eip155:0:${BUSINESS_ACCOUNT}`, - isLoadingAccount: false, - scopes: ['eip155:1'], - }, - ], - selectedAddresses: [BUSINESS_ACCOUNT], - isAutoScrollEnabled: true, - }, - { - name: 'with isSelected fallback', - accounts: [ - { - name: 'Account 1', - address: BUSINESS_ACCOUNT, - assets: { fiatBalance: '$3200.00\n1 ETH' }, - type: KeyringTypes.hd, - yOffset: 150, - isSelected: false, - balanceError: undefined, - caipAccountId: `eip155:0:${BUSINESS_ACCOUNT}`, - isLoadingAccount: false, - scopes: ['eip155:1'], - }, - { - name: 'Account 2', - address: PERSONAL_ACCOUNT, - assets: { fiatBalance: '$6400.00\n2 ETH' }, - type: KeyringTypes.hd, - yOffset: 300, - isSelected: true, - balanceError: undefined, - caipAccountId: `eip155:0:${PERSONAL_ACCOUNT}`, - isLoadingAccount: false, - scopes: ['eip155:1'], - }, - ], - selectedAddresses: [], - isAutoScrollEnabled: true, - }, - { - name: 'with no selected account', - accounts: [ - { - name: 'Account 1', - address: BUSINESS_ACCOUNT, - assets: { fiatBalance: '$3200.00\n1 ETH' }, - type: KeyringTypes.hd, - yOffset: 150, - isSelected: false, - balanceError: undefined, - caipAccountId: `eip155:0:${BUSINESS_ACCOUNT}`, - isLoadingAccount: false, - scopes: ['eip155:1'], - }, - ], - selectedAddresses: [], - isAutoScrollEnabled: true, - }, - { - name: 'with invalid address', - accounts: [ - { - name: 'Account 1', - address: BUSINESS_ACCOUNT, - assets: { fiatBalance: '$3200.00\n1 ETH' }, - type: KeyringTypes.hd, - yOffset: 150, - isSelected: false, - balanceError: undefined, - caipAccountId: `eip155:0:${BUSINESS_ACCOUNT}`, - isLoadingAccount: false, - scopes: ['eip155:1'], - }, - ], - selectedAddresses: ['0xInvalidAddress'], - isAutoScrollEnabled: true, - }, - { - name: 'with auto-scroll disabled', - accounts: [ - { - name: 'Account 1', - address: BUSINESS_ACCOUNT, - assets: { fiatBalance: '$3200.00\n1 ETH' }, - type: KeyringTypes.hd, - yOffset: 150, - isSelected: true, - balanceError: undefined, - caipAccountId: `eip155:0:${BUSINESS_ACCOUNT}`, - isLoadingAccount: false, - scopes: ['eip155:1'], - }, - ], - selectedAddresses: [BUSINESS_ACCOUNT], - isAutoScrollEnabled: false, - }, - ]; - - testCases.forEach( - ({ - accounts: testAccounts, - selectedAddresses, - isAutoScrollEnabled, - }) => { - setAccountsMock(testAccounts); - - const TestComponent: React.FC = () => { - const { accounts, ensByAccountAddress } = useAccounts(); - return ( - - ); - }; - - const { getByTestId } = renderComponent(initialState, TestComponent); - const flatList = getByTestId(ACCOUNT_SELECTOR_LIST_TESTID); - - expect(flatList.props.onContentSizeChange).toBeDefined(); - expect(typeof flatList.props.onContentSizeChange).toBe('function'); - expect(() => flatList.props.onContentSizeChange()).not.toThrow(); - }, - ); - }); - - it('should call scrollToOffset with correct parameters when conditions are met', () => { - const mockScrollToOffset = jest.fn(); - const mockRef = { current: { scrollToOffset: mockScrollToOffset } }; - - const testScrollLogic = ( - accounts: Account[], - selectedAddresses?: string[], - isAutoScrollEnabled = true, - ) => { - if (!accounts.length || !isAutoScrollEnabled) return; - - // Simulate the accountsLengthRef logic - const accountsLengthRef = { current: 0 }; - - if (accountsLengthRef.current !== accounts.length) { - let selectedAccount: Account | undefined; - - if (selectedAddresses?.length) { - const selectedAddress = selectedAddresses[0]; - selectedAccount = accounts.find( - (acc) => - acc.address.toLowerCase() === selectedAddress.toLowerCase(), - ); - } - - // Fall back to the account with isSelected flag if no override or match found - if (!selectedAccount) { - selectedAccount = accounts.find((acc) => acc.isSelected); - } - - mockRef.current?.scrollToOffset({ - offset: selectedAccount?.yOffset || 0, - animated: false, - }); - - accountsLengthRef.current = accounts.length; - } - }; - - const mockAccounts: Account[] = [ - { - id: 'mock-account-id-1', - name: 'Account 1', - address: BUSINESS_ACCOUNT, - assets: { fiatBalance: '$3200.00\n1 ETH' }, - type: KeyringTypes.hd, - yOffset: 150, - isSelected: false, - balanceError: undefined, - caipAccountId: `eip155:1:${BUSINESS_ACCOUNT}` as const, - isLoadingAccount: false, - scopes: ['eip155:1'], - }, - ]; - - // Test the logic directly - testScrollLogic(mockAccounts, [BUSINESS_ACCOUNT], true); - - // Verify scrollToOffset was called with the correct parameters - expect(mockScrollToOffset).toHaveBeenCalledWith({ - offset: 150, - animated: false, - }); - }); - }); -}); diff --git a/app/components/UI/EvmAccountSelectorList/EvmAccountSelectorList.tsx b/app/components/UI/EvmAccountSelectorList/EvmAccountSelectorList.tsx deleted file mode 100644 index 7c5b91ee58d..00000000000 --- a/app/components/UI/EvmAccountSelectorList/EvmAccountSelectorList.tsx +++ /dev/null @@ -1,590 +0,0 @@ -import React, { useCallback, useRef, useMemo, useEffect } from 'react'; -import { - Alert, - InteractionManager, - View, - ViewStyle, - TouchableOpacity, - ScrollViewProps, -} from 'react-native'; -import { ScrollView } from 'react-native-gesture-handler'; -import { CaipChainId } from '@metamask/utils'; -import { useSelector } from 'react-redux'; -import { useNavigation } from '@react-navigation/native'; -import { KeyringTypes } from '@metamask/keyring-controller'; -import { isAddress as isSolanaAddress } from '@solana/addresses'; - -import Cell, { - CellVariant, -} from '../../../component-library/components/Cells/Cell'; -import { useStyles } from '../../../component-library/hooks'; -import Text, { - TextColor, - TextVariant, -} from '../../../component-library/components/Texts/Text'; -import SensitiveText, { - SensitiveTextLength, -} from '../../../component-library/components/Texts/SensitiveText'; -import { - areAddressesEqual, - formatAddress, - getLabelTextByInternalAccount, - toFormattedAddress, -} from '../../../util/address'; -import { isDefaultAccountName } from '../../../util/ENSUtils'; -import { strings } from '../../../../locales/i18n'; -import { AvatarVariant } from '../../../component-library/components/Avatars/Avatar/Avatar.types'; -import { Account, Assets } from '../../hooks/useAccounts'; -import Engine from '../../../core/Engine'; -import { removeAccountsFromPermissions } from '../../../core/Permissions'; -import Routes from '../../../constants/navigation/Routes'; -import { selectAccountSections } from '../../../selectors/multichainAccounts/accountTreeController'; -import { selectMultichainAccountsState1Enabled } from '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts'; -import { selectAvatarAccountType } from '../../../selectors/settings'; - -import { - AccountSection, - EvmAccountSelectorListProps, - FlattenedAccountListItem, -} from './EvmAccountSelectorList.types'; -import styleSheet from './EvmAccountSelectorList.styles'; -import { AccountListBottomSheetSelectorsIDs } from '../../Views/AccountSelector/AccountListBottomSheet.testIds'; -import { WalletViewSelectorsIDs } from '../../Views/Wallet/WalletView.testIds'; -import { ACCOUNT_SELECTOR_LIST_TESTID } from './EvmAccountSelectorList.constants'; -import { toHex } from '@metamask/controller-utils'; -import AccountNetworkIndicator from '../AccountNetworkIndicator'; -import { Skeleton } from '../../../component-library/components/Skeleton'; -import { - selectInternalAccounts, - selectInternalAccountsById, -} from '../../../selectors/accountsController'; -import { AccountWalletObject } from '@metamask/account-tree-controller'; -import { FlashList, ListRenderItem, FlashListRef } from '@shopify/flash-list'; - -/** - * @deprecated This component is deprecated in favor of the CaipAccountSelectorList component. - * Functionally they should be nearly identical except that EvmAccountSelectorList expects - * Hex addressess where as CaipAccountSelectorList expects CaipAccountIds. - * - * If changes need to be made to this component, please instead make them to CaipAccountSelectorList - * and adopt that component instead. - */ -const EvmAccountSelectorList = ({ - onSelectAccount, - onRemoveImportedAccount, - accounts, - ensByAccountAddress, - isLoading = false, - selectedAddresses, - isMultiSelect = false, - isSelectWithoutMenu = false, - renderRightAccessory, - isSelectionDisabled, - isRemoveAccountEnabled = false, - isAutoScrollEnabled = true, - privacyMode = false, - ...props -}: EvmAccountSelectorListProps) => { - const { navigate } = useNavigation(); - /** - * Ref for the FlashList component. - */ - const accountListRef = useRef>(null); - const accountsLengthRef = useRef(0); - const { styles } = useStyles(styleSheet, {}); - - const accountAvatarType = useSelector(selectAvatarAccountType); - - const isMultichainAccountsState1Enabled = useSelector( - selectMultichainAccountsState1Enabled, - ); - const accountTreeSections = useSelector(selectAccountSections); - - const internalAccounts = useSelector(selectInternalAccounts); - const internalAccountsById = useSelector(selectInternalAccountsById); - - const accountSections = useMemo((): AccountSection[] => { - if (isMultichainAccountsState1Enabled) { - const accountsById = new Map(); - internalAccounts.forEach((account) => { - const accountObj = accounts.find((a) => a.id === account.id); - if (accountObj) { - accountsById.set(account.id, accountObj); - } - }); - - // Use AccountTreeController sections and match accounts to their IDs - return accountTreeSections.map((section) => ({ - title: section.title, - wallet: section.wallet, - data: section.data - .map((accountId: string) => accountsById.get(accountId)) - .filter( - (account: Account | undefined) => account !== undefined, - ) as Account[], - })); - } - // Fallback for old behavior - return accounts.length > 0 ? [{ title: 'Accounts', data: accounts }] : []; - }, [ - accounts, - isMultichainAccountsState1Enabled, - accountTreeSections, - internalAccounts, - ]); - - // Flatten sections into a single array for FlatList - const flattenedData = useMemo((): FlattenedAccountListItem[] => { - const items: FlattenedAccountListItem[] = []; - let accountIndex = 0; - - accountSections.forEach((section, sectionIndex) => { - if (isMultichainAccountsState1Enabled) { - items.push({ - type: 'header', - data: section, - sectionIndex, - }); - } - - section.data.forEach((account) => { - items.push({ - type: 'account', - data: account, - sectionIndex, - accountIndex, - }); - accountIndex++; - }); - - if ( - isMultichainAccountsState1Enabled && - sectionIndex < accountSections.length - 1 - ) { - items.push({ - type: 'footer', - data: section, - sectionIndex, - }); - } - }); - - return items; - }, [accountSections, isMultichainAccountsState1Enabled]); - - const getKeyExtractor = (item: FlattenedAccountListItem) => { - if (item.type === 'header') { - return `header-${item.sectionIndex}`; - } - if (item.type === 'footer') { - return `footer-${item.sectionIndex}`; - } - return item.data.address; - }; - - // FlashList optimization: Define item types for better recycling - const getItemType = useCallback( - (item: FlattenedAccountListItem) => item.type, - [], - ); - - const useMultichainAccountDesign = Boolean(isMultichainAccountsState1Enabled); - - const selectedAddressesLookup = useMemo(() => { - if (!selectedAddresses?.length) return undefined; - const lookupSet = new Set(); - selectedAddresses.forEach((addr) => { - if (addr) lookupSet.add(toFormattedAddress(addr)); - }); - return lookupSet; - }, [selectedAddresses]); - - const renderAccountBalances = useCallback( - ( - { fiatBalance }: Assets, - partialAccount: { address: string; scopes: CaipChainId[] }, - isLoadingAccount: boolean, - ) => { - const fiatBalanceStrSplit = fiatBalance.split('\n'); - const fiatBalanceAmount = fiatBalanceStrSplit[0] || ''; - - return ( - - {isLoadingAccount ? ( - - ) : ( - <> - - {fiatBalanceAmount} - - - - - )} - - ); - }, - [styles.balancesContainer, styles.balanceLabel, privacyMode], - ); - - const onLongPress = useCallback( - ({ - address, - isAccountRemoveable, - isSelected, - index, - }: { - address: string; - isAccountRemoveable: boolean; - isSelected: boolean; - index: number; - }) => { - if (!isAccountRemoveable || !isRemoveAccountEnabled) return; - Alert.alert( - strings('accounts.remove_account_title'), - strings('accounts.remove_account_message'), - [ - { - text: strings('accounts.no'), - onPress: () => false, - style: 'cancel', - }, - { - text: strings('accounts.yes_remove_it'), - onPress: async () => { - InteractionManager.runAfterInteractions(async () => { - // Determine which account should be active after removal - let nextActiveAddress: string; - - if (isSelected) { - // If removing the selected account, choose an adjacent one - const nextActiveIndex = index === 0 ? 1 : index - 1; - nextActiveAddress = accounts[nextActiveIndex]?.address; - } else { - // Not removing selected account, so keep current selection - nextActiveAddress = - selectedAddresses?.[0] || - accounts.find((acc) => acc.isSelected)?.address || - ''; - } - - // Switching accounts on the PreferencesController must happen before account is removed from the KeyringController, otherwise UI will break. - // If needed, place Engine.setSelectedAddress in onRemoveImportedAccount callback. - onRemoveImportedAccount?.({ - removedAddress: address, - nextActiveAddress, - }); - // Revocation of accounts from PermissionController is needed whenever accounts are removed. - // If there is an instance where this is not the case, this logic will need to be updated. - removeAccountsFromPermissions([toHex(address)]); - await Engine.context.KeyringController.removeAccount(address); - }); - }, - }, - ], - { cancelable: false }, - ); - }, - [ - accounts, - onRemoveImportedAccount, - isRemoveAccountEnabled, - selectedAddresses, - ], - ); - - const onNavigateToAccountActions = useCallback( - (selectedAccountAddress: string) => { - const account = Engine.context.AccountsController.getAccountByAddress( - selectedAccountAddress, - ); - - if (!account) return; - - navigate(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SHEET.ACCOUNT_ACTIONS, - params: { selectedAccount: account }, - }); - }, - [navigate], - ); - - const onNavigateToWalletDetails = useCallback( - (wallet: AccountWalletObject) => { - navigate(Routes.MULTICHAIN_ACCOUNTS.WALLET_DETAILS, { - walletId: wallet.id, - }); - }, - [navigate], - ); - - const renderSectionHeader = useCallback( - ({ title, wallet }: { title: string; wallet?: AccountWalletObject }) => ( - - - {title} - - wallet && onNavigateToWalletDetails(wallet)} - > - - {strings('multichain_accounts.accounts_list.details')} - - - - ), - [ - styles.sectionHeader, - styles.sectionDetailsLink, - onNavigateToWalletDetails, - ], - ); - - const renderSectionFooter = useCallback( - () => , - [styles.sectionSeparator], - ); - - const scrollToSelectedAccount = useCallback(() => { - if (!accounts.length || !isAutoScrollEnabled || !accountListRef.current) - return; - - let selectedAccount: Account | undefined; - - if (selectedAddresses?.length) { - const selectedAddressLower = selectedAddresses[0].toLowerCase(); - selectedAccount = accounts.find( - (acc) => acc.address.toLowerCase() === selectedAddressLower, - ); - } - - if (selectedAccount) { - // Find the item index for the selected account in flattened data - const selectedItemIndex = flattenedData.findIndex( - (item) => - item.type === 'account' && - areAddressesEqual(item.data.address, selectedAccount.address), - ); - - if (selectedItemIndex !== -1) { - // Use requestAnimationFrame to ensure smooth scrolling - requestAnimationFrame(() => { - accountListRef.current?.scrollToIndex({ - index: selectedItemIndex, - animated: true, - viewPosition: 0.5, // Center the item in the view - }); - }); - } - } - }, [ - accounts, - accountListRef, - selectedAddresses, - isAutoScrollEnabled, - flattenedData, - ]); - - // Scroll to selected account when selection changes or on mount - useEffect(() => { - scrollToSelectedAccount(); - }, [scrollToSelectedAccount]); - - const renderItem: ListRenderItem = useCallback( - ({ item }) => { - if (item.type === 'header') { - return renderSectionHeader(item.data); - } - - if (item.type === 'footer') { - return renderSectionFooter(); - } - - // Render account item - const { - id, - name, - address, - assets, - type, - isSelected, - balanceError, - isLoadingAccount, - } = item.data; - - const internalAccount = internalAccountsById[id]; - - const shortAddress = formatAddress(address, 'short'); - const tagLabel = isMultichainAccountsState1Enabled - ? undefined - : getLabelTextByInternalAccount(internalAccount); - const ensName = ensByAccountAddress[address]; - const accountName = - isDefaultAccountName(name) && ensName ? ensName : name; - const isDisabled = !!balanceError || isLoading || isSelectionDisabled; - let cellVariant = CellVariant.SelectWithMenu; - - if (isMultiSelect) { - cellVariant = CellVariant.MultiSelect; - } - if (isSelectWithoutMenu) { - cellVariant = CellVariant.Select; - } - let isSelectedAccount = isSelected; - if (selectedAddressesLookup) { - isSelectedAccount = selectedAddressesLookup.has( - toFormattedAddress(address), - ); - } - - const cellStyle: ViewStyle = { - opacity: isLoading ? 0.5 : 1, - }; - if (!isMultiSelect) { - cellStyle.alignItems = 'center'; - } - - const handleLongPress = () => { - onLongPress({ - address, - isAccountRemoveable: - type === KeyringTypes.simple || - (type === KeyringTypes.snap && !isSolanaAddress(address)), - isSelected: isSelectedAccount, - index: item.accountIndex, - }); - }; - - const handlePress = () => { - onSelectAccount?.(address, isSelectedAccount); - }; - - const handleButtonClick = () => { - if (useMultichainAccountDesign) { - const account = internalAccount; - - if (!account) return; - - navigate(Routes.MULTICHAIN_ACCOUNTS.ACCOUNT_DETAILS, { - account, - }); - return; - } - - onNavigateToAccountActions(address); - }; - - const buttonProps = { - onButtonClick: handleButtonClick, - buttonTestId: WalletViewSelectorsIDs.ACCOUNT_ACTIONS, - }; - - const avatarProps = { - variant: AvatarVariant.Account as const, - type: accountAvatarType, - accountAddress: address, - }; - - return ( - - {renderRightAccessory?.(address, accountName) || - (assets && - renderAccountBalances(assets, item.data, isLoadingAccount))} - - ); - }, - [ - ensByAccountAddress, - isLoading, - isSelectionDisabled, - isMultiSelect, - isSelectWithoutMenu, - selectedAddressesLookup, - accountAvatarType, - renderRightAccessory, - renderAccountBalances, - onLongPress, - onSelectAccount, - useMultichainAccountDesign, - onNavigateToAccountActions, - navigate, - styles.titleText, - isMultichainAccountsState1Enabled, - renderSectionHeader, - renderSectionFooter, - internalAccountsById, - ], - ); - - const onContentSizeChanged = useCallback(() => { - // Handle auto scroll to account - if (!accounts.length || !isAutoScrollEnabled) return; - - if (accountsLengthRef.current !== accounts.length) { - let selectedAccount: Account | undefined; - - if (selectedAddresses?.length) { - const selectedAddress = selectedAddresses[0]; - selectedAccount = accounts.find((acc) => - areAddressesEqual(acc.address, selectedAddress), - ); - } - - // Fall back to the account with isSelected flag if no override or match found - if (!selectedAccount) { - selectedAccount = accounts.find((acc) => acc.isSelected); - } - - accountListRef.current?.scrollToOffset({ - offset: selectedAccount?.yOffset || 0, - animated: false, - }); - - accountsLengthRef.current = accounts.length; - } - }, [accounts, accountListRef, selectedAddresses, isAutoScrollEnabled]); - - return ( - - - } - testID={ACCOUNT_SELECTOR_LIST_TESTID} - {...props} - /> - - ); -}; - -export default React.memo(EvmAccountSelectorList); diff --git a/app/components/UI/EvmAccountSelectorList/EvmAccountSelectorList.types.ts b/app/components/UI/EvmAccountSelectorList/EvmAccountSelectorList.types.ts deleted file mode 100644 index ecd195a07e2..00000000000 --- a/app/components/UI/EvmAccountSelectorList/EvmAccountSelectorList.types.ts +++ /dev/null @@ -1,86 +0,0 @@ -// Third party dependencies. -import React from 'react'; -import { FlashListProps } from '@shopify/flash-list'; - -// External dependencies -import { Account, UseAccounts } from '../../hooks/useAccounts'; -import { AccountWalletObject } from '@metamask/account-tree-controller'; - -type FlattenedAccountListItem = - | { type: 'header'; data: AccountSection; sectionIndex: number } - | { - type: 'account'; - data: Account; - sectionIndex: number; - accountIndex: number; - } - | { type: 'footer'; data: AccountSection; sectionIndex: number }; - -/** - * EvmAccountSelectorList props. - */ -export interface EvmAccountSelectorListProps - extends Partial>, - Omit { - /** - * Optional callback to trigger when account is selected. - */ - onSelectAccount?: (address: string, isSelected: boolean) => void; - /** - * Optional callback to trigger when imported account is removed. - */ - onRemoveImportedAccount?: (params: { - removedAddress: string; - nextActiveAddress: string; - }) => void; - /** - * Optional boolean that indicates if accounts are being processed in the background. The accounts will be unselectable as long as this is true. - * @default false - */ - isLoading?: boolean; - /** - * Optional list of selected addresses that will be used to show selected accounts. - * Scenarios where this can be used includes temporarily showing one or more selected accounts. - * This is required for multi select to work since the list does not track selected accounts by itself. - */ - selectedAddresses?: string[]; - /** - * Optional boolean that indicates if list should be used as multi select. - */ - isMultiSelect?: boolean; - /** - * Optional boolean that indicates if list should be used as select without menu. - */ - isSelectWithoutMenu?: boolean; - /** - * Optional boolean that indicates if list should auto scroll to selected address. - */ - isAutoScrollEnabled?: boolean; - /** - * Optional render function to replace the right accessory of each account element. - */ - renderRightAccessory?: ( - accountAddress: string, - accountName: string, - ) => React.ReactNode; - /** - * Optional boolean to disable selection of the account elements. - */ - isSelectionDisabled?: boolean; - /** - * Optional boolean to enable removing accounts. - */ - isRemoveAccountEnabled?: boolean; - /** - * Optional boolean to indicate if privacy mode is enabled. - */ - privacyMode?: boolean; -} - -export interface AccountSection { - title: string; - wallet?: AccountWalletObject; - data: Account[]; -} - -export type { FlattenedAccountListItem }; diff --git a/app/components/UI/EvmAccountSelectorList/__snapshots__/EvmAccountSelectorList.test.tsx.snap b/app/components/UI/EvmAccountSelectorList/__snapshots__/EvmAccountSelectorList.test.tsx.snap deleted file mode 100644 index b33c6036516..00000000000 --- a/app/components/UI/EvmAccountSelectorList/__snapshots__/EvmAccountSelectorList.test.tsx.snap +++ /dev/null @@ -1,2213 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EvmAccountSelectorList renders all accounts with balances 1`] = ` - - - - - - - - - - - - - - - - - - - Account 1 - - - - 0xC4955...4D272 - - - - - - - $3200.00 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Account 2 - - - - 0xd0185...a78E7 - - - - - - - $6400.00 - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[`EvmAccountSelectorList renders all accounts with right accessory 1`] = ` - - - - - - - - - - - - - - - - - - - Account 1 - - - - 0xC4955...4D272 - - - - - - 0xC4955C0d639D99699Bfd7Ec54d9FaFEe40e4D272 - Account 1 - - - - - - - - - - - - - - - - - - - - - - - - - Account 2 - - - - 0xd0185...a78E7 - - - - - - 0xd018538C87232FF95acbCe4870629b75640a78E7 - Account 2 - - - - - - - - - - - - - - - - - - -`; - -exports[`EvmAccountSelectorList renders correctly 1`] = ` - - - - - - - - - - - - - - - - - - - Account 1 - - - - 0xC4955...4D272 - - - - - - - $3200.00 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Account 2 - - - - 0xd0185...a78E7 - - - - - - - $6400.00 - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[`EvmAccountSelectorList renders network icons for accounts with transaction activity 1`] = ` - - - - - - - - - - - - - - - - - - - Account 1 - - - - 0xC4955...4D272 - - - - - - - $3200.00 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Account 2 - - - - 0xd0185...a78E7 - - - - - - - $6400.00 - - - - - - - - - - - - - - - - - - - - - - -`; diff --git a/app/components/UI/EvmAccountSelectorList/index.ts b/app/components/UI/EvmAccountSelectorList/index.ts deleted file mode 100644 index 77db49739d2..00000000000 --- a/app/components/UI/EvmAccountSelectorList/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './EvmAccountSelectorList'; -export type { EvmAccountSelectorListProps } from './EvmAccountSelectorList.types'; diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index 0df028aaaff..0756aba20f7 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -48,12 +48,6 @@ import HeaderBase, { } from '../../../component-library/components/HeaderBase'; import getHeaderCompactStandardNavbarOptions from '../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions'; import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader'; -import AvatarToken from '../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; -import { AvatarSize } from '../../../component-library/components/Avatars/Avatar'; -import BadgeNetwork from '../../../component-library/components/Badges/Badge/variants/BadgeNetwork'; -import BadgeWrapperComponent, { - BadgePosition, -} from '../../../component-library/components/Badges/BadgeWrapper'; import AddressCopy from '../AddressCopy'; import PickerAccount from '../../../component-library/components/Pickers/PickerAccount'; import { createAccountSelectorNavDetails } from '../../../components/Views/AccountSelector'; @@ -74,7 +68,6 @@ import { import { withMetaMetrics } from '../Stake/utils/metaMetrics/withMetaMetrics'; import { BridgeViewMode } from '../Bridge/types'; import CardButton from '../Card/components/CardButton'; -import { Skeleton } from '../../../component-library/components/Skeleton'; const styles = StyleSheet.create({ hitSlop: { @@ -1955,146 +1948,6 @@ export function getDeFiProtocolPositionDetailsNavbarOptions(navigation) { }; } -/** - * Function that returns the navigation options for the Ramps Build Quote screen - * - * @param {Object} navigation - Navigation object required to navigate between screens - * @param {Object} options - Options for the navbar - * @param {string} [options.tokenName] - Name of the selected token (used for avatar) - * @param {string} [options.tokenSymbol] - Symbol/ticker of the selected token (e.g., "ETH") - * @param {string} [options.tokenIconUrl] - URL for the token icon - * @param {string} [options.networkName] - Name of the network - * @param {Object} [options.networkImageSource] - Image source for the network icon - * @param {Function} [options.onSettingsPress] - Callback for settings button press - * @param {Function} [options.onBackPress] - Callback for back button press - * @returns {Object} - Navigation options object - */ -export function getRampsBuildQuoteNavbarOptions( - navigation, - { - tokenName, - tokenSymbol, - tokenIconUrl, - networkName, - networkImageSource, - onSettingsPress, - onBackPress, - } = {}, -) { - const innerStyles = StyleSheet.create({ - centerContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 16, - }, - labelsContainer: { - gap: 0, - marginTop: -2, - }, - backButton: { - marginLeft: 16, - }, - skeletonAvatar: { - borderRadius: 20, - }, - skeletonTitle: { - borderRadius: 4, - }, - skeletonSubtitle: { - borderRadius: 4, - marginTop: 4, - }, - }); - - const isLoading = !tokenName || !tokenSymbol || !networkName; - - return { - header: () => ( - { - onBackPress?.(); - navigation.goBack(); - }} - size={ButtonIconSize.Md} - iconName={IconName.ArrowLeft} - iconColor={IconColor.Default} - testID="build-quote-back-button" - /> - } - endAccessory={ - - } - > - - {isLoading ? ( - <> - - - - - - - ) : ( - <> - - } - > - - - - - {strings('fiat_on_ramp.buy', { ticker: tokenSymbol })} - - - {strings('fiat_on_ramp.on_network', { networkName })} - - - - )} - - - ), - }; -} - export function getRampsOrderDetailsNavbarOptions( navigation, { title, showBack = true }, diff --git a/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx b/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx index 96ba0ac4dfb..adacb60285c 100644 --- a/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx +++ b/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react-native'; import PredictActionButtons from './PredictActionButtons'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; import { PredictMarket, PredictOutcome, @@ -79,7 +80,7 @@ const createMockGameMarket = (): PredictMarket => name: 'Seattle Seahawks', logo: 'https://example.com/sea.png', abbreviation: 'SEA', - color: '#002244', + color: TEST_HEX_COLORS.TEAM_SEA, alias: 'Seahawks', }, homeTeam: { @@ -87,7 +88,7 @@ const createMockGameMarket = (): PredictMarket => name: 'Denver Broncos', logo: 'https://example.com/den.png', abbreviation: 'DEN', - color: '#FB4F14', + color: TEST_HEX_COLORS.TEAM_DEN, alias: 'Broncos', }, }, diff --git a/app/components/UI/Predict/components/PredictActionButtons/PredictBetButton.test.tsx b/app/components/UI/Predict/components/PredictActionButtons/PredictBetButton.test.tsx index 4ef21ec3254..c0b4c8dc111 100644 --- a/app/components/UI/Predict/components/PredictActionButtons/PredictBetButton.test.tsx +++ b/app/components/UI/Predict/components/PredictActionButtons/PredictBetButton.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react-native'; import PredictBetButton from './PredictBetButton'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; const createDefaultProps = (overrides = {}) => ({ label: 'Yes', @@ -42,7 +43,7 @@ describe('PredictBetButton', () => { const props = createDefaultProps({ label: 'SEA', price: 49, - teamColor: '#002244', + teamColor: TEST_HEX_COLORS.TEAM_SEA, }); renderWithProvider(); @@ -115,7 +116,7 @@ describe('PredictBetButton', () => { describe('team color styling', () => { it('renders with team color background when teamColor is provided', () => { - const props = createDefaultProps({ teamColor: '#002244' }); + const props = createDefaultProps({ teamColor: TEST_HEX_COLORS.TEAM_SEA }); renderWithProvider(); diff --git a/app/components/UI/Predict/components/PredictActionButtons/PredictBetButtons.test.tsx b/app/components/UI/Predict/components/PredictActionButtons/PredictBetButtons.test.tsx index 0082953e3c7..f7d72266169 100644 --- a/app/components/UI/Predict/components/PredictActionButtons/PredictBetButtons.test.tsx +++ b/app/components/UI/Predict/components/PredictActionButtons/PredictBetButtons.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react-native'; import PredictBetButtons from './PredictBetButtons'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; const createDefaultProps = (overrides = {}) => ({ yesLabel: 'Yes', @@ -107,8 +108,8 @@ describe('PredictBetButtons', () => { const props = createDefaultProps({ yesLabel: 'SEA', noLabel: 'DEN', - yesTeamColor: '#002244', - noTeamColor: '#FB4F14', + yesTeamColor: TEST_HEX_COLORS.TEAM_SEA, + noTeamColor: TEST_HEX_COLORS.TEAM_DEN, }); renderWithProvider(); diff --git a/app/components/UI/Predict/components/PredictAmountDisplay/PredictAmountDisplay.test.tsx b/app/components/UI/Predict/components/PredictAmountDisplay/PredictAmountDisplay.test.tsx index 5b990f8bef2..ae95548fc6f 100644 --- a/app/components/UI/Predict/components/PredictAmountDisplay/PredictAmountDisplay.test.tsx +++ b/app/components/UI/Predict/components/PredictAmountDisplay/PredictAmountDisplay.test.tsx @@ -3,20 +3,12 @@ import React from 'react'; import { PerpsAmountDisplaySelectorsIDs } from '../../../Perps/Perps.testIds'; import PredictAmountDisplay from './PredictAmountDisplay'; -jest.mock('../../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - text: { - default: '#141618', - alternative: '#9fa6ae', - }, - primary: { - default: '#037DD6', - }, - }, - themeAppearance: 'light', - }), -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useTheme: jest.fn(() => mockTheme), + }; +}); describe('PredictAmountDisplay', () => { beforeEach(() => { diff --git a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx index a0ceee7db5b..972197fd160 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx +++ b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import PredictDetailsChart, { ChartSeries } from './PredictDetailsChart'; +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; jest.mock('react-native-svg-charts', () => ({ LineChart: jest.fn(({ children, data, svg, ...props }) => { @@ -69,31 +70,18 @@ jest.mock('d3-shape', () => ({ })), })); -jest.mock('../../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - success: { - default: '#28C76F', - muted: '#28C76F80', - }, - text: { - default: '#000000', - }, - border: { - muted: '#E0E0E0', - }, - background: { - default: '#FFFFFF', - }, - }, - }), -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useTheme: jest.fn(() => mockTheme), + }; +}); describe('PredictDetailsChart', () => { const mockSingleSeries: ChartSeries[] = [ { label: 'Outcome 1', - color: '#28C76F', + color: TEST_HEX_COLORS.CHART_SUCCESS, data: [ { timestamp: 1640995200000, value: 0.5 }, { timestamp: 1640998800000, value: 0.6 }, @@ -106,7 +94,7 @@ describe('PredictDetailsChart', () => { const mockMultipleSeries: ChartSeries[] = [ { label: 'Outcome A', - color: '#4459FF', + color: TEST_HEX_COLORS.CHART_PRIMARY, data: [ { timestamp: 1640995200000, value: 0.5 }, { timestamp: 1640998800000, value: 0.6 }, @@ -116,7 +104,7 @@ describe('PredictDetailsChart', () => { }, { label: 'Outcome B', - color: '#CA3542', + color: TEST_HEX_COLORS.CHART_ERROR, data: [ { timestamp: 1640995200000, value: 0.3 }, { timestamp: 1640998800000, value: 0.2 }, @@ -189,7 +177,7 @@ describe('PredictDetailsChart', () => { it('renders empty state when no data provided', () => { const { queryByText } = setupTest({ - data: [{ label: 'Empty', color: '#000', data: [] }], + data: [{ label: 'Empty', color: TEST_HEX_COLORS.PURE_BLACK, data: [] }], isLoading: false, }); @@ -199,7 +187,7 @@ describe('PredictDetailsChart', () => { it('renders custom empty label when provided', () => { const customLabel = 'No data available'; const { getByText } = setupTest({ - data: [{ label: 'Empty', color: '#000', data: [] }], + data: [{ label: 'Empty', color: TEST_HEX_COLORS.PURE_BLACK, data: [] }], emptyLabel: customLabel, }); @@ -235,7 +223,7 @@ describe('PredictDetailsChart', () => { ...mockMultipleSeries, { label: 'Outcome C', - color: '#F0B034', + color: TEST_HEX_COLORS.CHART_WARNING, data: [ { timestamp: 1640995200000, value: 0.1 }, { timestamp: 1640998800000, value: 0.15 }, @@ -243,7 +231,7 @@ describe('PredictDetailsChart', () => { }, { label: 'Outcome D', - color: '#00FF00', + color: TEST_HEX_COLORS.PURE_GREEN, data: [ { timestamp: 1640995200000, value: 0.05 }, { timestamp: 1640998800000, value: 0.1 }, @@ -293,7 +281,7 @@ describe('PredictDetailsChart', () => { const singlePointData: ChartSeries[] = [ { label: 'Outcome', - color: '#28C76F', + color: TEST_HEX_COLORS.CHART_SUCCESS, data: [{ timestamp: 1640995200000, value: 0.5 }], }, ]; @@ -309,7 +297,7 @@ describe('PredictDetailsChart', () => { const emptyData: ChartSeries[] = [ { label: 'Empty', - color: '#28C76F', + color: TEST_HEX_COLORS.CHART_SUCCESS, data: [], }, ]; @@ -333,7 +321,7 @@ describe('PredictDetailsChart', () => { const sameValueData: ChartSeries[] = [ { label: 'Same', - color: '#28C76F', + color: TEST_HEX_COLORS.CHART_SUCCESS, data: [ { timestamp: 1640995200000, value: 0.5 }, { timestamp: 1640998800000, value: 0.5 }, @@ -363,7 +351,7 @@ describe('PredictDetailsChart', () => { const largeData: ChartSeries[] = [ { label: 'Large', - color: '#28C76F', + color: TEST_HEX_COLORS.CHART_SUCCESS, data: [ { timestamp: 1640995200000, value: 1000000 }, { timestamp: 1640998800000, value: 2000000 }, @@ -379,7 +367,7 @@ describe('PredictDetailsChart', () => { const negativeData: ChartSeries[] = [ { label: 'Negative', - color: '#28C76F', + color: TEST_HEX_COLORS.CHART_SUCCESS, data: [ { timestamp: 1640995200000, value: -0.5 }, { timestamp: 1640998800000, value: -0.3 }, @@ -395,7 +383,7 @@ describe('PredictDetailsChart', () => { const largeDataset: ChartSeries[] = [ { label: 'Large Dataset', - color: '#28C76F', + color: TEST_HEX_COLORS.CHART_SUCCESS, data: Array.from({ length: 100 }, (_, i) => ({ timestamp: 1640995200000 + i * 3600000, value: Math.random(), @@ -449,7 +437,7 @@ describe('PredictDetailsChart', () => { const longLabelSeries: ChartSeries[] = [ { label: 'This is a very long outcome label that exceeds limit', - color: '#4459FF', + color: TEST_HEX_COLORS.CHART_PRIMARY, data: [ { timestamp: 1640995200000, value: 0.5 }, { timestamp: 1640998800000, value: 0.6 }, @@ -457,7 +445,7 @@ describe('PredictDetailsChart', () => { }, { label: 'Short Label', - color: '#FF6B6B', + color: TEST_HEX_COLORS.CHART_CORAL, data: [ { timestamp: 1640995200000, value: 0.3 }, { timestamp: 1640998800000, value: 0.4 }, @@ -478,7 +466,7 @@ describe('PredictDetailsChart', () => { const specialCharSeries: ChartSeries[] = [ { label: 'Outcome #1 (Test) - Result', - color: '#4459FF', + color: TEST_HEX_COLORS.CHART_PRIMARY, data: [ { timestamp: 1640995200000, value: 0.5 }, { timestamp: 1640998800000, value: 0.6 }, @@ -486,7 +474,7 @@ describe('PredictDetailsChart', () => { }, { label: 'Normal Label', - color: '#FF6B6B', + color: TEST_HEX_COLORS.CHART_CORAL, data: [ { timestamp: 1640995200000, value: 0.3 }, { timestamp: 1640998800000, value: 0.4 }, @@ -523,7 +511,7 @@ describe('PredictDetailsChart', () => { const crossingSeries: ChartSeries[] = [ { label: 'Series A', - color: '#4459FF', + color: TEST_HEX_COLORS.CHART_PRIMARY, data: [ { timestamp: 1, value: 0.3 }, { timestamp: 2, value: 0.7 }, @@ -531,7 +519,7 @@ describe('PredictDetailsChart', () => { }, { label: 'Series B', - color: '#FF6B6B', + color: TEST_HEX_COLORS.CHART_CORAL, data: [ { timestamp: 1, value: 0.7 }, { timestamp: 2, value: 0.3 }, @@ -550,7 +538,7 @@ describe('PredictDetailsChart', () => { const closeSeries: ChartSeries[] = [ { label: 'Close A', - color: '#4459FF', + color: TEST_HEX_COLORS.CHART_PRIMARY, data: [ { timestamp: 1, value: 0.5 }, { timestamp: 2, value: 0.501 }, @@ -558,7 +546,7 @@ describe('PredictDetailsChart', () => { }, { label: 'Close B', - color: '#FF6B6B', + color: TEST_HEX_COLORS.CHART_CORAL, data: [ { timestamp: 1, value: 0.502 }, { timestamp: 2, value: 0.503 }, @@ -586,7 +574,7 @@ describe('PredictDetailsChart', () => { const fullRangeSeries: ChartSeries[] = [ { label: 'Full Range', - color: '#4459FF', + color: TEST_HEX_COLORS.CHART_PRIMARY, data: Array.from({ length: 50 }, (_, i) => ({ timestamp: 1640995200000 + i * 3600000, value: 0.3 + (i / 50) * 0.4, // Values from 0.3 to 0.7 @@ -613,12 +601,12 @@ describe('PredictDetailsChart', () => { const coloredSeries: ChartSeries[] = [ { label: 'Blue', - color: '#0000FF', + color: TEST_HEX_COLORS.PURE_BLUE, data: [{ timestamp: 1, value: 0.5 }], }, { label: 'Red', - color: '#FF0000', + color: TEST_HEX_COLORS.PURE_RED, data: [{ timestamp: 1, value: 0.5 }], }, ]; @@ -643,7 +631,7 @@ describe('PredictDetailsChart', () => { const threeSeries: ChartSeries[] = [ { label: 'Series 1', - color: '#4459FF', + color: TEST_HEX_COLORS.CHART_PRIMARY, data: [ { timestamp: 1, value: 0.5 }, { timestamp: 2, value: 0.6 }, @@ -651,7 +639,7 @@ describe('PredictDetailsChart', () => { }, { label: 'Series 2', - color: '#FF6B6B', + color: TEST_HEX_COLORS.CHART_CORAL, data: [ { timestamp: 1, value: 0.3 }, { timestamp: 2, value: 0.4 }, @@ -659,7 +647,7 @@ describe('PredictDetailsChart', () => { }, { label: 'Series 3', - color: '#F0B034', + color: TEST_HEX_COLORS.CHART_WARNING, data: [ { timestamp: 1, value: 0.2 }, { timestamp: 2, value: 0.25 }, @@ -722,7 +710,7 @@ describe('PredictDetailsChart', () => { data: [ { label: 'Dedup Series', - color: '#123456', + color: TEST_HEX_COLORS.EXAMPLE, data: axisData, }, ], diff --git a/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.test.tsx b/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.test.tsx index 45a9d9f2235..d668892c613 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.test.tsx +++ b/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import renderWithProvider from '../../../../../../util/test/renderWithProvider'; import ChartLegend from './ChartLegend'; import { ChartSeries } from '../PredictDetailsChart'; +import { TEST_HEX_COLORS } from '../../../testUtils/mockColors'; jest.mock('../utils', () => ({ formatTickValue: jest.fn((value: number, range: number) => { @@ -22,7 +23,7 @@ describe('ChartLegend', () => { const mockSingleSeries: ChartSeries[] = [ { label: 'Outcome A', - color: '#4459FF', + color: TEST_HEX_COLORS.CHART_PRIMARY, data: [ { timestamp: 1640995200000, value: 0.5 }, { timestamp: 1640998800000, value: 0.6 }, @@ -34,7 +35,7 @@ describe('ChartLegend', () => { const mockMultipleSeries: ChartSeries[] = [ { label: 'Outcome A', - color: '#4459FF', + color: TEST_HEX_COLORS.CHART_PRIMARY, data: [ { timestamp: 1640995200000, value: 0.5 }, { timestamp: 1640998800000, value: 0.6 }, @@ -43,7 +44,7 @@ describe('ChartLegend', () => { }, { label: 'Outcome B', - color: '#FF6B6B', + color: TEST_HEX_COLORS.CHART_CORAL, data: [ { timestamp: 1640995200000, value: 0.3 }, { timestamp: 1640998800000, value: 0.2 }, @@ -132,7 +133,7 @@ describe('ChartLegend', () => { const seriesWithEmptyData: ChartSeries[] = [ { label: 'Empty Series', - color: '#4459FF', + color: TEST_HEX_COLORS.CHART_PRIMARY, data: [], }, ]; @@ -203,7 +204,7 @@ describe('ChartLegend', () => { const seriesWithEmptyData: ChartSeries[] = [ { label: 'Empty', - color: '#4459FF', + color: TEST_HEX_COLORS.CHART_PRIMARY, data: [], }, ]; @@ -217,7 +218,7 @@ describe('ChartLegend', () => { const seriesWithSinglePoint: ChartSeries[] = [ { label: 'Single Point', - color: '#4459FF', + color: TEST_HEX_COLORS.CHART_PRIMARY, data: [{ timestamp: 1640995200000, value: 0.42 }], }, ]; @@ -250,7 +251,7 @@ describe('ChartLegend', () => { const seriesWithSmallValues: ChartSeries[] = [ { label: 'Small', - color: '#4459FF', + color: TEST_HEX_COLORS.CHART_PRIMARY, data: [{ timestamp: 1640995200000, value: 0.001 }], }, ]; @@ -267,7 +268,7 @@ describe('ChartLegend', () => { const seriesWithLargeValues: ChartSeries[] = [ { label: 'Large', - color: '#4459FF', + color: TEST_HEX_COLORS.CHART_PRIMARY, data: [{ timestamp: 1640995200000, value: 999.99 }], }, ]; @@ -303,7 +304,7 @@ describe('ChartLegend', () => { const seriesWithDifferentLengths: ChartSeries[] = [ { label: 'Long', - color: '#4459FF', + color: TEST_HEX_COLORS.CHART_PRIMARY, data: [ { timestamp: 1, value: 0.1 }, { timestamp: 2, value: 0.2 }, @@ -312,7 +313,7 @@ describe('ChartLegend', () => { }, { label: 'Short', - color: '#FF6B6B', + color: TEST_HEX_COLORS.CHART_CORAL, data: [{ timestamp: 1, value: 0.5 }], }, ]; diff --git a/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.test.tsx b/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.test.tsx index 59dfe195ba7..ca6acbdeb7a 100644 --- a/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.test.tsx +++ b/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.test.tsx @@ -8,27 +8,12 @@ import { PREDICT_GTM_MODAL_SHOWN } from '../../../../../constants/storage'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../../util/test/initial-root-state'; -const mockTheme = { - colors: { - background: { - default: '#ffffff', - alternative: '#f2f4f6', - }, - text: { - default: '#24272a', - }, - shadow: { - default: '#000000', - }, - accent02: { - light: '#EAC2FF', - }, - }, -}; - -jest.mock('../../../../../util/theme', () => ({ - useTheme: () => mockTheme, -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useTheme: jest.fn(() => mockTheme), + }; +}); jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string) => key, diff --git a/app/components/UI/Predict/components/PredictGameChart/ChartTooltip.test.tsx b/app/components/UI/Predict/components/PredictGameChart/ChartTooltip.test.tsx index 453a8fb6f7c..f68c44d9fac 100644 --- a/app/components/UI/Predict/components/PredictGameChart/ChartTooltip.test.tsx +++ b/app/components/UI/Predict/components/PredictGameChart/ChartTooltip.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { render } from '@testing-library/react-native'; import ChartTooltip from './ChartTooltip'; import { GameChartSeries, GameChartDataPoint } from './PredictGameChart.types'; +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; jest.mock('react-native-svg', () => { const { View, Text } = jest.requireActual('react-native'); @@ -44,14 +45,12 @@ jest.mock('react-native-svg', () => { }; }); -jest.mock('../../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - text: { alternative: '#888888', default: '#000000' }, - background: { default: '#FFFFFF' }, - }, - }), -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useTheme: jest.fn(() => mockTheme), + }; +}); const mockXFunction = (index: number) => index * 10 + 50; const mockYFunction = (value: number) => 200 - value * 2; @@ -65,12 +64,12 @@ const mockPrimaryData: GameChartDataPoint[] = [ const mockNonEmptySeries: GameChartSeries[] = [ { label: 'Team A', - color: '#FF0000', + color: TEST_HEX_COLORS.PURE_RED, data: mockPrimaryData, }, { label: 'Team B', - color: '#0000FF', + color: TEST_HEX_COLORS.PURE_BLUE, data: [ { timestamp: 1704067200000, value: 50 }, { timestamp: 1704070800000, value: 45 }, @@ -193,12 +192,12 @@ describe('ChartTooltip', () => { const closeValuesSeries: GameChartSeries[] = [ { label: 'Team A', - color: '#FF0000', + color: TEST_HEX_COLORS.PURE_RED, data: [{ timestamp: 1704067200000, value: 50 }], }, { label: 'Team B', - color: '#0000FF', + color: TEST_HEX_COLORS.PURE_BLUE, data: [{ timestamp: 1704067200000, value: 51 }], }, ]; @@ -220,12 +219,12 @@ describe('ChartTooltip', () => { const farApartSeries: GameChartSeries[] = [ { label: 'Team A', - color: '#FF0000', + color: TEST_HEX_COLORS.PURE_RED, data: [{ timestamp: 1704067200000, value: 20 }], }, { label: 'Team B', - color: '#0000FF', + color: TEST_HEX_COLORS.PURE_BLUE, data: [{ timestamp: 1704067200000, value: 80 }], }, ]; @@ -247,12 +246,12 @@ describe('ChartTooltip', () => { const firstAboveSeries: GameChartSeries[] = [ { label: 'Team A', - color: '#FF0000', + color: TEST_HEX_COLORS.PURE_RED, data: [{ timestamp: 1704067200000, value: 70 }], }, { label: 'Team B', - color: '#0000FF', + color: TEST_HEX_COLORS.PURE_BLUE, data: [{ timestamp: 1704067200000, value: 71 }], }, ]; @@ -273,12 +272,12 @@ describe('ChartTooltip', () => { const firstBelowSeries: GameChartSeries[] = [ { label: 'Team A', - color: '#FF0000', + color: TEST_HEX_COLORS.PURE_RED, data: [{ timestamp: 1704067200000, value: 31 }], }, { label: 'Team B', - color: '#0000FF', + color: TEST_HEX_COLORS.PURE_BLUE, data: [{ timestamp: 1704067200000, value: 30 }], }, ]; @@ -301,12 +300,12 @@ describe('ChartTooltip', () => { const partialSeries: GameChartSeries[] = [ { label: 'Team A', - color: '#FF0000', + color: TEST_HEX_COLORS.PURE_RED, data: mockPrimaryData, }, { label: 'Team B', - color: '#0000FF', + color: TEST_HEX_COLORS.PURE_BLUE, data: [{ timestamp: 1704067200000, value: 50 }], }, ]; diff --git a/app/components/UI/Predict/components/PredictGameChart/EndpointDots.test.tsx b/app/components/UI/Predict/components/PredictGameChart/EndpointDots.test.tsx index 2ddb156cd88..1629efab437 100644 --- a/app/components/UI/Predict/components/PredictGameChart/EndpointDots.test.tsx +++ b/app/components/UI/Predict/components/PredictGameChart/EndpointDots.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { render } from '@testing-library/react-native'; import EndpointDots from './EndpointDots'; import { GameChartSeries } from './PredictGameChart.types'; +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; jest.mock('react-native-svg', () => { const { View, Text } = jest.requireActual('react-native'); @@ -33,13 +34,12 @@ jest.mock('react-native-svg', () => { }; }); -jest.mock('../../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - text: { default: '#000000' }, - }, - }), -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useTheme: jest.fn(() => mockTheme), + }; +}); const mockXFunction = (index: number) => index * 10 + 50; const mockYFunction = (value: number) => 200 - value * 2; @@ -47,7 +47,7 @@ const mockYFunction = (value: number) => 200 - value * 2; const mockNonEmptySeries: GameChartSeries[] = [ { label: 'Team A', - color: '#FF0000', + color: TEST_HEX_COLORS.PURE_RED, data: [ { timestamp: 1704067200000, value: 50 }, { timestamp: 1704070800000, value: 55 }, @@ -56,7 +56,7 @@ const mockNonEmptySeries: GameChartSeries[] = [ }, { label: 'Team B', - color: '#0000FF', + color: TEST_HEX_COLORS.PURE_BLUE, data: [ { timestamp: 1704067200000, value: 50 }, { timestamp: 1704070800000, value: 45 }, @@ -151,7 +151,7 @@ describe('EndpointDots', () => { it('handles series with empty data array', () => { const emptyDataSeries: GameChartSeries[] = [ - { label: 'Empty', color: '#000', data: [] }, + { label: 'Empty', color: TEST_HEX_COLORS.PURE_BLACK, data: [] }, ]; const { queryAllByTestId } = render( @@ -177,7 +177,7 @@ describe('EndpointDots', () => { const differentLengthSeries: GameChartSeries[] = [ { label: 'Primary', - color: '#FF0000', + color: TEST_HEX_COLORS.PURE_RED, data: [ { timestamp: 1704067200000, value: 50 }, { timestamp: 1704070800000, value: 55 }, @@ -188,7 +188,7 @@ describe('EndpointDots', () => { }, { label: 'Overlay', - color: '#0000FF', + color: TEST_HEX_COLORS.PURE_BLUE, data: [ { timestamp: 1704067200000, value: 50 }, { timestamp: 1704070800000, value: 45 }, @@ -226,12 +226,12 @@ describe('EndpointDots', () => { const closeValuesSeries: GameChartSeries[] = [ { label: 'Team A', - color: '#FF0000', + color: TEST_HEX_COLORS.PURE_RED, data: [{ timestamp: 1704067200000, value: 50 }], }, { label: 'Team B', - color: '#0000FF', + color: TEST_HEX_COLORS.PURE_BLUE, data: [{ timestamp: 1704067200000, value: 51 }], }, ]; @@ -248,12 +248,12 @@ describe('EndpointDots', () => { const farApartSeries: GameChartSeries[] = [ { label: 'Team A', - color: '#FF0000', + color: TEST_HEX_COLORS.PURE_RED, data: [{ timestamp: 1704067200000, value: 20 }], }, { label: 'Team B', - color: '#0000FF', + color: TEST_HEX_COLORS.PURE_BLUE, data: [{ timestamp: 1704067200000, value: 80 }], }, ]; @@ -270,12 +270,12 @@ describe('EndpointDots', () => { const firstAboveSeries: GameChartSeries[] = [ { label: 'Team A', - color: '#FF0000', + color: TEST_HEX_COLORS.PURE_RED, data: [{ timestamp: 1704067200000, value: 71 }], }, { label: 'Team B', - color: '#0000FF', + color: TEST_HEX_COLORS.PURE_BLUE, data: [{ timestamp: 1704067200000, value: 70 }], }, ]; @@ -291,12 +291,12 @@ describe('EndpointDots', () => { const firstBelowSeries: GameChartSeries[] = [ { label: 'Team A', - color: '#FF0000', + color: TEST_HEX_COLORS.PURE_RED, data: [{ timestamp: 1704067200000, value: 30 }], }, { label: 'Team B', - color: '#0000FF', + color: TEST_HEX_COLORS.PURE_BLUE, data: [{ timestamp: 1704067200000, value: 31 }], }, ]; @@ -314,7 +314,7 @@ describe('EndpointDots', () => { const decimalSeries: GameChartSeries[] = [ { label: 'Team A', - color: '#FF0000', + color: TEST_HEX_COLORS.PURE_RED, data: [{ timestamp: 1704067200000, value: 55.7 }], }, ]; diff --git a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.test.tsx b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.test.tsx index 5abe968b7ff..09c40a62ed3 100644 --- a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.test.tsx +++ b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.test.tsx @@ -3,6 +3,7 @@ import { fireEvent } from '@testing-library/react-native'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import PredictGameChartContent from './PredictGameChartContent'; import { GameChartSeries } from './PredictGameChart.types'; +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; jest.mock('react-native-svg-charts', () => { const { View, Text } = jest.requireActual('react-native'); @@ -58,20 +59,16 @@ jest.mock('d3-shape', () => ({ curveStepAfter: 'step-after-curve', })); -jest.mock('../../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - primary: { default: '#0376C9' }, - background: { default: '#FFFFFF' }, - border: { muted: '#E0E0E0' }, - text: { muted: '#9CA3AF', default: '#1A1A1A', alternative: '#6B7280' }, - }, - }), -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useTheme: jest.fn(() => mockTheme), + }; +}); const mockAwayTeamData: GameChartSeries = { label: 'SEA', - color: '#002244', + color: TEST_HEX_COLORS.TEAM_SEA, data: [ { timestamp: 1000, value: 50 }, { timestamp: 2000, value: 55 }, @@ -82,7 +79,7 @@ const mockAwayTeamData: GameChartSeries = { const mockHomeTeamData: GameChartSeries = { label: 'DEN', - color: '#FB4F14', + color: TEST_HEX_COLORS.TEAM_DEN, data: [ { timestamp: 1000, value: 50 }, { timestamp: 2000, value: 45 }, @@ -134,7 +131,7 @@ describe('PredictGameChartContent (Chart UI)', () => { it('renders empty state when data has empty series', () => { const emptySeriesData: GameChartSeries[] = [ - { label: 'Empty', color: '#000', data: [] }, + { label: 'Empty', color: TEST_HEX_COLORS.PURE_BLACK, data: [] }, ]; const { getByText } = renderWithProvider( , @@ -247,7 +244,11 @@ describe('PredictGameChartContent (Chart UI)', () => { it('limits series to maximum of 2', () => { const threeSeries: GameChartSeries[] = [ ...mockDualSeriesData, - { label: 'Extra', color: '#000', data: [{ timestamp: 1, value: 50 }] }, + { + label: 'Extra', + color: TEST_HEX_COLORS.PURE_BLACK, + data: [{ timestamp: 1, value: 50 }], + }, ]; const { getAllByTestId } = renderWithProvider( @@ -340,7 +341,7 @@ describe('PredictGameChartContent (Chart UI)', () => { const extremeData: GameChartSeries[] = [ { label: 'Extreme', - color: '#000', + color: TEST_HEX_COLORS.PURE_BLACK, data: [ { timestamp: 1, value: 5 }, { timestamp: 2, value: 95 }, @@ -359,7 +360,7 @@ describe('PredictGameChartContent (Chart UI)', () => { it('renders empty state when series has no data points', () => { const emptyData: GameChartSeries[] = [ - { label: 'Empty', color: '#000', data: [] }, + { label: 'Empty', color: TEST_HEX_COLORS.PURE_BLACK, data: [] }, ]; const { getByText } = renderWithProvider( @@ -385,7 +386,7 @@ describe('PredictGameChartContent (Chart UI)', () => { const sameValueData: GameChartSeries[] = [ { label: 'SEA', - color: '#002244', + color: TEST_HEX_COLORS.TEAM_SEA, data: [ { timestamp: 1, value: 50 }, { timestamp: 2, value: 50 }, @@ -394,7 +395,7 @@ describe('PredictGameChartContent (Chart UI)', () => { }, { label: 'DEN', - color: '#FB4F14', + color: TEST_HEX_COLORS.TEAM_DEN, data: [ { timestamp: 1, value: 50 }, { timestamp: 2, value: 50 }, @@ -461,7 +462,7 @@ describe('PredictGameChartContent (Chart UI)', () => { const largeDataset: GameChartSeries[] = [ { label: 'SEA', - color: '#002244', + color: TEST_HEX_COLORS.TEAM_SEA, data: Array.from({ length: 100 }, (_, i) => ({ timestamp: i * 1000, value: 30 + (i % 10) * 4, // Deterministic: cycles 30, 34, 38... 66, 30, 34... @@ -469,7 +470,7 @@ describe('PredictGameChartContent (Chart UI)', () => { }, { label: 'DEN', - color: '#FB4F14', + color: TEST_HEX_COLORS.TEAM_DEN, data: Array.from({ length: 100 }, (_, i) => ({ timestamp: i * 1000, value: 70 - (i % 10) * 4, // Deterministic: cycles 70, 66, 62... 34, 70, 66... @@ -488,7 +489,7 @@ describe('PredictGameChartContent (Chart UI)', () => { const inverseData: GameChartSeries[] = [ { label: 'SEA', - color: '#002244', + color: TEST_HEX_COLORS.TEAM_SEA, data: [ { timestamp: 1, value: 70 }, { timestamp: 2, value: 60 }, @@ -497,7 +498,7 @@ describe('PredictGameChartContent (Chart UI)', () => { }, { label: 'DEN', - color: '#FB4F14', + color: TEST_HEX_COLORS.TEAM_DEN, data: [ { timestamp: 1, value: 30 }, { timestamp: 2, value: 40 }, diff --git a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.wrapper.test.tsx b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.wrapper.test.tsx index bfc7f450f6d..0d595714504 100644 --- a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.wrapper.test.tsx +++ b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.wrapper.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; import { render, act, waitFor } from '@testing-library/react-native'; import PredictGameChart from './PredictGameChart'; import { usePredictPriceHistory } from '../../hooks/usePredictPriceHistory'; @@ -71,7 +72,7 @@ const mockBaseGame = { id: 'team-home', name: 'Team B', abbreviation: 'TB', - color: '#0000FF', + color: TEST_HEX_COLORS.PURE_BLUE, alias: 'Team B', logo: 'https://example.com/logo-b.png', }, @@ -79,7 +80,7 @@ const mockBaseGame = { id: 'team-away', name: 'Team A', abbreviation: 'TA', - color: '#FF0000', + color: TEST_HEX_COLORS.PURE_RED, alias: 'Team A', logo: 'https://example.com/logo-a.png', }, @@ -219,7 +220,7 @@ describe('PredictGameChart Wrapper', () => { expect(data).toHaveLength(2); expect(data[0].label).toBe('TA'); - expect(data[0].color).toBe('#FF0000'); + expect(data[0].color).toBe(TEST_HEX_COLORS.PURE_RED); expect(data[0].data).toHaveLength(3); expect(data[0].data[0].value).toBe(60); }); diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.test.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.test.tsx index 4cd59de58e0..f3fa5f95f4c 100644 --- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.test.tsx +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; import { render, fireEvent } from '@testing-library/react-native'; import PredictGameDetailsContent from './PredictGameDetailsContent'; import { PredictMarket, PredictMarketStatus } from '../../types'; @@ -164,7 +165,7 @@ const mockBaseGame = { id: 'team-home', name: 'Team A', abbreviation: 'TA', - color: '#FF0000', + color: TEST_HEX_COLORS.PURE_RED, alias: 'Team A', logo: 'https://example.com/logo-a.png', }, @@ -172,7 +173,7 @@ const mockBaseGame = { id: 'team-away', name: 'Team B', abbreviation: 'TB', - color: '#0000FF', + color: TEST_HEX_COLORS.PURE_BLUE, alias: 'Team B', logo: 'https://example.com/logo-b.png', }, diff --git a/app/components/UI/Predict/components/PredictGameDetailsFooter/PredictGameDetailsFooter.test.tsx b/app/components/UI/Predict/components/PredictGameDetailsFooter/PredictGameDetailsFooter.test.tsx index a52f05b92ff..3335cb16f0e 100644 --- a/app/components/UI/Predict/components/PredictGameDetailsFooter/PredictGameDetailsFooter.test.tsx +++ b/app/components/UI/Predict/components/PredictGameDetailsFooter/PredictGameDetailsFooter.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; import { fireEvent, screen } from '@testing-library/react-native'; import PredictGameDetailsFooter from './PredictGameDetailsFooter'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; @@ -92,7 +93,7 @@ const createMockGameMarket = (): PredictMarket => name: 'Seattle Seahawks', logo: 'https://example.com/sea.png', abbreviation: 'SEA', - color: '#002244', + color: TEST_HEX_COLORS.TEAM_SEA, alias: 'Seahawks', }, homeTeam: { @@ -100,7 +101,7 @@ const createMockGameMarket = (): PredictMarket => name: 'Denver Broncos', logo: 'https://example.com/den.png', abbreviation: 'DEN', - color: '#FB4F14', + color: TEST_HEX_COLORS.TEAM_DEN, alias: 'Broncos', }, }, diff --git a/app/components/UI/Predict/components/PredictMarket/PredictMarket.test.tsx b/app/components/UI/Predict/components/PredictMarket/PredictMarket.test.tsx index e3ee0516332..b69ff884779 100644 --- a/app/components/UI/Predict/components/PredictMarket/PredictMarket.test.tsx +++ b/app/components/UI/Predict/components/PredictMarket/PredictMarket.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; import { backgroundState } from '../../../../../util/test/initial-root-state'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import { @@ -175,7 +176,7 @@ const mockNflMarket: PredictMarketType = { name: 'Denver Broncos', logo: 'https://example.com/broncos.png', abbreviation: 'DEN', - color: '#FC4C02', + color: TEST_HEX_COLORS.TEAM_ALT_ORANGE, alias: 'Broncos', }, awayTeam: { @@ -183,7 +184,7 @@ const mockNflMarket: PredictMarketType = { name: 'Seattle Seahawks', logo: 'https://example.com/seahawks.png', abbreviation: 'SEA', - color: '#002244', + color: TEST_HEX_COLORS.TEAM_SEA, alias: 'Seahawks', }, }, diff --git a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx index 892265b91ee..4f77ae673b7 100644 --- a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx +++ b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx @@ -1,5 +1,6 @@ -import { fireEvent } from '@testing-library/react-native'; +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; import { backgroundState } from '../../../../../util/test/initial-root-state'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import { Recurrence, PredictMarket as PredictMarketType } from '../../types'; @@ -122,7 +123,7 @@ const mockMarket: PredictMarketType = { name: 'Seattle Seahawks', logo: '', abbreviation: 'SEA', - color: '#002244', + color: TEST_HEX_COLORS.TEAM_SEA, alias: 'Seahawks', }, homeTeam: { @@ -130,7 +131,7 @@ const mockMarket: PredictMarketType = { name: 'Denver Broncos', logo: '', abbreviation: 'DEN', - color: '#FB4F14', + color: TEST_HEX_COLORS.TEAM_DEN, alias: 'Broncos', }, }, diff --git a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.test.tsx b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.test.tsx index e7aca7af7e5..296207ebf47 100644 --- a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.test.tsx +++ b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; import { fireEvent } from '@testing-library/react-native'; import { backgroundState } from '../../../../../util/test/initial-root-state'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; @@ -104,7 +105,7 @@ const mockMarket: PredictMarketType = { name: 'Team A', logo: '', abbreviation: 'TA', - color: '#FF0000', + color: TEST_HEX_COLORS.PURE_RED, alias: 'Team A', }, homeTeam: { @@ -112,7 +113,7 @@ const mockMarket: PredictMarketType = { name: 'Team B', logo: '', abbreviation: 'TB', - color: '#0000FF', + color: TEST_HEX_COLORS.PURE_BLUE, alias: 'Team B', }, }, diff --git a/app/components/UI/Predict/components/PredictSportFootballIcon/PredictSportFootballIcon.test.tsx b/app/components/UI/Predict/components/PredictSportFootballIcon/PredictSportFootballIcon.test.tsx index 11d499ef7a1..5bb991e94f1 100644 --- a/app/components/UI/Predict/components/PredictSportFootballIcon/PredictSportFootballIcon.test.tsx +++ b/app/components/UI/Predict/components/PredictSportFootballIcon/PredictSportFootballIcon.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; import { render } from '@testing-library/react-native'; import Svg from 'react-native-svg'; import PredictSportFootballIcon from './PredictSportFootballIcon'; @@ -31,7 +32,7 @@ describe('PredictSportFootballIcon', () => { }); it('renders football icon with custom color prop', () => { - const customColor = '#FF0000'; + const customColor = TEST_HEX_COLORS.PURE_RED; const { getByTestId } = render( , @@ -83,7 +84,7 @@ describe('PredictSportFootballIcon', () => { }); it('applies custom hex color when color prop is provided', () => { - const customColor = '#FF5733'; + const customColor = TEST_HEX_COLORS.CUSTOM_ORANGE; const { getByTestId } = render( , @@ -105,7 +106,7 @@ describe('PredictSportFootballIcon', () => { describe('edge cases', () => { it('applies hex color with alpha channel', () => { - const colorWithAlpha = '#FF0000FF'; + const colorWithAlpha = TEST_HEX_COLORS.PURE_RED_ALPHA; const { getByTestId } = render( , @@ -115,7 +116,7 @@ describe('PredictSportFootballIcon', () => { }); it('applies short hex color format', () => { - const shortHexColor = '#F00'; + const shortHexColor = TEST_HEX_COLORS.PURE_RED_SHORT; const { getByTestId } = render( , diff --git a/app/components/UI/Predict/components/PredictSportScoreboard/PredictSportScoreboard.test.tsx b/app/components/UI/Predict/components/PredictSportScoreboard/PredictSportScoreboard.test.tsx index b0e885467b0..660b452ed54 100644 --- a/app/components/UI/Predict/components/PredictSportScoreboard/PredictSportScoreboard.test.tsx +++ b/app/components/UI/Predict/components/PredictSportScoreboard/PredictSportScoreboard.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; import { render } from '@testing-library/react-native'; import PredictSportScoreboard from './PredictSportScoreboard'; import { PredictMarketGame, PredictGameStatus } from '../../types'; @@ -58,7 +59,7 @@ const createGame = ( name: 'Denver Broncos', logo: 'https://example.com/den.png', abbreviation: 'DEN', - color: '#FB4F14', + color: TEST_HEX_COLORS.TEAM_DEN, alias: 'Broncos', }, awayTeam: { @@ -66,7 +67,7 @@ const createGame = ( name: 'Seattle Seahawks', logo: 'https://example.com/sea.png', abbreviation: 'SEA', - color: '#002244', + color: TEST_HEX_COLORS.TEAM_SEA, alias: 'Seahawks', }, ...overrides, diff --git a/app/components/UI/Predict/components/PredictSportTeamGradient/PredictSportTeamGradient.test.tsx b/app/components/UI/Predict/components/PredictSportTeamGradient/PredictSportTeamGradient.test.tsx index d1337961ad9..9ea67d09dd8 100644 --- a/app/components/UI/Predict/components/PredictSportTeamGradient/PredictSportTeamGradient.test.tsx +++ b/app/components/UI/Predict/components/PredictSportTeamGradient/PredictSportTeamGradient.test.tsx @@ -3,14 +3,15 @@ import { render } from '@testing-library/react-native'; import { Text } from 'react-native'; import LinearGradient from 'react-native-linear-gradient'; import PredictSportTeamGradient from './PredictSportTeamGradient'; +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; jest.mock('react-native-linear-gradient', () => 'LinearGradient'); describe('PredictSportTeamGradient', () => { describe('rendering', () => { it('renders gradient with team colors', () => { - const awayColor = '#002244'; - const homeColor = '#FB4F14'; + const awayColor = TEST_HEX_COLORS.TEAM_SEA; + const homeColor = TEST_HEX_COLORS.TEAM_DEN; const { getByTestId } = render( { }); it('renders gradient with NFL team colors', () => { - const seattleBlue = '#002244'; - const seattleGreen = '#69BE28'; + const seattleBlue = TEST_HEX_COLORS.TEAM_SEA; + const seattleGreen = TEST_HEX_COLORS.TEAM_SEA_GREEN; const { getByTestId } = render( { describe('gradient colors', () => { it('applies 20% opacity to 6-character hex colors', () => { - const awayColor = '#002244'; - const homeColor = '#FB4F14'; + const awayColor = TEST_HEX_COLORS.TEAM_SEA; + const homeColor = TEST_HEX_COLORS.TEAM_DEN; const { UNSAFE_getAllByType } = render( { }); it('applies 20% opacity to uppercase hex colors', () => { - const awayColor = '#ABCDEF'; - const homeColor = '#123456'; + const awayColor = TEST_HEX_COLORS.EXAMPLE_LIGHT; + const homeColor = TEST_HEX_COLORS.EXAMPLE; const { UNSAFE_getAllByType } = render( { }); it('applies 20% opacity to 3-character hex colors', () => { - const awayColor = '#F00'; - const homeColor = '#0F0'; + const awayColor = TEST_HEX_COLORS.PURE_RED_SHORT; + const homeColor = TEST_HEX_COLORS.PURE_GREEN_SHORT; const { UNSAFE_getAllByType } = render( { }); it('applies 20% opacity to 8-character hex colors with existing alpha', () => { - const awayColor = '#002244FF'; - const homeColor = '#FB4F1480'; + const awayColor = TEST_HEX_COLORS.TEAM_SEA_ALPHA; + const homeColor = TEST_HEX_COLORS.TEAM_DEN_ALPHA; const { UNSAFE_getAllByType } = render( { }); it('applies 20% opacity to 4-character hex colors with existing alpha', () => { - const awayColor = '#F00F'; - const homeColor = '#0F08'; + const awayColor = TEST_HEX_COLORS.PURE_RED_SHORT_ALPHA; + const homeColor = TEST_HEX_COLORS.PURE_GREEN_SHORT_ALPHA; const { UNSAFE_getAllByType } = render( { describe('gradient direction', () => { it('configures 45 degree gradient with diagonal direction', () => { - const awayColor = '#002244'; - const homeColor = '#FB4F14'; + const awayColor = TEST_HEX_COLORS.TEAM_SEA; + const homeColor = TEST_HEX_COLORS.TEAM_DEN; const { UNSAFE_getAllByType } = render( { const childText = 'Game Content'; const { getByText } = render( - + {childText} , ); @@ -203,7 +207,10 @@ describe('PredictSportTeamGradient', () => { it('renders multiple children inside gradient', () => { const { getByText } = render( - + Team A Team B Score @@ -218,8 +225,8 @@ describe('PredictSportTeamGradient', () => { it('renders gradient without children', () => { const { getByTestId } = render( , ); @@ -234,8 +241,8 @@ describe('PredictSportTeamGradient', () => { const { getByTestId } = render( , @@ -253,8 +260,8 @@ describe('PredictSportTeamGradient', () => { const { getByTestId } = render( , @@ -269,7 +276,7 @@ describe('PredictSportTeamGradient', () => { describe('edge cases', () => { it('renders gradient with identical away and home colors', () => { - const sameColor = '#002244'; + const sameColor = TEST_HEX_COLORS.TEAM_SEA; const { UNSAFE_getAllByType } = render( { }); it('renders gradient with 3-character hex colors', () => { - const awayColor = '#FFF'; - const homeColor = '#000'; + const awayColor = TEST_HEX_COLORS.WHITE_SHORT; + const homeColor = TEST_HEX_COLORS.PURE_BLACK; const { UNSAFE_getAllByType } = render( { }); it('renders gradient with lowercase hex colors', () => { - const awayColor = '#abc123'; - const homeColor = '#def456'; + const awayColor = TEST_HEX_COLORS.EXAMPLE_LOWER_ABC123; + const homeColor = TEST_HEX_COLORS.EXAMPLE_LOWER_DEF456; const { UNSAFE_getAllByType } = render( { }); it('renders gradient with uppercase hex colors', () => { - const awayColor = '#ABC123'; - const homeColor = '#DEF456'; + const awayColor = TEST_HEX_COLORS.EXAMPLE_UPPER_ABC123; + const homeColor = TEST_HEX_COLORS.EXAMPLE_UPPER_DEF456; const { UNSAFE_getAllByType } = render( { it('wraps content in relative positioned container', () => { const { getByTestId } = render( , ); @@ -372,8 +379,8 @@ describe('PredictSportTeamGradient', () => { it('positions gradient absolutely within container', () => { const { UNSAFE_getAllByType } = render( Content @@ -387,8 +394,8 @@ describe('PredictSportTeamGradient', () => { describe('memoization', () => { it('memoizes gradient colors when props remain unchanged', () => { - const awayColor = '#002244'; - const homeColor = '#FB4F14'; + const awayColor = TEST_HEX_COLORS.TEAM_SEA; + const homeColor = TEST_HEX_COLORS.TEAM_DEN; const { UNSAFE_getAllByType, rerender } = render( { }); it('recalculates gradient colors when away color changes', () => { - const homeColor = '#FB4F14'; + const homeColor = TEST_HEX_COLORS.TEAM_DEN; const { UNSAFE_getAllByType, rerender } = render( , @@ -428,7 +435,7 @@ describe('PredictSportTeamGradient', () => { rerender( , @@ -441,12 +448,12 @@ describe('PredictSportTeamGradient', () => { }); it('recalculates gradient colors when home color changes', () => { - const awayColor = '#002244'; + const awayColor = TEST_HEX_COLORS.TEAM_SEA; const { UNSAFE_getAllByType, rerender } = render( , ); @@ -456,7 +463,7 @@ describe('PredictSportTeamGradient', () => { rerender( , ); diff --git a/app/components/UI/Predict/components/PredictSportTeamHelmet/PredictSportTeamHelmet.test.tsx b/app/components/UI/Predict/components/PredictSportTeamHelmet/PredictSportTeamHelmet.test.tsx index 1059df688cf..11520b15904 100644 --- a/app/components/UI/Predict/components/PredictSportTeamHelmet/PredictSportTeamHelmet.test.tsx +++ b/app/components/UI/Predict/components/PredictSportTeamHelmet/PredictSportTeamHelmet.test.tsx @@ -2,11 +2,12 @@ import React from 'react'; import { render } from '@testing-library/react-native'; import Svg, { G } from 'react-native-svg'; import PredictSportTeamHelmet from './PredictSportTeamHelmet'; +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; describe('PredictSportTeamHelmet', () => { describe('rendering', () => { it('renders helmet with required color prop', () => { - const teamColor = '#002244'; + const teamColor = TEST_HEX_COLORS.TEAM_SEA; const { getByTestId } = render( , @@ -16,7 +17,7 @@ describe('PredictSportTeamHelmet', () => { }); it('renders helmet with team-specific color', () => { - const customTeamColor = '#1D4E9B'; + const customTeamColor = TEST_HEX_COLORS.TEAM_NE; const { getByTestId } = render( , @@ -31,7 +32,10 @@ describe('PredictSportTeamHelmet', () => { const defaultSize = 48; const { getByTestId } = render( - , + , ); const svg = getByTestId('helmet'); @@ -46,7 +50,11 @@ describe('PredictSportTeamHelmet', () => { [80, '80px'], ])('renders helmet at %s size', (size) => { const { getByTestId } = render( - , + , ); const svg = getByTestId('helmet'); @@ -61,7 +69,7 @@ describe('PredictSportTeamHelmet', () => { const { getByTestId } = render( , @@ -75,7 +83,7 @@ describe('PredictSportTeamHelmet', () => { const { getByTestId } = render( , @@ -86,7 +94,10 @@ describe('PredictSportTeamHelmet', () => { it('renders helmet facing right when flipped prop is omitted', () => { const { getByTestId } = render( - , + , ); expect(getByTestId('helmet')).toBeOnTheScreen(); @@ -95,7 +106,7 @@ describe('PredictSportTeamHelmet', () => { describe('edge cases', () => { it('renders helmet with hex color including alpha channel', () => { - const colorWithAlpha = '#002244FF'; + const colorWithAlpha = TEST_HEX_COLORS.TEAM_SEA_ALPHA; const { getByTestId } = render( , @@ -105,7 +116,7 @@ describe('PredictSportTeamHelmet', () => { }); it('renders helmet with short hex color format', () => { - const shortHexColor = '#FFF'; + const shortHexColor = TEST_HEX_COLORS.WHITE_SHORT; const { getByTestId } = render( , @@ -129,7 +140,7 @@ describe('PredictSportTeamHelmet', () => { const { getByTestId } = render( , @@ -145,7 +156,7 @@ describe('PredictSportTeamHelmet', () => { const { getByTestId } = render( , @@ -163,7 +174,7 @@ describe('PredictSportTeamHelmet', () => { const { UNSAFE_getByType } = render( , @@ -175,7 +186,11 @@ describe('PredictSportTeamHelmet', () => { it('applies scale transform when flipped is true', () => { const { UNSAFE_getAllByType } = render( - , + , ); const gElements = UNSAFE_getAllByType(G); @@ -192,7 +207,7 @@ describe('PredictSportTeamHelmet', () => { it('omits transform when flipped is false', () => { const { UNSAFE_getAllByType } = render( , diff --git a/app/components/UI/Predict/components/PredictSportWinner/PredictSportWinner.test.tsx b/app/components/UI/Predict/components/PredictSportWinner/PredictSportWinner.test.tsx index 21475b3810a..2c3b032bb90 100644 --- a/app/components/UI/Predict/components/PredictSportWinner/PredictSportWinner.test.tsx +++ b/app/components/UI/Predict/components/PredictSportWinner/PredictSportWinner.test.tsx @@ -2,11 +2,12 @@ import React from 'react'; import { render } from '@testing-library/react-native'; import Svg from 'react-native-svg'; import PredictSportWinner from './PredictSportWinner'; +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; describe('PredictSportWinner', () => { describe('rendering', () => { it('renders trophy with required color prop', () => { - const trophyColor = '#FFD700'; + const trophyColor = TEST_HEX_COLORS.GOLD; const { getByTestId } = render( , @@ -16,7 +17,7 @@ describe('PredictSportWinner', () => { }); it('renders trophy with team-specific color', () => { - const customTeamColor = '#1D4E9B'; + const customTeamColor = TEST_HEX_COLORS.TEAM_NE; const { getByTestId } = render( , @@ -31,7 +32,7 @@ describe('PredictSportWinner', () => { const defaultSize = 16; const { getByTestId } = render( - , + , ); const svg = getByTestId('trophy'); @@ -46,7 +47,11 @@ describe('PredictSportWinner', () => { [24, '24px'], ])('renders trophy at %s size', (size) => { const { getByTestId } = render( - , + , ); const svg = getByTestId('trophy'); @@ -57,7 +62,7 @@ describe('PredictSportWinner', () => { describe('edge cases', () => { it('renders trophy with hex color including alpha channel', () => { - const colorWithAlpha = '#FFD700FF'; + const colorWithAlpha = TEST_HEX_COLORS.GOLD_ALPHA; const { getByTestId } = render( , @@ -67,7 +72,7 @@ describe('PredictSportWinner', () => { }); it('renders trophy with short hex color format', () => { - const shortHexColor = '#FFF'; + const shortHexColor = TEST_HEX_COLORS.WHITE_SHORT; const { getByTestId } = render( , @@ -91,7 +96,7 @@ describe('PredictSportWinner', () => { const { getByTestId } = render( , @@ -106,7 +111,11 @@ describe('PredictSportWinner', () => { const largeSize = 64; const { getByTestId } = render( - , + , ); const svg = getByTestId('trophy'); @@ -121,7 +130,7 @@ describe('PredictSportWinner', () => { const { UNSAFE_getByType } = render( , diff --git a/app/components/UI/Predict/hooks/usePredictClaim.test.ts b/app/components/UI/Predict/hooks/usePredictClaim.test.ts index 440f02562d4..8157d5a49b4 100644 --- a/app/components/UI/Predict/hooks/usePredictClaim.test.ts +++ b/app/components/UI/Predict/hooks/usePredictClaim.test.ts @@ -1,4 +1,5 @@ import { NavigationProp } from '@react-navigation/native'; +import { TEST_HEX_COLORS as mockTestHexColors } from '../testUtils/mockColors'; import { renderHook } from '@testing-library/react-hooks'; import React from 'react'; import { strings } from '../../../../../locales/i18n'; @@ -33,10 +34,10 @@ jest.mock('../../../../util/theme', () => ({ useAppThemeFromContext: jest.fn(() => ({ colors: { error: { - default: '#ca3542', + default: mockTestHexColors.ERROR_DARK, }, accent04: { - normal: '#89b0ff', + normal: mockTestHexColors.ACCENT_BLUE, }, }, })), @@ -201,8 +202,8 @@ describe('usePredictClaim', () => { }, ], iconName: IconName.Error, - iconColor: '#ca3542', - backgroundColor: '#89b0ff', + iconColor: mockTestHexColors.ERROR_DARK, + backgroundColor: mockTestHexColors.ACCENT_BLUE, hasNoTimeout: false, linkButtonOptions: { label: strings('predict.claim.toasts.error.try_again'), diff --git a/app/components/UI/Predict/hooks/usePredictDeposit.test.ts b/app/components/UI/Predict/hooks/usePredictDeposit.test.ts index 02fed6799f3..b8b64ccf785 100644 --- a/app/components/UI/Predict/hooks/usePredictDeposit.test.ts +++ b/app/components/UI/Predict/hooks/usePredictDeposit.test.ts @@ -32,14 +32,12 @@ jest.mock('../../../../util/Logger', () => ({ error: jest.fn(), })); -jest.mock('../../../../util/theme', () => ({ - useAppThemeFromContext: () => ({ - colors: { - error: { default: '#FF0000' }, - accent04: { normal: '#0000FF' }, - }, - }), -})); +jest.mock('../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../util/theme'); + return { + useAppThemeFromContext: () => mockTheme, + }; +}); jest.mock('../../../../component-library/components/Toast', () => { const actualReact = jest.requireActual('react'); diff --git a/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx b/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx index ef8a52f8623..da140bd3f1b 100644 --- a/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx +++ b/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx @@ -1,3 +1,4 @@ +import { TEST_HEX_COLORS as mockTestHexColors } from '../testUtils/mockColors'; import { act, renderHook } from '@testing-library/react-hooks'; import Routes from '../../../../constants/navigation/Routes'; @@ -37,9 +38,9 @@ jest.mock('@react-navigation/native', () => ({ jest.mock('../../../../util/theme', () => ({ useAppThemeFromContext: () => ({ colors: { - success: { default: '#00ff00' }, - error: { default: '#ff0000' }, - accent04: { normal: '#ffffff' }, + success: { default: mockTestHexColors.SUCCESS_BRIGHT }, + error: { default: mockTestHexColors.ERROR_BRIGHT }, + accent04: { normal: mockTestHexColors.WHITE_BRIGHT }, }, }), })); diff --git a/app/components/UI/Predict/providers/polymarket/GameCache.test.ts b/app/components/UI/Predict/providers/polymarket/GameCache.test.ts index 2bc6769c74c..bd7a9a9b660 100644 --- a/app/components/UI/Predict/providers/polymarket/GameCache.test.ts +++ b/app/components/UI/Predict/providers/polymarket/GameCache.test.ts @@ -1,3 +1,4 @@ +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; import { GameUpdate, PredictMarket, Recurrence } from '../../types'; import { GameCache } from './GameCache'; @@ -51,7 +52,7 @@ const createMockMarketWithGame = ( name: 'Seattle Seahawks', logo: 'https://example.com/sea.png', abbreviation: 'SEA', - color: '#002244', + color: TEST_HEX_COLORS.TEAM_SEA, alias: 'Seahawks', }, awayTeam: { @@ -59,7 +60,7 @@ const createMockMarketWithGame = ( name: 'Denver Broncos', logo: 'https://example.com/den.png', abbreviation: 'DEN', - color: '#FB4F14', + color: TEST_HEX_COLORS.TEAM_DEN, alias: 'Broncos', }, }, diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index 76437ee1328..9b204acc7e0 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -51,7 +51,6 @@ import { getDeployProxyWalletTransaction, getProxyWalletAllowancesTransaction, hasAllowances, - hasPermit2Allowance, } from './safe/utils'; import { PERMIT2_ADDRESS } from './safe/constants'; import { @@ -126,7 +125,6 @@ jest.mock('./safe/utils', () => ({ getDeployProxyWalletTransaction: jest.fn(), getProxyWalletAllowancesTransaction: jest.fn(), hasAllowances: jest.fn(), - hasPermit2Allowance: jest.fn(), getWithdrawTransactionCallData: jest .fn() .mockResolvedValue('0xsignedcalldata'), @@ -222,7 +220,6 @@ const mockCreatePermit2FeeAuthorization = const mockCreateSafeFeeAuthorization = createSafeFeeAuthorization as jest.Mock; const mockGetClaimTransaction = getClaimTransaction as jest.Mock; const mockHasAllowances = hasAllowances as jest.Mock; -const mockHasPermit2Allowance = hasPermit2Allowance as jest.Mock; const mockQuery = query as jest.Mock; const mockPreviewOrder = previewOrder as jest.Mock; const mockGetBalance = getBalance as jest.Mock; @@ -893,7 +890,6 @@ describe('PolymarketProvider', () => { sig: '0xsig', }, }); - mockHasPermit2Allowance.mockResolvedValue(false); mockCreatePermit2FeeAuthorization.mockResolvedValue({ type: 'safe-permit2', authorization: { @@ -1429,26 +1425,12 @@ describe('PolymarketProvider', () => { }); it.each([ - { - permit2Ready: true, - fakOrdersEnabled: true, - expectedOrderType: 'FAK', - }, - { - permit2Ready: false, - fakOrdersEnabled: true, - expectedOrderType: 'FOK', - }, - { - permit2Ready: true, - fakOrdersEnabled: false, - expectedOrderType: 'FOK', - }, + { fakOrdersEnabled: true, expectedOrderType: 'FAK' }, + { fakOrdersEnabled: false, expectedOrderType: 'FOK' }, ] as const)( - 'returns $expectedOrderType orderType when permit2Ready=$permit2Ready and fakOrdersEnabled=$fakOrdersEnabled', - async ({ permit2Ready, fakOrdersEnabled, expectedOrderType }) => { + 'returns $expectedOrderType orderType when fakOrdersEnabled=$fakOrdersEnabled and permit2 config is active', + async ({ fakOrdersEnabled, expectedOrderType }) => { mockPreviewOrderWithFees(); - mockHasPermit2Allowance.mockResolvedValue(permit2Ready); const provider = createPermit2PreviewProvider(fakOrdersEnabled); const result = await provider.previewOrder(createPreviewOrderParams()); @@ -1457,25 +1439,13 @@ describe('PolymarketProvider', () => { }, ); - it('returns FOK orderType when permit2 allowance check throws', async () => { - mockPreviewOrderWithFees(); - mockHasPermit2Allowance.mockRejectedValue(new Error('RPC timeout')); - const provider = createPermit2PreviewProvider(true); - - const result = await provider.previewOrder(createPreviewOrderParams()); - - expect(result.orderType).toBe('FOK'); - }); - it('returns FAK orderType when fees are absent and FAK flags are enabled', async () => { mockPreviewOrder.mockResolvedValue({}); - mockHasPermit2Allowance.mockResolvedValue(true); const provider = createPermit2PreviewProvider(true); const result = await provider.previewOrder(createPreviewOrderParams()); expect(result.orderType).toBe('FAK'); - expect(mockHasPermit2Allowance).not.toHaveBeenCalled(); }); }); @@ -1847,7 +1817,6 @@ describe('PolymarketProvider', () => { permit2Enabled: true, }, }); - mockHasPermit2Allowance.mockResolvedValueOnce(true); await provider.placeOrder({ preview, signer: mockSigner }); @@ -1866,7 +1835,7 @@ describe('PolymarketProvider', () => { ); }); - it('falls back to Safe fee authorization when Permit2 allowance is not set', async () => { + it('uses Permit2 fee authorization even when Permit2 allowance is not yet set on-chain', async () => { jest.clearAllMocks(); const { provider, mockSigner } = setupPlaceOrderTest(); const preview = createMockOrderPreview({ @@ -1881,12 +1850,11 @@ describe('PolymarketProvider', () => { permit2Enabled: true, }, }); - mockHasPermit2Allowance.mockResolvedValueOnce(false); await provider.placeOrder({ preview, signer: mockSigner }); - expect(mockCreatePermit2FeeAuthorization).not.toHaveBeenCalled(); - expect(mockCreateSafeFeeAuthorization).toHaveBeenCalled(); + expect(mockCreatePermit2FeeAuthorization).toHaveBeenCalled(); + expect(mockCreateSafeFeeAuthorization).not.toHaveBeenCalled(); }); it('falls back to Safe fee authorization when permit2Enabled is false', async () => { @@ -1907,7 +1875,6 @@ describe('PolymarketProvider', () => { await provider.placeOrder({ preview, signer: mockSigner }); - expect(mockHasPermit2Allowance).not.toHaveBeenCalled(); expect(mockCreatePermit2FeeAuthorization).not.toHaveBeenCalled(); expect(mockCreateSafeFeeAuthorization).toHaveBeenCalled(); }); @@ -1930,7 +1897,6 @@ describe('PolymarketProvider', () => { await provider.placeOrder({ preview, signer: mockSigner }); - expect(mockHasPermit2Allowance).not.toHaveBeenCalled(); expect(mockCreatePermit2FeeAuthorization).not.toHaveBeenCalled(); expect(mockCreateSafeFeeAuthorization).toHaveBeenCalled(); }); @@ -1959,7 +1925,7 @@ describe('PolymarketProvider', () => { }, fakOrdersEnabled: true, }); - mockHasPermit2Allowance.mockResolvedValue(true); + mockHasAllowances.mockResolvedValue(true); const preview = createMockOrderPreview({ side: Side.BUY, fees: { @@ -1992,7 +1958,6 @@ describe('PolymarketProvider', () => { }, fakOrdersEnabled: false, }); - mockHasPermit2Allowance.mockResolvedValue(true); const preview = createMockOrderPreview({ side: Side.BUY, fees: { @@ -2015,7 +1980,7 @@ describe('PolymarketProvider', () => { ); }); - it('submits FOK order type when falling back to Safe tx regardless of fakOrdersEnabled', async () => { + it('submits FAK order type when Permit2 fee auth and allowance are ready', async () => { jest.clearAllMocks(); const { provider, mockSigner } = setupPlaceOrderTest({ feeCollection: { @@ -2025,7 +1990,7 @@ describe('PolymarketProvider', () => { }, fakOrdersEnabled: true, }); - mockHasPermit2Allowance.mockResolvedValue(false); + mockHasAllowances.mockResolvedValue(true); const preview = createMockOrderPreview({ side: Side.BUY, fees: { @@ -2043,11 +2008,219 @@ describe('PolymarketProvider', () => { expect(mockSubmitClobOrder).toHaveBeenCalledWith( expect.objectContaining({ - clobOrder: expect.objectContaining({ orderType: 'FOK' }), + clobOrder: expect.objectContaining({ orderType: 'FAK' }), + }), + ); + }); + }); + + describe('placeOrder with allowancesTx', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + function setupAllowancesTxTest(overrides?: { + permit2Enabled?: boolean; + hasAllowances?: boolean; + executors?: string[]; + }) { + const result = setupPlaceOrderTest({ + feeCollection: { + ...DEFAULT_FEE_COLLECTION_FLAG, + permit2Enabled: overrides?.permit2Enabled ?? true, + executors: overrides?.executors ?? ['0xexecutor1'], + }, + }); + mockComputeProxyAddress.mockReturnValue('0xSafeAddress'); + (isSmartContractAddress as jest.Mock).mockResolvedValue(true); + mockHasAllowances.mockResolvedValue(overrides?.hasAllowances ?? false); + mockFindNetworkClientIdByChainId.mockReturnValue('polygon'); + mockGetNetworkClientById.mockReturnValue({ provider: {} }); + return result; + } + + it('attaches allowancesTx when proxy wallet lacks allowances with fees', async () => { + const { provider, mockSigner } = setupAllowancesTxTest(); + const preview = createMockOrderPreview({ + side: Side.BUY, + fees: { + metamaskFee: 0.02, + providerFee: 0.02, + totalFee: 0.04, + totalFeePercentage: 0.04, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + }); + (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ + params: { to: '0xSafe', data: '0xallowances' }, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + allowancesTx: { to: '0xSafe', data: '0xallowances' }, + }), + ); + }); + + it('attaches allowancesTx when proxy wallet lacks allowances without fees', async () => { + const { provider, mockSigner } = setupAllowancesTxTest(); + const preview = createMockOrderPreview({ + side: Side.BUY, + fees: { + metamaskFee: 0, + providerFee: 0, + totalFee: 0, + totalFeePercentage: 0, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + }); + (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ + params: { to: '0xSafe', data: '0xallowances' }, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + allowancesTx: { to: '0xSafe', data: '0xallowances' }, + }), + ); + }); + + it('attaches allowancesTx regardless of Permit2 on-chain allowance status', async () => { + const { provider, mockSigner } = setupAllowancesTxTest(); + const preview = createMockOrderPreview({ + side: Side.BUY, + fees: { + metamaskFee: 0.02, + providerFee: 0.02, + totalFee: 0.04, + totalFeePercentage: 0.04, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + }); + (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ + params: { to: '0xSafe', data: '0xallowances' }, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + allowancesTx: { to: '0xSafe', data: '0xallowances' }, + }), + ); + }); + + it('does not attach allowancesTx when hasAllowances is true', async () => { + const { provider, mockSigner } = setupAllowancesTxTest({ + hasAllowances: true, + }); + const preview = createMockOrderPreview({ + side: Side.BUY, + fees: { + metamaskFee: 0.02, + providerFee: 0.02, + totalFee: 0.04, + totalFeePercentage: 0.04, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + allowancesTx: undefined, + }), + ); + }); + + it('does not attach allowancesTx when permit2 is disabled', async () => { + const { provider, mockSigner } = setupAllowancesTxTest({ + permit2Enabled: false, + executors: [], + }); + const preview = createMockOrderPreview({ + side: Side.BUY, + fees: { + metamaskFee: 0.02, + providerFee: 0.02, + totalFee: 0.04, + totalFeePercentage: 0.04, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + }, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + allowancesTx: undefined, + }), + ); + expect(getProxyWalletAllowancesTransaction).not.toHaveBeenCalled(); + }); + + it('continues order placement when getProxyWalletAllowancesTransaction throws', async () => { + const { provider, mockSigner } = setupAllowancesTxTest(); + const preview = createMockOrderPreview({ + side: Side.BUY, + fees: { + metamaskFee: 0.02, + providerFee: 0.02, + totalFee: 0.04, + totalFeePercentage: 0.04, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + }); + (getProxyWalletAllowancesTransaction as jest.Mock).mockRejectedValue( + new Error('TX generation failed'), + ); + + const result = await provider.placeOrder({ preview, signer: mockSigner }); + + expect(result.success).toBe(true); + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + allowancesTx: undefined, + }), + ); + }); + + it('attaches allowancesTx for SELL orders', async () => { + const { provider, mockSigner } = setupAllowancesTxTest(); + const preview = createMockOrderPreview({ + side: Side.SELL, + fees: undefined, + }); + (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ + params: { to: '0xSafe', data: '0xallowances' }, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(getProxyWalletAllowancesTransaction).toHaveBeenCalled(); + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + allowancesTx: { to: '0xSafe', data: '0xallowances' }, }), ); }); }); + describe('placeOrder FAK order type for sell orders', () => { it('submits FAK order type for sell order without fees when FAK is enabled', async () => { jest.clearAllMocks(); @@ -2071,7 +2244,6 @@ describe('PolymarketProvider', () => { clobOrder: expect.objectContaining({ orderType: 'FAK' }), }), ); - expect(mockHasPermit2Allowance).not.toHaveBeenCalled(); }); it('submits FOK order type for sell order without fees when FAK is disabled', async () => { diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index fb1b02de3d3..d20e9dbc6b6 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -74,7 +74,6 @@ import { getSafeUsdcAmount, getWithdrawTransactionCallData, hasAllowances, - hasPermit2Allowance, } from './safe/utils'; import { Permit2FeeAuthorization, SafeFeeAuthorization } from './safe/types'; import { @@ -214,22 +213,6 @@ export class PolymarketProvider implements PredictProvider { ); } - async #isPermit2AllowanceReady(ownerAddress: string): Promise { - const safeAddress = - this.#accountStateByAddress.get(ownerAddress)?.address ?? - computeProxyAddress(ownerAddress); - - try { - return await hasPermit2Allowance({ address: safeAddress }); - } catch (error) { - DevLogger.log('PolymarketProvider: Permit2 allowance check failed', { - error, - ownerAddress, - }); - return false; - } - } - public async getMarketDetails({ marketId, }: { @@ -1025,31 +1008,18 @@ export class PolymarketProvider implements PredictProvider { // Determine intended order type from feature flags. // FAK is used when Permit2 config is active and FAK orders are enabled. - // The Permit2 allowance check is only needed when fees must be collected. + // placeOrder() guarantees the Permit2 allowance is set before submission, + // so we can always use FAK when the config allows it. let orderType = OrderType.FOK; - const couldUseFak = this.#shouldUseFakOrderType({ - permit2Enabled: feeCollection.permit2Enabled, - executors: feeCollection.executors, - fakOrdersEnabled, - }); - - if (couldUseFak) { - const hasFees = - basePreview.fees !== undefined && basePreview.fees.totalFee > 0; - if (hasFees) { - // TODO: remove this once placeOrder guarantees Permit2 allowance - // is set automatically before order submission. - const permit2Ready = await this.#isPermit2AllowanceReady( - params.signer.address, - ); - if (permit2Ready) { - orderType = OrderType.FAK; - } - } else { - // No fees to collect via Permit2 — FAK can be used directly. - orderType = OrderType.FAK; - } + if ( + this.#shouldUseFakOrderType({ + permit2Enabled: feeCollection.permit2Enabled, + executors: feeCollection.executors, + fakOrdersEnabled, + }) + ) { + orderType = OrderType.FAK; } if (params.signer) { @@ -1239,30 +1209,20 @@ export class PolymarketProvider implements PredictProvider { ); if (shouldUsePermit2) { + // Always use Permit2 fee authorization when permit2 is enabled. + // The relay will submit the allowancesTx on-chain first (if + // attached) before redeeming the Permit2 authorization. + permit2FeeReady = true; const executors = fees.executors ?? []; - const permit2Ready = await this.#isPermit2AllowanceReady( - signer.address, - ); - - if (permit2Ready) { - permit2FeeReady = true; - const randomIndex = new Uint32Array(1); - global.crypto.getRandomValues(randomIndex); - executor = executors[randomIndex[0] % executors.length]; - feeAuthorization = await createPermit2FeeAuthorization({ - safeAddress, - signer, - amount: feeAmountInUsdc, - spender: executor, - }); - } else { - feeAuthorization = await createSafeFeeAuthorization({ - safeAddress, - signer, - amount: feeAmountInUsdc, - to: fees.collector, - }); - } + const randomIndex = new Uint32Array(1); + global.crypto.getRandomValues(randomIndex); + executor = executors[randomIndex[0] % executors.length]; + feeAuthorization = await createPermit2FeeAuthorization({ + safeAddress, + signer, + amount: feeAmountInUsdc, + spender: executor, + }); } else { feeAuthorization = await createSafeFeeAuthorization({ safeAddress, @@ -1273,9 +1233,59 @@ export class PolymarketProvider implements PredictProvider { } } - // Determine order type independently of fee authorization. - // FAK depends on feature flags only; the Permit2 allowance check - // is only needed when fees must be collected via Permit2. + let allowancesTx: { to: string; data: string } | undefined; + let permit2AllowanceReady = false; + + // When Permit2 is enabled via feature flags, ensure the proxy wallet + // has the required allowances. If not, generate a signed Safe TX that + // the relay will submit on-chain before processing the order. + // This uses the feature flag (not per-order fees) so it works for + // both BUY orders (with fees) and SELL orders (no fees). + // + // IMPORTANT: Skip when a Safe fee authorization was already signed, + // because both transactions read the same on-chain Safe nonce and + // the relay would invalidate one when executing the other. + const hasSafeFeeAuth = feeAuthorization !== undefined && !permit2FeeReady; + + if (feeCollection.permit2Enabled && !hasSafeFeeAuth) { + try { + const accountState = await this.getAccountState({ + ownerAddress: signer.address, + }); + + if (accountState.hasAllowances) { + permit2AllowanceReady = true; + } else { + const allowanceTx = await getProxyWalletAllowancesTransaction({ + signer, + extraUsdcSpenders: [PERMIT2_ADDRESS], + }); + + allowancesTx = allowanceTx.params; + permit2AllowanceReady = true; + } + } catch (allowanceError) { + // Log but don't block the order — the relay will fall back + // to FOK if FAK isn't viable without allowances. + DevLogger.log( + 'PolymarketProvider: Failed to generate allowances transaction', + { error: allowanceError }, + ); + Logger.error( + allowanceError instanceof Error + ? allowanceError + : new Error(String(allowanceError)), + this.getErrorContext('placeOrder:allowancesTx', { + operation: 'generate_allowances_tx', + }), + ); + } + } + + // Determine order type: FAK when Permit2 config is active and + // FAK orders are enabled. For orders with fees, the relay also + // needs Permit2 allowances (either already on-chain or via + // the attached allowancesTx). if ( this.#shouldUseFakOrderType({ permit2Enabled: feeCollection.permit2Enabled, @@ -1284,7 +1294,7 @@ export class PolymarketProvider implements PredictProvider { }) ) { const hasFees = fees !== undefined && fees.totalFee > 0; - if (!hasFees || permit2FeeReady) { + if (!hasFees || (permit2FeeReady && permit2AllowanceReady)) { orderType = OrderType.FAK; } } @@ -1329,6 +1339,7 @@ export class PolymarketProvider implements PredictProvider { clobOrder, feeAuthorization, executor, + allowancesTx, }); if (!success) { diff --git a/app/components/UI/Predict/providers/polymarket/TeamsCache.test.ts b/app/components/UI/Predict/providers/polymarket/TeamsCache.test.ts index 7adddd50aef..f8c9a98dcab 100644 --- a/app/components/UI/Predict/providers/polymarket/TeamsCache.test.ts +++ b/app/components/UI/Predict/providers/polymarket/TeamsCache.test.ts @@ -1,3 +1,4 @@ +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; import { TeamsCache } from './TeamsCache'; import { PolymarketApiTeam } from './types'; @@ -17,7 +18,7 @@ const createMockTeam = ( name: 'Seattle Seahawks', logo: 'https://example.com/sea.png', abbreviation: 'SEA', - color: '#002244', + color: TEST_HEX_COLORS.TEAM_SEA, alias: 'Seahawks', ...overrides, }); @@ -27,21 +28,21 @@ const mockNflTeams: PolymarketApiTeam[] = [ id: 'team-sea', name: 'Seattle Seahawks', abbreviation: 'SEA', - color: '#002244', + color: TEST_HEX_COLORS.TEAM_SEA, alias: 'Seahawks', }), createMockTeam({ id: 'team-den', name: 'Denver Broncos', abbreviation: 'DEN', - color: '#FB4F14', + color: TEST_HEX_COLORS.TEAM_DEN, alias: 'Broncos', }), createMockTeam({ id: 'team-sf', name: 'San Francisco 49ers', abbreviation: 'SF', - color: '#AA0000', + color: TEST_HEX_COLORS.TEAM_SF, alias: '49ers', }), ]; diff --git a/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts b/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts index fda95f0670c..b9c8f65eedb 100644 --- a/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts @@ -414,46 +414,19 @@ describe('safe utils', () => { }); describe('getPermit2Nonce', () => { - it('returns 0 when nonce bitmap call returns 0x', async () => { - mockNetworkController(); - mockQuery.mockReset(); - mockQuery.mockResolvedValueOnce('0x'); - - const nonce = await getPermit2Nonce({ safeAddress: TEST_SAFE_ADDRESS }); - - expect(nonce).toBe('0'); - }); + it('returns a numeric string', async () => { + const nonce = await getPermit2Nonce(); - it('returns first available nonce when bitmap is 0x7', async () => { - mockNetworkController(); - mockQuery.mockReset(); - mockQuery.mockResolvedValueOnce('0x7'); - - const nonce = await getPermit2Nonce({ safeAddress: TEST_SAFE_ADDRESS }); - - expect(nonce).toBe('3'); + expect(nonce).toMatch(/^\d+$/); }); - it('returns first available nonce when bitmap is 0x5', async () => { - mockNetworkController(); - mockQuery.mockReset(); - mockQuery.mockResolvedValueOnce('0x5'); - - const nonce = await getPermit2Nonce({ safeAddress: TEST_SAFE_ADDRESS }); - - expect(nonce).toBe('1'); - }); + it('generates nonce from crypto.getRandomValues', async () => { + const spy = jest.spyOn(global.crypto, 'getRandomValues'); - it('throws when nonce bitmap word 0 is fully used', async () => { - mockNetworkController(); - mockQuery.mockReset(); - mockQuery.mockResolvedValueOnce(`0x${'f'.repeat(64)}`); + await getPermit2Nonce(); - await expect( - getPermit2Nonce({ safeAddress: TEST_SAFE_ADDRESS }), - ).rejects.toThrow( - 'No available Permit2 nonce found in nonce bitmap word 0', - ); + expect(spy).toHaveBeenCalledWith(expect.any(Uint32Array)); + spy.mockRestore(); }); }); @@ -482,9 +455,6 @@ describe('safe utils', () => { describe('createPermit2FeeAuthorization', () => { it('creates safe-permit2 authorization payload', async () => { - mockNetworkController(); - mockQuery.mockReset(); - mockQuery.mockResolvedValueOnce('0x0'); mockSignPersonalMessage.mockResolvedValue( '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', ); @@ -503,7 +473,7 @@ describe('safe utils', () => { expect(authorization.authorization.permit.permitted.amount).toBe( '1000000', ); - expect(authorization.authorization.permit.nonce).toBe('0'); + expect(authorization.authorization.permit.nonce).toMatch(/^\d+$/); expect(authorization.authorization.spender).toBe(TEST_TO_ADDRESS); expect(authorization.authorization.signature).toMatch(/^0x[a-f0-9]+$/); }); diff --git a/app/components/UI/Predict/providers/polymarket/safe/utils.ts b/app/components/UI/Predict/providers/polymarket/safe/utils.ts index 787a3abd5ee..6e121b73901 100644 --- a/app/components/UI/Predict/providers/polymarket/safe/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/safe/utils.ts @@ -174,39 +174,16 @@ const getNonce = async ({ return BigInt(res); }; -export const getPermit2Nonce = async ({ - safeAddress, -}: { - safeAddress: string; -}): Promise => { - const { NetworkController } = Engine.context; - const networkClientId = NetworkController.findNetworkClientIdByChainId( - numberToHex(POLYGON_MAINNET_CHAIN_ID), - ); - const ethQuery = new EthQuery( - NetworkController.getNetworkClientById(networkClientId).provider, - ); - const nonceBitmapInterface = new Interface([ - 'function nonceBitmap(address, uint256) view returns (uint256)', - ]); - const res = (await query(ethQuery, 'call', [ - { - to: PERMIT2_ADDRESS, - data: nonceBitmapInterface.encodeFunctionData('nonceBitmap', [ - safeAddress, - 0, - ]), - }, - ])) as Hex; - const bitmap = res === '0x' ? 0n : BigInt(res); - let bitPos = 0n; - while (bitPos < 256n && ((bitmap >> bitPos) & 1n) === 1n) { - bitPos += 1n; - } - if (bitPos === 256n) { - throw new Error('No available Permit2 nonce found in nonce bitmap word 0'); - } - return ((0n << 8n) | bitPos).toString(); +/** + * Generate a random Permit2 nonce. Uses a random 32-bit value instead of + * reading the on-chain nonce bitmap to avoid an RPC round-trip and prevent + * nonce collisions on back-to-back orders whose fee collection hasn't + * settled on-chain yet. + */ +export const getPermit2Nonce = async (): Promise => { + const arr = new Uint32Array(1); + global.crypto.getRandomValues(arr); + return arr[0].toString(); }; const getTransactionHash = ({ @@ -362,7 +339,7 @@ export const createPermit2FeeAuthorization = async ({ amount: bigint; spender: string; }): Promise => { - const nonce = await getPermit2Nonce({ safeAddress }); + const nonce = await getPermit2Nonce(); const deadline = (Math.floor(Date.now() / 1000) + 3600).toString(); const token = MATIC_CONTRACTS.collateral; diff --git a/app/components/UI/Predict/providers/polymarket/utils.test.ts b/app/components/UI/Predict/providers/polymarket/utils.test.ts index caf72518f44..e85607d6f0b 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.test.ts @@ -1117,6 +1117,35 @@ describe('polymarket utils', () => { expect(parsedBody.feeAuthorization).toEqual(feeAuthorization); }); + + it('includes allowancesTx in request body when provided', async () => { + const allowancesTx = { to: '0xSafeAddress', data: '0xallowanceData' }; + + await submitClobOrder({ + headers: mockHeaders, + clobOrder: mockClobOrder, + allowancesTx, + }); + + const callArgs = mockFetch.mock.calls[0]; + const bodyString = callArgs[1].body; + const parsedBody = JSON.parse(bodyString); + + expect(parsedBody.allowancesTx).toEqual(allowancesTx); + }); + + it('omits allowancesTx from request body when not provided', async () => { + await submitClobOrder({ + headers: mockHeaders, + clobOrder: mockClobOrder, + }); + + const callArgs = mockFetch.mock.calls[0]; + const bodyString = callArgs[1].body; + const parsedBody = JSON.parse(bodyString); + + expect(parsedBody).not.toHaveProperty('allowancesTx'); + }); }); describe('parsePolymarketEvents', () => { diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts index 1c6dcc71c87..d3ca1f890e2 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.ts @@ -466,21 +466,25 @@ export const submitClobOrder = async ({ clobOrder, feeAuthorization, executor, + allowancesTx, }: { headers: ClobHeaders; clobOrder: ClobOrderObject; feeAuthorization?: SafeFeeAuthorization | Permit2FeeAuthorization; executor?: string; + allowancesTx?: { to: string; data: string }; }): Promise> => { const { CLOB_RELAYER } = getPolymarketEndpoints(); const url = `${CLOB_RELAYER}/order`; const body: ClobOrderObject & { feeAuthorization?: SafeFeeAuthorization | Permit2FeeAuthorization; executor?: string; + allowancesTx?: { to: string; data: string }; } = { ...clobOrder, feeAuthorization, ...(executor && { executor }), + ...(allowancesTx && { allowancesTx }), }; // For our relayer, we need to replace the underscores with dashes @@ -811,7 +815,7 @@ export const parsePolymarketEvents = ( recurrence: getRecurrence(event.series), endDate: event.endDate, category, - tags: tags.map((t) => t.label), + tags: tags.map((t) => t.slug), outcomes: markets.map((market: PolymarketApiMarket) => parsePolymarketMarket(market, event), ), @@ -1146,7 +1150,7 @@ async function waiveFees({ const market = await getMarketDetailsFromGammaApi({ marketId }); const { tags } = market; const slugs = tags?.map((t) => t.slug); - return slugs?.some((slug) => waiveList.includes(slug)) ?? false; + return slugs?.some((slug) => waiveList?.includes(slug)) ?? false; } export async function calculateFees({ diff --git a/app/components/UI/Predict/selectors/featureFlags/index.test.ts b/app/components/UI/Predict/selectors/featureFlags/index.test.ts index 16b634ef325..a155ed28100 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.test.ts @@ -1,4 +1,8 @@ -import { selectPredictEnabledFlag, selectPredictHotTabFlag } from '.'; +import { + selectPredictEnabledFlag, + selectPredictFeeCollectionFlag, + selectPredictHotTabFlag, +} from '.'; import mockedEngine from '../../../../../core/__mocks__/MockedEngine'; import { mockedState, @@ -638,4 +642,153 @@ describe('Predict Feature Flag Selectors', () => { }); }); }); + + describe('selectPredictFeeCollectionFlag', () => { + it('returns fee collection config when present in remote feature flags', () => { + const feeCollectionConfig = { + enabled: true, + collector: '0xe6a2026d58eaff3c7ad7ba9386fb143388002382', + metamaskFee: 0.03, + providerFee: 0.01, + waiveList: ['middle-east'], + executors: ['0x1234'], + permit2Enabled: true, + }; + const stateWithFeeCollection = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictFeeCollection: feeCollectionConfig, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictFeeCollectionFlag(stateWithFeeCollection); + + expect(result).toEqual(feeCollectionConfig); + }); + + it('returns default flag when remote flag is missing', () => { + const result = selectPredictFeeCollectionFlag(mockedEmptyFlagsState); + + expect(result).toEqual({ + enabled: true, + collector: expect.any(String), + metamaskFee: 0.02, + providerFee: 0.02, + waiveList: [], + executors: [], + permit2Enabled: false, + }); + }); + + it('returns default flag when remote flag is null', () => { + const stateWithNullFlag = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictFeeCollection: null, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictFeeCollectionFlag(stateWithNullFlag); + + expect(result).toEqual({ + enabled: true, + collector: expect.any(String), + metamaskFee: 0.02, + providerFee: 0.02, + waiveList: [], + executors: [], + permit2Enabled: false, + }); + }); + + it('returns default flag when controller is undefined', () => { + const stateWithUndefinedController = { + engine: { + backgroundState: { + RemoteFeatureFlagController: undefined, + }, + }, + }; + + const result = selectPredictFeeCollectionFlag( + stateWithUndefinedController, + ); + + expect(result).toEqual({ + enabled: true, + collector: expect.any(String), + metamaskFee: 0.02, + providerFee: 0.02, + waiveList: [], + executors: [], + permit2Enabled: false, + }); + }); + + it('returns remote config with custom waiveList', () => { + const stateWithWaiveList = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictFeeCollection: { + enabled: true, + collector: '0xabc', + metamaskFee: 0.05, + providerFee: 0.03, + waiveList: ['middle-east', 'humanitarian'], + executors: [], + permit2Enabled: false, + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictFeeCollectionFlag(stateWithWaiveList); + + expect(result.waiveList).toEqual(['middle-east', 'humanitarian']); + expect(result.metamaskFee).toBe(0.05); + expect(result.providerFee).toBe(0.03); + }); + + it('returns remote config when fee collection is disabled', () => { + const stateWithDisabledFees = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictFeeCollection: { + enabled: false, + collector: '0x0', + metamaskFee: 0, + providerFee: 0, + waiveList: [], + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictFeeCollectionFlag(stateWithDisabledFees); + + expect(result.enabled).toBe(false); + }); + }); }); diff --git a/app/components/UI/Predict/selectors/featureFlags/index.ts b/app/components/UI/Predict/selectors/featureFlags/index.ts index 94aea5a4588..bf49d9d2a84 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.ts @@ -4,8 +4,11 @@ import { VersionGatedFeatureFlag, validatedVersionGatedFeatureFlag, } from '../../../../../util/remoteFeatureFlag'; -import { PredictHotTabFlag } from '../../types/flags'; -import { DEFAULT_HOT_TAB_FLAG } from '../../constants/flags'; +import { PredictFeatureFlags, PredictHotTabFlag } from '../../types/flags'; +import { + DEFAULT_FEE_COLLECTION_FLAG, + DEFAULT_HOT_TAB_FLAG, +} from '../../constants/flags'; import { unwrapRemoteFeatureFlag } from '../../utils/flags'; /** @@ -109,3 +112,21 @@ export const selectPredictHotTabFlag = createSelector( return flag; }, ); + +/** + * Selector for Predict fee collection config flag + */ +export const selectPredictFeeCollectionFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + const flag = unwrapRemoteFeatureFlag( + remoteFeatureFlags?.predictFeeCollection, + ); + + if (!flag) { + return DEFAULT_FEE_COLLECTION_FLAG; + } + + return flag; + }, +); diff --git a/app/components/UI/Predict/testUtils/mockColors.ts b/app/components/UI/Predict/testUtils/mockColors.ts new file mode 100644 index 00000000000..76c97f2214f --- /dev/null +++ b/app/components/UI/Predict/testUtils/mockColors.ts @@ -0,0 +1,47 @@ +/* eslint-disable @metamask/design-tokens/color-no-hex */ +/** + * Centralized static color fixtures for Predict tests. + * Keep hardcoded test-only colors in this file to avoid repeated + * `color-no-hex` suppressions across test suites. + */ +export const TEST_HEX_COLORS = { + TEAM_SEA: '#002244', + TEAM_SEA_ALPHA: '#002244FF', + TEAM_DEN: '#FB4F14', + TEAM_DEN_ALPHA: '#FB4F1480', + TEAM_NE: '#1D4E9B', + TEAM_SEA_GREEN: '#69BE28', + TEAM_SF: '#AA0000', + TEAM_ALT_ORANGE: '#FC4C02', + GOLD: '#FFD700', + GOLD_ALPHA: '#FFD700FF', + CHART_SUCCESS: '#28C76F', + CHART_PRIMARY: '#4459FF', + CHART_ERROR: '#CA3542', + CHART_WARNING: '#F0B034', + CHART_CORAL: '#FF6B6B', + CUSTOM_ORANGE: '#FF5733', + ACCENT_BLUE: '#89b0ff', + ERROR_DARK: '#ca3542', + ERROR_BRIGHT: '#ff0000', + SUCCESS_BRIGHT: '#00ff00', + PURE_GREEN_SHORT: '#0F0', + PURE_GREEN_SHORT_ALPHA: '#0F08', + PURE_GREEN: '#00FF00', + PURE_RED: '#FF0000', + PURE_RED_ALPHA: '#FF0000FF', + PURE_RED_SHORT: '#F00', + PURE_RED_SHORT_ALPHA: '#F00F', + PURE_BLUE: '#0000FF', + WHITE_SHORT: '#FFF', + WHITE_BRIGHT: '#ffffff', + WHITE_FULL: '#FFFFFF', + PURE_BLACK: '#000', + EXAMPLE: '#123456', + EXAMPLE_LIGHT: '#ABCDEF', + EXAMPLE_789ABC: '#789ABC', + EXAMPLE_LOWER_ABC123: '#abc123', + EXAMPLE_LOWER_DEF456: '#def456', + EXAMPLE_UPPER_ABC123: '#ABC123', + EXAMPLE_UPPER_DEF456: '#DEF456', +} as const; diff --git a/app/components/UI/Predict/utils/gameParser.test.ts b/app/components/UI/Predict/utils/gameParser.test.ts index 7b18984e176..1ee93276f7e 100644 --- a/app/components/UI/Predict/utils/gameParser.test.ts +++ b/app/components/UI/Predict/utils/gameParser.test.ts @@ -1,3 +1,4 @@ +import { TEST_HEX_COLORS } from '../testUtils/mockColors'; import { parseGameSlugTeams, parseScore, @@ -21,7 +22,7 @@ const createMockApiTeam = ( name: 'Seattle Seahawks', logo: 'https://example.com/sea.png', abbreviation: 'SEA', - color: '#002244', + color: TEST_HEX_COLORS.TEAM_SEA, alias: 'Seahawks', ...overrides, }); @@ -285,7 +286,7 @@ describe('gameParser', () => { name: 'Seattle Seahawks', logo: 'https://example.com/sea.png', abbreviation: 'SEA', - color: '#002244', + color: TEST_HEX_COLORS.TEAM_SEA, alias: 'Seahawks', }); }); @@ -296,7 +297,7 @@ describe('gameParser', () => { name: 'Custom Team', logo: 'https://custom.com/logo.png', abbreviation: 'CUS', - color: '#FFFFFF', + color: TEST_HEX_COLORS.WHITE_FULL, alias: 'Customs', }); @@ -306,7 +307,7 @@ describe('gameParser', () => { expect(result.name).toBe('Custom Team'); expect(result.logo).toBe('https://custom.com/logo.png'); expect(result.abbreviation).toBe('CUS'); - expect(result.color).toBe('#FFFFFF'); + expect(result.color).toBe(TEST_HEX_COLORS.WHITE_FULL); expect(result.alias).toBe('Customs'); }); }); @@ -317,7 +318,7 @@ describe('gameParser', () => { name: 'Seattle Seahawks', logo: 'https://example.com/sea.png', abbreviation: 'SEA', - color: '#002244', + color: TEST_HEX_COLORS.TEAM_SEA, alias: 'Seahawks', }; @@ -326,7 +327,7 @@ describe('gameParser', () => { name: 'Denver Broncos', logo: 'https://example.com/den.png', abbreviation: 'DEN', - color: '#FB4F14', + color: TEST_HEX_COLORS.TEAM_DEN, alias: 'Broncos', }; diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index 1e569da5031..8c9a3974b61 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -592,6 +592,20 @@ function setupPredictMarketDetailsTest( PreferencesController: { privacyMode: false, }, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictFeeCollection: { + enabled: true, + collector: '0xe6a2026d58eaff3c7ad7ba9386fb143388002382', + metamaskFee: 0.02, + providerFee: 0.02, + waiveList: ['middle-east'], + executors: [], + permit2Enabled: false, + }, + }, + cacheTimestamp: 0, + }, }, }, }, @@ -3197,10 +3211,10 @@ describe('PredictMarketDetails', () => { }); describe('Fee Exemption Display', () => { - it('displays fee exemption message when market has Middle East tag', () => { - const marketWithMiddleEastTag = createMockMarket({ + it('displays fee exemption message when market tag matches waiveList', () => { + const marketWithWaivedTag = createMockMarket({ status: 'open', - tags: ['Middle East'], + tags: ['middle-east'], outcomes: [ { id: 'outcome-1', @@ -3214,17 +3228,17 @@ describe('PredictMarketDetails', () => { ], }); - setupPredictMarketDetailsTest(marketWithMiddleEastTag); + setupPredictMarketDetailsTest(marketWithWaivedTag); expect( screen.getByText('predict.market_details.fee_exemption'), ).toBeOnTheScreen(); }); - it('hides fee exemption message when market does not have Middle East tag', () => { - const marketWithoutMiddleEastTag = createMockMarket({ + it('hides fee exemption message when market tags do not match waiveList', () => { + const marketWithNonWaivedTags = createMockMarket({ status: 'open', - tags: ['Sports', 'Politics'], + tags: ['sports', 'politics'], outcomes: [ { id: 'outcome-1', @@ -3238,7 +3252,7 @@ describe('PredictMarketDetails', () => { ], }); - setupPredictMarketDetailsTest(marketWithoutMiddleEastTag); + setupPredictMarketDetailsTest(marketWithNonWaivedTags); expect( screen.queryByText('predict.market_details.fee_exemption'), @@ -3293,10 +3307,10 @@ describe('PredictMarketDetails', () => { ).not.toBeOnTheScreen(); }); - it('displays fee exemption message when Middle East tag exists among multiple tags', () => { + it('displays fee exemption message when waiveList tag exists among multiple tags', () => { const marketWithMultipleTags = createMockMarket({ status: 'open', - tags: ['Politics', 'Middle East', 'International'], + tags: ['politics', 'middle-east', 'international'], outcomes: [ { id: 'outcome-1', @@ -3317,10 +3331,10 @@ describe('PredictMarketDetails', () => { ).toBeOnTheScreen(); }); - it('displays fee exemption message when market is closed with Middle East tag', () => { - const closedMarketWithMiddleEastTag = createMockMarket({ + it('displays fee exemption message when market is closed with waiveList tag', () => { + const closedMarketWithWaivedTag = createMockMarket({ status: 'closed', - tags: ['Middle East'], + tags: ['middle-east'], outcomes: [ { id: 'outcome-1', @@ -3331,23 +3345,23 @@ describe('PredictMarketDetails', () => { ], }); - setupPredictMarketDetailsTest(closedMarketWithMiddleEastTag); + setupPredictMarketDetailsTest(closedMarketWithWaivedTag); - // Note: The component currently shows the fee exemption message for closed markets - // if they have the Middle East tag. This behavior matches the current implementation. + // Note: The component shows the fee exemption message for closed markets + // if they have a tag matching the waiveList. This matches the current implementation. expect( screen.getByText('predict.market_details.fee_exemption'), ).toBeOnTheScreen(); }); - it('removes fee exemption message when market updates without Middle East tag', async () => { + it('removes fee exemption message when market updates without waiveList tag', async () => { const { usePredictMarket } = jest.requireMock( '../../hooks/usePredictMarket', ); - const marketWithMiddleEastTag = createMockMarket({ + const marketWithWaivedTag = createMockMarket({ status: 'open', - tags: ['Middle East', 'Politics'], + tags: ['middle-east', 'politics'], outcomes: [ { id: 'outcome-1', @@ -3361,17 +3375,15 @@ describe('PredictMarketDetails', () => { ], }); - const { rerender } = setupPredictMarketDetailsTest( - marketWithMiddleEastTag, - ); + const { rerender } = setupPredictMarketDetailsTest(marketWithWaivedTag); expect( screen.getByText('predict.market_details.fee_exemption'), ).toBeOnTheScreen(); - const marketWithoutMiddleEastTag = createMockMarket({ + const marketWithoutWaivedTag = createMockMarket({ status: 'open', - tags: ['Politics'], + tags: ['politics'], outcomes: [ { id: 'outcome-1', @@ -3386,7 +3398,7 @@ describe('PredictMarketDetails', () => { }); usePredictMarket.mockReturnValue({ - market: marketWithoutMiddleEastTag, + market: marketWithoutWaivedTag, isFetching: false, refetch: jest.fn(), }); diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx index 42a4daa54a4..022226b9ea4 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx @@ -47,6 +47,8 @@ import PredictMarketDetailsTabContent from './components/PredictMarketDetailsTab import { useChartData } from './hooks/useChartData'; import { useOutcomeResolution } from './hooks/useOutcomeResolution'; import { useOpenOutcomes } from './hooks/useOpenOutcomes'; +import { useSelector } from 'react-redux'; +import { selectPredictFeeCollectionFlag } from '../../selectors/featureFlags'; // Use theme tokens instead of hex values for multi-series charts @@ -128,8 +130,11 @@ const PredictMarketDetails: React.FC = () => { enabled: !isMarketFetching && Boolean(resolvedMarketId), }); - // check if market has fee exemption (note: worth moveing to a const or util at some point)) - const isFeeExemption = market?.tags?.includes('Middle East') ?? false; + const feeCollectionConfig = useSelector(selectPredictFeeCollectionFlag); + const isFeeExemption = + market?.tags?.some((slug) => + feeCollectionConfig.waiveList?.includes(slug), + ) ?? false; // Tabs become ready when both market and positions queries have resolved const tabsReady = useMemo( diff --git a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx index ea9b1db2174..7a216e6ec3d 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx @@ -349,6 +349,46 @@ describe('Quotes', () => { }); }); + it('tracks OFFRAMP_QUOTES_EXPANDED when expanding quotes in sell mode', async () => { + mockUseRampSDKValues.rampType = RampType.SELL; + mockUseRampSDKValues.isSell = true; + mockUseRampSDKValues.isBuy = false; + render(Quotes); + fireEvent.press( + screen.getByRole('button', { name: 'Explore more options' }), + ); + act(() => { + jest.advanceTimersByTime(3000); + jest.clearAllTimers(); + }); + expect(mockTrackEvent.mock.lastCall).toMatchInlineSnapshot(` + [ + "OFFRAMP_QUOTES_EXPANDED", + { + "amount": 50, + "chain_id_source": "1", + "currency_destination": "USD", + "currency_source": "ETH", + "currency_source_network": "ETH", + "currency_source_symbol": "ETH", + "payment_method_id": "/payment-methods/test-payment-method", + "previously_used_count": 0, + "provider_onramp_first": "Banxa (Staging)", + "provider_onramp_list": [ + "Banxa (Staging)", + "MoonPay (Staging)", + "Transak (Staging)", + ], + "refresh_count": 1, + "results_count": 3, + }, + ] + `); + act(() => { + jest.useFakeTimers({ legacyFakeTimers: true }); + }); + }); + it('calls hardware back handler ', async () => { const backHandlerMock = jest.spyOn(BackHandler, 'addEventListener'); const removeMock = jest.fn(); @@ -978,6 +1018,44 @@ describe('Quotes', () => { }); }); + it('tracks OFFRAMP_QUOTE_ERROR for sell quotes with errors', async () => { + const mockQuoteError = { + provider: { + id: '/providers/transak-staging', + name: 'Transak (Staging)', + }, + message: 'Sell not available for this pair', + error: true, + }; + + mockUseRampSDKValues.rampType = RampType.SELL; + mockUseRampSDKValues.isSell = true; + mockUseRampSDKValues.isBuy = false; + mockUseQuotesAndCustomActionsValues = { + ...mockUseQuotesAndCustomActionsInitialValues, + quotesWithError: [mockQuoteError] as unknown as QuoteError[], + }; + render(Quotes); + act(() => { + jest.advanceTimersByTime(3000); + jest.clearAllTimers(); + }); + expect(mockTrackEvent).toHaveBeenCalledWith('OFFRAMP_QUOTE_ERROR', { + amount: 50, + payment_method_id: '/payment-methods/test-payment-method', + error_message: 'Sell not available for this pair', + currency_destination: 'USD', + currency_source: 'ETH', + currency_source_symbol: 'ETH', + currency_source_network: 'ETH', + provider_offramp: 'Transak (Staging)', + chain_id_source: '1', + }); + act(() => { + jest.useFakeTimers({ legacyFakeTimers: true }); + }); + }); + it('renders correctly with sdkError', async () => { mockUseRampSDKValues = { ...mockUseRampSDKInitialValues, diff --git a/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap index 72c64b7c7fc..3f0cf712fa2 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap @@ -813,7 +813,7 @@ exports[`Quotes custom action renders correctly after animation with the recomme ): RampsToken => ({ const mockTokenNetworkInfo = { networkName: 'Ethereum Mainnet', - networkImageSource: { uri: 'https://example.com/eth.png' }, }; const mockGetTokenNetworkInfo = jest.fn(() => mockTokenNetworkInfo); -const mockGetRampsBuildQuoteNavbarOptions = jest.fn( - (_navigation: unknown, _options: unknown) => ({}), -); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -86,10 +82,50 @@ jest.mock('../../../../../../locales/i18n', () => ({ }, })); -jest.mock('../../../Navbar', () => ({ - getRampsBuildQuoteNavbarOptions: (navigation: unknown, options: unknown) => - mockGetRampsBuildQuoteNavbarOptions(navigation, options), -})); +jest.mock( + '../../../../../component-library/components-temp/HeaderCompactStandard', + () => { + const { View, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + subtitle, + onBack, + backButtonProps, + endButtonIconProps, + }: { + title?: string; + subtitle?: string; + onBack?: () => void; + backButtonProps?: { testID?: string }; + endButtonIconProps?: { + iconName: string; + onPress?: () => void; + testID?: string; + }[]; + }) => ( + + {title ? {title} : null} + {subtitle ? {subtitle} : null} + {onBack ? ( + + ) : null} + {endButtonIconProps?.map((btn, i) => ( + + ))} + + ), + }; + }, +); jest.mock('../../../../hooks/useFormatters', () => ({ useFormatters: () => ({ @@ -366,24 +402,14 @@ describe('BuildQuote', () => { expect(getByText('$10')).toBeOnTheScreen(); }); - it('sets navigation options with token and network data', () => { - renderWithTheme(); + it('renders inline header with token and network data', () => { + const { getByTestId, getByText } = renderWithTheme(); - expect(mockGetRampsBuildQuoteNavbarOptions).toHaveBeenCalledWith( - expect.objectContaining({ - navigate: mockNavigate, - setOptions: mockSetOptions, - goBack: mockGoBack, - }), - expect.objectContaining({ - tokenName: 'USD Coin', - tokenSymbol: 'USDC', - tokenIconUrl: 'https://example.com/usdc.png', - networkName: 'Ethereum Mainnet', - networkImageSource: { uri: 'https://example.com/eth.png' }, - onSettingsPress: expect.any(Function), - }), - ); + expect(getByTestId('header-compact-standard')).toBeOnTheScreen(); + expect(getByText('fiat_on_ramp.buy')).toBeOnTheScreen(); + expect(getByText('fiat_on_ramp.on_network')).toBeOnTheScreen(); + expect(getByTestId('build-quote-back-button')).toBeOnTheScreen(); + expect(getByTestId('build-quote-settings-button')).toBeOnTheScreen(); }); it('renders the payment method pill', () => { @@ -406,29 +432,17 @@ describe('BuildQuote', () => { ); }); - it('sets navigation options with undefined values when token is not found (shows skeleton)', () => { + it('renders inline header without title or subtitle when token is not found', () => { mockTokens = { allTokens: [], topTokens: [], }; - renderWithTheme(); + const { getByTestId, queryByText } = renderWithTheme(); - expect(mockGetRampsBuildQuoteNavbarOptions).toHaveBeenCalledWith( - expect.objectContaining({ - navigate: mockNavigate, - setOptions: mockSetOptions, - goBack: mockGoBack, - }), - expect.objectContaining({ - tokenName: undefined, - tokenSymbol: undefined, - tokenIconUrl: undefined, - networkName: undefined, - networkImageSource: undefined, - onSettingsPress: expect.any(Function), - }), - ); + expect(getByTestId('header-compact-standard')).toBeOnTheScreen(); + expect(queryByText('fiat_on_ramp.buy')).toBeNull(); + expect(queryByText('fiat_on_ramp.on_network')).toBeNull(); }); it('renders quick amount buttons when amount is zero', () => { diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx index 8b65ad8f17d..b1cf8368963 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx @@ -22,10 +22,11 @@ import { Button, ButtonVariant, ButtonSize, + IconName, } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; -import { getRampsBuildQuoteNavbarOptions } from '../../../Navbar'; +import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import Routes from '../../../../../constants/navigation/Routes'; import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './BuildQuote.styles'; @@ -325,38 +326,29 @@ function BuildQuote() { return getTokenNetworkInfo(selectedToken.chainId as CaipChainId); }, [selectedToken, getTokenNetworkInfo]); - useEffect(() => { - navigation.setOptions( - getRampsBuildQuoteNavbarOptions(navigation, { - tokenName: selectedToken?.name, - tokenSymbol: selectedToken?.symbol, - tokenIconUrl: selectedToken?.iconUrl, - networkName: networkInfo?.networkName ?? undefined, - networkImageSource: networkInfo?.networkImageSource, - onSettingsPress: () => { - trackEvent( - createEventBuilder(MetaMetricsEvents.RAMPS_SETTINGS_CLICKED) - .addProperties({ - location: 'Amount Input', - ramp_type: 'UNIFIED_BUY_2', - }) - .build(), - ); - navigation.navigate(...createSettingsModalNavDetails()); - }, - onBackPress: () => { - trackEvent( - createEventBuilder(MetaMetricsEvents.RAMPS_BACK_BUTTON_CLICKED) - .addProperties({ - location: 'Amount Input', - ramp_type: 'UNIFIED_BUY_2', - }) - .build(), - ); - }, - }), + const handleSettingsPress = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.RAMPS_SETTINGS_CLICKED) + .addProperties({ + location: 'Amount Input', + ramp_type: 'UNIFIED_BUY_2', + }) + .build(), + ); + navigation.navigate(...createSettingsModalNavDetails()); + }, [trackEvent, createEventBuilder, navigation]); + + const handleBackPress = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.RAMPS_BACK_BUTTON_CLICKED) + .addProperties({ + location: 'Amount Input', + ramp_type: 'UNIFIED_BUY_2', + }) + .build(), ); - }, [navigation, selectedToken, networkInfo, trackEvent, createEventBuilder]); + navigation.goBack(); + }, [trackEvent, createEventBuilder, navigation]); const handleKeypadChange = useCallback( ({ value, valueAsNumber, pressedKey }: KeypadChangeData) => { @@ -641,110 +633,136 @@ function BuildQuote() { : strings('fiat_on_ramp.no_quotes_available'); return ( - - - - - - - {formatCurrency(amountAsNumber, currency, { - currencyDisplay: 'narrowSymbol', - })} - - - - - - {quoteFetchError && ( - - )} - - - {hasAmount ? ( - <> - {nativeFlowError ? ( - link.name === PROVIDER_LINKS.SUPPORT, - )?.url - } - /> - ) : hasNoQuotes ? ( - - ) : ( - selectedProvider && ( - - {strings('fiat_on_ramp.powered_by_provider', { - provider: selectedProvider.name, - })} - - ) - )} - - - ) : ( - quickAmounts.length > 0 && ( - + - ) + + + + {quoteFetchError && ( + )} - - - - - + + + {hasAmount ? ( + <> + {nativeFlowError ? ( + link.name === PROVIDER_LINKS.SUPPORT, + )?.url + } + /> + ) : hasNoQuotes ? ( + + ) : ( + selectedProvider && ( + + {strings('fiat_on_ramp.powered_by_provider', { + provider: selectedProvider.name, + })} + + ) + )} + + + ) : ( + quickAmounts.length > 0 && ( + + ) + )} + + + + + + ); } diff --git a/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap index d43bfc59fa4..27a64293c0a 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap +++ b/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap @@ -1,36 +1,45 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`BuildQuote Continue button displays error banner when quote fetch fails 1`] = ` - +[ + + fiat_on_ramp.buy + + + fiat_on_ramp.on_network + + + + , + - - $100 - - - - - - Card + $100 - - - - + > + + + + Card + + + + + + - - - + > + + + + + Network error + + @@ -210,146 +253,109 @@ exports[`BuildQuote Continue button displays error banner when quote fetch fails accessibilityRole="text" style={ { - "color": "#131416", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, "lineHeight": 22, + "textAlign": "center", } } > - Network error + fiat_on_ramp.powered_by_provider - - - - - fiat_on_ramp.powered_by_provider - - - - fiat_on_ramp.continue - + + fiat_on_ramp.continue + + - - - - - 1 - - - - - + 1 + + + + - - 2 - - - - - + 2 + + + + - - 3 - - + + 3 + + + - - - - - 4 - - - - - + 4 + + + + - - 5 - - - - - + 5 + + + + - - 6 - - + + 6 + + + - - - - - 7 - - - - - + 7 + + + + - - 8 - - - - - + 8 + + + + - - 9 - - + + 9 + + + - - - - - . - - - - - + . + + + + - - 0 - - - - - + 0 + + + + - - + testID="keypad-delete-button" + > + + + - - + , +] `; exports[`BuildQuote matches snapshot 1`] = ` - +[ + + fiat_on_ramp.buy + + + fiat_on_ramp.on_network + + + + , + - - $100 - - - - - - fiat_on_ramp.select_payment_method + $100 - - - - + > + + + + fiat_on_ramp.select_payment_method + + + + + + - - + - - fiat_on_ramp.continue - + + fiat_on_ramp.continue + + - - - - - 1 - - - - - + 1 + + + + - - 2 - - - - - + 2 + + + + - - 3 - - + + 3 + + + - - - - - 4 - - - - - + 4 + + + + - - 5 - - - - - + 5 + + + + - - 6 - - + + 6 + + + - - - - - 7 - - - - - + 7 + + + + - - 8 - - - - - + 8 + + + + - - 9 - - + + 9 + + + - - - - - . - - - - - + . + + + + - - 0 - - - - - + 0 + + + + - - + testID="keypad-delete-button" + > + + + - - + , +] `; diff --git a/app/components/UI/Ramp/Views/Checkout/Checkout.tsx b/app/components/UI/Ramp/Views/Checkout/Checkout.tsx index fa918b8e227..2750806b14b 100644 --- a/app/components/UI/Ramp/Views/Checkout/Checkout.tsx +++ b/app/components/UI/Ramp/Views/Checkout/Checkout.tsx @@ -109,7 +109,6 @@ const Checkout = () => { callbackKey, } = params ?? {}; - const headerTitle = providerName ?? ''; const initialUriRef = useRef(uri); const callbackKeyRef = useRef(callbackKey); const hasCallbackFlow = Boolean(providerCode && walletAddress); @@ -127,7 +126,7 @@ const Checkout = () => { navigation.setOptions( getDepositNavbarOptions( navigation, - { title: providerName ?? headerTitle }, + { title: providerName ?? '' }, theme, () => { trackEvent( @@ -146,7 +145,6 @@ const Checkout = () => { navigation, theme, providerName, - headerTitle, createEventBuilder, trackEvent, rampRoutingDecision, @@ -314,9 +312,7 @@ const Checkout = () => { /> } style={styles.headerWithoutPadding} - > - {headerTitle} - + /> ); if (error) { diff --git a/app/components/UI/Ramp/Views/Checkout/__snapshots__/Checkout.test.tsx.snap b/app/components/UI/Ramp/Views/Checkout/__snapshots__/Checkout.test.tsx.snap index bcb33d8ac79..40ca84fc400 100644 --- a/app/components/UI/Ramp/Views/Checkout/__snapshots__/Checkout.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Checkout/__snapshots__/Checkout.test.tsx.snap @@ -466,29 +466,7 @@ exports[`Checkout renders error view when no URL is provided 1`] = ` undefined, ] } - > - - Test Provider - - + /> - - Test Provider - - + /> ( name={Routes.RAMP.TOKEN_SELECTION} component={TokenSelection} /> - + diff --git a/app/components/UI/Rewards/Views/RewardsSettingsView.test.tsx b/app/components/UI/Rewards/Views/RewardsSettingsView.test.tsx index 99e275cfeb2..e9d0eb02461 100644 --- a/app/components/UI/Rewards/Views/RewardsSettingsView.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsSettingsView.test.tsx @@ -7,6 +7,7 @@ import { configureStore } from '@reduxjs/toolkit'; import RewardsSettingsView, { REWARDS_SETTINGS_SAFE_AREA_TEST_ID, } from './RewardsSettingsView'; +import type { OffDeviceAccount } from '../hooks/useLinkedOffDeviceAccounts'; // Mock navigation const mockNavigate = jest.fn(); @@ -112,6 +113,53 @@ jest.mock('../../../hooks/useMetrics', () => ({ // Mock selectors jest.mock('../../../../selectors/rewards', () => ({})); +// Mock useLinkedOffDeviceAccounts hook +const mockUseLinkedOffDeviceAccounts = jest.fn( + () => [], +); +jest.mock('../hooks/useLinkedOffDeviceAccounts', () => ({ + useLinkedOffDeviceAccounts: () => mockUseLinkedOffDeviceAccounts(), +})); + +// Mock RewardsInfoBanner — pressable element that calls onConfirm +jest.mock('../components/RewardsInfoBanner', () => { + const ReactActual = jest.requireActual('react'); + const { TouchableOpacity } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + onConfirm, + testID, + }: { + onConfirm?: () => void; + testID?: string; + [key: string]: unknown; + }) => + ReactActual.createElement(TouchableOpacity, { + testID: testID ?? 'rewards-info-banner', + onPress: onConfirm, + }), + }; +}); + +// Mock LinkedOffDeviceAccountsSheet — renders with a close button for interaction tests +jest.mock('../components/Settings/LinkedOffDeviceAccountsSheet', () => { + const ReactActual = jest.requireActual('react'); + const { View, TouchableOpacity } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ onClose }: { accounts?: unknown[]; onClose?: () => void }) => + ReactActual.createElement( + View, + { testID: 'linked-off-device-accounts-sheet' }, + ReactActual.createElement(TouchableOpacity, { + testID: 'close-sheet-button', + onPress: onClose, + }), + ), + }; +}); + describe('RewardsSettingsView', () => { let store: ReturnType; const Stack = createStackNavigator(); @@ -157,6 +205,9 @@ describe('RewardsSettingsView', () => { mockUseRoute.mockReturnValue({ params: {}, }); + + // Default: no off-device accounts + mockUseLinkedOffDeviceAccounts.mockReturnValue([]); }); const renderWithNavigation = (component: React.ReactElement) => @@ -253,4 +304,123 @@ describe('RewardsSettingsView', () => { ); }); }); + + describe('Off-device accounts banner', () => { + it('does not render the banner when there are no off-device accounts', () => { + // Arrange + mockUseLinkedOffDeviceAccounts.mockReturnValue([]); + + // Act + const { queryByTestId } = renderWithNavigation(); + + // Assert + expect(queryByTestId('rewards-info-banner')).toBeNull(); + }); + + it('renders the banner when off-device accounts exist', () => { + // Arrange + mockUseLinkedOffDeviceAccounts.mockReturnValue([ + { + caip10: 'eip155:1:0x1234567890123456789012345678901234567890', + caipChainId: 'eip155:1', + address: '0x1234567890123456789012345678901234567890', + }, + ]); + + // Act + const { getByTestId } = renderWithNavigation(); + + // Assert + expect(getByTestId('rewards-info-banner')).toBeOnTheScreen(); + }); + + it('renders the banner for multiple off-device accounts', () => { + // Arrange + mockUseLinkedOffDeviceAccounts.mockReturnValue([ + { + caip10: 'eip155:1:0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + caipChainId: 'eip155:1', + address: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + { + caip10: 'eip155:1:0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + caipChainId: 'eip155:1', + address: '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + }, + ]); + + // Act + const { getByTestId } = renderWithNavigation(); + + // Assert + expect(getByTestId('rewards-info-banner')).toBeOnTheScreen(); + }); + }); + + describe('Off-device accounts sheet', () => { + const singleOffDeviceAccount = [ + { + caip10: 'eip155:1:0x1234567890123456789012345678901234567890', + caipChainId: 'eip155:1', + address: '0x1234567890123456789012345678901234567890', + }, + ]; + + it('does not render the sheet on initial mount', () => { + // Arrange + mockUseLinkedOffDeviceAccounts.mockReturnValue(singleOffDeviceAccount); + + // Act + const { queryByTestId } = renderWithNavigation(); + + // Assert + expect(queryByTestId('linked-off-device-accounts-sheet')).toBeNull(); + }); + + it('opens the sheet when the banner confirm button is pressed', () => { + // Arrange + mockUseLinkedOffDeviceAccounts.mockReturnValue(singleOffDeviceAccount); + + // Act + const { getByTestId } = renderWithNavigation(); + fireEvent.press(getByTestId('rewards-info-banner')); + + // Assert + expect(getByTestId('linked-off-device-accounts-sheet')).toBeOnTheScreen(); + }); + + it('closes the sheet when onClose is called', () => { + // Arrange + mockUseLinkedOffDeviceAccounts.mockReturnValue(singleOffDeviceAccount); + const { getByTestId, queryByTestId } = renderWithNavigation( + , + ); + + // Open the sheet + fireEvent.press(getByTestId('rewards-info-banner')); + expect(getByTestId('linked-off-device-accounts-sheet')).toBeOnTheScreen(); + + // Act — close via the sheet's onClose callback + fireEvent.press(getByTestId('close-sheet-button')); + + // Assert + expect(queryByTestId('linked-off-device-accounts-sheet')).toBeNull(); + }); + + it('can reopen the sheet after closing', () => { + // Arrange + mockUseLinkedOffDeviceAccounts.mockReturnValue(singleOffDeviceAccount); + const { getByTestId, queryByTestId } = renderWithNavigation( + , + ); + + // Open → close → reopen + fireEvent.press(getByTestId('rewards-info-banner')); + fireEvent.press(getByTestId('close-sheet-button')); + expect(queryByTestId('linked-off-device-accounts-sheet')).toBeNull(); + + fireEvent.press(getByTestId('rewards-info-banner')); + expect(getByTestId('linked-off-device-accounts-sheet')).toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/UI/Rewards/Views/RewardsSettingsView.tsx b/app/components/UI/Rewards/Views/RewardsSettingsView.tsx index 9196c6b4800..1444aa4e10d 100644 --- a/app/components/UI/Rewards/Views/RewardsSettingsView.tsx +++ b/app/components/UI/Rewards/Views/RewardsSettingsView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigation } from '@react-navigation/native'; import { Box } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -8,6 +8,9 @@ import ErrorBoundary from '../../../Views/ErrorBoundary'; import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; import RewardSettingsAccountGroupList from '../components/Settings/RewardSettingsAccountGroupList'; +import RewardsInfoBanner from '../components/RewardsInfoBanner'; +import LinkedOffDeviceAccountsSheet from '../components/Settings/LinkedOffDeviceAccountsSheet'; +import { useLinkedOffDeviceAccounts } from '../hooks/useLinkedOffDeviceAccounts'; export const REWARDS_SETTINGS_SAFE_AREA_TEST_ID = 'rewards-settings-safe-area'; @@ -16,6 +19,18 @@ const RewardsSettingsView: React.FC = () => { const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useMetrics(); const hasTrackedSettingsViewed = useRef(false); + const [isOffDeviceSheetOpen, setIsOffDeviceSheetOpen] = useState(false); + + // Computes off-device accounts; internally fetches subscription accounts from the backend + const offDeviceAccounts = useLinkedOffDeviceAccounts(); + + const handleOpenOffDeviceSheet = useCallback(() => { + setIsOffDeviceSheetOpen(true); + }, []); + + const handleCloseOffDeviceSheet = useCallback(() => { + setIsOffDeviceSheetOpen(false); + }, []); useEffect(() => { if (!hasTrackedSettingsViewed.current) { @@ -40,8 +55,31 @@ const RewardsSettingsView: React.FC = () => { includesTopInset /> + {offDeviceAccounts.length > 0 && ( + + + + )} + + {isOffDeviceSheetOpen && ( + + )} ); diff --git a/app/components/UI/Rewards/components/Settings/LinkedOffDeviceAccountsSheet.test.tsx b/app/components/UI/Rewards/components/Settings/LinkedOffDeviceAccountsSheet.test.tsx new file mode 100644 index 00000000000..c80cc4e5769 --- /dev/null +++ b/app/components/UI/Rewards/components/Settings/LinkedOffDeviceAccountsSheet.test.tsx @@ -0,0 +1,489 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import LinkedOffDeviceAccountsSheet from './LinkedOffDeviceAccountsSheet'; +import ClipboardManager from '../../../../../core/ClipboardManager'; +import type { OffDeviceAccount } from '../../hooks/useLinkedOffDeviceAccounts'; + +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +// Mock FlatList from gesture handler with the real RN FlatList so items render +jest.mock('react-native-gesture-handler', () => { + const actual = jest.requireActual('react-native'); + return { + FlatList: actual.FlatList, + ScrollView: actual.ScrollView, + }; +}); + +// Mock BottomSheet — renders children directly +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + children, + }: { + children?: React.ReactNode; + [key: string]: unknown; + }) => + ReactActual.createElement(View, { testID: 'bottom-sheet' }, children), + }; + }, +); + +// Mock HeaderCompactStandard — renders title text directly +jest.mock( + '../../../../../component-library/components-temp/HeaderCompactStandard', + () => { + const ReactActual = jest.requireActual('react'); + const { View, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ title }: { title?: string; onClose?: () => void }) => + ReactActual.createElement( + View, + { testID: 'header-compact-standard' }, + ReactActual.createElement(Text, null, title), + ), + }; + }, +); + +// Mock Avatar +jest.mock('../../../../../component-library/components/Avatars/Avatar', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + name, + testID, + }: { + name?: string; + testID?: string; + [key: string]: unknown; + }) => + ReactActual.createElement(View, { + testID: testID ?? `avatar-${name}`, + }), + AvatarSize: { Xs: 'xs', Sm: 'sm', Md: 'md', Lg: 'lg', Xl: 'xl' }, + AvatarVariant: { Account: 'Account', Network: 'Network' }, + }; +}); + +// Mock design system components +jest.mock('@metamask/design-system-react-native', () => { + const ReactActual = jest.requireActual('react'); + const { + View, + Text: RNText, + TouchableOpacity, + } = jest.requireActual('react-native'); + return { + Box: ({ + children, + testID, + ...props + }: { + children?: React.ReactNode; + testID?: string; + [key: string]: unknown; + }) => ReactActual.createElement(View, { testID, ...props }, children), + Text: ({ + children, + onPress, + testID, + ...props + }: { + children?: React.ReactNode; + onPress?: () => void; + testID?: string; + [key: string]: unknown; + }) => + ReactActual.createElement( + RNText, + { onPress, testID, ...props }, + children, + ), + ButtonIcon: ({ + onPress, + testID, + }: { + onPress?: () => void; + testID?: string; + [key: string]: unknown; + }) => + ReactActual.createElement(TouchableOpacity, { + onPress, + testID: testID ?? 'copy-button', + }), + TextVariant: { BodyMd: 'BodyMd', BodySm: 'BodySm', HeadingMd: 'HeadingMd' }, + BoxFlexDirection: { Row: 'row', Column: 'column' }, + BoxAlignItems: { Center: 'center', Start: 'flex-start' }, + IconName: { Copy: 'copy', Info: 'info', Close: 'close' }, + IconSize: { Md: 'md', Xl: 'xl' }, + ButtonIconSize: { Md: 'md', Lg: 'lg' }, + FontWeight: { Medium: 'medium', Bold: 'bold' }, + }; +}); + +// Mock i18n — returns the key so tests can assert on i18n keys +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +// Mock address utils +jest.mock('../../../../../util/address', () => ({ + formatAddress: jest.fn((address: string) => `short:${address.slice(2, 8)}`), +})); + +// Mock network utils +jest.mock('../../../../../util/networks', () => ({ + getNetworkImageSource: jest.fn(() => undefined), + getDefaultNetworkByChainId: jest.fn(() => ({ shortName: 'Ethereum' })), +})); + +// Mock ClipboardManager +jest.mock('../../../../../core/ClipboardManager', () => ({ + __esModule: true, + default: { setString: jest.fn().mockResolvedValue(undefined) }, +})); + +// Mock @metamask/controller-utils toHex +jest.mock('@metamask/controller-utils', () => ({ + toHex: jest.fn(() => '0x1'), +})); + +// ─── Test fixtures ──────────────────────────────────────────────────────────── + +const mockAccount1: OffDeviceAccount = { + caip10: 'eip155:1:0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + caipChainId: 'eip155:1', + address: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', +}; + +const mockAccount2: OffDeviceAccount = { + caip10: 'eip155:1:0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + caipChainId: 'eip155:1', + address: '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', +}; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('LinkedOffDeviceAccountsSheet', () => { + const mockOnClose = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + it('renders the bottom sheet', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('bottom-sheet')).toBeOnTheScreen(); + }); + + it('renders the sheet header', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('header-compact-standard')).toBeOnTheScreen(); + }); + + it('renders the sheet title', () => { + const { getByText } = render( + , + ); + expect( + getByText('rewards.settings.off_device_accounts_sheet_title'), + ).toBeOnTheScreen(); + }); + + it('renders the sheet description', () => { + const { getByText } = render( + , + ); + // The description Text also contains a nested "let us know" Text child, so the + // full text content includes both strings — use a regex for a partial match. + expect( + getByText(/rewards\.settings\.off_device_accounts_sheet_description/), + ).toBeOnTheScreen(); + }); + + it('renders the "let us know" link text', () => { + const { getByText } = render( + , + ); + expect( + getByText('rewards.settings.off_device_accounts_sheet_let_us_know'), + ).toBeOnTheScreen(); + }); + + it('renders a chain name for each account', () => { + const { getAllByText } = render( + , + ); + // Both accounts map to 'Ethereum' via the getDefaultNetworkByChainId mock + expect(getAllByText('Ethereum')).toHaveLength(2); + }); + + it('renders a formatted address for each account', () => { + const { getAllByText } = render( + , + ); + expect(getAllByText(/^short:/)).toHaveLength(2); + }); + + it('renders a copy button for each account', () => { + const { getAllByTestId } = render( + , + ); + expect(getAllByTestId('copy-button')).toHaveLength(2); + }); + + it('renders a network avatar for each account', () => { + const { getAllByTestId } = render( + , + ); + // Avatar testID = 'avatar-{caipChainId}' + expect(getAllByTestId('avatar-eip155:1')).toHaveLength(2); + }); + + it('renders without errors when the accounts list is empty', () => { + const renderResult = render( + , + ); + expect(renderResult).toBeTruthy(); + }); + + it('renders without errors when onClose is not provided', () => { + const renderResult = render( + , + ); + expect(renderResult).toBeTruthy(); + }); + }); + + describe('Account sorting', () => { + it('sorts accounts alphabetically by address ascending', () => { + // Input: [account2 (0xBBBB...), account1 (0xAAAA...)] — reverse order + const { getAllByText } = render( + , + ); + // After sorting, account1 (0xAAAA → 'short:AAAAAA') appears before account2 + const addressTexts = getAllByText(/^short:/); + expect(addressTexts[0]).toHaveTextContent('short:AAAAAA'); + expect(addressTexts[1]).toHaveTextContent('short:BBBBBB'); + }); + }); + + describe('Interactions', () => { + it('navigates to the SimpleWebview with the support URL when "let us know" is pressed', () => { + const { getByText } = render( + , + ); + fireEvent.press( + getByText('rewards.settings.off_device_accounts_sheet_let_us_know'), + ); + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: expect.objectContaining({ + title: 'app_settings.contact_support', + }), + }); + }); + + it('navigates exactly once per press', () => { + const { getByText } = render( + , + ); + fireEvent.press( + getByText('rewards.settings.off_device_accounts_sheet_let_us_know'), + ); + expect(mockNavigate).toHaveBeenCalledTimes(1); + }); + + it('calls ClipboardManager.setString with the account address when copy is pressed', () => { + const { getByTestId } = render( + , + ); + fireEvent.press(getByTestId('copy-button')); + expect(ClipboardManager.setString).toHaveBeenCalledWith( + mockAccount1.address, + ); + }); + + it('calls ClipboardManager.setString with the correct address for each account', () => { + const { getAllByTestId } = render( + , + ); + // After sorting: account1 (0xAAAA) first, account2 (0xBBBB) second + const [firstCopyBtn, secondCopyBtn] = getAllByTestId('copy-button'); + + fireEvent.press(firstCopyBtn); + expect(ClipboardManager.setString).toHaveBeenLastCalledWith( + mockAccount1.address, + ); + + fireEvent.press(secondCopyBtn); + expect(ClipboardManager.setString).toHaveBeenLastCalledWith( + mockAccount2.address, + ); + }); + }); + + describe('Chain name resolution (getChainShortName)', () => { + it('displays the network shortName from getDefaultNetworkByChainId', () => { + const { getByText } = render( + , + ); + expect(getByText('Ethereum')).toBeOnTheScreen(); + }); + + it('falls back to the raw caipChainId when the network has no shortName', () => { + const { getDefaultNetworkByChainId } = jest.requireMock( + '../../../../../util/networks', + ); + getDefaultNetworkByChainId.mockReturnValueOnce({}); + + const accountNoShortName: OffDeviceAccount = { + caip10: 'eip155:1:0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + caipChainId: 'eip155:1', + address: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + }; + + const { getByText } = render( + , + ); + expect(getByText('eip155:1')).toBeOnTheScreen(); + }); + + it('falls back to the raw caipChainId when getDefaultNetworkByChainId returns undefined', () => { + const { getDefaultNetworkByChainId } = jest.requireMock( + '../../../../../util/networks', + ); + getDefaultNetworkByChainId.mockReturnValueOnce(undefined); + + const accountUnknownChain: OffDeviceAccount = { + caip10: 'eip155:999:0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD', + caipChainId: 'eip155:999', + address: '0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD', + }; + + const { getByText } = render( + , + ); + expect(getByText('eip155:999')).toBeOnTheScreen(); + }); + + it('falls back to the raw caipChainId when getDefaultNetworkByChainId throws', () => { + const { getDefaultNetworkByChainId } = jest.requireMock( + '../../../../../util/networks', + ); + getDefaultNetworkByChainId.mockImplementationOnce(() => { + throw new Error('Network not found'); + }); + + const { getByText } = render( + , + ); + expect(getByText('eip155:1')).toBeOnTheScreen(); + }); + }); + + describe('Error handling', () => { + it('renders successfully when getNetworkImageSource throws', () => { + const { getNetworkImageSource } = jest.requireMock( + '../../../../../util/networks', + ); + getNetworkImageSource.mockImplementationOnce(() => { + throw new Error('Image not found'); + }); + + const renderResult = render( + , + ); + expect(renderResult).toBeTruthy(); + }); + + it('does not throw when ClipboardManager.setString rejects', () => { + (ClipboardManager.setString as jest.Mock).mockRejectedValueOnce( + new Error('Clipboard unavailable'), + ); + + const { getByTestId } = render( + , + ); + + expect(() => fireEvent.press(getByTestId('copy-button'))).not.toThrow(); + }); + }); +}); diff --git a/app/components/UI/Rewards/components/Settings/LinkedOffDeviceAccountsSheet.tsx b/app/components/UI/Rewards/components/Settings/LinkedOffDeviceAccountsSheet.tsx new file mode 100644 index 00000000000..31ffdfe2880 --- /dev/null +++ b/app/components/UI/Rewards/components/Settings/LinkedOffDeviceAccountsSheet.tsx @@ -0,0 +1,176 @@ +import React, { useCallback, useMemo } from 'react'; +import { StyleSheet } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { FlatList } from 'react-native-gesture-handler'; +import { + Box, + Text, + TextVariant, + BoxFlexDirection, + BoxAlignItems, + ButtonIcon, + IconName, + IconSize, + FontWeight, +} from '@metamask/design-system-react-native'; +import { toHex } from '@metamask/controller-utils'; +import BottomSheet from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import Avatar, { + AvatarSize, + AvatarVariant, +} from '../../../../../component-library/components/Avatars/Avatar'; +import { strings } from '../../../../../../locales/i18n'; +import { formatAddress } from '../../../../../util/address'; +import { + getNetworkImageSource, + getDefaultNetworkByChainId, +} from '../../../../../util/networks'; +import ClipboardManager from '../../../../../core/ClipboardManager'; +import type { OffDeviceAccount } from '../../hooks/useLinkedOffDeviceAccounts'; +import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; + +const styles = StyleSheet.create({ + list: { + maxHeight: 275, + }, +}); + +/** + * Returns the short display name for a CAIP chain ID (e.g. "eip155:1" → "Ethereum"). + * Falls back to the raw caipChainId string when the chain is not in the default network list. + */ +function getChainShortName(caipChainId: string): string { + try { + const colonIdx = caipChainId.indexOf(':'); + if (colonIdx === -1) return caipChainId; + const reference = caipChainId.slice(colonIdx + 1); + const hexChainId = toHex(reference); + const network = getDefaultNetworkByChainId(hexChainId); + return ( + (network as unknown as { shortName?: string })?.shortName ?? caipChainId + ); + } catch { + return caipChainId; + } +} + +interface LinkedOffDeviceAccountsSheetProps { + accounts: OffDeviceAccount[]; + onClose?: () => void; +} + +const LinkedOffDeviceAccountsSheet: React.FC< + LinkedOffDeviceAccountsSheetProps +> = ({ accounts, onClose }) => { + const navigation = useNavigation(); + + const handleContactSupport = useCallback(() => { + let supportUrl = 'https://support.metamask.io'; + + ///: BEGIN:ONLY_INCLUDE_IF(beta) + supportUrl = 'https://intercom.help/internal-beta-testing/en/'; + ///: END:ONLY_INCLUDE_IF + + navigation.navigate('Webview', { + screen: 'SimpleWebview', + params: { + url: supportUrl, + title: strings('app_settings.contact_support'), + }, + }); + }, [navigation]); + + const handleCopy = useCallback(async (address: string) => { + try { + await ClipboardManager.setString(address); + } catch { + // clipboard write failures are non-critical; swallow silently + } + }, []); + + const sortedAccounts = useMemo( + () => [...accounts].sort((a, b) => a.address.localeCompare(b.address)), + [accounts], + ); + + const renderItem = useCallback( + ({ item }: { item: OffDeviceAccount }) => { + let networkImageSource; + try { + networkImageSource = getNetworkImageSource({ + chainId: item.caipChainId, + }); + } catch { + networkImageSource = undefined; + } + + const chainName = getChainShortName(item.caipChainId); + + return ( + + {/* Network icon */} + + + {/* Chain name + shortened address */} + + + {chainName} + + + {formatAddress(item.address, 'short')} + + + + {/* Copy CTA */} + handleCopy(item.address)} + iconName={IconName.Copy} + iconProps={{ size: IconSize.Md }} + /> + + ); + }, + [handleCopy], + ); + + return ( + + + + + + {strings('rewards.settings.off_device_accounts_sheet_description')}{' '} + + {strings('rewards.settings.off_device_accounts_sheet_let_us_know')} + + + + item.caip10} + renderItem={renderItem} + style={styles.list} + scrollEnabled + /> + + + ); +}; + +export default LinkedOffDeviceAccountsSheet; diff --git a/app/components/UI/Rewards/hooks/useLinkedOffDeviceAccounts.test.ts b/app/components/UI/Rewards/hooks/useLinkedOffDeviceAccounts.test.ts new file mode 100644 index 00000000000..25fb0ea23c4 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useLinkedOffDeviceAccounts.test.ts @@ -0,0 +1,267 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useDispatch, useSelector } from 'react-redux'; +import { useFocusEffect } from '@react-navigation/native'; +import { + useLinkedOffDeviceAccounts, + type OffDeviceAccount, +} from './useLinkedOffDeviceAccounts'; +import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { selectMissingEnrolledAccountsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; +import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; +import { AuthorizationFailedError } from '../../../../core/Engine/controllers/rewards-controller/services/rewards-data-service'; +import { + resetRewardsState, + setCandidateSubscriptionId, +} from '../../../../reducers/rewards'; + +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), + useSelector: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { + call: jest.fn(), + }, +})); + +jest.mock('../../../../selectors/rewards', () => ({ + selectRewardsSubscriptionId: jest.fn(), +})); + +jest.mock('../../../../selectors/featureFlagController/rewards', () => ({ + selectMissingEnrolledAccountsRewardsEnabledFlag: jest.fn(), +})); + +jest.mock('@react-navigation/native', () => ({ + useFocusEffect: jest.fn(), +})); + +jest.mock('./useInvalidateByRewardEvents', () => ({ + useInvalidateByRewardEvents: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards', () => ({ + resetRewardsState: jest.fn(), + setCandidateSubscriptionId: jest.fn(), +})); + +describe('useLinkedOffDeviceAccounts', () => { + const mockDispatch = jest.fn(); + const mockEngineCall = Engine.controllerMessenger.call as jest.MockedFunction< + typeof Engine.controllerMessenger.call + >; + const mockUseDispatch = useDispatch as jest.MockedFunction< + typeof useDispatch + >; + const mockUseSelector = useSelector as jest.MockedFunction< + typeof useSelector + >; + const mockUseFocusEffect = useFocusEffect as jest.MockedFunction< + typeof useFocusEffect + >; + const mockUseInvalidateByRewardEvents = + useInvalidateByRewardEvents as jest.MockedFunction< + typeof useInvalidateByRewardEvents + >; + const mockResetRewardsState = resetRewardsState as jest.MockedFunction< + typeof resetRewardsState + >; + const mockSetCandidateSubscriptionId = + setCandidateSubscriptionId as jest.MockedFunction< + typeof setCandidateSubscriptionId + >; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + mockResetRewardsState.mockReturnValue({ + type: 'rewards/resetRewardsState', + payload: undefined, + }); + mockSetCandidateSubscriptionId.mockReturnValue({ + type: 'rewards/setCandidateSubscriptionId', + payload: 'retry', + }); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) return 'sub-123'; + if (selector === selectMissingEnrolledAccountsRewardsEnabledFlag) + return true; + return undefined; + }); + }); + + describe('initial state', () => { + it('returns an empty array initially', () => { + const { result } = renderHook(() => useLinkedOffDeviceAccounts()); + + expect(result.current).toEqual([]); + }); + }); + + describe('fetching accounts', () => { + it('calls Engine controller with the subscription ID', async () => { + const mockCaip10Accounts = [ + 'eip155:1:0xabc1234567890abcdef1234567890abcdef12345', + ]; + mockEngineCall.mockResolvedValueOnce(mockCaip10Accounts); + + renderHook(() => useLinkedOffDeviceAccounts()); + + await act(async () => { + const focusCallback = mockUseFocusEffect.mock.calls[0][0]; + await focusCallback(); + }); + + expect(mockEngineCall).toHaveBeenCalledWith( + 'RewardsController:getOffDeviceSubscriptionAccounts', + 'sub-123', + ); + }); + + it('parses CAIP-10 accounts into OffDeviceAccount objects', async () => { + const mockCaip10Accounts = [ + 'eip155:1:0xabc1234567890abcdef1234567890abcdef12345', + 'eip155:137:0xdef9876543210fedcba9876543210fedcba98765', + ]; + mockEngineCall.mockResolvedValueOnce(mockCaip10Accounts); + + const { result } = renderHook(() => useLinkedOffDeviceAccounts()); + + await act(async () => { + const focusCallback = mockUseFocusEffect.mock.calls[0][0]; + await focusCallback(); + }); + + const expected: OffDeviceAccount[] = [ + { + caip10: 'eip155:1:0xabc1234567890abcdef1234567890abcdef12345', + caipChainId: 'eip155:1', + address: '0xabc1234567890abcdef1234567890abcdef12345', + }, + { + caip10: 'eip155:137:0xdef9876543210fedcba9876543210fedcba98765', + caipChainId: 'eip155:137', + address: '0xdef9876543210fedcba9876543210fedcba98765', + }, + ]; + expect(result.current).toEqual(expected); + }); + + it('returns empty array when subscriptionId is null', async () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) return null; + if (selector === selectMissingEnrolledAccountsRewardsEnabledFlag) + return true; + return undefined; + }); + + const { result } = renderHook(() => useLinkedOffDeviceAccounts()); + + await act(async () => { + const focusCallback = mockUseFocusEffect.mock.calls[0][0]; + await focusCallback(); + }); + + expect(mockEngineCall).not.toHaveBeenCalled(); + expect(result.current).toEqual([]); + }); + + it('returns empty array and skips fetch when feature flag is disabled', async () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) return 'sub-123'; + if (selector === selectMissingEnrolledAccountsRewardsEnabledFlag) + return false; + return undefined; + }); + + const { result } = renderHook(() => useLinkedOffDeviceAccounts()); + + await act(async () => { + const focusCallback = mockUseFocusEffect.mock.calls[0][0]; + await focusCallback(); + }); + + expect(mockEngineCall).not.toHaveBeenCalled(); + expect(result.current).toEqual([]); + }); + + it('skips entries with no colon in the CAIP-10 string', async () => { + const mockCaip10Accounts = [ + 'invalid-no-colon', + 'eip155:1:0xabc1234567890abcdef1234567890abcdef12345', + ]; + mockEngineCall.mockResolvedValueOnce(mockCaip10Accounts); + + const { result } = renderHook(() => useLinkedOffDeviceAccounts()); + + await act(async () => { + const focusCallback = mockUseFocusEffect.mock.calls[0][0]; + await focusCallback(); + }); + + expect(result.current).toHaveLength(1); + expect(result.current[0].caip10).toBe( + 'eip155:1:0xabc1234567890abcdef1234567890abcdef12345', + ); + }); + }); + + describe('error handling', () => { + it('sets empty array and triggers auth recovery on AuthorizationFailedError', async () => { + mockEngineCall.mockRejectedValueOnce( + new AuthorizationFailedError('Rewards authorization failed'), + ); + + const { result } = renderHook(() => useLinkedOffDeviceAccounts()); + + await act(async () => { + const focusCallback = mockUseFocusEffect.mock.calls[0][0]; + await focusCallback(); + }); + + expect(result.current).toEqual([]); + expect(mockDispatch).toHaveBeenCalledWith(mockResetRewardsState()); + expect(mockDispatch).toHaveBeenCalledWith( + mockSetCandidateSubscriptionId('retry'), + ); + }); + + it('sets empty array and does NOT dispatch auth actions on generic errors', async () => { + mockEngineCall.mockRejectedValueOnce(new Error('Network failed')); + + const { result } = renderHook(() => useLinkedOffDeviceAccounts()); + + await act(async () => { + const focusCallback = mockUseFocusEffect.mock.calls[0][0]; + await focusCallback(); + }); + + expect(result.current).toEqual([]); + expect(mockDispatch).not.toHaveBeenCalledWith(mockResetRewardsState()); + expect(mockDispatch).not.toHaveBeenCalledWith( + mockSetCandidateSubscriptionId('retry'), + ); + }); + }); + + describe('useFocusEffect integration', () => { + it('registers a focus effect callback', () => { + renderHook(() => useLinkedOffDeviceAccounts()); + + expect(mockUseFocusEffect).toHaveBeenCalledWith(expect.any(Function)); + }); + }); + + describe('useInvalidateByRewardEvents integration', () => { + it('registers RewardsController:accountLinked as an invalidation event', () => { + renderHook(() => useLinkedOffDeviceAccounts()); + + expect(mockUseInvalidateByRewardEvents).toHaveBeenCalledWith( + ['RewardsController:accountLinked'], + expect.any(Function), + ); + }); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useLinkedOffDeviceAccounts.ts b/app/components/UI/Rewards/hooks/useLinkedOffDeviceAccounts.ts new file mode 100644 index 00000000000..1d585986c00 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useLinkedOffDeviceAccounts.ts @@ -0,0 +1,86 @@ +import { useState, useCallback, useMemo } from 'react'; +import { useFocusEffect } from '@react-navigation/native'; +import { useSelector, useDispatch } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { selectMissingEnrolledAccountsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; +import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; +import { AuthorizationFailedError } from '../../../../core/Engine/controllers/rewards-controller/services/rewards-data-service'; +import { + resetRewardsState, + setCandidateSubscriptionId, +} from '../../../../reducers/rewards'; + +export interface OffDeviceAccount { + /** Full CAIP-10 string as returned by the backend */ + caip10: string; + /** Chain namespace:reference portion (e.g. "eip155:1") */ + caipChainId: string; + /** Bare address portion of the CAIP-10 string */ + address: string; +} + +/** + * Returns the list of accounts that are linked to the subscription on the + * backend (via the rewards API) but are NOT present on this device. + * + * The delta computation is performed inside the RewardsController and cached + * for 5 minutes. Results are re-fetched on focus and on accountLinked events. + */ +export const useLinkedOffDeviceAccounts = (): OffDeviceAccount[] => { + const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isMissingEnrolledAccountsEnabled = useSelector( + selectMissingEnrolledAccountsRewardsEnabledFlag, + ); + const dispatch = useDispatch(); + const [offDeviceAccounts, setOffDeviceAccounts] = useState< + OffDeviceAccount[] + >([]); + + const fetchOffDeviceAccounts = useCallback(async (): Promise => { + if (!isMissingEnrolledAccountsEnabled || !subscriptionId) { + setOffDeviceAccounts([]); + return; + } + + try { + const caip10Accounts = await Engine.controllerMessenger.call( + 'RewardsController:getOffDeviceSubscriptionAccounts', + subscriptionId, + ); + + const parsed: OffDeviceAccount[] = []; + for (const caip10 of caip10Accounts) { + const lastColon = caip10.lastIndexOf(':'); + if (lastColon === -1) continue; + parsed.push({ + caip10, + caipChainId: caip10.slice(0, lastColon), + address: caip10.slice(lastColon + 1), + }); + } + setOffDeviceAccounts(parsed); + } catch (error) { + if (error instanceof AuthorizationFailedError) { + dispatch(resetRewardsState()); + dispatch(setCandidateSubscriptionId('retry')); + } + setOffDeviceAccounts([]); + } + }, [dispatch, isMissingEnrolledAccountsEnabled, subscriptionId]); + + useFocusEffect( + useCallback(() => { + fetchOffDeviceAccounts(); + }, [fetchOffDeviceAccounts]), + ); + + const invalidateEvents = useMemo( + () => ['RewardsController:accountLinked' as const], + [], + ); + + useInvalidateByRewardEvents(invalidateEvents, fetchOffDeviceAccounts); + + return offDeviceAccounts; +}; diff --git a/app/components/Views/Identity/TurnOnBackupAndSync/TurnOnBackupAndSync.styles.ts b/app/components/Views/Identity/TurnOnBackupAndSync/TurnOnBackupAndSync.styles.ts deleted file mode 100644 index fc715fb9162..00000000000 --- a/app/components/Views/Identity/TurnOnBackupAndSync/TurnOnBackupAndSync.styles.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* eslint-disable import/prefer-default-export */ -import { StyleSheet } from 'react-native'; -import type { Theme } from '@metamask/design-tokens'; -import Device from '../../../../util/device'; -import scaling from '../../../../util/scaling'; - -const HEIGHT = scaling.scale(240); - -export const createStyles = ({ colors }: Theme) => - StyleSheet.create({ - wrapper: { - flex: 1, - alignItems: 'flex-start', - backgroundColor: colors.background.default, - paddingTop: 60, - paddingHorizontal: 16, - }, - card: { - height: HEIGHT, - width: Device.getDeviceWidth() - 32, - alignSelf: 'center', - borderRadius: 12, - overflow: 'hidden', - marginBottom: 32, - }, - image: { - resizeMode: 'cover', - height: HEIGHT, - width: Device.getDeviceWidth() - 32, - }, - btnContainer: { - flexDirection: 'row', - justifyContent: 'space-between', - alignSelf: 'center', - marginBottom: 16, - }, - ctaBtn: { - margin: 4, - width: '48%', - alignSelf: 'center', - }, - textSpace: { - marginBottom: 16, - }, - textTitle: { - marginBottom: 16, - alignSelf: 'center', - }, - textSettings: { - flexDirection: 'column', - }, - }); diff --git a/app/components/Views/Identity/TurnOnBackupAndSync/TurnOnBackupAndSync.test.tsx b/app/components/Views/Identity/TurnOnBackupAndSync/TurnOnBackupAndSync.test.tsx deleted file mode 100644 index d2c08208bd7..00000000000 --- a/app/components/Views/Identity/TurnOnBackupAndSync/TurnOnBackupAndSync.test.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import React from 'react'; - -import TurnOnBackupAndSync, { - turnOnBackupAndSyncTestIds, -} from './TurnOnBackupAndSync'; -import renderWithProvider from '../../../../util/test/renderWithProvider'; -import Routes from '../../../../constants/navigation/Routes'; -import { fireEvent, waitFor } from '@testing-library/react-native'; -import { BACKUPANDSYNC_FEATURES } from '@metamask/profile-sync-controller/user-storage'; -import { useMetrics } from '../../../hooks/useMetrics'; -import { MetricsEventBuilder } from '../../../../core/Analytics/MetricsEventBuilder'; - -const mockTrackEvent = jest.fn(); -jest.mock('../../../hooks/useMetrics'); - -(useMetrics as jest.MockedFn).mockReturnValue({ - trackEvent: mockTrackEvent, - createEventBuilder: MetricsEventBuilder.createEventBuilder, - enable: jest.fn(), - addTraitsToUser: jest.fn(), - createDataDeletionTask: jest.fn(), - checkDataDeleteStatus: jest.fn(), - getDeleteRegulationCreationDate: jest.fn(), - getDeleteRegulationId: jest.fn(), - isDataRecorded: jest.fn(), - isEnabled: jest.fn(), - getMetaMetricsId: jest.fn(), -}); - -const MOCK_STORE_STATE = { - engine: { - backgroundState: { - UserStorageController: { - isBackupAndSyncEnabled: true, - }, - AuthenticationController: { - isSignedIn: true, - }, - }, - }, - settings: { - basicFunctionalityEnabled: true, - }, -}; - -const { InteractionManager } = jest.requireActual('react-native'); - -InteractionManager.runAfterInteractions = jest.fn(async (callback) => - callback(), -); - -const mockNavigate = jest.fn(); -jest.mock('@react-navigation/native', () => { - const actualNav = jest.requireActual('@react-navigation/native'); - return { - ...actualNav, - useNavigation: () => ({ - navigate: mockNavigate, - goBack: jest.fn(), - }), - }; -}); - -const mockSetIsBackupAndSyncFeatureEnabled = jest.fn(); -jest.mock('../../../../util/identity/hooks/useBackupAndSync', () => ({ - useBackupAndSync: () => ({ - setIsBackupAndSyncFeatureEnabled: mockSetIsBackupAndSyncFeatureEnabled, - error: null, - }), -})); - -describe('TurnOnBackupAndSync', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders correctly', () => { - const { toJSON } = renderWithProvider(, { - state: MOCK_STORE_STATE, - }); - expect(toJSON()).toMatchSnapshot(); - }); - - it('sends a MetaMetrics event when the modal is dismissed', () => { - const { getByTestId } = renderWithProvider(, { - state: MOCK_STORE_STATE, - }); - - const cancelButton = getByTestId(turnOnBackupAndSyncTestIds.cancelButton); - fireEvent.press(cancelButton); - - expect(mockTrackEvent).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'Profile Activity Updated', - properties: expect.objectContaining({ - feature_name: 'Backup And Sync Carousel Modal', - action: 'Modal Dismissed', - }), - }), - ); - }); - - it('enables backup and sync when clicking on the cta if backup and sync is disabled, and navigates to backup and sync settings either way', async () => { - const { getByTestId } = renderWithProvider(, { - state: { - ...MOCK_STORE_STATE, - engine: { - backgroundState: { - ...MOCK_STORE_STATE.engine.backgroundState, - UserStorageController: { - isBackupAndSyncEnabled: false, - }, - }, - }, - }, - }); - - const switchElement = getByTestId(turnOnBackupAndSyncTestIds.enableButton); - fireEvent.press(switchElement); - - await waitFor(() => { - expect(mockSetIsBackupAndSyncFeatureEnabled).toHaveBeenCalledWith( - BACKUPANDSYNC_FEATURES.main, - true, - ); - expect(mockNavigate).toHaveBeenCalledWith(Routes.SETTINGS_VIEW, { - screen: Routes.SETTINGS.BACKUP_AND_SYNC, - }); - }); - }); - - it('opens a modal when clicking on the cta while basic functionality is off', () => { - const { getByTestId } = renderWithProvider(, { - state: { - ...MOCK_STORE_STATE, - engine: { - backgroundState: { - ...MOCK_STORE_STATE.engine.backgroundState, - UserStorageController: { - isBackupAndSyncEnabled: false, - }, - }, - }, - settings: { - ...MOCK_STORE_STATE.settings, - basicFunctionalityEnabled: false, - }, - }, - }); - - const switchElement = getByTestId(turnOnBackupAndSyncTestIds.enableButton); - fireEvent.press(switchElement); - - expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SHEET.CONFIRM_TURN_ON_BACKUP_AND_SYNC, - params: { - enableBackupAndSync: expect.any(Function), - trackEnableBackupAndSyncEvent: expect.any(Function), - }, - }); - }); - - it('sends a MetaMetrics event when enabling backup and sync', async () => { - const { getByTestId } = renderWithProvider(, { - state: MOCK_STORE_STATE, - }); - - const switchElement = getByTestId(turnOnBackupAndSyncTestIds.enableButton); - fireEvent.press(switchElement); - - await waitFor(() => { - expect(mockTrackEvent).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'Profile Activity Updated', - properties: expect.objectContaining({ - feature_name: 'Backup And Sync Carousel Modal', - action: 'Turned On', - }), - }), - ); - }); - }); -}); diff --git a/app/components/Views/Identity/TurnOnBackupAndSync/TurnOnBackupAndSync.tsx b/app/components/Views/Identity/TurnOnBackupAndSync/TurnOnBackupAndSync.tsx deleted file mode 100644 index 25cd0ea5e9e..00000000000 --- a/app/components/Views/Identity/TurnOnBackupAndSync/TurnOnBackupAndSync.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import React, { Fragment } from 'react'; -import { Image, View, Linking, ScrollView } from 'react-native'; - -import Button, { - ButtonVariants, -} from '../../../../component-library/components/Buttons/Button'; -import { strings } from '../../../../../locales/i18n'; -import Text, { - TextColor, - TextVariant, -} from '../../../../component-library/components/Texts/Text'; -import { useTheme } from '../../../../util/theme'; -import EnableBackupAndSyncCardImage from '../../../../images/enableBackupAndSyncCard.png'; -import { createStyles } from './TurnOnBackupAndSync.styles'; -import AppConstants from '../../../../core/AppConstants'; -import SwitchLoadingModal from '../../../UI/Notification/SwitchLoadingModal'; -import { useNavigation } from '@react-navigation/native'; -import { useSelector } from 'react-redux'; -import { RootState } from '../../../../reducers'; -import { selectIsBackupAndSyncEnabled } from '../../../../selectors/identity'; -import Routes from '../../../../constants/navigation/Routes'; -import { useBackupAndSync } from '../../../../util/identity/hooks/useBackupAndSync'; -import { BACKUPANDSYNC_FEATURES } from '@metamask/profile-sync-controller/user-storage'; -import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; -import { selectIsMetamaskNotificationsEnabled } from '../../../../selectors/notifications'; - -export const turnOnBackupAndSyncTestIds = { - view: 'turn-on-backup-and-sync-view', - cancelButton: 'turn-on-backup-and-sync-cancel-button', - enableButton: 'turn-on-backup-and-sync-enable-button', -}; - -const TurnOnBackupAndSync = () => { - const theme = useTheme(); - const styles = createStyles(theme); - const navigation = useNavigation(); - - const { setIsBackupAndSyncFeatureEnabled } = useBackupAndSync(); - const { trackEvent, createEventBuilder } = useMetrics(); - - const isBasicFunctionalityEnabled = useSelector((state: RootState) => - Boolean(state?.settings?.basicFunctionalityEnabled), - ); - const isBackupAndSyncEnabled = useSelector(selectIsBackupAndSyncEnabled); - const isMetamaskNotificationsEnabled = useSelector( - selectIsMetamaskNotificationsEnabled, - ); - - const goToLearnMore = () => { - Linking.openURL(AppConstants.URLS.PROFILE_SYNC); - }; - - const trackEnableBackupAndSyncEvent = (newValue: boolean) => { - trackEvent( - createEventBuilder(MetaMetricsEvents.SETTINGS_UPDATED) - .addProperties({ - settings_group: 'security_privacy', - settings_type: 'profile_syncing', - old_value: !newValue, - new_value: newValue, - was_notifications_on: isMetamaskNotificationsEnabled, - }) - .build(), - ); - }; - - const handleGoBack = () => { - trackEvent( - createEventBuilder(MetaMetricsEvents.PROFILE_ACTIVITY_UPDATED) - .addProperties({ - feature_name: 'Backup And Sync Carousel Modal', - action: 'Modal Dismissed', - }) - .build(), - ); - navigation.goBack(); - }; - - const handleEnableBackupAndSync = async () => { - trackEvent( - createEventBuilder(MetaMetricsEvents.PROFILE_ACTIVITY_UPDATED) - .addProperties({ - feature_name: 'Backup And Sync Carousel Modal', - action: 'Turned On', - }) - .build(), - ); - - if (!isBasicFunctionalityEnabled) { - navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SHEET.CONFIRM_TURN_ON_BACKUP_AND_SYNC, - params: { - enableBackupAndSync: async () => { - navigation.navigate(Routes.SETTINGS_VIEW, { - screen: Routes.SETTINGS.BACKUP_AND_SYNC, - }); - await setIsBackupAndSyncFeatureEnabled( - BACKUPANDSYNC_FEATURES.main, - true, - ); - }, - trackEnableBackupAndSyncEvent, - }, - }); - return; - } - if (!isBackupAndSyncEnabled) { - await setIsBackupAndSyncFeatureEnabled(BACKUPANDSYNC_FEATURES.main, true); - } - navigation.navigate(Routes.SETTINGS_VIEW, { - screen: Routes.SETTINGS.BACKUP_AND_SYNC, - }); - }; - - return ( - - - - {strings('backupAndSync.enable.title')} - - - - - - - {strings('backupAndSync.enable.description')}{' '} - - {strings('backupAndSync.privacyLink')} - - - - {strings('backupAndSync.enable.updatePreferences')} - - - {strings('backupAndSync.enable.settingsPath')} - - - - -