Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 2 additions & 7 deletions app/components/UI/Perps/hooks/usePerpsSearch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useCallback, useMemo } from 'react';
import type { PerpsMarketData } from '../controllers/types';
import { filterMarketsByQuery } from '../utils/marketUtils';

interface UsePerpsSearchParams {
/**
Expand Down Expand Up @@ -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 {
Expand Down
69 changes: 69 additions & 0 deletions app/components/UI/Perps/utils/marketUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -916,4 +918,71 @@ describe('marketUtils', () => {
});
});
});

describe('filterMarketsByQuery', () => {
const mockMarkets: Partial<PerpsMarketData>[] = [
{ 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]]);
});
});
});
31 changes: 31 additions & 0 deletions app/components/UI/Perps/utils/marketUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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 },
]);
});
});
});
54 changes: 35 additions & 19 deletions app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -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<ViewStyle> = () => (
<View style={styles.container}>
{/* Logo skeleton */}
<Skeleton height={40} width={40} style={styles.iconSkeleton} />

{/* Content skeleton */}
<View style={styles.contentContainer}>
<Skeleton height={20} width="60%" style={styles.nameSkeleton} />
<Skeleton height={16} width="40%" style={styles.urlSkeleton} />
<View>
<Skeleton
height={iconSize}
width={iconSize}
style={styles.iconSkeleton}
/>
</View>
<View style={[styles.leftContainer, { minHeight: iconSize }]}>
<View style={styles.tokenHeaderRow}>
<Skeleton height={20} width="60%" style={styles.tokenNameSkeleton} />
</View>
<Skeleton height={18} width="80%" style={styles.marketStatsSkeleton} />
</View>
</View>
);

export default SiteSkeleton;
export default SitesSkeleton;
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ describe('useSitesData', () => {
json: async () => ({ dapps: [] }),
});

renderHook(() => useSitesData({ limit: 50 }));
renderHook(() => useSitesData(undefined, 50));

await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
Expand Down Expand Up @@ -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);
Expand Down
32 changes: 22 additions & 10 deletions app/components/UI/Sites/hooks/useSiteData/useSitesData.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -21,10 +21,6 @@ interface ApiSitesResponse {
dapps: ApiDappResponse[];
}

interface UseSitesDataParams {
limit?: number;
}

interface UseSitesDataResult {
sites: SiteData[];
isLoading: boolean;
Expand All @@ -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<SiteData[]>([]);
): UseSitesDataResult => {
const [allSites, setAllSites] = useState<SiteData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);

Expand Down Expand Up @@ -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);
}
Expand All @@ -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 };
};
Loading
Loading