diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index 84c7edc9c0a..b5c21785947 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -67,6 +67,7 @@ const PerpsMarketListView = ({ route.params?.showWatchlistOnly ?? propShowWatchlistOnly ?? false; const defaultMarketTypeFilter = route.params?.defaultMarketTypeFilter ?? 'all'; + const defaultSortOptionId = route.params?.defaultSortOptionId; const fadeAnimation = useRef(new Animated.Value(0)).current; const [isSortFieldSheetVisible, setIsSortFieldSheetVisible] = useState(false); @@ -84,6 +85,7 @@ const PerpsMarketListView = ({ enablePolling: false, showWatchlistOnly, defaultMarketTypeFilter, + defaultSortOptionId, showZeroVolume: __DEV__, }); diff --git a/app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts b/app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts index 2390f2de6b6..cbaca476250 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts @@ -305,6 +305,68 @@ describe('usePerpsMarketListView', () => { }); }); + it('defaultSortOptionId overrides the saved sort option and resets direction to default', () => { + let selectorCallCount = 0; + mockUseSelector.mockImplementation(() => { + selectorCallCount++; + if (selectorCallCount % 2 === 1) { + return ['BTC']; + } + // Saved preference is volume/asc — user had it sorted ascending + return { optionId: 'volume', direction: 'asc' }; + }); + + renderHook(() => + usePerpsMarketListView({ defaultSortOptionId: 'priceChange' }), + ); + + // Option overridden → direction must reset to default (desc), not carry 'asc' + expect(mockUsePerpsSorting).toHaveBeenCalledWith({ + initialOptionId: 'priceChange', + initialDirection: 'desc', + }); + }); + + it('preserves saved direction when defaultSortOptionId matches the saved option', () => { + let selectorCallCount = 0; + mockUseSelector.mockImplementation(() => { + selectorCallCount++; + if (selectorCallCount % 2 === 1) { + return ['BTC']; + } + // Saved preference is priceChange/asc + return { optionId: 'priceChange', direction: 'asc' }; + }); + + renderHook(() => + usePerpsMarketListView({ defaultSortOptionId: 'priceChange' }), + ); + + // Same option — carry the saved direction, don't reset + expect(mockUsePerpsSorting).toHaveBeenCalledWith({ + initialOptionId: 'priceChange', + initialDirection: 'asc', + }); + }); + + it('falls back to saved sort preference when defaultSortOptionId is not provided', () => { + let selectorCallCount = 0; + mockUseSelector.mockImplementation(() => { + selectorCallCount++; + if (selectorCallCount % 2 === 1) { + return ['BTC']; + } + return { optionId: 'fundingRate', direction: 'asc' }; + }); + + renderHook(() => usePerpsMarketListView()); + + expect(mockUsePerpsSorting).toHaveBeenCalledWith({ + initialOptionId: 'fundingRate', + initialDirection: 'asc', + }); + }); + it('exposes sort state correctly', () => { const { result } = renderHook(() => usePerpsMarketListView()); diff --git a/app/components/UI/Perps/hooks/usePerpsMarketListView.ts b/app/components/UI/Perps/hooks/usePerpsMarketListView.ts index 6953520563c..8eeb0afff5c 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketListView.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketListView.ts @@ -4,6 +4,7 @@ import { usePerpsMarkets } from './usePerpsMarkets'; import { usePerpsSearch } from './usePerpsSearch'; import { usePerpsSorting } from './usePerpsSorting'; import { + MARKET_SORTING_CONFIG, sortMarkets, type PerpsMarketData, type MarketTypeFilter, @@ -33,6 +34,11 @@ interface UsePerpsMarketListViewParams { * @default 'all' */ defaultMarketTypeFilter?: MarketTypeFilter; + /** + * Initial sort option ID — overrides the persisted user preference when provided. + * @default undefined (falls back to saved user preference) + */ + defaultSortOptionId?: SortOptionId; /** * Show markets with $0.00 volume * @default false @@ -133,6 +139,7 @@ export const usePerpsMarketListView = ({ enablePolling = false, showWatchlistOnly = false, defaultMarketTypeFilter = 'all', + defaultSortOptionId, showZeroVolume = false, }: UsePerpsMarketListViewParams = {}): UsePerpsMarketListViewReturn => { // Fetch markets data @@ -196,10 +203,20 @@ export const usePerpsMarketListView = ({ return searchedMarkets; }, [searchedMarkets, marketTypeFilter]); - // Use sorting hook for sort state and sorting logic + // Use sorting hook for sort state and sorting logic. + // defaultSortOptionId (from navigation params) takes precedence over the saved user + // preference. When it overrides a *different* option, reset direction to the default + // so the market list opens sorted the same way the explore feed displayed it (always desc). + // When there is no override, or the override matches the saved option, carry the saved direction. + const isOptionOverridden = + defaultSortOptionId !== undefined && + defaultSortOptionId !== savedSortPreference.optionId; const sortingHook = usePerpsSorting({ - initialOptionId: savedSortPreference.optionId as SortOptionId, - initialDirection: savedSortPreference.direction, + initialOptionId: (defaultSortOptionId ?? + savedSortPreference.optionId) as SortOptionId, + initialDirection: isOptionOverridden + ? MARKET_SORTING_CONFIG.DefaultDirection + : savedSortPreference.direction, }); // Wrap handleOptionChange to save preference to PerpsController diff --git a/app/components/UI/Perps/types/navigation.ts b/app/components/UI/Perps/types/navigation.ts index c8c98c56985..58ee90e2599 100644 --- a/app/components/UI/Perps/types/navigation.ts +++ b/app/components/UI/Perps/types/navigation.ts @@ -5,6 +5,7 @@ import { type OrderType, type PerpsMarketData, type TPSLTrackingData, + type SortOptionId, } from '@metamask/perps-controller'; import { PerpsTransaction } from './transactionHistory'; import type { DataMonitorParams } from '../hooks/usePerpsDataMonitor'; @@ -90,6 +91,7 @@ export interface PerpsNavigationParamList extends ParamListBase { | 'commodities' | 'forex' | 'new'; + defaultSortOptionId?: SortOptionId; fromHome?: boolean; button_clicked?: string; button_location?: string; diff --git a/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.test.tsx b/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.test.tsx new file mode 100644 index 00000000000..dd354e95942 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.test.tsx @@ -0,0 +1,263 @@ +/** + * PerpsToggleBlock — unit tests + * + * Core concerns: + * 1. Renders title and the default pill's items. + * 2. "View All" button calls onViewAll with the active pill key and the + * sortOptionId prop — this is the critical wiring introduced alongside the + * sort-param feature. + * 3. Switching pills updates the key passed to onViewAll. + * 4. Shows skeletons while loading. + * 5. Analytics: trackExploreInteracted is called when a row is pressed. + */ + +jest.mock('@shopify/flash-list', () => { + const RN = jest.requireActual('react-native'); + return { FlashList: RN.FlatList }; +}); + +jest.mock('../../search/analytics', () => ({ + trackExploreInteracted: jest.fn(), +})); + +jest.mock('@metamask/design-system-twrnc-preset', () => { + const twFn = () => ({}); + twFn.style = () => ({}); + return { useTailwind: () => twFn }; +}); + +import React from 'react'; +import { Text } from 'react-native'; +import { render, fireEvent, act } from '@testing-library/react-native'; +import type { PerpsMarketData, SortOptionId } from '@metamask/perps-controller'; +import { trackExploreInteracted } from '../../search/analytics'; +import PerpsToggleBlock, { + type PerpsToggleBlockProps, +} from './PerpsToggleBlock'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeMarket = (symbol: string): PerpsMarketData => + ({ + symbol, + name: `${symbol} Market`, + price: '$1.00', + change24h: '+1%', + change24hPercent: '1', + volume: '$100M', + maxLeverage: '10x', + isHip3: true, + marketType: 'equity', + }) as PerpsMarketData; + +// Minimal PerpsRowItem mock — renders the symbol so assertions are simple. +jest.mock('./PerpsRowItem', () => { + const { TouchableOpacity, Text: RNText } = jest.requireActual('react-native'); + return function MockPerpsRowItem({ + market, + onCardPress, + }: { + market: PerpsMarketData; + onCardPress?: () => void; + }) { + return ( + + {market.symbol} + + ); + }; +}); + +jest.mock('../../../../UI/Perps/components/PerpsRowSkeleton', () => { + const { View, Text: RNText } = jest.requireActual('react-native'); + return function MockPerpsRowSkeleton() { + return ( + + skeleton + + ); + }; +}); + +const Skeleton = () => sk; + +const STOCKS_MARKETS = [makeMarket('AAPL'), makeMarket('GOOGL')]; +const COMMODITY_MARKETS = [makeMarket('GOLD')]; + +const DEFAULT_TABS = [ + { key: 'stocks', name: 'Stocks', items: STOCKS_MARKETS }, + { key: 'commodities', name: 'Commodities', items: COMMODITY_MARKETS }, +]; + +const DEFAULT_PROPS: PerpsToggleBlockProps = { + title: 'Stocks & Commodities', + tabs: DEFAULT_TABS, + isLoading: false, + defaultPillKey: 'stocks', + onViewAll: jest.fn(), + sortOptionId: 'volume', + tabName: 'Macro', + sectionName: 'perps_stocks_commodities', + headerTestID: 'section-header-view-all-test', + idPrefix: 'test', + testIdPrefix: 'test-toggle', + listTestId: 'test-list', +}; + +const renderBlock = (props = DEFAULT_PROPS) => + render(); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('PerpsToggleBlock', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders the section title', () => { + const { getByText } = renderBlock(); + expect(getByText('Stocks & Commodities')).toBeTruthy(); + }); + + it('renders items for the default pill', () => { + const { getByTestId, queryByTestId } = renderBlock(); + expect(getByTestId('perps-row-AAPL')).toBeTruthy(); + expect(getByTestId('perps-row-GOOGL')).toBeTruthy(); + expect(queryByTestId('perps-row-GOLD')).toBeNull(); + }); + + it('renders pill buttons for each tab', () => { + const { getByTestId } = renderBlock(); + expect(getByTestId('test-toggle-pill-stocks')).toBeTruthy(); + expect(getByTestId('test-toggle-pill-commodities')).toBeTruthy(); + }); + }); + + describe('loading state', () => { + it('shows skeletons while isLoading is true', () => { + const { getAllByTestId } = renderBlock({ + ...DEFAULT_PROPS, + isLoading: true, + }); + expect(getAllByTestId('perps-row-skeleton').length).toBeGreaterThan(0); + }); + }); + + describe('pill switching', () => { + it('shows the commodities items after selecting the commodities pill', () => { + const { getByTestId, queryByTestId } = renderBlock(); + + act(() => { + fireEvent.press(getByTestId('test-toggle-pill-commodities')); + }); + + expect(getByTestId('perps-row-GOLD')).toBeTruthy(); + expect(queryByTestId('perps-row-AAPL')).toBeNull(); + }); + + it('switching back to stocks shows stocks items again', () => { + const { getByTestId } = renderBlock(); + + act(() => { + fireEvent.press(getByTestId('test-toggle-pill-commodities')); + }); + act(() => { + fireEvent.press(getByTestId('test-toggle-pill-stocks')); + }); + + expect(getByTestId('perps-row-AAPL')).toBeTruthy(); + }); + }); + + describe('View All — sort and filter forwarding', () => { + it('calls onViewAll with the defaultPillKey and sortOptionId before any pill change', () => { + const onViewAll = jest.fn(); + const { getByTestId } = renderBlock({ ...DEFAULT_PROPS, onViewAll }); + + fireEvent.press(getByTestId('section-header-view-all-test')); + + expect(onViewAll).toHaveBeenCalledTimes(1); + expect(onViewAll).toHaveBeenCalledWith('stocks', 'volume'); + }); + + it('calls onViewAll with the newly active pill key after switching pills', () => { + const onViewAll = jest.fn(); + const { getByTestId } = renderBlock({ ...DEFAULT_PROPS, onViewAll }); + + act(() => { + fireEvent.press(getByTestId('test-toggle-pill-commodities')); + }); + + fireEvent.press(getByTestId('section-header-view-all-test')); + + expect(onViewAll).toHaveBeenCalledWith('commodities', 'volume'); + }); + + it('forwards the sortOptionId prop unchanged regardless of active pill', () => { + const onViewAll = jest.fn(); + const { getByTestId } = renderBlock({ + ...DEFAULT_PROPS, + sortOptionId: 'priceChange', + onViewAll, + }); + + act(() => { + fireEvent.press(getByTestId('test-toggle-pill-commodities')); + }); + fireEvent.press(getByTestId('section-header-view-all-test')); + + expect(onViewAll).toHaveBeenCalledWith('commodities', 'priceChange'); + }); + + it('calls onViewAll only once per press', () => { + const onViewAll = jest.fn(); + const { getByTestId } = renderBlock({ ...DEFAULT_PROPS, onViewAll }); + + fireEvent.press(getByTestId('section-header-view-all-test')); + fireEvent.press(getByTestId('section-header-view-all-test')); + + expect(onViewAll).toHaveBeenCalledTimes(2); + }); + }); + + describe('analytics', () => { + it('calls trackExploreInteracted with correct context when a row is pressed', () => { + const mockTrack = trackExploreInteracted as jest.Mock; + const { getByTestId } = renderBlock(); + + fireEvent.press(getByTestId('perps-row-AAPL')); + + expect(mockTrack).toHaveBeenCalledWith( + expect.objectContaining({ + interaction_type: 'section_item_tapped', + tab_name: 'Macro', + section_name: 'perps_stocks_commodities', + asset_type: 'perp', + item_clicked: 'AAPL', + }), + ); + }); + + it('includes the correct position index in analytics', () => { + const mockTrack = trackExploreInteracted as jest.Mock; + const { getByTestId } = renderBlock(); + + fireEvent.press(getByTestId('perps-row-GOOGL')); + + expect(mockTrack).toHaveBeenCalledWith( + expect.objectContaining({ + position: 1, + item_clicked: 'GOOGL', + }), + ); + }); + }); +}); diff --git a/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.tsx b/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.tsx index 048a00954c4..bed6dd344a3 100644 --- a/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.tsx +++ b/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useRef } from 'react'; import { Box } from '@metamask/design-system-react-native'; import type { ListRenderItem } from '@shopify/flash-list'; -import type { PerpsMarketData } from '@metamask/perps-controller'; +import type { PerpsMarketData, SortOptionId } from '@metamask/perps-controller'; import PerpsRowItem from './PerpsRowItem'; import PerpsRowSkeleton from '../../../../UI/Perps/components/PerpsRowSkeleton'; import PillToggleCardList, { @@ -21,7 +21,8 @@ export interface PerpsToggleBlockProps { tabs: PillToggleCardListTab[]; isLoading: boolean; defaultPillKey: string; - onViewAll: (filter: string) => void; + onViewAll: (filter: string, sortOptionId: SortOptionId) => void; + sortOptionId: SortOptionId; /** Analytics context */ tabName: ExploreTabName; sectionName: ExploreSectionName; @@ -42,6 +43,7 @@ const PerpsToggleBlock: React.FC = ({ isLoading, defaultPillKey, onViewAll, + sortOptionId, tabName, sectionName, headerTestID, @@ -74,7 +76,7 @@ const PerpsToggleBlock: React.FC = ({ onViewAll(activePillKey.current)} + onViewAll={() => onViewAll(activePillKey.current, sortOptionId)} testID={headerTestID} tabName={tabName} sectionName={sectionName} diff --git a/app/components/Views/TrendingView/feeds/perps/perpsNavigation.test.ts b/app/components/Views/TrendingView/feeds/perps/perpsNavigation.test.ts index 9694f999e8d..c1f0e39d246 100644 --- a/app/components/Views/TrendingView/feeds/perps/perpsNavigation.test.ts +++ b/app/components/Views/TrendingView/feeds/perps/perpsNavigation.test.ts @@ -39,4 +39,34 @@ describe('navigateToPerpsMarketList', () => { }), ); }); + + it('passes a custom sort option ID', () => { + const navigate = jest.fn(); + const navigation = { + navigate, + } as unknown as NavigationProp; + + navigateToPerpsMarketList(navigation, 'all', 'priceChange'); + + expect(navigate).toHaveBeenCalledWith( + Routes.PERPS.ROOT, + expect.objectContaining({ + params: expect.objectContaining({ + defaultSortOptionId: 'priceChange', + }), + }), + ); + }); + + it('does not include defaultSortOptionId when not provided', () => { + const navigate = jest.fn(); + const navigation = { + navigate, + } as unknown as NavigationProp; + + navigateToPerpsMarketList(navigation, 'crypto'); + + const callParams = navigate.mock.calls[0][1].params; + expect(callParams).not.toHaveProperty('defaultSortOptionId'); + }); }); diff --git a/app/components/Views/TrendingView/feeds/perps/perpsNavigation.ts b/app/components/Views/TrendingView/feeds/perps/perpsNavigation.ts index ccb0e226efd..df7fd5d64a2 100644 --- a/app/components/Views/TrendingView/feeds/perps/perpsNavigation.ts +++ b/app/components/Views/TrendingView/feeds/perps/perpsNavigation.ts @@ -1,18 +1,23 @@ import { NavigationProp } from '@react-navigation/native'; -import { PERPS_EVENT_VALUE } from '@metamask/perps-controller'; +import { + PERPS_EVENT_VALUE, + type SortOptionId, +} from '@metamask/perps-controller'; import type { PerpsNavigationParamList } from '../../../../UI/Perps/types/navigation'; import Routes from '../../../../../constants/navigation/Routes'; -/** Navigate to the perps market list, optionally pre-filtering by market type. */ +/** Navigate to the perps market list, optionally pre-filtering by market type and pre-sorting by a sort option. */ export const navigateToPerpsMarketList = ( navigation: NavigationProp, filter: string = 'all', + sortOptionId?: SortOptionId, ): void => { navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_LIST, params: { defaultMarketTypeFilter: filter, source: PERPS_EVENT_VALUE.SOURCE.EXPLORE, + ...(sortOptionId !== undefined && { defaultSortOptionId: sortOptionId }), }, }); }; diff --git a/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.test.ts b/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.test.ts new file mode 100644 index 00000000000..0c76f327dd6 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.test.ts @@ -0,0 +1,226 @@ +/** + * usePerpsFeed — unit tests + * + * Focuses on the sorting/ordering logic that lives inside the useMemo: + * 1. No-query path: items sorted by the variant's comparator. + * 2. Query path (non-macro): Fuse.js relevance order is preserved. + * 3. Query path (macro): sorted by volume even when a query is present. + * 4. defaultSortOptionId matches PERPS_VARIANT_SORT_OPTION for each variant. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import type { PerpsMarketData } from '@metamask/perps-controller'; +import { usePerpsFeed, PERPS_VARIANT_SORT_OPTION } from './usePerpsFeed'; + +// --------------------------------------------------------------------------- +// Core dependency mocks +// --------------------------------------------------------------------------- + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(() => []), +})); + +const mockMarkets: PerpsMarketData[] = []; +const mockRefetch = jest.fn(); + +jest.mock('../../../../UI/Perps/hooks', () => ({ + usePerpsMarkets: jest.fn(() => ({ + markets: mockMarkets, + isLoading: false, + refresh: mockRefetch, + isRefreshing: false, + })), +})); + +jest.mock('../../../../UI/Perps/providers/PerpsConnectionProvider', () => ({ + PerpsConnectionContext: { _currentValue: null }, +})); + +jest.mock('../../../../UI/Perps/selectors/perpsController', () => ({ + selectPerpsWatchlistMarkets: jest.fn(), +})); + +jest.mock( + '../../../Homepage/Sections/Perpetuals/hooks/useHomepageSparklines', + () => ({ + useHomepageSparklines: jest.fn(() => ({ sparklines: {} })), + }), +); + +jest.mock('../../hooks/useFeedRefresh', () => ({ + useFeedRefresh: jest.fn(), +})); + +// --------------------------------------------------------------------------- +// fuseSearch mock — controllable so we can verify order is preserved +// --------------------------------------------------------------------------- + +const mockFuseSearch = jest.fn(); +jest.mock('../search-utils', () => ({ + fuseSearch: (...args: unknown[]) => mockFuseSearch(...args), + PERPS_FUSE_OPTIONS: {}, +})); + +jest.mock('@metamask/perps-controller', () => ({ + filterMarketsByQuery: jest.fn((items: unknown[]) => items), +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +import { usePerpsMarkets } from '../../../../UI/Perps/hooks'; + +const makeMarket = ( + symbol: string, + change24hPercent: string, + volumeNumber: number, +): PerpsMarketData => + ({ + symbol, + name: symbol, + change24hPercent, + volumeNumber, + marketType: 'equity', + isHip3: false, + }) as unknown as PerpsMarketData; + +const renderFeed = (options: Parameters[0] = {}) => + renderHook(() => usePerpsFeed(options)); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('usePerpsFeed', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Default: fuseSearch returns items as-is + mockFuseSearch.mockImplementation((items: unknown[]) => items); + }); + + describe('no-query path', () => { + it('sorts all/crypto/rwa variants by 24h price change descending', () => { + const markets = [ + makeMarket('LOW', '1', 100), + makeMarket('HIGH', '5', 50), + makeMarket('MID', '3', 75), + ]; + (usePerpsMarkets as jest.Mock).mockReturnValue({ + markets, + isLoading: false, + refresh: mockRefetch, + isRefreshing: false, + }); + + for (const variant of ['all', 'crypto', 'rwa'] as const) { + const { result } = renderFeed({ variant }); + const symbols = result.current.data.map((d) => d.market.symbol); + expect(symbols).toEqual(['HIGH', 'MID', 'LOW']); + } + }); + + it('sorts macro variant by volume descending', () => { + const markets = [ + makeMarket('LOW_VOL', '5', 10), + makeMarket('HIGH_VOL', '1', 200), + makeMarket('MID_VOL', '3', 100), + ].map((m) => ({ ...m, marketType: 'equity' as const })); + + (usePerpsMarkets as jest.Mock).mockReturnValue({ + markets, + isLoading: false, + refresh: mockRefetch, + isRefreshing: false, + }); + + const { result } = renderFeed({ variant: 'macro' }); + const symbols = result.current.data.map((d) => d.market.symbol); + expect(symbols).toEqual(['HIGH_VOL', 'MID_VOL', 'LOW_VOL']); + }); + }); + + describe('query path', () => { + it('preserves Fuse.js relevance order for non-macro variants', () => { + const markets = [ + makeMarket('BTC', '1', 100), + makeMarket('ETH', '5', 50), + makeMarket('SOL', '3', 75), + ]; + (usePerpsMarkets as jest.Mock).mockReturnValue({ + markets, + isLoading: false, + refresh: mockRefetch, + isRefreshing: false, + }); + + // Fuse returns a specific relevance order (SOL first, then ETH, then BTC) + const fuseRelevanceOrder = [ + makeMarket('SOL', '3', 75), + makeMarket('ETH', '5', 50), + makeMarket('BTC', '1', 100), + ]; + mockFuseSearch.mockReturnValue(fuseRelevanceOrder); + + for (const variant of ['all', 'crypto', 'rwa'] as const) { + const { result } = renderFeed({ variant, query: 'S' }); + const symbols = result.current.data.map((d) => d.market.symbol); + // Must match fuse order, NOT sorted by price change (which would be ETH→SOL→BTC) + expect(symbols).toEqual(['SOL', 'ETH', 'BTC']); + } + }); + + it('sorts macro fuse results by volume, overriding relevance order', () => { + const markets = [ + makeMarket('AAPL', '1', 10), + makeMarket('MSFT', '5', 200), + makeMarket('NVDA', '3', 100), + ].map((m) => ({ ...m, marketType: 'equity' as const })); + + (usePerpsMarkets as jest.Mock).mockReturnValue({ + markets, + isLoading: false, + refresh: mockRefetch, + isRefreshing: false, + }); + + // Fuse returns relevance order: AAPL first + mockFuseSearch.mockReturnValue([ + markets[0], // AAPL — low volume, but top relevance match + markets[1], // MSFT + markets[2], // NVDA + ]); + + const { result } = renderFeed({ variant: 'macro', query: 'A' }); + const symbols = result.current.data.map((d) => d.market.symbol); + // Must be sorted by volume desc, NOT fuse order + expect(symbols).toEqual(['MSFT', 'NVDA', 'AAPL']); + }); + }); + + describe('defaultSortOptionId', () => { + it.each([ + ['all', 'priceChange'], + ['crypto', 'priceChange'], + ['rwa', 'priceChange'], + ['macro', 'volume'], + ] as const)( + 'returns "%s" for variant "%s"', + (variant, expectedSortOptionId) => { + (usePerpsMarkets as jest.Mock).mockReturnValue({ + markets: [], + isLoading: false, + refresh: mockRefetch, + isRefreshing: false, + }); + + const { result } = renderFeed({ variant }); + expect(result.current.defaultSortOptionId).toBe(expectedSortOptionId); + // Also verify it matches the canonical map + expect(result.current.defaultSortOptionId).toBe( + PERPS_VARIANT_SORT_OPTION[variant], + ); + }, + ); + }); +}); diff --git a/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.ts b/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.ts index a986016d6cf..d1422a5ff6d 100644 --- a/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.ts +++ b/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.ts @@ -3,6 +3,7 @@ import { useSelector } from 'react-redux'; import { filterMarketsByQuery, type PerpsMarketData, + type SortOptionId, } from '@metamask/perps-controller'; import { usePerpsMarkets } from '../../../../UI/Perps/hooks'; import type { PerpsMarketDataWithVolumeNumber } from '../../../../UI/Perps/hooks/usePerpsMarkets'; @@ -41,8 +42,22 @@ export interface UsePerpsFeedResult { data: PerpsFeedItem[]; isLoading: boolean; refetch: () => Promise; + /** The sort option ID that matches this feed's sort order — pass to `navigateToPerpsMarketList` so the market list opens consistently sorted. */ + defaultSortOptionId: SortOptionId; } +/** + * Maps each feed variant to the sort option ID it uses when displaying items. + * This is the single source of truth — both the feed's internal sort and the + * "View All" navigation use this mapping so they stay in sync. + */ +export const PERPS_VARIANT_SORT_OPTION: Record = { + all: 'priceChange', + crypto: 'priceChange', + rwa: 'priceChange', + macro: 'volume', +}; + const sortByVolumeDesc = (a: PerpsMarketData, b: PerpsMarketData) => { const av = (a as PerpsMarketDataWithVolumeNumber).volumeNumber ?? 0; const bv = (b as PerpsMarketDataWithVolumeNumber).volumeNumber ?? 0; @@ -52,6 +67,17 @@ const sortByVolumeDesc = (a: PerpsMarketData, b: PerpsMarketData) => { const sortByChange24hDesc = (a: PerpsMarketData, b: PerpsMarketData) => (parseFloat(b.change24hPercent) || 0) - (parseFloat(a.change24hPercent) || 0); +/** Maps each SortOptionId to the comparator used inside the feed. */ +const SORT_FNS: Record< + SortOptionId, + (a: PerpsMarketData, b: PerpsMarketData) => number +> = { + volume: sortByVolumeDesc, + priceChange: sortByChange24hDesc, + openInterest: sortByVolumeDesc, + fundingRate: sortByVolumeDesc, +}; + const filterByVariant = ( markets: PerpsMarketData[], variant: PerpsVariant, @@ -102,14 +128,16 @@ export const usePerpsFeed = ({ const filtered = useMemo(() => { if (connectionContext?.error) return []; const subset = filterByVariant(markets, variant); + const sortFn = SORT_FNS[PERPS_VARIANT_SORT_OPTION[variant]]; if (!query) { - return [...subset].sort( - variant === 'macro' ? sortByVolumeDesc : sortByChange24hDesc, - ); + return [...subset].sort(sortFn); } const queryFiltered = filterMarketsByQuery(subset, query); const fused = fuseSearch(queryFiltered, query, PERPS_FUSE_OPTIONS); - return variant === 'macro' ? [...fused].sort(sortByVolumeDesc) : fused; + // Preserve Fuse.js relevance ordering for variants that sort by price change + // (the relevance signal is more useful than a metric sort during search). + // Macro sorts by volume even in search results, consistent with its feed order. + return variant === 'macro' ? [...fused].sort(sortFn) : fused; }, [connectionContext?.error, markets, variant, query]); // Only visible carousel tiles need candle sparklines; each symbol is a stream @@ -141,5 +169,6 @@ export const usePerpsFeed = ({ data, isLoading: connectionContext?.error ? false : isLoading || isRefreshing, refetch, + defaultSortOptionId: PERPS_VARIANT_SORT_OPTION[variant], }; }; diff --git a/app/components/Views/TrendingView/feeds/stocks/useStocksFeed.test.ts b/app/components/Views/TrendingView/feeds/stocks/useStocksFeed.test.ts new file mode 100644 index 00000000000..9c4bc9c15c2 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/stocks/useStocksFeed.test.ts @@ -0,0 +1,100 @@ +import { renderHook } from '@testing-library/react-native'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import { useRwaTokens } from '../../../../UI/Trending/hooks/useRwaTokens/useRwaTokens'; +import { useStocksFeed } from './useStocksFeed'; + +jest.mock('../../../../UI/Trending/hooks/useRwaTokens/useRwaTokens', () => ({ + useRwaTokens: jest.fn(), +})); + +const mockUseRwaTokens = jest.mocked(useRwaTokens); +const mockRefetch = jest.fn(); + +const makeAsset = (assetId: string, symbol: string): TrendingAsset => + ({ + assetId, + symbol, + name: symbol, + }) as unknown as TrendingAsset; + +const ETH_OUSG = makeAsset('eip155:1/erc20:0xaaa', 'OUSG'); +const ETH_BUIDL = makeAsset('eip155:1/erc20:0xbbb', 'BUIDL'); +const BNB_OUSG = makeAsset('eip155:56/erc20:0xccc', 'bOUSG'); + +const ALL_RWA_ASSETS = [ETH_OUSG, ETH_BUIDL, BNB_OUSG]; + +const arrangeRwaTokens = (assets = ALL_RWA_ASSETS) => { + mockUseRwaTokens.mockReturnValue({ + data: assets, + isLoading: false, + refetch: mockRefetch, + }); +}; + +describe('useStocksFeed', () => { + beforeEach(() => { + jest.clearAllMocks(); + arrangeRwaTokens(); + }); + + describe('no-query path (tab sections)', () => { + it('filters to Ethereum-only assets', () => { + const { result } = renderHook(() => useStocksFeed()); + const symbols = result.current.data.map((d) => d.symbol); + expect(symbols).toEqual(['OUSG', 'BUIDL']); + expect(symbols).not.toContain('bOUSG'); + }); + + it('passes undefined searchQuery to useRwaTokens', () => { + renderHook(() => useStocksFeed()); + expect(mockUseRwaTokens).toHaveBeenCalledWith( + expect.objectContaining({ searchQuery: undefined }), + ); + }); + }); + + describe('query path (omni-search)', () => { + it('includes tokens from all RWA chains, not just Ethereum', () => { + const { result } = renderHook(() => useStocksFeed({ query: 'OUSG' })); + const symbols = result.current.data.map((d) => d.symbol); + expect(symbols).toContain('OUSG'); + expect(symbols).toContain('bOUSG'); + }); + + it('does not filter out BNB tokens when a query is present', () => { + const { result } = renderHook(() => useStocksFeed({ query: 'token' })); + expect(result.current.data).toHaveLength(ALL_RWA_ASSETS.length); + }); + + it('passes the query through to useRwaTokens as searchQuery', () => { + renderHook(() => useStocksFeed({ query: 'OUSG' })); + expect(mockUseRwaTokens).toHaveBeenCalledWith( + expect.objectContaining({ searchQuery: 'OUSG' }), + ); + }); + + it('treats a whitespace-only query the same as no query (Ethereum-only)', () => { + const { result } = renderHook(() => useStocksFeed({ query: ' ' })); + const symbols = result.current.data.map((d) => d.symbol); + expect(symbols).toEqual(['OUSG', 'BUIDL']); + expect(symbols).not.toContain('bOUSG'); + }); + }); + + describe('loading and refetch passthrough', () => { + it('forwards isLoading from useRwaTokens', () => { + mockUseRwaTokens.mockReturnValue({ + data: [], + isLoading: true, + refetch: mockRefetch, + }); + const { result } = renderHook(() => useStocksFeed()); + expect(result.current.isLoading).toBe(true); + }); + + it('forwards refetch from useRwaTokens', () => { + const { result } = renderHook(() => useStocksFeed()); + expect(result.current.refetch).toBe(mockRefetch); + }); + }); +}); diff --git a/app/components/Views/TrendingView/feeds/stocks/useStocksFeed.ts b/app/components/Views/TrendingView/feeds/stocks/useStocksFeed.ts index b042c86810d..73a6b7fc408 100644 --- a/app/components/Views/TrendingView/feeds/stocks/useStocksFeed.ts +++ b/app/components/Views/TrendingView/feeds/stocks/useStocksFeed.ts @@ -17,7 +17,18 @@ export interface UseStocksFeedResult { refetch: () => Promise; } -/** Tokenized stocks (RWAs). Only Ethereum mainnet tokens are shown in the section. */ +/** + * Tokenized stocks (RWAs) feed. + * + * Tab sections (no query): only Ethereum mainnet tokens are shown, matching + * the design intent of the RWAs/Now tab. + * + * Search (query present): all chains in RWA_CHAIN_IDS are included so users + * can find stocks across Ethereum and BNB. + * + * Chain filtering is done client-side (not in the request) to share the same + * server-side cache across all surfaces. + */ export const useStocksFeed = ({ query, refresh, @@ -26,15 +37,17 @@ export const useStocksFeed = ({ searchQuery: query, }); - // Keep mainnet filtering here (not in the request) so all surfaces share the same - // RWA cache (server-side); chain-specific params would split the cache and diverge from the main feed. - const ethereumData = useMemo( - () => - data.filter((asset) => asset.assetId.startsWith(ETHEREUM_CAIP_CHAIN_ID)), - [data], - ); + const filteredData = useMemo(() => { + // During search, surface tokens from all supported RWA chains so the user + // can find any matching stock regardless of chain. + if (query?.trim()) return data; + // Tab sections only show Ethereum mainnet tokens. + return data.filter((asset) => + asset.assetId.startsWith(ETHEREUM_CAIP_CHAIN_ID), + ); + }, [data, query]); useFeedRefresh(refresh, refetch); - return { data: ethereumData, isLoading, refetch }; + return { data: filteredData, isLoading, refetch }; }; diff --git a/app/components/Views/TrendingView/tabs/CryptoTab.tsx b/app/components/Views/TrendingView/tabs/CryptoTab.tsx index 7a5213d96f4..9557b4579b0 100644 --- a/app/components/Views/TrendingView/tabs/CryptoTab.tsx +++ b/app/components/Views/TrendingView/tabs/CryptoTab.tsx @@ -16,6 +16,7 @@ import { getCaipChainIdFromAssetId } from '../../../UI/Trending/components/Trend import { TokenRowItem } from '../feeds/tokens/TokenRowItem'; import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; import { usePerpsFeed, type PerpsFeedItem } from '../feeds/perps/usePerpsFeed'; +import type { SortOptionId } from '@metamask/perps-controller'; import PerpsSectionProvider from '../feeds/perps/PerpsSectionProvider'; import PerpsTileRowItem from '../feeds/perps/PerpsTileRowItem'; import PerpsMarketTileCardSkeleton from '../../Homepage/Sections/Perpetuals/components/PerpsMarketTileCardSkeleton'; @@ -34,7 +35,7 @@ import { trackExploreInteracted } from '../search/analytics'; interface CryptoPerpsBlockProps { refresh: TabProps['refresh']; - onViewAll: () => void; + onViewAll: (sortOptionId: SortOptionId) => void; } const CryptoPerpsBlock: React.FC = ({ @@ -53,7 +54,7 @@ const CryptoPerpsBlock: React.FC = ({ onViewAll(perps.defaultSortOptionId)} testID="section-header-view-all-crypto_perps" tabName="Crypto" sectionName="perps_crypto" @@ -79,7 +80,7 @@ const CryptoPerpsBlock: React.FC = ({ )} keyExtractor={(item) => item.market.symbol} Skeleton={PerpsMarketTileCardSkeleton} - onViewMore={onViewAll} + onViewMore={() => onViewAll(perps.defaultSortOptionId)} testID="explore-crypto_perps-carousel" viewMoreTestID="crypto_perps-view-more-card" /> @@ -181,8 +182,8 @@ const CryptoTab: React.FC = ({ refresh, refreshing, onRefresh }) => { - navigateToPerpsMarketList(perpsNavigation, 'crypto') + onViewAll={(sortOptionId) => + navigateToPerpsMarketList(perpsNavigation, 'crypto', sortOptionId) } /> diff --git a/app/components/Views/TrendingView/tabs/MacroTab.tsx b/app/components/Views/TrendingView/tabs/MacroTab.tsx index e20a30ee710..471f5aee9e5 100644 --- a/app/components/Views/TrendingView/tabs/MacroTab.tsx +++ b/app/components/Views/TrendingView/tabs/MacroTab.tsx @@ -4,7 +4,7 @@ import { useSelector } from 'react-redux'; import { Box } from '@metamask/design-system-react-native'; import type { ListRenderItem } from '@shopify/flash-list'; import type { PredictMarket as PredictMarketType } from '../../../UI/Predict/types'; -import type { PerpsMarketData } from '@metamask/perps-controller'; +import type { PerpsMarketData, SortOptionId } from '@metamask/perps-controller'; import type { PerpsNavigationParamList } from '../../../UI/Perps/types/navigation'; import type { AppNavigationProp } from '../../../../core/NavigationService/types'; import { selectPerpsEnabledFlag } from '../../../UI/Perps'; @@ -27,7 +27,7 @@ import { trackExploreInteracted } from '../search/analytics'; interface MacroPerpsBlockProps { refresh: TabProps['refresh']; - onViewAll: (filter: string) => void; + onViewAll: (filter: string, sortOptionId: SortOptionId) => void; } const MacroPerpsBlock: React.FC = ({ @@ -65,6 +65,7 @@ const MacroPerpsBlock: React.FC = ({ isLoading={perps.isLoading} defaultPillKey="stocks" onViewAll={onViewAll} + sortOptionId={perps.defaultSortOptionId} tabName="Macro" sectionName="perps_stocks_commodities" headerTestID="section-header-view-all-macro_stocks_commodity_perps" @@ -142,8 +143,8 @@ const MacroTab: React.FC = ({ refresh, refreshing, onRefresh }) => { - navigateToPerpsMarketList(perpsNavigation, filter) + onViewAll={(filter, sortOptionId) => + navigateToPerpsMarketList(perpsNavigation, filter, sortOptionId) } /> diff --git a/app/components/Views/TrendingView/tabs/NowTab.test.tsx b/app/components/Views/TrendingView/tabs/NowTab.test.tsx index 245db97ea27..35927344c0c 100644 --- a/app/components/Views/TrendingView/tabs/NowTab.test.tsx +++ b/app/components/Views/TrendingView/tabs/NowTab.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen } from '@testing-library/react-native'; +import { render, screen, fireEvent } from '@testing-library/react-native'; import { NavigationContainer } from '@react-navigation/native'; const mockNavigate = jest.fn(); @@ -19,8 +19,24 @@ jest.mock('../feeds/tokens/useTokensFeed', () => ({ useTokensFeed: jest.fn(() => ({ data: [], isLoading: false })), })); +const mockUsePerpsFeed = jest.fn(() => ({ + data: [], + isLoading: false, + refetch: jest.fn(), + defaultSortOptionId: 'priceChange' as const, +})); + jest.mock('../feeds/perps/usePerpsFeed', () => ({ - usePerpsFeed: jest.fn(() => ({ data: [], isLoading: false })), + usePerpsFeed: () => mockUsePerpsFeed(), +})); + +const mockNavigateToPerpsMarketList = jest.fn(); +jest.mock('../feeds/perps/perpsNavigation', () => ({ + navigateToPerpsMarketList: ( + nav: unknown, + filter: unknown, + sortOptionId: unknown, + ) => mockNavigateToPerpsMarketList(nav, filter, sortOptionId), })); jest.mock('../feeds/predictions/usePredictionsFeed', () => ({ @@ -145,3 +161,55 @@ describe('NowTab — WhatsHappeningSection integration', () => { expect(forwardedRef).not.toBeNull(); }); }); + +describe('NowTab — Perps Movers "View All" navigation', () => { + const mockUseSelector = useSelector as jest.MockedFunction< + typeof useSelector + >; + + // Selector base: perps enabled, everything else off. + const mockSelectorBase = (selector: unknown) => { + if (selector === selectPerpsEnabledFlag) return true; + if (selector === selectPredictEnabledFlag) return false; + return undefined; + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockImplementation(mockSelectorBase); + mockWhatsHappeningImpl.mockReturnValue(null); + }); + + it('calls navigateToPerpsMarketList with "all" filter and the defaultSortOptionId from usePerpsFeed', () => { + // Return one market so PerpsBlock does not bail out with an early null return. + mockUsePerpsFeed.mockReturnValue({ + data: [{ market: { symbol: 'BTC' } }] as never, + isLoading: false, + refetch: jest.fn(), + defaultSortOptionId: 'priceChange' as const, + }); + + renderNowTab(); + + fireEvent.press(screen.getByTestId('section-header-view-all-perps')); + + expect(mockNavigateToPerpsMarketList).toHaveBeenCalledTimes(1); + expect(mockNavigateToPerpsMarketList).toHaveBeenCalledWith( + expect.anything(), // navigation object + 'all', + 'priceChange', + ); + }); + + it('does not render the Perps Movers section when the perps flag is disabled', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPerpsEnabledFlag) return false; + if (selector === selectPredictEnabledFlag) return false; + return undefined; + }); + + renderNowTab(); + + expect(screen.queryByTestId('section-header-view-all-perps')).toBeNull(); + }); +}); diff --git a/app/components/Views/TrendingView/tabs/NowTab.tsx b/app/components/Views/TrendingView/tabs/NowTab.tsx index 27cad000f23..5369fae39f3 100644 --- a/app/components/Views/TrendingView/tabs/NowTab.tsx +++ b/app/components/Views/TrendingView/tabs/NowTab.tsx @@ -57,7 +57,13 @@ const PerpsBlock: React.FC = ({ refresh, navigation }) => { navigateToPerpsMarketList(navigation)} + onViewAll={() => + navigateToPerpsMarketList( + navigation, + 'all', + perps.defaultSortOptionId, + ) + } testID="section-header-view-all-perps" tabName="Now" sectionName="perps_movers" diff --git a/app/components/Views/TrendingView/tabs/RwasTab.tsx b/app/components/Views/TrendingView/tabs/RwasTab.tsx index 6cd85c8e373..8b9ed6cadeb 100644 --- a/app/components/Views/TrendingView/tabs/RwasTab.tsx +++ b/app/components/Views/TrendingView/tabs/RwasTab.tsx @@ -4,7 +4,7 @@ import { useSelector } from 'react-redux'; import { Box } from '@metamask/design-system-react-native'; import type { ListRenderItem } from '@shopify/flash-list'; import type { TrendingAsset } from '@metamask/assets-controllers'; -import type { PerpsMarketData } from '@metamask/perps-controller'; +import type { PerpsMarketData, SortOptionId } from '@metamask/perps-controller'; import type { PerpsNavigationParamList } from '../../../UI/Perps/types/navigation'; import type { PredictMarket as PredictMarketType } from '../../../UI/Predict/types'; import type { AppNavigationProp } from '../../../../core/NavigationService/types'; @@ -35,7 +35,7 @@ import { trackExploreInteracted } from '../search/analytics'; interface RwaPerpsBlockProps { refresh: TabProps['refresh']; - onViewAll: (filter: string) => void; + onViewAll: (filter: string, sortOptionId: SortOptionId) => void; } const RwaPerpsBlock: React.FC = ({ @@ -78,6 +78,7 @@ const RwaPerpsBlock: React.FC = ({ isLoading={perps.isLoading} defaultPillKey="commodities" onViewAll={onViewAll} + sortOptionId={perps.defaultSortOptionId} tabName="RWAs" sectionName="perps_markets" headerTestID="section-header-view-all-rwa_perps" @@ -201,8 +202,8 @@ const RwasTab: React.FC = ({ refresh, refreshing, onRefresh }) => { - navigateToPerpsMarketList(perpsNavigation, filter) + onViewAll={(filter, sortOptionId) => + navigateToPerpsMarketList(perpsNavigation, filter, sortOptionId) } />