Skip to content
Closed
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
43 changes: 43 additions & 0 deletions src/components/safe-apps/AddCustomAppModal/CustomApp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
import { Typography, SvgIcon } from '@mui/material'
import CheckIcon from '@mui/icons-material/Check'

import CopyButton from '@/components/common/CopyButton'
import ShareIcon from '@/public/images/common/share.svg'
import css from './styles.module.css'
import SafeAppIconCard from '@/components/safe-apps/SafeAppIconCard'

type CustomAppProps = {
safeApp: SafeAppData
shareUrl: string
}

const CustomApp = ({ safeApp, shareUrl }: CustomAppProps) => {
return (
<div className={css.customAppContainer}>
<SafeAppIconCard src={safeApp.iconUrl} alt={safeApp.name} width={48} height={48} />

<Typography component="h2" mt={2} color="text.primary" fontWeight={700}>
{safeApp.name}
</Typography>

<Typography variant="body2" mt={1} color="text.secondary">
{safeApp.description}
</Typography>

{shareUrl ? (
<CopyButton
className={css.customAppCheckIcon}
text={shareUrl}
initialToolTipText={`Copy share URL for ${safeApp.name}`}
>
<SvgIcon component={ShareIcon} inheritViewBox color="border" fontSize="small" />
</CopyButton>
) : (
<CheckIcon color="success" className={css.customAppCheckIcon} />
)}
</div>
)
}

export default CustomApp
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { SvgIcon, Typography } from '@mui/material'
import classNames from 'classnames'

import SafeAppIcon from '@/public/images/apps/apps-icon.svg'

import css from './styles.module.css'

type CustomAppPlaceholderProps = {
error?: string
}

const CustomAppPlaceholder = ({ error = '' }: CustomAppPlaceholderProps) => {
return (
<div className={css.customAppPlaceholderContainer}>
<SvgIcon
className={classNames({
[css.customAppPlaceholderIconError]: error,
[css.customAppPlaceholderIconDefault]: !error,
})}
component={SafeAppIcon}
inheritViewBox
/>
<Typography ml={2} color={error ? 'error' : 'text.secondary'}>
{error || 'Safe App card'}
</Typography>
</div>
)
}

export default CustomAppPlaceholder
170 changes: 170 additions & 0 deletions src/components/safe-apps/AddCustomAppModal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { useCallback } from 'react'
import { useRouter } from 'next/router'
import type { SubmitHandler } from 'react-hook-form'
import { useForm } from 'react-hook-form'
import {
DialogActions,
DialogContent,
Typography,
Button,
TextField,
FormControlLabel,
Checkbox,
Box,
FormHelperText,
} from '@mui/material'
import CheckIcon from '@mui/icons-material/Check'
import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'
import ModalDialog from '@/components/common/ModalDialog'
import { isValidURL } from '@/utils/validation'
import { useCurrentChain } from '@/hooks/useChains'
import useAsync from '@/hooks/useAsync'
import useDebounce from '@/hooks/useDebounce'
import { fetchSafeAppFromManifest } from '@/services/safe-apps/manifest'
import { isSameUrl, trimTrailingSlash } from '@/utils/url'
import CustomAppPlaceholder from './CustomAppPlaceholder'
import CustomApp from './CustomApp'
import { useShareSafeAppUrl } from '@/components/safe-apps/hooks/useShareSafeAppUrl'

import css from './styles.module.css'
import ExternalLink from '@/components/common/ExternalLink'

type Props = {
open: boolean
onClose: () => void
onSave: (data: SafeAppData) => void
// A list of safe apps to check if the app is already there
safeAppsList: SafeAppData[]
}

type CustomAppFormData = {
appUrl: string
riskAcknowledgement: boolean
safeApp: SafeAppData
}

