From 2ae45e581c284cd0d0e2e525b869eb9a876ac700 Mon Sep 17 00:00:00 2001 From: Bryan Fullam Date: Mon, 10 Nov 2025 05:14:43 -0300 Subject: [PATCH 1/8] fix: trigger rewards animation value update on state transition cp-7.59.0 (#22351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** **Problem** The rewards points animation was inconsistently displaying 0 in the UI despite estimatedPoints containing the correct value. This occurred when transitioning from Loading to Idle state because the animation effect only triggers when the value prop changes, not when the animation state changes. When Loading state set animatedValue to 0 and then transitioned to Idle with the same estimatedPoints value, the display remained stuck at 0. **Solution** Added synchronization logic in handleIdleState and handleRefreshFinishedState to ensure animatedValue is always updated to match the current value when entering these states. This guarantees the display correctly animates to the actual points value regardless of whether the value prop changed during state transitions. The fix resolves the race condition between animation state changes and value updates. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/22364 ## **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** https://github.com/user-attachments/assets/e435e3ba-ec00-4ef7-b63d-61ece252876a ### **After** https://github.com/user-attachments/assets/a7945403-e93a-43f9-adbc-f581ade567ff ## **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] > Synchronizes `animatedValue` on Idle and RefreshFinished state entries to avoid the points display sticking at 0; updates callback dependencies accordingly. > > - **UI/Rewards** (`app/components/UI/Rewards/hooks/useRewardsAnimation.ts`): > - **State entry sync**: > - On `Idle` and `RefreshFinished`, ensure `animatedValue` animates to the current `value` if mismatched, preventing a stuck 0 after `Loading`. > - **Animation handling**: > - Repositions fox via `rivePosition` on state transitions; triggers appropriate Rive animations. > - **Hook stability**: > - Expands `useCallback` dependencies (e.g., `animatedValue`, `value`, `rivePosition`) for updated handlers. > - Adds `/* istanbul ignore next */` around Reanimated shared value mutations. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 37f3f6ca6164ae9669292fc321281cffdfb74279. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Rewards/hooks/useRewardsAnimation.ts | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/app/components/UI/Rewards/hooks/useRewardsAnimation.ts b/app/components/UI/Rewards/hooks/useRewardsAnimation.ts index 0571bada65b..e711c74626e 100644 --- a/app/components/UI/Rewards/hooks/useRewardsAnimation.ts +++ b/app/components/UI/Rewards/hooks/useRewardsAnimation.ts @@ -198,6 +198,16 @@ export const useRewardsAnimation = ({ duration: ANIMATION_DURATION.FAST, }); + // Ensure animated value is set to current value when entering Idle state + // This fixes the issue where transitioning from Loading to Idle with the same value + // would leave the display stuck at 0 + /* istanbul ignore next - Reanimated shared value mutation not tracked by Istanbul */ + if (animatedValue.value !== currentValue) { + animatedValue.value = withTiming(currentValue, { + duration: ANIMATION_DURATION.FAST, + }); + } + // Only trigger Rive animation if value changed if (currentValue !== previousValue) { const trigger = @@ -208,7 +218,13 @@ export const useRewardsAnimation = ({ triggerRiveAnimation(trigger); previousValueRef.current = currentValue; } - }, [value, rivePosition, triggerRiveAnimation, clearAllTimeouts]); + }, [ + value, + rivePosition, + triggerRiveAnimation, + clearAllTimeouts, + animatedValue, + ]); const handleRefreshLoadingState = useCallback(() => { if (!riveRef.current) return; @@ -250,8 +266,23 @@ export const useRewardsAnimation = ({ duration: ANIMATION_DURATION.FAST, }); + // Ensure animated value is set to current value when entering RefreshFinished state + // This ensures the display shows the correct value after a refresh + /* istanbul ignore next - Reanimated shared value mutation not tracked by Istanbul */ + if (animatedValue.value !== value) { + animatedValue.value = withTiming(value, { + duration: ANIMATION_DURATION.FAST, + }); + } + triggerRiveAnimation(RewardsIconTriggers.RefreshLeft); - }, [triggerRiveAnimation, clearAllTimeouts, rivePosition]); + }, [ + triggerRiveAnimation, + clearAllTimeouts, + rivePosition, + animatedValue, + value, + ]); // State machine effect - triggers appropriate animation based on state useEffect(() => { From 0c623c3ddba33ba2ec1f1a9115831262f37fe6b2 Mon Sep 17 00:00:00 2001 From: javiergarciavera <76975121+javiergarciavera@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:53:33 +0100 Subject: [PATCH 2/8] test: Mmqa fix perf flakiness (#22192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **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] > Reduces flakiness in send flow perf tests by adding Android-specific selectors, extending timeouts, and cleaning up unused options. > > - **Tests (performance)**: > - Increase test timeout to 30 min in `appwright/tests/performance/login/send-flows.spec.js`. > - **Screen Objects**: > - `wdio/screen-objects/SendScreen.js`: > - Platform-specific `searchTokenField` selector (iOS: catch-all; Android: `textfieldsearch`). > - Platform-specific first token badge selection (iOS: XPath; Android: `badge-wrapper-badge` ID). > - **Utils**: > - `appwright/utils/Flows.js`: > - Remove unused `skipIntro` option from `login`. > - Increase `dismissMultichainAccountsIntroModal` timeout to 10s. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8082395e19a8a610e81a4f879d4aa126b60c1b65. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- appwright/tests/performance/login/send-flows.spec.js | 3 +-- appwright/utils/Flows.js | 4 ++-- wdio/screen-objects/SendScreen.js | 8 ++++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/appwright/tests/performance/login/send-flows.spec.js b/appwright/tests/performance/login/send-flows.spec.js index ba2f2681c91..a9f1dd8ee1d 100644 --- a/appwright/tests/performance/login/send-flows.spec.js +++ b/appwright/tests/performance/login/send-flows.spec.js @@ -23,6 +23,7 @@ test('Send flow - Ethereum, SRP 1 + SRP 2 + SRP 3', async ({ device, performanceTracker, }, testInfo) => { + test.setTimeout(1800000); // TODO: Investigate why this is taking so long on Android WalletAccountModal.device = device; WalletMainScreen.device = device; AccountListComponent.device = device; @@ -53,14 +54,12 @@ test('Send flow - Ethereum, SRP 1 + SRP 2 + SRP 3', async ({ await SendScreen.assetsListIsDisplayed(); timer1.stop(); await SendScreen.typeTokenName('Link\n'); - console.log('Ethereum typed, so waiting 5 seconds'); await SendScreen.clickOnFirstTokenBadge(); timer2.start(); await AmountScreen.isVisible(); timer2.stop(); await AmountScreen.enterAmount(TEST_AMOUNTS.ETHEREUM); - await AmountScreen.tapOnNextButton(); timer3.start(); await SendScreen.isSelectAddressScreenDisplayed(); diff --git a/appwright/utils/Flows.js b/appwright/utils/Flows.js index dc9f8ecac62..e05580b5a94 100644 --- a/appwright/utils/Flows.js +++ b/appwright/utils/Flows.js @@ -115,7 +115,7 @@ export async function importSRPFlow(device, srp) { export async function login(device, options = {}) { LoginScreen.device = device; - const { skipIntro = false, scenarioType = 'login' } = options; + const { scenarioType = 'login' } = options; const password = getPasswordForScenario(scenarioType); @@ -149,7 +149,7 @@ export async function dismissRewardsBottomSheetModal(device) { export async function dismissMultichainAccountsIntroModal( device, - timeout = 5000, + timeout = 10000, ) { MultichainAccountEducationModal.device = device; const closeButton = await MultichainAccountEducationModal.closeButton; diff --git a/wdio/screen-objects/SendScreen.js b/wdio/screen-objects/SendScreen.js index 3037ae4a4a8..2856da16c1c 100644 --- a/wdio/screen-objects/SendScreen.js +++ b/wdio/screen-objects/SendScreen.js @@ -81,7 +81,11 @@ class SendScreen { } get searchTokenField() { - return AppwrightSelectors.getElementByCatchAll(this._device, 'Search tokens and NFTs'); + if (AppwrightSelectors.isIOS(this._device)) { + return AppwrightSelectors.getElementByCatchAll(this._device, 'Search tokens and NFTs'); + } else { + return AppwrightSelectors.getElementByID(this._device, 'textfieldsearch'); + } } @@ -203,7 +207,7 @@ class SendScreen { } async clickOnFirstTokenBadge() { - const firstTokenBadge = await AppwrightSelectors.getElementByXpath(this._device, `//XCUIElementTypeOther[@name="badge-wrapper-badge"]`); + const firstTokenBadge = AppwrightSelectors.isIOS(this._device) ? await AppwrightSelectors.getElementByXpath(this._device, `//XCUIElementTypeOther[@name="badge-wrapper-badge"]`) : await AppwrightSelectors.getElementByID(this._device, 'badge-wrapper-badge'); appwrightExpect(firstTokenBadge).toBeVisible(); await AppwrightGestures.tap(firstTokenBadge); } From 25e5d7d70edcf6cfe0cc94423e0b4dc46a569bd1 Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:03:45 +0100 Subject: [PATCH 3/8] feat: support importing and ignoring non-evm tokens (#22103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The feature to hide tokens is available for EVM assets but not for non-EVM assets. This task involves implementing a similar token hiding feature for non-EVM assets. This is crucial for improving user experience by allowing users to hide unwanted tokens, especially in light of spam and malicious token issues. The implementation should be prioritized to align with upcoming Solana campaigns. Furthermore, having the possibility of hiding tokens is not great if you cannot add tokens back. Therefore I have implemented the whole logic to add non-VEVM tokens to your wallet and remove them from ignored tokens list. In order to get this done the following PRs have been merged into Core: - Support for adding non-evm tokens [here](https://github.com/MetaMask/core/pull/7016) - Support for ignoring non-evm tokens [here](https://github.com/MetaMask/core/pull/6981) Furthermore this PR solves a couple of bugs: - Flashy behavior on the import token header when opening import token screen - Already added tokens could be re-added which was incorrect, they have been disabled now ## **Changelog** CHANGELOG entry: support importing and ignoring non-evm tokens ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-1425 ## **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** https://github.com/user-attachments/assets/77de0410-53b3-43cc-a6e4-2951db6b2b32 ### **After** https://github.com/user-attachments/assets/629c34bd-e0a9-4eef-bf3c-ec03be874b1a ## **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] > Enables importing and hiding non‑EVM tokens, updates Add Asset/search UI to work across EVM/non‑EVM networks, and improves token removal, explorer links, and already‑added token handling. > > - **Non‑EVM token support**: > - Add tokens via `MultichainAssetsController.addAssets` in `SearchTokenAutocomplete`; retain EVM via `TokensController.addTokens`. > - New `removeNonEvmToken` util to hide non‑EVM tokens; integrate in `Tokens` and `AssetOptions`. > - Disable re‑adding already‑added tokens (EVM via `selectTokensByChainIdAndAddress`, non‑EVM via `selectMultichainAssets`). > - **Add Asset flow**: > - Use `useTopTokens` to source token lists; show loading state; only fetch token list for EVM. > - Render custom Token/NFT tabs only on EVM networks; support non‑EVM search/import. > - Network selector filters to EVM when not adding tokens. > - **Search and lists**: > - Refactor `AssetSearch` to `useTokenSearch` with `allTokens` prop and debounced updates. > - `MultiAssetListItems` switches to `FlashList`, shows network badges, and disables already‑added tokens. > - **Navigation/UI**: > - Update Import screens to use `getImportTokenNavbarOptions` (BottomSheetHeader). > - Enable "Add token" button on non‑EVM networks in `TokenListControlBar`. > - **Asset details**: > - `AssetOptions` handles CAIP addresses for explorers, hides "Remove token" for native/wSOL, and supports non‑EVM removal with notifications/metrics. > - `ConfirmAddAsset` balances support non‑EVM addresses (hexified), and header updated. > - **Tests**: > - Extensive unit/snapshot updates for new behaviors across search, add, list, confirm, tokens, and asset options. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f43d635e184c22c6902880f3380e7494843237ce. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Kylan Hurt <6249205+smilingkylan@users.noreply.github.com> Co-authored-by: Luis Taniça Co-authored-by: Caainã Jeronimo Co-authored-by: Kevin Bluer --- app/components/Nav/Main/MainNavigator.js | 17 +- .../__snapshots__/MainNavigator.test.tsx.snap | 6 +- .../__snapshots__/index.test.tsx.snap | 5 +- app/components/UI/AssetSearch/index.test.tsx | 111 ++- app/components/UI/AssetSearch/index.tsx | 91 +-- .../UI/Bridge/hooks/useTopTokens/index.ts | 23 +- .../ConfirmAddAsset/ConfirmAddAsset.test.tsx | 139 +++- .../UI/ConfirmAddAsset/ConfirmAddAsset.tsx | 128 ++-- .../MultiAssetListItems.test.tsx | 154 +++- .../MultiAssetListItems.tsx | 71 +- .../MultiAssetListItems.test.tsx.snap | 62 +- app/components/UI/Navbar/index.js | 82 +- .../__snapshots__/index.test.tsx.snap | 705 +++++++++++++++--- .../UI/SearchTokenAutocomplete/index.test.tsx | 563 +++++++++++++- .../UI/SearchTokenAutocomplete/index.tsx | 210 ++++-- .../TokenListControlBar.test.tsx | 4 +- .../TokenListControlBar.tsx | 2 - app/components/UI/Tokens/index.test.tsx | 117 ++- app/components/UI/Tokens/index.tsx | 88 +-- app/components/UI/Tokens/util/index.ts | 1 + .../UI/Tokens/util/removeNonEvmToken.test.ts | 95 +++ .../UI/Tokens/util/removeNonEvmToken.ts | 33 + .../Views/AddAsset/AddAsset.test.tsx | 366 +++++---- app/components/Views/AddAsset/AddAsset.tsx | 180 ++--- .../__snapshots__/AddAsset.test.tsx.snap | 430 ++++++----- .../components/NetworkListBottomSheet.tsx | 9 +- .../Views/AssetOptions/AssetOptions.test.tsx | 526 +++++++++++-- .../Views/AssetOptions/AssetOptions.tsx | 97 +-- .../__snapshots__/AssetOptions.test.tsx.snap | 145 ---- 29 files changed, 3214 insertions(+), 1246 deletions(-) create mode 100644 app/components/UI/Tokens/util/removeNonEvmToken.test.ts create mode 100644 app/components/UI/Tokens/util/removeNonEvmToken.ts diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 30161217c0d..8b30f6fa537 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -128,6 +128,13 @@ import RewardsClaimBottomSheetModal from '../../UI/Rewards/components/Tabs/Level import RewardOptInAccountGroupModal from '../../UI/Rewards/components/Settings/RewardOptInAccountGroupModal'; import ReferralBottomSheetModal from '../../UI/Rewards/components/ReferralBottomSheetModal'; import { selectRewardsSubscriptionId } from '../../../selectors/rewards'; +import { getImportTokenNavbarOptions } from '../../UI/Navbar'; +import { + TOKEN_TITLE, + NFT_TITLE, + TOKEN, +} from '../../Views/AddAsset/AddAsset.constants'; +import { strings } from '../../../../locales/i18n'; const Stack = createStackNavigator(); const Tab = createBottomTabNavigator(); @@ -988,7 +995,15 @@ const MainNavigator = () => { ({ + ...getImportTokenNavbarOptions( + navigation, + strings( + `add_asset.${route.params?.assetType === TOKEN ? TOKEN_TITLE : NFT_TITLE}`, + ), + ), + headerShown: true, + })} /> ({ init: () => mockedEngine.init({}), context: { @@ -60,20 +64,56 @@ const initialState = { }; describe('AssetSearch', () => { - it('renders correctly with selected chain', () => { + beforeEach(() => { + jest.clearAllTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + const mockAllTokens = [ + { + address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', + symbol: 'SNX', + decimals: 18, + chainId: '0x1' as const, + }, + ]; + + it('renders correctly with allTokens', () => { const { toJSON } = renderWithProvider( , { state: initialState }, ); expect(toJSON()).toMatchSnapshot(); }); - it('calls onSearch when clear button is pressed', () => { + it('calls onSearch on mount with initial empty results and search query', () => { + const onSearch = jest.fn(); + + renderWithProvider( + , + { state: initialState }, + ); + + expect(onSearch).toHaveBeenCalledWith({ + results: [], + searchQuery: '', + }); + }); + + it('calls onSearch when clear button is pressed with empty results and search query', () => { const onSearch = jest.fn(); const { getByTestId } = renderWithProvider( @@ -81,29 +121,86 @@ describe('AssetSearch', () => { onSearch={onSearch} onFocus={jest.fn} onBlur={jest.fn} - selectedChainId={'0x1'} + allTokens={mockAllTokens} />, { state: initialState }, ); + // Clear initial mount call + onSearch.mockClear(); + + // First, set a search value + const searchBar = getByTestId(ImportTokenViewSelectorsIDs.SEARCH_BAR); + fireEvent.changeText(searchBar, 'SNX'); + + // Advance timers to trigger the debounce (300ms default) + act(() => { + jest.advanceTimersByTime(500); + }); + + // Wait for the search to complete and clear previous calls + expect(onSearch).toHaveBeenCalled(); + onSearch.mockClear(); + + // Now clear the search const clearSearchBar = getByTestId( ImportTokenViewSelectorsIDs.CLEAR_SEARCH_BAR, ); fireEvent.press(clearSearchBar); - expect(onSearch).toHaveBeenCalled(); + // Advance timers to trigger the debounce and useEffect + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(onSearch).toHaveBeenCalledWith( + expect.objectContaining({ + results: [], + searchQuery: '', + }), + ); }); - it('renders with null selectedChainId', () => { + it('renders with empty allTokens array', () => { const { toJSON } = renderWithProvider( , { state: initialState }, ); expect(toJSON()).toBeDefined(); }); + + it('calls onSearch with searchResults and debouncedSearchString when search text changes', () => { + const onSearch = jest.fn(); + + const { getByTestId } = renderWithProvider( + , + { state: initialState }, + ); + + onSearch.mockClear(); + + const searchBar = getByTestId(ImportTokenViewSelectorsIDs.SEARCH_BAR); + fireEvent.changeText(searchBar, 'SNX'); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(onSearch).toHaveBeenCalledWith( + expect.objectContaining({ + results: expect.any(Array), + searchQuery: 'SNX', + }), + ); + }); }); diff --git a/app/components/UI/AssetSearch/index.tsx b/app/components/UI/AssetSearch/index.tsx index ca639468e12..b8a4e27f674 100644 --- a/app/components/UI/AssetSearch/index.tsx +++ b/app/components/UI/AssetSearch/index.tsx @@ -1,15 +1,9 @@ -import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import React, { useEffect, useState } from 'react'; import { TextInput, View, StyleSheet, TextStyle } from 'react-native'; -import { Hex } from '@metamask/utils'; import { fontStyles } from '../../../styles/common'; import { strings } from '../../../../locales/i18n'; -import Fuse from 'fuse.js'; -import { toLowerCaseEquals } from '../../../util/general'; -import { useSelector } from 'react-redux'; -import { TokenListToken } from '@metamask/assets-controllers'; import { useTheme } from '../../../util/theme'; import { ImportTokenViewSelectorsIDs } from '../../../../e2e/selectors/wallet/ImportTokenView.selectors'; -import { selectERC20TokensByChain } from '../../../selectors/tokenListController'; import Icon, { IconName, IconSize, @@ -17,14 +11,14 @@ import Icon, { import ButtonIcon, { ButtonIconSizes, } from '../../../component-library/components/Buttons/ButtonIcon'; +import { BridgeToken } from '../Bridge/types'; +import { useTokenSearch } from '../Bridge/hooks/useTokenSearch'; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any const createStyles = (colors: any) => StyleSheet.create({ searchSection: { - marginTop: 16, - flex: 1, flexDirection: 'row', justifyContent: 'center', alignItems: 'center', @@ -34,9 +28,7 @@ const createStyles = (colors: any) => color: colors.text.default, }, searchSectionFocused: { - marginTop: 16, marginBottom: 0, - flex: 1, flexDirection: 'row', justifyContent: 'center', alignItems: 'center', @@ -48,7 +40,6 @@ const createStyles = (colors: any) => textInput: { ...fontStyles.normal, color: colors.text.default, - flex: 1, } as TextStyle, icon: { paddingLeft: 20, @@ -67,25 +58,12 @@ const createStyles = (colors: any) => }, }); -const fuse = new Fuse([], { - shouldSort: true, - threshold: 0.45, - location: 0, - distance: 100, - maxPatternLength: 32, - minMatchCharLength: 1, - keys: [ - { name: 'name', weight: 0.5 }, - { name: 'symbol', weight: 0.5 }, - ], -}); - interface Props { onSearch: ({ results, searchQuery, }: { - results: TokenListToken[]; + results: BridgeToken[]; searchQuery: string; }) => void; /** @@ -100,57 +78,27 @@ interface Props { /** * The selected network chain ID */ - selectedChainId: Hex | null; + allTokens: BridgeToken[]; } // eslint-disable-next-line react/display-name -const AssetSearch = ({ onSearch, onFocus, onBlur, selectedChainId }: Props) => { - const [searchQuery, setSearchQuery] = useState(''); +const AssetSearch = ({ onSearch, onFocus, onBlur, allTokens }: Props) => { const [isFocus, setIsFocus] = useState(false); - const tokenListForAllChains = useSelector(selectERC20TokensByChain); const { colors, themeAppearance } = useTheme(); const styles = createStyles(colors); - const tokenList = useMemo(() => { - // If no network is selected, return empty list - if (!selectedChainId) { - return []; - } - - // Use the selected network's tokens - return Object.values( - tokenListForAllChains?.[selectedChainId]?.data ?? [], - ).map((item) => ({ - ...item, - chainId: selectedChainId, - })); - }, [selectedChainId, tokenListForAllChains]); - - // Update fuse list - useEffect(() => { - if (Array.isArray(tokenList)) { - fuse.setCollection(tokenList); - } - }, [tokenList]); - - const handleSearch = useCallback( - (searchText: string) => { - setSearchQuery(searchText); - const fuseSearchResult = fuse.search(searchText); - const addressSearchResult = tokenList?.filter((token: TokenListToken) => - toLowerCaseEquals(token.address, searchText), - ); - const results = [...addressSearchResult, ...fuseSearchResult]; - onSearch({ searchQuery: searchText, results }); - }, - [setSearchQuery, onSearch, tokenList], - ); + const { + searchString, + setSearchString, + searchResults, + debouncedSearchString, + } = useTokenSearch({ + tokens: allTokens || [], + }); useEffect(() => { - setSearchQuery(''); - handleSearch(''); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedChainId]); + onSearch({ results: searchResults, searchQuery: debouncedSearchString }); + }, [searchResults, debouncedSearchString, onSearch]); return ( { { onFocus(); setIsFocus(true); @@ -175,7 +123,7 @@ const AssetSearch = ({ onSearch, onFocus, onBlur, selectedChainId }: Props) => { }} placeholder={strings('token.search_tokens_placeholder')} placeholderTextColor={colors.text.muted} - onChangeText={handleSearch} + onChangeText={(searchText) => setSearchString(searchText)} testID={ImportTokenViewSelectorsIDs.SEARCH_BAR} keyboardAppearance={themeAppearance} /> @@ -186,8 +134,7 @@ const AssetSearch = ({ onSearch, onFocus, onBlur, selectedChainId }: Props) => { size={ButtonIconSizes.Sm} iconName={IconName.Close} onPress={() => { - setSearchQuery(''); - handleSearch(''); + setSearchString(''); }} testID={ImportTokenViewSelectorsIDs.CLEAR_SEARCH_BAR} /> diff --git a/app/components/UI/Bridge/hooks/useTopTokens/index.ts b/app/components/UI/Bridge/hooks/useTopTokens/index.ts index ff99a0e6d02..647208f7d54 100644 --- a/app/components/UI/Bridge/hooks/useTopTokens/index.ts +++ b/app/components/UI/Bridge/hooks/useTopTokens/index.ts @@ -99,14 +99,21 @@ export const useTopTokens = ({ } => { const swapsChainCache: SwapsControllerState['chainCache'] = useSelector(selectChainCache); - const swapsTopAssets = useMemo( - () => (chainId ? swapsChainCache[chainId]?.topAssets : null), - [chainId, swapsChainCache], - ); - // For non-EVM chains, we don't need to fetch top assets from the Swaps API - const swapsTopAssetsPending = isCaipChainId(chainId) - ? false - : !swapsTopAssets; + const { swapsTopAssets, swapsTopAssetsPending } = useMemo(() => { + if (!chainId) { + return { swapsTopAssets: null, swapsTopAssetsPending: true }; + } + + // For non-EVM chains, we don't need to fetch top assets from the Swaps API + if (isCaipChainId(chainId)) { + return { swapsTopAssets: null, swapsTopAssetsPending: false }; + } + + return { + swapsTopAssets: swapsChainCache[chainId]?.topAssets || null, + swapsTopAssetsPending: false, + }; + }, [chainId, swapsChainCache]); // Get cached tokens from TokenListController const cachedEvmTokensByChain = useSelector(selectERC20TokensByChain); diff --git a/app/components/UI/ConfirmAddAsset/ConfirmAddAsset.test.tsx b/app/components/UI/ConfirmAddAsset/ConfirmAddAsset.test.tsx index 56d9cc51a67..930489eebd0 100644 --- a/app/components/UI/ConfirmAddAsset/ConfirmAddAsset.test.tsx +++ b/app/components/UI/ConfirmAddAsset/ConfirmAddAsset.test.tsx @@ -16,6 +16,20 @@ import { useParams } from '../../../util/navigation/navUtils'; import { TESTID_BOTTOMSHEETFOOTER_BUTTON_SUBSEQUENT } from '../../../component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter.constants'; import Routes from '../../../constants/navigation/Routes'; +const mockIsNonEvmAddress = jest.fn(); +const mockToHex = jest.fn(); + +jest.mock('../../../core/Multichain/utils', () => ({ + ...jest.requireActual('../../../core/Multichain/utils'), + isNonEvmAddress: (address: string) => mockIsNonEvmAddress(address), +})); + +jest.mock('../../../core/Delegation/utils', () => ({ + ...jest.requireActual('../../../core/Delegation/utils'), + toHex: (value: string | number | boolean | Buffer | Uint8Array) => + mockToHex(value), +})); + const mockSetOptions = jest.fn(); const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); @@ -41,7 +55,7 @@ jest.mock('../../../util/navigation/navUtils', () => ({ address: '0xdac17f958d2ee523a2206206994597c13d831ec7', symbol: 'USDT', name: 'Tether USD', - iconUrl: 'https://example.com/usdt.png', + image: 'https://example.com/usdt.png', decimals: 18, chainId: '0x1', }, @@ -98,6 +112,12 @@ const mockInitialState: DeepPartial = { }; describe('ConfirmAddAsset', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsNonEvmAddress.mockReturnValue(false); + mockToHex.mockImplementation((val) => val); + }); + it('render matches previous snapshot', () => { const wrapper = renderWithProvider(, { state: mockInitialState, @@ -126,12 +146,125 @@ describe('ConfirmAddAsset', () => { ).toBeOnTheScreen(); }); - it('should call addTokenList and navigate when confirm button is pressed', async () => { + it('calls useBalance with original address for EVM addresses', () => { + const mockAsset = { + address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + symbol: 'USDT', + name: 'Tether USD', + image: 'https://example.com/usdt.png', + decimals: 18, + chainId: '0x1', + }; + + mockIsNonEvmAddress.mockReturnValue(false); + + (useParams as jest.Mock).mockReturnValue({ + selectedAsset: [mockAsset], + networkName: 'Ethereum Main Network', + addTokenList: mockAddTokenList, + }); + + renderWithProvider(, { + state: mockInitialState, + }); + + expect(mockIsNonEvmAddress).toHaveBeenCalledWith(mockAsset.address); + expect(mockToHex).not.toHaveBeenCalled(); + expect(useBalance).toHaveBeenCalledWith({ + address: mockAsset.address, + decimals: mockAsset.decimals, + }); + }); + + it('calls useBalance with hex converted address for non-EVM addresses', () => { + const mockNonEvmAddress = 'solana_address_123'; + const mockConvertedHex = '0x1234567890abcdef'; + const mockAsset = { + address: mockNonEvmAddress, + symbol: 'SOL', + name: 'Solana', + image: 'https://example.com/sol.png', + decimals: 9, + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + }; + + mockIsNonEvmAddress.mockReturnValue(true); + mockToHex.mockReturnValue(mockConvertedHex); + + (useParams as jest.Mock).mockReturnValue({ + selectedAsset: [mockAsset], + networkName: 'Solana', + addTokenList: mockAddTokenList, + }); + + renderWithProvider(, { + state: mockInitialState, + }); + + expect(mockIsNonEvmAddress).toHaveBeenCalledWith(mockNonEvmAddress); + expect(mockToHex).toHaveBeenCalledWith(mockNonEvmAddress); + expect(useBalance).toHaveBeenCalledWith({ + address: mockConvertedHex, + decimals: mockAsset.decimals, + }); + }); + + it('renders AssetIcon when asset has image property', () => { + const mockAsset = { + address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + symbol: 'USDT', + name: 'Tether USD', + image: 'https://example.com/usdt.png', + decimals: 18, + chainId: '0x1', + }; + + (useParams as jest.Mock).mockReturnValue({ + selectedAsset: [mockAsset], + networkName: 'Ethereum Main Network', + addTokenList: mockAddTokenList, + }); + + const { getByText } = renderWithProvider(, { + state: mockInitialState, + }); + + // Asset should render with name and symbol when image exists + expect(getByText('Tether USD')).toBeOnTheScreen(); + expect(getByText('USDT')).toBeOnTheScreen(); + expect(useBalance).toHaveBeenCalled(); + }); + + it('does not render AssetIcon when asset image is missing', () => { + const mockAsset = { + address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + symbol: 'USDT', + name: 'Tether USD', + decimals: 18, + chainId: '0x1', + }; + + (useParams as jest.Mock).mockReturnValue({ + selectedAsset: [mockAsset], + networkName: 'Ethereum Main Network', + addTokenList: mockAddTokenList, + }); + + const { getByText } = renderWithProvider(, { + state: mockInitialState, + }); + + // Asset should still render with name and symbol + expect(getByText('Tether USD')).toBeOnTheScreen(); + expect(getByText('USDT')).toBeOnTheScreen(); + }); + + it('calls addTokenList and navigate when confirm button is pressed', async () => { const mockAsset = { address: '0xdac17f958d2ee523a2206206994597c13d831ec7', symbol: 'USDT', name: 'Tether USD', - iconUrl: 'https://example.com/usdt.png', + image: 'https://example.com/usdt.png', decimals: 18, chainId: '0x1', }; diff --git a/app/components/UI/ConfirmAddAsset/ConfirmAddAsset.tsx b/app/components/UI/ConfirmAddAsset/ConfirmAddAsset.tsx index 93470ac2d7e..13d637497d4 100644 --- a/app/components/UI/ConfirmAddAsset/ConfirmAddAsset.tsx +++ b/app/components/UI/ConfirmAddAsset/ConfirmAddAsset.tsx @@ -35,21 +35,30 @@ import { ImportTokenViewSelectorsIDs } from '../../../../e2e/selectors/wallet/Im import { TOKEN_TITLE } from '../../../components/Views/AddAsset/AddAsset.constants'; import { Hex } from '@metamask/utils'; import { NetworkBadgeSource } from '../AssetOverview/Balance/Balance'; +import { BridgeToken } from '../Bridge/types'; +import { toHex } from '../../../core/Delegation/utils'; +import { isNonEvmAddress } from '../../../core/Multichain/utils'; -const RenderBalance = (asset: { - symbol: string; - address: string; - iconUrl: string; - name: string; - decimals: number; -}) => { +const RenderBalance = ( + asset: + | BridgeToken + | { + symbol: string; + address: string; + iconUrl: string; + name: string; + decimals: number; + }, +) => { const { colors } = useTheme(); const styles = createStyles(colors); const { balanceFiat } = useBalance( asset ? { - address: asset.address, + address: isNonEvmAddress(asset.address) + ? toHex(asset.address) + : asset.address, decimals: asset.decimals, } : undefined, @@ -68,10 +77,20 @@ const RenderBalance = (asset: { }; const ConfirmAddAsset = () => { - const { selectedAsset, networkName, addTokenList } = - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - useParams(); + const { selectedAsset, networkName, addTokenList } = useParams<{ + selectedAsset: + | BridgeToken[] + | { + symbol: string; + address: string; + iconUrl: string; + name: string; + decimals: number; + chainId: Hex; + }[]; + networkName: string; + addTokenList: () => void; + }>(); const { colors } = useTheme(); const styles = createStyles(colors); @@ -94,16 +113,12 @@ const ConfirmAddAsset = () => { const updateNavBar = useCallback(() => { navigation.setOptions( getImportTokenNavbarOptions( - `add_asset.${TOKEN_TITLE}`, - false, navigation, - colors, - true, - 0, + strings(`add_asset.${TOKEN_TITLE}`), () => setShowExitModal(true), ), ); - }, [colors, navigation]); + }, [navigation]); useEffect(() => { updateNavBar(); @@ -165,48 +180,47 @@ const ConfirmAddAsset = () => { {strings('wallet.import_token')} - {selectedAsset?.map( - ( - asset: { - symbol: string; - address: string; - iconUrl: string; - name: string; - decimals: number; - chainId: string; - }, - i: number, - ) => ( - - - - } - > - ( + + + - - + } + > + {(() => { + const assetImage = 'image' in asset ? asset.image : undefined; + const assetIconUrl = + 'iconUrl' in asset ? asset.iconUrl : undefined; + const logo = assetImage || assetIconUrl; - - {asset.name} - - {asset.symbol} - - - + return ( + logo && ( + + ) + ); + })()} + - ), - )} + + + {asset.name} + + {asset.symbol} + + + + + ))} diff --git a/app/components/UI/MultiAssetListItems/MultiAssetListItems.test.tsx b/app/components/UI/MultiAssetListItems/MultiAssetListItems.test.tsx index 6c00586ae18..d22e87cab4f 100644 --- a/app/components/UI/MultiAssetListItems/MultiAssetListItems.test.tsx +++ b/app/components/UI/MultiAssetListItems/MultiAssetListItems.test.tsx @@ -3,6 +3,7 @@ import { render } from '@testing-library/react-native'; import MultiAssetListItems from './MultiAssetListItems'; import { useSelector } from 'react-redux'; import { selectProviderConfig } from '../../../selectors/networkController'; +import { ImportTokenViewSelectorsIDs } from '../../../../e2e/selectors/wallet/ImportTokenView.selectors'; const mockProviderConfig = { type: 'mainnet', @@ -13,8 +14,9 @@ const mockSearchResults = [ address: '0xdac17f958d2ee523a2206206994597c13d831ec7', symbol: 'USDT', name: 'Tether USD', - iconUrl: 'https://example.com/usdt.png', - chainId: '0x1', + image: 'https://example.com/usdt.png', + decimals: 6, + chainId: '0x1' as const, }, ]; @@ -23,7 +25,9 @@ const mockSelectedAsset = [ address: '0xdac17f958d2ee523a2206206994597c13d831ec7', symbol: 'USDT', name: 'Tether USD', - iconUrl: 'https://example.com/usdt.png', + image: 'https://example.com/usdt.png', + decimals: 6, + chainId: '0x1' as const, }, ]; @@ -87,7 +91,7 @@ describe('MultiAssetListItems', () => { expect( getByText("We couldn't find any tokens with that name."), - ).toBeTruthy(); + ).toBeOnTheScreen(); }); it('renders search results correctly', () => { @@ -106,7 +110,145 @@ describe('MultiAssetListItems', () => { />, ); - expect(getByText('Tether USD')).toBeTruthy(); - expect(getByText('USDT')).toBeTruthy(); + expect(getByText('Tether USD')).toBeOnTheScreen(); + expect(getByText('USDT')).toBeOnTheScreen(); + }); + + it('renders AssetIcon when asset has image property', () => { + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === selectProviderConfig) return mockProviderConfig; + }); + + const { getByText } = render( + ({})} + selectedAsset={[]} + searchQuery="" + chainId="1" + networkName="Ethereum" + />, + ); + + // Asset should render with name and symbol when image exists + expect(getByText('Tether USD')).toBeOnTheScreen(); + expect(getByText('USDT')).toBeOnTheScreen(); + }); + + it('does not render AssetIcon when asset image is missing', () => { + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === selectProviderConfig) return mockProviderConfig; + }); + + const assetWithoutImage = [ + { + address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + chainId: '0x1' as const, + }, + ]; + + const { getByText } = render( + ({})} + selectedAsset={[]} + searchQuery="" + chainId="1" + networkName="Ethereum" + />, + ); + + // Asset should still render with name and symbol even without image + expect(getByText('Tether USD')).toBeOnTheScreen(); + expect(getByText('USDT')).toBeOnTheScreen(); + }); + + it('renders all search results with FlashList', () => { + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === selectProviderConfig) return mockProviderConfig; + }); + + const manyResults = Array.from({ length: 10 }, (_, i) => ({ + address: `0x${i.toString().padStart(40, '0')}`, + symbol: `TOKEN${i}`, + name: `Token ${i}`, + image: `https://example.com/token${i}.png`, + decimals: 18, + chainId: '0x1' as const, + })); + + const { getByText } = render( + ({})} + selectedAsset={[]} + searchQuery="" + chainId="1" + networkName="Ethereum" + />, + ); + + // Should render all 10 items, not just first 6 + expect(getByText('Token 0')).toBeOnTheScreen(); + expect(getByText('Token 9')).toBeOnTheScreen(); + expect(getByText('TOKEN0')).toBeOnTheScreen(); + expect(getByText('TOKEN9')).toBeOnTheScreen(); + }); + + it('renders already added tokens with alreadyAddedTokens prop', () => { + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === selectProviderConfig) return mockProviderConfig; + }); + + const alreadyAddedTokens = new Set([ + '0xdac17f958d2ee523a2206206994597c13d831ec7', + ]); + + const { getByTestId, getByText } = render( + ({})} + selectedAsset={[]} + searchQuery="" + chainId="1" + networkName="Ethereum" + alreadyAddedTokens={alreadyAddedTokens} + />, + ); + + expect( + getByTestId(ImportTokenViewSelectorsIDs.SEARCH_TOKEN_RESULT), + ).toBeOnTheScreen(); + expect(getByText('Tether USD')).toBeOnTheScreen(); + }); + + it('marks already added tokens as disabled', () => { + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === selectProviderConfig) return mockProviderConfig; + }); + + const alreadyAddedTokens = new Set([ + '0xdac17f958d2ee523a2206206994597c13d831ec7', + ]); + + const { getByTestId } = render( + ({})} + selectedAsset={[]} + searchQuery="" + chainId="1" + networkName="Ethereum" + alreadyAddedTokens={alreadyAddedTokens} + />, + ); + + const listItem = getByTestId( + ImportTokenViewSelectorsIDs.SEARCH_TOKEN_RESULT, + ); + expect(listItem).toHaveProp('disabled', true); }); }); diff --git a/app/components/UI/MultiAssetListItems/MultiAssetListItems.tsx b/app/components/UI/MultiAssetListItems/MultiAssetListItems.tsx index 255ae0a18bd..a5d90a02f06 100644 --- a/app/components/UI/MultiAssetListItems/MultiAssetListItems.tsx +++ b/app/components/UI/MultiAssetListItems/MultiAssetListItems.tsx @@ -16,26 +16,22 @@ import AssetIcon from '../AssetIcon'; import { strings } from '../../../../locales/i18n'; import { ImportTokenViewSelectorsIDs } from '../../../../e2e/selectors/wallet/ImportTokenView.selectors'; import { NetworkBadgeSource } from '../AssetOverview/Balance/Balance'; +import { BridgeToken } from '../Bridge/types'; +import { FlashList } from '@shopify/flash-list'; interface Props { /** * Array of assets objects returned from the search */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - searchResults: any[]; + searchResults: BridgeToken[]; /** * Callback triggered when a token is selected */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - handleSelectAsset: (asset: any) => void; + handleSelectAsset: (asset: BridgeToken) => void; /** * Object of the currently-selected token */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - selectedAsset: any[]; + selectedAsset: BridgeToken[]; /** * Search query that generated "searchResults" */ @@ -52,6 +48,10 @@ interface Props { * Name of the network */ networkName?: string; + /** + * Set of already added token addresses (lowercase) + */ + alreadyAddedTokens?: Set; } const MultiAssetListItems = ({ @@ -60,29 +60,31 @@ const MultiAssetListItems = ({ selectedAsset, searchQuery, networkName, + alreadyAddedTokens, }: Props) => { const { styles } = useStyles(stylesheet, {}); return ( - - {searchResults.length === 0 && searchQuery?.length ? ( - - {strings('token.no_tokens_found')} - - ) : null} - {searchResults.slice(0, 6)?.map((_, i) => { - const { symbol, name, address, iconUrl } = searchResults[i] || {}; + { + const { symbol, name, address, image } = item || {}; const isOnSelected = selectedAsset.some( (token) => token.address === address, ); const isSelected = selectedAsset && isOnSelected; + // Check if token is already added + const isAlreadyAdded = alreadyAddedTokens?.has(address.toLowerCase()); + const isDisabled = isAlreadyAdded; + return ( handleSelectAsset(searchResults[i])} + key={`search-result-${index}`} + onPress={() => !isDisabled && handleSelectAsset(item)} testID={ImportTokenViewSelectorsIDs.SEARCH_TOKEN_RESULT} > @@ -91,16 +93,20 @@ const MultiAssetListItems = ({ badgeElement={ } > - + {image && ( + + )} @@ -109,8 +115,17 @@ const MultiAssetListItems = ({ ); - })} - + }} + keyExtractor={(_, index) => `token-search-row-${index}`} + decelerationRate="fast" + ListEmptyComponent={ + searchQuery?.length > 0 ? ( + + {strings('token.no_tokens_found')} + + ) : null + } + /> ); }; diff --git a/app/components/UI/MultiAssetListItems/__snapshots__/MultiAssetListItems.test.tsx.snap b/app/components/UI/MultiAssetListItems/__snapshots__/MultiAssetListItems.test.tsx.snap index 96178da18f5..5131b72bccd 100644 --- a/app/components/UI/MultiAssetListItems/__snapshots__/MultiAssetListItems.test.tsx.snap +++ b/app/components/UI/MultiAssetListItems/__snapshots__/MultiAssetListItems.test.tsx.snap @@ -2,25 +2,65 @@ exports[`MultiAssetListItems render matches previous snapshot 1`] = ` - - We couldn't find any tokens with that name. - + + + + + + We couldn't find any tokens with that name. + + + `; diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index 0fa1e02dbf3..901ebb03e69 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -51,6 +51,7 @@ import { SettingsViewSelectorsIDs } from '../../../../e2e/selectors/Settings/Set import HeaderBase, { HeaderBaseVariant, } from '../../../component-library/components/HeaderBase'; +import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader'; import AddressCopy from '../AddressCopy'; import PickerAccount from '../../../component-library/components/Pickers/PickerAccount'; import { createAccountSelectorNavDetails } from '../../../components/Views/AccountSelector'; @@ -1176,85 +1177,22 @@ export function getWalletNavbarOptions( } /** - * Function that returns the navigation options containing title and network indicator + * Function that returns the navigation options for the Import Asset screen * - * @param {string} title - Title in string format - * @param {boolean} translate - Boolean that specifies if the title needs translation * @param {Object} navigation - Navigation object required to push new views - * @param {Object} themeColors - Colors from theme - * @param {boolean} disableNetwork - Boolean that determines if network is accessible from navbar - * @param {Function} onClose - Onclose navbar function - * @returns {Object} - Corresponding navbar options containing headerTitle and headerTitle + * @param {string} title - Title in string format + * @returns {Object} - Corresponding navbar options */ -export function getImportTokenNavbarOptions( - title, - translate, - navigation, - themeColors, - disableNetwork = false, - contentOffset = 0, - onClose = undefined, -) { - const innerStyles = StyleSheet.create({ - headerStyle: { - backgroundColor: themeColors.background.default, - shadowColor: importedColors.transparent, - elevation: 0, - }, - headerShadow: { - elevation: 2, - shadowColor: themeColors.background.primary, - shadowOpacity: contentOffset < 20 ? contentOffset / 100 : 0.2, - shadowOffset: { height: 4, width: 0 }, - shadowRadius: 8, - }, - headerIcon: { - color: themeColors.primary.default, - }, - title: { - textAlign: 'center', - fontWeight: 'bold', - }, - }); +export function getImportTokenNavbarOptions(navigation, title, onPress) { return { - headerTitle: () => ( - ( + navigation.goBack())} > {title} - + ), - headerRight: () => ( - // eslint-disable-next-line react/jsx-no-bind - - onClose() - : () => - navigation.navigate(Routes.WALLET.HOME, { - screen: Routes.WALLET.TAB_STACK_FLOW, - params: { - screen: Routes.WALLET_VIEW, - }, - }) - } - /> - - ), - headerLeft: null, - headerStyle: [ - innerStyles.headerStyle, - contentOffset && innerStyles.headerShadow, - ], }; } diff --git a/app/components/UI/SearchTokenAutocomplete/__snapshots__/index.test.tsx.snap b/app/components/UI/SearchTokenAutocomplete/__snapshots__/index.test.tsx.snap index 88fb5426c00..366e072b39f 100644 --- a/app/components/UI/SearchTokenAutocomplete/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/SearchTokenAutocomplete/__snapshots__/index.test.tsx.snap @@ -319,145 +319,672 @@ exports[`SearchTokenAutocomplete renders correctly with selected chain 1`] = ` } } > - - - + + - + + + + + + - - - + width={16} + /> + + + + + + + + + + - + testID="searched-token-result" + > + + + + + + + + + + + + + + + + + + + + + + + + Test Token + + + TEST + + + + + - + > + + + + + + + + + + + + + + + + + + + + + + + USD Coin + + + USDC + + + + - - + - + ({ context: { + TokensController: { + addTokens: jest.fn().mockResolvedValue(undefined), + }, + MultichainAssetsController: { + addAssets: jest.fn().mockResolvedValue(undefined), + }, + NetworkController: { + state: { + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + rpcEndpoints: [ + { + networkClientId: 'mainnet', + }, + ], + defaultRpcEndpointIndex: 0, + }, + }, + }, + }, PreferencesController: { setTokenNetworkFilter: jest.fn(), }, }, })); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(); +const mockBuild = jest.fn(); +const mockAddProperties = jest.fn(() => ({ build: mockBuild })); + +jest.mock('../../../components/hooks/useMetrics', () => ({ + useMetrics: jest.fn(() => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + })), +})); + +jest.mock('../../../core/NotificationManager', () => ({ + showSimpleNotification: jest.fn(), +})); + +jest.mock('react-native', () => { + const RN = jest.requireActual('react-native'); + return { + ...RN, + InteractionManager: { + runAfterInteractions: jest.fn((callback) => callback()), + }, + }; +}); + +const mockIsNonEvmChainId = jest.fn(); + +jest.mock('../../../core/Multichain/utils', () => ({ + ...jest.requireActual('../../../core/Multichain/utils'), + isNonEvmChainId: (chainId: string) => mockIsNonEvmChainId(chainId), +})); + +const mockSelectInternalAccountByScope = jest.fn(); + +jest.mock('../../../selectors/multichainAccounts/accounts', () => ({ + ...jest.requireActual('../../../selectors/multichainAccounts/accounts'), + selectSelectedInternalAccountByScope: jest.fn( + () => mockSelectInternalAccountByScope, + ), +})); + +jest.mock('../../../selectors/tokensController', () => ({ + ...jest.requireActual('../../../selectors/tokensController'), + selectTokensByChainIdAndAddress: jest.fn(() => ({})), +})); + +jest.mock('../../../selectors/multichain/multichain', () => ({ + ...jest.requireActual('../../../selectors/multichain/multichain'), + selectMultichainAssets: jest.fn(() => ({})), +})); + describe('SearchTokenAutocomplete', () => { beforeEach(() => { jest.clearAllMocks(); + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + }); + mockBuild.mockReturnValue({ event: 'mock-event' }); + mockIsNonEvmChainId.mockReturnValue(false); + mockSelectInternalAccountByScope.mockReturnValue(null); + // Reset mocks to return resolved promises + (Engine.context.TokensController.addTokens as jest.Mock).mockResolvedValue( + undefined, + ); + ( + Engine.context.MultichainAssetsController.addAssets as jest.Mock + ).mockResolvedValue(undefined); }); it('renders correctly with selected chain', () => { + const WrapperComponent = () => ( + + ); + const { toJSON } = renderScreen( - SearchTokenAutocomplete as FunctionComponent, + WrapperComponent as FunctionComponent, { name: 'SearchTokenAutocomplete' }, { state: mockInitialState, @@ -43,16 +160,31 @@ describe('SearchTokenAutocomplete', () => { expect(toJSON()).toMatchSnapshot(); }); - it('handles search and updates results', () => { + it('updates search results when search query changes', () => { + const WrapperComponent = () => ( + + ); + const { getByTestId, getByText } = renderScreen( - SearchTokenAutocomplete as FunctionComponent, + WrapperComponent as FunctionComponent, { name: 'SearchTokenAutocomplete' }, { state: mockInitialState, }, ); - const mockAsset = { address: '0x123', symbol: 'TEST', chainId: '0x1' }; + const mockAsset = { + address: '0x123', + symbol: 'TEST', + name: 'Test Token', + decimals: 18, + chainId: '0x1', + }; const assetSearch = getByTestId(ImportTokenViewSelectorsIDs.SEARCH_BAR); const mockResults = [mockAsset]; @@ -60,16 +192,15 @@ describe('SearchTokenAutocomplete', () => { fireEvent(assetSearch, 'onSearch', { results: mockResults, searchQuery: 'TEST', - chainId: '0x1', }); expect( getByTestId(ImportTokenViewSelectorsIDs.SEARCH_TOKEN_RESULT), - ).toBeTruthy(); - expect(getByText(mockAsset.symbol)).toBeTruthy(); + ).toBeOnTheScreen(); + expect(getByText(mockAsset.symbol)).toBeOnTheScreen(); }); - it('displays token detection banner when detection is disabled', () => { + it('displays token detection banner when detection is disabled and search is not focused', () => { const stateWithDetectionDisabled = { ...mockInitialState, engine: { @@ -82,39 +213,91 @@ describe('SearchTokenAutocomplete', () => { }, }; + const WrapperComponent = () => ( + + ); + const { getByText } = renderScreen( - SearchTokenAutocomplete as FunctionComponent, + WrapperComponent as FunctionComponent, { name: 'SearchTokenAutocomplete' }, { state: stateWithDetectionDisabled, }, ); - expect(getByText(/token detection/i)).toBeTruthy(); + expect(getByText(/token detection/i)).toBeOnTheScreen(); }); - it('navigates to ConfirmAddAsset when asset is selected', () => { + it('hides token detection banner when search is focused', () => { + const stateWithDetectionDisabled = { + ...mockInitialState, + engine: { + backgroundState: { + ...mockInitialState.engine.backgroundState, + PreferencesController: { + useTokenDetection: false, + }, + }, + }, + }; + + const WrapperComponent = () => ( + + ); + + const { getByTestId, queryByText } = renderScreen( + WrapperComponent as FunctionComponent, + { name: 'SearchTokenAutocomplete' }, + { + state: stateWithDetectionDisabled, + }, + ); + + const assetSearch = getByTestId(ImportTokenViewSelectorsIDs.SEARCH_BAR); + fireEvent(assetSearch, 'focus'); + + expect(queryByText(/token detection/i)).toBeNull(); + }); + + it('navigates to ConfirmAddAsset when next button is pressed with selected asset', () => { const mockNavigation = { push: jest.fn(), + navigate: jest.fn(), }; - const { getByTestId, getByText } = renderWithProvider( + const { getByTestId } = renderWithProvider( , { state: mockInitialState }, ); - const mockAsset = { address: '0x123', symbol: 'TEST', chainId: '0x1' }; + const mockAsset = { + address: '0x123', + symbol: 'TEST', + name: 'Test Token', + decimals: 18, + chainId: '0x1', + }; const assetSearch = getByTestId(ImportTokenViewSelectorsIDs.SEARCH_BAR); fireEvent(assetSearch, 'onSearch', { results: [mockAsset], searchQuery: 'TEST', - chainId: '0x1', }); const selectAssetButton = getByTestId( @@ -122,8 +305,6 @@ describe('SearchTokenAutocomplete', () => { ); fireEvent.press(selectAssetButton); - expect(getByText(mockAsset.symbol)).toBeTruthy(); - const addTokenButton = getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON); fireEvent.press(addTokenButton); @@ -139,9 +320,72 @@ describe('SearchTokenAutocomplete', () => { }); }); + it('disables next button when no assets are selected', () => { + const mockNavigation = { + push: jest.fn(), + navigate: jest.fn(), + }; + + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const addTokenButton = getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON); + + expect(addTokenButton).toHaveProp('disabled', true); + }); + + it('enables next button when at least one asset is selected', () => { + const mockNavigation = { + push: jest.fn(), + navigate: jest.fn(), + }; + + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const mockAsset = { + address: '0x123', + symbol: 'TEST', + name: 'Test Token', + decimals: 18, + chainId: '0x1', + }; + + const assetSearch = getByTestId(ImportTokenViewSelectorsIDs.SEARCH_BAR); + + fireEvent(assetSearch, 'onSearch', { + results: [mockAsset], + searchQuery: 'TEST', + }); + + const selectAssetButton = getByTestId( + ImportTokenViewSelectorsIDs.SEARCH_TOKEN_RESULT, + ); + fireEvent.press(selectAssetButton); + + const addTokenButton = getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON); + + expect(addTokenButton).toHaveProp('disabled', false); + }); + it('renders with null selectedChainId', () => { const mockNavigation = { push: jest.fn(), + navigate: jest.fn(), }; const { getByTestId } = renderWithProvider( @@ -149,10 +393,293 @@ describe('SearchTokenAutocomplete', () => { navigation={mockNavigation} tabLabel={''} selectedChainId={null} + allTokens={mockAllTokens} + />, + { state: mockInitialState }, + ); + + expect( + getByTestId(ImportTokenViewSelectorsIDs.SEARCH_BAR), + ).toBeOnTheScreen(); + }); + + it('tracks analytics when navigating to confirm add asset', () => { + const mockNavigation = { + push: jest.fn(), + navigate: jest.fn(), + }; + + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const mockAsset = { + address: '0x123', + symbol: 'TEST', + name: 'Test Token', + decimals: 18, + chainId: '0x1', + }; + + const assetSearch = getByTestId(ImportTokenViewSelectorsIDs.SEARCH_BAR); + + fireEvent(assetSearch, 'onSearch', { + results: [mockAsset], + searchQuery: 'TEST', + }); + + const selectAssetButton = getByTestId( + ImportTokenViewSelectorsIDs.SEARCH_TOKEN_RESULT, + ); + fireEvent.press(selectAssetButton); + + const addTokenButton = getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON); + fireEvent.press(addTokenButton); + + expect(mockCreateEventBuilder).toHaveBeenCalled(); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('sets searchResults to allTokens when searchQuery is empty', () => { + const mockNavigation = { + push: jest.fn(), + navigate: jest.fn(), + }; + + const { getByTestId, getByText } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const assetSearch = getByTestId(ImportTokenViewSelectorsIDs.SEARCH_BAR); + + // When search query is empty, should show all tokens + fireEvent(assetSearch, 'onSearch', { + results: [], + searchQuery: '', + }); + + // Should display all tokens from allTokens + expect(getByText('TEST')).toBeOnTheScreen(); + expect(getByText('USDC')).toBeOnTheScreen(); + }); + + it('calls TokensController.addTokens for EVM chains', async () => { + const mockNavigation = { + push: jest.fn(), + navigate: jest.fn(), + }; + + mockIsNonEvmChainId.mockReturnValue(false); + + const { getByTestId } = renderWithProvider( + , { state: mockInitialState }, ); - expect(getByTestId(ImportTokenViewSelectorsIDs.SEARCH_BAR)).toBeTruthy(); + const mockAsset = { + address: '0x123', + symbol: 'TEST', + name: 'Test Token', + decimals: 18, + chainId: '0x1', + }; + + const assetSearch = getByTestId(ImportTokenViewSelectorsIDs.SEARCH_BAR); + + fireEvent(assetSearch, 'onSearch', { + results: [mockAsset], + searchQuery: 'TEST', + }); + + const selectAssetButton = getByTestId( + ImportTokenViewSelectorsIDs.SEARCH_TOKEN_RESULT, + ); + fireEvent.press(selectAssetButton); + + const addTokenButton = getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON); + fireEvent.press(addTokenButton); + + // Navigate to confirm screen first + expect(mockNavigation.push).toHaveBeenCalled(); + + // Simulate calling addTokenList from the confirm screen + const [, params] = mockNavigation.push.mock.calls[0]; + await params.addTokenList(); + + // Should call addTokens for EVM chain + expect(mockIsNonEvmChainId).toHaveBeenCalledWith('0x1'); + expect(Engine.context.TokensController.addTokens).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ address: '0x123' })]), + 'mainnet', + ); + expect( + Engine.context.MultichainAssetsController.addAssets, + ).not.toHaveBeenCalled(); + }); + + it('calls MultichainAssetsController.addAssets for non-EVM chains', async () => { + const mockNavigation = { + push: jest.fn(), + navigate: jest.fn(), + }; + + const mockNonEvmAccount = { + id: 'non-evm-account-id', + address: 'non-evm-address', + }; + + mockIsNonEvmChainId.mockReturnValue(true); + mockSelectInternalAccountByScope.mockReturnValue(mockNonEvmAccount); + + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const mockAsset = { + address: 'solana-address-123', + symbol: 'SOL', + name: 'Solana', + decimals: 9, + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + }; + + const assetSearch = getByTestId(ImportTokenViewSelectorsIDs.SEARCH_BAR); + + fireEvent(assetSearch, 'onSearch', { + results: [mockAsset], + searchQuery: 'SOL', + }); + + const selectAssetButton = getByTestId( + ImportTokenViewSelectorsIDs.SEARCH_TOKEN_RESULT, + ); + fireEvent.press(selectAssetButton); + + const addTokenButton = getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON); + fireEvent.press(addTokenButton); + + // Navigate to confirm screen first + expect(mockNavigation.push).toHaveBeenCalled(); + + // Simulate calling addTokenList from the confirm screen + const [, params] = mockNavigation.push.mock.calls[0]; + await params.addTokenList(); + + // Should call MultichainAssetsController.addAssets for non-EVM + expect(mockIsNonEvmChainId).toHaveBeenCalledWith( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + ); + expect(mockSelectInternalAccountByScope).toHaveBeenCalledWith( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + ); + expect( + Engine.context.MultichainAssetsController.addAssets, + ).toHaveBeenCalledWith(['solana-address-123'], 'non-evm-account-id'); + expect(Engine.context.TokensController.addTokens).not.toHaveBeenCalled(); + }); + + it('renders with already added EVM tokens', () => { + const mockNavigation = { + push: jest.fn(), + navigate: jest.fn(), + }; + + const addedTokens = { + '0x123': { address: '0x123', symbol: 'TEST', decimals: 18 }, + }; + + jest.mocked(selectTokensByChainIdAndAddress).mockReturnValue(addedTokens); + mockIsNonEvmChainId.mockReturnValue(false); + + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + expect( + getByTestId(ImportTokenViewSelectorsIDs.SEARCH_BAR), + ).toBeOnTheScreen(); + }); + + it('renders with already added non-EVM tokens', () => { + const mockNavigation = { + push: jest.fn(), + navigate: jest.fn(), + }; + + const mockNonEvmAccount = { + id: 'non-evm-account-id', + address: 'non-evm-address', + }; + + const addedAssets = { + 'non-evm-account-id': [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501:solana-address-123' as const, + ], + }; + + jest.mocked(selectMultichainAssets).mockReturnValue(addedAssets); + mockIsNonEvmChainId.mockReturnValue(true); + mockSelectInternalAccountByScope.mockReturnValue(mockNonEvmAccount); + + const solanaToken: BridgeToken = { + address: 'solana-address-123', + symbol: 'SOL', + name: 'Solana', + decimals: 9, + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const, + }; + + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + expect( + getByTestId(ImportTokenViewSelectorsIDs.SEARCH_BAR), + ).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/SearchTokenAutocomplete/index.tsx b/app/components/UI/SearchTokenAutocomplete/index.tsx index 0c7951c7698..826cc652374 100644 --- a/app/components/UI/SearchTokenAutocomplete/index.tsx +++ b/app/components/UI/SearchTokenAutocomplete/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { View, StyleSheet, @@ -24,7 +24,6 @@ import { getDecimalChainId } from '../../../util/networks'; import { useMetrics } from '../../../components/hooks/useMetrics'; import Routes from '../../../constants/navigation/Routes'; import MultiAssetListItems from '../MultiAssetListItems/MultiAssetListItems'; -import { ScrollView } from 'react-native-gesture-handler'; import Button, { ButtonSize, ButtonVariants, @@ -33,6 +32,13 @@ import Button, { import { ImportTokenViewSelectorsIDs } from '../../../../e2e/selectors/wallet/ImportTokenView.selectors'; import Logger from '../../../util/Logger'; import { Hex } from '@metamask/utils'; +import { SupportedCaipChainId } from '@metamask/multichain-network-controller'; +import { BridgeToken } from '../Bridge/types'; +import { isNonEvmChainId } from '../../../core/Multichain/utils'; +import { selectSelectedInternalAccountByScope } from '../../../selectors/multichainAccounts/accounts'; +import { selectTokensByChainIdAndAddress } from '../../../selectors/tokensController'; +import { selectMultichainAssets } from '../../../selectors/multichain/multichain'; +import { RootState } from '../../../reducers'; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -41,6 +47,9 @@ const createStyles = (colors: any) => container: { flex: 1, }, + content: { + flex: 1, + }, base: { padding: 16, }, @@ -63,7 +72,7 @@ const createStyles = (colors: any) => paddingVertical: 16, }, searchInput: { - paddingBottom: 16, + paddingTop: 16, }, }); @@ -79,16 +88,23 @@ interface Props { /** * The selected network chain ID */ - selectedChainId: Hex | null; + selectedChainId: SupportedCaipChainId | Hex | null; + + allTokens: BridgeToken[]; } /** * Component that provides ability to add searched assets with metadata. */ -const SearchTokenAutocomplete = ({ navigation, selectedChainId }: Props) => { +const SearchTokenAutocomplete = ({ + navigation, + selectedChainId, + allTokens, +}: Props) => { const { trackEvent, createEventBuilder } = useMetrics(); - const [searchResults, setSearchResults] = useState([]); + const [searchResults, setSearchResults] = useState([]); const [searchQuery, setSearchQuery] = useState(''); + // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any const [selectedAssets, setSelectedAssets] = useState([]); @@ -100,6 +116,56 @@ const SearchTokenAutocomplete = ({ navigation, selectedChainId }: Props) => { const isTokenDetectionEnabled = useSelector(selectUseTokenDetection); const ticker = useSelector(selectEvmTicker); + const selectInternalAccountByScope = useSelector( + selectSelectedInternalAccountByScope, + ); + + // Get already added EVM tokens for the selected chain + const addedEvmTokens = useSelector((state: RootState) => + selectedChainId && !isNonEvmChainId(selectedChainId) + ? selectTokensByChainIdAndAddress(state, selectedChainId as Hex) + : {}, + ); + + // Get already added non-EVM tokens + const multichainAssets = useSelector(selectMultichainAssets); + + // Create a Set of already added token addresses for quick lookup + const alreadyAddedTokens = useMemo(() => { + const addresses = new Set(); + + if (selectedChainId) { + if (isNonEvmChainId(selectedChainId)) { + // For non-EVM chains + const selectedNonEvmAccount = selectInternalAccountByScope( + selectedChainId as SupportedCaipChainId, + ); + if (selectedNonEvmAccount?.id) { + const accountAssets = + multichainAssets?.[selectedNonEvmAccount.id] || []; + // accountAssets is an array of CAIP asset address strings + accountAssets.forEach((assetAddress: string) => { + // Extract the token address from CAIP format (e.g., "bip122:..." or "solana:...") + // The address is already the full identifier, just normalize it + addresses.add(assetAddress.toLowerCase()); + }); + } + } else { + // For EVM chains + Object.keys(addedEvmTokens).forEach((address) => { + addresses.add(address.toLowerCase()); + }); + } + } + + return addresses; + }, [ + selectedChainId, + addedEvmTokens, + multichainAssets, + selectInternalAccountByScope, + ]); + const setFocusState = useCallback( (isFocused: boolean) => { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); @@ -131,13 +197,15 @@ const SearchTokenAutocomplete = ({ navigation, selectedChainId }: Props) => { ); const handleSearch = useCallback( - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (opts: any) => { - setSearchResults(opts.results); + (opts: { results: BridgeToken[]; searchQuery: string }) => { + if (opts.searchQuery.length === 0) { + setSearchResults(allTokens); + } else { + setSearchResults(opts.results); + } setSearchQuery(opts.searchQuery); }, - [setSearchResults, setSearchQuery], + [setSearchResults, setSearchQuery, allTokens], ); const handleSelectAsset = useCallback( @@ -165,25 +233,31 @@ const SearchTokenAutocomplete = ({ navigation, selectedChainId }: Props) => { [selectedAssets, setSelectedAssets], ); - const addToken = useCallback( - async ({ - address, - symbol, - decimals, - iconUrl, - name, - chainId: networkId, - }: { - address: Hex; - symbol: string; - decimals: number; - iconUrl: string; - name: string; - chainId: Hex; - }) => { + const addTokens = useCallback(async () => { + if (!selectedChainId) { + return; + } + + const addresses = selectedAssets.map((asset) => asset.address); + if (isNonEvmChainId(selectedChainId)) { + const selectedNonEvmAccount = selectInternalAccountByScope( + selectedChainId as SupportedCaipChainId, + ); + + if (!selectedNonEvmAccount) { + Logger.log('SearchTokenAutoComplete: No account ID found'); + return; + } + + const { MultichainAssetsController } = Engine.context; + await MultichainAssetsController.addAssets( + addresses, + selectedNonEvmAccount.id, + ); + } else { const networkConfig = Engine.context.NetworkController.state - ?.networkConfigurationsByChainId?.[networkId]; + ?.networkConfigurationsByChainId?.[selectedChainId as Hex]; if (!networkConfig) { return; @@ -199,19 +273,14 @@ const SearchTokenAutocomplete = ({ navigation, selectedChainId }: Props) => { // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { TokensController } = Engine.context as any; - await TokensController.addToken({ - address, - symbol, - decimals, - image: iconUrl, - name, - networkClientId: networkClient, - }); + const { TokensController } = Engine.context; + await TokensController.addTokens(selectedAssets, networkClient); + } + selectedAssets.forEach((asset) => { const analyticsParams = getTokenAddedAnalyticsParams({ - address, - symbol, + address: asset.address as Hex, + symbol: asset.symbol, }); if (analyticsParams) { @@ -221,9 +290,15 @@ const SearchTokenAutocomplete = ({ navigation, selectedChainId }: Props) => { .build(), ); } - }, - [getTokenAddedAnalyticsParams, trackEvent, createEventBuilder], - ); + }); + }, [ + getTokenAddedAnalyticsParams, + trackEvent, + createEventBuilder, + selectInternalAccountByScope, + selectedAssets, + selectedChainId, + ]); /** * Go to wallet page @@ -238,12 +313,9 @@ const SearchTokenAutocomplete = ({ navigation, selectedChainId }: Props) => { }, [navigation]); const addTokenList = useCallback(async () => { - for (const asset of selectedAssets) { - await addToken({ ...asset }); - } + await addTokens(); setSearchResults([]); - setSearchQuery(''); setSelectedAssets([]); InteractionManager.runAfterInteractions(() => { @@ -260,7 +332,7 @@ const SearchTokenAutocomplete = ({ navigation, selectedChainId }: Props) => { : strings('wallet.token_toast.token_imported_desc_1'), }); }); - }, [addToken, selectedAssets, goToWalletPage]); + }, [addTokens, selectedAssets, goToWalletPage]); const networkName = useSelector(selectNetworkName); @@ -338,29 +410,31 @@ const SearchTokenAutocomplete = ({ navigation, selectedChainId }: Props) => { return ( - - - {renderTokenDetectionBanner()} - - { - setFocusState(true); - }} - onBlur={() => setFocusState(false)} - selectedChainId={selectedChainId} - /> - - + + { + setFocusState(true); + }} + onBlur={() => setFocusState(false)} + allTokens={allTokens} /> - + + + +