diff --git a/components/common/Identicon/index.tsx b/components/common/Identicon/index.tsx index a835513ff6..873d200ca7 100644 --- a/components/common/Identicon/index.tsx +++ b/components/common/Identicon/index.tsx @@ -1,22 +1,28 @@ -import { ReactElement, useMemo } from 'react' +import { ReactElement, useMemo, CSSProperties } from 'react' import makeBlockie from 'ethereum-blockies-base64' +import Skeleton from '@mui/material/Skeleton' + import css from './styles.module.css' -interface IdenticonProps { +export interface IdenticonProps { address: string } const Identicon = ({ address }: IdenticonProps): ReactElement => { - const iconSrc = useMemo(() => { - if (!address) return '' - try { - return makeBlockie(address) - } catch (e) { - return '' + const style = useMemo(() => { + if (!address) { + return null } + + let blockie = '' + try { + blockie = makeBlockie(address) + } catch (e) {} + + return blockie ? { backgroundImage: `url(${blockie})` } : null }, [address]) - return
+ return !style ? :
} export default Identicon diff --git a/components/common/Identicon/styles.module.css b/components/common/Identicon/styles.module.css index 9fc4dfca36..418b113538 100644 --- a/components/common/Identicon/styles.module.css +++ b/components/common/Identicon/styles.module.css @@ -6,6 +6,5 @@ min-width: 20px; min-height: 20px; border-radius: 50%; - background-color: #eee; background-size: cover; } diff --git a/components/common/Navigation/index.tsx b/components/common/Navigation/index.tsx deleted file mode 100644 index 56edec7ce5..0000000000 --- a/components/common/Navigation/index.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import React, { ReactElement, useState } from 'react' -import Link from 'next/link' -import { useRouter } from 'next/router' -import ListItemButton from '@mui/material/ListItemButton' -import ListItemText from '@mui/material/ListItemText' -import Collapse from '@mui/material/Collapse' -import ExpandLess from '@mui/icons-material/ExpandLess' -import ExpandMore from '@mui/icons-material/ExpandMore' -import List from '@mui/material/List' - -type NavItem = { - label: string - href: string - items?: NavItem[] -} - -const navItems: NavItem[] = [ - { - label: 'Home', - href: '/safe', - }, - { - label: 'Assets', - href: '/safe/balances', - items: [ - { - label: 'Coins', - href: '/safe/balances', - }, - { - label: 'NFTs', - href: '/safe/balances/nfts', - }, - ], - }, - { - label: 'Transactions', - href: '/safe/transactions/history', - items: [ - { - label: 'Queue', - href: '/safe/transactions/queue', - }, - { - label: 'History', - href: '/safe/transactions/history', - }, - ], - }, - { - label: 'Address Book', - href: '/safe/address-book', - }, - { - label: 'Apps', - href: '/safe/apps', - }, - { - label: 'Settings', - href: '/safe/settings/details', - items: [ - { - label: 'Safe Details', - href: '/safe/settings/details', - }, - { - label: 'Appearance', - href: '/safe/settings/appearance', - }, - { - label: 'Owners', - href: '/safe/settings/owners', - }, - { - label: 'Policies', - href: '/safe/settings/policies', - }, - { - label: 'Spending Limit', - href: '/safe/settings/spending-limit', - }, - { - label: 'Advanced', - href: '/safe/settings/advanced', - }, - ], - }, -] - -const Navigation = () => { - const [open, setOpen] = useState>({}) - - const toggleOpen = (item: NavItem) => { - setOpen((prev) => ({ [item.href]: !prev[item.href] })) - } - - return ( - - {navItems.map((item) => - item.items ? ( - toggleOpen(item)} /> - ) : ( - - ), - )} - - ) -} - -const SingleLevel = ({ item }: { item: NavItem }): ReactElement => { - const router = useRouter() - const selected = router.pathname === item.href - - return ( - - - {item.label} - - - ) -} - -const MultiLevel = ({ - item, - open, - toggleOpen, -}: { - item: NavItem - open: boolean - toggleOpen: () => void -}): ReactElement => { - const router = useRouter() - - return ( - <> - - - {item.label} - {open ? : } - - - - - {item.items?.map((subItem) => { - const selected = router.pathname === subItem.href - - return ( - - - {subItem.label} - - - ) - })} - - - - ) -} - -export default React.memo(Navigation) diff --git a/components/common/PageLayout/index.tsx b/components/common/PageLayout/index.tsx index f3b4e03b56..40599c0cd4 100644 --- a/components/common/PageLayout/index.tsx +++ b/components/common/PageLayout/index.tsx @@ -1,7 +1,7 @@ import { useState, type ReactElement } from 'react' import { Box, Drawer } from '@mui/material' -import Sidebar from '@/components/common/Sidebar' +import Sidebar from '@/components/sidebar/Sidebar' import Header from '@/components/common//Header' import css from './styles.module.css' diff --git a/components/common/SafeHeader/index.tsx b/components/common/SafeHeader/index.tsx deleted file mode 100644 index e9bfe81f50..0000000000 --- a/components/common/SafeHeader/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { type ReactElement } from 'react' -import { shortenAddress } from '@/services/formatters' -import useSafeInfo from '@/services/useSafeInfo' -import useBalances from '@/services/useBalances' -import FiatValue from '../FiatValue' -import Identicon from '../Identicon' -import css from './styles.module.css' - -const SafeHeader = (): ReactElement => { - const { safe } = useSafeInfo() - const { balances } = useBalances() - - const address = safe?.address.value || '' - const { threshold, owners } = safe || {} - - return ( -
-
- - -
- {threshold || ''}/{owners?.length || ''} -
-
- - {address ? shortenAddress(address) : '...'} - -
- Total value - -
-
- ) -} - -export default SafeHeader diff --git a/components/common/SafeHeader/styles.module.css b/components/common/SafeHeader/styles.module.css deleted file mode 100644 index f62eed3e76..0000000000 --- a/components/common/SafeHeader/styles.module.css +++ /dev/null @@ -1,42 +0,0 @@ -.container { - display: flex; - flex-direction: column; - flex-wrap: wrap; - justify-content: center; - align-items: center; - text-align: center; -} - -.icon { - position: relative; -} - -.threshold { - position: absolute; - left: 0; - top: 0; - z-index: 1; - transform: translateX(-30%) translateY(-30%); - padding: 4px; - border-radius: 100%; - background: #ddd; - font-size: 11px; - min-width: 25px; - min-height: 25px; - text-align: center; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} - -.totalValue { - font-size: 1.2em; - margin-top: 10px; -} - -.totalValue span { - display: block; - margin-bottom: 0.1em; - color: #999; -} diff --git a/components/common/SafeIcon/index.tsx b/components/common/SafeIcon/index.tsx new file mode 100644 index 0000000000..543a4b0ed2 --- /dev/null +++ b/components/common/SafeIcon/index.tsx @@ -0,0 +1,36 @@ +import type { ReactElement } from 'react' +import { Box } from '@mui/material' + +import css from './styles.module.css' +import Identicon, { type IdenticonProps } from '../Identicon' + +interface ThresholdProps { + threshold: number | string + owners: number | string +} +const Threshold = ({ threshold, owners }: ThresholdProps): ReactElement => ( + ({ + background: palette.primaryGreen[200], + // @ts-expect-error type '400' can't be used to index type 'PaletteColor' + color: palette.primary[400], + })} + > + {threshold}/{owners} + +) + +interface SafeIconProps extends IdenticonProps { + threshold?: ThresholdProps['threshold'] + owners?: ThresholdProps['owners'] +} + +const SafeIcon = ({ address, threshold, owners }: SafeIconProps): ReactElement => ( +
+ {threshold && owners && } + +
+) + +export default SafeIcon diff --git a/components/common/SafeIcon/styles.module.css b/components/common/SafeIcon/styles.module.css new file mode 100644 index 0000000000..7d9c023783 --- /dev/null +++ b/components/common/SafeIcon/styles.module.css @@ -0,0 +1,24 @@ +.container { + position: relative; + height: 40px; + width: 40px; +} + +.threshold { + position: absolute; + margin-top: -8px; + margin-left: -8px; + left: 36px; + z-index: 1; + border-radius: 100%; + font-size: 12px; + min-width: 25px; + min-height: 25px; + text-align: center; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + line-height: 16px; + font-weight: 700; +} diff --git a/components/common/SafeList/index.tsx b/components/common/SafeList/index.tsx deleted file mode 100644 index d42bdff46a..0000000000 --- a/components/common/SafeList/index.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import type { ReactElement } from 'react' -import { getOwnedSafes, type OwnedSafes } from '@gnosis.pm/safe-react-gateway-sdk' - -import useAsync from '@/services/useAsync' -import Link from 'next/link' -import chains from '@/config/chains' -import useSafeAddress from '@/services/useSafeAddress' -import { shortenAddress } from '@/services/formatters' -import useWallet from '@/services/wallets/useWallet' -import css from '@/components/common/SafeList/styles.module.css' -import { useAppSelector } from '@/store' -import { selectAddedSafes } from '@/store/addedSafesSlice' -import useAddressBook from '@/services/useAddressBook' -import { useChainId } from '@/services/useChainId' - -const SafesList = ({ safes, chainId, safeAddress }: { safes: string[]; chainId: string; safeAddress: string }) => { - const shortName = Object.keys(chains).find((key) => chains[key] === chainId) - const addressBook = useAddressBook() - - return ( - - ) -} - -const AllSafes = (): ReactElement | null => { - const address = useSafeAddress() - const chainId = useChainId() - const wallet = useWallet() - const walletAddress = wallet?.address - const addedSafes = useAppSelector((state) => selectAddedSafes(state, chainId)) - - const [ownedSafes, error, loading] = useAsync(async () => { - if (!walletAddress || !chainId) return - return getOwnedSafes(chainId, walletAddress) - }, [chainId, walletAddress]) - - return ( -
-

Added Safes

- - - - {wallet && ( - <> -

Owned Safes

- - {loading && 'Loading owned Safes...'} - - {!loading && error && `Error loading owned Safes: ${error.message}`} - - {!loading && !error && ( - - )} - - )} -
- ) -} - -export default AllSafes diff --git a/components/common/SafeList/styles.module.css b/components/common/SafeList/styles.module.css deleted file mode 100644 index 00dee94202..0000000000 --- a/components/common/SafeList/styles.module.css +++ /dev/null @@ -1,20 +0,0 @@ -.container { - text-align: left; - margin-left: 40px; -} - -.ownedSafes { - list-style: none; - margin: 0; - padding: 0; -} - -.ownedSafes li:not(.selected) a { - text-decoration: underline; - cursor: pointer; -} - -.ownedSafes li { - margin: 0 0 0.5em; - padding: 0; -} diff --git a/components/common/NewTxButton/index.tsx b/components/sidebar/NewTxButton/index.tsx similarity index 75% rename from components/common/NewTxButton/index.tsx rename to components/sidebar/NewTxButton/index.tsx index ad9a6c3a0e..6126b04a48 100644 --- a/components/common/NewTxButton/index.tsx +++ b/components/sidebar/NewTxButton/index.tsx @@ -1,10 +1,12 @@ import { useState, type ReactElement } from 'react' -import { Button } from '@mui/material' +import Button from '@mui/material/Button' import useSafeInfo from '@/services/useSafeInfo' import useWallet from '@/services/wallets/useWallet' import TokenTransferModal from '@/components/tx/modals/TokenTransferModal' import { isOwner } from '@/components/transactions/utils' +import css from './styles.module.css' + const NewTxButton = (): ReactElement => { const [txOpen, setTxOpen] = useState(false) const { safe } = useSafeInfo() @@ -14,7 +16,14 @@ const NewTxButton = (): ReactElement => { return ( <> - +
+ {configs.map((chain) => { + const { ownedSafesOnChain, addedSafesOnChain } = _extractSafesByChainId({ + chainId: chain.chainId, + ownedSafes, + addedSafes, + }) + + const isCurrentChain = chain.chainId === chainId + const addedSafeEntriesOnChain = Object.entries(addedSafesOnChain) + + if (!isCurrentChain && !ownedSafesOnChain.length && !addedSafeEntriesOnChain.length) { + return null + } + + const isOpen = + chain.chainId in open + ? open[chain.chainId] + : _shouldExpandSafeList({ + isCurrentChain, + safeAddress, + ownedSafesOnChain, + addedSafesOnChain, + }) + + return ( + +
+ + {chain.chainName} + + + {!addedSafeEntriesOnChain.length && !ownedSafesOnChain.length && ( + ({ color: palette.black[400] })}> + + Create or add + {' '} + an existing Safe on this network + + )} +
+ + + {addedSafeEntriesOnChain.map(([address, { threshold, owners }]) => ( + + ))} + + {ownedSafesOnChain.length > 0 && ( +
toggleOpen(chain.chainId)} className={css.ownedLabel}> + ({ color: palette.black[400] })} + display="inline" + paddingTop={!addedSafeEntriesOnChain.length ? '22px' : undefined} + > + Safes owned on {chain.chainName} ({ownedSafesOnChain.length}) + ({ fill: palette.black[400] })} disableRipple> + {isOpen ? : } + + +
+ )} + {ownedSafesOnChain.length > 0 && ( + + + {ownedSafesOnChain.map((address) => ( + + ))} + + + )} +
+ ) + })} +
+ ) +} + +export default SafeList diff --git a/components/sidebar/SafeList/styles.module.css b/components/sidebar/SafeList/styles.module.css new file mode 100644 index 0000000000..a5e40c6027 --- /dev/null +++ b/components/sidebar/SafeList/styles.module.css @@ -0,0 +1,48 @@ +.container { + overflow-y: auto; +} + +.header { + display: flex; + justify-content: space-between; + padding: 22px 24px 18px; +} + +.addButton { + border-radius: 8px; + font-weight: 700; +} + +.chainDivider { + display: flex; + align-items: center; + justify-content: center; + padding: 0 0; + width: unset; + border-radius: 8px; + text-transform: none; + height: 27px; +} + +.chainHeader { + padding-left: 12px; + padding-right: 12px; +} + +.ownedLabel { + padding-top: 0; + padding-bottom: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.ownedLabel:hover { + background-color: unset; +} + +.list { + overflow-x: hidden; + overflow-y: hidden; + padding: 0; +} diff --git a/components/sidebar/SafeListContextMenu/index.tsx b/components/sidebar/SafeListContextMenu/index.tsx new file mode 100644 index 0000000000..ed6151e24c --- /dev/null +++ b/components/sidebar/SafeListContextMenu/index.tsx @@ -0,0 +1,69 @@ +import { useState, MouseEvent, type ReactElement } from 'react' +import Image from 'next/image' +import ListItemIcon from '@mui/material/ListItemIcon' +import IconButton from '@mui/material/IconButton' +import MoreVertIcon from '@mui/icons-material/MoreVert' +import Menu from '@mui/material/Menu' +import MenuItem from '@mui/material/MenuItem' +import ListItemText from '@mui/material/ListItemText' + +import { useAppDispatch } from '@/store' +import { removeSafe } from '@/store/addedSafesSlice' + +const SafeListContextMenu = ({ chainId, address }: { chainId: string; address: string }): ReactElement => { + const dispatch = useAppDispatch() + const [anchorEl, setAnchorEl] = useState() + + const handleClick = (e: MouseEvent) => { + setAnchorEl(e.currentTarget) + } + + const handleClose = () => { + setAnchorEl(undefined) + } + + const handleRemove = () => { + dispatch(removeSafe({ chainId, address })) + } + return ( + <> + { + e.stopPropagation() + handleClick(e) + }} + > + ({ color: palette.secondaryBlack[300] })} /> + + ({ + '.MuiPaper-root': { borderRadius: '8px !important', width: '138px' }, + '.MuiList-root': { p: '4px' }, + '.MuiMenuItem-root': { + '&:hover': { borderRadius: '8px !important', backgroundColor: palette.gray[300] }, + }, + })} + > + + + Rename + + Rename + + + + Remove + + Remove + + + + ) +} + +export default SafeListContextMenu diff --git a/components/sidebar/SafeListItem/index.tsx b/components/sidebar/SafeListItem/index.tsx new file mode 100644 index 0000000000..6f0c2da6c4 --- /dev/null +++ b/components/sidebar/SafeListItem/index.tsx @@ -0,0 +1,114 @@ +import { useEffect, useRef, type ReactElement } from 'react' +import { useRouter } from 'next/router' +import ListItemButton from '@mui/material/ListItemButton' +import ListItemText from '@mui/material/ListItemText' +import ListItem from '@mui/material/ListItem' +import ListItemIcon from '@mui/material/ListItemIcon' + +import SafeIcon from '@/components/common/SafeIcon' +import { shortenAddress } from '@/services/formatters' +import { useAppSelector } from '@/store' +import useSafeAddress from '@/services/useSafeAddress' +import useAddressBook from '@/services/useAddressBook' +import { selectChainById } from '@/store/chainsSlice' +import SafeListItemSecondaryAction from '@/components/sidebar/SafeListItemSecondaryAction' +import useChainId from '@/services/useChainId' +import { AppRoutes } from '@/config/routes' +import SafeListContextMenu from '@/components/sidebar/SafeListContextMenu' +import Box from '@mui/material/Box' + +const SafeListItem = ({ + address, + chainId, + closeDrawer, + shouldScrollToSafe, + ...rest +}: { + address: string + chainId: string + closeDrawer: () => void + shouldScrollToSafe: boolean + threshold?: string | number + owners?: string | number +}): ReactElement => { + const router = useRouter() + const safeRef = useRef(null) + const safeAddress = useSafeAddress() + const chain = useAppSelector((state) => selectChainById(state, chainId)) + + const currChainId = useChainId() + const isCurrentSafe = currChainId === currChainId && safeAddress.toLowerCase() === address.toLowerCase() + + useEffect(() => { + if (isCurrentSafe && shouldScrollToSafe) { + safeRef.current?.scrollIntoView({ block: 'center' }) + } + }, [isCurrentSafe, shouldScrollToSafe]) + + const addressBook = useAddressBook() + const name = addressBook?.[address] + + const isOpen = address.toLowerCase() === safeAddress.toLowerCase() + + const handleNavigate = (href: string) => { + router.push(href) + closeDrawer() + } + + const handleOpenSafe = () => { + handleNavigate(`${AppRoutes.safe.home}?safe=${chain?.shortName}:${address}`) + } + const handleAddSafe = () => { + handleNavigate(`${AppRoutes.welcome}?chain=${chain?.shortName}`) + } + + const formattedAddress = ( + <> + {chain?.shortName}:{shortenAddress(address)} + + ) + + return ( + + + + + } + sx={{ + margin: '12px 12px', + width: 'unset', + }} + > + ({ + borderRadius: '8px', + // @ts-expect-error type '400' can't be used to index type 'PaletteColor' + borderLeft: isOpen ? `6px solid ${palette.primary[400]}` : undefined, + paddingLeft: '22px', + '&.Mui-selected': { + backgroundColor: palette.gray[300], + paddingLeft: '16px', + }, + })} + ref={safeRef} + > + + + + + + + ) +} + +export default SafeListItem diff --git a/components/sidebar/SafeListItemSecondaryAction/index.tsx b/components/sidebar/SafeListItemSecondaryAction/index.tsx new file mode 100644 index 0000000000..12ee0a5516 --- /dev/null +++ b/components/sidebar/SafeListItemSecondaryAction/index.tsx @@ -0,0 +1,72 @@ +import Image from 'next/image' +import Button from '@mui/material/Button' +import Typography from '@mui/material/Typography' + +import { useAppSelector } from '@/store' +import { selectAddedSafes } from '@/store/addedSafesSlice' +import useWallet from '@/services/wallets/useWallet' +import useChains from '@/services/useChains' + +import css from './styles.module.css' + +const SafeListItemSecondaryAction = ({ + chainId, + address, + handleAddSafe, +}: { + chainId: string + address: string + handleAddSafe: () => void +}) => { + const { configs } = useChains() + const wallet = useWallet() + const addedSafes = useAppSelector((state) => selectAddedSafes(state, chainId)) + const isAdded = !!addedSafes?.[address] + const isOwner = addedSafes?.[address]?.owners.some( + ({ value }) => value.toLowerCase() === wallet?.address?.toLowerCase(), + ) + + if (!isAdded) { + return ( + + ) + } + + if (!isOwner) { + return ( + ({ color: palette.secondaryBlack[300] })}> + Read only Read only + + ) + } + + if (addedSafes?.[address]?.ethBalance) { + const { nativeCurrency } = configs.find((chain) => chain.chainId === chainId) || {} + + return ( + + {addedSafes[address].ethBalance} {nativeCurrency?.symbol || 'ETH'} + + ) + } + + return null +} + +export default SafeListItemSecondaryAction diff --git a/components/sidebar/SafeListItemSecondaryAction/styles.module.css b/components/sidebar/SafeListItemSecondaryAction/styles.module.css new file mode 100644 index 0000000000..0706dba476 --- /dev/null +++ b/components/sidebar/SafeListItemSecondaryAction/styles.module.css @@ -0,0 +1,4 @@ +.addButton { + border-radius: 8px; + padding: 8px 10px; +} diff --git a/components/common/Sidebar/index.tsx b/components/sidebar/Sidebar/index.tsx similarity index 56% rename from components/common/Sidebar/index.tsx rename to components/sidebar/Sidebar/index.tsx index 4e93983d10..09c3591ea0 100644 --- a/components/common/Sidebar/index.tsx +++ b/components/sidebar/Sidebar/index.tsx @@ -1,16 +1,16 @@ import { useState, type ReactElement } from 'react' -import Link from 'next/link' -import { Box, Button, Divider, Drawer, IconButton } from '@mui/material' +import { Box, Divider, Drawer, IconButton } from '@mui/material' import { ChevronRight } from '@mui/icons-material' -import css from './styles.module.css' import useSafeInfo from '@/services/useSafeInfo' -import ChainIndicator from '../ChainIndicator' -import SafeHeader from '../SafeHeader' -import SafeList from '../SafeList' -import NewTxButton from '../NewTxButton' -import Navigation from '@/components/common/Navigation' +import ChainIndicator from '@/components/common/ChainIndicator' +import SidebarHeader from '@/components/sidebar/SidebarHeader' +import SafeList from '@/components/sidebar/SafeList' +import SidebarNavigation from '@/components/sidebar/SidebarNavigation' import useSafeAddress from '@/services/useSafeAddress' +import SidebarFooter from '@/components/sidebar/SidebarFooter' + +import css from './styles.module.css' const Sidebar = (): ReactElement => { const address = useSafeAddress() @@ -21,34 +21,26 @@ const Sidebar = (): ReactElement => { setIsDrawerOpen((prev) => !prev) } - const onDrawerToggleDelayed = () => { - setTimeout(onDrawerToggle, 200) - } - return (
- - + ({ backgroundColor: theme.palette.gray[500] })} + > - {/* For routes with a Safe address */} {address ? ( <> - {!error && } - -
- -
+ {!error && } -
- -
+ {loading && 'Loading Safe info...'} @@ -57,14 +49,15 @@ const Sidebar = (): ReactElement => { ) : (
)} +
- -
- - - + - + + + +
+ setIsDrawerOpen(false)} />
diff --git a/components/common/Sidebar/styles.module.css b/components/sidebar/Sidebar/styles.module.css similarity index 70% rename from components/common/Sidebar/styles.module.css rename to components/sidebar/Sidebar/styles.module.css index 3da82e686b..2e2aac3648 100644 --- a/components/common/Sidebar/styles.module.css +++ b/components/sidebar/Sidebar/styles.module.css @@ -1,38 +1,34 @@ .container, .drawer { - width: 220px; height: 100%; overflow-y: auto; overflow-x: hidden; position: relative; + display: flex; + flex-direction: column; } .container { - padding: 20px 0; + width: 220px; + padding: 20px 0 0; border: none; box-shadow: rgb(40 54 61 / 8%) 1px 2px 12px; } .drawer { + width: 400px; text-align: center; - padding: 74px 20px 20px; + padding: 52px 0 0; } .chain { - margin: -20px 0 20px; - text-align: center; - font-size: 11px; + margin-top: -20px; } .chain > * { padding: 3px; } -.newTxButton { - margin: 20px 0; - text-align: center; -} - .noSafeSidebar { background: url('/keyhole.svg') center 10px no-repeat; min-height: 100px; @@ -44,17 +40,12 @@ right: 0; transform: translateX(50%); top: 40px; - background-color: #f6f7f8; /* @TODO: move to the theme */ } .drawerButton svg { transform: translateX(-25%); } -.menu { - padding: 20px; -} - @media (max-width: 768px) { .container { padding-top: 74px; diff --git a/components/sidebar/SidebarFiat/index.tsx b/components/sidebar/SidebarFiat/index.tsx new file mode 100644 index 0000000000..286463db7f --- /dev/null +++ b/components/sidebar/SidebarFiat/index.tsx @@ -0,0 +1,43 @@ +import { useMemo, type ReactElement } from 'react' + +import useBalances from '@/services/useBalances' +import { Typography } from '@mui/material' +import { useAppSelector } from '@/store' +import { selectCurrency } from '@/store/sessionSlice' +import React from 'react' + +const SidebarFiat = (): ReactElement => { + const currency = useAppSelector(selectCurrency) + const { balances } = useBalances() + + // TODO: Extract when implementing formatter functions + const { wholeNumber, decimalSeparator, decimals } = useMemo(() => { + // Intl.NumberFormat always returns the currency code or symbol so we must manually remove it + const formatter = new Intl.NumberFormat([], { style: 'currency', currency }) + + const parts = formatter.formatToParts(Number(balances.fiatTotal)) + + const wholeNumber = parts.find(({ type }) => type === 'integer')?.value || 0 + const decimalSeparator = parts.find(({ type }) => type === 'decimal')?.value || '' + const decimals = parts.find(({ type }) => type === 'fraction')?.value || '' + + return { wholeNumber, decimalSeparator, decimals } + }, [currency, balances.fiatTotal]) + + return ( + <> + + {wholeNumber} + + ({ color: palette.secondaryBlack[300] })}> + { + // Some currencies don't have decimals + decimalSeparator && decimals ? `${decimalSeparator}${decimals}` : '' + }{' '} + {currency.toUpperCase()} + + + ) +} + +export default React.memo(SidebarFiat) diff --git a/components/sidebar/SidebarFooter/index.tsx b/components/sidebar/SidebarFooter/index.tsx new file mode 100644 index 0000000000..713c8ff62c --- /dev/null +++ b/components/sidebar/SidebarFooter/index.tsx @@ -0,0 +1,44 @@ +import type { ReactElement } from 'react' +import { useRouter } from 'next/router' +import Image from 'next/image' + +import { + SidebarList, + SidebarListItemButton, + SidebarListItemIcon, + SidebarListItemText, +} from '@/components/sidebar/SidebarList' + +const WHATS_NEW_PATH = '' +const HELP_CENTER_PATH = '' + +const SidebarFooter = (): ReactElement => { + const router = useRouter() + + const isSelected = (href: string) => router.pathname === href + + return ( + + + + What's New + + What's new + + + + Help Center + + Help Center + + + ) +} + +export default SidebarFooter diff --git a/components/sidebar/SidebarHeader/index.tsx b/components/sidebar/SidebarHeader/index.tsx new file mode 100644 index 0000000000..86f69a6703 --- /dev/null +++ b/components/sidebar/SidebarHeader/index.tsx @@ -0,0 +1,80 @@ +import type { ReactElement } from 'react' +import Image from 'next/image' +import Typography from '@mui/material/Typography' +import Divider from '@mui/material/Divider' +import IconButton, { IconButtonProps } from '@mui/material/IconButton' + +import { shortenAddress } from '@/services/formatters' +import useSafeInfo from '@/services/useSafeInfo' +import SafeIcon from '@/components/common/SafeIcon' +import NewTxButton from '@/components/sidebar/NewTxButton' +import SidebarFiat from '@/components/sidebar/SidebarFiat' +import useAddressBook from '@/services/useAddressBook' + +import css from './styles.module.css' + +const HeaderIconButton = ({ children }: Omit) => ( + ({ + backgroundColor: palette.gray[300], + '&:hover': { + // @ts-expect-error type '200' can't be used to index type 'PaletteColor' + backgroundColor: palette.primary[200], + }, + })} + > + {children} + +) + +const SafeHeader = (): ReactElement => { + const { safe } = useSafeInfo() + const addressBook = useAddressBook() + + const address = safe?.address.value || '' + const name = addressBook?.[address] + + const { threshold, owners } = safe || {} + + return ( + <> + {name && ( + <> + ({ color: palette.secondaryBlack[300] })}> + Current Safe + + + {name} + + + + )} +
+
+
+ +
+
+ {address ? shortenAddress(address) : '...'} + +
+
+
+ + Address QR Code + + + Copy Address + + + Open Block Explorer + +
+ +
+ + ) +} + +export default SafeHeader diff --git a/components/sidebar/SidebarHeader/styles.module.css b/components/sidebar/SidebarHeader/styles.module.css new file mode 100644 index 0000000000..9e6e7c227c --- /dev/null +++ b/components/sidebar/SidebarHeader/styles.module.css @@ -0,0 +1,27 @@ +.container { + display: grid; + padding: 8px 8px; + gap: 16px; +} + +.safe { + display: flex; + gap: 16px; + text-align: left; + align-items: center; +} + +.icon { + padding-top: 8px; + padding-right: 8px; +} + +.iconButtons { + display: flex; + gap: 8px; +} + +.iconButton { + border-radius: 4px; + padding: 6px; +} diff --git a/components/sidebar/SidebarList/index.tsx b/components/sidebar/SidebarList/index.tsx new file mode 100644 index 0000000000..dc5593a36b --- /dev/null +++ b/components/sidebar/SidebarList/index.tsx @@ -0,0 +1,59 @@ +import type { ReactElement } from 'react' +import List, { type ListProps } from '@mui/material/List' +import ListItemButton, { type ListItemButtonProps } from '@mui/material/ListItemButton' +import ListItemIcon, { type ListItemIconProps } from '@mui/material/ListItemIcon' +import ListItemText, { type ListItemTextProps } from '@mui/material/ListItemText' +import { useRouter } from 'next/router' +import { type LinkProps } from 'next/link' + +import css from './styles.module.css' + +export const SidebarList = ({ children, ...rest }: Omit): ReactElement => ( + + {children} + +) + +export const SidebarListItemButton = ({ + href, + children, + ...rest +}: Omit & { href: LinkProps['href'] }): ReactElement => { + const router = useRouter() + return ( + router.push(href)} + sx={({ palette }) => ({ + borderRadius: '6px', + '&.MuiListItemButton-root:hover, &.MuiListItemButton-root.Mui-selected': { + backgroundColor: `${palette.primaryGreen[200]} !important`, + img: { + filter: rest.selected + ? // #008C73 - palette.primary[400] + 'invert(30%) sepia(41%) saturate(4854%) hue-rotate(155deg) brightness(92%) contrast(102%)' + : undefined, + }, + }, + })} + {...rest} + > + {children} + + ) +} + +export const SidebarListItemIcon = ({ children, ...rest }: Omit): ReactElement => ( + + {children} + +) + +export const SidebarListItemText = ({ + children, + bold = false, + ...rest +}: ListItemTextProps & { bold?: boolean }): ReactElement => ( + + {children} + +) diff --git a/components/sidebar/SidebarList/styles.module.css b/components/sidebar/SidebarList/styles.module.css new file mode 100644 index 0000000000..543cba318e --- /dev/null +++ b/components/sidebar/SidebarList/styles.module.css @@ -0,0 +1,11 @@ +.list { + display: grid; + gap: 4px; + padding-left: 8px; + padding-right: 8px; +} + +.icon { + min-width: unset; + margin-right: 14px; +} diff --git a/components/sidebar/SidebarNavigation/index.tsx b/components/sidebar/SidebarNavigation/index.tsx new file mode 100644 index 0000000000..6cdd73ef25 --- /dev/null +++ b/components/sidebar/SidebarNavigation/index.tsx @@ -0,0 +1,205 @@ +import React, { Fragment, useState, type ReactElement } from 'react' +import Image from 'next/image' +import { useRouter } from 'next/router' +import ListItemButton from '@mui/material/ListItemButton' +import Collapse from '@mui/material/Collapse' +import ExpandLess from '@mui/icons-material/ExpandLess' +import ExpandMore from '@mui/icons-material/ExpandMore' +import List from '@mui/material/List' + +import { + SidebarList, + SidebarListItemButton, + SidebarListItemIcon, + SidebarListItemText, +} from '@/components/sidebar/SidebarList' + +import css from './styles.module.css' +import { AppRoutes } from '@/config/routes' + +type NavItem = { + label: string + icon?: string + href: string + items?: NavItem[] +} + +const navItems: NavItem[] = [ + { + label: 'Home', + icon: '/images/sidebar/home.svg', + href: AppRoutes.safe.home, + }, + { + label: 'Assets', + icon: '/images/sidebar/assets.svg', + href: AppRoutes.safe.balances.index, + items: [ + { + label: 'Coins', + href: AppRoutes.safe.balances.index, + }, + { + label: 'NFTs', + href: AppRoutes.safe.balances.nfts, + }, + ], + }, + { + label: 'Transactions', + icon: '/images/sidebar/transactions.svg', + href: AppRoutes.safe.transactions.index, + items: [ + { + label: 'Queue', + href: AppRoutes.safe.transactions.queue, + }, + { + label: 'History', + href: AppRoutes.safe.transactions.history, + }, + ], + }, + { + label: 'Address Book', + icon: '/images/sidebar/address-book.svg', + href: AppRoutes.safe.addressBook, + }, + { + label: 'Apps', + icon: '/images/sidebar/apps.svg', + href: AppRoutes.safe.apps, + }, + { + label: 'Settings', + icon: '/images/sidebar/settings.svg', + href: AppRoutes.safe.settings.details, + items: [ + { + label: 'Safe Details', + href: AppRoutes.safe.settings.details, + }, + { + label: 'Appearance', + href: AppRoutes.safe.settings.appearance, + }, + { + label: 'Owners', + href: AppRoutes.safe.settings.owners, + }, + { + label: 'Policies', + href: AppRoutes.safe.settings.policies, + }, + { + label: 'Spending Limit', + href: AppRoutes.safe.settings.spendingLimit, + }, + { + label: 'Advanced', + href: AppRoutes.safe.settings.advanced, + }, + ], + }, +] + +const Navigation = (): ReactElement => { + const router = useRouter() + const [open, setOpen] = useState>({}) + + const toggleOpen = ({ href }: NavItem) => { + setOpen((prev) => ({ [href]: !prev[href] })) + } + + const isSelected = (href: string) => router.pathname === href + + return ( + + {navItems.map((item) => { + if (!item.items) { + return ( + + {item.icon && ( + + {item.label} + + )} + {item.label} + + ) + } + + const isExpanded = open[item.href] || router.pathname.includes(item.href) + + return ( + + toggleOpen(item)} + selected={isExpanded} + href={{ pathname: item.href, query: router.query }} + > + {item.icon && ( + + {item.label} + + )} + {item.label} + {isExpanded ? : } + + + ({ + borderLeft: `solid 1px ${palette.gray[500]}`, + '::after': { + content: '""', + height: '23px', + width: '1px', + position: 'absolute', + bottom: 0, + left: '-1px', + backgroundColor: 'background.paper', // Cannot move to CSS module + }, + })} + > + {item.items.map((subItem) => ( + { + toggleOpen(subItem) + router.push({ pathname: subItem.href, query: router.query }) + }} + selected={isSelected(subItem.href)} + sx={({ palette }) => ({ + '::before': { + content: '""', + width: '6px', + height: '1px', + background: palette.gray[500], + position: 'absolute', + left: '-10px', + }, + borderRadius: '6px', + '&.MuiListItemButton-root:hover, &.MuiListItemButton-root.Mui-selected': { + backgroundColor: `${palette.gray[300]} !important`, + }, + })} + > + {subItem.label} + + ))} + + + + ) + })} + + ) +} + +export default React.memo(Navigation) diff --git a/components/sidebar/SidebarNavigation/styles.module.css b/components/sidebar/SidebarNavigation/styles.module.css new file mode 100644 index 0000000000..f0d9913655 --- /dev/null +++ b/components/sidebar/SidebarNavigation/styles.module.css @@ -0,0 +1,7 @@ +.sublist { + display: grid; + gap: 4px; + padding-left: 0; + margin-left: 10px; + padding: 0 0 0 10px; +} diff --git a/jest.setup.js b/jest.setup.js index 9449064441..400d9af0fb 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -5,6 +5,17 @@ // Learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom/extend-expect' +jest.mock('@web3-onboard/coinbase', () => jest.fn()) +jest.mock('@web3-onboard/fortmatic', () => jest.fn()) +jest.mock('@web3-onboard/injected-wallets', () => jest.fn()) +jest.mock('@web3-onboard/keepkey', () => jest.fn()) +// jest.mock('@web3-onboard/keystone', () => jest.fn()) +jest.mock('@web3-onboard/ledger', () => jest.fn()) +jest.mock('@web3-onboard/portis', () => jest.fn()) +jest.mock('@web3-onboard/torus', () => jest.fn()) +jest.mock('@web3-onboard/trezor', () => jest.fn()) +jest.mock('@web3-onboard/walletconnect', () => jest.fn()) + const mockOnboardState = { chains: [], walletModules: [], diff --git a/pages/_document.tsx b/pages/_document.tsx index ca415278dc..5bec3a70f7 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -1,8 +1,8 @@ import * as React from 'react' import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document' import createEmotionServer from '@emotion/server/create-instance' -import { safeTheme } from '@gnosis.pm/safe-react-components' import createEmotionCache from '@/services/createEmotionCache' +import theme from '@/styles/theme' class SafeWebCoreDocument extends Document { static async getInitialProps(ctx: DocumentContext) { @@ -42,7 +42,7 @@ class SafeWebCoreDocument extends Document { {/* PWA primary color */} - + {/* @ts-ignore */} {this.props.emotionStyleTags} diff --git a/public/images/sidebar/address-book.svg b/public/images/sidebar/address-book.svg new file mode 100644 index 0000000000..2a5d306cb6 --- /dev/null +++ b/public/images/sidebar/address-book.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/sidebar/apps.svg b/public/images/sidebar/apps.svg new file mode 100644 index 0000000000..e90e62e194 --- /dev/null +++ b/public/images/sidebar/apps.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/sidebar/assets.svg b/public/images/sidebar/assets.svg new file mode 100644 index 0000000000..3ee6150830 --- /dev/null +++ b/public/images/sidebar/assets.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/sidebar/block-explorer.svg b/public/images/sidebar/block-explorer.svg new file mode 100644 index 0000000000..af4c6486f3 --- /dev/null +++ b/public/images/sidebar/block-explorer.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/sidebar/copy.svg b/public/images/sidebar/copy.svg new file mode 100644 index 0000000000..ecb7f8fe4f --- /dev/null +++ b/public/images/sidebar/copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/sidebar/help-center.svg b/public/images/sidebar/help-center.svg new file mode 100644 index 0000000000..1e9c3f5ca7 --- /dev/null +++ b/public/images/sidebar/help-center.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/sidebar/home.svg b/public/images/sidebar/home.svg new file mode 100644 index 0000000000..d6f1aa8ee6 --- /dev/null +++ b/public/images/sidebar/home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/sidebar/qr.svg b/public/images/sidebar/qr.svg new file mode 100644 index 0000000000..2c1f065aee --- /dev/null +++ b/public/images/sidebar/qr.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/sidebar/safe-list/eye.svg b/public/images/sidebar/safe-list/eye.svg new file mode 100644 index 0000000000..ef81dcab54 --- /dev/null +++ b/public/images/sidebar/safe-list/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/sidebar/safe-list/pencil.svg b/public/images/sidebar/safe-list/pencil.svg new file mode 100644 index 0000000000..70bb733cbe --- /dev/null +++ b/public/images/sidebar/safe-list/pencil.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/sidebar/safe-list/trash.svg b/public/images/sidebar/safe-list/trash.svg new file mode 100644 index 0000000000..d6d6495b59 --- /dev/null +++ b/public/images/sidebar/safe-list/trash.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/sidebar/settings.svg b/public/images/sidebar/settings.svg new file mode 100644 index 0000000000..dfa673dd1c --- /dev/null +++ b/public/images/sidebar/settings.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/sidebar/transactions.svg b/public/images/sidebar/transactions.svg new file mode 100644 index 0000000000..b3bd01b503 --- /dev/null +++ b/public/images/sidebar/transactions.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/sidebar/whats-new.svg b/public/images/sidebar/whats-new.svg new file mode 100644 index 0000000000..1016d171cd --- /dev/null +++ b/public/images/sidebar/whats-new.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/services/localStorage/useLocalStorage.ts b/services/localStorage/useLocalStorage.ts index 6da4744c86..139fcc87f5 100644 --- a/services/localStorage/useLocalStorage.ts +++ b/services/localStorage/useLocalStorage.ts @@ -1,19 +1,16 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useCallback, Dispatch, SetStateAction } from 'react' import local from './local' -const useLocalStorage = (key: string): [T | undefined, (val: T | undefined) => void] => { - const [cache, setCache] = useState() - - useEffect(() => { - const saved = local.getItem(key) - setCache(saved) - }, [key]) +const useLocalStorage = (key: string, initialState: T): [T, Dispatch>] => { + const [cache, setCache] = useState(local.getItem(key) ?? initialState) const setNewValue = useCallback( - (newVal: T | undefined) => { + (value: T | ((prevState: T) => T)) => { + const newVal = value instanceof Function ? value(cache) : value setCache(newVal) - local.setItem(key, newVal) + local.setItem(key, newVal) }, + // eslint-disable-next-line react-hooks/exhaustive-deps [setCache, key], ) diff --git a/services/useOwnedSafes.ts b/services/useOwnedSafes.ts new file mode 100644 index 0000000000..77496a143b --- /dev/null +++ b/services/useOwnedSafes.ts @@ -0,0 +1,47 @@ +import { useEffect } from 'react' +import { getOwnedSafes, type OwnedSafes } from '@gnosis.pm/safe-react-gateway-sdk' + +import useChainId from '@/services/useChainId' +import useLocalStorage from '@/services/localStorage/useLocalStorage' +import useWallet from '@/services/wallets/useWallet' +import useAsync from './useAsync' + +const CACHE_KEY = 'ownedSafes' + +type OwnedSafesCache = { + [walletAddress: string]: { + [chainId: string]: OwnedSafes['safes'] + } +} + +const useOwnedSafes = (): OwnedSafesCache['walletAddress'] => { + const chainId = useChainId() + const wallet = useWallet() + const walletAddress = wallet?.address + + const [ownedSafes] = useAsync(async () => { + return !chainId || !walletAddress ? undefined : getOwnedSafes(chainId, walletAddress) + }, [chainId, walletAddress]) + + const [ownedSafesCache, setOwnedSafesCache] = useLocalStorage(CACHE_KEY, {}) + + useEffect(() => { + if (!ownedSafes?.safes || !walletAddress || !chainId) { + return + } + + setOwnedSafesCache((prev) => ({ + ...prev, + [walletAddress]: { + ...(prev[walletAddress] || {}), + [chainId]: ownedSafes.safes, + }, + })) + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ownedSafes]) + + return walletAddress ? ownedSafesCache[walletAddress] ?? {} : {} +} + +export default useOwnedSafes diff --git a/services/wallets/wallets.ts b/services/wallets/wallets.ts index 778e744cc4..ca0c9c7a7d 100644 --- a/services/wallets/wallets.ts +++ b/services/wallets/wallets.ts @@ -5,7 +5,8 @@ import coinbaseModule from '@web3-onboard/coinbase' import fortmaticModule from '@web3-onboard/fortmatic' import injectedWalletModule from '@web3-onboard/injected-wallets' import keepkeyModule from '@web3-onboard/keepkey' -import keystoneModule from '@web3-onboard/keystone' +// TODO: Breaking tests. Jest cannot find module when trying to mock +// import keystoneModule from '@web3-onboard/keystone' import ledgerModule from '@web3-onboard/ledger' import portisModule from '@web3-onboard/portis' import torusModule from '@web3-onboard/torus' @@ -17,7 +18,7 @@ const enum WALLET_KEYS { FORTMATIC = 'FORTMATIC', INJECTED = 'INJECTED', KEEPKEY = 'KEEPKEY', - KEYSTONE = 'KEYSTONE', + // KEYSTONE = 'KEYSTONE', LEDGER = 'LEDGER', // MAGIC = 'MAGIC', // Magic requires an API key PORTIS = 'PORTIS', @@ -31,7 +32,7 @@ const CGW_NAMES: { [key in WALLET_KEYS]: string | undefined } = { [WALLET_KEYS.FORTMATIC]: 'fortmatic', [WALLET_KEYS.INJECTED]: 'detectedwallet', [WALLET_KEYS.KEEPKEY]: undefined, - [WALLET_KEYS.KEYSTONE]: 'keystone', + // [WALLET_KEYS.KEYSTONE]: 'keystone', [WALLET_KEYS.LEDGER]: 'ledger', [WALLET_KEYS.PORTIS]: 'portis', [WALLET_KEYS.TORUS]: 'torus', @@ -45,7 +46,7 @@ const WALLET_MODULES: { [key in WALLET_KEYS]: () => WalletInit } = { [WALLET_KEYS.FORTMATIC]: () => fortmaticModule({ apiKey: FORTMATIC_KEY }), [WALLET_KEYS.INJECTED]: injectedWalletModule, [WALLET_KEYS.KEEPKEY]: keepkeyModule, - [WALLET_KEYS.KEYSTONE]: keystoneModule, + // [WALLET_KEYS.KEYSTONE]: keystoneModule, [WALLET_KEYS.LEDGER]: ledgerModule, [WALLET_KEYS.PORTIS]: () => portisModule({ apiKey: PORTIS_KEY }), [WALLET_KEYS.TORUS]: torusModule, diff --git a/store/__tests__/addedSafesSlice.test.ts b/store/__tests__/addedSafesSlice.test.ts index 75bec17582..59ae50ae39 100644 --- a/store/__tests__/addedSafesSlice.test.ts +++ b/store/__tests__/addedSafesSlice.test.ts @@ -1,19 +1,131 @@ -import { addSafe, removeSafe, addedSafesSlice } from '../addedSafesSlice' +import { SafeBalanceResponse, SafeInfo, TokenType } from '@gnosis.pm/safe-react-gateway-sdk' +import { + addOrUpdateSafe, + removeSafe, + addedSafesSlice, + AddedSafesState, + updateAddedSafeBalance, +} from '../addedSafesSlice' describe('addedSafesSlice', () => { it('should add a Safe to the store', () => { - const state = addedSafesSlice.reducer(undefined, addSafe({ chainId: '1', address: '0x0' })) - expect(state).toEqual({ '1': ['0x0'] }) + const safe0 = { chainId: '1', address: { value: '0x0' }, threshold: 1, owners: [{ value: '0x123' }] } as SafeInfo + const state = addedSafesSlice.reducer(undefined, addOrUpdateSafe({ safe: safe0 })) + expect(state).toEqual({ + '1': { ['0x0']: { owners: [{ value: '0x123' }], threshold: 1 } }, + }) - const stateB = addedSafesSlice.reducer(state, addSafe({ chainId: '4', address: '0x1' })) - expect(stateB).toEqual({ '1': ['0x0'], '4': ['0x1'] }) + const safe1 = { chainId: '4', address: { value: '0x1' }, threshold: 1, owners: [{ value: '0x456' }] } as SafeInfo + const stateB = addedSafesSlice.reducer(state, addOrUpdateSafe({ safe: safe1 })) + expect(stateB).toEqual({ + '1': { ['0x0']: { owners: [{ value: '0x123' }], threshold: 1 } }, + '4': { ['0x1']: { threshold: 1, owners: [{ value: '0x456' }] } }, + }) - const stateC = addedSafesSlice.reducer(stateB, addSafe({ chainId: '1', address: '0x2' })) - expect(stateC).toEqual({ '1': ['0x0', '0x2'], '4': ['0x1'] }) + const safe2 = { chainId: '1', address: { value: '0x2' }, threshold: 1, owners: [{ value: '0x789' }] } as SafeInfo + const stateC = addedSafesSlice.reducer(stateB, addOrUpdateSafe({ safe: safe2 })) + expect(stateC).toEqual({ + '1': { + ['0x0']: { owners: [{ value: '0x123' }], threshold: 1 }, + ['0x2']: { owners: [{ value: '0x789' }], threshold: 1 }, + }, + '4': { ['0x1']: { threshold: 1, owners: [{ value: '0x456' }] } }, + }) + }) + + it('should add the Safe balance to the store', () => { + const balances: SafeBalanceResponse = { + fiatTotal: '', + items: [ + { + tokenInfo: { + type: 'NATIVE_TOKEN' as TokenType, + address: '', + decimals: 18, + symbol: '', + name: '', + logoUri: null, + }, + balance: '8000000000000000000', + fiatBalance: '', + fiatConversion: '', + }, + { + tokenInfo: { + type: 'ERC20' as TokenType, + address: '', + decimals: 18, + symbol: '', + name: '', + logoUri: null, + }, + balance: '9000000000000000000', + fiatBalance: '', + fiatConversion: '', + }, + ], + } + const state: AddedSafesState = { + '4': { ['0x1']: { threshold: 1, owners: [{ value: '0x456', name: null, logoUri: null }] } }, + } + + const result = addedSafesSlice.reducer(state, updateAddedSafeBalance({ chainId: '4', address: '0x1', balances })) + expect(result).toEqual({ + '4': { ['0x1']: { threshold: 1, owners: [{ value: '0x456', name: null, logoUri: null }], ethBalance: '8' } }, + }) + }) + + it("shouldn't add the balance if the Safe isn't added", () => { + const balances: SafeBalanceResponse = { + fiatTotal: '', + items: [ + { + tokenInfo: { + type: 'NATIVE_TOKEN' as TokenType, + address: '', + decimals: 18, + symbol: '', + name: '', + logoUri: null, + }, + balance: '123', + fiatBalance: '', + fiatConversion: '', + }, + { + tokenInfo: { + type: 'ERC20' as TokenType, + address: '', + decimals: 18, + symbol: '', + name: '', + logoUri: null, + }, + balance: '456', + fiatBalance: '', + fiatConversion: '', + }, + ], + } + const state: AddedSafesState = {} + + const result = addedSafesSlice.reducer(state, updateAddedSafeBalance({ chainId: '4', address: '0x1', balances })) + expect(result).toStrictEqual({}) }) it('should remove a Safe from the store', () => { - const state = addedSafesSlice.reducer({ '1': ['0x0'], '4': ['0x0'] }, removeSafe({ chainId: '1', address: '0x0' })) - expect(state).toEqual({ '4': ['0x0'], '1': [] }) + const state = addedSafesSlice.reducer( + { '1': { ['0x0']: {} as SafeInfo, ['0x1']: {} as SafeInfo }, '4': { ['0x0']: {} as SafeInfo } }, + removeSafe({ chainId: '1', address: '0x1' }), + ) + expect(state).toEqual({ '1': { ['0x0']: {} as SafeInfo }, '4': { ['0x0']: {} as SafeInfo } }) + }) + + it('should remove the chain from the store', () => { + const state = addedSafesSlice.reducer( + { '1': { ['0x0']: {} as SafeInfo }, '4': { ['0x0']: {} as SafeInfo } }, + removeSafe({ chainId: '1', address: '0x0' }), + ) + expect(state).toEqual({ '4': { ['0x0']: {} as SafeInfo } }) }) }) diff --git a/store/addedSafesSlice.ts b/store/addedSafesSlice.ts index 9a0641d518..1bab337a2c 100644 --- a/store/addedSafesSlice.ts +++ b/store/addedSafesSlice.ts @@ -1,35 +1,117 @@ -import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' +import { createSelector, createSlice, Middleware, type PayloadAction } from '@reduxjs/toolkit' +import { AddressEx, SafeBalanceResponse, SafeInfo, TokenType } from '@gnosis.pm/safe-react-gateway-sdk' import type { RootState } from '.' +import { selectSafeInfo, setSafeInfo, type SetSafeInfoPayload } from '@/store/safeInfoSlice' +import { setBalances } from './balancesSlice' +import { formatDecimals } from '@/services/formatters' -type AddedSafesState = { - [chainId: string]: string[] +export type AddedSafesOnChain = { + [safeAddress: string]: { + owners: AddressEx[] + threshold: number + ethBalance?: string + } +} + +export type AddedSafesState = { + [chainId: string]: AddedSafesOnChain } const initialState: AddedSafesState = {} +const isAddedSafe = (state: AddedSafesState, chainId: string, safeAddress: string) => { + return !!state[chainId]?.[safeAddress] +} + export const addedSafesSlice = createSlice({ name: 'addedSafes', initialState, reducers: { - addSafe: (state, { payload }: PayloadAction<{ chainId: string; address: string }>) => { - state[payload.chainId] ??= [] - state[payload.chainId].push(payload.address) + addOrUpdateSafe: (state, { payload }: PayloadAction<{ safe: SafeInfo }>) => { + const { chainId, address, owners, threshold } = payload.safe + + state[chainId] ??= {} + state[chainId][address.value] = { owners, threshold } + }, + updateAddedSafeBalance: ( + state, + { payload }: PayloadAction<{ chainId: string; address: string; balances: SafeBalanceResponse }>, + ) => { + const { chainId, address, balances } = payload + + if (!isAddedSafe(state, chainId, address)) { + return + } + + for (const item of balances.items) { + if (item.tokenInfo.type !== TokenType.NATIVE_TOKEN) { + continue + } + + state[chainId][address].ethBalance = formatDecimals(item.balance, item.tokenInfo.decimals) + + return + } }, removeSafe: (state, { payload }: PayloadAction<{ chainId: string; address: string }>) => { - state[payload.chainId] = (state[payload.chainId] || []).filter((address) => address !== payload.address) + const { chainId, address } = payload + + delete state[chainId]?.[address] + + if (Object.keys(state[chainId]).length === 0) { + delete state[chainId] + } }, }, + extraReducers(builder) { + builder.addCase(setSafeInfo.type, (state, { payload }: SetSafeInfoPayload) => { + if (!payload.safe) { + return + } + + const { chainId, address } = payload.safe + + if (isAddedSafe(state, chainId, address.value)) { + addedSafesSlice.caseReducers.addOrUpdateSafe(state, { + type: addOrUpdateSafe.type, + payload: { safe: payload.safe }, + }) + } + }) + }, }) -export const { addSafe, removeSafe } = addedSafesSlice.actions +export const { addOrUpdateSafe, updateAddedSafeBalance, removeSafe } = addedSafesSlice.actions -const selectAllAddedSafes = (state: RootState): AddedSafesState => { +export const selectAllAddedSafes = (state: RootState): AddedSafesState => { return state[addedSafesSlice.name] } export const selectAddedSafes = createSelector( [selectAllAddedSafes, (_: RootState, chainId: string) => chainId], - (allAddedSafes, chainId): string[] => { - return allAddedSafes[chainId] || [] + (allAddedSafes, chainId) => { + return allAddedSafes[chainId] }, ) + +export const addedSafesMiddleware: Middleware<{}, RootState> = (store) => (next) => (action) => { + const result = next(action) + + switch (action.type) { + case setBalances.type: { + const state = store.getState() + const { safe } = selectSafeInfo(state) + + const chainId = safe?.chainId + const address = safe?.address.value + + if (!chainId || !address) { + break + } + + store.dispatch(updateAddedSafeBalance({ chainId, address, balances: action.payload.balances })) + } + } + + return result +} diff --git a/store/index.ts b/store/index.ts index db1e88456d..6e958b9a2d 100644 --- a/store/index.ts +++ b/store/index.ts @@ -17,7 +17,7 @@ import { addressBookSlice } from './addressBookSlice' import { notificationsSlice } from './notificationsSlice' import { getPreloadedState, persistState } from './persistStore' import { pendingTxsSlice } from './pendingTxsSlice' -import { addedSafesSlice } from './addedSafesSlice' +import { addedSafesMiddleware, addedSafesSlice } from './addedSafesSlice' const rootReducer = combineReducers({ [chainsSlice.name]: chainsSlice.reducer, @@ -40,7 +40,7 @@ const persistedSlices: (keyof PreloadedState)[] = [ addedSafesSlice.name, ] -const middleware = [persistState(persistedSlices), txHistoryMiddleware] +const middleware = [persistState(persistedSlices), txHistoryMiddleware, addedSafesMiddleware] export const store = configureStore({ reducer: rootReducer, diff --git a/store/safeInfoSlice.ts b/store/safeInfoSlice.ts index 211c56299e..d03083e512 100644 --- a/store/safeInfoSlice.ts +++ b/store/safeInfoSlice.ts @@ -13,11 +13,13 @@ const initialState: SafeInfoState = { safe: undefined, } +export type SetSafeInfoPayload = PayloadAction + export const safeInfoSlice = createSlice({ name: 'safeInfo', initialState, reducers: { - setSafeInfo: (state, action: PayloadAction) => { + setSafeInfo: (_, action: SetSafeInfoPayload) => { return action.payload }, }, diff --git a/styles/colors.ts b/styles/colors.ts index 85383839c6..6d7f1f70ff 100644 --- a/styles/colors.ts +++ b/styles/colors.ts @@ -53,3 +53,7 @@ export const secondaryBlack = { export const primaryBlack = { 500: '#162D45', } + +export const primaryGreen = { + 200: '#EFFAF8', +} diff --git a/styles/globals.css b/styles/globals.css index 97e2d0d04e..7378a40fcc 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -17,8 +17,6 @@ body { padding: 0; margin: 0; font-family: Averta, sans-serif; - background-color: #F6F7F8 !important; /* @TODO: move to the theme */ - color: #001428; } a { @@ -29,3 +27,7 @@ a { * { box-sizing: border-box; } + +:root { + --onboard-modal-z-index: 1201; +} diff --git a/styles/theme.ts b/styles/theme.ts index 66f903c318..c28be91627 100644 --- a/styles/theme.ts +++ b/styles/theme.ts @@ -1,6 +1,18 @@ import { Color, createTheme } from '@mui/material' -import { black, error, gray, green, orange, primary, primaryBlack, red, secondaryBlack, warning } from '@/styles/colors' +import { + black, + error, + gray, + green, + orange, + primary, + primaryBlack, + primaryGreen, + red, + secondaryBlack, + warning, +} from '@/styles/colors' interface ThemeColors { black: Pick @@ -11,6 +23,7 @@ interface ThemeColors { // Not listed in colour scheme but present in wireframes secondaryBlack: Pick primaryBlack: Pick + primaryGreen: Pick } declare module '@mui/material/styles' { @@ -73,11 +86,11 @@ const theme = createTheme({ primaryBlack: { 500: primaryBlack[500], }, + primaryGreen: { + 200: primaryGreen[200], + }, }, typography: { - allVariants: { - color: primaryBlack[500], - }, fontFamily: [ 'Averta', 'Roboto', @@ -93,8 +106,65 @@ const theme = createTheme({ 'BlinkMacSystemFont', 'sans-serif', ].join(','), - button: { - textTransform: 'none', + allVariants: { + color: primaryBlack[500], + }, + h1: { + fontSize: '32px', + lineHeight: '36px', + fontWeight: 700, + }, + h2: { + fontSize: '27px', + lineHeight: '34px', + fontWeight: 700, + }, + h3: { + fontSize: '24px', + lineHeight: '30px', + }, + h4: { + fontSize: '20px', + lineHeight: '26px', + }, + body1: { + fontSize: '16px', + lineHeight: '22px', + }, + body2: { + fontSize: '14px', + lineHeight: '20px', + }, + caption: { + fontSize: '12px', + lineHeight: '16px', + }, + overline: { + fontSize: '11px', + lineHeight: '14px', + textTransform: 'uppercase', + }, + }, + components: { + MuiButton: { + styleOverrides: { + root: ({ theme }) => ({ + borderRadius: '8px', + // @ts-expect-error type '400' can't be used to index type 'PaletteColor' + borderColor: theme.palette.primary[400], + textTransform: 'none', + '&.Mui-disabled': { + color: '#fff', + backgroundColor: theme.palette.secondaryBlack[300], + }, + }), + outlined: { + border: '2px solid', + '&:hover': { + border: '2px solid', + }, + }, + }, }, }, })