diff --git a/.github/workflows/run-e2e-smoke-tests-android.yml b/.github/workflows/run-e2e-smoke-tests-android.yml index 1a297e91905..ea9e35aa3c9 100644 --- a/.github/workflows/run-e2e-smoke-tests-android.yml +++ b/.github/workflows/run-e2e-smoke-tests-android.yml @@ -134,20 +134,20 @@ jobs: changed_files: ${{ inputs.changed_files }} secrets: inherit - # prediction-market-android-smoke: - # strategy: - # matrix: - # split: [1] - # fail-fast: false - # uses: ./.github/workflows/run-e2e-workflow.yml - # with: - # test-suite-name: prediction_market_android_smoke-${{ matrix.split }} - # platform: android - # test_suite_tag: 'SmokePredictions' - # split_number: ${{ matrix.split }} - # total_splits: 1 - # changed_files: ${{ inputs.changed_files }} - # secrets: inherit + prediction-market-android-smoke: + strategy: + matrix: + split: [1] + fail-fast: false + uses: ./.github/workflows/run-e2e-workflow.yml + with: + test-suite-name: prediction_market_android_smoke-${{ matrix.split }} + platform: android + test_suite_tag: 'SmokePredictions' + split_number: ${{ matrix.split }} + total_splits: 1 + changed_files: ${{ inputs.changed_files }} + secrets: inherit rewards-android-smoke: strategy: @@ -177,7 +177,7 @@ jobs: - network-abstraction-android-smoke - network-expansion-android-smoke - confirmations-redesigned-android-smoke - # - prediction-market-android-smoke + - prediction-market-android-smoke - rewards-android-smoke steps: - name: Checkout diff --git a/.github/workflows/run-e2e-smoke-tests-ios.yml b/.github/workflows/run-e2e-smoke-tests-ios.yml index 812d7adc8a6..3265ab8d1a2 100644 --- a/.github/workflows/run-e2e-smoke-tests-ios.yml +++ b/.github/workflows/run-e2e-smoke-tests-ios.yml @@ -134,20 +134,20 @@ jobs: changed_files: ${{ inputs.changed_files }} secrets: inherit - # prediction-market-ios-smoke: - # strategy: - # matrix: - # split: [1] - # fail-fast: false - # uses: ./.github/workflows/run-e2e-workflow.yml - # with: - # test-suite-name: prediction_market_ios_smoke-${{ matrix.split }} - # platform: ios - # test_suite_tag: 'SmokePredictions' - # split_number: ${{ matrix.split }} - # total_splits: 1 - # changed_files: ${{ inputs.changed_files }} - # secrets: inherit + prediction-market-ios-smoke: + strategy: + matrix: + split: [1] + fail-fast: false + uses: ./.github/workflows/run-e2e-workflow.yml + with: + test-suite-name: prediction_market_ios_smoke-${{ matrix.split }} + platform: ios + test_suite_tag: 'SmokePredictions' + split_number: ${{ matrix.split }} + total_splits: 1 + changed_files: ${{ inputs.changed_files }} + secrets: inherit rewards-ios-smoke: strategy: @@ -177,7 +177,7 @@ jobs: - accounts-ios-smoke - network-abstraction-ios-smoke - network-expansion-ios-smoke - # - prediction-market-ios-smoke + - prediction-market-ios-smoke - rewards-ios-smoke steps: - name: Checkout diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 5985222e771..f6b0463d12f 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -60,6 +60,7 @@ import RampRoutes from '../../UI/Ramp/Aggregator/routes'; import { RampType } from '../../UI/Ramp/Aggregator/types'; import RampSettings from '../../UI/Ramp/Aggregator/Views/Settings'; import RampActivationKeyForm from '../../UI/Ramp/Aggregator/Views/Settings/ActivationKeyForm'; +import RampTokenSelection from '../../UI/Ramp/components/TokenSelection'; import DepositOrderDetails from '../../UI/Ramp/Deposit/Views/DepositOrderDetails/DepositOrderDetails'; import DepositRoutes from '../../UI/Ramp/Deposit/routes'; @@ -1065,6 +1066,10 @@ const MainNavigator = () => { options={{ headerShown: false }} /> + {() => } diff --git a/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap b/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap index 53644c664ca..a28adbe9dfc 100644 --- a/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap +++ b/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap @@ -128,6 +128,10 @@ exports[`MainNavigator matches rendered snapshot 1`] = ` component={[Function]} name="PaymentRequestView" /> + diff --git a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/BridgeDestNetworkSelector.test.tsx b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/BridgeDestNetworkSelector.test.tsx index 0de895d4e4d..f08859cc7e5 100644 --- a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/BridgeDestNetworkSelector.test.tsx +++ b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/BridgeDestNetworkSelector.test.tsx @@ -152,3 +152,105 @@ describe('BridgeDestNetworkSelector', () => { expect(queryByText('Optimism')).toBeTruthy(); }); }); + +describe('BridgeDestNetworkSelector - ChainPopularity fallback', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('assigns Infinity to chains without defined popularity', () => { + // Add networks with and without defined popularity to test all branch combinations: + // - Optimism: HAS defined popularity (10 in ChainPopularity) + // - Palm: NO defined popularity (triggers ?? Infinity) + // - zkSync Era: NO defined popularity (triggers ?? Infinity) + // This ensures all branch combinations are tested: + // 1. Both have defined popularity (Optimism already tested in existing tests) + // 2. Both lack defined popularity (Palm vs zkSync Era) + // 3. One has, one doesn't (Optimism vs Palm/zkSync) + const stateWithMultipleNetworks = { + ...initialState, + engine: { + ...initialState.engine, + backgroundState: { + ...initialState.engine.backgroundState, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + bridgeConfig: { + minimumVersion: '0.0.0', + maxRefreshCount: 5, + refreshRate: 30000, + support: true, + chains: { + 'eip155:1': { + isActiveSrc: true, + isActiveDest: true, + }, + 'eip155:10': { + // Optimism - HAS defined popularity + isActiveSrc: true, + isActiveDest: true, + }, + 'eip155:11297108109': { + // Palm - NOT in ChainPopularity + isActiveSrc: true, + isActiveDest: true, + }, + 'eip155:324': { + // zkSync Era - NOT in ChainPopularity + isActiveSrc: true, + isActiveDest: true, + }, + }, + }, + bridgeConfigV2: { + minimumVersion: '0.0.0', + maxRefreshCount: 5, + refreshRate: 30000, + support: true, + chains: { + 'eip155:1': { + isActiveSrc: true, + isActiveDest: true, + isGaslessSwapEnabled: true, + }, + 'eip155:10': { + // Optimism + isActiveSrc: true, + isActiveDest: true, + isGaslessSwapEnabled: false, + }, + 'eip155:11297108109': { + // Palm + isActiveSrc: true, + isActiveDest: true, + isGaslessSwapEnabled: false, + }, + 'eip155:324': { + // zkSync Era + isActiveSrc: true, + isActiveDest: true, + isGaslessSwapEnabled: false, + }, + }, + }, + }, + }, + }, + }, + }; + + const { getByText } = renderScreen( + BridgeDestNetworkSelector, + { + name: Routes.BRIDGE.MODALS.DEST_NETWORK_SELECTOR, + }, + { state: stateWithMultipleNetworks }, + ); + + // All three networks should be visible and sorted by popularity + // Optimism (popularity 10) should appear before Palm and zkSync Era (both Infinity) + expect(getByText('Optimism')).toBeTruthy(); + expect(getByText('Palm')).toBeTruthy(); + expect(getByText('zkSync Era')).toBeTruthy(); + }); +}); diff --git a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/__snapshots__/BridgeDestNetworkSelector.test.tsx.snap b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/__snapshots__/BridgeDestNetworkSelector.test.tsx.snap index 62e8094525b..4f2f44449b7 100644 --- a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/__snapshots__/BridgeDestNetworkSelector.test.tsx.snap +++ b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/__snapshots__/BridgeDestNetworkSelector.test.tsx.snap @@ -637,7 +637,7 @@ exports[`BridgeDestNetworkSelector renders with initial state and displays netwo } } > - Optimism + Bitcoin @@ -831,7 +831,7 @@ exports[`BridgeDestNetworkSelector renders with initial state and displays netwo } } > - Bitcoin + Optimism diff --git a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/index.tsx b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/index.tsx index 6300a61aaf7..be92828a3bc 100644 --- a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/index.tsx +++ b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/index.tsx @@ -17,6 +17,7 @@ import { NetworkRow } from '../NetworkRow'; import Routes from '../../../../../constants/navigation/Routes'; import { selectChainId } from '../../../../../selectors/networkController'; import { BridgeViewMode } from '../../types'; +import { ChainPopularity } from '../BridgeDestNetworksBar'; export interface BridgeDestNetworkSelectorRouteParams { shouldGoToTokens?: boolean; @@ -68,6 +69,11 @@ export const BridgeDestNetworkSelector: React.FC = () => { } return chain.chainId !== currentChainId; }) + .sort((a, b) => { + const aPopularity = ChainPopularity[a.chainId] ?? Infinity; + const bPopularity = ChainPopularity[b.chainId] ?? Infinity; + return aPopularity - bPopularity; + }) .map((chain) => ( { @@ -72,21 +72,22 @@ const createStyles = (params: { theme: Theme }) => { * 1 = most popular * Infinity = least popular */ -const ChainPopularity: Record = { +export const ChainPopularity: Record = { [ETH_CHAIN_ID]: 1, + [BSC_CHAIN_ID]: 2, ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - [SolScope.Mainnet]: 2, + [BtcScope.Mainnet]: 3, + [SolScope.Mainnet]: 4, ///: END:ONLY_INCLUDE_IF - [BASE_CHAIN_ID]: 3, - [BSC_CHAIN_ID]: 4, - [LINEA_CHAIN_ID]: 5, - [OPTIMISM_CHAIN_ID]: 6, - [ARBITRUM_CHAIN_ID]: 7, - [AVALANCHE_CHAIN_ID]: 9, + [BASE_CHAIN_ID]: 5, + [ARBITRUM_CHAIN_ID]: 6, + [LINEA_CHAIN_ID]: 7, [POLYGON_CHAIN_ID]: 8, - [ZKSYNC_ERA_CHAIN_ID]: 10, - [NETWORKS_CHAIN_ID.SEI]: 11, - [NETWORKS_CHAIN_ID.MONAD]: 12, + [AVALANCHE_CHAIN_ID]: 9, + [OPTIMISM_CHAIN_ID]: 10, + [ZKSYNC_ERA_CHAIN_ID]: 11, + [NETWORKS_CHAIN_ID.SEI]: 12, + [NETWORKS_CHAIN_ID.MONAD]: 13, }; const ShortChainNames: Record = { diff --git a/app/components/UI/Bridge/components/BridgeDestTokenSelector/__snapshots__/BridgeDestTokenSelector.test.tsx.snap b/app/components/UI/Bridge/components/BridgeDestTokenSelector/__snapshots__/BridgeDestTokenSelector.test.tsx.snap index ab71cd059c7..b8a83c85e58 100644 --- a/app/components/UI/Bridge/components/BridgeDestTokenSelector/__snapshots__/BridgeDestTokenSelector.test.tsx.snap +++ b/app/components/UI/Bridge/components/BridgeDestTokenSelector/__snapshots__/BridgeDestTokenSelector.test.tsx.snap @@ -663,7 +663,7 @@ exports[`BridgeDestTokenSelector renders with initial state and displays tokens } } > - Solana + Bitcoin @@ -717,7 +717,7 @@ exports[`BridgeDestTokenSelector renders with initial state and displays tokens } } > - Optimism + Solana @@ -771,7 +771,7 @@ exports[`BridgeDestTokenSelector renders with initial state and displays tokens } } > - Bitcoin + Optimism diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts index 6eea4d028f5..5821b66aa82 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts @@ -17,14 +17,12 @@ import { fromTokenMinimalUnit, isNumberValue, } from '../../../../../util/number'; -import { selectPrimaryCurrency } from '../../../../../selectors/settings'; import { isQuoteExpired, getQuoteRefreshRate, shouldRefreshQuote, } from '../../utils/quoteUtils'; -import { selectTicker } from '../../../../../selectors/networkController'; import { BigNumber } from 'bignumber.js'; import I18n from '../../../../../../locales/i18n'; import useFiatFormatter from '../../../SimulationDetails/FiatDisplay/useFiatFormatter'; @@ -51,8 +49,6 @@ export const useBridgeQuoteData = ({ const isSubmittingTx = useSelector(selectIsSubmittingTx); const locale = I18n.locale; const fiatFormatter = useFiatFormatter(); - const primaryCurrency = useSelector(selectPrimaryCurrency) ?? 'ETH'; - const ticker = useSelector(selectTicker); const quotes = useSelector(selectBridgeQuotes); const bridgeFeatureFlags = useSelector(selectBridgeFeatureFlags); const isSolanaSwap = useSelector(selectIsSolanaSwap); @@ -123,20 +119,12 @@ export const useBridgeQuoteData = ({ return '-'; } - const networkFeeFormatter = getIntlNumberFormatter(locale, { - maximumFractionDigits: 6, - }); - const formattedAmount = `${networkFeeFormatter.format( - Number(amount), - )} ${ticker}`; const formattedValueInCurrency = fiatFormatter( new BigNumber(valueInCurrency), ); - return primaryCurrency === 'ETH' - ? formattedAmount - : formattedValueInCurrency; - }, [activeQuote, locale, ticker, fiatFormatter, primaryCurrency]); + return formattedValueInCurrency; + }, [activeQuote, fiatFormatter]); const formattedQuoteData = useMemo(() => { if (!activeQuote) return undefined; diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts index 0e53507e4c5..13012a03ba2 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts @@ -25,12 +25,6 @@ jest.mock('../../utils/quoteUtils', () => ({ shouldRefreshQuote: jest.fn(), })); -const mockSelectPrimaryCurrency = jest.fn(); -jest.mock('../../../../../selectors/settings', () => ({ - ...jest.requireActual('../../../../../selectors/settings'), - selectPrimaryCurrency: () => mockSelectPrimaryCurrency(), -})); - // Mock the bridge-controller module jest.mock('@metamask/bridge-controller', () => { const actual = jest.requireActual('@metamask/bridge-controller'); @@ -90,7 +84,6 @@ describe('useBridgeQuoteData', () => { (isQuoteExpired as jest.Mock).mockReturnValue(false); (getQuoteRefreshRate as jest.Mock).mockReturnValue(5000); (shouldRefreshQuote as jest.Mock).mockReturnValue(false); - mockSelectPrimaryCurrency.mockReturnValue('ETH'); mockUseIsInsufficientBalance.mockReturnValue(false); mockValidateBridgeTx.mockResolvedValue({ status: 'SUCCESS' }); }); @@ -329,17 +322,9 @@ describe('useBridgeQuoteData', () => { }); }); - it('formats network fee in ETH currency', () => { - mockSelectPrimaryCurrency.mockReturnValue('ETH'); - + it('returns undefined when activeQuote is undefined', () => { (selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({ - recommendedQuote: { - ...mockQuoteWithMetadata, - totalNetworkFee: { - amount: '0.01', - valueInCurrency: '10', - }, - }, + recommendedQuote: undefined, alternativeQuotes: [], })); @@ -349,19 +334,14 @@ describe('useBridgeQuoteData', () => { state: testState, }); - expect(result.current.formattedQuoteData?.networkFee).toBe('0.01 ETH'); + expect(result.current.formattedQuoteData?.networkFee).toBe(undefined); }); - it('formats network fee in USD currency', () => { - mockSelectPrimaryCurrency.mockReturnValue('USD'); - + it('returns "-" when totalNetworkFee is missing', () => { (selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({ recommendedQuote: { ...mockQuoteWithMetadata, - totalNetworkFee: { - amount: '0.01', - valueInCurrency: '10', - }, + totalNetworkFee: null, }, alternativeQuotes: [], })); @@ -372,29 +352,16 @@ describe('useBridgeQuoteData', () => { state: testState, }); - expect(result.current.formattedQuoteData?.networkFee).toBe('$10'); - }); - - it('returns undefined when activeQuote is undefined', () => { - (selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({ - recommendedQuote: undefined, - alternativeQuotes: [], - })); - - const testState = createBridgeTestState({}); - - const { result } = renderHookWithProvider(() => useBridgeQuoteData(), { - state: testState, - }); - - expect(result.current.formattedQuoteData?.networkFee).toBe(undefined); + expect(result.current.formattedQuoteData?.networkFee).toBe('-'); }); - it('returns "-" when totalNetworkFee is missing', () => { + it('returns "-" when totalNetworkFee amount is missing', () => { (selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({ recommendedQuote: { ...mockQuoteWithMetadata, - totalNetworkFee: null, + totalNetworkFee: { + valueInCurrency: '10', + }, }, alternativeQuotes: [], })); @@ -408,12 +375,12 @@ describe('useBridgeQuoteData', () => { expect(result.current.formattedQuoteData?.networkFee).toBe('-'); }); - it('returns "-" when totalNetworkFee amount is missing', () => { + it('returns "-" when totalNetworkFee valueInCurrency is missing', () => { (selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({ recommendedQuote: { ...mockQuoteWithMetadata, totalNetworkFee: { - valueInCurrency: '10', + amount: '0.01', }, }, alternativeQuotes: [], @@ -428,12 +395,13 @@ describe('useBridgeQuoteData', () => { expect(result.current.formattedQuoteData?.networkFee).toBe('-'); }); - it('returns "-" when totalNetworkFee valueInCurrency is missing', () => { + it('formats network fee with fiat formatter for normal values', () => { (selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({ recommendedQuote: { ...mockQuoteWithMetadata, totalNetworkFee: { amount: '0.01', + valueInCurrency: '10', }, }, alternativeQuotes: [], @@ -445,16 +413,16 @@ describe('useBridgeQuoteData', () => { state: testState, }); - expect(result.current.formattedQuoteData?.networkFee).toBe('-'); + expect(result.current.formattedQuoteData?.networkFee).toBe('$10'); }); - it('formats network fee with valid totalNetworkFee data', () => { + it('formats network fee as "<$0.01" when value is less than 0.01', () => { (selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({ recommendedQuote: { ...mockQuoteWithMetadata, totalNetworkFee: { - amount: '0.01', - valueInCurrency: '10', + amount: '0.0001', + valueInCurrency: '0.005', }, }, alternativeQuotes: [], @@ -466,19 +434,16 @@ describe('useBridgeQuoteData', () => { state: testState, }); - // The exact format will depend on the locale and formatter, but we can check it's not '-' - expect(result.current.formattedQuoteData?.networkFee).not.toBe('-'); + expect(result.current.formattedQuoteData?.networkFee).toBe('<$0.01'); }); - it('formats network fee in ETH currency when primary currency is not specified', () => { - mockSelectPrimaryCurrency.mockReturnValue(undefined); - + it('formats network fee normally when value is exactly 0.01', () => { (selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({ recommendedQuote: { ...mockQuoteWithMetadata, totalNetworkFee: { - amount: '0.01', - valueInCurrency: '10', + amount: '0.0001', + valueInCurrency: '0.01', }, }, alternativeQuotes: [], @@ -490,19 +455,16 @@ describe('useBridgeQuoteData', () => { state: testState, }); - // Should default to ETH format when primary currency is not specified - expect(result.current.formattedQuoteData?.networkFee).toContain('ETH'); + expect(result.current.formattedQuoteData?.networkFee).toBe('$0.01'); }); - it('formats network fee in USD currency when primary currency is not ETH', () => { - mockSelectPrimaryCurrency.mockReturnValue('GBP'); - + it('formats network fee normally when value is 0', () => { (selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({ recommendedQuote: { ...mockQuoteWithMetadata, totalNetworkFee: { - amount: '0.01', - valueInCurrency: '10', + amount: '0', + valueInCurrency: '0', }, }, alternativeQuotes: [], @@ -514,8 +476,7 @@ describe('useBridgeQuoteData', () => { state: testState, }); - // Should use USD format when primary currency is not ETH - expect(result.current.formattedQuoteData?.networkFee).toBe('$10'); + expect(result.current.formattedQuoteData?.networkFee).toBe('$0'); }); // Additional coverage tests diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index 901ebb03e69..9294084150b 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -1962,7 +1962,7 @@ export function getDepositNavbarOptions( ? () => ( { diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index f4a13e84591..50b55fa3496 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -74,6 +74,7 @@ const PerpsMarketListView = ({ const fadeAnimation = useRef(new Animated.Value(0)).current; const tabScrollViewRef = useRef(null); + const isScrollingProgrammatically = useRef(false); const [isSortFieldSheetVisible, setIsSortFieldSheetVisible] = useState(false); const [isStocksCommoditiesSheetVisible, setIsStocksCommoditiesSheetVisible] = useState(false); @@ -132,83 +133,20 @@ const PerpsMarketListView = ({ [onMarketSelect, perpsNavigation, route.params?.source], ); - // Get filtered markets for specific tab (used within each tab) - const getFilteredMarketsForTab = useCallback( - (filter: 'all' | 'crypto' | 'stocks_and_commodities') => { - if (searchQuery.trim()) { - // When searching, show all search results (filtering handled by search) - return filteredMarkets; - } - - // Filter by tab when not searching - if (filter === 'all') { - // All = Crypto + Stocks + Commodities (excluding forex) - return filteredMarkets.filter( - (m) => - !m.marketType || - m.marketType === 'equity' || - m.marketType === 'commodity', - ); - } - if (filter === 'crypto') { - // Crypto markets have no marketType set - return filteredMarkets.filter((m) => !m.marketType); - } - if (filter === 'stocks_and_commodities') { - // Combined stocks and commodities filter - apply sub-filter - let stocksCommoditiesMarkets = filteredMarkets.filter( - (m) => m.marketType === 'equity' || m.marketType === 'commodity', - ); - - // Apply stocks/commodities sub-filter if not 'all' - if (stocksCommoditiesFilter !== 'all') { - stocksCommoditiesMarkets = stocksCommoditiesMarkets.filter( - (m) => m.marketType === stocksCommoditiesFilter, - ); - } - - return stocksCommoditiesMarkets; - } - return filteredMarkets; - }, - [filteredMarkets, searchQuery, stocksCommoditiesFilter], - ); - - // Market type tab content component (filters markets by tab type) - // tabLabel is extracted by TabsList component for display, not used here - const MarketTypeTabContent = useCallback( - ({ - tabFilter, - tabLabel: _tabLabel, - }: { - tabFilter: 'all' | 'crypto' | 'stocks_and_commodities'; - tabLabel: string; - }) => { - const tabMarkets = getFilteredMarketsForTab(tabFilter); - return ( - - - + // Apply stocks/commodities sub-filter when on Stocks tab + const displayMarkets = useMemo(() => { + // If on stocks_and_commodities tab and sub-filter is active, apply it + if ( + marketTypeFilter === 'stocks_and_commodities' && + stocksCommoditiesFilter !== 'all' + ) { + return filteredMarkets.filter( + (m) => m.marketType === stocksCommoditiesFilter, ); - }, - [ - getFilteredMarketsForTab, - handleMarketPress, - sortBy, - fadeAnimation, - styles.animatedListContainer, - styles.tabContentContainer, - ], - ); + } + // Otherwise, use markets already filtered by the hook + return filteredMarkets; + }, [filteredMarkets, marketTypeFilter, stocksCommoditiesFilter]); // Build tabs data for TabsBar const tabsData = useMemo(() => { @@ -249,19 +187,6 @@ const PerpsMarketListView = ({ return tabs; }, [marketCounts]); - // Build tab content components - const tabsToRender = useMemo( - () => - tabsData.map((tab) => ( - - )), - [tabsData, MarketTypeTabContent], - ); - // Calculate active tab index from current marketTypeFilter const activeTabIndex = useMemo(() => { if (tabsData.length === 0) { @@ -294,9 +219,14 @@ const PerpsMarketListView = ({ [tabsData, setMarketTypeFilter], ); - // Handle scroll to sync active tab + // Handle scroll to sync active tab (for swipe gestures) const handleScroll = useCallback( (event: { nativeEvent: { contentOffset: { x: number } } }) => { + // Ignore programmatic scrolls to prevent feedback loop with useEffect + if (isScrollingProgrammatically.current) { + return; + } + const offsetX = event.nativeEvent.contentOffset.x; const index = Math.round(offsetX / containerWidth); if (index >= 0 && index < tabsData.length) { @@ -309,29 +239,34 @@ const PerpsMarketListView = ({ [containerWidth, tabsData, marketTypeFilter, setMarketTypeFilter], ); - // Sync scroll position when active tab changes (e.g., from navigation param) + // Sync scroll position when active tab changes (e.g., from tab bar press or navigation param) useEffect(() => { if ( tabScrollViewRef.current && activeTabIndex >= 0 && tabsData.length > 0 ) { + isScrollingProgrammatically.current = true; tabScrollViewRef.current.scrollTo({ x: activeTabIndex * containerWidth, animated: true, }); + // Clear flag after animation completes (~300ms animation + 50ms buffer) + setTimeout(() => { + isScrollingProgrammatically.current = false; + }, 350); } }, [activeTabIndex, containerWidth, tabsData.length]); useEffect(() => { - if (filteredMarkets.length > 0) { + if (displayMarkets.length > 0) { Animated.timing(fadeAnimation, { toValue: 1, duration: 300, useNativeDriver: true, }).start(); } - }, [filteredMarkets.length, fadeAnimation]); + }, [displayMarkets.length, fadeAnimation]); // Reset stocks/commodities filter to 'all' when switching tabs // This ensures that when switching to the Stocks tab, it always shows both stocks and commodities @@ -364,7 +299,7 @@ const PerpsMarketListView = ({ // Performance tracking: Measure screen load time until market data is displayed usePerpsMeasurement({ traceName: TraceName.PerpsMarketListView, - conditions: [filteredMarkets.length > 0], + conditions: [displayMarkets.length > 0], }); // Track markets screen viewed event @@ -372,7 +307,7 @@ const PerpsMarketListView = ({ route.params?.source || PerpsEventValues.SOURCE.MAIN_ACTION_BUTTON; usePerpsEventTracking({ eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, - conditions: [filteredMarkets.length > 0], + conditions: [displayMarkets.length > 0], properties: { [PerpsEventProperties.SCREEN_TYPE]: PerpsEventValues.SCREEN_TYPE.MARKETS, [PerpsEventProperties.SOURCE]: source, @@ -397,7 +332,7 @@ const PerpsMarketListView = ({ } // Error (Failed to load markets) - if (error && filteredMarkets.length === 0) { + if (error && displayMarkets.length === 0) { return ( ({ key: tab.key, label: tab.label, - content: null, // Content is rendered separately in ScrollView + content: null, isDisabled: false, }))} activeIndex={activeTabIndex} @@ -527,7 +462,7 @@ const PerpsMarketListView = ({ /> {/* Filter Bar - Between tabs and content */} - {(filteredMarkets.length > 0 || showFavoritesOnly) && ( + {(displayMarkets.length > 0 || showFavoritesOnly) && ( setIsSortFieldSheetVisible(true)} @@ -542,7 +477,7 @@ const PerpsMarketListView = ({ /> )} - {/* Tab Content - Scrollable */} + {/* Tab Content - Swipeable */} - {tabsToRender.map((tabContent, index) => ( + {tabsData.map((tab) => ( - {tabContent} + + + ))} )} - {/* Market list hidden when tabs are shown (tabs contain the list) */} + {/* Market list when no tabs shown (rare case) */} {!isSearchVisible && !isLoadingMarkets && !error && - tabsToRender.length === 0 && ( + tabsData.length === 0 && ( {renderMarketList()} diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx index eaa8e32e921..85765781939 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx @@ -94,7 +94,7 @@ describe('PerpsOrderTransactionView', () => { mockUsePerpsOrderFees.mockReturnValue({ totalFee: 10.5, protocolFee: 7.5, - metamaskFee: 3.0, + metamaskFee: 3, protocolFeeRate: 0.1, metamaskFeeRate: 0.05, isLoadingMetamaskFee: false, @@ -162,7 +162,7 @@ describe('PerpsOrderTransactionView', () => { expect(zeroFees).toHaveLength(3); // All three fees should be $0 }); - it('should handle small fees correctly', () => { + it('should show "< $0.01" for fees less than 0.01', () => { mockUsePerpsOrderFees.mockReturnValue({ totalFee: 0.005, protocolFee: 0.003, @@ -173,11 +173,135 @@ describe('PerpsOrderTransactionView', () => { error: null, }); - const { getByText } = render(); + const { getAllByText } = render(); + + // All three fees should show "< $0.01" since they're all less than 0.01 + const smallFeeLabels = getAllByText('< $0.01'); + expect(smallFeeLabels).toHaveLength(3); + }); + + it('should format fees normally when they are exactly 0.01', () => { + mockUsePerpsOrderFees.mockReturnValue({ + totalFee: 0.03, + protocolFee: 0.01, + metamaskFee: 0.01, + protocolFeeRate: 0.1, + metamaskFeeRate: 0.05, + isLoadingMetamaskFee: false, + error: null, + }); + + const { getAllByText, queryByText, getByText } = render( + , + ); + + // Fees at exactly 0.01 should be formatted normally, not show "< $0.01" + expect(queryByText('< $0.01')).toBeNull(); + // Both metamask and protocol fees are 0.01 + const fee01Labels = getAllByText('$0.01'); + expect(fee01Labels.length).toBeGreaterThanOrEqual(2); + expect(getByText('$0.03')).toBeTruthy(); // Total fee + }); + + it('should format fees normally when they are greater than 0.01', () => { + mockUsePerpsOrderFees.mockReturnValue({ + totalFee: 0.015, + protocolFee: 0.012, + metamaskFee: 0.003, + protocolFeeRate: 0.1, + metamaskFeeRate: 0.05, + isLoadingMetamaskFee: false, + error: null, + }); + + const { getByText, getAllByText } = render(); + + // Metamask fee is less than 0.01, should show "< $0.01" + expect(getAllByText('< $0.01')).toHaveLength(1); + // Protocol and total fees are >= 0.01, should be formatted normally + expect(getByText('$0.01')).toBeTruthy(); // Protocol fee formatted + expect(getByText('$0.02')).toBeTruthy(); // Total fee formatted (rounded) + }); + + it('should handle mixed small and large fees correctly', () => { + mockUsePerpsOrderFees.mockReturnValue({ + totalFee: 0.025, + protocolFee: 0.02, + metamaskFee: 0.005, + protocolFeeRate: 0.1, + metamaskFeeRate: 0.05, + isLoadingMetamaskFee: false, + error: null, + }); + + const { getByText, getAllByText } = render(); + + // Metamask fee is less than 0.01 + const smallFeeLabels = getAllByText('< $0.01'); + expect(smallFeeLabels).toHaveLength(1); + // Protocol and total fees are >= 0.01, should be formatted + expect(getByText('$0.02')).toBeTruthy(); // Protocol fee + expect(getByText('$0.03')).toBeTruthy(); // Total fee (rounded) + }); + + it('should handle edge case: fee just below 0.01 threshold', () => { + mockUsePerpsOrderFees.mockReturnValue({ + totalFee: 0.029, + protocolFee: 0.0099, + metamaskFee: 0.0099, + protocolFeeRate: 0.1, + metamaskFeeRate: 0.05, + isLoadingMetamaskFee: false, + error: null, + }); + + const { getAllByText } = render(); + + // Both metamask and protocol fees are just below 0.01 + const smallFeeLabels = getAllByText('< $0.01'); + expect(smallFeeLabels).toHaveLength(2); + // Total fee is >= 0.01, should be formatted + }); + + it('should handle edge case: fee just above 0.01 threshold', () => { + mockUsePerpsOrderFees.mockReturnValue({ + totalFee: 0.0201, + protocolFee: 0.0101, + metamaskFee: 0.01, + protocolFeeRate: 0.1, + metamaskFeeRate: 0.05, + isLoadingMetamaskFee: false, + error: null, + }); + + const { queryByText, getAllByText, getByText } = render( + , + ); + + // All fees are >= 0.01, should be formatted normally + expect(queryByText('< $0.01')).toBeNull(); + // Metamask fee and protocol fee (rounded) both show $0.01 + const fee01Labels = getAllByText('$0.01'); + expect(fee01Labels.length).toBeGreaterThanOrEqual(2); + expect(getByText('$0.02')).toBeTruthy(); // Total fee (rounded) + }); + + it('should show "< $0.01" for all fees when all are below threshold', () => { + mockUsePerpsOrderFees.mockReturnValue({ + totalFee: 0.008, + protocolFee: 0.005, + metamaskFee: 0.003, + protocolFeeRate: 0.1, + metamaskFeeRate: 0.05, + isLoadingMetamaskFee: false, + error: null, + }); + + const { getAllByText } = render(); - expect(getByText('$0.002')).toBeTruthy(); - expect(getByText('$0.003')).toBeTruthy(); - expect(getByText('$0.005')).toBeTruthy(); + // All three fees are below 0.01 + const smallFeeLabels = getAllByText('< $0.01'); + expect(smallFeeLabels).toHaveLength(3); }); it('should navigate to block explorer in browser tab when button is pressed', () => { diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx index c6eee742d05..b3c69c16497 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx @@ -11,7 +11,6 @@ import Text, { TextVariant, } from '../../../../../component-library/components/Texts/Text'; -import { BigNumber } from 'bignumber.js'; import { useSelector } from 'react-redux'; import { PerpsTransactionSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; import Button, { @@ -28,6 +27,7 @@ import { usePerpsBlockExplorerUrl, usePerpsOrderFees } from '../../hooks'; import { PerpsNavigationParamList } from '../../types/navigation'; import { PerpsOrderTransactionRouteProp } from '../../types/transactionHistory'; import { + formatFee, formatPerpsFiat, formatTransactionDate, } from '../../utils/formatUtils'; @@ -103,44 +103,21 @@ const PerpsOrderTransactionView: React.FC = () => { ]; const isFilled = transaction.order?.text === 'Filled'; + // Fee breakdown const feeRows = [ { label: strings('perps.transactions.order.metamask_fee'), - value: `${ - isFilled - ? `${ - BigNumber(metamaskFee).isLessThan(0.01) - ? `$${metamaskFee}` - : formatPerpsFiat(metamaskFee) - }` - : '$0' - }`, + value: formatFee(isFilled ? metamaskFee : 0), }, { label: strings('perps.transactions.order.hyperliquid_fee'), - value: `${ - isFilled - ? `${ - BigNumber(protocolFee).isLessThan(0.01) - ? `$${protocolFee}` - : formatPerpsFiat(protocolFee) - }` - : '$0' - }`, + value: formatFee(isFilled ? protocolFee : 0), }, { label: strings('perps.transactions.order.total_fee'), - value: `${ - isFilled - ? `${ - BigNumber(totalFee).isLessThan(0.01) - ? `$${totalFee}` - : formatPerpsFiat(totalFee) - }` - : '$0' - }`, + value: formatFee(isFilled ? totalFee : 0), }, ]; diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.test.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.test.tsx index 05e12176fee..9776b363ee9 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.test.tsx @@ -361,7 +361,7 @@ describe('PerpsPositionTransactionView', () => { expect(getByText('$5')).toBeOnTheScreen(); }); - it('should display fees with $ prefix directly for amounts < 0.01', () => { + it('should display fees with < $0.01 label for amounts < 0.01', () => { // Given a transaction with fee less than 0.01 const smallFeeTransaction = { ...mockTransaction, @@ -379,9 +379,9 @@ describe('PerpsPositionTransactionView', () => { state: mockInitialState, }); - // Then fee should display with $ prefix directly (not formatted through formatPerpsFiat) + // Then fee should display with < $0.01 label (not the actual value) expect(getByText('Total fees')).toBeOnTheScreen(); - expect(getByText('$0.005')).toBeOnTheScreen(); + expect(getByText('< $0.01')).toBeOnTheScreen(); }); it('should not render points when not present', () => { diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx index 42f1dd39602..6f9db04cfd6 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx @@ -30,6 +30,7 @@ import { PerpsTransaction, } from '../../types/transactionHistory'; import { + formatFee, formatPerpsFiat, formatTransactionDate, PRICE_RANGES_UNIVERSAL, @@ -115,9 +116,7 @@ const PerpsPositionTransactionView: React.FC = () => { transaction.fill?.fee !== undefined && transaction.fill?.fee !== null && { label: strings('perps.transactions.position.fees'), - value: BigNumber(transaction.fill.fee).isGreaterThan(0.01) - ? formatPerpsFiat(transaction.fill.fee) - : `$${transaction.fill.fee}`, + value: formatFee(transaction.fill.fee), textColor: TextColor.Default, }, ].filter(Boolean); diff --git a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.test.tsx b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.test.tsx index 6e4d46a17b1..4d7cdc5831c 100644 --- a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.test.tsx @@ -390,21 +390,21 @@ describe('PerpsMarketRowItem', () => { mockUsePerpsLivePrices.mockReturnValue({ BTC: { price: '50000', volume24h: 750000000 }, }); - rerender(); + rerender(); expect(screen.getByText('$750.00M')).toBeOnTheScreen(); // M shows 2 decimals // Test thousands (0 decimals with formatVolume) mockUsePerpsLivePrices.mockReturnValue({ BTC: { price: '50000', volume24h: 50000 }, }); - rerender(); + rerender(); expect(screen.getByText('$50K')).toBeOnTheScreen(); // K shows no decimals // Test small values (2 decimals with formatVolume) mockUsePerpsLivePrices.mockReturnValue({ BTC: { price: '50000', volume24h: 123.45 }, }); - rerender(); + rerender(); expect(screen.getByText('$123.45')).toBeOnTheScreen(); // Shows 2 decimals }); diff --git a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx index f442069360a..22f810f4e46 100644 --- a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx +++ b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx @@ -211,4 +211,4 @@ const PerpsMarketRowItem = ({ ); }; -export default PerpsMarketRowItem; +export default React.memo(PerpsMarketRowItem); diff --git a/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.test.tsx b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.test.tsx index 11d3a24b0bf..0c594c770ca 100644 --- a/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.test.tsx @@ -242,7 +242,7 @@ describe('PerpsMarketTypeSection', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_LIST, params: { - defaultMarketTypeFilter: 'all', + defaultMarketTypeFilter: 'crypto', }, }); }); @@ -261,7 +261,7 @@ describe('PerpsMarketTypeSection', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_LIST, params: { - defaultMarketTypeFilter: 'all', + defaultMarketTypeFilter: 'equity', }, }); }); @@ -280,7 +280,7 @@ describe('PerpsMarketTypeSection', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_LIST, params: { - defaultMarketTypeFilter: 'all', + defaultMarketTypeFilter: 'commodity', }, }); }); diff --git a/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.tsx b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.tsx index 6935946afbc..60ae3757572 100644 --- a/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.tsx @@ -23,7 +23,13 @@ export interface PerpsMarketTypeSectionProps { /** Markets to display */ markets: PerpsMarketData[]; /** Market type for filtering when "See All" is pressed */ - marketType: 'crypto' | 'equity' | 'commodity' | 'forex' | 'all'; + marketType: + | 'crypto' + | 'equity' + | 'commodity' + | 'forex' + | 'all' + | 'stocks_and_commodities'; /** Sort field for market list */ sortBy?: SortField; /** Whether markets are loading */ @@ -58,7 +64,7 @@ export interface PerpsMarketTypeSectionProps { const PerpsMarketTypeSection: React.FC = ({ title, markets, - marketType: _marketType, // Unused but kept for API compatibility + marketType, sortBy = 'volume', isLoading, testID, @@ -67,14 +73,14 @@ const PerpsMarketTypeSection: React.FC = ({ const navigation = useNavigation>(); const handleViewAll = useCallback(() => { - // Always navigate to "All" tab when "See all" is pressed + // Navigate to the specific market type tab when "See all" is pressed navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_LIST, params: { - defaultMarketTypeFilter: 'all', + defaultMarketTypeFilter: marketType, }, }); - }, [navigation]); + }, [navigation, marketType]); const handleMarketPress = useCallback( (market: PerpsMarketData) => { diff --git a/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.test.ts b/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.test.ts index bd7bb37ffbe..437046430b2 100644 --- a/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.test.ts @@ -145,7 +145,7 @@ describe('usePerpsCloseAllCalculations', () => { }); describe('Total Margin Calculation', () => { - it('calculates total margin including P&L for single position', () => { + it('calculates total margin excluding P&L for single position', () => { // Arrange const positions = [ createMockPosition({ @@ -161,7 +161,7 @@ describe('usePerpsCloseAllCalculations', () => { ); // Assert - expect(result.current.totalMargin).toBe(1100); // 1000 + 100 + expect(result.current.totalMargin).toBe(1000); // Only marginUsed, PnL excluded }); it('calculates total margin for multiple positions', () => { @@ -189,10 +189,10 @@ describe('usePerpsCloseAllCalculations', () => { ); // Assert - expect(result.current.totalMargin).toBe(1550); // (1000+100) + (500-50) + expect(result.current.totalMargin).toBe(1500); // 1000 + 500, PnL excluded }); - it('handles negative P&L correctly', () => { + it('excludes negative P&L from margin calculation', () => { // Arrange const positions = [ createMockPosition({ @@ -208,7 +208,47 @@ describe('usePerpsCloseAllCalculations', () => { ); // Assert - expect(result.current.totalMargin).toBe(800); // 1000 - 200 + expect(result.current.totalMargin).toBe(1000); // Only marginUsed, negative PnL excluded + }); + + it('excludes positive P&L from margin calculation', () => { + // Arrange + const positions = [ + createMockPosition({ + marginUsed: '500', + unrealizedPnl: '250', + }), + ]; + const priceData = { BTC: { price: '52000' } }; + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + expect(result.current.totalMargin).toBe(500); // Only marginUsed, positive PnL excluded + expect(result.current.totalPnl).toBe(250); // PnL is tracked separately + }); + + it('calculates total margin with zero P&L', () => { + // Arrange + const positions = [ + createMockPosition({ + marginUsed: '800', + unrealizedPnl: '0', + }), + ]; + const priceData = { BTC: { price: '50000' } }; + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + expect(result.current.totalMargin).toBe(800); // Only marginUsed + expect(result.current.totalPnl).toBe(0); }); }); @@ -778,10 +818,12 @@ describe('usePerpsCloseAllCalculations', () => { expect(result.current.isLoading).toBe(false); }); // Total fee recalculated: 25 + 25 = 50 - expect(result.current.receiveAmount).toBe(1050); // (1000 + 100) - 50 + // Receive amount: 1000 (margin only, PnL excluded) - 50 (fees) = 950 + expect(result.current.receiveAmount).toBe(950); + expect(result.current.totalPnl).toBe(100); // PnL tracked separately }); - it('handles negative receive amount when fees exceed margin', async () => { + it('returns zero receive amount when fees equal margin', async () => { // Arrange const positions = [ createMockPosition({ @@ -807,7 +849,53 @@ describe('usePerpsCloseAllCalculations', () => { await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.receiveAmount).toBe(-50); // (100 - 50) - 100 + // Receive amount: 100 (margin only) - 100 (fees) = 0 + expect(result.current.receiveAmount).toBe(0); + expect(result.current.totalPnl).toBe(-50); // PnL tracked separately + }); + + it('calculates receive amount excluding P&L for multiple positions', async () => { + // Arrange + const positions = [ + createMockPosition({ + coin: 'BTC', + marginUsed: '2000', + unrealizedPnl: '300', + }), + createMockPosition({ + coin: 'ETH', + marginUsed: '1500', + unrealizedPnl: '-100', + }), + ]; + const priceData = { + BTC: { price: '51000' }, + ETH: { price: '3000' }, + }; + mockCalculateFees.mockResolvedValue( + createMockFeeResult({ + feeAmount: 150, + metamaskFeeAmount: 100, + protocolFeeAmount: 50, + }), + ); + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + // Total margin: 2000 + 1500 = 3500 (PnL excluded) + // Total fees: 150 + 150 = 300 (two positions) + // Receive amount: 3500 - 300 = 3200 + expect(result.current.totalMargin).toBe(3500); + expect(result.current.totalPnl).toBe(200); // 300 - 100 + expect(result.current.totalFees).toBe(300); + expect(result.current.receiveAmount).toBe(3200); }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.ts b/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.ts index b3ebca08f9d..a95f253d5d0 100644 --- a/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.ts +++ b/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.ts @@ -14,7 +14,7 @@ import { formatAccountToCaipAccountId } from '../utils/rewardsUtils'; * Aggregated calculations result for closing all positions */ export interface CloseAllCalculationsResult { - /** Total margin across all positions (includes P&L) */ + /** Total margin across all positions (excludes P&L) */ totalMargin: number; /** Total unrealized P&L across all positions */ totalPnl: number; @@ -120,13 +120,12 @@ export function usePerpsCloseAllCalculations({ const hasValidResultsRef = useRef(false); const hasValidDiscountRef = useRef(false); - // Calculate total margin (including P&L) + // Calculate total margin const totalMargin = useMemo( () => positions.reduce((sum, pos) => { - const margin = parseFloat(pos.marginUsed) || 0; - const pnl = parseFloat(pos.unrealizedPnl) || 0; - return sum + margin + pnl; + const margin = Number.parseFloat(pos.marginUsed) || 0; + return sum + margin; }, 0), [positions], ); @@ -135,7 +134,7 @@ export function usePerpsCloseAllCalculations({ const totalPnl = useMemo( () => positions.reduce( - (sum, pos) => sum + (parseFloat(pos.unrealizedPnl) || 0), + (sum, pos) => sum + (Number.parseFloat(pos.unrealizedPnl) || 0), 0, ), [positions], diff --git a/app/components/UI/Perps/utils/formatUtils.test.ts b/app/components/UI/Perps/utils/formatUtils.test.ts index 8d9eafa47de..cf231c46565 100644 --- a/app/components/UI/Perps/utils/formatUtils.test.ts +++ b/app/components/UI/Perps/utils/formatUtils.test.ts @@ -15,6 +15,7 @@ import { formatTransactionDate, formatDateSection, formatFundingRate, + formatFee, PRICE_RANGES_UNIVERSAL, PRICE_RANGES_MINIMAL_VIEW, } from './formatUtils'; @@ -194,6 +195,140 @@ describe('formatUtils', () => { }); }); + describe('formatFee', () => { + it('returns "$0" when fee is exactly zero', () => { + // Given a fee of exactly zero + const fee = 0; + + // When formatting the fee + const result = formatFee(fee); + + // Then it returns "$0" + expect(result).toBe('$0'); + }); + + it('returns "< $0.01" when fee is below threshold', () => { + // Given a fee below the 0.01 threshold + const fee = 0.005; + + // When formatting the fee + const result = formatFee(fee); + + // Then it returns "< $0.01" + expect(result).toBe('< $0.01'); + }); + + it('formats fee normally when exactly at 0.01 threshold', () => { + // Given a fee at exactly the 0.01 threshold + const fee = 0.01; + + // When formatting the fee + const result = formatFee(fee); + + // Then it formats normally + expect(result).toBe('$0.01'); + }); + + it('formats fee normally when above threshold', () => { + // Given a fee above the 0.01 threshold + const fee = 1.5; + + // When formatting the fee + const result = formatFee(fee); + + // Then it formats normally + expect(result).toBe('$1.50'); + }); + + it('returns "< $0.01" for very small positive fees', () => { + // Given a very small positive fee + const fee = 0.0001; + + // When formatting the fee + const result = formatFee(fee); + + // Then it returns "< $0.01" + expect(result).toBe('< $0.01'); + }); + + it('returns "< $0.01" for fee just below threshold', () => { + // Given a fee just below the 0.01 threshold + const fee = 0.0099; + + // When formatting the fee + const result = formatFee(fee); + + // Then it returns "< $0.01" + expect(result).toBe('< $0.01'); + }); + + it('formats fee normally when just above threshold', () => { + // Given a fee just above the 0.01 threshold + const fee = 0.0101; + + // When formatting the fee + const result = formatFee(fee); + + // Then it formats normally (rounded to $0.01) + expect(result).toBe('$0.01'); + }); + + it('formats large fees with proper decimals', () => { + // Given a large fee value + const fee = 123.45; + + // When formatting the fee + const result = formatFee(fee); + + // Then it formats with proper decimals + expect(result).toBe('$123.45'); + }); + + it('strips trailing zeros for whole number fees', () => { + // Given a whole number fee + const fee = 100; + + // When formatting the fee + const result = formatFee(fee); + + // Then trailing zeros are stripped + expect(result).toBe('$100'); + }); + + it('handles fees with many decimal places', () => { + // Given a fee with many decimal places + const fee = 1.23456789; + + // When formatting the fee + const result = formatFee(fee); + + // Then it rounds appropriately + expect(result).toBe('$1.23'); + }); + + it('returns "$0" for negative zero', () => { + // Given a negative zero value + const fee = -0; + + // When formatting the fee + const result = formatFee(fee); + + // Then it returns "$0" + expect(result).toBe('$0'); + }); + + it('returns "< $0.01" for smallest representable positive fee', () => { + // Given the smallest positive fee + const fee = 0.00000001; + + // When formatting the fee + const result = formatFee(fee); + + // Then it returns "< $0.01" + expect(result).toBe('< $0.01'); + }); + }); + describe('formatPerpsFiat', () => { it('should format balance with default 2 decimal places (fiat-style stripping)', () => { expect(formatPerpsFiat(1234.56)).toBe('$1,234.56'); // Has meaningful decimals: preserved diff --git a/app/components/UI/Perps/utils/formatUtils.ts b/app/components/UI/Perps/utils/formatUtils.ts index 3470a1fab1b..8a965b150ab 100644 --- a/app/components/UI/Perps/utils/formatUtils.ts +++ b/app/components/UI/Perps/utils/formatUtils.ts @@ -1,6 +1,7 @@ /** * Shared formatting utilities for Perps components */ +import { BigNumber } from 'bignumber.js'; import { formatWithThreshold } from '../../../../util/assets'; import { FUNDING_RATE_CONFIG, @@ -341,6 +342,26 @@ export const formatPerpsFiat = ( return formatted; }; +/** + * Formats a fee value as USD currency with appropriate decimal places + * @param fee - Raw numeric or string fee value (e.g., 1234.56, not token minimal denomination) + * @returns Formatted currency string with variable decimals based on configured ranges + * @example formatFee(1234.56) => "$1,234.56" + * @example formatFee(0.005) => "< $0.01" + * @example formatFee(0) => "$0" + */ +export const formatFee = (fee: number | string): string => { + const smallFeeThreshold = 0.01; + + if (BigNumber(fee).isEqualTo(0)) { + return '$0'; + } + if (BigNumber(fee).isLessThan(smallFeeThreshold)) { + return '< $0.01'; + } + return formatPerpsFiat(fee); +}; + /** * Default price range configurations * Applied in order - first matching condition wins diff --git a/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx b/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx index 78230801149..f7a287385c8 100644 --- a/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx +++ b/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx @@ -88,12 +88,11 @@ const renderComponent = (overrides?: Partial) => { }; describe('PredictActivity', () => { - it('renders BUY activity with title, market, detail, amount and percent', () => { + it('renders BUY activity with title, market, amount and percent', () => { renderComponent(); expect(screen.getByText('Buy')).toBeOnTheScreen(); expect(screen.getByText(baseItem.marketTitle)).toBeOnTheScreen(); - expect(screen.getByText(baseItem.detail)).toBeOnTheScreen(); expect(screen.getByText('-$1,234.50')).toBeOnTheScreen(); expect(screen.getByText('+1.50%')).toBeOnTheScreen(); }); diff --git a/app/components/UI/Predict/components/PredictActivity/PredictActivity.tsx b/app/components/UI/Predict/components/PredictActivity/PredictActivity.tsx index e65429d3138..27f93385f4b 100644 --- a/app/components/UI/Predict/components/PredictActivity/PredictActivity.tsx +++ b/app/components/UI/Predict/components/PredictActivity/PredictActivity.tsx @@ -32,7 +32,6 @@ const PredictActivity: React.FC = ({ item }) => { const tw = useTailwind(); const navigation = useNavigation(); const isDebit = item.type === PredictActivityType.BUY; - const isCredit = !isDebit; const signedAmount = `${isDebit ? '-' : '+'}${formatPrice( Math.abs(item.amountUsd), { @@ -41,7 +40,6 @@ const PredictActivity: React.FC = ({ item }) => { }, )}`; - const amountColor = isCredit ? 'text-success-default' : 'text-error-default'; const percentColor = (item.percentChange ?? 0) >= 0 ? 'text-success-default' @@ -64,42 +62,31 @@ const PredictActivity: React.FC = ({ item }) => { justifyContent={BoxJustifyContent.Between} twClassName="w-full p-2" > - - {item.icon ? ( - - ) : ( - - )} + + + {item.icon ? ( + + ) : ( + + )} + {activityTitleByType[item.type]} - + {item.marketTitle} - {item.type !== PredictActivityType.CLAIM ? ( - - {item.detail} - - ) : null} - + {signedAmount} {item.percentChange !== undefined ? ( diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index 957b5b3a038..4c95fea1aad 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -1157,70 +1157,58 @@ export class PredictController extends BaseController< // Track Predict Action Completed or Failed const completionDuration = performance.now() - startTime; - if (result.success) { - const { spentAmount, receivedAmount } = result.response; - - const cachedBalance = - this.state.balances[providerId]?.[signer.address]?.balance ?? 0; - let realAmountUsd = amountUsd; - let realSharePrice = sharePrice; - try { - if (preview.side === Side.BUY) { - realAmountUsd = parseFloat(spentAmount); - realSharePrice = - parseFloat(spentAmount) / parseFloat(receivedAmount); - - // Optimistically update balance - this.update((state) => { - state.balances[providerId] = state.balances[providerId] || {}; - state.balances[providerId][signer.address] = { - balance: cachedBalance - realAmountUsd, - // valid for 5 seconds (since it takes some time to reflect balance on-chain) - validUntil: Date.now() + 5000, - }; - }); - } else { - realAmountUsd = parseFloat(receivedAmount); - realSharePrice = - parseFloat(receivedAmount) / parseFloat(spentAmount); - - // Optimistically update balance - this.update((state) => { - state.balances[providerId] = state.balances[providerId] || {}; - state.balances[providerId][signer.address] = { - balance: cachedBalance + realAmountUsd, - // valid for 5 seconds (since it takes some time to reflect balance on-chain) - validUntil: Date.now() + 5000, - }; - }); - } - } catch (_e) { - // If we can't get real share price, continue without it - } - - // Track Predict Action Completed (fire and forget) - this.trackPredictOrderEvent({ - eventType: PredictEventType.COMPLETED, - amountUsd: realAmountUsd, - analyticsProperties, - providerId, - completionDuration, - sharePrice: realSharePrice, - }); - } else { - // Track Predict Action Failed (fire and forget) - this.trackPredictOrderEvent({ - eventType: PredictEventType.FAILED, - amountUsd, - analyticsProperties, - providerId, - sharePrice, - completionDuration, - failureReason: result.error || 'Unknown error', - }); + if (!result.success) { throw new Error(result.error); } + const { spentAmount, receivedAmount } = result.response; + + const cachedBalance = + this.state.balances[providerId]?.[signer.address]?.balance ?? 0; + let realAmountUsd = amountUsd; + let realSharePrice = sharePrice; + try { + if (preview.side === Side.BUY) { + realAmountUsd = parseFloat(spentAmount); + realSharePrice = parseFloat(spentAmount) / parseFloat(receivedAmount); + + // Optimistically update balance + this.update((state) => { + state.balances[providerId] = state.balances[providerId] || {}; + state.balances[providerId][signer.address] = { + balance: cachedBalance - realAmountUsd, + // valid for 5 seconds (since it takes some time to reflect balance on-chain) + validUntil: Date.now() + 5000, + }; + }); + } else { + realAmountUsd = parseFloat(receivedAmount); + realSharePrice = parseFloat(receivedAmount) / parseFloat(spentAmount); + + // Optimistically update balance + this.update((state) => { + state.balances[providerId] = state.balances[providerId] || {}; + state.balances[providerId][signer.address] = { + balance: cachedBalance + realAmountUsd, + // valid for 5 seconds (since it takes some time to reflect balance on-chain) + validUntil: Date.now() + 5000, + }; + }); + } + } catch (_e) { + // If we can't get real share price, continue without it + } + + // Track Predict Action Completed (fire and forget) + this.trackPredictOrderEvent({ + eventType: PredictEventType.COMPLETED, + amountUsd: realAmountUsd, + analyticsProperties, + providerId, + completionDuration, + sharePrice: realSharePrice, + }); + return result as unknown as Result; } catch (error) { const completionDuration = performance.now() - startTime; @@ -1229,6 +1217,7 @@ export class PredictController extends BaseController< ? error.message : PREDICT_ERROR_CODES.PLACE_ORDER_FAILED; + // Track Predict Action Failed (fire and forget) this.trackPredictOrderEvent({ eventType: PredictEventType.FAILED, amountUsd, diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index 09a49af9e84..90bd9a40df3 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -711,7 +711,10 @@ export class PolymarketProvider implements PredictProvider { if (error.includes(`order couldn't be fully filled`)) { throw new Error(PREDICT_ERROR_CODES.ORDER_NOT_FULLY_FILLED); } - if (error.includes(`not available in your region`)) { + if ( + error.includes(`not available in your region`) || + error.includes(`unable to access this provider`) + ) { throw new Error(PREDICT_ERROR_CODES.NOT_ELIGIBLE); } throw new Error(error ?? PREDICT_ERROR_CODES.PLACE_ORDER_FAILED); diff --git a/app/components/UI/Predict/providers/polymarket/utils.test.ts b/app/components/UI/Predict/providers/polymarket/utils.test.ts index 9af965eb9c0..5ff1b1f4fcd 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.test.ts @@ -2085,13 +2085,13 @@ describe('polymarket utils', () => { } }); - it('maps non-TRADE to claimWinnings entries and handles defaults', () => { + it('maps REDEEM with payout to claimWinnings entries', () => { const input = [ { type: 'REDEEM' as const, side: '' as const, timestamp: 3000, - usdcSize: 1.23, + usdcSize: 1.23, // Winning claim with actual payout price: 0, conditionId: '', outcomeIndex: 0, @@ -2102,11 +2102,32 @@ describe('polymarket utils', () => { }, ]; const result = parsePolymarketActivity(input); + expect(result).toHaveLength(1); expect(result[0].entry.type).toBe('claimWinnings'); expect(result[0].entry.amount).toBe(1.23); expect(result[0].id).toBe('0xhash3'); }); + it('filters out REDEEM activities with no payout (lost positions)', () => { + const input = [ + { + type: 'REDEEM' as const, + side: '' as const, + timestamp: 3000, + usdcSize: 0, // No payout - lost position + price: 0, + conditionId: '', + outcomeIndex: 0, + title: 'Lost Market', + outcome: '' as const, + icon: '', + transactionHash: '0xhash3', + }, + ]; + const result = parsePolymarketActivity(input); + expect(result).toHaveLength(0); + }); + it('generates fallback id and timestamp when missing', () => { const input = [ { diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts index 119c9829a5c..798df38283a 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.ts @@ -450,6 +450,7 @@ export const parsePolymarketEvents = ( /** * Normalizes Polymarket /activity entries to PredictActivity[] * Keeps essential metadata used by UI (title/outcome/icon) + * Filters out claim activities with no payout (lost positions - technical clearing only) */ export const parsePolymarketActivity = ( activities: PolymarketApiActivity[], @@ -458,53 +459,67 @@ export const parsePolymarketActivity = ( return []; } - const parsedActivities: PredictActivity[] = activities.map((activity) => { - // Normalize entry type: TRADE with explicit side => buy/sell, otherwise claimWinnings - const entryType: 'buy' | 'sell' | 'claimWinnings' = - activity.type === 'TRADE' - ? activity.side === 'BUY' - ? 'buy' - : activity.side === 'SELL' - ? 'sell' - : 'claimWinnings' - : 'claimWinnings'; - - const id = - activity.transactionHash ?? String(activity.timestamp ?? Math.random()); - const timestamp = Number(activity.timestamp ?? Date.now()); - - const price = Number(activity.price ?? 0); - const amount = Number(activity.usdcSize ?? 0); - - const outcomeId = String(activity.conditionId ?? ''); - const marketId = String(activity.conditionId ?? ''); - const outcomeTokenId = Number(activity.outcomeIndex ?? 0); - const title = String(activity.title ?? 'Market'); - const outcome = activity.outcome ? String(activity.outcome) : undefined; - const icon = activity.icon as string | undefined; - - const parsedActivity: PredictActivity = { - id, - providerId: 'polymarket', - entry: - entryType === 'claimWinnings' - ? { type: 'claimWinnings', timestamp, amount } - : { - type: entryType, - timestamp, - marketId, - outcomeId, - outcomeTokenId, - amount, - price, - }, - title, - outcome, - icon, - } as PredictActivity & { title?: string; outcome?: string; icon?: string }; - - return parsedActivity; - }); + const parsedActivities: PredictActivity[] = activities + .map((activity) => { + // Normalize entry type: TRADE with explicit side => buy/sell, otherwise claimWinnings + const entryType: 'buy' | 'sell' | 'claimWinnings' = + activity.type === 'TRADE' + ? activity.side === 'BUY' + ? 'buy' + : activity.side === 'SELL' + ? 'sell' + : 'claimWinnings' + : 'claimWinnings'; + + const id = + activity.transactionHash ?? String(activity.timestamp ?? Math.random()); + const timestamp = Number(activity.timestamp ?? Date.now()); + + const price = Number(activity.price ?? 0); + const amount = Number(activity.usdcSize ?? 0); + + const outcomeId = String(activity.conditionId ?? ''); + const marketId = String(activity.conditionId ?? ''); + const outcomeTokenId = Number(activity.outcomeIndex ?? 0); + const title = String(activity.title ?? 'Market'); + const outcome = activity.outcome ? String(activity.outcome) : undefined; + const icon = activity.icon as string | undefined; + + const parsedActivity: PredictActivity = { + id, + providerId: 'polymarket', + entry: + entryType === 'claimWinnings' + ? { type: 'claimWinnings', timestamp, amount } + : { + type: entryType, + timestamp, + marketId, + outcomeId, + outcomeTokenId, + amount, + price, + }, + title, + outcome, + icon, + } as PredictActivity & { + title?: string; + outcome?: string; + icon?: string; + }; + + return parsedActivity; + }) + .filter((activity) => { + // Filter out claim activities with no actual payout + // These are lost positions being cleared - just technical operations with no transaction value + if (activity.entry.type === 'claimWinnings') { + return activity.entry.amount > 0; + } + return true; + }); + return parsedActivities; }; @@ -756,6 +771,9 @@ export async function calculateFees({ totalFee = (userBetAmount * FEE_PERCENTAGE) / 100; + // Round to 4 decimals + totalFee = Math.round(totalFee * 10000) / 10000; + // split total 50/50 between metamask and provider const metamaskFee = totalFee / 2; const providerFee = totalFee - metamaskFee; diff --git a/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.test.tsx b/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.test.tsx index bf3ce236c19..6ea227a355a 100644 --- a/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.test.tsx +++ b/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.test.tsx @@ -107,6 +107,8 @@ describe('PredictTransactionsView', () => { }); it('renders list items mapped from activity entries', () => { + const mockTimestamp = Math.floor(Date.now() / 1000); // Current time in seconds + (usePredictActivity as jest.Mock).mockReturnValueOnce({ isLoading: false, activity: [ @@ -115,28 +117,52 @@ describe('PredictTransactionsView', () => { title: 'Market A', outcome: 'Yes', icon: 'https://example.com/a.png', - entry: { type: 'buy', amount: 50, price: 0.34 }, + entry: { + type: 'buy', + amount: 50, + price: 0.34, + timestamp: mockTimestamp, + marketId: 'market-a', + outcomeId: 'outcome-yes', + outcomeTokenId: 1, + }, }, { id: 'b2', title: 'Market B', outcome: 'No', icon: 'https://example.com/b.png', - entry: { type: 'sell', amount: 12.3, price: 0.7 }, + entry: { + type: 'sell', + amount: 12.3, + price: 0.7, + timestamp: mockTimestamp - 100, + marketId: 'market-b', + outcomeId: 'outcome-no', + outcomeTokenId: 2, + }, }, { id: 'c3', title: 'Market C', outcome: 'Yes', icon: 'https://example.com/c.png', - entry: { type: 'claimWinnings', amount: 99.99 }, + entry: { + type: 'claimWinnings', + amount: 99.99, + timestamp: mockTimestamp - 200, + }, }, { id: 'd4', title: 'Market D', outcome: 'Yes', icon: 'https://example.com/d.png', - entry: { type: 'unknown', amount: 1.23 }, + entry: { + type: 'unknown', + amount: 1.23, + timestamp: mockTimestamp - 300, + }, }, ], }); diff --git a/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.tsx b/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.tsx index aa03447a063..44286d1bd49 100644 --- a/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.tsx +++ b/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.tsx @@ -1,5 +1,5 @@ -import React, { useMemo, useEffect } from 'react'; -import { ActivityIndicator, FlatList } from 'react-native'; +import React, { useMemo, useEffect, useCallback } from 'react'; +import { ActivityIndicator, SectionList } from 'react-native'; import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import PredictActivity from '../../components/PredictActivity/PredictActivity'; @@ -16,6 +16,46 @@ interface PredictTransactionsViewProps { isVisible?: boolean; } +interface ActivitySection { + title: string; + data: PredictActivityItem[]; +} + +/** + * Groups activities by individual day (Today, Yesterday, or specific date) + * Matches Perps date format: "Today", "Yesterday", or "Jan 15" + * @param timestamp Unix timestamp in seconds + * @param todayTime Start of today in milliseconds + * @param yesterdayTime Start of yesterday in milliseconds + */ +const getDateGroupLabel = ( + timestamp: number, + todayTime: number, + yesterdayTime: number, +): string => { + // Convert timestamp from seconds to milliseconds + const timestampMs = timestamp * 1000; + const activityDate = new Date(timestampMs); + + // Reset time to start of day for accurate comparison + activityDate.setHours(0, 0, 0, 0); + const activityTime = activityDate.getTime(); + + if (activityTime === todayTime) { + return strings('predict.transactions.today'); + } else if (activityTime === yesterdayTime) { + return strings('predict.transactions.yesterday'); + } + + // Format all other dates as "MMM D" (e.g., "Jan 15") to match Perps + const formatter = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + }); + + return formatter.format(activityDate); +}; + const PredictTransactionsView: React.FC = ({ isVisible, }) => { @@ -31,89 +71,173 @@ const PredictTransactionsView: React.FC = ({ } }, [isVisible, isLoading]); - const items: PredictActivityItem[] = useMemo( - () => - activity.map((activityEntry) => { - const e = activityEntry.entry; - - switch (e.type) { - case 'buy': { - const amountUsd = e.amount; - const priceCents = formatCents(e.price ?? 0); - const outcome = activityEntry.outcome; - - return { - id: activityEntry.id, - type: PredictActivityType.BUY, - marketTitle: activityEntry.title ?? '', - detail: strings('predict.transactions.buy_detail', { - amountUsd, - outcome, - priceCents, - }), + const sections: ActivitySection[] = useMemo(() => { + // Cache today and yesterday timestamps for reuse + const now = Date.now(); + const today = new Date(now); + const yesterday = new Date(now - 24 * 60 * 60 * 1000); + today.setHours(0, 0, 0, 0); + yesterday.setHours(0, 0, 0, 0); + const todayTime = today.getTime(); + const yesterdayTime = yesterday.getTime(); + + // Pre-compute date order labels + const todayLabel = strings('predict.transactions.today'); + const yesterdayLabel = strings('predict.transactions.yesterday'); + + // Map and group in a single pass for better performance + const groupedByDate: Record = {}; + const sectionOrder: string[] = []; + + activity.forEach((activityEntry) => { + const e = activityEntry.entry; + + // Map activity to item + let item: PredictActivityItem; + switch (e.type) { + case 'buy': { + const amountUsd = e.amount; + const priceCents = formatCents(e.price ?? 0); + const outcome = activityEntry.outcome; + + item = { + id: activityEntry.id, + type: PredictActivityType.BUY, + marketTitle: activityEntry.title ?? '', + detail: strings('predict.transactions.buy_detail', { amountUsd, - icon: activityEntry.icon, outcome, - providerId: activityEntry.providerId, - entry: e, - }; - } - case 'sell': { - const amountUsd = e.amount; - const priceCents = formatCents(e.price ?? 0); - return { - id: activityEntry.id, - type: PredictActivityType.SELL, - marketTitle: activityEntry.title ?? '', - detail: strings('predict.transactions.sell_detail', { - priceCents, - }), - amountUsd, - icon: activityEntry.icon, - outcome: activityEntry.outcome, - providerId: activityEntry.providerId, - entry: e, - }; - } - case 'claimWinnings': { - const amountUsd = e.amount; - return { - id: activityEntry.id, - type: PredictActivityType.CLAIM, - marketTitle: activityEntry.title ?? '', - detail: strings('predict.transactions.claim_detail'), - amountUsd, - icon: activityEntry.icon, - outcome: activityEntry.outcome, - providerId: activityEntry.providerId, - entry: e, - }; - } - default: { - return { - id: activityEntry.id, - type: PredictActivityType.CLAIM, - marketTitle: activityEntry.title ?? '', - detail: strings('predict.transactions.claim_detail'), - amountUsd: 0, - icon: activityEntry.icon, - outcome: activityEntry.outcome, - providerId: activityEntry.providerId, - entry: e, - }; - } + priceCents, + }), + amountUsd, + icon: activityEntry.icon, + outcome, + providerId: activityEntry.providerId, + entry: e, + }; + break; + } + case 'sell': { + const amountUsd = e.amount; + const priceCents = formatCents(e.price ?? 0); + item = { + id: activityEntry.id, + type: PredictActivityType.SELL, + marketTitle: activityEntry.title ?? '', + detail: strings('predict.transactions.sell_detail', { + priceCents, + }), + amountUsd, + icon: activityEntry.icon, + outcome: activityEntry.outcome, + providerId: activityEntry.providerId, + entry: e, + }; + break; } - }), - [activity], + case 'claimWinnings': { + const amountUsd = e.amount; + item = { + id: activityEntry.id, + type: PredictActivityType.CLAIM, + marketTitle: activityEntry.title ?? '', + detail: strings('predict.transactions.claim_detail'), + amountUsd, + icon: activityEntry.icon, + outcome: activityEntry.outcome, + providerId: activityEntry.providerId, + entry: e, + }; + break; + } + default: { + item = { + id: activityEntry.id, + type: PredictActivityType.CLAIM, + marketTitle: activityEntry.title ?? '', + detail: strings('predict.transactions.claim_detail'), + amountUsd: 0, + icon: activityEntry.icon, + outcome: activityEntry.outcome, + providerId: activityEntry.providerId, + entry: e, + }; + break; + } + } + + // Group by date + const dateLabel = getDateGroupLabel( + item.entry.timestamp, + todayTime, + yesterdayTime, + ); + + if (!groupedByDate[dateLabel]) { + groupedByDate[dateLabel] = []; + sectionOrder.push(dateLabel); + } + groupedByDate[dateLabel].push(item); + }); + + // Convert to sections array, maintaining chronological order + const sections: ActivitySection[] = []; + + // Add Today first if it exists + if (groupedByDate[todayLabel]) { + sections.push({ title: todayLabel, data: groupedByDate[todayLabel] }); + } + + // Add Yesterday second if it exists + if (groupedByDate[yesterdayLabel]) { + sections.push({ + title: yesterdayLabel, + data: groupedByDate[yesterdayLabel], + }); + } + + // Add all other dates in chronological order + sectionOrder.forEach((label) => { + if (label !== todayLabel && label !== yesterdayLabel) { + sections.push({ title: label, data: groupedByDate[label] }); + } + }); + + return sections; + }, [activity]); + + const renderSectionHeader = useCallback( + ({ section }: { section: ActivitySection }) => ( + + + {section.title} + + + ), + [], + ); + + const renderItem = useCallback( + ({ item }: { item: PredictActivityItem }) => ( + + + + ), + [], ); + const keyExtractor = useCallback((item: PredictActivityItem) => item.id, []); + return ( {isLoading ? ( - ) : items.length === 0 ? ( + ) : sections.length === 0 ? ( = ({ ) : ( // TODO: Improve loading state, pagination, consider FlashList for better performance, pull down to refresh, etc. - - data={items} - keyExtractor={(item) => item.id} - renderItem={({ item }) => ( - - - - )} + + sections={sections} + keyExtractor={keyExtractor} + renderItem={renderItem} + renderSectionHeader={renderSectionHeader} contentContainerStyle={tw.style('p-2')} showsVerticalScrollIndicator={false} - nestedScrollEnabled style={tw.style('flex-1')} + stickySectionHeadersEnabled + removeClippedSubviews + maxToRenderPerBatch={10} + updateCellsBatchingPeriod={50} + initialNumToRender={10} + windowSize={5} /> )} diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx index 13cb0aa9faf..98e17969992 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx @@ -100,6 +100,7 @@ import { trace, endTrace, TraceName } from '../../../../../../util/trace'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import { createUnsupportedRegionModalNavigationDetails } from '../../components/UnsupportedRegionModal'; import { regex } from '../../../../../../util/regex'; +import { createBuySettingsModalNavigationDetails } from '../Modals/Settings/SettingsModal'; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -417,6 +418,10 @@ const BuildQuote = () => { } }, [screenLocation, isBuy, selectedAsset?.network?.chainId, trackEvent]); + const handleConfigurationPress = useCallback(() => { + navigation.navigate(...createBuySettingsModalNavigationDetails()); + }, [navigation]); + useEffect(() => { navigation.setOptions( getDepositNavbarOptions( @@ -426,12 +431,21 @@ const BuildQuote = () => { ? strings('fiat_on_ramp_aggregator.amount_to_buy') : strings('fiat_on_ramp_aggregator.amount_to_sell'), showBack: params.showBack, + showConfiguration: isBuy, + onConfigurationPress: handleConfigurationPress, }, theme, handleCancelPress, ), ); - }, [navigation, theme, handleCancelPress, params.showBack, isBuy]); + }, [ + navigation, + theme, + handleCancelPress, + params.showBack, + isBuy, + handleConfigurationPress, + ]); /** * * Keypad style, handlers and effects diff --git a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.test.tsx b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.test.tsx new file mode 100644 index 00000000000..2f485b13143 --- /dev/null +++ b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.test.tsx @@ -0,0 +1,150 @@ +// Third party dependencies. +import { fireEvent } from '@testing-library/react-native'; + +// Internal dependencies. +import SettingsModal from './SettingsModal'; +import { renderScreen } from '../../../../../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../../../../../util/test/initial-root-state'; +import Routes from '../../../../../../../constants/navigation/Routes'; +import { createDepositNavigationDetails } from '../../../../Deposit/routes/utils'; + +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); +const mockDangerouslyGetParent = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + ...actualReactNavigation.useNavigation(), + navigate: mockNavigate, + goBack: mockGoBack, + dangerouslyGetParent: mockDangerouslyGetParent, + }), + }; +}); + +function render() { + return renderScreen( + SettingsModal, + { + name: 'SettingsModal', + }, + { + state: { + engine: { + backgroundState, + }, + }, + }, + ); +} + +describe('SettingsModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDangerouslyGetParent.mockReturnValue({ + dangerouslyGetParent: jest.fn().mockReturnValue({ + goBack: jest.fn(), + }), + }); + }); + + it('renders snapshot correctly', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('displays settings title in header', () => { + const { getByText } = render(); + + expect(getByText('Settings')).toBeTruthy(); + }); + + it('displays view order history menu item', () => { + const { getByText } = render(); + + expect(getByText('View order history')).toBeTruthy(); + }); + + it('displays use new buy experience menu item', () => { + const { getByText } = render(); + + expect(getByText('Use new buy experience')).toBeTruthy(); + expect(getByText('Try new native on ramp')).toBeTruthy(); + }); + + it('navigates to transactions view when view order history is pressed', () => { + const { getByText } = render(); + const viewOrderHistoryButton = getByText('View order history'); + + fireEvent.press(viewOrderHistoryButton); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW, { + screen: Routes.TRANSACTIONS_VIEW, + params: { + redirectToOrders: true, + }, + }); + }); + + it('navigates to deposit when use new buy experience is pressed', () => { + const { getByText } = render(); + const newBuyExperienceButton = getByText('Use new buy experience'); + + fireEvent.press(newBuyExperienceButton); + + expect(mockDangerouslyGetParent).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith( + ...createDepositNavigationDetails(), + ); + }); + + it('navigates back through parent navigation when deposit is pressed', () => { + const mockParentGoBack = jest.fn(); + mockDangerouslyGetParent.mockReturnValue({ + dangerouslyGetParent: jest.fn().mockReturnValue({ + goBack: mockParentGoBack, + }), + }); + + const { getByText } = render(); + const newBuyExperienceButton = getByText('Use new buy experience'); + + fireEvent.press(newBuyExperienceButton); + + expect(mockParentGoBack).toHaveBeenCalled(); + }); + + describe('bottom sheet behavior', () => { + it('renders bottom sheet with settings content', () => { + const { getByText } = render(); + + expect(getByText('Settings')).toBeTruthy(); + }); + }); + + describe('menu item icons', () => { + it('renders clock icon for view order history', () => { + const { getByText } = render(); + + expect(getByText('View order history')).toBeTruthy(); + }); + + it('renders add icon for new buy experience', () => { + const { getByText } = render(); + + expect(getByText('Use new buy experience')).toBeTruthy(); + }); + }); + + describe('callback functions', () => { + it('calls navigation callbacks only when menu items are pressed', () => { + render(); + + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockDangerouslyGetParent).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.tsx b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.tsx new file mode 100644 index 00000000000..83827590482 --- /dev/null +++ b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.tsx @@ -0,0 +1,67 @@ +import React, { useCallback, useRef } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { strings } from '../../../../../../../../locales/i18n'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import { IconName } from '../../../../../../../component-library/components/Icons/Icon'; +import Routes from '../../../../../../../constants/navigation/Routes'; +import { createNavigationDetails } from '../../../../../../../util/navigation/navUtils'; +import MenuItem from '../../../../components/MenuItem'; +import { createDepositNavigationDetails } from '../../../../Deposit/routes/utils'; + +export const createBuySettingsModalNavigationDetails = createNavigationDetails( + Routes.RAMP.MODALS.ID, + Routes.RAMP.MODALS.SETTINGS, +); + +function SettingsModal() { + const sheetRef = useRef(null); + const navigation = useNavigation(); + + const handleNavigateToOrderHistory = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + navigation.navigate(Routes.TRANSACTIONS_VIEW, { + screen: Routes.TRANSACTIONS_VIEW, + params: { + redirectToOrders: true, + }, + }); + }, [navigation]); + + const handleDepositPress = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + navigation.dangerouslyGetParent()?.dangerouslyGetParent()?.goBack(); + navigation.navigate(...createDepositNavigationDetails()); + }, [navigation]); + + const handleClosePress = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + }, []); + + return ( + + + {strings('fiat_on_ramp_aggregator.settings_modal.title')} + + + + + ); +} + +export default SettingsModal; diff --git a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/__snapshots__/SettingsModal.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/__snapshots__/SettingsModal.test.tsx.snap new file mode 100644 index 00000000000..8616d94bd89 --- /dev/null +++ b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/__snapshots__/SettingsModal.test.tsx.snap @@ -0,0 +1,728 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SettingsModal renders snapshot correctly 1`] = ` + + + + + + + + + + + + + SettingsModal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Settings + + + + + + + + + + + + + + + + + + + + View order history + + + + + + + + + + + + + + + Use new buy experience + + + Try new native on ramp + + + + + + + + + + + + + + + + + + +`; diff --git a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/index.ts b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/index.ts new file mode 100644 index 00000000000..a5bf953a756 --- /dev/null +++ b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/index.ts @@ -0,0 +1 @@ +export { default } from './SettingsModal'; diff --git a/app/components/UI/Ramp/Aggregator/routes/index.tsx b/app/components/UI/Ramp/Aggregator/routes/index.tsx index 50f349e2d83..a8904721176 100644 --- a/app/components/UI/Ramp/Aggregator/routes/index.tsx +++ b/app/components/UI/Ramp/Aggregator/routes/index.tsx @@ -15,6 +15,7 @@ import { colors } from '../../../../../styles/common'; import IncompatibleAccountTokenModal from '../components/IncompatibleAccountTokenModal'; import RegionSelectorModal from '../components/RegionSelectorModal'; import UnsupportedRegionModal from '../components/UnsupportedRegionModal'; +import SettingsModal from '../Views/Modals/Settings'; const Stack = createStackNavigator(); const ModalsStack = createStackNavigator(); @@ -92,6 +93,10 @@ const RampModalsRoutes = () => ( name={Routes.RAMP.MODALS.UNSUPPORTED_REGION} component={UnsupportedRegionModal} /> + ); diff --git a/app/components/UI/Ramp/Aggregator/routes/utils.ts b/app/components/UI/Ramp/Aggregator/routes/utils.ts index b160f44769c..4bfa48a5c5d 100644 --- a/app/components/UI/Ramp/Aggregator/routes/utils.ts +++ b/app/components/UI/Ramp/Aggregator/routes/utils.ts @@ -1,5 +1,6 @@ import { RampIntent, RampType } from '../types'; import Routes from '../../../../../constants/navigation/Routes'; +// import useRampsUnifiedV1Enabled from '../../hooks/useRampsUnifiedV1Enabled'; function createRampNavigationDetails(rampType: RampType, intent?: RampIntent) { const route = rampType === RampType.BUY ? Routes.RAMP.BUY : Routes.RAMP.SELL; @@ -19,6 +20,17 @@ function createRampNavigationDetails(rampType: RampType, intent?: RampIntent) { } export function createBuyNavigationDetails(intent?: RampIntent) { + // TODO: Use goToRamps hook for managing ramps navigation + // https://consensyssoftware.atlassian.net/browse/TRAM-2813 + // const isRampsUnifiedV1Enabled = useRampsUnifiedV1Enabled(); + // if (isRampsUnifiedV1Enabled) { + // return [ + // Routes.RAMP.TOKEN_SELECTION, + // { + // rampType: 'BUY', + // }, + // ]; + // } return createRampNavigationDetails(RampType.BUY, intent); } diff --git a/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap index b93d4c2e455..4bdcce5f13d 100644 --- a/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap @@ -183,7 +183,7 @@ exports[`BuildQuote Component Continue button functionality displays error when > - StyleSheet.create({ - container: { - paddingTop: 6, - }, - }); - -export default styleSheet; diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.test.tsx b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.test.tsx index 451afb70e7e..e7f5cf2a242 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.test.tsx +++ b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.test.tsx @@ -7,6 +7,7 @@ import { fireEvent, waitFor } from '@testing-library/react-native'; import Routes from '../../../../../../../constants/navigation/Routes'; import { TRANSAK_SUPPORT_URL } from '../../../constants/constants'; import { ToastContext } from '../../../../../../../component-library/components/Toast'; +import { createBuyNavigationDetails } from '../../../../Aggregator/routes/utils'; const mockShowToast = jest.fn(); const mockToastRef = { @@ -51,6 +52,7 @@ jest.mock('@react-navigation/native', () => { return { ...actualReactNavigation, useNavigation: () => ({ + ...actualReactNavigation.useNavigation(), navigate: mockNavigate, goBack: mockGoBack, setOptions: mockSetNavigationOptions.mockImplementation( @@ -112,6 +114,13 @@ describe('ConfigurationModal', () => { }); }); + it('navigates to aggregator when more ways to buy is pressed', () => { + const { getByText } = renderWithProvider(ConfigurationModal); + const moreWaysToBuyButton = getByText('More ways to buy'); + fireEvent.press(moreWaysToBuyButton); + expect(mockNavigate).toHaveBeenCalledWith(...createBuyNavigationDetails()); + }); + it('should open support URL when contact support is pressed', () => { const { getByText } = renderWithProvider(ConfigurationModal); const contactSupportButton = getByText('Contact support'); @@ -129,13 +138,13 @@ describe('ConfigurationModal', () => { it('should display logout option', () => { const { getByText } = renderWithProvider(ConfigurationModal); - expect(getByText('Log out')).toBeTruthy(); + expect(getByText('Log out of Transak')).toBeTruthy(); }); it('should clear auth token and show success toast when logout is successful', async () => { mockClearAuthToken.mockResolvedValue(undefined); const { getByText } = renderWithProvider(ConfigurationModal); - const logoutButton = getByText('Log out'); + const logoutButton = getByText('Log out of Transak'); fireEvent.press(logoutButton); expect(mockClearAuthToken).toHaveBeenCalled(); @@ -155,7 +164,7 @@ describe('ConfigurationModal', () => { const mockError = new Error('Logout failed'); mockClearAuthToken.mockRejectedValue(mockError); const { getByText } = renderWithProvider(ConfigurationModal); - const logoutButton = getByText('Log out'); + const logoutButton = getByText('Log out of Transak'); fireEvent.press(logoutButton); diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.tsx b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.tsx index 838cac9ab50..9d0b41720cd 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.tsx +++ b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.tsx @@ -1,25 +1,15 @@ import React, { useCallback, useRef, useContext } from 'react'; -import { Linking, View } from 'react-native'; -import Text, { - TextVariant, -} from '../../../../../../../component-library/components/Texts/Text'; +import { Linking } from 'react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../../../component-library/components/BottomSheets/BottomSheet'; -import ListItemSelect from '../../../../../../../component-library/components/List/ListItemSelect'; -import ListItemColumn, { - WidthType, -} from '../../../../../../../component-library/components/List/ListItemColumn'; -import Icon, { +import { IconName, - IconSize, IconColor, } from '../../../../../../../component-library/components/Icons/Icon'; -import { useStyles } from '../../../../../../hooks/useStyles'; -import styleSheet from './ConfigurationModal.styles'; - import { createNavigationDetails } from '../../../../../../../util/navigation/navUtils'; +import { createBuyNavigationDetails } from '../../../../Aggregator/routes/utils'; import Routes from '../../../../../../../constants/navigation/Routes'; import { strings } from '../../../../../../../../locales/i18n'; import { TRANSAK_SUPPORT_URL } from '../../../constants/constants'; @@ -30,6 +20,8 @@ import { ToastVariants, } from '../../../../../../../component-library/components/Toast'; import Logger from '../../../../../../../util/Logger'; +import BottomSheetHeader from '../../../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import MenuItem from '../../../../components/MenuItem'; export const createConfigurationModalNavigationDetails = createNavigationDetails( @@ -37,39 +29,8 @@ export const createConfigurationModalNavigationDetails = Routes.DEPOSIT.MODALS.CONFIGURATION, ); -interface MenuItemProps { - iconName: IconName; - title: string; - onPress: () => void; -} - -function MenuItem({ iconName, title, onPress }: MenuItemProps) { - const { theme } = useStyles(styleSheet, {}); - - return ( - - - - - - {title} - - - ); -} - function ConfigurationModal() { const sheetRef = useRef(null); - const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation(); const { toastRef } = useContext(ToastContext); @@ -90,6 +51,11 @@ function ConfigurationModal() { Linking.openURL(TRANSAK_SUPPORT_URL); }, []); + const handleNavigateToAggregator = useCallback(() => { + navigation.dangerouslyGetParent()?.dangerouslyGetParent()?.goBack(); + navigation.navigate(...createBuyNavigationDetails()); + }, [navigation]); + const handleLogOut = useCallback(async () => { try { await logoutFromProvider(); @@ -118,29 +84,43 @@ function ConfigurationModal() { } }, [logoutFromProvider, toastRef]); + const handleClosePress = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + }, []); + return ( - - - + + {strings('deposit.configuration_modal.title')} + + + + + + {isAuthenticated && ( + )} - {isAuthenticated && ( - + + onPress={handleNavigateToAggregator} + /> ); } diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap index aac0a38ecbf..f057f3d43ff 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap @@ -430,189 +430,375 @@ exports[`ConfigurationModal render matches snapshot 1`] = ` /> + + + + + + Settings + + + + + + + + + + + - - - + + + + - - + View order history + + + + + + + + + + - + + + - - View order history - - + Contact support + - - + + + - - - - + + + + - + More ways to buy + + - - Contact support - - + Use a different payment provider + - - + + diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx b/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx index 8e678dc0ec1..1b19d74051c 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx +++ b/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx @@ -5,6 +5,7 @@ import { CaipChainId } from '@metamask/utils'; import NetworksFilterBar from '../../../components/NetworksFilterBar'; import NetworksFilterSelector from '../../../components/NetworksFilterSelector/NetworksFilterSelector'; +import TokenListItem from '../../../../components/TokenListItem'; import Text, { TextVariant, @@ -14,15 +15,6 @@ import BottomSheet, { } from '../../../../../../../component-library/components/BottomSheets/BottomSheet'; import BottomSheetHeader from '../../../../../../../component-library/components/BottomSheets/BottomSheetHeader'; import ListItemSelect from '../../../../../../../component-library/components/List/ListItemSelect'; -import ListItemColumn, { - WidthType, -} from '../../../../../../../component-library/components/List/ListItemColumn'; -import AvatarToken from '../../../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; -import { AvatarSize } from '../../../../../../../component-library/components/Avatars/Avatar'; -import BadgeNetwork from '../../../../../../../component-library/components/Badges/Badge/variants/BadgeNetwork'; -import BadgeWrapper, { - BadgePosition, -} from '../../../../../../../component-library/components/Badges/BadgeWrapper'; import TextFieldSearch from '../../../../../../../component-library/components/Form/TextFieldSearch'; import styleSheet from './TokenSelectorModal.styles'; @@ -35,11 +27,9 @@ import { useParams, } from '../../../../../../../util/navigation/navUtils'; import { useDepositCryptoCurrencyNetworkName } from '../../../hooks/useDepositCryptoCurrencyNetworkName'; -import { getNetworkImageSource } from '../../../../../../../util/networks'; import { DepositCryptoCurrency } from '@consensys/native-ramps-sdk'; import Routes from '../../../../../../../constants/navigation/Routes'; import { strings } from '../../../../../../../../locales/i18n'; -import { DEPOSIT_NETWORKS_BY_CHAIN_ID } from '../../../constants/networks'; import { useTheme } from '../../../../../../../util/theme'; import useAnalytics from '../../../../hooks/useAnalytics'; @@ -138,48 +128,15 @@ function TokenSelectorModal() { }, [handleSearchTextChange]); const renderToken = useCallback( - ({ item: token }: { item: DepositCryptoCurrency }) => { - const networkName = getNetworkName(token.chainId); - const networkImageSource = getNetworkImageSource({ - chainId: token.chainId, - }); - const depositNetworkName = - DEPOSIT_NETWORKS_BY_CHAIN_ID[token.chainId]?.name; - return ( - handleSelectAssetIdCallback(token.assetId)} - accessibilityRole="button" - accessible - > - - - } - > - - - - - {token.symbol} - - {depositNetworkName ?? networkName} - - - - ); - }, + ({ item: token }: { item: DepositCryptoCurrency }) => ( + handleSelectAssetIdCallback(token.assetId)} + textColor={colors.text.alternative} + /> + ), [ - getNetworkName, colors.text.alternative, handleSelectAssetIdCallback, selectedCryptoCurrency?.assetId, diff --git a/app/components/UI/Ramp/Deposit/components/NetworksFilterBar/NetworksFilterBar.tsx b/app/components/UI/Ramp/Deposit/components/NetworksFilterBar/NetworksFilterBar.tsx index 939e9b6d9bd..fdab9c544a2 100644 --- a/app/components/UI/Ramp/Deposit/components/NetworksFilterBar/NetworksFilterBar.tsx +++ b/app/components/UI/Ramp/Deposit/components/NetworksFilterBar/NetworksFilterBar.tsx @@ -1,5 +1,4 @@ import React, { useMemo } from 'react'; -import { useSelector } from 'react-redux'; import { CaipChainId } from '@metamask/utils'; import { ScrollView } from 'react-native-gesture-handler'; @@ -22,11 +21,9 @@ import Icon, { import styleSheet from './NetworksFilterBar.styles'; -import { selectNetworkConfigurationsByCaipChainId } from '../../../../../../selectors/networkController'; import { useStyles } from '../../../../../hooks/useStyles'; -import { DEPOSIT_NETWORKS_BY_CHAIN_ID } from '../../constants/networks'; import { excludeFromArray } from '../../utils'; -import { getNetworkImageSource } from '../../../../../../util/networks'; +import { useTokenNetworkInfo } from '../../../hooks/useTokenNetworkInfo'; import { useTheme } from '../../../../../../util/theme'; import { strings } from '../../../../../../../locales/i18n'; @@ -45,29 +42,30 @@ function NetworksFilterBar({ }: Readonly) { const { styles } = useStyles(styleSheet, {}); const { colors } = useTheme(); - - const allNetworkConfigurations = useSelector( - selectNetworkConfigurationsByCaipChainId, - ); + const getTokenNetworkInfo = useTokenNetworkInfo(); const allNetworksIcons = useMemo(() => { const headSize = 3; const reversedHead = networks.slice(0, headSize).reverse(); return ( - {reversedHead.map((chainId) => ( - - ))} + {reversedHead.map((chainId) => { + const { depositNetworkName, networkName, networkImageSource } = + getTokenNetworkInfo(chainId); + return ( + + ); + })} ); - }, [allNetworkConfigurations, styles.overlappedNetworkIcon, networks]); + }, [getTokenNetworkInfo, styles.overlappedNetworkIcon, networks]); return ( {networks.map((chainId) => { const isSelected = networkFilter.includes(chainId); - const networkName = - DEPOSIT_NETWORKS_BY_CHAIN_ID[chainId]?.name ?? - allNetworkConfigurations[chainId]?.name; + const { depositNetworkName, networkName, networkImageSource } = + getTokenNetworkInfo(chainId); + const displayName = depositNetworkName ?? networkName; return (