From a1c0502cb2f05cb881027fcf696cbfdf41fd867b Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Mon, 9 Jan 2023 14:47:47 +0100 Subject: [PATCH] [Spam tokens] manually hide tokens (#1377) Tokens can be manually hidden on the assets page: - "Hide token" IconButton in action column of AssetTable - "Hide tokens" Button to enter "hide token menu" which lets users select multiple tokens to hide / unhide Hidden tokens will have an effect on: - displayed total balance of Safes - displayed tokens in Asset table - displayed tokens in dialogs: Send Funds, New Spending limit - number of assets in dashboard / overview Co-authored-by: Usame Algan --- .../address-book/AddressBookTable/index.tsx | 88 ++-- .../balances/AssetsHeader/index.tsx | 15 +- .../balances/AssetsTable/index.test.tsx | 431 ++++++++++++++++++ src/components/balances/AssetsTable/index.tsx | 256 +++++++---- .../balances/AssetsTable/useHideAssets.ts | 93 ++++ .../balances/HiddenTokenButton/index.test.tsx | 85 ++++ .../balances/HiddenTokenButton/index.tsx | 45 ++ .../HiddenTokenButton/styles.module.css | 5 + src/components/balances/TokenMenu/index.tsx | 53 +++ .../balances/TokenMenu/styles.module.css | 38 ++ src/components/common/EnhancedTable/index.tsx | 41 +- .../common/EnhancedTable/styles.module.css | 13 + .../dashboard/Overview/Overview.tsx | 4 +- .../steps/SpendingLimitForm.tsx | 4 +- .../SpendingLimits/SpendingLimitsTable.tsx | 72 +-- .../settings/owner/OwnerList/index.tsx | 30 +- .../sidebar/SidebarHeader/index.tsx | 4 +- .../TokenTransferModal/SendAssetsForm.tsx | 4 +- src/hooks/__tests__/useBalances.test.ts | 70 +++ .../__tests__/useVisibleBalances.test.ts | 224 +++++++++ src/hooks/useHiddenTokens.ts | 10 + src/hooks/useVisibleBalances.ts | 59 +++ src/pages/balances/index.tsx | 21 +- src/services/analytics/events/assets.ts | 29 ++ src/services/analytics/useMetaEvents.ts | 11 + src/store/settingsSlice.ts | 20 +- src/styles/theme.ts | 10 + 27 files changed, 1515 insertions(+), 220 deletions(-) create mode 100644 src/components/balances/AssetsTable/index.test.tsx create mode 100644 src/components/balances/AssetsTable/useHideAssets.ts create mode 100644 src/components/balances/HiddenTokenButton/index.test.tsx create mode 100644 src/components/balances/HiddenTokenButton/index.tsx create mode 100644 src/components/balances/HiddenTokenButton/styles.module.css create mode 100644 src/components/balances/TokenMenu/index.tsx create mode 100644 src/components/balances/TokenMenu/styles.module.css create mode 100644 src/hooks/__tests__/useBalances.test.ts create mode 100644 src/hooks/__tests__/useVisibleBalances.test.ts create mode 100644 src/hooks/useHiddenTokens.ts create mode 100644 src/hooks/useVisibleBalances.ts diff --git a/src/components/address-book/AddressBookTable/index.tsx b/src/components/address-book/AddressBookTable/index.tsx index c371bc308b..5126459ab1 100644 --- a/src/components/address-book/AddressBookTable/index.tsx +++ b/src/components/address-book/AddressBookTable/index.tsx @@ -82,50 +82,52 @@ const AddressBookTable = () => { }, [addressBookEntries, searchQuery]) const rows = filteredEntries.map(([address, name]) => ({ - name: { - rawValue: name, - content: name, - }, - address: { - rawValue: address, - content: , - }, - actions: { - rawValue: '', - sticky: true, - content: ( -
- - - handleOpenModalWithValues(ModalType.ENTRY, address, name)} size="small"> - - - - - - - - handleOpenModalWithValues(ModalType.REMOVE, address, name)} size="small"> - - - - - - {isGranted && ( - - + cells: { + name: { + rawValue: name, + content: name, + }, + address: { + rawValue: address, + content: , + }, + actions: { + rawValue: '', + sticky: true, + content: ( +
+ + + handleOpenModalWithValues(ModalType.ENTRY, address, name)} size="small"> + + + - )} -
- ), + + + + handleOpenModalWithValues(ModalType.REMOVE, address, name)} size="small"> + + + + + + {isGranted && ( + + + + )} +
+ ), + }, }, })) diff --git a/src/components/balances/AssetsHeader/index.tsx b/src/components/balances/AssetsHeader/index.tsx index a2fa351654..66278b13d4 100644 --- a/src/components/balances/AssetsHeader/index.tsx +++ b/src/components/balances/AssetsHeader/index.tsx @@ -1,20 +1,21 @@ import { Box } from '@mui/material' -import type { ReactElement } from 'react' +import type { ReactElement, ReactNode } from 'react' import NavTabs from '@/components/common/NavTabs' import PageHeader from '@/components/common/PageHeader' -import CurrencySelect from '@/components/balances/CurrencySelect' import { balancesNavItems } from '@/components/sidebar/SidebarNavigation/config' -const AssetsHeader = ({ currencySelect = false }: { currencySelect?: boolean }): ReactElement => { +const AssetsHeader = ({ children }: { children?: ReactNode }): ReactElement => { return ( - - {currencySelect && } - + <> + + + {children} + + } /> ) diff --git a/src/components/balances/AssetsTable/index.test.tsx b/src/components/balances/AssetsTable/index.test.tsx new file mode 100644 index 0000000000..e513560925 --- /dev/null +++ b/src/components/balances/AssetsTable/index.test.tsx @@ -0,0 +1,431 @@ +import * as useChainId from '@/hooks/useChainId' +import useHiddenTokens from '@/hooks/useHiddenTokens' +import { act, fireEvent, getByRole, getByTestId, render, waitFor } from '@/tests/test-utils' +import { safeParseUnits } from '@/utils/formatters' +import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' +import { hexZeroPad } from 'ethers/lib/utils' +import { useState } from 'react' +import AssetsTable from '.' +import { COLLAPSE_TIMEOUT_MS } from './useHideAssets' + +const getParentRow = (element: HTMLElement | null) => { + while (element !== null) { + if (element.tagName.toLowerCase() === 'tr') { + return element + } + element = element.parentElement + } + return null +} + +const TestComponent = () => { + const [showHidden, setShowHidden] = useState(false) + const hiddenTokens = useHiddenTokens() + return ( + <> + + - - ), + {!isNative && } + + ), + }, + balance: { + rawValue: Number(item.balance) / 10 ** item.tokenInfo.decimals, + collapsed: item.tokenInfo.address === hidingAsset, + content: ( + + ), + }, + value: { + rawValue: rawFiatValue, + collapsed: item.tokenInfo.address === hidingAsset, + content: ( + <> + + {rawFiatValue === 0 && ( + + + + + + )} + + ), + }, + actions: { + rawValue: '', + sticky: true, + collapsed: item.tokenInfo.address === hidingAsset, + content: ( + + <> + {!shouldHideSend && ( + + + + )} + {showHiddenAssets ? ( + toggleAsset(item.tokenInfo.address)} /> + ) : ( + + + hideAsset(item.tokenInfo.address)} + > + + + + + )} + + + ), + }, }, } }) return ( -
- - {selectedAsset && ( - setSelectedAsset(undefined)} - initialData={[{ tokenAddress: selectedAsset }]} - /> - )} -
+ <> + + +
+ + {selectedAsset && ( + setSelectedAsset(undefined)} + initialData={[{ tokenAddress: selectedAsset }]} + /> + )} +
+ ) } diff --git a/src/components/balances/AssetsTable/useHideAssets.ts b/src/components/balances/AssetsTable/useHideAssets.ts new file mode 100644 index 0000000000..f5b36a2050 --- /dev/null +++ b/src/components/balances/AssetsTable/useHideAssets.ts @@ -0,0 +1,93 @@ +import useBalances from '@/hooks/useBalances' +import useChainId from '@/hooks/useChainId' +import useHiddenTokens from '@/hooks/useHiddenTokens' +import { useAppDispatch } from '@/store' +import { setHiddenTokensForChain } from '@/store/settingsSlice' +import { useCallback, useState } from 'react' + +// This is the default for MUI Collapse +export const COLLAPSE_TIMEOUT_MS = 300 + +export const useHideAssets = (closeDialog: () => void) => { + const dispatch = useAppDispatch() + const chainId = useChainId() + const { balances } = useBalances() + + const [assetsToHide, setAssetsToHide] = useState([]) + const [assetsToUnhide, setAssetsToUnhide] = useState([]) + const [hidingAsset, setHidingAsset] = useState() + const hiddenAssets = useHiddenTokens() + + const toggleAsset = useCallback( + (address: string) => { + if (assetsToHide.includes(address)) { + setAssetsToHide(assetsToHide.filter((asset) => asset !== address)) + return + } + + if (assetsToUnhide.includes(address)) { + setAssetsToUnhide(assetsToUnhide.filter((asset) => asset !== address)) + return + } + + const assetIsHidden = hiddenAssets.includes(address) + if (!assetIsHidden) { + setAssetsToHide(assetsToHide.concat(address)) + } else { + setAssetsToUnhide(assetsToUnhide.concat(address)) + } + }, + [assetsToHide, assetsToUnhide, hiddenAssets], + ) + + /** + * Unhide all assets which are included in the current Safe's balance. + */ + const deselectAll = useCallback(() => { + setAssetsToHide([]) + setAssetsToUnhide([ + ...hiddenAssets.filter((asset) => balances.items.some((item) => item.tokenInfo.address === asset)), + ]) + }, [hiddenAssets, balances]) + + // Assets are selected if they are either hidden or marked for hiding + const isAssetSelected = useCallback( + (address: string) => + (hiddenAssets.includes(address) && !assetsToUnhide.includes(address)) || assetsToHide.includes(address), + [assetsToHide, assetsToUnhide, hiddenAssets], + ) + + const cancel = useCallback(() => { + setAssetsToHide([]) + setAssetsToUnhide([]) + closeDialog() + }, [closeDialog]) + + const hideAsset = useCallback( + (address: string) => { + setHidingAsset(address) + setTimeout(() => { + const newHiddenAssets = [...hiddenAssets, address] + dispatch(setHiddenTokensForChain({ chainId, assets: newHiddenAssets })) + setHidingAsset(undefined) + }, COLLAPSE_TIMEOUT_MS) + }, + [chainId, dispatch, hiddenAssets], + ) + + const saveChanges = useCallback(() => { + const newHiddenAssets = [...hiddenAssets.filter((asset) => !assetsToUnhide.includes(asset)), ...assetsToHide] + dispatch(setHiddenTokensForChain({ chainId, assets: newHiddenAssets })) + cancel() + }, [assetsToHide, assetsToUnhide, chainId, dispatch, hiddenAssets, cancel]) + + return { + hideAsset, + saveChanges, + cancel, + toggleAsset, + isAssetSelected, + deselectAll, + hidingAsset, + } +} diff --git a/src/components/balances/HiddenTokenButton/index.test.tsx b/src/components/balances/HiddenTokenButton/index.test.tsx new file mode 100644 index 0000000000..5f736d4c98 --- /dev/null +++ b/src/components/balances/HiddenTokenButton/index.test.tsx @@ -0,0 +1,85 @@ +import * as useChainId from '@/hooks/useChainId' +import { fireEvent, render } from '@/tests/test-utils' +import { hexZeroPad } from 'ethers/lib/utils' +import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' +import { safeParseUnits } from '@/utils/formatters' +import HiddenTokenButton from '.' +import { useState } from 'react' + +const TestComponent = () => { + const [showHidden, setShowHidden] = useState(false) + return ( + setShowHidden((prev) => !prev)} /> + ) +} + +describe('HiddenTokenToggle', () => { + beforeEach(() => { + jest.clearAllMocks() + window.localStorage.clear() + jest.spyOn(useChainId, 'default').mockReturnValue('5') + }) + + test('button disabled if hidden assets are visible', async () => { + const mockHiddenAssets = { + '5': [hexZeroPad('0x3', 20)], + } + const mockBalances = { + data: { + fiatTotal: '300', + items: [ + { + balance: safeParseUnits('100', 18)!.toString(), + fiatBalance: '100', + fiatConversion: '1', + tokenInfo: { + address: hexZeroPad('0x2', 20), + decimals: 18, + logoUri: '', + name: 'DAI', + symbol: 'DAI', + type: TokenType.ERC20, + }, + }, + { + balance: safeParseUnits('200', 18)!.toString(), + fiatBalance: '200', + fiatConversion: '1', + tokenInfo: { + address: hexZeroPad('0x3', 20), + decimals: 18, + logoUri: '', + name: 'SPAM', + symbol: 'SPM', + type: TokenType.ERC20, + }, + }, + ], + }, + loading: false, + error: undefined, + } + + const result = render(, { + initialReduxState: { + balances: mockBalances, + settings: { + currency: 'usd', + hiddenTokens: mockHiddenAssets, + shortName: { + show: true, + copy: true, + qr: true, + }, + theme: { + darkMode: true, + }, + }, + }, + }) + fireEvent.click(result.getByTestId('toggle-hidden-assets')) + + // Now it is disabled + expect(result.getByTestId('toggle-hidden-assets')).toBeDisabled() + }) +}) diff --git a/src/components/balances/HiddenTokenButton/index.tsx b/src/components/balances/HiddenTokenButton/index.tsx new file mode 100644 index 0000000000..c97a48ce6f --- /dev/null +++ b/src/components/balances/HiddenTokenButton/index.tsx @@ -0,0 +1,45 @@ +import { type ReactElement } from 'react' +import { Typography, Button } from '@mui/material' +import { ASSETS_EVENTS } from '@/services/analytics' +import useHiddenTokens from '@/hooks/useHiddenTokens' +import useBalances from '@/hooks/useBalances' +import { VisibilityOutlined } from '@mui/icons-material' +import Track from '@/components/common/Track' + +import css from './styles.module.css' + +const HiddenTokenButton = ({ + toggleShowHiddenAssets, + showHiddenAssets, +}: { + toggleShowHiddenAssets?: () => void + showHiddenAssets?: boolean +}): ReactElement | null => { + const { balances } = useBalances() + const currentHiddenAssets = useHiddenTokens() + + const hiddenAssetCount = + balances.items?.filter((item) => currentHiddenAssets.includes(item.tokenInfo.address)).length || 0 + + return ( + + + + ) +} + +export default HiddenTokenButton diff --git a/src/components/balances/HiddenTokenButton/styles.module.css b/src/components/balances/HiddenTokenButton/styles.module.css new file mode 100644 index 0000000000..fddd67f640 --- /dev/null +++ b/src/components/balances/HiddenTokenButton/styles.module.css @@ -0,0 +1,5 @@ +@media (max-width: 600px) { + .hiddenTokenButton { + display: none; + } +} diff --git a/src/components/balances/TokenMenu/index.tsx b/src/components/balances/TokenMenu/index.tsx new file mode 100644 index 0000000000..f1d6c46538 --- /dev/null +++ b/src/components/balances/TokenMenu/index.tsx @@ -0,0 +1,53 @@ +import Track from '@/components/common/Track' +import { ASSETS_EVENTS } from '@/services/analytics' +import { VisibilityOffOutlined } from '@mui/icons-material' +import { Box, Typography, Button } from '@mui/material' + +import css from './styles.module.css' + +const TokenMenu = ({ + saveChanges, + cancel, + selectedAssetCount, + showHiddenAssets, + deselectAll, +}: { + saveChanges: () => void + cancel: () => void + deselectAll: () => void + selectedAssetCount: number + showHiddenAssets: boolean +}) => { + if (selectedAssetCount === 0 && !showHiddenAssets) { + return null + } + return ( + + + + + {selectedAssetCount} {selectedAssetCount === 1 ? 'token' : 'tokens'} selected + + + + + + + + + + + + + + + ) +} + +export default TokenMenu diff --git a/src/components/balances/TokenMenu/styles.module.css b/src/components/balances/TokenMenu/styles.module.css new file mode 100644 index 0000000000..090719ec44 --- /dev/null +++ b/src/components/balances/TokenMenu/styles.module.css @@ -0,0 +1,38 @@ +.hideTokensHeader { + display: flex; + flex-direction: row; + flex: 1; + gap: var(--space-1); + padding: 5px var(--space-2); + background-color: var(--color-background-light); + border-radius: 6px; + min-width: 185px; +} + +.stickyBox { + position: sticky; + top: 111px; /* under AssetHeader */ + z-index: 1; + padding: var(--space-2) 0; + background-color: var(--color-background-default); + display: flex; + flex-wrap: wrap; + flex-direction: row; + align-items: center; + margin-top: -24px; + gap: var(--space-3); +} + +@media (max-width: 600px) { + .stickyBox { + margin-top: -16px; + } +} + +.cancelButton { + padding: 4px 10px; +} + +.applyButton { + padding: 6px var(--space-3); +} diff --git a/src/components/common/EnhancedTable/index.tsx b/src/components/common/EnhancedTable/index.tsx index 225db6813c..59ccf86230 100644 --- a/src/components/common/EnhancedTable/index.tsx +++ b/src/components/common/EnhancedTable/index.tsx @@ -16,16 +16,21 @@ import type { PaperTypeMap } from '@mui/material/Paper/Paper' import classNames from 'classnames' import css from './styles.module.css' +import { Collapse } from '@mui/material' -type EnhancedRow = Record< - string, - { - content: ReactNode - rawValue: string | number - sticky?: boolean - hide?: boolean - } -> +type EnhancedCell = { + content: ReactNode + rawValue: string | number + sticky?: boolean + hide?: boolean +} + +type EnhancedRow = { + selected?: boolean + collapsed?: boolean + key?: string + cells: Record +} type EnhancedHeadCell = { id: string @@ -36,10 +41,10 @@ type EnhancedHeadCell = { } function descendingComparator(a: EnhancedRow, b: EnhancedRow, orderBy: string) { - if (b[orderBy].rawValue < a[orderBy].rawValue) { + if (b.cells[orderBy].rawValue < a.cells[orderBy].rawValue) { return -1 } - if (b[orderBy].rawValue > a[orderBy].rawValue) { + if (b.cells[orderBy].rawValue > a.cells[orderBy].rawValue) { return 1 } return 0 @@ -139,16 +144,24 @@ function EnhancedTable({ rows, headCells, variant }: EnhancedTableProps) { {pagedRows.length > 0 ? ( pagedRows.map((row, index) => ( - - {Object.entries(row).map(([key, cell]) => ( + + {Object.entries(row.cells).map(([key, cell]) => ( - {cell.content} + + {cell.content} + ))} diff --git a/src/components/common/EnhancedTable/styles.module.css b/src/components/common/EnhancedTable/styles.module.css index 68a1a93c79..47f327d413 100644 --- a/src/components/common/EnhancedTable/styles.module.css +++ b/src/components/common/EnhancedTable/styles.module.css @@ -2,6 +2,19 @@ display: none; } +.tableCell { + transition: padding 0s; +} + +.collapsedCell { + padding: 0px !important; + transition: padding 300ms ease-in-out; +} + +.collapsedRow { + border-bottom: none !important; +} + .actions { display: flex; justify-content: flex-end; diff --git a/src/components/dashboard/Overview/Overview.tsx b/src/components/dashboard/Overview/Overview.tsx index 5087b8f015..59a77c1bab 100644 --- a/src/components/dashboard/Overview/Overview.tsx +++ b/src/components/dashboard/Overview/Overview.tsx @@ -7,7 +7,6 @@ import { Box, Button, Grid, Skeleton, Typography } from '@mui/material' import { Card, WidgetBody, WidgetContainer } from '../styled' import useSafeInfo from '@/hooks/useSafeInfo' import { useCurrentChain } from '@/hooks/useChains' -import useBalances from '@/hooks/useBalances' import SafeIcon from '@/components/common/SafeIcon' import ChainIndicator from '@/components/common/ChainIndicator' import EthHashInfo from '@/components/common/EthHashInfo' @@ -15,6 +14,7 @@ import { AppRoutes } from '@/config/routes' import useSafeAddress from '@/hooks/useSafeAddress' import useCollectibles from '@/hooks/useCollectibles' import type { UrlObject } from 'url' +import { useVisibleBalances } from '@/hooks/useVisibleBalances' const IdenticonContainer = styled.div` position: relative; @@ -83,7 +83,7 @@ const Overview = (): ReactElement => { const router = useRouter() const safeAddress = useSafeAddress() const { safe, safeLoading } = useSafeInfo() - const { balances } = useBalances() + const { balances } = useVisibleBalances() const [nfts] = useCollectibles() const chain = useCurrentChain() const { chainId } = chain || {} diff --git a/src/components/settings/SpendingLimits/NewSpendingLimit/steps/SpendingLimitForm.tsx b/src/components/settings/SpendingLimits/NewSpendingLimit/steps/SpendingLimitForm.tsx index 9dc173f5da..083752cf35 100644 --- a/src/components/settings/SpendingLimits/NewSpendingLimit/steps/SpendingLimitForm.tsx +++ b/src/components/settings/SpendingLimits/NewSpendingLimit/steps/SpendingLimitForm.tsx @@ -16,13 +16,13 @@ import { } from '@mui/material' import AddressBookInput from '@/components/common/AddressBookInput' import { validateAmount } from '@/utils/validation' -import useBalances from '@/hooks/useBalances' import { AutocompleteItem } from '@/components/tx/modals/TokenTransferModal/SendAssetsForm' import useChainId from '@/hooks/useChainId' import { getResetTimeOptions } from '@/components/transactions/TxDetails/TxData/SpendingLimits' import { defaultAbiCoder } from '@ethersproject/abi' import { parseUnits } from 'ethers/lib/utils' import NumberField from '@/components/common/NumberField' +import { useVisibleBalances } from '@/hooks/useVisibleBalances' export type NewSpendingLimitData = { beneficiary: string @@ -49,7 +49,7 @@ export const _validateSpendingLimit = (val: string, decimals?: number) => { export const SpendingLimitForm = ({ data, onSubmit }: Props) => { const chainId = useChainId() const [showResetTime, setShowResetTime] = useState(false) - const { balances } = useBalances() + const { balances } = useVisibleBalances() const resetTimeOptions = useMemo(() => getResetTimeOptions(chainId), [chainId]) diff --git a/src/components/settings/SpendingLimits/SpendingLimitsTable.tsx b/src/components/settings/SpendingLimits/SpendingLimitsTable.tsx index c68373e42e..18cdb19477 100644 --- a/src/components/settings/SpendingLimits/SpendingLimitsTable.tsx +++ b/src/components/settings/SpendingLimits/SpendingLimitsTable.tsx @@ -58,41 +58,43 @@ export const SpendingLimitsTable = ({ spendingLimits }: { spendingLimits: Spendi const formattedSpent = safeFormatUnits(spent, token?.tokenInfo.decimals) return { - beneficiary: { - rawValue: spendingLimit.beneficiary, - content: ( - - ), - }, - spent: { - rawValue: spendingLimit.spent, - content: ( - - - {`${formattedSpent} of ${formattedAmount} ${token?.tokenInfo.symbol}`} - - ), - }, - resetTime: { - rawValue: spendingLimit.resetTimeMin, - content: ( - - ), - }, - actions: { - rawValue: '', - sticky: true, - hide: shouldHideactions, - content: ( - - onRemove(spendingLimit)} color="error" size="small"> - - - - ), + cells: { + beneficiary: { + rawValue: spendingLimit.beneficiary, + content: ( + + ), + }, + spent: { + rawValue: spendingLimit.spent, + content: ( + + + {`${formattedSpent} of ${formattedAmount} ${token?.tokenInfo.symbol}`} + + ), + }, + resetTime: { + rawValue: spendingLimit.resetTimeMin, + content: ( + + ), + }, + actions: { + rawValue: '', + sticky: true, + hide: shouldHideactions, + content: ( + + onRemove(spendingLimit)} color="error" size="small"> + + + + ), + }, }, } }), diff --git a/src/components/settings/owner/OwnerList/index.tsx b/src/components/settings/owner/OwnerList/index.tsx index 3e6dcf07d4..85b7080842 100644 --- a/src/components/settings/owner/OwnerList/index.tsx +++ b/src/components/settings/owner/OwnerList/index.tsx @@ -26,20 +26,22 @@ export const OwnerList = ({ isGranted }: { isGranted: boolean }) => { const name = addressBook[address] return { - owner: { - rawValue: address, - content: , - }, - actions: { - rawValue: '', - sticky: true, - content: ( -
- {isGranted && } - - {isGranted && } -
- ), + cells: { + owner: { + rawValue: address, + content: , + }, + actions: { + rawValue: '', + sticky: true, + content: ( +
+ {isGranted && } + + {isGranted && } +
+ ), + }, }, } }) diff --git a/src/components/sidebar/SidebarHeader/index.tsx b/src/components/sidebar/SidebarHeader/index.tsx index a84952d881..e31b1898eb 100644 --- a/src/components/sidebar/SidebarHeader/index.tsx +++ b/src/components/sidebar/SidebarHeader/index.tsx @@ -9,7 +9,6 @@ import { formatCurrency } from '@/utils/formatNumber' import useSafeInfo from '@/hooks/useSafeInfo' import SafeIcon from '@/components/common/SafeIcon' import NewTxButton from '@/components/sidebar/NewTxButton' -import useBalances from '@/hooks/useBalances' import { useAppSelector } from '@/store' import { selectCurrency } from '@/store/settingsSlice' @@ -27,10 +26,11 @@ import QrCodeButton from '../QrCodeButton' import Track from '@/components/common/Track' import { OVERVIEW_EVENTS } from '@/services/analytics/events/overview' import { SvgIcon } from '@mui/material' +import { useVisibleBalances } from '@/hooks/useVisibleBalances' const SafeHeader = (): ReactElement => { const currency = useAppSelector(selectCurrency) - const { balances, loading: balancesLoading } = useBalances() + const { balances, loading: balancesLoading } = useVisibleBalances() const { safe, safeAddress, safeLoading } = useSafeInfo() const { threshold, owners } = safe const chain = useCurrentChain() diff --git a/src/components/tx/modals/TokenTransferModal/SendAssetsForm.tsx b/src/components/tx/modals/TokenTransferModal/SendAssetsForm.tsx index 21e9eb8358..866334f1be 100644 --- a/src/components/tx/modals/TokenTransferModal/SendAssetsForm.tsx +++ b/src/components/tx/modals/TokenTransferModal/SendAssetsForm.tsx @@ -18,7 +18,6 @@ import { BigNumber } from '@ethersproject/bignumber' import TokenIcon from '@/components/common/TokenIcon' import { formatVisualAmount, safeFormatUnits } from '@/utils/formatters' import { validateDecimalLength, validateLimitedAmount } from '@/utils/validation' -import useBalances from '@/hooks/useBalances' import AddressBookInput from '@/components/common/AddressBookInput' import InputValueHelper from '@/components/common/InputValueHelper' import SendFromBlock from '../../SendFromBlock' @@ -32,6 +31,7 @@ import { sameAddress } from '@/utils/addresses' import InfoIcon from '@/public/images/notifications/info.svg' import useIsSafeTokenPaused from '@/components/tx/modals/TokenTransferModal/useIsSafeTokenPaused' import NumberField from '@/components/common/NumberField' +import { useVisibleBalances } from '@/hooks/useVisibleBalances' export const AutocompleteItem = (item: { tokenInfo: TokenInfo; balance: string }): ReactElement => ( @@ -73,7 +73,7 @@ type SendAssetsFormProps = { } const SendAssetsForm = ({ onSubmit, formData, disableSpendingLimit = false }: SendAssetsFormProps): ReactElement => { - const { balances } = useBalances() + const { balances } = useVisibleBalances() const addressBook = useAddressBook() const chainId = useChainId() const safeTokenAddress = getSafeTokenAddress(chainId) diff --git a/src/hooks/__tests__/useBalances.test.ts b/src/hooks/__tests__/useBalances.test.ts new file mode 100644 index 0000000000..8a591e2873 --- /dev/null +++ b/src/hooks/__tests__/useBalances.test.ts @@ -0,0 +1,70 @@ +import { type SafeBalanceResponse, TokenType } from '@safe-global/safe-gateway-typescript-sdk' +import * as store from '@/store' +import { renderHook } from '@/tests/test-utils' +import useBalances from '../useBalances' +import { hexZeroPad } from 'ethers/lib/utils' + +describe('useBalances', () => { + test('empty balance', () => { + const balance: SafeBalanceResponse = { + fiatTotal: '0', + items: [], + } + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => + selector({ + balances: { data: balance, error: undefined, loading: false }, + } as store.RootState), + ) + + const { result } = renderHook(() => useBalances()) + + expect(result.current.balances.fiatTotal).toEqual('0') + expect(result.current.balances.items).toHaveLength(0) + }) + + test('return all balances', () => { + const tokenAddress = hexZeroPad('0x2', 20) + const balance: SafeBalanceResponse = { + fiatTotal: '100', + items: [ + { + balance: '40', + fiatBalance: '40', + fiatConversion: '1', + tokenInfo: { + address: tokenAddress, + decimals: 18, + logoUri: '', + name: 'Hidden Token', + symbol: 'HT', + type: TokenType.ERC20, + }, + }, + { + balance: '60', + fiatBalance: '60', + fiatConversion: '1', + tokenInfo: { + address: tokenAddress, + decimals: 18, + logoUri: '', + name: 'Visible Token', + symbol: 'VT', + type: TokenType.ERC20, + }, + }, + ], + } + + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => + selector({ + balances: { data: balance, error: undefined, loading: false }, + } as store.RootState), + ) + + const { result } = renderHook(() => useBalances()) + + expect(result.current.balances.fiatTotal).toEqual('100') + expect(result.current.balances.items).toHaveLength(2) + }) +}) diff --git a/src/hooks/__tests__/useVisibleBalances.test.ts b/src/hooks/__tests__/useVisibleBalances.test.ts new file mode 100644 index 0000000000..5da95c24df --- /dev/null +++ b/src/hooks/__tests__/useVisibleBalances.test.ts @@ -0,0 +1,224 @@ +import { type SafeBalanceResponse, TokenType } from '@safe-global/safe-gateway-typescript-sdk' +import * as store from '@/store' +import { renderHook } from '@/tests/test-utils' +import { hexZeroPad } from 'ethers/lib/utils' +import { useVisibleBalances } from '../useVisibleBalances' + +describe('useVisibleBalances', () => { + const hiddenTokenAddress = hexZeroPad('0x2', 20) + const visibleTokenAddress = hexZeroPad('0x3', 20) + + test('empty balance', () => { + const balance: SafeBalanceResponse = { + fiatTotal: '0', + items: [], + } + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => + selector({ + balances: { data: balance, error: undefined, loading: false }, + settings: { + currency: 'USD', + shortName: { + copy: true, + qr: true, + show: true, + }, + theme: { + darkMode: false, + }, + hiddenTokens: { ['4']: [hiddenTokenAddress] }, + }, + } as unknown as store.RootState), + ) + + const { result } = renderHook(() => useVisibleBalances()) + + expect(result.current.balances.fiatTotal).toEqual('0') + expect(result.current.balances.items).toHaveLength(0) + }) + + test('return only visible balance', () => { + const balance: SafeBalanceResponse = { + fiatTotal: '100', + items: [ + { + balance: '40', + fiatBalance: '40', + fiatConversion: '1', + tokenInfo: { + address: hiddenTokenAddress, + decimals: 18, + logoUri: '', + name: 'Hidden Token', + symbol: 'HT', + type: TokenType.ERC20, + }, + }, + { + balance: '60', + fiatBalance: '60', + fiatConversion: '1', + tokenInfo: { + address: visibleTokenAddress, + decimals: 18, + logoUri: '', + name: 'Visible Token', + symbol: 'VT', + type: TokenType.ERC20, + }, + }, + ], + } + + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => + selector({ + balances: { data: balance, error: undefined, loading: false }, + settings: { + currency: 'USD', + shortName: { + copy: true, + qr: true, + show: true, + }, + theme: { + darkMode: false, + }, + hiddenTokens: { ['4']: [hiddenTokenAddress] }, + }, + } as unknown as store.RootState), + ) + + const { result } = renderHook(() => useVisibleBalances()) + + expect(result.current.balances.fiatTotal).toEqual('60') + expect(result.current.balances.items).toHaveLength(1) + }) + + test('computation works for high precision numbers', () => { + const balance: SafeBalanceResponse = { + fiatTotal: '200.01234567890123456789', + items: [ + { + balance: '100', + fiatBalance: '100', + fiatConversion: '1', + tokenInfo: { + address: hiddenTokenAddress, + decimals: 18, + logoUri: '', + name: 'Hidden Token', + symbol: 'HT', + type: TokenType.ERC20, + }, + }, + { + balance: '60.0123456789', + fiatBalance: '60.0123456789', + fiatConversion: '1', + tokenInfo: { + address: visibleTokenAddress, + decimals: 18, + logoUri: '', + name: 'Visible Token', + symbol: 'VT', + type: TokenType.ERC20, + }, + }, + { + balance: '40.00000000000123456789', + fiatBalance: '40.00000000000123456789', + fiatConversion: '1', + tokenInfo: { + address: visibleTokenAddress, + decimals: 18, + logoUri: '', + name: 'Visible Token', + symbol: 'VT', + type: TokenType.ERC20, + }, + }, + ], + } + + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => + selector({ + balances: { data: balance, error: undefined, loading: false }, + settings: { + currency: 'USD', + shortName: { + copy: true, + qr: true, + show: true, + }, + theme: { + darkMode: false, + }, + hiddenTokens: { ['4']: [hiddenTokenAddress] }, + }, + } as unknown as store.RootState), + ) + + const { result } = renderHook(() => useVisibleBalances()) + + expect(result.current.balances.fiatTotal).toEqual('100.012345678901234567') + expect(result.current.balances.items).toHaveLength(2) + }) + + test('computation works for high USD values', () => { + const balance: SafeBalanceResponse = { + // Current total USD value of all Safes on mainnet * 1 million + fiatTotal: '28303710905000100.0123456789', + items: [ + { + balance: '100', + fiatBalance: '100', + fiatConversion: '1', + tokenInfo: { + address: hiddenTokenAddress, + decimals: 18, + logoUri: '', + name: 'Hidden Token', + symbol: 'HT', + type: TokenType.ERC20, + }, + }, + { + balance: '28303710905000000.0123456789', + fiatBalance: '28303710905000000.0123456789', + fiatConversion: '1', + tokenInfo: { + address: visibleTokenAddress, + decimals: 18, + logoUri: '', + name: 'USDC', + symbol: 'USDC', + type: TokenType.ERC20, + }, + }, + ], + } + + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => + selector({ + balances: { data: balance, error: undefined, loading: false }, + settings: { + currency: 'USD', + shortName: { + copy: true, + qr: true, + show: true, + }, + theme: { + darkMode: false, + }, + hiddenTokens: { ['4']: [hiddenTokenAddress] }, + }, + } as unknown as store.RootState), + ) + + const { result } = renderHook(() => useVisibleBalances()) + + expect(result.current.balances.fiatTotal).toEqual('28303710905000000.0123456789') + expect(result.current.balances.items).toHaveLength(1) + }) +}) diff --git a/src/hooks/useHiddenTokens.ts b/src/hooks/useHiddenTokens.ts new file mode 100644 index 0000000000..16ce3ac15c --- /dev/null +++ b/src/hooks/useHiddenTokens.ts @@ -0,0 +1,10 @@ +import { useAppSelector } from '@/store' +import { selectHiddenTokensPerChain } from '@/store/settingsSlice' +import useChainId from './useChainId' + +const useHiddenTokens = () => { + const chainId = useChainId() + return useAppSelector((state) => selectHiddenTokensPerChain(state, chainId)) +} + +export default useHiddenTokens diff --git a/src/hooks/useVisibleBalances.ts b/src/hooks/useVisibleBalances.ts new file mode 100644 index 0000000000..2cdba42cd4 --- /dev/null +++ b/src/hooks/useVisibleBalances.ts @@ -0,0 +1,59 @@ +import { safeFormatUnits, safeParseUnits } from '@/utils/formatters' +import type { SafeBalanceResponse } from '@safe-global/safe-gateway-typescript-sdk' +import { BigNumber } from 'ethers' +import { useMemo } from 'react' +import useBalances from './useBalances' +import useHiddenTokens from './useHiddenTokens' + +const PRECISION = 18 + +/** + * We have to avoid underflows for too high precisions. + * We only display very few floating points anyway so a precision of 18 should be more than enough. + */ +const truncateNumber = (balance: string): string => { + const floatingPointPosition = balance.indexOf('.') + if (floatingPointPosition < 0) { + return balance + } + + const currentPrecision = balance.length - floatingPointPosition - 1 + return currentPrecision < PRECISION ? balance : balance.slice(0, floatingPointPosition + PRECISION + 1) +} + +const filterHiddenTokens = (items: SafeBalanceResponse['items'], hiddenAssets: string[]) => + items.filter((balanceItem) => !hiddenAssets.includes(balanceItem.tokenInfo.address)) + +const getVisibleFiatTotal = (balances: SafeBalanceResponse, hiddenAssets: string[]): string => { + return safeFormatUnits( + balances.items + .reduce((acc, balanceItem) => { + if (hiddenAssets.includes(balanceItem.tokenInfo.address)) { + return acc.sub(safeParseUnits(truncateNumber(balanceItem.fiatBalance), PRECISION) || 0) + } + return acc + }, BigNumber.from(balances.fiatTotal === '' ? 0 : safeParseUnits(truncateNumber(balances.fiatTotal), PRECISION))) + .toString(), + PRECISION, + ) +} + +export const useVisibleBalances = (): { + balances: SafeBalanceResponse + loading: boolean + error?: string +} => { + const balances = useBalances() + const hiddenTokens = useHiddenTokens() + + return useMemo( + () => ({ + ...balances, + balances: { + items: filterHiddenTokens(balances.balances.items, hiddenTokens), + fiatTotal: getVisibleFiatTotal(balances.balances, hiddenTokens), + }, + }), + [balances, hiddenTokens], + ) +} diff --git a/src/pages/balances/index.tsx b/src/pages/balances/index.tsx index 85ca2e632b..b2db31492d 100644 --- a/src/pages/balances/index.tsx +++ b/src/pages/balances/index.tsx @@ -1,15 +1,23 @@ import type { NextPage } from 'next' import Head from 'next/head' -import { CircularProgress } from '@mui/material' +import { Box, CircularProgress } from '@mui/material' import AssetsTable from '@/components/balances/AssetsTable' import AssetsHeader from '@/components/balances/AssetsHeader' import useBalances from '@/hooks/useBalances' +import { useState } from 'react' + import PagePlaceholder from '@/components/common/PagePlaceholder' import NoAssetsIcon from '@/public/images/balances/no-assets.svg' +import useHiddenTokens from '@/hooks/useHiddenTokens' +import HiddenTokenButton from '@/components/balances/HiddenTokenButton' +import CurrencySelect from '@/components/balances/CurrencySelect' const Balances: NextPage = () => { const { balances, loading, error } = useBalances() + const hiddenAssets = useHiddenTokens() + const [showHiddenAssets, setShowHiddenAssets] = useState(false) + const toggleShowHiddenAssets = () => setShowHiddenAssets((prev) => !prev) return ( <> @@ -17,13 +25,20 @@ const Balances: NextPage = () => { Safe – Assets - + + + {hiddenAssets && ( + + )} + + +
{loading && } {!error ? ( - + ) : ( } text="There was an error loading your assets" /> )} diff --git a/src/services/analytics/events/assets.ts b/src/services/analytics/events/assets.ts index 5622cbd7b7..e6ff4c3231 100644 --- a/src/services/analytics/events/assets.ts +++ b/src/services/analytics/events/assets.ts @@ -7,6 +7,10 @@ export const ASSETS_EVENTS = { action: 'Currency menu', category: ASSETS_CATEGORY, }, + TOKEN_LIST_MENU: { + action: 'Token list menu', + category: ASSETS_CATEGORY, + }, CHANGE_CURRENCY: { event: EventType.META, action: 'Change currency', @@ -17,8 +21,33 @@ export const ASSETS_EVENTS = { action: 'Tokens', category: ASSETS_CATEGORY, }, + HIDDEN_TOKENS: { + event: EventType.META, + action: 'Hidden tokens', + category: ASSETS_CATEGORY, + }, + SHOW_HIDDEN_ASSETS: { + action: 'Show hidden assets', + category: ASSETS_CATEGORY, + }, SEND: { action: 'Send', category: ASSETS_CATEGORY, }, + HIDE_TOKEN: { + action: 'Hide single token', + category: ASSETS_CATEGORY, + }, + CANCEL_HIDE_DIALOG: { + action: 'Cancel hide dialog', + category: ASSETS_CATEGORY, + }, + SAVE_HIDE_DIALOG: { + action: 'Save hide dialog', + category: ASSETS_CATEGORY, + }, + DESELECT_ALL_HIDE_DIALOG: { + action: 'Deselect all hide dialog', + category: ASSETS_CATEGORY, + }, } diff --git a/src/services/analytics/useMetaEvents.ts b/src/services/analytics/useMetaEvents.ts index b5746f24cf..42d98fea87 100644 --- a/src/services/analytics/useMetaEvents.ts +++ b/src/services/analytics/useMetaEvents.ts @@ -7,6 +7,7 @@ import { useAppSelector } from '@/store' import useChainId from '@/hooks/useChainId' import useBalances from '@/hooks/useBalances' import useSafeInfo from '@/hooks/useSafeInfo' +import useHiddenTokens from '@/hooks/useHiddenTokens' // Track meta events on app load const useMetaEvents = (isAnalyticsEnabled: boolean) => { @@ -45,6 +46,16 @@ const useMetaEvents = (isAnalyticsEnabled: boolean) => { gtmTrack({ ...ASSETS_EVENTS.DIFFERING_TOKENS, label: totalTokens }) }, [isAnalyticsEnabled, totalTokens, safeAddress, chainId]) + + // Manually hidden tokens + const hiddenTokens = useHiddenTokens() + const totalHiddenFromBalance = + balances?.items.filter((item) => hiddenTokens.includes(item.tokenInfo.address)).length || 0 + useEffect(() => { + if (!isAnalyticsEnabled || !safeAddress || totalTokens <= 0) return + + gtmTrack({ ...ASSETS_EVENTS.HIDDEN_TOKENS, label: totalHiddenFromBalance }) + }, [isAnalyticsEnabled, safeAddress, totalHiddenFromBalance, totalTokens]) } export default useMetaEvents diff --git a/src/store/settingsSlice.ts b/src/store/settingsSlice.ts index 6328b8781f..05cbaa582e 100644 --- a/src/store/settingsSlice.ts +++ b/src/store/settingsSlice.ts @@ -6,6 +6,12 @@ import type { RootState } from '@/store' export type SettingsState = { currency: string + hiddenTokens: + | { + [chainId: string]: string[] + } + | undefined /* This was added to the slice later, so hydration will set it to undefined initially */ + shortName: { show: boolean copy: boolean @@ -19,6 +25,8 @@ export type SettingsState = { const initialState: SettingsState = { currency: 'usd', + hiddenTokens: {}, + shortName: { show: true, copy: true, @@ -46,13 +54,23 @@ export const settingsSlice = createSlice({ setDarkMode: (state, { payload }: PayloadAction) => { state.theme.darkMode = payload }, + setHiddenTokensForChain: (state, { payload }: PayloadAction<{ chainId: string; assets: string[] }>) => { + const { chainId, assets } = payload + state.hiddenTokens = {} + state.hiddenTokens[chainId] ??= assets + }, }, }) -export const { setCurrency, setShowShortName, setCopyShortName, setQrShortName, setDarkMode } = settingsSlice.actions +export const { setCurrency, setShowShortName, setCopyShortName, setQrShortName, setDarkMode, setHiddenTokensForChain } = + settingsSlice.actions export const selectSettings = (state: RootState): SettingsState => state[settingsSlice.name] export const selectCurrency = (state: RootState): SettingsState['currency'] => { return state[settingsSlice.name].currency || initialState.currency } + +export const selectHiddenTokensPerChain = (state: RootState, chainId: string): string[] => { + return state[settingsSlice.name].hiddenTokens?.[chainId] || [] +} diff --git a/src/styles/theme.ts b/src/styles/theme.ts index c03c29d3f5..3f78e6a5bc 100644 --- a/src/styles/theme.ts +++ b/src/styles/theme.ts @@ -299,6 +299,13 @@ const initTheme = (darkMode: boolean) => { }, }, }, + MuiToggleButton: { + styleOverrides: { + root: { + textTransform: 'none', + }, + }, + }, MuiAlert: { styleOverrides: { standardError: ({ theme }) => ({ @@ -401,6 +408,9 @@ const initTheme = (darkMode: boolean) => { '& .MuiTableRow-root:hover': { backgroundColor: theme.palette.background.light, }, + '& .MuiTableRow-root.Mui-selected': { + backgroundColor: theme.palette.background.light, + }, }), }, },