diff --git a/src/__swaps__/screens/Swap/resources/search/searchV2.ts b/src/__swaps__/screens/Swap/resources/search/searchV2.ts index a8676397df2..ba98a7a788d 100644 --- a/src/__swaps__/screens/Swap/resources/search/searchV2.ts +++ b/src/__swaps__/screens/Swap/resources/search/searchV2.ts @@ -1,5 +1,6 @@ import qs from 'qs'; import { getProvider } from '@/handlers/web3'; +import { groupBy } from 'lodash'; import { RainbowError, logger } from '@/logger'; import { RainbowFetchClient } from '@/rainbow-fetch'; import { getAddress, isAddress } from '@ethersproject/address'; @@ -14,6 +15,10 @@ import { SearchAsset, TokenSearchAssetKey } from '@/__swaps__/types/search'; import { time } from '@/utils'; import { parseTokenSearchResults } from './utils'; +type SearchItemWithRelevance = SearchAsset & { + relevance: number; +}; + const tokenSearchClient = new RainbowFetchClient({ baseURL: 'https://token-search.rainbow.me/v3/tokens', headers: { @@ -29,6 +34,11 @@ type TokenSearchParams = { query: string | undefined; }; +type DiscoverSearchParams = { + list?: string; + query: string; +}; + type TokenSearchState = { bridgeAsset: SearchAsset | null; }; @@ -44,6 +54,12 @@ type VerifiedTokenData = { unverifiedAssets: SearchAsset[]; }; +type DiscoverSearchResults = { + verifiedAssets: SearchAsset[]; + highLiquidityAssets: SearchAsset[]; + lowLiquidityAssets: SearchAsset[]; +}; + enum TokenLists { HighLiquidity = 'highLiquidityAssets', LowLiquidity = 'lowLiquidityAssets', @@ -55,6 +71,7 @@ const MAX_UNVERIFIED_RESULTS = 6; const MAX_CROSSCHAIN_RESULTS = 3; const NO_RESULTS: VerifiedTokenData = { bridgeAsset: null, crosschainResults: [], verifiedAssets: [], unverifiedAssets: [] }; +const NO_DISCOVER_RESULTS: DiscoverSearchResults = { verifiedAssets: [], highLiquidityAssets: [], lowLiquidityAssets: [] }; export const useSwapsSearchStore = createRainbowStore(() => ({ searchQuery: '' })); @@ -77,6 +94,120 @@ export const useTokenSearchStore = createQueryStore( + { + fetcher: (params, abortController) => discoverSearchQueryFunction(params, abortController), + cacheTime: params => (params.query?.length ? time.seconds(15) : time.hours(1)), + disableAutoRefetching: true, + keepPreviousData: true, + params: { + query: $ => $(useSwapsSearchStore, state => (state.searchQuery.trim().length ? state.searchQuery.trim() : '')), + }, + staleTime: time.minutes(2), + }, + + () => ({ bridgeAsset: null }), + + { persistThrottleMs: time.seconds(8), storageKey: 'discoverTokenSearch' } +); + +const sortForDefaultList = (tokens: SearchAsset[]) => { + const curated = tokens.filter(asset => asset.highLiquidity && asset.isRainbowCurated && asset.icon_url); + return curated.sort((a, b) => (b.market?.market_cap?.value || 0) - (a.market?.market_cap?.value || 0)); +}; + +const sortTokensByRelevance = (tokens: SearchAsset[], query: string): SearchItemWithRelevance[] => { + const normalizedQuery = query.toLowerCase().trim(); + const tokenWithRelevance: SearchItemWithRelevance[] = tokens.map(token => { + const normalizedTokenName = token.name.toLowerCase(); + + const normalizedTokenSymbol = token.symbol.toLowerCase(); + const tokenNameWords = normalizedTokenName.split(' '); + const relevance = getTokenRelevance({ + token, + normalizedTokenName, + normalizedQuery, + normalizedTokenSymbol, + tokenNameWords, + }); + return { ...token, relevance }; + }); + + return tokenWithRelevance.sort((a, b) => b.relevance - a.relevance); +}; + +// higher number indicates higher relevance +const getTokenRelevance = ({ + token, + normalizedTokenName, + normalizedQuery, + normalizedTokenSymbol, + tokenNameWords, +}: { + token: SearchAsset; + normalizedTokenName: string; + normalizedQuery: string; + normalizedTokenSymbol?: string; + tokenNameWords: string[]; +}) => { + // High relevance: Leading word in token name starts with query or exact match on symbol + if (normalizedTokenName.startsWith(normalizedQuery) || (normalizedTokenSymbol && normalizedTokenSymbol === normalizedQuery)) { + return 5; + } + + // Medium relevance: Non-leading word in token name starts with query + if (tokenNameWords.some((word, index) => index !== 0 && word.startsWith(normalizedQuery))) { + return 4; + } + + // Low relevance: Token name contains query + if (tokenNameWords.some(word => word.includes(normalizedQuery))) { + return 3; + } + + return 0; +}; + +const selectTopDiscoverSearchResults = ({ + abortController, + searchQuery, + data, +}: { + abortController: AbortController | null; + searchQuery: string; + data: SearchAsset[]; +}): DiscoverSearchResults => { + if (abortController?.signal.aborted) return NO_DISCOVER_RESULTS; + const results = data.filter(asset => { + const hasIcon = asset.icon_url; + const isMatch = hasIcon || searchQuery.length > 2; + + if (!isMatch) { + const crosschainMatch = getExactMatches([asset], searchQuery); + return crosschainMatch.length > 0; + } + + return isMatch; + }); + if (abortController?.signal.aborted) return NO_DISCOVER_RESULTS; + const topResults = searchQuery === '' ? sortForDefaultList(results) : sortTokensByRelevance(results, searchQuery); + const { verifiedAssets, highLiquidityAssets, lowLiquidityAssets } = groupBy(topResults, searchResult => { + if (searchResult.isVerified) { + return 'verifiedAssets'; + } else if (!searchResult.isVerified && searchResult.highLiquidity) { + return 'highLiquidityAssets'; + } else { + return 'lowLiquidityAssets'; + } + }); + + return { + verifiedAssets, + highLiquidityAssets, + lowLiquidityAssets, + }; +}; + function selectTopSearchResults({ abortController, data, @@ -201,6 +332,42 @@ const getImportedAsset = async (searchQuery: string, chainId: number = ChainId.m return []; }; +export async function discoverSearchQueryFunction( + { query }: DiscoverSearchParams, + abortController: AbortController | null +): Promise { + const queryParams: DiscoverSearchParams = { + query, + }; + + const isAddressSearch = query && isAddress(query); + + const searchDefaultVerifiedList = query === ''; + if (searchDefaultVerifiedList) { + queryParams.list = 'verifiedAssets'; + } + + const url = `${searchDefaultVerifiedList ? `/${ChainId.mainnet}` : ''}/?${qs.stringify(queryParams)}`; + + try { + const tokenSearch = await tokenSearchClient.get<{ data: SearchAsset[] }>(url); + + if (isAddressSearch && (tokenSearch?.data?.data?.length || 0) === 0) { + const result = await getImportedAsset(query); + return { + verifiedAssets: [], + highLiquidityAssets: [], + lowLiquidityAssets: result, + }; + } + + return selectTopDiscoverSearchResults({ abortController, data: parseTokenSearchResults(tokenSearch.data.data), searchQuery: query }); + } catch (e) { + logger.error(new RainbowError('[tokenSearchQueryFunction]: Token search failed'), { url }); + return NO_DISCOVER_RESULTS; + } +} + export async function tokenSearchQueryFunction( { chainId, query }: TokenSearchParams, abortController: AbortController | null diff --git a/src/hooks/useSearchCurrencyList.ts b/src/hooks/useSearchCurrencyList.ts index 0c9c52f6028..0c09eb927ff 100644 --- a/src/hooks/useSearchCurrencyList.ts +++ b/src/hooks/useSearchCurrencyList.ts @@ -1,6 +1,5 @@ import lang from 'i18n-js'; import { rankings } from 'match-sorter'; -import { groupBy } from 'lodash'; import { useCallback, useMemo } from 'react'; import { useTheme } from '../theme/ThemeContext'; import { addHexPrefix } from '@/handlers/web3'; @@ -10,91 +9,18 @@ import { IS_TEST } from '@/env'; import { useFavorites } from '@/resources/favorites'; import { ChainId } from '@/state/backendNetworks/types'; import { getUniqueId } from '@/utils/ethereumUtils'; -import { TokenSearchResult, useTokenSearchAllNetworks } from '@/__swaps__/screens/Swap/resources/search/search'; +import { useSwapsSearchStore, useDiscoverSearchStore } from '@/__swaps__/screens/Swap/resources/search/searchV2'; import { SearchAsset, TokenSearchAssetKey, TokenSearchThreshold } from '@/__swaps__/types/search'; import { isAddress } from '@ethersproject/address'; const MAX_VERIFIED_RESULTS = 48; -const getExactMatches = (data: TokenSearchResult, query: string) => { - const isQueryAddress = isAddress(query.trim()); - return data.filter(asset => { - if (isQueryAddress) { - return !!(asset.address?.toLowerCase() === query.trim().toLowerCase()); - } - const symbolMatch = isLowerCaseMatch(asset.symbol, query); - const nameMatch = isLowerCaseMatch(asset.name, query); - return symbolMatch || nameMatch; - }); -}; - const abcSort = (list: any[], key?: string) => { return list.sort((a, b) => { return key ? a[key]?.localeCompare(b[key]) : a?.localeCompare(b); }); }; -type SearchItemWithRelevance = SearchAsset & { - relevance: number; -}; - -const sortForDefaultList = (tokens: SearchAsset[]) => { - const curated = tokens.filter(asset => asset.highLiquidity && asset.isRainbowCurated && asset.icon_url); - return curated.sort((a, b) => (b.market?.market_cap?.value || 0) - (a.market?.market_cap?.value || 0)); -}; - -const sortTokensByRelevance = (tokens: SearchAsset[], query: string): SearchItemWithRelevance[] => { - const normalizedQuery = query.toLowerCase().trim(); - const tokenWithRelevance: SearchItemWithRelevance[] = tokens.map(token => { - const normalizedTokenName = token.name.toLowerCase(); - - const normalizedTokenSymbol = token.symbol.toLowerCase(); - const tokenNameWords = normalizedTokenName.split(' '); - const relevance = getTokenRelevance({ - token, - normalizedTokenName, - normalizedQuery, - normalizedTokenSymbol, - tokenNameWords, - }); - return { ...token, relevance }; - }); - - return tokenWithRelevance.sort((a, b) => b.relevance - a.relevance); -}; - -// higher number indicates higher relevance -const getTokenRelevance = ({ - token, - normalizedTokenName, - normalizedQuery, - normalizedTokenSymbol, - tokenNameWords, -}: { - token: SearchAsset; - normalizedTokenName: string; - normalizedQuery: string; - normalizedTokenSymbol?: string; - tokenNameWords: string[]; -}) => { - // High relevance: Leading word in token name starts with query or exact match on symbol - if (normalizedTokenName.startsWith(normalizedQuery) || (normalizedTokenSymbol && normalizedTokenSymbol === normalizedQuery)) { - return 5; - } - - // Medium relevance: Non-leading word in token name starts with query - if (tokenNameWords.some((word, index) => index !== 0 && word.startsWith(normalizedQuery))) { - return 4; - } - - // Low relevance: Token name contains query - if (tokenNameWords.some(word => word.includes(normalizedQuery))) { - return 3; - } - - return 0; -}; - const useSearchCurrencyList = (searchQuery: string) => { const searching = useMemo(() => searchQuery !== '', [searchQuery]); @@ -141,49 +67,15 @@ const useSearchCurrencyList = (searchQuery: string) => { const { colors } = useTheme(); - const selectTopSearchResults = useCallback( - (data: TokenSearchResult) => { - const results = data.filter(asset => { - const isFavorite = favoriteAddresses.map(a => a?.toLowerCase()).includes(asset.uniqueId?.toLowerCase()); - if (isFavorite) return false; - - const hasIcon = asset.icon_url; - const isMatch = hasIcon || searchQuery.length > 2; - - if (!isMatch) { - const crosschainMatch = getExactMatches([asset], searchQuery); - return crosschainMatch.length > 0; - } - - return isMatch; - }); - const topResults = searchQuery === '' ? sortForDefaultList(results) : sortTokensByRelevance(results, searchQuery); - return topResults.slice(0, MAX_VERIFIED_RESULTS); - }, - [searchQuery, favoriteAddresses] - ); - - const { data: searchResultAssets, isFetching: loading } = useTokenSearchAllNetworks( - { - query: searchQuery, - }, - { - select: selectTopSearchResults, - staleTime: 10 * 60 * 1_000, // 10 min - } - ); + // TODO JIN searchQuery, loading + const searchResultAssets = useDiscoverSearchStore(state => state.getData()); + // TODO JIN - need to filter out favorites from these results and then slice const currencyList = useMemo(() => { const list = []; - const { verifiedAssets, highLiquidityAssets, lowLiquidityAssets } = groupBy(searchResultAssets, searchResult => { - if (searchResult.isVerified) { - return 'verifiedAssets'; - } else if (!searchResult.isVerified && searchResult.highLiquidity) { - return 'highLiquidityAssets'; - } else { - return 'lowLiquidityAssets'; - } - }); + const verifiedAssets = searchResultAssets?.verifiedAssets || []; + const highLiquidityAssets = searchResultAssets?.highLiquidityAssets || []; + const lowLiquidityAssets = searchResultAssets?.lowLiquidityAssets || []; if (searching) { if (favoriteAssets?.length) {