diff --git a/src/components/safe-apps/AddCustomAppModal/CustomApp.tsx b/src/components/safe-apps/AddCustomAppModal/CustomApp.tsx new file mode 100644 index 000000000..28a948d9d --- /dev/null +++ b/src/components/safe-apps/AddCustomAppModal/CustomApp.tsx @@ -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 ( +
+ + + + {safeApp.name} + + + + {safeApp.description} + + + {shareUrl ? ( + + + + ) : ( + + )} +
+ ) +} + +export default CustomApp diff --git a/src/components/safe-apps/AddCustomAppModal/CustomAppPlaceholder.tsx b/src/components/safe-apps/AddCustomAppModal/CustomAppPlaceholder.tsx new file mode 100644 index 000000000..2fdd4a239 --- /dev/null +++ b/src/components/safe-apps/AddCustomAppModal/CustomAppPlaceholder.tsx @@ -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 ( +
+ + + {error || 'Safe App card'} + +
+ ) +} + +export default CustomAppPlaceholder diff --git a/src/components/safe-apps/AddCustomAppModal/index.tsx b/src/components/safe-apps/AddCustomAppModal/index.tsx new file mode 100644 index 000000000..c67191fdd --- /dev/null +++ b/src/components/safe-apps/AddCustomAppModal/index.tsx @@ -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({ defaultValues: { riskAcknowledgement: false }, mode: 'onChange' }) + + const onSubmit: SubmitHandler = (_, __) => { + if (safeApp) { + onSave(safeApp) + reset() + onClose() + } + } + + const appUrl = watch('appUrl') + const debouncedUrl = useDebounce(trimTrailingSlash(appUrl || ''), 300) + + const [safeApp, manifestError] = useAsync(() => { + 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 ( + +
+ +
+ (isValidURL(val) ? undefined : INVALID_URL_ERROR), + alreadyExists: (val: string) => + isAppAlreadyInTheList(val) ? APP_ALREADY_IN_THE_LIST_ERROR : undefined, + }, + })} + /> + + {safeApp ? ( + <> + + {isCustomAppInTheDefaultList ? ( + + + This Safe App is already registered + + ) : ( + <> + + } + 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 && ( + Accepting the disclaimer is mandatory + )} + + )} + + ) : ( + + )} + +
+ +
+ + Learn more about building + + Safe Apps + + . +
+
+ + + + + +
+
+ ) +} diff --git a/src/components/safe-apps/AddCustomAppModal/styles.module.css b/src/components/safe-apps/AddCustomAppModal/styles.module.css new file mode 100644 index 000000000..14973bb76 --- /dev/null +++ b/src/components/safe-apps/AddCustomAppModal/styles.module.css @@ -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); +} diff --git a/src/components/safe-apps/AddCustomSafeAppCard/index.tsx b/src/components/safe-apps/AddCustomSafeAppCard/index.tsx new file mode 100644 index 000000000..aaf1cef17 --- /dev/null +++ b/src/components/safe-apps/AddCustomSafeAppCard/index.tsx @@ -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(false) + + return ( + <> + + + {/* Add Custom Safe App Icon */} + + + {/* Add Custom Safe App Button */} + + + + + {/* Add Custom Safe App Modal */} + setAddCustomAppModalOpen(false)} + onSave={onSave} + safeAppsList={safeAppList} + /> + + ) +} + +export default AddCustomSafeAppCard diff --git a/src/components/safe-apps/AppFrame/SafeAppIframe.tsx b/src/components/safe-apps/AppFrame/SafeAppIframe.tsx new file mode 100644 index 000000000..b6eaf4601 --- /dev/null +++ b/src/components/safe-apps/AppFrame/SafeAppIframe.tsx @@ -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 + 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 ( +