const HELP_LINK = 'https://docs.safe.global/safe-core-aa-sdk/safe-apps/get-started'
const APP_ALREADY_IN_THE_LIST_ERROR = 'This Safe App is already in the list'
const MANIFEST_ERROR = "The app doesn't support Safe App functionality"
const INVALID_URL_ERROR = 'The url is invalid'

export const AddCustomAppModal = ({ open, onClose, onSave, safeAppsList }: Props) => {
const currentChain = useCurrentChain()
const router = useRouter()

const {
register,
handleSubmit,
formState: { errors, isValid },
watch,
reset,
} = useForm<CustomAppFormData>({ defaultValues: { riskAcknowledgement: false }, mode: 'onChange' })

const onSubmit: SubmitHandler<CustomAppFormData> = (_, __) => {
if (safeApp) {
onSave(safeApp)
reset()
onClose()
}
}

const appUrl = watch('appUrl')
const debouncedUrl = useDebounce(trimTrailingSlash(appUrl || ''), 300)

const [safeApp, manifestError] = useAsync<SafeAppData | undefined>(() => {
if (!isValidURL(debouncedUrl)) return

return fetchSafeAppFromManifest(debouncedUrl, currentChain?.chainId || '')
}, [currentChain, debouncedUrl])

const handleClose = () => {
reset()
onClose()
}

const isAppAlreadyInTheList = useCallback(
(appUrl: string) => safeAppsList.some((app) => isSameUrl(app.url, appUrl)),
[safeAppsList],
)

const shareSafeAppUrl = useShareSafeAppUrl(safeApp?.url || '')
const isSafeAppValid = isValid && safeApp
const isCustomAppInTheDefaultList = errors?.appUrl?.type === 'alreadyExists'

return (
<ModalDialog open={open} onClose={handleClose} dialogTitle="Add custom Safe App">
<form onSubmit={handleSubmit(onSubmit)}>
<DialogContent className={css.addCustomAppContainer}>
<div className={css.addCustomAppFields}>
<TextField
required
label="Safe App URL"
error={errors?.appUrl?.type === 'validUrl'}
helperText={errors?.appUrl?.type === 'validUrl' && errors?.appUrl?.message}
autoComplete="off"
{...register('appUrl', {
required: true,
validate: {
validUrl: (val: string) => (isValidURL(val) ? undefined : INVALID_URL_ERROR),
alreadyExists: (val: string) =>
isAppAlreadyInTheList(val) ? APP_ALREADY_IN_THE_LIST_ERROR : undefined,
},
})}
/>
<Box mt={2}>
{safeApp ? (
<>
<CustomApp safeApp={safeApp} shareUrl={isCustomAppInTheDefaultList ? shareSafeAppUrl : ''} />
{isCustomAppInTheDefaultList ? (
<Box display="flex" mt={2} alignItems="center">
<CheckIcon color="success" />
<Typography ml={1}>This Safe App is already registered</Typography>
</Box>
) : (
<>
<FormControlLabel
aria-required
control={
<Checkbox
{...register('riskAcknowledgement', {
required: true,
})}
/>
}
label="This Safe App is not part of Safe{Wallet} and I agree to use it at my own risk."
sx={{ mt: 2 }}
/>

{errors.riskAcknowledgement && (
<FormHelperText error>Accepting the disclaimer is mandatory</FormHelperText>
)}
</>
)}
</>
) : (
<CustomAppPlaceholder error={isValidURL(debouncedUrl) && manifestError ? MANIFEST_ERROR : ''} />
)}
</Box>
</div>

<div className={css.addCustomAppHelp}>
<InfoOutlinedIcon className={css.addCustomAppHelpIcon} />
<Typography ml={0.5}>Learn more about building</Typography>
<ExternalLink className={css.addCustomAppHelpLink} href={HELP_LINK} fontWeight={700}>
Safe Apps
</ExternalLink>
.
</div>
</DialogContent>

<DialogActions disableSpacing>
<Button onClick={handleClose}>Cancel</Button>
<Button type="submit" variant="contained" disabled={!isSafeAppValid}>
Add
</Button>
</DialogActions>
</form>
</ModalDialog>
)
}
59 changes: 59 additions & 0 deletions src/components/safe-apps/AddCustomAppModal/styles.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
.addCustomAppContainer {
padding: 0;
}

