Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] feat: Token search #13328

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
14 changes: 13 additions & 1 deletion app/components/Nav/Main/MainNavigator.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import NftDetailsFullImage from '../../Views/NftDetails/NFtDetailsFullImage';
import AccountPermissions from '../../../components/Views/AccountPermissions';
import { AccountPermissionsScreens } from '../../../components/Views/AccountPermissions/AccountPermissions.types';
import { StakeModalStack, StakeScreenStack } from '../../UI/Stake/routes';
import { AssetLoader } from '../../Views/AssetLoader';

const Stack = createStackNavigator();
const Tab = createBottomTabNavigator();
Expand Down Expand Up @@ -197,7 +198,8 @@ const TransactionsHome = () => (
</Stack.Navigator>
);

const BrowserFlow = () => (
/* eslint-disable react/prop-types */
const BrowserFlow = (props) => (
<Stack.Navigator
initialRouteName={Routes.BROWSER.VIEW}
mode={'modal'}
Expand All @@ -210,6 +212,16 @@ const BrowserFlow = () => (
component={Browser}
options={{ headerShown: false }}
/>
<Stack.Screen
name={Routes.BROWSER.ASSET_LOADER}
component={AssetLoader}
options={{ headerShown: false }}
/>
<Stack.Screen
name={Routes.BROWSER.ASSET_VIEW}
component={Asset}
initialParams={props.route.params}
/>
</Stack.Navigator>
);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export const MAX_RECENTS = 5;
export const ORDERED_CATEGORIES = ['sites', 'recents', 'favorites'];
export const ORDERED_CATEGORIES = ['sites', 'tokens', 'recents', 'favorites'];
76 changes: 47 additions & 29 deletions app/components/UI/UrlAutocomplete/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { useStyles } from '../../../component-library/hooks';
import {
UrlAutocompleteComponentProps,
FuseSearchResult,
TokenSearchResult,
AutocompleteSearchResult,
UrlAutocompleteRef,
} from './types';
import { debounce } from 'lodash';
Expand All @@ -31,7 +33,7 @@ import { Result } from './Result';

export * from './types';

const dappsWithType = dappUrlList.map(i => ({...i, type: 'sites'}));
const dappsWithType = dappUrlList.map(i => ({...i, type: 'sites'} as const));

/**
* Autocomplete list that appears when the browser url bar is focused
Expand All @@ -40,25 +42,20 @@ const UrlAutocomplete = forwardRef<
UrlAutocompleteRef,
UrlAutocompleteComponentProps
>(({ onSelect, onDismiss }, ref) => {
const [resultsByCategory, setResultsByCategory] = useState<{category: string, data: FuseSearchResult[]}[]>([]);
const hasResults = resultsByCategory.length > 0;

const browserHistory = useSelector(selectBrowserHistoryWithType);
const bookmarks = useSelector(selectBrowserBookmarksWithType);
const fuseRef = useRef<Fuse<FuseSearchResult> | null>(null);
const resultsRef = useRef<View | null>(null);
const { styles } = useStyles(styleSheet, {});

/**
* Show the results view
*/
const show = () => {
resultsRef.current?.setNativeProps({ style: { display: 'flex' } });
};
const [fuseResults, setFuseResults] = useState<FuseSearchResult[]>([]);
const [tokenResults, setTokenResults] = useState<TokenSearchResult[]>([]);
const hasResults = fuseResults.length > 0 || tokenResults.length > 0;

const resultsByCategory: {category: string, data: AutocompleteSearchResult[]}[] = useMemo(() => (
ORDERED_CATEGORIES.flatMap((category) => {
if (category === 'tokens') {
return {
category,
data: tokenResults,
};
}

const updateResults = useCallback((results: FuseSearchResult[]) => {
const newResultsByCategory = ORDERED_CATEGORIES.flatMap((category) => {
let data = results.filter((result, index, self) =>
let data = fuseResults.filter((result, index, self) =>
result.type === category &&
index === self.findIndex(r => r.url === result.url && r.type === result.type)
);
Expand All @@ -72,28 +69,48 @@ const UrlAutocomplete = forwardRef<
category,
data,
};
});
})
), [fuseResults, tokenResults]);

const browserHistory = useSelector(selectBrowserHistoryWithType);
const bookmarks = useSelector(selectBrowserBookmarksWithType);
const fuseRef = useRef<Fuse<FuseSearchResult> | null>(null);
const resultsRef = useRef<View | null>(null);
const { styles } = useStyles(styleSheet, {});

setResultsByCategory(newResultsByCategory);
}, []);
/**
* Show the results view
*/
const show = () => {
resultsRef.current?.setNativeProps({ style: { display: 'flex' } });
};

const latestSearchTerm = useRef<string | null>(null);
const search = useCallback((text: string) => {
latestSearchTerm.current = text;
if (!text) {
updateResults([
setFuseResults([
...browserHistory,
...bookmarks,
]);
setTokenResults([
{
type: 'tokens',
name: 'DEGEN',
symbol: 'DEGEN',
address: '0x4ed4e862860bed51a9570b96d89af5e1b0efefed',
chainId: '0x2105',
}
]);
return;
}
const fuseSearchResult = fuseRef.current?.search(text);
if (Array.isArray(fuseSearchResult)) {
updateResults([...fuseSearchResult]);
setFuseResults(fuseSearchResult);
} else {
updateResults([]);
setFuseResults([]);
}
}, [updateResults, browserHistory, bookmarks]);
}, [browserHistory, bookmarks]);

/**
* Debounce the search function
Expand All @@ -107,7 +124,8 @@ const UrlAutocomplete = forwardRef<
// Cancel the search
debouncedSearch.cancel();
resultsRef.current?.setNativeProps({ style: { display: 'none' } });
setResultsByCategory([]);
setFuseResults([]);
setTokenResults([]);
}, [debouncedSearch]);

const dismissAutocomplete = () => {
Expand Down Expand Up @@ -157,7 +175,7 @@ const UrlAutocomplete = forwardRef<
result={item}
onPress={() => {
hide();
onSelect(item.url);
onSelect(item);
}}
/>
), [hide, onSelect]);
Expand All @@ -177,7 +195,7 @@ const UrlAutocomplete = forwardRef<
<SectionList
contentContainerStyle={styles.contentContainer}
sections={resultsByCategory}
keyExtractor={(item) => `${item.type}-${item.url}`}
keyExtractor={(item) => `${item.type}-${item.type === 'tokens' ? `${item.chainId}-${item.address}` : item.url}`}
renderSectionHeader={renderSectionHeader}
renderItem={renderItem}
keyboardShouldPersistTaps="handled"
Expand Down
23 changes: 20 additions & 3 deletions app/components/UI/UrlAutocomplete/types.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
/**
* Props for the UrlAutocomplete component
*/

import { Hex } from '@metamask/utils';

// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type UrlAutocompleteComponentProps = {
/**
* Callback that is triggered while
* choosing one of the autocomplete options
*/
onSelect: (url: string) => void;
onSelect: (item: AutocompleteSearchResult) => void;
/**
* Callback that is triggered while
* tapping on the background
Expand Down Expand Up @@ -35,11 +38,25 @@ export type UrlAutocompleteRef = {
};

/**
* The result of an Fuse search
* The result of a Fuse search
*/
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type FuseSearchResult = {
type: 'sites' | 'recents' | 'favorites';
url: string;
name: string;
type: string;
};

/**
* The result of a token search
*/
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type TokenSearchResult = {
type: 'tokens';
name: string;
symbol: string;
address: string;
chainId: Hex;
};

export type AutocompleteSearchResult = FuseSearchResult | TokenSearchResult;
55 changes: 55 additions & 0 deletions app/components/Views/AssetLoader/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React, { useEffect } from 'react';
import { Hex } from '@metamask/utils';
import { useSelector } from 'react-redux';
import { selectSearchedToken } from '../../../selectors/tokensController';
import { RootState } from '../../../reducers';
import { ActivityIndicator, Text, View } from 'react-native';
import Engine from '../../../core/Engine/Engine';
import { StackActions, useNavigation } from '@react-navigation/native';
import Routes from '../../../constants/navigation/Routes';

export interface AssetLoaderProps {
route: {
params: {
address: string;
chainId: Hex;
}
}
}

export const AssetLoader: React.FC<AssetLoaderProps> = ({ route: { params: { address, chainId } } }) => {
const tokenResult = useSelector((state: RootState) => selectSearchedToken(state, chainId, address));

const navigation = useNavigation();

useEffect(() => {
if (!tokenResult) {
Engine.context.TokensController.addSearchedToken(address, chainId);
Copy link
Contributor

@gambinish gambinish Feb 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a user searches for a token on a network not yet added to their account, we won't have some data needed to handle certain fiat conversion throughout the app (for instance, on AsssetOverview)

From a technical perspective, this may require us to add the required chain to the NetworkController state whenever you TokensController.addSearchedToken(), so that the networkConfigurations.[chainId].nativeCurrency for the fiat conversion is also available for the UI to consume.

We will likely also want to handle the network switch on behalf of the user, so that the correct network appears below the ticker on the TabView:

Screenshot 2025-02-05 at 1 41 20 PM

Slack conversation for reference

} else if (tokenResult.found) {
navigation.dispatch(
StackActions.replace(Routes.BROWSER.ASSET_VIEW, {
...tokenResult.token,
chainId
})
);
}
}, [tokenResult, address, chainId, navigation]);

if (!tokenResult) {
return (
<View>
<ActivityIndicator size="large" />
</View>
);
}

if (!tokenResult.found) {
return (
<View>
<Text>Token not found</Text>
</View>
);
}

return null;
}
14 changes: 11 additions & 3 deletions app/components/Views/BrowserTab/BrowserTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ import { getURLProtocol } from '../../../util/general';
import { PROTOCOLS } from '../../../constants/deeplinks';
import Options from './components/Options';
import IpfsBanner from './components/IpfsBanner';
import UrlAutocomplete, { UrlAutocompleteRef } from '../../UI/UrlAutocomplete';
import UrlAutocomplete, { AutocompleteSearchResult, UrlAutocompleteRef } from '../../UI/UrlAutocomplete';
import { selectSearchEngine } from '../../../reducers/browser/selectors';

/**
Expand Down Expand Up @@ -1174,10 +1174,18 @@ export const BrowserTab: React.FC<BrowserTabProps> = ({
/**
* Handle autocomplete selection
*/
const onSelect = (url: string) => {
const onSelect = (item: AutocompleteSearchResult) => {
// Unfocus the url bar and hide the autocomplete results
urlBarRef.current?.hide();
onSubmitEditing(url);

if (item.type === 'tokens') {
navigation.navigate(Routes.BROWSER.ASSET_LOADER, {
chainId: item.chainId,
address: item.address,
});
} else {
onSubmitEditing(item.url);
}
};

/**
Expand Down
2 changes: 2 additions & 0 deletions app/constants/navigation/Routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ const Routes = {
BROWSER: {
HOME: 'BrowserTabHome',
VIEW: 'BrowserView',
ASSET_LOADER: 'AssetLoader',
ASSET_VIEW: 'AssetView',
},
WEBVIEW: {
MAIN: 'Webview',
Expand Down
4 changes: 2 additions & 2 deletions app/selectors/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ interface SiteItem {

export const selectBrowserHistoryWithType = createDeepEqualSelector(
(state: RootState) => state.browser.history,
(history: SiteItem[]) => history.map(item => ({...item, type: 'recents'})).reverse()
(history: SiteItem[]) => history.map(item => ({...item, type: 'recents'} as const)).reverse()
);

export const selectBrowserBookmarksWithType = createDeepEqualSelector(
(state: RootState) => state.bookmarks,
(bookmarks: SiteItem[]) => bookmarks.map(item => ({...item, type: 'favorites'}))
(bookmarks: SiteItem[]) => bookmarks.map(item => ({...item, type: 'favorites'} as const))
);
9 changes: 9 additions & 0 deletions app/selectors/tokensController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,12 @@ export const selectTransformedTokens = createSelector(
return flatList;
},
);

export const selectSearchedToken = createSelector(
selectTokensControllerState,
(_state: RootState, chainId: Hex) => chainId,
(_state: RootState, _chainId: Hex, address: string) => address,
(tokensControllerState: TokensControllerState, chainId: Hex, address: string) => (
tokensControllerState.allSearchedTokens.find((token) => token.chainId === chainId && token.address === address)
),
);
3 changes: 2 additions & 1 deletion locales/languages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@
"placeholder": "Search by site or address",
"recents": "Recents",
"favorites": "Favorites",
"sites": "Sites"
"sites": "Sites",
"tokens": "Tokens"
},
"navigation": {
"back": "Back",
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@
"sha256-uint8array": "0.10.3",
"express": "4.21.2",
"nanoid": "^3.3.8",
"undici": "5.28.5"
"undici": "5.28.5",
"@metamask/assets-controllers": "file:../metamask-core/packages/assets-controllers"
},
"dependencies": {
"@config-plugins/detox": "^8.0.0",
Expand Down
Loading