Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Nested Safes #4932

Open
wants to merge 14 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/public/images/sidebar/nested-safes-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions apps/web/public/images/sidebar/nested-safes.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 10 additions & 2 deletions apps/web/src/components/common/ModalDialog/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { type ReactElement, type ReactNode } from 'react'
import { IconButton, type ModalProps } from '@mui/material'
import { Dialog, DialogTitle, type DialogProps, useMediaQuery } from '@mui/material'
import {
Dialog,
DialogTitle,
type DialogProps,
type DialogTitleProps as MuiDialogTitleProps,
useMediaQuery,
} from '@mui/material'
import { useTheme } from '@mui/material/styles'
import ChainIndicator from '@/components/common/ChainIndicator'
import CloseIcon from '@mui/icons-material/Close'
Expand All @@ -18,19 +24,21 @@ interface DialogTitleProps {
onClose?: ModalProps['onClose']
hideChainIndicator?: boolean
chainId?: string
sx?: MuiDialogTitleProps['sx']
}

export const ModalDialogTitle = ({
children,
onClose,
hideChainIndicator = false,
chainId,
sx = {},
...other
}: DialogTitleProps) => {
return (
<DialogTitle
data-testid="modal-title"
sx={{ m: 0, px: 3, pt: 3, pb: 2, display: 'flex', alignItems: 'center', fontWeight: 'bold' }}
sx={{ m: 0, px: 3, pt: 3, pb: 2, display: 'flex', alignItems: 'center', fontWeight: 'bold', ...sx }}
{...other}
>
{children}
Expand Down
59 changes: 59 additions & 0 deletions apps/web/src/components/common/NestedSafeBreadcrumbs/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useRouter } from 'next/router'
import Link from 'next/link'
import { Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'
import type { ReactElement } from 'react'
import type { UrlObject } from 'url'

import useSafeInfo from '@/hooks/useSafeInfo'
import useAddressBook from '@/hooks/useAddressBook'
import Identicon from '../Identicon'
import { shortenAddress } from '@/utils/formatters'

import css from './styles.module.css'
import { useParentSafe } from '@/hooks/useParentSafe'

export function NestedSafeBreadcrumbs(): ReactElement | null {
const { pathname } = useRouter()
const { safeAddress } = useSafeInfo()
const parentSafe = useParentSafe()

if (!parentSafe) {
return null
}

return (
<div className={css.container}>
<BreadcrumbItem
title="Parent Safe"
address={parentSafe.address.value}
href={{ pathname, query: { safe: parentSafe.address.value } }}
/>
<Typography variant="body2">/</Typography>
<BreadcrumbItem title="Nested Safe" address={safeAddress} />
</div>
)
}

const BreadcrumbItem = ({ title, address, href }: { title: string; address: string; href?: UrlObject }) => {
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
const addressBook = useAddressBook()
const name = addressBook[address] ?? (isMobile ? shortenAddress(address) : address)

return (
<Tooltip title={title}>
<div className={css.breadcrumb}>
<Identicon address={address} size={20} />
{href ? (
<Link href={href}>
<Typography variant="body2" color="text.secondary">
{name}
</Typography>
</Link>
) : (
<Typography variant="body2">{name}</Typography>
)}
</div>
</Tooltip>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.container {
height: 36px;
display: flex;
align-items: center;
background-color: var(--color-background-paper);
border-bottom: 1px solid var(--color-border-light);
padding: var(--space-1) var(--space-3);
gap: var(--space-1);
}

.breadcrumb {
display: flex;
align-items: center;
gap: calc(var(--space-1) / 2);
}
6 changes: 5 additions & 1 deletion apps/web/src/components/common/PageLayout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import SideDrawer from './SideDrawer'
import { useIsSidebarRoute } from '@/hooks/useIsSidebarRoute'
import { TxModalContext } from '@/components/tx-flow'
import BatchSidebar from '@/components/batch/BatchSidebar'
import { NestedSafeBreadcrumbs } from '../NestedSafeBreadcrumbs'

const PageLayout = ({ pathname, children }: { pathname: string; children: ReactElement }): ReactElement => {
const [isSidebarRoute, isAnimated] = useIsSidebarRoute(pathname)
Expand All @@ -35,7 +36,10 @@ const PageLayout = ({ pathname, children }: { pathname: string; children: ReactE
})}
>
<div className={css.content}>
<SafeLoadingError>{children}</SafeLoadingError>
<SafeLoadingError>
<NestedSafeBreadcrumbs />
{children}
</SafeLoadingError>
</div>

<BatchSidebar isOpen={isBatchOpen} onToggle={setBatchOpen} />
Expand Down
131 changes: 131 additions & 0 deletions apps/web/src/components/settings/NestedSafesList/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { Paper, Grid2, Typography, Button, SvgIcon, Tooltip, IconButton } from '@mui/material'
import { skipToken } from '@reduxjs/toolkit/query'
import { useContext, useMemo, useState } from 'react'
import type { ReactElement } from 'react'

import AddIcon from '@/public/images/common/add.svg'
import EditIcon from '@/public/images/common/edit.svg'
import CheckWallet from '@/components/common/CheckWallet'
import EthHashInfo from '@/components/common/EthHashInfo'
import ExternalLink from '@/components/common/ExternalLink'
import { CreateNestedSafe } from '@/components/tx-flow/flows/CreateNestedSafe'
import EntryDialog from '@/components/address-book/EntryDialog'
import { TxModalContext } from '@/components/tx-flow'
import EnhancedTable from '@/components/common/EnhancedTable'
import useSafeInfo from '@/hooks/useSafeInfo'
import { useGetOwnedSafesQuery } from '@/store/slices'
import { NESTED_SAFE_EVENTS } from '@/services/analytics/events/nested-safes'
import Track from '@/components/common/Track'
import { useIsTargetedFeature } from '@/features/targetedFeatures/hooks/useIsTargetedFeature'
import { FEATURES } from '@/utils/chains'

import tableCss from '@/components/common/EnhancedTable/styles.module.css'

export function NestedSafesList(): ReactElement | null {
const isEnabled = useIsTargetedFeature(FEATURES.TARGETED_NESTED_SAFES)
const { setTxFlow } = useContext(TxModalContext)
const [addressToRename, setAddressToRename] = useState<string | null>(null)

const { safe, safeLoaded, safeAddress } = useSafeInfo()
const { data: nestedSafes } = useGetOwnedSafesQuery(
isEnabled && safeLoaded ? { chainId: safe.chainId, ownerAddress: safeAddress } : skipToken,
)

const rows = useMemo(() => {
return nestedSafes?.safes.map((nestedSafe) => {
return {
cells: {
owner: {
rawValue: nestedSafe,
content: (
<EthHashInfo address={nestedSafe} showCopyButton shortAddress={false} showName={true} hasExplorer />
),
},
actions: {
rawValue: '',
sticky: true,
content: (
<div className={tableCss.actions}>
<CheckWallet>
{(isOk) => (
<Track {...NESTED_SAFE_EVENTS.RENAME}>
<Tooltip title={isOk ? 'Rename nested Safe' : undefined}>
<span>
<IconButton onClick={() => setAddressToRename(nestedSafe)} size="small" disabled={!isOk}>
<SvgIcon component={EditIcon} inheritViewBox fontSize="small" color="border" />
</IconButton>
</span>
</Tooltip>
</Track>
)}
</CheckWallet>
</div>
),
},
},
}
})
}, [nestedSafes?.safes])

if (!isEnabled) {
return null
}

return (
<>
<Paper sx={{ padding: 4, mt: 2 }}>
<Grid2 container direction="row" justifyContent="space-between" spacing={3} mb={2}>
<Grid2 size={{ lg: 4, xs: 12 }}>
<Typography variant="h4" fontWeight={700}>
Nested Safes
</Typography>
</Grid2>

<Grid2 size="grow">
<Typography mb={3}>
Nested Safes are separate wallets owned by your main Account, perfect for organizing different funds and
projects.{' '}
<ExternalLink
// TODO: Add link
href="#"
>
Learn more
</ExternalLink>
</Typography>

{nestedSafes?.safes.length === 0 && (
<Typography mb={3}>
You don&apos;t have any Nested Safes yet. Set one up now to better organize your assets
</Typography>
)}

<CheckWallet>
{(isOk) => (
<Button
onClick={() => setTxFlow(<CreateNestedSafe />)}
variant="text"
startIcon={<SvgIcon component={AddIcon} inheritViewBox fontSize="small" />}
disabled={!isOk}
sx={{ mb: 3 }}
>
Add nested Safe
</Button>
)}
</CheckWallet>

{rows && rows.length > 0 && <EnhancedTable rows={rows} headCells={[]} />}
</Grid2>
</Grid2>
</Paper>

{addressToRename && (
<EntryDialog
handleClose={() => setAddressToRename(null)}
defaultValues={{ name: '', address: addressToRename }}
chainIds={[safe.chainId]}
disableAddressInput
/>
)}
</>
)
}
62 changes: 62 additions & 0 deletions apps/web/src/components/sidebar/NestedSafeInfo/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Tooltip, SvgIcon, Typography, List, ListItem, Box, ListItemAvatar, Avatar, ListItemText } from '@mui/material'
import CheckIcon from '@mui/icons-material/Check'
import type { ReactElement } from 'react'

import NestedSafesIcon from '@/public/images/sidebar/nested-safes-icon.svg'
import NestedSafes from '@/public/images/sidebar/nested-safes.svg'
import InfoIcon from '@/public/images/notifications/info.svg'

export function NestedSafeInfo(): ReactElement {
return (
<Box display="flex" flexDirection="column" alignItems="center" pt={1}>
<NestedSafes />
<Box display="flex" gap={1} py={2}>
<Typography fontWeight={700}>No Nested Safes yet</Typography>
<Tooltip
title="Nested Safes are separate wallets owned by your main Account, perfect for organizing different funds and projects."
placement="top"
arrow
sx={{ ml: 1 }}
>
<span>
<SvgIcon
component={InfoIcon}
inheritViewBox
fontSize="small"
color="border"
sx={{ verticalAlign: 'middle' }}
/>
</span>
</Tooltip>
</Box>
<Box display="flex" gap={2} alignItems="center" pt={1} pb={4}>
<Avatar sx={{ padding: '20px', backgroundColor: 'success.background' }}>
<SvgIcon component={NestedSafesIcon} inheritViewBox color="primary" sx={{ fontSize: 20 }} />
</Avatar>
<Typography variant="body2" fontWeight={700}>
Nested Safes allow you to:
</Typography>
</Box>
<List sx={{ p: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
{[
'rebuild your organizational structure onchain',
'explore new DeFi opportunities without exposing your main Account',
'deploy specialized modules and extend Safe functionality',
].map((item) => {
return (
<ListItem key={item} sx={{ p: 0, pl: 1.5, alignItems: 'unset' }}>
<ListItemAvatar sx={{ minWidth: 'unset', mr: 3 }}>
<Avatar sx={{ width: 25, height: 25, backgroundColor: 'success.background' }}>
<CheckIcon fontSize="small" color="success" />
</Avatar>
</ListItemAvatar>
<ListItemText sx={{ m: 0 }} primaryTypographyProps={{ variant: 'body2' }}>
{item}
</ListItemText>
</ListItem>
)
})}
</List>
</Box>
)
}
Loading
Loading