.addCustomAppFields {
display: flex;
flex-direction: column;
padding: var(--space-3);
}

.addCustomAppHelp {
display: flex;
align-items: center;
border-top: 2px solid var(--color-border-light);
padding: var(--space-3);
}

.addCustomAppHelpLink {
text-decoration: none;
margin-left: calc(var(--space-1) / 2);
}

.addCustomAppHelpIcon {
font-size: 16px;
color: var(--color-text-secondary);
}

.customAppContainer {
width: 100%;
display: flex;
flex-direction: column;
position: relative;
border: 1px solid var(--color-text-primary);
border-radius: 6px;
padding: var(--space-3);
}

.customAppCheckIcon {
position: absolute;
top: 27px;
right: 25px;
}

.customAppPlaceholderContainer {
width: 100%;
display: flex;
align-items: center;
border: 1px solid var(--color-border-main);
border-radius: 6px;
padding: 16px 12px;
}

.customAppPlaceholderIconDefault > path {
fill: var(--color-text-secondary);
}

.customAppPlaceholderIconError > path {
fill: var(--color-error-main);
}
47 changes: 47 additions & 0 deletions src/components/safe-apps/AddCustomSafeAppCard/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useState } from 'react'
import Card from '@mui/material/Card'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'

import AddCustomAppIcon from '@/public/images/apps/add-custom-app.svg'
import { AddCustomAppModal } from '@/components/safe-apps/AddCustomAppModal'

type Props = { onSave: (data: SafeAppData) => void; safeAppList: SafeAppData[] }

const AddCustomSafeAppCard = ({ onSave, safeAppList }: Props) => {
const [addCustomAppModalOpen, setAddCustomAppModalOpen] = useState<boolean>(false)

return (
<>
<Card>
<Box padding="48px 12px" display="flex" flexDirection="column" alignItems="center">
{/* Add Custom Safe App Icon */}
<AddCustomAppIcon alt="Add Custom Safe App card" />

{/* Add Custom Safe App Button */}
<Button
variant="contained"
size="small"
onClick={() => setAddCustomAppModalOpen(true)}
sx={{
mt: 3,
}}
>
Add custom Safe App
</Button>
</Box>
</Card>

{/* Add Custom Safe App Modal */}
<AddCustomAppModal
open={addCustomAppModalOpen}
onClose={() => setAddCustomAppModalOpen(false)}
onSave={onSave}
safeAppsList={safeAppList}
/>
</>
)
}

export default AddCustomSafeAppCard
31 changes: 31 additions & 0 deletions src/components/safe-apps/AppFrame/SafeAppIframe.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { MutableRefObject, ReactElement } from 'react'
import css from './styles.module.css'

type SafeAppIFrameProps = {
appUrl: string
allowedFeaturesList: string
title?: string
iframeRef?: MutableRefObject<HTMLIFrameElement | null>
onLoad?: () => void
}

// see sandbox mdn docs for more details https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox
const IFRAME_SANDBOX_ALLOWED_FEATURES =
'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms allow-downloads allow-orientation-lock'

const SafeAppIframe = ({ appUrl, allowedFeaturesList, iframeRef, onLoad, title }: SafeAppIFrameProps): ReactElement => {
return (
<iframe
className={css.iframe}
id={`iframe-${appUrl}`}
ref={iframeRef}
src={appUrl}
title={title}
onLoad={onLoad}
sandbox={IFRAME_SANDBOX_ALLOWED_FEATURES}
allow={allowedFeaturesList}
/>
)
}

export default SafeAppIframe
Loading
Loading