diff --git a/.github/workflows/auto-create-release-pr.yml.disabled b/.github/workflows/auto-create-release-pr.yml similarity index 100% rename from .github/workflows/auto-create-release-pr.yml.disabled rename to .github/workflows/auto-create-release-pr.yml diff --git a/.github/workflows/run-e2e-smoke-tests-android.yml b/.github/workflows/run-e2e-smoke-tests-android.yml index ea9e35aa3c9c..9d9dd2988b53 100644 --- a/.github/workflows/run-e2e-smoke-tests-android.yml +++ b/.github/workflows/run-e2e-smoke-tests-android.yml @@ -141,7 +141,7 @@ jobs: fail-fast: false uses: ./.github/workflows/run-e2e-workflow.yml with: - test-suite-name: prediction_market_android_smoke-${{ matrix.split }} + test-suite-name: prediction-market-android-smoke-${{ matrix.split }} platform: android test_suite_tag: 'SmokePredictions' split_number: ${{ matrix.split }} diff --git a/.github/workflows/run-e2e-smoke-tests-ios.yml b/.github/workflows/run-e2e-smoke-tests-ios.yml index 3265ab8d1a2c..710b89ecbe1b 100644 --- a/.github/workflows/run-e2e-smoke-tests-ios.yml +++ b/.github/workflows/run-e2e-smoke-tests-ios.yml @@ -141,7 +141,7 @@ jobs: fail-fast: false uses: ./.github/workflows/run-e2e-workflow.yml with: - test-suite-name: prediction_market_ios_smoke-${{ matrix.split }} + test-suite-name: prediction-market-ios-smoke-${{ matrix.split }} platform: ios test_suite_tag: 'SmokePredictions' split_number: ${{ matrix.split }} diff --git a/app/component-library/components/Navigation/TabBar/TabBar.test.tsx b/app/component-library/components/Navigation/TabBar/TabBar.test.tsx index 223359a47513..2680330faab8 100644 --- a/app/component-library/components/Navigation/TabBar/TabBar.test.tsx +++ b/app/component-library/components/Navigation/TabBar/TabBar.test.tsx @@ -30,11 +30,6 @@ interface TestDescriptors { [key: string]: TestTabDescriptor; } -// Force rewards feature flag to be enabled for this test file -jest.mock('../../../../selectors/featureFlagController/rewards', () => ({ - selectRewardsEnabledFlag: () => true, -})); - // Mock trending tokens feature flag selector jest.mock('../../../../selectors/featureFlagController/assetsTrendingTokens'); diff --git a/app/component-library/components/Navigation/TabBar/TabBar.tsx b/app/component-library/components/Navigation/TabBar/TabBar.tsx index ee07838913aa..a620ca4ab10e 100644 --- a/app/component-library/components/Navigation/TabBar/TabBar.tsx +++ b/app/component-library/components/Navigation/TabBar/TabBar.tsx @@ -27,14 +27,12 @@ import { LABEL_BY_TAB_BAR_ICON_KEY, } from './TabBar.constants'; import { selectChainId } from '../../../../selectors/networkController'; -import { selectRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; import { selectAssetsTrendingTokensEnabled } from '../../../../selectors/featureFlagController/assetsTrendingTokens'; const TabBar = ({ state, descriptors, navigation }: TabBarProps) => { const { trackEvent, createEventBuilder } = useMetrics(); const { bottom: bottomInset } = useSafeAreaInsets(); const chainId = useSelector(selectChainId); - const isRewardsEnabled = useSelector(selectRewardsEnabledFlag); const isAssetsTrendingTokensEnabled = useSelector( selectAssetsTrendingTokensEnabled, ); @@ -86,9 +84,7 @@ const TabBar = ({ state, descriptors, navigation }: TabBarProps) => { navigation.navigate(Routes.TRANSACTIONS_VIEW); break; case Routes.REWARDS_VIEW: - if (isRewardsEnabled) { - navigation.navigate(Routes.REWARDS_VIEW); - } + navigation.navigate(Routes.REWARDS_VIEW); break; case Routes.SETTINGS_VIEW: navigation.navigate(Routes.SETTINGS_VIEW, { @@ -127,7 +123,6 @@ const TabBar = ({ state, descriptors, navigation }: TabBarProps) => { trackEvent, createEventBuilder, tw, - isRewardsEnabled, isAssetsTrendingTokensEnabled, ], ); diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 703a68aad012..08b3bc785d31 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -107,7 +107,6 @@ import { PredictModalStack, selectPredictEnabledFlag, } from '../../UI/Predict'; -import { selectRewardsEnabledFlag } from '../../../selectors/featureFlagController/rewards'; import { selectAssetsTrendingTokensEnabled } from '../../../selectors/featureFlagController/assetsTrendingTokens'; import PerpsPositionTransactionView from '../../UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView'; import PerpsOrderTransactionView from '../../UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView'; @@ -526,7 +525,6 @@ const HomeTabs = () => { const [isKeyboardHidden, setIsKeyboardHidden] = useState(true); const accountsLength = useSelector(selectAccountsLength); - const isRewardsEnabled = useSelector(selectRewardsEnabledFlag); const rewardsSubscription = useSelector(selectRewardsSubscriptionId); const isAssetsTrendingTokensEnabled = useSelector( selectAssetsTrendingTokensEnabled, @@ -649,11 +647,7 @@ const HomeTabs = () => { const currentRoute = state.routes[state.index]; // Hide tab bar for rewards onboarding splash screen - if ( - currentRoute.name?.startsWith('Rewards') && - isRewardsEnabled && - !rewardsSubscription - ) { + if (currentRoute.name?.startsWith('Rewards') && !rewardsSubscription) { return null; } @@ -715,21 +709,12 @@ const HomeTabs = () => { component={TransactionsHome} layout={({ children }) => {children}} /> - {isRewardsEnabled ? ( - UnmountOnBlurComponent(children)} - /> - ) : ( - UnmountOnBlurComponent(children)} - /> - )} + UnmountOnBlurComponent(children)} + /> ); }; @@ -948,7 +933,6 @@ const MainNavigator = () => { const { enabled: isSendRedesignEnabled } = useSelector( selectSendRedesignFlags, ); - const isRewardsEnabled = useSelector(selectRewardsEnabledFlag); return ( { component={ConfirmAddAsset} options={{ headerShown: true }} /> - {isRewardsEnabled && ( - ({ - cardStyle: { - transform: [ - { - translateX: current.progress.interpolate({ - inputRange: [0, 1], - outputRange: [layouts.screen.width, 0], - }), - }, - ], - }, - }), - }} - /> - )} + ({ + cardStyle: { + transform: [ + { + translateX: current.progress.interpolate({ + inputRange: [0, 1], + outputRange: [layouts.screen.width, 0], + }), + }, + ], + }, + }), + }} + /> diff --git a/app/components/Nav/Main/MainNavigator.test.js b/app/components/Nav/Main/MainNavigator.test.js index 91db9aac03ea..cb5d0825f7b7 100644 --- a/app/components/Nav/Main/MainNavigator.test.js +++ b/app/components/Nav/Main/MainNavigator.test.js @@ -12,18 +12,14 @@ jest.mock('./MainNavigator', () => { const { TabBarIconKey, } = require('../../../component-library/components/Navigation/TabBar/TabBar.types'); - const { - selectRewardsEnabledFlag, - } = require('../../../selectors/featureFlagController/rewards'); const { selectAssetsTrendingTokensEnabled, } = require('../../../selectors/featureFlagController/assetsTrendingTokens'); const { selectBrowserFullscreen } = require('../../../selectors/browser'); const Routes = require('../../../constants/navigation/Routes').default; - // Mock implementation that tests tab visibility based on rewards flag and browser fullscreen state + // Mock implementation that tests tab visibility based on browser fullscreen state return function MockMainNavigator({ route }) { - const isRewardsEnabled = selectRewardsEnabledFlag(); const isTrendingEnabled = selectAssetsTrendingTokensEnabled(); const isBrowserFullscreen = selectBrowserFullscreen(); @@ -62,21 +58,11 @@ jest.mock('./MainNavigator', () => { }), ); - // Add Rewards tab if enabled - if (isRewardsEnabled) { - tabs.push( - React.createElement(View, { - key: 'rewards', - testID: `tab-bar-item-${TabBarIconKey.Rewards}`, - }), - ); - } - - // Add Settings tab (always shown at the end) + // Add Rewards tab tabs.push( React.createElement(View, { - key: 'settings', - testID: `tab-bar-item-${TabBarIconKey.Setting}`, + key: 'rewards', + testID: `tab-bar-item-${TabBarIconKey.Rewards}`, }), ); @@ -86,7 +72,6 @@ jest.mock('./MainNavigator', () => { // Mock the rewards selector jest.mock('../../../selectors/featureFlagController/rewards', () => ({ - selectRewardsEnabledFlag: jest.fn(), selectRewardsSubscriptionId: jest.fn().mockReturnValue(null), })); @@ -103,7 +88,6 @@ jest.mock('../../../selectors/browser', () => ({ selectBrowserFullscreen: jest.fn(), })); -import { selectRewardsEnabledFlag } from '../../../selectors/featureFlagController/rewards'; import { selectAssetsTrendingTokensEnabled } from '../../../selectors/featureFlagController/assetsTrendingTokens'; import { selectBrowserFullscreen } from '../../../selectors/browser'; import MainNavigator from './MainNavigator'; @@ -111,13 +95,11 @@ import MainNavigator from './MainNavigator'; describe('MainNavigator', () => { beforeEach(() => { jest.clearAllMocks(); - selectRewardsEnabledFlag.mockReturnValue(false); selectAssetsTrendingTokensEnabled.mockReturnValue(false); selectBrowserFullscreen.mockReturnValue(false); }); it('shows Browser tab when trending feature flag is off', () => { - selectRewardsEnabledFlag.mockReturnValue(false); selectAssetsTrendingTokensEnabled.mockReturnValue(false); const { getByTestId, queryByTestId } = render(); @@ -126,11 +108,10 @@ describe('MainNavigator', () => { expect(queryByTestId('tab-bar-item-Trending')).toBeNull(); expect(getByTestId('tab-bar-item-Wallet')).toBeDefined(); expect(getByTestId('tab-bar-item-Trade')).toBeDefined(); - expect(getByTestId('tab-bar-item-Setting')).toBeDefined(); + expect(getByTestId('tab-bar-item-Rewards')).toBeDefined(); }); it('shows Trending tab and hides Browser tab when trending feature flag is on', () => { - selectRewardsEnabledFlag.mockReturnValue(false); selectAssetsTrendingTokensEnabled.mockReturnValue(true); const { getByTestId, queryByTestId } = render(); @@ -139,37 +120,20 @@ describe('MainNavigator', () => { expect(queryByTestId('tab-bar-item-Browser')).toBeNull(); expect(getByTestId('tab-bar-item-Wallet')).toBeDefined(); expect(getByTestId('tab-bar-item-Trade')).toBeDefined(); - expect(getByTestId('tab-bar-item-Setting')).toBeDefined(); - }); - - it('shows Settings tab when rewards feature flag is off', () => { - selectRewardsEnabledFlag.mockReturnValue(false); - selectAssetsTrendingTokensEnabled.mockReturnValue(false); - - const { getByTestId, queryByTestId } = render(); - - expect(getByTestId('tab-bar-item-Setting')).toBeDefined(); - expect(queryByTestId('tab-bar-item-Rewards')).toBeNull(); - expect(getByTestId('tab-bar-item-Wallet')).toBeDefined(); - expect(getByTestId('tab-bar-item-Browser')).toBeDefined(); - expect(getByTestId('tab-bar-item-Trade')).toBeDefined(); + expect(getByTestId('tab-bar-item-Rewards')).toBeDefined(); }); - it('shows Rewards tab when rewards feature flag is on', () => { - selectRewardsEnabledFlag.mockReturnValue(true); - selectAssetsTrendingTokensEnabled.mockReturnValue(false); - + it('should show Rewards tab', () => { const { getByTestId } = render(); expect(getByTestId('tab-bar-item-Rewards')).toBeDefined(); - expect(getByTestId('tab-bar-item-Setting')).toBeDefined(); + // Verify other core tabs are present expect(getByTestId('tab-bar-item-Wallet')).toBeDefined(); expect(getByTestId('tab-bar-item-Browser')).toBeDefined(); expect(getByTestId('tab-bar-item-Trade')).toBeDefined(); }); it('shows Trending and Rewards tabs and hides Browser tab when both feature flags are on', () => { - selectRewardsEnabledFlag.mockReturnValue(true); selectAssetsTrendingTokensEnabled.mockReturnValue(true); const { getByTestId, queryByTestId } = render(); @@ -179,7 +143,6 @@ describe('MainNavigator', () => { expect(queryByTestId('tab-bar-item-Browser')).toBeNull(); expect(getByTestId('tab-bar-item-Wallet')).toBeDefined(); expect(getByTestId('tab-bar-item-Trade')).toBeDefined(); - expect(getByTestId('tab-bar-item-Setting')).toBeDefined(); }); it('should show navbar tabs when browser is not in fullscreen mode', () => { @@ -193,7 +156,7 @@ describe('MainNavigator', () => { expect(getByTestId('tab-bar-item-Wallet')).toBeDefined(); expect(getByTestId('tab-bar-item-Browser')).toBeDefined(); expect(getByTestId('tab-bar-item-Trade')).toBeDefined(); - expect(getByTestId('tab-bar-item-Setting')).toBeDefined(); + expect(getByTestId('tab-bar-item-Rewards')).toBeDefined(); }); it('should not show navbar when browser is in fullscreen mode', () => { @@ -209,7 +172,7 @@ describe('MainNavigator', () => { expect(queryByTestId('tab-bar-item-Wallet')).toBeNull(); expect(queryByTestId('tab-bar-item-Browser')).toBeNull(); expect(queryByTestId('tab-bar-item-Trade')).toBeNull(); - expect(queryByTestId('tab-bar-item-Setting')).toBeNull(); + expect(queryByTestId('tab-bar-item-Rewards')).toBeNull(); }); it('should show navbar tabs when browser is in fullscreen mode but on non-browser route', () => { @@ -225,7 +188,7 @@ describe('MainNavigator', () => { expect(getByTestId('tab-bar-item-Wallet')).toBeDefined(); expect(getByTestId('tab-bar-item-Browser')).toBeDefined(); expect(getByTestId('tab-bar-item-Trade')).toBeDefined(); - expect(getByTestId('tab-bar-item-Setting')).toBeDefined(); + expect(getByTestId('tab-bar-item-Rewards')).toBeDefined(); }); it('should return null when isBrowserFullscreen is true AND route starts with BrowserTabHome', () => { diff --git a/app/components/Nav/Main/__snapshots__/MainNavigator.test.js.snap b/app/components/Nav/Main/__snapshots__/MainNavigator.test.js.snap index d3b7b04c55c1..a4f509330f24 100644 --- a/app/components/Nav/Main/__snapshots__/MainNavigator.test.js.snap +++ b/app/components/Nav/Main/__snapshots__/MainNavigator.test.js.snap @@ -17,7 +17,7 @@ exports[`MainNavigator should match snapshot when browser is not infullscreen mo testID="tab-bar-item-Activity" /> `; diff --git a/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap b/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap index a28adbe9dfc0..03b93bc231c7 100644 --- a/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap +++ b/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap @@ -61,6 +61,17 @@ exports[`MainNavigator matches rendered snapshot 1`] = ` } } /> + { '../../../../../util/test/keyringControllerTestUtils', ); return { + controllerMessenger: { + call: jest.fn(), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + }, context: { SwapsController: { fetchAggregatorMetadataWithCache: jest.fn(), @@ -272,6 +277,21 @@ jest.mock('../../../../../util/address', () => ({ isHardwareAccount: jest.fn(), })); +jest.mock('react-native-fade-in-image', () => { + const React = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + children, + placeholderStyle, + }: { + children: React.ReactNode; + placeholderStyle?: unknown; + }) => React.createElement(View, { style: placeholderStyle }, children), + }; +}); + describe('BridgeView', () => { const token2Address = '0x0000000000000000000000000000000000000002' as Hex; diff --git a/app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap b/app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap index 5b8d1c5d2f63..b9de503587f9 100644 --- a/app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap +++ b/app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap @@ -476,9 +476,7 @@ exports[`BridgeView Bottom Content blurs input when opening QuoteExpiredModal 1` testID="badge-wrapper-badge" > - + - - - - + - - - { }; }); +jest.mock('react-native-fade-in-image', () => { + const React = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + children, + placeholderStyle, + }: { + children: React.ReactNode; + placeholderStyle?: unknown; + }) => React.createElement(View, { style: placeholderStyle }, children), + }; +}); + const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -67,9 +82,33 @@ jest.mock('../../hooks/useBridgeQuoteData', () => ({ jest.mock('../../../../../core/Engine', () => ({ controllerMessenger: { call: jest.fn(), + subscribe: jest.fn(), + unsubscribe: jest.fn(), }, })); +// Mock formatChainIdToCaip for AddRewardsAccount component +jest.mock('@metamask/bridge-controller', () => ({ + ...jest.requireActual('@metamask/bridge-controller'), + formatChainIdToCaip: jest.fn((chainId: string) => { + // If already in CAIP format, return as-is + if (chainId.includes(':')) { + return chainId as `${string}:${string}`; + } + // Otherwise, convert to CAIP format + return `eip155:${chainId}` as `${string}:${string}`; + }), +})); + +// Mock useLinkAccountAddress for AddRewardsAccount component +jest.mock('../../../../UI/Rewards/hooks/useLinkAccountAddress', () => ({ + useLinkAccountAddress: jest.fn(() => ({ + linkAccountAddress: jest.fn(), + isLoading: false, + isError: false, + })), +})); + // Mock the bridge selectors jest.mock('../../../../../core/redux/slices/bridge', () => ({ ...jest.requireActual('../../../../../core/redux/slices/bridge'), @@ -486,6 +525,9 @@ describe('QuoteDetailsCard', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } + if (method === 'RewardsController:getFirstSubscriptionId') { + return Promise.resolve('subscription-id-1'); + } if (method === 'RewardsController:getHasAccountOptedIn') { return Promise.resolve(true); } @@ -516,6 +558,9 @@ describe('QuoteDetailsCard', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } + if (method === 'RewardsController:getFirstSubscriptionId') { + return Promise.resolve('subscription-id-1'); + } if (method === 'RewardsController:getHasAccountOptedIn') { return Promise.resolve(true); } @@ -568,30 +613,42 @@ describe('QuoteDetailsCard', () => { }); }); - it('does not display rewards row when user has not opted in', async () => { + it('displays AddRewardsAccount when user has not opted in', async () => { // Given rewards feature is enabled but user has not opted in mockEngine.controllerMessenger.call.mockImplementation( (method: string) => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } + if (method === 'RewardsController:getFirstSubscriptionId') { + return Promise.resolve('subscription-id-1'); + } if (method === 'RewardsController:getHasAccountOptedIn') { return Promise.resolve(false); } + if (method === 'RewardsController:isOptInSupported') { + return Promise.resolve(true); + } return Promise.resolve(null); }, ); // When rendering the component - const { queryByText } = renderScreen( + const { getByText, getByTestId, queryByTestId } = renderScreen( QuoteDetailsCard, { name: Routes.BRIDGE.ROOT }, { state: testState }, ); - // Then the rewards row should not be displayed + // Then the rewards row should be displayed await waitFor(() => { - expect(queryByText(strings('bridge.points'))).toBeNull(); + expect(getByText(strings('bridge.points'))).toBeOnTheScreen(); + }); + + // And AddRewardsAccount should be shown instead of RewardsAnimations + await waitFor(() => { + expect(getByTestId('bridge-add-rewards-account')).toBeOnTheScreen(); + expect(queryByTestId('mock-rive-animation')).toBeNull(); }); }); @@ -602,6 +659,9 @@ describe('QuoteDetailsCard', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } + if (method === 'RewardsController:getFirstSubscriptionId') { + return Promise.resolve('subscription-id-1'); + } if (method === 'RewardsController:getHasAccountOptedIn') { return Promise.resolve(true); } @@ -632,6 +692,9 @@ describe('QuoteDetailsCard', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } + if (method === 'RewardsController:getFirstSubscriptionId') { + return Promise.resolve('subscription-id-1'); + } if (method === 'RewardsController:getHasAccountOptedIn') { return Promise.resolve(true); } @@ -668,6 +731,9 @@ describe('QuoteDetailsCard', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } + if (method === 'RewardsController:getFirstSubscriptionId') { + return Promise.resolve('subscription-id-1'); + } if (method === 'RewardsController:getHasAccountOptedIn') { return Promise.resolve(true); } @@ -702,6 +768,9 @@ describe('QuoteDetailsCard', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } + if (method === 'RewardsController:getFirstSubscriptionId') { + return Promise.resolve('subscription-id-1'); + } if (method === 'RewardsController:getHasAccountOptedIn') { return Promise.resolve(true); } @@ -733,6 +802,9 @@ describe('QuoteDetailsCard', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } + if (method === 'RewardsController:getFirstSubscriptionId') { + return Promise.resolve('subscription-id-1'); + } if (method === 'RewardsController:getHasAccountOptedIn') { return Promise.resolve(true); } @@ -764,6 +836,9 @@ describe('QuoteDetailsCard', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } + if (method === 'RewardsController:getFirstSubscriptionId') { + return Promise.resolve('subscription-id-1'); + } if (method === 'RewardsController:getHasAccountOptedIn') { return Promise.resolve(true); } @@ -812,6 +887,9 @@ describe('QuoteDetailsCard', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } + if (method === 'RewardsController:getFirstSubscriptionId') { + return Promise.resolve('subscription-id-1'); + } if (method === 'RewardsController:getHasAccountOptedIn') { return Promise.resolve(true); } @@ -826,7 +904,7 @@ describe('QuoteDetailsCard', () => { ); // When rendering the component - const { getByText } = renderScreen( + const { getByText, getByTestId } = renderScreen( QuoteDetailsCard, { name: Routes.BRIDGE.ROOT }, { state: testState }, @@ -834,6 +912,7 @@ describe('QuoteDetailsCard', () => { // Rewards row should be shown await waitFor(() => { + expect(getByTestId('bridge-rewards-row')).toBeOnTheScreen(); expect(getByText(strings('bridge.points'))).toBeOnTheScreen(); }); diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx index 3d1413fb3900..e6f319775b39 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx @@ -34,6 +34,7 @@ import { useRewards } from '../../hooks/useRewards'; import RewardsAnimations, { RewardAnimationState, } from '../../../Rewards/components/RewardPointsAnimation'; +import AddRewardsAccount from '../../../Rewards/components/AddRewardsAccount/AddRewardsAccount'; import QuoteCountdownTimer from '../QuoteCountdownTimer'; import QuoteDetailsRecipientKeyValueRow from '../QuoteDetailsRecipientKeyValueRow/QuoteDetailsRecipientKeyValueRow'; import { toSentenceCase } from '../../../../../util/string'; @@ -69,6 +70,7 @@ const QuoteDetailsCard: React.FC = () => { isLoading: isRewardsLoading, shouldShowRewardsRow, hasError: hasRewardsError, + accountOptedIn, } = useRewards({ activeQuote, isQuoteLoading, @@ -270,51 +272,57 @@ const QuoteDetailsCard: React.FC = () => { {/* Estimated Points */} {shouldShowRewardsRow && ( - - - - ), - ...(hasRewardsError && { + + + }} + value={{ + label: ( + + {accountOptedIn ? ( + + ) : ( + + )} + + ), + ...(hasRewardsError && { + tooltip: { + title: strings('bridge.points_error'), + content: strings('bridge.points_error_content'), + size: TooltipSizes.Sm, + iconName: IconName.Info, + }, + }), + }} + /> + )} diff --git a/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts b/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts index d2d00ca4ae4d..35ff166f366f 100644 --- a/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts +++ b/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts @@ -10,6 +10,8 @@ import { CaipAssetType, Hex } from '@metamask/utils'; jest.mock('../../../../../core/Engine', () => ({ controllerMessenger: { call: jest.fn(), + subscribe: jest.fn(), + unsubscribe: jest.fn(), }, })); @@ -222,6 +224,7 @@ describe('useRewards', () => { isLoading: false, estimatedPoints: null, hasError: false, + accountOptedIn: null, }); }); @@ -232,14 +235,20 @@ describe('useRewards', () => { }); describe('when user has not opted in', () => { - it('should return default state when user has not opted in', async () => { + it('should return default state when user has not opted in and opt-in is not supported', async () => { mockCall.mockImplementation((method) => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } + if (method === 'RewardsController:getFirstSubscriptionId') { + return Promise.resolve('subscription-id-1'); + } if (method === 'RewardsController:getHasAccountOptedIn') { return Promise.resolve(false); } + if (method === 'RewardsController:isOptInSupported') { + return Promise.resolve(false); + } return Promise.resolve(null); }); @@ -266,13 +275,78 @@ describe('useRewards', () => { isLoading: false, estimatedPoints: null, hasError: false, + accountOptedIn: false, }); }); + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getFirstSubscriptionId', + ); + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getHasAccountOptedIn', + 'eip155:1:0x1234567890123456789012345678901234567890', + ); + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:isOptInSupported', + expect.any(Object), + ); + }); + + it('should show rewards row when user has not opted in but opt-in is supported', async () => { + mockCall.mockImplementation((method) => { + if (method === 'RewardsController:isRewardsFeatureEnabled') { + return Promise.resolve(true); + } + if (method === 'RewardsController:getFirstSubscriptionId') { + return Promise.resolve('subscription-id-1'); + } + if (method === 'RewardsController:getHasAccountOptedIn') { + return Promise.resolve(false); + } + if (method === 'RewardsController:isOptInSupported') { + return Promise.resolve(true); + } + return Promise.resolve(null); + }); + + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + sourceToken: defaultSourceToken, + destToken: defaultDestToken, + sourceAmount: '1', + }, + }); + + const { result } = renderHookWithProvider( + () => + useRewards({ + activeQuote: mockActiveQuote, + isQuoteLoading: false, + }), + { state: testState }, + ); + + await waitFor(() => { + expect(result.current).toEqual({ + shouldShowRewardsRow: true, + isLoading: false, + estimatedPoints: null, + hasError: false, + accountOptedIn: false, + }); + }); + + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getFirstSubscriptionId', + ); expect(mockCall).toHaveBeenCalledWith( 'RewardsController:getHasAccountOptedIn', 'eip155:1:0x1234567890123456789012345678901234567890', ); + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:isOptInSupported', + expect.any(Object), + ); }); }); @@ -282,6 +356,9 @@ describe('useRewards', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } + if (method === 'RewardsController:getFirstSubscriptionId') { + return Promise.resolve('subscription-id-1'); + } if (method === 'RewardsController:getHasAccountOptedIn') { return Promise.resolve(true); } @@ -314,6 +391,7 @@ describe('useRewards', () => { isLoading: false, estimatedPoints: 100, hasError: false, + accountOptedIn: true, }); }); @@ -348,6 +426,9 @@ describe('useRewards', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } + if (method === 'RewardsController:getFirstSubscriptionId') { + return Promise.resolve('subscription-id-1'); + } if (method === 'RewardsController:getHasAccountOptedIn') { return Promise.resolve(true); } @@ -417,6 +498,7 @@ describe('useRewards', () => { isLoading: false, estimatedPoints: null, hasError: false, + accountOptedIn: null, }); // Should not call Engine methods @@ -446,6 +528,7 @@ describe('useRewards', () => { isLoading: false, estimatedPoints: null, hasError: false, + accountOptedIn: null, }); }); @@ -472,6 +555,7 @@ describe('useRewards', () => { isLoading: false, estimatedPoints: null, hasError: false, + accountOptedIn: null, }); }); @@ -498,16 +582,72 @@ describe('useRewards', () => { isLoading: false, estimatedPoints: null, hasError: false, + accountOptedIn: null, }); }); }); + describe('when subscription ID is missing', () => { + it('should return default state when there is no subscription', async () => { + mockCall.mockImplementation((method) => { + if (method === 'RewardsController:isRewardsFeatureEnabled') { + return Promise.resolve(true); + } + if (method === 'RewardsController:getFirstSubscriptionId') { + return Promise.resolve(null); + } + return Promise.resolve(null); + }); + + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + sourceToken: defaultSourceToken, + destToken: defaultDestToken, + sourceAmount: '1', + }, + }); + + const { result } = renderHookWithProvider( + () => + useRewards({ + activeQuote: mockActiveQuote, + isQuoteLoading: false, + }), + { state: testState }, + ); + + await waitFor(() => { + expect(result.current).toEqual({ + shouldShowRewardsRow: false, + isLoading: false, + estimatedPoints: null, + hasError: false, + accountOptedIn: null, + }); + }); + + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:isRewardsFeatureEnabled', + ); + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getFirstSubscriptionId', + ); + expect(mockCall).not.toHaveBeenCalledWith( + 'RewardsController:getHasAccountOptedIn', + expect.any(String), + ); + }); + }); + describe('error handling', () => { it('should handle rewards estimation error gracefully', async () => { mockCall.mockImplementation((method) => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } + if (method === 'RewardsController:getFirstSubscriptionId') { + return Promise.resolve('subscription-id-1'); + } if (method === 'RewardsController:getHasAccountOptedIn') { return Promise.resolve(true); } @@ -540,6 +680,7 @@ describe('useRewards', () => { isLoading: false, estimatedPoints: null, hasError: true, + accountOptedIn: true, }); }); }); @@ -575,6 +716,7 @@ describe('useRewards', () => { isLoading: false, estimatedPoints: null, hasError: true, + accountOptedIn: null, }); }); }); @@ -584,6 +726,9 @@ describe('useRewards', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } + if (method === 'RewardsController:getFirstSubscriptionId') { + return Promise.resolve('subscription-id-1'); + } if (method === 'RewardsController:getHasAccountOptedIn') { throw new Error('Opt-in check failed'); } @@ -613,6 +758,7 @@ describe('useRewards', () => { isLoading: false, estimatedPoints: null, hasError: true, + accountOptedIn: null, }); }); }); @@ -623,6 +769,9 @@ describe('useRewards', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } + if (method === 'RewardsController:getFirstSubscriptionId') { + return Promise.resolve('subscription-id-1'); + } if (method === 'RewardsController:getHasAccountOptedIn') { return Promise.resolve(true); } @@ -659,6 +808,9 @@ describe('useRewards', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } + if (method === 'RewardsController:getFirstSubscriptionId') { + return Promise.resolve('subscription-id-1'); + } if (method === 'RewardsController:getHasAccountOptedIn') { return Promise.resolve(true); } @@ -687,6 +839,7 @@ describe('useRewards', () => { isLoading: false, estimatedPoints: 100, hasError: false, + accountOptedIn: true, }); }); }); diff --git a/app/components/UI/Bridge/hooks/useRewards/useRewards.ts b/app/components/UI/Bridge/hooks/useRewards/useRewards.ts index 97e0ee14f856..c191efcf93f6 100644 --- a/app/components/UI/Bridge/hooks/useRewards/useRewards.ts +++ b/app/components/UI/Bridge/hooks/useRewards/useRewards.ts @@ -68,6 +68,7 @@ interface UseRewardsResult { isLoading: boolean; estimatedPoints: number | null; hasError: boolean; + accountOptedIn: boolean | null; } /** @@ -98,6 +99,7 @@ export const useRewards = ({ const [estimatedPoints, setEstimatedPoints] = useState(null); const [shouldShowRewardsRow, setShouldShowRewardsRow] = useState(false); const [hasError, setHasError] = useState(false); + const [accountOptedIn, setAccountOptedIn] = useState(null); const prevRequestId = usePrevious(activeQuote?.quote?.requestId); // Selectors @@ -143,6 +145,22 @@ export const useRewards = ({ if (!isRewardsEnabled) { setEstimatedPoints(null); + setShouldShowRewardsRow(false); + setAccountOptedIn(null); + setIsLoading(false); + return; + } + + // Check if there's a subscription first + const firstSubscriptionId = await Engine.controllerMessenger.call( + 'RewardsController:getFirstSubscriptionId', + ); + + if (!firstSubscriptionId) { + setEstimatedPoints(null); + setShouldShowRewardsRow(false); + setAccountOptedIn(null); + setHasError(false); setIsLoading(false); return; } @@ -155,6 +173,8 @@ export const useRewards = ({ if (!caipAccount) { setEstimatedPoints(null); + setShouldShowRewardsRow(false); + setAccountOptedIn(null); setIsLoading(false); return; } @@ -165,14 +185,29 @@ export const useRewards = ({ caipAccount, ); + setAccountOptedIn(hasOptedIn); + + // Determine if we should show the rewards row + // Show row if: opted in OR (not opted in AND opt-in is supported) + let shouldShow = hasOptedIn; + + if (!hasOptedIn && selectedAccount) { + const isOptInSupported = await Engine.controllerMessenger.call( + 'RewardsController:isOptInSupported', + selectedAccount, + ); + shouldShow = isOptInSupported; + } + + setShouldShowRewardsRow(shouldShow); + if (!hasOptedIn) { setEstimatedPoints(null); + setHasError(false); setIsLoading(false); return; } - setShouldShowRewardsRow(true); - // Convert source amount to atomic unit const atomicSourceAmount = activeQuote.quote.srcTokenAmount; @@ -236,12 +271,13 @@ export const useRewards = ({ setIsLoading(false); } }, [ - activeQuote, + activeQuote?.quote, sourceToken, destToken, sourceAmount, selectedAddress, sourceChainId, + selectedAccount, ]); // Estimate points when dependencies change @@ -256,10 +292,30 @@ export const useRewards = ({ prevRequestId, ]); + // Subscribe to account linked event to retrigger estimate + useEffect(() => { + const handleAccountLinked = () => { + estimatePoints(); + }; + + Engine.controllerMessenger.subscribe( + 'RewardsController:accountLinked', + handleAccountLinked, + ); + + return () => { + Engine.controllerMessenger.unsubscribe( + 'RewardsController:accountLinked', + handleAccountLinked, + ); + }; + }, [estimatePoints]); + return { shouldShowRewardsRow, isLoading: isLoading || isQuoteLoading, estimatedPoints, hasError, + accountOptedIn, }; }; diff --git a/app/components/UI/Bridge/utils/transaction-history.test.ts b/app/components/UI/Bridge/utils/transaction-history.test.ts index 5a7710046ac5..ad3ec7b132f4 100644 --- a/app/components/UI/Bridge/utils/transaction-history.test.ts +++ b/app/components/UI/Bridge/utils/transaction-history.test.ts @@ -391,7 +391,7 @@ describe('decodeSwapsTx', () => { renderFrom: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', actionKey: 'Swap USDC to ETH', notificationKey: 'Swap complete (USDC to ETH)', - value: '-5.0 USDC', + value: '5 USDC', fiatValue: '$5.01', transactionType: 'swaps_transaction', }, @@ -399,12 +399,12 @@ describe('decodeSwapsTx', () => { renderFrom: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', renderTo: '0x881d40237659c251811cec9c364ef91dc08d300c', hash: '0xac561978ed01a8828e30c193c8368b0baec0f8c8c85c933c324c06352a16aeb6', - renderValue: '5.0 USDC', + renderValue: '5 USDC', renderGas: 264667, renderGasPrice: undefined, renderTotalGas: '0.00053 ETH', txChainId: '0x1', - summaryAmount: '5.0 USDC', + summaryAmount: '5 USDC', summaryFee: '0.00053 ETH', summaryTotalAmount: '5.00053 ETH', summarySecondaryTotalAmount: '$6.33', @@ -638,7 +638,8 @@ describe('decodeBridgeTx', () => { renderTo: '0x0439e60f02a8900a951603950d8d4527f400c3f1', renderFrom: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', actionKey: 'Bridge to Optimism', - value: '-0.00099125 ETH', + notificationKey: undefined, + value: '-0.00099 ETH', fiatValue: '$2.49', transactionType: 'bridge_transaction', }, diff --git a/app/components/UI/Bridge/utils/transaction-history.ts b/app/components/UI/Bridge/utils/transaction-history.ts index b79ec88398c7..d272b9776bf8 100644 --- a/app/components/UI/Bridge/utils/transaction-history.ts +++ b/app/components/UI/Bridge/utils/transaction-history.ts @@ -19,6 +19,7 @@ import { balanceToFiatNumber, weiToFiatNumber, weiToFiat, + formatAmountWithThreshold, } from '../../../../util/number'; import { Hex } from '@metamask/utils'; import { ethers } from 'ethers'; @@ -72,10 +73,14 @@ export const decodeBridgeTx = (args: { const { quote } = bridgeTxHistoryItem; const sourceTokenSymbol = quote.srcAsset?.symbol; - const sourceAmountSent = ethers.utils.formatUnits( - bridgeTxHistoryItem.quote.srcTokenAmount, - quote.srcAsset.decimals, + const rawSourceAmount = parseFloat( + ethers.utils.formatUnits( + bridgeTxHistoryItem.quote.srcTokenAmount, + quote.srcAsset.decimals, + ), ); + const sourceAmountSent = formatAmountWithThreshold(rawSourceAmount, 5); + const renderTo = tx.txParams.to; const renderFrom = tx.txParams.from; @@ -85,7 +90,7 @@ export const decodeBridgeTx = (args: { : contractExchangeRates?.[toFormattedAddress(quote.srcAsset.address)] ?.price; const sourceAmountFiatNumber = balanceToFiatNumber( - Number(sourceAmountSent), + rawSourceAmount, conversionRate, sourceExchangeRate, ); @@ -135,10 +140,14 @@ export const decodeSwapsTx = (args: { const sourceTokenSymbol = quote.srcAsset?.symbol; const destTokenSymbol = quote.destAsset?.symbol; - const sourceAmountSent = ethers.utils.formatUnits( - bridgeTxHistoryItem.quote.srcTokenAmount, - quote.srcAsset.decimals, + const rawSourceAmount = parseFloat( + ethers.utils.formatUnits( + bridgeTxHistoryItem.quote.srcTokenAmount, + quote.srcAsset.decimals, + ), ); + const sourceAmountSent = formatAmountWithThreshold(rawSourceAmount, 5); + const renderTo = tx.txParams.to; const renderFrom = tx.txParams.from; @@ -154,7 +163,7 @@ export const decodeSwapsTx = (args: { : contractExchangeRates?.[toFormattedAddress(quote.srcAsset.address)] ?.price; const sourceAmountFiatNumber = balanceToFiatNumber( - Number(sourceAmountSent), + rawSourceAmount, conversionRate, sourceExchangeRate, ); @@ -179,13 +188,13 @@ export const decodeSwapsTx = (args: { destinationToken: destTokenSymbol, }, ), - value: `-${sourceAmountSent} ${sourceTokenSymbol}`, + value: `${sourceAmountSent} ${sourceTokenSymbol}`, fiatValue: sourceAmountFiatValue, transactionType: TRANSACTION_TYPES.SWAPS_TRANSACTION, }; const summaryTotalAmountNativeToken = `${ - Number(sourceAmountSent) + Number(totalGasDecimalAmount) + rawSourceAmount + Number(totalGasDecimalAmount) } ${gasTokenSymbol}`; const summaryTotalAmountNativeTokenFiat = addCurrencySymbol( sourceAmountFiatNumber + weiToFiatNumber(totalGas, conversionRate), diff --git a/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.test.tsx b/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.test.tsx index c6ca3117b1af..5b7fb0bd6f47 100644 --- a/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.test.tsx +++ b/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.test.tsx @@ -131,7 +131,7 @@ describe('MultichainBridgeTransactionListItem', () => { getByText('bridge_transaction_details.bridge_to_chain'), ).toBeTruthy(); expect(getByText('transaction.confirmed')).toBeTruthy(); - expect(getByText('1.0 ETH')).toBeTruthy(); + expect(getByText('1 ETH')).toBeTruthy(); expect(getByText('Mar 15, 2025')).toBeTruthy(); }); @@ -184,4 +184,52 @@ describe('MultichainBridgeTransactionListItem', () => { }, ); }); + + it('displays less than threshold for very small amounts', () => { + const verySmallAmountBridgeHistoryItem = { + ...mockBridgeHistoryItem, + quote: { + ...mockBridgeHistoryItem.quote, + srcTokenAmount: '123456789012', + srcAsset: { + ...mockBridgeHistoryItem.quote.srcAsset, + decimals: 18, + }, + }, + }; + + const { getByText } = renderWithProvider( + } + />, + ); + + expect(getByText(/< 0\.00001 ETH/)).toBeTruthy(); + }); + + it('caps amount display at 5 decimal places for larger values', () => { + const largerAmountBridgeHistoryItem = { + ...mockBridgeHistoryItem, + quote: { + ...mockBridgeHistoryItem.quote, + srcTokenAmount: '123456789012345', + srcAsset: { + ...mockBridgeHistoryItem.quote.srcAsset, + decimals: 18, + }, + }, + }; + + const { getByText } = renderWithProvider( + } + />, + ); + + expect(getByText(/0\.00012 ETH/)).toBeTruthy(); + }); }); diff --git a/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx b/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx index 107f07d6ad02..3111a06a0430 100644 --- a/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx +++ b/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx @@ -22,6 +22,7 @@ import { handleUnifiedSwapsTxHistoryItemClick, } from '../Bridge/utils/transaction-history'; import { ethers } from 'ethers'; +import { formatAmountWithThreshold } from '../../../util/number'; const MultichainBridgeTransactionListItem = ({ transaction, @@ -68,11 +69,15 @@ const MultichainBridgeTransactionListItem = ({ bridgeHistoryItem.status.destChain?.txHash, ); - const displayAmount = ethers.utils.formatUnits( - bridgeHistoryItem.quote.srcTokenAmount, - bridgeHistoryItem.quote.srcAsset.decimals, + const rawAmount = parseFloat( + ethers.utils.formatUnits( + bridgeHistoryItem.quote.srcTokenAmount, + bridgeHistoryItem.quote.srcAsset.decimals, + ), ); + const displayAmount = formatAmountWithThreshold(rawAmount, 5); + return ( <> )} - {isRewardsEnabled && ( - - )} + } @@ -2025,7 +2021,6 @@ export const getSettingsNavigationOptions = ( title, themeColors, navigation, - isRewardsEnabled = false, ) => { const innerStyles = StyleSheet.create({ headerStyle: { @@ -2047,16 +2042,15 @@ export const getSettingsNavigationOptions = ( {title} ), - headerRight: () => - isRewardsEnabled ? ( - navigation?.goBack()} - style={innerStyles.accessories} - testID={NetworksViewSelectorsIDs.CLOSE_ICON} - /> - ) : null, + headerRight: () => ( + navigation?.goBack()} + style={innerStyles.accessories} + testID={NetworksViewSelectorsIDs.CLOSE_ICON} + /> + ), ...innerStyles, }; }; diff --git a/app/components/UI/Navbar/index.test.jsx b/app/components/UI/Navbar/index.test.jsx index e6056f44a90f..0daf4af93300 100644 --- a/app/components/UI/Navbar/index.test.jsx +++ b/app/components/UI/Navbar/index.test.jsx @@ -633,28 +633,44 @@ describe('getSettingsNavigationOptions', () => { }; describe('Basic Functionality', () => { - it('should return navigation options object', () => { - const options = getSettingsNavigationOptions(mockTitle, mockThemeColors); + it('returns navigation options object', () => { + const options = getSettingsNavigationOptions( + mockTitle, + mockThemeColors, + mockNavigation, + ); expect(options).toBeDefined(); expect(typeof options).toBe('object'); }); - it('should set headerLeft to null', () => { - const options = getSettingsNavigationOptions(mockTitle, mockThemeColors); + it('sets headerLeft to null', () => { + const options = getSettingsNavigationOptions( + mockTitle, + mockThemeColors, + mockNavigation, + ); expect(options.headerLeft).toBeNull(); }); - it('should return headerTitle as a function', () => { - const options = getSettingsNavigationOptions(mockTitle, mockThemeColors); + it('returns headerTitle as a function', () => { + const options = getSettingsNavigationOptions( + mockTitle, + mockThemeColors, + mockNavigation, + ); expect(options.headerTitle).toBeDefined(); expect(typeof options.headerTitle).toBe('function'); }); - it('should include headerStyle with correct background color', () => { - const options = getSettingsNavigationOptions(mockTitle, mockThemeColors); + it('includes headerStyle with correct background color', () => { + const options = getSettingsNavigationOptions( + mockTitle, + mockThemeColors, + mockNavigation, + ); expect(options.headerStyle).toBeDefined(); expect(options.headerStyle.backgroundColor).toBe( @@ -662,44 +678,35 @@ describe('getSettingsNavigationOptions', () => { ); }); - it('should set transparent shadow and elevation', () => { - const options = getSettingsNavigationOptions(mockTitle, mockThemeColors); - - expect(options.headerStyle.shadowColor).toBe('transparent'); - expect(options.headerStyle.elevation).toBe(0); - }); - }); - - describe('Rewards Enabled Functionality', () => { - it('should show close button when rewards are enabled', () => { + it('sets transparent shadow and elevation', () => { const options = getSettingsNavigationOptions( mockTitle, mockThemeColors, mockNavigation, - true, ); - expect(options.headerRight).toBeDefined(); - expect(typeof options.headerRight).toBe('function'); + expect(options.headerStyle.shadowColor).toBe('transparent'); + expect(options.headerStyle.elevation).toBe(0); }); + }); - it('should not show close button when rewards are disabled', () => { + describe('Close Button Functionality', () => { + it('shows close button in headerRight', () => { const options = getSettingsNavigationOptions( mockTitle, mockThemeColors, mockNavigation, - false, ); - expect(options.headerRight()).toBeNull(); + expect(options.headerRight).toBeDefined(); + expect(typeof options.headerRight).toBe('function'); }); - it('should call navigation.goBack when close button is pressed', () => { + it('calls navigation.goBack when close button is pressed', () => { const options = getSettingsNavigationOptions( mockTitle, mockThemeColors, mockNavigation, - true, ); const HeaderRightComponent = options.headerRight; @@ -713,24 +720,22 @@ describe('getSettingsNavigationOptions', () => { expect(mockNavigation.goBack).toHaveBeenCalledTimes(1); }); - it('should handle missing navigation object gracefully', () => { + it('handles missing navigation object gracefully', () => { const options = getSettingsNavigationOptions( mockTitle, mockThemeColors, null, - true, ); expect(options.headerRight).toBeDefined(); expect(typeof options.headerRight).toBe('function'); }); - it('should handle undefined navigation object when rewards enabled', () => { + it('handles undefined navigation object gracefully', () => { const options = getSettingsNavigationOptions( mockTitle, mockThemeColors, undefined, - true, ); expect(options.headerRight).toBeDefined(); @@ -739,8 +744,12 @@ describe('getSettingsNavigationOptions', () => { }); describe('HeaderTitle Component', () => { - it('should render MorphText component with correct props', () => { - const options = getSettingsNavigationOptions(mockTitle, mockThemeColors); + it('renders MorphText component with correct props', () => { + const options = getSettingsNavigationOptions( + mockTitle, + mockThemeColors, + mockNavigation, + ); const HeaderTitleComponent = options.headerTitle; const { getByText, getByTestId } = renderWithProvider( @@ -751,11 +760,12 @@ describe('getSettingsNavigationOptions', () => { expect(getByText(mockTitle)).toBeDefined(); }); - it('should display the provided title text', () => { + it('displays the provided title text', () => { const customTitle = 'Custom Settings Title'; const options = getSettingsNavigationOptions( customTitle, mockThemeColors, + mockNavigation, ); const HeaderTitleComponent = options.headerTitle; @@ -768,19 +778,23 @@ describe('getSettingsNavigationOptions', () => { }); describe('Parameter Validation', () => { - it('should handle different title types', () => { + it('handles different title types', () => { const titles = ['Settings', 'Privacy & Security', 'Networks', '']; titles.forEach((title) => { expect(() => { - const options = getSettingsNavigationOptions(title, mockThemeColors); + const options = getSettingsNavigationOptions( + title, + mockThemeColors, + mockNavigation, + ); expect(options).toBeDefined(); expect(options.headerTitle).toBeDefined(); }).not.toThrow(); }); }); - it('should handle different theme colors', () => { + it('handles different theme colors', () => { const themeVariations = [ { background: { default: '#000000' } }, { background: { default: '#FFFFFF' } }, @@ -789,7 +803,11 @@ describe('getSettingsNavigationOptions', () => { themeVariations.forEach((theme) => { expect(() => { - const options = getSettingsNavigationOptions(mockTitle, theme); + const options = getSettingsNavigationOptions( + mockTitle, + theme, + mockNavigation, + ); expect(options).toBeDefined(); expect(options.headerStyle.backgroundColor).toBe( theme.background.default, @@ -798,55 +816,53 @@ describe('getSettingsNavigationOptions', () => { }); }); - it('should handle undefined or null parameters gracefully', () => { + it('handles undefined or null parameters gracefully', () => { // Test with undefined title expect(() => { const options = getSettingsNavigationOptions( undefined, mockThemeColors, + mockNavigation, ); expect(options).toBeDefined(); }).not.toThrow(); // Test with null title - expect(() => { - const options = getSettingsNavigationOptions(null, mockThemeColors); - expect(options).toBeDefined(); - }).not.toThrow(); - }); - - it('should handle new navigation and isRewardsEnabled parameters', () => { expect(() => { const options = getSettingsNavigationOptions( - mockTitle, + null, mockThemeColors, mockNavigation, - true, ); expect(options).toBeDefined(); - expect(options.headerRight).toBeDefined(); }).not.toThrow(); + }); + it('handles navigation parameter', () => { expect(() => { const options = getSettingsNavigationOptions( mockTitle, mockThemeColors, mockNavigation, - false, ); expect(options).toBeDefined(); - expect(options.headerRight()).toBeNull(); + expect(options.headerRight).toBeDefined(); }).not.toThrow(); }); }); describe('Return Value Structure', () => { - it('should return object with expected properties', () => { - const options = getSettingsNavigationOptions(mockTitle, mockThemeColors); + it('returns object with expected properties', () => { + const options = getSettingsNavigationOptions( + mockTitle, + mockThemeColors, + mockNavigation, + ); expect(options).toMatchObject({ headerLeft: null, headerTitle: expect.any(Function), + headerRight: expect.any(Function), headerStyle: expect.objectContaining({ backgroundColor: expect.any(String), shadowColor: 'transparent', @@ -855,11 +871,19 @@ describe('getSettingsNavigationOptions', () => { }); }); - it('should maintain consistent structure across different inputs', () => { - const options1 = getSettingsNavigationOptions('Title 1', mockThemeColors); - const options2 = getSettingsNavigationOptions('Title 2', { - background: { default: '#000000' }, - }); + it('maintains consistent structure across different inputs', () => { + const options1 = getSettingsNavigationOptions( + 'Title 1', + mockThemeColors, + mockNavigation, + ); + const options2 = getSettingsNavigationOptions( + 'Title 2', + { + background: { default: '#000000' }, + }, + mockNavigation, + ); expect(Object.keys(options1)).toEqual(Object.keys(options2)); expect(typeof options1.headerTitle).toBe(typeof options2.headerTitle); @@ -868,9 +892,13 @@ describe('getSettingsNavigationOptions', () => { }); describe('Integration', () => { - it('should work with React Navigation stack', () => { + it('works with React Navigation stack', () => { const Stack = createStackNavigator(); - const options = getSettingsNavigationOptions(mockTitle, mockThemeColors); + const options = getSettingsNavigationOptions( + mockTitle, + mockThemeColors, + mockNavigation, + ); expect(() => { renderWithProvider( diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx index 44dcb8930c5b..b6e7127f67dd 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx @@ -53,10 +53,6 @@ jest.mock('../../hooks/usePerpsOrderFees', () => ({ formatFeeRate: jest.fn((rate) => `${((rate || 0) * 100).toFixed(3)}%`), })); -jest.mock('../../../../../selectors/featureFlagController/rewards', () => ({ - selectRewardsEnabledFlag: jest.fn(() => true), -})); - // Mock PerpsMarketBalanceActions dependencies jest.mock('../../hooks/stream', () => ({ usePerpsLiveAccount: jest.fn(() => ({ @@ -122,7 +118,7 @@ jest.mock('../../hooks', () => ({ navigateToBrowser: jest.fn(), navigateToActions: jest.fn(), navigateToActivity: jest.fn(), - navigateToRewardsOrSettings: jest.fn(), + navigateToRewards: jest.fn(), navigateToMarketDetails: jest.fn(), navigateToHome: jest.fn(), navigateToMarketList: jest.fn(), diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index b9a11dbb43e4..83573e42d987 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -7,7 +7,6 @@ import { waitFor, } from '@testing-library/react-native'; import React, { useCallback } from 'react'; -import { useSelector } from 'react-redux'; import { TouchableOpacity } from 'react-native'; import { Text } from '@metamask/design-system-react-native'; import { SafeAreaProvider, Metrics } from 'react-native-safe-area-context'; @@ -67,7 +66,6 @@ import { } from '../../providers/PerpsStreamManager'; import { usePerpsOrderContext } from '../../contexts/PerpsOrderContext'; import PerpsOrderView from './PerpsOrderView'; -import { selectRewardsEnabledFlag } from '../../../../../selectors/featureFlagController/rewards'; jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(), @@ -281,10 +279,7 @@ jest.mock('react-redux', () => ({ }), })); -// Mock rewards selector -jest.mock('../../../../../selectors/featureFlagController/rewards', () => ({ - selectRewardsEnabledFlag: jest.fn(() => false), -})); +// Rewards feature flag removed - rewards are always enabled // Mock DevLogger (module appears to use default export with .log()) jest.mock('../../../../../core/SDKConnect/utils/DevLogger', () => { @@ -1903,15 +1898,8 @@ describe('PerpsOrderView', () => { }); describe('Rewards Points Row', () => { - it('should display points row when rewards are enabled and should show', async () => { - // Arrange - Enable rewards - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === selectRewardsEnabledFlag) { - return true; - } - return undefined; - }); - + it('displays points row when rewards should show', async () => { + // Arrange - Rewards are always enabled (usePerpsRewards as jest.Mock).mockReturnValue({ shouldShowRewardsRow: true, estimatedPoints: 100, @@ -1931,43 +1919,8 @@ describe('PerpsOrderView', () => { }); }); - it('should not display points row when rewards are disabled', async () => { - // Arrange - Disable rewards - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === selectRewardsEnabledFlag) { - return false; - } - return undefined; - }); - - (usePerpsRewards as jest.Mock).mockReturnValue({ - shouldShowRewardsRow: false, - estimatedPoints: undefined, - isLoading: false, - hasError: false, - bonusBips: undefined, - feeDiscountPercentage: undefined, - isRefresh: false, - }); - - // Act - render(, { wrapper: TestWrapper }); - - // Assert - await waitFor(() => { - expect(screen.queryByText('perps.estimated_points')).toBeFalsy(); - }); - }); - - it('should handle points tooltip interaction', async () => { - // Arrange - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === selectRewardsEnabledFlag) { - return true; - } - return undefined; - }); - + it('handles points tooltip interaction', async () => { + // Arrange - Rewards are always enabled (usePerpsRewards as jest.Mock).mockReturnValue({ shouldShowRewardsRow: true, estimatedPoints: 150, @@ -1989,15 +1942,8 @@ describe('PerpsOrderView', () => { expect(screen.getByText('perps.estimated_points')).toBeTruthy(); }); - it('should render RewardsAnimations component with correct props when rewards shown', async () => { - // Arrange - Enable rewards with specific values - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === selectRewardsEnabledFlag) { - return true; - } - return undefined; - }); - + it('renders RewardsAnimations component with correct props when rewards shown', async () => { + // Arrange - Rewards are always enabled (usePerpsRewards as jest.Mock).mockReturnValue({ shouldShowRewardsRow: true, estimatedPoints: 1000, @@ -2019,15 +1965,8 @@ describe('PerpsOrderView', () => { }); }); - it('should render RewardsAnimations in loading state', async () => { - // Arrange - Enable rewards in loading state - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === selectRewardsEnabledFlag) { - return true; - } - return undefined; - }); - + it('renders RewardsAnimations in loading state', async () => { + // Arrange - Rewards are always enabled, in loading state (usePerpsRewards as jest.Mock).mockReturnValue({ shouldShowRewardsRow: true, estimatedPoints: 0, @@ -2047,15 +1986,8 @@ describe('PerpsOrderView', () => { }); }); - it('should render RewardsAnimations in error state', async () => { - // Arrange - Enable rewards in error state - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === selectRewardsEnabledFlag) { - return true; - } - return undefined; - }); - + it('renders RewardsAnimations in error state', async () => { + // Arrange - Rewards are always enabled, in error state (usePerpsRewards as jest.Mock).mockReturnValue({ shouldShowRewardsRow: true, estimatedPoints: 0, @@ -2076,15 +2008,8 @@ describe('PerpsOrderView', () => { }); }); - it('should render RewardsAnimations with bonus bips when provided', async () => { - // Arrange - Enable rewards with bonus - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === selectRewardsEnabledFlag) { - return true; - } - return undefined; - }); - + it('renders RewardsAnimations with bonus bips when provided', async () => { + // Arrange - Rewards are always enabled, with bonus (usePerpsRewards as jest.Mock).mockReturnValue({ shouldShowRewardsRow: true, estimatedPoints: 2500, @@ -2281,16 +2206,8 @@ describe('PerpsOrderView', () => { }); describe('Points section with rewards', () => { - it('should display points row and handle tooltip when rewards enabled', async () => { - // Enable rewards flag - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === selectRewardsEnabledFlag) { - return true; - } - return undefined; - }); - - // Mock rewards with points + it('displays points row and handles tooltip when rewards should show', async () => { + // Arrange - Rewards are always enabled (usePerpsRewards as jest.Mock).mockReturnValue({ shouldShowRewardsRow: true, isLoading: false, @@ -2301,9 +2218,10 @@ describe('PerpsOrderView', () => { isRefresh: false, }); + // Act render(, { wrapper: TestWrapper }); - // Verify points section is displayed + // Assert - Verify points section is displayed await waitFor(() => { expect(screen.getByText('perps.estimated_points')).toBeDefined(); }); @@ -2474,16 +2392,8 @@ describe('PerpsOrderView', () => { }); }); - it('should show rewards state integration with fee discount', async () => { - // Enable rewards and mock state - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === selectRewardsEnabledFlag) { - return true; - } - return undefined; - }); - - // Mock rewards state with all properties + it('shows rewards state integration with fee discount', async () => { + // Arrange - Rewards are always enabled (usePerpsRewards as jest.Mock).mockReturnValue({ shouldShowRewardsRow: true, isLoading: false, diff --git a/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts b/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts index 451ebeb36e88..97da0adcd31d 100644 --- a/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts +++ b/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts @@ -383,7 +383,7 @@ describe('usePerpsLivePositions', () => { }); }); - it('uses mark price over mid price when available', async () => { + it('uses price over mark price when available', async () => { let positionsCallback: (positions: Position[]) => void = jest.fn(); let pricesCallback: (prices: Record) => void = jest.fn(); @@ -428,7 +428,7 @@ describe('usePerpsLivePositions', () => { await waitFor(() => { const updatedPosition = result.current.positions[0]; - expect(updatedPosition.unrealizedPnl).toBe('1500'); + expect(updatedPosition.unrealizedPnl).toBe('1000'); }); }); @@ -709,16 +709,22 @@ describe('usePerpsLivePositions', () => { expect(enriched[0]).toEqual(position); }); - it('returns position unchanged when margin is NaN', () => { + it('calculates PnL even when margin is NaN (uses leverage instead)', () => { const position: Position = { ...mockPosition, + entryPrice: '50000', + size: '1.0', marginUsed: 'invalid', - unrealizedPnl: '500', + leverage: { + type: 'isolated', + value: 10, + }, }; const enriched = enrichPositionsWithLivePnL([position], basePriceData); - expect(enriched[0]).toEqual(position); + expect(enriched[0].unrealizedPnl).toBe('2000'); + expect(enriched[0].returnOnEquity).toBe('0.4'); }); it('handles multiple positions with mixed price availability', () => { diff --git a/app/components/UI/Perps/hooks/stream/usePerpsLivePositions.ts b/app/components/UI/Perps/hooks/stream/usePerpsLivePositions.ts index 488e8731186c..312ffc262905 100644 --- a/app/components/UI/Perps/hooks/stream/usePerpsLivePositions.ts +++ b/app/components/UI/Perps/hooks/stream/usePerpsLivePositions.ts @@ -2,6 +2,7 @@ import { useEffect, useState, useRef } from 'react'; import { usePerpsStream } from '../../providers/PerpsStreamManager'; import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; import type { Position, PriceUpdate } from '../../controllers/types'; +import { calculateRoEForPrice } from '../../utils/tpslValidation'; // Stable empty array reference to prevent re-renders const EMPTY_POSITIONS: Position[] = []; @@ -39,32 +40,41 @@ export function enrichPositionsWithLivePnL( } // Use mark price if available, fallback to mid price - const markPrice = priceUpdate.markPrice - ? Number.parseFloat(priceUpdate.markPrice) - : Number.parseFloat(priceUpdate.price); + const currentPrice = Number.parseFloat( + priceUpdate.price ?? priceUpdate.markPrice, + ); - if (!markPrice || Number.isNaN(markPrice) || markPrice <= 0) { + if (!currentPrice || Number.isNaN(currentPrice) || currentPrice <= 0) { return position; } const entryPrice = Number.parseFloat(position.entryPrice); const size = Number.parseFloat(position.size); - const marginUsed = Number.parseFloat(position.marginUsed); + const leverage = position.leverage?.value ?? 1; - if ( - Number.isNaN(entryPrice) || - Number.isNaN(size) || - Number.isNaN(marginUsed) - ) { + if (Number.isNaN(entryPrice) || Number.isNaN(size) || entryPrice <= 0) { return position; } - // Calculate unrealized PnL: (markPrice - entryPrice) * size - const calculatedUnrealizedPnl = (markPrice - entryPrice) * size; + const direction = size >= 0 ? 'long' : 'short'; + + const calculatedUnrealizedPnl = (currentPrice - entryPrice) * size; + + const roePercentage = calculateRoEForPrice( + currentPrice.toString(), + calculatedUnrealizedPnl >= 0, // isProfit + true, // isForPositionBoundTpsl - true for existing positions + { + currentPrice, + direction, + leverage, + entryPrice, + }, + ); - // Calculate ROE: (unrealizedPnl / marginUsed) as decimal (not percentage) - const calculatedRoe = - marginUsed > 0 ? calculatedUnrealizedPnl / marginUsed : 0; + const calculatedRoe = roePercentage + ? Number.parseFloat(roePercentage) / 100 + : 0; return { ...position, diff --git a/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts b/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts index ff2a1f325d5e..1c23ae838086 100644 --- a/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts @@ -1,6 +1,5 @@ import { renderHook } from '@testing-library/react-hooks'; import { useNavigation } from '@react-navigation/native'; -import { useSelector } from 'react-redux'; import { usePerpsNavigation } from './usePerpsNavigation'; import Routes from '../../../../constants/navigation/Routes'; @@ -8,10 +7,6 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(), })); -jest.mock('react-redux', () => ({ - useSelector: jest.fn(), -})); - describe('usePerpsNavigation', () => { const mockNavigate = jest.fn(); const mockCanGoBack = jest.fn(); @@ -19,9 +14,6 @@ describe('usePerpsNavigation', () => { const mockUseNavigation = useNavigation as jest.MockedFunction< typeof useNavigation >; - const mockUseSelector = useSelector as jest.MockedFunction< - typeof useSelector - >; beforeEach(() => { jest.clearAllMocks(); @@ -33,7 +25,6 @@ describe('usePerpsNavigation', () => { } as Partial> as ReturnType< typeof useNavigation >); - mockUseSelector.mockReturnValue(false); // isRewardsEnabled = false }); describe('Main App Navigation', () => { @@ -81,22 +72,10 @@ describe('usePerpsNavigation', () => { }); }); - it('navigates to settings when rewards disabled', () => { - mockUseSelector.mockReturnValue(false); - const { result } = renderHook(() => usePerpsNavigation()); - - result.current.navigateToRewardsOrSettings(); - - expect(mockNavigate).toHaveBeenCalledWith(Routes.SETTINGS_VIEW, { - screen: 'Settings', - }); - }); - - it('navigates to rewards when rewards enabled', () => { - mockUseSelector.mockReturnValue(true); + it('navigates to rewards', () => { const { result } = renderHook(() => usePerpsNavigation()); - result.current.navigateToRewardsOrSettings(); + result.current.navigateToRewards(); expect(mockNavigate).toHaveBeenCalledWith(Routes.REWARDS_VIEW); }); diff --git a/app/components/UI/Perps/hooks/usePerpsNavigation.ts b/app/components/UI/Perps/hooks/usePerpsNavigation.ts index 71b87751c185..8c5d9c9252ee 100644 --- a/app/components/UI/Perps/hooks/usePerpsNavigation.ts +++ b/app/components/UI/Perps/hooks/usePerpsNavigation.ts @@ -1,8 +1,6 @@ import { useCallback } from 'react'; import { useNavigation, NavigationProp } from '@react-navigation/native'; -import { useSelector } from 'react-redux'; import Routes from '../../../../constants/navigation/Routes'; -import { selectRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; import type { PerpsNavigationParamList } from '../types/navigation'; import type { PerpsMarketData } from '../controllers/types'; @@ -15,7 +13,7 @@ export interface PerpsNavigationHandlers { navigateToBrowser: () => void; navigateToActions: () => void; navigateToActivity: () => void; - navigateToRewardsOrSettings: () => void; + navigateToRewards: () => void; // Perps-specific navigation navigateToMarketDetails: (market: PerpsMarketData, source?: string) => void; @@ -62,7 +60,6 @@ export interface PerpsNavigationHandlers { */ export const usePerpsNavigation = (): PerpsNavigationHandlers => { const navigation = useNavigation>(); - const isRewardsEnabled = useSelector(selectRewardsEnabledFlag); // Main app navigation handlers const navigateToWallet = useCallback(() => { @@ -93,15 +90,9 @@ export const usePerpsNavigation = (): PerpsNavigationHandlers => { }); }, [navigation]); - const navigateToRewardsOrSettings = useCallback(() => { - if (isRewardsEnabled) { - navigation.navigate(Routes.REWARDS_VIEW); - } else { - navigation.navigate(Routes.SETTINGS_VIEW, { - screen: 'Settings', - }); - } - }, [navigation, isRewardsEnabled]); + const navigateToRewards = useCallback(() => { + navigation.navigate(Routes.REWARDS_VIEW); + }, [navigation]); // Perps-specific navigation handlers const navigateToMarketDetails = useCallback( @@ -159,7 +150,7 @@ export const usePerpsNavigation = (): PerpsNavigationHandlers => { navigateToBrowser, navigateToActions, navigateToActivity, - navigateToRewardsOrSettings, + navigateToRewards, // Perps-specific navigation navigateToMarketDetails, diff --git a/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts index 043153da24d0..4f2f9c891ee3 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts @@ -28,11 +28,6 @@ jest.mock('../../../../core/Engine', () => ({ context: mockEngineContext, })); -// Mock specific selectors directly -jest.mock('../../../../selectors/featureFlagController/rewards', () => ({ - selectRewardsEnabledFlag: jest.fn().mockReturnValue(true), -})); - jest.mock('../../../../selectors/accountsController', () => ({ selectSelectedInternalAccountFormattedAddress: jest .fn() @@ -424,12 +419,7 @@ describe('usePerpsOrderFees', () => { expect(result.current.estimatedPoints).toBeUndefined(); }); - it('should handle rewards disabled', async () => { - const { selectRewardsEnabledFlag } = jest.requireMock( - '../../../../selectors/featureFlagController/rewards', - ); - selectRewardsEnabledFlag.mockReturnValue(false); - + it('should handle rewards enabled', async () => { const mockFeeResult: FeeCalculationResult = { feeRate: 0.00045, feeAmount: 45, diff --git a/app/components/UI/Perps/hooks/usePerpsOrderFees.ts b/app/components/UI/Perps/hooks/usePerpsOrderFees.ts index e6f65d7172e1..315b971b114b 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderFees.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderFees.ts @@ -3,7 +3,6 @@ import { useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; import { selectSelectedInternalAccountFormattedAddress } from '../../../../selectors/accountsController'; -import { selectRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; import { selectChainId } from '../../../../selectors/networkController'; import { setMeasurement } from '@sentry/react-native'; @@ -113,7 +112,6 @@ export function usePerpsOrderFees({ currentBidPrice, }: UsePerpsOrderFeesParams): OrderFeesResult { const { calculateFees } = usePerpsTrading(); - const rewardsEnabled = useSelector(selectRewardsEnabledFlag); const selectedAddress = useSelector( selectSelectedInternalAccountFormattedAddress, ); @@ -153,11 +151,6 @@ export function usePerpsOrderFees({ async ( address: string, ): Promise<{ discountBips?: number; tier?: string }> => { - // Early return if feature flag is disabled - never make API call - if (!rewardsEnabled) { - return {}; - } - // Check cache first const now = Date.now(); if ( @@ -225,7 +218,7 @@ export function usePerpsOrderFees({ return {}; } }, - [rewardsEnabled, currentChainId], + [currentChainId], ); /** @@ -239,11 +232,6 @@ export function usePerpsOrderFees({ isClose: boolean, actualFeeUSD?: number, ): Promise => { - // Early return if feature flag is disabled - never make API call - if (!rewardsEnabled) { - return null; - } - try { const amountNum = Number.parseFloat(tradeAmount || '0'); if (amountNum <= 0) { @@ -314,7 +302,7 @@ export function usePerpsOrderFees({ return null; } }, - [rewardsEnabled, currentChainId], + [currentChainId], ); // State for fees from provider @@ -345,7 +333,7 @@ export function usePerpsOrderFees({ */ const applyFeeDiscount = useCallback( async (originalRate: number) => { - if (!rewardsEnabled || !selectedAddress) { + if (!selectedAddress) { return { adjustedRate: originalRate, discountPercentage: undefined }; } @@ -390,7 +378,7 @@ export function usePerpsOrderFees({ return { adjustedRate: originalRate, discountPercentage: undefined }; } }, - [rewardsEnabled, fetchFeeDiscount, amount, selectedAddress], + [fetchFeeDiscount, amount, selectedAddress], ); /** @@ -401,7 +389,7 @@ export function usePerpsOrderFees({ userAddress: string, actualFeeUSD: number, ): Promise<{ points?: number; bonusBips?: number }> => { - if (!rewardsEnabled || Number.parseFloat(amount) <= 0) { + if (Number.parseFloat(amount) <= 0) { return {}; } @@ -491,7 +479,7 @@ export function usePerpsOrderFees({ return {}; } }, - [rewardsEnabled, amount, coin, isClosing, estimatePoints], + [amount, coin, isClosing, estimatePoints], ); /** diff --git a/app/components/UI/Perps/hooks/usePerpsRewards.test.ts b/app/components/UI/Perps/hooks/usePerpsRewards.test.ts index 7da5a254e55b..d7a297ae4105 100644 --- a/app/components/UI/Perps/hooks/usePerpsRewards.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsRewards.test.ts @@ -2,11 +2,6 @@ import { renderHook, act } from '@testing-library/react-native'; import { usePerpsRewards } from './usePerpsRewards'; import type { OrderFeesResult } from './usePerpsOrderFees'; -// Mock the Redux selector -jest.mock('react-redux', () => ({ - useSelector: jest.fn(), -})); - // Mock the development config jest.mock('../constants/perpsConfig', () => ({ DEVELOPMENT_CONFIG: { @@ -15,9 +10,6 @@ jest.mock('../constants/perpsConfig', () => ({ }, })); -import { useSelector } from 'react-redux'; -const mockUseSelector = useSelector as jest.MockedFunction; - describe('usePerpsRewards', () => { // Mock fee results for testing const createMockFeeResults = ( @@ -39,35 +31,11 @@ describe('usePerpsRewards', () => { beforeEach(() => { jest.clearAllMocks(); - // Default: rewards enabled - mockUseSelector.mockReturnValue(true); }); - describe('Feature flag scenarios', () => { - it('should not show rewards row when feature flag is disabled', () => { - // Arrange - mockUseSelector.mockReturnValue(false); - const feeResults = createMockFeeResults({ estimatedPoints: 100 }); - - // Act - const { result } = renderHook(() => - usePerpsRewards({ - feeResults, - hasValidAmount: true, - isFeesLoading: false, - orderAmount: '1000', - }), - ); - - // Assert - expect(result.current.shouldShowRewardsRow).toBe(false); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(false); - }); - - it('should show rewards row when feature flag is enabled and has valid amount', () => { + describe('Rewards row visibility', () => { + it('should show rewards row when has valid amount', () => { // Arrange - mockUseSelector.mockReturnValue(true); const feeResults = createMockFeeResults({ estimatedPoints: 100 }); // Act @@ -87,7 +55,6 @@ describe('usePerpsRewards', () => { it('should not show rewards row when hasValidAmount is false', () => { // Arrange - mockUseSelector.mockReturnValue(true); const feeResults = createMockFeeResults({ estimatedPoints: 100 }); // Act diff --git a/app/components/UI/Perps/hooks/usePerpsRewards.ts b/app/components/UI/Perps/hooks/usePerpsRewards.ts index a9268dd7809b..e8f28bb4a644 100644 --- a/app/components/UI/Perps/hooks/usePerpsRewards.ts +++ b/app/components/UI/Perps/hooks/usePerpsRewards.ts @@ -1,6 +1,4 @@ import { useEffect, useMemo, useState } from 'react'; -import { useSelector } from 'react-redux'; -import { selectRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; import { DEVELOPMENT_CONFIG } from '../constants/perpsConfig'; import { OrderFeesResult } from './usePerpsOrderFees'; @@ -42,9 +40,6 @@ export const usePerpsRewards = ({ isFeesLoading = false, orderAmount = '', }: UsePerpsRewardsParams): UsePerpsRewardsResult => { - // Get rewards feature flag - const rewardsEnabled = useSelector(selectRewardsEnabledFlag); - // Track previous points to detect refresh state const [previousPoints, setPreviousPoints] = useState(); @@ -69,8 +64,8 @@ export const usePerpsRewards = ({ // Determine if we should show rewards row const shouldShowRewardsRow = useMemo( - () => rewardsEnabled && hasValidAmount, // Show row if we have valid amount (even if there's an error or points are undefined) - [rewardsEnabled, hasValidAmount], + () => hasValidAmount, // Show row if we have valid amount (even if there's an error or points are undefined) + [hasValidAmount], ); // Determine loading state diff --git a/app/components/UI/Rewards/components/AddRewardsAccount/AddRewardsAccount.test.tsx b/app/components/UI/Rewards/components/AddRewardsAccount/AddRewardsAccount.test.tsx new file mode 100644 index 000000000000..1dae9899cd01 --- /dev/null +++ b/app/components/UI/Rewards/components/AddRewardsAccount/AddRewardsAccount.test.tsx @@ -0,0 +1,516 @@ +import React from 'react'; +import { fireEvent, act } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import { InternalAccount } from '@metamask/keyring-internal-api'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import AddRewardsAccount from './AddRewardsAccount'; +import { useLinkAccountAddress } from '../../hooks/useLinkAccountAddress'; +import { formatChainIdToCaip } from '@metamask/bridge-controller'; +import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; +import { selectSourceToken } from '../../../../../core/redux/slices/bridge'; + +// Mock dependencies +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + ...actual, + useSelector: jest.fn(), + }; +}); + +jest.mock('../../hooks/useLinkAccountAddress', () => ({ + useLinkAccountAddress: jest.fn(), +})); + +jest.mock('@metamask/bridge-controller', () => ({ + formatChainIdToCaip: jest.fn(), +})); + +jest.mock('../../../../../util/Logger', () => ({ + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), +})); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: jest.fn(() => ({ + style: jest.fn(() => ({})), + })), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +jest.mock('../../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: jest.fn(), +})); + +jest.mock('../../../../../core/redux/slices/bridge', () => ({ + selectSourceToken: jest.fn(), +})); + +// Mock SVG - override the global SVG mock for this specific file +jest.mock( + '../../../../../images/rewards/metamask-rewards-points-alternative.svg', + () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + + const SvgComponent = ReactActual.forwardRef( + (props: Record, ref: unknown) => + ReactActual.createElement(View, { + testID: 'metamask-rewards-points-alternative-image', + ref, + ...props, + }), + ); + + SvgComponent.displayName = 'MetamaskRewardsPointsAlternativeImage'; + + return SvgComponent; + }, +); + +// Mock design system components +jest.mock('@metamask/design-system-react-native', () => { + const ReactActual = jest.requireActual('react'); + const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); + + const Box = ({ + children, + ...props + }: { + children?: React.ReactNode; + [key: string]: unknown; + }) => ReactActual.createElement(View, props, children); + + const TextComponent = ({ + children, + ...props + }: { + children?: React.ReactNode; + [key: string]: unknown; + }) => ReactActual.createElement(Text, props, children); + + const Button = ({ + children, + onPress, + testID, + isDisabled, + isLoading, + startAccessory, + ...props + }: { + children?: React.ReactNode; + onPress?: () => void; + testID?: string; + isDisabled?: boolean; + isLoading?: boolean; + startAccessory?: React.ReactNode; + [key: string]: unknown; + }) => + ReactActual.createElement( + TouchableOpacity, + { + onPress, + testID, + disabled: isDisabled, + accessibilityState: { + disabled: isDisabled || false, + busy: isLoading || false, + }, + ...props, + }, + startAccessory, + ReactActual.createElement(Text, {}, children), + ); + + return { + Box, + Text: TextComponent, + Button, + ButtonSize: { + Sm: 'sm', + }, + ButtonVariant: { + Tertiary: 'tertiary', + }, + }; +}); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseLinkAccountAddress = useLinkAccountAddress as jest.MockedFunction< + typeof useLinkAccountAddress +>; +const mockFormatChainIdToCaip = formatChainIdToCaip as jest.MockedFunction< + typeof formatChainIdToCaip +>; +const mockSelectSourceToken = selectSourceToken as jest.MockedFunction< + typeof selectSourceToken +>; +const mockSelectSelectedInternalAccountByScope = + selectSelectedInternalAccountByScope as jest.MockedFunction< + typeof selectSelectedInternalAccountByScope + >; + +describe('AddRewardsAccount', () => { + const mockLinkAccountAddress = jest.fn(); + const mockGetSelectedAccountByScope = jest.fn(); + + const mockAccount: InternalAccount = { + id: 'test-account-id', + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + scopes: ['eip155:1'], + options: {}, + methods: [], + metadata: { + name: 'Test Account', + importTime: Date.now(), + keyring: { + type: 'HD Key Tree', + }, + }, + }; + + const mockSourceToken = { + chainId: '0x1', + address: '0xTokenAddress', + symbol: 'ETH', + decimals: 18, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Default mock implementations + mockUseLinkAccountAddress.mockReturnValue({ + linkAccountAddress: mockLinkAccountAddress, + isLoading: false, + isError: false, + }); + + mockUseSelector.mockImplementation((selector) => { + if (selector === mockSelectSourceToken) { + return mockSourceToken; + } + if (selector === mockSelectSelectedInternalAccountByScope) { + return mockGetSelectedAccountByScope; + } + return undefined; + }); + + mockFormatChainIdToCaip.mockImplementation( + (chainId: string | number) => + `eip155:${parseInt(chainId as string, 16)}` as `${string}:${string}`, + ); + + mockGetSelectedAccountByScope.mockReturnValue(mockAccount); + }); + + describe('Rendering', () => { + it('renders button when account prop is provided', () => { + const { getByTestId } = renderWithProvider( + , + ); + + expect(getByTestId('add-rewards-account')).toBeOnTheScreen(); + }); + + it('renders button when accountScope is derived from sourceToken', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId('add-rewards-account')).toBeOnTheScreen(); + }); + + it('returns null when no accountScope is available', () => { + mockGetSelectedAccountByScope.mockReturnValue(undefined); + + const { queryByTestId } = renderWithProvider(); + + expect(queryByTestId('add-rewards-account')).toBeNull(); + }); + + it('returns null when sourceToken is undefined', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === mockSelectSourceToken) { + return undefined; + } + if (selector === mockSelectSelectedInternalAccountByScope) { + return mockGetSelectedAccountByScope; + } + return undefined; + }); + + const { queryByTestId } = renderWithProvider(); + + expect(queryByTestId('add-rewards-account')).toBeNull(); + }); + + it('returns null when sourceToken chainId is undefined', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === mockSelectSourceToken) { + return { ...mockSourceToken, chainId: undefined }; + } + if (selector === mockSelectSelectedInternalAccountByScope) { + return mockGetSelectedAccountByScope; + } + return undefined; + }); + + const { queryByTestId } = renderWithProvider(); + + expect(queryByTestId('add-rewards-account')).toBeNull(); + }); + + it('uses custom testID when provided', () => { + const customTestID = 'custom-test-id'; + + const { getByTestId } = renderWithProvider( + , + ); + + expect(getByTestId(customTestID)).toBeOnTheScreen(); + }); + }); + + describe('Account Scope Resolution', () => { + it('uses account prop when provided', () => { + const customAccount: InternalAccount = { + ...mockAccount, + id: 'custom-account-id', + address: '0xCustomAddress', + }; + + renderWithProvider(); + + // Verify that linkAccountAddress would be called with custom account + // This is tested indirectly through button press + const { getByTestId } = renderWithProvider( + , + ); + + fireEvent.press(getByTestId('add-rewards-account')); + + expect(mockLinkAccountAddress).toHaveBeenCalledWith(customAccount); + }); + + it('derives accountScope from sourceToken when account prop is not provided', () => { + const derivedAccount: InternalAccount = { + ...mockAccount, + id: 'derived-account-id', + }; + mockGetSelectedAccountByScope.mockReturnValue(derivedAccount); + + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId('add-rewards-account')); + + expect(mockFormatChainIdToCaip).toHaveBeenCalledWith('0x1'); + expect(mockGetSelectedAccountByScope).toHaveBeenCalledWith('eip155:1'); + expect(mockLinkAccountAddress).toHaveBeenCalledWith(derivedAccount); + }); + + it('handles formatChainIdToCaip conversion correctly', () => { + mockFormatChainIdToCaip.mockReturnValue( + 'eip155:137' as `${string}:${string}`, + ); + + renderWithProvider(); + + expect(mockFormatChainIdToCaip).toHaveBeenCalledWith('0x1'); + expect(mockGetSelectedAccountByScope).toHaveBeenCalledWith('eip155:137'); + }); + }); + + describe('Button Interactions', () => { + it('calls linkAccountAddress when button is pressed', async () => { + mockLinkAccountAddress.mockResolvedValue(true); + + const { getByTestId } = renderWithProvider( + , + ); + + await act(async () => { + fireEvent.press(getByTestId('add-rewards-account')); + }); + + expect(mockLinkAccountAddress).toHaveBeenCalledWith(mockAccount); + }); + + it('does not call linkAccountAddress when accountScope is undefined', () => { + mockGetSelectedAccountByScope.mockReturnValue(undefined); + + const { queryByTestId } = renderWithProvider(); + + expect(queryByTestId('add-rewards-account')).toBeNull(); + expect(mockLinkAccountAddress).not.toHaveBeenCalled(); + }); + + it('sets isSuccess state when linkAccountAddress succeeds', async () => { + mockLinkAccountAddress.mockResolvedValue(true); + + const { getByTestId, queryByTestId } = renderWithProvider( + , + ); + + await act(async () => { + fireEvent.press(getByTestId('add-rewards-account')); + }); + + // Component should return null after successful link + expect(queryByTestId('add-rewards-account')).toBeNull(); + }); + + it('does not set isSuccess state when linkAccountAddress fails', async () => { + mockLinkAccountAddress.mockResolvedValue(false); + + const { getByTestId } = renderWithProvider( + , + ); + + await act(async () => { + fireEvent.press(getByTestId('add-rewards-account')); + }); + + // Component should still render after failed link + expect(getByTestId('add-rewards-account')).toBeOnTheScreen(); + }); + }); + + describe('Loading States', () => { + it('disables button when isLoading is true', () => { + mockUseLinkAccountAddress.mockReturnValue({ + linkAccountAddress: mockLinkAccountAddress, + isLoading: true, + isError: false, + }); + + const { getByTestId } = renderWithProvider( + , + ); + + const button = getByTestId('add-rewards-account'); + expect(button).toHaveProp('disabled', true); + }); + + it('enables button when isLoading is false', () => { + mockUseLinkAccountAddress.mockReturnValue({ + linkAccountAddress: mockLinkAccountAddress, + isLoading: false, + isError: false, + }); + + const { getByTestId } = renderWithProvider( + , + ); + + const button = getByTestId('add-rewards-account'); + expect(button).toHaveProp('disabled', false); + }); + + it('shows loading state on button when isLoading is true', () => { + mockUseLinkAccountAddress.mockReturnValue({ + linkAccountAddress: mockLinkAccountAddress, + isLoading: true, + isError: false, + }); + + const { getByTestId } = renderWithProvider( + , + ); + + const button = getByTestId('add-rewards-account'); + expect(button).toHaveProp('accessibilityState', { + disabled: true, + busy: true, + }); + }); + }); + + describe('Edge Cases', () => { + it('handles accountScope becoming undefined after initial render', () => { + const { rerender, queryByTestId } = renderWithProvider( + , + ); + + expect(queryByTestId('add-rewards-account')).toBeOnTheScreen(); + + // Simulate accountScope becoming undefined + mockGetSelectedAccountByScope.mockReturnValue(undefined); + + rerender(); + + expect(queryByTestId('add-rewards-account')).toBeNull(); + }); + + it('handles multiple rapid button presses', async () => { + mockLinkAccountAddress.mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve(true), 100); + }), + ); + + const { getByTestId } = renderWithProvider( + , + ); + + const button = getByTestId('add-rewards-account'); + + await act(async () => { + fireEvent.press(button); + fireEvent.press(button); + fireEvent.press(button); + }); + + // Should only be called once per press, but multiple times total + expect(mockLinkAccountAddress).toHaveBeenCalled(); + }); + }); + + describe('Success State', () => { + it('hides component after successful account linking', async () => { + mockLinkAccountAddress.mockResolvedValue(true); + + const { getByTestId, queryByTestId } = renderWithProvider( + , + ); + + expect(getByTestId('add-rewards-account')).toBeOnTheScreen(); + + await act(async () => { + fireEvent.press(getByTestId('add-rewards-account')); + }); + + // Wait for state update + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(queryByTestId('add-rewards-account')).toBeNull(); + }); + + it('maintains success state across re-renders', async () => { + mockLinkAccountAddress.mockResolvedValue(true); + + const { getByTestId, rerender, queryByTestId } = renderWithProvider( + , + ); + + await act(async () => { + fireEvent.press(getByTestId('add-rewards-account')); + // Wait for promise to settle + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // Re-render with same props + await act(async () => { + rerender(); + }); + + expect(queryByTestId('add-rewards-account')).toBeNull(); + }); + }); +}); diff --git a/app/components/UI/Rewards/components/AddRewardsAccount/AddRewardsAccount.tsx b/app/components/UI/Rewards/components/AddRewardsAccount/AddRewardsAccount.tsx new file mode 100644 index 000000000000..45bc6e5bb0f9 --- /dev/null +++ b/app/components/UI/Rewards/components/AddRewardsAccount/AddRewardsAccount.tsx @@ -0,0 +1,85 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { InternalAccount } from '@metamask/keyring-internal-api'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { formatChainIdToCaip } from '@metamask/bridge-controller'; +import MetamaskRewardsPointsAlternativeImage from '../../../../../images/rewards/metamask-rewards-points-alternative.svg'; +import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; +import { selectSourceToken } from '../../../../../core/redux/slices/bridge'; +import { useLinkAccountAddress } from '../../hooks/useLinkAccountAddress'; +import { strings } from '../../../../../../locales/i18n'; + +interface AddRewardsAccountProps { + account?: InternalAccount; + testID?: string; +} + +const AddRewardsAccount: React.FC = ({ + account, + testID = 'add-rewards-account', +}) => { + const tw = useTailwind(); + const sourceToken = useSelector(selectSourceToken); + const getSelectedAccountByScope = useSelector( + selectSelectedInternalAccountByScope, + ); + const [isSuccess, setIsSuccess] = useState(false); + const accountScope = useMemo(() => { + if (account) { + return account; + } + const sourceChainId = sourceToken?.chainId + ? formatChainIdToCaip(sourceToken.chainId) + : undefined; + if (sourceChainId) { + return getSelectedAccountByScope(sourceChainId); + } + return undefined; + }, [account, sourceToken, getSelectedAccountByScope]); + + const { linkAccountAddress, isLoading } = useLinkAccountAddress(true); + + const handlePress = useCallback(async () => { + if (!accountScope) { + return; + } + + const success = await linkAccountAddress(accountScope); + if (success) { + setIsSuccess(true); + } + }, [accountScope, linkAccountAddress]); + + // Don't render if no account available or if successfully linked + if (!accountScope || isSuccess) { + return null; + } + + return ( + + ); +}; + +export default AddRewardsAccount; diff --git a/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts b/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts new file mode 100644 index 000000000000..cdb674e245a7 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts @@ -0,0 +1,668 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { InternalAccount } from '@metamask/keyring-internal-api'; +import { useLinkAccountAddress } from './useLinkAccountAddress'; +import Engine from '../../../../core/Engine'; +import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; +import { deriveAccountMetricProps } from '../utils'; +import useRewardsToast from './useRewardsToast'; +import { strings } from '../../../../../locales/i18n'; +import { formatAddress } from '../../../../util/address'; +import { IMetaMetricsEvent } from '../../../../core/Analytics'; + +// Mock dependencies +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { + call: jest.fn(), + }, +})); + +jest.mock('../../../hooks/useMetrics', () => ({ + MetaMetricsEvents: { + REWARDS_ACCOUNT_LINKING_STARTED: 'Rewards Account Linking Started', + REWARDS_ACCOUNT_LINKING_COMPLETED: 'Rewards Account Linking Completed', + REWARDS_ACCOUNT_LINKING_FAILED: 'Rewards Account Linking Failed', + }, + useMetrics: jest.fn(), +})); + +jest.mock('../utils', () => ({ + deriveAccountMetricProps: jest.fn(), +})); + +jest.mock('./useRewardsToast', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, params?: Record) => { + if (key === 'rewards.link_account_group.link_account_address_error') { + return `Failed to link ${params?.address || 'account'}`; + } + return key; + }), +})); + +jest.mock('../../../../util/address', () => ({ + formatAddress: jest.fn( + (address: string) => `${address.slice(0, 6)}...${address.slice(-4)}`, + ), +})); + +describe('useLinkAccountAddress', () => { + const mockEngineCall = Engine.controllerMessenger.call as jest.MockedFunction< + typeof Engine.controllerMessenger.call + >; + const mockUseMetrics = jest.mocked(useMetrics); + const mockDeriveAccountMetricProps = jest.mocked(deriveAccountMetricProps); + const mockUseRewardsToast = jest.mocked(useRewardsToast); + const mockStrings = jest.mocked(strings); + const mockFormatAddress = jest.mocked(formatAddress); + + const mockTrackEvent = jest.fn(); + const mockCreateEventBuilder = jest.fn().mockReturnValue({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({ + event: expect.any(String), + properties: expect.any(Object), + } as unknown as IMetaMetricsEvent), + }); + + const mockShowToast = jest.fn(); + const mockRewardsToastOptions = { + error: jest.fn().mockReturnValue({ + variant: 'icon', + iconName: 'error', + hapticsType: 'error', + }), + }; + + const mockAccount: InternalAccount = { + id: 'test-account-id', + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + scopes: ['eip155:1'], + options: {}, + methods: [], + metadata: { + name: 'Test Account', + importTime: Date.now(), + keyring: { + type: 'HD Key Tree', + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup useMetrics mock + mockUseMetrics.mockReturnValue({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + } as never); + + // Setup useRewardsToast mock + mockUseRewardsToast.mockReturnValue({ + showToast: mockShowToast, + RewardsToastOptions: { + ...mockRewardsToastOptions, + success: jest.fn().mockReturnValue({ + variant: 'icon', + iconName: 'confirmation', + hapticsType: 'success', + }), + }, + }); + + // Setup deriveAccountMetricProps mock + mockDeriveAccountMetricProps.mockReturnValue({ + scope: 'evm', + account_type: 'HD Key Tree', + }); + + // Setup formatAddress mock + mockFormatAddress.mockImplementation( + (address: string) => `${address.slice(0, 6)}...${address.slice(-4)}`, + ); + }); + + describe('Hook initialization', () => { + it('returns hook interface with initial state', () => { + const { result } = renderHook(() => useLinkAccountAddress()); + + expect(result.current).toEqual({ + linkAccountAddress: expect.any(Function), + isLoading: false, + isError: false, + }); + }); + + it('initializes with showToasts defaulting to true', () => { + const { result } = renderHook(() => useLinkAccountAddress()); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isError).toBe(false); + expect(typeof result.current.linkAccountAddress).toBe('function'); + }); + + it('initializes with showToasts set to false when provided', () => { + const { result } = renderHook(() => useLinkAccountAddress(false)); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isError).toBe(false); + }); + }); + + describe('Successful account linking', () => { + it('links account when opt-in is supported and not already opted in', async () => { + mockEngineCall + .mockResolvedValueOnce(true) // isOptInSupported + .mockResolvedValueOnce({ ois: [false] }) // getOptInStatus + .mockResolvedValueOnce(true); // linkAccountToSubscriptionCandidate + + const { result } = renderHook(() => useLinkAccountAddress()); + + let linkResult: boolean | undefined; + await act(async () => { + linkResult = await result.current.linkAccountAddress(mockAccount); + }); + + expect(linkResult).toBe(true); + expect(mockEngineCall).toHaveBeenCalledTimes(3); + expect(mockEngineCall).toHaveBeenNthCalledWith( + 1, + 'RewardsController:isOptInSupported', + mockAccount, + ); + expect(mockEngineCall).toHaveBeenNthCalledWith( + 2, + 'RewardsController:getOptInStatus', + { addresses: [mockAccount.address] }, + ); + expect(mockEngineCall).toHaveBeenNthCalledWith( + 3, + 'RewardsController:linkAccountToSubscriptionCandidate', + mockAccount, + ); + }); + + it('tracks started event when linking begins', async () => { + mockEngineCall + .mockResolvedValueOnce(true) // isOptInSupported + .mockResolvedValueOnce({ ois: [false] }) // getOptInStatus + .mockResolvedValueOnce(true); // linkAccountToSubscriptionCandidate + + const { result } = renderHook(() => useLinkAccountAddress()); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(mockDeriveAccountMetricProps).toHaveBeenCalledWith(mockAccount); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.REWARDS_ACCOUNT_LINKING_STARTED, + ); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('tracks completed event when linking succeeds', async () => { + mockEngineCall + .mockResolvedValueOnce(true) // isOptInSupported + .mockResolvedValueOnce({ ois: [false] }) // getOptInStatus + .mockResolvedValueOnce(true); // linkAccountToSubscriptionCandidate + + const { result } = renderHook(() => useLinkAccountAddress()); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.REWARDS_ACCOUNT_LINKING_COMPLETED, + ); + expect(mockTrackEvent).toHaveBeenCalledTimes(2); // Started + Completed + }); + + it('clears loading state in finally block', async () => { + mockEngineCall + .mockResolvedValueOnce(true) + .mockResolvedValueOnce({ ois: [false] }) + .mockResolvedValueOnce(true); + + const { result } = renderHook(() => useLinkAccountAddress()); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(result.current.isLoading).toBe(false); + }); + }); + + describe('Account already opted in', () => { + it('returns true immediately when account is already opted in', async () => { + mockEngineCall + .mockResolvedValueOnce(true) // isOptInSupported + .mockResolvedValueOnce({ ois: [true] }); // getOptInStatus - already opted in + + const { result } = renderHook(() => useLinkAccountAddress()); + + let linkResult: boolean | undefined; + await act(async () => { + linkResult = await result.current.linkAccountAddress(mockAccount); + }); + + expect(linkResult).toBe(true); + expect(mockEngineCall).toHaveBeenCalledTimes(2); + expect(mockEngineCall).not.toHaveBeenCalledWith( + 'RewardsController:linkAccountToSubscriptionCandidate', + expect.anything(), + ); + }); + + it('does not track events when account is already opted in', async () => { + mockEngineCall + .mockResolvedValueOnce(true) + .mockResolvedValueOnce({ ois: [true] }); + + const { result } = renderHook(() => useLinkAccountAddress()); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('does not show toast when account is already opted in', async () => { + mockEngineCall + .mockResolvedValueOnce(true) + .mockResolvedValueOnce({ ois: [true] }); + + const { result } = renderHook(() => useLinkAccountAddress()); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(mockShowToast).not.toHaveBeenCalled(); + }); + }); + + describe('Account not supported', () => { + it('returns false when account does not support opt-in', async () => { + mockEngineCall.mockResolvedValueOnce(false); // isOptInSupported + + const { result } = renderHook(() => useLinkAccountAddress()); + + let linkResult: boolean | undefined; + await act(async () => { + linkResult = await result.current.linkAccountAddress(mockAccount); + }); + + expect(linkResult).toBe(false); + expect(mockEngineCall).toHaveBeenCalledTimes(1); + expect(mockEngineCall).toHaveBeenCalledWith( + 'RewardsController:isOptInSupported', + mockAccount, + ); + }); + + it('sets error state when account does not support opt-in', async () => { + mockEngineCall.mockResolvedValueOnce(false); + + const { result } = renderHook(() => useLinkAccountAddress()); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(result.current.isError).toBe(true); + }); + + it('shows error toast when account does not support opt-in and showToasts is true', async () => { + mockEngineCall.mockResolvedValueOnce(false); + + const { result } = renderHook(() => useLinkAccountAddress(true)); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(mockFormatAddress).toHaveBeenCalledWith( + mockAccount.address, + 'short', + ); + expect(mockStrings).toHaveBeenCalledWith( + 'rewards.link_account_group.link_account_address_error', + { + address: expect.any(String), + }, + ); + expect(mockRewardsToastOptions.error).toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalled(); + }); + + it('does not show toast when account does not support opt-in and showToasts is false', async () => { + mockEngineCall.mockResolvedValueOnce(false); + + const { result } = renderHook(() => useLinkAccountAddress(false)); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(mockShowToast).not.toHaveBeenCalled(); + }); + }); + + describe('Linking failure', () => { + it('returns false when linkAccountToSubscriptionCandidate returns false', async () => { + mockEngineCall + .mockResolvedValueOnce(true) // isOptInSupported + .mockResolvedValueOnce({ ois: [false] }) // getOptInStatus + .mockResolvedValueOnce(false); // linkAccountToSubscriptionCandidate - fails + + const { result } = renderHook(() => useLinkAccountAddress()); + + let linkResult: boolean | undefined; + await act(async () => { + linkResult = await result.current.linkAccountAddress(mockAccount); + }); + + expect(linkResult).toBe(false); + expect(result.current.isError).toBe(true); + }); + + it('tracks failed event when linkAccountToSubscriptionCandidate returns false', async () => { + mockEngineCall + .mockResolvedValueOnce(true) + .mockResolvedValueOnce({ ois: [false] }) + .mockResolvedValueOnce(false); + + const { result } = renderHook(() => useLinkAccountAddress()); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.REWARDS_ACCOUNT_LINKING_FAILED, + ); + expect(mockTrackEvent).toHaveBeenCalledTimes(2); // Started + Failed + }); + + it('shows error toast when linkAccountToSubscriptionCandidate returns false and showToasts is true', async () => { + mockEngineCall + .mockResolvedValueOnce(true) + .mockResolvedValueOnce({ ois: [false] }) + .mockResolvedValueOnce(false); + + const { result } = renderHook(() => useLinkAccountAddress(true)); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(mockShowToast).toHaveBeenCalled(); + expect(mockRewardsToastOptions.error).toHaveBeenCalled(); + }); + + it('does not show toast when linkAccountToSubscriptionCandidate returns false and showToasts is false', async () => { + mockEngineCall + .mockResolvedValueOnce(true) + .mockResolvedValueOnce({ ois: [false] }) + .mockResolvedValueOnce(false); + + const { result } = renderHook(() => useLinkAccountAddress(false)); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(mockShowToast).not.toHaveBeenCalled(); + }); + }); + + describe('Error handling', () => { + it('handles error during isOptInSupported check', async () => { + const testError = new Error('Network error'); + mockEngineCall.mockRejectedValueOnce(testError); + + const { result } = renderHook(() => useLinkAccountAddress()); + + let linkResult: boolean | undefined; + await act(async () => { + linkResult = await result.current.linkAccountAddress(mockAccount); + }); + + expect(linkResult).toBe(false); + expect(result.current.isError).toBe(true); + expect(result.current.isLoading).toBe(false); + }); + + it('shows error toast when isOptInSupported throws and showToasts is true', async () => { + const testError = new Error('Network error'); + mockEngineCall.mockRejectedValueOnce(testError); + + const { result } = renderHook(() => useLinkAccountAddress(true)); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(mockShowToast).toHaveBeenCalled(); + expect(mockRewardsToastOptions.error).toHaveBeenCalled(); + }); + + it('handles error during getOptInStatus check', async () => { + const testError = new Error('Status check failed'); + mockEngineCall + .mockResolvedValueOnce(true) // isOptInSupported + .mockRejectedValueOnce(testError); // getOptInStatus + + const { result } = renderHook(() => useLinkAccountAddress()); + + let linkResult: boolean | undefined; + await act(async () => { + linkResult = await result.current.linkAccountAddress(mockAccount); + }); + + expect(linkResult).toBe(false); + expect(result.current.isError).toBe(true); + }); + + it('handles error during linkAccountToSubscriptionCandidate', async () => { + const testError = new Error('Linking failed'); + mockEngineCall + .mockResolvedValueOnce(true) // isOptInSupported + .mockResolvedValueOnce({ ois: [false] }) // getOptInStatus + .mockRejectedValueOnce(testError); // linkAccountToSubscriptionCandidate + + const { result } = renderHook(() => useLinkAccountAddress()); + + let linkResult: boolean | undefined; + await act(async () => { + linkResult = await result.current.linkAccountAddress(mockAccount); + }); + + expect(linkResult).toBe(false); + expect(result.current.isError).toBe(true); + }); + + it('tracks failed event when linkAccountToSubscriptionCandidate throws', async () => { + const testError = new Error('Linking failed'); + mockEngineCall + .mockResolvedValueOnce(true) + .mockResolvedValueOnce({ ois: [false] }) + .mockRejectedValueOnce(testError); + + const { result } = renderHook(() => useLinkAccountAddress()); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.REWARDS_ACCOUNT_LINKING_FAILED, + ); + expect(mockTrackEvent).toHaveBeenCalledTimes(2); // Started + Failed + }); + + it('shows error toast when linkAccountToSubscriptionCandidate throws and showToasts is true', async () => { + const testError = new Error('Linking failed'); + mockEngineCall + .mockResolvedValueOnce(true) + .mockResolvedValueOnce({ ois: [false] }) + .mockRejectedValueOnce(testError); + + const { result } = renderHook(() => useLinkAccountAddress(true)); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(mockShowToast).toHaveBeenCalled(); + expect(mockRewardsToastOptions.error).toHaveBeenCalled(); + }); + + it('does not show toast when error occurs and showToasts is false', async () => { + const testError = new Error('Network error'); + mockEngineCall.mockRejectedValueOnce(testError); + + const { result } = renderHook(() => useLinkAccountAddress(false)); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('clears loading state even when error occurs', async () => { + const testError = new Error('Network error'); + mockEngineCall.mockRejectedValueOnce(testError); + + const { result } = renderHook(() => useLinkAccountAddress()); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(result.current.isLoading).toBe(false); + }); + }); + + describe('State management', () => { + it('resets error state when starting new link attempt', async () => { + // First attempt fails + mockEngineCall.mockResolvedValueOnce(false); + + const { result } = renderHook(() => useLinkAccountAddress()); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(result.current.isError).toBe(true); + + // Second attempt succeeds + mockEngineCall + .mockResolvedValueOnce(true) + .mockResolvedValueOnce({ ois: [false] }) + .mockResolvedValueOnce(true); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(result.current.isError).toBe(false); + }); + + it('maintains separate state for multiple hook instances', () => { + const { result: result1 } = renderHook(() => useLinkAccountAddress()); + const { result: result2 } = renderHook(() => useLinkAccountAddress()); + + expect(result1.current.isLoading).toBe(false); + expect(result2.current.isLoading).toBe(false); + expect(result1.current.isError).toBe(false); + expect(result2.current.isError).toBe(false); + }); + }); + + describe('Event tracking integration', () => { + it('calls deriveAccountMetricProps with correct account', async () => { + mockEngineCall + .mockResolvedValueOnce(true) + .mockResolvedValueOnce({ ois: [false] }) + .mockResolvedValueOnce(true); + + const { result } = renderHook(() => useLinkAccountAddress()); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(mockDeriveAccountMetricProps).toHaveBeenCalledWith(mockAccount); + }); + + it('builds event with account metric properties', async () => { + const mockAccountProps = { + scope: 'evm', + account_type: 'HD Key Tree', + }; + mockDeriveAccountMetricProps.mockReturnValue(mockAccountProps); + + mockEngineCall + .mockResolvedValueOnce(true) + .mockResolvedValueOnce({ ois: [false] }) + .mockResolvedValueOnce(true); + + const { result } = renderHook(() => useLinkAccountAddress()); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + const mockAddProperties = mockCreateEventBuilder().addProperties; + expect(mockAddProperties).toHaveBeenCalledWith(mockAccountProps); + }); + }); + + describe('Toast integration', () => { + it('formats address correctly for toast message', async () => { + mockEngineCall.mockResolvedValueOnce(false); + + const { result } = renderHook(() => useLinkAccountAddress(true)); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(mockFormatAddress).toHaveBeenCalledWith( + mockAccount.address, + 'short', + ); + }); + + it('uses formatted address in error message', async () => { + const formattedAddress = '0x1234...7890'; + mockFormatAddress.mockReturnValue(formattedAddress); + mockEngineCall.mockResolvedValueOnce(false); + + const { result } = renderHook(() => useLinkAccountAddress(true)); + + await act(async () => { + await result.current.linkAccountAddress(mockAccount); + }); + + expect(mockStrings).toHaveBeenCalledWith( + 'rewards.link_account_group.link_account_address_error', + { + address: formattedAddress, + }, + ); + }); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useLinkAccountAddress.ts b/app/components/UI/Rewards/hooks/useLinkAccountAddress.ts new file mode 100644 index 000000000000..3b84a3cd4a9f --- /dev/null +++ b/app/components/UI/Rewards/hooks/useLinkAccountAddress.ts @@ -0,0 +1,159 @@ +import { useCallback, useState } from 'react'; +import { InternalAccount } from '@metamask/keyring-internal-api'; +import Engine from '../../../../core/Engine'; +import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; +import { deriveAccountMetricProps } from '../utils'; +import { IMetaMetricsEvent } from '../../../../core/Analytics'; +import useRewardsToast from './useRewardsToast'; +import { strings } from '../../../../../locales/i18n'; +import { formatAddress } from '../../../../util/address'; + +interface UseLinkAccountAddressResult { + linkAccountAddress: (account: InternalAccount) => Promise; + isLoading: boolean; + isError: boolean; +} + +export const useLinkAccountAddress = ( + showToasts: boolean = true, +): UseLinkAccountAddressResult => { + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const { trackEvent, createEventBuilder } = useMetrics(); + const { showToast, RewardsToastOptions } = useRewardsToast(); + + const triggerAccountLinkingEvent = useCallback( + (event: IMetaMetricsEvent, account: InternalAccount) => { + const accountMetricProps = deriveAccountMetricProps(account); + trackEvent( + createEventBuilder(event).addProperties(accountMetricProps).build(), + ); + }, + [createEventBuilder, trackEvent], + ); + + const linkAccountAddress = useCallback( + async (account: InternalAccount): Promise => { + setIsLoading(true); + setIsError(false); + + try { + // Check if account supports opt-in + const isSupported = await Engine.controllerMessenger.call( + 'RewardsController:isOptInSupported', + account, + ); + + if (!isSupported) { + setIsError(true); + if (showToasts) { + showToast( + RewardsToastOptions.error( + strings( + 'rewards.link_account_group.link_account_address_error', + { + address: formatAddress(account.address, 'short'), + }, + ), + ), + ); + } + return false; + } + + // Check opt-in status + const optInResponse = await Engine.controllerMessenger.call( + 'RewardsController:getOptInStatus', + { addresses: [account.address] }, + ); + + // If already opted in, return success + if (optInResponse.ois[0]) { + return true; + } + + // Emit started event + triggerAccountLinkingEvent( + MetaMetricsEvents.REWARDS_ACCOUNT_LINKING_STARTED, + account, + ); + + try { + // Link the account + const success = await Engine.controllerMessenger.call( + 'RewardsController:linkAccountToSubscriptionCandidate', + account, + ); + + if (success) { + triggerAccountLinkingEvent( + MetaMetricsEvents.REWARDS_ACCOUNT_LINKING_COMPLETED, + account, + ); + return true; + } + + triggerAccountLinkingEvent( + MetaMetricsEvents.REWARDS_ACCOUNT_LINKING_FAILED, + account, + ); + if (showToasts) { + showToast( + RewardsToastOptions.error( + strings( + 'rewards.link_account_group.link_account_address_error', + { + address: formatAddress(account.address, 'short'), + }, + ), + ), + ); + } + setIsError(true); + return false; + } catch (err) { + triggerAccountLinkingEvent( + MetaMetricsEvents.REWARDS_ACCOUNT_LINKING_FAILED, + account, + ); + if (showToasts) { + showToast( + RewardsToastOptions.error( + strings( + 'rewards.link_account_group.link_account_address_error', + { + address: formatAddress(account.address, 'short'), + }, + ), + ), + ); + } + setIsError(true); + return false; + } + } catch (err) { + if (showToasts) { + showToast( + RewardsToastOptions.error( + strings('rewards.link_account_group.link_account_address_error', { + address: formatAddress(account.address, 'short'), + }), + ), + ); + } + setIsError(true); + return false; + } finally { + setIsLoading(false); + } + }, + [showToasts, triggerAccountLinkingEvent, showToast, RewardsToastOptions], + ); + + return { + linkAccountAddress, + isLoading, + isError, + }; +}; diff --git a/app/components/UI/Rewards/hooks/useRewardsIntroModal.test.ts b/app/components/UI/Rewards/hooks/useRewardsIntroModal.test.ts index da2daf946d31..5b0b63d5c22b 100644 --- a/app/components/UI/Rewards/hooks/useRewardsIntroModal.test.ts +++ b/app/components/UI/Rewards/hooks/useRewardsIntroModal.test.ts @@ -3,10 +3,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; import Routes from '../../../../constants/navigation/Routes'; import { useRewardsIntroModal } from './useRewardsIntroModal'; -import { - selectRewardsEnabledFlag, - selectRewardsAnnouncementModalEnabledFlag, -} from '../../../../selectors/featureFlagController/rewards'; +import { selectRewardsAnnouncementModalEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; import { selectMultichainAccountsIntroModalSeen } from '../../../../reducers/user'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { setOnboardingActiveStep } from '../../../../reducers/rewards'; @@ -65,7 +62,6 @@ describe('useRewardsIntroModal', () => { // Default selector values: all conditions satisfied mockUseSelector.mockImplementation((selector: unknown) => { - if (selector === selectRewardsEnabledFlag) return true; if (selector === selectRewardsAnnouncementModalEnabledFlag) return true; if (selector === selectMultichainAccountsIntroModalSeen) return true; if (selector === selectMultichainAccountsState2Enabled) return true; @@ -109,27 +105,8 @@ describe('useRewardsIntroModal', () => { }); }); - it('does not navigate when rewards feature is disabled', async () => { - mockUseSelector.mockImplementation((selector: unknown) => { - if (selector === selectRewardsEnabledFlag) return false; - if (selector === selectRewardsAnnouncementModalEnabledFlag) return true; - if (selector === selectMultichainAccountsIntroModalSeen) return true; - if (selector === selectMultichainAccountsState2Enabled) return true; - return undefined; - }); - (StorageWrapper.getItem as jest.Mock).mockResolvedValueOnce('false'); - - renderHook(() => useRewardsIntroModal()); - - // Give effects a tick - await waitFor(() => { - expect(navigate).not.toHaveBeenCalled(); - }); - }); - it('does not navigate when announcement flag is disabled', async () => { mockUseSelector.mockImplementation((selector: unknown) => { - if (selector === selectRewardsEnabledFlag) return true; if (selector === selectRewardsAnnouncementModalEnabledFlag) return false; if (selector === selectMultichainAccountsIntroModalSeen) return true; if (selector === selectMultichainAccountsState2Enabled) return true; @@ -146,7 +123,6 @@ describe('useRewardsIntroModal', () => { it('does not navigate when BIP44 intro modal has not been seen', async () => { mockUseSelector.mockImplementation((selector: unknown) => { - if (selector === selectRewardsEnabledFlag) return true; if (selector === selectRewardsAnnouncementModalEnabledFlag) return true; if (selector === selectMultichainAccountsIntroModalSeen) return false; if (selector === selectMultichainAccountsState2Enabled) return true; @@ -163,7 +139,6 @@ describe('useRewardsIntroModal', () => { it('does not navigate when subscriptionId is present', async () => { mockUseSelector.mockImplementation((selector: unknown) => { - if (selector === selectRewardsEnabledFlag) return true; if (selector === selectRewardsAnnouncementModalEnabledFlag) return true; if (selector === selectMultichainAccountsIntroModalSeen) return true; if (selector === selectMultichainAccountsState2Enabled) return true; @@ -184,7 +159,6 @@ describe('useRewardsIntroModal', () => { it('sets storage flag when subscriptionId is present', async () => { // Arrange mockUseSelector.mockImplementation((selector: unknown) => { - if (selector === selectRewardsEnabledFlag) return true; if (selector === selectRewardsAnnouncementModalEnabledFlag) return true; if (selector === selectMultichainAccountsIntroModalSeen) return true; if (selector === selectMultichainAccountsState2Enabled) return true; @@ -217,7 +191,6 @@ describe('useRewardsIntroModal', () => { // Mock BIP-44 modal as already seen (from previous session) mockUseSelector.mockImplementation((selector: unknown) => { - if (selector === selectRewardsEnabledFlag) return true; if (selector === selectRewardsAnnouncementModalEnabledFlag) return true; if (selector === selectMultichainAccountsIntroModalSeen) return true; // Seen in previous session if (selector === selectMultichainAccountsState2Enabled) return true; @@ -247,7 +220,6 @@ describe('useRewardsIntroModal', () => { // Start with BIP-44 modal already seen (from previous session) mockUseSelector.mockImplementation((selector: unknown) => { - if (selector === selectRewardsEnabledFlag) return true; if (selector === selectRewardsAnnouncementModalEnabledFlag) return true; if (selector === selectMultichainAccountsIntroModalSeen) return true; // Already seen if (selector === selectMultichainAccountsState2Enabled) return true; @@ -278,7 +250,6 @@ describe('useRewardsIntroModal', () => { // Start with BIP-44 modal NOT seen initially mockUseSelector.mockImplementation((selector: unknown) => { - if (selector === selectRewardsEnabledFlag) return true; if (selector === selectRewardsAnnouncementModalEnabledFlag) return true; if (selector === selectMultichainAccountsIntroModalSeen) return false; // Initially not seen if (selector === selectMultichainAccountsState2Enabled) return true; @@ -303,7 +274,6 @@ describe('useRewardsIntroModal', () => { // Now simulate BIP-44 modal being seen (state changes from false to true) mockUseSelector.mockImplementation((selector: unknown) => { - if (selector === selectRewardsEnabledFlag) return true; if (selector === selectRewardsAnnouncementModalEnabledFlag) return true; if (selector === selectMultichainAccountsIntroModalSeen) return true; // Now seen if (selector === selectMultichainAccountsState2Enabled) return true; diff --git a/app/components/UI/Rewards/hooks/useRewardsIntroModal.ts b/app/components/UI/Rewards/hooks/useRewardsIntroModal.ts index babe3a56dc9b..e168415c2373 100644 --- a/app/components/UI/Rewards/hooks/useRewardsIntroModal.ts +++ b/app/components/UI/Rewards/hooks/useRewardsIntroModal.ts @@ -1,10 +1,7 @@ import { useNavigation } from '@react-navigation/native'; import { useCallback, useEffect, useState, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { - selectRewardsAnnouncementModalEnabledFlag, - selectRewardsEnabledFlag, -} from '../../../../selectors/featureFlagController/rewards'; +import { selectRewardsAnnouncementModalEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; import { selectMultichainAccountsIntroModalSeen } from '../../../../reducers/user'; import StorageWrapper from '../../../../store/storage-wrapper'; import { @@ -24,17 +21,15 @@ const isE2ETest = /** * Hook to handle showing the rewards GTM intro modal * Shows the modal only when: - * 1. Rewards feature flag is enabled - * 2. Rewards announcement feature flag is enabled - * 3. The modal hasn't been seen before - * 4. The MultichainAccountsIntroModal has been seen in a PREVIOUS session (not current) - * 5. User does not have an active subscription + * 1. Rewards announcement feature flag is enabled + * 2. The modal hasn't been seen before + * 3. The MultichainAccountsIntroModal has been seen in a PREVIOUS session (not current) + * 4. User does not have an active subscription */ export const useRewardsIntroModal = () => { const navigation = useNavigation(); const dispatch = useDispatch(); - const isRewardsFeatureEnabled = useSelector(selectRewardsEnabledFlag); const isRewardsAnnouncementEnabled = useSelector( selectRewardsAnnouncementModalEnabledFlag, ); @@ -76,7 +71,6 @@ export const useRewardsIntroModal = () => { const isUpdate = !!lastAppVersion && currentAppVersion !== lastAppVersion; const shouldShow = - isRewardsFeatureEnabled && isRewardsAnnouncementEnabled && // BIP44 intro modal has been seen in a PREVIOUS session (not current) // OR it's a fresh install (which doesn't trigger bip44 modal) @@ -95,7 +89,6 @@ export const useRewardsIntroModal = () => { }); } }, [ - isRewardsFeatureEnabled, isMultichainAccountsState2Enabled, isRewardsAnnouncementEnabled, hasSeenBIP44IntroModal, @@ -121,7 +114,6 @@ export const useRewardsIntroModal = () => { }, [checkAndShowRewardsIntroModal]); return { - isRewardsFeatureEnabled, hasSeenRewardsIntroModal, }; }; diff --git a/app/components/UI/TransactionElement/utils.js b/app/components/UI/TransactionElement/utils.js index 8260b46cd921..91079fb6f7b5 100644 --- a/app/components/UI/TransactionElement/utils.js +++ b/app/components/UI/TransactionElement/utils.js @@ -83,8 +83,7 @@ function getTokenTransfer(args) { } const isIncomplete = isTransactionIncomplete(status); - const isSent = - renderFullAddress(from)?.toLowerCase() === selectedAddress?.toLowerCase(); + const isSent = from?.toLowerCase() === selectedAddress?.toLowerCase(); let actionVerb; if (isSent) { @@ -192,7 +191,7 @@ function getCollectibleTransfer(args) { } = args; const isIncomplete = isTransactionIncomplete(status); - const isSent = renderFullAddress(from) === selectedAddress; + const isSent = from?.toLowerCase() === selectedAddress?.toLowerCase(); let actionVerb; if (isSent) { @@ -339,10 +338,7 @@ function decodeIncomingTransfer(args) { : weiToFiatNumber(totalGas, conversionRate); const { SENT_TOKEN, RECEIVED_TOKEN } = TRANSACTION_TYPES; - const transactionType = - renderFullAddress(from)?.toLowerCase() === selectedAddress?.toLowerCase() - ? SENT_TOKEN - : RECEIVED_TOKEN; + const transactionType = !isIncoming ? SENT_TOKEN : RECEIVED_TOKEN; let transactionDetails = { renderTotalGas: `${renderFromWei(totalGas)} ${ticker}`, diff --git a/app/components/Views/Settings/index.tsx b/app/components/Views/Settings/index.tsx index 6b8ce875973f..35ce6806c0d4 100644 --- a/app/components/Views/Settings/index.tsx +++ b/app/components/Views/Settings/index.tsx @@ -22,7 +22,6 @@ import { isTest } from '../../../util/test/utils'; import { isPermissionsSettingsV1Enabled } from '../../../util/networks'; import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; import { selectSeedlessOnboardingLoginFlow } from '../../../selectors/seedlessOnboardingController'; -import { selectRewardsEnabledFlag } from '../../../selectors/featureFlagController/rewards'; const createStyles = (colors: Colors) => StyleSheet.create({ @@ -37,7 +36,6 @@ const Settings = () => { const { colors } = useTheme(); const { trackEvent, createEventBuilder } = useMetrics(); const styles = createStyles(colors); - const isRewardsEnabled = useSelector(selectRewardsEnabledFlag); // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any const navigation = useNavigation(); @@ -56,10 +54,9 @@ const Settings = () => { strings('app_settings.title'), colors, navigation, - isRewardsEnabled, ), ); - }, [navigation, colors, isRewardsEnabled]); + }, [navigation, colors]); useEffect(() => { updateNavBar(); diff --git a/app/components/Views/SimpleWebview/index.tsx b/app/components/Views/SimpleWebview/index.tsx index 691d29fd1c97..ba48ba91f9be 100644 --- a/app/components/Views/SimpleWebview/index.tsx +++ b/app/components/Views/SimpleWebview/index.tsx @@ -34,8 +34,11 @@ const SimpleWebView = () => { useEffect(() => { navigation.setOptions(getWebviewNavbar(navigation, route, colors)); - navigation && navigation.setParams({ dispatch: share }); - }, [navigation, route, share, colors]); + }, [navigation, route, colors]); + + useEffect(() => { + navigation.setParams({ dispatch: share }); + }, [navigation, share]); return ( diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx index 43a678480b64..fc683663db4a 100644 --- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx +++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx @@ -116,10 +116,8 @@ const ExploreSearchResults: React.FC = ({ return section.renderSkeleton(); } - // Get the onPress handler from the section config if it exists // Cast navigation to 'never' to satisfy different navigation param list types - const onPressHandler = section.getOnPressHandler?.(navigation as never); - return section.renderItem(item.data as never, onPressHandler as never); + return section.renderRowItem(item.data, navigation); }, [navigation, renderSectionHeader], ); @@ -130,7 +128,7 @@ const ExploreSearchResults: React.FC = ({ return `skeleton-${item.sectionId}-${item.index}`; const section = SECTIONS_CONFIG[item.sectionId]; - return section ? section.keyExtractor(item.data as never) : `item-${index}`; + return section ? section.keyExtractor(item.data) : `item-${index}`; }, []); if (flatData.length === 0) { diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.ts b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.ts index bdb2d5b3fedb..b63597a5ce39 100644 --- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.ts +++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.ts @@ -1,54 +1,15 @@ import { useState, useEffect, useMemo } from 'react'; import { SECTIONS_ARRAY, + useSectionsData, type SectionId, - type SectionData, } from '../../../../config/sections.config'; -import { usePerpsMarkets } from '../../../../../../UI/Perps/hooks/usePerpsMarkets'; -import { usePredictMarketData } from '../../../../../../UI/Predict/hooks/usePredictMarketData'; -import { useTrendingRequest } from '../../../../../../UI/Assets/hooks/useTrendingRequest'; export interface ExploreSearchResult { data: Record; isLoading: Record; } -/** - * Internal hook to fetch data from all sections. - * When adding a new section, add the hook call here. - */ -const useExploreSearchData = ( - debouncedQuery: string, -): Record => { - const { results: trendingTokens, isLoading: isTokensLoading } = - useTrendingRequest({}); - - const { markets: perpsMarkets, isLoading: isPerpsLoading } = - usePerpsMarkets(); - - const { marketData: predictionMarkets, isFetching: isPredictionsLoading } = - usePredictMarketData({ - category: 'trending', - q: debouncedQuery || undefined, - pageSize: debouncedQuery ? 20 : 3, - }); - - return { - tokens: { - data: trendingTokens, - isLoading: isTokensLoading, - }, - perps: { - data: perpsMarkets, - isLoading: isPerpsLoading, - }, - predictions: { - data: predictionMarkets, - isLoading: isPredictionsLoading, - }, - }; -}; - /** * GENERIC EXPLORE SEARCH HOOK * @@ -58,10 +19,6 @@ const useExploreSearchData = ( * - Filtering results based on section configurations * - Returning top 3 items when no query is present * - * TO ADD A NEW SECTION: - * 1. Add section configuration to sections.config.tsx - * 2. Add hook call to useExploreSearchData above - * * @param query - Search query string * @returns Search results grouped by section */ @@ -76,7 +33,8 @@ export const useExploreSearch = (query: string): ExploreSearchResult => { return () => clearTimeout(timer); }, [query]); - const allSectionsData = useExploreSearchData(debouncedQuery); + // Fetch data for all sections using centralized hook + const allSectionsData = useSectionsData(debouncedQuery); const filteredResults = useMemo(() => { const isLoading: Record = {} as Record< @@ -102,7 +60,7 @@ export const useExploreSearch = (query: string): ExploreSearchResult => { } else { // Filter items based on section's searchable text data[section.id] = sectionData.data.filter((item) => - section.getSearchableText(item as never).includes(searchTerm), + section.getSearchableText(item).includes(searchTerm), ); } }); diff --git a/app/components/Views/TrendingView/PerpsSection/PerpsSection.test.tsx b/app/components/Views/TrendingView/PerpsSection/PerpsSection.test.tsx deleted file mode 100644 index e060b08d2e95..000000000000 --- a/app/components/Views/TrendingView/PerpsSection/PerpsSection.test.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import React from 'react'; -import { fireEvent } from '@testing-library/react-native'; -import renderWithProvider from '../../../../util/test/renderWithProvider'; -import { backgroundState } from '../../../../util/test/initial-root-state'; -import PerpsSection from './PerpsSection'; -import { usePerpsMarkets } from '../../../UI/Perps/hooks'; -import { PerpsMarketData } from '../../../UI/Perps/controllers/types'; - -// Mock external dependencies and leaf components with deep dependencies -jest.mock('../../../UI/Perps/hooks'); -jest.mock('../../../UI/Perps/components/PerpsMarketRowItem', () => - jest.fn(() => null), -); -jest.mock( - '../../../UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton', - () => { - const { View } = jest.requireActual('react-native'); - return jest.fn(() => ); - }, -); -jest.mock('@shopify/flash-list', () => { - const { FlatList } = jest.requireActual('react-native'); - return { - FlashList: FlatList, - }; -}); - -// Mock navigation -const mockNavigate = jest.fn(); - -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ navigate: mockNavigate }), -})); - -const mockUsePerpsMarkets = jest.mocked(usePerpsMarkets); - -const initialState = { - engine: { - backgroundState, - }, -}; - -describe('PerpsSection', () => { - const createMockMarket = ( - symbol: string, - ): PerpsMarketData & { volumeNumber: number } => ({ - symbol, - name: `${symbol} Token`, - maxLeverage: '40x', - price: '$50,000.00', - change24h: '+$1,250.00', - change24hPercent: '+2.5%', - volume: '$1.2B', - volumeNumber: 1200000000, - openInterest: '$500M', - fundingRate: 0.0001, - marketType: 'crypto', - }); - - const mockMarkets: (PerpsMarketData & { volumeNumber: number })[] = [ - createMockMarket('BTC'), - createMockMarket('ETH'), - createMockMarket('SOL'), - createMockMarket('AVAX'), - createMockMarket('MATIC'), - ]; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders skeleton loaders when data is loading', () => { - mockUsePerpsMarkets.mockReturnValue({ - markets: [], - isLoading: true, - error: null, - refresh: jest.fn(), - isRefreshing: false, - }); - - const { getAllByTestId, queryByTestId } = renderWithProvider( - , - { - state: initialState, - }, - ); - - const skeletons = getAllByTestId('perps-skeleton'); - expect(skeletons).toHaveLength(3); - expect(queryByTestId('perps-tokens-list')).toBeNull(); - }); - - it('displays first 3 markets from hook data', () => { - mockUsePerpsMarkets.mockReturnValue({ - markets: mockMarkets, - isLoading: false, - error: null, - refresh: jest.fn(), - isRefreshing: false, - }); - - const { getByTestId } = renderWithProvider(, { - state: initialState, - }); - - const list = getByTestId('perps-tokens-list'); - - expect(list.props.data).toHaveLength(3); - expect(list.props.data[0].symbol).toBe('BTC'); - expect(list.props.data[1].symbol).toBe('ETH'); - expect(list.props.data[2].symbol).toBe('SOL'); - }); - - it('navigates to market list when view all button is pressed', () => { - mockUsePerpsMarkets.mockReturnValue({ - markets: mockMarkets, - isLoading: false, - error: null, - refresh: jest.fn(), - isRefreshing: false, - }); - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - fireEvent.press(getByText('View all')); - - expect(mockNavigate).toHaveBeenCalledWith('Perps', { - screen: 'PerpsTrendingView', - params: { - defaultMarketTypeFilter: 'all', - }, - }); - }); - - it('navigates to market details when market item is pressed', () => { - mockUsePerpsMarkets.mockReturnValue({ - markets: mockMarkets, - isLoading: false, - error: null, - refresh: jest.fn(), - isRefreshing: false, - }); - - const { getByTestId } = renderWithProvider(, { - state: initialState, - }); - - const list = getByTestId('perps-tokens-list'); - const renderItem = list.props.renderItem; - const renderedItem = renderItem({ item: mockMarkets[0], index: 0 }); - - renderedItem.props.onPress(); - - expect(mockNavigate).toHaveBeenCalledWith('Perps', { - screen: 'PerpsMarketDetails', - params: { - market: mockMarkets[0], - }, - }); - }); -}); diff --git a/app/components/Views/TrendingView/PerpsSection/PerpsSection.tsx b/app/components/Views/TrendingView/PerpsSection/PerpsSection.tsx deleted file mode 100644 index ed1ba6d91135..000000000000 --- a/app/components/Views/TrendingView/PerpsSection/PerpsSection.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, { useCallback } from 'react'; -import { View } from 'react-native'; -import SectionHeader from '../components/SectionHeader/SectionHeader'; -import SectionCard from '../components/SectionCard/SectionCard'; -import PerpsMarketRowSkeleton from '../../../UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton'; -import { FlashList } from '@shopify/flash-list'; -import { usePerpsMarkets } from '../../../UI/Perps/hooks'; -import PerpsMarketRowItem from '../../../UI/Perps/components/PerpsMarketRowItem'; -import { PerpsMarketData } from '../../../UI/Perps/controllers/types'; -import { useNavigation } from '@react-navigation/native'; -import Routes from '../../../../constants/navigation/Routes'; - -const PerpsSection = () => { - const navigation = useNavigation(); - const { markets, isLoading } = usePerpsMarkets(); - const perpsTokens = markets.slice(0, 3); - - const handleTokenPress = useCallback( - (market: PerpsMarketData) => { - navigation.navigate(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKET_DETAILS, - params: { market }, - }); - }, - [navigation], - ); - - return ( - - - - {isLoading || perpsTokens.length === 0 ? ( - <> - - - - - ) : ( - ( - handleTokenPress(item)} - /> - )} - keyExtractor={(item) => item.symbol} - keyboardShouldPersistTaps="handled" - testID="perps-tokens-list" - /> - )} - - - ); -}; - -export default PerpsSection; diff --git a/app/components/Views/TrendingView/PredictionSection/PredictionSection.styles.ts b/app/components/Views/TrendingView/PredictionSection/PredictionSection.styles.ts deleted file mode 100644 index 3385572a1fd2..000000000000 --- a/app/components/Views/TrendingView/PredictionSection/PredictionSection.styles.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { StyleSheet } from 'react-native'; -import { Theme } from '../../../../util/theme/models'; - -interface PredictionSectionStylesVars { - activeIndex: number; - cardWidth: number; -} - -const styleSheet = (params: { - theme: Theme; - vars: PredictionSectionStylesVars; -}) => { - const { theme } = params; - const { colors } = theme; - - return StyleSheet.create({ - carouselItem: { - width: params.vars.cardWidth * 0.8, - borderRadius: 16, - paddingHorizontal: 8, - overflow: 'hidden', - borderColor: colors.border.default, - shadowColor: colors.shadow.default, - }, - carouselItemLast: { - width: params.vars.cardWidth, - borderRadius: 16, - paddingHorizontal: 8, - overflow: 'hidden', - borderColor: colors.border.default, - shadowColor: colors.shadow.default, - }, - carouselContentContainer: { - paddingRight: 16, - }, - paginationContainer: { - marginTop: 16, - gap: 8, - }, - dot: { - height: 8, - width: 8, - borderRadius: 4, - backgroundColor: colors.border.muted, - }, - dotActive: { - height: 8, - width: 24, - borderRadius: 4, - backgroundColor: colors.text.default, - }, - }); -}; - -export default styleSheet; diff --git a/app/components/Views/TrendingView/PredictionSection/PredictionSection.test.tsx b/app/components/Views/TrendingView/PredictionSection/PredictionSection.test.tsx deleted file mode 100644 index 8d9a26d5dd46..000000000000 --- a/app/components/Views/TrendingView/PredictionSection/PredictionSection.test.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import React from 'react'; -import { fireEvent } from '@testing-library/react-native'; -import renderWithProvider from '../../../../util/test/renderWithProvider'; -import { backgroundState } from '../../../../util/test/initial-root-state'; -import PredictionSection from './PredictionSection'; -import { usePredictMarketData } from '../../../UI/Predict/hooks/usePredictMarketData'; -import Routes from '../../../../constants/navigation/Routes'; -import { - PredictMarket as PredictMarketType, - Recurrence, -} from '../../../UI/Predict/types'; - -// Mock navigation -const mockNavigate = jest.fn(); -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - navigate: mockNavigate, - }), -})); - -// Mock dependencies -jest.mock('../../../UI/Predict/hooks/usePredictMarketData'); -jest.mock('../../../UI/Predict/components/PredictMarket', () => { - const { View, Text } = jest.requireActual('react-native'); - return jest.fn(({ market, testID }) => ( - - PredictMarket: {market.title} - - )); -}); -jest.mock('../../../UI/Predict/components/PredictMarketSkeleton', () => { - const { View, Text } = jest.requireActual('react-native'); - return jest.fn(({ testID }) => ( - - Loading... - - )); -}); -jest.mock('@shopify/flash-list', () => { - const { FlatList } = jest.requireActual('react-native'); - return { - FlashList: FlatList, - }; -}); - -const mockUsePredictMarketData = usePredictMarketData as jest.MockedFunction< - typeof usePredictMarketData ->; - -const initialState = { - engine: { - backgroundState, - }, -}; - -describe('PredictionSection', () => { - const createMockMarket = (id: string): PredictMarketType => ({ - id, - providerId: 'test-provider', - slug: `market-${id}`, - title: `Market ${id}`, - description: `Description for market ${id}`, - image: `https://example.com/image-${id}.png`, - status: 'open', - recurrence: Recurrence.NONE, - category: 'crypto', - tags: [], - outcomes: [], - liquidity: 10000, - volume: 50000, - }); - - const mockMarketData: PredictMarketType[] = [ - createMockMarket('1'), - createMockMarket('2'), - createMockMarket('3'), - createMockMarket('4'), - createMockMarket('5'), - createMockMarket('6'), - ]; - - beforeEach(() => { - jest.clearAllMocks(); - mockNavigate.mockClear(); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('loading state', () => { - it('renders skeleton loaders when fetching data', () => { - mockUsePredictMarketData.mockReturnValue({ - marketData: [], - isFetching: true, - isFetchingMore: false, - error: null, - hasMore: false, - refetch: jest.fn(), - fetchMore: jest.fn(), - }); - - const { getByText, getAllByTestId } = renderWithProvider( - , - { state: initialState }, - ); - - expect(getByText('Predictions')).toBeOnTheScreen(); - expect(getByText('View all')).toBeOnTheScreen(); - expect( - getAllByTestId('prediction-carousel-skeleton').length, - ).toBeGreaterThan(0); - }); - - it('renders header with view all button during loading', () => { - mockUsePredictMarketData.mockReturnValue({ - marketData: [], - isFetching: true, - isFetchingMore: false, - error: null, - hasMore: false, - refetch: jest.fn(), - fetchMore: jest.fn(), - }); - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - expect(getByText('Predictions')).toBeOnTheScreen(); - expect(getByText('View all')).toBeOnTheScreen(); - }); - - it('navigates to market list when view all is pressed during loading', () => { - mockUsePredictMarketData.mockReturnValue({ - marketData: [], - isFetching: true, - isFetchingMore: false, - error: null, - hasMore: false, - refetch: jest.fn(), - fetchMore: jest.fn(), - }); - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - fireEvent.press(getByText('View all')); - - expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { - screen: Routes.PREDICT.MARKET_LIST, - }); - }); - }); - - describe('empty state', () => { - it('renders nothing when not fetching and data is empty', () => { - mockUsePredictMarketData.mockReturnValue({ - marketData: [], - isFetching: false, - isFetchingMore: false, - error: null, - hasMore: false, - refetch: jest.fn(), - fetchMore: jest.fn(), - }); - - const { toJSON } = renderWithProvider(, { - state: initialState, - }); - - expect(toJSON()).toBeNull(); - }); - }); - - describe('carousel with data', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.resetAllMocks(); - mockUsePredictMarketData.mockReturnValue({ - marketData: mockMarketData, - isFetching: false, - isFetchingMore: false, - error: null, - hasMore: false, - refetch: jest.fn(), - fetchMore: jest.fn(), - }); - }); - - it('renders section header with title and view all button', () => { - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - expect(getByText('Predictions')).toBeOnTheScreen(); - expect(getByText('View all')).toBeOnTheScreen(); - }); - }); - - describe('view all button', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.resetAllMocks(); - mockNavigate.mockClear(); - mockUsePredictMarketData.mockReturnValue({ - marketData: mockMarketData, - isFetching: false, - isFetchingMore: false, - error: null, - hasMore: false, - refetch: jest.fn(), - fetchMore: jest.fn(), - }); - }); - - it('navigates to market list when view all button is pressed', () => { - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - fireEvent.press(getByText('View all')); - - expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { - screen: Routes.PREDICT.MARKET_LIST, - }); - }); - }); - - describe('data fetching', () => { - it('calls usePredictMarketData with correct parameters', () => { - mockUsePredictMarketData.mockReturnValue({ - marketData: mockMarketData, - isFetching: false, - isFetchingMore: false, - error: null, - hasMore: false, - refetch: jest.fn(), - fetchMore: jest.fn(), - }); - - renderWithProvider(, { - state: initialState, - }); - - expect(mockUsePredictMarketData).toHaveBeenCalledWith({ - category: 'trending', - pageSize: 6, - }); - }); - }); -}); diff --git a/app/components/Views/TrendingView/PredictionSection/PredictionSection.tsx b/app/components/Views/TrendingView/PredictionSection/PredictionSection.tsx deleted file mode 100644 index b034fefdcf03..000000000000 --- a/app/components/Views/TrendingView/PredictionSection/PredictionSection.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { - Box, - BoxFlexDirection, - BoxAlignItems, - BoxJustifyContent, -} from '@metamask/design-system-react-native'; -import React, { useCallback, useRef, useState } from 'react'; -import { - Dimensions, - NativeScrollEvent, - NativeSyntheticEvent, - Pressable, -} from 'react-native'; -import { FlashList, FlashListRef } from '@shopify/flash-list'; -import { usePredictMarketData } from '../../../UI/Predict/hooks/usePredictMarketData'; -import PredictMarket from '../../../UI/Predict/components/PredictMarket'; -import { PredictMarket as PredictMarketType } from '../../../UI/Predict/types'; -import { PredictEventValues } from '../../../UI/Predict/constants/eventNames'; -import PredictMarketSkeleton from '../../../UI/Predict/components/PredictMarketSkeleton'; -import { useStyles } from '../../../../component-library/hooks'; -import styleSheet from './PredictionSection.styles'; -import SectionHeader from '../components/SectionHeader/SectionHeader'; - -const { width: SCREEN_WIDTH } = Dimensions.get('window'); -const CARD_WIDTH = SCREEN_WIDTH - 32; // 16px padding on each side -const CARD_SPACING = 16; -const ACTUAL_CARD_WIDTH = CARD_WIDTH * 0.8; // Actual rendered card width (80% to show peek of next card) -const SNAP_INTERVAL = ACTUAL_CARD_WIDTH + CARD_SPACING; - -const PredictionSection = () => { - const [activeIndex, setActiveIndex] = useState(0); - const flashListRef = useRef>(null); - - const { styles } = useStyles(styleSheet, { - activeIndex, - cardWidth: CARD_WIDTH, - }); - - // Fetch prediction market data with limit of 6 - const { marketData, isFetching } = usePredictMarketData({ - category: 'trending', - pageSize: 6, - }); - - const marketDataLength = marketData?.length ?? 0; - - const handleScroll = useCallback( - (event: NativeSyntheticEvent) => { - const scrollPosition = event.nativeEvent.contentOffset.x; - const index = Math.round(scrollPosition / SNAP_INTERVAL); - setActiveIndex(index); - }, - [], - ); - - const scrollToIndex = useCallback((index: number) => { - flashListRef.current?.scrollToIndex({ - index, - animated: true, - }); - setActiveIndex(index); - }, []); - - const renderCarouselItem = useCallback( - ({ item, index }: { item: PredictMarketType; index: number }) => { - const isLast = index === marketDataLength - 1; - - return ( - - - - ); - }, - [styles, marketDataLength], - ); - - const renderPaginationDots = useCallback( - () => ( - - {Array.from({ length: marketDataLength }).map((_, index) => { - const isActive = activeIndex === index; - return ( - scrollToIndex(index)} - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - > - - - ); - })} - - ), - [marketDataLength, activeIndex, scrollToIndex, styles], - ); - - // Show loading state while fetching - if (isFetching) { - return ( - - - - { - const isLast = index === 2; // 3 items (0, 1, 2) - - return ( - - - - ); - }} - keyExtractor={(item) => `skeleton-${item}`} - contentContainerStyle={styles.carouselContentContainer} - /> - - - - {[0, 1, 2].map((index) => ( - - ))} - - - - ); - } - - // Show empty state when no data - if (marketDataLength === 0) { - return null; // Don't show the section if there are no predictions - } - - return ( - - - - - item.id} - horizontal - pagingEnabled={false} - showsHorizontalScrollIndicator={false} - snapToInterval={SNAP_INTERVAL} - decelerationRate="fast" - onScroll={handleScroll} - scrollEventThrottle={16} - contentContainerStyle={styles.carouselContentContainer} - /> - - - {renderPaginationDots()} - - ); -}; - -export default PredictionSection; diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx index e5b068690082..3dcdd345e698 100644 --- a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx +++ b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx @@ -325,7 +325,7 @@ describe('TrendingTokenRowItem', () => { rpcPrefs: { imageSource: 'https://popular-network.png', }, - } as never); + }); mockGetDefaultNetworkByChainId.mockReturnValue(undefined); const token = createMockToken(); @@ -352,7 +352,7 @@ describe('TrendingTokenRowItem', () => { rpcPrefs: { imageSource: 'https://unpopular-network.png', }, - } as never); + }); mockGetDefaultNetworkByChainId.mockReturnValue(undefined); const token = createMockToken(); diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensSection.tsx b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensSection.tsx deleted file mode 100644 index e5a679112226..000000000000 --- a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensSection.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React, { useCallback } from 'react'; -import { View } from 'react-native'; -import { TrendingAsset } from '@metamask/assets-controllers'; -import TrendingTokensSkeleton from './TrendingTokenSkeleton/TrendingTokensSkeleton'; -import TrendingTokensList from './TrendingTokensList'; -import { useTrendingRequest } from '../../../UI/Assets/hooks/useTrendingRequest'; -import SectionHeader from '../components/SectionHeader/SectionHeader'; -import SectionCard from '../components/SectionCard/SectionCard'; - -const TrendingTokensSection = () => { - const { results: trendingTokensResults, isLoading } = useTrendingRequest({}); - const trendingTokens = trendingTokensResults.slice(0, 3); - - const handleTokenPress = useCallback((token: TrendingAsset) => { - // eslint-disable-next-line no-console - console.log('🚀 ~ TrendingTokensSection ~ token:', token); - // TODO: Implement token press logic - }, []); - - return ( - - - - {isLoading || trendingTokens.length === 0 ? ( - - ) : ( - - )} - - - ); -}; - -export default TrendingTokensSection; diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx index ee7e25446564..93d69fecae7b 100644 --- a/app/components/Views/TrendingView/TrendingView.tsx +++ b/app/components/Views/TrendingView/TrendingView.tsx @@ -23,15 +23,18 @@ import { lastTrendingScreenRef, updateLastTrendingScreen, } from '../../Nav/Main/MainNavigator'; -import TrendingTokensSection from './TrendingTokensSection/TrendingTokensSection'; -import { PerpsStreamProvider } from '../../UI/Perps/providers/PerpsStreamManager'; import ExploreSearchScreen from './ExploreSearchScreen/ExploreSearchScreen'; import ExploreSearchBar from './ExploreSearchBar/ExploreSearchBar'; -import { PredictModalStack } from '../../UI/Predict/routes'; -import PredictionSection from './PredictionSection/PredictionSection'; -import PerpsSection from './PerpsSection/PerpsSection'; -import { PerpsConnectionProvider } from '../../UI/Perps/providers/PerpsConnectionProvider'; +import { + PredictScreenStack, + PredictModalStack, + PredictMarketDetails, + PredictSellPreview, +} from '../../UI/Predict'; +import PredictBuyPreview from '../../UI/Predict/views/PredictBuyPreview/PredictBuyPreview'; import QuickActions from './components/QuickActions/QuickActions'; +import SectionHeader from './components/SectionHeader/SectionHeader'; +import { HOME_SECTIONS_ARRAY } from './config/sections.config'; const Stack = createStackNavigator(); @@ -129,13 +132,13 @@ const TrendingFeed: React.FC = () => { showsVerticalScrollIndicator={false} > - - - - - - - + + {HOME_SECTIONS_ARRAY.map((section) => ( + + + {section.renderSection()} + + ))} ); @@ -157,6 +160,17 @@ const TrendingView: React.FC = () => { name={Routes.EXPLORE_SEARCH} component={ExploreSearchScreen} /> + { animationEnabled: false, }} /> + + + ); }; diff --git a/app/components/Views/TrendingView/components/QuickActions/QuickActions.tsx b/app/components/Views/TrendingView/components/QuickActions/QuickActions.tsx index c44b4403c1cf..64b1d4cd7484 100644 --- a/app/components/Views/TrendingView/components/QuickActions/QuickActions.tsx +++ b/app/components/Views/TrendingView/components/QuickActions/QuickActions.tsx @@ -21,7 +21,7 @@ const QuickActions: React.FC = () => { section.navigationAction(navigation)} + onPress={() => section.viewAllAction(navigation)} testID={`quick-action-${section.id}`} textProps={{ variant: TextVariant.BodySm }} > diff --git a/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx b/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx index 64a67852fb7b..c63f5f62a0f5 100644 --- a/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx +++ b/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx @@ -1,8 +1,11 @@ -import React, { PropsWithChildren, useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { StyleSheet } from 'react-native'; import { Theme } from '../../../../../util/theme/models'; import { useAppThemeFromContext } from '../../../../../util/theme'; import Card from '../../../../../component-library/components/Cards/Card'; +import { SectionId, SECTIONS_CONFIG } from '../../config/sections.config'; +import { FlashList, ListRenderItem } from '@shopify/flash-list'; +import { useNavigation } from '@react-navigation/native'; const createStyles = (theme: Theme) => StyleSheet.create({ @@ -12,17 +15,46 @@ const createStyles = (theme: Theme) => paddingVertical: 16, paddingHorizontal: 16, backgroundColor: theme.colors.background.muted, - borderColor: theme.colors.border.muted, + borderWidth: 0, }, }); +interface SectionCardProps { + sectionId: SectionId; +} -const SectionCard: React.FC = ({ children }) => { +const SectionCard: React.FC = ({ sectionId }) => { + const navigation = useNavigation(); const theme = useAppThemeFromContext(); const styles = useMemo(() => createStyles(theme), [theme]); + const { data, isLoading } = SECTIONS_CONFIG[sectionId].useSectionData(); + + const renderFlatItem: ListRenderItem = useCallback( + ({ item }) => { + const section = SECTIONS_CONFIG[sectionId]; + return section.renderRowItem(item, navigation); + }, + [navigation, sectionId], + ); + return ( - {children} + {isLoading && ( + <> + {SECTIONS_CONFIG[sectionId].renderSkeleton()} + {SECTIONS_CONFIG[sectionId].renderSkeleton()} + {SECTIONS_CONFIG[sectionId].renderSkeleton()} + + )} + {!isLoading && ( + SECTIONS_CONFIG[sectionId].keyExtractor(item)} + keyboardShouldPersistTaps="handled" + testID="perps-tokens-list" + /> + )} ); }; diff --git a/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.test.tsx b/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.test.tsx new file mode 100644 index 000000000000..0aafba1ccedb --- /dev/null +++ b/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.test.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../../../util/test/initial-root-state'; +import SectionCarrousel from './SectionCarrousel'; +import type { PredictMarket } from '../../../../UI/Predict/types'; + +// Mock navigation +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + })), + }; +}); + +// Mock Predict components +jest.mock( + '../../../../UI/Predict/components/PredictMarket', + () => 'PredictMarket', +); +jest.mock( + '../../../../UI/Predict/components/PredictMarketSkeleton', + () => 'PredictMarketSkeleton', +); + +// Mock Predict data hook +const mockUsePredictMarketData = jest.fn(); +jest.mock('../../../../UI/Predict/hooks/usePredictMarketData', () => ({ + usePredictMarketData: () => mockUsePredictMarketData(), +})); + +const initialState = { + engine: { + backgroundState, + }, +}; + +const createMockPredictMarket = (id: string, title: string): PredictMarket => + ({ + id, + title, + outcomes: [], + status: 'active', + }) as unknown as PredictMarket; + +describe('SectionCarrousel', () => { + const mockData: PredictMarket[] = [ + createMockPredictMarket('1', 'Market 1'), + createMockPredictMarket('2', 'Market 2'), + createMockPredictMarket('3', 'Market 3'), + ]; + + beforeEach(() => { + jest.clearAllMocks(); + mockUsePredictMarketData.mockReturnValue({ + marketData: mockData, + isFetching: false, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('renders carousel with data items and pagination dots', () => { + const { getByTestId } = renderWithProvider( + , + { state: initialState }, + ); + + expect(getByTestId('predictions-flash-list')).toBeOnTheScreen(); + expect(getByTestId('predictions-pagination-dot-0')).toBeOnTheScreen(); + expect(getByTestId('predictions-pagination-dot-1')).toBeOnTheScreen(); + expect(getByTestId('predictions-pagination-dot-2')).toBeOnTheScreen(); + }); + + it('renders skeleton items with pagination when loading', () => { + mockUsePredictMarketData.mockReturnValue({ + marketData: [], + isFetching: true, + }); + + const { getByTestId } = renderWithProvider( + , + { state: initialState }, + ); + + expect(getByTestId('predictions-flash-list')).toBeOnTheScreen(); + expect(getByTestId('predictions-pagination-dot-0')).toBeOnTheScreen(); + expect(getByTestId('predictions-pagination-dot-1')).toBeOnTheScreen(); + expect(getByTestId('predictions-pagination-dot-2')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx b/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx new file mode 100644 index 000000000000..56c5a5d07cc4 --- /dev/null +++ b/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx @@ -0,0 +1,137 @@ +import { + Box, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import React, { useCallback, useRef, useState } from 'react'; +import { + Dimensions, + NativeScrollEvent, + NativeSyntheticEvent, + Pressable, +} from 'react-native'; +import { FlashList, FlashListRef } from '@shopify/flash-list'; +import { SectionId, SECTIONS_CONFIG } from '../../config/sections.config'; +import { useNavigation } from '@react-navigation/native'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const CARD_WIDTH = SCREEN_WIDTH - 32; // 16px padding on each side +const CARD_SPACING = 16; +const ACTUAL_CARD_WIDTH = CARD_WIDTH * 0.8; // Actual rendered card width (80% to show peek of next card) +const SNAP_INTERVAL = ACTUAL_CARD_WIDTH + CARD_SPACING; + +export interface SectionCarrouselProps { + sectionId: SectionId; +} + +const SectionCarrousel: React.FC = ({ sectionId }) => { + const tw = useTailwind(); + const navigation = useNavigation(); + const [activeIndex, setActiveIndex] = useState(0); + const flashListRef = useRef>(null); + + const section = SECTIONS_CONFIG[sectionId]; + const { data, isLoading } = section.useSectionData(); + + const skeletonCount = 3; + const skeletonData = Array.from({ length: skeletonCount }); + + const displayData = isLoading ? skeletonData : data; + const displayDataLength = displayData.length; + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const scrollPosition = event.nativeEvent.contentOffset.x; + const index = Math.round(scrollPosition / SNAP_INTERVAL); + setActiveIndex(index); + }, + [], + ); + + const scrollToIndex = useCallback((index: number) => { + flashListRef.current?.scrollToIndex({ + index, + animated: true, + }); + setActiveIndex(index); + }, []); + + const renderItem = useCallback( + ({ item, index }: { item: unknown; index: number }) => { + const isLast = index === displayDataLength - 1; + const cardWidthStyle = { width: isLast ? CARD_WIDTH : CARD_WIDTH * 0.8 }; + + return ( + + {isLoading + ? section.renderSkeleton() + : section.renderRowItem(item, navigation)} + + ); + }, + [displayDataLength, isLoading, section, navigation], + ); + + const renderPaginationDots = useCallback( + () => ( + + {Array.from({ length: displayDataLength }).map((_, index) => { + const isActive = activeIndex === index; + return ( + scrollToIndex(index)} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + testID={`${sectionId}-pagination-dot-${index}`} + > + + + ); + })} + + ), + [displayDataLength, activeIndex, scrollToIndex, sectionId], + ); + + return ( + + + isLoading ? `skeleton-${index}` : section.keyExtractor(item) + } + horizontal + pagingEnabled={false} + showsHorizontalScrollIndicator={false} + snapToInterval={SNAP_INTERVAL} + decelerationRate="fast" + onScroll={handleScroll} + scrollEventThrottle={16} + contentContainerStyle={tw.style('pr-4')} + testID={`${sectionId}-flash-list`} + /> + + {renderPaginationDots()} + + ); +}; + +export default SectionCarrousel; diff --git a/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx b/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx index 823dc8da2069..9431c91ea7b0 100644 --- a/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx +++ b/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx @@ -46,9 +46,7 @@ const SectionHeader: React.FC = ({ sectionId }) => { {sectionConfig.title} - sectionConfig.navigationAction(navigation)} - > + sectionConfig.viewAllAction(navigation)}> {strings('trending.view_all')} diff --git a/app/components/Views/TrendingView/config/sections.config.tsx b/app/components/Views/TrendingView/config/sections.config.tsx index fd92ebc2689d..166d33c19c48 100644 --- a/app/components/Views/TrendingView/config/sections.config.tsx +++ b/app/components/Views/TrendingView/config/sections.config.tsx @@ -12,111 +12,195 @@ import PredictMarket from '../../../UI/Predict/components/PredictMarket'; import type { PredictMarket as PredictMarketType } from '../../../UI/Predict/types'; import type { PerpsNavigationParamList } from '../../../UI/Perps/types/navigation'; import PredictMarketSkeleton from '../../../UI/Predict/components/PredictMarketSkeleton'; +import SectionCard from '../components/SectionCard/SectionCard'; +import SectionCarrousel from '../components/SectionCarrousel/SectionCarrousel'; +import { useTrendingRequest } from '../../../UI/Assets/hooks/useTrendingRequest'; +import { usePredictMarketData } from '../../../UI/Predict/hooks/usePredictMarketData'; +import { usePerpsMarkets } from '../../../UI/Perps/hooks'; +import { PerpsConnectionProvider } from '../../../UI/Perps/providers/PerpsConnectionProvider'; +import { PerpsStreamProvider } from '../../../UI/Perps/providers/PerpsStreamManager'; export type SectionId = 'predictions' | 'tokens' | 'perps'; -export interface SectionData { +interface SectionData { data: unknown[]; isLoading: boolean; } -/** - * Configuration for each section in the Trending View. - * This includes navigation, display, and search functionality. - */ -export interface SectionConfig { +interface SectionConfig { + id: SectionId; title: string; - navigationAction: (navigation: NavigationProp) => void; - renderItem: (item: unknown, onPress?: (item: unknown) => void) => JSX.Element; + viewAllAction: (navigation: NavigationProp) => void; + renderRowItem: ( + item: unknown, + navigation: NavigationProp, + ) => JSX.Element; renderSkeleton: () => JSX.Element; getSearchableText: (item: unknown) => string; keyExtractor: (item: unknown) => string; - getOnPressHandler?: ( - navigation: NavigationProp, - ) => (item: unknown) => void; + renderSection: () => JSX.Element; + useSectionData: (searchQuery?: string) => { + data: unknown[]; + isLoading: boolean; + }; } -const tokensConfig: SectionConfig = { - title: strings('trending.tokens'), - navigationAction: (_navigation) => { - // TODO: Implement tokens navigation when ready - // _navigation.navigate(...); - }, - renderItem: (item) => ( - undefined} - /> - ), - renderSkeleton: () => , - getSearchableText: (item) => - `${(item as TrendingAsset).symbol} ${(item as TrendingAsset).name}`.toLowerCase(), - keyExtractor: (item) => `token-${(item as TrendingAsset).assetId}`, -}; +/** + * Centralized configuration for all Trending View sections. + * This config is used by QuickActions, SectionHeaders, Search, and TrendingView rendering. + * + * To add a new section (EVERYTHING IN THIS FILE): + * 1. Add the section ID to the SectionId type above + * 2. Add the config to SECTIONS_CONFIG, HOME_SECTIONS_ARRAY, and SECTIONS_ARRAY below + * 3. Add the hook to useSectionsData below + * + * The section will automatically appear in: + * - TrendingView main feed + * - QuickActions buttons + * - Search results + * - Section headers with "View All" navigation + */ + +export const SECTIONS_CONFIG: Record = { + tokens: { + id: 'tokens', + title: strings('trending.tokens'), + viewAllAction: (_navigation) => { + // TODO: Implement tokens navigation when ready + // _navigation.navigate(...); + }, + renderRowItem: (item) => ( + undefined} + /> + ), + renderSkeleton: () => , + getSearchableText: (item) => + `${(item as TrendingAsset).symbol} ${(item as TrendingAsset).name}`.toLowerCase(), + keyExtractor: (item) => `token-${(item as TrendingAsset).assetId}`, + renderSection: () => , + useSectionData: () => { + const { results, isLoading } = useTrendingRequest({}); -const perpsConfig: SectionConfig = { - title: strings('trending.perps'), - navigationAction: (navigation) => { - navigation.navigate(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKET_LIST, - params: { - defaultMarketTypeFilter: 'all', - }, - }); + return { data: results, isLoading }; + }, }, - renderItem: (item, onPress) => ( - onPress?.(item)} - showBadge={false} - /> - ), - renderSkeleton: () => , - getSearchableText: (item) => - `${(item as PerpsMarketData).symbol} ${(item as PerpsMarketData).name || ''}`.toLowerCase(), - keyExtractor: (item) => `perp-${(item as PerpsMarketData).symbol}`, - getOnPressHandler: (navigation) => (market) => { - (navigation as NavigationProp).navigate( - Routes.PERPS.ROOT, - { - screen: Routes.PERPS.MARKET_DETAILS, - params: { market: market as PerpsMarketData }, - }, - ); + perps: { + id: 'perps', + title: strings('trending.perps'), + viewAllAction: (navigation) => { + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_LIST, + params: { + defaultMarketTypeFilter: 'all', + }, + }); + }, + renderRowItem: (item, navigation) => ( + { + (navigation as NavigationProp)?.navigate( + Routes.PERPS.ROOT, + { + screen: Routes.PERPS.MARKET_DETAILS, + params: { market: item as PerpsMarketData }, + }, + ); + }} + showBadge={false} + /> + ), + renderSkeleton: () => , + getSearchableText: (item) => + `${(item as PerpsMarketData).symbol} ${(item as PerpsMarketData).name || ''}`.toLowerCase(), + keyExtractor: (item) => `perp-${(item as PerpsMarketData).symbol}`, + renderSection: () => ( + + + + + + ), + useSectionData: () => { + const { markets, isLoading } = usePerpsMarkets(); + + return { data: markets, isLoading }; + }, }, -}; + predictions: { + id: 'predictions', + title: strings('wallet.predict'), + viewAllAction: (navigation) => { + navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + }); + }, + renderRowItem: (item) => ( + + ), + renderSkeleton: () => , + getSearchableText: (item) => + (item as PredictMarketType).title.toLowerCase(), + keyExtractor: (item) => `prediction-${(item as PredictMarketType).id}`, + renderSection: () => , + useSectionData: (searchQuery?: string) => { + const { marketData, isFetching } = usePredictMarketData({ + category: 'trending', + pageSize: searchQuery ? 20 : 6, + q: searchQuery || undefined, + }); -const predictionsConfig: SectionConfig = { - title: strings('wallet.predict'), - navigationAction: (navigation) => { - navigation.navigate(Routes.PREDICT.ROOT, { - screen: Routes.PREDICT.MARKET_LIST, - }); + return { data: marketData, isLoading: isFetching }; + }, }, - renderItem: (item) => , - renderSkeleton: () => , - getSearchableText: (item) => (item as PredictMarketType).title.toLowerCase(), - keyExtractor: (item) => `prediction-${(item as PredictMarketType).id}`, }; +// Sorted by order on the main screen +export const HOME_SECTIONS_ARRAY: (SectionConfig & { id: SectionId })[] = [ + SECTIONS_CONFIG.predictions, + SECTIONS_CONFIG.tokens, + SECTIONS_CONFIG.perps, +]; + +// Sorted by order on the QuickAction buttons and SearchResults +export const SECTIONS_ARRAY: (SectionConfig & { id: SectionId })[] = [ + SECTIONS_CONFIG.tokens, + SECTIONS_CONFIG.perps, + SECTIONS_CONFIG.predictions, +]; + /** - * Centralized configuration for all Trending View sections. - * This config is used by QuickActions, SectionHeaders, and Search functionality. + * Centralized hook that fetches data for all sections. + * When adding a new section, add its hook call here. + * This keeps all section-related logic in one file. * - * To add a new section: - * 1. Add the section ID to the SectionId type - * 2. Create a config constant above (e.g., newSectionConfig) - * 3. Add it to both SECTIONS_CONFIG and SECTIONS_ARRAY below - * 4. Add data fetching in useExploreSearchData hook + * @param searchQuery - Optional search query for sections that support search + * @returns Data and loading state for all sections */ -export const SECTIONS_CONFIG: Record = { - tokens: tokensConfig, - perps: perpsConfig, - predictions: predictionsConfig, -}; +export const useSectionsData = ( + searchQuery?: string, +): Record => { + const { data: trendingTokens, isLoading: isTokensLoading } = + SECTIONS_CONFIG.tokens.useSectionData(); + const { data: perpsMarkets, isLoading: isPerpsLoading } = + SECTIONS_CONFIG.perps.useSectionData(); + const { data: predictionMarkets, isLoading: isPredictionsLoading } = + SECTIONS_CONFIG.predictions.useSectionData(searchQuery); -export const SECTIONS_ARRAY: (SectionConfig & { id: SectionId })[] = [ - { id: 'tokens', ...tokensConfig }, - { id: 'perps', ...perpsConfig }, - { id: 'predictions', ...predictionsConfig }, -]; + return { + tokens: { + data: trendingTokens, + isLoading: isTokensLoading, + }, + perps: { + data: perpsMarkets, + isLoading: isPerpsLoading, + }, + predictions: { + data: predictionMarkets, + isLoading: isPredictionsLoading, + }, + }; +}; diff --git a/app/components/Views/Wallet/__snapshots__/index.test.tsx.snap b/app/components/Views/Wallet/__snapshots__/index.test.tsx.snap index 43df814c857d..610c7cafec79 100644 --- a/app/components/Views/Wallet/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/Wallet/__snapshots__/index.test.tsx.snap @@ -448,6 +448,93 @@ exports[`Wallet Conditional Rendering should render banner when basic functional } /> + + + + + @@ -1537,6 +1624,93 @@ exports[`Wallet Conditional Rendering should render loader when no selected acco } /> + + + + + @@ -2626,6 +2800,93 @@ exports[`Wallet should render correctly 1`] = ` } /> + + + + + @@ -3715,6 +3976,93 @@ exports[`Wallet should render correctly when Solana support is enabled 1`] = ` } /> + + + + + @@ -4804,6 +5152,93 @@ exports[`Wallet should render correctly when there are no detected tokens 1`] = } /> + + + + + diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index e4e3fb1d7f8c..f05216dd4cd2 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -183,7 +183,6 @@ import PredictTabView from '../../UI/Predict/views/PredictTabView'; import { InitSendLocation } from '../confirmations/constants/send'; import { useSendNavigation } from '../confirmations/hooks/useSendNavigation'; import { selectCarouselBannersFlag } from '../../UI/Carousel/selectors/featureFlags'; -import { selectRewardsEnabledFlag } from '../../../selectors/featureFlagController/rewards'; import { SolScope } from '@metamask/keyring-api'; import { selectSelectedInternalAccountByScope } from '../../../selectors/multichainAccounts/accounts'; import { EVM_SCOPE } from '../../UI/Earn/constants/networks'; @@ -1091,7 +1090,6 @@ const Wallet = ({ ); const shouldDisplayCardButton = useSelector(selectDisplayCardButton); - const isRewardsEnabled = useSelector(selectRewardsEnabledFlag); const isHomepageRedesignV1Enabled = useSelector( selectHomepageRedesignV1Enabled, ); @@ -1113,7 +1111,6 @@ const Wallet = ({ unreadNotificationCount, readNotificationCount, shouldDisplayCardButton, - isRewardsEnabled, ), ); }, [ @@ -1129,7 +1126,6 @@ const Wallet = ({ unreadNotificationCount, readNotificationCount, shouldDisplayCardButton, - isRewardsEnabled, ]); const getTokenAddedAnalyticsParams = useCallback( diff --git a/app/components/Views/confirmations/components/confirm/confirm-component.tsx b/app/components/Views/confirmations/components/confirm/confirm-component.tsx index 9dba197333bd..2b8e9a855086 100755 --- a/app/components/Views/confirmations/components/confirm/confirm-component.tsx +++ b/app/components/Views/confirmations/components/confirm/confirm-component.tsx @@ -31,6 +31,14 @@ import { useTransactionMetadataRequest } from '../../hooks/transactions/useTrans import { hasTransactionType } from '../../utils/transaction'; import { PredictClaimInfoSkeleton } from '../info/predict-claim-info'; +const TRANSACTION_TYPES_DISABLE_SCROLL = [TransactionType.predictClaim]; + +const TRANSACTION_TYPES_DISABLE_ALERT_BANNER = [ + TransactionType.perpsDeposit, + TransactionType.predictDeposit, + TransactionType.predictWithdraw, +]; + export enum ConfirmationLoader { Default = 'default', CustomAmount = 'customAmount', @@ -67,7 +75,9 @@ const ConfirmWrapped = ({ > <> - + @@ -210,5 +220,5 @@ function InfoLoader({ function useDisableScroll() { const transaction = useTransactionMetadataRequest(); - return hasTransactionType(transaction, [TransactionType.predictClaim]); + return hasTransactionType(transaction, TRANSACTION_TYPES_DISABLE_SCROLL); } diff --git a/app/components/Views/confirmations/components/deposit-keyboard/deposit-keyboard.tsx b/app/components/Views/confirmations/components/deposit-keyboard/deposit-keyboard.tsx index 52171c611aae..de90632f0445 100644 --- a/app/components/Views/confirmations/components/deposit-keyboard/deposit-keyboard.tsx +++ b/app/components/Views/confirmations/components/deposit-keyboard/deposit-keyboard.tsx @@ -103,7 +103,7 @@ export const DepositKeyboard = memo( {!alertMessage && hasInput && (