diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 61dce2e639a1..573af5830661 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -497,6 +497,10 @@ "allCustodianAccountsConnectedTitle": { "message": "No accounts available to connect" }, + "allNetworks": { + "message": "All Networks", + "description": "Speicifies to token network filter to filter by all Networks" + }, "allOfYour": { "message": "All of your $1", "description": "$1 is the symbol or name of the token that the user is approving spending" @@ -1355,6 +1359,10 @@ "currentLanguage": { "message": "Current language" }, + "currentNetwork": { + "message": "Current Network", + "description": "Speicifies to token network filter to filter by current Network. Will render when network nickname is not available" + }, "currentRpcUrlDeprecated": { "message": "The current rpc url for this network has been deprecated." }, diff --git a/app/scripts/controllers/preferences-controller.test.ts b/app/scripts/controllers/preferences-controller.test.ts index 9215ff8571a7..74daf39e17ad 100644 --- a/app/scripts/controllers/preferences-controller.test.ts +++ b/app/scripts/controllers/preferences-controller.test.ts @@ -750,6 +750,7 @@ describe('preferences controller', () => { order: 'dsc', sortCallback: 'stringNumeric', }, + tokenNetworkFilter: {}, }); }); @@ -779,6 +780,7 @@ describe('preferences controller', () => { order: 'dsc', sortCallback: 'stringNumeric', }, + tokenNetworkFilter: {}, }); }); }); diff --git a/app/scripts/controllers/preferences-controller.ts b/app/scripts/controllers/preferences-controller.ts index ee18403c210b..f6537952d651 100644 --- a/app/scripts/controllers/preferences-controller.ts +++ b/app/scripts/controllers/preferences-controller.ts @@ -120,6 +120,7 @@ export type Preferences = { order: string; sortCallback: string; }; + tokenNetworkFilter: Record; shouldShowAggregatedBalancePopover: boolean; }; @@ -222,6 +223,7 @@ export const getDefaultPreferencesControllerState = order: 'dsc', sortCallback: 'stringNumeric', }, + tokenNetworkFilter: {}, }, // ENS decentralized website resolution ipfsGateway: IPFS_DEFAULT_GATEWAY_URL, diff --git a/builds.yml b/builds.yml index c9c122312fba..824e08e32167 100644 --- a/builds.yml +++ b/builds.yml @@ -273,6 +273,8 @@ env: - BARAD_DUR: '' # Determines if feature flagged Chain permissions - CHAIN_PERMISSIONS: '' + # Determines if feature flagged Filter toggle + - FILTER_TOKENS_TOGGLE: '' # Enables use of test gas fee flow to debug gas fee estimation - TEST_GAS_FEE_FLOWS: false # Temporary mechanism to enable security alerts API prior to release diff --git a/ui/components/app/assets/asset-list/asset-list-control-bar/asset-list-control-bar.tsx b/ui/components/app/assets/asset-list/asset-list-control-bar/asset-list-control-bar.tsx index 696c3ca7c89f..de771976e677 100644 --- a/ui/components/app/assets/asset-list/asset-list-control-bar/asset-list-control-bar.tsx +++ b/ui/components/app/assets/asset-list/asset-list-control-bar/asset-list-control-bar.tsx @@ -1,4 +1,6 @@ import React, { useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { getCurrentNetwork, getPreferences } from '../../../../../selectors'; import { Box, ButtonBase, @@ -25,6 +27,7 @@ import { ENVIRONMENT_TYPE_NOTIFICATION, ENVIRONMENT_TYPE_POPUP, } from '../../../../../../shared/constants/app'; +import NetworkFilter from '../network-filter'; type AssetListControlBarProps = { showTokensLinks?: boolean; @@ -32,55 +35,116 @@ type AssetListControlBarProps = { const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { const t = useI18nContext(); - const controlBarRef = useRef(null); // Create a ref - const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const popoverRef = useRef(null); + const currentNetwork = useSelector(getCurrentNetwork); + const { tokenNetworkFilter } = useSelector(getPreferences); + const [isTokenSortPopoverOpen, setIsTokenSortPopoverOpen] = useState(false); + const [isNetworkFilterPopoverOpen, setIsNetworkFilterPopoverOpen] = + useState(false); + + const allNetworksFilterShown = Object.keys(tokenNetworkFilter ?? {}).length; const windowType = getEnvironmentType(); const isFullScreen = windowType !== ENVIRONMENT_TYPE_NOTIFICATION && windowType !== ENVIRONMENT_TYPE_POPUP; - const handleOpenPopover = () => { - setIsPopoverOpen(!isPopoverOpen); + const toggleTokenSortPopover = () => { + setIsNetworkFilterPopoverOpen(false); + setIsTokenSortPopoverOpen(!isTokenSortPopoverOpen); + }; + + const toggleNetworkFilterPopover = () => { + setIsTokenSortPopoverOpen(false); + setIsNetworkFilterPopoverOpen(!isNetworkFilterPopoverOpen); }; const closePopover = () => { - setIsPopoverOpen(false); + setIsTokenSortPopoverOpen(false); + setIsNetworkFilterPopoverOpen(false); }; return ( - - {t('sortBy')} - - + {process.env.FILTER_TOKENS_TOGGLE && ( + + {allNetworksFilterShown + ? currentNetwork?.nickname ?? t('currentNetwork') + : t('allNetworks')} + + )} + + + {t('sortBy')} + + + + + + + + void; +}; + +const NetworkFilter = ({ handleClose }: SortControlProps) => { + const t = useI18nContext(); + const dispatch = useDispatch(); + const chainId = useSelector(getCurrentChainId); + const selectedAccount = useSelector(getSelectedInternalAccount); + const currentNetwork = useSelector(getCurrentNetwork); + const allNetworks = useSelector(getNetworkConfigurationsByChainId); + const isTestnet = useSelector(getIsTestnet); + const { tokenNetworkFilter, showNativeTokenAsMainBalance } = + useSelector(getPreferences); + const shouldHideZeroBalanceTokens = useSelector( + getShouldHideZeroBalanceTokens, + ); + + const { totalFiatBalance: selectedAccountBalance } = + useAccountTotalFiatBalance(selectedAccount, shouldHideZeroBalanceTokens); + + // TODO: fetch balances across networks + // const multiNetworkAccountBalance = useMultichainAccountBalance() + + const handleFilter = (chainFilters: Record) => { + dispatch(setTokenNetworkFilter(chainFilters)); + + // TODO Add metrics + handleClose(); + }; + + return ( + <> + handleFilter({})} + > + + + + {t('allNetworks')} + + + {/* TODO: Should query cross chain account balance */} + $1,000.00 + + + + {Object.values(allNetworks) + .slice(0, 5) // only show a max of 5 icons overlapping + .map((network, index) => { + const networkImageUrl = + CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[ + network.chainId as keyof typeof CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP + ]; + return ( + + ); + })} + + + + handleFilter({ [chainId]: true })} + > + + + + {t('currentNetwork')} + + + + + + + + ); +}; + +export default NetworkFilter; diff --git a/ui/components/app/assets/asset-list/sort-control/sort-control.tsx b/ui/components/app/assets/asset-list/sort-control/sort-control.tsx index c45a5488f1a6..8e216b5ed6c2 100644 --- a/ui/components/app/assets/asset-list/sort-control/sort-control.tsx +++ b/ui/components/app/assets/asset-list/sort-control/sort-control.tsx @@ -1,13 +1,11 @@ import React, { ReactNode, useContext } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import classnames from 'classnames'; -import { Box, Text } from '../../../../component-library'; +import { Box } from '../../../../component-library'; import { SortOrder, SortingCallbacksT } from '../../util/sort'; import { BackgroundColor, BorderRadius, - TextColor, - TextVariant, } from '../../../../../helpers/constants/design-system'; import { setTokenSortConfig } from '../../../../../store/actions'; import { MetaMetricsContext } from '../../../../../contexts/metametrics'; @@ -45,9 +43,7 @@ export const SelectableListItem = ({ })} onClick={onClick} > - - {children} - + {children} {isSelected && ( { + // TODO filter assets by networkTokenFilter before sorting return sortAssets( [nativeTokenWithBalance, ...tokensWithBalances], tokenSortConfig, @@ -59,6 +60,7 @@ export default function TokenList({ }, [ tokensWithBalances, tokenSortConfig, + tokenNetworkFilter, conversionRate, contractExchangeRates, ]); diff --git a/ui/components/app/assets/util/filter.test.ts b/ui/components/app/assets/util/filter.test.ts new file mode 100644 index 000000000000..fd5a612d590b --- /dev/null +++ b/ui/components/app/assets/util/filter.test.ts @@ -0,0 +1,98 @@ +import { filterAssets, FilterCriteria } from './filter'; + +describe('filterAssets function - balance and chainId filtering', () => { + type MockToken = { + name: string; + symbol: string; + chainId: string; // Updated to string (e.g., '0x01', '0x89') + balance: number; + }; + + const mockTokens: MockToken[] = [ + { name: 'Token1', symbol: 'T1', chainId: '0x01', balance: 100 }, + { name: 'Token2', symbol: 'T2', chainId: '0x02', balance: 50 }, + { name: 'Token3', symbol: 'T3', chainId: '0x01', balance: 200 }, + { name: 'Token4', symbol: 'T4', chainId: '0x89', balance: 150 }, + ]; + + test('filters by inclusive chainId', () => { + const criteria: FilterCriteria[] = [ + { + key: 'chainId', + opts: { '0x01': true, '0x89': true }, // ChainId must be '0x01' or '0x89' + filterCallback: 'inclusive', + }, + ]; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered.length).toBe(3); // Should include 3 tokens with chainId '0x01' and '0x89' + expect(filtered.map((token) => token.chainId)).toEqual([ + '0x01', + '0x01', + '0x89', + ]); + }); + + test('filters tokens with balance between 100 and 150 inclusive', () => { + const criteria: FilterCriteria[] = [ + { + key: 'balance', + opts: { min: 100, max: 150 }, // Balance between 100 and 150 + filterCallback: 'range', + }, + ]; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered.length).toBe(2); // Token1 and Token4 + expect(filtered.map((token) => token.balance)).toEqual([100, 150]); + }); + + test('filters by inclusive chainId and balance range', () => { + const criteria: FilterCriteria[] = [ + { + key: 'chainId', + opts: { '0x01': true, '0x89': true }, // ChainId must be '0x01' or '0x89' + filterCallback: 'inclusive', + }, + { + key: 'balance', + opts: { min: 100, max: 150 }, // Balance between 100 and 150 + filterCallback: 'range', + }, + ]; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered.length).toBe(2); // Token1 and Token4 meet both criteria + }); + + test('returns no tokens if no chainId matches', () => { + const criteria: FilterCriteria[] = [ + { + key: 'chainId', + opts: { '0x04': true }, // No token with chainId '0x04' + filterCallback: 'inclusive', + }, + ]; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered.length).toBe(0); // No matching tokens + }); + + test('returns no tokens if balance is not within range', () => { + const criteria: FilterCriteria[] = [ + { + key: 'balance', + opts: { min: 300, max: 400 }, // No token with balance between 300 and 400 + filterCallback: 'range', + }, + ]; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered.length).toBe(0); // No matching tokens + }); +}); diff --git a/ui/components/app/assets/util/filter.ts b/ui/components/app/assets/util/filter.ts new file mode 100644 index 000000000000..20ca7cebcc58 --- /dev/null +++ b/ui/components/app/assets/util/filter.ts @@ -0,0 +1,62 @@ +import { get } from 'lodash'; + +export type FilterCriteria = { + key: string; + opts: Record; // Use opts for range, inclusion, etc. + filterCallback: FilterCallbackKeys; // Specify the type of filter: 'range', 'inclusive', etc. +}; + +export type FilterType = string | number | boolean | Date; +type FilterCallbackKeys = keyof FilterCallbacksT; + +export type FilterCallbacksT = { + inclusive: (value: string, opts: Record) => boolean; + range: (value: number, opts: Record) => boolean; +}; + +const filterCallbacks: FilterCallbacksT = { + inclusive: (value: string, opts: Record) => { + if (Object.entries(opts).length === 0) { + return false; + } + return opts[value]; + }, + range: (value: number, opts: Record) => + value >= opts.min && value <= opts.max, +}; + +function getNestedValue(obj: T, keyPath: string): FilterType { + return get(obj, keyPath); +} + +export function filterAssets(assets: T[], criteria: FilterCriteria[]): T[] { + if (criteria.length === 0) { + return assets; + } + + return assets.filter((asset) => + criteria.every(({ key, opts, filterCallback }) => { + const nestedValue = getNestedValue(asset, key); + + // If there's no callback or options, exit early and don't filter based on this criterion. + if (!filterCallback || !opts) { + return true; + } + + switch (filterCallback) { + case 'inclusive': + return filterCallbacks.inclusive( + nestedValue as string, + opts as Record, + ); + case 'range': + return filterCallbacks.range( + nestedValue as number, + opts as { min: number; max: number }, + ); + default: + return true; + } + }), + ); +} diff --git a/ui/store/actions.ts b/ui/store/actions.ts index f8e232f3a519..06b892db0b1c 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -3113,6 +3113,10 @@ export function setTokenSortConfig(value: SortCriteria) { return setPreference('tokenSortConfig', value, false); } +export function setTokenNetworkFilter(value: Record) { + return setPreference('tokenNetworkFilter', value, false); +} + export function setSmartTransactionsPreferenceEnabled( value: boolean, ): ThunkAction {