From 5cbee3ad991e762691f88af033d16afbe39e5196 Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Mon, 17 Nov 2025 12:37:32 +0100 Subject: [PATCH 01/12] fix: cp-7.59.0 Fix layout of small devices in asset amount (#22703) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** After recent changes on keypad component keypad component was overflowing and not letting users to click "0" button. This PR aims to fix layout in send flow amount page. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/22702 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/3396b9d5-1dab-45d5-aa77-489c4cef4e65 ### **After** Small devices: after 6 after2 after 3 Larger devices: after 5 after1 after 4 ## **Pre-merge author checklist** - [X] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [X] I've completed the PR template to the best of my ability - [X] I’ve included tests if applicable - [X] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [X] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Refactors the send amount screen layout to center content, move balance display and keyboard, and remove NFT-specific spacing in styles. > > - **Send Amount UI** (`amount.tsx`): > - Reorganize layout: wrap header content in `topSection` and move `AmountKeyboard` outside it. > - Display `balanceDisplayValue` using new `styles.balanceText` within the top section. > - Stop passing `isNFT` to styles. > - **Styles** (`amount.styles.ts`): > - Replace `balanceSection` with `balanceText`; remove NFT-dependent margins and input offsets. > - Make `topSection` flexible (`flex: 1`) and center content; remove vertical padding. > - Keep input sizing via `getFontSizeForInputLength` but simplify layout by removing unused spacing. > - Remove `marginTop` from `nftImageWrapper`; drop conditional `inputSection` margin. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c2ea8821b4af6640c2ce8c4faff1cacba550fcd5. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../components/send/amount/amount.styles.ts | 13 ++++++------- .../components/send/amount/amount.tsx | 19 ++++++++----------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/app/components/Views/confirmations/components/send/amount/amount.styles.ts b/app/components/Views/confirmations/components/send/amount/amount.styles.ts index a552f14e6fa..c2f36fc2956 100644 --- a/app/components/Views/confirmations/components/send/amount/amount.styles.ts +++ b/app/components/Views/confirmations/components/send/amount/amount.styles.ts @@ -30,17 +30,16 @@ export const styleSheet = (params: { theme: Theme; vars: { contentLength: number; - isNFT: boolean; }; }) => { const { theme, - vars: { contentLength, isNFT }, + vars: { contentLength }, } = params; return StyleSheet.create({ - balanceSection: { + balanceText: { alignSelf: 'center', - marginBottom: isNFT ? 40 : 60, + marginTop: 16, }, container: { backgroundColor: theme.colors.background.default, @@ -60,7 +59,6 @@ export const styleSheet = (params: { inputSection: { flexDirection: FlexDirection.Row, justifyContent: JustifyContent.center, - marginTop: isNFT ? 0 : 80, width: '100%', }, inputText: { @@ -76,7 +74,6 @@ export const styleSheet = (params: { nftImage: { alignSelf: 'center', height: 100, width: 100 }, nftImageWrapper: { alignItems: AlignItems.center, - marginTop: 32, width: '100%', }, tokenSymbolWrapper: { @@ -84,8 +81,10 @@ export const styleSheet = (params: { width: '50%', }, topSection: { + flex: 1, + alignContent: 'center', + justifyContent: 'center', paddingHorizontal: 8, - paddingVertical: 32, }, }); }; diff --git a/app/components/Views/confirmations/components/send/amount/amount.tsx b/app/components/Views/confirmations/components/send/amount/amount.tsx index aa704e10d27..5c4bd37bb2d 100644 --- a/app/components/Views/confirmations/components/send/amount/amount.tsx +++ b/app/components/Views/confirmations/components/send/amount/amount.tsx @@ -52,7 +52,6 @@ export const Amount = () => { const assetDisplaySymbol = assetSymbol ?? (isNFT ? 'NFT' : ''); const { styles } = useStyles(styleSheet, { contentLength: amount.length + assetDisplaySymbol.length, - isNFT, }); const isIos = Device.isIos(); const { setAmountInputTypeFiat, setAmountInputTypeToken } = @@ -171,17 +170,15 @@ export const Amount = () => { )} + + {balanceDisplayValue} + - - - {balanceDisplayValue} - - - + ); }; From c9f531dc14e5b8311f2d65bb4c4bd663c2e3a5bd Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:05:56 +0100 Subject: [PATCH 02/12] feat: [Trending] make sections dynamic and restructure code part 2/2 (#22724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR focuses on restructuring the whole trending feature so that developers can add sections dynamically just by modifying a single file. This PR is a continuation of [this](https://github.com/MetaMask/metamask-mobile/pull/22700) one. Here is a summary of the components that get dynamically created based on a centralized configuration: Quick Action Buttons - 🟢 Search Sections - 🟢 Actual sections - 🟢 Now the developer will have a section created just by modifying [this](https://github.com/MetaMask/metamask-mobile/blob/23f99a5d7ac7f02f8aa48174560a3e94b122725e/app/components/Views/TrendingView/config/sections.config.tsx) file ## **Changelog** CHANGELOG entry: dynamically create sections for trending ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** image image ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Replaces hardcoded Trending sections with a centralized config-driven system powering feed, quick actions, search, and navigation. > > - **Architecture**: > - Introduces centralized `SECTIONS_CONFIG` with `renderRowItem`, `viewAllAction`, `renderSection`, and `useSectionData` plus `HOME_SECTIONS_ARRAY`/`SECTIONS_ARRAY`. > - Adds `useSectionsData` to fetch all section data generically. > - **Search**: > - `ExploreSearchResults` now renders items via `section.renderRowItem` and keys via `section.keyExtractor`. > - `useExploreSearch` debounces and filters using `SECTIONS_ARRAY` + `useSectionsData`. > - **UI Components**: > - New `SectionCard` (list-style sections) and `SectionCarrousel` (carousel-style, with dots) used by sections. > - `QuickActions` and `SectionHeader` use `section.viewAllAction`. > - **Trending Feed**: > - `TrendingView` renders sections from `HOME_SECTIONS_ARRAY` and adds Predict routes (`PredictScreenStack`, details, buy/sell previews). > - **Removed Legacy**: > - Deletes `PerpsSection` and `PredictionSection` (and related styles/tests) in favor of config-driven rendering. > - **Tests**: > - Adds `SectionCarrousel` tests; minor test cleanup in `TrendingTokenRowItem.test`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 23f99a5d7ac7f02f8aa48174560a3e94b122725e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../ExploreSearchResults.tsx | 6 +- .../config/useExploreSearch.ts | 50 +--- .../PerpsSection/PerpsSection.test.tsx | 164 ----------- .../PerpsSection/PerpsSection.tsx | 57 ---- .../PredictionSection.styles.ts | 55 ---- .../PredictionSection.test.tsx | 254 ------------------ .../PredictionSection/PredictionSection.tsx | 182 ------------- .../TrendingTokenRowItem.test.tsx | 4 +- .../TrendingTokensSection.tsx | 37 --- .../Views/TrendingView/TrendingView.tsx | 61 ++++- .../components/QuickActions/QuickActions.tsx | 2 +- .../components/SectionCard/SectionCard.tsx | 40 ++- .../SectionCarrousel.test.tsx | 95 +++++++ .../SectionCarrousel/SectionCarrousel.tsx | 137 ++++++++++ .../SectionHeader/SectionHeader.tsx | 4 +- .../TrendingView/config/sections.config.tsx | 252 +++++++++++------ 16 files changed, 494 insertions(+), 906 deletions(-) delete mode 100644 app/components/Views/TrendingView/PerpsSection/PerpsSection.test.tsx delete mode 100644 app/components/Views/TrendingView/PerpsSection/PerpsSection.tsx delete mode 100644 app/components/Views/TrendingView/PredictionSection/PredictionSection.styles.ts delete mode 100644 app/components/Views/TrendingView/PredictionSection/PredictionSection.test.tsx delete mode 100644 app/components/Views/TrendingView/PredictionSection/PredictionSection.tsx delete mode 100644 app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensSection.tsx create mode 100644 app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.test.tsx create mode 100644 app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx index 43a678480b6..fc683663db4 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 bdb2d5b3fed..b63597a5ce3 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 e060b08d2e9..00000000000 --- 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 ed1ba6d9113..00000000000 --- 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 3385572a1fd..00000000000 --- 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 8d9a26d5dd4..00000000000 --- 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 b034fefdcf0..00000000000 --- 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 e5b06869008..3dcdd345e69 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 e5a67911222..00000000000 --- 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 ee7e2544656..93d69fecae7 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 c44b4403c1c..64b1d4cd748 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 64a67852fb7..c63f5f62a0f 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 00000000000..0aafba1cced --- /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 00000000000..56c5a5d07cc --- /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 823dc8da206..9431c91ea7b 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 fd92ebc2689..166d33c19c4 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, + }, + }; +}; From da5fdf80a67d3dacc3e71fbc1fcb91b9d7eb0de9 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Mon, 17 Nov 2025 22:50:03 +0800 Subject: [PATCH 03/12] fix: useEffect infinity loop in SimpleWebview (#22534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Rerouting to webView is crashing the app. It is root caused due to infinity useEffect loop Jira Link : https://consensyssoftware.atlassian.net/browse/SL-298 ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/22548 ## **Manual testing steps** ```gherkin Feature: fix useEffect infinity loop Scenario: user check term of use Given installed the app When user click on create new wallet Then user click on the terms of use link ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/f81b1e83-699b-401c-b01d-6ee63178ec00 ### **After** https://github.com/user-attachments/assets/89512f44-33f8-4736-8be0-0e20fc347650 ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Separates navbar options and param dispatch updates into two effects to prevent an update loop in `SimpleWebView`. > > - **SimpleWebView (`app/components/Views/SimpleWebview/index.tsx`)**: > - Decouples navbar options from param dispatch updates by splitting into two `useEffect` hooks. > - `navigation.setOptions(getWebviewNavbar(...))` now depends on `navigation`, `route`, `colors`. > - `navigation.setParams({ dispatch: share })` now depends on `navigation`, `share`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 019a4ae63e95516f7ea26114335ee37d01bef69f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/Views/SimpleWebview/index.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/components/Views/SimpleWebview/index.tsx b/app/components/Views/SimpleWebview/index.tsx index 691d29fd1c9..ba48ba91f9b 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 ( From aa50cb46b5424a6acdf1c58d133b1809f5a2ecb0 Mon Sep 17 00:00:00 2001 From: VGR Date: Mon, 17 Nov 2025 15:52:25 +0100 Subject: [PATCH 04/12] chore: remove rewards feature flag (#22318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Remove rewards feature flag, this is now always enabled. ## **Changelog** CHANGELOG entry: null ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Removes the rewards feature flag across app, always enables Rewards UI/flows, updates navigation, hooks, controller logic, selectors, and tests/e2e accordingly. > > - **Navigation/UI**: > - Always render and navigate to `Routes.REWARDS_VIEW`; remove `selectRewardsEnabledFlag` checks in `TabBar`, `MainNavigator`, and tests/snapshots. > - Navbar: always show hamburger menu button; `getSettingsNavigationOptions` always renders a close button and no longer accepts rewards flag; update Wallet/Settings to stop passing it. > - **Perps**: > - Replace `navigateToRewardsOrSettings` with `navigateToRewards`; remove rewards flag usage in `usePerpsNavigation`, `usePerpsOrderFees`, and `usePerpsRewards`, enabling rewards-related calls unconditionally; update related tests. > - **Rewards onboarding**: > - `useRewardsIntroModal` no longer depends on rewards-enabled flag; continues to use announcement flag and subscription checks. > - **Controller**: > - `RewardsController` no longer reads Redux feature flag; `isRewardsFeatureEnabled` now only respects `isDisabled` callback; update tests to reflect new gating. > - **Selectors**: > - Remove `selectRewardsEnabledFlag` and related tests; keep other rewards selectors (e.g., announcement modal) with defaults. > - **E2E**: > - Settings access updated to use wallet hamburger menu and new close button; adjust tests to close drawer before proceeding. > - **Tests/Snapshots**: > - Widespread updates to unit tests and snapshots to remove rewards flag conditions and assert Rewards tab/button presence. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 43da35a63727ce16571dcb91b5d154f6716de44b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Priya Narayanaswamy --- .../Navigation/TabBar/TabBar.test.tsx | 5 - .../components/Navigation/TabBar/TabBar.tsx | 7 +- app/components/Nav/Main/MainNavigator.js | 72 ++- app/components/Nav/Main/MainNavigator.test.js | 59 +-- .../__snapshots__/MainNavigator.test.js.snap | 2 +- .../__snapshots__/MainNavigator.test.tsx.snap | 11 + app/components/UI/Navbar/index.js | 40 +- app/components/UI/Navbar/index.test.jsx | 146 +++--- .../PerpsMarketListView.test.tsx | 6 +- .../PerpsOrderView/PerpsOrderView.test.tsx | 128 +----- .../UI/Perps/hooks/usePerpsNavigation.test.ts | 25 +- .../UI/Perps/hooks/usePerpsNavigation.ts | 19 +- .../UI/Perps/hooks/usePerpsOrderFees.test.ts | 12 +- .../UI/Perps/hooks/usePerpsOrderFees.ts | 24 +- .../UI/Perps/hooks/usePerpsRewards.test.ts | 37 +- .../UI/Perps/hooks/usePerpsRewards.ts | 9 +- .../hooks/useRewardsIntroModal.test.ts | 32 +- .../UI/Rewards/hooks/useRewardsIntroModal.ts | 18 +- app/components/Views/Settings/index.tsx | 5 +- .../Wallet/__snapshots__/index.test.tsx.snap | 435 ++++++++++++++++++ app/components/Views/Wallet/index.tsx | 4 - .../RewardsController.test.ts | 296 +++++------- .../rewards-controller/RewardsController.ts | 6 +- .../rewards/index.test.ts | 87 ---- .../featureFlagController/rewards/index.ts | 31 +- e2e/pages/Settings/SettingsView.ts | 11 + e2e/pages/wallet/TabBarComponent.ts | 6 +- e2e/pages/wallet/WalletView.ts | 12 + e2e/selectors/wallet/WalletView.selectors.ts | 1 + .../account-syncing-settings-toggle.spec.ts | 7 +- .../contact-sync-toggle.spec.ts | 6 + 31 files changed, 805 insertions(+), 754 deletions(-) diff --git a/app/component-library/components/Navigation/TabBar/TabBar.test.tsx b/app/component-library/components/Navigation/TabBar/TabBar.test.tsx index 223359a4751..2680330faab 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 ee07838913a..a620ca4ab10 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 703a68aad01..08b3bc785d3 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 91db9aac03e..cb5d0825f7b 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 d3b7b04c55c..a4f509330f2 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 a28adbe9dfc..03b93bc231c 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`] = ` } } /> + )} - {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 e6056f44a90..0daf4af9330 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 44dcb8930c5..b6e7127f67d 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 b9a11dbb43e..83573e42d98 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/usePerpsNavigation.test.ts b/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts index ff2a1f325d5..1c23ae83808 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 71b87751c18..8c5d9c9252e 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 043153da24d..4f2f9c891ee 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 e6f65d7172e..315b971b114 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 7da5a254e55..d7a297ae410 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 a9268dd7809..e8f28bb4a64 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/hooks/useRewardsIntroModal.test.ts b/app/components/UI/Rewards/hooks/useRewardsIntroModal.test.ts index da2daf946d3..5b0b63d5c22 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 babe3a56dc9..e168415c237 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/Views/Settings/index.tsx b/app/components/Views/Settings/index.tsx index 6b8ce875973..35ce6806c0d 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/Wallet/__snapshots__/index.test.tsx.snap b/app/components/Views/Wallet/__snapshots__/index.test.tsx.snap index 43df814c857..610c7cafec7 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 e4e3fb1d7f8..f05216dd4cd 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/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index 5d6557c9ec4..2cd012b7856 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -38,8 +38,6 @@ jest.mock('../../../../util/Logger', () => ({ debug: jest.fn(), }, })); -jest.mock('../../../../selectors/featureFlagController/rewards'); -jest.mock('../../../../store'); jest.mock('../../../../util/address', () => ({ isHardwareAccount: jest.fn(), })); @@ -75,8 +73,6 @@ jest.mock('ethers/lib/utils', () => ({ })); // Import mocked modules -import { selectRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; -import { store } from '../../../../store'; import { InternalAccount } from '@metamask/keyring-internal-api'; import { isHardwareAccount } from '../../../../util/address'; import { isNonEvmAddress } from '../../../Multichain/utils'; @@ -99,11 +95,7 @@ import { } from './services/rewards-data-service'; // Type the mocked modules -const mockSelectRewardsEnabledFlag = - selectRewardsEnabledFlag as jest.MockedFunction< - typeof selectRewardsEnabledFlag - >; -const mockStore = store as jest.Mocked; +// Note: mockStore is kept for potential future use but currently unused after feature flag removal const mockIsHardwareAccount = isHardwareAccount as jest.MockedFunction< typeof isHardwareAccount >; @@ -364,9 +356,6 @@ describe('RewardsController', () => { // Mock Date.now to return a consistent timestamp jest.spyOn(Date, 'now').mockReturnValue(123); - // Mock feature flag to be enabled by default for all tests - mockSelectRewardsEnabledFlag.mockReturnValue(true); - // Reset import mocks // @ts-expect-error TODO: Resolve type mismatch mockStoreSubscriptionToken.mockResolvedValue(undefined); @@ -406,9 +395,6 @@ describe('RewardsController', () => { unsubscribe: jest.fn(), } as unknown as jest.Mocked; - // Reset feature flag to enabled by default - mockSelectRewardsEnabledFlag.mockReturnValue(true); - controller = new RewardsController({ messenger: mockMessenger, isDisabled: () => false, @@ -522,10 +508,16 @@ describe('RewardsController', () => { }); describe('getHasAccountOptedIn', () => { - it('should return false when feature flag is disabled', async () => { - mockSelectRewardsEnabledFlag.mockReturnValue(false); + it('should return false when disabled via isDisabled callback', async () => { + const isDisabled = () => true; + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled, + }); - const result = await controller.getHasAccountOptedIn(CAIP_ACCOUNT_1); + const result = + await disabledController.getHasAccountOptedIn(CAIP_ACCOUNT_1); expect(result).toBe(false); expect(mockMessenger.call).not.toHaveBeenCalledWith( @@ -765,8 +757,13 @@ describe('RewardsController', () => { }); describe('estimatePoints', () => { - it('should return default response when feature flag is disabled', async () => { - mockSelectRewardsEnabledFlag.mockReturnValue(false); + it('should return default response when disabled via isDisabled callback', async () => { + const isDisabled = () => true; + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled, + }); const mockRequest = { activityType: 'SWAP' as const, @@ -774,7 +771,7 @@ describe('RewardsController', () => { activityContext: {}, }; - const result = await controller.estimatePoints(mockRequest); + const result = await disabledController.estimatePoints(mockRequest); expect(result).toEqual({ pointsEstimate: 0, bonusBips: 0 }); expect(mockMessenger.call).not.toHaveBeenCalledWith( @@ -854,8 +851,13 @@ describe('RewardsController', () => { }); describe('getPointsEvents', () => { - it('should return empty response when feature flag is disabled', async () => { - mockSelectRewardsEnabledFlag.mockReturnValue(false); + it('should return empty response when disabled via isDisabled callback', async () => { + const isDisabled = () => true; + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled, + }); const mockRequest = { seasonId: 'current', @@ -863,7 +865,7 @@ describe('RewardsController', () => { cursor: null, }; - const result = await controller.getPointsEvents(mockRequest); + const result = await disabledController.getPointsEvents(mockRequest); expect(result).toEqual({ has_more: false, @@ -1853,11 +1855,16 @@ describe('RewardsController', () => { }); describe('getPerpsDiscountForAccount', () => { - it('should return 0 when feature flag is disabled', async () => { - mockSelectRewardsEnabledFlag.mockReturnValue(false); + it('should return 0 when disabled via isDisabled callback', async () => { + const isDisabled = () => true; + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled, + }); const result = - await controller.getPerpsDiscountForAccount(CAIP_ACCOUNT_1); + await disabledController.getPerpsDiscountForAccount(CAIP_ACCOUNT_1); expect(result).toBe(0); }); @@ -2253,33 +2260,23 @@ describe('RewardsController', () => { }); describe('isRewardsFeatureEnabled', () => { - it('should return true when feature flag is enabled', () => { - // Mock the feature flag selector to return true - mockSelectRewardsEnabledFlag.mockReturnValue(true); - + it('should return true when not disabled', () => { const result = controller.isRewardsFeatureEnabled(); expect(result).toBe(true); - expect(mockSelectRewardsEnabledFlag).toHaveBeenCalled(); }); - it('should return false when feature flag is disabled', () => { - // Mock the feature flag selector to return false - mockSelectRewardsEnabledFlag.mockReturnValue(false); + it('should return false when disabled via isDisabled callback', () => { + const isDisabled = () => true; + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled, + }); - const result = controller.isRewardsFeatureEnabled(); + const result = disabledController.isRewardsFeatureEnabled(); expect(result).toBe(false); - expect(mockSelectRewardsEnabledFlag).toHaveBeenCalled(); - }); - - it('should call selectRewardsEnabledFlag with store state', () => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); - - controller.isRewardsFeatureEnabled(); - - expect(mockStore.getState).toHaveBeenCalled(); - expect(mockSelectRewardsEnabledFlag).toHaveBeenCalled(); }); }); @@ -2302,7 +2299,6 @@ describe('RewardsController', () => { }; beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); mockIsHardwareAccount.mockReturnValue(false); mockIsSolanaAddress.mockReturnValue(false); @@ -2715,14 +2711,14 @@ describe('RewardsController', () => { const mockSeasonId = 'season123'; const mockSubscriptionId = 'sub123'; - beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); - }); - it('should return null when feature flag is disabled', async () => { - mockSelectRewardsEnabledFlag.mockReturnValue(false); + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => true, + }); - const result = await controller.getSeasonStatus( + const result = await disabledController.getSeasonStatus( mockSubscriptionId, mockSeasonId, ); @@ -4198,10 +4194,8 @@ describe('RewardsController', () => { describe('getSeasonMetadata', () => { const mockSeasonId = 'season123'; - it('returns null when rewards feature is not enabled', async () => { - // Mock the feature flag to be disabled - mockSelectRewardsEnabledFlag.mockReturnValue(false); - + it('returns null when disabled via isDisabled callback', async () => { + const isDisabled = () => true; controller = new RewardsController({ messenger: mockMessenger, state: { @@ -4213,12 +4207,12 @@ describe('RewardsController', () => { seasonStatuses: {}, pointsEvents: {}, }, + isDisabled, }); const result = await controller.getSeasonMetadata('current'); expect(result).toBeNull(); - expect(mockSelectRewardsEnabledFlag).toHaveBeenCalled(); expect(mockMessenger.call).not.toHaveBeenCalled(); }); @@ -4668,14 +4662,8 @@ describe('RewardsController', () => { const mockSubscriptionId = 'sub123'; const mockSeasonId = 'season456'; - beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); - }); - it('returns null when feature flag is disabled', async () => { - mockSelectRewardsEnabledFlag.mockReturnValue(false); - - controller = new RewardsController({ + const disabledController = new RewardsController({ messenger: mockMessenger, state: { activeAccount: null, @@ -4688,9 +4676,10 @@ describe('RewardsController', () => { unlockedRewards: {}, pointsEvents: {}, }, + isDisabled: () => true, }); - const result = await controller.getReferralDetails( + const result = await disabledController.getReferralDetails( mockSubscriptionId, mockSeasonId, ); @@ -4933,7 +4922,6 @@ describe('RewardsController', () => { describe('handleAuthenticationTrigger', () => { beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); mockIsHardwareAccount.mockReturnValue(false); mockIsSolanaAddress.mockReturnValue(false); }); @@ -5457,7 +5445,6 @@ describe('RewardsController', () => { describe('shouldSkipSilentAuth', () => { beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); mockIsHardwareAccount.mockReturnValue(false); mockIsSolanaAddress.mockReturnValue(false); }); @@ -5787,16 +5774,17 @@ describe('RewardsController', () => { // Removed outdated 'reset' tests; behavior covered by 'resetAll' and 'logout' tests describe('logout', () => { - beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); - }); - - it('should skip logout when feature flag is disabled', async () => { + it('should skip logout when disabled via isDisabled callback', async () => { // Arrange - mockSelectRewardsEnabledFlag.mockReturnValue(false); + const isDisabled = () => true; + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled, + }); // Act - await controller.logout(); + await disabledController.logout(); // Assert - Should not call logout service expect(mockMessenger.call).not.toHaveBeenCalledWith( @@ -6010,13 +5998,8 @@ describe('RewardsController', () => { }); describe('resetAll', () => { - beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); - }); - it('should skip reset when feature flag is disabled', async () => { // Arrange - mockSelectRewardsEnabledFlag.mockReturnValue(false); const mockSubscriptionId = 'sub-abc'; const activeAccountState = { account: CAIP_ACCOUNT_1, @@ -6044,7 +6027,7 @@ describe('RewardsController', () => { seasonStatuses: {}, pointsEvents: {}, }, - isDisabled: () => false, + isDisabled: () => true, }); // Act @@ -6161,16 +6144,16 @@ describe('RewardsController', () => { }); describe('validateReferralCode', () => { - beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); - }); - it('should return false when feature flag is disabled', async () => { // Arrange - mockSelectRewardsEnabledFlag.mockReturnValue(false); + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => true, + }); // Act - const result = await controller.validateReferralCode('ABC123'); + const result = await disabledController.validateReferralCode('ABC123'); // Assert expect(result).toBe(false); @@ -6269,10 +6252,6 @@ describe('RewardsController', () => { }); describe('calculateTierStatus', () => { - beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); - }); - it('should throw error when current tier ID is not found in season tiers', () => { // Arrange const tiers = createTestTiers(); @@ -6416,10 +6395,6 @@ describe('RewardsController', () => { }); describe('convertToSeasonStatusDto', () => { - beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); - }); - it('should convert SeasonDtoState and SeasonStateDto to SeasonStatusDto with all required fields', () => { // Arrange const tiers = createTestTiers(); @@ -6627,10 +6602,6 @@ describe('RewardsController', () => { }); describe('convertInternalAccountToCaipAccountId', () => { - beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); - }); - it('should log error when conversion fails due to invalid internal account', () => { // Arrange const invalidInternalAccount = { @@ -7153,16 +7124,16 @@ describe('RewardsController', () => { }); describe('getGeoRewardsMetadata', () => { - beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); - }); - it('should return default metadata when rewards feature is disabled', async () => { // Arrange - mockSelectRewardsEnabledFlag.mockReturnValue(false); + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => true, + }); // Act - const result = await controller.getGeoRewardsMetadata(); + const result = await disabledController.getGeoRewardsMetadata(); // Assert expect(result).toEqual({ @@ -7339,7 +7310,6 @@ describe('RewardsController', () => { }; beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); // Clear calls resulting from top-level `beforeEach` jest.clearAllMocks(); }); @@ -7421,11 +7391,14 @@ describe('RewardsController', () => { it('should return null when rewards feature is disabled', async () => { // Arrange - mockSelectRewardsEnabledFlag.mockReturnValue(false); - const mockAccounts = [mockEvmInternalAccount]; + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => true, + }); // Act - const result = await controller.optIn(mockAccounts); + const result = await disabledController.optIn([]); // Assert expect(result).toBeNull(); @@ -7835,10 +7808,16 @@ describe('RewardsController', () => { const mockAccounts = [mockEvmInternalAccount]; // Mock rewards disabled check inside #optIn - mockSelectRewardsEnabledFlag.mockImplementation(() => { - // First call (in main optIn) returns true, second call (in #optIn) returns false - const callCount = mockSelectRewardsEnabledFlag.mock.calls.length; - return callCount === 1; + // Test with isDisabled callback that returns false initially, then true + let callCount = 0; + const isDisabled = () => { + callCount++; + return callCount === 2; // Second call returns true (disabled) + }; + const testController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled, }); mockMessenger.call.mockImplementation((_, ..._args): any => @@ -7851,7 +7830,7 @@ describe('RewardsController', () => { })); // Act & Assert - await expect(controller.optIn(mockAccounts)).rejects.toThrow( + await expect(testController.optIn(mockAccounts)).rejects.toThrow( 'Failed to opt in any account from the account group', ); }); @@ -8058,10 +8037,6 @@ describe('RewardsController', () => { }, } as unknown as InternalAccount; - beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); - }); - it('should return empty array when accounts array is empty', async () => { // Act const result = await controller.linkAccountsToSubscriptionCandidate([]); @@ -8072,7 +8047,6 @@ describe('RewardsController', () => { it('should return all accounts as failed when rewards feature is disabled', async () => { // Arrange - mockSelectRewardsEnabledFlag.mockReturnValue(false); const accounts = [mockEvmInternalAccount, mockEvmInternalAccount2]; // Act @@ -8308,10 +8282,6 @@ describe('RewardsController', () => { }); describe('optOut', () => { - beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); - }); - it('should return false when subscription ID is not found', async () => { // Arrange const testController = new TestableRewardsController({ @@ -8551,10 +8521,6 @@ describe('RewardsController', () => { }); describe('optIn and optOut edge cases', () => { - beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); - }); - describe('optIn edge cases', () => { it('should handle empty account group gracefully', async () => { // Arrange @@ -8882,13 +8848,8 @@ describe('RewardsController', () => { }); describe('getCandidateSubscriptionId', () => { - beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); - }); - it('should return null when feature flag is disabled', async () => { // Arrange - mockSelectRewardsEnabledFlag.mockReturnValue(false); // Act const result = await controller.getCandidateSubscriptionId(); @@ -9094,9 +9055,8 @@ describe('RewardsController', () => { // Create a test controller with a custom implementation class TestRewardsController extends RewardsController { async getCandidateSubscriptionId(): Promise { - // Mock the first part of the method to return null for active account and subscriptions - const rewardsEnabled = selectRewardsEnabledFlag(store.getState()); - if (!rewardsEnabled) { + // Mock the first part of the method to return null when disabled + if (this.isRewardsFeatureEnabled() === false) { return null; } @@ -9948,17 +9908,20 @@ describe('RewardsController', () => { } as InternalAccount; beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); mockIsSolanaAddress.mockReturnValue(false); // Default to non-Solana }); it('should return false when feature flag is disabled', async () => { // Arrange - mockSelectRewardsEnabledFlag.mockReturnValue(false); + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => true, + }); // Act const result = - await controller.linkAccountToSubscriptionCandidate( + await disabledController.linkAccountToSubscriptionCandidate( mockInternalAccount, ); @@ -10910,18 +10873,21 @@ describe('RewardsController', () => { } as InternalAccount; beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); mockIsSolanaAddress.mockReturnValue(false); }); it('should return all accounts as failed when feature flag is disabled', async () => { // Arrange - mockSelectRewardsEnabledFlag.mockReturnValue(false); + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => true, + }); const accounts = [mockInternalAccount1, mockInternalAccount2]; // Act const result = - await controller.linkAccountsToSubscriptionCandidate(accounts); + await disabledController.linkAccountsToSubscriptionCandidate(accounts); // Assert expect(result).toEqual([ @@ -10994,16 +10960,16 @@ describe('RewardsController', () => { const mockParams = { addresses: ['0x123', '0x456'] }; const mockResponse = { ois: [true, false], sids: ['sub_123', null] }; - beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); - }); - it('should return false array when feature flag is disabled', async () => { // Arrange - mockSelectRewardsEnabledFlag.mockReturnValue(false); + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => true, + }); // Act - const result = await controller.getOptInStatus(mockParams); + const result = await disabledController.getOptInStatus(mockParams); // Assert expect(result).toEqual({ ois: [false, false], sids: [null, null] }); @@ -11626,7 +11592,6 @@ describe('RewardsController', () => { const mockResponse = { boosts: mockBoosts }; mockMessenger.call.mockResolvedValue(mockResponse); - mockSelectRewardsEnabledFlag.mockReturnValue(true); // Act const result = await controller.getActivePointsBoosts( @@ -11654,7 +11619,6 @@ describe('RewardsController', () => { const mockResponse = { boosts: mockEmptyBoosts }; mockMessenger.call.mockResolvedValue(mockResponse); - mockSelectRewardsEnabledFlag.mockReturnValue(true); // Act const result = await controller.getActivePointsBoosts( @@ -11674,7 +11638,6 @@ describe('RewardsController', () => { const mockError = new Error('Data service error'); mockMessenger.call.mockRejectedValue(mockError); - mockSelectRewardsEnabledFlag.mockReturnValue(true); // Act & Assert await expect( @@ -11695,7 +11658,6 @@ describe('RewardsController', () => { const timeoutError = new Error('Request timeout after 10000ms'); mockMessenger.call.mockRejectedValue(timeoutError); - mockSelectRewardsEnabledFlag.mockReturnValue(true); // Act & Assert await expect( @@ -11710,7 +11672,6 @@ describe('RewardsController', () => { const authError = new Error('Authentication failed'); mockMessenger.call.mockRejectedValue(authError); - mockSelectRewardsEnabledFlag.mockReturnValue(true); // Act & Assert await expect( @@ -11738,7 +11699,6 @@ describe('RewardsController', () => { const mockResponse = { boosts: mockBoosts }; mockMessenger.call.mockResolvedValue(mockResponse); - mockSelectRewardsEnabledFlag.mockReturnValue(true); // Act const result = await controller.getActivePointsBoosts( @@ -11758,12 +11718,16 @@ describe('RewardsController', () => { it('should return empty array when rewards feature is disabled', async () => { // Arrange + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => true, + }); const seasonId = 'season-123'; const subscriptionId = 'sub-456'; - mockSelectRewardsEnabledFlag.mockReturnValue(false); // Act - const result = await controller.getActivePointsBoosts( + const result = await disabledController.getActivePointsBoosts( seasonId, subscriptionId, ); @@ -11830,8 +11794,6 @@ describe('RewardsController', () => { }, }); - mockSelectRewardsEnabledFlag.mockReturnValue(true); - // Act const result = await controller.getActivePointsBoosts( seasonId, @@ -11925,7 +11887,6 @@ describe('RewardsController', () => { const mockResponse = { boosts: mockFreshBoosts }; mockMessenger.call.mockResolvedValue(mockResponse); - mockSelectRewardsEnabledFlag.mockReturnValue(true); // Act const result = await controller.getActivePointsBoosts( @@ -11994,7 +11955,6 @@ describe('RewardsController', () => { const mockResponse = { boosts: mockFreshBoosts }; mockMessenger.call.mockResolvedValue(mockResponse); - mockSelectRewardsEnabledFlag.mockReturnValue(true); // Act const result = await controller.getActivePointsBoosts( @@ -12057,7 +12017,6 @@ describe('RewardsController', () => { const mockResponse = { boosts: mockBoosts }; mockMessenger.call.mockResolvedValue(mockResponse); - mockSelectRewardsEnabledFlag.mockReturnValue(true); // Act const result = await controller.getActivePointsBoosts( @@ -12154,7 +12113,6 @@ describe('RewardsController', () => { // Clear any calls made during controller initialization mockMessenger.call.mockClear(); - mockSelectRewardsEnabledFlag.mockReturnValue(true); const mockResponse2 = { boosts: mockBoosts2 }; mockMessenger.call.mockResolvedValue(mockResponse2); @@ -12213,19 +12171,16 @@ describe('RewardsController', () => { registerInitialEventPayload: jest.fn(), unsubscribe: jest.fn(), } as unknown as jest.Mocked; - - mockSelectRewardsEnabledFlag.mockReturnValue(true); }); it('should return empty array when feature flag is disabled', async () => { - mockSelectRewardsEnabledFlag.mockReturnValue(false); - - controller = new RewardsController({ + const disabledController = new RewardsController({ messenger: mockMessenger, state: getRewardsControllerDefaultState(), + isDisabled: () => true, }); - const result = await controller.getUnlockedRewards( + const result = await disabledController.getUnlockedRewards( mockSeasonId, mockSubscriptionId, ); @@ -12603,7 +12558,6 @@ describe('RewardsController', () => { const mockSubscriptionId = 'test-subscription-id'; beforeEach(() => { - mockSelectRewardsEnabledFlag.mockReturnValue(true); mockMessenger.call.mockClear(); mockMessenger.publish.mockClear(); mockLogger.log.mockClear(); @@ -12764,15 +12718,15 @@ describe('RewardsController', () => { it('should throw error when rewards are not enabled', async () => { // Arrange - mockSelectRewardsEnabledFlag.mockReturnValue(false); - controller = new RewardsController({ + const disabledController = new RewardsController({ messenger: mockMessenger, state: getRewardsControllerDefaultState(), + isDisabled: () => true, }); // Act & Assert await expect( - controller.claimReward(mockRewardId, mockSubscriptionId), + disabledController.claimReward(mockRewardId, mockSubscriptionId), ).rejects.toThrow('Rewards are not enabled'); expect(mockMessenger.call).not.toHaveBeenCalled(); diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index b35b4db9700..762d9870316 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -40,8 +40,6 @@ import Logger from '../../../../util/Logger'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { isAddress as isSolanaAddress } from '@solana/addresses'; import { isHardwareAccount } from '../../../../util/address'; -import { selectRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; -import { store } from '../../../../store'; import { CaipAccountId, parseCaipChainId, @@ -1602,13 +1600,13 @@ export class RewardsController extends BaseController< } /** - * Check if the rewards feature is enabled via feature flag + * Check if the rewards feature is enabled * @returns boolean - True if rewards feature is enabled, false otherwise */ isRewardsFeatureEnabled(): boolean { const isDisabled = this.#isDisabled(); if (isDisabled) return false; - return selectRewardsEnabledFlag(store.getState()); + return true; } /** diff --git a/app/selectors/featureFlagController/rewards/index.test.ts b/app/selectors/featureFlagController/rewards/index.test.ts index 3842963ef41..cc50455664f 100644 --- a/app/selectors/featureFlagController/rewards/index.test.ts +++ b/app/selectors/featureFlagController/rewards/index.test.ts @@ -1,5 +1,4 @@ import { - selectRewardsEnabledFlag, selectRewardsAnnouncementModalEnabledFlag, selectRewardsCardSpendFeatureFlags, selectRewardsMusdDepositEnabledFlag, @@ -31,92 +30,6 @@ describe('Rewards Feature Flag Selectors', () => { mockHasMinimumRequiredVersion?.mockRestore(); }); - describe('selectRewardsEnabledFlag', () => { - it('returns false when basic functionality is disabled', () => { - const result = selectRewardsEnabledFlag.resultFunc( - { - rewardsEnabled: { - enabled: true, - minimumVersion: '1.0.0', - }, - }, - false, - ); - - expect(result).toBe(false); - }); - - it('returns true when remote flag is valid and enabled and basic functionality is enabled', () => { - const result = selectRewardsEnabledFlag.resultFunc( - { - rewardsEnabled: { - enabled: true, - minimumVersion: '1.0.0', - }, - }, - true, - ); - - expect(result).toBe(true); - }); - - it('returns false when remote flag is valid but disabled and basic functionality is enabled', () => { - const result = selectRewardsEnabledFlag.resultFunc( - { - rewardsEnabled: { - enabled: false, - minimumVersion: '1.0.0', - }, - }, - true, - ); - - expect(result).toBe(false); - }); - - it('returns false when version check fails and basic functionality is enabled', () => { - mockHasMinimumRequiredVersion.mockReturnValue(false); - - const result = selectRewardsEnabledFlag.resultFunc( - { - rewardsEnabled: { - enabled: true, - minimumVersion: '99.0.0', - }, - }, - true, - ); - - expect(result).toBe(false); - }); - - it('returns false when remote flag is invalid and basic functionality is enabled', () => { - const result = selectRewardsEnabledFlag.resultFunc( - { - rewardsEnabled: { - enabled: 'invalid', - minimumVersion: 123, - }, - }, - true, - ); - - expect(result).toBe(false); - }); - - it('returns false when remote feature flags are empty and basic functionality is enabled', () => { - const result = selectRewardsEnabledFlag.resultFunc({}, true); - - expect(result).toBe(false); - }); - - it('returns false when remote feature flags are empty and basic functionality is disabled', () => { - const result = selectRewardsEnabledFlag.resultFunc({}, false); - - expect(result).toBe(false); - }); - }); - describe('selectRewardsAnnouncementModalEnabledFlag', () => { it('returns true when remote flag is valid and enabled', () => { const result = selectRewardsAnnouncementModalEnabledFlag.resultFunc({ diff --git a/app/selectors/featureFlagController/rewards/index.ts b/app/selectors/featureFlagController/rewards/index.ts index 3c65b3a6e42..275ae27c7cf 100644 --- a/app/selectors/featureFlagController/rewards/index.ts +++ b/app/selectors/featureFlagController/rewards/index.ts @@ -5,50 +5,27 @@ import { validatedVersionGatedFeatureFlag, VersionGatedFeatureFlag, } from '../../../util/remoteFeatureFlag'; -import { selectBasicFunctionalityEnabled } from '../../settings'; -const DEFAULT_REWARDS_ENABLED = false; +const DEFAULT_REWARDS_ANNOUNCEMENT_MODAL_ENABLED = false; const DEFAULT_CARD_SPEND_ENABLED = false; const DEFAULT_MUSD_DEPOSIT_ENABLED = false; -export const FEATURE_FLAG_NAME = 'rewardsEnabled'; export const ANNOUNCEMENT_MODAL_FLAG_NAME = 'rewardsAnnouncementModalEnabled'; export const CARD_SPEND_FLAG_NAME = 'rewardsEnableCardSpend'; export const MUSD_DEPOSIT_FLAG_NAME = 'rewardsEnableMusdDeposit'; -export const selectRewardsEnabledFlag = createSelector( - selectRemoteFeatureFlags, - selectBasicFunctionalityEnabled, - (remoteFeatureFlags, isBasicFunctionalityEnabled) => { - // If basic functionality is disabled, rewards should be disabled - if (!isBasicFunctionalityEnabled) { - return false; - } - - if (!hasProperty(remoteFeatureFlags, FEATURE_FLAG_NAME)) { - return DEFAULT_REWARDS_ENABLED; - } - const remoteFlag = remoteFeatureFlags[ - FEATURE_FLAG_NAME - ] as unknown as VersionGatedFeatureFlag; - - return ( - validatedVersionGatedFeatureFlag(remoteFlag) ?? DEFAULT_REWARDS_ENABLED - ); - }, -); - export const selectRewardsAnnouncementModalEnabledFlag = createSelector( selectRemoteFeatureFlags, (remoteFeatureFlags) => { if (!hasProperty(remoteFeatureFlags, ANNOUNCEMENT_MODAL_FLAG_NAME)) { - return DEFAULT_REWARDS_ENABLED; + return DEFAULT_REWARDS_ANNOUNCEMENT_MODAL_ENABLED; } const remoteFlag = remoteFeatureFlags[ ANNOUNCEMENT_MODAL_FLAG_NAME ] as unknown as VersionGatedFeatureFlag; return ( - validatedVersionGatedFeatureFlag(remoteFlag) ?? DEFAULT_REWARDS_ENABLED + validatedVersionGatedFeatureFlag(remoteFlag) ?? + DEFAULT_REWARDS_ANNOUNCEMENT_MODAL_ENABLED ); }, ); diff --git a/e2e/pages/Settings/SettingsView.ts b/e2e/pages/Settings/SettingsView.ts index 3999f28579b..ef908b1eab1 100644 --- a/e2e/pages/Settings/SettingsView.ts +++ b/e2e/pages/Settings/SettingsView.ts @@ -5,6 +5,7 @@ import { SettingsViewSelectorsText, } from '../../selectors/Settings/SettingsView.selectors'; import { CommonSelectorsText } from '../../selectors/Common.selectors'; +import { NetworksViewSelectorsIDs } from '../../selectors/Settings/NetworksView.selectors'; class SettingsView { get title(): DetoxElement { @@ -193,6 +194,16 @@ class SettingsView { elemDescription: 'Settings - Snaps Button', }); } + + get closeButton(): DetoxElement { + return Matchers.getElementByID(NetworksViewSelectorsIDs.CLOSE_ICON); + } + + async tapCloseButton(): Promise { + await Gestures.tap(this.closeButton, { + elemDescription: 'Settings - Close Button', + }); + } } export default new SettingsView(); diff --git a/e2e/pages/wallet/TabBarComponent.ts b/e2e/pages/wallet/TabBarComponent.ts index 22974b44b57..7d9cb757e91 100644 --- a/e2e/pages/wallet/TabBarComponent.ts +++ b/e2e/pages/wallet/TabBarComponent.ts @@ -81,9 +81,9 @@ class TabBarComponent { async tapSettings(): Promise { await Utilities.executeWithRetry( async () => { - await Gestures.waitAndTap(this.tabBarSettingButton, { - elemDescription: 'Tab Bar - Settings Button', - }); + // Ensure we're on WalletView where the hamburger menu is located + await this.tapWallet(); + await WalletView.tapHamburgerMenu(); await Assertions.expectElementToBeVisible(SettingsView.title); }, { diff --git a/e2e/pages/wallet/WalletView.ts b/e2e/pages/wallet/WalletView.ts index 8ce927f3422..f67a850a068 100644 --- a/e2e/pages/wallet/WalletView.ts +++ b/e2e/pages/wallet/WalletView.ts @@ -51,6 +51,12 @@ class WalletView { ); } + get hamburgerMenuButton(): DetoxElement { + return Matchers.getElementByID( + WalletViewSelectorsIDs.WALLET_HAMBURGER_MENU_BUTTON, + ); + } + get navbarNetworkText(): DetoxElement { return Matchers.getElementByID(WalletViewSelectorsIDs.NAVBAR_NETWORK_TEXT); } @@ -220,6 +226,12 @@ class WalletView { }); } + async tapHamburgerMenu(): Promise { + await Gestures.waitAndTap(this.hamburgerMenuButton, { + elemDescription: 'Hamburger Menu Button', + }); + } + async tapNetworksButtonOnNavBar(): Promise { await TestHelpers.tap(WalletViewSelectorsIDs.NAVBAR_NETWORK_BUTTON); } diff --git a/e2e/selectors/wallet/WalletView.selectors.ts b/e2e/selectors/wallet/WalletView.selectors.ts index f2edfc01b53..eb4a67864c4 100644 --- a/e2e/selectors/wallet/WalletView.selectors.ts +++ b/e2e/selectors/wallet/WalletView.selectors.ts @@ -6,6 +6,7 @@ export const WalletViewSelectorsIDs = { NFT_CONTAINER: 'collectible-name', WALLET_SCAN_BUTTON: 'wallet-scan-button', WALLET_NOTIFICATIONS_BUTTON: 'wallet-notifications-button', + WALLET_HAMBURGER_MENU_BUTTON: 'navbar-hamburger-menu-button', WALLET_TOKEN_DETECTION_LINK_BUTTON: 'wallet-token-detection-link-button', TOTAL_BALANCE_TEXT: 'total-balance-text', CARD_BUTTON: 'card-button', diff --git a/e2e/specs/identity/account-syncing/account-syncing-settings-toggle.spec.ts b/e2e/specs/identity/account-syncing/account-syncing-settings-toggle.spec.ts index c026c6c8292..94e14547315 100644 --- a/e2e/specs/identity/account-syncing/account-syncing-settings-toggle.spec.ts +++ b/e2e/specs/identity/account-syncing/account-syncing-settings-toggle.spec.ts @@ -131,8 +131,13 @@ describe(SmokeIdentity('Account syncing - Setting'), () => { await Assertions.expectElementToBeVisible( SettingsView.backupAndSyncSectionButton, ); - await TabBarComponent.tapWallet(); + // Close settings drawer (opened from hamburger menu) to return to wallet view + await SettingsView.tapCloseButton(); await Assertions.expectElementToBeVisible(WalletView.container); + // Wait for settings drawer to fully close and tab bar to be visible + await Assertions.expectElementToBeVisible( + TabBarComponent.tabBarWalletButton, + ); // Create third account with sync disabled - this should NOT sync to user storage await WalletView.tapIdenticon(); diff --git a/e2e/specs/identity/contact-syncing/contact-sync-toggle.spec.ts b/e2e/specs/identity/contact-syncing/contact-sync-toggle.spec.ts index a54b078e738..395dbcad58f 100644 --- a/e2e/specs/identity/contact-syncing/contact-sync-toggle.spec.ts +++ b/e2e/specs/identity/contact-syncing/contact-sync-toggle.spec.ts @@ -12,6 +12,7 @@ import BackupAndSyncView from '../../../pages/Settings/BackupAndSyncView'; import { createUserStorageController } from '../utils/mocks.ts'; import ContactsView from '../../../pages/Settings/Contacts/ContactsView.ts'; import AddContactView from '../../../pages/Settings/Contacts/AddContactView.ts'; +import CommonView from '../../../pages/CommonView.ts'; describe(SmokeIdentity('Contacts syncing - Settings'), () => { let sharedUserStorageController: UserStorageMockttpController; @@ -87,6 +88,8 @@ describe(SmokeIdentity('Contacts syncing - Settings'), () => { await SettingsView.tapContacts(); await Assertions.expectElementToBeVisible(ContactsView.container); await ContactsView.expectContactIsVisible(TEST_CONTACT_NAME); + await CommonView.tapBackButton(); + await SettingsView.tapCloseButton(); // Disable contact syncing await TabBarComponent.tapSettings(); @@ -106,6 +109,9 @@ describe(SmokeIdentity('Contacts syncing - Settings'), () => { BackupAndSyncView.contactSyncToggle, ); + await CommonView.tapBackButton(); + await SettingsView.tapCloseButton(); + // Add second contact while sync is disabled await TabBarComponent.tapSettings(); await Assertions.expectElementToBeVisible( From 3c0f86275a2927bdc16f3edf62ec2743e0d52f56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:03:01 +0100 Subject: [PATCH 05/12] fix: swaps amount truncation and received tokens action key (#22705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR fixes amount truncating for swaps withing the Activity tab. It also fixes incorrect title for "Received Tokens" transaction type (previously it was incorrectly set to "Sent Tokens). ## **Changelog** CHANGELOG entry: Fixes amount truncating for swaps withing the Activity tab. It also fixes incorrect icon for "Received Tokens" transaction type (previously it was incorrectly set to "Sent Tokens"). ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/20358 https://consensyssoftware.atlassian.net/browse/TMCU-89 ## **Manual testing steps** ```gherkin Feature: List EVM swap transactions Scenario: user lists swap transactions Given user have swap transaction on EVM in their activity with amount lower than 0.00001 of selected token When user navigates to Activity tab Then they should see "< 0.00001" in the amount row ``` ## **Screenshots/Recordings** ### **Before** Screenshot 2025-11-14 at 12 21 17 Screenshot 2025-11-14 at 13 45 23 ### **After** Screenshot 2025-11-14 at 14 48 11 Screenshot 2025-11-14 at 13 44 53 ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Formats swap/bridge amounts with a display threshold and fixes token transfer direction/action keys; updates UI/tests and i18n accordingly. > > - **Number formatting**: > - Add `formatAmountWithThreshold` and `MINIMUM_DISPLAY_THRESHOLD` in `app/util/number`, capping to 5 decimals and showing `< 0.00001` for tiny values. > - Use in `app/components/UI/Bridge/utils/transaction-history.ts` and `app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx` to render amounts. > - **Swaps/Bridge display**: > - `decodeSwapsTx` and UI now show source amounts without trailing zeros (e.g., `5 USDC`) and use raw amounts for summaries. > - `decodeBridgeTx` applies threshold formatting (e.g., `-0.00099 ETH`) and sets `notificationKey` to `undefined`. > - **Transaction direction logic**: > - In `app/components/UI/TransactionElement/utils.js`, compute `isSent` via lowercase `from` vs `selectedAddress` and set `transactionType` using `isIncoming`. > - In `app/util/transactions/index.js`, update `getActionKey` for `SEND_TOKEN_ACTION_KEY` to return `sent_tokens`/`received_tokens`/`self_sent_tokens`; leave ETH logic consistent. > - Add i18n key `transactions.received_tokens` in `locales/languages/en.json`. > - **Tests**: > - Update tests in `transaction-history.test.ts` and `MultichainBridgeTransactionListItem.test.tsx` to expect new amount formats and threshold behaviors. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e50052dadd50dfc904615c2ff3e959b54b5ec877. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Bridge/utils/transaction-history.test.ts | 9 ++-- .../UI/Bridge/utils/transaction-history.ts | 29 +++++++---- ...ltichainBridgeTransactionListItem.test.tsx | 50 ++++++++++++++++++- .../MultichainBridgeTransactionListItem.tsx | 11 ++-- app/components/UI/TransactionElement/utils.js | 10 ++-- app/util/number/index.js | 19 +++++++ app/util/transactions/index.js | 22 ++++++++ locales/languages/en.json | 1 + 8 files changed, 126 insertions(+), 25 deletions(-) diff --git a/app/components/UI/Bridge/utils/transaction-history.test.ts b/app/components/UI/Bridge/utils/transaction-history.test.ts index 5a7710046ac..ad3ec7b132f 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 b79ec88398c..d272b9776bf 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 c6ca3117b1a..5b7fb0bd6f4 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 107f07d6ad0..3111a06a043 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 ( <> 0) { + return `< ${MINIMUM_DISPLAY_THRESHOLD}`; + } + return limitToMaximumDecimalPlaces(num, maxDecimalPlaces); +} + /** * Converts fiat number as human-readable fiat string to token miniml unit expressed as a BN * diff --git a/app/util/transactions/index.js b/app/util/transactions/index.js index a5217a4c44e..35cf5fe4068 100644 --- a/app/util/transactions/index.js +++ b/app/util/transactions/index.js @@ -643,6 +643,28 @@ export function isTransactionIncomplete(status) { */ export async function getActionKey(tx, selectedAddress, ticker, chainId) { const actionKey = await getTransactionActionKey(tx, chainId); + + // Handle token transfers with direction logic (similar to ETH transfers) + if (actionKey === SEND_TOKEN_ACTION_KEY) { + const fromAddress = safeToChecksumAddress(tx.txParams.from)?.toLowerCase(); + const toAddress = safeToChecksumAddress(tx.txParams.to)?.toLowerCase(); + const selectedAddr = selectedAddress?.toLowerCase(); + + const sentByUser = fromAddress === selectedAddr; + const incoming = !sentByUser; + const selfSent = fromAddress === selectedAddr && toAddress === selectedAddr; + + if (selfSent) { + return strings('transactions.self_sent_tokens'); + } + + if (incoming) { + return strings('transactions.received_tokens'); + } + + return strings('transactions.sent_tokens'); + } + if (actionKey === SEND_ETHER_ACTION_KEY) { let currencySymbol = ticker; diff --git a/locales/languages/en.json b/locales/languages/en.json index ce365af8e98..a0e712b3165 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -3475,6 +3475,7 @@ "self_sent_dai": "Sent Yourself DAI", "received_dai": "Received DAI", "sent_tokens": "Sent Tokens", + "received_tokens": "Received Tokens", "ether": "Ether", "sent_unit": "Sent {{unit}}", "self_sent_unit": "Sent Yourself {{unit}}", From 3ae787a68ed3d87de788d157f3023edf148d530c Mon Sep 17 00:00:00 2001 From: Pavel Dvorkin Date: Mon, 17 Nov 2025 10:06:43 -0500 Subject: [PATCH 06/12] chore: Reenable Autocreate Release PR workflow (#22330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Reenable the autocreate release PR after automating getting build number in create-release-pr workflow in previous [PR](https://github.com/MetaMask/metamask-mobile/pull/21687) Testing: https://github.com/consensys-test/metamask-mobile-test-workflow/actions/runs/19174950933/job/54817224179 ## **Changelog** CHANGELOG entry: None ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Adds a workflow to auto-create release PRs by extracting semver from `release/*` branches and invoking the reusable release PR workflow. > > - **CI**: > - **New workflow** `/.github/workflows/auto-create-release-pr.yml`: > - Triggers on repo `create` events for `release/*` branches. > - Extracts `semver` via `.github/scripts/extract-semver.sh`. > - Invokes reusable `create-release-pr.yml` with `semver-version` and required secrets (`PR_TOKEN`, `GCP_RLS_SHEET_ACCOUNT_BASE64`). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 690dcc6b847d25a7d706287868f1d8d1938b0a85. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- ...-create-release-pr.yml.disabled => auto-create-release-pr.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{auto-create-release-pr.yml.disabled => auto-create-release-pr.yml} (100%) 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 From a8295c62752a0c150321e7b6606108a548277da1 Mon Sep 17 00:00:00 2001 From: jvbriones <1674192+jvbriones@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:30:33 +0100 Subject: [PATCH 07/12] chore: rename test suite for the test report (#22798) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Rename prediction market smoke test suite names in Android/iOS E2E workflows from underscore to hyphen format. > > - **CI / Workflows**: > - Rename `test-suite-name` for prediction market smoke tests to hyphenated form: > - Android (`.github/workflows/run-e2e-smoke-tests-android.yml`): `prediction_market_android_smoke-*` → `prediction-market-android-smoke-*`. > - iOS (`.github/workflows/run-e2e-smoke-tests-ios.yml`): `prediction_market_ios_smoke-*` → `prediction-market-ios-smoke-*`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit dc56721b41525c1869ef177fa4634554d64f8a20. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/workflows/run-e2e-smoke-tests-android.yml | 2 +- .github/workflows/run-e2e-smoke-tests-ios.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-e2e-smoke-tests-android.yml b/.github/workflows/run-e2e-smoke-tests-android.yml index ea9e35aa3c9..9d9dd2988b5 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 3265ab8d1a2..710b89ecbe1 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 }} From 83230ef5f03131b8c650fdfef860af6dcfdc74f9 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Mon, 17 Nov 2025 16:45:19 +0100 Subject: [PATCH 08/12] fix(perps): use centralized ROE calculation in live positions cp-7.59.0 (#22791) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR refactors the Return on Equity (ROE) calculation logic in the usePerpsLivePositions hook to use the centralized calculateRoEForPrice utility function instead of the previous manual calculation. ## **Changelog** CHANGELOG entry: Fixed a bug where the PnL was showing incorrect values in the position card ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2080 Fixes https://github.com/MetaMask/metamask-mobile/issues/22790 ## **Manual testing steps** ```gherkin Feature: Live ROE calculation for Perps positions Scenario: user views live positions with updated ROE Given user has open perpetual positions And the positions are being streamed with live price updates When the market price changes Then the ROE percentage should update in real-time And the ROE should be calculated consistently with TP/SL ROE calculations And the ROE should properly reflect the position direction (long/short) And the ROE should account for the position's leverage ``` ## **Screenshots/Recordings** ### **Before** PnL on the list is not matching the PnL on the position card https://github.com/user-attachments/assets/72729f8d-080f-45a5-bcd0-ce3776f2efde ### **After** PnL in the position card is matching the PnL from the list Simulator Screenshot - iPhone 16e -
2025-11-17 at 13 14 10 Simulator Screenshot - iPhone 16e -
2025-11-17 at 13 14 02 ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Reworks live PnL/ROE to use centralized ROE utility, prefer `price` over `markPrice`, and handle leverage when margin is invalid, with corresponding test updates. > > - **Perps Live Positions Hook (`usePerpsLivePositions.ts`)**: > - Use centralized `calculateRoEForPrice` for `returnOnEquity`. > - Prefer `price` over `markPrice` for live calculations; validate positive numeric `currentPrice`. > - Compute PnL as `(currentPrice - entryPrice) * size`; infer direction from `size` (supports shorts). > - Derive ROE from leverage (fallback when `marginUsed` invalid); ignore invalid `entryPrice`/`size`/price. > - **Tests (`useLivePositions.test.ts`)**: > - Update expectations to reflect `price` precedence and centralized ROE calc. > - Add coverage for short positions and leverage-based ROE when `marginUsed` is NaN. > - Maintain behavior when price data missing/empty and multiple position scenarios. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 75a749fbe640d70c562b869a983f6e7c70ef74d2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../hooks/stream/useLivePositions.test.ts | 16 +++++--- .../hooks/stream/usePerpsLivePositions.ts | 40 ++++++++++++------- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts b/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts index 451ebeb36e8..97da0adcd31 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 488e8731186..312ffc26290 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, From 4ba3db23b68bb0e70f88d030c6223ad60f80e7bd Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 17 Nov 2025 16:04:35 +0000 Subject: [PATCH 09/12] fix: cp-7.60.0 predict confirmation design (#22745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Minor design fixes for Predict deposit confirmation. - Increase height of confirm button. - Update keyboard done label. - Hide alert banner. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: #22726 #22731 #22761 ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Hides the alert banner for predict/perps flows, enlarges the confirm button, renames the keyboard “Done” action to “Continue,” and updates tests/i18n accordingly. > > - **Confirmations UI**: > - Extend `AlertBanner` ignore list to `perpsDeposit`, `predictDeposit`, `predictWithdraw`. > - Centralize scroll disabling via `TRANSACTION_TYPES_DISABLE_SCROLL`; apply to `ScrollView`. > - **Custom Amount / Keyboard**: > - Keyboard done label now uses `strings('confirm.edit_amount_done')` (text changed to “Continue”). > - Confirm button set to `ButtonSize.Lg`. > - **Tests & i18n**: > - Update tests to reference `strings('confirm.edit_amount_done')`. > - Update `locales/languages/en.json`: `confirm.edit_amount_done` → "Continue". > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit dd2677c6c53cb0f0ed75ff19909685d6fb9d6650. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../components/confirm/confirm-component.tsx | 14 ++++++++++++-- .../deposit-keyboard/deposit-keyboard.tsx | 2 +- .../edit-amount-keyboard.test.tsx | 9 +++++---- .../custom-amount-info/custom-amount-info.test.tsx | 4 +--- .../info/custom-amount-info/custom-amount-info.tsx | 4 ++-- locales/languages/en.json | 2 +- 6 files changed, 22 insertions(+), 13 deletions(-) diff --git a/app/components/Views/confirmations/components/confirm/confirm-component.tsx b/app/components/Views/confirmations/components/confirm/confirm-component.tsx index 9dba197333b..2b8e9a85508 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 52171c611aa..de90632f044 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 && ( + ); +}; + +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 00000000000..cdb674e245a --- /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 00000000000..3b84a3cd4a9 --- /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/images/rewards/metamask-rewards-points-alternative.svg b/app/images/rewards/metamask-rewards-points-alternative.svg new file mode 100644 index 00000000000..936341681cb --- /dev/null +++ b/app/images/rewards/metamask-rewards-points-alternative.svg @@ -0,0 +1,3 @@ + + + diff --git a/locales/languages/en.json b/locales/languages/en.json index 28ade34df25..22c95cb9abd 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6814,7 +6814,8 @@ "unsupported": "Unsupported", "tracked_count": "{{optedIn}}/{{total}} tracked", "link_account_success": "{{accountName}} added successfully", - "link_account_error": "Failed to add one or more addresses for {{accountName}}" + "link_account_error": "Failed to add one or more addresses for {{accountName}}", + "link_account_address_error": "Failed to add {{address}}" }, "active_boosts_title": "Active boosts", "season_1": "Season 1",