From af42dafdc691b4ca3cd81f768956a2421bafb641 Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Wed, 26 Nov 2025 14:51:48 +0100 Subject: [PATCH 01/16] chore: re-organize trending and bug fixes (#23280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** File changes: - Moved Sites outside of Trending folder since it should be its own screen - Flattened out index files to continue with the pattern we have been following until now Improvements: - Updated **prediction** market single and multiple components to handle correct navigation and allow removing prediction navigation from the Trending stack - Made Sites screen full-screen like the rest of screens - Made a reusable footer component for sites to be reused in Trending to avoid duplication - Moved BrowserWrapper from of the Trending navigation to the main navigation for full-screen support - Removed shadow from bottom browser - Stop using explorerSearchBar in browser and instead use the correct header bar Bug fixes: - When opening a site from the sites full view and clicking on the back button it takes users to the main trending screen. Expected to go one step back ## **Changelog** CHANGELOG entry: improvements for trending feature ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-1837 ## **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** https://github.com/user-attachments/assets/b936b347-4c25-4187-82e4-9a2b8ff9d619 ## **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] > Extracts Sites into a new full-screen flow with reusable list/search, moves the Trending browser to main navigation, updates Predict navigation, and removes bottom bar shadow. > > - **Navigation/Routes**: > - Add `TrendingBrowser` screen in main navigator using `BrowserWrapper` for full-screen browser and back behavior. > - Replace `Routes.SITES_LIST_VIEW` with `Routes.SITES_FULL_VIEW`; wire up stack transition in `MainNavigator`. > - Simplify `TrendingView` stack to `TrendingFeed` and `ExploreSearch` only; remove in-stack browser and Predict screens. > > - **Sites (extracted from Trending)**: > - New full-screen `SitesFullView` with header search, pull-to-refresh, filtering, and footer actions. > - New reusable components: `SitesList` (FlashList), `SiteRowItem`, `SiteRowItemWrapper`, `SiteSkeleton`, and `SitesSearchFooter`. > - New `useSitesData` hook path (`UI/Sites/...`) and API fetch with limit param; update references across app and search. > - Update Trending sections config to use new Sites components and `viewAll` to `SITES_FULL_VIEW`. > > - **Search**: > - Reuse `SitesSearchFooter` in Explore search results; footer only when query present. > > - **Predict**: > - Update `PredictMarketSingle`/`Multiple` to navigate via `Routes.PREDICT.ROOT` nested screens. > > - **Browser UI**: > - Replace `ElevatedView` with `View` in `BrowserBottomBar` (remove shadow) and adjust snapshots. > > - **Removals/cleanup**: > - Delete legacy `TrendingView/SitesListView` and related indices/exports; update tests and imports accordingly. > > - **Tests**: > - Add comprehensive tests for new Sites views/components and update snapshots for navigation and browser bottom bar. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 45fc7bb0098153c83aa05374911fcfb8ebbf432e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/Nav/Main/MainNavigator.js | 64 +- .../__snapshots__/MainNavigator.test.tsx.snap | 21 + .../__snapshots__/index.test.tsx.snap | 216 ++---- app/components/UI/BrowserBottomBar/index.tsx | 7 +- .../PredictMarketMultiple.test.tsx | 29 +- .../PredictMarketMultiple.tsx | 26 +- .../PredictMarketSingle.test.tsx | 29 +- .../PredictMarketSingle.tsx | 26 +- .../SiteRowItem/SiteRowItem.test.tsx | 38 - .../components}/SiteRowItem/SiteRowItem.tsx | 9 +- .../SiteRowItemWrapper.test.tsx | 77 +- .../SiteRowItemWrapper.tsx | 10 +- .../SiteSkeleton/SiteSkeleton.test.tsx | 34 - .../components}/SiteSkeleton/SiteSkeleton.tsx | 11 +- .../components/SitesList/SitesList.test.tsx | 197 ++++++ .../Sites/components/SitesList/SitesList.tsx | 41 ++ .../SitesSearchFooter.test.tsx | 246 +++++++ .../SitesSearchFooter/SitesSearchFooter.tsx | 123 ++++ .../hooks/useSiteData}/useSiteData.test.ts | 0 .../Sites/hooks/useSiteData}/useSitesData.ts | 2 +- .../__snapshots__/index.test.tsx.snap | 27 +- .../SitesFullView/SitesFullView.test.tsx | 465 +++++++++++++ .../Views/SitesFullView/SitesFullView.tsx | 154 ++++ .../ExploreSearchResults.test.tsx | 248 +------ .../ExploreSearchResults.tsx | 106 +-- .../config/useExploreSearch.test.ts | 2 +- .../SectionSites/SiteRowItem/index.ts | 2 - .../SectionSites/SiteSkeleton/index.ts | 1 - .../TrendingView/SectionSites/hooks/index.ts | 1 - .../Views/TrendingView/SectionSites/index.ts | 5 - .../SitesListView/SitesListView.test.tsx | 656 ------------------ .../SitesListView/SitesListView.tsx | 226 ------ .../Views/TrendingView/SitesListView/index.ts | 1 - .../Views/TrendingView/TrendingView.test.tsx | 166 +---- .../Views/TrendingView/TrendingView.tsx | 66 +- .../BrowserWrapper/BrowserWrapper.tsx | 37 + .../TrendingView/config/sections.config.tsx | 10 +- app/constants/navigation/Routes.ts | 2 +- 38 files changed, 1535 insertions(+), 1846 deletions(-) rename app/components/{Views/TrendingView/SectionSites => UI/Sites/components}/SiteRowItem/SiteRowItem.test.tsx (76%) rename app/components/{Views/TrendingView/SectionSites => UI/Sites/components}/SiteRowItem/SiteRowItem.tsx (91%) rename app/components/{Views/TrendingView/SectionSites => UI/Sites/components/SiteRowItemWrapper}/SiteRowItemWrapper.test.tsx (78%) rename app/components/{Views/TrendingView/SectionSites => UI/Sites/components/SiteRowItemWrapper}/SiteRowItemWrapper.tsx (70%) rename app/components/{Views/TrendingView/SectionSites => UI/Sites/components}/SiteSkeleton/SiteSkeleton.test.tsx (62%) rename app/components/{Views/TrendingView/SectionSites => UI/Sites/components}/SiteSkeleton/SiteSkeleton.tsx (77%) create mode 100644 app/components/UI/Sites/components/SitesList/SitesList.test.tsx create mode 100644 app/components/UI/Sites/components/SitesList/SitesList.tsx create mode 100644 app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx create mode 100644 app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx rename app/components/{Views/TrendingView/SectionSites/hooks => UI/Sites/hooks/useSiteData}/useSiteData.test.ts (100%) rename app/components/{Views/TrendingView/SectionSites/hooks => UI/Sites/hooks/useSiteData}/useSitesData.ts (97%) create mode 100644 app/components/Views/SitesFullView/SitesFullView.test.tsx create mode 100644 app/components/Views/SitesFullView/SitesFullView.tsx delete mode 100644 app/components/Views/TrendingView/SectionSites/SiteRowItem/index.ts delete mode 100644 app/components/Views/TrendingView/SectionSites/SiteSkeleton/index.ts delete mode 100644 app/components/Views/TrendingView/SectionSites/hooks/index.ts delete mode 100644 app/components/Views/TrendingView/SectionSites/index.ts delete mode 100644 app/components/Views/TrendingView/SitesListView/SitesListView.test.tsx delete mode 100644 app/components/Views/TrendingView/SitesListView/SitesListView.tsx delete mode 100644 app/components/Views/TrendingView/SitesListView/index.ts create mode 100644 app/components/Views/TrendingView/components/BrowserWrapper/BrowserWrapper.tsx diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 470de7876337..2ea2e0cb4cbe 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -52,7 +52,8 @@ import ContactForm from '../../Views/Settings/Contacts/ContactForm'; import ActivityView from '../../Views/ActivityView'; import RewardsNavigator from '../../UI/Rewards/RewardsNavigator'; import TrendingView from '../../Views/TrendingView/TrendingView'; -import SitesListView from '../../Views/TrendingView/SitesListView'; +import SwapsAmountView from '../../UI/Swaps'; +import SwapsQuotesView from '../../UI/Swaps/QuotesView'; import CollectiblesDetails from '../../UI/CollectibleModal'; import OptinMetrics from '../../UI/OptinMetrics'; @@ -131,6 +132,8 @@ import { TOKEN, } from '../../Views/AddAsset/AddAsset.constants'; import { strings } from '../../../../locales/i18n'; +import SitesFullView from '../../Views/SitesFullView/SitesFullView'; +import BrowserWrapper from '../../Views/TrendingView/components/BrowserWrapper/BrowserWrapper'; import BridgeView from '../../UI/Bridge/Views/BridgeView'; const Stack = createStackNavigator(); @@ -291,26 +294,6 @@ const TrendingHome = () => ( component={TrendingView} options={{ headerShown: false }} /> - ({ - cardStyle: { - transform: [ - { - translateX: current.progress.interpolate({ - inputRange: [0, 1], - outputRange: [layouts.screen.width, 0], - }), - }, - ], - }, - }), - }} - /> ); @@ -967,6 +950,26 @@ const MainNavigator = () => { }} /> + ({ + 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], + }), + }, + ], + }, + }), + }} + /> + + = ({ const optionsDisabled = !toggleOptions; return ( - + = ({ style={[styles.icon, optionsDisabled && styles.disabledIcon]} /> - + ); }; diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx index b552bd41f9a5..c05c0db60bba 100644 --- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx +++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx @@ -119,27 +119,27 @@ describe('PredictMarketMultiple', () => { // Press the "Yes" button fireEvent.press(buttons[0]); - expect(mockNavigate).toHaveBeenCalledWith( - Routes.PREDICT.MODALS.BUY_PREVIEW, - { + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MODALS.BUY_PREVIEW, + params: { market: mockMarket, outcome: mockMarket.outcomes[0], outcomeToken: mockMarket.outcomes[0].tokens[0], entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED, }, - ); + }); // Press the "No" button fireEvent.press(buttons[1]); - expect(mockNavigate).toHaveBeenCalledWith( - Routes.PREDICT.MODALS.BUY_PREVIEW, - { + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MODALS.BUY_PREVIEW, + params: { market: mockMarket, outcome: mockMarket.outcomes[0], outcomeToken: mockMarket.outcomes[0].tokens[1], entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED, }, - ); + }); }); it('handle missing or invalid market data gracefully', () => { @@ -296,11 +296,14 @@ describe('PredictMarketMultiple', () => { ); fireEvent.press(marketTitle); - expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MARKET_DETAILS, { - marketId: mockMarket.id, - entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED, - title: mockMarket.title, - image: mockMarket.image, + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: mockMarket.id, + entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED, + title: mockMarket.title, + image: mockMarket.image, + }, }); }); diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx index 5374c0910ed5..68bb4241a3ff 100644 --- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx +++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx @@ -125,11 +125,14 @@ const PredictMarketMultiple: React.FC = ({ ) => { executeGuardedAction( () => { - navigation.navigate(Routes.PREDICT.MODALS.BUY_PREVIEW, { - market, - outcome, - outcomeToken, - entryPoint, + navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MODALS.BUY_PREVIEW, + params: { + market, + outcome, + outcomeToken, + entryPoint, + }, }); }, { @@ -148,11 +151,14 @@ const PredictMarketMultiple: React.FC = ({ { - navigation.navigate(Routes.PREDICT.MARKET_DETAILS, { - marketId: market.id, - entryPoint, - title: market.title, - image: market.image, + navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: market.id, + entryPoint, + title: market.title, + image: market.image, + }, }); }} > diff --git a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx index 2a657c6fa82c..956b7f2bf232 100644 --- a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx +++ b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx @@ -140,26 +140,26 @@ describe('PredictMarketSingle', () => { const noButton = getByText('No'); fireEvent.press(yesButton); - expect(mockNavigate).toHaveBeenCalledWith( - Routes.PREDICT.MODALS.BUY_PREVIEW, - { + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MODALS.BUY_PREVIEW, + params: { market: mockMarket, outcome: mockOutcome, outcomeToken: mockOutcome.tokens[0], entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED, }, - ); + }); fireEvent.press(noButton); - expect(mockNavigate).toHaveBeenCalledWith( - Routes.PREDICT.MODALS.BUY_PREVIEW, - { + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MODALS.BUY_PREVIEW, + params: { market: mockMarket, outcome: mockOutcome, outcomeToken: mockOutcome.tokens[1], entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED, }, - ); + }); }); it('handle missing or invalid market data gracefully', () => { @@ -311,11 +311,14 @@ describe('PredictMarketSingle', () => { ); fireEvent.press(marketTitle); - expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MARKET_DETAILS, { - marketId: mockMarket.id, - entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED, - title: mockMarket.title, - image: mockMarket.image, + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: mockMarket.id, + entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED, + title: mockMarket.title, + image: mockMarket.image, + }, }); }); diff --git a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx index 101529f8d632..0ab281675351 100644 --- a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx +++ b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx @@ -175,11 +175,14 @@ const PredictMarketSingle: React.FC = ({ const handleBuy = (token: PredictOutcomeToken) => { executeGuardedAction( () => { - navigation.navigate(Routes.PREDICT.MODALS.BUY_PREVIEW, { - market, - outcome, - outcomeToken: token, - entryPoint, + navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MODALS.BUY_PREVIEW, + params: { + market, + outcome, + outcomeToken: token, + entryPoint, + }, }); }, { @@ -193,11 +196,14 @@ const PredictMarketSingle: React.FC = ({ { - navigation.navigate(Routes.PREDICT.MARKET_DETAILS, { - marketId: market.id, - entryPoint, - title: market.title, - image: getImageUrl(), + navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: market.id, + entryPoint, + title: market.title, + image: getImageUrl(), + }, }); }} > diff --git a/app/components/Views/TrendingView/SectionSites/SiteRowItem/SiteRowItem.test.tsx b/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.test.tsx similarity index 76% rename from app/components/Views/TrendingView/SectionSites/SiteRowItem/SiteRowItem.test.tsx rename to app/components/UI/Sites/components/SiteRowItem/SiteRowItem.test.tsx index 37af81dc712a..d5e205d53083 100644 --- a/app/components/Views/TrendingView/SectionSites/SiteRowItem/SiteRowItem.test.tsx +++ b/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.test.tsx @@ -66,44 +66,6 @@ describe('SiteRowItem', () => { }); }); - describe('padding behavior', () => { - it('renders with isViewAll prop set to true', () => { - const site = createSite(); - - const { getByTestId } = render( - , - ); - - const pressable = getByTestId('site-row-item'); - expect(pressable).toBeOnTheScreen(); - // Component renders successfully with isViewAll={true} - }); - - it('renders with isViewAll prop set to false', () => { - const site = createSite(); - - const { getByTestId } = render( - , - ); - - const pressable = getByTestId('site-row-item'); - expect(pressable).toBeOnTheScreen(); - // Component renders successfully with isViewAll={false} - }); - - it('renders with isViewAll prop not provided', () => { - const site = createSite(); - - const { getByTestId } = render( - , - ); - - const pressable = getByTestId('site-row-item'); - expect(pressable).toBeOnTheScreen(); - // Component renders successfully with default isViewAll - }); - }); - describe('interaction', () => { it('calls onPress when pressed', () => { const site = createSite(); diff --git a/app/components/Views/TrendingView/SectionSites/SiteRowItem/SiteRowItem.tsx b/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.tsx similarity index 91% rename from app/components/Views/TrendingView/SectionSites/SiteRowItem/SiteRowItem.tsx rename to app/components/UI/Sites/components/SiteRowItem/SiteRowItem.tsx index 72e0d5b414b7..4f49b2a1d50d 100644 --- a/app/components/Views/TrendingView/SectionSites/SiteRowItem/SiteRowItem.tsx +++ b/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.tsx @@ -22,14 +22,9 @@ export interface SiteData { interface SiteRowItemProps { site: SiteData; onPress: () => void; - isViewAll?: boolean; } -const SiteRowItem = ({ - site, - onPress, - isViewAll = false, -}: SiteRowItemProps) => { +const SiteRowItem = ({ site, onPress }: SiteRowItemProps) => { const tw = useTailwind(); const [imageError, setImageError] = useState(false); @@ -42,7 +37,7 @@ const SiteRowItem = ({ {/* Logo */} diff --git a/app/components/Views/TrendingView/SectionSites/SiteRowItemWrapper.test.tsx b/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.test.tsx similarity index 78% rename from app/components/Views/TrendingView/SectionSites/SiteRowItemWrapper.test.tsx rename to app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.test.tsx index df79947456c3..90762de9a586 100644 --- a/app/components/Views/TrendingView/SectionSites/SiteRowItemWrapper.test.tsx +++ b/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.test.tsx @@ -1,27 +1,26 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import SiteRowItemWrapper from './SiteRowItemWrapper'; -import { updateLastTrendingScreen } from '../../../Nav/Main/MainNavigator'; +import { updateLastTrendingScreen } from '../../../../Nav/Main/MainNavigator'; import type { NavigationProp, ParamListBase } from '@react-navigation/native'; -import type { SiteData } from './SiteRowItem/SiteRowItem'; +import type { SiteData } from '../SiteRowItem/SiteRowItem'; // Mock the dependencies -jest.mock('../../../Nav/Main/MainNavigator', () => ({ +jest.mock('../../../../Nav/Main/MainNavigator', () => ({ updateLastTrendingScreen: jest.fn(), })); -jest.mock('./SiteRowItem/SiteRowItem', () => { +jest.mock('../SiteRowItem/SiteRowItem', () => { const { TouchableOpacity, Text } = jest.requireActual('react-native'); return { __esModule: true, - default: jest.fn(({ onPress, site, isViewAll }) => ( + default: jest.fn(({ onPress, site }) => ( {site.id} {site.name} {site.url} {site.displayUrl} - {String(isViewAll)} {site.logoUrl && {site.logoUrl}} {site.featured && Featured} @@ -87,26 +86,6 @@ describe('SiteRowItemWrapper', () => { ); }); - it('should pass isViewAll as false by default', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId('is-view-all').props.children).toBe('false'); - }); - - it('should pass isViewAll as true when provided', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId('is-view-all').props.children).toBe('true'); - }); - it('should render site with logoUrl', () => { const { getByTestId } = render( , @@ -271,40 +250,6 @@ describe('SiteRowItemWrapper', () => { expect(mockNavigation.navigate).toHaveBeenCalledTimes(3); }); - it('should use current timestamp on each press', () => { - dateNowSpy.mockRestore(); - const timestamps = [1000000000, 2000000000, 3000000000]; - let callCount = 0; - - dateNowSpy = jest - .spyOn(Date, 'now') - .mockImplementation(() => timestamps[callCount++]); - - const { getByTestId } = render( - , - ); - - const siteRowItem = getByTestId('site-row-item'); - - fireEvent.press(siteRowItem); - expect(mockNavigation.navigate).toHaveBeenLastCalledWith( - 'TrendingBrowser', - expect.objectContaining({ timestamp: 1000000000 }), - ); - - fireEvent.press(siteRowItem); - expect(mockNavigation.navigate).toHaveBeenLastCalledWith( - 'TrendingBrowser', - expect.objectContaining({ timestamp: 2000000000 }), - ); - - fireEvent.press(siteRowItem); - expect(mockNavigation.navigate).toHaveBeenLastCalledWith( - 'TrendingBrowser', - expect.objectContaining({ timestamp: 3000000000 }), - ); - }); - it('should always pass fromTrending as true', () => { const { getByTestId } = render( , @@ -343,17 +288,5 @@ describe('SiteRowItemWrapper', () => { fromTrending: true, }); }); - - it('should work with isViewAll explicitly set to false', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId('is-view-all').props.children).toBe('false'); - }); }); }); diff --git a/app/components/Views/TrendingView/SectionSites/SiteRowItemWrapper.tsx b/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.tsx similarity index 70% rename from app/components/Views/TrendingView/SectionSites/SiteRowItemWrapper.tsx rename to app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.tsx index 8221682c7c12..38ce335a0573 100644 --- a/app/components/Views/TrendingView/SectionSites/SiteRowItemWrapper.tsx +++ b/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.tsx @@ -1,18 +1,16 @@ import React from 'react'; import type { NavigationProp, ParamListBase } from '@react-navigation/native'; -import SiteRowItem, { type SiteData } from './SiteRowItem/SiteRowItem'; -import { updateLastTrendingScreen } from '../../../Nav/Main/MainNavigator'; +import SiteRowItem, { type SiteData } from '../SiteRowItem/SiteRowItem'; +import { updateLastTrendingScreen } from '../../../../Nav/Main/MainNavigator'; interface SiteRowItemWrapperProps { site: SiteData; navigation: NavigationProp; - isViewAll?: boolean; } const SiteRowItemWrapper: React.FC = ({ site, navigation, - isViewAll = false, }) => { const handlePress = () => { // Update last trending screen state @@ -26,9 +24,7 @@ const SiteRowItemWrapper: React.FC = ({ }); }; - return ( - - ); + return ; }; export default SiteRowItemWrapper; diff --git a/app/components/Views/TrendingView/SectionSites/SiteSkeleton/SiteSkeleton.test.tsx b/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.test.tsx similarity index 62% rename from app/components/Views/TrendingView/SectionSites/SiteSkeleton/SiteSkeleton.test.tsx rename to app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.test.tsx index b249be13ed7b..7a418ceb5356 100644 --- a/app/components/Views/TrendingView/SectionSites/SiteSkeleton/SiteSkeleton.test.tsx +++ b/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.test.tsx @@ -74,38 +74,4 @@ describe('SiteSkeleton', () => { }); }); }); - - describe('padding behavior', () => { - it('does not apply horizontal padding when isViewAll is false', () => { - const { getAllByTestId } = render(); - - const skeletons = getAllByTestId('skeleton'); - const container = skeletons[0].parent; - const styles = Array.isArray(container?.props.style) - ? container?.props.style - : [container?.props.style]; - const hasPaddingHorizontal = styles.some( - (style: { paddingHorizontal?: number }) => - style?.paddingHorizontal === 8, - ); - - expect(hasPaddingHorizontal).toBe(false); - }); - - it('does not apply horizontal padding when isViewAll is not provided', () => { - const { getAllByTestId } = render(); - - const skeletons = getAllByTestId('skeleton'); - const container = skeletons[0].parent; - const styles = Array.isArray(container?.props.style) - ? container?.props.style - : [container?.props.style]; - const hasPaddingHorizontal = styles.some( - (style: { paddingHorizontal?: number }) => - style?.paddingHorizontal === 8, - ); - - expect(hasPaddingHorizontal).toBe(false); - }); - }); }); diff --git a/app/components/Views/TrendingView/SectionSites/SiteSkeleton/SiteSkeleton.tsx b/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.tsx similarity index 77% rename from app/components/Views/TrendingView/SectionSites/SiteSkeleton/SiteSkeleton.tsx rename to app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.tsx index f2165549503f..130d3a817225 100644 --- a/app/components/Views/TrendingView/SectionSites/SiteSkeleton/SiteSkeleton.tsx +++ b/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.tsx @@ -8,9 +8,6 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingVertical: 16, }, - containerViewAll: { - paddingHorizontal: 8, - }, iconSkeleton: { borderRadius: 20, marginBottom: 0, @@ -27,12 +24,8 @@ const styles = StyleSheet.create({ }, }); -interface SiteSkeletonProps { - isViewAll?: boolean; -} - -const SiteSkeleton = ({ isViewAll = false }: SiteSkeletonProps) => ( - +const SiteSkeleton = () => ( + {/* Logo skeleton */} diff --git a/app/components/UI/Sites/components/SitesList/SitesList.test.tsx b/app/components/UI/Sites/components/SitesList/SitesList.test.tsx new file mode 100644 index 000000000000..d6607f79cbde --- /dev/null +++ b/app/components/UI/Sites/components/SitesList/SitesList.test.tsx @@ -0,0 +1,197 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import SitesList from './SitesList'; +import { useNavigation } from '@react-navigation/native'; +import type { SiteData } from '../SiteRowItem/SiteRowItem'; + +// Mock FlashList to render items in tests +jest.mock('@shopify/flash-list', () => { + const { View } = jest.requireActual('react-native'); + return { + FlashList: ({ + data, + renderItem, + keyExtractor, + testID, + refreshControl, + ListFooterComponent, + }: { + data: SiteData[]; + renderItem: ({ item }: { item: SiteData }) => React.ReactElement; + keyExtractor: (item: SiteData) => string; + testID: string; + refreshControl?: React.ReactElement; + ListFooterComponent?: React.ReactElement | null; + showsVerticalScrollIndicator?: boolean; + }) => ( + + {data.map((item: SiteData) => { + const key = keyExtractor(item); + return ( + + {renderItem({ item })} + + ); + })} + {refreshControl} + {ListFooterComponent} + + ), + }; +}); + +// Mock dependencies +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), +})); + +jest.mock('../SiteRowItemWrapper/SiteRowItemWrapper', () => { + const { View, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + site, + navigation, + }: { + site: SiteData; + navigation: unknown; + }) => ( + + {site.id} + {site.name} + {String(!!navigation)} + + ), + }; +}); + +describe('SitesList', () => { + const mockNavigation = { + navigate: jest.fn(), + }; + + const createSite = ( + id: string, + overrides: Partial = {}, + ): SiteData => ({ + id, + name: `Site ${id}`, + url: `https://site${id}.com`, + displayUrl: `site${id}.com`, + ...overrides, + }); + + beforeEach(() => { + jest.clearAllMocks(); + (useNavigation as jest.Mock).mockReturnValue(mockNavigation); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('rendering', () => { + it('renders with empty sites array', () => { + const { getByTestId, queryByTestId } = render(); + + expect(getByTestId('sites-list')).toBeOnTheScreen(); + expect(queryByTestId('site-wrapper-1')).toBeNull(); + }); + + it('renders with single site', () => { + const sites = [createSite('1')]; + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('site-wrapper-1')).toBeOnTheScreen(); + expect(queryByTestId('site-wrapper-2')).toBeNull(); + }); + + it('renders multiple sites', () => { + const sites = [ + createSite('1'), + createSite('2'), + createSite('3'), + createSite('4'), + createSite('5'), + ]; + + const { getByTestId } = render(); + + expect(getByTestId('site-wrapper-1')).toBeOnTheScreen(); + expect(getByTestId('site-wrapper-2')).toBeOnTheScreen(); + expect(getByTestId('site-wrapper-3')).toBeOnTheScreen(); + expect(getByTestId('site-wrapper-4')).toBeOnTheScreen(); + expect(getByTestId('site-wrapper-5')).toBeOnTheScreen(); + }); + }); + + describe('props passthrough', () => { + it('passes navigation to SiteRowItemWrapper', () => { + const sites = [createSite('1')]; + + const { getByTestId } = render(); + + expect(getByTestId('has-navigation-1').props.children).toBe('true'); + }); + }); + + describe('site data rendering', () => { + it('renders sites with correct data', () => { + const sites = [ + createSite('1', { name: 'MetaMask' }), + createSite('unique-id-2', { name: 'Uniswap' }), + createSite('3', { name: 'OpenSea' }), + ]; + + const { getByTestId } = render(); + + expect(getByTestId('site-name-1').props.children).toBe('MetaMask'); + expect(getByTestId('site-name-unique-id-2').props.children).toBe( + 'Uniswap', + ); + expect(getByTestId('site-id-unique-id-2').props.children).toBe( + 'unique-id-2', + ); + }); + }); + + describe('edge cases', () => { + it('renders with sites containing special characters in IDs', () => { + const sites = [ + createSite('site-with-dash'), + createSite('site_with_underscore'), + createSite('site.with.dot'), + ]; + + const { getByTestId } = render(); + + expect(getByTestId('site-wrapper-site-with-dash')).toBeOnTheScreen(); + expect( + getByTestId('site-wrapper-site_with_underscore'), + ).toBeOnTheScreen(); + expect(getByTestId('site-wrapper-site.with.dot')).toBeOnTheScreen(); + }); + + it('renders with large number of sites', () => { + const sites = Array.from({ length: 50 }, (_, i) => createSite(`${i}`)); + + const { getByTestId } = render(); + + expect(getByTestId('site-wrapper-0')).toBeOnTheScreen(); + expect(getByTestId('site-wrapper-25')).toBeOnTheScreen(); + expect(getByTestId('site-wrapper-49')).toBeOnTheScreen(); + }); + + it('renders when navigation is not provided', () => { + (useNavigation as jest.Mock).mockReturnValue(undefined); + const sites = [createSite('1')]; + + const { getByTestId } = render(); + + expect(getByTestId('has-navigation-1').props.children).toBe('false'); + }); + }); +}); diff --git a/app/components/UI/Sites/components/SitesList/SitesList.tsx b/app/components/UI/Sites/components/SitesList/SitesList.tsx new file mode 100644 index 000000000000..a6e783458985 --- /dev/null +++ b/app/components/UI/Sites/components/SitesList/SitesList.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { FlashList } from '@shopify/flash-list'; +import { useNavigation } from '@react-navigation/native'; +// eslint-disable-next-line no-duplicate-imports +import type { NavigationProp, ParamListBase } from '@react-navigation/native'; +import SiteRowItemWrapper from '../SiteRowItemWrapper/SiteRowItemWrapper'; +import type { SiteData } from '../SiteRowItem/SiteRowItem'; + +export interface SitesListProps { + sites: SiteData[]; + refreshControl?: React.ReactElement; + ListFooterComponent?: React.ReactElement | null; +} + +const SitesList: React.FC = ({ + sites, + refreshControl, + ListFooterComponent, +}) => { + const navigation = useNavigation>(); + + const renderSiteItem = ({ item }: { item: SiteData }) => ( + + ); + + return ( + item.id} + showsVerticalScrollIndicator={false} + refreshControl={refreshControl} + ListFooterComponent={ListFooterComponent} + /> + ); +}; + +SitesList.displayName = 'SitesList'; + +export default SitesList; diff --git a/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx new file mode 100644 index 000000000000..434ffbc62ade --- /dev/null +++ b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx @@ -0,0 +1,246 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { useNavigation } from '@react-navigation/native'; +// eslint-disable-next-line no-duplicate-imports +import type { NavigationProp, ParamListBase } from '@react-navigation/native'; +import SitesSearchFooter from './SitesSearchFooter'; + +// Mock dependencies +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), +})); + +describe('SitesSearchFooter', () => { + let mockNavigation: jest.Mocked>; + let dateNowSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + + mockNavigation = { + navigate: jest.fn(), + } as unknown as jest.Mocked>; + + (useNavigation as jest.Mock).mockReturnValue(mockNavigation); + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1234567890); + }); + + afterEach(() => { + dateNowSpy.mockRestore(); + jest.resetAllMocks(); + }); + + describe('rendering', () => { + it('returns null when searchQuery is empty', () => { + const { queryByTestId } = render(); + + expect(queryByTestId('trending-search-footer-google-link')).toBeNull(); + expect(queryByTestId('trending-search-footer-url-link')).toBeNull(); + }); + + it('renders Google search link when query is provided', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId('trending-search-footer-google-link'), + ).toBeOnTheScreen(); + }); + + it('renders URL link when query looks like a URL', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen(); + expect( + getByTestId('trending-search-footer-google-link'), + ).toBeOnTheScreen(); + }); + + it('does not render URL link when query is plain text', () => { + const { queryByTestId, getByTestId } = render( + , + ); + + expect(queryByTestId('trending-search-footer-url-link')).toBeNull(); + expect( + getByTestId('trending-search-footer-google-link'), + ).toBeOnTheScreen(); + }); + }); + + describe('URL detection', () => { + it('detects URLs with http protocol', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen(); + }); + + it('detects URLs with https protocol', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen(); + }); + + it('detects URLs with path', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen(); + }); + + it('detects URLs with query parameters', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen(); + }); + + it('detects subdomains as URLs', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen(); + }); + + it('does not detect plain text as URL', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('trending-search-footer-url-link')).toBeNull(); + }); + + it('does not detect single word as URL', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('trending-search-footer-url-link')).toBeNull(); + }); + }); + + describe('navigation', () => { + it('navigates to URL when URL link is pressed', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('trending-search-footer-url-link')); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('TrendingBrowser', { + newTabUrl: 'metamask.io', + timestamp: 1234567890, + fromTrending: true, + }); + expect(mockNavigation.navigate).toHaveBeenCalledTimes(1); + }); + + it('navigates to Google search when Google link is pressed', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('trending-search-footer-google-link')); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('TrendingBrowser', { + newTabUrl: 'https://www.google.com/search?q=ethereum', + timestamp: 1234567890, + fromTrending: true, + }); + expect(mockNavigation.navigate).toHaveBeenCalledTimes(1); + }); + + it('encodes special characters in Google search query', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('trending-search-footer-google-link')); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('TrendingBrowser', { + newTabUrl: 'https://www.google.com/search?q=ethereum%20%26%20bitcoin', + timestamp: 1234567890, + fromTrending: true, + }); + }); + }); + + describe('text display', () => { + it('displays search query in Google search link', () => { + const { getByText } = render( + , + ); + + expect(getByText('ethereum')).toBeOnTheScreen(); + expect(getByText(/on Google/)).toBeOnTheScreen(); + }); + }); + + describe('edge cases', () => { + it('handles URL with uppercase characters', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen(); + }); + + it('handles query with leading/trailing spaces', () => { + const { queryByTestId } = render( + , + ); + + // Component trims or handles spaces, but doesn't return null + expect( + queryByTestId('trending-search-footer-google-link'), + ).toBeOnTheScreen(); + }); + + it('handles URL with multiple subdomains', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen(); + }); + + it('handles URL with port number', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('trending-search-footer-url-link')).toBeNull(); + }); + + it('handles special characters in query', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId('trending-search-footer-google-link'), + ).toBeOnTheScreen(); + }); + + it('handles query with emojis', () => { + const { getByText, getByTestId } = render( + , + ); + + expect( + getByTestId('trending-search-footer-google-link'), + ).toBeOnTheScreen(); + expect(getByText('ethereum 🚀')).toBeOnTheScreen(); + }); + }); +}); diff --git a/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx new file mode 100644 index 000000000000..d773c31e4c48 --- /dev/null +++ b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx @@ -0,0 +1,123 @@ +import React, { useCallback } from 'react'; +import { TouchableOpacity } from 'react-native'; +import { + Box, + Text, + TextVariant, + Icon, + IconName, + IconSize, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + NavigationProp, + ParamListBase, + useNavigation, +} from '@react-navigation/native'; + +export interface SitesSearchFooterProps { + searchQuery: string; +} + +/** + * Checks if a string looks like a URL + */ +function looksLikeUrl(str: string): boolean { + return /^(https?:\/\/)?[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+([/?].*)?$/.test(str); +} + +const SitesSearchFooter: React.FC = ({ + searchQuery, +}) => { + const tw = useTailwind(); + const navigation = useNavigation>(); + + const onPressLink = useCallback( + (url: string) => { + navigation.navigate('TrendingBrowser', { + newTabUrl: url, + timestamp: Date.now(), + fromTrending: true, + }); + }, + [navigation], + ); + + if (!searchQuery || searchQuery.length === 0) { + return null; + } + + const isUrl = looksLikeUrl(searchQuery.toLowerCase()); + + return ( + + {isUrl && ( + onPressLink(searchQuery)} + testID="trending-search-footer-url-link" + > + + + {searchQuery} + + + + + + + )} + + + onPressLink( + `https://www.google.com/search?q=${encodeURIComponent(searchQuery)}`, + ) + } + testID="trending-search-footer-google-link" + > + + + Search for {'"'} + + + {searchQuery} + + + {'"'} on Google + + + + + + + + ); +}; + +SitesSearchFooter.displayName = 'SitesSearchFooter'; + +export default SitesSearchFooter; diff --git a/app/components/Views/TrendingView/SectionSites/hooks/useSiteData.test.ts b/app/components/UI/Sites/hooks/useSiteData/useSiteData.test.ts similarity index 100% rename from app/components/Views/TrendingView/SectionSites/hooks/useSiteData.test.ts rename to app/components/UI/Sites/hooks/useSiteData/useSiteData.test.ts diff --git a/app/components/Views/TrendingView/SectionSites/hooks/useSitesData.ts b/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts similarity index 97% rename from app/components/Views/TrendingView/SectionSites/hooks/useSitesData.ts rename to app/components/UI/Sites/hooks/useSiteData/useSitesData.ts index 21b042710f54..5b2372f49465 100644 --- a/app/components/Views/TrendingView/SectionSites/hooks/useSitesData.ts +++ b/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback } from 'react'; import Logger from '../../../../../util/Logger'; -import type { SiteData } from '../SiteRowItem/SiteRowItem'; +import type { SiteData } from '../../components/SiteRowItem/SiteRowItem'; interface ApiDappResponse { id: string; diff --git a/app/components/Views/BrowserTab/__snapshots__/index.test.tsx.snap b/app/components/Views/BrowserTab/__snapshots__/index.test.tsx.snap index a92d3c1dbd5d..4003ff63656e 100644 --- a/app/components/Views/BrowserTab/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/BrowserTab/__snapshots__/index.test.tsx.snap @@ -413,24 +413,15 @@ exports[`BrowserTab render Browser 1`] = ` ({ + SafeAreaView: jest.requireActual('react-native').View, + useSafeAreaInsets: () => ({ top: 50, bottom: 34, left: 0, right: 0 }), +})); + +const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + navigate: mockNavigate, + goBack: mockGoBack, + }), +})); + +jest.mock('../../../util/theme', () => ({ + useAppThemeFromContext: () => ({ + colors: { + background: { default: '#FFFFFF' }, + primary: { default: '#037DD6' }, + icon: { default: '#24272A' }, + }, + }), +})); + +jest.mock('../../UI/shared/ListHeaderWithSearch/ListHeaderWithSearch', () => { + const ReactNative = jest.requireActual('react-native'); + return jest.fn( + ({ + defaultTitle, + isSearchVisible, + searchQuery, + onSearchQueryChange, + onBack, + onSearchToggle, + testID, + }) => ( + + {!isSearchVisible ? ( + <> + + Back + + + {defaultTitle} + + + Search + + + ) : ( + <> + + + Cancel + + + )} + + ), + ); +}); + +jest.mock('../../UI/Sites/components/SitesList/SitesList', () => { + const ReactNative = jest.requireActual('react-native'); + return jest.fn(({ sites, refreshControl, ListFooterComponent }) => ( + + {sites.map((site: SiteData) => ( + + {site.name} + + ))} + {refreshControl && ( + + {refreshControl} + + )} + {ListFooterComponent} + + )); +}); + +jest.mock('../../UI/Sites/components/SiteSkeleton/SiteSkeleton', () => + jest.fn(() => { + const ReactNative = jest.requireActual('react-native'); + return ( + + Loading... + + ); + }), +); + +jest.mock( + '../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter', + () => { + const ReactNative = jest.requireActual('react-native'); + return jest.fn(({ searchQuery }) => + searchQuery ? ( + + {searchQuery} + + ) : null, + ); + }, +); + +const mockUseSitesData = useSitesData as jest.Mock; +const mockRefetch = jest.fn(); + +describe('SitesFullView', () => { + const mockSites: SiteData[] = [ + { + id: '1', + name: 'MetaMask', + url: 'https://metamask.io', + displayUrl: 'metamask.io', + logoUrl: 'https://example.com/metamask.png', + featured: true, + }, + { + id: '2', + name: 'OpenSea', + url: 'https://opensea.io', + displayUrl: 'opensea.io', + logoUrl: 'https://example.com/opensea.png', + featured: false, + }, + { + id: '3', + name: 'Uniswap', + url: 'https://uniswap.org', + displayUrl: 'uniswap.org', + logoUrl: 'https://example.com/uniswap.png', + featured: true, + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + mockRefetch.mockClear(); + }); + + describe('Rendering', () => { + it('renders header with back button and title', () => { + mockUseSitesData.mockReturnValue({ + sites: mockSites, + isLoading: false, + refetch: mockRefetch, + }); + + const { getByTestId } = render(); + + expect(getByTestId('sites-full-view-header')).toBeOnTheScreen(); + expect( + getByTestId('sites-full-view-header-back-button'), + ).toBeOnTheScreen(); + expect(getByTestId('sites-full-view-header-title')).toBeOnTheScreen(); + expect( + getByTestId('sites-full-view-header-search-toggle'), + ).toBeOnTheScreen(); + }); + + it('renders SitesList component with all site items', () => { + mockUseSitesData.mockReturnValue({ + sites: mockSites, + isLoading: false, + refetch: mockRefetch, + }); + + const { getByTestId } = render(); + + expect(getByTestId('sites-list')).toBeOnTheScreen(); + expect(getByTestId('site-item-1')).toBeOnTheScreen(); + expect(getByTestId('site-item-2')).toBeOnTheScreen(); + expect(getByTestId('site-item-3')).toBeOnTheScreen(); + }); + + it('renders skeletons when loading', () => { + mockUseSitesData.mockReturnValue({ + sites: [], + isLoading: true, + refetch: mockRefetch, + }); + + const { getAllByTestId } = render(); + + const skeletons = getAllByTestId('site-skeleton'); + expect(skeletons.length).toBe(10); + }); + + it('renders RefreshControl', () => { + mockUseSitesData.mockReturnValue({ + sites: mockSites, + isLoading: false, + refetch: mockRefetch, + }); + + const { getByTestId } = render(); + + expect(getByTestId('refresh-control')).toBeOnTheScreen(); + }); + }); + + describe('Navigation', () => { + it('navigates back when back button is pressed', () => { + mockUseSitesData.mockReturnValue({ + sites: mockSites, + isLoading: false, + refetch: mockRefetch, + }); + + const { getByTestId } = render(); + const backButton = getByTestId('sites-full-view-header-back-button'); + + fireEvent.press(backButton); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + }); + + describe('Search Functionality', () => { + it('filters sites by name, URL, and display URL', () => { + mockUseSitesData.mockReturnValue({ + sites: mockSites, + isLoading: false, + refetch: mockRefetch, + }); + + const { getByTestId, queryByTestId } = render(); + + // Activate search + fireEvent.press(getByTestId('sites-full-view-header-search-toggle')); + const searchInput = getByTestId('sites-full-view-header-search-bar'); + + // Search by name + fireEvent.changeText(searchInput, 'Meta'); + expect(getByTestId('site-item-1')).toBeOnTheScreen(); + expect(queryByTestId('site-item-2')).toBeNull(); + + // Search by URL + fireEvent.changeText(searchInput, 'opensea'); + expect(queryByTestId('site-item-1')).toBeNull(); + expect(getByTestId('site-item-2')).toBeOnTheScreen(); + + // Search by display URL + fireEvent.changeText(searchInput, 'uniswap.org'); + expect(queryByTestId('site-item-2')).toBeNull(); + expect(getByTestId('site-item-3')).toBeOnTheScreen(); + }); + + it('shows all sites when search query is empty', () => { + mockUseSitesData.mockReturnValue({ + sites: mockSites, + isLoading: false, + refetch: mockRefetch, + }); + + const { getByTestId } = render(); + + // Activate search + fireEvent.press(getByTestId('sites-full-view-header-search-toggle')); + const searchInput = getByTestId('sites-full-view-header-search-bar'); + + // Empty search + fireEvent.changeText(searchInput, ''); + + // All sites should be visible + expect(getByTestId('site-item-1')).toBeOnTheScreen(); + expect(getByTestId('site-item-2')).toBeOnTheScreen(); + expect(getByTestId('site-item-3')).toBeOnTheScreen(); + }); + + it('clears search query when search is closed', () => { + mockUseSitesData.mockReturnValue({ + sites: mockSites, + isLoading: false, + refetch: mockRefetch, + }); + + const { getByTestId } = render(); + + // Activate search + fireEvent.press(getByTestId('sites-full-view-header-search-toggle')); + const searchInput = getByTestId('sites-full-view-header-search-bar'); + + // Type search query + fireEvent.changeText(searchInput, 'test'); + + // Close search + fireEvent.press(getByTestId('sites-full-view-header-search-close')); + + // Reopen search + fireEvent.press(getByTestId('sites-full-view-header-search-toggle')); + + // Search input should be empty + const newSearchInput = getByTestId('sites-full-view-header-search-bar'); + expect(newSearchInput.props.value).toBe(''); + }); + + it('displays SitesSearchFooter when search is active', () => { + mockUseSitesData.mockReturnValue({ + sites: mockSites, + isLoading: false, + refetch: mockRefetch, + }); + + const { getByTestId, queryByTestId } = render(); + + // Initially no footer + expect(queryByTestId('sites-search-footer')).toBeNull(); + + // Activate search + fireEvent.press(getByTestId('sites-full-view-header-search-toggle')); + const searchInput = getByTestId('sites-full-view-header-search-bar'); + + // Type search query + fireEvent.changeText(searchInput, 'test'); + + // Footer should appear + expect(getByTestId('sites-search-footer')).toBeOnTheScreen(); + }); + + it('hides SitesSearchFooter when search query is empty or search is inactive', () => { + mockUseSitesData.mockReturnValue({ + sites: mockSites, + isLoading: false, + refetch: mockRefetch, + }); + + const { getByTestId, queryByTestId } = render(); + + // Footer should not appear when search is inactive + expect(queryByTestId('sites-search-footer')).toBeNull(); + + // Activate search + fireEvent.press(getByTestId('sites-full-view-header-search-toggle')); + + // Footer should not appear with empty query + expect(queryByTestId('sites-search-footer')).toBeNull(); + }); + }); + + describe('Data Fetching', () => { + it('fetches sites with limit of 100', () => { + mockUseSitesData.mockReturnValue({ + sites: mockSites, + isLoading: false, + refetch: mockRefetch, + }); + + render(); + + expect(mockUseSitesData).toHaveBeenCalledWith({ limit: 100 }); + }); + + it('calls refetch when refresh is triggered', async () => { + mockUseSitesData.mockReturnValue({ + sites: mockSites, + isLoading: false, + refetch: mockRefetch, + }); + + render(); + + const SitesListMock = jest.requireMock( + '../../UI/Sites/components/SitesList/SitesList', + ); + + // Get the refreshControl prop passed to SitesList + const sitesListProps = SitesListMock.mock.calls[0][0]; + const refreshControl = sitesListProps.refreshControl; + + expect(refreshControl).toBeDefined(); + + // Simulate refresh + await act(async () => { + await refreshControl.props.onRefresh(); + }); + + await waitFor(() => { + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('Edge Cases', () => { + it('handles sites with missing optional fields', () => { + const minimalSites: SiteData[] = [ + { + id: '1', + name: 'Test', + url: 'https://test.com', + displayUrl: 'test.com', + }, + ]; + + mockUseSitesData.mockReturnValue({ + sites: minimalSites, + isLoading: false, + refetch: mockRefetch, + }); + + const { getByTestId } = render(); + + expect(getByTestId('site-item-1')).toBeOnTheScreen(); + }); + + it('handles empty sites array', () => { + mockUseSitesData.mockReturnValue({ + sites: [], + isLoading: false, + refetch: mockRefetch, + }); + + const { getByTestId, queryByTestId } = render(); + + expect(getByTestId('sites-list')).toBeOnTheScreen(); + expect(queryByTestId('site-item-1')).toBeNull(); + }); + + it('performs case-insensitive search', () => { + mockUseSitesData.mockReturnValue({ + sites: mockSites, + isLoading: false, + refetch: mockRefetch, + }); + + const { getByTestId, queryByTestId } = render(); + + // Activate search + fireEvent.press(getByTestId('sites-full-view-header-search-toggle')); + const searchInput = getByTestId('sites-full-view-header-search-bar'); + + // Search with different case + fireEvent.changeText(searchInput, 'METAMASK'); + + // MetaMask should still be found + expect(getByTestId('site-item-1')).toBeOnTheScreen(); + expect(queryByTestId('site-item-2')).toBeNull(); + }); + }); +}); diff --git a/app/components/Views/SitesFullView/SitesFullView.tsx b/app/components/Views/SitesFullView/SitesFullView.tsx new file mode 100644 index 000000000000..abc6783059fb --- /dev/null +++ b/app/components/Views/SitesFullView/SitesFullView.tsx @@ -0,0 +1,154 @@ +import React, { useCallback, useState, useMemo } from 'react'; +import { StyleSheet, View, RefreshControl } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +// eslint-disable-next-line no-duplicate-imports +import type { NavigationProp, ParamListBase } from '@react-navigation/native'; +import { + SafeAreaView, + useSafeAreaInsets, +} from 'react-native-safe-area-context'; +import { useAppThemeFromContext } from '../../../util/theme'; +import { Theme } from '../../../util/theme/models'; +import { useSitesData } from '../../UI/Sites/hooks/useSiteData/useSitesData'; +import SitesList from '../../UI/Sites/components/SitesList/SitesList'; +import SiteSkeleton from '../../UI/Sites/components/SiteSkeleton/SiteSkeleton'; +import SitesSearchFooter from '../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter'; +import { strings } from '../../../../locales/i18n'; +import ListHeaderWithSearch from '../../UI/shared/ListHeaderWithSearch/ListHeaderWithSearch'; + +const createStyles = (theme: Theme) => + StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: theme.colors.background.default, + paddingBottom: 16, + }, + headerContainer: { + backgroundColor: theme.colors.background.default, + }, + listContainer: { + flex: 1, + paddingLeft: 16, + paddingRight: 16, + }, + }); + +const SitesFullView: React.FC = () => { + const theme = useAppThemeFromContext(); + const styles = useMemo(() => createStyles(theme), [theme]); + const insets = useSafeAreaInsets(); + const navigation = useNavigation>(); + const [searchQuery, setSearchQuery] = useState(''); + const [isSearchActive, setIsSearchActive] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + // Fetch all sites (no limit) + const { + sites, + isLoading, + refetch: refetchSites, + } = useSitesData({ limit: 100 }); + + // Filter sites based on search query + const filteredSites = useMemo(() => { + if (!searchQuery.trim()) { + return sites; + } + + const query = searchQuery.toLowerCase(); + return sites.filter( + (site) => + site.name.toLowerCase().includes(query) || + site.displayUrl.toLowerCase().includes(query) || + site.url.toLowerCase().includes(query), + ); + }, [sites, searchQuery]); + + const handleBackPress = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleSearchToggle = useCallback(() => { + setIsSearchActive((prev) => { + if (prev) { + // Closing search, clear the query + setSearchQuery(''); + } + return !prev; + }); + }, []); + + // Handle pull-to-refresh + const handleRefresh = useCallback(async () => { + setRefreshing(true); + try { + refetchSites?.(); + } catch (error) { + console.warn('Failed to refresh sites:', error); + } finally { + setRefreshing(false); + } + }, [refetchSites]); + + const renderSkeleton = () => ( + <> + {[...Array(10)].map((_, index) => ( + + ))} + + ); + + const renderFooter = useMemo(() => { + if (!isSearchActive) return null; + + return ; + }, [isSearchActive, searchQuery]); + + return ( + + + + + + {isLoading ? ( + {renderSkeleton()} + ) : ( + + + } + ListFooterComponent={renderFooter} + /> + + )} + + ); +}; + +SitesFullView.displayName = 'SitesFullView'; + +export default SitesFullView; diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx index 7827383f36a3..139b6a2b501d 100644 --- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx +++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx @@ -1,14 +1,12 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; +import { render } from '@testing-library/react-native'; import ExploreSearchResults from './ExploreSearchResults'; import { useExploreSearch } from './config/useExploreSearch'; -const mockNavigate = jest.fn(); - jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: () => ({ - navigate: mockNavigate, + navigate: jest.fn(), }), })); @@ -33,82 +31,26 @@ jest.mock( () => () => null, ); +jest.mock( + '../../../../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter', + () => { + const ReactNative = jest.requireActual('react-native'); + return jest.fn(({ searchQuery }) => + searchQuery ? ( + + {searchQuery} + + ) : null, + ); + }, +); + describe('ExploreSearchResults', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('renders list when data is available', () => { - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [ - { assetId: '1', symbol: 'BTC', name: 'Bitcoin' }, - { assetId: '2', symbol: 'ETH', name: 'Ethereum' }, - ], - perps: [], - predictions: [], - sites: [], - }, - isLoading: { - tokens: false, - perps: false, - predictions: false, - sites: false, - }, - }); - - const { getByTestId } = render(); - - expect(getByTestId('trending-search-results-list')).toBeDefined(); - }); - - it('renders section headers when sections have data', () => { - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [{ assetId: '1', symbol: 'BTC', name: 'Bitcoin' }], - perps: [{ symbol: 'ETH-USD', name: 'Ethereum' }], - predictions: [], - sites: [], - }, - isLoading: { - tokens: false, - perps: false, - predictions: false, - sites: false, - }, - }); - - const { getByText } = render(); - - expect(getByText('Tokens')).toBeDefined(); - expect(getByText('Perps')).toBeDefined(); - }); - - it('displays skeleton loaders when loading', () => { - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [], - perps: [], - predictions: [], - sites: [], - }, - isLoading: { - tokens: true, - perps: false, - predictions: false, - sites: false, - }, - }); - - const { getByTestId, getByText } = render( - , - ); - - expect(getByTestId('trending-search-results-list')).toBeDefined(); - expect(getByText('Tokens')).toBeDefined(); - }); - - it('renders multiple sections with data simultaneously', () => { + it('renders section headers for sections with data or loading', () => { mockUseExploreSearch.mockReturnValue({ data: { tokens: [ @@ -127,8 +69,11 @@ describe('ExploreSearchResults', () => { }, }); - const { getByText } = render(); + const { getByText, getByTestId } = render( + , + ); + expect(getByTestId('trending-search-results-list')).toBeDefined(); expect(getByText('Tokens')).toBeDefined(); expect(getByText('Perps')).toBeDefined(); expect(getByText('Predictions')).toBeDefined(); @@ -180,33 +125,8 @@ describe('ExploreSearchResults', () => { expect(mockUseExploreSearch).toHaveBeenCalledWith('ethereum'); }); - it('handles empty query by displaying top results', () => { - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [ - { assetId: '1', symbol: 'BTC', name: 'Bitcoin' }, - { assetId: '2', symbol: 'ETH', name: 'Ethereum' }, - { assetId: '3', symbol: 'SOL', name: 'Solana' }, - ], - perps: [], - predictions: [], - sites: [], - }, - isLoading: { - tokens: false, - perps: false, - predictions: false, - sites: false, - }, - }); - - const { getByTestId } = render(); - - expect(getByTestId('trending-search-results-list')).toBeDefined(); - }); - describe('Footer', () => { - it('displays Google search option when search query is provided and loading is finished', () => { + it('displays SitesSearchFooter when search query is provided', () => { mockUseExploreSearch.mockReturnValue({ data: { tokens: [{ assetId: '1', symbol: 'BTC', name: 'Bitcoin' }], @@ -222,38 +142,11 @@ describe('ExploreSearchResults', () => { }, }); - const { getByTestId, getByText } = render( + const { getByTestId } = render( , ); - expect(getByTestId('trending-search-footer-google-link')).toBeDefined(); - expect(getByText('bitcoin')).toBeDefined(); - expect(getByText(/on Google/)).toBeDefined(); - }); - - it('displays direct URL link when search query looks like a URL', () => { - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [], - perps: [], - predictions: [], - sites: [], - }, - isLoading: { - tokens: false, - perps: false, - predictions: false, - sites: false, - }, - }); - - const { getByTestId, getAllByText } = render( - , - ); - - expect(getByTestId('trending-search-footer-url-link')).toBeDefined(); - expect(getByTestId('trending-search-footer-google-link')).toBeDefined(); - expect(getAllByText('example.com').length).toBeGreaterThan(0); + expect(getByTestId('sites-search-footer')).toBeDefined(); }); it('does not display footer when search query is empty', () => { @@ -272,98 +165,9 @@ describe('ExploreSearchResults', () => { }, }); - const { queryByText } = render(); - - expect(queryByText('Search for')).toBeNull(); - expect(queryByText('on Google')).toBeNull(); - }); - - it('does not display footer when still loading', () => { - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [], - perps: [], - predictions: [], - sites: [], - }, - isLoading: { - tokens: true, - perps: false, - predictions: false, - sites: false, - }, - }); - - const { queryByText } = render( - , - ); - - expect(queryByText('Search for')).toBeNull(); - expect(queryByText('on Google')).toBeNull(); - }); - - it('navigates to Google search when Google search option is pressed', () => { - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [], - perps: [], - predictions: [], - sites: [], - }, - isLoading: { - tokens: false, - perps: false, - predictions: false, - sites: false, - }, - }); - - const { getByTestId } = render( - , - ); + const { queryByTestId } = render(); - const googleSearchButton = getByTestId( - 'trending-search-footer-google-link', - ); - - fireEvent.press(googleSearchButton); - - expect(mockNavigate).toHaveBeenCalledWith('TrendingBrowser', { - newTabUrl: 'https://www.google.com/search?q=ethereum', - timestamp: expect.any(Number), - fromTrending: true, - }); - }); - - it('navigates to URL when direct URL link is pressed', () => { - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [], - perps: [], - predictions: [], - sites: [], - }, - isLoading: { - tokens: false, - perps: false, - predictions: false, - sites: false, - }, - }); - - const { getByTestId } = render( - , - ); - - const urlButton = getByTestId('trending-search-footer-url-link'); - - fireEvent.press(urlButton); - - expect(mockNavigate).toHaveBeenCalledWith('TrendingBrowser', { - newTabUrl: 'example.com', - timestamp: expect.any(Number), - fromTrending: true, - }); + expect(queryByTestId('sites-search-footer')).toBeNull(); }); }); }); diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx index 2c3ff842f2a9..9e1d187d894b 100644 --- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx +++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx @@ -1,15 +1,7 @@ import React, { useMemo, useCallback, useRef, useEffect } from 'react'; -import { TouchableOpacity } from 'react-native'; import { FlashList, ListRenderItem, FlashListRef } from '@shopify/flash-list'; import { useNavigation } from '@react-navigation/native'; -import { - Box, - Text, - TextVariant, - Icon, - IconName, - IconSize, -} from '@metamask/design-system-react-native'; +import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { SECTIONS_CONFIG, @@ -17,10 +9,8 @@ import { type SectionId, } from '../../../config/sections.config'; import { useExploreSearch } from './config/useExploreSearch'; +import SitesSearchFooter from '../../../../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter'; -function looksLikeUrl(str: string): boolean { - return /^(https?:\/\/)?[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+([/?].*)?$/.test(str); -} interface ExploreSearchResultsProps { searchQuery: string; } @@ -52,17 +42,6 @@ const ExploreSearchResults: React.FC = ({ const { data, isLoading } = useExploreSearch(searchQuery); const flashListRef = useRef>(null); - const handlePressFooterLink = useCallback( - (url: string) => { - navigation.navigate('TrendingBrowser', { - newTabUrl: url, - timestamp: Date.now(), - fromTrending: true, - }); - }, - [navigation], - ); - const renderSectionHeader = useCallback( (title: string) => ( @@ -125,84 +104,11 @@ const ExploreSearchResults: React.FC = ({ } }, [searchQuery, flatData.length]); - const finishedLoading = useMemo( - () => Object.values(isLoading).every((value) => !value), - [isLoading], - ); - const renderFooter = useMemo(() => { - if (!finishedLoading || searchQuery.length === 0) return null; - - const isUrl = looksLikeUrl(searchQuery.toLowerCase()); - - return ( - - {isUrl && ( - handlePressFooterLink(searchQuery)} - testID="trending-search-footer-url-link" - > - - - {searchQuery} - - - - - - - )} - - - handlePressFooterLink( - `https://www.google.com/search?q=${encodeURIComponent(searchQuery)}`, - ) - } - testID="trending-search-footer-google-link" - > - - - Search for {'"'} - - - {searchQuery} - - - {'"'} on Google - - - - - - - - ); - }, [finishedLoading, searchQuery, handlePressFooterLink, tw]); + if (searchQuery.length === 0) return null; + + return ; + }, [searchQuery]); const renderFlatItem: ListRenderItem = useCallback( ({ item }) => { diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts index 7afd60bad5b0..cc4eca8f44c1 100644 --- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts +++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts @@ -66,7 +66,7 @@ jest.mock('../../../../../../UI/Predict/hooks/usePredictMarketData', () => ({ usePredictMarketData: () => mockUsePredictMarketData(), })); -jest.mock('../../../../SectionSites/hooks/useSitesData', () => ({ +jest.mock('../../../../../../UI/Sites/hooks/useSiteData/useSitesData', () => ({ useSitesData: () => mockUseSitesData(), })); diff --git a/app/components/Views/TrendingView/SectionSites/SiteRowItem/index.ts b/app/components/Views/TrendingView/SectionSites/SiteRowItem/index.ts deleted file mode 100644 index 7e23bce8fbeb..000000000000 --- a/app/components/Views/TrendingView/SectionSites/SiteRowItem/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './SiteRowItem'; -export type { SiteData } from './SiteRowItem'; diff --git a/app/components/Views/TrendingView/SectionSites/SiteSkeleton/index.ts b/app/components/Views/TrendingView/SectionSites/SiteSkeleton/index.ts deleted file mode 100644 index 4ac14e2d69fe..000000000000 --- a/app/components/Views/TrendingView/SectionSites/SiteSkeleton/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './SiteSkeleton'; diff --git a/app/components/Views/TrendingView/SectionSites/hooks/index.ts b/app/components/Views/TrendingView/SectionSites/hooks/index.ts deleted file mode 100644 index 9d5449d252ab..000000000000 --- a/app/components/Views/TrendingView/SectionSites/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useSitesData } from './useSitesData'; diff --git a/app/components/Views/TrendingView/SectionSites/index.ts b/app/components/Views/TrendingView/SectionSites/index.ts deleted file mode 100644 index bbeccb8d542f..000000000000 --- a/app/components/Views/TrendingView/SectionSites/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { default as SiteRowItem } from './SiteRowItem'; -export { default as SiteRowItemWrapper } from './SiteRowItemWrapper'; -export { default as SiteSkeleton } from './SiteSkeleton'; -export { useSitesData } from './hooks/useSitesData'; -export type { SiteData } from './SiteRowItem'; diff --git a/app/components/Views/TrendingView/SitesListView/SitesListView.test.tsx b/app/components/Views/TrendingView/SitesListView/SitesListView.test.tsx deleted file mode 100644 index 4101ce7dff5f..000000000000 --- a/app/components/Views/TrendingView/SitesListView/SitesListView.test.tsx +++ /dev/null @@ -1,656 +0,0 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; -import SitesListView from './SitesListView'; -import { useSitesData } from '../SectionSites/hooks/useSitesData'; -import type { SiteData } from '../SectionSites/SiteRowItem/SiteRowItem'; - -// Mock dependencies -jest.mock('../SectionSites/hooks/useSitesData'); - -jest.mock('react-native-safe-area-context', () => ({ - useSafeAreaInsets: () => ({ top: 50, bottom: 34, left: 0, right: 0 }), -})); - -const mockGoBack = jest.fn(); -const mockNavigate = jest.fn(); - -jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ - navigate: mockNavigate, - goBack: mockGoBack, - }), -})); - -const mockTwStyle = jest.fn((...args: unknown[]) => { - const flatArgs = args.flat().filter(Boolean); - return flatArgs.reduce((acc: Record, arg) => { - if (typeof arg === 'string') { - return { ...acc, [arg]: true }; - } - if (typeof arg === 'object') { - return { ...acc, ...arg }; - } - return acc; - }, {}); -}); - -// Make mockTw callable as both function and object with style method -const mockTw = Object.assign(mockTwStyle, { style: mockTwStyle }); - -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: () => mockTw, -})); - -jest.mock('../SectionSites/SiteRowItemWrapper', () => { - const ReactNative = jest.requireActual('react-native'); - return jest.fn(({ site }) => ( - - {site.name} - - )); -}); - -jest.mock('../SectionSites/SiteSkeleton/SiteSkeleton', () => - jest.fn(() => { - const ReactNative = jest.requireActual('react-native'); - return ( - - Loading... - - ); - }), -); - -jest.mock('../../../../component-library/components/HeaderBase', () => ({ - __esModule: true, - default: jest.fn(({ children, startAccessory, endAccessory }) => { - const ReactNative = jest.requireActual('react-native'); - return ( - - {startAccessory} - {children} - {endAccessory} - - ); - }), - HeaderBaseVariant: { - Display: 'Display', - }, -})); - -jest.mock( - '../../../../component-library/components/Buttons/ButtonIcon', - () => ({ - __esModule: true, - default: jest.fn(({ onPress, iconName, testID }) => { - const ReactNative = jest.requireActual('react-native'); - return ( - - {iconName} - - ); - }), - ButtonIconSizes: { - Lg: 'Lg', - }, - }), -); - -jest.mock('../ExploreSearchBar/ExploreSearchBar', () => { - const ReactNative = jest.requireActual('react-native'); - return jest.fn(({ searchQuery, onSearchChange, onCancel, placeholder }) => ( - - - - Cancel - - - )); -}); - -const mockUseSitesData = useSitesData as jest.Mock; - -describe('SitesListView', () => { - const mockSites: SiteData[] = [ - { - id: '1', - name: 'MetaMask', - url: 'https://metamask.io', - displayUrl: 'metamask.io', - logoUrl: 'https://example.com/metamask.png', - featured: true, - }, - { - id: '2', - name: 'OpenSea', - url: 'https://opensea.io', - displayUrl: 'opensea.io', - logoUrl: 'https://example.com/opensea.png', - featured: false, - }, - { - id: '3', - name: 'Uniswap', - url: 'https://uniswap.org', - displayUrl: 'uniswap.org', - logoUrl: 'https://example.com/uniswap.png', - featured: true, - }, - ]; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('Rendering', () => { - it('renders header with back and search buttons', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - error: null, - }); - - const { getByTestId } = render(); - - expect(getByTestId('header-base')).toBeOnTheScreen(); - expect(getByTestId('back-button')).toBeOnTheScreen(); - expect(getByTestId('search-button')).toBeOnTheScreen(); - }); - - it('renders all site items', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - error: null, - }); - - const { getByTestId } = render(); - - expect(getByTestId('site-item-1')).toBeOnTheScreen(); - expect(getByTestId('site-item-2')).toBeOnTheScreen(); - expect(getByTestId('site-item-3')).toBeOnTheScreen(); - }); - - it('renders skeletons when loading', () => { - mockUseSitesData.mockReturnValue({ - sites: [], - isLoading: true, - error: null, - }); - - const { getAllByTestId } = render(); - - const skeletons = getAllByTestId('site-skeleton'); - expect(skeletons.length).toBe(10); - }); - }); - - describe('Navigation', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('navigates back when back button is pressed', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - error: null, - }); - - const { getByTestId } = render(); - const backButton = getByTestId('back-button'); - - fireEvent.press(backButton); - - expect(mockGoBack).toHaveBeenCalledTimes(1); - }); - - it('activates search mode when search button is pressed', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - error: null, - }); - - const { getByTestId, queryByTestId } = render(); - const searchButton = getByTestId('search-button'); - - expect(queryByTestId('explore-search-bar')).toBeNull(); - - fireEvent.press(searchButton); - - expect(getByTestId('explore-search-bar')).toBeOnTheScreen(); - }); - - it('closes search mode when cancel button is pressed', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - error: null, - }); - - const { getByTestId, queryByTestId } = render(); - - // Activate search - fireEvent.press(getByTestId('search-button')); - expect(getByTestId('explore-search-bar')).toBeOnTheScreen(); - - // Press cancel - fireEvent.press(getByTestId('explore-search-cancel-button')); - - // Search should be closed - expect(queryByTestId('explore-search-bar')).toBeNull(); - expect(mockGoBack).not.toHaveBeenCalled(); - }); - }); - - describe('Search Functionality', () => { - it('filters sites by name', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - error: null, - }); - - const { getByTestId, queryByTestId } = render(); - - // Activate search - fireEvent.press(getByTestId('search-button')); - const searchInput = getByTestId('explore-view-search-input'); - - // Search for "Meta" - fireEvent.changeText(searchInput, 'Meta'); - - // Only MetaMask should be visible - expect(getByTestId('site-item-1')).toBeOnTheScreen(); - expect(queryByTestId('site-item-2')).toBeNull(); - expect(queryByTestId('site-item-3')).toBeNull(); - }); - - it('filters sites by URL', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - error: null, - }); - - const { getByTestId, queryByTestId } = render(); - - // Activate search - fireEvent.press(getByTestId('search-button')); - const searchInput = getByTestId('explore-view-search-input'); - - // Search for "opensea" - fireEvent.changeText(searchInput, 'opensea'); - - // Only OpenSea should be visible - expect(queryByTestId('site-item-1')).toBeNull(); - expect(getByTestId('site-item-2')).toBeOnTheScreen(); - expect(queryByTestId('site-item-3')).toBeNull(); - }); - - it('shows all sites when search query is empty', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - error: null, - }); - - const { getByTestId } = render(); - - // Activate search - fireEvent.press(getByTestId('search-button')); - const searchInput = getByTestId('explore-view-search-input'); - - // Empty search - fireEvent.changeText(searchInput, ''); - - // All sites should be visible - expect(getByTestId('site-item-1')).toBeOnTheScreen(); - expect(getByTestId('site-item-2')).toBeOnTheScreen(); - expect(getByTestId('site-item-3')).toBeOnTheScreen(); - }); - - it('shows cancel button when search is active', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - error: null, - }); - - const { getByTestId, queryByTestId } = render(); - - // Initially no cancel button - expect(queryByTestId('explore-search-cancel-button')).toBeNull(); - - // Activate search - fireEvent.press(getByTestId('search-button')); - - // Cancel button should appear - expect(getByTestId('explore-search-cancel-button')).toBeOnTheScreen(); - }); - - it('clears search and closes search mode when cancel button is pressed', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - error: null, - }); - - const { getByTestId, queryByTestId } = render(); - - // Activate search and type - fireEvent.press(getByTestId('search-button')); - const searchInput = getByTestId('explore-view-search-input'); - fireEvent.changeText(searchInput, 'test'); - - // Cancel - fireEvent.press(getByTestId('explore-search-cancel-button')); - - // Search should be closed - expect(queryByTestId('explore-search-bar')).toBeNull(); - }); - - it('shows search on Google option when there is a search query', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - error: null, - }); - - const { getByTestId, queryByTestId } = render(); - - // Activate search - fireEvent.press(getByTestId('search-button')); - - // Initially no Google search option - expect(queryByTestId('search-on-google-button')).toBeNull(); - - // Type any search query - fireEvent.changeText(getByTestId('explore-view-search-input'), 'test'); - - // Google search option should appear - expect(getByTestId('search-on-google-button')).toBeOnTheScreen(); - }); - - it('navigates to Google search when search on Google button is pressed', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - error: null, - }); - - const { getByTestId } = render(); - - // Activate search and type - fireEvent.press(getByTestId('search-button')); - fireEvent.changeText( - getByTestId('explore-view-search-input'), - 'test query', - ); - - // Press Google search button - fireEvent.press(getByTestId('search-on-google-button')); - - // Should navigate to TrendingBrowser with Google search URL - expect(mockNavigate).toHaveBeenCalledWith( - 'TrendingBrowser', - expect.objectContaining({ - newTabUrl: 'https://www.google.com/search?q=test%20query', - fromTrending: true, - }), - ); - }); - - it('displays URL item when search query is a valid URL', () => { - mockUseSitesData.mockReturnValue({ - sites: [], - isLoading: false, - error: null, - }); - - const { getByTestId } = render(); - - // Activate search - fireEvent.press(getByTestId('search-button')); - - // Type a valid URL - fireEvent.changeText( - getByTestId('explore-view-search-input'), - 'example.com', - ); - - // Should show the URL item - expect(getByTestId('url-item')).toBeOnTheScreen(); - }); - - it('displays URL item with https protocol', () => { - mockUseSitesData.mockReturnValue({ - sites: [], - isLoading: false, - error: null, - }); - - const { getByTestId } = render(); - - // Activate search - fireEvent.press(getByTestId('search-button')); - - // Type a valid URL with protocol - fireEvent.changeText( - getByTestId('explore-view-search-input'), - 'https://example.com', - ); - - // Should show the URL item - expect(getByTestId('url-item')).toBeOnTheScreen(); - }); - - it('shows URL item separately from matching sites', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - error: null, - }); - - const { getByTestId } = render(); - - // Activate search - fireEvent.press(getByTestId('search-button')); - - // Type a URL that also matches existing sites - fireEvent.changeText( - getByTestId('explore-view-search-input'), - 'metamask.io', - ); - - // URL item should appear - expect(getByTestId('url-item')).toBeOnTheScreen(); - // Original matching sites should still appear - expect(getByTestId('site-item-1')).toBeOnTheScreen(); - }); - - it('shows both URL item and Google search option for valid URLs', () => { - mockUseSitesData.mockReturnValue({ - sites: [], - isLoading: false, - error: null, - }); - - const { getByTestId } = render(); - - // Activate search - fireEvent.press(getByTestId('search-button')); - - // Type a valid URL - fireEvent.changeText( - getByTestId('explore-view-search-input'), - 'example.com', - ); - - // Should show both URL item - expect(getByTestId('url-item')).toBeOnTheScreen(); - - // AND Google search option - expect(getByTestId('search-on-google-button')).toBeOnTheScreen(); - }); - - it('navigates to URL when URL item is pressed', () => { - mockUseSitesData.mockReturnValue({ - sites: [], - isLoading: false, - error: null, - }); - - const { getByTestId } = render(); - - // Activate search and type URL - fireEvent.press(getByTestId('search-button')); - fireEvent.changeText( - getByTestId('explore-view-search-input'), - 'example.com', - ); - - // Press URL item - fireEvent.press(getByTestId('url-item')); - - // Should navigate to the URL - expect(mockNavigate).toHaveBeenCalledWith( - 'TrendingBrowser', - expect.objectContaining({ - newTabUrl: 'https://example.com', - fromTrending: true, - }), - ); - }); - - it('does not show URL item for non-URL search queries', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - error: null, - }); - - const { getByTestId, queryByTestId } = render(); - - // Activate search - fireEvent.press(getByTestId('search-button')); - - // Type a non-URL query - fireEvent.changeText(getByTestId('explore-view-search-input'), 'meta'); - - // URL item should not appear - expect(queryByTestId('url-item')).toBeNull(); - - // But matching sites should appear - expect(getByTestId('site-item-1')).toBeOnTheScreen(); - }); - - it('hides Google search option when search query is empty', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - error: null, - }); - - const { getByTestId, queryByTestId } = render(); - - // Activate search - fireEvent.press(getByTestId('search-button')); - - // Google search should not appear with empty query - expect(queryByTestId('search-on-google-button')).toBeNull(); - }); - }); - - describe('Data Fetching', () => { - it('fetches sites with limit of 100', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - error: null, - }); - - render(); - - expect(mockUseSitesData).toHaveBeenCalledWith({ limit: 100 }); - }); - - it('passes isViewAll prop to child components', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - error: null, - }); - - const SiteRowItemWrapper = jest.requireMock( - '../SectionSites/SiteRowItemWrapper', - ); - - render(); - - expect(SiteRowItemWrapper).toHaveBeenCalledWith( - expect.objectContaining({ - isViewAll: true, - }), - expect.anything(), - ); - }); - }); - - describe('Edge Cases', () => { - it('handles transition from loading to loaded', () => { - mockUseSitesData.mockReturnValue({ - sites: [], - isLoading: true, - error: null, - }); - - const { rerender, getAllByTestId, queryByTestId, getByTestId } = render( - , - ); - - expect(getAllByTestId('site-skeleton').length).toBe(10); - - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - error: null, - }); - - rerender(); - - expect(queryByTestId('site-skeleton')).toBeNull(); - expect(getByTestId('site-item-1')).toBeOnTheScreen(); - }); - - it('handles sites with missing optional fields', () => { - const minimalSites: SiteData[] = [ - { - id: '1', - name: 'Test', - url: 'https://test.com', - displayUrl: 'test.com', - }, - ]; - - mockUseSitesData.mockReturnValue({ - sites: minimalSites, - isLoading: false, - error: null, - }); - - const { getByTestId } = render(); - - expect(getByTestId('site-item-1')).toBeOnTheScreen(); - }); - }); -}); diff --git a/app/components/Views/TrendingView/SitesListView/SitesListView.tsx b/app/components/Views/TrendingView/SitesListView/SitesListView.tsx deleted file mode 100644 index d4c7e83bee91..000000000000 --- a/app/components/Views/TrendingView/SitesListView/SitesListView.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import React, { useCallback, useState, useMemo } from 'react'; -import { FlatList, TouchableOpacity } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; -// eslint-disable-next-line no-duplicate-imports -import type { NavigationProp, ParamListBase } from '@react-navigation/native'; -import { - Box, - Icon, - IconName, - IconSize, -} from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useSitesData } from '../SectionSites/hooks/useSitesData'; -import SiteRowItemWrapper from '../SectionSites/SiteRowItemWrapper'; -import SiteSkeleton from '../SectionSites/SiteSkeleton/SiteSkeleton'; -import type { SiteData } from '../SectionSites/SiteRowItem/SiteRowItem'; -import HeaderBase, { - HeaderBaseVariant, -} from '../../../../component-library/components/HeaderBase'; -import ButtonIcon, { - ButtonIconSizes, -} from '../../../../component-library/components/Buttons/ButtonIcon'; -import { IconName as IconNameType } from '../../../../component-library/components/Icons/Icon'; -import Text, { - TextColor, - TextVariant, -} from '../../../../component-library/components/Texts/Text'; -import { strings } from '../../../../../locales/i18n'; -import ExploreSearchBar from '../ExploreSearchBar/ExploreSearchBar'; - -function looksLikeUrl(str: string): boolean { - return /^(https?:\/\/)?[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+([/?].*)?$/.test(str); -} - -const SitesListView: React.FC = () => { - const tw = useTailwind(); - const insets = useSafeAreaInsets(); - const navigation = useNavigation>(); - const [searchQuery, setSearchQuery] = useState(''); - const [isSearchActive, setIsSearchActive] = useState(false); - - // Fetch all sites (no limit) - const { sites, isLoading } = useSitesData({ limit: 100 }); - - // Filter sites based on search query - const filteredSites = useMemo(() => { - if (!searchQuery.trim()) { - return sites; - } - - const query = searchQuery.toLowerCase(); - return sites.filter( - (site) => - site.name.toLowerCase().includes(query) || - site.displayUrl.toLowerCase().includes(query) || - site.url.toLowerCase().includes(query), - ); - }, [sites, searchQuery]); - - const handleBackPress = useCallback(() => { - if (isSearchActive) { - setIsSearchActive(false); - setSearchQuery(''); - } else { - navigation.goBack(); - } - }, [navigation, isSearchActive]); - - const handleSearchPress = useCallback(() => { - setIsSearchActive(true); - }, []); - - const handleCancelSearch = useCallback(() => { - setIsSearchActive(false); - setSearchQuery(''); - }, []); - - const handlePressFooterLink = useCallback( - (url: string) => { - navigation.navigate('TrendingBrowser', { - newTabUrl: url, - timestamp: Date.now(), - fromTrending: true, - }); - }, - [navigation], - ); - - const renderSiteItem = ({ item }: { item: SiteData }) => ( - - ); - - const renderSkeleton = () => ( - <> - {[...Array(10)].map((_, index) => ( - - ))} - - ); - - const renderFooter = useMemo(() => { - if (!isSearchActive || searchQuery.length === 0) return null; - - const isUrl = looksLikeUrl(searchQuery.toLowerCase()); - const urlWithProtocol = - isUrl && !searchQuery.startsWith('http') - ? `https://${searchQuery}` - : searchQuery; - - return ( - - {isUrl && ( - handlePressFooterLink(urlWithProtocol)} - testID="url-item" - > - - - {searchQuery} - - - - - - - )} - - - handlePressFooterLink( - `https://www.google.com/search?q=${encodeURIComponent(searchQuery)}`, - ) - } - testID="search-on-google-button" - > - - - Search for {'"'} - - - {searchQuery} - - - {'"'} on Google - - - - - - - - ); - }, [isSearchActive, searchQuery, handlePressFooterLink, tw]); - - return ( - - {/* Header */} - - {isSearchActive ? ( - - ) : ( - - } - endAccessory={ - - } - style={tw.style('flex-row items-center gap-1')} - > - - {strings('trending.popular_sites')} - - - )} - - - {/* Sites List */} - - {isLoading ? ( - renderSkeleton() - ) : ( - item.id} - contentContainerStyle={tw.style('pb-4')} - showsVerticalScrollIndicator={false} - ListFooterComponent={renderFooter} - /> - )} - - - ); -}; - -export default SitesListView; diff --git a/app/components/Views/TrendingView/SitesListView/index.ts b/app/components/Views/TrendingView/SitesListView/index.ts deleted file mode 100644 index f705e80c2ab1..000000000000 --- a/app/components/Views/TrendingView/SitesListView/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './SitesListView'; diff --git a/app/components/Views/TrendingView/TrendingView.test.tsx b/app/components/Views/TrendingView/TrendingView.test.tsx index 327b39c09049..6a21ab1058f2 100644 --- a/app/components/Views/TrendingView/TrendingView.test.tsx +++ b/app/components/Views/TrendingView/TrendingView.test.tsx @@ -27,7 +27,6 @@ jest.mock('react-redux', () => ({ })); import TrendingView from './TrendingView'; -import { updateLastTrendingScreen } from '../../Nav/Main/MainNavigator'; import { selectChainId, selectPopularNetworkConfigurationsByCaipChainId, @@ -52,11 +51,6 @@ jest.mock('../../../util/browser', () => ({ })), })); -jest.mock('../Browser', () => ({ - __esModule: true, - default: jest.fn(() => null), -})); - // Mock the network hooks used by useTrendingRequest jest.mock( '../../../components/hooks/useNetworksByNamespace/useNetworksByNamespace', @@ -387,52 +381,7 @@ describe('TrendingView', () => { expect(getByText('99')).toBeDefined(); }); - it('navigates to TrendingBrowser when button is pressed with no tabs', () => { - mockUseSelector.mockImplementation((selector) => { - // Handle browser tabs count selector - if (typeof selector === 'function') { - const selectorStr = selector.toString(); - if (selectorStr.includes('browser') && selectorStr.includes('tabs')) { - return 0; - } - if (selectorStr.includes('dataCollectionForMarketing')) { - return false; - } - } - // Return default mock values for other selectors - if (selector === selectChainId) { - return '0x1'; - } - if (selector === selectIsEvmNetworkSelected) { - return true; - } - if (selector === selectEnabledNetworksByNamespace) { - return { eip155: { '0x1': true } }; - } - if (selector === selectPopularNetworkConfigurationsByCaipChainId) { - return []; - } - if (selector === selectCustomNetworkConfigurationsByCaipChainId) { - return []; - } - if (selector === selectMultichainAccountsState2Enabled) { - return false; - } - if (selector === selectSelectedInternalAccountByScope) { - return (_scope: string) => null; - } - if (typeof selector === 'function') { - const selectorStr = selector.toString(); - if ( - selectorStr.includes('selectSelectedInternalAccountByScope') || - selectorStr.includes('SelectedInternalAccountByScope') - ) { - return (_scope: string) => null; - } - } - return undefined; - }); - + it('navigates to TrendingBrowser when button is pressed', () => { const { getByTestId } = render( @@ -442,75 +391,13 @@ describe('TrendingView', () => { const browserButton = getByTestId('trending-view-browser-button'); fireEvent.press(browserButton); - expect(mockNavigate).toHaveBeenCalledWith('TrendingBrowser', { - newTabUrl: expect.stringContaining('?metamaskEntry=mobile'), - timestamp: expect.any(Number), - fromTrending: true, - }); - expect(updateLastTrendingScreen).toHaveBeenCalledWith('TrendingBrowser'); - }); - - it('navigates to TrendingBrowser when button is pressed with existing tabs', () => { - mockUseSelector.mockImplementation((selector) => { - // Handle browser tabs count selector - if (typeof selector === 'function') { - const selectorStr = selector.toString(); - if (selectorStr.includes('browser') && selectorStr.includes('tabs')) { - return 3; - } - if (selectorStr.includes('dataCollectionForMarketing')) { - return false; - } - } - // Return default mock values for other selectors - if (selector === selectChainId) { - return '0x1'; - } - if (selector === selectIsEvmNetworkSelected) { - return true; - } - if (selector === selectEnabledNetworksByNamespace) { - return { eip155: { '0x1': true } }; - } - if (selector === selectPopularNetworkConfigurationsByCaipChainId) { - return []; - } - if (selector === selectCustomNetworkConfigurationsByCaipChainId) { - return []; - } - if (selector === selectMultichainAccountsState2Enabled) { - return false; - } - if (selector === selectSelectedInternalAccountByScope) { - return (_scope: string) => null; - } - if (typeof selector === 'function') { - const selectorStr = selector.toString(); - if ( - selectorStr.includes('selectSelectedInternalAccountByScope') || - selectorStr.includes('SelectedInternalAccountByScope') - ) { - return (_scope: string) => null; - } - } - return undefined; - }); - - const { getByTestId } = render( - - - , + expect(mockNavigate).toHaveBeenCalledWith( + 'TrendingBrowser', + expect.objectContaining({ + newTabUrl: expect.stringContaining('?metamaskEntry=mobile'), + fromTrending: true, + }), ); - - const browserButton = getByTestId('trending-view-browser-button'); - fireEvent.press(browserButton); - - expect(mockNavigate).toHaveBeenCalledWith('TrendingBrowser', { - newTabUrl: expect.stringContaining('?metamaskEntry=mobile'), - timestamp: expect.any(Number), - fromTrending: true, - }); - expect(updateLastTrendingScreen).toHaveBeenCalledWith('TrendingBrowser'); }); }); @@ -524,45 +411,6 @@ describe('TrendingView', () => { expect(getByText('Explore')).toBeDefined(); }); - it('navigates to TrendingBrowser route when browser button is pressed', () => { - const { getByTestId } = render( - - - , - ); - - const browserButton = getByTestId('trending-view-browser-button'); - - fireEvent.press(browserButton); - - expect(mockNavigate).toHaveBeenCalledWith('TrendingBrowser', { - newTabUrl: expect.stringContaining('?metamaskEntry=mobile'), - timestamp: expect.any(Number), - fromTrending: true, - }); - expect(updateLastTrendingScreen).toHaveBeenCalledWith('TrendingBrowser'); - }); - - it('includes portfolio URL with correct parameters when browser button is pressed', () => { - const { getByTestId } = render( - - - , - ); - - const browserButton = getByTestId('trending-view-browser-button'); - - fireEvent.press(browserButton); - - expect(mockNavigate).toHaveBeenCalledWith( - 'TrendingBrowser', - expect.objectContaining({ - newTabUrl: expect.stringContaining('metamaskEntry=mobile'), - fromTrending: true, - }), - ); - }); - it('renders search bar button', () => { const { getByTestId } = render( diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx index 5593c6cd6446..5eea579782d0 100644 --- a/app/components/Views/TrendingView/TrendingView.tsx +++ b/app/components/Views/TrendingView/TrendingView.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { ScrollView, TouchableOpacity, RefreshControl } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; @@ -18,7 +18,6 @@ import AppConstants from '../../../core/AppConstants'; import { appendURLParams } from '../../../util/browser'; import { useMetrics } from '../../hooks/useMetrics'; import { useTheme } from '../../../util/theme'; -import Browser from '../Browser'; import Routes from '../../../constants/navigation/Routes'; import { lastTrendingScreenRef, @@ -26,42 +25,12 @@ import { } from '../../Nav/Main/MainNavigator'; import ExploreSearchScreen from './ExploreSearchScreen/ExploreSearchScreen'; import ExploreSearchBar from './ExploreSearchBar/ExploreSearchBar'; -import { - 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(); -// Wrapper component to intercept navigation -const BrowserWrapper: React.FC<{ route: object }> = ({ route }) => { - const navigation = useNavigation(); - - // Create a custom navigation object that intercepts navigate calls - const customNavigation = useMemo(() => { - const originalNavigate = navigation.navigate.bind(navigation); - - return { - ...navigation, - navigate: (routeName: string, params?: object) => { - // If trying to navigate to TRENDING_VIEW, go back in stack instead - if (routeName === Routes.TRENDING_VIEW) { - navigation.goBack(); - } else { - originalNavigate(routeName, params); - } - }, - }; - }, [navigation]); - - return ; -}; - const TrendingFeed: React.FC = () => { const tw = useTailwind(); const insets = useSafeAreaInsets(); @@ -198,43 +167,10 @@ const TrendingView: React.FC = () => { }} > - - - - - ); }; diff --git a/app/components/Views/TrendingView/components/BrowserWrapper/BrowserWrapper.tsx b/app/components/Views/TrendingView/components/BrowserWrapper/BrowserWrapper.tsx new file mode 100644 index 000000000000..ab877cef7edf --- /dev/null +++ b/app/components/Views/TrendingView/components/BrowserWrapper/BrowserWrapper.tsx @@ -0,0 +1,37 @@ +import React, { useMemo } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import Browser from '../../../Browser'; +import Routes from '../../../../../constants/navigation/Routes'; + +// Wrapper component to intercept navigation +const BrowserWrapper: React.FC<{ route: object }> = ({ route }) => { + const navigation = useNavigation(); + const tw = useTailwind(); + + // Create a custom navigation object that intercepts navigate calls + const customNavigation = useMemo(() => { + const originalNavigate = navigation.navigate.bind(navigation); + + return { + ...navigation, + navigate: (routeName: string, params?: object) => { + // If trying to navigate to TRENDING_VIEW, go back in stack instead + if (routeName === Routes.TRENDING_VIEW) { + navigation.goBack(); + } else { + originalNavigate(routeName, params); + } + }, + }; + }, [navigation]); + + return ( + + + + ); +}; + +export default BrowserWrapper; diff --git a/app/components/Views/TrendingView/config/sections.config.tsx b/app/components/Views/TrendingView/config/sections.config.tsx index 989a94b1b1b7..45887c5c11d3 100644 --- a/app/components/Views/TrendingView/config/sections.config.tsx +++ b/app/components/Views/TrendingView/config/sections.config.tsx @@ -19,10 +19,10 @@ import { usePerpsMarkets } from '../../../UI/Perps/hooks'; import { PerpsConnectionProvider } from '../../../UI/Perps/providers/PerpsConnectionProvider'; import { PerpsStreamProvider } from '../../../UI/Perps/providers/PerpsStreamManager'; import { Box, IconName } from '@metamask/design-system-react-native'; -import type { SiteData } from '../SectionSites/SiteRowItem/SiteRowItem'; -import SiteRowItemWrapper from '../SectionSites/SiteRowItemWrapper'; -import SiteSkeleton from '../SectionSites/SiteSkeleton/SiteSkeleton'; -import { useSitesData } from '../SectionSites/hooks/useSitesData'; +import type { SiteData } from '../../../UI/Sites/components/SiteRowItem/SiteRowItem'; +import SiteRowItemWrapper from '../../../UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper'; +import SiteSkeleton from '../../../UI/Sites/components/SiteSkeleton/SiteSkeleton'; +import { useSitesData } from '../../../UI/Sites/hooks/useSiteData/useSitesData'; import { useTrendingSearch } from '../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'; export type SectionId = 'predictions' | 'tokens' | 'perps' | 'sites'; @@ -180,7 +180,7 @@ export const SECTIONS_CONFIG: Record = { title: strings('trending.sites'), icon: IconName.Global, viewAllAction: (navigation) => { - navigation.navigate(Routes.SITES_LIST_VIEW); + navigation.navigate(Routes.SITES_FULL_VIEW); }, RowItem: ({ item, navigation }) => ( diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 74a47afa24ac..dc3108646399 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -79,7 +79,7 @@ const Routes = { REWARDS_SETTINGS_VIEW: 'RewardsSettingsView', REWARDS_DASHBOARD: 'RewardsDashboard', TRENDING_VIEW: 'TrendingView', - SITES_LIST_VIEW: 'SitesListView', + SITES_FULL_VIEW: 'SitesFullView', EXPLORE_SEARCH: 'ExploreSearch', REWARDS_ONBOARDING_FLOW: 'RewardsOnboardingFlow', REWARDS_ONBOARDING_INTRO: 'RewardsOnboardingIntro', From ace43469b039669ae34dbafcd79298a204656944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:18:33 +0100 Subject: [PATCH 02/16] refactor: update AccountSelector animations to use screen width instead of height (#23313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Updated the Account List transition animation from bottom-top (vertical slide) to right-left (horizontal slide) to align with the new design system patterns. The change affects the full-page account list view when the `fullPageAccountList` feature flag is enabled. **What is the reason for the change?** - The design system has been updated to use right-left transitions for full-page modals instead of the previous bottom-top pattern - This change ensures consistency with the new design system guidelines **What is the improvement/solution?** - Changed animation from `translateY` (vertical) to `translateX` (horizontal) - Updated initial animation value from `screenHeight` to `screenWidth` - Updated backdrop opacity interpolation to work with horizontal translation - Maintained the same spring animation configuration for smooth transitions ## **Changelog** CHANGELOG entry: Updated Account List transition animation to slide in from the right instead of from the bottom (behind feature flag) ## **Related issues** Fixes: [TMCU-223](https://consensyssoftware.atlassian.net/browse/TMCU-223) ## **Manual testing steps** ```gherkin Feature: Account List Transition Animation Scenario: user opens account list with full-page feature flag enabled Given the app is running with the `fullPageAccountList` feature flag enabled And the user is on the wallet/home screen When user taps on the account selector/account name Then the account list should slide in from the right (left-to-right animation) And the backdrop should fade in smoothly And when user selects an account or taps back, the list should slide out to the right ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/fcc87382-b920-4a12-94a1-2caf3e6e7f7d ### **After** https://github.com/user-attachments/assets/494ccf12-2648-4a40-b8cd-4ecc151aff12 ## **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. [TMCU-223]: https://consensyssoftware.atlassian.net/browse/TMCU-223?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > Switches full-page AccountSelector transition from vertical (height/translateY) to horizontal (width/translateX), updating animations and backdrop interpolation accordingly. > > - **AccountSelector (`app/components/Views/AccountSelector/AccountSelector.tsx`)**: > - Replace vertical animation with horizontal: > - Use `screenWidth` instead of `screenHeight` and `translateX` instead of `translateY`. > - Update enter/exit animations to spring/timing on `translateX`. > - Adjust backdrop opacity interpolation to `[screenWidth, 0]` on `translateX`. > - Update animated styles to transform with `translateX`. > - Applies only when `fullPageAccountList` feature flag is enabled. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 7028f659afb40ea2a3efb3b3cfb9a185dce328ea. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/AccountSelector/AccountSelector.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/components/Views/AccountSelector/AccountSelector.tsx b/app/components/Views/AccountSelector/AccountSelector.tsx index da16539bf735..a38b2ba43ebf 100644 --- a/app/components/Views/AccountSelector/AccountSelector.tsx +++ b/app/components/Views/AccountSelector/AccountSelector.tsx @@ -90,7 +90,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { const dispatch = useDispatch(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const { height: screenHeight } = useWindowDimensions(); + const { width: screenWidth } = useWindowDimensions(); const { trackEvent, createEventBuilder } = useMetrics(); const routeParams = useMemo(() => route?.params, [route?.params]); @@ -162,11 +162,11 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { useState(false); // Animation using react-native-reanimated - only for full-page version - const translateY = useSharedValue(screenHeight); + const translateX = useSharedValue(screenWidth); - // Backdrop opacity animation - fades in as screen slides up + // Backdrop opacity animation - fades in as screen slides in from right const backdropOpacity = useDerivedValue(() => - interpolate(translateY.value, [screenHeight, 0], [0, 0.5]), + interpolate(translateX.value, [screenWidth, 0], [0, 0.5]), ); useEffect(() => { @@ -185,7 +185,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { }); }; - translateY.value = withSpring( + translateX.value = withSpring( 0, { damping: 20, @@ -204,8 +204,8 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { navigation.goBack(); }; - translateY.value = withTiming( - screenHeight, + translateX.value = withTiming( + screenWidth, { duration: AnimationDuration.Fast }, () => runOnJS(onCloseComplete)(), ); @@ -213,7 +213,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { // BottomSheet version: close the sheet sheetRef.current?.onCloseBottomSheet(); } - }, [isFullPageAccountList, translateY, navigation, screenHeight]); + }, [isFullPageAccountList, translateX, navigation, screenWidth]); const _onSelectAccount = useCallback( (address: string) => { @@ -412,7 +412,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { ]); const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateY: translateY.value }], + transform: [{ translateX: translateX.value }], })); const backdropStyle = useAnimatedStyle(() => ({ From 97fe70301b8fe33f8e4c9fd7941ba4769dfbdbc9 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 26 Nov 2025 15:50:15 +0100 Subject: [PATCH 03/16] chore: bump `eth-snap-keyring` (to enable `:accountCreated` idempotency) cp-7.60.0 (#23310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Bumping `@metamask/eth-snap-keyring` to enable `notify:accountCreated` idempotency which is required by the Bitcoin Snap. This will reduce the number of "misaligned" warnings we had with Bitcoin. Similar to: - https://github.com/MetaMask/metamask-extension/pull/38292 ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: - https://github.com/MetaMask/metamask-mobile/issues/23324 ## **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] > Upgrade `@metamask/eth-snap-keyring` to ^18.0.2 and refresh related keyring dependencies in `yarn.lock`. > > - **Dependencies**: > - Bump `@metamask/eth-snap-keyring` from `^18.0.0` to `^18.0.2` in `package.json`. > - Update lockfile to resolve to `18.0.2` and align transitive deps: > - `@metamask/keyring-api` -> `^21.2.0` > - `@metamask/keyring-internal-api` -> `9.1.1` > - `@metamask/keyring-internal-snap-client` -> `8.0.1` > - `@metamask/keyring-snap-client` -> `8.1.1` > - `@metamask/keyring-snap-sdk` -> `7.1.1` > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 02bd3bf893a7763b30270c8de3441666ba73b1d2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- package.json | 2 +- yarn.lock | 66 ++++++++++++++++++++++++++-------------------------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index d93573cf1b9c..058289542c4d 100644 --- a/package.json +++ b/package.json @@ -223,7 +223,7 @@ "@metamask/eth-qr-keyring": "^1.1.0", "@metamask/eth-query": "^4.0.0", "@metamask/eth-sig-util": "^8.0.0", - "@metamask/eth-snap-keyring": "^18.0.0", + "@metamask/eth-snap-keyring": "^18.0.2", "@metamask/etherscan-link": "^2.0.0", "@metamask/ethjs-contract": "^0.4.1", "@metamask/ethjs-query": "^0.7.1", diff --git a/yarn.lock b/yarn.lock index 37ac84c03151..73ba24ac5cc3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8160,16 +8160,16 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-snap-keyring@npm:^18.0.0": - version: 18.0.0 - resolution: "@metamask/eth-snap-keyring@npm:18.0.0" +"@metamask/eth-snap-keyring@npm:^18.0.0, @metamask/eth-snap-keyring@npm:^18.0.2": + version: 18.0.2 + resolution: "@metamask/eth-snap-keyring@npm:18.0.2" dependencies: "@ethereumjs/tx": "npm:^5.4.0" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/keyring-api": "npm:^21.1.0" - "@metamask/keyring-internal-api": "npm:^9.1.0" - "@metamask/keyring-internal-snap-client": "npm:^8.0.0" - "@metamask/keyring-snap-sdk": "npm:^7.1.0" + "@metamask/keyring-api": "npm:^21.2.0" + "@metamask/keyring-internal-api": "npm:^9.1.1" + "@metamask/keyring-internal-snap-client": "npm:^8.0.1" + "@metamask/keyring-snap-sdk": "npm:^7.1.1" "@metamask/keyring-utils": "npm:^3.1.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/superstruct": "npm:^3.1.0" @@ -8177,8 +8177,8 @@ __metadata: "@types/uuid": "npm:^9.0.8" uuid: "npm:^9.0.1" peerDependencies: - "@metamask/keyring-api": ^21.1.0 - checksum: 10/39a6380e351997e53776c8db9d1558769517a1a12ec1431c40cedb516d90ae447a81b7b1c21bc8d8ffcbc31188cf52f17057a1416d509013cfe8b2f46b314e02 + "@metamask/keyring-api": ^21.2.0 + checksum: 10/2c37e55cf4b56089fb5a081d3809b9004b8bbe2822267fbe5b8884cd687da4a43e122b053ebbc418173353232066a4763edc90002f51ce55a84e53a7009c16e6 languageName: node linkType: hard @@ -8391,7 +8391,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-api@npm:^21.0.0, @metamask/keyring-api@npm:^21.1.0, @metamask/keyring-api@npm:^21.2.0": +"@metamask/keyring-api@npm:^21.0.0, @metamask/keyring-api@npm:^21.2.0": version: 21.2.0 resolution: "@metamask/keyring-api@npm:21.2.0" dependencies: @@ -8426,35 +8426,35 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-internal-api@npm:^9.0.0, @metamask/keyring-internal-api@npm:^9.1.0": - version: 9.1.0 - resolution: "@metamask/keyring-internal-api@npm:9.1.0" +"@metamask/keyring-internal-api@npm:^9.0.0, @metamask/keyring-internal-api@npm:^9.1.0, @metamask/keyring-internal-api@npm:^9.1.1": + version: 9.1.1 + resolution: "@metamask/keyring-internal-api@npm:9.1.1" dependencies: - "@metamask/keyring-api": "npm:^21.1.0" + "@metamask/keyring-api": "npm:^21.2.0" "@metamask/keyring-utils": "npm:^3.1.0" "@metamask/superstruct": "npm:^3.1.0" - checksum: 10/6b19f35f57bc1b5dc73957d7f3185236780c93e6292678e22d84f9eb2fe92e15a98437a9bc4fbe5e5e10143d4db36afa2c420636f2cca4bd984e8455ca4332c6 + checksum: 10/ab0fb8e153a02d3d0acf739d77356a1c60e0a7bf998dcbba9468f9f231605beaed472d8bff27dc56323d0a2529167336499e23dcad911fa8c3e37999ed14d2d1 languageName: node linkType: hard -"@metamask/keyring-internal-snap-client@npm:^8.0.0": - version: 8.0.0 - resolution: "@metamask/keyring-internal-snap-client@npm:8.0.0" +"@metamask/keyring-internal-snap-client@npm:^8.0.1": + version: 8.0.1 + resolution: "@metamask/keyring-internal-snap-client@npm:8.0.1" dependencies: - "@metamask/keyring-api": "npm:^21.1.0" - "@metamask/keyring-internal-api": "npm:^9.1.0" - "@metamask/keyring-snap-client": "npm:^8.1.0" + "@metamask/keyring-api": "npm:^21.2.0" + "@metamask/keyring-internal-api": "npm:^9.1.1" + "@metamask/keyring-snap-client": "npm:^8.1.1" "@metamask/keyring-utils": "npm:^3.1.0" "@metamask/messenger": "npm:^0.3.0" - checksum: 10/7a4aa08ac6ac1bda064182420af01b785aaaff37068d14577007ce40e53f4da33b3bbc1a18625ebd75cee6d08c34de8dc860e6c927477335d5f1df72328b563a + checksum: 10/40a686cd3d1f49accde83bb2a983ac9e897498e1de5a0ccb0768e382d44dd4c273230db95bcd6eace4ad8a184e7ab4fc780770f617994a2ca29b4302890f31b6 languageName: node linkType: hard -"@metamask/keyring-snap-client@npm:^8.0.0, @metamask/keyring-snap-client@npm:^8.1.0": - version: 8.1.0 - resolution: "@metamask/keyring-snap-client@npm:8.1.0" +"@metamask/keyring-snap-client@npm:^8.0.0, @metamask/keyring-snap-client@npm:^8.1.0, @metamask/keyring-snap-client@npm:^8.1.1": + version: 8.1.1 + resolution: "@metamask/keyring-snap-client@npm:8.1.1" dependencies: - "@metamask/keyring-api": "npm:^21.1.0" + "@metamask/keyring-api": "npm:^21.2.0" "@metamask/keyring-utils": "npm:^3.1.0" "@metamask/superstruct": "npm:^3.1.0" "@types/uuid": "npm:^9.0.8" @@ -8462,13 +8462,13 @@ __metadata: webextension-polyfill: "npm:^0.12.0" peerDependencies: "@metamask/providers": ^19.0.0 - checksum: 10/e92aa7f6e1454150870e8e0a6d9cf4fac7bbc22280d85a252ca7ee428842dfbaaaccae78dfc5ad773e21d757febfcbe6933a72b966c4478f1a2b3fc0088419a1 + checksum: 10/dcdc9a286137a4ae884b709e565b988fb2e555a8a80db5d2ed3e93ee5262c81567a4efac6ff663b6751caf5b1173f92bc8437a395696058018a3b6e93fc30b35 languageName: node linkType: hard -"@metamask/keyring-snap-sdk@npm:^7.1.0": - version: 7.1.0 - resolution: "@metamask/keyring-snap-sdk@npm:7.1.0" +"@metamask/keyring-snap-sdk@npm:^7.1.1": + version: 7.1.1 + resolution: "@metamask/keyring-snap-sdk@npm:7.1.1" dependencies: "@metamask/keyring-utils": "npm:^3.1.0" "@metamask/snaps-sdk": "npm:^9.0.0" @@ -8476,9 +8476,9 @@ __metadata: "@metamask/utils": "npm:^11.1.0" webextension-polyfill: "npm:^0.12.0" peerDependencies: - "@metamask/keyring-api": ^21.1.0 + "@metamask/keyring-api": ^21.2.0 "@metamask/providers": ^19.0.0 - checksum: 10/1a1809733c1f21af87f3491d292c499c5441afa0780e848718ec2b6aff50d76bb03ea44ee93ecaa80d79453a98926d84cd13ff406256ab6a2136d9e31250faa8 + checksum: 10/ac4ce050f4647096ef66ebd04d99d1423c002ca0fb05bd83e11caec59754b56d73bb8a95ac3a76f64472713256205e889d6785003dfe2c35f5f1d67c2f2efd12 languageName: node linkType: hard @@ -35667,7 +35667,7 @@ __metadata: "@metamask/eth-qr-keyring": "npm:^1.1.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/eth-sig-util": "npm:^8.0.0" - "@metamask/eth-snap-keyring": "npm:^18.0.0" + "@metamask/eth-snap-keyring": "npm:^18.0.2" "@metamask/etherscan-link": "npm:^2.0.0" "@metamask/ethjs-contract": "npm:^0.4.1" "@metamask/ethjs-query": "npm:^0.7.1" From 011271d8f68ad9235d1daa244a2c76f4f68d8a67 Mon Sep 17 00:00:00 2001 From: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:57:41 +0100 Subject: [PATCH 04/16] feat: track RPC update from network connection banner (#22879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR tracks when users complete the RPC update flow initiated from the network connection banner, matching the implementation in metamask-extension https://github.com/MetaMask/metamask-extension/pull/37751. **Context**: Currently we track when users click "Update RPC" from the network connection banner (`NetworkConnectionBannerUpdateRpcClicked`), but we don't track whether they actually complete the update by saving changes in the network settings form. **Solution**: Added the `NetworkConnectionBannerRpcUpdated` MetaMetrics event that fires when: 1. User clicks "Update RPC" from the network connection banner (sets `trackRpcUpdateFromBanner: true` in navigation params) 2. User modifies the RPC endpoint in the NetworkSettings form 3. User saves the changes This provides complete funnel tracking for the network banner → RPC update flow. ## **Changelog** CHANGELOG entry: null (Internal analytics tracking - not user-facing) ## **Related issues** Fixes: [ttps://consensyssoftware.atlassian.net/browse/WPC-172](https://consensyssoftware.atlassian.net/browse/WPC-172) ## **Manual testing steps** Feature: Track RPC update from network connection banner Scenario: user completes RPC update from network connection banner Given user has a network with degraded or unavailable status And network connection banner is shown When user clicks "Update RPC" button in the banner And user changes the RPC endpoint in the network settings form And user saves the network settings Then NetworkConnectionBannerRpcUpdated event is tracked And event includes chain_id_caip, from_rpc_domain, and to_rpc_domain properties Scenario: user saves network settings without banner tracking flag Given user is in network settings form And trackRpcUpdateFromBanner is not set When user modifies RPC endpoint And user saves the network settings Then NetworkConnectionBannerRpcUpdated event is not tracked ## **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] > Tracks a new "Network Connection Banner RPC Updated" event when a banner-initiated RPC change is saved, with sanitized domains and CAIP chain IDs, and wires the tracking flag through navigation with comprehensive tests. > > - **Analytics** > - Add `MetaMetricsEvents.NetworkConnectionBannerRpcUpdated` and expose in events map. > - **Settings › NetworkSettings** > - On save, when `trackRpcUpdateFromBanner` is true, emit `NetworkConnectionBannerRpcUpdated` with `chain_id_caip`, `from_rpc_domain`, `to_rpc_domain`. > - Sanitize endpoints via `isPublicEndpointUrl`/`onlyKeepHost`; compute CAIP chain ID with `hexToNumber`. > - **Hooks** > - `useNetworkConnectionBanner.updateRpc` now navigates to `Routes.EDIT_NETWORK` with `trackRpcUpdateFromBanner: true`. > - **Tests** > - Add/extend tests for event emission, non-emission, URL sanitization (`custom`), `unknown` fallback, and navigation param propagation. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5f125ba1c7ee1ce0b682ad65ced0609960d1f759. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../NetworksSettings/NetworkSettings/index.js | 41 ++- .../NetworkSettings/index.test.tsx | 295 +++++++++++++++++- .../useNetworkConnectionBanner.test.tsx | 1 + .../useNetworkConnectionBanner.ts | 1 + app/core/Analytics/MetaMetrics.events.ts | 4 + 5 files changed, 335 insertions(+), 7 deletions(-) diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js index 5ba833a4d429..0e49cbc92bc6 100644 --- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js +++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js @@ -32,6 +32,7 @@ import sanitizeUrl, { compareSanitizedUrl, } from '../../../../../util/sanitizeUrl'; import onlyKeepHost from '../../../../../util/onlyKeepHost'; +import { isPublicEndpointUrl } from '../../../../../core/Engine/controllers/network-controller/utils'; import { themeAppearanceLight } from '../../../../../constants/storage'; import CustomNetwork from './CustomNetworkView/CustomNetwork'; import Button, { @@ -48,6 +49,7 @@ import { selectIsRpcFailoverEnabled } from '../../../../../selectors/featureFlag import { regex } from '../../../../../../app/util/regex'; import { NetworksViewSelectorsIDs } from '../../../../../../e2e/selectors/Settings/NetworksView.selectors'; import { isSafeChainId, toHex } from '@metamask/controller-utils'; +import { hexToNumber } from '@metamask/utils'; import { CustomDefaultNetworkIDs } from '../../../../../../e2e/selectors/Onboarding/CustomDefaultNetwork.selectors'; import { updateIncomingTransactions } from '../../../../../util/transaction-controller'; import { withMetricsAwareness } from '../../../../../components/hooks/useMetrics'; @@ -83,7 +85,7 @@ import Tag from '../../../../../component-library/components/Tags/Tag/Tag'; import { CellComponentSelectorsIDs } from '../../../../../../e2e/selectors/wallet/CellComponent.selectors'; import stripProtocol from '../../../../../util/stripProtocol'; import stripKeyFromInfuraUrl from '../../../../../util/stripKeyFromInfuraUrl'; -import { MetaMetrics } from '../../../../../core/Analytics'; +import { MetaMetrics, MetaMetricsEvents } from '../../../../../core/Analytics'; import { addItemToChainIdList, removeItemFromChainIdList, @@ -532,6 +534,7 @@ export class NetworkSettings extends PureComponent { isCustomMainnet, shouldNetworkSwitchPopToWallet, navigation, + trackRpcUpdateFromBanner, }) => { const { NetworkController } = Engine.context; @@ -569,6 +572,38 @@ export class NetworkSettings extends PureComponent { } : undefined, ); + + // Track RPC update from network connection banner + if (trackRpcUpdateFromBanner) { + const newRpcEndpoint = + networkConfig.rpcEndpoints[networkConfig.defaultRpcEndpointIndex]; + const oldRpcEndpoint = + existingNetwork.rpcEndpoints?.[ + existingNetwork.defaultRpcEndpointIndex ?? 0 + ]; + + const chainIdAsDecimal = hexToNumber(chainId); + + const sanitizeRpcUrl = (url) => + isPublicEndpointUrl(url, infuraProjectId) + ? onlyKeepHost(url) + : 'custom'; + + this.props.metrics.trackEvent( + this.props.metrics + .createEventBuilder( + MetaMetricsEvents.NetworkConnectionBannerRpcUpdated, + ) + .addProperties({ + chain_id_caip: `eip155:${chainIdAsDecimal}`, + from_rpc_domain: oldRpcEndpoint?.url + ? sanitizeRpcUrl(oldRpcEndpoint.url) + : 'unknown', + to_rpc_domain: sanitizeRpcUrl(newRpcEndpoint.url), + }) + .build(), + ); + } } else { await NetworkController.addNetwork({ ...networkConfig, @@ -614,6 +649,9 @@ export class NetworkSettings extends PureComponent { const shouldNetworkSwitchPopToWallet = route.params?.shouldNetworkSwitchPopToWallet ?? true; + + const trackRpcUpdateFromBanner = + route.params?.trackRpcUpdateFromBanner ?? false; // Check if CTA is disabled const isCtaDisabled = !enableAction || this.disabledByChainId() || this.disabledBySymbol(); @@ -684,6 +722,7 @@ export class NetworkSettings extends PureComponent { networkType, networkUrl, showNetworkOnboarding, + trackRpcUpdateFromBanner, }); }; diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx index 21847e0f60c5..b2cc6d34a4bb 100644 --- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx +++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx @@ -24,6 +24,7 @@ import * as jsonRequest from '../../../../../util/jsonRpcRequest'; import Logger from '../../../../../util/Logger'; import Engine from '../../../../../core/Engine'; import { isRemoveGlobalNetworkSelectorEnabled } from '../../../../../util/networks'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; const { PreferencesController } = Engine.context; jest.mock( @@ -38,14 +39,28 @@ jest.mock( }), ); +const mockTrackEvent = jest.fn(); + +const mockCreateEventBuilder = jest.fn((eventName) => { + let properties = {}; + return { + addProperties(props: Record) { + properties = { ...properties, ...props }; + return this; + }, + build() { + return { + name: eventName, + properties, + }; + }, + }; +}); + jest.mock('../../../../../components/hooks/useMetrics', () => ({ useMetrics: () => ({ - trackEvent: jest.fn(), - createEventBuilder: jest.fn(() => ({ - addProperties: jest.fn(() => ({ - build: jest.fn(), - })), - })), + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, }), withMetricsAwareness: (Component: unknown) => Component, })); @@ -1410,6 +1425,274 @@ describe('NetworkSettings', () => { { replacementSelectedRpcEndpointIndex: 0 }, ); }); + + it('tracks RPC update event when trackRpcUpdateFromBanner is true', async () => { + const PROPS_WITH_METRICS = { + ...SAMPLE_PROPS, + metrics: { + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }, + networkConfigurations: { + '0x64': { + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + chainId: '0x64', + rpcEndpoints: [ + { + networkClientId: 'custom', + type: 'custom', + url: 'https://mainnet.infura.io/v3/', + }, + ], + name: 'Custom Network', + nativeCurrency: 'ETH', + }, + }, + }; + + const wrapper5 = shallow( + + + , + ) + .find(NetworkSettings) + .dive(); + + const instance = wrapper5.instance() as NetworkSettings; + + await instance.handleNetworkUpdate({ + rpcUrl: 'https://monad-mainnet.infura.io/v3/', + rpcUrls: [ + { + url: 'https://monad-mainnet.infura.io/v3/', + type: 'custom', + name: 'Monad RPC', + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + blockExplorerUrl: 'https://etherscan.io', + nickname: 'Custom Network', + ticker: 'ETH', + isNetworkExists: [], + chainId: '0x64', + navigation: mockNavigation, + isCustomMainnet: false, + shouldNetworkSwitchPopToWallet: true, + trackRpcUpdateFromBanner: true, + }); + + expect(Engine.context.NetworkController.updateNetwork).toHaveBeenCalled(); + expect(mockTrackEvent).toHaveBeenCalledWith( + mockCreateEventBuilder( + MetaMetricsEvents.NetworkConnectionBannerRpcUpdated, + ) + .addProperties({ + chain_id_caip: 'eip155:100', + from_rpc_domain: 'mainnet.infura.io', + to_rpc_domain: 'monad-mainnet.infura.io', + }) + .build(), + ); + }); + + it('does not track RPC update event when trackRpcUpdateFromBanner is false', async () => { + const PROPS_WITHOUT_TRACKING = { + ...SAMPLE_PROPS, + metrics: { + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }, + networkConfigurations: { + '0x64': { + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + chainId: '0x64', + rpcEndpoints: [ + { + networkClientId: 'custom', + type: 'custom', + url: 'https://mainnet.infura.io/v3/', + }, + ], + name: 'Custom Network', + nativeCurrency: 'ETH', + }, + }, + }; + + const wrapper6 = shallow( + + + , + ) + .find(NetworkSettings) + .dive(); + + const instance = wrapper6.instance() as NetworkSettings; + + await instance.handleNetworkUpdate({ + rpcUrl: 'https://monad-mainnet.infura.io/v3/', + rpcUrls: [ + { + url: 'https://monad-mainnet.infura.io/v3/', + type: 'custom', + name: 'Monad RPC', + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + blockExplorerUrl: 'https://etherscan.io', + nickname: 'Custom Network', + ticker: 'ETH', + isNetworkExists: [], + chainId: '0x64', + navigation: mockNavigation, + isCustomMainnet: false, + shouldNetworkSwitchPopToWallet: true, + trackRpcUpdateFromBanner: false, + }); + + expect(Engine.context.NetworkController.updateNetwork).toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('sanitizes custom RPC URLs as "custom" in tracking event', async () => { + const PROPS_WITH_CUSTOM_RPC = { + ...SAMPLE_PROPS, + metrics: { + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }, + networkConfigurations: { + '0x64': { + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + chainId: '0x64', + rpcEndpoints: [ + { + networkClientId: 'custom', + type: 'custom', + url: 'https://my-private-rpc.com', + }, + ], + name: 'Custom Network', + nativeCurrency: 'ETH', + }, + }, + }; + + const wrapper7 = shallow( + + + , + ) + .find(NetworkSettings) + .dive(); + + const instance = wrapper7.instance() as NetworkSettings; + + await instance.handleNetworkUpdate({ + rpcUrl: 'https://another-private-rpc.com', + rpcUrls: [ + { + url: 'https://another-private-rpc.com', + type: 'custom', + name: 'Another Custom RPC', + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + blockExplorerUrl: 'https://etherscan.io', + nickname: 'Custom Network', + ticker: 'ETH', + isNetworkExists: [], + chainId: '0x64', + navigation: mockNavigation, + isCustomMainnet: false, + shouldNetworkSwitchPopToWallet: true, + trackRpcUpdateFromBanner: true, + }); + + expect(Engine.context.NetworkController.updateNetwork).toHaveBeenCalled(); + expect(mockTrackEvent).toHaveBeenCalledWith( + mockCreateEventBuilder( + MetaMetricsEvents.NetworkConnectionBannerRpcUpdated, + ) + .addProperties({ + chain_id_caip: 'eip155:100', + from_rpc_domain: 'custom', + to_rpc_domain: 'custom', + }) + .build(), + ); + }); + + it('tracks unknown for missing old RPC endpoint', async () => { + const PROPS_WITHOUT_OLD_ENDPOINT = { + ...SAMPLE_PROPS, + metrics: { + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }, + networkConfigurations: { + '0x64': { + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: undefined, + chainId: '0x64', + rpcEndpoints: [], + name: 'Custom Network', + nativeCurrency: 'ETH', + }, + }, + }; + + const wrapper8 = shallow( + + + , + ) + .find(NetworkSettings) + .dive(); + + const instance = wrapper8.instance() as NetworkSettings; + + await instance.handleNetworkUpdate({ + rpcUrl: 'https://new-rpc.infura.io/v3/', + rpcUrls: [ + { + url: 'https://new-rpc.infura.io/v3/', + type: 'custom', + name: 'New RPC', + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + blockExplorerUrl: 'https://etherscan.io', + nickname: 'Custom Network', + ticker: 'ETH', + isNetworkExists: [], + chainId: '0x64', + navigation: mockNavigation, + isCustomMainnet: false, + shouldNetworkSwitchPopToWallet: true, + trackRpcUpdateFromBanner: true, + }); + + expect(Engine.context.NetworkController.updateNetwork).toHaveBeenCalled(); + expect(mockTrackEvent).toHaveBeenCalledWith( + mockCreateEventBuilder( + MetaMetricsEvents.NetworkConnectionBannerRpcUpdated, + ) + .addProperties({ + chain_id_caip: 'eip155:100', + from_rpc_domain: 'unknown', + to_rpc_domain: 'new-rpc.infura.io', + }) + .build(), + ); + }); }); describe('checkIfRpcUrlExists', () => { diff --git a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx index 94ef0fa137ee..b310f5e227b9 100644 --- a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx +++ b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx @@ -226,6 +226,7 @@ describe('useNetworkConnectionBanner', () => { network: rpcUrl, shouldNetworkSwitchPopToWallet: false, shouldShowPopularNetworks: false, + trackRpcUpdateFromBanner: true, }, ); }); diff --git a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts index 4cae292f9294..0e4f2c518cff 100644 --- a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts +++ b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts @@ -65,6 +65,7 @@ const useNetworkConnectionBanner = (): { network: rpcUrl, shouldNetworkSwitchPopToWallet: false, shouldShowPopularNetworks: false, + trackRpcUpdateFromBanner: true, }); trackEvent( diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 3c3783c2035e..104af28f0f0e 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -514,6 +514,7 @@ enum EVENT_NAME { // NETWORK CONNECTION BANNER NETWORK_CONNECTION_BANNER_SHOWN = 'Network Connection Banner Shown', NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED = 'Network Connection Banner Update RPC Clicked', + NetworkConnectionBannerRpcUpdated = 'Network Connection Banner RPC Updated', // Deep Link Modal Viewed DEEP_LINK_PRIVATE_MODAL_VIEWED = 'Deep Link Private Modal Viewed', @@ -1332,6 +1333,9 @@ const events = { NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED: generateOpt( EVENT_NAME.NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED, ), + NetworkConnectionBannerRpcUpdated: generateOpt( + EVENT_NAME.NetworkConnectionBannerRpcUpdated, + ), // Multi SRP IMPORT_SECRET_RECOVERY_PHRASE_CLICKED: generateOpt( From dc09bef86fe3f71c91fcd1046f65f56298925f8c Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Wed, 26 Nov 2025 16:11:27 +0100 Subject: [PATCH 05/16] feat: basic functionality toggle for trending (#23252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** When basic functionality toggle is on, user should only be able to search within sites. ## **Changelog** CHANGELOG entry: User should be prompted to enable BFT when its off ## **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** https://github.com/user-attachments/assets/15c342bb-40a0-4e77-8dff-5f33c88c5bbe ## **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] > Explore now respects the basic functionality toggle: hides content when off, shows an enable-CTA empty state, and limits search UI to sites. > > - **Trending/Explore**: > - **Toggle support**: Use `selectBasicFunctionalityEnabled` to control Explore rendering and search behavior. > - When off: > - Show `BasicFunctionalityEmptyState` with CTA navigating to `Routes.SHEET.BASIC_FUNCTIONALITY`. > - Hide Explore home sections (`HOME_SECTIONS_ARRAY`). > - In search results (`ExploreSearchResults`), filter out all sections. > - In search bar (`ExploreSearchBar`), switch placeholder to `strings('trending.search_sites')`. > - When on: existing Explore content and multi-section search remain. > - **i18n**: Add `trending.enable_basic_functionality`, `trending.basic_functionality_disabled_title`, and `trending.basic_functionality_disabled_description`. > - **Tests**: Expand unit tests for toggle behavior across `TrendingView`, `ExploreSearchBar`, and `ExploreSearchResults`; add tests for new empty state component and navigation. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2998dd5e6951ecaa9d2aa8b143a8da6013584480. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../ExploreSearchBar.test.tsx | 39 ++++++++++++++ .../ExploreSearchBar/ExploreSearchBar.tsx | 9 +++- .../ExploreSearchResults.test.tsx | 16 ++++++ .../ExploreSearchResults.tsx | 12 ++++- .../Views/TrendingView/TrendingView.test.tsx | 24 +++++++++ .../Views/TrendingView/TrendingView.tsx | 52 +++++++++++-------- .../BasicFunctionalityEmptyState.test.tsx | 43 +++++++++++++++ .../BasicFunctionalityEmptyState.tsx | 52 +++++++++++++++++++ locales/languages/en.json | 5 +- 9 files changed, 227 insertions(+), 25 deletions(-) create mode 100644 app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.test.tsx create mode 100644 app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.tsx diff --git a/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.test.tsx b/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.test.tsx index 26856254d1ca..eac3a5eb2a46 100644 --- a/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.test.tsx +++ b/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.test.tsx @@ -1,8 +1,28 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import ExploreSearchBar from './ExploreSearchBar'; +import { useSelector } from 'react-redux'; +import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.MockedFunction; describe('ExploreSearchBar', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Mock selectBasicFunctionalityEnabled to return true by default + mockUseSelector.mockImplementation((selector) => { + if (selector === selectBasicFunctionalityEnabled) { + return true; + } + return undefined; + }); + }); describe('Button Mode', () => { it('renders button with placeholder text', () => { const mockOnPress = jest.fn(); @@ -203,4 +223,23 @@ describe('ExploreSearchBar', () => { expect(input.props.autoFocus).toBe(true); }); }); + + describe('basic functionality toggle', () => { + it('displays sites-only placeholder when basic functionality is disabled', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectBasicFunctionalityEnabled) { + return false; + } + return undefined; + }); + + const mockOnPress = jest.fn(); + + const { getByText } = render( + , + ); + + expect(getByText('Search sites')).toBeDefined(); + }); + }); }); diff --git a/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.tsx b/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.tsx index 56360565a429..009fbafcff6c 100644 --- a/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.tsx +++ b/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.tsx @@ -15,6 +15,8 @@ import { import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { useTheme } from '../../../../util/theme'; import { strings } from '../../../../../locales/i18n'; +import { useSelector } from 'react-redux'; +import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings'; interface ExploreSearchBarButtonProps { type: 'button'; @@ -38,10 +40,15 @@ const ExploreSearchBar: React.FC = (props) => { const tw = useTailwind(); const { colors } = useTheme(); + const isBasicFunctionalityEnabled = useSelector( + selectBasicFunctionalityEnabled, + ); const isInteractiveMode = props.type === 'interactive'; const isButtonMode = props.type === 'button'; const placeholder = - props.placeholder || strings('trending.search_placeholder'); + props.placeholder || isBasicFunctionalityEnabled + ? strings('trending.search_placeholder') + : strings('trending.search_sites'); const handleCancel = () => { if (isInteractiveMode) { diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx index 139b6a2b501d..547b1b3ccb87 100644 --- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx +++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { render } from '@testing-library/react-native'; import ExploreSearchResults from './ExploreSearchResults'; import { useExploreSearch } from './config/useExploreSearch'; +import { useSelector } from 'react-redux'; +import { selectBasicFunctionalityEnabled } from '../../../../../../selectors/settings'; jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -10,10 +12,16 @@ jest.mock('@react-navigation/native', () => ({ }), })); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + jest.mock('./config/useExploreSearch'); const mockUseExploreSearch = useExploreSearch as jest.MockedFunction< typeof useExploreSearch >; +const mockUseSelector = useSelector as jest.MockedFunction; // Mock child components that render individual items jest.mock( @@ -48,6 +56,14 @@ jest.mock( describe('ExploreSearchResults', () => { beforeEach(() => { jest.clearAllMocks(); + + // Mock selectBasicFunctionalityEnabled to return true by default + mockUseSelector.mockImplementation((selector) => { + if (selector === selectBasicFunctionalityEnabled) { + return true; + } + return undefined; + }); }); it('renders section headers for sections with data or loading', () => { diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx index 9e1d187d894b..a9bce9bcdda1 100644 --- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx +++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx @@ -9,7 +9,9 @@ import { type SectionId, } from '../../../config/sections.config'; import { useExploreSearch } from './config/useExploreSearch'; +import { selectBasicFunctionalityEnabled } from '../../../../../../selectors/settings'; import SitesSearchFooter from '../../../../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter'; +import { useSelector } from 'react-redux'; interface ExploreSearchResultsProps { searchQuery: string; @@ -41,6 +43,9 @@ const ExploreSearchResults: React.FC = ({ const tw = useTailwind(); const { data, isLoading } = useExploreSearch(searchQuery); const flashListRef = useRef>(null); + const isBasicFunctionalityEnabled = useSelector( + selectBasicFunctionalityEnabled, + ); const renderSectionHeader = useCallback( (title: string) => ( @@ -57,7 +62,10 @@ const ExploreSearchResults: React.FC = ({ const flatData = useMemo(() => { const result: FlatListItem[] = []; - SECTIONS_ARRAY.forEach((section) => { + // Filter sections based on basic functionality toggle + const sectionsToShow = isBasicFunctionalityEnabled ? SECTIONS_ARRAY : []; + + sectionsToShow.forEach((section) => { const items = data[section.id]; const sectionIsLoading = isLoading[section.id]; @@ -92,7 +100,7 @@ const ExploreSearchResults: React.FC = ({ }); return result; - }, [data, isLoading]); + }, [data, isLoading, isBasicFunctionalityEnabled]); // Scroll to top when search query changes useEffect(() => { diff --git a/app/components/Views/TrendingView/TrendingView.test.tsx b/app/components/Views/TrendingView/TrendingView.test.tsx index 6a21ab1058f2..61e2865b41ed 100644 --- a/app/components/Views/TrendingView/TrendingView.test.tsx +++ b/app/components/Views/TrendingView/TrendingView.test.tsx @@ -36,6 +36,7 @@ import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetwork import { selectEnabledNetworksByNamespace } from '../../../selectors/networkEnablementController'; import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts'; import { selectSelectedInternalAccountByScope } from '../../../selectors/multichainAccounts/accounts'; +import { selectBasicFunctionalityEnabled } from '../../../selectors/settings'; import { useSelector } from 'react-redux'; jest.mock('../../../components/hooks/useMetrics', () => ({ @@ -137,6 +138,10 @@ describe('TrendingView', () => { // Return false to use default networks behavior return false; } + if (selector === selectBasicFunctionalityEnabled) { + // Return true by default (enabled) + return true; + } // Handle selectSelectedInternalAccountByScope which is a selector factory // It returns a function that takes a scope and returns an account if (selector === selectSelectedInternalAccountByScope) { @@ -436,4 +441,23 @@ describe('TrendingView', () => { expect(mockNavigate).toHaveBeenCalledWith('ExploreSearch'); }); + + describe('basic functionality toggle', () => { + it('displays empty state when basic functionality is disabled', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectBasicFunctionalityEnabled) { + return false; + } + return undefined; + }); + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('basic-functionality-empty-state')).toBeDefined(); + }); + }); }); diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx index 5eea579782d0..7c6b5a6ec35c 100644 --- a/app/components/Views/TrendingView/TrendingView.tsx +++ b/app/components/Views/TrendingView/TrendingView.tsx @@ -28,6 +28,8 @@ import ExploreSearchBar from './ExploreSearchBar/ExploreSearchBar'; import QuickActions from './components/QuickActions/QuickActions'; import SectionHeader from './components/SectionHeader/SectionHeader'; import { HOME_SECTIONS_ARRAY } from './config/sections.config'; +import { selectBasicFunctionalityEnabled } from '../../../selectors/settings'; +import BasicFunctionalityEmptyState from './components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState'; const Stack = createStackNavigator(); @@ -57,6 +59,10 @@ const TrendingFeed: React.FC = () => { const browserTabsCount = useSelector( (state: { browser: { tabs: unknown[] } }) => state.browser.tabs.length, ); + // check if basic functionality toggle is on + const isBasicFunctionalityEnabled = useSelector( + selectBasicFunctionalityEnabled, + ); const portfolioUrl = appendURLParams(AppConstants.PORTFOLIO.URL, { metamaskEntry: 'mobile', @@ -131,27 +137,31 @@ const TrendingFeed: React.FC = () => { - - } - > - - - {HOME_SECTIONS_ARRAY.map((section) => ( - - - - - ))} - + {isBasicFunctionalityEnabled ? ( + + } + > + + + {HOME_SECTIONS_ARRAY.map((section) => ( + + + + + ))} + + ) : ( + + )} ); }; diff --git a/app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.test.tsx b/app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.test.tsx new file mode 100644 index 000000000000..beabeff54da7 --- /dev/null +++ b/app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import BasicFunctionalityEmptyState from './BasicFunctionalityEmptyState'; +import Routes from '../../../../../constants/navigation/Routes'; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: mockNavigate, + }), +})); + +describe('BasicFunctionalityEmptyState', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders empty state', () => { + const { getByText } = render(); + + expect(getByText('Explore is not available')).toBeDefined(); + expect( + getByText( + "We can't fetch the required metadata when basic functionality is disabled.", + ), + ).toBeDefined(); + expect(getByText('Enable basic functionality')).toBeDefined(); + }); + + it('navigates to basic functionality settings when button is pressed', () => { + const { getByText } = render(); + + const enableButton = getByText('Enable basic functionality'); + + fireEvent.press(enableButton); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.BASIC_FUNCTIONALITY, + }); + }); +}); diff --git a/app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.tsx b/app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.tsx new file mode 100644 index 000000000000..d4217104fddc --- /dev/null +++ b/app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.tsx @@ -0,0 +1,52 @@ +import React, { useCallback } from 'react'; +import { + Box, + Text, + TextVariant, + Button, + ButtonVariant, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; +import { useNavigation } from '@react-navigation/native'; +import Routes from '../../../../../constants/navigation/Routes'; + +const BasicFunctionalityEmptyState = () => { + const navigation = useNavigation(); + + const handleEnableBasicFunctionality = useCallback(() => { + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.BASIC_FUNCTIONALITY, + }); + }, [navigation]); + + return ( + + + + {strings('trending.basic_functionality_disabled_title')} + + + {strings('trending.basic_functionality_disabled_description')} + + + + + ); +}; + +export default BasicFunctionalityEmptyState; diff --git a/locales/languages/en.json b/locales/languages/en.json index 54f24dcaed19..560fdc71a27f 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7003,6 +7003,9 @@ "no_results": "No results found", "sites": "Sites", "popular_sites": "Popular Sites", - "search_sites": "Search sites" + "search_sites": "Search sites", + "enable_basic_functionality": "Enable basic functionality", + "basic_functionality_disabled_title": "Explore is not available", + "basic_functionality_disabled_description": "We can't fetch the required metadata when basic functionality is disabled." } } From 2528ac4716f1f32c7ceaa266edf5e851d4363e48 Mon Sep 17 00:00:00 2001 From: Vince Howard Date: Wed, 26 Nov 2025 08:31:31 -0700 Subject: [PATCH 06/16] fix: correct token/fiat toggle background (#23228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixed token/fiat toggle background ## **Changelog** CHANGELOG entry: Fixed token/fiat toggle background ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MDP-254 ## **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** Simulator Screenshot - iPhone 16 Pro Max -
2025-11-24 at 17 10 00 ### **After** Simulator Screenshot - iPhone 16 Pro Max -
2025-11-24 at 17 10 20 ## **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] > Switches `currencyTag` background to `theme.colors.background.section` to correct the token/fiat toggle appearance. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 08a15a0450df8845d2a7213fea21b6342eaf81e7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/confirmations/components/send/amount/amount.styles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c2f36fc2956c..85bf4053f242 100644 --- a/app/components/Views/confirmations/components/send/amount/amount.styles.ts +++ b/app/components/Views/confirmations/components/send/amount/amount.styles.ts @@ -49,7 +49,7 @@ export const styleSheet = (params: { }, currencyTag: { alignSelf: 'center', - backgroundColor: theme.colors.background.alternative, + backgroundColor: theme.colors.background.section, color: theme.colors.text.alternative, flexDirection: FlexDirection.Row, justifyContent: JustifyContent.center, From df8533b5393a537125becab668140a0300ff1166 Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 26 Nov 2025 16:43:32 +0100 Subject: [PATCH 07/16] fix: bump bitcoin (#23317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Updates bitcoin to 1.7.0, which adds signRewardsMessage method. ## **Changelog** CHANGELOG entry: Add `signRewardsMessage` method ([#566](https://github.com/MetaMask/snap-bitcoin-wallet/pull/566)) ## **Related issues** Fixes: Add `signRewardsMessage` method. ## **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] > Bumps @metamask/bitcoin-wallet-snap from ^1.6.0 to ^1.7.0. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2405f60da87634d3c8df5e0fca0b22dc87b37780. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 058289542c4d..d5275774147e 100644 --- a/package.json +++ b/package.json @@ -199,7 +199,7 @@ "@metamask/approval-controller": "^8.0.0", "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A89.0.1#~/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch", "@metamask/base-controller": "^9.0.0", - "@metamask/bitcoin-wallet-snap": "^1.6.0", + "@metamask/bitcoin-wallet-snap": "^1.7.0", "@metamask/bridge-controller": "^61.0.0", "@metamask/bridge-status-controller": "^61.0.0", "@metamask/chain-agnostic-permission": "^1.2.2", diff --git a/yarn.lock b/yarn.lock index 73ba24ac5cc3..bb24dc284980 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7574,10 +7574,10 @@ __metadata: languageName: node linkType: hard -"@metamask/bitcoin-wallet-snap@npm:^1.6.0": - version: 1.6.0 - resolution: "@metamask/bitcoin-wallet-snap@npm:1.6.0" - checksum: 10/e5d391ecc88c52fa56b888e0a341331da8c8fec18a228ae3238f9ace9c0216012ef2af06134cab25fe251e6e829a14db706aa8d01ed70976fe47fd40017a6c8d +"@metamask/bitcoin-wallet-snap@npm:^1.7.0": + version: 1.7.0 + resolution: "@metamask/bitcoin-wallet-snap@npm:1.7.0" + checksum: 10/34943af054bdceeaf11ca6ed876f582194257c1bdc06e37cca06aabf650c6f541fe0f9bfaef07c8fe5e4179354f0ae81445194ce6c77a526f53221d3deb6a26c languageName: node linkType: hard @@ -35639,7 +35639,7 @@ __metadata: "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A89.0.1#~/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch" "@metamask/auto-changelog": "npm:^5.1.0" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/bitcoin-wallet-snap": "npm:^1.6.0" + "@metamask/bitcoin-wallet-snap": "npm:^1.7.0" "@metamask/bridge-controller": "npm:^61.0.0" "@metamask/bridge-status-controller": "npm:^61.0.0" "@metamask/browser-passworder": "npm:^5.0.0" From f5e436ebb16e1894026edf60f2015fd2692f385e Mon Sep 17 00:00:00 2001 From: imblue <106779544+imblue-dabadee@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:48:12 -0600 Subject: [PATCH 08/16] feat: malicious token screening on transactions (#22688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Introduces token screening on incoming tokens received in transactions. This comes in the form of two different alerts (yellow and red). ## **Changelog** CHANGELOG entry: Added an alert if an incoming token is malicious or suspicious. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Token screening on incoming tokens in transactions Scenario: user initiates a transaction where they receive malicious tokens Given the user is on the `Transaction request` screen And they are receiving tokens that is flagged as malicious When user views the screen Then they will see a red alert on `You receive` And a red `Review alert` button Scenario: user initiates a transaction where they receive suspicious tokens Given the user is on the `Transaction request` screen And they are receiving tokens that is flagged as suspicious When user views the screen Then they will see a yellow alert on `You receive` ``` - For a malicious token, you can swap for `0x69e8b9528cabda89fe846c67675b5d73d463a916` on a swap website. - For a suspicious token, you can swap for `0xd0cd466b34a24fcb2f87676278af2005ca8a78c4` on a swap website. ## **Screenshots/Recordings** ### **Before** **1. No yellow alert** Screenshot 2025-11-18 at 12 00 50 PM **2. No red alert** Screenshot 2025-11-18 at 12 01 34 PM ### **After** **1. Yellow alert** Screenshot 2025-11-18 at 11 41 51 AM **2. Red alert** Screenshot 2025-11-19 at 9 15 25 AM **When you click the inline alert** Screenshot 2025-11-19 at 9 15 40 AM **When you click the 'Review alert' button** Screenshot 2025-11-19 at 9 17 41 AM ## **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 malicious/suspicious token screening to transaction confirmations with inline alerts, selectors, strings, and tests. > > - **Confirmations UI/UX**: > - Show `AlertRow` on `BalanceChangeRow` label when `hasIncomingTokens` is true, with style override in `alert-row.styles`. > - Compute `hasIncomingTokens` in `BalanceChangeList` and pass to rows. > - **Alerts System**: > - New `useTokenTrustSignalAlerts` hook to derive alerts from token scan results; integrated into `useConfirmationAlerts`. > - Added `RowAlertKey.IncomingTokens` and new alert keys `TokenTrustSignalMalicious`/`TokenTrustSignalWarning` with metrics mappings. > - **State Selectors**: > - New `selectMultipleTokenScanResults` to read `PhishingController.tokenScanCache` for multiple tokens. > - **Localization**: > - English strings for malicious/suspicious token alerts. > - **Dependencies**: > - Upgrade `@metamask/phishing-controller` to `^16.1.0`. > - **Tests**: > - Unit tests for `BalanceChangeRow`, `AlertRow`, `useTokenTrustSignalAlerts`, `useConfirmationAlerts`, and phishing selectors. > - **Test fixtures**: > - Update initial background state to include `addressScanCache` and `tokenScanCache`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ff7ae25421cd55a871411b09dc71cb062a48fbf6. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: sethkfman --- .../BalanceChangeList/BalanceChangeList.tsx | 8 + .../BalanceChangeRow.test.tsx | 37 ++ .../BalanceChangeRow/BalanceChangeRow.tsx | 41 ++- .../UI/info-row/alert-row/alert-row.styles.ts | 4 + .../UI/info-row/alert-row/alert-row.test.tsx | 16 + .../UI/info-row/alert-row/alert-row.tsx | 4 +- .../UI/info-row/alert-row/constants.ts | 1 + .../Views/confirmations/constants/alerts.ts | 2 + .../alerts/useConfirmationAlerts.test.ts | 16 + .../hooks/alerts/useConfirmationAlerts.ts | 4 + .../alerts/useTokenTrustSignalAlerts.test.ts | 348 ++++++++++++++++++ .../hooks/alerts/useTokenTrustSignalAlerts.ts | 101 +++++ .../metrics/useConfirmationAlertMetrics.ts | 2 + app/selectors/phishingController.test.ts | 124 +++++++ app/selectors/phishingController.ts | 54 +++ app/util/test/initial-background-state.json | 1 + locales/languages/en.json | 10 + package.json | 2 +- yarn.lock | 28 +- 19 files changed, 786 insertions(+), 17 deletions(-) create mode 100644 app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.test.ts create mode 100644 app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.ts create mode 100644 app/selectors/phishingController.test.ts create mode 100644 app/selectors/phishingController.ts diff --git a/app/components/UI/SimulationDetails/BalanceChangeList/BalanceChangeList.tsx b/app/components/UI/SimulationDetails/BalanceChangeList/BalanceChangeList.tsx index 84a8b5dea79e..09e903599515 100644 --- a/app/components/UI/SimulationDetails/BalanceChangeList/BalanceChangeList.tsx +++ b/app/components/UI/SimulationDetails/BalanceChangeList/BalanceChangeList.tsx @@ -8,6 +8,7 @@ import BalanceChangeRow from '../BalanceChangeRow/BalanceChangeRow'; import { BalanceChange } from '../types'; import { TotalFiatDisplay } from '../FiatDisplay/FiatDisplay'; import styleSheet from './BalanceChangeList.styles'; + interface BalanceChangeListProperties extends ViewProps { heading: string; balanceChanges: BalanceChange[]; @@ -28,6 +29,12 @@ const BalanceChangeList: React.FC = ({ [sortedBalanceChanges], ); + const hasIncomingTokens = useMemo( + () => + balanceChanges.some((balanceChange) => balanceChange.amount.isPositive()), + [balanceChanges], + ); + if (sortedBalanceChanges.length === 0) { return null; } @@ -45,6 +52,7 @@ const BalanceChangeList: React.FC = ({ label={index === 0 ? heading : undefined} balanceChange={balanceChange} showFiat={!showFiatTotal} + hasIncomingTokens={hasIncomingTokens} /> ))} {showFiatTotal && ( diff --git a/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx b/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx index 5ec3376df6c2..ff254ed18d0a 100644 --- a/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx +++ b/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx @@ -80,4 +80,41 @@ describe('BalanceChangeList', () => { expect(getByTestId('edit-spending-cap-button')).toBeTruthy(); }); + + it('renders an alert row if there are incoming tokens and a label is provided', () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId('info-row')).toBeTruthy(); + expect(queryByTestId('balance-change-row-label')).toBeNull(); + }); + + it('does not render an alert row if there are no incoming tokens', () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId('balance-change-row-label')).toBeTruthy(); + expect(queryByTestId('info-row')).toBeNull(); + }); + + it('does not render an alert row if no label is provided', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId('info-row')).toBeNull(); + }); }); diff --git a/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.tsx b/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.tsx index 232f012d7cd5..c8d5b74bf9d9 100644 --- a/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.tsx +++ b/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.tsx @@ -14,6 +14,9 @@ import AmountPill from '../AmountPill/AmountPill'; import AssetPill from '../AssetPill/AssetPill'; import { IndividualFiatDisplay } from '../FiatDisplay/FiatDisplay'; import styleSheet from './BalanceChangeRow.styles'; +import AlertRow from '../../../Views/confirmations/components/UI/info-row/alert-row'; +import { RowAlertKey } from '../../../Views/confirmations/components/UI/info-row/alert-row/constants'; +import alertRowStyleSheet from '../../../Views/confirmations/components/UI/info-row/alert-row/alert-row.styles'; interface BalanceChangeRowProperties extends ViewProps { approveMethod?: ApproveMethod; @@ -24,6 +27,7 @@ interface BalanceChangeRowProperties extends ViewProps { newSpendingCap: string, ) => Promise; showFiat?: boolean; + hasIncomingTokens?: boolean; } const BalanceChangeRow: React.FC = ({ @@ -32,23 +36,42 @@ const BalanceChangeRow: React.FC = ({ label, onApprovalAmountUpdate, showFiat, + hasIncomingTokens, }) => { const { styles } = useStyles(styleSheet, {}); + const { styles: alertRowStyles } = useStyles(alertRowStyleSheet, {}); const { asset, amount, fiatAmount, isAllApproval, isUnlimitedApproval } = balanceChange; const isERC20 = balanceChange.asset.type === AssetType.ERC20; const shouldShowEditSpendingCapButton = isERC20 && onApprovalAmountUpdate; + + const renderLabel = () => { + if (!label) { + return null; + } + if (hasIncomingTokens) { + return ( + + ); + } + return ( + + {label} + + ); + }; + return ( - {label && ( - - {label} - - )} + {renderLabel()} {shouldShowEditSpendingCapButton ? ( diff --git a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.styles.ts b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.styles.ts index 458de4fbe272..b711f41152d2 100644 --- a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.styles.ts +++ b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.styles.ts @@ -6,6 +6,10 @@ const styleSheet = () => paddingBottom: 4, paddingHorizontal: 8, }, + alertRowOverride: { + marginLeft: 0, + paddingLeft: 0, + }, }); export default styleSheet; diff --git a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx index 4c3c4a332628..7364c13e41cb 100755 --- a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx +++ b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx @@ -6,6 +6,7 @@ import { Severity } from '../../../../types/alerts'; import { IconName } from '../../../../../../../component-library/components/Icons/Icon'; import { useConfirmationAlertMetrics } from '../../../../hooks/metrics/useConfirmationAlertMetrics'; import { InfoRowVariant } from '../info-row'; +import styleSheet from './alert-row.styles'; jest.mock('../../../../context/alert-system-context', () => ({ useAlerts: jest.fn(), @@ -135,4 +136,19 @@ describe('AlertRow', () => { expect(getByText(CHILDREN_MOCK)).toBeDefined(); expect(queryByTestId('inline-alert')).toBeNull(); }); + + it('renders with the given style if provided', () => { + const props = { ...baseProps, style: { backgroundColor: 'red' } }; + const { getByTestId } = render(); + const infoRow = getByTestId('info-row'); + expect(infoRow.props.style.backgroundColor).toBe('red'); + }); + + it('renders with styles.infoRowOverride if no style is provided', () => { + const styles = styleSheet(); + const { getByTestId } = render(); + const infoRow = getByTestId('info-row'); + + expect(infoRow.props.style).toMatchObject(styles.infoRowOverride); + }); }); diff --git a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx index df3f82862ab6..78684ad61b61 100755 --- a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx +++ b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx @@ -44,7 +44,7 @@ const AlertRow = ({ const { fieldAlerts } = useAlerts(); const alertSelected = fieldAlerts.find((a) => a.field === alertField); const { styles } = useStyles(styleSheet, {}); - const { rowVariant } = props; + const { rowVariant, style } = props; if (!alertSelected && isShownWithAlertsOnly) { return null; @@ -66,7 +66,7 @@ const AlertRow = ({ return ( ); diff --git a/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts b/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts index adae95330f7e..2567101efa72 100755 --- a/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts +++ b/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts @@ -9,4 +9,5 @@ export enum RowAlertKey { PayWithFee = 'payWithFee', PendingTransaction = 'pendingTransaction', RequestFrom = 'requestFrom', + IncomingTokens = 'incomingTokens', } diff --git a/app/components/Views/confirmations/constants/alerts.ts b/app/components/Views/confirmations/constants/alerts.ts index b16baefc8d28..e975afe2e2d1 100644 --- a/app/components/Views/confirmations/constants/alerts.ts +++ b/app/components/Views/confirmations/constants/alerts.ts @@ -13,4 +13,6 @@ export enum AlertKeys { PerpsDepositMinimum = 'perps_deposit_minimum', PerpsHardwareAccount = 'perps_hardware_account', SignedOrSubmitted = 'signed_or_submitted', + TokenTrustSignalMalicious = 'token_trust_signal_malicious', + TokenTrustSignalWarning = 'token_trust_signal_warning', } diff --git a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts index 36e111f040a0..09f95eed771a 100644 --- a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts +++ b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts @@ -17,6 +17,7 @@ import { useInsufficientPayTokenBalanceAlert } from './useInsufficientPayTokenBa import { useNoPayTokenQuotesAlert } from './useNoPayTokenQuotesAlert'; import { useInsufficientPredictBalanceAlert } from './useInsufficientPredictBalanceAlert'; import { useBurnAddressAlert } from './useBurnAddressAlert'; +import { useTokenTrustSignalAlerts } from './useTokenTrustSignalAlerts'; jest.mock('./useBlockaidAlerts'); jest.mock('./useDomainMismatchAlerts'); @@ -29,6 +30,7 @@ jest.mock('./useInsufficientPayTokenBalanceAlert'); jest.mock('./useNoPayTokenQuotesAlert'); jest.mock('./useInsufficientPredictBalanceAlert'); jest.mock('./useBurnAddressAlert'); +jest.mock('./useTokenTrustSignalAlerts'); describe('useConfirmationAlerts', () => { const ALERT_MESSAGE_MOCK = 'This is a test alert message.'; @@ -133,6 +135,15 @@ describe('useConfirmationAlerts', () => { }, ]; + const mockTokenTrustSignalAlerts: Alert[] = [ + { + key: 'TokenTrustSignalAlert', + title: 'Test Token Trust Signal Alert', + message: ALERT_MESSAGE_MOCK, + severity: Severity.Danger, + }, + ]; + beforeEach(() => { jest.clearAllMocks(); (useBlockaidAlerts as jest.Mock).mockReturnValue([]); @@ -146,6 +157,7 @@ describe('useConfirmationAlerts', () => { (useNoPayTokenQuotesAlert as jest.Mock).mockReturnValue([]); (useInsufficientPredictBalanceAlert as jest.Mock).mockReturnValue([]); (useBurnAddressAlert as jest.Mock).mockReturnValue([]); + (useTokenTrustSignalAlerts as jest.Mock).mockReturnValue([]); }); it('returns empty array if no alerts', () => { @@ -211,6 +223,9 @@ describe('useConfirmationAlerts', () => { mockInsufficientPredictBalanceAlert, ); (useBurnAddressAlert as jest.Mock).mockReturnValue(mockBurnAddressAlert); + (useTokenTrustSignalAlerts as jest.Mock).mockReturnValue( + mockTokenTrustSignalAlerts, + ); const { result } = renderHookWithProvider(() => useConfirmationAlerts(), { state: siweSignatureConfirmationState, }); @@ -225,6 +240,7 @@ describe('useConfirmationAlerts', () => { ...mockNoPayTokenQuotesAlert, ...mockInsufficientPredictBalanceAlert, ...mockBurnAddressAlert, + ...mockTokenTrustSignalAlerts, ...mockUpgradeAccountAlert, ]); }); diff --git a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts index 981a76bf5f91..6341378dc378 100644 --- a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts +++ b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts @@ -11,6 +11,7 @@ import { useInsufficientPayTokenBalanceAlert } from './useInsufficientPayTokenBa import { useNoPayTokenQuotesAlert } from './useNoPayTokenQuotesAlert'; import { useInsufficientPredictBalanceAlert } from './useInsufficientPredictBalanceAlert'; import { useBurnAddressAlert } from './useBurnAddressAlert'; +import { useTokenTrustSignalAlerts } from './useTokenTrustSignalAlerts'; function useSignatureAlerts(): Alert[] { const domainMismatchAlerts = useDomainMismatchAlerts(); @@ -28,6 +29,7 @@ function useTransactionAlerts(): Alert[] { const noPayTokenQuotesAlert = useNoPayTokenQuotesAlert(); const insufficientPredictBalanceAlert = useInsufficientPredictBalanceAlert(); const burnAddressAlert = useBurnAddressAlert(); + const tokenTrustSignalAlerts = useTokenTrustSignalAlerts(); return useMemo( () => [ @@ -39,6 +41,7 @@ function useTransactionAlerts(): Alert[] { ...noPayTokenQuotesAlert, ...insufficientPredictBalanceAlert, ...burnAddressAlert, + ...tokenTrustSignalAlerts, ], [ insufficientBalanceAlert, @@ -49,6 +52,7 @@ function useTransactionAlerts(): Alert[] { noPayTokenQuotesAlert, insufficientPredictBalanceAlert, burnAddressAlert, + tokenTrustSignalAlerts, ], ); } diff --git a/app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.test.ts b/app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.test.ts new file mode 100644 index 000000000000..68fa932da159 --- /dev/null +++ b/app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.test.ts @@ -0,0 +1,348 @@ +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; +import { AlertKeys } from '../../constants/alerts'; +import { useTokenTrustSignalAlerts } from './useTokenTrustSignalAlerts'; +import { Severity } from '../../types/alerts'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { TransactionMeta } from '@metamask/transaction-controller'; + +jest.mock('../transactions/useTransactionMetadataRequest', () => ({ + useTransactionMetadataRequest: jest.fn(), +})); + +describe('useTokenTrustSignalAlerts', () => { + const mockUseTransactionMetadataRequest = jest.mocked( + useTransactionMetadataRequest, + ); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseTransactionMetadataRequest.mockReturnValue({ + simulationData: { + tokenBalanceChanges: [ + { + address: '0x1234567890123456789012345678901234567890', + }, + ], + }, + chainId: '0x1', + } as unknown as TransactionMeta); + }); + + it('returns a malicious alert if the token scan result is malicious', () => { + const { result } = renderHookWithProvider( + () => useTokenTrustSignalAlerts(), + { + state: { + engine: { + backgroundState: { + PhishingController: { + tokenScanCache: { + '0x1:0x1234567890123456789012345678901234567890': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Malicious', + }, + }, + }, + }, + }, + }, + }, + }, + ); + + expect(result.current).toEqual([ + { + key: AlertKeys.TokenTrustSignalMalicious, + field: RowAlertKey.IncomingTokens, + message: + 'This token has been identified as malicious. Interacting with this token may result in a loss of funds.', + title: 'Malicious token', + severity: Severity.Danger, + isBlocking: false, + }, + ]); + }); + + it('returns a warning alert if the token scan result is warning', () => { + const { result } = renderHookWithProvider( + () => useTokenTrustSignalAlerts(), + { + state: { + engine: { + backgroundState: { + PhishingController: { + tokenScanCache: { + '0x1:0x1234567890123456789012345678901234567890': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Warning', + }, + }, + }, + }, + }, + }, + }, + }, + ); + + expect(result.current).toEqual([ + { + key: AlertKeys.TokenTrustSignalWarning, + field: RowAlertKey.IncomingTokens, + message: + 'This token shows strong signs of malicious behavior. Continuing may result in loss of funds.', + title: 'Suspicious token', + severity: Severity.Warning, + isBlocking: false, + }, + ]); + }); + + it('returns no alerts if the token scan result is benign', () => { + const { result } = renderHookWithProvider( + () => useTokenTrustSignalAlerts(), + { + state: { + engine: { + backgroundState: { + PhishingController: { + tokenScanCache: { + '0x1:0x1234567890123456789012345678901234567890': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Benign', + }, + }, + }, + }, + }, + }, + }, + }, + ); + + expect(result.current).toEqual([]); + }); + + it('returns no alerts if the token scan result does not exist', () => { + const { result } = renderHookWithProvider( + () => useTokenTrustSignalAlerts(), + { + state: { + engine: { + backgroundState: { + PhishingController: { + tokenScanCache: {}, + }, + }, + }, + }, + }, + ); + + expect(result.current).toEqual([]); + }); + + it('returns no alerts if the transaction metadata is undefined', () => { + mockUseTransactionMetadataRequest.mockReturnValue(undefined); + const { result } = renderHookWithProvider( + () => useTokenTrustSignalAlerts(), + { + state: { + engine: { + backgroundState: { + PhishingController: { + tokenScanCache: { + '0x1:0x1234567890123456789012345678901234567890': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Benign', + }, + }, + }, + }, + }, + }, + }, + }, + ); + expect(result.current).toEqual([]); + }); + + it('detects malicious token when it is not the first incoming token', () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + simulationData: { + tokenBalanceChanges: [ + { + address: '0x0000000000000000000000000000000000000001', + isDecrease: false, + }, + { + address: '0x0000000000000000000000000000000000000002', + isDecrease: false, + }, + ], + }, + chainId: '0x1', + } as unknown as TransactionMeta); + + const { result } = renderHookWithProvider( + () => useTokenTrustSignalAlerts(), + { + state: { + engine: { + backgroundState: { + PhishingController: { + tokenScanCache: { + '0x1:0x0000000000000000000000000000000000000001': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Benign', + }, + }, + '0x1:0x0000000000000000000000000000000000000002': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Malicious', + }, + }, + }, + }, + }, + }, + }, + }, + ); + + expect(result.current).toEqual([ + { + key: AlertKeys.TokenTrustSignalMalicious, + field: RowAlertKey.IncomingTokens, + message: + 'This token has been identified as malicious. Interacting with this token may result in a loss of funds.', + title: 'Malicious token', + severity: Severity.Danger, + isBlocking: false, + }, + ]); + }); + + it('returns the highest severity alert if there are multiple tokens that are flagged as malicious or warning', () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + simulationData: { + tokenBalanceChanges: [ + { + address: '0x0000000000000000000000000000000000000001', + isDecrease: false, + }, + { + address: '0x0000000000000000000000000000000000000002', + isDecrease: false, + }, + ], + }, + chainId: '0x1', + } as unknown as TransactionMeta); + + const { result } = renderHookWithProvider( + () => useTokenTrustSignalAlerts(), + { + state: { + engine: { + backgroundState: { + PhishingController: { + tokenScanCache: { + '0x1:0x0000000000000000000000000000000000000001': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Warning', + }, + }, + '0x1:0x0000000000000000000000000000000000000002': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Malicious', + }, + }, + }, + }, + }, + }, + }, + }, + ); + + expect(result.current).toEqual([ + { + key: AlertKeys.TokenTrustSignalMalicious, + field: RowAlertKey.IncomingTokens, + message: + 'This token has been identified as malicious. Interacting with this token may result in a loss of funds.', + title: 'Malicious token', + severity: Severity.Danger, + isBlocking: false, + }, + ]); + }); + + it('returns exactly one alert if there are multiple tokens that are flagged as malicious or warning', () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + simulationData: { + tokenBalanceChanges: [ + { + address: '0x0000000000000000000000000000000000000001', + isDecrease: false, + }, + { + address: '0x0000000000000000000000000000000000000002', + isDecrease: false, + }, + { + address: '0x0000000000000000000000000000000000000003', + isDecrease: false, + }, + ], + }, + chainId: '0x1', + } as unknown as TransactionMeta); + + const { result } = renderHookWithProvider( + () => useTokenTrustSignalAlerts(), + { + state: { + engine: { + backgroundState: { + PhishingController: { + tokenScanCache: { + '0x1:0x0000000000000000000000000000000000000001': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Warning', + }, + }, + '0x1:0x0000000000000000000000000000000000000002': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Malicious', + }, + }, + '0x1:0x0000000000000000000000000000000000000003': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Malicious', + }, + }, + }, + }, + }, + }, + }, + }, + ); + + expect(result.current.length).toBe(1); + }); +}); diff --git a/app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.ts b/app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.ts new file mode 100644 index 000000000000..d5a5607e00d3 --- /dev/null +++ b/app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.ts @@ -0,0 +1,101 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { Alert, Severity } from '../../types/alerts'; +import { AlertKeys } from '../../constants/alerts'; +import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { selectMultipleTokenScanResults } from '../../../../../selectors/phishingController'; +import { RootState } from '../../../../../reducers'; +import { strings } from '../../../../../../locales/i18n'; + +export function useTokenTrustSignalAlerts(): Alert[] { + const transactionMetadata = useTransactionMetadataRequest(); + + const incomingTokens = useMemo(() => { + const tokens: { address: string; chainId: string }[] = []; + const tokenBalanceChanges = + transactionMetadata?.simulationData?.tokenBalanceChanges; + + if ( + !tokenBalanceChanges || + !Array.isArray(tokenBalanceChanges) || + !transactionMetadata?.chainId + ) { + return tokens; + } + + const chainId = transactionMetadata.chainId; + + tokenBalanceChanges.forEach((change) => { + if (!change.isDecrease) { + tokens.push({ + address: change.address || '', + chainId, + }); + } + }); + + return tokens; + }, [transactionMetadata]); + + const tokenScanResults = useSelector((state: RootState) => + selectMultipleTokenScanResults(state, { tokens: incomingTokens }), + ); + + const alerts = useMemo(() => { + const alertsList: Alert[] = []; + let highestSeverity: Severity | null = null; + + tokenScanResults.forEach(({ scanResult }) => { + if (!scanResult) { + return; + } + + const resultType = scanResult.result_type; + let severity: Severity | null = null; + + if (resultType === 'Malicious') { + severity = Severity.Danger; + } else if (resultType === 'Warning') { + severity = Severity.Warning; + } + + if (!severity) { + return; + } + + if (!highestSeverity || severity === Severity.Danger) { + highestSeverity = severity; + } + }); + + if (highestSeverity) { + const isDanger = highestSeverity === Severity.Danger; + + const alertKey = isDanger + ? AlertKeys.TokenTrustSignalMalicious + : AlertKeys.TokenTrustSignalWarning; + + const message = isDanger + ? strings('alert_system.token_trust_signal.malicious.message') + : strings('alert_system.token_trust_signal.warning.message'); + + const title = isDanger + ? strings('alert_system.token_trust_signal.malicious.title') + : strings('alert_system.token_trust_signal.warning.title'); + + alertsList.push({ + key: alertKey, + field: RowAlertKey.IncomingTokens, + message, + title, + severity: highestSeverity, + isBlocking: false, + }); + } + + return alertsList; + }, [tokenScanResults]); + + return alerts; +} diff --git a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts index 63fb70b90842..a998f0a08fc5 100644 --- a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts +++ b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts @@ -120,6 +120,8 @@ const ALERTS_NAME_METRICS: AlertNameMetrics = { [AlertKeys.PerpsDepositMinimum]: 'minimum_deposit', [AlertKeys.PerpsHardwareAccount]: 'perps_hardware_account', [AlertKeys.SignedOrSubmitted]: 'signed_or_submitted', + [AlertKeys.TokenTrustSignalMalicious]: 'token_trust_signal_malicious', + [AlertKeys.TokenTrustSignalWarning]: 'token_trust_signal_warning', }; function getAlertName(alertKey: string): string { diff --git a/app/selectors/phishingController.test.ts b/app/selectors/phishingController.test.ts new file mode 100644 index 000000000000..f5c9230bbf7b --- /dev/null +++ b/app/selectors/phishingController.test.ts @@ -0,0 +1,124 @@ +import { PhishingControllerState } from '@metamask/phishing-controller'; +import { RootState } from '../reducers'; +import { selectMultipleTokenScanResults } from './phishingController'; + +describe('PhishingController Selectors', () => { + const createMockRootState = ( + phishingControllerState: Partial = {}, + ): RootState => + ({ + engine: { + backgroundState: { + PhishingController: phishingControllerState, + }, + }, + }) as RootState; + + describe('selectMultipleTokenScanResults', () => { + it('returns the scan result for one token', () => { + const state = createMockRootState({ + tokenScanCache: { + '0x1:0x1234567890123456789012345678901234567890': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Malicious', + }, + }, + }, + }); + + const result = selectMultipleTokenScanResults(state, { + tokens: [ + { + address: '0x1234567890123456789012345678901234567890', + chainId: '0x1', + }, + ], + }); + + expect(result).toEqual([ + { + address: '0x1234567890123456789012345678901234567890', + chainId: '0x1', + scanResult: { + result_type: 'Malicious', + }, + }, + ]); + }); + + it('returns multiple scan results for multiple tokens', () => { + const state = createMockRootState({ + tokenScanCache: { + '0x1:0x1234567890123456789012345678901234567890': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Malicious', + }, + }, + '0x1:0x1234567890123456789012345678901234567891': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Malicious', + }, + }, + }, + }); + + const result = selectMultipleTokenScanResults(state, { + tokens: [ + { + address: '0x1234567890123456789012345678901234567890', + chainId: '0x1', + }, + { + address: '0x1234567890123456789012345678901234567891', + chainId: '0x1', + }, + ], + }); + + expect(result).toEqual([ + { + address: '0x1234567890123456789012345678901234567890', + chainId: '0x1', + scanResult: { + result_type: 'Malicious', + }, + }, + { + address: '0x1234567890123456789012345678901234567891', + chainId: '0x1', + scanResult: { + result_type: 'Malicious', + }, + }, + ]); + }); + + it('returns an empty array if no tokens are provided', () => { + const state = createMockRootState(); + const result = selectMultipleTokenScanResults(state, { tokens: [] }); + expect(result).toEqual([]); + }); + + it('returns an empty array if no scan results are found', () => { + const state = createMockRootState(); + const result = selectMultipleTokenScanResults(state, { + tokens: [ + { + address: '0x1234567890123456789012345678901234567890', + chainId: '0x1', + }, + ], + }); + expect(result).toEqual([ + { + address: '0x1234567890123456789012345678901234567890', + chainId: '0x1', + scanResult: undefined, + }, + ]); + }); + }); +}); diff --git a/app/selectors/phishingController.ts b/app/selectors/phishingController.ts new file mode 100644 index 000000000000..fa913eb92223 --- /dev/null +++ b/app/selectors/phishingController.ts @@ -0,0 +1,54 @@ +import type { TokenScanCacheData } from '@metamask/phishing-controller'; +import { RootState } from '../reducers'; +import { createDeepEqualSelector } from './util'; + +const selectPhishingControllerState = (state: RootState) => + state.engine.backgroundState.PhishingController; + +/** + * Select the scan results for multiple token addresses + * + * @param state - Redux root state + * @param params - Parameters object + * @param params.tokens - Array of token objects with address and chainId + * @returns Array of scan results with their addresses + */ +export const selectMultipleTokenScanResults = createDeepEqualSelector( + selectPhishingControllerState, + ( + _state: RootState, + params: { tokens: { address: string; chainId: string }[] }, + ) => params.tokens, + (phishingControllerState, tokens) => { + if (!tokens || tokens.length === 0) { + return []; + } + + const tokenScanCache = phishingControllerState?.tokenScanCache || {}; + + return tokens.reduce< + { + address: string; + chainId: string; + scanResult: TokenScanCacheData; + }[] + >((acc, token) => { + const { address, chainId } = token; + + if (!address || !chainId) { + return acc; + } + + const cacheKey = `${chainId}:${address.toLowerCase()}`; + const cacheEntry = tokenScanCache[cacheKey]; + + acc.push({ + address: address.toLowerCase(), + chainId, + scanResult: cacheEntry?.data, + }); + + return acc; + }, []); + }, +); diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index f516cba2fff4..2632734f4683 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -271,6 +271,7 @@ } }, "PhishingController": { + "addressScanCache": {}, "c2DomainBlocklistLastFetched": 0, "phishingLists": [], "whitelist": [], diff --git a/locales/languages/en.json b/locales/languages/en.json index 560fdc71a27f..f245f8bb0781 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -104,6 +104,16 @@ "burn_address": { "message": "You're sending your assets to a burn address. If you continue, you'll lose your assets.", "title": "Sending Assets to Burn Address" + }, + "token_trust_signal": { + "malicious": { + "title": "Malicious token", + "message": "This token has been identified as malicious. Interacting with this token may result in a loss of funds." + }, + "warning": { + "title": "Suspicious token", + "message": "This token shows strong signs of malicious behavior. Continuing may result in loss of funds." + } } }, "blockaid_banner": { diff --git a/package.json b/package.json index d5275774147e..43e78f137bc4 100644 --- a/package.json +++ b/package.json @@ -253,7 +253,7 @@ "@metamask/network-enablement-controller": "patch:@metamask/network-enablement-controller@npm%3A3.1.0#~/.yarn/patches/@metamask-network-enablement-controller-npm-3.1.0-1c0cfefdc3.patch", "@metamask/notification-services-controller": "^20.0.0", "@metamask/permission-controller": "^12.1.0", - "@metamask/phishing-controller": "^15.0.0", + "@metamask/phishing-controller": "^16.1.0", "@metamask/post-message-stream": "^10.0.0", "@metamask/preferences-controller": "^21.0.0", "@metamask/preinstalled-example-snap": "^0.7.2", diff --git a/yarn.lock b/yarn.lock index bb24dc284980..9f622ebca3f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8892,11 +8892,11 @@ __metadata: linkType: hard "@metamask/phishing-controller@npm:^15.0.0": - version: 15.0.0 - resolution: "@metamask/phishing-controller@npm:15.0.0" + version: 15.0.1 + resolution: "@metamask/phishing-controller@npm:15.0.1" dependencies: "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.14.1" + "@metamask/controller-utils": "npm:^11.15.0" "@metamask/messenger": "npm:^0.3.0" "@noble/hashes": "npm:^1.8.0" "@types/punycode": "npm:^2.1.0" @@ -8905,7 +8905,25 @@ __metadata: punycode: "npm:^2.1.1" peerDependencies: "@metamask/transaction-controller": ^61.0.0 - checksum: 10/84e10ddcba9bb1351538c2de1105863dda030ad5f6dfa54bb17d731e436e948e6bcc4630fa914162046bda1b925514de37224f34cc00145e102b2f7f3f83059e + checksum: 10/2f3bc2946f8231256c4a17af8369637f9fc4e3beef31b30b45372059e899fedfa22261cf7b526db62fe607e752e74c63de4a0dea6bd811fae046aa677e4929d0 + languageName: node + linkType: hard + +"@metamask/phishing-controller@npm:^16.1.0": + version: 16.1.0 + resolution: "@metamask/phishing-controller@npm:16.1.0" + dependencies: + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.16.0" + "@metamask/messenger": "npm:^0.3.0" + "@noble/hashes": "npm:^1.8.0" + "@types/punycode": "npm:^2.1.0" + ethereum-cryptography: "npm:^2.1.2" + fastest-levenshtein: "npm:^1.0.16" + punycode: "npm:^2.1.1" + peerDependencies: + "@metamask/transaction-controller": ^62.0.0 + checksum: 10/af956177cd1a3dd10150cefd8895cc479bb35bddd4ae751031985a89d929a9f63febf55462d09d9e6970612b00f5b90e27ff84dbb82f5ce503f8d429a4b0803b languageName: node linkType: hard @@ -35700,7 +35718,7 @@ __metadata: "@metamask/notification-services-controller": "npm:^20.0.0" "@metamask/object-multiplex": "npm:^1.1.0" "@metamask/permission-controller": "npm:^12.1.0" - "@metamask/phishing-controller": "npm:^15.0.0" + "@metamask/phishing-controller": "npm:^16.1.0" "@metamask/post-message-stream": "npm:^10.0.0" "@metamask/preferences-controller": "npm:^21.0.0" "@metamask/preinstalled-example-snap": "npm:^0.7.2" From ec748864c7322756aca8101a6a40ebca53e3c2a8 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Anglada Date: Wed, 26 Nov 2025 16:54:08 +0100 Subject: [PATCH 09/16] fix: cp-7.60.0 non-evm accounts not found (#23318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fix Non-EVM accounts not found Fixes part of this https://github.com/MetaMask/metamask-extension/issues/38104 ## **Changelog** CHANGELOG entry: fix: non-evm accounts not found ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/38104 ## **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] > Updates Solana and Tron wallet snap dependencies to newer minor versions. > > - **Dependencies**: > - Bump `@metamask/solana-wallet-snap` from `^2.4.7` to `^2.5.0` in `package.json`. > - Bump `@metamask/tron-wallet-snap` from `^1.12.1` to `^1.13.0` in `package.json`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 628c91e74c7d3c0ad6cc4ae1957080073dbabb81. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- package.json | 4 ++-- yarn.lock | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 43e78f137bc4..c1167de5e5c9 100644 --- a/package.json +++ b/package.json @@ -280,7 +280,7 @@ "@metamask/snaps-rpc-methods": "^14.1.1", "@metamask/snaps-sdk": "^10.1.0", "@metamask/snaps-utils": "^11.6.1", - "@metamask/solana-wallet-snap": "^2.4.7", + "@metamask/solana-wallet-snap": "^2.5.0", "@metamask/solana-wallet-standard": "^0.6.0", "@metamask/stake-sdk": "^3.2.0", "@metamask/swappable-obj-proxy": "^2.1.0", @@ -288,7 +288,7 @@ "@metamask/token-search-discovery-controller": "^4.0.0", "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch", "@metamask/transaction-pay-controller": "^10.1.0", - "@metamask/tron-wallet-snap": "^1.12.1", + "@metamask/tron-wallet-snap": "^1.13.0", "@metamask/utils": "^11.8.1", "@ngraveio/bc-ur": "^1.1.6", "@nktkas/hyperliquid": "^0.25.9", diff --git a/yarn.lock b/yarn.lock index 9f622ebca3f4..a2e2ad99328d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9454,10 +9454,10 @@ __metadata: languageName: node linkType: hard -"@metamask/solana-wallet-snap@npm:^2.4.7": - version: 2.4.7 - resolution: "@metamask/solana-wallet-snap@npm:2.4.7" - checksum: 10/3867ddf07c5cf2cdd50cd000b39c8e97a1fd6ef8d8270820c07f7b4d2edcc0fed383ac9015afe8827c0a46dc94ae9623c447dec32980219c5cd83a20cae145a0 +"@metamask/solana-wallet-snap@npm:^2.5.0": + version: 2.5.0 + resolution: "@metamask/solana-wallet-snap@npm:2.5.0" + checksum: 10/cee4cbece192269fb02a59a90cbb8369dd6af3dab33eaecbb40fdb9723568c2da1dcd98b214063f34268696074a438a895cff40a421231e05cfab0afb1c71ea6 languageName: node linkType: hard @@ -9721,10 +9721,10 @@ __metadata: languageName: node linkType: hard -"@metamask/tron-wallet-snap@npm:^1.12.1": - version: 1.12.1 - resolution: "@metamask/tron-wallet-snap@npm:1.12.1" - checksum: 10/6f48c8dd6f625d7bb290bf3d39978839a0f4b905c14883e43fb35538b5ffa822f9611b8977fc54e9cb83711a95a9cbce93ad6a0149c4c31cfd1272af4b7055b0 +"@metamask/tron-wallet-snap@npm:^1.13.0": + version: 1.13.0 + resolution: "@metamask/tron-wallet-snap@npm:1.13.0" + checksum: 10/de3fc0ab146e0fab5f8d2f69e6dda918c22f40158ec770b24850ddb424114116dd74b1efdae119ef3bb716e6b2c96b12eb131ea2d63da89f692a756208f6be90 languageName: node linkType: hard @@ -35746,7 +35746,7 @@ __metadata: "@metamask/snaps-rpc-methods": "npm:^14.1.1" "@metamask/snaps-sdk": "npm:^10.1.0" "@metamask/snaps-utils": "npm:^11.6.1" - "@metamask/solana-wallet-snap": "npm:^2.4.7" + "@metamask/solana-wallet-snap": "npm:^2.5.0" "@metamask/solana-wallet-standard": "npm:^0.6.0" "@metamask/stake-sdk": "npm:^3.2.0" "@metamask/swappable-obj-proxy": "npm:^2.1.0" @@ -35757,7 +35757,7 @@ __metadata: "@metamask/token-search-discovery-controller": "npm:^4.0.0" "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" "@metamask/transaction-pay-controller": "npm:^10.1.0" - "@metamask/tron-wallet-snap": "npm:^1.12.1" + "@metamask/tron-wallet-snap": "npm:^1.13.0" "@metamask/utils": "npm:^11.8.1" "@ngraveio/bc-ur": "npm:^1.1.6" "@nktkas/hyperliquid": "npm:^0.25.9" From 3af29ede8146d0592d61133ebb4f30bd34d4485f Mon Sep 17 00:00:00 2001 From: Kevin Bluer Date: Wed, 26 Nov 2025 10:05:40 -0600 Subject: [PATCH 10/16] fix(predict): Resolves issue when selecting MAX on market details chart few new-ish markets (#23077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - Resolves an issue wherein a given date would be repeated on the x-axis when selecting `MAX` for the date range on the market details chart, if the market is less than a month old. It also resulting in very sparse data points being retrieved / plotted. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: NA ## **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** image ### **After** image ## **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] > Deduplicates x-axis labels and adapts MAX timeframe label formatting and data fidelity based on actual time span, with supporting timestamp utilities and tests. > > - **PredictDetailsChart (`PredictDetailsChart.tsx`)**: > - Compute chart time range via `getTimestampInMs` and pass to `formatPriceHistoryLabel` for adaptive labels. > - Deduplicate consecutive x-axis labels; update axis label generation and keys. > - **PredictMarketDetails (`PredictMarketDetails.tsx`)**: > - Add adaptive fidelity for `MAX` interval: use higher fidelity (`~240`) when history span < `30` days, default to `1440` otherwise. > - Compute history span using `getTimestampInMs`; manage `maxIntervalAdaptiveFidelity` state. > - **Utils (`utils.ts`)**: > - Add `MS_IN_SECOND`, `DAY_IN_MS`, `getTimestampInMs`. > - Extend `formatPriceHistoryLabel` to accept `{ timeRangeMs }` and adjust `MAX` formatting (month/day for <30 days; time for <1 day). > - **Tests**: > - Add axis label deduplication test in `PredictDetailsChart.test.tsx`. > - Add adaptive `MAX` formatting tests and export of `DAY_IN_MS` in `utils.test.ts`. > - Add fidelity adjustment tests for short vs long `MAX` spans in `PredictMarketDetails.test.tsx`; minor test name tweak. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fa847f0731027b2eae10a0524dde0b9d69d10cc7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PredictDetailsChart.test.tsx | 42 +++++++++++ .../PredictDetailsChart.tsx | 49 +++++++++--- .../PredictDetailsChart/utils.test.ts | 25 +++++++ .../components/PredictDetailsChart/utils.ts | 36 ++++++++- .../PredictMarketDetails.test.tsx | 74 ++++++++++++++++++- .../PredictMarketDetails.tsx | 67 ++++++++++++++++- 6 files changed, 280 insertions(+), 13 deletions(-) diff --git a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx index 162b6fa42b4d..a0ceee7db5b7 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx +++ b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx @@ -692,5 +692,47 @@ describe('PredictDetailsChart', () => { expect(timeLabels.length).toBeGreaterThan(0); }); }); + + describe('Axis Label Deduplication', () => { + it('removes consecutive duplicate axis labels', () => { + const axisData = [ + { timestamp: 1740000000000, value: 0.2 }, + { timestamp: 1740003600000, value: 0.3 }, + { timestamp: 1740007200000, value: 0.4 }, + { timestamp: 1740010800000, value: 0.5 }, + ]; + + const labelByTimestamp = new Map([ + [axisData[0].timestamp, 'AXIS_LABEL_ONE'], + [axisData[1].timestamp, 'AXIS_LABEL_ONE'], + [axisData[2].timestamp, 'AXIS_LABEL_TWO'], + [axisData[3].timestamp, 'AXIS_LABEL_TWO'], + ]); + + const chartUtils = + jest.requireActual('./utils'); + const formatSpy = jest + .spyOn(chartUtils, 'formatPriceHistoryLabel') + .mockImplementation( + (timestamp: number) => + labelByTimestamp.get(Number(timestamp)) ?? 'AXIS_FALLBACK', + ); + + const { getAllByText } = setupTest({ + data: [ + { + label: 'Dedup Series', + color: '#123456', + data: axisData, + }, + ], + }); + + expect(getAllByText('AXIS_LABEL_ONE')).toHaveLength(1); + expect(getAllByText('AXIS_LABEL_TWO')).toHaveLength(1); + + formatSpy.mockRestore(); + }); + }); }); }); diff --git a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx index 0aad87d77893..994c342406bc 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx +++ b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx @@ -30,6 +30,7 @@ import { CHART_CONTENT_INSET, MAX_SERIES, formatPriceHistoryLabel, + getTimestampInMs, } from './utils'; export interface ChartSeries { @@ -279,16 +280,30 @@ const PredictDetailsChart: React.FC = ({ const isMultipleSeries = seriesToRender.length > 1; // Process data with labels + const chartTimeRangeMs = React.useMemo(() => { + const timestamps = seriesToRender + .flatMap((series) => series.data) + .map((point) => getTimestampInMs(point.timestamp)); + + if (!timestamps.length) { + return 0; + } + + return Math.max(...timestamps) - Math.min(...timestamps); + }, [seriesToRender]); + const seriesWithLabels = React.useMemo( () => seriesToRender.map((series) => ({ ...series, data: series.data.map((point) => ({ ...point, - label: formatPriceHistoryLabel(point.timestamp, selectedTimeframe), + label: formatPriceHistoryLabel(point.timestamp, selectedTimeframe, { + timeRangeMs: chartTimeRangeMs, + }), })), })), - [seriesToRender, selectedTimeframe], + [seriesToRender, selectedTimeframe, chartTimeRangeMs], ); // Filter out empty series @@ -459,10 +474,26 @@ const PredictDetailsChart: React.FC = ({ // Calculate axis labels const axisLabelStep = Math.max(1, Math.floor(primaryData.length / 4) || 1); - const axisLabels = primaryData.filter( - (_, index) => - index % axisLabelStep === 0 || index === primaryData.length - 1, - ); + const axisLabelEntries = primaryData + .map((point, index) => ({ + point, + label: point.label ?? '', + key: `${point.timestamp}-${index}`, + index, + })) + .filter( + (entry) => + entry.index % axisLabelStep === 0 || + entry.index === primaryData.length - 1, + ); + + const dedupedAxisLabels = axisLabelEntries.filter((entry, idx, arr) => { + if (!entry.label) { + return true; + } + const previous = arr[idx - 1]; + return !previous || previous.label !== entry.label; + }); return ( @@ -540,13 +571,13 @@ const PredictDetailsChart: React.FC = ({ justifyContent={BoxJustifyContent.Between} twClassName="px-4" > - {axisLabels.map((point, index) => ( + {dedupedAxisLabels.map(({ label, key }) => ( - {point.label} + {label} ))} diff --git a/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts b/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts index ae8fa6183490..de7e611feda0 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts +++ b/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts @@ -7,6 +7,7 @@ import { MAX_SERIES, formatPriceHistoryLabel, formatTickValue, + DAY_IN_MS, } from './utils'; describe('PredictDetailsChart utils', () => { @@ -143,6 +144,30 @@ describe('PredictDetailsChart utils', () => { expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{2}$/); }); + it('formats MAX interval with month and day when time range is under 30 days', () => { + const timestamp = createSecondsTimestamp('2024-01-15T12:00:00.000Z'); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.MAX, + { timeRangeMs: 15 * DAY_IN_MS }, + ); + + expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{1,2}$/); + }); + + it('formats MAX interval with time when time range is under 1 day', () => { + const timestamp = createSecondsTimestamp('2024-01-15T12:00:00.000Z'); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.MAX, + { timeRangeMs: 0.5 * DAY_IN_MS }, + ); + + expect(result).toMatch(/^\d{1,2}:\d{2}\s?(AM|PM)?$/); + }); + it('formats unknown interval as month and 2-digit year', () => { const timestamp = createSecondsTimestamp('2024-01-15T12:00:00.000Z'); diff --git a/app/components/UI/Predict/components/PredictDetailsChart/utils.ts b/app/components/UI/Predict/components/PredictDetailsChart/utils.ts index 37f80c73b033..4a3dd5a75ea0 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/utils.ts +++ b/app/components/UI/Predict/components/PredictDetailsChart/utils.ts @@ -11,13 +11,45 @@ export const CHART_CONTENT_INSET = { right: 48, }; export const MAX_SERIES = 3; +export const MS_IN_SECOND = 1000; +export const DAY_IN_MS = 24 * 60 * 60 * 1000; + +const MAX_INTERVAL_SHORT_RANGE_THRESHOLD_IN_MS = 30 * DAY_IN_MS; + +export const getTimestampInMs = (timestamp: number): number => + timestamp > 1_000_000_000_000 ? timestamp : timestamp * MS_IN_SECOND; + +export interface FormatPriceHistoryLabelOptions { + timeRangeMs?: number; +} export const formatPriceHistoryLabel = ( timestamp: number, interval: PredictPriceHistoryInterval | string, + options?: FormatPriceHistoryLabelOptions, ) => { - const isMilliseconds = timestamp > 1_000_000_000_000; - const date = new Date(isMilliseconds ? timestamp : timestamp * 1000); + const date = new Date(getTimestampInMs(timestamp)); + const timeRangeMs = + typeof options?.timeRangeMs === 'number' ? options.timeRangeMs : null; + + const shouldUseShortMaxFormat = + interval === PredictPriceHistoryInterval.MAX && + typeof timeRangeMs === 'number' && + timeRangeMs > 0 && + timeRangeMs < MAX_INTERVAL_SHORT_RANGE_THRESHOLD_IN_MS; + + if (shouldUseShortMaxFormat) { + if (timeRangeMs !== null && timeRangeMs < DAY_IN_MS) { + return new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + minute: '2-digit', + }).format(date); + } + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + }).format(date); + } switch (interval) { case PredictPriceHistoryInterval.ONE_HOUR: diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index 4fe6b0195eab..1563e512eeb5 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -9,6 +9,8 @@ import { useRoute, } from '@react-navigation/native'; import PredictMarketDetails from './PredictMarketDetails'; +import { PredictPriceHistoryInterval } from '../../types'; +import type { UsePredictPriceHistoryOptions } from '../../hooks/usePredictPriceHistory'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import { PredictEventValues } from '../../constants/eventNames'; @@ -926,7 +928,7 @@ describe('PredictMarketDetails', () => { expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen(); }); - it('does not render chart when market has no open outcomes', () => { + it('removes chart when closed market lacks open outcomes', () => { const emptyOutcomesMarket = createMockMarket({ status: 'closed', outcomes: [ @@ -2097,6 +2099,76 @@ describe('PredictMarketDetails', () => { expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen(); }); + describe('Price history fidelity adjustments', () => { + const BASE_TIMESTAMP = 1_700_000_000_000; + const SHORT_RANGE_FIDELITY = 240; + const MAX_DEFAULT_FIDELITY = 1440; + + const buildPriceHistory = (deltaMs: number) => [ + [ + { timestamp: BASE_TIMESTAMP, price: 0.5 }, + { timestamp: BASE_TIMESTAMP + deltaMs, price: 0.6 }, + ], + [], + ]; + + it('requests higher fidelity when MAX history span is shorter than a month', async () => { + const shortRangeHistory = buildPriceHistory(12 * 60 * 60 * 1000); // 12 hours + + setupPredictMarketDetailsTest( + { status: 'closed' }, + {}, + { priceHistory: { priceHistories: shortRangeHistory } }, + ); + + const { usePredictPriceHistory } = jest.requireMock( + '../../hooks/usePredictPriceHistory', + ); + + await waitFor(() => { + const maxCalls = usePredictPriceHistory.mock.calls.filter( + (call: [UsePredictPriceHistoryOptions]) => + call[0]?.interval === PredictPriceHistoryInterval.MAX, + ); + expect(maxCalls.length).toBeGreaterThan(0); + expect( + maxCalls.some( + (call: [UsePredictPriceHistoryOptions]) => + call[0]?.fidelity === SHORT_RANGE_FIDELITY, + ), + ).toBe(true); + }); + }); + + it('keeps default MAX fidelity when history span exceeds the threshold', async () => { + const longRangeHistory = buildPriceHistory(45 * 24 * 60 * 60 * 1000); // 45 days + + setupPredictMarketDetailsTest( + { status: 'closed' }, + {}, + { priceHistory: { priceHistories: longRangeHistory } }, + ); + + const { usePredictPriceHistory } = jest.requireMock( + '../../hooks/usePredictPriceHistory', + ); + + await waitFor(() => { + const maxCalls = usePredictPriceHistory.mock.calls.filter( + (call: [UsePredictPriceHistoryOptions]) => + call[0]?.interval === PredictPriceHistoryInterval.MAX, + ); + expect(maxCalls.length).toBeGreaterThan(0); + expect( + maxCalls.every( + (call: [UsePredictPriceHistoryOptions]) => + call[0]?.fidelity === MAX_DEFAULT_FIDELITY, + ), + ).toBe(true); + }); + }); + }); + it('handles no balance scenario for Yes button', () => { const { usePredictBalance } = jest.requireMock( '../../hooks/usePredictBalance', diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx index 80136750fbba..b1f6a10ed464 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx @@ -50,6 +50,10 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset'; import PredictDetailsChart, { ChartSeries, } from '../../components/PredictDetailsChart/PredictDetailsChart'; +import { + DAY_IN_MS, + getTimestampInMs, +} from '../../components/PredictDetailsChart/utils'; import PredictPositionDetail from '../../components/PredictPositionDetail'; import { usePredictMarket } from '../../hooks/usePredictMarket'; import { usePredictPriceHistory } from '../../hooks/usePredictPriceHistory'; @@ -91,6 +95,12 @@ const DEFAULT_FIDELITY_BY_INTERVAL: Partial< [PredictPriceHistoryInterval.MAX]: 1440, // 24-hour resolution for max window }; +const MAX_INTERVAL_SHORT_RANGE_THRESHOLD_DAYS = 30; +const MAX_INTERVAL_SHORT_RANGE_MS = + MAX_INTERVAL_SHORT_RANGE_THRESHOLD_DAYS * DAY_IN_MS; +const MAX_INTERVAL_SHORT_RANGE_FIDELITY = + DEFAULT_FIDELITY_BY_INTERVAL[PredictPriceHistoryInterval.ONE_WEEK] ?? 240; + // Use theme tokens instead of hex values for multi-series charts interface PredictMarketDetailsProps {} @@ -105,6 +115,8 @@ const PredictMarketDetails: React.FC = () => { const tw = useTailwind(); const [selectedTimeframe, setSelectedTimeframe] = useState(PredictPriceHistoryInterval.ONE_DAY); + const [maxIntervalAdaptiveFidelity, setMaxIntervalAdaptiveFidelity] = + useState(null); const [activeTab, setActiveTab] = useState(null); const [userSelectedTab, setUserSelectedTab] = useState(false); const insets = useSafeAreaInsets(); @@ -315,7 +327,16 @@ const PredictMarketDetails: React.FC = () => { [chartOpenOutcomes], ); - const selectedFidelity = DEFAULT_FIDELITY_BY_INTERVAL[selectedTimeframe]; + const selectedFidelity = useMemo(() => { + if ( + selectedTimeframe === PredictPriceHistoryInterval.MAX && + maxIntervalAdaptiveFidelity + ) { + return maxIntervalAdaptiveFidelity; + } + + return DEFAULT_FIDELITY_BY_INTERVAL[selectedTimeframe]; + }, [selectedTimeframe, maxIntervalAdaptiveFidelity]); const { priceHistories, isFetching: isPriceHistoryFetching, @@ -329,6 +350,50 @@ const PredictMarketDetails: React.FC = () => { enabled: chartOutcomeTokenIds.length > 0, }); + const maxIntervalRangeMs = useMemo(() => { + if (selectedTimeframe !== PredictPriceHistoryInterval.MAX) { + return null; + } + + const timestamps = priceHistories.flatMap((history) => + history.map((point) => getTimestampInMs(point.timestamp)), + ); + + if (!timestamps.length) { + return null; + } + + return Math.max(...timestamps) - Math.min(...timestamps); + }, [priceHistories, selectedTimeframe]); + + useEffect(() => { + if (selectedTimeframe !== PredictPriceHistoryInterval.MAX) { + if (maxIntervalAdaptiveFidelity !== null) { + setMaxIntervalAdaptiveFidelity(null); + } + return; + } + + if ( + typeof maxIntervalRangeMs === 'number' && + maxIntervalRangeMs > 0 && + maxIntervalRangeMs < MAX_INTERVAL_SHORT_RANGE_MS + ) { + if (maxIntervalAdaptiveFidelity !== MAX_INTERVAL_SHORT_RANGE_FIDELITY) { + setMaxIntervalAdaptiveFidelity(MAX_INTERVAL_SHORT_RANGE_FIDELITY); + } + return; + } + + if ( + maxIntervalAdaptiveFidelity !== null && + (maxIntervalRangeMs === null || + maxIntervalRangeMs >= MAX_INTERVAL_SHORT_RANGE_MS) + ) { + setMaxIntervalAdaptiveFidelity(null); + } + }, [maxIntervalRangeMs, maxIntervalAdaptiveFidelity, selectedTimeframe]); + const chartData: ChartSeries[] = useMemo(() => { const palette = [ colors.primary.default, From d7464ad1cb41d3180ab010381ae2fa2e33349c2e Mon Sep 17 00:00:00 2001 From: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:08:31 +0100 Subject: [PATCH 11/16] feat(analytics): Add rpc_domain property to custom RPC analytics events (#23322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds `rpc_domain` property to custom RPC UX analytics events while retaining the existing `rpc_endpoint_url` property. ## Changes - Added `rpc_domain` property to RPC analytics events: - `RPC_SERVICE_UNAVAILABLE` - `RPC_SERVICE_DEGRADED` - `NETWORK_CONNECTION_BANNER_SHOWN` - `NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED` - Both `rpc_domain` and `rpc_endpoint_url` now track the same value: - `'custom'` for custom RPC endpoints - Domain host for public endpoints (e.g., `'mainnet.infura.io'`) - Marked `rpc_endpoint_url` as deprecated for future removal - Updated unit tests to assert both properties ## Files Changed - `app/core/Engine/controllers/network-controller/messenger-action-handlers.ts` - `app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts` - Corresponding test files --- ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/WPC-133 ## **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] > Add rpc_domain to RPC analytics events (mirroring deprecated rpc_endpoint_url) and update hook/controller logic and tests to use sanitized domain values. > > - **Analytics**: > - Add `rpc_domain` to RPC telemetry and align with deprecated `rpc_endpoint_url` (same value: host for public endpoints, `custom` for non-public). > - Apply to `RPC_SERVICE_UNAVAILABLE`, `RPC_SERVICE_DEGRADED`, `NETWORK_CONNECTION_BANNER_SHOWN`, and `NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED`. > - Centralize domain sanitization (via `sanitizeRpcUrl`/`onlyKeepHost`); continue capturing `http_status` when present. > - **Hook (`useNetworkConnectionBanner`)**: > - Compute `sanitizedUrl` once; include `rpc_domain` and deprecated `rpc_endpoint_url` in tracked properties. > - **Controller (`messenger-action-handlers`)**: > - Derive `rpcDomain` once; add `rpc_domain` and keep deprecated `rpc_endpoint_url`. > - **Tests**: > - Update assertions to include `rpc_domain` across scenarios (public/custom, degraded/unavailable, with/without `http_status`). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3414c3a8d05e55948db95393839e47f529fb8bbe. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../useNetworkConnectionBanner.test.tsx | 6 ++++++ .../useNetworkConnectionBanner.ts | 11 +++++++---- .../messenger-action-handlers.test.ts | 6 ++++++ .../network-controller/messenger-action-handlers.ts | 8 +++++--- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx index b310f5e227b9..5555346fd19b 100644 --- a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx +++ b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx @@ -259,6 +259,7 @@ describe('useNetworkConnectionBanner', () => { banner_type: 'degraded', chain_id_caip: 'eip155:1', rpc_endpoint_url: 'mainnet.infura.io', + rpc_domain: 'mainnet.infura.io', }); }); @@ -290,6 +291,7 @@ describe('useNetworkConnectionBanner', () => { banner_type: 'unavailable', chain_id_caip: 'eip155:1', rpc_endpoint_url: 'mainnet.infura.io', + rpc_domain: 'mainnet.infura.io', }); }); @@ -324,6 +326,7 @@ describe('useNetworkConnectionBanner', () => { banner_type: 'degraded', chain_id_caip: 'eip155:1', rpc_endpoint_url: 'custom', + rpc_domain: 'custom', }); }); @@ -598,6 +601,7 @@ describe('useNetworkConnectionBanner', () => { banner_type: 'degraded', chain_id_caip: 'eip155:137', rpc_endpoint_url: 'polygon-rpc.com', + rpc_domain: 'polygon-rpc.com', }); }); @@ -626,6 +630,7 @@ describe('useNetworkConnectionBanner', () => { banner_type: 'unavailable', chain_id_caip: 'eip155:137', rpc_endpoint_url: 'polygon-rpc.com', + rpc_domain: 'polygon-rpc.com', }); }); @@ -780,6 +785,7 @@ describe('useNetworkConnectionBanner', () => { banner_type: 'degraded', chain_id_caip: 'eip155:137', rpc_endpoint_url: 'polygon-rpc.com', + rpc_domain: 'polygon-rpc.com', }); }); diff --git a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts index 0e4f2c518cff..f7a8ac0684e6 100644 --- a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts +++ b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts @@ -68,6 +68,7 @@ const useNetworkConnectionBanner = (): { trackRpcUpdateFromBanner: true, }); + const sanitizedUrl = sanitizeRpcUrl(rpcUrl); trackEvent( createEventBuilder( MetaMetricsEvents.NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED, @@ -75,7 +76,9 @@ const useNetworkConnectionBanner = (): { .addProperties({ banner_type: status, chain_id_caip: `eip155:${hexToNumber(chainId)}`, - rpc_endpoint_url: sanitizeRpcUrl(rpcUrl), + // @deprecated: will be removed in a future release + rpc_endpoint_url: sanitizedUrl, + rpc_domain: sanitizedUrl, }) .build(), ); @@ -197,6 +200,7 @@ const useNetworkConnectionBanner = (): { useEffect(() => { if (networkConnectionBannerState.visible) { + const sanitizedUrl = sanitizeRpcUrl(networkConnectionBannerState.rpcUrl); trackEvent( createEventBuilder(MetaMetricsEvents.NETWORK_CONNECTION_BANNER_SHOWN) .addProperties({ @@ -204,9 +208,8 @@ const useNetworkConnectionBanner = (): { chain_id_caip: `eip155:${hexToNumber( networkConnectionBannerState.chainId, )}`, - rpc_endpoint_url: sanitizeRpcUrl( - networkConnectionBannerState.rpcUrl, - ), + rpc_endpoint_url: sanitizedUrl, + rpc_domain: sanitizedUrl, }) .build(), ); diff --git a/app/core/Engine/controllers/network-controller/messenger-action-handlers.test.ts b/app/core/Engine/controllers/network-controller/messenger-action-handlers.test.ts index 29113a49e4b8..90318b1fe709 100644 --- a/app/core/Engine/controllers/network-controller/messenger-action-handlers.test.ts +++ b/app/core/Engine/controllers/network-controller/messenger-action-handlers.test.ts @@ -79,6 +79,7 @@ describe('onRpcEndpointUnavailable', () => { properties: { chain_id_caip: 'eip155:11155111', rpc_endpoint_url: 'example.com', + rpc_domain: 'example.com', }, }); /* eslint-enable @typescript-eslint/naming-convention */ @@ -109,6 +110,7 @@ describe('onRpcEndpointUnavailable', () => { chain_id_caip: 'eip155:11155111', http_status: 420, rpc_endpoint_url: 'example.com', + rpc_domain: 'example.com', }, }); /* eslint-enable @typescript-eslint/naming-convention */ @@ -138,6 +140,7 @@ describe('onRpcEndpointUnavailable', () => { properties: { chain_id_caip: 'eip155:11155111', rpc_endpoint_url: 'custom', + rpc_domain: 'custom', }, }); /* eslint-enable @typescript-eslint/naming-convention */ @@ -236,6 +239,7 @@ describe('onRpcEndpointDegraded', () => { properties: { chain_id_caip: 'eip155:11155111', rpc_endpoint_url: 'example.com', + rpc_domain: 'example.com', }, }); /* eslint-enable @typescript-eslint/naming-convention */ @@ -266,6 +270,7 @@ describe('onRpcEndpointDegraded', () => { chain_id_caip: 'eip155:11155111', http_status: 420, rpc_endpoint_url: 'example.com', + rpc_domain: 'example.com', }, }); /* eslint-enable @typescript-eslint/naming-convention */ @@ -295,6 +300,7 @@ describe('onRpcEndpointDegraded', () => { properties: { chain_id_caip: 'eip155:11155111', rpc_endpoint_url: 'custom', + rpc_domain: 'custom', }, }); /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/app/core/Engine/controllers/network-controller/messenger-action-handlers.ts b/app/core/Engine/controllers/network-controller/messenger-action-handlers.ts index d2917d59e074..0156be74b184 100644 --- a/app/core/Engine/controllers/network-controller/messenger-action-handlers.ts +++ b/app/core/Engine/controllers/network-controller/messenger-action-handlers.ts @@ -146,13 +146,15 @@ export function trackRpcEndpointEvent( return; } + const isPublicEndpoint = isPublicEndpointUrl(endpointUrl, infuraProjectId); + const rpcDomain = isPublicEndpoint ? onlyKeepHost(endpointUrl) : 'custom'; // The names of Segment properties have a particular case. /* eslint-disable @typescript-eslint/naming-convention */ const properties = { chain_id_caip: `eip155:${hexToNumber(chainId)}`, - rpc_endpoint_url: isPublicEndpointUrl(endpointUrl, infuraProjectId) - ? onlyKeepHost(endpointUrl) - : 'custom', + // @deprecated: will be removed in a future release + rpc_endpoint_url: rpcDomain, + rpc_domain: rpcDomain, ...(isObject(error) && 'httpStatus' in error && isValidJson(error.httpStatus) From 2ccb892d502a5c2002741f75890183947fa94127 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Thu, 27 Nov 2025 00:49:05 +0800 Subject: [PATCH 12/16] feat(perps): design v2 for perps asset screen (#23230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR implements a comprehensive redesign of the Perps Asset Details screen (v2) with enhanced position management capabilities. When users have an open position, the screen now provides intuitive actions for position management including closing, modifying exposure, flipping direction, and adjusting margin. **Key improvements:** 1. **Dynamic button layout**: Replaces static Long/Short buttons with context-aware Close/Modify/Share buttons when position is open 2. **Position modification modal**: New bottom sheet with 6 position management options (Increase/Reduce exposure, Flip, Add TP/SL, Add/Remove margin) 3. **Margin adjustment views**: New screens for adding and removing margin with real-time impact visualization 4. **Position flipping**: One-tap position reversal while maintaining size and leverage 5. **Improved scrollability**: Full asset page is now scrollable to access all information **Related Jira Tickets:** - TAT-1910: Replace Long/Short buttons with Close/Modify/Share when position is open - TAT-2046: Enable scrolling through asset page for all relevant information - TAT-1541: Add or remove margin from open positions - TAT-1690: Flip position functionality ## **Changelog** CHANGELOG entry: Added enhanced position management UI with Close/Modify/Share buttons, margin adjustment, and position flipping capabilities for Perps trading ## **Related issues** Fixes: TAT-1910, TAT-2046, TAT-1541, TAT-1690 ## **Manual testing steps** ```gherkin Feature: Enhanced Position Management on Asset Details Screen Scenario: User views asset screen without open position Given user is on Perps asset details screen And user has no open position When user views the screen Then user sees Long and Short buttons And user does not see Close/Modify/Share buttons Scenario: User views asset screen with open position Given user is on Perps asset details screen And user has an open position When user views the screen Then user sees Close, Modify, and Share buttons And user sees "Add Stop loss / Take profit" button below position card And user does not see Long and Short buttons Scenario: User closes position Given user has an open position And user is on asset details screen When user taps Close button Then close position screen opens Scenario: User modifies position via bottom sheet Given user has an open position When user taps Modify button Then bottom sheet opens with 6 options: | Option | | Increase exposure | | Reduce exposure | | Flip | | Add TP/SL | | Add margin | | Remove margin | Scenario: User increases exposure with existing TP/SL Given user has position with TP/SL configured When user selects "Increase exposure" Then trade screen opens in same direction as current position And TP/SL from position is pre-filled And user can modify TP/SL for this specific trade Scenario: User flips position Given user has a LONG position of 2.5 ETH with 10x leverage When user selects "Flip" from Modify menu Then trade screen opens for SHORT direction And position size remains 2.5 ETH And leverage remains 10x And user can use available margin + margin from closed position And TP/SL are cancelled Scenario: User adds margin to position Given user has open position with $500 margin And user has $300 available to add When user selects "Add margin" Then margin adjustment screen opens And screen shows current margin: $500 And screen shows available to add: $300 And screen shows current liquidation price And user can use slider or keyboard input And user sees real-time impact on liquidation price and leverage Scenario: User removes margin from position Given user has open position with $500 margin And user has $200 available to withdraw When user selects "Remove margin" Then margin adjustment screen opens And screen shows current margin: $500 And screen shows available to withdraw: $200 And user can use slider or keyboard input And user sees real-time impact on liquidation price Scenario: User scrolls through asset page Given user is on asset details screen When user scrolls down Then user can access all sections: | Section | | Price chart | | Position card (if exists)| | Market statistics | | Order details | ``` ## **Screenshots/Recordings** ### **Before** Original asset screen with static Long/Short buttons ### **After** **HIP-2 Markets (regular crypto):** ![Asset Screen V2 - HIP-2](asset_screenv2_hip2.png) **HIP-3 - No Positions:** ![Asset Screen V2 - No Positions](asset_screenv2_no_positions.png) **HIP-3 - With Active Positions:** ![Asset Screen V2 - With Positions](asset_screenv2_with_positions.png) **With TP/SL and Limit Order:** ![Asset Screen V2 - TP/SL Limit](asset_screenv2_tpsl_limit.png) **Close Position Flow:** ![Close Position](asset_screenv2_closeposition.png) **Flip Position Flow:** ![Flip Position](asset_screenv2_flip.png) **Add Margin Flow:** ![Add Margin](asset_screenv2_addmargin.png) ## **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)). ## **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. --- ## **Technical Notes** ### Architecture Changes: - **PerpsMarketDetailsView**: Refactored to conditionally render button sets based on position state - **PerpsPositionCard**: Now uses callback pattern (`onAutoClosePress`, `onFlipPress`, `onMarginPress`, `onSharePress`) instead of internal navigation - **New Views**: - `PerpsAdjustMarginView` - For adding/removing margin - `PerpsOrderDetailsView` - For viewing order details - **New Components**: - `PerpsFlipPositionConfirmSheet` - Confirmation sheet for position flipping ### Test Updates: - Fixed failing tests after merge with main branch - Updated PerpsPositionCard tests to use testID pattern (removed 35 obsolete tests) - Added 10 missing locale keys for new UI elements - Added testIDs for PNL, ROE, Size, and Margin values ### Navigation Flow: ``` PerpsMarketDetailsView (with position) |-- Close Button -> PerpsClosePositionView |-- Modify Button -> Bottom Sheet | |-- Increase exposure -> PerpsTradeView (same direction) | |-- Reduce exposure -> PerpsClosePositionView | |-- Flip -> PerpsFlipPositionConfirmSheet -> PerpsTradeView (opposite) | |-- Add TP/SL -> PerpsTpSlView | |-- Add margin -> PerpsAdjustMarginView (mode: add) | `-- Remove margin -> PerpsAdjustMarginView (mode: remove) `-- Share Button -> PerpsPnlHeroCard ``` --- > [!NOTE] > Redesigns the perps asset screen with full position management—Modify/Close/Share, margin add/remove, and flip—plus new order details, hooks, services, routes, and tests. > > - **UI/Views**: > - **Asset Details (MarketDetailsView)**: Reworked layout; shows Modify/Close when a position exists; adds Position card, compact open orders list, updated statistics (incl. oracle price), tooltips, and scrollable sections; integrates bottom sheets for modify, adjust margin, and flip confirm. > - **New Screens**: `PerpsAdjustMarginView` (add/remove margin with live impact), `PerpsOrderDetailsView` (view/cancel), selection sheets: `PerpsSelectModifyActionView`, `PerpsSelectAdjustMarginActionView`, `PerpsSelectOrderTypeView`. > - **Components**: New `PerpsFlipPositionConfirmSheet`, `PerpsAdjustMarginActionSheet`, `PerpsModifyActionSheet`, `PerpsCompactOrderRow`; redesigned `PerpsPositionCard`; enhanced `PerpsMarketStatisticsCard`; styling updates to trades/activity lists and badges. > - **Hooks/Logic**: > - Added `usePerpsMarginAdjustment`, `usePerpsFlipPosition`, `usePositionManagement`; extended `usePerpsTrading` and navigation helpers. > - New margin utilities: `calculateMaxRemovableMargin`, `calculateNewLiquidationPrice`, `assessMarginRemovalRisk`. > - **Controller/Provider**: > - PerpsController/TradingService: new `updateMargin` and `flipPosition`; HyperLiquidProvider implements `updateMargin`. > - Config: `MARGIN_ADJUSTMENT_CONFIG`; new traces for adjust margin/order details/flip. > - **Navigation/Routes**: Added routes for adjust margin, order details, and selection sheets. > - **Toasts/i18n**: Margin adjustment toasts; new strings. > - **Tests/Docs**: Extensive unit/e2e updates; new tests for views/hooks/services; docs updated for new screens. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a207786024ac28f0bcea3ad58b29113f18da1e93. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Nick Gambino <35090461+gambinish@users.noreply.github.com> Co-authored-by: Salim TOUBAL Co-authored-by: George Gkasdrogkas Co-authored-by: Matthew Walsh Co-authored-by: MetaMask Bot <37885440+metamaskbot@users.noreply.github.com> Co-authored-by: metamaskbot Co-authored-by: Michal Szorad Co-authored-by: Patryk Łucka <5708018+PatrykLucka@users.noreply.github.com> Co-authored-by: VGR Co-authored-by: sahar-fehri Co-authored-by: OGPoyraz Co-authored-by: Pedro Pablo Aste Kompen Co-authored-by: Aslau Mario-Daniel Co-authored-by: Nico MASSART Co-authored-by: Corey Janssen <107953793+coreyjanssen@users.noreply.github.com> Co-authored-by: Bernardo Garces Chapero Co-authored-by: António Regadas Co-authored-by: Alejandro Garcia Co-authored-by: Nicholas Smith Co-authored-by: Juanmi <95381763+juanmigdr@users.noreply.github.com> Co-authored-by: Andre Pimenta Co-authored-by: Vince Howard Co-authored-by: Curtis David Co-authored-by: Brian August Nguyen Co-authored-by: Kylan Hurt <6249205+smilingkylan@users.noreply.github.com> --- .../PerpsAdjustMarginView.styles.ts | 179 ++++ .../PerpsAdjustMarginView.test.tsx | 286 ++++++ .../PerpsAdjustMarginView.tsx | 547 +++++++++++ .../Views/PerpsAdjustMarginView/index.ts | 1 + .../PerpsMarketDetailsView.styles.ts | 5 +- .../PerpsMarketDetailsView.test.tsx | 57 +- .../PerpsMarketDetailsView.tsx | 491 +++++----- .../PerpsOrderDetailsView.styles.ts | 107 +++ .../PerpsOrderDetailsView.test.tsx | 320 +++++++ .../PerpsOrderDetailsView.tsx | 348 +++++++ .../Views/PerpsOrderDetailsView/index.ts | 1 + .../Views/PerpsOrderView/PerpsOrderView.tsx | 107 ++- ...PerpsSelectAdjustMarginActionView.test.tsx | 192 ++++ .../PerpsSelectAdjustMarginActionView.tsx | 82 ++ .../index.ts | 1 + .../PerpsSelectModifyActionView.test.tsx | 276 ++++++ .../PerpsSelectModifyActionView.tsx | 120 +++ .../PerpsSelectModifyActionView/index.ts | 1 + .../PerpsSelectOrderTypeView.test.tsx | 193 ++++ .../PerpsSelectOrderTypeView.tsx | 61 ++ .../Views/PerpsSelectOrderTypeView/index.ts | 1 + .../PerpsAdjustMarginActionSheet.styles.ts | 25 + .../PerpsAdjustMarginActionSheet.test.tsx | 192 ++++ .../PerpsAdjustMarginActionSheet.tsx | 129 +++ .../PerpsAdjustMarginActionSheet.types.ts | 11 + .../PerpsAdjustMarginActionSheet/index.ts | 5 + .../PerpsBadge/PerpsBadge.styles.ts | 14 +- .../components/PerpsBadge/PerpsBadge.types.ts | 2 +- .../PerpsBottomSheetTooltip.types.ts | 1 + .../content/contentRegistry.ts | 1 + .../PerpsCompactOrderRow.styles.ts | 39 + .../PerpsCompactOrderRow.test.tsx | 189 ++++ .../PerpsCompactOrderRow.tsx | 129 +++ .../components/PerpsCompactOrderRow/index.ts | 1 + .../PerpsFlipPositionConfirmSheet.styles.ts | 64 ++ .../PerpsFlipPositionConfirmSheet.test.tsx | 374 ++++++++ .../PerpsFlipPositionConfirmSheet.tsx | 284 ++++++ .../PerpsFlipPositionConfirmSheet.types.ts | 9 + .../PerpsFlipPositionConfirmSheet/index.ts | 2 + .../PerpsMarketStatisticsCard.styles.ts | 44 +- .../PerpsMarketStatisticsCard.test.tsx | 27 +- .../PerpsMarketStatisticsCard.tsx | 256 ++--- .../PerpsMarketStatisticsCard.types.ts | 4 + .../PerpsMarketTabs/PerpsMarketTabs.tsx | 111 ++- .../PerpsMarketTradesList.styles.ts | 19 +- .../PerpsMarketTradesList.tsx | 23 +- .../PerpsModifyActionSheet.styles.ts | 38 + .../PerpsModifyActionSheet.test.tsx | 329 +++++++ .../PerpsModifyActionSheet.tsx | 145 +++ .../PerpsModifyActionSheet.types.ts | 4 + .../PerpsModifyActionSheet/index.ts | 2 + .../PerpsOrderTypeBottomSheet.tsx | 23 +- .../PerpsPositionCard.styles.ts | 178 ++-- .../PerpsPositionCard.test.tsx | 754 ++------------- .../PerpsPositionCard/PerpsPositionCard.tsx | 877 ++++++++---------- .../PerpsRecentActivityList.styles.ts | 18 +- .../PerpsRecentActivityList.tsx | 41 +- .../PerpsTabControlBar/PerpsTabControlBar.tsx | 2 +- .../UI/Perps/constants/perpsConfig.ts | 30 + .../UI/Perps/contexts/PerpsOrderContext.tsx | 15 +- .../Perps/controllers/PerpsController.test.ts | 134 +++ .../UI/Perps/controllers/PerpsController.ts | 40 + .../providers/HyperLiquidProvider.ts | 89 ++ .../services/TradingService.test.ts | 361 +++++++ .../controllers/services/TradingService.ts | 229 +++++ .../UI/Perps/controllers/types/index.ts | 16 + app/components/UI/Perps/hooks/index.ts | 1 + .../Perps/hooks/usePerpsFlipPosition.test.ts | 319 +++++++ .../UI/Perps/hooks/usePerpsFlipPosition.ts | 129 +++ .../hooks/usePerpsMarginAdjustment.test.ts | 367 ++++++++ .../Perps/hooks/usePerpsMarginAdjustment.ts | 139 +++ .../UI/Perps/hooks/usePerpsNavigation.ts | 29 +- .../UI/Perps/hooks/usePerpsOrderFees.test.ts | 6 + .../UI/Perps/hooks/usePerpsPositions.test.ts | 2 + .../UI/Perps/hooks/usePerpsToasts.test.ts | 87 ++ .../UI/Perps/hooks/usePerpsToasts.tsx | 36 + .../UI/Perps/hooks/usePerpsTrading.test.ts | 119 +++ .../UI/Perps/hooks/usePerpsTrading.ts | 21 + .../Perps/hooks/usePositionManagement.test.ts | 181 ++++ .../UI/Perps/hooks/usePositionManagement.ts | 141 +++ app/components/UI/Perps/routes/index.tsx | 47 + app/components/UI/Perps/types/navigation.ts | 25 +- .../UI/Perps/utils/marginUtils.test.ts | 390 ++++++++ app/components/UI/Perps/utils/marginUtils.ts | 191 ++++ app/constants/navigation/Routes.ts | 5 + app/util/trace.ts | 5 + docs/perps/perps-screens.md | 86 +- docs/perps/perps-sentry-reference.md | 8 +- e2e/pages/Perps/PerpsMarketDetailsView.ts | 3 +- e2e/pages/Perps/PerpsView.ts | 6 +- e2e/selectors/Perps/Perps.selectors.ts | 30 +- locales/languages/en.json | 85 ++ 92 files changed, 9346 insertions(+), 1768 deletions(-) create mode 100644 app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.styles.ts create mode 100644 app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx create mode 100644 app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx create mode 100644 app/components/UI/Perps/Views/PerpsAdjustMarginView/index.ts create mode 100644 app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.styles.ts create mode 100644 app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx create mode 100644 app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.tsx create mode 100644 app/components/UI/Perps/Views/PerpsOrderDetailsView/index.ts create mode 100644 app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/PerpsSelectAdjustMarginActionView.test.tsx create mode 100644 app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/PerpsSelectAdjustMarginActionView.tsx create mode 100644 app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/index.ts create mode 100644 app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx create mode 100644 app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx create mode 100644 app/components/UI/Perps/Views/PerpsSelectModifyActionView/index.ts create mode 100644 app/components/UI/Perps/Views/PerpsSelectOrderTypeView/PerpsSelectOrderTypeView.test.tsx create mode 100644 app/components/UI/Perps/Views/PerpsSelectOrderTypeView/PerpsSelectOrderTypeView.tsx create mode 100644 app/components/UI/Perps/Views/PerpsSelectOrderTypeView/index.ts create mode 100644 app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.styles.ts create mode 100644 app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.test.tsx create mode 100644 app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.tsx create mode 100644 app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.types.ts create mode 100644 app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/index.ts create mode 100644 app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.styles.ts create mode 100644 app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.test.tsx create mode 100644 app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.tsx create mode 100644 app/components/UI/Perps/components/PerpsCompactOrderRow/index.ts create mode 100644 app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.styles.ts create mode 100644 app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.test.tsx create mode 100644 app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.tsx create mode 100644 app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.types.ts create mode 100644 app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/index.ts create mode 100644 app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.styles.ts create mode 100644 app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.test.tsx create mode 100644 app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.tsx create mode 100644 app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.types.ts create mode 100644 app/components/UI/Perps/components/PerpsModifyActionSheet/index.ts create mode 100644 app/components/UI/Perps/hooks/usePerpsFlipPosition.test.ts create mode 100644 app/components/UI/Perps/hooks/usePerpsFlipPosition.ts create mode 100644 app/components/UI/Perps/hooks/usePerpsMarginAdjustment.test.ts create mode 100644 app/components/UI/Perps/hooks/usePerpsMarginAdjustment.ts create mode 100644 app/components/UI/Perps/hooks/usePositionManagement.test.ts create mode 100644 app/components/UI/Perps/hooks/usePositionManagement.ts create mode 100644 app/components/UI/Perps/utils/marginUtils.test.ts create mode 100644 app/components/UI/Perps/utils/marginUtils.ts diff --git a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.styles.ts b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.styles.ts new file mode 100644 index 000000000000..c706f351a716 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.styles.ts @@ -0,0 +1,179 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + }, + headerTitle: { + textAlign: 'center', + }, + headerSpacer: { + width: 32, + }, + contentContainer: { + flex: 1, + paddingHorizontal: 16, + justifyContent: 'space-between', + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: 16, + paddingBottom: 24, + }, + section: { + marginTop: 24, + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + amountSection: { + marginTop: 24, + marginBottom: 16, + }, + sliderSection: { + marginBottom: 24, + }, + infoSection: { + gap: 16, + marginBottom: 16, + }, + labelWithIcon: { + flexDirection: 'row', + alignItems: 'center', + }, + infoIcon: { + marginLeft: 4, + padding: 4, + }, + keypadFooter: { + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 16, + backgroundColor: colors.background.default, + }, + percentageButtonsContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: 8, + marginBottom: 16, + }, + percentageButton: { + flex: 1, + }, + keypad: {}, + infoCard: { + backgroundColor: colors.background.alternative, + borderRadius: 8, + padding: 16, + gap: 12, + }, + infoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + labelRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + maxButton: { + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 4, + backgroundColor: colors.primary.muted, + }, + amountDisplay: { + alignItems: 'center', + paddingVertical: 16, + }, + slider: { + width: '100%', + height: 40, + }, + sliderLabels: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 8, + }, + impactCard: { + backgroundColor: colors.info.muted, + borderRadius: 8, + padding: 16, + gap: 12, + }, + impactCardWarning: { + backgroundColor: colors.warning.muted, + }, + impactCardDanger: { + backgroundColor: colors.error.muted, + }, + impactHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 4, + }, + impactTitle: { + flex: 1, + }, + impactRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + changeContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + strikethrough: { + textDecorationLine: 'line-through', + }, + warningCard: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 12, + backgroundColor: colors.warning.muted, + borderRadius: 8, + padding: 16, + }, + warningCardDanger: { + backgroundColor: colors.error.muted, + }, + warningText: { + flex: 1, + }, + warningTextContainer: { + flex: 1, + gap: 4, + }, + footer: { + padding: 16, + borderTopWidth: 1, + borderTopColor: colors.border.muted, + backgroundColor: colors.background.default, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx new file mode 100644 index 000000000000..c544f4cc4949 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx @@ -0,0 +1,286 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import PerpsAdjustMarginView from './PerpsAdjustMarginView'; +import type { Position } from '../../controllers/types'; + +// Mock dependencies +jest.mock('react-native-reanimated', () => + jest.requireActual('react-native-reanimated/mock'), +); + +jest.mock('react-native-gesture-handler', () => ({ + GestureHandlerRootView: 'View', + GestureDetector: 'View', + Gesture: { + Pan: jest.fn().mockReturnValue({ + onUpdate: jest.fn().mockReturnThis(), + onEnd: jest.fn().mockReturnThis(), + }), + }, +})); + +jest.mock('react-native-safe-area-context', () => { + const { View } = jest.requireActual('react-native'); + const inset = { top: 0, right: 0, bottom: 0, left: 0 }; + return { + SafeAreaProvider: jest.fn().mockImplementation(({ children }) => children), + SafeAreaView: jest + .fn() + .mockImplementation(({ children, ...props }) => ( + {children} + )), + useSafeAreaInsets: jest.fn().mockImplementation(() => inset), + }; +}); + +const mockHandleAddMargin = jest.fn(); +const mockHandleRemoveMargin = jest.fn(); +const mockGoBack = jest.fn(); +const mockUsePerpsMarginAdjustment = jest.fn(); + +jest.mock('../../hooks/usePerpsMarginAdjustment', () => ({ + usePerpsMarginAdjustment: (opts: unknown) => + mockUsePerpsMarginAdjustment(opts), +})); + +const mockUsePerpsLiveAccount = jest.fn(); +const mockUsePerpsLivePrices = jest.fn(); + +jest.mock('../../hooks/stream', () => ({ + usePerpsLiveAccount: () => mockUsePerpsLiveAccount(), + usePerpsLivePrices: () => mockUsePerpsLivePrices(), +})); + +const mockUsePerpsMarkets = jest.fn(); + +jest.mock('../../hooks/usePerpsMarkets', () => ({ + usePerpsMarkets: () => mockUsePerpsMarkets(), +})); + +jest.mock('../../hooks/usePerpsMeasurement', () => ({ + usePerpsMeasurement: jest.fn(), +})); + +jest.mock('../../utils/marginUtils', () => ({ + calculateMaxRemovableMargin: jest.fn(() => 200), + calculateNewLiquidationPrice: jest.fn(() => 1800), +})); + +jest.mock('../../../../../util/Logger', () => ({ + error: jest.fn(), +})); + +jest.mock('../../utils/formatUtils', () => ({ + formatPerpsFiat: jest.fn((value) => { + const num = typeof value === 'string' ? parseFloat(value) : value; + return `$${num.toFixed(2)}`; + }), + PRICE_RANGES_UNIVERSAL: {}, + PRICE_RANGES_MINIMAL_VIEW: {}, +})); + +const mockNavigation = { + navigate: jest.fn(), + goBack: mockGoBack, + setOptions: jest.fn(), + addListener: jest.fn(), + canGoBack: jest.fn(() => true), +}; + +let mockRouteParams: Record = {}; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => mockNavigation, + useRoute: () => ({ + params: mockRouteParams, + key: 'test-route', + name: 'PerpsAdjustMargin', + }), +})); + +jest.mock('./PerpsAdjustMarginView.styles', () => ({ + __esModule: true, + default: () => ({ + container: {}, + scrollView: {}, + scrollContent: {}, + amountSection: {}, + sliderSection: {}, + infoSection: {}, + infoRow: {}, + changeContainer: {}, + footer: {}, + errorContainer: {}, + }), +})); + +jest.mock('../../../../../util/theme', () => ({ + useTheme: () => ({ + colors: { + icon: { alternative: '#888' }, + }, + }), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key) => key), +})); + +// Mock PerpsOrderHeader component to render title prop +jest.mock('../../components/PerpsOrderHeader', () => { + const ReactModule = jest.requireActual('react'); + const RNModule = jest.requireActual('react-native'); + return function MockPerpsOrderHeader({ title }: { title: string }) { + return ReactModule.createElement(RNModule.Text, null, title); + }; +}); +jest.mock('../../components/PerpsAmountDisplay', () => 'PerpsAmountDisplay'); +jest.mock('../../components/PerpsSlider', () => 'PerpsSlider'); + +describe('PerpsAdjustMarginView', () => { + const mockPosition: Position = { + coin: 'ETH', + size: '2.5', + marginUsed: '500', + entryPrice: '2000', + liquidationPrice: '1900', + unrealizedPnl: '100', + returnOnEquity: '0.20', + leverage: { value: 10, type: 'isolated' }, + cumulativeFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + positionValue: '5000', + maxLeverage: 50, + takeProfitCount: 0, + stopLossCount: 0, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockHandleAddMargin.mockResolvedValue(undefined); + mockHandleRemoveMargin.mockResolvedValue(undefined); + + // Set default mock return values + mockUsePerpsMarginAdjustment.mockReturnValue({ + handleAddMargin: mockHandleAddMargin, + handleRemoveMargin: mockHandleRemoveMargin, + isAdjusting: false, + }); + + mockUsePerpsLiveAccount.mockReturnValue({ + account: { availableBalance: '1000' }, + }); + + mockUsePerpsLivePrices.mockReturnValue({ + ETH: { price: '2000', markPrice: '2000', percentChange24h: '2.5' }, + }); + + mockUsePerpsMarkets.mockReturnValue({ + markets: [{ coin: 'ETH', maxLeverage: 50 }], + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('add mode', () => { + beforeEach(() => { + mockRouteParams = { + position: mockPosition, + mode: 'add', + }; + }); + + it('renders add margin title', () => { + render(); + + expect( + screen.getByText('perps.adjust_margin.add_title'), + ).toBeOnTheScreen(); + }); + + it('displays perps balance available to add', () => { + render(); + + expect( + screen.getByText('perps.adjust_margin.perps_balance'), + ).toBeOnTheScreen(); + expect(screen.getByText('$1000.00')).toBeOnTheScreen(); + }); + + it('displays liquidation price label', () => { + render(); + + expect( + screen.getByText('perps.adjust_margin.liquidation_price'), + ).toBeOnTheScreen(); + }); + + it('displays liquidation distance label', () => { + render(); + + expect( + screen.getByText('perps.adjust_margin.liquidation_distance'), + ).toBeOnTheScreen(); + }); + + it('displays add margin button label', () => { + render(); + + expect( + screen.getByText('perps.adjust_margin.add_margin'), + ).toBeOnTheScreen(); + }); + }); + + describe('remove mode', () => { + beforeEach(() => { + mockRouteParams = { + position: mockPosition, + mode: 'remove', + }; + }); + + it('renders remove margin title', () => { + render(); + + expect( + screen.getByText('perps.adjust_margin.remove_title'), + ).toBeOnTheScreen(); + }); + + it('displays current position margin', () => { + render(); + + expect( + screen.getByText('perps.adjust_margin.margin_in_position'), + ).toBeOnTheScreen(); + expect(screen.getByText('$500.00')).toBeOnTheScreen(); + }); + + it('displays reduce margin button label', () => { + render(); + + expect( + screen.getByText('perps.adjust_margin.reduce_margin'), + ).toBeOnTheScreen(); + }); + }); + + describe('error handling', () => { + it('renders view when route params are provided', () => { + mockRouteParams = { + position: mockPosition, + mode: 'add', + }; + + render(); + + // Verify view rendered by checking for title + expect( + screen.getByText('perps.adjust_margin.add_title'), + ).toBeOnTheScreen(); + }); + }); +}); diff --git a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx new file mode 100644 index 000000000000..609766be7652 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx @@ -0,0 +1,547 @@ +import React, { useState, useCallback, useMemo } from 'react'; +import { View, TouchableOpacity } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { useStyles } from '../../../../../component-library/hooks'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import Button, { + ButtonVariants, + ButtonWidthTypes, + ButtonSize, +} from '../../../../../component-library/components/Buttons/Button'; +import { strings } from '../../../../../../locales/i18n'; +import { usePerpsLiveAccount, usePerpsLivePrices } from '../../hooks/stream'; +import type { Position } from '../../controllers/types'; +import styleSheet from './PerpsAdjustMarginView.styles'; +import { useTheme } from '../../../../../util/theme'; +import Icon, { + IconName, + IconSize, + IconColor, +} from '../../../../../component-library/components/Icons/Icon'; +import ButtonIcon, { + ButtonIconSizes, +} from '../../../../../component-library/components/Buttons/ButtonIcon'; +import { usePerpsMarginAdjustment } from '../../hooks/usePerpsMarginAdjustment'; +import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; +import { usePerpsMarkets } from '../../hooks/usePerpsMarkets'; +import { TraceName } from '../../../../../util/trace'; +import Logger from '../../../../../util/Logger'; +import { ensureError } from '../../utils/perpsErrorHandler'; +import { + calculateMaxRemovableMargin, + calculateNewLiquidationPrice, +} from '../../utils/marginUtils'; +import PerpsAmountDisplay from '../../components/PerpsAmountDisplay'; +import PerpsSlider from '../../components/PerpsSlider'; +import PerpsBottomSheetTooltip from '../../components/PerpsBottomSheetTooltip'; +import { PerpsTooltipContentKey } from '../../components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types'; +import Keypad from '../../../../Base/Keypad'; +import { + formatPerpsFiat, + PRICE_RANGES_UNIVERSAL, + PRICE_RANGES_MINIMAL_VIEW, +} from '../../utils/formatUtils'; +import { MARGIN_ADJUSTMENT_CONFIG } from '../../constants/perpsConfig'; + +interface AdjustMarginRouteParams { + position: Position; + mode: 'add' | 'remove'; +} + +const PerpsAdjustMarginView: React.FC = () => { + const navigation = useNavigation(); + const route = + useRoute>(); + const { position, mode } = route.params || {}; + const { styles } = useStyles(styleSheet, {}); + const { colors } = useTheme(); + const { account } = usePerpsLiveAccount(); + + const [marginAmountString, setMarginAmountString] = useState('0'); + const [isInputFocused, setIsInputFocused] = useState(false); + const [selectedTooltip, setSelectedTooltip] = + useState(null); + + // Derived numeric value from string + const marginAmount = useMemo( + () => parseFloat(marginAmountString) || 0, + [marginAmountString], + ); + + const isAddMode = mode === 'add'; + + // Use margin adjustment hook for handling margin operations + const { handleAddMargin, handleRemoveMargin, isAdjusting } = + usePerpsMarginAdjustment({ + onSuccess: () => navigation.goBack(), + }); + + // Get market info for max leverage (needed for remove mode) + // Each token has different max leverage limits - must look up from markets + const { markets } = usePerpsMarkets(); + const marketInfo = useMemo( + () => + position?.coin ? markets.find((m) => m.symbol === position.coin) : null, + [position?.coin, markets], + ); + // maxLeverage in PerpsMarketData is a formatted string (e.g., '40x'), parse to number + const maxLeverage = marketInfo?.maxLeverage + ? parseInt(marketInfo.maxLeverage, 10) + : MARGIN_ADJUSTMENT_CONFIG.FALLBACK_MAX_LEVERAGE; + + // Add performance measurement for this view + usePerpsMeasurement({ + traceName: TraceName.PerpsAdjustMarginView, + conditions: [!isAdjusting, !!position], + debugContext: { mode }, + }); + + // Get live prices for liquidation distance calculation + const livePrices = usePerpsLivePrices({ + symbols: position?.coin ? [position.coin] : [], + throttleMs: 1000, + }); + const currentPrice = useMemo( + () => parseFloat(livePrices?.[position?.coin]?.price || '0'), + [livePrices, position?.coin], + ); + + // Current position data + const currentMargin = useMemo( + () => parseFloat(position?.marginUsed || '0'), + [position], + ); + + const currentLiquidationPrice = useMemo( + () => parseFloat(position?.liquidationPrice || '0'), + [position], + ); + + const positionSize = useMemo( + () => Math.abs(parseFloat(position?.size || '0')), + [position], + ); + + const entryPrice = useMemo( + () => parseFloat(position?.entryPrice || '0'), + [position], + ); + + const isLong = useMemo( + () => parseFloat(position?.size || '0') > 0, + [position], + ); + + // Available balance for add mode + const availableBalance = useMemo( + () => parseFloat(account?.availableBalance || '0'), + [account], + ); + + // Calculate maximum amount based on mode + const maxAmount = useMemo(() => { + if (isAddMode) { + return Math.max(0, availableBalance); + } + return calculateMaxRemovableMargin({ + currentMargin, + positionSize, + entryPrice, + currentPrice, + maxLeverage, + }); + }, [ + isAddMode, + availableBalance, + currentMargin, + positionSize, + entryPrice, + currentPrice, + maxLeverage, + ]); + + // Calculate new values after adjustment + const newMargin = useMemo(() => { + if (isAddMode) { + return currentMargin + marginAmount; + } + return Math.max(0, currentMargin - marginAmount); + }, [isAddMode, currentMargin, marginAmount]); + + // Calculate new liquidation price + const newLiquidationPrice = useMemo(() => { + if (newMargin === 0 || positionSize === 0) return currentLiquidationPrice; + + // For add mode, use simplified calculation + if (isAddMode) { + const marginPerUnit = newMargin / positionSize; + if (isLong) { + return Math.max(0, entryPrice - marginPerUnit); + } + return entryPrice + marginPerUnit; + } + + // For remove mode, use utility function + return calculateNewLiquidationPrice({ + newMargin, + positionSize, + entryPrice, + isLong, + currentLiquidationPrice, + }); + }, [ + isAddMode, + newMargin, + positionSize, + entryPrice, + isLong, + currentLiquidationPrice, + ]); + + // Calculate liquidation distance percentage + const calculateLiquidationDistance = useCallback( + (liquidationPrice: number) => { + if (currentPrice === 0 || !currentPrice || liquidationPrice === 0) { + return 0; + } + return (Math.abs(currentPrice - liquidationPrice) / currentPrice) * 100; + }, + [currentPrice], + ); + + const currentLiquidationDistance = useMemo( + () => calculateLiquidationDistance(currentLiquidationPrice), + [calculateLiquidationDistance, currentLiquidationPrice], + ); + + const newLiquidationDistance = useMemo( + () => calculateLiquidationDistance(newLiquidationPrice), + [calculateLiquidationDistance, newLiquidationPrice], + ); + + const handleSliderChange = useCallback((value: number) => { + setMarginAmountString(Math.floor(value).toString()); + }, []); + + const handleMaxPress = useCallback(() => { + setMarginAmountString(Math.floor(maxAmount).toString()); + }, [maxAmount]); + + // Keypad handlers + const handleAmountPress = useCallback(() => { + setIsInputFocused(true); + }, []); + + const handleKeypadChange = useCallback( + ({ value }: { value: string }) => { + const numValue = parseFloat(value) || 0; + // Clamp to maxAmount for remove mode to prevent invalid submissions + if (!isAddMode && numValue > maxAmount) { + setMarginAmountString(Math.floor(maxAmount).toString()); + } else { + setMarginAmountString(value || '0'); + } + }, + [isAddMode, maxAmount], + ); + + const handleDonePress = useCallback(() => { + setIsInputFocused(false); + }, []); + + const handlePercentagePress = useCallback( + (percentage: number) => { + const amount = Math.floor(maxAmount * percentage); + setMarginAmountString(amount.toString()); + }, + [maxAmount], + ); + + // Tooltip handlers + const handleTooltipPress = useCallback( + (contentKey: PerpsTooltipContentKey) => { + setSelectedTooltip(contentKey); + }, + [], + ); + + const handleTooltipClose = useCallback(() => { + setSelectedTooltip(null); + }, []); + + const handleConfirm = useCallback(async () => { + if (marginAmount <= 0 || !position) return; + + // Prevent submission if amount exceeds max removable (extra safety for remove mode) + if (!isAddMode && marginAmount > maxAmount) { + return; + } + + try { + if (isAddMode) { + await handleAddMargin(position.coin, marginAmount); + } else { + await handleRemoveMargin(position.coin, marginAmount); + } + } catch (error) { + Logger.error( + ensureError(error), + `Failed to ${isAddMode ? 'add' : 'remove'} margin for ${position.coin}`, + ); + // Note: Toast notification is handled by usePerpsMarginAdjustment hook + } + }, [ + marginAmount, + position, + isAddMode, + maxAmount, + handleAddMargin, + handleRemoveMargin, + ]); + + if (!position || !mode) { + return ( + + + + {strings('perps.errors.position_not_found')} + + + + ); + } + + const title = isAddMode + ? strings('perps.adjust_margin.add_title') + : strings('perps.adjust_margin.remove_title'); + + const buttonLabel = isAddMode + ? strings('perps.adjust_margin.add_margin') + : strings('perps.adjust_margin.reduce_margin'); + + return ( + + + navigation.goBack()} + iconColor={IconColor.Default} + size={ButtonIconSizes.Md} + /> + + {title} + + + + + + {/* Amount Display */} + + + + + {/* Slider - Hide when keypad is active */} + {!isInputFocused && ( + + + + )} + + {/* Info Section - Always visible */} + + {/* First row: Perps balance or Margin in position */} + + + {isAddMode + ? strings('perps.adjust_margin.perps_balance') + : strings('perps.adjust_margin.margin_in_position')} + + + {formatPerpsFiat(isAddMode ? availableBalance : currentMargin, { + ranges: PRICE_RANGES_MINIMAL_VIEW, + })} + + + + {/* Second row: Liquidation price with transition */} + + + + {strings('perps.adjust_margin.liquidation_price')} + + handleTooltipPress('liquidation_price')} + style={styles.infoIcon} + > + + + + {marginAmount > 0 ? ( + + + {formatPerpsFiat(currentLiquidationPrice, { + ranges: PRICE_RANGES_UNIVERSAL, + })} + + + + {formatPerpsFiat(newLiquidationPrice, { + ranges: PRICE_RANGES_UNIVERSAL, + })} + + + ) : ( + + {formatPerpsFiat(currentLiquidationPrice, { + ranges: PRICE_RANGES_UNIVERSAL, + })} + + )} + + + {/* Third row: Liquidation distance with transition */} + + + + {strings('perps.adjust_margin.liquidation_distance')} + + handleTooltipPress('liquidation_distance')} + style={styles.infoIcon} + > + + + + {marginAmount > 0 ? ( + + + {currentLiquidationDistance.toFixed(0)}% + + + + {newLiquidationDistance.toFixed(0)}% + + + ) : ( + + {currentLiquidationDistance.toFixed(0)}% + + )} + + + + + {/* Footer - Shows either Add Margin button or Keypad */} + {!isInputFocused ? ( + +