From 207dfd17a0f6b22b272bd4308d26bab2cd3dd1d0 Mon Sep 17 00:00:00 2001 From: James Mealy Date: Mon, 25 Nov 2024 19:52:38 +0700 Subject: [PATCH] Epic: sidebar improvements (#4442) * Feat(Sidebar): Add pinned safes list to sidebar and accounts page [SW-304] (#4412) * feat: allow pinning and unpinning accounts * feat: allow pinning multichain safes * fix: pinning of multichain safes * feat: simplify all accounts list * feat: load safe info when safelist item is scrolled into view * feat: remove safes when unpinning if they werent added before being pinned * fix: pinning behaviour of multichain safes * fix: multichain sub itemstyles * feat: add empty state for pinned list * fix: lint errors * fix: show threshold for counterfactual safes * fix: mobile layout * restore loading balances for safes * fix: subaccount item layout, and app crashing on unpin * feat: expand all accounts if there are no pinned safes * fix: flickering when loading all safes * fix: account for default threshold and owner numbers * reduce transition animation duration for pinning and unpinning safes * reduce space between chains and balance * Feat(sidebar): sort safes by name and recently used [SW-307] (#4458) * Feat: add option to sort by recently visited and by name * tests: add new properties to unit tests * fix: make order by preference persist * feat: sort sub account items by name and recently visited * refactor: fix incorrect variable names and remove console log * Feat(sidebar): filter safes by search query [SW-305] (#4484) * feat: filter safes by search query * feat: display current order by option. fix text colors * feat: search by adddress name and chain name * fix: use ignoreLocation to match the final address characters when searching * add tracking event for search * fix: cypress tests * Feat(sidebar): Add tracking events for search, sorting, and pinning/unpinning safes [SW-308] (#4500) * track the number of pined safes on the accounts page * feat: track pinning, unpinning, and sorting * fix: separate watchlist and pined safes for tracking * fix: remove wallet from dependency array * Update src/components/welcome/MyAccounts/useTrackedSafesCount.ts Co-authored-by: Usame Algan <5880855+usame-algan@users.noreply.github.com> * fix: lint --------- Co-authored-by: Usame Algan <5880855+usame-algan@users.noreply.github.com> * Refactor(Sidebar): use feature directory structure for MyAccounts (#4508) * refactor: move myAccounts to features * fix:align queue and status chips with address for large screens * remove commented code * fix: chip styles * fix(sidebar): fix designs for data widget and empty/disconnected state [SW-507] (#4509) * fix: add connect wallet button and empty state * fix: datawidget styles * Fix(Sidebar): Include sub safe in search results [SW-305] (#4527) * fix: CF chip dark mode color * include multichain sub safe names in search * fix: queue chips being cut off * Fix: rm Remove option * Refactor: pinned = added safes [SW-304] (#4552) * Refactor: pinned = added safes * Rm Watchlist word * Fix duplicate cf safes --------- Co-authored-by: Usame Algan <5880855+usame-algan@users.noreply.github.com> Co-authored-by: katspaugh Co-authored-by: katspaugh <381895+katspaugh@users.noreply.github.com> --- cypress/e2e/pages/sidebar.pages.js | 11 +- .../common/NetworkSelector/index.tsx | 2 +- .../create/steps/ReviewStep/index.tsx | 4 +- src/components/new-safe/load/index.tsx | 2 +- .../GlobalPushNotifications.tsx | 2 +- .../sidebar/SafeListContextMenu/index.tsx | 8 +- .../sidebar/SafeListRemoveDialog/index.tsx | 2 +- src/components/sidebar/Sidebar/index.tsx | 4 +- .../sidebar/Sidebar/styles.module.css | 2 +- .../sidebar/WatchlistAddButton/index.tsx | 4 +- .../welcome/MyAccounts/PaginatedSafeList.tsx | 139 --------- src/components/welcome/MyAccounts/index.tsx | 138 --------- src/components/welcome/WelcomeLogin/index.tsx | 2 +- .../InconsistentSignerSetupWarning.tsx | 2 +- .../multichain/hooks/useIsMultichainSafe.ts | 2 +- src/features/multichain/utils/utils.test.ts | 68 ++++- src/features/multichain/utils/utils.ts | 4 +- .../components/AccountInfoChips/index.tsx | 115 ++++++++ .../AccountInfoChips/styles.module.css | 20 ++ .../AccountItems}/MultiAccountItem.tsx | 121 +++++--- .../AccountItems/SingleAccountItem.tsx} | 152 ++++++---- .../AccountItems}/SubAccountItem.tsx | 97 ++++--- .../AccountItems}/styles.module.css | 121 ++------ .../components/AddNetworkButton/index.tsx} | 0 .../components/CreateButton/index.tsx} | 2 +- .../components/DataWidget/index.tsx} | 46 ++- .../components/DataWidget/styles.module.css | 24 ++ .../components/OrderByButton/index.tsx | 80 +++++ .../components/QueueActions/index.tsx} | 60 ++-- .../myAccounts/components/SafesList/index.tsx | 45 +++ .../myAccounts/hooks}/useAllOwnedSafes.ts | 0 .../myAccounts/hooks}/useAllSafes.ts | 40 ++- .../myAccounts/hooks}/useAllSafesGrouped.ts | 17 +- .../myAccounts/hooks}/useGetHref.ts | 0 .../myAccounts/hooks/useSafesSearch.ts | 63 ++++ .../myAccounts/hooks}/useTrackedSafesCount.ts | 45 ++- .../myAccounts/hooks/useVisitedSafes.ts | 29 ++ src/features/myAccounts/index.tsx | 273 ++++++++++++++++++ src/features/myAccounts/styles.module.css | 88 ++++++ src/features/myAccounts/utils/utils.ts | 19 ++ src/pages/_app.tsx | 2 + src/pages/welcome/accounts.tsx | 2 +- src/services/analytics/events/overview.ts | 22 ++ src/store/__tests__/safeOverviews.test.ts | 171 +++++++++-- src/store/addedSafesSlice.ts | 16 +- src/store/api/gateway/safeOverviews.ts | 2 +- src/store/index.ts | 4 + src/store/orderByPreferenceSlice.ts | 29 ++ src/store/slices.ts | 2 + src/store/visitedSafesSlice.ts | 34 +++ 50 files changed, 1475 insertions(+), 662 deletions(-) delete mode 100644 src/components/welcome/MyAccounts/PaginatedSafeList.tsx delete mode 100644 src/components/welcome/MyAccounts/index.tsx create mode 100644 src/features/myAccounts/components/AccountInfoChips/index.tsx create mode 100644 src/features/myAccounts/components/AccountInfoChips/styles.module.css rename src/{components/welcome/MyAccounts => features/myAccounts/components/AccountItems}/MultiAccountItem.tsx (68%) rename src/{components/welcome/MyAccounts/AccountItem.tsx => features/myAccounts/components/AccountItems/SingleAccountItem.tsx} (53%) rename src/{components/welcome/MyAccounts => features/myAccounts/components/AccountItems}/SubAccountItem.tsx (65%) rename src/{components/welcome/MyAccounts => features/myAccounts/components/AccountItems}/styles.module.css (55%) rename src/{components/welcome/MyAccounts/AddNetworkButton.tsx => features/myAccounts/components/AddNetworkButton/index.tsx} (100%) rename src/{components/welcome/MyAccounts/CreateButton.tsx => features/myAccounts/components/CreateButton/index.tsx} (88%) rename src/{components/welcome/MyAccounts/DataWidget.tsx => features/myAccounts/components/DataWidget/index.tsx} (71%) create mode 100644 src/features/myAccounts/components/DataWidget/styles.module.css create mode 100644 src/features/myAccounts/components/OrderByButton/index.tsx rename src/{components/welcome/MyAccounts/QueueActions.tsx => features/myAccounts/components/QueueActions/index.tsx} (59%) create mode 100644 src/features/myAccounts/components/SafesList/index.tsx rename src/{components/welcome/MyAccounts => features/myAccounts/hooks}/useAllOwnedSafes.ts (100%) rename src/{components/welcome/MyAccounts => features/myAccounts/hooks}/useAllSafes.ts (60%) rename src/{components/welcome/MyAccounts => features/myAccounts/hooks}/useAllSafesGrouped.ts (70%) rename src/{components/welcome/MyAccounts => features/myAccounts/hooks}/useGetHref.ts (100%) create mode 100644 src/features/myAccounts/hooks/useSafesSearch.ts rename src/{components/welcome/MyAccounts => features/myAccounts/hooks}/useTrackedSafesCount.ts (50%) create mode 100644 src/features/myAccounts/hooks/useVisitedSafes.ts create mode 100644 src/features/myAccounts/index.tsx create mode 100644 src/features/myAccounts/styles.module.css create mode 100644 src/features/myAccounts/utils/utils.ts create mode 100644 src/store/orderByPreferenceSlice.ts create mode 100644 src/store/visitedSafesSlice.ts diff --git a/cypress/e2e/pages/sidebar.pages.js b/cypress/e2e/pages/sidebar.pages.js index d7ead5c158..a1ef546f27 100644 --- a/cypress/e2e/pages/sidebar.pages.js +++ b/cypress/e2e/pages/sidebar.pages.js @@ -26,13 +26,12 @@ export const safeItemOptionsRemoveBtn = '[data-testid="remove-btn"]' export const safeItemOptionsAddChainBtn = '[data-testid="add-chain-btn"]' const nameInput = '[data-testid="name-input"]' const saveBtn = '[data-testid="save-btn"]' -const cancelBtn = '[data-testid="cancel-btn"]' const deleteBtn = '[data-testid="delete-btn"]' const readOnlyVisibility = '[data-testid="read-only-visibility"]' const currencySection = '[data-testid="currency-section"]' const missingSignatureInfo = '[data-testid="missing-signature-info"]' const queuedTxInfo = '[data-testid="queued-tx-info"]' -const showMoreBtn = '[data-testid="show-more-btn" ]' +const expandSafesList = '[data-testid="expand-safes-list" ]' export const importBtn = '[data-testid="import-btn"]' export const pendingActivationIcon = '[data-testid="pending-activation-icon"]' const safeItemMenuIcon = '[data-testid="MoreVertIcon"]' @@ -54,7 +53,6 @@ export const addedNetworkOption = 'li[role="option"]' const modalAddNetworkName = '[data-testid="added-network"]' const networkSeperator = 'div[role="separator"]' export const addNetworkTooltip = '[data-testid="add-network-tooltip"]' -const networkOptionNetworkSwitch = 'span[data-track="overview: Add new network"] > li' export const importBtnStr = 'Import' export const exportBtnStr = 'Export' export const undeployedSafe = 'Undeployed Sepolia' @@ -112,10 +110,9 @@ export function clickOnSidebarImportBtn() { export function showAllSafes() { cy.get('body').then(($body) => { - if ($body.find(showMoreBtn).length > 0) { - cy.get(showMoreBtn).click() + if ($body.find(expandSafesList).length > 0) { + cy.get(expandSafesList).click() cy.wait(500) - showAllSafes() } }) } @@ -467,7 +464,7 @@ export function checkNetworksInRange(expectedString, expectedCount, direction = return cy .get(startSelector) - [traversalMethod](endSelector, 'li') + [traversalMethod](endSelector, 'li') .then((liElements) => { expect(liElements.length).to.equal(expectedCount) const optionTexts = [...liElements].map((li) => li.innerText) diff --git a/src/components/common/NetworkSelector/index.tsx b/src/components/common/NetworkSelector/index.tsx index be1c268b38..8da8316fdd 100644 --- a/src/components/common/NetworkSelector/index.tsx +++ b/src/components/common/NetworkSelector/index.tsx @@ -28,7 +28,7 @@ import { useChainId } from '@/hooks/useChainId' import { type ReactElement, useCallback, useMemo, useState } from 'react' import { OVERVIEW_EVENTS, OVERVIEW_LABELS, trackEvent } from '@/services/analytics' -import { useAllSafesGrouped } from '@/components/welcome/MyAccounts/useAllSafesGrouped' +import { useAllSafesGrouped } from '@/features/myAccounts/hooks/useAllSafesGrouped' import useSafeAddress from '@/hooks/useSafeAddress' import { sameAddress } from '@/utils/addresses' import uniq from 'lodash/uniq' diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index 57f1e906a3..6a79f937c3 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -42,7 +42,7 @@ import { useRouter } from 'next/router' import { useMemo, useState } from 'react' import ChainIndicator from '@/components/common/ChainIndicator' import NetworkWarning from '../../NetworkWarning' -import useAllSafes from '@/components/welcome/MyAccounts/useAllSafes' +import useAllSafes from '@/features/myAccounts/hooks/useAllSafes' import { uniq } from 'lodash' import { selectRpc } from '@/store/settingsSlice' import { AppRoutes } from '@/config/routes' @@ -521,7 +521,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps - {isCreating ? : 'Create Account'} + {isCreating ? : 'Create account'} diff --git a/src/components/new-safe/load/index.tsx b/src/components/new-safe/load/index.tsx index e0295b7c9b..5356afffcf 100644 --- a/src/components/new-safe/load/index.tsx +++ b/src/components/new-safe/load/index.tsx @@ -68,7 +68,7 @@ const LoadSafe = ({ initialData }: { initialData?: TxStepperProps - Add Safe Account to watchlist + Add existing Safe Account { - const addedSafes = useAppSelector((state) => selectAddedSafes(state, chainId)) - const isAdded = !!addedSafes?.[address] const addressBook = useAddressBook() const hasName = address in addressBook @@ -89,7 +87,7 @@ const SafeListContextMenu = ({ )} - {isAdded && ( + {undeployedSafe && ( diff --git a/src/components/sidebar/SafeListRemoveDialog/index.tsx b/src/components/sidebar/SafeListRemoveDialog/index.tsx index 6332ba7a78..53ca700c8b 100644 --- a/src/components/sidebar/SafeListRemoveDialog/index.tsx +++ b/src/components/sidebar/SafeListRemoveDialog/index.tsx @@ -40,7 +40,7 @@ const SafeListRemoveDialog = ({ - Are you sure you want to remove {safe} from your Watchlist? + Are you sure you want to remove the {safe} account? diff --git a/src/components/sidebar/Sidebar/index.tsx b/src/components/sidebar/Sidebar/index.tsx index b845cec463..0423611e99 100644 --- a/src/components/sidebar/Sidebar/index.tsx +++ b/src/components/sidebar/Sidebar/index.tsx @@ -10,7 +10,7 @@ import IndexingStatus from '@/components/sidebar/IndexingStatus' import css from './styles.module.css' import { trackEvent, OVERVIEW_EVENTS } from '@/services/analytics' -import MyAccounts from '@/components/welcome/MyAccounts' +import MyAccounts from '@/features/myAccounts' const Sidebar = (): ReactElement => { const [isDrawerOpen, setIsDrawerOpen] = useState(false) @@ -60,7 +60,7 @@ const Sidebar = (): ReactElement => {
- +
diff --git a/src/components/sidebar/Sidebar/styles.module.css b/src/components/sidebar/Sidebar/styles.module.css index f5f3013f33..b90513e727 100644 --- a/src/components/sidebar/Sidebar/styles.module.css +++ b/src/components/sidebar/Sidebar/styles.module.css @@ -21,7 +21,7 @@ } .drawer { - width: 480px; + width: 550px; max-width: 90vw; padding-top: var(--header-height); border-right: 1px solid var(--color-border-light); diff --git a/src/components/sidebar/WatchlistAddButton/index.tsx b/src/components/sidebar/WatchlistAddButton/index.tsx index 43b37ac580..f0df8fff3b 100644 --- a/src/components/sidebar/WatchlistAddButton/index.tsx +++ b/src/components/sidebar/WatchlistAddButton/index.tsx @@ -43,7 +43,7 @@ const WatchlistAddButton = () => { disableElevation sx={{ py: 1.3, px: 1 }} > - Remove from watchlist + Remove account ) : ( @@ -58,7 +58,7 @@ const WatchlistAddButton = () => { sx={{ py: 1.3 }} startIcon={} > - Add to watchlist + Add read-only )} diff --git a/src/components/welcome/MyAccounts/PaginatedSafeList.tsx b/src/components/welcome/MyAccounts/PaginatedSafeList.tsx deleted file mode 100644 index ef621e51e4..0000000000 --- a/src/components/welcome/MyAccounts/PaginatedSafeList.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { type ReactElement, type ReactNode, useState, useCallback, useEffect, useMemo } from 'react' -import { Paper, Typography } from '@mui/material' -import AccountItem from './AccountItem' -import { type SafeItem } from './useAllSafes' -import css from './styles.module.css' -import InfiniteScroll from '@/components/common/InfiniteScroll' -import { type MultiChainSafeItem } from './useAllSafesGrouped' -import MultiAccountItem from './MultiAccountItem' -import { isMultiChainSafeItem } from '@/features/multichain/utils/utils' - -type PaginatedSafeListProps = { - safes?: (SafeItem | MultiChainSafeItem)[] - title: ReactNode - noSafesMessage?: ReactNode - action?: ReactElement - onLinkClick?: () => void -} - -type SafeListPageProps = { - safes: (SafeItem | MultiChainSafeItem)[] - onLinkClick: PaginatedSafeListProps['onLinkClick'] -} - -const DEFAULT_PAGE_SIZE = 10 - -export const SafeListPage = ({ safes, onLinkClick }: SafeListPageProps) => { - return ( - <> - {safes.map((item) => - isMultiChainSafeItem(item) ? ( - - ) : ( - - ), - )} - - ) -} - -const AllSafeListPages = ({ - safes, - onLinkClick, - pageSize = DEFAULT_PAGE_SIZE, -}: SafeListPageProps & { pageSize?: number }) => { - const totalPages = Math.ceil(safes.length / pageSize) - const [pages, setPages] = useState<(SafeItem | MultiChainSafeItem)[][]>([]) - - const onNextPage = useCallback(() => { - setPages((prev) => { - const pageIndex = prev.length - const nextPage = safes.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize) - return prev.concat([nextPage]) - }) - }, [safes, pageSize]) - - useEffect(() => { - if (safes.length > 0) { - setPages([safes.slice(0, pageSize)]) - } - }, [safes, pageSize]) - - return ( - <> - {pages.map((pageSafes, index) => ( - - ))} - - {totalPages > pages.length && } - - ) -} - -const PaginatedSafeList = ({ safes, title, action, noSafesMessage, onLinkClick }: PaginatedSafeListProps) => { - const multiChainSafes = useMemo(() => safes?.filter(isMultiChainSafeItem), [safes]) - const singleChainSafes = useMemo(() => safes?.filter((safe) => !isMultiChainSafeItem(safe)), [safes]) - - const totalMultiChainSafes = multiChainSafes?.length ?? 0 - const totalSingleChainSafes = singleChainSafes?.length ?? 0 - const totalSafes = totalMultiChainSafes + totalSingleChainSafes - - return ( - -
- - {title} - - {safes && safes.length > 0 && ( - - {' '} - ({safes.length}) - - )} - - - {action} -
- {totalSafes > 0 ? ( - <> - {multiChainSafes && multiChainSafes.length > 0 && ( - - )} - {singleChainSafes && singleChainSafes.length > 0 && ( - - )} - - ) : ( - - {noSafesMessage} - - )} -
- ) -} - -export default PaginatedSafeList diff --git a/src/components/welcome/MyAccounts/index.tsx b/src/components/welcome/MyAccounts/index.tsx deleted file mode 100644 index e21f679806..0000000000 --- a/src/components/welcome/MyAccounts/index.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useMemo } from 'react' -import { Box, Button, Link, SvgIcon, Typography } from '@mui/material' -import madProps from '@/utils/mad-props' -import CreateButton from './CreateButton' -import Track from '@/components/common/Track' -import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' -import { DataWidget } from '@/components/welcome/MyAccounts/DataWidget' -import css from './styles.module.css' -import PaginatedSafeList from './PaginatedSafeList' -import { VisibilityOutlined } from '@mui/icons-material' -import AddIcon from '@/public/images/common/add.svg' -import { AppRoutes } from '@/config/routes' -import ConnectWalletButton from '@/components/common/ConnectWallet/ConnectWalletButton' -import useWallet from '@/hooks/wallets/useWallet' -import { useRouter } from 'next/router' -import useTrackSafesCount from './useTrackedSafesCount' -import { type AllSafesGrouped, useAllSafesGrouped, type MultiChainSafeItem } from './useAllSafesGrouped' -import { type SafeItem } from './useAllSafes' - -const NO_SAFES_MESSAGE = "You don't have any Safe Accounts yet" -const NO_WATCHED_MESSAGE = 'Watch any Safe Account to keep an eye on its activity' - -type AccountsListProps = { - safes: AllSafesGrouped - onLinkClick?: () => void -} -const AccountsList = ({ safes, onLinkClick }: AccountsListProps) => { - const wallet = useWallet() - const router = useRouter() - - // We consider a multiChain account owned if at least one of the multiChain accounts is not on the watchlist - const ownedMultiChainSafes = useMemo( - () => safes.allMultiChainSafes?.filter((account) => account.safes.some(({ isWatchlist }) => !isWatchlist)), - [safes], - ) - - // If all safes of a multichain account are on the watchlist we put the entire account on the watchlist - const watchlistMultiChainSafes = useMemo( - () => safes.allMultiChainSafes?.filter((account) => !account.safes.some(({ isWatchlist }) => !isWatchlist)), - [safes], - ) - - const ownedSafes = useMemo<(MultiChainSafeItem | SafeItem)[]>( - () => [...(ownedMultiChainSafes ?? []), ...(safes.allSingleSafes?.filter(({ isWatchlist }) => !isWatchlist) ?? [])], - [safes, ownedMultiChainSafes], - ) - const watchlistSafes = useMemo<(MultiChainSafeItem | SafeItem)[]>( - () => [ - ...(watchlistMultiChainSafes ?? []), - ...(safes.allSingleSafes?.filter(({ isWatchlist }) => isWatchlist) ?? []), - ], - [safes, watchlistMultiChainSafes], - ) - - useTrackSafesCount(ownedSafes, watchlistSafes, wallet) - - const isLoginPage = router.pathname === AppRoutes.welcome.accounts - const trackingLabel = isLoginPage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar - - return ( - - - - - Safe accounts - - - - - - - - - Connect a wallet to view your Safe Accounts or to create a new one - - - - - - ) - } - /> - - - - Watchlist - - } - safes={watchlistSafes || []} - action={ - - - - - - } - noSafesMessage={NO_WATCHED_MESSAGE} - onLinkClick={onLinkClick} - /> - - - - - ) -} - -const MyAccounts = madProps(AccountsList, { - safes: useAllSafesGrouped, -}) - -export default MyAccounts diff --git a/src/components/welcome/WelcomeLogin/index.tsx b/src/components/welcome/WelcomeLogin/index.tsx index 3d7ee16d80..00d493ab08 100644 --- a/src/components/welcome/WelcomeLogin/index.tsx +++ b/src/components/welcome/WelcomeLogin/index.tsx @@ -6,7 +6,7 @@ import { useRouter } from 'next/router' import { CREATE_SAFE_EVENTS } from '@/services/analytics/events/createLoadSafe' import { OVERVIEW_EVENTS, OVERVIEW_LABELS, trackEvent } from '@/services/analytics' import useWallet from '@/hooks/wallets/useWallet' -import { useHasSafes } from '../MyAccounts/useAllSafes' +import { useHasSafes } from '@/features/myAccounts/hooks/useAllSafes' import Track from '@/components/common/Track' import { useCallback, useEffect, useState } from 'react' import WalletLogin from './WalletLogin' diff --git a/src/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning.tsx b/src/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning.tsx index e1e3f83532..60537e047b 100644 --- a/src/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning.tsx +++ b/src/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning.tsx @@ -4,7 +4,7 @@ import ErrorMessage from '@/components/tx/ErrorMessage' import useSafeAddress from '@/hooks/useSafeAddress' import { useAppSelector } from '@/store' import { selectCurrency, selectUndeployedSafes, useGetMultipleSafeOverviewsQuery } from '@/store/slices' -import { useAllSafesGrouped } from '@/components/welcome/MyAccounts/useAllSafesGrouped' +import { useAllSafesGrouped } from '@/features/myAccounts/hooks/useAllSafesGrouped' import { sameAddress } from '@/utils/addresses' import { useMemo } from 'react' import { getDeviatingSetups, getSafeSetups } from '@/features/multichain/utils/utils' diff --git a/src/features/multichain/hooks/useIsMultichainSafe.ts b/src/features/multichain/hooks/useIsMultichainSafe.ts index 89205b3e7c..0f235feace 100644 --- a/src/features/multichain/hooks/useIsMultichainSafe.ts +++ b/src/features/multichain/hooks/useIsMultichainSafe.ts @@ -1,4 +1,4 @@ -import { useAllSafesGrouped } from '@/components/welcome/MyAccounts/useAllSafesGrouped' +import { useAllSafesGrouped } from '@/features/myAccounts/hooks/useAllSafesGrouped' import useSafeAddress from '@/hooks/useSafeAddress' import { sameAddress } from '@/utils/addresses' import { useMemo } from 'react' diff --git a/src/features/multichain/utils/utils.test.ts b/src/features/multichain/utils/utils.test.ts index 0735b11d5a..4eff5c8ba6 100644 --- a/src/features/multichain/utils/utils.test.ts +++ b/src/features/multichain/utils/utils.test.ts @@ -13,9 +13,15 @@ describe('multiChain/utils', () => { { address: faker.finance.ethereumAddress(), chainId: '1', - isWatchlist: false, + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, }, ], + isPinned: false, + lastVisited: 0, + name: undefined, }), ).toBeTruthy() }) @@ -25,7 +31,10 @@ describe('multiChain/utils', () => { isMultiChainSafeItem({ address: faker.finance.ethereumAddress(), chainId: '1', - isWatchlist: false, + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, }), ).toBeFalsy() }) @@ -229,7 +238,10 @@ describe('multiChain/utils', () => { { address: faker.finance.ethereumAddress(), chainId: '1', - isWatchlist: false, + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, }, ], [], @@ -245,7 +257,10 @@ describe('multiChain/utils', () => { { address: faker.finance.ethereumAddress(), chainId: '1', - isWatchlist: false, + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, }, ], [], @@ -265,12 +280,18 @@ describe('multiChain/utils', () => { { address, chainId: '1', - isWatchlist: false, + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, }, { address, chainId: '100', - isWatchlist: false, + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, }, ], [ @@ -317,12 +338,18 @@ describe('multiChain/utils', () => { { address, chainId: '1', - isWatchlist: false, + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, }, { address, chainId: '100', - isWatchlist: false, + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, }, ], [], @@ -375,17 +402,26 @@ describe('multiChain/utils', () => { { address, chainId: '1', - isWatchlist: false, + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, }, { address, chainId: '100', - isWatchlist: false, + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, }, { address, chainId: '5', - isWatchlist: false, + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, }, ], [ @@ -436,12 +472,18 @@ describe('multiChain/utils', () => { { address, chainId: '1', - isWatchlist: false, + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, }, { address, chainId: '100', - isWatchlist: false, + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, }, ], [ diff --git a/src/features/multichain/utils/utils.ts b/src/features/multichain/utils/utils.ts index 1f60254d53..05384ee149 100644 --- a/src/features/multichain/utils/utils.ts +++ b/src/features/multichain/utils/utils.ts @@ -9,8 +9,8 @@ import { extractCounterfactualSafeSetup } from '@/features/counterfactual/utils' import { encodeSafeSetupCall } from '@/components/new-safe/create/logic' import { memoize } from 'lodash' import { FEATURES, hasFeature } from '@/utils/chains' -import { type SafeItem } from '@/components/welcome/MyAccounts/useAllSafes' -import { type MultiChainSafeItem } from '@/components/welcome/MyAccounts/useAllSafesGrouped' +import { type SafeItem } from '@/features/myAccounts/hooks/useAllSafes' +import { type MultiChainSafeItem } from '@/features/myAccounts/hooks/useAllSafesGrouped' type SafeSetup = { owners: string[] diff --git a/src/features/myAccounts/components/AccountInfoChips/index.tsx b/src/features/myAccounts/components/AccountInfoChips/index.tsx new file mode 100644 index 0000000000..6181fcf9e5 --- /dev/null +++ b/src/features/myAccounts/components/AccountInfoChips/index.tsx @@ -0,0 +1,115 @@ +import { Box, Chip, Typography, useMediaQuery, useTheme } from '@mui/material' +import VisibilityIcon from '@mui/icons-material/Visibility' +import { LoopIcon } from '@/features/counterfactual/CounterfactualStatusButton' +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline' +import css from './styles.module.css' +import QueueActions from '../QueueActions' +import type { ChainInfo, SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' +import type { UrlObject } from 'url' +import Link from 'next/link' +import Track from '@/components/common/Track' +import { OVERVIEW_EVENTS } from '@/services/analytics' + +const AccountStatusChip = ({ isActivating }: { isActivating: boolean }) => { + return ( + + ) : ( + + ) + } + /> + ) +} + +const ReadOnlyChip = () => { + return ( + } + label={ + + Read-only + + } + /> + ) +} + +export const AccountInfoChips = ({ + isActivating, + isReadOnly, + undeployedSafe, + isVisible, + safeOverview, + chain, + href, + onLinkClick, + trackingLabel, +}: { + isActivating: boolean + isReadOnly: boolean + isVisible: boolean + undeployedSafe: boolean + safeOverview: SafeOverview | null + chain: ChainInfo | undefined + href: UrlObject | string + onLinkClick: (() => void) | undefined + trackingLabel: string +}) => { + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('sm')) + const showQueueActions = isVisible && !undeployedSafe && !isReadOnly + + return ( + + {undeployedSafe ? ( + <> + {isMobile ? ( + + + + + + ) : ( + // For larger screens, the Chip is within the parent Link + + )} + + ) : isReadOnly ? ( + <> + {isMobile ? ( + + + + + + ) : ( + // For larger screens, the Chip is within the parent Link + + )} + + ) : showQueueActions && safeOverview ? ( + + ) : null} + + ) +} diff --git a/src/features/myAccounts/components/AccountInfoChips/styles.module.css b/src/features/myAccounts/components/AccountInfoChips/styles.module.css new file mode 100644 index 0000000000..db9942e344 --- /dev/null +++ b/src/features/myAccounts/components/AccountInfoChips/styles.module.css @@ -0,0 +1,20 @@ +.chip { + border-radius: var(--space-2); + padding-left: 4px; + padding-right: 4px; +} + +.visibilityIcon { + font-size: 16px !important; + color: var(--color-border-main) !important; +} + +.pendingLoopIcon { + color: var(--color-info-dark) !important; +} + +@media (max-width: 600px) { + .chip { + margin: 0 var(--space-2) var(--space-2) var(--space-2); + } +} diff --git a/src/components/welcome/MyAccounts/MultiAccountItem.tsx b/src/features/myAccounts/components/AccountItems/MultiAccountItem.tsx similarity index 68% rename from src/components/welcome/MyAccounts/MultiAccountItem.tsx rename to src/features/myAccounts/components/AccountItems/MultiAccountItem.tsx index 7e00f2e083..5cc0e071c0 100644 --- a/src/components/welcome/MyAccounts/MultiAccountItem.tsx +++ b/src/features/myAccounts/components/AccountItems/MultiAccountItem.tsx @@ -12,24 +12,25 @@ import { AccordionSummary, Divider, Tooltip, + SvgIcon, + IconButton, } from '@mui/material' import SafeIcon from '@/components/common/SafeIcon' -import { OVERVIEW_EVENTS, OVERVIEW_LABELS, trackEvent } from '@/services/analytics' +import { OVERVIEW_EVENTS, OVERVIEW_LABELS, PIN_SAFE_LABELS, trackEvent } from '@/services/analytics' import { AppRoutes } from '@/config/routes' -import { useAppSelector } from '@/store' +import { useAppDispatch, useAppSelector } from '@/store' import css from './styles.module.css' -import { selectAllAddressBooks } from '@/store/addressBookSlice' import useSafeAddress from '@/hooks/useSafeAddress' import { sameAddress } from '@/utils/addresses' import classnames from 'classnames' import { useRouter } from 'next/router' import FiatValue from '@/components/common/FiatValue' -import { type MultiChainSafeItem } from './useAllSafesGrouped' +import { type MultiChainSafeItem } from '@/features/myAccounts/hooks/useAllSafesGrouped' import { shortenAddress } from '@/utils/formatters' -import { type SafeItem } from './useAllSafes' +import { type SafeItem } from '@/features/myAccounts/hooks/useAllSafes' import SubAccountItem from './SubAccountItem' import { getSafeSetups, getSharedSetup, hasMultiChainAddNetworkFeature } from '@/features/multichain/utils/utils' -import { AddNetworkButton } from './AddNetworkButton' +import { AddNetworkButton } from '../AddNetworkButton' import { isPredictedSafeProps } from '@/features/counterfactual/utils' import ChainIndicator from '@/components/common/ChainIndicator' import MultiAccountContextMenu from '@/components/sidebar/SafeListContextMenu/MultiAccountContextMenu' @@ -37,6 +38,12 @@ import { useGetMultipleSafeOverviewsQuery } from '@/store/api/gateway' import useWallet from '@/hooks/wallets/useWallet' import { selectCurrency } from '@/store/settingsSlice' import { selectChains } from '@/store/chainsSlice' +import BookmarkIcon from '@/public/images/apps/bookmark.svg' +import BookmarkedIcon from '@/public/images/apps/bookmarked.svg' +import { addOrUpdateSafe, pinSafe, selectAllAddedSafes, unpinSafe } from '@/store/addedSafesSlice' +import { defaultSafeInfo } from '@/store/safeInfoSlice' +import { selectOrderByPreference } from '@/store/orderByPreferenceSlice' +import { getComparator } from '@/features/myAccounts/utils/utils' type MultiAccountItemProps = { multiSafeAccountItem: MultiChainSafeItem @@ -78,7 +85,7 @@ const MultichainIndicator = ({ safes }: { safes: SafeItem[] }) => { } const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountItemProps) => { - const { address, safes } = multiSafeAccountItem + const { address, safes, isPinned } = multiSafeAccountItem const undeployedSafes = useAppSelector(selectUndeployedSafes) const safeAddress = useSafeAddress() const router = useRouter() @@ -86,11 +93,18 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountIte const isWelcomePage = router.pathname === AppRoutes.welcome.accounts const [expanded, setExpanded] = useState(isCurrentSafe) const chains = useAppSelector(selectChains) + const { orderBy } = useAppSelector(selectOrderByPreference) + + const sortComparator = getComparator(orderBy) + const sortedSafes = useMemo(() => safes.sort(sortComparator), [safes, sortComparator]) + + const allAddedSafes = useAppSelector((state) => selectAllAddedSafes(state)) + const dispatch = useAppDispatch() const deployedChainIds = useMemo(() => safes.map((safe) => safe.chainId), [safes]) - const isWatchlist = useMemo( - () => multiSafeAccountItem.safes.every((safe) => safe.isWatchlist), + const isReadOnly = useMemo( + () => multiSafeAccountItem.safes.every((safe) => safe.isReadOnly), [multiSafeAccountItem.safes], ) @@ -101,11 +115,6 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountIte setExpanded((prev) => !prev) } - const allAddressBooks = useAppSelector(selectAllAddressBooks) - const name = useMemo(() => { - return Object.values(allAddressBooks).find((ab) => ab[address] !== undefined)?.[address] - }, [address, allAddressBooks]) - const currency = useAppSelector(selectCurrency) const { address: walletAddress } = useWallet() ?? {} const deployedSafes = useMemo( @@ -147,6 +156,39 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountIte [safeOverviews], ) + const addToPinnedList = useCallback(() => { + const isGroupAdded = safes.every((safe) => allAddedSafes[safe.chainId]?.[safe.address]) + if (isGroupAdded) { + for (const safe of safes) { + dispatch(pinSafe({ chainId: safe.chainId, address: safe.address })) + } + } else { + for (const safe of safes) { + const overview = findOverview(safe) + dispatch( + addOrUpdateSafe({ + safe: { + ...defaultSafeInfo, + chainId: safe.chainId, + address: { value: address }, + owners: overview ? overview.owners : defaultSafeInfo.owners, + threshold: overview ? overview.threshold : defaultSafeInfo.threshold, + }, + }), + ) + dispatch(pinSafe({ chainId: safe.chainId, address: safe.address })) + } + } + trackEvent({ ...OVERVIEW_EVENTS.PIN_SAFE, label: PIN_SAFE_LABELS.pin }) + }, [safes, allAddedSafes, dispatch, findOverview, address]) + + const removeFromPinnedList = useCallback(() => { + for (const safe of safes) { + dispatch(unpinSafe({ chainId: safe.chainId, address: safe.address })) + } + trackEvent({ ...OVERVIEW_EVENTS.PIN_SAFE, label: PIN_SAFE_LABELS.unpin }) + }, [safes, dispatch]) + return ( - - + + - {name && ( - - {name} + {multiSafeAccountItem.name && ( + + {multiSafeAccountItem.name} )} + { + event.stopPropagation() + isPinned ? removeFromPinnedList() : addToPinnedList() + }} + > + + - {safes.map((safeItem) => ( + {sortedSafes.map((safeItem) => ( ))} - {!isWatchlist && hasReplayableSafe && ( + {!isReadOnly && hasReplayableSafe && ( <> safe.chainId)} /> diff --git a/src/components/welcome/MyAccounts/AccountItem.tsx b/src/features/myAccounts/components/AccountItems/SingleAccountItem.tsx similarity index 53% rename from src/components/welcome/MyAccounts/AccountItem.tsx rename to src/features/myAccounts/components/AccountItems/SingleAccountItem.tsx index 2d25ddba2d..f7b1c4a9f0 100644 --- a/src/components/welcome/MyAccounts/AccountItem.tsx +++ b/src/features/myAccounts/components/AccountItems/SingleAccountItem.tsx @@ -1,14 +1,12 @@ -import { LoopIcon } from '@/features/counterfactual/CounterfactualStatusButton' import { selectUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' -import type { SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' -import { useMemo } from 'react' -import { ListItemButton, Box, Typography, Chip, Skeleton } from '@mui/material' +import { type SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' +import { useMemo, useRef } from 'react' +import { ListItemButton, Box, Typography, IconButton, SvgIcon, Skeleton, useTheme, useMediaQuery } from '@mui/material' import Link from 'next/link' -import SafeIcon from '@/components/common/SafeIcon' import Track from '@/components/common/Track' -import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' +import { OVERVIEW_EVENTS, OVERVIEW_LABELS, PIN_SAFE_LABELS, trackEvent } from '@/services/analytics' import { AppRoutes } from '@/config/routes' -import { useAppSelector } from '@/store' +import { useAppDispatch, useAppSelector } from '@/store' import { selectChainById } from '@/store/chainsSlice' import ChainIndicator from '@/components/common/ChainIndicator' import css from './styles.module.css' @@ -20,16 +18,21 @@ import useChainId from '@/hooks/useChainId' import { sameAddress } from '@/utils/addresses' import classnames from 'classnames' import { useRouter } from 'next/router' -import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline' -import type { SafeItem } from './useAllSafes' -import FiatValue from '@/components/common/FiatValue' -import QueueActions from './QueueActions' -import { useGetHref } from './useGetHref' +import type { SafeItem } from '@/features/myAccounts/hooks/useAllSafes' +import { useGetHref } from '@/features/myAccounts/hooks/useGetHref' import { extractCounterfactualSafeSetup, isPredictedSafeProps } from '@/features/counterfactual/utils' -import { useGetSafeOverviewQuery } from '@/store/api/gateway' import useWallet from '@/hooks/wallets/useWallet' -import { skipToken } from '@reduxjs/toolkit/query' import { hasMultiChainAddNetworkFeature } from '@/features/multichain/utils/utils' +import BookmarkIcon from '@/public/images/apps/bookmark.svg' +import BookmarkedIcon from '@/public/images/apps/bookmarked.svg' +import { addOrUpdateSafe, unpinSafe } from '@/store/addedSafesSlice' +import SafeIcon from '@/components/common/SafeIcon' +import useOnceVisible from '@/hooks/useOnceVisible' +import { skipToken } from '@reduxjs/toolkit/query' +import { defaultSafeInfo, useGetSafeOverviewQuery } from '@/store/slices' +import FiatValue from '@/components/common/FiatValue' +import { AccountInfoChips } from '../AccountInfoChips' + type AccountItemProps = { safeItem: SafeItem safeOverview?: SafeOverview @@ -37,7 +40,7 @@ type AccountItemProps = { } const AccountItem = ({ onLinkClick, safeItem }: AccountItemProps) => { - const { chainId, address } = safeItem + const { chainId, address, isReadOnly, isPinned } = safeItem const chain = useAppSelector((state) => selectChainById(state, chainId)) const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, chainId, address)) const safeAddress = useSafeAddress() @@ -46,6 +49,12 @@ const AccountItem = ({ onLinkClick, safeItem }: AccountItemProps) => { const isCurrentSafe = chainId === currChainId && sameAddress(safeAddress, address) const isWelcomePage = router.pathname === AppRoutes.welcome.accounts const { address: walletAddress } = useWallet() ?? {} + const elementRef = useRef(null) + const isVisible = useOnceVisible(elementRef) + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('sm')) + + const dispatch = useAppDispatch() const trackingLabel = isWelcomePage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar @@ -65,12 +74,10 @@ const AccountItem = ({ onLinkClick, safeItem }: AccountItemProps) => { const addNetworkFeatureEnabled = hasMultiChainAddNetworkFeature(chain) const isReplayable = - addNetworkFeatureEnabled && - !safeItem.isWatchlist && - (!undeployedSafe || !isPredictedSafeProps(undeployedSafe.props)) + addNetworkFeatureEnabled && !isReadOnly && (!undeployedSafe || !isPredictedSafeProps(undeployedSafe.props)) const { data: safeOverview } = useGetSafeOverviewQuery( - undeployedSafe + undeployedSafe || !isVisible ? skipToken : { chainId: safeItem.chainId, @@ -79,8 +86,33 @@ const AccountItem = ({ onLinkClick, safeItem }: AccountItemProps) => { }, ) + const safeThreshold = safeOverview?.threshold ?? counterfactualSetup?.threshold ?? defaultSafeInfo.threshold + const safeOwners = + safeOverview?.owners ?? counterfactualSetup?.owners.map((address) => ({ value: address })) ?? defaultSafeInfo.owners + + const addToPinnedList = () => { + dispatch( + addOrUpdateSafe({ + safe: { + ...defaultSafeInfo, + chainId, + address: { value: address }, + owners: safeOwners, + threshold: safeThreshold, + }, + }), + ) + trackEvent({ ...OVERVIEW_EVENTS.PIN_SAFE, label: PIN_SAFE_LABELS.pin }) + } + + const removeFromPinnedList = () => { + dispatch(unpinSafe({ chainId, address })) + trackEvent({ ...OVERVIEW_EVENTS.PIN_SAFE, label: PIN_SAFE_LABELS.unpin }) + } + return ( { > 0 ? safeOwners.length : undefined} + threshold={safeThreshold > 0 ? safeThreshold : undefined} chainId={chainId} /> @@ -123,51 +155,63 @@ const AccountItem = ({ onLinkClick, safeItem }: AccountItemProps) => { > {shortenAddress(address)} - {undeployedSafe && ( -
- - ) : ( - - ) - } - className={classnames(css.chip, { - [css.pendingAccount]: isActivating, - })} - /> -
+ {!isMobile && ( + )} - - {safeOverview ? ( + + {undeployedSafe ? null : safeOverview ? ( - ) : undeployedSafe ? null : ( + ) : ( )} - - + + + + + + {isMobile && ( + + )}
) } diff --git a/src/components/welcome/MyAccounts/SubAccountItem.tsx b/src/features/myAccounts/components/AccountItems/SubAccountItem.tsx similarity index 65% rename from src/components/welcome/MyAccounts/SubAccountItem.tsx rename to src/features/myAccounts/components/AccountItems/SubAccountItem.tsx index 53dbc90f0d..e5a8e80a98 100644 --- a/src/components/welcome/MyAccounts/SubAccountItem.tsx +++ b/src/features/myAccounts/components/AccountItems/SubAccountItem.tsx @@ -1,8 +1,7 @@ -import { LoopIcon } from '@/features/counterfactual/CounterfactualStatusButton' import { selectUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' import type { SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' -import { useMemo } from 'react' -import { ListItemButton, Box, Typography, Chip, Skeleton } from '@mui/material' +import { useMemo, useRef } from 'react' +import { ListItemButton, Box, Typography, Skeleton, useMediaQuery, useTheme } from '@mui/material' import Link from 'next/link' import SafeIcon from '@/components/common/SafeIcon' import Track from '@/components/common/Track' @@ -18,12 +17,12 @@ import useChainId from '@/hooks/useChainId' import { sameAddress } from '@/utils/addresses' import classnames from 'classnames' import { useRouter } from 'next/router' -import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline' -import type { SafeItem } from './useAllSafes' +import type { SafeItem } from '@/features/myAccounts/hooks/useAllSafes' import FiatValue from '@/components/common/FiatValue' -import QueueActions from './QueueActions' -import { useGetHref } from './useGetHref' +import { useGetHref } from '@/features/myAccounts/hooks/useGetHref' import { extractCounterfactualSafeSetup } from '@/features/counterfactual/utils' +import useOnceVisible from '@/hooks/useOnceVisible' +import { AccountInfoChips } from '../AccountInfoChips' type SubAccountItem = { safeItem: SafeItem @@ -40,6 +39,10 @@ const SubAccountItem = ({ onLinkClick, safeItem, safeOverview }: SubAccountItem) const router = useRouter() const isCurrentSafe = chainId === currChainId && sameAddress(safeAddress, address) const isWelcomePage = router.pathname === AppRoutes.welcome.accounts + const elementRef = useRef(null) + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('sm')) + const isVisible = useOnceVisible(elementRef) const trackingLabel = isWelcomePage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar @@ -57,17 +60,14 @@ const SubAccountItem = ({ onLinkClick, safeItem, safeOverview }: SubAccountItem) return ( - - + + {chain?.chainName} - {undeployedSafe && ( -
- - ) : ( - - ) - } - className={classnames(css.chip, { - [css.pendingAccount]: isActivating, - })} - /> -
+ {!isMobile && ( + )} - - {safeOverview ? ( + + {undeployedSafe ? null : safeOverview ? ( - ) : undeployedSafe ? null : ( + ) : ( )} {undeployedSafe && ( - + + )} + + {isMobile && ( + )} -
) } diff --git a/src/components/welcome/MyAccounts/styles.module.css b/src/features/myAccounts/components/AccountItems/styles.module.css similarity index 55% rename from src/components/welcome/MyAccounts/styles.module.css rename to src/features/myAccounts/components/AccountItems/styles.module.css index 485a31323a..4adeb324f6 100644 --- a/src/components/welcome/MyAccounts/styles.module.css +++ b/src/features/myAccounts/components/AccountItems/styles.module.css @@ -1,33 +1,3 @@ -.container { - container-type: inline-size; - container-name: my-accounts-container; - display: flex; - justify-content: center; -} - -.myAccounts { - width: 100%; - max-width: 600px; - margin: var(--space-2); -} - -.safeList { - padding: var(--space-3); - margin-bottom: var(--space-3); -} - -.safeList :last-child { - margin-bottom: 0; -} - -.header { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - padding: var(--space-3) 0; - gap: var(--space-1); -} - .listItem { border: 1px solid var(--color-border-light); border-radius: var(--space-1); @@ -69,6 +39,7 @@ border-radius: 6px; border: 1px solid var(--color-border-light); } + .subItem.currentListItem .borderLeft { border-left: 4px solid var(--color-secondary-light); } @@ -81,15 +52,16 @@ .safeLink { display: grid; padding: var(--space-2) var(--space-1) var(--space-2) var(--space-2); - grid-template-columns: 60px 3fr 3fr minmax(auto, 2fr); + grid-template-columns: 60px 10fr 2fr 3fr; align-items: center; } +.multiSafeLink { + grid-template-columns: 60px 8fr 5fr 3fr; +} + .safeSubLink { - display: grid; - padding: var(--space-2) var(--space-1) var(--space-2) var(--space-2); grid-template-columns: 60px 3fr minmax(auto, 2fr); - align-items: center; } .safeName, @@ -100,52 +72,11 @@ } .listHeader { - padding-bottom: var(--space-1); display: flex; - justify-content: space-between; } -.listTitle { - margin: auto 0; -} - -.card { - margin: auto; - padding: var(--space-3); - display: flex; - flex-direction: column; - background-color: transparent; -} - -.card :global .MuiCardHeader-root, -.card :global .MuiCardContent-root { - padding: 0; -} - -.cardHeader { - text-align: center; -} - -.infoIcon { - vertical-align: middle; - width: 1rem; - height: 1rem; - margin: 4px; - color: var(--color-text-secondary); -} - -.chip { - border-radius: 4px; - background-color: var(--color-warning-background); - margin-top: 4px; -} - -.pendingAccount { - background-color: var(--color-info-light); -} - -.pendingLoopIcon { - color: var(--color-info-dark) !important; +.listHeader svg path { + stroke: var(--color-text-primary); } .multiChains { @@ -165,11 +96,15 @@ justify-content: flex-end; } -@media (max-width: 899.95px) { - .container { - width: auto; - } +.chipSection { + width: 100%; +} +.chipSection:empty { + display: none; +} + +@media (max-width: 899.95px) { .safeLink { padding-right: 0; } @@ -200,28 +135,8 @@ .multiChains { justify-content: flex-start; } -} -@container my-accounts-container (max-width: 500px) { - .myAccounts { - margin: 0; - } - - .header { - padding: var(--space-3); - border-bottom: 1px solid var(--color-border-light); - } - - .safeList { - border-radius: 0; - margin-bottom: 0; - } - - .title { - font-size: 20px; - } - - .card { - border-top: 1px solid var(--color-border-light); + .chainIndicator { + justify-content: flex-start; } } diff --git a/src/components/welcome/MyAccounts/AddNetworkButton.tsx b/src/features/myAccounts/components/AddNetworkButton/index.tsx similarity index 100% rename from src/components/welcome/MyAccounts/AddNetworkButton.tsx rename to src/features/myAccounts/components/AddNetworkButton/index.tsx diff --git a/src/components/welcome/MyAccounts/CreateButton.tsx b/src/features/myAccounts/components/CreateButton/index.tsx similarity index 88% rename from src/components/welcome/MyAccounts/CreateButton.tsx rename to src/features/myAccounts/components/CreateButton/index.tsx index 51153ce78b..d3ee1f8c87 100644 --- a/src/components/welcome/MyAccounts/CreateButton.tsx +++ b/src/features/myAccounts/components/CreateButton/index.tsx @@ -2,7 +2,7 @@ import { Button } from '@mui/material' import Link from 'next/link' import { AppRoutes } from '@/config/routes' -const buttonSx = { width: ['100%', 'auto'] } +const buttonSx = { width: ['100%', 'auto'], height: '36px', px: 2 } const CreateButton = ({ isPrimary }: { isPrimary: boolean }) => { return ( diff --git a/src/components/welcome/MyAccounts/DataWidget.tsx b/src/features/myAccounts/components/DataWidget/index.tsx similarity index 71% rename from src/components/welcome/MyAccounts/DataWidget.tsx rename to src/features/myAccounts/components/DataWidget/index.tsx index cc6f0c10e7..a8799633e1 100644 --- a/src/components/welcome/MyAccounts/DataWidget.tsx +++ b/src/features/myAccounts/components/DataWidget/index.tsx @@ -1,4 +1,4 @@ -import { Button, Grid, SvgIcon, Card, CardHeader, CardContent, Tooltip } from '@mui/material' +import { Button, SvgIcon, Card, CardHeader, CardContent, Tooltip, Box } from '@mui/material' import { useState } from 'react' import type { ReactElement } from 'react' @@ -56,37 +56,33 @@ export const DataWidget = (): ReactElement => { } /> - + {hasData && ( - - - - - - )} - - + - - + )} + + + + {importModalOpen && ( void +} + +const orderByLabels = { + [OrderByOption.LAST_VISITED]: 'Most recent', + [OrderByOption.NAME]: 'Name', +} + +const OrderByButton = ({ orderBy: orderBy, onOrderByChange: onOrderByChange }: OrderByButtonProps) => { + const [anchorEl, setAnchorEl] = useState() + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + + const handleClose = () => { + setAnchorEl(undefined) + } + + const handleOrderByChange = (newOrderBy: OrderByOption) => { + trackEvent({ ...OVERVIEW_EVENTS.SORT_SAFES, label: orderByLabels[newOrderBy] }) + onOrderByChange(newOrderBy) + handleClose() + } + + return ( + + + + + + Sort by + + handleOrderByChange(OrderByOption.LAST_VISITED)} + selected={orderBy === OrderByOption.LAST_VISITED} + > + {orderByLabels[OrderByOption.LAST_VISITED]} + {orderBy === OrderByOption.LAST_VISITED && } + + handleOrderByChange(OrderByOption.NAME)} selected={orderBy === OrderByOption.NAME}> + {orderByLabels[OrderByOption.NAME]} + {orderBy === OrderByOption.NAME && } + + + + ) +} + +export default OrderByButton diff --git a/src/components/welcome/MyAccounts/QueueActions.tsx b/src/features/myAccounts/components/QueueActions/index.tsx similarity index 59% rename from src/components/welcome/MyAccounts/QueueActions.tsx rename to src/features/myAccounts/components/QueueActions/index.tsx index cacdd5a3f1..5da57be62e 100644 --- a/src/components/welcome/MyAccounts/QueueActions.tsx +++ b/src/features/myAccounts/components/QueueActions/index.tsx @@ -32,11 +32,13 @@ const QueueActions = ({ chainShortName, queued, awaitingConfirmation, + isMobile = false, }: { safeAddress: string chainShortName: string queued: number awaitingConfirmation: number + isMobile?: boolean }) => { const queueLink = useMemo( () => ({ @@ -51,39 +53,33 @@ const QueueActions = ({ } return ( - - - - - {queued > 0 && ( - - - {queued} pending transaction{queued > 1 ? 's' : ''} - - )} + + + + {queued > 0 && ( + + + {queued} pending + + )} - {awaitingConfirmation > 0 && ( - - - {awaitingConfirmation} to confirm - - )} - - - - + {awaitingConfirmation > 0 && ( + + + {awaitingConfirmation} to confirm + + )} + + + ) } diff --git a/src/features/myAccounts/components/SafesList/index.tsx b/src/features/myAccounts/components/SafesList/index.tsx new file mode 100644 index 0000000000..63f8985725 --- /dev/null +++ b/src/features/myAccounts/components/SafesList/index.tsx @@ -0,0 +1,45 @@ +import AccountItem from '@/features/myAccounts/components/AccountItems/SingleAccountItem' +import type { SafeItem } from '@/features/myAccounts/hooks/useAllSafes' +import type { MultiChainSafeItem } from '@/features/myAccounts/hooks/useAllSafesGrouped' +import MultiAccountItem from '@/features/myAccounts/components/AccountItems/MultiAccountItem' +import { isMultiChainSafeItem } from '@/features/multichain/utils/utils' +import { TransitionGroup } from 'react-transition-group' +import { Collapse } from '@mui/material' + +type SafeListProps = { + safes?: (SafeItem | MultiChainSafeItem)[] + onLinkClick?: () => void + useTransitions?: boolean +} + +const renderSafeItem = (item: SafeItem | MultiChainSafeItem, onLinkClick?: () => void) => { + return isMultiChainSafeItem(item) ? ( + + ) : ( + + ) +} + +const SafesList = ({ safes, onLinkClick, useTransitions = true }: SafeListProps) => { + if (!safes || safes.length === 0) { + return null + } + + return useTransitions ? ( + + {safes.map((item) => ( + + {renderSafeItem(item, onLinkClick)} + + ))} + + ) : ( + <> + {safes.map((item) => ( +
{renderSafeItem(item, onLinkClick)}
+ ))} + + ) +} + +export default SafesList diff --git a/src/components/welcome/MyAccounts/useAllOwnedSafes.ts b/src/features/myAccounts/hooks/useAllOwnedSafes.ts similarity index 100% rename from src/components/welcome/MyAccounts/useAllOwnedSafes.ts rename to src/features/myAccounts/hooks/useAllOwnedSafes.ts diff --git a/src/components/welcome/MyAccounts/useAllSafes.ts b/src/features/myAccounts/hooks/useAllSafes.ts similarity index 60% rename from src/components/welcome/MyAccounts/useAllSafes.ts rename to src/features/myAccounts/hooks/useAllSafes.ts index 84c3a40020..6990098fad 100644 --- a/src/components/welcome/MyAccounts/useAllSafes.ts +++ b/src/features/myAccounts/hooks/useAllSafes.ts @@ -3,15 +3,18 @@ import uniq from 'lodash/uniq' import isEmpty from 'lodash/isEmpty' import { useAppSelector } from '@/store' import { selectAllAddedSafes } from '@/store/addedSafesSlice' -import useAllOwnedSafes from './useAllOwnedSafes' import useChains from '@/hooks/useChains' import useWallet from '@/hooks/wallets/useWallet' -import { selectUndeployedSafes } from '@/store/slices' +import { selectAllAddressBooks, selectAllVisitedSafes, selectUndeployedSafes } from '@/store/slices' import { sameAddress } from '@/utils/addresses' +import useAllOwnedSafes from './useAllOwnedSafes' export type SafeItem = { chainId: string address: string - isWatchlist: boolean + isReadOnly: boolean + isPinned: boolean + lastVisited: number + name: string | undefined } export type SafeItems = SafeItem[] @@ -36,37 +39,46 @@ export const useHasSafes = () => { const useAllSafes = (): SafeItems | undefined => { const { address: walletAddress = '' } = useWallet() || {} - const [allOwned, , allOwnedLoading] = useAllOwnedSafes(walletAddress) + const [allOwned] = useAllOwnedSafes(walletAddress) const allAdded = useAddedSafes() + const allUndeployed = useAppSelector(selectUndeployedSafes) + const allVisitedSafes = useAppSelector(selectAllVisitedSafes) const { configs } = useChains() - const undeployedSafes = useAppSelector(selectUndeployedSafes) + const allSafeNames = useAppSelector(selectAllAddressBooks) - return useMemo(() => { - if (walletAddress && (allOwned === undefined || allOwnedLoading)) { - return undefined + return useMemo(() => { + if (walletAddress && allOwned === undefined) { + return [] } - const chains = uniq(Object.keys(allAdded).concat(Object.keys(allOwned || {}))) + const chains = uniq(Object.keys(allOwned || {}).concat(Object.keys(allAdded), Object.keys(allUndeployed))) + chains.sort((a, b) => parseInt(a) - parseInt(b)) return chains.flatMap((chainId) => { if (!configs.some((item) => item.chainId === chainId)) return [] const addedOnChain = Object.keys(allAdded[chainId] || {}) const ownedOnChain = (allOwned || {})[chainId] - const undeployedOnChain = Object.keys(undeployedSafes[chainId] || {}) - const uniqueAddresses = uniq(addedOnChain.concat(ownedOnChain)).filter(Boolean) + const undeployedOnChain = Object.keys(allUndeployed[chainId] || {}) + const uniqueAddresses = uniq(addedOnChain.concat(ownedOnChain, undeployedOnChain).filter(Boolean)) + uniqueAddresses.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())) return uniqueAddresses.map((address) => { const owners = allAdded?.[chainId]?.[address]?.owners + const isPinned = !!allAdded?.[chainId]?.[address] const isOwner = owners?.some(({ value }) => sameAddress(walletAddress, value)) - const isUndeployed = undeployedOnChain.includes(address) const isOwned = (ownedOnChain || []).includes(address) || isOwner + const lastVisited = allVisitedSafes?.[chainId]?.[address]?.lastVisited || 0 + const name = allSafeNames?.[chainId]?.[address] return { address, chainId, - isWatchlist: !isOwned && !isUndeployed, + isReadOnly: !isOwned, + isPinned, + lastVisited, + name, } }) }) - }, [allAdded, allOwned, allOwnedLoading, configs, undeployedSafes, walletAddress]) + }, [allAdded, allOwned, allUndeployed, configs, walletAddress, allVisitedSafes, allSafeNames]) } export default useAllSafes diff --git a/src/components/welcome/MyAccounts/useAllSafesGrouped.ts b/src/features/myAccounts/hooks/useAllSafesGrouped.ts similarity index 70% rename from src/components/welcome/MyAccounts/useAllSafesGrouped.ts rename to src/features/myAccounts/hooks/useAllSafesGrouped.ts index 8877a7a1c0..23f3a4663f 100644 --- a/src/components/welcome/MyAccounts/useAllSafesGrouped.ts +++ b/src/features/myAccounts/hooks/useAllSafesGrouped.ts @@ -3,7 +3,13 @@ import useAllSafes, { type SafeItem, type SafeItems } from './useAllSafes' import { useMemo } from 'react' import { sameAddress } from '@/utils/addresses' -export type MultiChainSafeItem = { address: string; safes: SafeItem[] } +export type MultiChainSafeItem = { + address: string + safes: SafeItem[] + isPinned: boolean + lastVisited: number + name: string | undefined +} export type AllSafesGrouped = { allSingleSafes: SafeItems | undefined @@ -14,8 +20,13 @@ const getMultiChainAccounts = (safes: SafeItems): MultiChainSafeItem[] => { const groupedByAddress = groupBy(safes, (safe) => safe.address) const multiChainSafeItems = Object.entries(groupedByAddress) .filter((entry) => entry[1].length > 1) - .map((entry): MultiChainSafeItem => ({ address: entry[0], safes: entry[1] })) - + .map((entry) => { + const [address, safes] = entry + const isPinned = safes.some((safe) => safe.isPinned) + const lastVisited = safes.reduce((acc, safe) => Math.max(acc, safe.lastVisited || 0), 0) + const name = safes.find((safe) => safe.name !== undefined)?.name + return { address, safes, isPinned, lastVisited, name } + }) return multiChainSafeItems } diff --git a/src/components/welcome/MyAccounts/useGetHref.ts b/src/features/myAccounts/hooks/useGetHref.ts similarity index 100% rename from src/components/welcome/MyAccounts/useGetHref.ts rename to src/features/myAccounts/hooks/useGetHref.ts diff --git a/src/features/myAccounts/hooks/useSafesSearch.ts b/src/features/myAccounts/hooks/useSafesSearch.ts new file mode 100644 index 0000000000..c8fe879452 --- /dev/null +++ b/src/features/myAccounts/hooks/useSafesSearch.ts @@ -0,0 +1,63 @@ +import { useEffect, useMemo } from 'react' +import Fuse from 'fuse.js' +import type { MultiChainSafeItem } from './useAllSafesGrouped' +import type { SafeItem } from './useAllSafes' +import { selectChains } from '@/store/chainsSlice' +import { useAppSelector } from '@/store' +import { isMultiChainSafeItem } from '@/features/multichain/utils/utils' +import { OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' + +const useSafesSearch = (safes: (SafeItem | MultiChainSafeItem)[], query: string): (SafeItem | MultiChainSafeItem)[] => { + const chains = useAppSelector(selectChains) + + useEffect(() => { + if (query) { + trackEvent({ + category: OVERVIEW_EVENTS.SEARCH.category, + action: OVERVIEW_EVENTS.SEARCH.action, + }) + } + }, [query]) + + // Include chain names in the search + const safesWithChainNames = useMemo( + () => + safes.map((safe) => { + if (isMultiChainSafeItem(safe)) { + const subSafeChains = safe.safes.map( + (subSafe) => chains.data.find((chain) => chain.chainId === subSafe.chainId)?.chainName, + ) + const subSafeNames = safe.safes.map((subSafe) => subSafe.name) + return { ...safe, chainNames: subSafeChains, names: subSafeNames } + } + const chain = chains.data.find((chain) => chain.chainId === safe.chainId) + return { ...safe, chainNames: [chain?.chainName], names: [safe.name] } + }), + [safes, chains.data], + ) + + const fuse = useMemo( + () => + new Fuse(safesWithChainNames, { + keys: [{ name: 'names' }, { name: 'address' }, { name: 'chainNames' }], + threshold: 0.2, + findAllMatches: true, + ignoreLocation: true, + }), + [safesWithChainNames], + ) + + // Return results in the original format + return useMemo( + () => + query + ? fuse.search(query).map((result) => { + const { chainNames: _chainNames, names: _names, ...safe } = result.item + return safe + }) + : [], + [fuse, query], + ) +} + +export { useSafesSearch } diff --git a/src/components/welcome/MyAccounts/useTrackedSafesCount.ts b/src/features/myAccounts/hooks/useTrackedSafesCount.ts similarity index 50% rename from src/components/welcome/MyAccounts/useTrackedSafesCount.ts rename to src/features/myAccounts/hooks/useTrackedSafesCount.ts index 1b0b0ec3ff..9c90115828 100644 --- a/src/components/welcome/MyAccounts/useTrackedSafesCount.ts +++ b/src/features/myAccounts/hooks/useTrackedSafesCount.ts @@ -1,23 +1,51 @@ import { AppRoutes } from '@/config/routes' import { OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' import { useRouter } from 'next/router' -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' import { type SafeItem } from './useAllSafes' +import type { AllSafesGrouped } from './useAllSafesGrouped' import { type MultiChainSafeItem } from './useAllSafesGrouped' import { isMultiChainSafeItem } from '@/features/multichain/utils/utils' let isOwnedSafesTracked = false +let isPinnedSafesTracked = false let isWatchlistTracked = false const useTrackSafesCount = ( - ownedSafes: (MultiChainSafeItem | SafeItem)[] | undefined, - watchlistSafes: (MultiChainSafeItem | SafeItem)[] | undefined, + safes: AllSafesGrouped, + pinnedSafes: (MultiChainSafeItem | SafeItem)[], wallet: ConnectedWallet | null, ) => { const router = useRouter() const isLoginPage = router.pathname === AppRoutes.welcome.accounts + const ownedMultiChainSafes = useMemo( + () => safes.allMultiChainSafes?.filter((account) => account.safes.some(({ isReadOnly }) => !isReadOnly)), + [safes], + ) + + // If all safes of a multichain account are on the watchlist we put the entire account on the watchlist + const watchlistMultiChainSafes = useMemo( + () => + safes.allMultiChainSafes?.filter((account) => + account.safes.some(({ isReadOnly, isPinned }) => isReadOnly && !isPinned), + ), + [safes], + ) + + const ownedSafes = useMemo<(MultiChainSafeItem | SafeItem)[]>( + () => [...(ownedMultiChainSafes ?? []), ...(safes.allSingleSafes?.filter(({ isReadOnly }) => !isReadOnly) ?? [])], + [safes, ownedMultiChainSafes], + ) + const watchlistSafes = useMemo<(MultiChainSafeItem | SafeItem)[]>( + () => [ + ...(watchlistMultiChainSafes ?? []), + ...(safes.allSingleSafes?.filter(({ isReadOnly, isPinned }) => isReadOnly && !isPinned) ?? []), + ], + [safes, watchlistMultiChainSafes], + ) + // Reset tracking for new wallet useEffect(() => { isOwnedSafesTracked = false @@ -34,6 +62,17 @@ const useTrackSafesCount = ( } }, [isLoginPage, ownedSafes, wallet]) + useEffect(() => { + const totalSafesPinned = pinnedSafes?.reduce( + (prev, current) => prev + (isMultiChainSafeItem(current) ? current.safes.length : 1), + 0, + ) + if (!isPinnedSafesTracked && pinnedSafes && pinnedSafes.length > 0 && isLoginPage) { + trackEvent({ ...OVERVIEW_EVENTS.TOTAL_SAFES_PINNED, label: totalSafesPinned }) + isPinnedSafesTracked = true + } + }, [isLoginPage, pinnedSafes]) + useEffect(() => { const totalSafesWatched = watchlistSafes?.reduce( (prev, current) => prev + (isMultiChainSafeItem(current) ? current.safes.length : 1), diff --git a/src/features/myAccounts/hooks/useVisitedSafes.ts b/src/features/myAccounts/hooks/useVisitedSafes.ts new file mode 100644 index 0000000000..6836add9d7 --- /dev/null +++ b/src/features/myAccounts/hooks/useVisitedSafes.ts @@ -0,0 +1,29 @@ +import { useRouter } from 'next/router' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useAppDispatch } from '@/store' +import { useCallback, useEffect } from 'react' +import { upsertVisitedSafe } from '@/store/visitedSafesSlice' + +export const useVisitedSafes = () => { + const router = useRouter() + const { safe } = useSafeInfo() + const dispatch = useAppDispatch() + + const handleRouteChange = useCallback(() => { + const { query } = router + if (query.safe && safe.address.value) { + const visitedSafe = { + chainId: safe.chainId, + address: safe.address.value, + lastVisited: Date.now(), + } + dispatch(upsertVisitedSafe(visitedSafe)) + } + }, [router, safe.address.value, safe.chainId, dispatch]) + + useEffect(() => { + if (router.query.safe) { + handleRouteChange() + } + }, [handleRouteChange, router.query.safe]) +} diff --git a/src/features/myAccounts/index.tsx b/src/features/myAccounts/index.tsx new file mode 100644 index 0000000000..832e95ad75 --- /dev/null +++ b/src/features/myAccounts/index.tsx @@ -0,0 +1,273 @@ +import { useCallback, useMemo, useState } from 'react' +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Button, + Divider, + InputAdornment, + Link, + Paper, + SvgIcon, + TextField, + Typography, +} from '@mui/material' +import debounce from 'lodash/debounce' +import madProps from '@/utils/mad-props' +import CreateButton from '@/features/myAccounts/components/CreateButton' +import AddIcon from '@/public/images/common/add.svg' +import Track from '@/components/common/Track' +import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' +import css from '@/features/myAccounts/styles.module.css' +import SafesList from '@/features/myAccounts/components/SafesList' +import { AppRoutes } from '@/config/routes' +import useWallet from '@/hooks/wallets/useWallet' +import { useRouter } from 'next/router' +import { + type AllSafesGrouped, + useAllSafesGrouped, + type MultiChainSafeItem, +} from '@/features/myAccounts/hooks/useAllSafesGrouped' +import { type SafeItem } from '@/features/myAccounts/hooks/useAllSafes' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import BookmarkIcon from '@/public/images/apps/bookmark.svg' +import classNames from 'classnames' +import { getComparator } from '@/features/myAccounts/utils/utils' +import SearchIcon from '@/public/images/common/search.svg' +import type { OrderByOption } from '@/store/orderByPreferenceSlice' +import { selectOrderByPreference, setOrderByPreference } from '@/store/orderByPreferenceSlice' +import { useAppDispatch, useAppSelector } from '@/store' +import { useSafesSearch } from '@/features/myAccounts/hooks/useSafesSearch' +import useTrackSafesCount from '@/features/myAccounts/hooks/useTrackedSafesCount' +import { DataWidget } from '@/features/myAccounts/components/DataWidget' +import OrderByButton from '@/features/myAccounts/components/OrderByButton' +import ConnectWalletButton from '@/components/common/ConnectWallet/ConnectWalletButton' + +type AccountsListProps = { + safes: AllSafesGrouped + isSidebar?: boolean + onLinkClick?: () => void +} + +const AccountsList = ({ safes, onLinkClick, isSidebar = false }: AccountsListProps) => { + const wallet = useWallet() + const router = useRouter() + const { orderBy } = useAppSelector(selectOrderByPreference) + const dispatch = useAppDispatch() + const sortComparator = getComparator(orderBy) + const [searchQuery, setSearchQuery] = useState('') + + const allSafes = useMemo( + () => [...(safes.allMultiChainSafes ?? []), ...(safes.allSingleSafes ?? [])].sort(sortComparator), + [safes.allMultiChainSafes, safes.allSingleSafes, sortComparator], + ) + const filteredSafes = useSafesSearch(allSafes ?? [], searchQuery).sort(sortComparator) + + const pinnedSafes = useMemo<(MultiChainSafeItem | SafeItem)[]>( + () => [...(allSafes?.filter(({ isPinned }) => isPinned) ?? [])], + [allSafes], + ) + + const handleOrderByChange = (orderBy: OrderByOption) => { + dispatch(setOrderByPreference({ orderBy })) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + const handleSearch = useCallback(debounce(setSearchQuery, 300), []) + + useTrackSafesCount(safes, pinnedSafes, wallet) + + const isLoginPage = router.pathname === AppRoutes.welcome.accounts + const trackingLabel = isLoginPage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar + + return ( + + + + + My accounts + + + + + + + + + {wallet ? ( + + + + ) : ( + + + + )} + + + + + + + { + handleSearch(e.target.value) + }} + InputProps={{ + startAdornment: ( + + + + ), + disableUnderline: true, + }} + fullWidth + size="small" + /> + + + + + {isSidebar && } + + + {searchQuery ? ( + <> + {/* Search results */} + + Found {filteredSafes.length} result{filteredSafes.length === 1 ? '' : 's'} + + + + + + ) : ( + <> + {/* Pinned Accounts */} + +
+ + + Pinned + +
+ {pinnedSafes.length > 0 ? ( + + ) : ( + + + Personalize your account list by clicking the + + icon on the accounts most important to you. + + + )} +
+ + {/* All Accounts */} + + } + sx={{ + padding: 0, + '& .MuiAccordionSummary-content': { margin: '0 !important', mb: 1, flexGrow: 0 }, + }} + > +
+ + Accounts + {allSafes && allSafes.length > 0 && ( + + {' '} + ({allSafes.length}) + + )} + +
+
+ + {allSafes.length > 0 ? ( + + + + ) : ( + + {!wallet ? ( + <> + Connect a wallet to view your Safe Accounts or to create a new one + + + + + ) : ( + "You don't have any safes yet" + )} + + )} + +
+ + )} +
+
+ {isSidebar && } + +
+
+ ) +} + +const MyAccounts = madProps(AccountsList, { + safes: useAllSafesGrouped, +}) + +export default MyAccounts diff --git a/src/features/myAccounts/styles.module.css b/src/features/myAccounts/styles.module.css new file mode 100644 index 0000000000..84df9b9a56 --- /dev/null +++ b/src/features/myAccounts/styles.module.css @@ -0,0 +1,88 @@ +.container { + container-type: inline-size; + container-name: my-accounts-container; + display: flex; + justify-content: center; +} + +.myAccounts { + width: 100vw; + max-width: 750px; + margin: var(--space-2); +} + +.sidebarAccounts { + margin: 0 !important; +} + +.safeList { + padding: var(--space-2) var(--space-2) var(--space-3); + margin-bottom: var(--space-1); +} + +.header { + display: flex; + justify-content: space-between; + padding: var(--space-3) 0; + gap: var(--space-2); +} + +.sidebarHeader { + padding: var(--space-3) var(--space-2); + border-bottom: 1px solid var(--color-border-light); +} + +.sidebarHeader > h1 { + font-size: 24px; +} + +.headerButtons { + display: flex; + flex-direction: row; + gap: var(--space-1); +} + +.noPinnedSafesMessage { + display: flex; + justify-content: center; + border: 1px solid var(--color-border-light); + padding: var(--space-3); + border-radius: var(--space-1); + border-style: dashed; +} + +.listHeader { + display: flex; +} + +.listHeader svg path { + stroke: var(--color-text-primary); +} + +@media (max-width: 899.95px) { + .container { + width: auto; + } +} + +.safeList :global .MuiAccordionSummary-root { + background: var(--color-background-paper) !important; + padding-left: 0; + min-height: 0; + justify-content: left; + vertical-align: middle; +} + +@media (max-width: 599.95px) { + .header { + flex-direction: column; + } + + .headerButtons > span { + flex: 1; + } + + .headerButtons > span > a { + width: 100%; + } +} diff --git a/src/features/myAccounts/utils/utils.ts b/src/features/myAccounts/utils/utils.ts new file mode 100644 index 0000000000..59af191b5c --- /dev/null +++ b/src/features/myAccounts/utils/utils.ts @@ -0,0 +1,19 @@ +import { OrderByOption } from '@/store/orderByPreferenceSlice' +import type { SafeItem } from '@/features/myAccounts/hooks/useAllSafes' +import type { MultiChainSafeItem } from '@/features/myAccounts/hooks/useAllSafesGrouped' + +export const nameComparator = (a: SafeItem | MultiChainSafeItem, b: SafeItem | MultiChainSafeItem) => { + // Put undefined names last + if (!a.name && !b.name) return 0 + if (!a.name) return 1 + if (!b.name) return -1 + return a.name.localeCompare(b.name) +} + +export const lastVisitedComparator = (a: SafeItem | MultiChainSafeItem, b: SafeItem | MultiChainSafeItem) => { + return b.lastVisited - a.lastVisited +} + +export const getComparator = (orderBy: OrderByOption) => { + return orderBy === OrderByOption.NAME ? nameComparator : lastVisitedComparator +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index f960836ede..6ff90ba635 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -45,6 +45,7 @@ import WalletProvider from '@/components/common/WalletProvider' import CounterfactualHooks from '@/features/counterfactual/CounterfactualHooks' import PkModulePopup from '@/services/private-key-module/PkModulePopup' import GeoblockingProvider from '@/components/common/GeoblockingProvider' +import { useVisitedSafes } from '@/features/myAccounts/hooks/useVisitedSafes' import OutreachPopup from '@/features/targetedOutreach/components/OutreachPopup' export const GATEWAY_URL = IS_PRODUCTION || cgwDebugStorage.get() ? GATEWAY_URL_PRODUCTION : GATEWAY_URL_STAGING @@ -71,6 +72,7 @@ const InitApp = (): null => { useTxTracking() useSafeMsgTracking() useBeamer() + useVisitedSafes() return null } diff --git a/src/pages/welcome/accounts.tsx b/src/pages/welcome/accounts.tsx index b85f0a906e..3cb3983d41 100644 --- a/src/pages/welcome/accounts.tsx +++ b/src/pages/welcome/accounts.tsx @@ -1,6 +1,6 @@ import type { NextPage } from 'next' import Head from 'next/head' -import MyAccounts from '@/components/welcome/MyAccounts' +import MyAccounts from '@/features/myAccounts' const Accounts: NextPage = () => { return ( diff --git a/src/services/analytics/events/overview.ts b/src/services/analytics/events/overview.ts index e2d2fbb4bd..00a1d32d65 100644 --- a/src/services/analytics/events/overview.ts +++ b/src/services/analytics/events/overview.ts @@ -52,11 +52,24 @@ export const OVERVIEW_EVENTS = { category: OVERVIEW_CATEGORY, event: EventType.META, }, + TOTAL_SAFES_PINNED: { + action: 'Total Safes pinned', + category: OVERVIEW_CATEGORY, + event: EventType.META, + }, TOTAL_SAFES_WATCHLIST: { action: 'Total Safes watchlist', category: OVERVIEW_CATEGORY, event: EventType.META, }, + SEARCH: { + action: 'Search safes', + category: OVERVIEW_CATEGORY, + }, + SORT_SAFES: { + action: 'Sort Safes', + category: OVERVIEW_CATEGORY, + }, SIDEBAR: { action: 'Sidebar', category: OVERVIEW_CATEGORY, @@ -129,6 +142,10 @@ export const OVERVIEW_EVENTS = { category: OVERVIEW_CATEGORY, //label: OPEN_SAFE_LABELS }, + PIN_SAFE: { + action: 'Toggle Safe pinned state', + category: OVERVIEW_CATEGORY, + }, // Track clicks on links to Safe Accounts EXPAND_MULTI_SAFE: { action: 'Expand multi Safe', @@ -176,6 +193,11 @@ export const OVERVIEW_EVENTS = { }, } +export enum PIN_SAFE_LABELS { + pin = 'pin', + unpin = 'unpin', +} + export enum OPEN_SAFE_LABELS { sidebar = 'sidebar', after_create = 'after_create', diff --git a/src/store/__tests__/safeOverviews.test.ts b/src/store/__tests__/safeOverviews.test.ts index dab26c3159..579c1192e9 100644 --- a/src/store/__tests__/safeOverviews.test.ts +++ b/src/store/__tests__/safeOverviews.test.ts @@ -220,8 +220,22 @@ describe('safeOverviews', () => { const request = { currency: 'usd', safes: [ - { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, - { address: faker.finance.ethereumAddress(), chainId: '10', isWatchlist: false }, + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, + }, + { + address: faker.finance.ethereumAddress(), + chainId: '10', + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, + }, ], } @@ -263,8 +277,22 @@ describe('safeOverviews', () => { const request = { currency: 'usd', safes: [ - { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, - { address: faker.finance.ethereumAddress(), chainId: '10', isWatchlist: false }, + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, + }, + { + address: faker.finance.ethereumAddress(), + chainId: '10', + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, + }, ], } @@ -288,21 +316,126 @@ describe('safeOverviews', () => { const request = { currency: 'usd', safes: [ - { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, - { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, - { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, - { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, - { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, - { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, - { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, - { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, - { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, - { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, - { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, - { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, - { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, - { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, - { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, + }, + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, + }, + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, + }, + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, + }, + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, + }, + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, + }, + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, + }, + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, + }, + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, + }, + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, + }, + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, + }, + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, + }, + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, + }, + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, + }, + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: undefined, + }, ], } diff --git a/src/store/addedSafesSlice.ts b/src/store/addedSafesSlice.ts index eefde3d421..e6b22dcda9 100644 --- a/src/store/addedSafesSlice.ts +++ b/src/store/addedSafesSlice.ts @@ -45,6 +45,20 @@ export const addedSafesSlice = createSlice({ delete state[chainId]?.[address] + if (Object.keys(state[chainId]).length === 0) { + delete state[chainId] + } + }, + pinSafe: (state, { payload }: PayloadAction<{ chainId: string; address: string }>) => { + const { chainId, address } = payload + state[chainId] ??= {} + state[chainId][address] = state[chainId][address] ?? {} + }, + unpinSafe: (state, { payload }: PayloadAction<{ chainId: string; address: string }>) => { + const { chainId, address } = payload + + delete state[chainId]?.[address] + if (Object.keys(state[chainId]).length === 0) { delete state[chainId] } @@ -52,7 +66,7 @@ export const addedSafesSlice = createSlice({ }, }) -export const { addOrUpdateSafe, removeSafe } = addedSafesSlice.actions +export const { addOrUpdateSafe, removeSafe, pinSafe, unpinSafe } = addedSafesSlice.actions export const selectAllAddedSafes = (state: RootState): AddedSafesState => { return state[addedSafesSlice.name] diff --git a/src/store/api/gateway/safeOverviews.ts b/src/store/api/gateway/safeOverviews.ts index d781eedd0f..8bb8aa728f 100644 --- a/src/store/api/gateway/safeOverviews.ts +++ b/src/store/api/gateway/safeOverviews.ts @@ -4,7 +4,7 @@ import { type SafeOverview, getSafeOverviews } from '@safe-global/safe-gateway-t import { sameAddress } from '@/utils/addresses' import type { RootState } from '../..' import { selectCurrency } from '../../settingsSlice' -import { type SafeItem } from '@/components/welcome/MyAccounts/useAllSafes' +import { type SafeItem } from '@/features/myAccounts/hooks/useAllSafes' import { asError } from '@/services/exceptions/utils' type SafeOverviewQueueItem = { diff --git a/src/store/index.ts b/src/store/index.ts index 1dcd5903fb..8617ee59da 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -50,6 +50,8 @@ const rootReducer = combineReducers({ [slices.batchSlice.name]: slices.batchSlice.reducer, [slices.undeployedSafesSlice.name]: slices.undeployedSafesSlice.reducer, [slices.swapParamsSlice.name]: slices.swapParamsSlice.reducer, + [slices.visitedSafesSlice.name]: slices.visitedSafesSlice.reducer, + [slices.orderByPreferenceSlice.name]: slices.orderByPreferenceSlice.reducer, [ofacApi.reducerPath]: ofacApi.reducer, [safePassApi.reducerPath]: safePassApi.reducer, [slices.gatewayApi.reducerPath]: slices.gatewayApi.reducer, @@ -68,6 +70,8 @@ const persistedSlices: (keyof Partial)[] = [ slices.undeployedSafesSlice.name, slices.swapParamsSlice.name, slices.swapOrderSlice.name, + slices.visitedSafesSlice.name, + slices.orderByPreferenceSlice.name, ] export const getPersistedState = () => { diff --git a/src/store/orderByPreferenceSlice.ts b/src/store/orderByPreferenceSlice.ts new file mode 100644 index 0000000000..6894e0e070 --- /dev/null +++ b/src/store/orderByPreferenceSlice.ts @@ -0,0 +1,29 @@ +import type { PayloadAction } from '@reduxjs/toolkit' +import { createSlice } from '@reduxjs/toolkit' +import type { RootState } from '@/store' + +export enum OrderByOption { + NAME = 'name', + LAST_VISITED = 'lastVisited', +} + +export type OrderByPreferenceState = { orderBy: OrderByOption } + +const initialState: OrderByPreferenceState = { orderBy: OrderByOption.LAST_VISITED } + +export const orderByPreferenceSlice = createSlice({ + name: 'orderByPreference', + initialState, + reducers: { + setOrderByPreference: (state, { payload }: PayloadAction<{ orderBy: OrderByOption }>) => { + const { orderBy } = payload + state.orderBy = orderBy + }, + }, +}) + +export const { setOrderByPreference } = orderByPreferenceSlice.actions + +export const selectOrderByPreference = (state: RootState): OrderByPreferenceState => { + return state[orderByPreferenceSlice.name] || initialState +} diff --git a/src/store/slices.ts b/src/store/slices.ts index a36e3c1806..7f4dd6cd21 100644 --- a/src/store/slices.ts +++ b/src/store/slices.ts @@ -21,3 +21,5 @@ export * from '@/features/swap/store/swapParamsSlice' export * from './swapOrderSlice' export * from './api/gateway' export * from './api/gateway/safeOverviews' +export * from './visitedSafesSlice' +export * from './orderByPreferenceSlice' diff --git a/src/store/visitedSafesSlice.ts b/src/store/visitedSafesSlice.ts new file mode 100644 index 0000000000..fc0787d350 --- /dev/null +++ b/src/store/visitedSafesSlice.ts @@ -0,0 +1,34 @@ +import type { PayloadAction } from '@reduxjs/toolkit' +import { createSlice } from '@reduxjs/toolkit' +import type { RootState } from '@/store' + +export type VisitedSafesState = { + [chainId: string]: { + [safeAddress: string]: { + lastVisited: number + } + } +} + +const initialState: VisitedSafesState = {} + +export const visitedSafesSlice = createSlice({ + name: 'visitedSafes', + initialState, + reducers: { + upsertVisitedSafe: ( + state, + { payload }: PayloadAction<{ chainId: string; address: string; lastVisited: number }>, + ) => { + const { chainId, address, lastVisited } = payload + state[chainId] ??= {} + state[chainId][address] = { lastVisited } + }, + }, +}) + +export const { upsertVisitedSafe } = visitedSafesSlice.actions + +export const selectAllVisitedSafes = (state: RootState): VisitedSafesState => { + return state[visitedSafesSlice.name] || initialState +}