From cc8b52e32f79290280d149fdf8b3d75320fe0a4b Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Fri, 28 Nov 2025 00:36:27 +0100 Subject: [PATCH 1/2] chore: [Trending] bug fixes and improvements (#23359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Some bug fixes on Trending plus some improvements: Improvements: - Search results were not the same when searching on omnisearch and when searching on each of the pages separately, I have moved the search to each of the hooks so that search is handled there (also removes the need of getSearchQuery from the sectionsConfig) - Removed the need for a keyExtractor in the sectionsConfig - Homogenized and reworked Skeletons so that the experience as a user is better Bug fixes: - Clicking on a native asset within trending throws error and makes the app restart ## **Changelog** CHANGELOG entry: trending bug fixes and improvements ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-1837 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Moves search filtering into section hooks, standardizes skeleton UIs, and fixes navigation for native tokens from Trending. > > - **Search architecture**: > - Move filtering into hooks: `useTrendingSearch`, `useSitesData(searchQuery, limit)`, `usePerpsSearch`, and sections via `useSectionsData(searchQuery)`; remove per-section `getSearchableText`/key extractors. > - `useTrendingSearch`: filters trending by query, merges with search API, dedupes; exposes loading; tests added. > - `useSearchRequest`: `chainIds` optional; debounced explore search uses section-provided data. > - **Perps**: > - Add `filterMarketsByQuery` in `marketUtils` (+ tests) and use in `usePerpsSearch` and sections. > - **Sites**: > - `useSitesData` now returns all sites and filters locally by `searchQuery`; API fetch unchanged; tests updated. > - `SitesFullView` passes `searchQuery` to hook; removes local filtering. > - **Trending tokens**: > - `TrendingTokenRowItem`: refactor badge/source helpers; add native-token nav support (`isNative`/`isETH`) and memoization; color helper for pct change; tests for ETH/MATIC/native cases. > - `TrendingTokensFullView`: uses unified search results; updates skeleton rendering. > - **Explore search & sections**: > - `useExploreSearch`: show top items on empty query; delegate filtering to sections; improved loading/debounce handling. > - Switch list `keyExtractor`s to stable index-based keys per section. > - `SECTIONS_CONFIG`: pass `searchQuery` to hooks; unify Perps skeleton. > - **UI/Skeletons**: > - Rework `TrendingTokensSkeleton` and `SiteSkeleton` to consistent 44px circular icon + text rows; tests adjusted. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 43099a7133eb4675bde1706571483c6cd778c076. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Perps/hooks/usePerpsSearch.ts | 9 +- .../UI/Perps/utils/marketUtils.test.ts | 69 +++++ app/components/UI/Perps/utils/marketUtils.ts | 31 +++ .../SiteSkeleton/SiteSkeleton.test.tsx | 16 +- .../components/SiteSkeleton/SiteSkeleton.tsx | 54 ++-- ...eSiteData.test.ts => useSitesData.test.ts} | 11 +- .../Sites/hooks/useSiteData/useSitesData.ts | 32 ++- .../TrendingTokenRowItem.test.tsx | 116 ++++++++ .../TrendingTokenRowItem.tsx | 241 ++++++++--------- .../TrendingTokensSkeleton.test.tsx | 78 ++++-- .../TrendingTokensSkeleton.tsx | 83 ++---- .../useSearchRequest/useSearchRequest.ts | 4 +- .../useTrendingSearch.test.ts | 89 ++++++- .../useTrendingSearch/useTrendingSearch.ts | 13 +- .../SitesFullView/SitesFullView.test.tsx | 42 +-- .../Views/SitesFullView/SitesFullView.tsx | 19 +- .../TrendingTokensFullView.test.tsx | 12 +- .../TrendingTokensFullView.tsx | 38 +-- .../ExploreSearchResults.tsx | 2 +- .../config/useExploreSearch.test.ts | 248 +++++------------- .../config/useExploreSearch.ts | 5 +- .../components/SectionCard/SectionCard.tsx | 2 +- .../SectionCarrousel/SectionCarrousel.tsx | 2 +- .../TrendingView/config/sections.config.tsx | 34 +-- 24 files changed, 715 insertions(+), 535 deletions(-) rename app/components/UI/Sites/hooks/useSiteData/{useSiteData.test.ts => useSitesData.test.ts} (97%) diff --git a/app/components/UI/Perps/hooks/usePerpsSearch.ts b/app/components/UI/Perps/hooks/usePerpsSearch.ts index f28da065eb7..10955a7a260 100644 --- a/app/components/UI/Perps/hooks/usePerpsSearch.ts +++ b/app/components/UI/Perps/hooks/usePerpsSearch.ts @@ -1,5 +1,6 @@ import { useState, useCallback, useMemo } from 'react'; import type { PerpsMarketData } from '../controllers/types'; +import { filterMarketsByQuery } from '../utils/marketUtils'; interface UsePerpsSearchParams { /** @@ -87,13 +88,7 @@ export const usePerpsSearch = ({ return markets; } - const lowerQuery = searchQuery.toLowerCase().trim(); - - return markets.filter( - (market) => - market.symbol?.toLowerCase().includes(lowerQuery) || - market.name?.toLowerCase().includes(lowerQuery), - ); + return filterMarketsByQuery(markets, searchQuery); }, [markets, searchQuery, isSearchVisible]); return { diff --git a/app/components/UI/Perps/utils/marketUtils.test.ts b/app/components/UI/Perps/utils/marketUtils.test.ts index 067509f7392..6d46076c0da 100644 --- a/app/components/UI/Perps/utils/marketUtils.test.ts +++ b/app/components/UI/Perps/utils/marketUtils.test.ts @@ -8,8 +8,10 @@ import { shouldIncludeMarket, validateMarketPattern, getPerpsDisplaySymbol, + filterMarketsByQuery, } from './marketUtils'; import type { CandleData } from '../types/perps-types'; +import type { PerpsMarketData } from '../controllers/types'; import { CandlePeriod } from '../constants/chartConfig'; jest.mock('../constants/hyperLiquidConfig', () => ({ @@ -916,4 +918,71 @@ describe('marketUtils', () => { }); }); }); + + describe('filterMarketsByQuery', () => { + const mockMarkets: Partial[] = [ + { symbol: 'BTC', name: 'Bitcoin' }, + { symbol: 'ETH', name: 'Ethereum' }, + { symbol: 'xyz:TSLA', name: 'Tesla Stock' }, + ]; + + it('returns all markets when query is empty or whitespace', () => { + const result1 = filterMarketsByQuery( + mockMarkets as PerpsMarketData[], + '', + ); + const result2 = filterMarketsByQuery( + mockMarkets as PerpsMarketData[], + ' ', + ); + + expect(result1).toEqual(mockMarkets); + expect(result2).toEqual(mockMarkets); + }); + + it('filters markets by symbol case-insensitively', () => { + const result = filterMarketsByQuery( + mockMarkets as PerpsMarketData[], + 'btc', + ); + + expect(result).toEqual([mockMarkets[0]]); + }); + + it('filters markets by name case-insensitively', () => { + const result = filterMarketsByQuery( + mockMarkets as PerpsMarketData[], + 'ethereum', + ); + + expect(result).toEqual([mockMarkets[1]]); + }); + + it('filters markets by partial matches in symbol or name', () => { + const result = filterMarketsByQuery( + mockMarkets as PerpsMarketData[], + 'Stock', + ); + + expect(result).toEqual([mockMarkets[2]]); + }); + + it('returns empty array when no markets match query', () => { + const result = filterMarketsByQuery( + mockMarkets as PerpsMarketData[], + 'NonExistent', + ); + + expect(result).toEqual([]); + }); + + it('trims whitespace from query before matching', () => { + const result = filterMarketsByQuery( + mockMarkets as PerpsMarketData[], + ' BTC ', + ); + + expect(result).toEqual([mockMarkets[0]]); + }); + }); }); diff --git a/app/components/UI/Perps/utils/marketUtils.ts b/app/components/UI/Perps/utils/marketUtils.ts index 66c5a05f1b6..57256ae5643 100644 --- a/app/components/UI/Perps/utils/marketUtils.ts +++ b/app/components/UI/Perps/utils/marketUtils.ts @@ -392,6 +392,37 @@ export const getMarketBadgeType = ( // Main DEX markets without marketType show no badge market.marketType || (market.marketSource ? 'experimental' : undefined); +/** + * Filter markets by search query + * Searches through both symbol and name fields (case-insensitive) + * + * @param markets - Array of markets to filter + * @param searchQuery - Search query string + * @returns Filtered array of markets matching the query + * + * @example + * filterMarketsByQuery([{ symbol: 'BTC', name: 'Bitcoin' }], 'btc') // → [{ symbol: 'BTC', name: 'Bitcoin' }] + * filterMarketsByQuery([{ symbol: 'BTC', name: 'Bitcoin' }], 'coin') // → [{ symbol: 'BTC', name: 'Bitcoin' }] + * filterMarketsByQuery([{ symbol: 'BTC', name: 'Bitcoin' }], '') // → [{ symbol: 'BTC', name: 'Bitcoin' }] + */ +export const filterMarketsByQuery = ( + markets: PerpsMarketData[], + searchQuery: string, +): PerpsMarketData[] => { + // Return all markets if query is empty + if (!searchQuery?.trim()) { + return markets; + } + + const lowerQuery = searchQuery.toLowerCase().trim(); + + return markets.filter( + (market) => + market.symbol?.toLowerCase().includes(lowerQuery) || + market.name?.toLowerCase().includes(lowerQuery), + ); +}; + /** * Generate the appropriate icon URL for an asset symbol * Handles both regular assets and HIP-3 assets (dex:symbol format) diff --git a/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.test.tsx b/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.test.tsx index 7a418ceb535..3149960421d 100644 --- a/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.test.tsx +++ b/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.test.tsx @@ -44,10 +44,10 @@ describe('SiteSkeleton', () => { const skeletons = getAllByTestId('skeleton'); const logoSkeleton = skeletons[0]; - expect(logoSkeleton.props.style).toContainEqual({ - height: 40, - width: 40, - }); + expect(logoSkeleton.props.style).toEqual([ + { height: 44, width: 44 }, + { borderRadius: 100 }, + ]); }); it('renders name skeleton with correct dimensions', () => { @@ -68,10 +68,10 @@ describe('SiteSkeleton', () => { const skeletons = getAllByTestId('skeleton'); const urlSkeleton = skeletons[2]; - expect(urlSkeleton.props.style).toContainEqual({ - height: 16, - width: '40%', - }); + expect(urlSkeleton.props.style).toEqual([ + { height: 18, width: '80%' }, + { marginBottom: 0, marginTop: 2 }, + ]); }); }); }); diff --git a/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.tsx b/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.tsx index 130d3a81722..9abc415b43f 100644 --- a/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.tsx +++ b/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.tsx @@ -1,40 +1,56 @@ import React from 'react'; -import { View, StyleSheet } from 'react-native'; +import { View, StyleSheet, type ViewStyle } from 'react-native'; import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; const styles = StyleSheet.create({ container: { + display: 'flex', flexDirection: 'row', - alignItems: 'center', - paddingVertical: 16, + alignItems: 'flex-start', + alignSelf: 'stretch', + paddingTop: 8, + paddingBottom: 8, }, iconSkeleton: { - borderRadius: 20, - marginBottom: 0, + borderRadius: 100, // Fully circular }, - contentContainer: { + leftContainer: { + paddingLeft: 16, flex: 1, - marginLeft: 16, + justifyContent: 'space-between', + }, + tokenHeaderRow: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 4, }, - nameSkeleton: { - marginBottom: 8, + tokenNameSkeleton: { + marginBottom: 0, }, - urlSkeleton: { + marketStatsSkeleton: { + marginTop: 2, marginBottom: 0, }, }); -const SiteSkeleton = () => ( +const iconSize = 44; +const SitesSkeleton: React.FC = () => ( - {/* Logo skeleton */} - - - {/* Content skeleton */} - - - + + + + + + + + ); -export default SiteSkeleton; +export default SitesSkeleton; diff --git a/app/components/UI/Sites/hooks/useSiteData/useSiteData.test.ts b/app/components/UI/Sites/hooks/useSiteData/useSitesData.test.ts similarity index 97% rename from app/components/UI/Sites/hooks/useSiteData/useSiteData.test.ts rename to app/components/UI/Sites/hooks/useSiteData/useSitesData.test.ts index e6c1fc7e160..428dd6477db 100644 --- a/app/components/UI/Sites/hooks/useSiteData/useSiteData.test.ts +++ b/app/components/UI/Sites/hooks/useSiteData/useSitesData.test.ts @@ -96,7 +96,7 @@ describe('useSitesData', () => { json: async () => ({ dapps: [] }), }); - renderHook(() => useSitesData({ limit: 50 })); + renderHook(() => useSitesData(undefined, 50)); await waitFor(() => { expect(fetch).toHaveBeenCalledWith( @@ -280,9 +280,12 @@ describe('useSitesData', () => { json: async () => ({ dapps: [] }), }); - const { rerender } = renderHook(({ limit }) => useSitesData({ limit }), { - initialProps: { limit: 10 }, - }); + const { rerender } = renderHook( + ({ limit }) => useSitesData(undefined, limit), + { + initialProps: { limit: 10 }, + }, + ); await waitFor(() => { expect(fetch).toHaveBeenCalledTimes(1); diff --git a/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts b/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts index 5b2372f4946..707250ce478 100644 --- a/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts +++ b/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useMemo } from 'react'; import Logger from '../../../../../util/Logger'; import type { SiteData } from '../../components/SiteRowItem/SiteRowItem'; @@ -21,10 +21,6 @@ interface ApiSitesResponse { dapps: ApiDappResponse[]; } -interface UseSitesDataParams { - limit?: number; -} - interface UseSitesDataResult { sites: SiteData[]; isLoading: boolean; @@ -51,10 +47,11 @@ const extractDisplayUrl = (url: string): string => { * @param params - Parameters for the API request * @returns Sites data, loading state, and error */ -export const useSitesData = ({ +export const useSitesData = ( + searchQuery?: string, limit = 100, -}: UseSitesDataParams = {}): UseSitesDataResult => { - const [sites, setSites] = useState([]); +): UseSitesDataResult => { + const [allSites, setAllSites] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -84,13 +81,13 @@ export const useSitesData = ({ featured: dapp.featured, })); - setSites(transformedSites); + setAllSites(transformedSites); } catch (err) { const fetchError = err instanceof Error ? err : new Error(String(err)); Logger.error(fetchError, '[useSitesData] Error fetching sites'); setError(fetchError); // Don't use fallback data - return empty array to show the error - setSites([]); + setAllSites([]); } finally { setIsLoading(false); } @@ -104,5 +101,20 @@ export const useSitesData = ({ fetchSites(); }, [fetchSites]); + // Filter sites locally based on search query + const sites = useMemo(() => { + if (!searchQuery?.trim()) { + return allSites; + } + + const query = searchQuery.toLowerCase().trim(); + return allSites.filter( + (site) => + site.name.toLowerCase().includes(query) || + site.displayUrl.toLowerCase().includes(query) || + site.url.toLowerCase().includes(query), + ); + }, [allSites, searchQuery]); + return { sites, isLoading, error, refetch }; }; diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx index 12500ebddc1..8fb3602b3ff 100644 --- a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx +++ b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx @@ -563,6 +563,122 @@ describe('TrendingTokenRowItem', () => { image: 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', pricePercentChange1d: 3.44, + isNative: false, + isETH: false, + }); + }); + + it('navigates to Asset page with isETH true for native ETH on Ethereum mainnet', () => { + const token = createMockToken({ + assetId: 'eip155:1/slip44:60', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + }); + + const networkAddedState = { + ...mockState, + engine: { + ...mockState.engine, + backgroundState: { + ...mockState.engine.backgroundState, + NetworkController: { + networkConfigurations: {}, + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + caipChainId: 'eip155:1', + name: 'Ethereum Mainnet', + }, + }, + }, + MultichainNetworkController: { + ...mockState.engine.backgroundState.MultichainNetworkController, + multichainNetworkConfigurationsByChainId: {}, + }, + }, + }, + }; + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/slip44:60', + ); + fireEvent.press(tokenRow); + + expect(mockNavigate).toHaveBeenCalledWith('Asset', { + chainId: '0x1', + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', + pricePercentChange1d: 3.44, + isNative: true, + isETH: true, + }); + }); + + it('navigates to Asset page with isNative true and isETH false for native token on non-Ethereum chain', () => { + const token = createMockToken({ + assetId: 'eip155:137/slip44:966', + symbol: 'MATIC', + name: 'Polygon', + decimals: 18, + }); + + const networkAddedState = { + ...mockState, + engine: { + ...mockState.engine, + backgroundState: { + ...mockState.engine.backgroundState, + NetworkController: { + networkConfigurations: {}, + networkConfigurationsByChainId: { + '0x89': { + chainId: '0x89', + caipChainId: 'eip155:137', + name: 'Polygon Mainnet', + }, + }, + }, + MultichainNetworkController: { + ...mockState.engine.backgroundState.MultichainNetworkController, + multichainNetworkConfigurationsByChainId: {}, + }, + }, + }, + }; + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:137/slip44:966', + ); + fireEvent.press(tokenRow); + + expect(mockNavigate).toHaveBeenCalledWith('Asset', { + chainId: '0x89', + address: '0x0000000000000000000000000000000000000000', + symbol: 'MATIC', + name: 'Polygon', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/137/slip44/966.png', + pricePercentChange1d: 3.44, + isNative: true, + isETH: false, }); }); diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx index 6e11503ba16..cf164178427 100644 --- a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx +++ b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx @@ -1,5 +1,5 @@ -import React, { useCallback, useState } from 'react'; -import { TouchableOpacity, View } from 'react-native'; +import React, { useCallback, useMemo, useState } from 'react'; +import { ImageSourcePropType, TouchableOpacity, View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import Text, { @@ -22,6 +22,7 @@ import { Hex, isCaipChainId, } from '@metamask/utils'; +import { NATIVE_SWAPS_TOKEN_ADDRESS } from '../../../../../constants/bridge'; import { getDefaultNetworkByChainId, getTestNetImageByChainId, @@ -42,6 +43,74 @@ import { selectNetworkConfigurationsByCaipChainId } from '../../../../../selecto import type { Network } from '../../../../Views/Settings/NetworksSettings/NetworkSettings/CustomNetworkView/CustomNetwork.types'; import { getTrendingTokenImageUrl } from '../../utils/getTrendingTokenImageUrl'; +/** + * Extracts CAIP chain ID from asset ID + */ +const getCaipChainIdFromAssetId = (assetId: string): CaipChainId => + assetId.split('/')[0] as CaipChainId; + +/** + * Converts CAIP chain ID to hex chain ID + */ +const caipChainIdToHex = (caipChainId: CaipChainId): Hex => { + const { namespace, reference } = parseCaipChainId(caipChainId); + return namespace === 'eip155' + ? (`0x${Number(reference).toString(16)}` as Hex) + : (caipChainId as Hex); +}; + +/** + * Gets network badge image source for a given CAIP chain ID + */ +const getNetworkBadgeSource = ( + caipChainId: CaipChainId, +): ImageSourcePropType | undefined => { + const hexChainId = caipChainIdToHex(caipChainId); + + if (isTestNet(hexChainId)) { + return getTestNetImageByChainId(hexChainId); + } + + const defaultNetwork = getDefaultNetworkByChainId(hexChainId) as + | { imageSource: ImageSourcePropType } + | undefined; + + if (defaultNetwork) { + return defaultNetwork.imageSource; + } + + const unpopularNetwork = UnpopularNetworkList.find( + (networkConfig) => networkConfig.chainId === hexChainId, + ); + + const customNetworkImg = CustomNetworkImgMapping[hexChainId]; + + const popularNetwork = PopularList.find( + (networkConfig) => networkConfig.chainId === hexChainId, + ); + + const network = unpopularNetwork || popularNetwork; + if (network) { + return network.rpcPrefs.imageSource; + } + if (isCaipChainId(caipChainId)) { + return getNonEvmNetworkImageSourceByChainId(caipChainId); + } + if (customNetworkImg) { + return customNetworkImg as ImageSourcePropType; + } + + return undefined; +}; + +/** + * Gets the text color for price percentage change + */ +const getPriceChangeColor = (priceChange: number): TextColor => { + if (priceChange === 0) return TextColor.Default; + return priceChange > 0 ? TextColor.Success : TextColor.Error; +}; + /** * Maps TimeOption to the corresponding priceChangePct field key */ @@ -67,6 +136,37 @@ interface TrendingTokenRowItemProps { iconSize?: number; selectedTimeOption?: TimeOption; } + +/** + * Converts a TrendingAsset to Asset navigation params + */ +const getAssetNavigationParams = (token: TrendingAsset) => { + const [caipChainId, assetIdentifier] = token.assetId.split('/'); + if (!isCaipChainId(caipChainId)) return null; + + const isEvmChain = caipChainId.startsWith('eip155:'); + const isNativeToken = assetIdentifier?.startsWith('slip44:'); + const address = ( + isNativeToken ? NATIVE_SWAPS_TOKEN_ADDRESS : assetIdentifier?.split(':')[1] + ) as Hex | undefined; + + const hexChainId = caipChainIdToHex(caipChainId); + + return { + chainId: hexChainId, + address: isEvmChain ? address : token.assetId, + symbol: token.symbol, + name: token.name, + decimals: token.decimals, + image: getTrendingTokenImageUrl(token.assetId), + pricePercentChange1d: token.priceChangePct?.h24 + ? parseFloat(token.priceChangePct.h24) + : undefined, + isNative: isNativeToken, + isETH: isNativeToken && hexChainId === '0x1', + }; +}; + const TrendingTokenRowItem = ({ token, iconSize = 44, @@ -74,52 +174,24 @@ const TrendingTokenRowItem = ({ }: TrendingTokenRowItemProps) => { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation(); - const chainId = token.assetId.split('/')[0] as CaipChainId; const networkConfigurations = useSelector( selectNetworkConfigurationsByCaipChainId, ); const [isNetworkModalVisible, setIsNetworkModalVisible] = useState(false); const [selectedNetwork, setSelectedNetwork] = useState(null); - const networkBadgeSource = useCallback((currentChainId: CaipChainId) => { - const { reference } = parseCaipChainId(currentChainId); - const hexChainId = `0x${Number(reference).toString(16)}` as Hex; - - if (isTestNet(hexChainId)) { - return getTestNetImageByChainId(hexChainId); - } - - const defaultNetwork = getDefaultNetworkByChainId(hexChainId) as - | { - imageSource: string; - } - | undefined; - - if (defaultNetwork) { - return defaultNetwork.imageSource; - } - - const unpopularNetwork = UnpopularNetworkList.find( - (networkConfig) => networkConfig.chainId === hexChainId, - ); - - const customNetworkImg = CustomNetworkImgMapping[hexChainId]; + // Memoize derived values + const caipChainId = useMemo( + () => getCaipChainIdFromAssetId(token.assetId), + [token.assetId], + ); - const popularNetwork = PopularList.find( - (networkConfig) => networkConfig.chainId === hexChainId, - ); + const assetParams = useMemo(() => getAssetNavigationParams(token), [token]); - const network = unpopularNetwork || popularNetwork; - if (network) { - return network.rpcPrefs.imageSource; - } - if (isCaipChainId(currentChainId)) { - return getNonEvmNetworkImageSourceByChainId(currentChainId); - } - if (customNetworkImg) { - return customNetworkImg; - } - }, []); + const networkBadgeImageSource = useMemo( + () => getNetworkBadgeSource(caipChainId), + [caipChainId], + ); // Parse price change percentage from API (comes as string like "-3.44" or "+0.456") // Use the correct field based on selected time option @@ -134,36 +206,15 @@ const TrendingTokenRowItem = ({ const hasPercentageChange = pricePercentChange !== undefined && !isNaN(pricePercentChange); const isPositiveChange = hasPercentageChange && pricePercentChange > 0; - const isNeutralChange = hasPercentageChange && pricePercentChange === 0; const handlePress = useCallback(() => { - // Parse assetId to extract chainId and address - // Format: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' - const [caipChainId, assetIdentifier] = token.assetId.split('/'); - // check if caipChainId is evm or non-evm - const isEvmChain = caipChainId.startsWith('eip155:'); - const address = assetIdentifier?.split(':')[1] as Hex | undefined; - - if (!address || !isCaipChainId(caipChainId)) { - return; - } - - // Convert CAIP chainId to Hex format for EVM networks - const { namespace, reference } = parseCaipChainId(caipChainId); - const hexChainId = - namespace === 'eip155' - ? (`0x${Number(reference).toString(16)}` as Hex) - : (caipChainId as Hex); + if (!assetParams) return; - // Check if network is already added by user - const isNetworkAdded = Boolean( - networkConfigurations[caipChainId as CaipChainId], - ); + const isNetworkAdded = Boolean(networkConfigurations[caipChainId]); - // If network is not added, show modal to add it if (!isNetworkAdded) { const popularNetwork = PopularList.find( - (network) => network.chainId === hexChainId, + (network) => network.chainId === assetParams.chainId, ); if (popularNetwork) { @@ -173,25 +224,8 @@ const TrendingTokenRowItem = ({ } } - // Construct image URL from assetId - const imageUrl = getTrendingTokenImageUrl(token.assetId); - - // Get 24-hour price change percentage (h24 corresponds to 1 day) - const priceChange24h = token.priceChangePct?.h24 - ? parseFloat(token.priceChangePct.h24) - : undefined; - - // Navigate to Asset page with token data - navigation.navigate('Asset', { - chainId: hexChainId, - address: isEvmChain ? address : token.assetId, - symbol: token.symbol, - name: token.name, - decimals: token.decimals, - image: imageUrl, - pricePercentChange1d: priceChange24h, - }); - }, [token, navigation, networkConfigurations]); + navigation.navigate('Asset', assetParams); + }, [assetParams, caipChainId, navigation, networkConfigurations]); const closeNetworkModal = useCallback(() => { setIsNetworkModalVisible(false); @@ -199,34 +233,11 @@ const TrendingTokenRowItem = ({ }, []); const handleNetworkModalAccept = useCallback(() => { - // Network has been added by NetworkModals' closeModal function - // Now navigate to Asset page - const [caipChainId, assetIdentifier] = token.assetId.split('/'); - const isEvmChain = caipChainId.startsWith('eip155:'); - const address = assetIdentifier?.split(':')[1] as Hex | undefined; - - if (address && isCaipChainId(caipChainId)) { - const { namespace, reference } = parseCaipChainId(caipChainId); - const hexChainId = - namespace === 'eip155' - ? (`0x${Number(reference).toString(16)}` as Hex) - : (caipChainId as Hex); - - navigation.navigate('Asset', { - chainId: hexChainId, - address: isEvmChain ? address : token.assetId, - symbol: token.symbol, - name: token.name, - image: getTrendingTokenImageUrl(token.assetId), - decimals: token.decimals, - pricePercentChange1d: token.priceChangePct?.h24 - ? parseFloat(token.priceChangePct.h24) - : undefined, - }); + if (assetParams) { + navigation.navigate('Asset', assetParams); } - closeNetworkModal(); - }, [token, navigation, closeNetworkModal]); + }, [assetParams, navigation, closeNetworkModal]); return ( <> @@ -265,7 +276,7 @@ const TrendingTokenRowItem = ({ } @@ -306,15 +317,9 @@ const TrendingTokenRowItem = ({ {hasPercentageChange && ( - {isNeutralChange ? '' : isPositiveChange ? '+' : '-'} + {pricePercentChange === 0 ? '' : isPositiveChange ? '+' : '-'} {Math.abs(pricePercentChange).toFixed(2)}% )} diff --git a/app/components/UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton.test.tsx b/app/components/UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton.test.tsx index bddf11fde0b..587b4acfb76 100644 --- a/app/components/UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton.test.tsx +++ b/app/components/UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton.test.tsx @@ -7,37 +7,71 @@ jest.mock( '../../../../../component-library/components/Skeleton/Skeleton', () => { const ReactNative = jest.requireActual('react-native'); + const SkeletonMock = ({ + height, + width, + style, + }: { + height?: number; + width?: number | string; + style?: object; + }) => ( + + ); return { __esModule: true, - default: jest.fn(({ height, width, style, testID }) => ( - - )), + default: SkeletonMock, }; }, ); describe('TrendingTokensSkeleton', () => { - it('renders successfully with default props', () => { - const { getAllByTestId } = render(); - const skeletons = getAllByTestId('skeleton'); - // Should render 5 skeleton elements: icon, token name, market stats, price, percentage - expect(skeletons.length).toBe(5); + beforeEach(() => { + jest.clearAllMocks(); }); - it('renders single skeleton row by default', () => { - const { getAllByTestId } = render(); - const skeletons = getAllByTestId('skeleton'); - // Should render 5 skeleton elements for one row - expect(skeletons.length).toBe(5); - }); + describe('rendering', () => { + it('renders successfully with default props', () => { + const { getAllByTestId } = render(); + + const skeletons = getAllByTestId('skeleton'); + expect(skeletons.length).toBe(5); + }); + + it('renders logo skeleton with correct dimensions', () => { + const { getAllByTestId } = render(); + + const skeletons = getAllByTestId('skeleton'); + const logoSkeleton = skeletons[0]; + + expect(logoSkeleton.props.style).toEqual([ + { height: 44, width: 44 }, + { borderRadius: 100 }, + ]); + }); + + it('renders name skeleton with correct dimensions', () => { + const { getAllByTestId } = render(); + + const skeletons = getAllByTestId('skeleton'); + const nameSkeleton = skeletons[1]; + + expect(nameSkeleton.props.style).toContainEqual({ + height: 20, + width: '60%', + }); + }); + + it('renders URL skeleton with correct dimensions', () => { + const { getAllByTestId } = render(); + + const skeletons = getAllByTestId('skeleton'); + const urlSkeleton = skeletons[2]; - it('renders multiple skeleton rows when count is provided', () => { - const { getAllByTestId } = render(); - const skeletons = getAllByTestId('skeleton'); - // Should render 5 skeleton elements per row (3 rows = 15 skeletons) - expect(skeletons.length).toBe(15); + expect(urlSkeleton.props.style).toEqual([ + { height: 18, width: '80%' }, + { marginBottom: 0, marginTop: 2 }, + ]); + }); }); }); diff --git a/app/components/UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton.tsx b/app/components/UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton.tsx index f8256ad3774..b7eae3dfa75 100644 --- a/app/components/UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton.tsx +++ b/app/components/UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton.tsx @@ -2,21 +2,6 @@ import React from 'react'; import { View, StyleSheet, type ViewStyle } from 'react-native'; import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; -export interface TrendingTokensSkeletonProps { - /** - * Number of skeleton rows to render - */ - count?: number; - /** - * Size of the icon skeleton (defaults to HOME_SCREEN_CONFIG.DEFAULT_ICON_SIZE) - */ - iconSize?: number; - /** - * Optional style for the container - */ - style?: ViewStyle; -} - const styles = StyleSheet.create({ container: { display: 'flex', @@ -61,51 +46,27 @@ const styles = StyleSheet.create({ }, }); -const TrendingTokensSkeleton: React.FC = ({ - count = 1, - iconSize = 44, - style, -}) => { - // Generate array for count - const rows = Array.from({ length: count }, (_, i) => i); - - return ( - <> - {rows.map((index) => ( - - - - - - - - - - - - - - - - ))} - - ); -}; +const iconSize = 44; +const TrendingTokensSkeleton: React.FC = () => ( + + + + + + + + + + + + + + + +); export default TrendingTokensSkeleton; diff --git a/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.ts b/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.ts index 56bdc3845b5..bf0c097a02d 100644 --- a/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.ts +++ b/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.ts @@ -8,11 +8,11 @@ import { useStableArray } from '../../../Perps/hooks/useStableArray'; * @returns {Object} An object containing the search results, loading state, and a function to trigger search */ export const useSearchRequest = (options: { - chainIds: CaipChainId[]; + chainIds?: CaipChainId[]; query: string; limit: number; }) => { - const { chainIds, query, limit } = options; + const { chainIds = [], query, limit } = options; const [results, setResults] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); diff --git a/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.test.ts b/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.test.ts index 8cb4b31f938..8e0ef466d22 100644 --- a/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.test.ts +++ b/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.test.ts @@ -96,6 +96,7 @@ describe('useTrendingSearch', () => { }); it('returns combined search and trending results when search query provided', async () => { + // Search for 'ETH' which matches one trending result mockUseSearchRequest.mockReturnValue({ results: mockSearchResults, isLoading: false, @@ -104,23 +105,24 @@ describe('useTrendingSearch', () => { }); const { result } = renderHookWithProvider(() => - useTrendingSearch('USDC', 'h24_trending'), + useTrendingSearch('ETH', 'h24_trending'), ); await waitFor(() => { - expect(result.current.data).toHaveLength(3); + expect(result.current.data).toHaveLength(2); }); + // Should contain ETH from trending (matches query) and USDC from search expect(result.current.data).toEqual( expect.arrayContaining([ - ...mockTrendingResults, + expect.objectContaining({ symbol: 'ETH' }), expect.objectContaining({ symbol: 'USDC' }), ]), ); }); it('removes duplicate results when combining search and trending', async () => { - const duplicateResult = mockTrendingResults[0]; + const duplicateResult = mockTrendingResults[0]; // ETH mockUseSearchRequest.mockReturnValue({ results: [duplicateResult, mockSearchResults[0]], isLoading: false, @@ -133,12 +135,20 @@ describe('useTrendingSearch', () => { ); await waitFor(() => { - expect(result.current.data).toHaveLength(3); + expect(result.current.data).toHaveLength(2); }); + // Should have ETH (deduplicated) and USDC (from search) + // DAI is filtered out because it doesn't match 'ETH' query const assetIds = result.current.data.map((item) => item.assetId); const uniqueAssetIds = new Set(assetIds); expect(assetIds.length).toBe(uniqueAssetIds.size); + expect(result.current.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ symbol: 'ETH' }), + expect.objectContaining({ symbol: 'USDC' }), + ]), + ); }); it('returns trending loading state when no search query', () => { @@ -168,4 +178,73 @@ describe('useTrendingSearch', () => { expect(result.current.isLoading).toBe(true); }); + + describe('filtering trending results by query', () => { + it('returns all trending results when query is empty or whitespace', async () => { + const sortedResults = mockTrendingResults; + mockSortTrendingTokens.mockReturnValue(sortedResults); + + const { result: result1 } = renderHookWithProvider(() => + useTrendingSearch(''), + ); + const { result: result2 } = renderHookWithProvider(() => + useTrendingSearch(' '), + ); + + await waitFor(() => { + expect(result1.current.data).toEqual(sortedResults); + expect(result2.current.data).toEqual(sortedResults); + }); + }); + + it('filters trending results by symbol case-insensitively', async () => { + const { result } = renderHookWithProvider(() => useTrendingSearch('eth')); + + await waitFor(() => { + expect(result.current.data).toHaveLength(1); + expect(result.current.data[0].symbol).toBe('ETH'); + }); + }); + + it('filters trending results by name case-insensitively', async () => { + const { result } = renderHookWithProvider(() => + useTrendingSearch('ethereum'), + ); + + await waitFor(() => { + expect(result.current.data).toHaveLength(1); + expect(result.current.data[0].name).toBe('Ethereum'); + }); + }); + + it('filters trending results by partial matches', async () => { + const { result } = renderHookWithProvider(() => useTrendingSearch('dai')); + + await waitFor(() => { + expect(result.current.data).toHaveLength(1); + expect(result.current.data[0].symbol).toBe('DAI'); + }); + }); + + it('returns empty array when no trending results match query', async () => { + const { result } = renderHookWithProvider(() => + useTrendingSearch('NonExistent'), + ); + + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); + }); + + it('trims whitespace from query before filtering', async () => { + const { result } = renderHookWithProvider(() => + useTrendingSearch(' ETH '), + ); + + await waitFor(() => { + expect(result.current.data).toHaveLength(1); + expect(result.current.data[0].symbol).toBe('ETH'); + }); + }); + }); }); diff --git a/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts b/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts index e2a9ed53d5c..e05cf5970db 100644 --- a/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts +++ b/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts @@ -22,7 +22,6 @@ export const useTrendingSearch = ( useSearchRequest({ query: searchQuery || '', limit: 20, - chainIds: [], }); const { @@ -35,13 +34,21 @@ export const useTrendingSearch = ( }); const data = useMemo(() => { - if (!searchQuery) { + if (!searchQuery?.trim()) { return sortTrendingTokens(trendingResults, PriceChangeOption.PriceChange); } + const query = searchQuery.toLowerCase().trim(); + + const filteredTrendingResults = trendingResults.filter( + (item) => + item.symbol?.toLowerCase().includes(query) || + item.name?.toLowerCase().includes(query), + ); + // Combine trending and search results, avoiding duplicates const resultMap = new Map( - trendingResults.map((result) => [result.assetId, result]), + filteredTrendingResults.map((result) => [result.assetId, result]), ); searchResults.forEach((result) => { diff --git a/app/components/Views/SitesFullView/SitesFullView.test.tsx b/app/components/Views/SitesFullView/SitesFullView.test.tsx index e94244ce848..0d90f27edae 100644 --- a/app/components/Views/SitesFullView/SitesFullView.test.tsx +++ b/app/components/Views/SitesFullView/SitesFullView.test.tsx @@ -158,6 +158,28 @@ describe('SitesFullView', () => { }, ]; + const setupMockWithSearchFilter = () => { + mockUseSitesData.mockImplementation((searchQuery: string) => { + let filteredSites = mockSites; + + if (searchQuery?.trim()) { + const query = searchQuery.toLowerCase().trim(); + filteredSites = mockSites.filter( + (site) => + site.name.toLowerCase().includes(query) || + site.displayUrl.toLowerCase().includes(query) || + site.url.toLowerCase().includes(query), + ); + } + + return { + sites: filteredSites, + isLoading: false, + refetch: mockRefetch, + }; + }); + }; + beforeEach(() => { jest.clearAllMocks(); mockRefetch.mockClear(); @@ -243,11 +265,7 @@ describe('SitesFullView', () => { describe('Search Functionality', () => { it('filters sites by name, URL, and display URL', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - refetch: mockRefetch, - }); + setupMockWithSearchFilter(); const { getByTestId, queryByTestId } = render(); @@ -272,11 +290,7 @@ describe('SitesFullView', () => { }); it('shows all sites when search query is empty', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - refetch: mockRefetch, - }); + setupMockWithSearchFilter(); const { getByTestId } = render(); @@ -373,7 +387,7 @@ describe('SitesFullView', () => { render(); - expect(mockUseSitesData).toHaveBeenCalledWith({ limit: 100 }); + expect(mockUseSitesData).toHaveBeenCalledWith('', 100); }); it('calls refetch when refresh is triggered', async () => { @@ -442,11 +456,7 @@ describe('SitesFullView', () => { }); it('performs case-insensitive search', () => { - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - refetch: mockRefetch, - }); + setupMockWithSearchFilter(); const { getByTestId, queryByTestId } = render(); diff --git a/app/components/Views/SitesFullView/SitesFullView.tsx b/app/components/Views/SitesFullView/SitesFullView.tsx index abc6783059f..55d0a0feca7 100644 --- a/app/components/Views/SitesFullView/SitesFullView.tsx +++ b/app/components/Views/SitesFullView/SitesFullView.tsx @@ -47,22 +47,7 @@ const SitesFullView: React.FC = () => { sites, isLoading, refetch: refetchSites, - } = useSitesData({ limit: 100 }); - - // Filter sites based on search query - const filteredSites = useMemo(() => { - if (!searchQuery.trim()) { - return sites; - } - - const query = searchQuery.toLowerCase(); - return sites.filter( - (site) => - site.name.toLowerCase().includes(query) || - site.displayUrl.toLowerCase().includes(query) || - site.url.toLowerCase().includes(query), - ); - }, [sites, searchQuery]); + } = useSitesData(searchQuery, 100); const handleBackPress = useCallback(() => { navigation.goBack(); @@ -132,7 +117,7 @@ const SitesFullView: React.FC = () => { ) : ( { fetch: jest.fn(), }); - const { getByTestId } = renderWithProvider( + const { queryAllByTestId } = renderWithProvider( , { state: mockState }, false, ); - expect(getByTestId('trending-tokens-skeleton')).toBeOnTheScreen(); + const skeletons = queryAllByTestId('trending-tokens-skeleton'); + expect(skeletons.length).toBeGreaterThan(0); + expect(skeletons[0]).toBeOnTheScreen(); }); it('displays skeleton loader when results are empty', () => { @@ -341,13 +343,15 @@ describe('TrendingTokensFullView', () => { fetch: jest.fn(), }); - const { getByTestId } = renderWithProvider( + const { queryAllByTestId } = renderWithProvider( , { state: mockState }, false, ); - expect(getByTestId('trending-tokens-skeleton')).toBeOnTheScreen(); + const skeletons = queryAllByTestId('trending-tokens-skeleton'); + expect(skeletons.length).toBeGreaterThan(0); + expect(skeletons[0]).toBeOnTheScreen(); }); it('displays trending tokens list when data is loaded', () => { diff --git a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx index 2ea31d167af..a14fb33b3f9 100644 --- a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx +++ b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx @@ -40,7 +40,6 @@ import { TimeOption, } from '../../../UI/Trending/components/TrendingTokensBottomSheet'; import { sortTrendingTokens } from '../../../UI/Trending/utils/sortTrendingTokens'; -import { SECTIONS_CONFIG } from '../../TrendingView/config/sections.config'; import { useTrendingSearch } from '../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'; interface TrendingTokensNavigationParamList { @@ -116,8 +115,6 @@ const createStyles = (theme: Theme) => }, }); -const MAX_TOKENS = 100; - const TrendingTokensFullView = () => { const navigation = useNavigation>(); @@ -199,32 +196,11 @@ const TrendingTokensFullView = () => { // - When no search query: returns trending results from useTrendingRequest // - When search query exists: returns merged trending + search results const { - data: tokensSectionData, + data: searchResults, isLoading, refetch: refetchTokensSection, } = useTrendingSearch(searchQuery || undefined, sortBy, selectedNetwork); - const searchResults = useMemo(() => { - // When search is not active, use the full section data - if (!isSearchVisible) { - return tokensSectionData as TrendingAsset[]; - } - - const searchTerm = searchQuery.toLowerCase().trim(); - - // If search box is empty, still use full section data - if (!searchTerm) { - return tokensSectionData as TrendingAsset[]; - } - - const tokensSectionConfig = SECTIONS_CONFIG.tokens; - - // Filter section data based on searchable text (symbol + name) - return (tokensSectionData as unknown[]).filter((item) => - tokensSectionConfig.getSearchableText(item).includes(searchTerm), - ) as TrendingAsset[]; - }, [isSearchVisible, searchQuery, tokensSectionData]); - // Sort and display tokens based on selected option and direction const trendingTokens = useMemo(() => { // Early return if no results @@ -232,22 +208,20 @@ const TrendingTokensFullView = () => { return []; } - const filteredResults = searchResults; - // If no sort option selected, return filtered results as-is (already sorted by API) if (!selectedPriceChangeOption) { - return filteredResults.slice(0, MAX_TOKENS); + return searchResults; } // Sort using the shared utility function const sorted = sortTrendingTokens( - filteredResults, + searchResults, selectedPriceChangeOption, priceChangeSortDirection, selectedTimeOption, ); - return sorted.slice(0, MAX_TOKENS); + return sorted; }, [ searchResults, selectedPriceChangeOption, @@ -394,7 +368,9 @@ const TrendingTokensFullView = () => { {isLoading || (searchResults as TrendingAsset[]).length === 0 ? ( - + {Array.from({ length: 10 }).map((_, index) => ( + + ))} ) : ( diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx index a9bce9bcdda..590b05fd217 100644 --- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx +++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx @@ -143,7 +143,7 @@ const ExploreSearchResults: React.FC = ({ return `skeleton-${item.sectionId}-${item.index}`; const section = SECTIONS_CONFIG[item.sectionId]; - return section ? section.keyExtractor(item.data) : `item-${index}`; + return section ? `${section.id}-${index}` : `item-${index}`; }, []); return ( diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts index cc4eca8f44c..2fb5233e81f 100644 --- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts +++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts @@ -46,59 +46,64 @@ const mockSites = [ }, ]; -const mockUseTrendingSearch = jest.fn(); -const mockUsePerpsMarkets = jest.fn(); -const mockUsePredictMarketData = jest.fn(); -const mockUseSitesData = jest.fn(); +let mockTrendingData = mockTrendingTokens; +let mockTrendingLoading = false; +let mockPerpsData = mockPerpsMarkets; +let mockPerpsLoading = false; +let mockPredictionsData = mockPredictionMarkets; +let mockPredictionsLoading = false; +let mockSitesData = mockSites; +let mockSitesLoading = false; jest.mock( '../../../../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch', () => ({ - useTrendingSearch: () => mockUseTrendingSearch(), + useTrendingSearch: () => ({ + data: mockTrendingData, + isLoading: mockTrendingLoading, + refetch: jest.fn(), + }), }), ); jest.mock('../../../../../../UI/Perps/hooks/usePerpsMarkets', () => ({ - usePerpsMarkets: () => mockUsePerpsMarkets(), + usePerpsMarkets: () => ({ + markets: mockPerpsData, + isLoading: mockPerpsLoading, + refresh: jest.fn(), + isRefreshing: false, + }), })); jest.mock('../../../../../../UI/Predict/hooks/usePredictMarketData', () => ({ - usePredictMarketData: () => mockUsePredictMarketData(), + usePredictMarketData: () => ({ + marketData: mockPredictionsData, + isFetching: mockPredictionsLoading, + refetch: jest.fn(), + }), })); jest.mock('../../../../../../UI/Sites/hooks/useSiteData/useSitesData', () => ({ - useSitesData: () => mockUseSitesData(), + useSitesData: () => ({ + sites: mockSitesData, + isLoading: mockSitesLoading, + refetch: jest.fn(), + }), })); describe('useExploreSearch', () => { beforeEach(() => { - jest.clearAllMocks(); jest.useFakeTimers(); - mockUseTrendingSearch.mockReturnValue({ - data: mockTrendingTokens, - isLoading: false, - refetch: jest.fn(), - }); - - mockUsePerpsMarkets.mockReturnValue({ - markets: mockPerpsMarkets, - isLoading: false, - refresh: jest.fn(), - isRefreshing: false, - }); - - mockUsePredictMarketData.mockReturnValue({ - marketData: mockPredictionMarkets, - isFetching: false, - refetch: jest.fn(), - }); - - mockUseSitesData.mockReturnValue({ - sites: mockSites, - isLoading: false, - refetch: jest.fn(), - }); + // Reset to default values + mockTrendingData = mockTrendingTokens; + mockTrendingLoading = false; + mockPerpsData = mockPerpsMarkets; + mockPerpsLoading = false; + mockPredictionsData = mockPredictionMarkets; + mockPredictionsLoading = false; + mockSitesData = mockSites; + mockSitesLoading = false; }); afterEach(() => { @@ -124,113 +129,13 @@ describe('useExploreSearch', () => { expect(result.current.data.sites).toHaveLength(3); }); - it('filters tokens by symbol when query matches', async () => { - const { result, rerender } = renderHook( - ({ query }) => useExploreSearch(query), - { initialProps: { query: '' } }, - ); - - rerender({ query: 'btc' }); - - await act(async () => { - jest.advanceTimersByTime(200); - }); - - await waitFor(() => { - expect(result.current.data.tokens).toHaveLength(1); - expect((result.current.data.tokens[0] as { symbol: string }).symbol).toBe( - 'BTC', - ); - }); - }); - - it('filters tokens by name when query matches', async () => { - const { result, rerender } = renderHook( - ({ query }) => useExploreSearch(query), - { initialProps: { query: '' } }, - ); - - rerender({ query: 'ethereum' }); - - await act(async () => { - jest.advanceTimersByTime(200); - }); - - await waitFor(() => { - expect(result.current.data.tokens).toHaveLength(1); - expect((result.current.data.tokens[0] as { name: string }).name).toBe( - 'Ethereum', - ); - }); - }); - - it('performs case insensitive search', async () => { - const { result, rerender } = renderHook( - ({ query }) => useExploreSearch(query), - { initialProps: { query: '' } }, - ); - - rerender({ query: 'BITCOIN' }); - - await act(async () => { - jest.advanceTimersByTime(200); - }); - - await waitFor(() => { - expect(result.current.data.tokens.length).toBeGreaterThan(0); - expect((result.current.data.tokens[0] as { name: string }).name).toBe( - 'Bitcoin', - ); - }); - }); - - it('filters perps markets by symbol', async () => { - const { result, rerender } = renderHook( - ({ query }) => useExploreSearch(query), - { initialProps: { query: '' } }, - ); - - rerender({ query: 'doge' }); - - await act(async () => { - jest.advanceTimersByTime(200); - }); - - await waitFor(() => { - expect(result.current.data.perps).toHaveLength(1); - expect((result.current.data.perps[0] as { symbol: string }).symbol).toBe( - 'DOGE-USD', - ); - }); - }); - - it('filters predictions by title', async () => { - const { result, rerender } = renderHook( - ({ query }) => useExploreSearch(query), - { initialProps: { query: '' } }, - ); + it('returns empty arrays when section hooks return no data', async () => { + mockTrendingData = []; + mockPerpsData = []; + mockPredictionsData = []; + mockSitesData = []; - rerender({ query: 'trump' }); - - await act(async () => { - jest.advanceTimersByTime(200); - }); - - await waitFor(() => { - expect(result.current.data.predictions).toHaveLength(1); - expect( - (result.current.data.predictions[0] as { title: string }).title, - ).toBe('Trump election results'); - }); - }); - - it('returns empty arrays when no items match query', async () => { - const { result, rerender } = renderHook( - ({ query }) => useExploreSearch(query), - { initialProps: { query: '' } }, - ); - - rerender({ query: 'nonexistent' }); + const { result } = renderHook(() => useExploreSearch('test')); await act(async () => { jest.advanceTimersByTime(200); @@ -254,76 +159,61 @@ describe('useExploreSearch', () => { rerender({ query: 'btc' }); + // Before debounce completes, should still show initial count (top 3) await act(async () => { jest.advanceTimersByTime(100); }); expect(result.current.data.tokens.length).toBe(initialTokenCount); + // After full debounce time, query should be processed await act(async () => { jest.advanceTimersByTime(100); }); await waitFor(() => { - expect(result.current.data.tokens.length).toBeLessThan(initialTokenCount); + expect(result.current.data.tokens.length).toBeGreaterThan(0); }); }); - it('returns loading states for each section', () => { - mockUseTrendingSearch.mockReturnValue({ - data: [], - isLoading: true, - refetch: jest.fn(), - }); - - mockUsePerpsMarkets.mockReturnValue({ - markets: [], - isLoading: true, - refresh: jest.fn(), - isRefreshing: false, - }); - - mockUsePredictMarketData.mockReturnValue({ - marketData: [], - isFetching: true, - refetch: jest.fn(), - }); + it('shows loading state while debouncing', async () => { + const { result, rerender } = renderHook( + ({ query }) => useExploreSearch(query), + { initialProps: { query: '' } }, + ); - mockUseSitesData.mockReturnValue({ - sites: [], - isLoading: true, - refetch: jest.fn(), - }); + expect(result.current.isLoading.tokens).toBe(false); - const { result } = renderHook(() => useExploreSearch('')); + rerender({ query: 'test' }); expect(result.current.isLoading.tokens).toBe(true); expect(result.current.isLoading.perps).toBe(true); expect(result.current.isLoading.predictions).toBe(true); expect(result.current.isLoading.sites).toBe(true); - }); - - it('filters across multiple sections simultaneously', async () => { - const { result, rerender } = renderHook( - ({ query }) => useExploreSearch(query), - { initialProps: { query: '' } }, - ); - - rerender({ query: 'sol' }); await act(async () => { jest.advanceTimersByTime(200); }); await waitFor(() => { - const hasTokenMatch = result.current.data.tokens.length > 0; - const hasPerpsMatch = result.current.data.perps.length > 0; - const hasPredictionsMatch = result.current.data.predictions.length > 0; - - expect(hasTokenMatch || hasPerpsMatch || hasPredictionsMatch).toBe(true); + expect(result.current.isLoading.tokens).toBe(false); }); }); + it('aggregates loading states from section hooks', () => { + mockTrendingLoading = true; + mockPerpsLoading = true; + mockPredictionsLoading = true; + mockSitesLoading = true; + + const { result } = renderHook(() => useExploreSearch('')); + + expect(result.current.isLoading.tokens).toBe(true); + expect(result.current.isLoading.perps).toBe(true); + expect(result.current.isLoading.predictions).toBe(true); + expect(result.current.isLoading.sites).toBe(true); + }); + it('processes all sections defined in config', () => { const { result } = renderHook(() => useExploreSearch('')); diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.ts b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.ts index 376d8e53ba5..990f9308e57 100644 --- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.ts +++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.ts @@ -50,7 +50,6 @@ export const useExploreSearch = (query: string): ExploreSearchResult => { >; const shouldShowTopItems = !debouncedQuery.trim(); - const searchTerm = debouncedQuery.toLowerCase(); // Process each section generically SECTIONS_ARRAY.forEach((section) => { @@ -64,9 +63,7 @@ export const useExploreSearch = (query: string): ExploreSearchResult => { data[section.id] = sectionData.data.slice(0, 3); } else { // Filter items based on section's searchable text - data[section.id] = sectionData.data.filter((item) => - section.getSearchableText(item).includes(searchTerm), - ); + data[section.id] = sectionData.data; } }); diff --git a/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx b/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx index 3fa1d09bbf3..b8e561a9a17 100644 --- a/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx +++ b/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx @@ -58,7 +58,7 @@ const SectionCard: React.FC = ({ section.keyExtractor(item)} + keyExtractor={(_, index) => `${section.id}-${index}`} keyboardShouldPersistTaps="handled" testID="perps-tokens-list" /> diff --git a/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx b/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx index 858c40355fe..98b770f0095 100644 --- a/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx +++ b/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx @@ -63,7 +63,7 @@ const SectionCarrousel: React.FC = ({ keyExtractor={ isLoading ? (_, index) => `skeleton-${index}` - : (item) => section.keyExtractor(item) + : (_, index) => `${section.id}-${index}` } horizontal pagingEnabled={false} diff --git a/app/components/Views/TrendingView/config/sections.config.tsx b/app/components/Views/TrendingView/config/sections.config.tsx index 45887c5c11d..a5c4669979b 100644 --- a/app/components/Views/TrendingView/config/sections.config.tsx +++ b/app/components/Views/TrendingView/config/sections.config.tsx @@ -6,7 +6,6 @@ import { strings } from '../../../../../locales/i18n'; import TrendingTokenRowItem from '../../../UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem'; import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; import PerpsMarketRowItem from '../../../UI/Perps/components/PerpsMarketRowItem'; -import PerpsMarketRowSkeleton from '../../../UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton'; import type { PerpsMarketData } from '../../../UI/Perps/controllers/types'; import PredictMarket from '../../../UI/Predict/components/PredictMarket'; import type { PredictMarket as PredictMarketType } from '../../../UI/Predict/types'; @@ -24,6 +23,7 @@ import SiteRowItemWrapper from '../../../UI/Sites/components/SiteRowItemWrapper/ import SiteSkeleton from '../../../UI/Sites/components/SiteSkeleton/SiteSkeleton'; import { useSitesData } from '../../../UI/Sites/hooks/useSiteData/useSitesData'; import { useTrendingSearch } from '../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'; +import { filterMarketsByQuery } from '../../../UI/Perps/utils/marketUtils'; export type SectionId = 'predictions' | 'tokens' | 'perps' | 'sites'; @@ -43,8 +43,6 @@ interface SectionConfig { navigation: NavigationProp; }>; Skeleton: React.ComponentType; - getSearchableText: (item: unknown) => string; - keyExtractor: (item: unknown) => string; Section: React.ComponentType<{ refreshTrigger?: number }>; useSectionData: (searchQuery?: string) => { data: unknown[]; @@ -81,15 +79,11 @@ export const SECTIONS_CONFIG: Record = { ), Skeleton: () => , - getSearchableText: (item) => - `${(item as TrendingAsset).symbol} ${(item as TrendingAsset).name}`.toLowerCase(), - keyExtractor: (item) => `token-${(item as TrendingAsset).assetId}`, Section: ({ refreshTrigger }) => ( ), useSectionData: (searchQuery) => { const { data, isLoading, refetch } = useTrendingSearch(searchQuery); - return { data, isLoading, refetch }; }, }, @@ -120,10 +114,8 @@ export const SECTIONS_CONFIG: Record = { showBadge={false} /> ), - Skeleton: () => , - getSearchableText: (item) => - `${(item as PerpsMarketData).symbol} ${(item as PerpsMarketData).name || ''}`.toLowerCase(), - keyExtractor: (item) => `perp-${(item as PerpsMarketData).symbol}`, + // Using trending skeleton cause PerpsMarketRowSkeleton has too much spacing + Skeleton: () => , Section: ({ refreshTrigger }) => ( @@ -131,11 +123,15 @@ export const SECTIONS_CONFIG: Record = { ), - useSectionData: () => { + useSectionData: (searchQuery) => { const { markets, isLoading, refresh, isRefreshing } = usePerpsMarkets(); + const filteredMarkets = searchQuery + ? filterMarketsByQuery(markets, searchQuery) + : markets; + return { - data: markets, + data: filteredMarkets, isLoading: isLoading || isRefreshing, refetch: refresh, }; @@ -156,9 +152,6 @@ export const SECTIONS_CONFIG: Record = { ), Skeleton: () => , - getSearchableText: (item) => - (item as PredictMarketType).title.toLowerCase(), - keyExtractor: (item) => `prediction-${(item as PredictMarketType).id}`, Section: ({ refreshTrigger }) => ( = { ), Skeleton: () => , - getSearchableText: (item) => - `${(item as SiteData).name} ${(item as SiteData).displayUrl}`.toLowerCase(), - keyExtractor: (item) => `site-${(item as SiteData).id}`, Section: ({ refreshTrigger }) => ( ), - useSectionData: () => { - const { sites, isLoading, refetch } = useSitesData({ limit: 100 }); + useSectionData: (searchQuery) => { + const { sites, isLoading, refetch } = useSitesData(searchQuery, 100); return { data: sites, isLoading, refetch }; }, }, @@ -224,7 +214,7 @@ export const SECTIONS_ARRAY: (SectionConfig & { id: SectionId })[] = [ * @returns Data and loading state for all sections */ export const useSectionsData = ( - searchQuery?: string, + searchQuery: string, ): Record => { const { data: trendingTokens, isLoading: isTokensLoading } = SECTIONS_CONFIG.tokens.useSectionData(searchQuery); From 676610eb0be454701c78be39da6d753371cbe7db Mon Sep 17 00:00:00 2001 From: MetaMask Bot <37885440+metamaskbot@users.noreply.github.com> Date: Fri, 28 Nov 2025 02:07:26 +0100 Subject: [PATCH 2/2] chore: Bump main version to 7.62.0 (#23382) ## Version Bump After Release This PR bumps the main branch version from 7.61.0 to 7.62.0 after cutting the release branch. ### Why this is needed: - **Nightly builds**: Each nightly build needs to be one minor version ahead of the current release candidate - **Version conflicts**: Prevents conflicts between nightlies and release candidates - **Platform alignment**: Maintains version alignment between MetaMask mobile and extension - **Update systems**: Ensures nightlies are accepted by app stores and browser update systems ### What changed: - Version bumped from `7.61.0` to `7.62.0` - Platform: `mobile` - Files updated by `set-semvar-version.sh` script ### Next steps: This PR should be **manually reviewed and merged by the release manager** to maintain proper version flow. ### Related: - Release version: 7.61.0 - Release branch: release/7.61.0 - Platform: mobile - Test mode: false --- *This PR was automatically created by the `create-platform-release-pr.sh` script.* Co-authored-by: metamaskbot --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 88871c88647..4f06959781d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -187,7 +187,7 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionName "7.61.0" + versionName "7.62.0" versionCode 3092 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/bitrise.yml b/bitrise.yml index 0563c419dcf..bde73a91159 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3574,13 +3574,13 @@ app: PROJECT_LOCATION_IOS: ios - opts: is_expand: false - VERSION_NAME: 7.61.0 + VERSION_NAME: 7.62.0 - opts: is_expand: false VERSION_NUMBER: 3092 - opts: is_expand: false - FLASK_VERSION_NAME: 7.61.0 + FLASK_VERSION_NAME: 7.62.0 - opts: is_expand: false FLASK_VERSION_NUMBER: 3092 diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 3a223be784a..4dbf5c3ab3f 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1319,7 +1319,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.61.0; + MARKETING_VERSION = 7.62.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1385,7 +1385,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.61.0; + MARKETING_VERSION = 7.62.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1454,7 +1454,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.61.0; + MARKETING_VERSION = 7.62.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1518,7 +1518,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.61.0; + MARKETING_VERSION = 7.62.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1684,7 +1684,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.61.0; + MARKETING_VERSION = 7.62.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "$(inherited)", @@ -1751,7 +1751,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.61.0; + MARKETING_VERSION = 7.62.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "$(inherited)", diff --git a/package.json b/package.json index 3802e66557c..fb769b492e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask", - "version": "7.61.0", + "version": "7.62.0", "private": true, "scripts": { "install:foundryup": "yarn mm-foundryup",