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 (
+
+
+
+ )
+}
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 */}
+ setAddCustomAppModalOpen(true)}
+ sx={{
+ mt: 3,
+ }}
+ >
+ Add custom Safe App
+
+
+
+
+ {/* 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 (
+
+ )
+}
+
+export default SafeAppIframe
diff --git a/src/components/safe-apps/AppFrame/ThirdPartyCookiesWarning.tsx b/src/components/safe-apps/AppFrame/ThirdPartyCookiesWarning.tsx
new file mode 100644
index 000000000..3e195771d
--- /dev/null
+++ b/src/components/safe-apps/AppFrame/ThirdPartyCookiesWarning.tsx
@@ -0,0 +1,31 @@
+import React from 'react'
+import { Alert, AlertTitle } from '@mui/material'
+import ExternalLink from '@/components/common/ExternalLink'
+import { HelpCenterArticle } from '@/config/constants'
+
+type ThirdPartyCookiesWarningProps = {
+ onClose: () => void
+}
+
+export const ThirdPartyCookiesWarning = ({ onClose }: ThirdPartyCookiesWarningProps): React.ReactElement => {
+ return (
+ ({
+ background: palette.warning.light,
+ border: 0,
+ borderBottom: `1px solid ${palette.warning.main}`,
+ borderRadius: '0px !important',
+ })}
+ >
+
+ Third party cookies are disabled. Safe Apps may therefore not work properly. You can find out more information
+ about this{' '}
+
+ here
+
+
+
+ )
+}
diff --git a/src/components/safe-apps/AppFrame/TransactionQueueBar/index.tsx b/src/components/safe-apps/AppFrame/TransactionQueueBar/index.tsx
new file mode 100644
index 000000000..07f513563
--- /dev/null
+++ b/src/components/safe-apps/AppFrame/TransactionQueueBar/index.tsx
@@ -0,0 +1,100 @@
+import type { Dispatch, ReactElement, SetStateAction } from 'react'
+import { Backdrop, Typography, Box, IconButton, Accordion, AccordionDetails, AccordionSummary } from '@mui/material'
+import { ClickAwayListener } from '@mui/base'
+import CloseIcon from '@mui/icons-material/Close'
+import ExpandLessIcon from '@mui/icons-material/ExpandLess'
+
+// import useTxQueue from '@/hooks/useTxQueue'
+// import PaginatedTxns from '@/components/common/PaginatedTxns'
+import styles from './styles.module.css'
+import { getQueuedTransactionCount } from '@/utils/transactions'
+// import { BatchExecuteHoverProvider } from '@/components/transactions/BatchExecuteButton/BatchExecuteHoverProvider'
+// import BatchExecuteButton from '@/components/transactions/BatchExecuteButton'
+import type { TransactionListPage } from '@safe-global/safe-gateway-typescript-sdk'
+
+type Props = {
+ expanded: boolean
+ visible: boolean
+ setExpanded: Dispatch>
+ onDismiss: () => void
+ transactions: TransactionListPage
+}
+
+const TransactionQueueBar = ({
+ expanded,
+ visible,
+ setExpanded,
+ onDismiss,
+ transactions,
+}: Props): ReactElement | null => {
+ if (!visible || transactions.results.length === 0) {
+ return null
+ }
+
+ const queuedTxCount = getQueuedTransactionCount(transactions)
+
+ // if you inline the expression, it will split put the `queuedTxCount` on a new line
+ // and make it harder to find this text for matchers in tests
+ const barTitle = `(${queuedTxCount}) Transaction queue`
+ return (
+ <>
+
+ setExpanded(false)} mouseEvent="onMouseDown" touchEvent="onTouchStart">
+ setExpanded((prev) => !prev)}
+ TransitionProps={{
+ timeout: {
+ appear: 400,
+ enter: 0,
+ exit: 500,
+ },
+ unmountOnExit: false,
+ mountOnEnter: true,
+ }}
+ sx={{
+ // there are very specific rules for the border radius that we have to override
+ borderBottomLeftRadius: '0 !important',
+ borderBottomRightRadius: '0 !important',
+ }}
+ >
+
+
+ {barTitle}
+
+
+ {
+ event.stopPropagation()
+ setExpanded((prev) => !prev)
+ }}
+ aria-label={`${expanded ? 'close' : 'expand'} transaction queue bar`}
+ sx={{ transform: expanded ? 'rotate(180deg)' : undefined }}
+ >
+
+
+
+
+
+
+
+ {/*
+
+
+
+
+ */}
+
+
+
+
+
+ >
+ )
+}
+
+export const TRANSACTION_BAR_HEIGHT = '64px'
+
+export default TransactionQueueBar
diff --git a/src/components/safe-apps/AppFrame/TransactionQueueBar/styles.module.css b/src/components/safe-apps/AppFrame/TransactionQueueBar/styles.module.css
new file mode 100644
index 000000000..c7d08f6d3
--- /dev/null
+++ b/src/components/safe-apps/AppFrame/TransactionQueueBar/styles.module.css
@@ -0,0 +1,12 @@
+.barWrapper {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ width: 100%;
+
+ /* MUI Drawer z-index default value see: https://mui.com/material-ui/customization/default-theme/?expand-path=$.zIndex */
+ z-index: 1200;
+
+ /*this rule is needed to prevent the bar from being expanded outside the screen without scrolling on mobile devices*/
+ max-height: 90vh;
+}
diff --git a/src/components/safe-apps/AppFrame/__tests__/AppFrame.test.tsx b/src/components/safe-apps/AppFrame/__tests__/AppFrame.test.tsx
new file mode 100644
index 000000000..be1d33055
--- /dev/null
+++ b/src/components/safe-apps/AppFrame/__tests__/AppFrame.test.tsx
@@ -0,0 +1,240 @@
+import React from 'react'
+import { render, screen } from '@/tests/test-utils'
+import AppFrame from '@/components/safe-apps/AppFrame'
+import { initialState as initialSettingsState } from '@/store/settingsSlice'
+
+jest.mock('@/hooks/useChainId', () => jest.fn(() => '1'))
+
+jest.mock('@/hooks/useSafeInfo', () =>
+ jest.fn(() => ({
+ safe: {
+ chainId: '1',
+ threshold: 1,
+ nonce: 0,
+ owners: [{ value: '0x0000000000000000000000000000000000000001' }],
+ },
+ safeLoaded: true,
+ safeAddress: '0x0000000000000000000000000000000000000123',
+ })),
+)
+
+jest.mock('@/hooks/useAddressBook', () => jest.fn(() => ({})))
+
+jest.mock('@/hooks/safe-apps/permissions', () => ({
+ useSafePermissions: jest.fn(() => ({
+ getPermissions: jest.fn(() => []),
+ hasPermission: jest.fn(() => true),
+ permissionsRequest: undefined,
+ setPermissionsRequest: jest.fn(),
+ confirmPermissionRequest: jest.fn(() => []),
+ })),
+}))
+
+jest.mock('@/hooks/useChains', () => ({
+ useCurrentChain: jest.fn(() => undefined),
+}))
+
+jest.mock('@/services/tx/txEvents', () => ({
+ TxEvent: { SAFE_APPS_REQUEST: 'SAFE_APPS_REQUEST' },
+ txSubscribe: jest.fn(() => jest.fn()),
+}))
+
+jest.mock('@/services/safe-messages/safeMsgEvents', () => ({
+ SafeMsgEvent: { SIGNATURE_PREPARED: 'SIGNATURE_PREPARED' },
+ safeMsgSubscribe: jest.fn(() => jest.fn()),
+}))
+
+jest.mock('@/components/safe-apps/AppFrame/useTransactionQueueBarState', () =>
+ jest.fn(() => ({
+ expanded: false,
+ dismissedByUser: false,
+ setExpanded: jest.fn(),
+ dismissQueueBar: jest.fn(),
+ transactions: { results: [] },
+ })),
+)
+
+jest.mock('@/components/safe-apps/AppFrame/useAppIsLoading', () =>
+ jest.fn(() => ({
+ iframeRef: { current: { src: 'https://example.com' } },
+ appIsLoading: false,
+ isLoadingSlow: false,
+ setAppIsLoading: jest.fn(),
+ })),
+)
+
+jest.mock('@/components/safe-apps/AppFrame/useAppCommunicator', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ send: jest.fn(),
+ })),
+ CommunicatorMessages: {
+ REJECT_TRANSACTION_MESSAGE: 'rejected',
+ },
+}))
+
+jest.mock('@/components/safe-apps/AppFrame/useGetSafeInfo', () => ({
+ __esModule: true,
+ default: jest.fn(() => jest.fn(() => ({ safeAddress: '0x123' }))),
+}))
+
+jest.mock('@/components/safe-apps/AppFrame/SafeAppIframe', () => ({
+ __esModule: true,
+ default: () =>
,
+}))
+
+jest.mock('@/components/safe-apps/AppFrame/TransactionQueueBar', () => ({
+ __esModule: true,
+ default: () => null,
+ TRANSACTION_BAR_HEIGHT: 0,
+}))
+
+jest.mock('@/components/safe-apps/PermissionsPrompt', () => ({
+ __esModule: true,
+ default: () => null,
+}))
+
+jest.mock('@/components/tx-flow/flows', () => ({
+ SafeAppsTxFlow: () => null,
+ SignMessageFlow: () => null,
+ SignMessageOnChainFlow: () => null,
+}))
+
+const mockUseAppCommunicator = jest.requireMock('@/components/safe-apps/AppFrame/useAppCommunicator')
+ .default as jest.Mock
+
+const safeAppFromManifest = {
+ id: 0.1,
+ url: 'https://example.com',
+ name: 'Example App',
+ description: 'Example app',
+ accessControl: { type: 'NO_RESTRICTIONS' },
+ tags: [],
+ features: [],
+ socialProfiles: [],
+ developerWebsite: '',
+ chainIds: ['1'],
+ iconUrl: 'https://example.com/icon.png',
+ safeAppsPermissions: [],
+} as any
+
+describe('AppFrame appearance', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ jest.clearAllMocks()
+ })
+
+ it('uses a light app surface by default', () => {
+ render( )
+
+ const appContainer = screen.getByTestId('safe-app-iframe').parentElement as HTMLElement
+ const wrapper = appContainer.parentElement as HTMLElement
+
+ expect(wrapper).toHaveStyle({ backgroundColor: '#fff' })
+ expect(appContainer).toHaveStyle({ backgroundColor: '#fff' })
+ })
+
+ it('disables the forced light app surface when the setting is off', () => {
+ render( , {
+ initialReduxState: {
+ settings: {
+ ...initialSettingsState,
+ theme: {
+ ...initialSettingsState.theme,
+ safeAppsUseLightBackground: false,
+ },
+ },
+ },
+ })
+
+ const appContainer = screen.getByTestId('safe-app-iframe').parentElement as HTMLElement
+ const wrapper = appContainer.parentElement as HTMLElement
+
+ expect(wrapper).not.toHaveStyle({ backgroundColor: '#fff' })
+ expect(appContainer).not.toHaveStyle({ backgroundColor: '#fff' })
+ })
+
+ it('returns local balances via communicator handler', async () => {
+ render( , {
+ initialReduxState: {
+ balances: {
+ data: [
+ {
+ tokenInfo: {
+ type: 'NATIVE_TOKEN',
+ address: '0x0000000000000000000000000000000000000000',
+ decimals: 18,
+ symbol: 'ETH',
+ name: 'Ether',
+ logoUri: 'https://example.com/eth.png',
+ },
+ balance: '42',
+ fiatBalance: '',
+ fiatConversion: '',
+ },
+ ],
+ loading: false,
+ error: undefined,
+ },
+ },
+ })
+
+ const handlers = mockUseAppCommunicator.mock.calls.at(-1)?.[3]
+ const balances = await handlers.onGetSafeBalances('usd')
+
+ expect(balances).toEqual({
+ fiatTotal: '0',
+ items: [
+ {
+ tokenInfo: {
+ type: 'NATIVE_TOKEN',
+ address: '0x0000000000000000000000000000000000000000',
+ decimals: 18,
+ symbol: 'ETH',
+ name: 'Ether',
+ logoUri: 'https://example.com/eth.png',
+ },
+ balance: '42',
+ fiatBalance: '0',
+ fiatConversion: '0',
+ },
+ ],
+ })
+ })
+
+ it('returns local transaction details via communicator handler', async () => {
+ render( , {
+ initialReduxState: {
+ txHistory: {
+ data: {
+ multisig_0x0000000000000000000000000000000000000123_0xabc: {
+ txId: 'multisig_0x0000000000000000000000000000000000000123_0xabc',
+ txHash: '0x1234',
+ safeTxHash: '0xabc',
+ timestamp: 1700000000000,
+ executor: '0x0000000000000000000000000000000000000005',
+ },
+ },
+ loading: false,
+ error: undefined,
+ },
+ },
+ })
+
+ const handlers = mockUseAppCommunicator.mock.calls.at(-1)?.[3]
+ const txDetails = await handlers.onGetTxBySafeTxHash('0xabc')
+
+ expect(txDetails.txInfo.type).toBe('Custom')
+ expect(txDetails.safeAddress).toBe('0x0000000000000000000000000000000000000123')
+ })
+
+ it('throws for off-chain signatures when not in local state', async () => {
+ render( )
+
+ const handlers = mockUseAppCommunicator.mock.calls.at(-1)?.[3]
+
+ await expect(handlers.onGetOffChainSignature('0xhash')).rejects.toThrow(
+ 'Off-chain signatures are not supported yet. See issue #7.',
+ )
+ })
+})
diff --git a/src/components/safe-apps/AppFrame/__tests__/SafeAppIframe.test.tsx b/src/components/safe-apps/AppFrame/__tests__/SafeAppIframe.test.tsx
new file mode 100644
index 000000000..0d0b1b6fd
--- /dev/null
+++ b/src/components/safe-apps/AppFrame/__tests__/SafeAppIframe.test.tsx
@@ -0,0 +1,16 @@
+import { render, screen } from '@/tests/test-utils'
+import SafeAppIframe from '@/components/safe-apps/AppFrame/SafeAppIframe'
+
+describe('SafeAppIframe', () => {
+ it('renders a safe app iframe with sandbox permissions', () => {
+ render(
+ ,
+ )
+
+ const iframe = screen.getByTitle('Tx Builder')
+
+ expect(iframe).toHaveAttribute('src', 'https://tx-builder.safe.global')
+ expect(iframe).toHaveAttribute('sandbox', expect.stringContaining('allow-scripts'))
+ expect(iframe).toHaveAttribute('allow', 'clipboard-read')
+ })
+})
diff --git a/src/components/safe-apps/AppFrame/index.tsx b/src/components/safe-apps/AppFrame/index.tsx
new file mode 100644
index 000000000..a70f9ec6c
--- /dev/null
+++ b/src/components/safe-apps/AppFrame/index.tsx
@@ -0,0 +1,350 @@
+import { useContext, useState } from 'react'
+import type { ReactElement } from 'react'
+import { useCallback, useEffect } from 'react'
+import { CircularProgress, Typography } from '@mui/material'
+import { useRouter } from 'next/router'
+import Head from 'next/head'
+import type {
+ AddressBookItem,
+ BaseTransaction,
+ EIP712TypedData,
+ RequestId,
+ SafeSettings,
+ SendTransactionRequestParams,
+} from '@safe-global/safe-apps-sdk'
+import { Methods } from '@safe-global/safe-apps-sdk'
+
+import { TxEvent, txSubscribe } from '@/services/tx/txEvents'
+import useSafeInfo from '@/hooks/useSafeInfo'
+import useChainId from '@/hooks/useChainId'
+import useAddressBook from '@/hooks/useAddressBook'
+import { useSafePermissions } from '@/hooks/safe-apps/permissions'
+import { useCurrentChain } from '@/hooks/useChains'
+import { isSameUrl } from '@/utils/url'
+import useTransactionQueueBarState from '@/components/safe-apps/AppFrame/useTransactionQueueBarState'
+import useAppIsLoading from './useAppIsLoading'
+import useAppCommunicator, { CommunicatorMessages } from './useAppCommunicator'
+import TransactionQueueBar, { TRANSACTION_BAR_HEIGHT } from './TransactionQueueBar'
+import { safeMsgSubscribe, SafeMsgEvent } from '@/services/safe-messages/safeMsgEvents'
+import { useAppSelector } from '@/store'
+import { selectSafeMessages } from '@/store/safeMessagesSlice'
+import { isSafeMessageListItem } from '@/utils/safe-message-guards'
+import { isOffchainEIP1271Supported } from '@/utils/safe-messages'
+import PermissionsPrompt from '@/components/safe-apps/PermissionsPrompt'
+import { PermissionStatus, type SafeAppDataWithPermissions } from '@/components/safe-apps/types'
+
+import css from './styles.module.css'
+import SafeAppIframe from './SafeAppIframe'
+import useGetSafeInfo from './useGetSafeInfo'
+import { hasFeature, FEATURES } from '@/utils/chains'
+import {
+ selectSafeAppsUseLightBackground,
+ selectTokenList,
+ selectOnChainSigning,
+ TOKEN_LISTS,
+} from '@/store/settingsSlice'
+import { TxModalContext } from '@/components/tx-flow'
+import { SafeAppsTxFlow, SignMessageFlow, SignMessageOnChainFlow } from '@/components/tx-flow/flows'
+import useBalances from '@/hooks/useBalances'
+import { selectAddedTxs } from '@/store/addedTxsSlice'
+import { selectTxHistory } from '@/store/txHistorySlice'
+import { extractTxDetails } from '@/services/tx/extractTxInfo'
+import { enrichTransactionDetailsFromHistory, partiallyDecodedTransaction } from '@/utils/transactions'
+
+type AppFrameProps = {
+ appUrl: string
+ allowedFeaturesList: string
+ safeAppFromManifest: SafeAppDataWithPermissions
+}
+
+const AppFrame = ({ appUrl, allowedFeaturesList, safeAppFromManifest }: AppFrameProps): ReactElement => {
+ const chainId = useChainId()
+ // We use offChainSigning by default
+ const [settings, setSettings] = useState({
+ offChainSigning: true,
+ })
+ const [currentRequestId, setCurrentRequestId] = useState()
+ const safeMessages = useAppSelector(selectSafeMessages)
+ const { safe, safeLoaded, safeAddress } = useSafeInfo()
+ const { balances } = useBalances()
+ const addedTxs = useAppSelector((state) => selectAddedTxs(state, chainId, safeAddress))
+ const { data: txHistory } = useAppSelector(selectTxHistory)
+ const tokenlist = useAppSelector(selectTokenList)
+ const onChainSigning = useAppSelector(selectOnChainSigning)
+ const useLightSafeAppsBackground = useAppSelector(selectSafeAppsUseLightBackground)
+
+ const addressBook = useAddressBook()
+ const chain = useCurrentChain()
+ const router = useRouter()
+ const {
+ expanded: queueBarExpanded,
+ dismissedByUser: queueBarDismissed,
+ setExpanded,
+ dismissQueueBar,
+ transactions,
+ } = useTransactionQueueBarState()
+ const queueBarVisible = transactions.results.length > 0 && !queueBarDismissed
+ const { iframeRef, appIsLoading, isLoadingSlow, setAppIsLoading } = useAppIsLoading()
+ const { getPermissions, hasPermission, permissionsRequest, setPermissionsRequest, confirmPermissionRequest } =
+ useSafePermissions()
+ const { setTxFlow } = useContext(TxModalContext)
+
+ const getTxBySafeTxHash = useCallback(
+ async (safeTxHash: string) => {
+ if (!safeAddress) {
+ throw new Error('Safe is not loaded yet')
+ }
+
+ const localTx = addedTxs?.[safeTxHash]
+ const executedTx = Object.values(txHistory || {}).find((tx) => tx.safeTxHash === safeTxHash)
+
+ if (localTx) {
+ const details = await extractTxDetails(safeAddress, localTx, safe)
+
+ if (executedTx) {
+ enrichTransactionDetailsFromHistory(details, executedTx)
+ }
+
+ return details
+ }
+
+ if (executedTx) {
+ return partiallyDecodedTransaction(executedTx, safeAddress).details
+ }
+
+ throw new Error('Transaction not found locally')
+ },
+ [addedTxs, safe, safeAddress, txHistory],
+ )
+
+ const onTxFlowClose = () => {
+ setCurrentRequestId((prevId) => {
+ if (prevId) {
+ communicator?.send(CommunicatorMessages.REJECT_TRANSACTION_MESSAGE, prevId, true)
+ }
+ return undefined
+ })
+ }
+
+ const communicator = useAppCommunicator(iframeRef, safeAppFromManifest, chain, {
+ onConfirmTransactions: (txs: BaseTransaction[], requestId: RequestId, params?: SendTransactionRequestParams) => {
+ const data = {
+ app: safeAppFromManifest,
+ requestId: requestId,
+ txs: txs,
+ params: params,
+ }
+
+ setCurrentRequestId(requestId)
+ setTxFlow( , onTxFlowClose)
+ },
+ onSignMessage: (
+ message: string | EIP712TypedData,
+ requestId: string,
+ method: Methods.signMessage | Methods.signTypedMessage,
+ sdkVersion: string,
+ ) => {
+ const isOffChainSigningSupported = isOffchainEIP1271Supported(safe, chain, sdkVersion)
+ const signOffChain = isOffChainSigningSupported && !onChainSigning && !!settings.offChainSigning
+
+ setCurrentRequestId(requestId)
+
+ if (signOffChain) {
+ setTxFlow(
+ ,
+ onTxFlowClose,
+ )
+ } else {
+ setTxFlow(
+ ,
+ )
+ }
+ },
+ onGetPermissions: getPermissions,
+ onSetPermissions: setPermissionsRequest,
+ onRequestAddressBook: (origin: string): AddressBookItem[] => {
+ if (hasPermission(origin, Methods.requestAddressBook)) {
+ return Object.entries(addressBook).map(([address, name]) => ({ address, name, chainId }))
+ }
+
+ return []
+ },
+ onGetTxBySafeTxHash: getTxBySafeTxHash,
+ onGetEnvironmentInfo: () => ({
+ origin: document.location.origin,
+ }),
+ onGetSafeInfo: useGetSafeInfo(),
+ onGetSafeBalances: async (_currency) => {
+ const isDefaultTokenlistSupported = chain && hasFeature(chain, FEATURES.DEFAULT_TOKENLIST)
+ const shouldIncludeOnlyTrustedTokens = isDefaultTokenlistSupported && TOKEN_LISTS.TRUSTED === tokenlist
+
+ return {
+ fiatTotal: '0',
+ items: balances
+ .filter((token) => {
+ if (!shouldIncludeOnlyTrustedTokens) return true
+ return !token.custom
+ })
+ .map((token) => ({
+ tokenInfo: token.tokenInfo,
+ balance: token.balance,
+ fiatBalance: token.fiatBalance || '0',
+ fiatConversion: token.fiatConversion || '0',
+ })),
+ }
+ },
+ onGetChainInfo: () => {
+ if (!chain) return
+
+ const { nativeCurrency, chainName, chainId, shortName, blockExplorerUriTemplate } = chain
+
+ return {
+ chainName,
+ chainId,
+ shortName,
+ nativeCurrency,
+ blockExplorerUriTemplate,
+ }
+ },
+ onSetSafeSettings: (safeSettings: SafeSettings) => {
+ const newSettings: SafeSettings = {
+ ...settings,
+ offChainSigning: !!safeSettings.offChainSigning,
+ }
+
+ setSettings(newSettings)
+
+ return newSettings
+ },
+ onGetOffChainSignature: async (messageHash: string) => {
+ const safeMessage = safeMessages.data?.results
+ ?.filter(isSafeMessageListItem)
+ ?.find((item) => item.messageHash === messageHash)
+
+ if (safeMessage) {
+ return safeMessage.preparedSignature
+ }
+
+ // TODO(issue #7): Re-implement off-chain signature lookup once issue #7 is closed.
+ throw new Error('Off-chain signatures are not supported yet. See issue #7.')
+ },
+ })
+
+ const onAcceptPermissionRequest = (_origin: string, requestId: RequestId) => {
+ const permissions = confirmPermissionRequest(PermissionStatus.GRANTED)
+ communicator?.send(permissions, requestId as string)
+ }
+
+ const onRejectPermissionRequest = (requestId?: RequestId) => {
+ if (requestId) {
+ confirmPermissionRequest(PermissionStatus.DENIED)
+ communicator?.send('Permissions were rejected', requestId as string, true)
+ } else {
+ setPermissionsRequest(undefined)
+ }
+ }
+
+ const onIframeLoad = useCallback(() => {
+ const iframe = iframeRef.current
+ if (!iframe || !isSameUrl(iframe.src, appUrl)) {
+ return
+ }
+
+ setAppIsLoading(false)
+ }, [appUrl, iframeRef, setAppIsLoading])
+
+ useEffect(() => {
+ const unsubscribe = txSubscribe(TxEvent.SAFE_APPS_REQUEST, async ({ safeAppRequestId, safeTxHash }) => {
+ if (safeAppRequestId && currentRequestId === safeAppRequestId) {
+ communicator?.send({ safeTxHash }, safeAppRequestId)
+ }
+ })
+
+ return unsubscribe
+ }, [chainId, communicator, currentRequestId])
+
+ useEffect(() => {
+ const unsubscribe = safeMsgSubscribe(SafeMsgEvent.SIGNATURE_PREPARED, ({ messageHash, requestId, signature }) => {
+ if (requestId && currentRequestId === requestId) {
+ communicator?.send({ messageHash, signature }, requestId)
+ }
+ })
+
+ return unsubscribe
+ }, [communicator, currentRequestId])
+
+ if (!safeLoaded) {
+ return
+ }
+
+ return (
+ <>
+
+ {`Safe Apps - Viewer - ${safeAppFromManifest.name}`}
+
+
+
+ {appIsLoading && (
+
+ {isLoadingSlow && (
+
+ The Safe App is taking too long to load, consider refreshing.
+
+ )}
+
+
+ )}
+
+
+
+
+
+
+
+ {permissionsRequest && (
+
+ )}
+
+ >
+ )
+}
+
+export default AppFrame
diff --git a/src/components/safe-apps/AppFrame/styles.module.css b/src/components/safe-apps/AppFrame/styles.module.css
new file mode 100644
index 000000000..b78dd5a04
--- /dev/null
+++ b/src/components/safe-apps/AppFrame/styles.module.css
@@ -0,0 +1,22 @@
+.wrapper {
+ width: 100%;
+ height: calc(100vh - var(--header-height));
+}
+
+.iframe {
+ display: block;
+ height: 100%;
+ width: 100%;
+ overflow: auto;
+ box-sizing: border-box;
+ border: none;
+}
+
+.loadingContainer {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+}
diff --git a/src/components/safe-apps/AppFrame/useAppCommunicator.ts b/src/components/safe-apps/AppFrame/useAppCommunicator.ts
new file mode 100644
index 000000000..ea4057ee2
--- /dev/null
+++ b/src/components/safe-apps/AppFrame/useAppCommunicator.ts
@@ -0,0 +1,198 @@
+import type { MutableRefObject } from 'react'
+import { useEffect, useMemo, useState } from 'react'
+import { getAddress } from 'ethers/lib/utils'
+import { BigNumber } from '@ethersproject/bignumber'
+import type {
+ SafeAppData,
+ ChainInfo as WebCoreChainInfo,
+ TransactionDetails,
+} from '@safe-global/safe-gateway-typescript-sdk'
+import type {
+ AddressBookItem,
+ BaseTransaction,
+ EIP712TypedData,
+ EnvironmentInfo,
+ GetBalanceParams,
+ GetTxBySafeTxHashParams,
+ RequestId,
+ RPCPayload,
+ SafeInfo,
+ SendTransactionRequestParams,
+ SendTransactionsParams,
+ SignMessageParams,
+ SignTypedMessageParams,
+ ChainInfo,
+ SafeBalances,
+} from '@safe-global/safe-apps-sdk'
+import { Methods, RPC_CALLS } from '@safe-global/safe-apps-sdk'
+import type { Permission, PermissionRequest } from '@safe-global/safe-apps-sdk/dist/types/types/permissions'
+import type { SafeSettings } from '@safe-global/safe-apps-sdk'
+import AppCommunicator from '@/services/safe-apps/AppCommunicator'
+import { Errors, logError } from '@/services/exceptions'
+import { createSafeAppsWeb3Provider } from '@/hooks/wallets/web3'
+import type { SafePermissionsRequest } from '@/hooks/safe-apps/permissions'
+
+export enum CommunicatorMessages {
+ REJECT_TRANSACTION_MESSAGE = 'Transaction was rejected',
+}
+
+type JsonRpcResponse = {
+ jsonrpc: string
+ id: number
+ result?: any
+ error?: string
+}
+
+export type UseAppCommunicatorHandlers = {
+ onConfirmTransactions: (txs: BaseTransaction[], requestId: RequestId, params?: SendTransactionRequestParams) => void
+ onSignMessage: (
+ message: string | EIP712TypedData,
+ requestId: string,
+ method: Methods.signMessage | Methods.signTypedMessage,
+ sdkVersion: string,
+ ) => void
+ onGetTxBySafeTxHash: (transactionId: string) => Promise
+ onGetEnvironmentInfo: () => EnvironmentInfo
+ onGetSafeBalances: (currency: string) => Promise
+ onGetSafeInfo: () => SafeInfo
+ onGetChainInfo: () => ChainInfo | undefined
+ onGetPermissions: (origin: string) => Permission[]
+ onSetPermissions: (permissionsRequest?: SafePermissionsRequest) => void
+ onRequestAddressBook: (origin: string) => AddressBookItem[]
+ onSetSafeSettings: (settings: SafeSettings) => SafeSettings
+ onGetOffChainSignature: (messageHash: string) => Promise
+}
+
+const useAppCommunicator = (
+ iframeRef: MutableRefObject,
+ app: SafeAppData | undefined,
+ chain: WebCoreChainInfo | undefined,
+ handlers: UseAppCommunicatorHandlers,
+): AppCommunicator | undefined => {
+ const [communicator, setCommunicator] = useState(undefined)
+
+ const safeAppWeb3Provider = useMemo(() => {
+ if (!chain) {
+ return
+ }
+
+ return createSafeAppsWeb3Provider(chain.rpcUri.value)
+ }, [chain])
+
+ useEffect(() => {
+ let communicatorInstance: AppCommunicator
+
+ const initCommunicator = (iframeRef: MutableRefObject, app?: SafeAppData) => {
+ communicatorInstance = new AppCommunicator(iframeRef, {
+ onMessage: (msg) => {
+ if (!msg.data) return
+ },
+ onError: (error) => {
+ logError(Errors._901, error.message)
+ },
+ })
+
+ setCommunicator(communicatorInstance)
+ }
+
+ if (app) {
+ initCommunicator(iframeRef as MutableRefObject, app)
+ }
+
+ return () => {
+ communicatorInstance?.clear()
+ }
+ }, [app, iframeRef])
+
+ // Adding communicator logic for the required SDK Methods
+ // We don't need to unsubscribe from the events because there can be just one subscription
+ // per event type and the next effect run will simply replace the handlers
+ useEffect(() => {
+ communicator?.on(Methods.getTxBySafeTxHash, (msg) => {
+ const { safeTxHash } = msg.data.params as GetTxBySafeTxHashParams
+
+ return handlers.onGetTxBySafeTxHash(safeTxHash)
+ })
+
+ communicator?.on(Methods.getEnvironmentInfo, handlers.onGetEnvironmentInfo)
+
+ communicator?.on(Methods.getSafeInfo, handlers.onGetSafeInfo)
+
+ communicator?.on(Methods.getSafeBalances, (msg) => {
+ const { currency = 'usd' } = msg.data.params as GetBalanceParams
+
+ return handlers.onGetSafeBalances(currency)
+ })
+
+ communicator?.on(Methods.rpcCall, async (msg) => {
+ const params = msg.data.params as RPCPayload
+
+ if (params.call === RPC_CALLS.safe_setSettings) {
+ const settings = params.params[0] as SafeSettings
+ return handlers.onSetSafeSettings(settings)
+ }
+
+ if (!safeAppWeb3Provider) {
+ throw new Error('SafeAppWeb3Provider is not initialized')
+ }
+
+ try {
+ return await safeAppWeb3Provider.send(params.call, params.params)
+ } catch (err) {
+ throw new Error((err as JsonRpcResponse).error)
+ }
+ })
+
+ communicator?.on(Methods.sendTransactions, (msg) => {
+ const { txs, params } = msg.data.params as SendTransactionsParams
+
+ const transactions = txs.map(({ to, value, data }) => {
+ return {
+ to: getAddress(to),
+ value: value ? BigNumber.from(value).toString() : '0',
+ data: data || '0x',
+ }
+ })
+
+ handlers.onConfirmTransactions(transactions, msg.data.id, params)
+ })
+
+ communicator?.on(Methods.signMessage, (msg) => {
+ const { message } = msg.data.params as SignMessageParams
+ const sdkVersion = msg.data.env.sdkVersion
+ handlers.onSignMessage(message, msg.data.id, Methods.signMessage, sdkVersion)
+ })
+
+ communicator?.on(Methods.getOffChainSignature, (msg) => {
+ return handlers.onGetOffChainSignature(msg.data.params as string)
+ })
+
+ communicator?.on(Methods.signTypedMessage, (msg) => {
+ const { typedData } = msg.data.params as SignTypedMessageParams
+ const sdkVersion = msg.data.env.sdkVersion
+ handlers.onSignMessage(typedData, msg.data.id, Methods.signTypedMessage, sdkVersion)
+ })
+
+ communicator?.on(Methods.getChainInfo, handlers.onGetChainInfo)
+
+ communicator?.on(Methods.wallet_getPermissions, (msg) => {
+ return handlers.onGetPermissions(msg.origin)
+ })
+
+ communicator?.on(Methods.wallet_requestPermissions, (msg) => {
+ handlers.onSetPermissions({
+ origin: msg.origin,
+ request: msg.data.params as PermissionRequest[],
+ requestId: msg.data.id,
+ })
+ })
+
+ communicator?.on(Methods.requestAddressBook, (msg) => {
+ return handlers.onRequestAddressBook(msg.origin)
+ })
+ }, [safeAppWeb3Provider, handlers, chain, communicator])
+
+ return communicator
+}
+
+export default useAppCommunicator
diff --git a/src/components/safe-apps/AppFrame/useAppIsLoading.ts b/src/components/safe-apps/AppFrame/useAppIsLoading.ts
new file mode 100644
index 000000000..ab1d74c94
--- /dev/null
+++ b/src/components/safe-apps/AppFrame/useAppIsLoading.ts
@@ -0,0 +1,56 @@
+import { useEffect, useRef, useState } from 'react'
+
+const APP_LOAD_ERROR_TIMEOUT = 30000
+const APP_SLOW_LOADING_WARNING_TIMEOUT = 15_000
+const APP_LOAD_ERROR = 'There was an error loading the Safe App. There might be a problem with the Safe App provider.'
+
+type UseAppIsLoadingReturnType = {
+ iframeRef: React.RefObject
+ appIsLoading: boolean
+ setAppIsLoading: (appIsLoading: boolean) => void
+ isLoadingSlow: boolean
+}
+
+const useAppIsLoading = (): UseAppIsLoadingReturnType => {
+ const [appIsLoading, setAppIsLoading] = useState(true)
+ const [isLoadingSlow, setIsLoadingSlow] = useState(false)
+ const [, setAppLoadError] = useState(false)
+
+ const iframeRef = useRef(null)
+ const timer = useRef()
+ const errorTimer = useRef()
+
+ useEffect(() => {
+ const clearTimeouts = () => {
+ clearTimeout(timer.current)
+ clearTimeout(errorTimer.current)
+ }
+
+ if (appIsLoading) {
+ timer.current = window.setTimeout(() => {
+ setIsLoadingSlow(true)
+ }, APP_SLOW_LOADING_WARNING_TIMEOUT)
+ errorTimer.current = window.setTimeout(() => {
+ setAppLoadError(() => {
+ throw Error(APP_LOAD_ERROR)
+ })
+ }, APP_LOAD_ERROR_TIMEOUT)
+ } else {
+ clearTimeouts()
+ setIsLoadingSlow(false)
+ }
+
+ return () => {
+ clearTimeouts()
+ }
+ }, [appIsLoading])
+
+ return {
+ iframeRef,
+ appIsLoading,
+ setAppIsLoading,
+ isLoadingSlow,
+ }
+}
+
+export default useAppIsLoading
diff --git a/src/components/safe-apps/AppFrame/useGetSafeInfo.ts b/src/components/safe-apps/AppFrame/useGetSafeInfo.ts
new file mode 100644
index 000000000..ad3a4048b
--- /dev/null
+++ b/src/components/safe-apps/AppFrame/useGetSafeInfo.ts
@@ -0,0 +1,28 @@
+import { useMemo } from 'react'
+import useChainId from '@/hooks/useChainId'
+import { useCurrentChain } from '@/hooks/useChains'
+import useIsSafeOwner from '@/hooks/useIsSafeOwner'
+import useSafeInfo from '@/hooks/useSafeInfo'
+import { getLegacyChainName } from '../utils'
+
+const useGetSafeInfo = () => {
+ const { safe, safeAddress } = useSafeInfo()
+ const isOwner = useIsSafeOwner()
+ const chainId = useChainId()
+ const chain = useCurrentChain()
+ const chainName = chain?.chainName || ''
+
+ return useMemo(
+ () => () => ({
+ safeAddress,
+ chainId: parseInt(chainId, 10),
+ owners: safe.owners.map((owner) => owner.value),
+ threshold: safe.threshold,
+ isReadOnly: !isOwner,
+ network: getLegacyChainName(chainName || '', chainId).toUpperCase(),
+ }),
+ [chainId, chainName, isOwner, safeAddress, safe.owners, safe.threshold],
+ )
+}
+
+export default useGetSafeInfo
diff --git a/src/components/safe-apps/AppFrame/useTransactionQueueBarState.ts b/src/components/safe-apps/AppFrame/useTransactionQueueBarState.ts
new file mode 100644
index 000000000..294e4472d
--- /dev/null
+++ b/src/components/safe-apps/AppFrame/useTransactionQueueBarState.ts
@@ -0,0 +1,27 @@
+import { useCallback, useContext, useEffect, useState } from 'react'
+import { TxModalContext } from '@/components/tx-flow'
+
+const useTransactionQueueBarState = () => {
+ const [expanded, setExpanded] = useState(false)
+ const [dismissedByUser, setDismissedByUser] = useState(false)
+ const { page = { results: [] } } = {}
+ const { txFlow } = useContext(TxModalContext)
+
+ const dismissQueueBar = useCallback((): void => {
+ setDismissedByUser(true)
+ }, [])
+
+ useEffect(() => {
+ if (txFlow) setExpanded(false)
+ }, [txFlow])
+
+ return {
+ expanded,
+ dismissedByUser,
+ setExpanded,
+ dismissQueueBar,
+ transactions: page,
+ }
+}
+
+export default useTransactionQueueBarState
diff --git a/src/components/safe-apps/PermissionCheckbox.tsx b/src/components/safe-apps/PermissionCheckbox.tsx
new file mode 100644
index 000000000..74a91476c
--- /dev/null
+++ b/src/components/safe-apps/PermissionCheckbox.tsx
@@ -0,0 +1,23 @@
+import { Checkbox, FormControlLabel } from '@mui/material'
+
+type PermissionsCheckboxProps = {
+ label: string
+ name: string
+ checked: boolean
+ onChange: (event: React.ChangeEvent, checked: boolean) => void
+}
+
+const PermissionsCheckbox = ({ label, checked, onChange, name }: PermissionsCheckboxProps): React.ReactElement => (
+ ({
+ flex: 1,
+ '.MuiIconButton-root:not(.Mui-checked)': {
+ color: palette.text.disabled,
+ },
+ })}
+ control={ }
+ label={label}
+ />
+)
+
+export default PermissionsCheckbox
diff --git a/src/components/safe-apps/PermissionsPrompt.tsx b/src/components/safe-apps/PermissionsPrompt.tsx
new file mode 100644
index 000000000..117476d8d
--- /dev/null
+++ b/src/components/safe-apps/PermissionsPrompt.tsx
@@ -0,0 +1,63 @@
+import type { ReactElement } from 'react'
+import type { PermissionRequest } from '@safe-global/safe-apps-sdk/dist/types/types/permissions'
+import { Button, Dialog, DialogActions, DialogContent, Divider, Typography } from '@mui/material'
+
+import { ModalDialogTitle } from '@/components/common/ModalDialog'
+import { getSafePermissionDisplayValues } from '@/hooks/safe-apps/permissions'
+
+interface PermissionsPromptProps {
+ origin: string
+ isOpen: boolean
+ requestId: string
+ permissions: PermissionRequest[]
+ onReject: (requestId?: string) => void
+ onAccept: (origin: string, requestId: string) => void
+}
+
+const PermissionsPrompt = ({
+ origin,
+ isOpen,
+ requestId,
+ permissions,
+ onReject,
+ onAccept,
+}: PermissionsPromptProps): ReactElement => {
+ return (
+
+ onReject()}>
+
+ Permissions Request
+
+
+
+
+
+ {origin} is requesting permissions for:
+
+
+ {permissions.map((permission, index) => (
+
+ {getSafePermissionDisplayValues(Object.keys(permission)[0]).description}
+
+ ))}
+
+
+
+ onReject(requestId)}
+ sx={{ minWidth: '130px' }}
+ >
+ Reject
+
+ onAccept(origin, requestId)} sx={{ minWidth: '130px' }}>
+ Accept
+
+
+
+ )
+}
+
+export default PermissionsPrompt
diff --git a/src/components/safe-apps/RemoveCustomAppModal.tsx b/src/components/safe-apps/RemoveCustomAppModal.tsx
new file mode 100644
index 000000000..74c835639
--- /dev/null
+++ b/src/components/safe-apps/RemoveCustomAppModal.tsx
@@ -0,0 +1,29 @@
+import * as React from 'react'
+import { DialogActions, DialogContent, Typography, Button } from '@mui/material'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+import ModalDialog from '@/components/common/ModalDialog'
+
+type Props = {
+ open: boolean
+ app: SafeAppData
+ onClose: () => void
+ onConfirm: (appId: number) => void
+}
+
+const RemoveCustomAppModal = ({ open, onClose, onConfirm, app }: Props) => (
+
+
+
+ Are you sure you want to remove the {app.name} app?
+
+
+
+ Cancel
+ onConfirm(app.id)}>
+ Remove
+
+
+
+)
+
+export { RemoveCustomAppModal }
diff --git a/src/components/safe-apps/SafeAppActionButtons/index.tsx b/src/components/safe-apps/SafeAppActionButtons/index.tsx
new file mode 100644
index 000000000..daf8e7d24
--- /dev/null
+++ b/src/components/safe-apps/SafeAppActionButtons/index.tsx
@@ -0,0 +1,95 @@
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+import Box from '@mui/material/Box'
+import IconButton from '@mui/material/IconButton'
+import Tooltip from '@mui/material/Tooltip'
+import SvgIcon from '@mui/material/SvgIcon'
+
+import { useShareSafeAppUrl } from '@/components/safe-apps/hooks/useShareSafeAppUrl'
+import CopyButton from '@/components/common/CopyButton'
+import ShareIcon from '@/public/images/common/share.svg'
+import BookmarkIcon from '@/public/images/apps/bookmark.svg'
+import BookmarkedIcon from '@/public/images/apps/bookmarked.svg'
+import DeleteIcon from '@/public/images/common/delete.svg'
+import InfoIcon from '@/public/images/notifications/info.svg'
+
+type SafeAppActionButtonsProps = {
+ safeApp: SafeAppData
+ isBookmarked?: boolean
+ onBookmarkSafeApp?: (safeAppId: number) => void
+ removeCustomApp?: (safeApp: SafeAppData) => void
+ openPreviewDrawer?: (safeApp: SafeAppData) => void
+}
+
+const SafeAppActionButtons = ({
+ safeApp,
+ isBookmarked,
+ onBookmarkSafeApp,
+ removeCustomApp,
+ openPreviewDrawer,
+}: SafeAppActionButtonsProps) => {
+ const shareSafeAppUrl = useShareSafeAppUrl(safeApp.url)
+
+ return (
+
+ {/* Open the preview drawer */}
+ {openPreviewDrawer && (
+ {
+ event.preventDefault()
+ event.stopPropagation()
+ openPreviewDrawer(safeApp)
+ }}
+ >
+
+
+ )}
+
+ {/* Copy share Safe App url button */}
+
+
+
+
+
+
+ {/* Bookmark Safe App button */}
+ {onBookmarkSafeApp && (
+
+ {
+ event.preventDefault()
+ event.stopPropagation()
+ onBookmarkSafeApp(safeApp.id)
+ }}
+ >
+
+
+
+ )}
+
+ {/* Remove Custom Safe App button */}
+ {removeCustomApp && (
+
+ {
+ event.preventDefault()
+ event.stopPropagation()
+ removeCustomApp(safeApp)
+ }}
+ >
+
+
+
+ )}
+
+ )
+}
+
+export default SafeAppActionButtons
diff --git a/src/components/safe-apps/SafeAppCard/index.tsx b/src/components/safe-apps/SafeAppCard/index.tsx
new file mode 100644
index 000000000..f8dbfd317
--- /dev/null
+++ b/src/components/safe-apps/SafeAppCard/index.tsx
@@ -0,0 +1,163 @@
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+import Card from '@mui/material/Card'
+import CardHeader from '@mui/material/CardHeader'
+import CardContent from '@mui/material/CardContent'
+import Typography from '@mui/material/Typography'
+import { resolveHref } from 'next/dist/client/resolve-href'
+import classNames from 'classnames'
+import type { ReactNode, SyntheticEvent } from 'react'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+import type { NextRouter } from 'next/router'
+
+import type { UrlObject } from 'url'
+import SafeAppIconCard from '@/components/safe-apps/SafeAppIconCard'
+import SafeAppActionButtons from '@/components/safe-apps/SafeAppActionButtons'
+import SafeAppTags from '@/components/safe-apps/SafeAppTags'
+import { isOptimizedForBatchTransactions } from '@/components/safe-apps/utils'
+import { AppRoutes } from '@/config/routes'
+import BatchIcon from '@/public/images/apps/batch-icon.svg'
+import css from './styles.module.css'
+
+type SafeAppCardProps = {
+ safeApp: SafeAppData
+ onClickSafeApp?: () => void
+ isBookmarked?: boolean
+ onBookmarkSafeApp?: (safeAppId: number) => void
+ removeCustomApp?: (safeApp: SafeAppData) => void
+ openPreviewDrawer?: (safeApp: SafeAppData) => void
+}
+
+const SafeAppCard = ({
+ safeApp,
+ onClickSafeApp,
+ isBookmarked,
+ onBookmarkSafeApp,
+ removeCustomApp,
+ openPreviewDrawer,
+}: SafeAppCardProps) => {
+ const router = useRouter()
+
+ const safeAppUrl = getSafeAppUrl(router, safeApp.url)
+
+ return (
+
+ )
+}
+
+export default SafeAppCard
+
+export const getSafeAppUrl = (router: NextRouter, safeAppUrl: string) => {
+ const shareUrlObj: UrlObject = {
+ pathname: AppRoutes.apps.open,
+ query: { safe: router.query.safe, appUrl: safeAppUrl },
+ }
+
+ return resolveHref(router, shareUrlObj)
+}
+
+type SafeAppCardViewProps = {
+ safeApp: SafeAppData
+ onClickSafeApp?: () => void
+ safeAppUrl: string
+ isBookmarked?: boolean
+ onBookmarkSafeApp?: (safeAppId: number) => void
+ removeCustomApp?: (safeApp: SafeAppData) => void
+ openPreviewDrawer?: (safeApp: SafeAppData) => void
+}
+
+const SafeAppCardGridView = ({
+ safeApp,
+ onClickSafeApp,
+ safeAppUrl,
+ isBookmarked,
+ onBookmarkSafeApp,
+ removeCustomApp,
+ openPreviewDrawer,
+}: SafeAppCardViewProps) => {
+ return (
+
+ {/* Safe App Header */}
+
+ {/* Batch transactions Icon */}
+ {isOptimizedForBatchTransactions(safeApp) && (
+
+ )}
+
+ {/* Safe App Icon */}
+
+
+ }
+ action={
+ <>
+ {/* Safe App Action Buttons */}
+
+ >
+ }
+ />
+
+
+ {/* Safe App Title */}
+
+ {safeApp.name}
+
+
+ {/* Safe App Description */}
+
+ {safeApp.description}
+
+
+ {/* Safe App Tags */}
+
+
+
+ )
+}
+
+type SafeAppCardContainerProps = {
+ onClickSafeApp?: () => void
+ safeAppUrl: string
+ children: ReactNode
+ height?: string
+ className?: string
+}
+
+export const SafeAppCardContainer = ({
+ children,
+ safeAppUrl,
+ onClickSafeApp,
+ height,
+ className,
+}: SafeAppCardContainerProps) => {
+ const handleClickSafeApp = (event: SyntheticEvent) => {
+ if (onClickSafeApp) {
+ event.preventDefault()
+ onClickSafeApp()
+ }
+ }
+
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/src/components/safe-apps/SafeAppCard/styles.module.css b/src/components/safe-apps/SafeAppCard/styles.module.css
new file mode 100644
index 000000000..506205689
--- /dev/null
+++ b/src/components/safe-apps/SafeAppCard/styles.module.css
@@ -0,0 +1,63 @@
+.safeAppContainer {
+ transition: background-color 0.3s ease-in-out, border 0.3s ease-in-out;
+ border: 1px solid transparent;
+}
+
+.safeAppContainer:hover {
+ background-color: var(--color-background-light);
+ border: 1px solid var(--color-secondary-light);
+}
+
+.safeAppHeader {
+ padding: var(--space-3) var(--space-2) 0 var(--space-2);
+}
+
+.safeAppContent {
+ padding: var(--space-2);
+}
+
+.safeAppIconContainer {
+ position: relative;
+}
+
+.safeAppIconContainer iframe {
+ display: block;
+}
+
+.safeAppBatchIcon {
+ position: absolute;
+ top: -6px;
+ right: -8px;
+}
+
+.safeAppTitle {
+ line-height: 175%;
+ margin: 0;
+
+ flex-grow: 1;
+
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.safeAppDescription {
+ /* Truncate Safe App Description (3 lines) */
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.safeAppTagContainer {
+ padding-top: var(--space-2);
+}
+
+.safeAppTagLabel {
+ border-radius: 4px;
+ height: 24px;
+}
+
+.safeAppTagLabel > * {
+ padding: var(--space-1);
+}
diff --git a/src/components/safe-apps/SafeAppLandingPage/AppActions.tsx b/src/components/safe-apps/SafeAppLandingPage/AppActions.tsx
new file mode 100644
index 000000000..df90e1eb8
--- /dev/null
+++ b/src/components/safe-apps/SafeAppLandingPage/AppActions.tsx
@@ -0,0 +1,190 @@
+import { Box, Button, MenuItem, Select, Typography, Grid, FormControl, InputLabel } from '@mui/material'
+import type { ChainInfo, SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+import { useEffect, useMemo, useState } from 'react'
+import Link from 'next/link'
+import type { UrlObject } from 'url'
+import type { ConnectedWallet } from '@/hooks/wallets/useOnboard'
+import { useAppSelector } from '@/store'
+import { selectAllAddressBooks } from '@/store/addressBookSlice'
+import { selectChains } from '@/store/chainsSlice'
+import useLastSafe from '@/hooks/useLastSafe'
+import { parsePrefixedAddress } from '@/utils/addresses'
+import SafeIcon from '@/components/common/SafeIcon'
+import EthHashInfo from '@/components/common/EthHashInfo'
+import { AppRoutes } from '@/config/routes'
+import { CTA_BUTTON_WIDTH, CTA_HEIGHT } from '@/components/safe-apps/SafeAppLandingPage/constants'
+import CreateNewSafeSVG from '@/public/images/open/safe-creation.svg'
+
+type Props = {
+ appUrl: string
+ wallet: ConnectedWallet | null
+ onConnectWallet: () => Promise
+ chain: ChainInfo
+ app: SafeAppData
+}
+
+type CompatibleSafesType = { address: string; chainId: string; shortName?: string }
+
+const AppActions = ({ wallet, onConnectWallet, chain, appUrl, app }: Props): React.ReactElement => {
+ const lastUsedSafe = useLastSafe()
+ const addressBook = useAppSelector(selectAllAddressBooks)
+ const chains = useAppSelector(selectChains)
+ const compatibleChains = app.chainIds
+
+ const compatibleSafes = useMemo(
+ () => getCompatibleSafes({}, compatibleChains, chains.data),
+ [compatibleChains, chains.data],
+ )
+
+ const [safeToUse, setSafeToUse] = useState()
+
+ useEffect(() => {
+ const defaultSafe = getDefaultSafe(compatibleSafes, chain.chainId, lastUsedSafe)
+ if (defaultSafe) {
+ setSafeToUse(defaultSafe)
+ }
+ }, [compatibleSafes, chain.chainId, lastUsedSafe])
+
+ const hasWallet = !!wallet
+ const hasSafes = compatibleSafes.length > 0
+ const shouldCreateSafe = hasWallet && !hasSafes
+
+ let button: React.ReactNode
+ switch (true) {
+ case hasWallet && hasSafes && !!safeToUse:
+ const safe = `${safeToUse?.shortName}:${safeToUse?.address}`
+ const href: UrlObject = {
+ pathname: AppRoutes.apps.open,
+ query: { safe, appUrl },
+ }
+
+ button = (
+
+
+ Use app
+
+
+ )
+ break
+ case shouldCreateSafe:
+ const redirect = `${AppRoutes.apps.index}?appUrl=${appUrl}`
+ const createSafeHrefWithRedirect: UrlObject = {
+ // pathname: AppRoutes.newSafe.create,
+ query: { safeViewRedirectURL: redirect, chain: chain.shortName },
+ }
+ button = (
+
+
+ Create new Safe Account
+
+
+ )
+ break
+ default:
+ button = (
+
+ Connect wallet
+
+ )
+ }
+ let body: React.ReactNode
+ if (hasWallet && hasSafes) {
+ body = (
+
+ Select a Safe Account
+ {
+ const safeToUse = compatibleSafes.find(({ address }) => address === e.target.value)
+ setSafeToUse(safeToUse)
+ }}
+ autoWidth
+ label="Select a Safe Account"
+ sx={({ spacing }) => ({
+ width: '311px',
+ minHeight: '56px',
+ '.MuiSelect-select': { padding: `${spacing(1)} ${spacing(2)}` },
+ })}
+ >
+ {compatibleSafes.map(({ address, chainId, shortName }) => (
+
+
+
+
+
+ {addressBook?.[chainId]?.[address]}
+
+
+
+
+
+ ))}
+
+
+ )
+ } else {
+ body =
+ }
+
+ return (
+
+
+ Use the App with your Safe Account
+
+ {body}
+ {button}
+
+ )
+}
+
+export { AppActions }
+
+const getCompatibleSafes = (
+ ownedSafes: { [chainId: string]: string[] },
+ compatibleChains: string[],
+ chainsData: ChainInfo[],
+): CompatibleSafesType[] => {
+ return compatibleChains.reduce((safes, chainId) => {
+ const chainData = chainsData.find((chain: ChainInfo) => chain.chainId === chainId)
+
+ return [
+ ...safes,
+ ...(ownedSafes[chainId] || []).map((address) => ({
+ address,
+ chainId,
+ shortName: chainData?.shortName,
+ })),
+ ]
+ }, [])
+}
+
+const getDefaultSafe = (
+ compatibleSafes: CompatibleSafesType[],
+ chainId: string,
+ lastUsedSafe = '',
+): CompatibleSafesType => {
+ // as a first option, we use the last used Safe in the provided chain
+ const lastViewedSafe = compatibleSafes.find((safe) => safe.address === parsePrefixedAddress(lastUsedSafe).address)
+
+ if (lastViewedSafe) {
+ return lastViewedSafe
+ }
+
+ // as a second option, we use any user Safe in the provided chain
+ const safeInTheSameChain = compatibleSafes.find((safe) => safe.chainId === chainId)
+
+ if (safeInTheSameChain) {
+ return safeInTheSameChain
+ }
+
+ // as a fallback we salect a random compatible user Safe
+ return compatibleSafes[0]
+}
diff --git a/src/components/safe-apps/SafeAppLandingPage/SafeAppDetails.tsx b/src/components/safe-apps/SafeAppLandingPage/SafeAppDetails.tsx
new file mode 100644
index 000000000..aaa6ce86c
--- /dev/null
+++ b/src/components/safe-apps/SafeAppLandingPage/SafeAppDetails.tsx
@@ -0,0 +1,79 @@
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+import { Box } from '@mui/system'
+import Typography from '@mui/material/Typography'
+import Divider from '@mui/material/Divider'
+import ChainIndicator from '@/components/common/ChainIndicator'
+import WarningIcon from '@/public/images/notifications/warning.svg'
+import SvgIcon from '@mui/material/SvgIcon'
+import SafeAppIconCard from '@/components/safe-apps/SafeAppIconCard'
+
+type DetailsProps = {
+ app: SafeAppData
+ showDefaultListWarning: boolean
+}
+
+const SafeAppDetails = ({ app, showDefaultListWarning }: DetailsProps) => (
+
+
+
+
+
+
+ {app.name}
+
+
+ {app.description}
+
+
+
+
+
+ Safe App URL
+ ({
+ mt: 1,
+ p: 1,
+ backgroundColor: palette.primary.background,
+ display: 'inline-block',
+ borderRadius: shape.borderRadius,
+ })}
+ fontWeight={700}
+ >
+ {app.url}
+
+
+
+ Available networks
+
+ {app.chainIds.map((chainId) => (
+
+ ))}
+
+
+
+ {showDefaultListWarning && (
+
+
+
+ {/*
+ //@ts-ignore - "warning.dark" is a present in the palette */}
+
+ ({ color: palette.warning.dark })}>
+ Warning
+
+
+ ({ color: palette.warning.dark })}>
+ The application is not in the default Safe App list
+
+
+ Check the app link and ensure it comes from a trusted source
+
+
+
+
+ )}
+
+)
+
+export { SafeAppDetails }
diff --git a/src/components/safe-apps/SafeAppLandingPage/TryDemo.tsx b/src/components/safe-apps/SafeAppLandingPage/TryDemo.tsx
new file mode 100644
index 000000000..3266a4237
--- /dev/null
+++ b/src/components/safe-apps/SafeAppLandingPage/TryDemo.tsx
@@ -0,0 +1,26 @@
+import { Box, Button, Typography } from '@mui/material'
+import { CTA_HEIGHT, CTA_BUTTON_WIDTH } from '@/components/safe-apps/SafeAppLandingPage/constants'
+import Link from 'next/link'
+import type { LinkProps } from 'next/link'
+import DemoAppSVG from '@/public/images/apps/apps-demo.svg'
+
+type Props = {
+ demoUrl: LinkProps['href']
+ onClick(): void
+}
+
+const TryDemo = ({ demoUrl, onClick }: Props) => (
+
+
+ Try the Safe App before using it
+
+
+
+
+ Try demo
+
+
+
+)
+
+export { TryDemo }
diff --git a/src/components/safe-apps/SafeAppLandingPage/constants.ts b/src/components/safe-apps/SafeAppLandingPage/constants.ts
new file mode 100644
index 000000000..07c83ecdf
--- /dev/null
+++ b/src/components/safe-apps/SafeAppLandingPage/constants.ts
@@ -0,0 +1,4 @@
+const CTA_HEIGHT = '218px'
+const CTA_BUTTON_WIDTH = '186px'
+
+export { CTA_HEIGHT, CTA_BUTTON_WIDTH }
diff --git a/src/components/safe-apps/SafeAppLandingPage/index.tsx b/src/components/safe-apps/SafeAppLandingPage/index.tsx
new file mode 100644
index 000000000..7872206d6
--- /dev/null
+++ b/src/components/safe-apps/SafeAppLandingPage/index.tsx
@@ -0,0 +1,64 @@
+import { Box, CircularProgress, Paper } from '@mui/material'
+import Grid from '@mui/material/Unstable_Grid2'
+import { useSafeAppFromManifest } from '@/hooks/safe-apps/useSafeAppFromManifest'
+import { SafeAppDetails } from '@/components/safe-apps/SafeAppLandingPage/SafeAppDetails'
+import { AppActions } from '@/components/safe-apps/SafeAppLandingPage/AppActions'
+import useWallet from '@/hooks/wallets/useWallet'
+import useOnboard from '@/hooks/wallets/useOnboard'
+import { Errors, logError } from '@/services/exceptions'
+import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk'
+
+type Props = {
+ appUrl: string
+ chain: ChainInfo
+}
+
+const CHAIN_ID_WITH_A_DEMO = '1'
+
+const SafeAppLanding = ({ appUrl, chain }: Props) => {
+ const { safeApp, isLoading } = useSafeAppFromManifest(appUrl, chain.chainId)
+ const wallet = useWallet()
+ const onboard = useOnboard()
+ const showDemo = chain.chainId === CHAIN_ID_WITH_A_DEMO
+
+ const handleConnectWallet = async () => {
+ if (!onboard) return
+
+ onboard.connectWallet().catch((e) => logError(Errors._302, e))
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (!safeApp) {
+ return No Safe App found
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export { SafeAppLanding }
diff --git a/src/components/safe-apps/SafeAppList/index.tsx b/src/components/safe-apps/SafeAppList/index.tsx
new file mode 100644
index 000000000..1dceec953
--- /dev/null
+++ b/src/components/safe-apps/SafeAppList/index.tsx
@@ -0,0 +1,102 @@
+import { useCallback } from 'react'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+
+import SafeAppCard from '@/components/safe-apps/SafeAppCard'
+import AddCustomSafeAppCard from '@/components/safe-apps/AddCustomSafeAppCard'
+import SafeAppPreviewDrawer from '@/components/safe-apps/SafeAppPreviewDrawer'
+import SafeAppsListHeader from '@/components/safe-apps/SafeAppsListHeader'
+import SafeAppsZeroResultsPlaceholder from '@/components/safe-apps/SafeAppsZeroResultsPlaceholder'
+import useSafeAppPreviewDrawer from '@/hooks/safe-apps/useSafeAppPreviewDrawer'
+import css from './styles.module.css'
+import { Skeleton } from '@mui/material'
+import { useOpenedSafeApps } from '@/hooks/safe-apps/useOpenedSafeApps'
+
+type SafeAppListProps = {
+ safeAppsList: SafeAppData[]
+ safeAppsListLoading?: boolean
+ bookmarkedSafeAppsId?: Set
+ onBookmarkSafeApp?: (safeAppId: number) => void
+ addCustomApp?: (safeApp: SafeAppData) => void
+ removeCustomApp?: (safeApp: SafeAppData) => void
+ title: string
+ query?: string
+}
+
+const SafeAppList = ({
+ safeAppsList,
+ safeAppsListLoading,
+ bookmarkedSafeAppsId,
+ onBookmarkSafeApp,
+ addCustomApp,
+ removeCustomApp,
+ title,
+ query,
+}: SafeAppListProps) => {
+ const { isPreviewDrawerOpen, previewDrawerApp, openPreviewDrawer, closePreviewDrawer } = useSafeAppPreviewDrawer()
+ const { openedSafeAppIds } = useOpenedSafeApps()
+
+ const showZeroResultsPlaceholder = query && safeAppsList.length === 0
+
+ const handleSafeAppClick = useCallback(
+ (safeApp: SafeAppData) => {
+ const isCustomApp = safeApp.id < 1
+
+ if (isCustomApp || openedSafeAppIds.includes(safeApp.id)) return
+
+ return () => openPreviewDrawer(safeApp)
+ },
+ [openPreviewDrawer, openedSafeAppIds],
+ )
+
+ return (
+ <>
+ {/* Safe Apps List Header */}
+
+
+ {/* Safe Apps List */}
+
+ {/* Add Custom Safe App Card */}
+ {addCustomApp && (
+
+
+
+ )}
+
+ {safeAppsListLoading &&
+ Array.from({ length: 8 }, (_, index) => (
+
+
+
+ ))}
+
+ {/* Flat list filtered by search query */}
+ {safeAppsList.map((safeApp) => (
+
+
+
+ ))}
+
+
+ {/* Zero results placeholder */}
+ {showZeroResultsPlaceholder && }
+
+ {/* Safe App Preview Drawer */}
+
+ >
+ )
+}
+
+export default SafeAppList
diff --git a/src/components/safe-apps/SafeAppList/styles.module.css b/src/components/safe-apps/SafeAppList/styles.module.css
new file mode 100644
index 000000000..6d2cb4f4d
--- /dev/null
+++ b/src/components/safe-apps/SafeAppList/styles.module.css
@@ -0,0 +1,7 @@
+.safeAppsContainer {
+ display: grid;
+ grid-gap: var(--space-3);
+ list-style-type: none;
+ padding: 0 0 var(--space-1);
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+}
diff --git a/src/components/safe-apps/SafeAppPreviewDrawer/index.tsx b/src/components/safe-apps/SafeAppPreviewDrawer/index.tsx
new file mode 100644
index 000000000..8f64e2e33
--- /dev/null
+++ b/src/components/safe-apps/SafeAppPreviewDrawer/index.tsx
@@ -0,0 +1,113 @@
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+import Drawer from '@mui/material/Drawer'
+import Box from '@mui/material/Box'
+import Typography from '@mui/material/Typography'
+import Button from '@mui/material/Button'
+import SvgIcon from '@mui/material/SvgIcon'
+import IconButton from '@mui/material/IconButton'
+import Tooltip from '@mui/material/Tooltip'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+
+import { getSafeAppUrl } from '@/components/safe-apps/SafeAppCard'
+import ChainIndicator from '@/components/common/ChainIndicator'
+import SafeAppIconCard from '@/components/safe-apps/SafeAppIconCard'
+import SafeAppActionButtons from '@/components/safe-apps/SafeAppActionButtons'
+import SafeAppTags from '@/components/safe-apps/SafeAppTags'
+import SafeAppSocialLinksCard from '@/components/safe-apps/SafeAppSocialLinksCard'
+import CloseIcon from '@/public/images/common/close.svg'
+import { useOpenedSafeApps } from '@/hooks/safe-apps/useOpenedSafeApps'
+import css from './styles.module.css'
+
+type SafeAppPreviewDrawerProps = {
+ safeApp?: SafeAppData
+ isOpen: boolean
+ isBookmarked?: boolean
+ onClose: () => void
+ onBookmark?: (safeAppId: number) => void
+}
+
+const SafeAppPreviewDrawer = ({ isOpen, safeApp, isBookmarked, onClose, onBookmark }: SafeAppPreviewDrawerProps) => {
+ const { markSafeAppOpened } = useOpenedSafeApps()
+ const router = useRouter()
+ const safeAppUrl = getSafeAppUrl(router, safeApp?.url || '')
+
+ const onOpenSafe = () => {
+ if (safeApp) {
+ markSafeAppOpened(safeApp.id)
+ }
+ }
+
+ return (
+
+
+ {/* Toolbar */}
+
+ {safeApp && (
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Safe App Info */}
+
+
+
+
+
+ {safeApp?.name}
+
+
+
+ {safeApp?.description}
+
+
+ {/* Tags */}
+
+
+ {/* Networks */}
+
+ Available networks
+
+
+
+ {safeApp?.chainIds.map((chainId) => (
+
+ ))}
+
+
+ {/* Open Safe App button */}
+
+
+ Open Safe App
+
+
+
+ {/* Safe App Social Links */}
+ {safeApp && }
+
+
+ )
+}
+
+export default SafeAppPreviewDrawer
diff --git a/src/components/safe-apps/SafeAppPreviewDrawer/styles.module.css b/src/components/safe-apps/SafeAppPreviewDrawer/styles.module.css
new file mode 100644
index 000000000..165313625
--- /dev/null
+++ b/src/components/safe-apps/SafeAppPreviewDrawer/styles.module.css
@@ -0,0 +1,10 @@
+.drawerContainer {
+ padding: calc(var(--header-height) + var(--space-3)) var(--space-3) 0 var(--space-3);
+ width: 100vw;
+}
+
+@media (min-width: 600px) {
+ .drawerContainer {
+ width: 450px;
+ }
+}
diff --git a/src/components/safe-apps/SafeAppSocialLinksCard/index.tsx b/src/components/safe-apps/SafeAppSocialLinksCard/index.tsx
new file mode 100644
index 000000000..dc149b96d
--- /dev/null
+++ b/src/components/safe-apps/SafeAppSocialLinksCard/index.tsx
@@ -0,0 +1,123 @@
+import Link from 'next/link'
+import Card from '@mui/material/Card'
+import Box from '@mui/material/Box'
+import Typography from '@mui/material/Typography'
+import IconButton from '@mui/material/IconButton'
+import Divider from '@mui/material/Divider'
+import { default as MuiLink } from '@mui/material/Link'
+import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded'
+import GitHubIcon from '@mui/icons-material/GitHub'
+import TwitterIcon from '@mui/icons-material/Twitter'
+import { SafeAppSocialPlatforms } from '@safe-global/safe-gateway-typescript-sdk'
+import type { SafeAppData, SafeAppSocialProfile } from '@safe-global/safe-gateway-typescript-sdk'
+
+import DiscordIcon from '@/public/images/common/discord-icon.svg'
+import css from './styles.module.css'
+
+type SafeAppSocialLinksCardProps = {
+ safeApp: SafeAppData
+}
+
+const SafeAppSocialLinksCard = ({ safeApp }: SafeAppSocialLinksCardProps) => {
+ const { socialProfiles, developerWebsite } = safeApp
+
+ const hasSocialLinks = socialProfiles?.length > 0
+
+ if (!hasSocialLinks && !developerWebsite) {
+ return null
+ }
+
+ const discordSocialLink = getSocialProfile(socialProfiles, SafeAppSocialPlatforms.DISCORD)
+ const twitterSocialLink = getSocialProfile(socialProfiles, SafeAppSocialPlatforms.TWITTER)
+ const githubSocialLink = getSocialProfile(socialProfiles, SafeAppSocialPlatforms.GITHUB)
+
+ return (
+
+
+ {/* Team Link section */}
+
+
+
+
+
+ Something wrong with the Safe App?
+
+
+ Get in touch with the team
+
+
+
+
+
+ {/* Social links section */}
+ {hasSocialLinks && (
+
+
+ Social Media
+
+
+
+ {discordSocialLink && (
+
+
+
+ )}
+
+ {twitterSocialLink && (
+
+
+
+ )}
+
+ {githubSocialLink && (
+
+
+
+ )}
+
+
+ )}
+
+ {hasSocialLinks && developerWebsite && (
+
+ )}
+
+ {/* Developer website section */}
+ {developerWebsite && (
+
+
+ Website
+
+
+
+
+ {developerWebsite}
+
+
+
+ )}
+
+
+ )
+}
+
+export default SafeAppSocialLinksCard
+
+const getSocialProfile = (socialProfiles: SafeAppSocialProfile[], platform: SafeAppSocialPlatforms) => {
+ const socialLink = socialProfiles.find((socialProfile) => socialProfile.platform === platform)
+
+ return socialLink
+}
diff --git a/src/components/safe-apps/SafeAppSocialLinksCard/styles.module.css b/src/components/safe-apps/SafeAppSocialLinksCard/styles.module.css
new file mode 100644
index 000000000..f573d3ea2
--- /dev/null
+++ b/src/components/safe-apps/SafeAppSocialLinksCard/styles.module.css
@@ -0,0 +1,26 @@
+.container {
+ margin-top: var(--space-4);
+ background-color: var(--color-info-background);
+ padding: var(--space-3);
+}
+
+.questionMarkIcon {
+ width: 40px;
+ height: 40px;
+ padding: var(--space-1);
+ font-size: 1.5rem;
+ border-radius: 50%;
+ background-color: #d7f6ff;
+}
+
+.socialLinksSectionContainer {
+ margin-top: var(--space-2);
+}
+
+.websiteLink {
+ /* Truncate Safe App link (2 lines) */
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
diff --git a/src/components/safe-apps/SafeAppTags/index.tsx b/src/components/safe-apps/SafeAppTags/index.tsx
new file mode 100644
index 000000000..50de82825
--- /dev/null
+++ b/src/components/safe-apps/SafeAppTags/index.tsx
@@ -0,0 +1,23 @@
+import Stack from '@mui/material/Stack'
+import Chip from '@mui/material/Chip'
+
+import { filterInternalCategories } from '@/components/safe-apps/utils'
+import css from './styles.module.css'
+
+type SafeAppTagsProps = {
+ tags: string[]
+}
+
+const SafeAppTags = ({ tags = [] }: SafeAppTagsProps) => {
+ const displayedTags = filterInternalCategories(tags)
+
+ return (
+
+ {displayedTags.map((tag) => (
+
+ ))}
+
+ )
+}
+
+export default SafeAppTags
diff --git a/src/components/safe-apps/SafeAppTags/styles.module.css b/src/components/safe-apps/SafeAppTags/styles.module.css
new file mode 100644
index 000000000..4937821d7
--- /dev/null
+++ b/src/components/safe-apps/SafeAppTags/styles.module.css
@@ -0,0 +1,8 @@
+.safeAppTagContainer {
+ padding-top: var(--space-2);
+}
+
+.safeAppTagLabel {
+ border-radius: 4px;
+ height: 24px;
+}
diff --git a/src/components/safe-apps/SafeAppsErrorBoundary/SafeAppsLoadError.tsx b/src/components/safe-apps/SafeAppsErrorBoundary/SafeAppsLoadError.tsx
new file mode 100644
index 000000000..77acb84c1
--- /dev/null
+++ b/src/components/safe-apps/SafeAppsErrorBoundary/SafeAppsLoadError.tsx
@@ -0,0 +1,37 @@
+import Typography from '@mui/material/Typography'
+import Button from '@mui/material/Button'
+import SvgIcon from '@mui/material/SvgIcon'
+import { DISCORD_URL } from '@/config/constants'
+import NetworkError from '@/public/images/apps/network-error.svg'
+
+import css from './styles.module.css'
+import ExternalLink from '@/components/common/ExternalLink'
+
+type SafeAppsLoadErrorProps = {
+ onBackToApps: () => void
+}
+
+const SafeAppsLoadError = ({ onBackToApps }: SafeAppsLoadErrorProps): React.ReactElement => {
+ return (
+
+
+
Safe App could not be loaded
+
+
+
+
+ In case the problem persists, please reach out to us via
+
+ Discord
+
+
+
+
+ Go back to the Safe Apps list
+
+
+
+ )
+}
+
+export default SafeAppsLoadError
diff --git a/src/components/safe-apps/SafeAppsErrorBoundary/index.tsx b/src/components/safe-apps/SafeAppsErrorBoundary/index.tsx
new file mode 100644
index 000000000..c4637a0af
--- /dev/null
+++ b/src/components/safe-apps/SafeAppsErrorBoundary/index.tsx
@@ -0,0 +1,41 @@
+import type { ReactNode, ErrorInfo } from 'react'
+import React from 'react'
+
+type SafeAppsErrorBoundaryProps = {
+ children?: ReactNode
+ render: () => ReactNode
+}
+
+type SafeAppsErrorBoundaryState = {
+ hasError: boolean
+ error?: Error
+}
+
+class SafeAppsErrorBoundary extends React.Component {
+ public state: SafeAppsErrorBoundaryState = {
+ hasError: false,
+ }
+
+ constructor(props: SafeAppsErrorBoundaryProps) {
+ super(props)
+ this.state = { hasError: false }
+ }
+
+ public componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
+ console.error('Uncaught error:', error, errorInfo)
+ }
+
+ public static getDerivedStateFromError(error: Error): SafeAppsErrorBoundaryState {
+ return { hasError: true, error }
+ }
+
+ public render(): React.ReactNode {
+ if (this.state.hasError) {
+ return this.props.render()
+ }
+
+ return this.props.children
+ }
+}
+
+export default SafeAppsErrorBoundary
diff --git a/src/components/safe-apps/SafeAppsErrorBoundary/styles.module.css b/src/components/safe-apps/SafeAppsErrorBoundary/styles.module.css
new file mode 100644
index 000000000..3ce2e006a
--- /dev/null
+++ b/src/components/safe-apps/SafeAppsErrorBoundary/styles.module.css
@@ -0,0 +1,43 @@
+.wrapper {
+ width: 100%;
+ height: calc(100vh - var(--header-height));
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+.content {
+ width: 400px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+}
+
+.content > * {
+ margin-top: 10px;
+}
+
+.linkWrapper {
+ display: inline-flex;
+ margin-bottom: 10px;
+ align-items: center;
+}
+
+.linkWrapper > :first-of-type {
+ margin-right: 5px;
+}
+
+.icon {
+ position: relative;
+ left: 3px;
+ top: 3px;
+}
+
+.image {
+ margin-top: 15px;
+ margin-bottom: 15px;
+ width: 64px;
+ height: 64px;
+}
diff --git a/src/components/safe-apps/SafeAppsFilters/index.tsx b/src/components/safe-apps/SafeAppsFilters/index.tsx
new file mode 100644
index 000000000..ecb7ee2eb
--- /dev/null
+++ b/src/components/safe-apps/SafeAppsFilters/index.tsx
@@ -0,0 +1,198 @@
+import Grid from '@mui/material/Grid'
+import TextField from '@mui/material/TextField'
+import InputAdornment from '@mui/material/InputAdornment'
+import SvgIcon from '@mui/material/SvgIcon'
+import InputLabel from '@mui/material/InputLabel'
+import MenuItem from '@mui/material/MenuItem'
+import OutlinedInput from '@mui/material/OutlinedInput'
+import ListItemText from '@mui/material/ListItemText'
+import Select from '@mui/material/Select'
+import IconButton from '@mui/material/IconButton'
+import Box from '@mui/material/Box'
+import Checkbox from '@mui/material/Checkbox'
+import FormLabel from '@mui/material/FormLabel'
+import FormControl from '@mui/material/FormControl'
+import FormControlLabel from '@mui/material/FormControlLabel'
+import Tooltip from '@mui/material/Tooltip'
+import CloseIcon from '@mui/icons-material/Close'
+import type { SelectChangeEvent } from '@mui/material/Select'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+
+import { getUniqueTags } from '@/components/safe-apps/utils'
+import SearchIcon from '@/public/images/common/search.svg'
+import BatchIcon from '@/public/images/apps/batch-icon.svg'
+import css from './styles.module.css'
+
+export type safeAppCatogoryOptionType = {
+ label: string
+ value: string
+}
+
+type SafeAppsFiltersProps = {
+ onChangeQuery: (newQuery: string) => void
+ onChangeFilterCategory: (category: string[]) => void
+ onChangeOptimizedWithBatch: (optimizedWithBatch: boolean) => void
+ selectedCategories: string[]
+ safeAppsList: SafeAppData[]
+}
+
+const SafeAppsFilters = ({
+ onChangeQuery,
+ onChangeFilterCategory,
+ onChangeOptimizedWithBatch,
+ selectedCategories,
+ safeAppsList,
+}: SafeAppsFiltersProps) => {
+ const categoryOptions = getCategoryOptions(safeAppsList)
+
+ return (
+
+
+ {/* Search by name */}
+ {
+ onChangeQuery(e.target.value)
+ }}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ disableUnderline: true,
+ }}
+ fullWidth
+ size="small"
+ sx={{
+ '& > .MuiInputBase-root': { padding: '8px 16px' },
+ }}
+ />
+
+
+ {/* Select Category */}
+
+
+
+ Category
+
+ ) => {
+ onChangeFilterCategory(event.target.value as string[])
+ }}
+ input={ }
+ renderValue={(selected) =>
+ selected.length === 0 ? 'Select category' : `${selected.length} categories selected`
+ }
+ fullWidth
+ MenuProps={categoryMenuProps}
+ >
+ {categoryOptions.length > 0 ? (
+ categoryOptions.map((category) => (
+
+
+
+
+ ))
+ ) : (
+
+
+
+ )}
+
+
+ {/* clear selected categories button */}
+ {selectedCategories.length > 0 && (
+
+ {
+ onChangeFilterCategory([])
+ }}
+ sx={{ position: 'absolute', top: '16px', right: '28px' }}
+ color="default"
+ component="label"
+ size="small"
+ >
+
+
+
+ )}
+
+
+
+ {/* Optimized with Batch Transaction */}
+
+
+ Merge multiple transactions into one to save time and gas fees inside apps offering this feature
+
+ }
+ >
+
+ Optimized with
+ }
+ onChange={(_, value) => {
+ onChangeOptimizedWithBatch(value)
+ }}
+ label={
+
+ Batch transactions
+
+ }
+ />
+
+
+
+
+ )
+}
+
+export default SafeAppsFilters
+
+const CATEGORY_OPTION_HEIGHT = 34
+const CATEGORY_OPTION_PADDING_TOP = 8
+const ITEMS_SHOWED = 11.5
+const categoryMenuProps = {
+ sx: {
+ '& .MuiList-root': { padding: '9px 0' },
+ },
+ PaperProps: {
+ style: {
+ maxHeight: CATEGORY_OPTION_HEIGHT * ITEMS_SHOWED + CATEGORY_OPTION_PADDING_TOP,
+ overflow: 'scroll',
+ },
+ },
+}
+
+const getCategoryOptions = (safeAppList: SafeAppData[]): safeAppCatogoryOptionType[] => {
+ return getUniqueTags(safeAppList).map((category) => ({
+ label: category,
+ value: category,
+ }))
+}
diff --git a/src/components/safe-apps/SafeAppsFilters/styles.module.css b/src/components/safe-apps/SafeAppsFilters/styles.module.css
new file mode 100644
index 000000000..809cbbc4e
--- /dev/null
+++ b/src/components/safe-apps/SafeAppsFilters/styles.module.css
@@ -0,0 +1,13 @@
+.filterContainer {
+ background-color: var(--color-background-main);
+ z-index: 2;
+ position: sticky !important;
+ top: calc(var(--header-height) + 49px);
+
+ padding: var(--space-1) 0 var(--space-1) 0;
+}
+
+.optimizedWithBatchLabel {
+ font-size: 12px;
+ color: var(--color-text-primary);
+}
diff --git a/src/components/safe-apps/SafeAppsHeader/index.tsx b/src/components/safe-apps/SafeAppsHeader/index.tsx
new file mode 100644
index 000000000..bb3cc8be4
--- /dev/null
+++ b/src/components/safe-apps/SafeAppsHeader/index.tsx
@@ -0,0 +1,26 @@
+import Box from '@mui/material/Box'
+import Typography from '@mui/material/Typography'
+import type { ReactElement } from 'react'
+import { useCurrentChain } from '@/hooks/useChains'
+
+import css from './styles.module.css'
+
+const SafeAppsHeader = (): ReactElement => {
+ const chain = useCurrentChain()
+ return (
+ <>
+
+
+ My custom Safe Apps{chain?.chainName ? ` on ${chain.chainName}` : ''}
+
+
+
+ Add and manage custom Safe Apps for your Safe Account. Note: custom Safe Apps may make calls to external APIs.
+ You use Safe Apps via Eternal Safe at your own risk.
+
+
+ >
+ )
+}
+
+export default SafeAppsHeader
diff --git a/src/components/safe-apps/SafeAppsHeader/styles.module.css b/src/components/safe-apps/SafeAppsHeader/styles.module.css
new file mode 100644
index 000000000..9c46c0932
--- /dev/null
+++ b/src/components/safe-apps/SafeAppsHeader/styles.module.css
@@ -0,0 +1,34 @@
+.container {
+ padding: var(--space-12) var(--space-3) 0;
+ margin-bottom: var(--space-5);
+ background-color: var(--color-background-main);
+}
+
+.title {
+ font-weight: 700;
+ font-size: 44px;
+ line-height: 150%;
+}
+
+.subtitle {
+ max-width: 700px;
+ letter-spacing: 0.15px;
+}
+
+.tabs {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ background-color: var(--color-background-main);
+ z-index: 2;
+ width: 100%;
+ position: sticky !important;
+ top: var(--header-height);
+ border-bottom: 1px solid var(--color-border-light);
+}
+
+@media (max-width: 599.95px) {
+ .tabs {
+ padding: 0 24px;
+ }
+}
diff --git a/src/components/safe-apps/SafeAppsInfoModal/AllowedFeaturesList.tsx b/src/components/safe-apps/SafeAppsInfoModal/AllowedFeaturesList.tsx
new file mode 100644
index 000000000..589669732
--- /dev/null
+++ b/src/components/safe-apps/SafeAppsInfoModal/AllowedFeaturesList.tsx
@@ -0,0 +1,55 @@
+import { Box, Typography, SvgIcon } from '@mui/material'
+import ShieldIcon from '@/public/images/settings/permissions/shield.svg'
+
+import { getBrowserPermissionDisplayValues } from '@/hooks/safe-apps/permissions'
+import PermissionsCheckbox from '../PermissionCheckbox'
+
+import type { AllowedFeatures, AllowedFeatureSelection } from '../types'
+import { isBrowserFeature } from '../types'
+
+type SafeAppsInfoAllowedFeaturesProps = {
+ features: AllowedFeatureSelection[]
+ onFeatureSelectionChange: (feature: AllowedFeatures, checked: boolean) => void
+}
+
+const AllowedFeaturesList: React.FC = ({
+ features,
+ onFeatureSelectionChange,
+}): React.ReactElement => {
+ return (
+ <>
+
+
+
+ Manage the features Safe Apps can use
+
+
+
+ This Safe App is requesting permission to use:
+
+
+ {features
+ .filter(({ feature }) => isBrowserFeature(feature))
+ .map(({ feature, checked }, index) => (
+ onFeatureSelectionChange(feature, checked)}
+ label={getBrowserPermissionDisplayValues(feature).displayName}
+ />
+ ))}
+
+
+ >
+ )
+}
+
+export default AllowedFeaturesList
diff --git a/src/components/safe-apps/SafeAppsInfoModal/Domain.tsx b/src/components/safe-apps/SafeAppsInfoModal/Domain.tsx
new file mode 100644
index 000000000..6bd9cee11
--- /dev/null
+++ b/src/components/safe-apps/SafeAppsInfoModal/Domain.tsx
@@ -0,0 +1,20 @@
+import React from 'react'
+import { Typography } from '@mui/material'
+import CheckIcon from '@mui/icons-material/Check'
+
+import styles from './styles.module.css'
+
+type DomainProps = {
+ url: string
+ showInOneLine?: boolean
+}
+
+const Domain: React.FC = ({ url, showInOneLine }): React.ReactElement => {
+ return (
+
+ {url}
+
+ )
+}
+
+export default Domain
diff --git a/src/components/safe-apps/SafeAppsInfoModal/LegalDisclaimer.tsx b/src/components/safe-apps/SafeAppsInfoModal/LegalDisclaimer.tsx
new file mode 100644
index 000000000..709818c5b
--- /dev/null
+++ b/src/components/safe-apps/SafeAppsInfoModal/LegalDisclaimer.tsx
@@ -0,0 +1,35 @@
+import ExternalLink from '@/components/common/ExternalLink'
+import { Typography } from '@mui/material'
+
+import css from './styles.module.css'
+
+const LegalDisclaimer = (): JSX.Element => (
+
+
+ Before starting to use Safe dApps...
+
+
+ Disclaimer
+
+
+
+ You are now accessing third-party apps, which we do not own, control, maintain or audit. We are not liable for
+ any loss you may suffer in connection with interacting with the apps, which is at your own risk.
+
+
+
+ You must read our Terms, which contain more detailed provisions binding on you relating to the apps.
+
+
+
+ I have read and understood the{' '}
+
+ Terms
+ {' '}
+ and this Disclaimer, and agree to be bound by them.
+
+
+
+)
+
+export default LegalDisclaimer
diff --git a/src/components/safe-apps/SafeAppsInfoModal/Slider.tsx b/src/components/safe-apps/SafeAppsInfoModal/Slider.tsx
new file mode 100644
index 000000000..12bb058c3
--- /dev/null
+++ b/src/components/safe-apps/SafeAppsInfoModal/Slider.tsx
@@ -0,0 +1,93 @@
+import { Box, Button } from '@mui/material'
+import React, { useState, useEffect, useMemo } from 'react'
+import css from './styles.module.css'
+
+type SliderProps = {
+ onSlideChange: (slideIndex: number) => void
+ initialStep?: number
+ children: React.ReactNode
+}
+
+const SLIDER_TIMEOUT = 500
+
+const Slider: React.FC = ({ onSlideChange, children, initialStep }) => {
+ const allSlides = useMemo(() => React.Children.toArray(children).filter(Boolean) as React.ReactElement[], [children])
+
+ const [activeStep, setActiveStep] = useState(initialStep || 0)
+ const [disabledBtn, setDisabledBtn] = useState(false)
+
+ useEffect(() => {
+ let id: ReturnType
+
+ if (disabledBtn) {
+ id = setTimeout(() => {
+ setDisabledBtn(false)
+ }, SLIDER_TIMEOUT)
+ }
+
+ return () => {
+ if (id) clearTimeout(id)
+ }
+ }, [disabledBtn])
+
+ const nextSlide = () => {
+ if (disabledBtn) return
+
+ const nextStep = activeStep + 1
+
+ onSlideChange(nextStep)
+ setActiveStep(nextStep)
+ setDisabledBtn(true)
+ }
+
+ const prevSlide = () => {
+ if (disabledBtn) return
+
+ const prevStep = activeStep - 1
+
+ onSlideChange(prevStep)
+ setActiveStep(prevStep)
+ setDisabledBtn(true)
+ }
+
+ const isFirstStep = activeStep === 0
+
+ return (
+ <>
+
+
+ {allSlides.map((slide, index) => (
+
+ {slide}
+
+ ))}
+
+
+
+
+ {isFirstStep ? 'Cancel' : 'Back'}
+
+
+
+ Continue
+
+
+ >
+ )
+}
+
+export default Slider
diff --git a/src/components/safe-apps/SafeAppsInfoModal/UnknownAppWarning.tsx b/src/components/safe-apps/SafeAppsInfoModal/UnknownAppWarning.tsx
new file mode 100644
index 000000000..044c4c556
--- /dev/null
+++ b/src/components/safe-apps/SafeAppsInfoModal/UnknownAppWarning.tsx
@@ -0,0 +1,56 @@
+import { useState } from 'react'
+import { Box, Checkbox, FormControlLabel, Typography } from '@mui/material'
+import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined'
+import lightPalette from '@/components/theme/lightPalette'
+import Domain from './Domain'
+
+type UnknownAppWarningProps = {
+ url?: string
+ onHideWarning?: (hideWarning: boolean) => void
+}
+
+const UnknownAppWarning = ({ url, onHideWarning }: UnknownAppWarningProps): React.ReactElement => {
+ const [toggleHideWarning, setToggleHideWarning] = useState(false)
+
+ const handleToggleWarningPreference = (): void => {
+ onHideWarning?.(!toggleHideWarning)
+ setToggleHideWarning(!toggleHideWarning)
+ }
+
+ return (
+
+
+
+
+ Warning
+
+
+
+ The application you are trying to access is not in the default Eternal Safe Apps list
+
+
+
+ Check the link you are using and ensure that it comes from a source you trust
+
+
+ {url && }
+
+ {onHideWarning && (
+
+
+ }
+ label="Don't show this warning again"
+ />
+
+ )}
+
+ )
+}
+
+export default UnknownAppWarning
diff --git a/src/components/safe-apps/SafeAppsInfoModal/constants.ts b/src/components/safe-apps/SafeAppsInfoModal/constants.ts
new file mode 100644
index 000000000..6fc808ce0
--- /dev/null
+++ b/src/components/safe-apps/SafeAppsInfoModal/constants.ts
@@ -0,0 +1,22 @@
+export const SECURITY_PRACTICES = [
+ {
+ id: '1',
+ title: 'Always load a Safe App from trusted sources.',
+ imageSrc: './safe-apps-security-practices/1.png',
+ },
+ {
+ id: '2',
+ title: 'Check the Safe App link you are trying to use.',
+ subtitle: 'Do you know the domain and trust it?',
+ },
+ {
+ id: '3',
+ title: 'Always check transaction information while creating it, before proposing it to the Safe Account.',
+ imageSrc: './safe-apps-security-practices/2.png',
+ },
+ {
+ id: '4',
+ title: 'Do a second check on transaction data before signing in the queue.',
+ imageSrc: './safe-apps-security-practices/3.png',
+ },
+]
diff --git a/src/components/safe-apps/SafeAppsInfoModal/index.tsx b/src/components/safe-apps/SafeAppsInfoModal/index.tsx
new file mode 100644
index 000000000..924c2597f
--- /dev/null
+++ b/src/components/safe-apps/SafeAppsInfoModal/index.tsx
@@ -0,0 +1,155 @@
+import { memo, useMemo, useState } from 'react'
+import { alpha, Box } from '@mui/system'
+import { Grid, LinearProgress } from '@mui/material'
+
+import type { BrowserPermission } from '@/hooks/safe-apps/permissions'
+import Slider from './Slider'
+import LegalDisclaimer from './LegalDisclaimer'
+import AllowedFeaturesList from './AllowedFeaturesList'
+import type { AllowedFeatures, AllowedFeatureSelection } from '../types'
+import { PermissionStatus } from '../types'
+import UnknownAppWarning from './UnknownAppWarning'
+import { getOrigin } from '../utils'
+
+type SafeAppsInfoModalProps = {
+ onCancel: () => void
+ onConfirm: (shouldHide: boolean, browserPermissions: BrowserPermission[]) => void
+ features: AllowedFeatures[]
+ appUrl: string
+ isConsentAccepted?: boolean
+ isPermissionsReviewCompleted: boolean
+ isSafeAppInDefaultList: boolean
+ isFirstTimeAccessingApp: boolean
+}
+
+const SafeAppsInfoModal = ({
+ onCancel,
+ onConfirm,
+ features,
+ appUrl,
+ isConsentAccepted,
+ isPermissionsReviewCompleted,
+ isSafeAppInDefaultList,
+ isFirstTimeAccessingApp,
+}: SafeAppsInfoModalProps): JSX.Element => {
+ const [hideWarning, setHideWarning] = useState(false)
+ const [selectedFeatures, setSelectedFeatures] = useState(
+ features.map((feature) => {
+ return {
+ feature,
+ checked: true,
+ }
+ }),
+ )
+ const [currentSlide, setCurrentSlide] = useState(0)
+
+ const totalSlides = useMemo(() => {
+ let totalSlides = 0
+
+ if (!isConsentAccepted) {
+ totalSlides += 1
+ }
+
+ if (!isPermissionsReviewCompleted) {
+ totalSlides += 1
+ }
+
+ if (!isSafeAppInDefaultList && isFirstTimeAccessingApp) {
+ totalSlides += 1
+ }
+
+ return totalSlides
+ }, [isConsentAccepted, isFirstTimeAccessingApp, isPermissionsReviewCompleted, isSafeAppInDefaultList])
+
+ const handleSlideChange = (newStep: number) => {
+ const isFirstStep = newStep === -1
+ const isLastStep = newStep === totalSlides
+
+ if (isFirstStep) {
+ onCancel()
+ }
+
+ if (isLastStep) {
+ onConfirm(
+ hideWarning,
+ selectedFeatures.map(({ feature, checked }) => {
+ return {
+ feature,
+ status: checked ? PermissionStatus.GRANTED : PermissionStatus.DENIED,
+ }
+ }),
+ )
+ }
+
+ setCurrentSlide(newStep)
+ }
+
+ const progressValue = useMemo(() => {
+ return ((currentSlide + 1) * 100) / totalSlides
+ }, [currentSlide, totalSlides])
+
+ const shouldShowUnknownAppWarning = useMemo(
+ () => !isSafeAppInDefaultList && isFirstTimeAccessingApp,
+ [isFirstTimeAccessingApp, isSafeAppInDefaultList],
+ )
+
+ const handleFeatureSelectionChange = (feature: AllowedFeatures, checked: boolean) => {
+ setSelectedFeatures(
+ selectedFeatures.map((feat) => {
+ if (feat.feature === feature) {
+ return {
+ feature,
+ checked,
+ }
+ }
+ return feat
+ }),
+ )
+ }
+
+ const origin = useMemo(() => getOrigin(appUrl), [appUrl])
+
+ return (
+
+ ({
+ width: '450px',
+ backgroundColor: palette.background.paper,
+ boxShadow: `1px 2px 10px 0 ${alpha(palette.text.primary, 0.18)}`,
+ })}
+ >
+ ({
+ height: '6px',
+ backgroundColor: palette.background.paper,
+ borderRadius: '8px 8px 0 0',
+ '> .MuiLinearProgress-bar': {
+ backgroundColor:
+ progressValue === 100 && shouldShowUnknownAppWarning ? palette.warning.main : palette.primary.main,
+ borderRadius: '8px',
+ },
+ })}
+ />
+
+
+ {!isConsentAccepted && }
+
+ {!isPermissionsReviewCompleted && (
+
+ )}
+
+ {shouldShowUnknownAppWarning && }
+
+
+
+
+ )
+}
+
+export default memo(SafeAppsInfoModal)
diff --git a/src/components/safe-apps/SafeAppsInfoModal/styles.module.css b/src/components/safe-apps/SafeAppsInfoModal/styles.module.css
new file mode 100644
index 000000000..f2655faa1
--- /dev/null
+++ b/src/components/safe-apps/SafeAppsInfoModal/styles.module.css
@@ -0,0 +1,51 @@
+.sliderContainer {
+ position: relative;
+ margin: 0 auto;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+
+.sliderInner {
+ position: relative;
+ display: flex;
+ min-width: 100%;
+ min-height: 100%;
+ transform: translateX(0);
+ height: 426px;
+ transition: transform 0.5s ease;
+}
+
+.sliderItem {
+ min-width: 100%;
+ min-height: 100%;
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: cover;
+}
+
+.disclaimerContainer p,
+.disclaimerContainer h3 {
+ line-height: 24px;
+}
+
+.disclaimerInner p {
+ text-align: justify;
+}
+
+.domainIcon {
+ position: relative;
+ top: 6px;
+ padding-right: 4px;
+}
+
+.domainText {
+ display: block;
+ font-size: 12px;
+ font-weight: bold;
+ overflow-wrap: anywhere;
+ background-color: var(--color-background-light);
+ padding: 0 15px 10px 10px;
+ border-radius: 8px;
+ max-width: 75%;
+}
diff --git a/src/components/safe-apps/SafeAppsInfoModal/useSafeAppsInfoModal.ts b/src/components/safe-apps/SafeAppsInfoModal/useSafeAppsInfoModal.ts
new file mode 100644
index 000000000..4863a1c79
--- /dev/null
+++ b/src/components/safe-apps/SafeAppsInfoModal/useSafeAppsInfoModal.ts
@@ -0,0 +1,145 @@
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+import type { BrowserPermission } from '@/hooks/safe-apps/permissions'
+import useChainId from '@/hooks/useChainId'
+import useLocalStorage from '@/services/local-storage/useLocalStorage'
+import type { AllowedFeatures } from '../types'
+import { PermissionStatus } from '../types'
+import { getOrigin } from '../utils'
+
+const SAFE_APPS_INFO_MODAL = 'SafeApps__infoModal'
+
+type useSafeAppsInfoModal = {
+ url: string
+ safeApp?: SafeAppData
+ permissions: AllowedFeatures[]
+ addPermissions: (origin: string, permissions: BrowserPermission[]) => void
+ getPermissions: (origin: string) => BrowserPermission[]
+ safeAppsLoading: boolean
+}
+
+type ModalInfoProps = {
+ [chainId: string]: {
+ consentsAccepted: boolean
+ warningCheckedCustomApps: string[]
+ }
+}
+
+const useSafeAppsInfoModal = ({
+ url,
+ safeApp,
+ permissions,
+ addPermissions,
+ getPermissions,
+ safeAppsLoading,
+}: useSafeAppsInfoModal): {
+ isModalVisible: boolean
+ isFirstTimeAccessingApp: boolean
+ isSafeAppInDefaultList: boolean
+ isConsentAccepted: boolean
+ isPermissionsReviewCompleted: boolean
+ onComplete: (shouldHide: boolean, permissions: BrowserPermission[]) => void
+} => {
+ const didMount = useRef(false)
+ const chainId = useChainId()
+ const [modalInfo = {}, setModalInfo] = useLocalStorage(SAFE_APPS_INFO_MODAL)
+ const [isDisclaimerReadingCompleted, setIsDisclaimerReadingCompleted] = useState(false)
+
+ useEffect(() => {
+ if (!url) {
+ setIsDisclaimerReadingCompleted(false)
+ }
+ }, [url])
+
+ useEffect(() => {
+ if (!didMount.current) {
+ didMount.current = true
+ return
+ }
+ }, [])
+
+ const isPermissionsReviewCompleted = useMemo(() => {
+ if (!url) return false
+
+ const safeAppRequiredFeatures = permissions || []
+ const featureHasBeenGrantedOrDenied = (feature: AllowedFeatures) =>
+ getPermissions(url).some((permission: BrowserPermission) => {
+ return permission.feature === feature && permission.status !== PermissionStatus.PROMPT
+ })
+
+ // If the app add a new feature in the manifest we need to detect it and show the modal again
+ return !!safeAppRequiredFeatures.every(featureHasBeenGrantedOrDenied)
+ }, [getPermissions, url, permissions])
+
+ const isSafeAppInDefaultList = useMemo(() => {
+ if (!url) return false
+
+ return !!safeApp
+ }, [safeApp, url])
+
+ const isFirstTimeAccessingApp = useMemo(() => {
+ if (!url) return true
+
+ if (!modalInfo[chainId]) {
+ return true
+ }
+
+ return !modalInfo[chainId]?.warningCheckedCustomApps?.includes(url)
+ }, [chainId, modalInfo, url])
+
+ const isModalVisible = useMemo(() => {
+ const isComponentReady = didMount.current
+ const shouldShowLegalDisclaimer = !modalInfo[chainId] || modalInfo[chainId].consentsAccepted === false
+ const shouldShowAllowedFeatures = !isPermissionsReviewCompleted
+ const shouldShowUnknownAppWarning =
+ !safeAppsLoading && !isSafeAppInDefaultList && isFirstTimeAccessingApp && !isDisclaimerReadingCompleted
+
+ return isComponentReady && (shouldShowLegalDisclaimer || shouldShowUnknownAppWarning || shouldShowAllowedFeatures)
+ }, [
+ chainId,
+ isPermissionsReviewCompleted,
+ isFirstTimeAccessingApp,
+ isSafeAppInDefaultList,
+ isDisclaimerReadingCompleted,
+ safeAppsLoading,
+ modalInfo,
+ ])
+
+ const onComplete = useCallback(
+ (shouldHide: boolean, browserPermissions: BrowserPermission[]) => {
+ const info = {
+ consentsAccepted: true,
+ warningCheckedCustomApps: [...(modalInfo[chainId]?.warningCheckedCustomApps || [])],
+ }
+
+ const origin = getOrigin(url)
+
+ if (shouldHide && !modalInfo[chainId]?.warningCheckedCustomApps?.includes(origin)) {
+ info.warningCheckedCustomApps.push(origin)
+ }
+
+ setModalInfo({
+ ...modalInfo,
+ [chainId]: info,
+ })
+
+ if (!isPermissionsReviewCompleted) {
+ addPermissions(url, browserPermissions)
+ }
+
+ setIsDisclaimerReadingCompleted(true)
+ },
+ [addPermissions, chainId, isPermissionsReviewCompleted, modalInfo, setModalInfo, url],
+ )
+
+ return {
+ isModalVisible,
+ isSafeAppInDefaultList,
+ isFirstTimeAccessingApp,
+ isPermissionsReviewCompleted,
+ isConsentAccepted: !!modalInfo?.[chainId]?.consentsAccepted,
+ onComplete,
+ }
+}
+
+export default useSafeAppsInfoModal
diff --git a/src/components/safe-apps/SafeAppsListHeader/index.tsx b/src/components/safe-apps/SafeAppsListHeader/index.tsx
new file mode 100644
index 000000000..3257c51fa
--- /dev/null
+++ b/src/components/safe-apps/SafeAppsListHeader/index.tsx
@@ -0,0 +1,16 @@
+import Typography from '@mui/material/Typography'
+
+type SafeAppsListHeaderProps = {
+ title: string
+ amount?: number
+}
+
+const SafeAppsListHeader = ({ title, amount }: SafeAppsListHeaderProps) => {
+ return (
+
+ {title} ({amount || 0})
+
+ )
+}
+
+export default SafeAppsListHeader
diff --git a/src/components/safe-apps/SafeAppsListHeader/styles.module.css b/src/components/safe-apps/SafeAppsListHeader/styles.module.css
new file mode 100644
index 000000000..9af451067
--- /dev/null
+++ b/src/components/safe-apps/SafeAppsListHeader/styles.module.css
@@ -0,0 +1,7 @@
+.gridView > rect {
+ fill: currentColor;
+}
+
+.listView > path {
+ fill: currentColor;
+}
diff --git a/src/components/safe-apps/SafeAppsZeroResultsPlaceholder/index.tsx b/src/components/safe-apps/SafeAppsZeroResultsPlaceholder/index.tsx
new file mode 100644
index 000000000..f07b4a79c
--- /dev/null
+++ b/src/components/safe-apps/SafeAppsZeroResultsPlaceholder/index.tsx
@@ -0,0 +1,20 @@
+import React from 'react'
+import Typography from '@mui/material/Typography'
+
+import PagePlaceholder from '@/components/common/PagePlaceholder'
+import AddCustomAppIcon from '@/public/images/apps/add-custom-app.svg'
+
+const SafeAppsZeroResultsPlaceholder = ({ searchQuery }: { searchQuery: string }) => {
+ return (
+ }
+ text={
+
+ No custom Safe Apps found matching {searchQuery} . Add your app URL to use it in this Safe.
+
+ }
+ />
+ )
+}
+
+export default SafeAppsZeroResultsPlaceholder
diff --git a/src/components/safe-apps/hooks/useShareSafeAppUrl.ts b/src/components/safe-apps/hooks/useShareSafeAppUrl.ts
new file mode 100644
index 000000000..1e89d84be
--- /dev/null
+++ b/src/components/safe-apps/hooks/useShareSafeAppUrl.ts
@@ -0,0 +1,30 @@
+import { useRouter } from 'next/router'
+import { resolveHref } from 'next/dist/client/resolve-href'
+import { useEffect, useState } from 'react'
+import type { UrlObject } from 'url'
+
+// import { AppRoutes } from '@/config/routes'
+import { useCurrentChain } from '@/hooks/useChains'
+
+export const useShareSafeAppUrl = (appUrl: string): string => {
+ const router = useRouter()
+ const chain = useCurrentChain()
+ const [shareSafeAppUrl, setShareSafeAppUrl] = useState('')
+
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ return
+ }
+
+ const shareUrlObj: UrlObject = {
+ protocol: window.location.protocol,
+ host: window.location.host,
+ // pathname: AppRoutes.share.safeApp,
+ query: { appUrl, chain: chain?.shortName },
+ }
+
+ setShareSafeAppUrl(resolveHref(router, shareUrlObj))
+ }, [appUrl, chain?.shortName, router])
+
+ return shareSafeAppUrl
+}
diff --git a/src/components/safe-apps/types.ts b/src/components/safe-apps/types.ts
new file mode 100644
index 000000000..27e4fd120
--- /dev/null
+++ b/src/components/safe-apps/types.ts
@@ -0,0 +1,53 @@
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+
+export enum PermissionStatus {
+ GRANTED = 'granted',
+ PROMPT = 'prompt',
+ DENIED = 'denied',
+}
+
+const FEATURES = [
+ 'accelerometer',
+ 'ambient-light-sensor',
+ 'autoplay',
+ 'battery',
+ 'camera',
+ 'cross-origin-isolated',
+ 'display-capture',
+ 'document-domain',
+ 'encrypted-media',
+ 'execution-while-not-rendered',
+ 'execution-while-out-of-viewport',
+ 'fullscreen',
+ 'geolocation',
+ 'gyroscope',
+ 'keyboard-map',
+ 'magnetometer',
+ 'microphone',
+ 'midi',
+ 'navigation-override',
+ 'payment',
+ 'picture-in-picture',
+ 'publickey-credentials-get',
+ 'screen-wake-lock',
+ 'sync-xhr',
+ 'usb',
+ 'web-share',
+ 'xr-spatial-tracking',
+ 'clipboard-read',
+ 'clipboard-write',
+ 'gamepad',
+ 'speaker-selection',
+]
+
+type FeaturesType = typeof FEATURES
+
+export type AllowedFeatures = FeaturesType[number]
+
+export const isBrowserFeature = (featureKey: string): featureKey is AllowedFeatures => {
+ return FEATURES.includes(featureKey as AllowedFeatures)
+}
+
+export type AllowedFeatureSelection = { feature: AllowedFeatures; checked: boolean }
+
+export type SafeAppDataWithPermissions = SafeAppData & { safeAppsPermissions: AllowedFeatures[] }
diff --git a/src/components/safe-apps/utils.ts b/src/components/safe-apps/utils.ts
new file mode 100644
index 000000000..f8421a944
--- /dev/null
+++ b/src/components/safe-apps/utils.ts
@@ -0,0 +1,115 @@
+import { isHexString, toUtf8String } from 'ethers/lib/utils'
+import { SafeAppAccessPolicyTypes } from '@safe-global/safe-gateway-typescript-sdk'
+import { SafeAppFeatures } from '@safe-global/safe-gateway-typescript-sdk'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+import type { BaseTransaction, ChainInfo } from '@safe-global/safe-apps-sdk'
+
+import { formatVisualAmount } from '@/utils/formatters'
+import { validateAddress } from '@/utils/validation'
+import type { SafeAppDataWithPermissions } from './types'
+import { SafeAppsTag } from '@/config/constants'
+
+const validateTransaction = (t: BaseTransaction): boolean => {
+ if (!['string', 'number'].includes(typeof t.value)) {
+ return false
+ }
+
+ if (typeof t.value === 'string' && !/^(0x)?[0-9a-f]+$/i.test(t.value)) {
+ return false
+ }
+
+ const isAddressValid = validateAddress(t.to) === undefined
+ return isAddressValid && !!t.data && typeof t.data === 'string'
+}
+
+export const isTxValid = (txs: BaseTransaction[]) => txs.length && txs.every((t) => validateTransaction(t))
+
+export const getInteractionTitle = (value?: string, chain?: ChainInfo) => {
+ const { decimals, symbol } = chain!.nativeCurrency
+ return `Interact with${
+ Number(value) !== 0 ? ` (and send ${formatVisualAmount(value || 0, decimals)} ${symbol} to)` : ''
+ }:`
+}
+
+/**
+ * If message is a hex value and is Utf8 encoded string we decode it, else we return the raw message
+ * @param {string} message raw input message
+ * @returns {string}
+ */
+export const getDecodedMessage = (message: string): string => {
+ if (isHexString(message)) {
+ // If is a hex string we try to extract a message
+ try {
+ return toUtf8String(message)
+ } catch (e) {
+ // the hex string is not UTF8 encoding so we will return the raw message.
+ }
+ }
+
+ return message
+}
+
+export const getLegacyChainName = (chainName: string, chainId: string): string => {
+ let network = chainName
+
+ switch (chainId) {
+ case '1':
+ network = 'MAINNET'
+ break
+ case '100':
+ network = 'XDAI'
+ }
+
+ return network
+}
+
+export const getEmptySafeApp = (url = ''): SafeAppDataWithPermissions => {
+ return {
+ id: Math.random(),
+ url,
+ name: 'unknown',
+ iconUrl: '/images/apps/apps-icon.svg',
+ description: '',
+ chainIds: [],
+ accessControl: {
+ type: SafeAppAccessPolicyTypes.NoRestrictions,
+ },
+ tags: [],
+ safeAppsPermissions: [],
+ features: [],
+ socialProfiles: [],
+ developerWebsite: '',
+ }
+}
+
+export const getOrigin = (url?: string): string => {
+ if (!url) return ''
+
+ const { origin } = new URL(url)
+
+ return origin
+}
+
+export const isOptimizedForBatchTransactions = (safeApp: SafeAppData) =>
+ safeApp.features?.includes(SafeAppFeatures.BATCHED_TRANSACTIONS)
+
+// some categories are used internally and we dont want to display them in the UI
+export const filterInternalCategories = (categories: string[]): string[] => {
+ const internalCategories = Object.values(SafeAppsTag)
+ return categories.filter((tag) => !internalCategories.some((internalCategory) => tag === internalCategory))
+}
+
+// Get unique tags from all apps
+export const getUniqueTags = (apps: SafeAppData[]): string[] => {
+ // Get the list of categories from the safeAppsList
+ const tags = apps.reduce>((result, app) => {
+ app.tags.forEach((tag) => result.add(tag))
+ return result
+ }, new Set())
+
+ // Filter out internal tags
+ const filteredTags = filterInternalCategories(Array.from(tags))
+
+ // Sort alphabetically
+ return filteredTags.sort()
+}
diff --git a/src/components/sidebar/SidebarNavigation/config.tsx b/src/components/sidebar/SidebarNavigation/config.tsx
index d700acf30..62b952186 100644
--- a/src/components/sidebar/SidebarNavigation/config.tsx
+++ b/src/components/sidebar/SidebarNavigation/config.tsx
@@ -5,6 +5,7 @@ import AssetsIcon from '@/public/images/sidebar/assets.svg'
import TransactionIcon from '@/public/images/sidebar/transactions.svg'
import ABIcon from '@/public/images/sidebar/address-book.svg'
import SettingsIcon from '@/public/images/sidebar/settings.svg'
+import AppsIcon from '@/public/images/apps/apps-icon.svg'
import { SvgIcon } from '@mui/material'
export type NavItem = {
@@ -29,6 +30,11 @@ export const navItems: NavItem[] = [
icon: ,
href: AppRoutes.addressBook,
},
+ {
+ label: 'Apps',
+ icon: ,
+ href: AppRoutes.apps.index,
+ },
{
label: 'Settings',
icon: ,
diff --git a/src/components/transactions/BatchExecuteButton/BatchExecuteHoverProvider.tsx b/src/components/transactions/BatchExecuteButton/BatchExecuteHoverProvider.tsx
new file mode 100644
index 000000000..539746b37
--- /dev/null
+++ b/src/components/transactions/BatchExecuteButton/BatchExecuteHoverProvider.tsx
@@ -0,0 +1,21 @@
+import type { ReactElement, ReactNode } from 'react'
+import { createContext, useState } from 'react'
+
+export const BatchExecuteHoverContext = createContext<{
+ activeHover: string[]
+ setActiveHover: (activeHover: string[]) => void
+}>({
+ activeHover: [],
+ setActiveHover: () => {},
+})
+
+// Used for highlighting transactions that will be included when executing them as a batch
+export const BatchExecuteHoverProvider = ({ children }: { children: ReactNode }): ReactElement => {
+ const [activeHover, setActiveHover] = useState([])
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/transactions/BatchExecuteButton/index.tsx b/src/components/transactions/BatchExecuteButton/index.tsx
new file mode 100644
index 000000000..f6b37927c
--- /dev/null
+++ b/src/components/transactions/BatchExecuteButton/index.tsx
@@ -0,0 +1,62 @@
+import { useCallback, useContext } from 'react'
+import { Button, Tooltip } from '@mui/material'
+import { BatchExecuteHoverContext } from '@/components/transactions/BatchExecuteButton/BatchExecuteHoverProvider'
+import { useAppSelector } from '@/store'
+import { selectPendingTxs } from '@/store/pendingTxsSlice'
+import useBatchedTxs from '@/hooks/useBatchedTxs'
+import { ExecuteBatchFlow } from '@/components/tx-flow/flows'
+import useWallet from '@/hooks/wallets/useWallet'
+import { TxModalContext } from '@/components/tx-flow'
+
+const BatchExecuteButton = () => {
+ const { setTxFlow } = useContext(TxModalContext)
+ const pendingTxs = useAppSelector(selectPendingTxs)
+ const hoverContext = useContext(BatchExecuteHoverContext)
+ const batchableTransactions = useBatchedTxs([])
+ const wallet = useWallet()
+
+ const isBatchable = batchableTransactions.length > 1
+ const hasPendingTx = batchableTransactions.some((tx) => pendingTxs[tx.transaction.id])
+ const isDisabled = !isBatchable || hasPendingTx || !wallet
+
+ const handleOnMouseEnter = useCallback(() => {
+ hoverContext.setActiveHover(batchableTransactions.map((tx) => tx.transaction.id))
+ }, [batchableTransactions, hoverContext])
+
+ const handleOnMouseLeave = useCallback(() => {
+ hoverContext.setActiveHover([])
+ }, [hoverContext])
+
+ const handleOpenModal = () => {
+ setTxFlow( , undefined, false)
+ }
+
+ return (
+ <>
+
+
+
+ Bulk execute{isBatchable && ` ${batchableTransactions.length} transactions`}
+
+
+
+ >
+ )
+}
+
+export default BatchExecuteButton
diff --git a/src/components/tx-flow/flows/ExecuteBatch/DecodedTxs.tsx b/src/components/tx-flow/flows/ExecuteBatch/DecodedTxs.tsx
new file mode 100644
index 000000000..0915315a6
--- /dev/null
+++ b/src/components/tx-flow/flows/ExecuteBatch/DecodedTxs.tsx
@@ -0,0 +1,82 @@
+import type {
+ DataDecoded,
+ TransactionDetails,
+ MultisigExecutionDetails,
+} from '@safe-global/safe-gateway-typescript-sdk'
+import { Box } from '@mui/material'
+import useSafeInfo from '@/hooks/useSafeInfo'
+import extractTxInfo from '@/services/tx/extractTxInfo'
+import { isCustomTxInfo, isNativeTokenTransfer, isTransferTxInfo } from '@/utils/transaction-guards'
+import SingleTxDecoded from '@/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded'
+import css from '@/components/transactions/TxDetails/TxData/DecodedData/Multisend/styles.module.css'
+import { useState } from 'react'
+import { MultisendActionsHeader } from '@/components/transactions/TxDetails/TxData/DecodedData/Multisend'
+import { type AccordionProps } from '@mui/material/Accordion/Accordion'
+
+const DecodedTxs = ({ txs }: { txs: TransactionDetails[] | undefined }) => {
+ const [openMap, setOpenMap] = useState>()
+ const { safeAddress } = useSafeInfo()
+
+ if (!txs) return null
+
+ return (
+ <>
+
+
+
+ {txs.map((transaction, idx) => {
+ if (!transaction.txData) return null
+
+ const onChange: AccordionProps['onChange'] = (_, expanded) => {
+ setOpenMap((prev) => ({
+ ...prev,
+ [idx]: expanded,
+ }))
+ }
+
+ const { txParams } = extractTxInfo(
+ transaction as TransactionDetails & {
+ detailedExecutionInfo: MultisigExecutionDetails
+ },
+ safeAddress,
+ )
+
+ let decodedDataParams: DataDecoded = {
+ method: '',
+ parameters: undefined,
+ }
+
+ if (isCustomTxInfo(transaction.txInfo) && transaction.txInfo.isCancellation) {
+ decodedDataParams.method = 'On-chain rejection'
+ }
+
+ if (isTransferTxInfo(transaction.txInfo) && isNativeTokenTransfer(transaction.txInfo.transferInfo)) {
+ decodedDataParams.method = 'transfer'
+ }
+
+ const dataDecoded = transaction.txData.dataDecoded || decodedDataParams
+
+ return (
+
+ )
+ })}
+
+ >
+ )
+}
+
+export default DecodedTxs
diff --git a/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.test.ts b/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.test.ts
new file mode 100644
index 000000000..3311813f4
--- /dev/null
+++ b/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.test.ts
@@ -0,0 +1,53 @@
+import { extractTxDetails } from '@/services/tx/extractTxInfo'
+import { getBatchTxDetailsFromLocalStore } from './ReviewBatch'
+
+jest.mock('@/services/tx/extractTxInfo', () => ({
+ extractTxDetails: jest.fn(),
+}))
+
+const mockExtractTxDetails = extractTxDetails as jest.Mock
+
+describe('getBatchTxDetailsFromLocalStore', () => {
+ const safe = {
+ chainId: '1',
+ address: { value: '0x0000000000000000000000000000000000000123' },
+ } as any
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('returns undefined when local transactions are not available', async () => {
+ const txs = [{ transaction: { id: 'multisig_0x0000000000000000000000000000000000000123_0xabc' } }] as any
+
+ expect(getBatchTxDetailsFromLocalStore(txs, undefined, safe)).toBeUndefined()
+ })
+
+ it('throws if a transaction id cannot be parsed', async () => {
+ const txs = [{ transaction: { id: 'invalid-id' } }] as any
+
+ await expect(getBatchTxDetailsFromLocalStore(txs, {} as any, safe)).rejects.toThrow(
+ 'Invalid transaction id: invalid-id',
+ )
+ })
+
+ it('throws if a transaction is missing from local storage', async () => {
+ const txs = [{ transaction: { id: 'multisig_0x0000000000000000000000000000000000000123_0xabc' } }] as any
+
+ await expect(getBatchTxDetailsFromLocalStore(txs, {} as any, safe)).rejects.toThrow(
+ 'Transaction multisig_0x0000000000000000000000000000000000000123_0xabc is not available locally',
+ )
+ })
+
+ it('builds transaction details from local transactions', async () => {
+ const txs = [{ transaction: { id: 'multisig_0x0000000000000000000000000000000000000123_0xabc' } }] as any
+ const localTx = { mock: true } as any
+ const expectedDetails = { txId: 'multisig_0x0000000000000000000000000000000000000123_0xabc' } as any
+ mockExtractTxDetails.mockResolvedValue(expectedDetails)
+
+ const result = await getBatchTxDetailsFromLocalStore(txs, { '0xabc': localTx } as any, safe)
+
+ expect(mockExtractTxDetails).toHaveBeenCalledWith(safe.address.value, localTx, safe, txs[0].transaction.id)
+ expect(result).toEqual([expectedDetails])
+ })
+})
diff --git a/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.tsx b/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.tsx
new file mode 100644
index 000000000..c5954af44
--- /dev/null
+++ b/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.tsx
@@ -0,0 +1,266 @@
+import { CircularProgress, Typography, Button, CardActions, Divider, Alert } from '@mui/material'
+import useAsync from '@/hooks/useAsync'
+import { FEATURES } from '@safe-global/safe-gateway-typescript-sdk'
+import type { TransactionDetails, MultisigExecutionDetails } from '@safe-global/safe-gateway-typescript-sdk'
+import { getReadOnlyMultiSendCallOnlyContract } from '@/services/contracts/safeContracts'
+import { useCurrentChain } from '@/hooks/useChains'
+import useSafeInfo from '@/hooks/useSafeInfo'
+import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types'
+import { arrayify } from '@ethersproject/bytes'
+import { pack as solidityPack } from '@ethersproject/solidity'
+import { useState, useMemo, useContext } from 'react'
+import type { SyntheticEvent } from 'react'
+import { generateDataRowValue } from '@/components/transactions/TxDetails/Summary/TxDataRow'
+import ErrorMessage from '@/components/tx/ErrorMessage'
+// import { ExecutionMethod, ExecutionMethodSelector } from '@/components/tx/ExecutionMethodSelector'
+import DecodedTxs from '@/components/tx-flow/flows/ExecuteBatch/DecodedTxs'
+import { TxSimulation } from '@/components/tx/security/tenderly'
+import { WrongChainWarning } from '@/components/tx/WrongChainWarning'
+// import { useRelaysBySafe } from '@/hooks/useRemainingRelays'
+import useOnboard from '@/hooks/wallets/useOnboard'
+import { logError, Errors } from '@/services/exceptions'
+import { dispatchBatchExecution /*, dispatchBatchExecutionRelay */ } from '@/services/tx/tx-sender'
+// import { hasRemainingRelays } from '@/utils/relaying'
+import { getMultiSendTxs, getTxKeyFromTxId } from '@/utils/transactions'
+import TxCard from '../../common/TxCard'
+import CheckWallet from '@/components/common/CheckWallet'
+import type { ExecuteBatchFlowProps } from '.'
+import { asError } from '@/services/exceptions/utils'
+import SendToBlock from '@/components/tx/SendToBlock'
+import ConfirmationTitle, { ConfirmationTitleTypes } from '@/components/tx/SignOrExecuteForm/ConfirmationTitle'
+import commonCss from '@/components/tx-flow/common/styles.module.css'
+import { TxModalContext } from '@/components/tx-flow'
+import useGasPrice from '@/hooks/useGasPrice'
+import { hasFeature } from '@/utils/chains'
+import type { PayableOverrides } from 'ethers'
+import { useAppSelector } from '@/store'
+import { selectAddedTxs } from '@/store/addedTxsSlice'
+import { extractTxDetails } from '@/services/tx/extractTxInfo'
+import { isEqual } from 'lodash'
+import type { SafeInfo, Transaction } from '@safe-global/safe-gateway-typescript-sdk'
+
+export const getBatchTxDetailsFromLocalStore = (
+ txs: Transaction[],
+ addedTxs: ReturnType,
+ safe: SafeInfo,
+): Promise | undefined => {
+ if (!addedTxs) {
+ return
+ }
+
+ return Promise.all(
+ txs.map(async (tx) => {
+ const txKey = getTxKeyFromTxId(tx.transaction.id)
+ if (!txKey) {
+ throw new Error(`Invalid transaction id: ${tx.transaction.id}`)
+ }
+
+ const localTx = addedTxs[txKey]
+ if (!localTx) {
+ throw new Error(`Transaction ${tx.transaction.id} is not available locally`)
+ }
+
+ return extractTxDetails(safe.address.value, localTx, safe, tx.transaction.id)
+ }),
+ )
+}
+
+function encodeMetaTransaction(tx: MetaTransactionData): string {
+ const data = arrayify(tx.data)
+ const encoded = solidityPack(
+ ['uint8', 'address', 'uint256', 'uint256', 'bytes'],
+ [tx.operation, tx.to, tx.value, data.length, data],
+ )
+ return encoded.slice(2)
+}
+
+export function encodeMultiSendData(txs: MetaTransactionData[]): string {
+ return '0x' + txs.map((tx) => encodeMetaTransaction(tx)).join('')
+}
+
+export const ReviewBatch = ({ params }: { params: ExecuteBatchFlowProps }) => {
+ const [isSubmittable, setIsSubmittable] = useState(true)
+ const [submitError, setSubmitError] = useState()
+ // const [executionMethod, setExecutionMethod] = useState(ExecutionMethod.RELAY)
+ const chain = useCurrentChain()
+ const { safe } = useSafeInfo()
+ const addedTxs = useAppSelector((state) => selectAddedTxs(state, safe.chainId, safe.address.value), isEqual)
+ // const [relays] = useRelaysBySafe()
+ const { setTxFlow } = useContext(TxModalContext)
+ const [gasPrice] = useGasPrice()
+
+ const maxFeePerGas = gasPrice?.maxFeePerGas
+ const maxPriorityFeePerGas = gasPrice?.maxPriorityFeePerGas
+
+ const isEIP1559 = chain && hasFeature(chain, FEATURES.EIP1559)
+
+ // Chain has relaying feature and available relays
+ // const canRelay = false
+ // const willRelay = canRelay && executionMethod === ExecutionMethod.RELAY
+ const onboard = useOnboard()
+
+ // First useAsync for txsWithDetails
+ const [txsWithDetails, error, loading] = useAsync(() => {
+ return getBatchTxDetailsFromLocalStore(params.txs, addedTxs, safe)
+ }, [addedTxs, params.txs, safe])
+
+ // Add new useAsync for multiSendTxs
+ const [multiSendTxs, multiSendError, multiSendLoading] = useAsync(async () => {
+ if (!txsWithDetails || !chain || !safe.version) return
+ const validTxs = txsWithDetails.filter(
+ (tx): tx is TransactionDetails & { detailedExecutionInfo: MultisigExecutionDetails } =>
+ tx.detailedExecutionInfo !== undefined && 'nonce' in tx.detailedExecutionInfo,
+ )
+ return getMultiSendTxs(validTxs, chain, safe.address.value, safe.version)
+ }, [txsWithDetails, chain, safe.address.value, safe.version])
+
+ const multiSendTxData = useMemo(() => {
+ if (!multiSendTxs) return
+ return encodeMultiSendData(multiSendTxs)
+ }, [multiSendTxs])
+
+ // Replace the useMemo with useAsync for multiSendContract
+ const [multiSendContract, contractError, contractLoading] = useAsync(async () => {
+ if (!chain?.chainId || !safe.version) return
+ return getReadOnlyMultiSendCallOnlyContract(chain.chainId, safe.version)
+ }, [chain?.chainId, safe.version])
+
+ const onExecute = async () => {
+ if (!onboard || !multiSendTxData || !multiSendContract || !txsWithDetails || !gasPrice) return
+
+ const overrides: PayableOverrides = isEIP1559
+ ? { maxFeePerGas: maxFeePerGas?.toString(), maxPriorityFeePerGas: maxPriorityFeePerGas?.toString() }
+ : { gasPrice: maxFeePerGas?.toString() }
+
+ await dispatchBatchExecution(
+ txsWithDetails,
+ multiSendContract,
+ multiSendTxData,
+ onboard,
+ safe.chainId,
+ safe.address.value,
+ overrides,
+ )
+ }
+
+ // const onRelay = async () => {
+ // if (!multiSendTxData || !multiSendContract || !txsWithDetails) return
+
+ // await dispatchBatchExecutionRelay(
+ // txsWithDetails,
+ // multiSendContract,
+ // multiSendTxData,
+ // safe.chainId,
+ // safe.address.value,
+ // )
+ // }
+
+ const handleSubmit = async (e: SyntheticEvent) => {
+ e.preventDefault()
+ setIsSubmittable(false)
+ setSubmitError(undefined)
+
+ try {
+ // await (willRelay ? onRelay() : onExecute())
+ await onExecute()
+ setTxFlow(undefined)
+ } catch (_err) {
+ const err = asError(_err)
+ logError(Errors._804, err)
+ setIsSubmittable(true)
+ setSubmitError(err)
+ return
+ }
+ }
+
+ // Update submitDisabled to include multiSendLoading and contractLoading
+ const submitDisabled = loading || multiSendLoading || contractLoading || !isSubmittable || !gasPrice
+
+ return (
+ <>
+
+
+ This transaction batches a total of {params.txs.length} transactions from your queue into a single Ethereum
+ transaction. Please check every included transaction carefully, especially if you have rejection transactions,
+ and make sure you want to execute all of them. Included transactions are highlighted in green when you hover
+ over the execute button.
+
+
+ {multiSendContract && }
+
+ {multiSendTxData && (
+
+
+ Data (hex encoded)
+
+ {generateDataRowValue(multiSendTxData, 'rawData')}
+
+ )}
+
+
+
+
+
+
+ {multiSendTxs && (
+
+ Transaction checks
+
+
+
+ )}
+
+
+
+
+
+
+ {/* {canRelay ? (
+ <>
+
+ >
+ ) : null} */}
+
+
+ Be aware that if any of the included transactions revert, none of them will be executed. This will result in
+ the loss of the allocated transaction fees.
+
+
+ {error && (
+
+ This transaction will most likely fail. To save gas costs, avoid creating the transaction.
+
+ )}
+
+ {submitError && (
+ Error submitting the transaction. Please try again.
+ )}
+
+
+
+
+
+
+ {(isOk) => (
+
+ {!isSubmittable ? : 'Submit'}
+
+ )}
+
+
+
+
+ >
+ )
+}
diff --git a/src/components/tx-flow/flows/ExecuteBatch/index.tsx b/src/components/tx-flow/flows/ExecuteBatch/index.tsx
new file mode 100644
index 000000000..a2b9143ad
--- /dev/null
+++ b/src/components/tx-flow/flows/ExecuteBatch/index.tsx
@@ -0,0 +1,19 @@
+import type { Transaction } from '@safe-global/safe-gateway-typescript-sdk'
+
+import TxLayout from '@/components/tx-flow/common/TxLayout'
+import { ReviewBatch } from './ReviewBatch'
+import BatchIcon from '@/public/images/apps/batch-icon.svg'
+
+export type ExecuteBatchFlowProps = {
+ txs: Transaction[]
+}
+
+const ExecuteBatchFlow = (props: ExecuteBatchFlowProps) => {
+ return (
+
+
+
+ )
+}
+
+export default ExecuteBatchFlow
diff --git a/src/components/tx-flow/flows/SafeAppsTx/ReviewSafeAppsTx.tsx b/src/components/tx-flow/flows/SafeAppsTx/ReviewSafeAppsTx.tsx
new file mode 100644
index 000000000..729c570f0
--- /dev/null
+++ b/src/components/tx-flow/flows/SafeAppsTx/ReviewSafeAppsTx.tsx
@@ -0,0 +1,80 @@
+import { useContext, useEffect } from 'react'
+import type { ReactElement } from 'react'
+import type { SafeTransaction } from '@safe-global/safe-core-sdk-types'
+import SendToBlock from '@/components/tx/SendToBlock'
+import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm'
+import { useCurrentChain } from '@/hooks/useChains'
+import type { SafeAppsTxParams } from '.'
+import { createMultiSendCallOnlyTx, createTx, dispatchSafeAppsTx } from '@/services/tx/tx-sender'
+import useOnboard from '@/hooks/wallets/useOnboard'
+import useSafeInfo from '@/hooks/useSafeInfo'
+import useHighlightHiddenTab from '@/hooks/useHighlightHiddenTab'
+import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider'
+import { getInteractionTitle, isTxValid } from '@/components/safe-apps/utils'
+import ErrorMessage from '@/components/tx/ErrorMessage'
+import { asError } from '@/services/exceptions/utils'
+
+type ReviewSafeAppsTxProps = {
+ safeAppsTx: SafeAppsTxParams
+ onSubmit?: (txId: string, safeTxHash: string) => void
+}
+
+const ReviewSafeAppsTx = ({
+ safeAppsTx: { txs, requestId, params, app },
+ onSubmit,
+}: ReviewSafeAppsTxProps): ReactElement => {
+ const { safe } = useSafeInfo()
+ const onboard = useOnboard()
+ const chain = useCurrentChain()
+ const { safeTx, setSafeTx, safeTxError, setSafeTxError } = useContext(SafeTxContext)
+
+ useHighlightHiddenTab()
+
+ useEffect(() => {
+ const createSafeTx = async (): Promise => {
+ const isMultiSend = txs.length > 1
+ const tx = isMultiSend ? await createMultiSendCallOnlyTx(txs) : await createTx(txs[0])
+
+ if (params?.safeTxGas) {
+ // FIXME: do it properly via the Core SDK
+ // @ts-expect-error safeTxGas readonly
+ tx.data.safeTxGas = params.safeTxGas
+ }
+
+ return tx
+ }
+
+ createSafeTx().then(setSafeTx).catch(setSafeTxError)
+ }, [txs, setSafeTx, setSafeTxError, params])
+
+ const handleSubmit = async (txId: string) => {
+ console.log('handleSubmit', safeTx)
+ if (!safeTx || !onboard) return
+
+ let safeTxHash = ''
+ try {
+ safeTxHash = await dispatchSafeAppsTx(safeTx, requestId, onboard, safe.chainId, txId)
+ } catch (error) {
+ setSafeTxError(asError(error))
+ }
+
+ onSubmit?.(txId, safeTxHash)
+ }
+
+ const error = !isTxValid(txs)
+
+ return (
+
+ {safeTx ? (
+
+ ) : error ? (
+
+ This Safe App initiated a transaction which cannot be processed. Please get in touch with the developer of
+ this Safe App for more information.
+
+ ) : null}
+
+ )
+}
+
+export default ReviewSafeAppsTx
diff --git a/src/components/tx-flow/flows/SafeAppsTx/index.tsx b/src/components/tx-flow/flows/SafeAppsTx/index.tsx
new file mode 100644
index 000000000..297d13f56
--- /dev/null
+++ b/src/components/tx-flow/flows/SafeAppsTx/index.tsx
@@ -0,0 +1,33 @@
+import type { BaseTransaction, RequestId, SendTransactionRequestParams } from '@safe-global/safe-apps-sdk'
+import TxLayout from '@/components/tx-flow/common/TxLayout'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+import ReviewSafeAppsTx from './ReviewSafeAppsTx'
+import { AppTitle } from '@/components/tx-flow/flows/SignMessage'
+
+export type SafeAppsTxParams = {
+ appId?: string
+ app?: Partial
+ requestId: RequestId
+ txs: BaseTransaction[]
+ params?: SendTransactionRequestParams
+}
+
+const SafeAppsTxFlow = ({
+ data,
+ onSubmit,
+}: {
+ data: SafeAppsTxParams
+ onSubmit?: (txId: string, safeTxHash: string) => void
+}) => {
+ return (
+ }
+ step={0}
+ >
+
+
+ )
+}
+
+export default SafeAppsTxFlow
diff --git a/src/components/tx-flow/flows/SignMessage/SignMessage.tsx b/src/components/tx-flow/flows/SignMessage/SignMessage.tsx
new file mode 100644
index 000000000..3e410477d
--- /dev/null
+++ b/src/components/tx-flow/flows/SignMessage/SignMessage.tsx
@@ -0,0 +1,276 @@
+import {
+ Grid,
+ Button,
+ Box,
+ Typography,
+ SvgIcon,
+ CardContent,
+ CardActions,
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+} from '@mui/material'
+import { useTheme } from '@mui/material/styles'
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
+import { useContext, useEffect } from 'react'
+import { SafeMessageListItemType, SafeMessageStatus } from '@safe-global/safe-gateway-typescript-sdk'
+import type { ReactElement } from 'react'
+import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk'
+import type { RequestId } from '@safe-global/safe-apps-sdk'
+import EthHashInfo from '@/components/common/EthHashInfo'
+import RequiredIcon from '@/public/images/messages/required.svg'
+import useSafeInfo from '@/hooks/useSafeInfo'
+
+import useIsSafeOwner from '@/hooks/useIsSafeOwner'
+import ErrorMessage from '@/components/tx/ErrorMessage'
+import useWallet from '@/hooks/wallets/useWallet'
+import useSafeMessage from '@/hooks/messages/useSafeMessage'
+import useOnboard, { switchWallet } from '@/hooks/wallets/useOnboard'
+import { TxModalContext } from '@/components/tx-flow'
+import CopyButton from '@/components/common/CopyButton'
+import { WrongChainWarning } from '@/components/tx/WrongChainWarning'
+import MsgSigners from '@/components/safe-messages/MsgSigners'
+import useDecodedSafeMessage from '@/hooks/messages/useDecodedSafeMessage'
+import useSyncSafeMessageSigner from '@/hooks/messages/useSyncSafeMessageSigner'
+import SuccessMessage from '@/components/tx/SuccessMessage'
+import useHighlightHiddenTab from '@/hooks/useHighlightHiddenTab'
+import InfoBox from '@/components/safe-messages/InfoBox'
+import { DecodedMsg } from '@/components/safe-messages/DecodedMsg'
+import TxCard from '@/components/tx-flow/common/TxCard'
+import { dispatchPreparedSignature } from '@/services/safe-messages/safeMsgNotifications'
+import { SafeTxContext } from '../../SafeTxProvider'
+
+const createSkeletonMessage = (confirmationsRequired: number): SafeMessage => {
+ return {
+ confirmations: [],
+ confirmationsRequired,
+ confirmationsSubmitted: 0,
+ creationTimestamp: 0,
+ message: '',
+ logoUri: null,
+ messageHash: '',
+ modifiedTimestamp: 0,
+ name: null,
+ proposedBy: {
+ value: '',
+ },
+ status: SafeMessageStatus.NEEDS_CONFIRMATION,
+ type: SafeMessageListItemType.MESSAGE,
+ }
+}
+
+const MessageHashField = ({ label, hashValue }: { label: string; hashValue: string }) => (
+ <>
+
+ {label}:
+
+
+
+
+ >
+)
+
+const DialogHeader = ({ threshold }: { threshold: number }) => (
+ <>
+
+
+
+
+ Confirm message
+
+ {threshold > 1 && (
+
+ To sign this message, collect signatures from {threshold} owners of your Safe Account.
+
+ )}
+ >
+)
+
+const MessageDialogError = ({ isOwner, submitError }: { isOwner: boolean; submitError: Error | undefined }) => {
+ const wallet = useWallet()
+ const onboard = useOnboard()
+
+ const errorMessage =
+ !wallet || !onboard
+ ? 'No wallet is connected.'
+ : !isOwner
+ ? "You are currently not an owner of this Safe Account and won't be able to confirm this message."
+ : submitError
+ ? 'Error confirming the message. Please try again.'
+ : null
+
+ if (errorMessage) {
+ return {errorMessage}
+ }
+ return null
+}
+
+const AlreadySignedByOwnerMessage = ({ hasSigned }: { hasSigned: boolean }) => {
+ const onboard = useOnboard()
+
+ const handleSwitchWallet = () => {
+ if (onboard) {
+ switchWallet(onboard)
+ }
+ }
+ if (!hasSigned) {
+ return null
+ }
+ return (
+
+
+
+ Your connected wallet has already signed this message.
+
+
+
+ Switch wallet
+
+
+
+
+ )
+}
+
+const SuccessCard = ({ safeMessage, onContinue }: { safeMessage: SafeMessage; onContinue: () => void }) => {
+ return (
+
+
+ Message successfully signed
+
+
+
+
+
+ Continue
+
+
+
+ )
+}
+
+type BaseProps = Pick
+
+// Custom Safe Apps do not have a `safeAppId`
+export type ProposeProps = BaseProps & {
+ safeAppId?: number
+ requestId: RequestId
+}
+
+// A proposed message does not return the `safeAppId` but the `logoUri` and `name` of the Safe App that proposed it
+export type ConfirmProps = BaseProps & {
+ safeAppId?: never
+ requestId?: RequestId
+}
+
+const SignMessage = ({ message, safeAppId, requestId }: ProposeProps | ConfirmProps): ReactElement => {
+ // Hooks & variables
+ const { setTxFlow } = useContext(TxModalContext)
+ const { setSafeMessage: setContextSafeMessage } = useContext(SafeTxContext)
+ const { palette } = useTheme()
+ const { safe } = useSafeInfo()
+ const isOwner = useIsSafeOwner()
+ const wallet = useWallet()
+ useHighlightHiddenTab()
+
+ const { decodedMessage, safeMessageMessage, safeMessageHash } = useDecodedSafeMessage(message, safe)
+ const [safeMessage, setSafeMessage] = useSafeMessage(safeMessageHash)
+ const isPlainTextMessage = typeof decodedMessage === 'string'
+ const decodedMessageAsString = isPlainTextMessage ? decodedMessage : JSON.stringify(decodedMessage, null, 2)
+ const hasSigned = !!safeMessage?.confirmations.some(({ owner }) => owner.value === wallet?.address)
+ const isFullySigned = !!safeMessage?.preparedSignature
+ const isDisabled = !isOwner || hasSigned
+
+ const { onSign, submitError } = useSyncSafeMessageSigner(
+ safeMessage,
+ decodedMessage,
+ safeMessageHash,
+ requestId,
+ safeAppId,
+ () => setTxFlow(undefined),
+ )
+
+ const handleSign = async () => {
+ const updatedMessage = await onSign()
+
+ if (updatedMessage) {
+ setSafeMessage(updatedMessage)
+ }
+ }
+
+ const onContinue = async () => {
+ if (!safeMessage) {
+ return
+ }
+ await dispatchPreparedSignature(safeMessage, safeMessageHash, () => setTxFlow(undefined), requestId)
+ }
+
+ // Set message for redefine scan
+ useEffect(() => {
+ if (typeof message !== 'string') {
+ setContextSafeMessage(message)
+ }
+ }, [message, setContextSafeMessage])
+
+ return (
+ <>
+
+
+
+
+
+ Message:
+
+
+
+
+ }>SafeMessage details
+
+
+
+
+
+
+
+
+ {isFullySigned ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sign
+
+
+
+ >
+ )}
+ >
+ )
+}
+
+export default SignMessage
diff --git a/src/components/tx-flow/flows/SignMessage/index.tsx b/src/components/tx-flow/flows/SignMessage/index.tsx
new file mode 100644
index 000000000..b19c85d95
--- /dev/null
+++ b/src/components/tx-flow/flows/SignMessage/index.tsx
@@ -0,0 +1,36 @@
+import TxLayout from '@/components/tx-flow/common/TxLayout'
+import SignMessage, { type ConfirmProps, type ProposeProps } from '@/components/tx-flow/flows/SignMessage/SignMessage'
+import { Box, Typography } from '@mui/material'
+import SafeAppIconCard from '@/components/safe-apps/SafeAppIconCard'
+
+const APP_LOGO_FALLBACK_IMAGE = '/images/apps/apps-icon.svg'
+const APP_NAME_FALLBACK = 'Sign message'
+
+export const AppTitle = ({ name, logoUri }: { name?: string | null; logoUri?: string | null }) => {
+ const appName = name || APP_NAME_FALLBACK
+ const appLogo = logoUri || APP_LOGO_FALLBACK_IMAGE
+ return (
+
+
+
+ {appName}
+
+
+ )
+}
+
+const SignMessageFlow = ({ ...props }: ProposeProps | ConfirmProps) => {
+ return (
+ }
+ step={0}
+ hideNonce
+ isMessage
+ >
+
+
+ )
+}
+
+export default SignMessageFlow
diff --git a/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.tsx b/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.tsx
new file mode 100644
index 000000000..0403dfb9a
--- /dev/null
+++ b/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.tsx
@@ -0,0 +1,157 @@
+import type { ReactElement } from 'react'
+import { useContext, useEffect, useMemo, useState } from 'react'
+import { hashMessage, _TypedDataEncoder } from 'ethers/lib/utils'
+import { Box } from '@mui/system'
+import { Typography, SvgIcon } from '@mui/material'
+import WarningIcon from '@/public/images/notifications/warning.svg'
+import { type EIP712TypedData, Methods, type RequestId } from '@safe-global/safe-apps-sdk'
+import { OperationType } from '@safe-global/safe-core-sdk-types'
+
+import SendFromBlock from '@/components/tx/SendFromBlock'
+import { InfoDetails } from '@/components/transactions/InfoDetails'
+import EthHashInfo from '@/components/common/EthHashInfo'
+import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm'
+import { generateDataRowValue } from '@/components/transactions/TxDetails/Summary/TxDataRow'
+import useChainId from '@/hooks/useChainId'
+import { getReadOnlySignMessageLibContract } from '@/services/contracts/safeContracts'
+import { DecodedMsg } from '@/components/safe-messages/DecodedMsg'
+import CopyButton from '@/components/common/CopyButton'
+import { getDecodedMessage } from '@/components/safe-apps/utils'
+import { createTx, dispatchSafeAppsTx } from '@/services/tx/tx-sender'
+import useOnboard from '@/hooks/wallets/useOnboard'
+import useSafeInfo from '@/hooks/useSafeInfo'
+import useHighlightHiddenTab from '@/hooks/useHighlightHiddenTab'
+import { type SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider'
+import { asError } from '@/services/exceptions/utils'
+import { isEIP712TypedData } from '@/utils/safe-messages'
+import useAsync from '@/hooks/useAsync'
+
+export type SignMessageOnChainProps = {
+ app?: SafeAppData
+ requestId: RequestId
+ message: string | EIP712TypedData
+ method: Methods.signMessage | Methods.signTypedMessage
+}
+
+const ReviewSignMessageOnChain = ({ message, method, requestId }: SignMessageOnChainProps): ReactElement => {
+ const chainId = useChainId()
+ const { safe } = useSafeInfo()
+ const onboard = useOnboard()
+ const { safeTx, setSafeTx, setSafeTxError } = useContext(SafeTxContext)
+
+ useHighlightHiddenTab()
+
+ const isTextMessage = method === Methods.signMessage && typeof message === 'string'
+ const isTypedMessage = method === Methods.signTypedMessage && isEIP712TypedData(message)
+
+ const [readOnlySignMessageLibContract] = useAsync(
+ () => getReadOnlySignMessageLibContract(chainId, safe.version),
+ [chainId, safe.version],
+ )
+
+ const [signMessageAddress, setSignMessageAddress] = useState('')
+
+ useEffect(() => {
+ if (!readOnlySignMessageLibContract) return
+ setSignMessageAddress(readOnlySignMessageLibContract.getAddress())
+ }, [readOnlySignMessageLibContract])
+
+ const [decodedMessage, readableMessage] = useMemo(() => {
+ if (isTextMessage) {
+ const decoded = getDecodedMessage(message)
+ return [decoded, decoded]
+ } else if (isTypedMessage) {
+ return [message, JSON.stringify(message, null, 2)]
+ }
+ return []
+ }, [isTextMessage, isTypedMessage, message])
+
+ useEffect(() => {
+ let txData
+ if (!readOnlySignMessageLibContract) return
+ if (!signMessageAddress) {
+ setSignMessageAddress(readOnlySignMessageLibContract.getAddress())
+ }
+
+ if (isTextMessage) {
+ txData = readOnlySignMessageLibContract.encode('signMessage', [hashMessage(getDecodedMessage(message))])
+ } else if (isTypedMessage) {
+ const typesCopy = { ...message.types }
+
+ // We need to remove the EIP712Domain type from the types object
+ // Because it's a part of the JSON-RPC payload, but for the `.hash` in ethers.js
+ // The types are not allowed to be recursive, so ever type must either be used by another type, or be
+ // the primary type. And there must only be one type that is not used by any other type.
+ delete typesCopy.EIP712Domain
+ txData = readOnlySignMessageLibContract.encode('signMessage', [
+ // @ts-ignore
+ _TypedDataEncoder.hash(message.domain, typesCopy, message.message),
+ ])
+ }
+
+ const params = {
+ to: signMessageAddress,
+ value: '0',
+ data: txData ?? '0x',
+ operation: OperationType.DelegateCall,
+ }
+ createTx(params).then(setSafeTx).catch(setSafeTxError)
+ }, [
+ isTextMessage,
+ isTypedMessage,
+ message,
+ readOnlySignMessageLibContract,
+ setSafeTx,
+ setSafeTxError,
+ signMessageAddress,
+ readOnlySignMessageLibContract,
+ ])
+
+ const handleSubmit = async () => {
+ if (!safeTx || !onboard) return
+
+ try {
+ await dispatchSafeAppsTx(safeTx, requestId, onboard, safe.chainId)
+ } catch (error) {
+ setSafeTxError(asError(error))
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ {safeTx && (
+
+
+ Data (hex encoded)
+
+ {generateDataRowValue(safeTx.data.data, 'rawData')}
+
+ )}
+
+
+ Signing method: {method}
+
+
+
+ Signing message: {readableMessage && }
+
+
+
+
+
+
+ Signing a message with your Safe Account requires a transaction on the blockchain
+
+
+
+ )
+}
+
+export default ReviewSignMessageOnChain
diff --git a/src/components/tx-flow/flows/SignMessageOnChain/index.tsx b/src/components/tx-flow/flows/SignMessageOnChain/index.tsx
new file mode 100644
index 000000000..bcfc3c767
--- /dev/null
+++ b/src/components/tx-flow/flows/SignMessageOnChain/index.tsx
@@ -0,0 +1,19 @@
+import TxLayout from '@/components/tx-flow/common/TxLayout'
+import { AppTitle } from '@/components/tx-flow/flows/SignMessage'
+import ReviewSignMessageOnChain, {
+ type SignMessageOnChainProps,
+} from '@/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain'
+
+const SignMessageOnChainFlow = ({ props }: { props: SignMessageOnChainProps }) => {
+ return (
+ }
+ step={0}
+ >
+
+
+ )
+}
+
+export default SignMessageOnChainFlow
diff --git a/src/components/tx-flow/flows/index.ts b/src/components/tx-flow/flows/index.ts
index 7cd4bf646..2d4bbfc2a 100644
--- a/src/components/tx-flow/flows/index.ts
+++ b/src/components/tx-flow/flows/index.ts
@@ -4,6 +4,7 @@ export const AddOwnerFlow = dynamic(() => import('./AddOwner'))
export const ChangeThresholdFlow = dynamic(() => import('./ChangeThreshold'))
export const ConfirmBatchFlow = dynamic(() => import('./ConfirmBatch'))
export const ConfirmTxFlow = dynamic(() => import('./ConfirmTx'))
+export const ExecuteBatchFlow = dynamic(() => import('./ExecuteBatch'))
export const NewSpendingLimitFlow = dynamic(() => import('./NewSpendingLimit'))
export const NewTxFlow = dynamic(() => import('./NewTx'))
export const NftTransferFlow = dynamic(() => import('./NftTransfer'))
@@ -14,6 +15,9 @@ export const RemoveOwnerFlow = dynamic(() => import('./RemoveOwner'))
export const RemoveSpendingLimitFlow = dynamic(() => import('./RemoveSpendingLimit'))
export const ReplaceOwnerFlow = dynamic(() => import('./ReplaceOwner'))
export const ReplaceTxFlow = dynamic(() => import('./ReplaceTx'))
+export const SafeAppsTxFlow = dynamic(() => import('./SafeAppsTx'))
+export const SignMessageFlow = dynamic(() => import('./SignMessage'))
+export const SignMessageOnChainFlow = dynamic(() => import('./SignMessageOnChain'))
export const SuccessScreenFlow = dynamic(() => import('./SuccessScreen'))
export const TokenTransferFlow = dynamic(() => import('./TokenTransfer'))
export const UpdateSafeFlow = dynamic(() => import('./UpdateSafe'))
diff --git a/src/components/tx/SignOrExecuteForm/index.tsx b/src/components/tx/SignOrExecuteForm/index.tsx
index 66d8804dc..8e5043031 100644
--- a/src/components/tx/SignOrExecuteForm/index.tsx
+++ b/src/components/tx/SignOrExecuteForm/index.tsx
@@ -35,6 +35,7 @@ export type SignOrExecuteProps = {
disableSubmit?: boolean
isCreation?: boolean
txDetails?: TransactionDetails
+ origin?: string
}
export const SignOrExecuteForm = ({
diff --git a/src/config/constants.ts b/src/config/constants.ts
index 5b58f89f2..2ce36e15e 100644
--- a/src/config/constants.ts
+++ b/src/config/constants.ts
@@ -69,3 +69,7 @@ export const DEFAULT_IPFS_GATEWAY = 'https://cloudflare-ipfs.com'
export const DEFAULT_TOKENLIST_IPNS = 'ipns/tokens.uniswap.org'
export const CHAINLIST_URL = 'https://chainlist.org/'
export const OFFICIAL_APP_URL = 'https://app.safe.global'
+
+// Risk mitigation (Redefine)
+export const REDEFINE_SIMULATION_URL = 'https://dashboard.redefine.net/reports/'
+export const REDEFINE_ARTICLE = 'https://safe.mirror.xyz/rInLWZwD_sf7enjoFerj6FIzCYmVMGrrV8Nhg4THdwI'
diff --git a/src/config/routes.ts b/src/config/routes.ts
index 640efb0e6..3fcfa47ed 100644
--- a/src/config/routes.ts
+++ b/src/config/routes.ts
@@ -6,6 +6,11 @@ export const AppRoutes = {
customChain: '/custom-chain',
addressBook: '/address-book',
addOwner: '/add-owner',
+ apps: {
+ open: '/apps/open',
+ index: '/apps',
+ bookmarked: '/apps/bookmarked',
+ },
balances: {
nfts: '/balances/nfts',
index: '/balances',
diff --git a/src/hooks/messages/__tests__/useSyncSafeMessageSigner.test.ts b/src/hooks/messages/__tests__/useSyncSafeMessageSigner.test.ts
new file mode 100644
index 000000000..d9a89d43f
--- /dev/null
+++ b/src/hooks/messages/__tests__/useSyncSafeMessageSigner.test.ts
@@ -0,0 +1,164 @@
+import { act, renderHook } from '@/tests/test-utils'
+import useSafeInfo from '@/hooks/useSafeInfo'
+import useOnboard from '@/hooks/wallets/useOnboard'
+import useSyncSafeMessageSigner from '../useSyncSafeMessageSigner'
+import { dispatchPreparedSignature } from '@/services/safe-messages/safeMsgNotifications'
+import { dispatchSafeMsgProposal, dispatchSafeMsgConfirmation } from '@/services/safe-messages/safeMsgSender'
+
+jest.mock('@/hooks/useSafeInfo')
+jest.mock('@/hooks/wallets/useOnboard')
+jest.mock('@/services/safe-messages/safeMsgNotifications', () => ({
+ dispatchPreparedSignature: jest.fn(),
+}))
+jest.mock('@/services/safe-messages/safeMsgSender', () => ({
+ dispatchSafeMsgProposal: jest.fn(),
+ dispatchSafeMsgConfirmation: jest.fn(),
+}))
+
+const mockedUseSafeInfo = useSafeInfo as jest.MockedFunction
+const mockedUseOnboard = useOnboard as jest.MockedFunction
+const mockedDispatchPreparedSignature = dispatchPreparedSignature as jest.MockedFunction<
+ typeof dispatchPreparedSignature
+>
+const mockedDispatchSafeMsgProposal = dispatchSafeMsgProposal as jest.MockedFunction
+const mockedDispatchSafeMsgConfirmation = dispatchSafeMsgConfirmation as jest.MockedFunction<
+ typeof dispatchSafeMsgConfirmation
+>
+
+describe('useSyncSafeMessageSigner', () => {
+ const safe = {
+ chainId: '1',
+ threshold: 1,
+ address: { value: '0x0000000000000000000000000000000000000123' },
+ } as any
+ const onboard = {} as any
+ const decodedMessage = '0xdeadbeef'
+ const safeMessageHash = '0xhash'
+ const safeAppId = 42
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockedUseSafeInfo.mockReturnValue({ safe } as any)
+ mockedUseOnboard.mockReturnValue(onboard)
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ it('proposes a new message without immediately dispatching a prepared signature', async () => {
+ jest.useFakeTimers()
+ mockedDispatchSafeMsgProposal.mockResolvedValue(undefined)
+ const onClose = jest.fn()
+
+ const { result } = renderHook(() =>
+ useSyncSafeMessageSigner(undefined, decodedMessage, safeMessageHash, 'request-id', safeAppId, onClose),
+ )
+
+ await act(async () => {
+ await result.current.onSign()
+ })
+
+ expect(mockedDispatchSafeMsgProposal).toHaveBeenCalledWith({
+ onboard,
+ safe,
+ message: decodedMessage,
+ safeAppId,
+ })
+
+ jest.runOnlyPendingTimers()
+
+ expect(mockedDispatchPreparedSignature).not.toHaveBeenCalled()
+ expect(onClose).not.toHaveBeenCalled()
+ })
+
+ it('confirms an existing message and closes immediately when no request id is provided', async () => {
+ const onClose = jest.fn()
+ const message = {} as any
+ mockedDispatchSafeMsgConfirmation.mockResolvedValue(undefined)
+
+ const { result } = renderHook(() =>
+ useSyncSafeMessageSigner(message, decodedMessage, safeMessageHash, undefined, safeAppId, onClose),
+ )
+
+ await act(async () => {
+ await result.current.onSign()
+ })
+
+ expect(mockedDispatchSafeMsgConfirmation).toHaveBeenCalledWith({
+ onboard,
+ safe,
+ message: decodedMessage,
+ })
+ expect(onClose).toHaveBeenCalledTimes(1)
+ expect(mockedDispatchPreparedSignature).not.toHaveBeenCalled()
+ })
+
+ it('confirms an existing message without closing immediately when request id is present', async () => {
+ const onClose = jest.fn()
+ const message = {} as any
+ mockedDispatchSafeMsgConfirmation.mockResolvedValue(undefined)
+
+ const { result } = renderHook(() =>
+ useSyncSafeMessageSigner(message, decodedMessage, safeMessageHash, 'request-id', safeAppId, onClose),
+ )
+
+ await act(async () => {
+ await result.current.onSign()
+ })
+
+ expect(mockedDispatchSafeMsgConfirmation).toHaveBeenCalledWith({
+ onboard,
+ safe,
+ message: decodedMessage,
+ })
+ expect(onClose).not.toHaveBeenCalled()
+ expect(mockedDispatchPreparedSignature).not.toHaveBeenCalled()
+ })
+
+ it('dispatches the prepared signature when the message gets a prepared signature', () => {
+ jest.useFakeTimers()
+ const onClose = jest.fn()
+ const message = { preparedSignature: '0xsig' } as any
+
+ renderHook(() =>
+ useSyncSafeMessageSigner(message, decodedMessage, safeMessageHash, 'request-id', safeAppId, onClose),
+ )
+
+ jest.advanceTimersByTime(3000)
+
+ expect(mockedDispatchPreparedSignature).toHaveBeenCalledWith(message, safeMessageHash, onClose, 'request-id')
+ })
+
+ it('sets submitError when signing fails', async () => {
+ const onClose = jest.fn()
+ const error = new Error('Failed to sign')
+ mockedDispatchSafeMsgProposal.mockRejectedValue(error)
+
+ const { result } = renderHook(() =>
+ useSyncSafeMessageSigner(undefined, decodedMessage, safeMessageHash, 'request-id', safeAppId, onClose),
+ )
+
+ await act(async () => {
+ await result.current.onSign()
+ })
+
+ expect(result.current.submitError).toEqual(error)
+ })
+
+ it('returns early when no wallet is connected', async () => {
+ mockedUseOnboard.mockReturnValue(undefined)
+ const onClose = jest.fn()
+
+ const { result } = renderHook(() =>
+ useSyncSafeMessageSigner(undefined, decodedMessage, safeMessageHash, 'request-id', safeAppId, onClose),
+ )
+
+ await act(async () => {
+ await result.current.onSign()
+ })
+
+ expect(mockedDispatchSafeMsgProposal).not.toHaveBeenCalled()
+ expect(mockedDispatchSafeMsgConfirmation).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/hooks/messages/useDecodedSafeMessage.ts b/src/hooks/messages/useDecodedSafeMessage.ts
new file mode 100644
index 000000000..b619701ed
--- /dev/null
+++ b/src/hooks/messages/useDecodedSafeMessage.ts
@@ -0,0 +1,44 @@
+import { getDecodedMessage } from '@/components/safe-apps/utils'
+import { generateSafeMessageMessage, generateSafeMessageHash } from '@/utils/safe-messages'
+import type { EIP712TypedData, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk'
+import { useMemo } from 'react'
+
+/**
+ * Returns the decoded message, the hash of the `message` and the hash of the `safeMessage`.
+ * The `safeMessageMessage` is the value inside the SafeMessage and the `safeMessageHash` gets signed if the connected wallet does not support `eth_signTypedData`.
+ *
+ * @param message message as string, UTF-8 encoded hex string or EIP-712 Typed Data
+ * @param safe SafeInfo of the opened Safe
+ * @returns `{
+ * decodedMessage,
+ * safeMessageMessage,
+ * safeMessageHash
+ * }`
+ */
+const useDecodedSafeMessage = (
+ message: string | EIP712TypedData,
+ safe: SafeInfo,
+): { decodedMessage: string | EIP712TypedData; safeMessageMessage: string; safeMessageHash: string } => {
+ // Decode message if UTF-8 encoded
+ const decodedMessage = useMemo(() => {
+ return typeof message === 'string' ? getDecodedMessage(message) : message
+ }, [message])
+
+ // Get `SafeMessage` message
+ const safeMessageMessage = useMemo(() => {
+ return generateSafeMessageMessage(decodedMessage)
+ }, [decodedMessage])
+
+ // Get `SafeMessage` hash
+ const safeMessageHash = useMemo(() => {
+ return generateSafeMessageHash(safe, decodedMessage)
+ }, [safe, decodedMessage])
+
+ return {
+ decodedMessage,
+ safeMessageMessage,
+ safeMessageHash,
+ }
+}
+
+export default useDecodedSafeMessage
diff --git a/src/hooks/messages/useSyncSafeMessageSigner.ts b/src/hooks/messages/useSyncSafeMessageSigner.ts
new file mode 100644
index 000000000..4947c33e1
--- /dev/null
+++ b/src/hooks/messages/useSyncSafeMessageSigner.ts
@@ -0,0 +1,63 @@
+import { asError } from '@/services/exceptions/utils'
+import { dispatchPreparedSignature } from '@/services/safe-messages/safeMsgNotifications'
+import { dispatchSafeMsgProposal, dispatchSafeMsgConfirmation } from '@/services/safe-messages/safeMsgSender'
+import { type EIP712TypedData, type SafeMessage } from '@safe-global/safe-gateway-typescript-sdk'
+import { useEffect, useCallback, useState } from 'react'
+import useSafeInfo from '../useSafeInfo'
+import useOnboard from '../wallets/useOnboard'
+
+const HIDE_DELAY = 3000
+
+const useSyncSafeMessageSigner = (
+ message: SafeMessage | undefined,
+ decodedMessage: string | EIP712TypedData,
+ safeMessageHash: string,
+ requestId: string | undefined,
+ safeAppId: number | undefined,
+ onClose: () => void,
+) => {
+ const [submitError, setSubmitError] = useState()
+ const onboard = useOnboard()
+ const { safe } = useSafeInfo()
+
+ // If the message gets updated in the messageSlice we dispatch it if the signature is complete
+ useEffect(() => {
+ let timeout: NodeJS.Timeout | undefined
+ if (message?.preparedSignature) {
+ timeout = setTimeout(() => dispatchPreparedSignature(message, safeMessageHash, onClose, requestId), HIDE_DELAY)
+ }
+ return () => clearTimeout(timeout)
+ }, [message, safe.chainId, safeMessageHash, onClose, requestId])
+
+ const onSign = useCallback(async (): Promise => {
+ // Error is shown when no wallet is connected, this appeases TypeScript
+ if (!onboard) {
+ return
+ }
+
+ setSubmitError(undefined)
+
+ try {
+ // When collecting the first signature
+ if (!message) {
+ await dispatchSafeMsgProposal({ onboard, safe, message: decodedMessage, safeAppId })
+ return
+ } else {
+ await dispatchSafeMsgConfirmation({ onboard, safe, message: decodedMessage })
+
+ // No requestID => we are in the confirm message dialog and do not need to leave the window open
+ if (!requestId) {
+ onClose()
+ return
+ }
+ return
+ }
+ } catch (e) {
+ setSubmitError(asError(e))
+ }
+ }, [onboard, requestId, message, safe, decodedMessage, safeAppId, onClose])
+
+ return { submitError, onSign }
+}
+
+export default useSyncSafeMessageSigner
diff --git a/src/hooks/safe-apps/permissions/index.ts b/src/hooks/safe-apps/permissions/index.ts
new file mode 100644
index 000000000..5532409db
--- /dev/null
+++ b/src/hooks/safe-apps/permissions/index.ts
@@ -0,0 +1,29 @@
+import { RestrictedMethods } from '@safe-global/safe-apps-sdk'
+import type { AllowedFeatures } from '@/components/safe-apps/types'
+import { capitalize } from '@/utils/formatters'
+
+type PermissionsDisplayType = {
+ displayName: string
+ description: string
+}
+
+export * from './useBrowserPermissions'
+export * from './useSafePermissions'
+
+const SAFE_PERMISSIONS_TEXTS: Record = {
+ [RestrictedMethods.requestAddressBook]: {
+ displayName: 'Address Book',
+ description: 'Access to your address book',
+ },
+}
+
+export const getSafePermissionDisplayValues = (method: string) => {
+ return SAFE_PERMISSIONS_TEXTS[method]
+}
+
+export const getBrowserPermissionDisplayValues = (feature: AllowedFeatures) => {
+ return {
+ displayName: capitalize(feature).replace(/-/g, ' '),
+ description: `Allow to use - ${feature}`,
+ }
+}
diff --git a/src/hooks/safe-apps/permissions/useBrowserPermissions.ts b/src/hooks/safe-apps/permissions/useBrowserPermissions.ts
new file mode 100644
index 000000000..cabbd30b2
--- /dev/null
+++ b/src/hooks/safe-apps/permissions/useBrowserPermissions.ts
@@ -0,0 +1,89 @@
+import type { AllowedFeatures } from '@/components/safe-apps/types'
+import { PermissionStatus } from '@/components/safe-apps/types'
+import useLocalStorage from '@/services/local-storage/useLocalStorage'
+import { useCallback } from 'react'
+import { trimTrailingSlash } from '@/utils/url'
+
+const BROWSER_PERMISSIONS = 'SafeApps__browserPermissions'
+
+export type BrowserPermission = { feature: AllowedFeatures; status: PermissionStatus }
+
+type BrowserPermissions = { [origin: string]: BrowserPermission[] }
+
+type BrowserPermissionChangeSet = { feature: AllowedFeatures; selected: boolean }[]
+
+type UseBrowserPermissionsReturnType = {
+ permissions: BrowserPermissions
+ getPermissions: (origin: string) => BrowserPermission[]
+ updatePermission: (origin: string, changeset: BrowserPermissionChangeSet) => void
+ addPermissions: (origin: string, permissions: BrowserPermission[]) => void
+ removePermissions: (origin: string) => void
+ getAllowedFeaturesList: (origin: string) => string
+}
+
+const useBrowserPermissions = (): UseBrowserPermissionsReturnType => {
+ const [permissions = {}, setPermissions] = useLocalStorage(BROWSER_PERMISSIONS)
+
+ const getPermissions = useCallback(
+ (origin: string) => {
+ return permissions[trimTrailingSlash(origin)] || []
+ },
+ [permissions],
+ )
+
+ const updatePermission = useCallback(
+ (origin: string, changeset: BrowserPermissionChangeSet) => {
+ const appUrl = trimTrailingSlash(origin)
+
+ setPermissions({
+ ...permissions,
+ [appUrl]: permissions[appUrl].map((p) => {
+ const change = changeset.find((change) => change.feature === p.feature)
+
+ if (change) {
+ p.status = change.selected ? PermissionStatus.GRANTED : PermissionStatus.DENIED
+ }
+
+ return p
+ }),
+ })
+ },
+ [permissions, setPermissions],
+ )
+
+ const removePermissions = useCallback(
+ (origin: string) => {
+ delete permissions[trimTrailingSlash(origin)]
+ setPermissions({ ...permissions })
+ },
+ [permissions, setPermissions],
+ )
+
+ const addPermissions = useCallback(
+ (origin: string, selectedPermissions: BrowserPermission[]) => {
+ setPermissions({ ...permissions, [trimTrailingSlash(origin)]: selectedPermissions })
+ },
+ [permissions, setPermissions],
+ )
+
+ const getAllowedFeaturesList = useCallback(
+ (origin: string): string => {
+ return getPermissions(origin)
+ .filter(({ status }) => status === PermissionStatus.GRANTED)
+ .map((permission) => permission.feature)
+ .join('; ')
+ },
+ [getPermissions],
+ )
+
+ return {
+ permissions,
+ getPermissions,
+ updatePermission,
+ addPermissions,
+ removePermissions,
+ getAllowedFeaturesList,
+ }
+}
+
+export { useBrowserPermissions }
diff --git a/src/hooks/safe-apps/permissions/useSafePermissions.ts b/src/hooks/safe-apps/permissions/useSafePermissions.ts
new file mode 100644
index 000000000..51343ec69
--- /dev/null
+++ b/src/hooks/safe-apps/permissions/useSafePermissions.ts
@@ -0,0 +1,175 @@
+import { useState, useCallback } from 'react'
+import type { Methods } from '@safe-global/safe-apps-sdk'
+import type {
+ Permission,
+ PermissionCaveat,
+ PermissionRequest,
+} from '@safe-global/safe-apps-sdk/dist/types/types/permissions'
+
+import { PermissionStatus } from '@/components/safe-apps/types'
+import useLocalStorage from '@/services/local-storage/useLocalStorage'
+import { trimTrailingSlash } from '@/utils/url'
+
+const SAFE_PERMISSIONS = 'SafeApps__safePermissions'
+const USER_RESTRICTED = 'userRestricted'
+
+export type SafePermissions = { [origin: string]: Permission[] }
+
+export type SafePermissionsRequest = {
+ origin: string
+ requestId: string
+ request: PermissionRequest[]
+}
+
+type SafePermissionChangeSet = { capability: string; selected: boolean }[]
+
+type UseSafePermissionsReturnType = {
+ permissions: SafePermissions
+ getPermissions: (origin: string) => Permission[]
+ updatePermission: (origin: string, changeset: SafePermissionChangeSet) => void
+ removePermissions: (origin: string) => void
+ permissionsRequest: SafePermissionsRequest | undefined
+ setPermissionsRequest: (permissionsRequest?: SafePermissionsRequest) => void
+ confirmPermissionRequest: (result: PermissionStatus) => Permission[]
+ hasPermission: (origin: string, permission: Methods) => boolean
+ isUserRestricted: (caveats?: PermissionCaveat[]) => boolean
+}
+
+const useSafePermissions = (): UseSafePermissionsReturnType => {
+ const [permissions = {}, setPermissions] = useLocalStorage(SAFE_PERMISSIONS)
+
+ const [permissionsRequest, setPermissionsRequest] = useState()
+
+ const getPermissions = useCallback(
+ (origin: string) => {
+ return permissions[trimTrailingSlash(origin)] || []
+ },
+ [permissions],
+ )
+
+ const updatePermission = useCallback(
+ (origin: string, changeset: SafePermissionChangeSet) => {
+ const appUrl = trimTrailingSlash(origin)
+
+ setPermissions({
+ ...permissions,
+ [appUrl]: permissions[appUrl].map((permission) => {
+ const change = changeset.find((change) => change.capability === permission.parentCapability)
+
+ if (change) {
+ if (change.selected) {
+ permission.caveats = permission.caveats?.filter((caveat) => caveat.type !== USER_RESTRICTED) || []
+ } else if (!isUserRestricted(permission.caveats)) {
+ permission.caveats = [
+ ...(permission.caveats || []),
+ {
+ type: USER_RESTRICTED,
+ value: true,
+ },
+ ]
+ }
+ }
+
+ return permission
+ }),
+ })
+ },
+ [permissions, setPermissions],
+ )
+
+ const removePermissions = useCallback(
+ (origin: string) => {
+ delete permissions[trimTrailingSlash(origin)]
+ setPermissions({ ...permissions })
+ },
+ [permissions, setPermissions],
+ )
+
+ const hasPermission = useCallback(
+ (origin: string, permission: Methods) => {
+ return permissions[trimTrailingSlash(origin)]?.some(
+ (p) => p.parentCapability === permission && !isUserRestricted(p.caveats),
+ )
+ },
+ [permissions],
+ )
+
+ const hasCapability = useCallback(
+ (origin: string, permission: Methods) => {
+ return permissions[trimTrailingSlash(origin)]?.some((p) => p.parentCapability === permission)
+ },
+ [permissions],
+ )
+
+ const confirmPermissionRequest = useCallback(
+ (result: PermissionStatus) => {
+ if (!permissionsRequest) return []
+
+ const updatedPermissionsByOrigin = [...(permissions[permissionsRequest.origin] || [])]
+
+ permissionsRequest.request.forEach((requestedPermission) => {
+ const capability = Object.keys(requestedPermission)[0]
+
+ if (hasCapability(permissionsRequest.origin, capability as Methods)) {
+ updatedPermissionsByOrigin.map((permission) => {
+ if (permission.parentCapability === capability) {
+ if (isUserRestricted(permission.caveats)) {
+ if (result === PermissionStatus.GRANTED) {
+ permission.caveats = permission.caveats?.filter((caveat) => caveat.type !== USER_RESTRICTED) || []
+ }
+ } else {
+ if (result === PermissionStatus.DENIED) {
+ permission.caveats?.push({
+ type: USER_RESTRICTED,
+ value: true,
+ })
+ }
+ }
+ }
+ })
+ } else {
+ updatedPermissionsByOrigin.push({
+ invoker: permissionsRequest.origin,
+ parentCapability: capability,
+ date: new Date().getTime(),
+ caveats:
+ result === PermissionStatus.DENIED
+ ? [
+ {
+ type: USER_RESTRICTED,
+ value: true,
+ },
+ ]
+ : [],
+ })
+ }
+ })
+
+ setPermissions({
+ ...permissions,
+ [permissionsRequest.origin]: updatedPermissionsByOrigin,
+ })
+ setPermissionsRequest(undefined)
+
+ return updatedPermissionsByOrigin
+ },
+ [permissionsRequest, permissions, setPermissions, hasCapability],
+ )
+
+ const isUserRestricted = (caveats?: PermissionCaveat[]) =>
+ !!caveats?.some((caveat) => caveat.type === USER_RESTRICTED && caveat.value === true)
+
+ return {
+ permissions,
+ isUserRestricted,
+ getPermissions,
+ updatePermission,
+ removePermissions,
+ permissionsRequest,
+ setPermissionsRequest,
+ confirmPermissionRequest,
+ hasPermission,
+ }
+}
+
+export { useSafePermissions }
diff --git a/src/hooks/safe-apps/useAppsFilterByCategory.ts b/src/hooks/safe-apps/useAppsFilterByCategory.ts
new file mode 100644
index 000000000..a9c36031c
--- /dev/null
+++ b/src/hooks/safe-apps/useAppsFilterByCategory.ts
@@ -0,0 +1,18 @@
+import { useMemo } from 'react'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+
+const useAppsFilterByCategory = (safeApps: SafeAppData[], selectedCategories: string[]): SafeAppData[] => {
+ const filteredApps = useMemo(() => {
+ const hasSelectedCategories = selectedCategories.length > 0
+
+ if (hasSelectedCategories) {
+ return safeApps.filter((safeApp) => selectedCategories.some((category) => safeApp.tags.includes(category)))
+ }
+
+ return safeApps
+ }, [safeApps, selectedCategories])
+
+ return filteredApps
+}
+
+export { useAppsFilterByCategory }
diff --git a/src/hooks/safe-apps/useAppsFilterByOptimizedForBatch.ts b/src/hooks/safe-apps/useAppsFilterByOptimizedForBatch.ts
new file mode 100644
index 000000000..0ed300675
--- /dev/null
+++ b/src/hooks/safe-apps/useAppsFilterByOptimizedForBatch.ts
@@ -0,0 +1,21 @@
+import { useMemo } from 'react'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+
+import { isOptimizedForBatchTransactions } from '@/components/safe-apps/utils'
+
+const useAppsFilterByOptimizedForBatch = (
+ safeApps: SafeAppData[],
+ optimizedWithBatchFilter: boolean,
+): SafeAppData[] => {
+ const filteredApps = useMemo(() => {
+ if (optimizedWithBatchFilter) {
+ return safeApps.filter((safeApp) => isOptimizedForBatchTransactions(safeApp))
+ }
+
+ return safeApps
+ }, [safeApps, optimizedWithBatchFilter])
+
+ return filteredApps
+}
+
+export { useAppsFilterByOptimizedForBatch }
diff --git a/src/hooks/safe-apps/useAppsSearch.ts b/src/hooks/safe-apps/useAppsSearch.ts
new file mode 100644
index 000000000..f2f57ad41
--- /dev/null
+++ b/src/hooks/safe-apps/useAppsSearch.ts
@@ -0,0 +1,34 @@
+import { useMemo } from 'react'
+import Fuse from 'fuse.js'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+
+const useAppsSearch = (apps: SafeAppData[], query: string): SafeAppData[] => {
+ const fuse = useMemo(
+ () =>
+ new Fuse(apps, {
+ keys: [
+ {
+ name: 'name',
+ weight: 0.99,
+ },
+ {
+ name: 'description',
+ weight: 0.5,
+ },
+ ],
+ // https://fusejs.io/api/options.html#threshold
+ // Very naive explanation: threshold represents how accurate the search results should be. The default is 0.6
+ // I tested it and found it to make the search results more accurate when the threshold is 0.3
+ // 0 - 1, where 0 is the exact match and 1 matches anything
+ threshold: 0.3,
+ findAllMatches: true,
+ }),
+ [apps],
+ )
+
+ const results = useMemo(() => (query ? fuse.search(query).map((result) => result.item) : apps), [fuse, apps, query])
+
+ return results
+}
+
+export { useAppsSearch }
diff --git a/src/hooks/safe-apps/useCustomSafeApps.ts b/src/hooks/safe-apps/useCustomSafeApps.ts
new file mode 100644
index 000000000..d6575d73c
--- /dev/null
+++ b/src/hooks/safe-apps/useCustomSafeApps.ts
@@ -0,0 +1,64 @@
+import { useState, useEffect, useCallback } from 'react'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+import local from '@/services/local-storage/local'
+import { fetchSafeAppFromManifest } from '@/services/safe-apps/manifest'
+import useChainId from '@/hooks/useChainId'
+
+type ReturnType = {
+ customSafeApps: SafeAppData[]
+ loading: boolean
+ updateCustomSafeApps: (newCustomSafeApps: SafeAppData[]) => void
+}
+
+const CUSTOM_SAFE_APPS_STORAGE_KEY = 'customSafeApps'
+
+const getChainSpecificSafeAppsStorageKey = (chainId: string) => `${CUSTOM_SAFE_APPS_STORAGE_KEY}-${chainId}`
+
+type StoredCustomSafeApp = { url: string }
+
+/*
+ This hook is used to manage the list of custom safe apps.
+ What it does:
+ 1. Loads a list of custom safe apps from local storage
+ 2. Does some backward compatibility checks (supported app networks, etc)
+ 3. Tries to fetch the app info (manifest.json) from the app url
+*/
+const useCustomSafeApps = (): ReturnType => {
+ const [customSafeApps, setCustomSafeApps] = useState([])
+ const [loading, setLoading] = useState(false)
+ const chainId = useChainId()
+
+ const updateCustomSafeApps = useCallback(
+ (newCustomSafeApps: SafeAppData[]) => {
+ setCustomSafeApps(newCustomSafeApps)
+
+ const chainSpecificSafeAppsStorageKey = getChainSpecificSafeAppsStorageKey(chainId)
+ local.setItem(
+ chainSpecificSafeAppsStorageKey,
+ newCustomSafeApps.map((app) => ({ url: app.url })),
+ )
+ },
+ [chainId],
+ )
+
+ useEffect(() => {
+ const loadCustomApps = async () => {
+ setLoading(true)
+ const chainSpecificSafeAppsStorageKey = getChainSpecificSafeAppsStorageKey(chainId)
+ const storedApps = local.getItem(chainSpecificSafeAppsStorageKey) || []
+ const appManifests = await Promise.allSettled(storedApps.map((app) => fetchSafeAppFromManifest(app.url, chainId)))
+ const resolvedApps = appManifests
+ .filter((promiseResult) => promiseResult.status === 'fulfilled')
+ .map((promiseResult) => (promiseResult as PromiseFulfilledResult).value)
+
+ setCustomSafeApps(resolvedApps)
+ setLoading(false)
+ }
+
+ loadCustomApps()
+ }, [chainId])
+
+ return { customSafeApps, loading, updateCustomSafeApps }
+}
+
+export { useCustomSafeApps }
diff --git a/src/hooks/safe-apps/useOpenedSafeApps.ts b/src/hooks/safe-apps/useOpenedSafeApps.ts
new file mode 100644
index 000000000..153b2044d
--- /dev/null
+++ b/src/hooks/safe-apps/useOpenedSafeApps.ts
@@ -0,0 +1,28 @@
+import { useCallback } from 'react'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+
+import useChainId from '@/hooks/useChainId'
+import { useAppDispatch, useAppSelector } from '@/store'
+import { selectOpened, markOpened } from '@/store/safeAppsSlice'
+
+type ReturnType = {
+ openedSafeAppIds: Array
+ markSafeAppOpened: (id: SafeAppData['id']) => void
+}
+
+// Return the ids of Safe Apps previously opened by the user
+export const useOpenedSafeApps = (): ReturnType => {
+ const chainId = useChainId()
+
+ const dispatch = useAppDispatch()
+ const openedSafeAppIds = useAppSelector((state) => selectOpened(state, chainId))
+
+ const markSafeAppOpened = useCallback(
+ (id: SafeAppData['id']) => {
+ dispatch(markOpened({ id, chainId }))
+ },
+ [dispatch, chainId],
+ )
+
+ return { openedSafeAppIds, markSafeAppOpened }
+}
diff --git a/src/hooks/safe-apps/usePinnedSafeApps.ts b/src/hooks/safe-apps/usePinnedSafeApps.ts
new file mode 100644
index 000000000..2d26ba57a
--- /dev/null
+++ b/src/hooks/safe-apps/usePinnedSafeApps.ts
@@ -0,0 +1,26 @@
+import { useCallback, useMemo } from 'react'
+import { useAppDispatch, useAppSelector } from '@/store'
+import { selectPinned, setPinned } from '@/store/safeAppsSlice'
+import useChainId from '../useChainId'
+
+type ReturnType = {
+ pinnedSafeAppIds: Set
+ updatePinnedSafeApps: (newPinnedSafeAppIds: Set) => void
+}
+
+// Return the pinned app ids across all chains
+export const usePinnedSafeApps = (): ReturnType => {
+ const chainId = useChainId()
+ const pinned = useAppSelector((state) => selectPinned(state, chainId))
+ const pinnedSafeAppIds = useMemo(() => new Set(pinned), [pinned])
+ const dispatch = useAppDispatch()
+
+ const updatePinnedSafeApps = useCallback(
+ (ids: Set) => {
+ dispatch(setPinned({ pinned: Array.from(ids), chainId }))
+ },
+ [dispatch, chainId],
+ )
+
+ return { pinnedSafeAppIds, updatePinnedSafeApps }
+}
diff --git a/src/hooks/safe-apps/useRankedSafeApps.ts b/src/hooks/safe-apps/useRankedSafeApps.ts
new file mode 100644
index 000000000..8099026e1
--- /dev/null
+++ b/src/hooks/safe-apps/useRankedSafeApps.ts
@@ -0,0 +1,25 @@
+import { useMemo } from 'react'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+import { SafeAppsTag } from '@/config/constants'
+
+// number of ranked Safe Apps that we want to display
+const NUMBER_OF_SAFE_APPS = 5
+
+const useRankedSafeApps = (safeApps: SafeAppData[], pinnedSafeApps: SafeAppData[]): SafeAppData[] => {
+ return useMemo(() => {
+ if (!safeApps.length) return []
+
+ const sortedApps = safeApps.slice().sort((a, b) => a.name.localeCompare(b.name))
+
+ const allRankedApps = pinnedSafeApps
+ .concat(sortedApps)
+ // Filter out Featured Apps because they are in their own section
+ .filter((app) => !app.tags.includes(SafeAppsTag.DASHBOARD_FEATURED))
+
+ // Use a Set to remove duplicates
+ return [...new Set(allRankedApps)].slice(0, NUMBER_OF_SAFE_APPS)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [safeApps])
+}
+
+export { useRankedSafeApps }
diff --git a/src/hooks/safe-apps/useRemoveAppModal.ts b/src/hooks/safe-apps/useRemoveAppModal.ts
new file mode 100644
index 000000000..ab9ed83e0
--- /dev/null
+++ b/src/hooks/safe-apps/useRemoveAppModal.ts
@@ -0,0 +1,28 @@
+import { useState, useCallback } from 'react'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+
+type ModalState =
+ | {
+ isOpen: false
+ app: null
+ }
+ | {
+ isOpen: true
+ app: SafeAppData
+ }
+
+type ReturnType = { state: ModalState; open: (app: SafeAppData) => void; close: () => void }
+
+const useRemoveAppModal = (): ReturnType => {
+ const [state, setState] = useState({ isOpen: false, app: null })
+
+ const open = useCallback((app: SafeAppData) => {
+ setState({ isOpen: true, app })
+ }, [])
+
+ const close = useCallback(() => setState(() => ({ isOpen: false, app: null })), [])
+
+ return { state, open, close }
+}
+
+export { useRemoveAppModal }
diff --git a/src/hooks/safe-apps/useSafeAppFromManifest.ts b/src/hooks/safe-apps/useSafeAppFromManifest.ts
new file mode 100644
index 000000000..015a9f5ee
--- /dev/null
+++ b/src/hooks/safe-apps/useSafeAppFromManifest.ts
@@ -0,0 +1,29 @@
+import { useEffect, useMemo } from 'react'
+import { Errors, logError } from '@/services/exceptions'
+import { fetchSafeAppFromManifest } from '@/services/safe-apps/manifest'
+import useAsync from '@/hooks/useAsync'
+import { getEmptySafeApp } from '@/components/safe-apps/utils'
+import type { SafeAppDataWithPermissions } from '@/components/safe-apps/types'
+import { asError } from '@/services/exceptions/utils'
+
+type UseSafeAppFromManifestReturnType = {
+ safeApp: SafeAppDataWithPermissions
+ isLoading: boolean
+}
+
+const useSafeAppFromManifest = (appUrl: string, chainId: string): UseSafeAppFromManifestReturnType => {
+ const [data, error, isLoading] = useAsync(() => {
+ if (appUrl && chainId) return fetchSafeAppFromManifest(appUrl, chainId)
+ }, [appUrl, chainId])
+
+ const emptyApp = useMemo(() => getEmptySafeApp(appUrl), [appUrl])
+
+ useEffect(() => {
+ if (!error) return
+ logError(Errors._903, `${appUrl}, ${asError(error).message}`)
+ }, [appUrl, error])
+
+ return { safeApp: data || emptyApp, isLoading }
+}
+
+export { useSafeAppFromManifest }
diff --git a/src/hooks/safe-apps/useSafeAppPreviewDrawer.ts b/src/hooks/safe-apps/useSafeAppPreviewDrawer.ts
new file mode 100644
index 000000000..8761924e9
--- /dev/null
+++ b/src/hooks/safe-apps/useSafeAppPreviewDrawer.ts
@@ -0,0 +1,27 @@
+import { useCallback, useState } from 'react'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+
+type ReturnType = {
+ isPreviewDrawerOpen: boolean
+ previewDrawerApp: SafeAppData | undefined
+ openPreviewDrawer: (safeApp: SafeAppData) => void
+ closePreviewDrawer: () => void
+}
+
+const useSafeAppPreviewDrawer = (): ReturnType => {
+ const [previewDrawerApp, setPreviewDrawerApp] = useState()
+ const [isPreviewDrawerOpen, setIsPreviewDrawerOpen] = useState(false)
+
+ const openPreviewDrawer = useCallback((safeApp: SafeAppData) => {
+ setPreviewDrawerApp(safeApp)
+ setIsPreviewDrawerOpen(true)
+ }, [])
+
+ const closePreviewDrawer = useCallback(() => {
+ setIsPreviewDrawerOpen(false)
+ }, [])
+
+ return { isPreviewDrawerOpen, previewDrawerApp, openPreviewDrawer, closePreviewDrawer }
+}
+
+export default useSafeAppPreviewDrawer
diff --git a/src/hooks/safe-apps/useSafeAppUrl.ts b/src/hooks/safe-apps/useSafeAppUrl.ts
new file mode 100644
index 000000000..051c50afb
--- /dev/null
+++ b/src/hooks/safe-apps/useSafeAppUrl.ts
@@ -0,0 +1,17 @@
+import { useRouter } from 'next/router'
+import { sanitizeUrl } from '@/utils/url'
+import { useEffect, useMemo, useState } from 'react'
+
+const useSafeAppUrl = (): string | undefined => {
+ const router = useRouter()
+ const [appUrl, setAppUrl] = useState()
+
+ useEffect(() => {
+ if (!router.isReady) return
+ setAppUrl(router.query.appUrl?.toString())
+ }, [router])
+
+ return useMemo(() => (appUrl ? sanitizeUrl(appUrl) : undefined), [appUrl])
+}
+
+export { useSafeAppUrl }
diff --git a/src/hooks/safe-apps/useSafeApps.ts b/src/hooks/safe-apps/useSafeApps.ts
new file mode 100644
index 000000000..f203f1d1d
--- /dev/null
+++ b/src/hooks/safe-apps/useSafeApps.ts
@@ -0,0 +1,82 @@
+import { useMemo, useCallback } from 'react'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+import { useCustomSafeApps } from '@/hooks/safe-apps/useCustomSafeApps'
+import { usePinnedSafeApps } from '@/hooks/safe-apps/usePinnedSafeApps'
+import { useBrowserPermissions, useSafePermissions } from './permissions'
+import { useRankedSafeApps } from '@/hooks/safe-apps/useRankedSafeApps'
+
+type ReturnType = {
+ allSafeApps: SafeAppData[]
+ pinnedSafeApps: SafeAppData[]
+ pinnedSafeAppIds: Set
+ customSafeApps: SafeAppData[]
+ rankedSafeApps: SafeAppData[]
+ customSafeAppsLoading: boolean
+ addCustomApp: (app: SafeAppData) => void
+ togglePin: (appId: number) => void
+ removeCustomApp: (appId: number) => void
+}
+
+const useSafeApps = (): ReturnType => {
+ const { customSafeApps, loading: customSafeAppsLoading, updateCustomSafeApps } = useCustomSafeApps()
+ const { pinnedSafeAppIds, updatePinnedSafeApps } = usePinnedSafeApps()
+ const { removePermissions: removeSafePermissions } = useSafePermissions()
+ const { removePermissions: removeBrowserPermissions } = useBrowserPermissions()
+
+ const allSafeApps = useMemo(() => [...customSafeApps].sort((a, b) => a.name.localeCompare(b.name)), [customSafeApps])
+
+ const pinnedSafeApps = useMemo(
+ () => allSafeApps.filter((app) => pinnedSafeAppIds.has(app.id)),
+ [allSafeApps, pinnedSafeAppIds],
+ )
+
+ const rankedSafeApps = useRankedSafeApps(allSafeApps, pinnedSafeApps)
+
+ const addCustomApp = useCallback(
+ (app: SafeAppData) => {
+ updateCustomSafeApps([...customSafeApps, app])
+ },
+ [updateCustomSafeApps, customSafeApps],
+ )
+
+ const removeCustomApp = useCallback(
+ (appId: number) => {
+ updateCustomSafeApps(customSafeApps.filter((app) => app.id !== appId))
+ const app = customSafeApps.find((app) => app.id === appId)
+
+ if (app) {
+ removeSafePermissions(app.url)
+ removeBrowserPermissions(app.url)
+ }
+ },
+ [updateCustomSafeApps, customSafeApps, removeSafePermissions, removeBrowserPermissions],
+ )
+
+ const togglePin = (appId: number) => {
+ const alreadyPinned = pinnedSafeAppIds.has(appId)
+ const newSet = new Set(pinnedSafeAppIds)
+
+ if (alreadyPinned) {
+ newSet.delete(appId)
+ } else {
+ newSet.add(appId)
+ }
+ updatePinnedSafeApps(newSet)
+ }
+
+ return {
+ allSafeApps,
+ rankedSafeApps,
+
+ pinnedSafeApps,
+ pinnedSafeAppIds,
+ togglePin,
+
+ customSafeApps,
+ customSafeAppsLoading,
+ addCustomApp,
+ removeCustomApp,
+ }
+}
+
+export { useSafeApps }
diff --git a/src/hooks/safe-apps/useSafeAppsFilters.ts b/src/hooks/safe-apps/useSafeAppsFilters.ts
new file mode 100644
index 000000000..e1baca71f
--- /dev/null
+++ b/src/hooks/safe-apps/useSafeAppsFilters.ts
@@ -0,0 +1,42 @@
+import { useState } from 'react'
+import type { Dispatch, SetStateAction } from 'react'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+
+import { useAppsFilterByCategory } from './useAppsFilterByCategory'
+import { useAppsSearch } from './useAppsSearch'
+import { useAppsFilterByOptimizedForBatch } from './useAppsFilterByOptimizedForBatch'
+
+type ReturnType = {
+ query: string
+ setQuery: Dispatch>
+ selectedCategories: string[]
+ setSelectedCategories: Dispatch>
+ optimizedWithBatchFilter: boolean
+ setOptimizedWithBatchFilter: Dispatch>
+ filteredApps: SafeAppData[]
+}
+
+const useSafeAppsFilters = (safeAppsList: SafeAppData[]): ReturnType => {
+ const [query, setQuery] = useState('')
+ const [selectedCategories, setSelectedCategories] = useState([])
+ const [optimizedWithBatchFilter, setOptimizedWithBatchFilter] = useState(false)
+
+ const filteredAppsByQuery = useAppsSearch(safeAppsList, query)
+ const filteredAppsByQueryAndCategories = useAppsFilterByCategory(filteredAppsByQuery, selectedCategories)
+ const filteredApps = useAppsFilterByOptimizedForBatch(filteredAppsByQueryAndCategories, optimizedWithBatchFilter)
+
+ return {
+ query,
+ setQuery,
+
+ selectedCategories,
+ setSelectedCategories,
+
+ optimizedWithBatchFilter,
+ setOptimizedWithBatchFilter,
+
+ filteredApps,
+ }
+}
+
+export default useSafeAppsFilters
diff --git a/src/hooks/useHighlightHiddenTab.ts b/src/hooks/useHighlightHiddenTab.ts
new file mode 100644
index 000000000..43a7d2e58
--- /dev/null
+++ b/src/hooks/useHighlightHiddenTab.ts
@@ -0,0 +1,64 @@
+import { useEffect } from 'react'
+
+const ALT_FAVICON = '/favicons/favicon-dot.ico'
+const TITLE_PREFIX = '‼️ '
+
+const setFavicon = (favicon: HTMLLinkElement | null, href: string) => {
+ if (favicon) favicon.href = href
+}
+
+const setDocumentTitle = (isPrefixed: boolean) => {
+ document.title = isPrefixed ? TITLE_PREFIX + document.title : document.title.replace(TITLE_PREFIX, '')
+}
+
+const blinkFavicon = (
+ favicon: HTMLLinkElement | null,
+ originalHref: string,
+ isBlinking = false,
+): ReturnType => {
+ const onBlink = () => {
+ setDocumentTitle(isBlinking)
+ setFavicon(favicon, isBlinking ? ALT_FAVICON : originalHref)
+ isBlinking = !isBlinking
+ }
+
+ onBlink()
+
+ return setInterval(onBlink, 300)
+}
+
+/**
+ * Blink favicon when the tab is hidden
+ */
+const useHighlightHiddenTab = () => {
+ useEffect(() => {
+ const favicon = document.querySelector('link[rel*="icon"]')
+ const originalHref = favicon?.href || ''
+ let interval: ReturnType
+
+ const reset = () => {
+ clearInterval(interval)
+ setFavicon(favicon, originalHref)
+ setDocumentTitle(false)
+ }
+
+ const handleVisibilityChange = () => {
+ if (document.hidden) {
+ interval = blinkFavicon(favicon, originalHref)
+ } else {
+ reset()
+ }
+ }
+
+ document.addEventListener('visibilitychange', handleVisibilityChange)
+
+ handleVisibilityChange()
+
+ return () => {
+ reset()
+ document.removeEventListener('visibilitychange', handleVisibilityChange)
+ }
+ }, [])
+}
+
+export default useHighlightHiddenTab
diff --git a/src/hooks/wallets/web3.ts b/src/hooks/wallets/web3.ts
index 486074c2c..dd69d2d88 100644
--- a/src/hooks/wallets/web3.ts
+++ b/src/hooks/wallets/web3.ts
@@ -18,6 +18,10 @@ export const createWeb3 = (walletProvider: EIP1193Provider): Web3Provider => {
export const { setStore: setWeb3, useStore: useWeb3 } = new ExternalStore()
+export const createSafeAppsWeb3Provider = (safeAppsRpcUri: string): JsonRpcProvider | undefined => {
+ return new JsonRpcProvider({ url: safeAppsRpcUri, timeout: 10_000 })
+}
+
export const {
getStore: getWeb3ReadOnly,
setStore: setWeb3ReadOnly,
diff --git a/src/pages/apps/bookmarked.tsx b/src/pages/apps/bookmarked.tsx
new file mode 100644
index 000000000..55e77b66a
--- /dev/null
+++ b/src/pages/apps/bookmarked.tsx
@@ -0,0 +1,22 @@
+import type { NextPage } from 'next'
+import Head from 'next/head'
+import { useRouter } from 'next/router'
+import { useEffect } from 'react'
+import { AppRoutes } from '@/config/routes'
+
+const BookmarkedSafeApps: NextPage = () => {
+ const router = useRouter()
+
+ // Redirect to /apps
+ useEffect(() => {
+ router.replace({ pathname: AppRoutes.apps.index, query: { safe: router.query.safe } })
+ }, [router])
+
+ return (
+
+ {'Safe{Wallet} – Safe Apps'}
+
+ )
+}
+
+export default BookmarkedSafeApps
diff --git a/src/pages/apps/index.tsx b/src/pages/apps/index.tsx
new file mode 100644
index 000000000..93e382e95
--- /dev/null
+++ b/src/pages/apps/index.tsx
@@ -0,0 +1,74 @@
+import { useEffect, useState } from 'react'
+import type { NextPage } from 'next'
+import Head from 'next/head'
+import { useRouter } from 'next/router'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+
+import { useSafeApps } from '@/hooks/safe-apps/useSafeApps'
+import SafeAppsHeader from '@/components/safe-apps/SafeAppsHeader'
+import SafeAppList from '@/components/safe-apps/SafeAppList'
+import { RemoveCustomAppModal } from '@/components/safe-apps/RemoveCustomAppModal'
+import { AppRoutes } from '@/config/routes'
+import { useHasFeature } from '@/hooks/useChains'
+import { FEATURES } from '@/utils/chains'
+
+const SafeApps: NextPage = () => {
+ const { query, isReady, push } = useRouter()
+ const appUrl = Array.isArray(query.appUrl) ? query.appUrl[0] : query.appUrl
+ const safe = Array.isArray(query.safe) ? query.safe[0] : query.safe
+ const isSafeAppsEnabled = useHasFeature(FEATURES.SAFE_APPS)
+ const { customSafeApps, addCustomApp, removeCustomApp } = useSafeApps()
+
+ const [isOpenRemoveSafeAppModal, setIsOpenRemoveSafeAppModal] = useState(false)
+ const [customSafeAppToRemove, setCustomSafeAppToRemove] = useState()
+
+ useEffect(() => {
+ if (!isReady) return
+
+ if (appUrl) {
+ push({ pathname: AppRoutes.apps.open, query: { safe, appUrl } })
+ }
+ }, [appUrl, isReady, push, safe])
+
+ if (!isSafeAppsEnabled) return <>>
+
+ const openRemoveCustomAppModal = (app: SafeAppData) => {
+ setIsOpenRemoveSafeAppModal(true)
+ setCustomSafeAppToRemove(app)
+ }
+
+ const onConfirmRemoveCustomAppModal = (safeAppId: number) => {
+ removeCustomApp(safeAppId)
+ setIsOpenRemoveSafeAppModal(false)
+ }
+
+ return (
+ <>
+
+ {'Safe{Wallet} – My custom Safe Apps'}
+
+
+
+
+
+
+
+
+ {customSafeAppToRemove && (
+ setIsOpenRemoveSafeAppModal(false)}
+ onConfirm={onConfirmRemoveCustomAppModal}
+ />
+ )}
+ >
+ )
+}
+
+export default SafeApps
diff --git a/src/pages/apps/open.tsx b/src/pages/apps/open.tsx
new file mode 100644
index 000000000..7dbe61284
--- /dev/null
+++ b/src/pages/apps/open.tsx
@@ -0,0 +1,98 @@
+import type { NextPage } from 'next'
+import { useRouter } from 'next/router'
+import { useCallback } from 'react'
+import { Box, CircularProgress } from '@mui/material'
+
+import { useSafeAppUrl } from '@/hooks/safe-apps/useSafeAppUrl'
+import { useSafeApps } from '@/hooks/safe-apps/useSafeApps'
+import SafeAppsInfoModal from '@/components/safe-apps/SafeAppsInfoModal'
+import useSafeAppsInfoModal from '@/components/safe-apps/SafeAppsInfoModal/useSafeAppsInfoModal'
+import SafeAppsErrorBoundary from '@/components/safe-apps/SafeAppsErrorBoundary'
+import SafeAppsLoadError from '@/components/safe-apps/SafeAppsErrorBoundary/SafeAppsLoadError'
+import AppFrame from '@/components/safe-apps/AppFrame'
+import { useSafeAppFromManifest } from '@/hooks/safe-apps/useSafeAppFromManifest'
+import { useBrowserPermissions } from '@/hooks/safe-apps/permissions'
+import useChainId from '@/hooks/useChainId'
+import { AppRoutes } from '@/config/routes'
+import { getOrigin } from '@/components/safe-apps/utils'
+import { useHasFeature } from '@/hooks/useChains'
+import { FEATURES } from '@/utils/chains'
+
+// TODO: Remove this once we properly deprecate the WC app
+const WC_SAFE_APP = /wallet-connect/
+
+const SafeApps: NextPage = () => {
+ const chainId = useChainId()
+ const router = useRouter()
+ const appUrl = useSafeAppUrl()
+ const { safeApp, isLoading } = useSafeAppFromManifest(appUrl || '', chainId)
+ const isSafeAppsEnabled = useHasFeature(FEATURES.SAFE_APPS)
+ const isWalletConnectEnabled = useHasFeature(FEATURES.NATIVE_WALLETCONNECT)
+
+ const { customSafeApps, customSafeAppsLoading } = useSafeApps()
+
+ const { addPermissions, getPermissions, getAllowedFeaturesList } = useBrowserPermissions()
+ const origin = getOrigin(appUrl)
+ const {
+ isModalVisible,
+ isSafeAppInDefaultList,
+ isFirstTimeAccessingApp,
+ isConsentAccepted,
+ isPermissionsReviewCompleted,
+ onComplete,
+ } = useSafeAppsInfoModal({
+ url: origin,
+ safeApp: customSafeApps.find((app) => app.url === appUrl),
+ permissions: safeApp?.safeAppsPermissions || [],
+ addPermissions,
+ getPermissions,
+ safeAppsLoading: customSafeAppsLoading,
+ })
+
+ const goToList = useCallback(() => {
+ router.push({
+ pathname: AppRoutes.apps.index,
+ query: { safe: router.query.safe },
+ })
+ }, [router])
+
+ // appUrl is required to be present
+ if (!isSafeAppsEnabled || !appUrl || !router.isReady) return null
+
+ if (isWalletConnectEnabled && WC_SAFE_APP.test(appUrl)) {
+ goToList()
+ return null
+ }
+
+ if (isModalVisible) {
+ return (
+
+ )
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+ router.back()} />}>
+
+
+ )
+}
+
+export default SafeApps
diff --git a/src/pages/settings/appearance.tsx b/src/pages/settings/appearance.tsx
index 7c012c89a..678dfe44d 100644
--- a/src/pages/settings/appearance.tsx
+++ b/src/pages/settings/appearance.tsx
@@ -4,7 +4,13 @@ import type { NextPage } from 'next'
import Head from 'next/head'
import { useAppDispatch, useAppSelector } from '@/store'
-import { selectSettings, setCopyShortName, setDarkMode, setShowShortName } from '@/store/settingsSlice'
+import {
+ selectSettings,
+ setCopyShortName,
+ setDarkMode,
+ setSafeAppsUseLightBackground,
+ setShowShortName,
+} from '@/store/settingsSlice'
import SettingsHeader from '@/components/settings/SettingsHeader'
import { useDarkMode } from '@/hooks/useDarkMode'
import ExternalLink from '@/components/common/ExternalLink'
@@ -14,7 +20,13 @@ const Appearance: NextPage = () => {
const settings = useAppSelector(selectSettings)
const isDarkMode = useDarkMode()
- const handleToggle = (action: typeof setCopyShortName | typeof setDarkMode | typeof setShowShortName) => {
+ const handleToggle = (
+ action:
+ | typeof setCopyShortName
+ | typeof setDarkMode
+ | typeof setSafeAppsUseLightBackground
+ | typeof setShowShortName,
+ ) => {
return (_: ChangeEvent, checked: boolean) => {
dispatch(action(checked))
}
@@ -68,6 +80,16 @@ const Appearance: NextPage = () => {
control={ }
label="Dark mode"
/>
+
+
+ }
+ label="Use light background for Safe Apps"
+ />
diff --git a/src/services/safe-apps/AppCommunicator.test.ts b/src/services/safe-apps/AppCommunicator.test.ts
new file mode 100644
index 000000000..df4741a9c
--- /dev/null
+++ b/src/services/safe-apps/AppCommunicator.test.ts
@@ -0,0 +1,52 @@
+import type { MutableRefObject } from 'react'
+import { Methods } from '@safe-global/safe-apps-sdk'
+import AppCommunicator from './AppCommunicator'
+
+describe('AppCommunicator', () => {
+ it('ignores messages coming from an unexpected origin', async () => {
+ const postMessage = jest.fn()
+ const contentWindow = { postMessage }
+ const iframeRef = {
+ current: {
+ contentWindow,
+ src: 'https://app.safe.global/frame',
+ },
+ } as unknown as MutableRefObject
+
+ const communicator = new AppCommunicator(iframeRef)
+ const handler = jest.fn().mockReturnValue({ safeAddress: '0x123' })
+ communicator.on(Methods.getSafeInfo, handler)
+
+ await communicator.handleIncomingMessage({
+ source: contentWindow,
+ origin: 'https://evil.example',
+ data: {
+ id: '1',
+ method: Methods.getSafeInfo,
+ },
+ } as any)
+
+ expect(handler).not.toHaveBeenCalled()
+
+ communicator.clear()
+ })
+
+ it('posts responses to the iframe origin instead of wildcard origin', () => {
+ const postMessage = jest.fn()
+ const contentWindow = { postMessage }
+ const iframeRef = {
+ current: {
+ contentWindow,
+ src: 'https://app.safe.global/frame',
+ },
+ } as unknown as MutableRefObject
+
+ const communicator = new AppCommunicator(iframeRef)
+
+ communicator.send({ ok: true }, 'request-id')
+
+ expect(postMessage).toHaveBeenCalledWith(expect.any(Object), 'https://app.safe.global')
+
+ communicator.clear()
+ })
+})
diff --git a/src/services/safe-apps/AppCommunicator.ts b/src/services/safe-apps/AppCommunicator.ts
new file mode 100644
index 000000000..b39baa3f8
--- /dev/null
+++ b/src/services/safe-apps/AppCommunicator.ts
@@ -0,0 +1,108 @@
+import type { MutableRefObject } from 'react'
+import type { SDKMessageEvent, MethodToResponse, ErrorResponse, RequestId } from '@safe-global/safe-apps-sdk'
+import { getSDKVersion, Methods, MessageFormatter } from '@safe-global/safe-apps-sdk'
+import { asError } from '../exceptions/utils'
+
+type MessageHandler = (
+ msg: SDKMessageEvent,
+) => void | MethodToResponse[Methods] | ErrorResponse | Promise
+
+type AppCommunicatorConfig = {
+ onMessage?: (msg: SDKMessageEvent) => void
+ onError?: (error: Error, data: any) => void
+}
+
+class AppCommunicator {
+ private iframeRef: MutableRefObject
+ private handlers = new Map()
+ private config: AppCommunicatorConfig
+
+ constructor(iframeRef: MutableRefObject, config?: AppCommunicatorConfig) {
+ this.iframeRef = iframeRef
+ this.config = config || {}
+
+ window.addEventListener('message', this.handleIncomingMessage)
+ }
+
+ private getIframeOrigin = (): string | undefined => {
+ const iframeSrc = this.iframeRef.current?.src
+ if (!iframeSrc) return
+
+ try {
+ return new URL(iframeSrc).origin
+ } catch {
+ return
+ }
+ }
+
+ on = (method: Methods, handler: MessageHandler): void => {
+ this.handlers.set(method, handler)
+ }
+
+ private isValidMessage = (msg: SDKMessageEvent): boolean => {
+ if (!msg.data) return false
+
+ const sentFromIframe = this.iframeRef.current?.contentWindow === msg.source
+ const iframeOrigin = this.getIframeOrigin()
+ const sentFromExpectedOrigin = !iframeOrigin || msg.origin === iframeOrigin
+
+ if (!sentFromIframe || !sentFromExpectedOrigin) {
+ return false
+ }
+
+ if (msg.data.hasOwnProperty('isCookieEnabled')) {
+ return true
+ }
+
+ const knownMethod = Object.values(Methods).includes(msg.data.method)
+
+ return knownMethod
+ }
+
+ private canHandleMessage = (msg: SDKMessageEvent): boolean => {
+ if (!msg.data) return false
+ return Boolean(this.handlers.get(msg.data.method))
+ }
+
+ send = (data: unknown, requestId: RequestId, error = false): void => {
+ const sdkVersion = getSDKVersion()
+ const msg = error
+ ? MessageFormatter.makeErrorResponse(requestId, data as string, sdkVersion)
+ : MessageFormatter.makeResponse(requestId, data, sdkVersion)
+
+ this.iframeRef.current?.contentWindow?.postMessage(msg, this.getIframeOrigin() || '*')
+ }
+
+ handleIncomingMessage = async (msg: SDKMessageEvent): Promise => {
+ const validMessage = this.isValidMessage(msg)
+ const hasHandler = this.canHandleMessage(msg)
+
+ if (validMessage && hasHandler) {
+ const handler = this.handlers.get(msg.data.method)
+
+ this.config?.onMessage?.(msg)
+
+ try {
+ // @ts-expect-error Handler existence is checked in this.canHandleMessage
+ const response = await handler(msg)
+
+ // If response is not returned, it means the response will be send somewhere else
+ if (typeof response !== 'undefined') {
+ this.send(response, msg.data.id)
+ }
+ } catch (e) {
+ const error = asError(e)
+
+ this.send(error.message, msg.data.id, true)
+ this.config?.onError?.(error, msg.data)
+ }
+ }
+ }
+
+ clear = (): void => {
+ window.removeEventListener('message', this.handleIncomingMessage)
+ this.handlers.clear()
+ }
+}
+
+export default AppCommunicator
diff --git a/src/services/safe-apps/manifest.test.ts b/src/services/safe-apps/manifest.test.ts
new file mode 100644
index 000000000..90541e6aa
--- /dev/null
+++ b/src/services/safe-apps/manifest.test.ts
@@ -0,0 +1,27 @@
+import { fetchSafeAppFromManifest } from './manifest'
+
+describe('fetchSafeAppFromManifest', () => {
+ const originalFetch = global.fetch
+
+ beforeEach(() => {
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ name: 'My App',
+ description: 'desc',
+ icons: [{ src: '/icon.png', sizes: '128x128' }],
+ }),
+ } as any)
+ })
+
+ afterEach(() => {
+ global.fetch = originalFetch
+ })
+
+ it('returns a deterministic app id for the same app url', async () => {
+ const app1 = await fetchSafeAppFromManifest('https://example.com', '1')
+ const app2 = await fetchSafeAppFromManifest('https://example.com/', '1')
+
+ expect(app1.id).toBe(app2.id)
+ })
+})
diff --git a/src/services/safe-apps/manifest.ts b/src/services/safe-apps/manifest.ts
new file mode 100644
index 000000000..c5e7a788f
--- /dev/null
+++ b/src/services/safe-apps/manifest.ts
@@ -0,0 +1,133 @@
+import type { AllowedFeatures, SafeAppDataWithPermissions } from '@/components/safe-apps/types'
+import { isRelativeUrl, trimTrailingSlash } from '@/utils/url'
+import { SafeAppAccessPolicyTypes } from '@safe-global/safe-gateway-typescript-sdk'
+
+type AppManifestIcon = {
+ src: string
+ sizes: string
+ type?: string
+ purpose?: string
+}
+
+export type AppManifest = {
+ // SPEC: https://developer.mozilla.org/en-US/docs/Web/Manifest
+ name: string
+ short_name?: string
+ description: string
+ icons?: AppManifestIcon[]
+ iconPath?: string
+ safe_apps_permissions?: AllowedFeatures[]
+}
+
+const MIN_ICON_WIDTH = 128
+const CUSTOM_SAFE_APP_ID_BUCKETS = 1_000_000_000
+
+const chooseBestIcon = (icons: AppManifestIcon[]): string => {
+ const svgIcon = icons.find((icon) => icon?.sizes?.includes('any') || icon?.type === 'image/svg+xml')
+
+ if (svgIcon) {
+ return svgIcon.src
+ }
+
+ for (const icon of icons) {
+ for (const size of icon.sizes.split(' ')) {
+ if (Number(size.split('x')[0]) >= MIN_ICON_WIDTH) {
+ return icon.src
+ }
+ }
+ }
+
+ return icons[0].src || ''
+}
+
+// The icons URL can be any of the following format:
+// - https://example.com/icon.png
+// - icon.png
+// - /icon.png
+// This function calculates the absolute URL of the icon taking into account the
+// different formats.
+const getAppLogoUrl = (appUrl: string, { icons = [], iconPath = '' }: AppManifest) => {
+ const iconUrl = icons.length ? chooseBestIcon(icons) : iconPath
+ const includesBaseUrl = iconUrl.startsWith('https://')
+ if (includesBaseUrl) {
+ return iconUrl
+ }
+
+ return `${appUrl}${isRelativeUrl(iconUrl) ? '' : '/'}${iconUrl}`
+}
+
+const fetchAppManifest = async (appUrl: string, timeout = 5000): Promise => {
+ const normalizedUrl = trimTrailingSlash(appUrl)
+ const manifestUrl = `${normalizedUrl}/manifest.json`
+
+ // A lot of apps are hosted on IPFS and IPFS never times out, so we add our own timeout
+ const controller = new AbortController()
+ const id = setTimeout(() => controller.abort(), timeout)
+
+ const response = await fetch(manifestUrl, {
+ signal: controller.signal,
+ })
+ clearTimeout(id)
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch manifest from ${manifestUrl}`)
+ }
+
+ return response.json()
+}
+
+const isAppManifestValid = (json: unknown): json is AppManifest => {
+ return (
+ json != null &&
+ typeof json === 'object' &&
+ 'name' in json &&
+ 'description' in json &&
+ ('icons' in json || 'iconPath' in json)
+ )
+}
+
+const createDeterministicCustomAppId = (appUrl: string): number => {
+ // FNV-1a hash to keep IDs deterministic for a given URL while remaining in the custom app ID range (< 1)
+ let hash = 2166136261
+
+ for (const char of appUrl.toLowerCase()) {
+ hash ^= char.charCodeAt(0)
+ hash = Math.imul(hash, 16777619)
+ }
+
+ const positiveHash = hash >>> 0
+ const bucket = (positiveHash % (CUSTOM_SAFE_APP_ID_BUCKETS - 1)) + 1
+
+ return bucket / CUSTOM_SAFE_APP_ID_BUCKETS
+}
+
+const fetchSafeAppFromManifest = async (
+ appUrl: string,
+ currentChainId: string,
+): Promise => {
+ const normalizedAppUrl = trimTrailingSlash(appUrl)
+ const appManifest = await fetchAppManifest(appUrl)
+
+ if (!isAppManifestValid(appManifest)) {
+ throw new Error('Invalid Safe App manifest')
+ }
+
+ const iconUrl = getAppLogoUrl(normalizedAppUrl, appManifest)
+
+ return {
+ id: createDeterministicCustomAppId(normalizedAppUrl),
+ url: normalizedAppUrl,
+ name: appManifest.name,
+ description: appManifest.description,
+ accessControl: { type: SafeAppAccessPolicyTypes.NoRestrictions },
+ tags: [],
+ features: [],
+ socialProfiles: [],
+ developerWebsite: '',
+ chainIds: [currentChainId],
+ iconUrl,
+ safeAppsPermissions: appManifest.safe_apps_permissions || [],
+ }
+}
+
+export { fetchAppManifest, isAppManifestValid, getAppLogoUrl, fetchSafeAppFromManifest }
diff --git a/src/store/__tests__/settingsSlice.test.ts b/src/store/__tests__/settingsSlice.test.ts
index f882e01e2..e9bf78c51 100644
--- a/src/store/__tests__/settingsSlice.test.ts
+++ b/src/store/__tests__/settingsSlice.test.ts
@@ -1,6 +1,7 @@
import {
settingsSlice,
initialState,
+ selectSafeAppsUseLightBackground,
selectWalletConnectApiKey,
selectWalletConnectPairingCode,
} from '../settingsSlice'
@@ -102,4 +103,20 @@ describe('settingsSlice', () => {
expect(selectWalletConnectPairingCode(state)).toBe('wc:pairing')
})
})
+
+ describe('safe apps appearance settings', () => {
+ it('defaults safe apps background to light', () => {
+ const state = {
+ [settingsSlice.name]: initialState,
+ } as any
+
+ expect(selectSafeAppsUseLightBackground(state)).toBe(true)
+ })
+
+ it('should set safe apps light background preference', () => {
+ const state = settingsSlice.reducer(initialState, settingsSlice.actions.setSafeAppsUseLightBackground(false))
+
+ expect(state.theme.safeAppsUseLightBackground).toBe(false)
+ })
+ })
})
diff --git a/src/store/settingsSlice.ts b/src/store/settingsSlice.ts
index e6fe8984e..a8bf4bcbb 100644
--- a/src/store/settingsSlice.ts
+++ b/src/store/settingsSlice.ts
@@ -36,6 +36,7 @@ export type SettingsState = {
}
theme: {
darkMode?: boolean
+ safeAppsUseLightBackground?: boolean
}
env: EnvState
signing: {
@@ -54,7 +55,9 @@ export const initialState: SettingsState = {
copy: true,
qr: true,
},
- theme: {},
+ theme: {
+ safeAppsUseLightBackground: true,
+ },
env: {
rpc: {},
tenderly: {
@@ -94,6 +97,12 @@ export const settingsSlice = createSlice({
setDarkMode: (state, { payload }: PayloadAction) => {
state.theme.darkMode = payload
},
+ setSafeAppsUseLightBackground: (
+ state,
+ { payload }: PayloadAction,
+ ) => {
+ state.theme.safeAppsUseLightBackground = payload
+ },
setTokenList: (state, { payload }: PayloadAction) => {
state.tokenList = payload
},
@@ -134,6 +143,7 @@ export const {
setCopyShortName,
setQrShortName,
setDarkMode,
+ setSafeAppsUseLightBackground,
setTokenList,
setRpc,
setIPFS,
@@ -172,3 +182,7 @@ export const selectWalletConnectPairingCode = createSelector(
)
export const selectOnChainSigning = createSelector(selectSettings, (settings) => settings.signing.onChainSigning)
+
+export const selectSafeAppsUseLightBackground = createSelector(selectSettings, (settings) => {
+ return settings.theme.safeAppsUseLightBackground ?? true
+})
diff --git a/src/tests/pages/apps-bookmarked.test.tsx b/src/tests/pages/apps-bookmarked.test.tsx
new file mode 100644
index 000000000..e5153795b
--- /dev/null
+++ b/src/tests/pages/apps-bookmarked.test.tsx
@@ -0,0 +1,34 @@
+import { render, waitFor } from '@/tests/test-utils'
+import BookmarkedSafeAppsPage from '@/pages/apps/bookmarked'
+import { useRouter } from 'next/router'
+import { AppRoutes } from '@/config/routes'
+
+jest.mock('next/router', () => ({
+ useRouter: jest.fn(),
+}))
+
+const mockUseRouter = useRouter as jest.Mock
+
+describe('/apps/bookmarked page', () => {
+ it('redirects to /apps while preserving the safe query param', async () => {
+ const replace = jest.fn()
+
+ mockUseRouter.mockReturnValue({
+ query: {
+ safe: 'eth:0x1234567890123456789012345678901234567890',
+ },
+ replace,
+ })
+
+ render( )
+
+ await waitFor(() => {
+ expect(replace).toHaveBeenCalledWith({
+ pathname: AppRoutes.apps.index,
+ query: {
+ safe: 'eth:0x1234567890123456789012345678901234567890',
+ },
+ })
+ })
+ })
+})
diff --git a/src/tests/pages/apps-index.test.tsx b/src/tests/pages/apps-index.test.tsx
new file mode 100644
index 000000000..48bd56747
--- /dev/null
+++ b/src/tests/pages/apps-index.test.tsx
@@ -0,0 +1,127 @@
+import React from 'react'
+import { render, screen, waitFor } from '@/tests/test-utils'
+import SafeAppsPage from '@/pages/apps'
+import { useRouter } from 'next/router'
+import { useSafeApps } from '@/hooks/safe-apps/useSafeApps'
+import { useHasFeature } from '@/hooks/useChains'
+import { AppRoutes } from '@/config/routes'
+import { FEATURES } from '@/utils/chains'
+
+jest.mock('next/router', () => ({
+ useRouter: jest.fn(),
+}))
+
+jest.mock('@/hooks/safe-apps/useSafeApps', () => ({
+ useSafeApps: jest.fn(),
+}))
+
+jest.mock('@/hooks/useChains', () => ({
+ useHasFeature: jest.fn(),
+}))
+
+jest.mock('@/components/safe-apps/SafeAppsHeader', () => ({
+ __esModule: true,
+ default: () =>
,
+}))
+
+jest.mock('@/components/safe-apps/SafeAppList', () => ({
+ __esModule: true,
+ default: ({ title }: { title: string }) => {title}
,
+}))
+
+jest.mock('@/components/safe-apps/RemoveCustomAppModal', () => ({
+ __esModule: true,
+ RemoveCustomAppModal: () =>
,
+}))
+
+const mockUseRouter = useRouter as jest.Mock
+const mockUseSafeApps = useSafeApps as jest.Mock
+const mockUseHasFeature = useHasFeature as jest.Mock
+
+describe('/apps page', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+
+ mockUseSafeApps.mockReturnValue({
+ customSafeApps: [{ id: 1, name: 'Custom app 1' }],
+ addCustomApp: jest.fn(),
+ removeCustomApp: jest.fn(),
+ })
+
+ mockUseHasFeature.mockImplementation((feature: FEATURES) => feature === FEATURES.SAFE_APPS)
+ })
+
+ it('redirects to /apps/open when appUrl query is present', async () => {
+ const push = jest.fn()
+
+ mockUseRouter.mockReturnValue({
+ isReady: true,
+ query: {
+ safe: 'eth:0x1234567890123456789012345678901234567890',
+ appUrl: 'https://tx-builder.safe.global',
+ },
+ push,
+ })
+
+ render( )
+
+ await waitFor(() => {
+ expect(push).toHaveBeenCalledWith({
+ pathname: AppRoutes.apps.open,
+ query: {
+ safe: 'eth:0x1234567890123456789012345678901234567890',
+ appUrl: 'https://tx-builder.safe.global',
+ },
+ })
+ })
+ })
+
+ it('does not redirect before the router is ready', async () => {
+ const push = jest.fn()
+
+ mockUseRouter.mockReturnValue({
+ isReady: false,
+ query: {
+ safe: 'eth:0x1234567890123456789012345678901234567890',
+ appUrl: 'https://tx-builder.safe.global',
+ },
+ push,
+ })
+
+ render( )
+
+ await waitFor(() => {
+ expect(push).not.toHaveBeenCalled()
+ })
+ })
+
+ it('shows custom apps view by default', () => {
+ mockUseRouter.mockReturnValue({
+ query: {
+ safe: 'eth:0x1234567890123456789012345678901234567890',
+ },
+ push: jest.fn(),
+ })
+
+ render( )
+
+ expect(screen.getByTestId('safe-apps-header')).toBeInTheDocument()
+ expect(screen.getByText('My custom apps')).toBeInTheDocument()
+ })
+
+ it('renders nothing when SAFE_APPS feature is disabled', () => {
+ mockUseHasFeature.mockReturnValue(false)
+
+ mockUseRouter.mockReturnValue({
+ query: {
+ safe: 'eth:0x1234567890123456789012345678901234567890',
+ },
+ push: jest.fn(),
+ })
+
+ render( )
+
+ expect(screen.queryByTestId('safe-apps-header')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('safe-apps-list')).not.toBeInTheDocument()
+ })
+})
diff --git a/src/tests/pages/apps-open.test.tsx b/src/tests/pages/apps-open.test.tsx
new file mode 100644
index 000000000..65644a2be
--- /dev/null
+++ b/src/tests/pages/apps-open.test.tsx
@@ -0,0 +1,152 @@
+import React from 'react'
+import { render, screen, waitFor } from '@/tests/test-utils'
+import SafeAppsOpenPage from '@/pages/apps/open'
+import { useRouter } from 'next/router'
+import { useHasFeature } from '@/hooks/useChains'
+import { useSafeAppUrl } from '@/hooks/safe-apps/useSafeAppUrl'
+import { useSafeAppFromManifest } from '@/hooks/safe-apps/useSafeAppFromManifest'
+import { useSafeApps } from '@/hooks/safe-apps/useSafeApps'
+import useSafeAppsInfoModal from '@/components/safe-apps/SafeAppsInfoModal/useSafeAppsInfoModal'
+import { useBrowserPermissions } from '@/hooks/safe-apps/permissions'
+import { FEATURES } from '@/utils/chains'
+
+jest.mock('next/router', () => ({
+ useRouter: jest.fn(),
+}))
+
+jest.mock('@/hooks/useChainId', () => jest.fn(() => '1'))
+
+jest.mock('@/hooks/useChains', () => ({
+ useHasFeature: jest.fn(),
+}))
+
+jest.mock('@/hooks/safe-apps/useSafeAppUrl', () => ({
+ useSafeAppUrl: jest.fn(),
+}))
+
+jest.mock('@/hooks/safe-apps/useSafeAppFromManifest', () => ({
+ useSafeAppFromManifest: jest.fn(),
+}))
+
+jest.mock('@/hooks/safe-apps/useSafeApps', () => ({
+ useSafeApps: jest.fn(),
+}))
+
+jest.mock('@/hooks/safe-apps/permissions', () => ({
+ useBrowserPermissions: jest.fn(),
+}))
+
+jest.mock('@/components/safe-apps/SafeAppsInfoModal/useSafeAppsInfoModal', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}))
+
+jest.mock('@/components/safe-apps/SafeAppsInfoModal', () => ({
+ __esModule: true,
+ default: () =>
,
+}))
+
+jest.mock('@/components/safe-apps/SafeAppsErrorBoundary', () => ({
+ __esModule: true,
+ default: ({ children }: { children: React.ReactNode }) => <>{children}>,
+}))
+
+jest.mock('@/components/safe-apps/SafeAppsErrorBoundary/SafeAppsLoadError', () => ({
+ __esModule: true,
+ default: () =>
,
+}))
+
+jest.mock('@/components/safe-apps/AppFrame', () => ({
+ __esModule: true,
+ default: ({ appUrl }: { appUrl: string }) =>
,
+}))
+
+const mockUseRouter = useRouter as jest.Mock
+const mockUseHasFeature = useHasFeature as jest.Mock
+const mockUseSafeAppUrl = useSafeAppUrl as jest.Mock
+const mockUseSafeAppFromManifest = useSafeAppFromManifest as jest.Mock
+const mockUseSafeApps = useSafeApps as jest.Mock
+const mockUseSafeAppsInfoModal = useSafeAppsInfoModal as jest.Mock
+const mockUseBrowserPermissions = useBrowserPermissions as jest.Mock
+
+describe('/apps/open page', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+
+ mockUseRouter.mockReturnValue({
+ query: {
+ safe: 'eth:0x1234567890123456789012345678901234567890',
+ },
+ isReady: true,
+ push: jest.fn(),
+ back: jest.fn(),
+ })
+
+ mockUseSafeAppUrl.mockReturnValue('https://tx-builder.safe.global')
+ mockUseSafeApps.mockReturnValue({
+ customSafeApps: [],
+ customSafeAppsLoading: false,
+ })
+ mockUseSafeAppFromManifest.mockReturnValue({
+ safeApp: {
+ url: 'https://tx-builder.safe.global',
+ safeAppsPermissions: [],
+ },
+ isLoading: false,
+ })
+
+ mockUseBrowserPermissions.mockReturnValue({
+ addPermissions: jest.fn(),
+ getPermissions: jest.fn(),
+ getAllowedFeaturesList: jest.fn(() => ''),
+ })
+
+ mockUseSafeAppsInfoModal.mockReturnValue({
+ isModalVisible: false,
+ isSafeAppInDefaultList: false,
+ isFirstTimeAccessingApp: false,
+ isConsentAccepted: true,
+ isPermissionsReviewCompleted: true,
+ onComplete: jest.fn(),
+ })
+
+ mockUseHasFeature.mockImplementation((feature: FEATURES) => {
+ if (feature === FEATURES.SAFE_APPS) return true
+ return false
+ })
+ })
+
+ it('renders the app frame for a valid safe app url', () => {
+ render( )
+
+ expect(screen.getByTestId('safe-app-frame')).toHaveAttribute('data-url', 'https://tx-builder.safe.global')
+ })
+
+ it('redirects wallet connect app urls to apps list when native wallet connect is enabled', async () => {
+ const push = jest.fn()
+
+ mockUseRouter.mockReturnValue({
+ query: {
+ safe: 'eth:0x1234567890123456789012345678901234567890',
+ },
+ isReady: true,
+ push,
+ back: jest.fn(),
+ })
+
+ mockUseSafeAppUrl.mockReturnValue('https://wallet-connect.safe.global')
+
+ mockUseHasFeature.mockImplementation((feature: FEATURES) => {
+ return feature === FEATURES.SAFE_APPS || feature === FEATURES.NATIVE_WALLETCONNECT
+ })
+
+ render( )
+
+ await waitFor(() => {
+ expect(push).toHaveBeenCalledWith({
+ pathname: '/apps',
+ query: { safe: 'eth:0x1234567890123456789012345678901234567890' },
+ })
+ })
+ })
+})
diff --git a/src/tests/pages/apps-routes-and-nav.test.ts b/src/tests/pages/apps-routes-and-nav.test.ts
new file mode 100644
index 000000000..db0196d34
--- /dev/null
+++ b/src/tests/pages/apps-routes-and-nav.test.ts
@@ -0,0 +1,13 @@
+import { AppRoutes } from '@/config/routes'
+import { navItems } from '@/components/sidebar/SidebarNavigation/config'
+
+describe('Safe Apps routing and navigation', () => {
+ it('exposes routes for safe apps listing and opening', () => {
+ expect(AppRoutes.apps.index).toBe('/apps')
+ expect(AppRoutes.apps.open).toBe('/apps/open')
+ })
+
+ it('includes an Apps sidebar entry', () => {
+ expect(navItems.some((item) => item.label === 'Apps' && item.href === AppRoutes.apps.index)).toBe(true)
+ })
+})
diff --git a/src/utils/__tests__/transactions.test.ts b/src/utils/__tests__/transactions.test.ts
index 52c47123a..8b378a118 100644
--- a/src/utils/__tests__/transactions.test.ts
+++ b/src/utils/__tests__/transactions.test.ts
@@ -7,7 +7,7 @@ import type {
} from '@safe-global/safe-gateway-typescript-sdk'
import { TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk'
import { isMultiSendTxInfo } from '../transaction-guards'
-import { getQueuedTransactionCount, getTxOrigin } from '../transactions'
+import { getQueuedTransactionCount, getTxKeyFromTxId, getTxOrigin } from '../transactions'
describe('transactions', () => {
describe('getQueuedTransactionCount', () => {
@@ -185,4 +185,14 @@ describe('transactions', () => {
).toBe(false)
})
})
+
+ describe('getTxKeyFromTxId', () => {
+ it('should return the tx hash key for a valid tx id', () => {
+ expect(getTxKeyFromTxId('multisig_0x0000000000000000000000000000000000000123_0xabc')).toBe('0xabc')
+ })
+
+ it('should return undefined for an invalid tx id format', () => {
+ expect(getTxKeyFromTxId('invalid-id')).toBeUndefined()
+ })
+ })
})
diff --git a/src/utils/chains.ts b/src/utils/chains.ts
index 8909fad41..586c10e99 100644
--- a/src/utils/chains.ts
+++ b/src/utils/chains.ts
@@ -15,6 +15,7 @@ export enum FEATURES {
RISK_MITIGATION = 'RISK_MITIGATION',
PUSH_NOTIFICATIONS = 'PUSH_NOTIFICATIONS',
NATIVE_WALLETCONNECT = 'NATIVE_WALLETCONNECT',
+ SAFE_APPS = 'SAFE_APPS',
RECOVERY = 'RECOVERY',
SOCIAL_LOGIN = 'SOCIAL_LOGIN',
}