{/* Share button */}
diff --git a/src/components/safe-apps/AppFrame/TransactionQueueBar/styles.module.css b/src/components/safe-apps/AppFrame/TransactionQueueBar/styles.module.css
index b213384cfd..c7d08f6d34 100644
--- a/src/components/safe-apps/AppFrame/TransactionQueueBar/styles.module.css
+++ b/src/components/safe-apps/AppFrame/TransactionQueueBar/styles.module.css
@@ -3,7 +3,9 @@
bottom: 0;
right: 0;
width: 100%;
- z-index: 1;
+
+ /* 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/index.tsx b/src/components/safe-apps/AppFrame/index.tsx
index d0286e8bf0..ac6a75127c 100644
--- a/src/components/safe-apps/AppFrame/index.tsx
+++ b/src/components/safe-apps/AppFrame/index.tsx
@@ -25,6 +25,7 @@ import useTransactionQueueBarState from '@/components/safe-apps/AppFrame/useTran
import { gtmTrackPageview } from '@/services/analytics/gtm'
import { getLegacyChainName } from '../utils'
import useThirdPartyCookies from './useThirdPartyCookies'
+import useAnalyticsFromSafeApp from './useFromAppAnalytics'
import useAppIsLoading from './useAppIsLoading'
import useAppCommunicator, { CommunicatorMessages } from './useAppCommunicator'
import { ThirdPartyCookiesWarning } from './ThirdPartyCookiesWarning'
@@ -62,10 +63,11 @@ const AppFrame = ({ appUrl, allowedFeaturesList }: AppFrameProps): ReactElement
transactions,
} = useTransactionQueueBarState()
const queueBarVisible = transactions.results.length > 0 && !queueBarDismissed
- const [remoteApp] = useSafeAppFromBackend(appUrl, safe.chainId)
+ const [remoteApp, , isBackendAppsLoading] = useSafeAppFromBackend(appUrl, safe.chainId)
const { safeApp: safeAppFromManifest } = useSafeAppFromManifest(appUrl, safe.chainId)
const { thirdPartyCookiesDisabled, setThirdPartyCookiesDisabled } = useThirdPartyCookies()
const { iframeRef, appIsLoading, isLoadingSlow, setAppIsLoading } = useAppIsLoading()
+ useAnalyticsFromSafeApp(iframeRef)
const { getPermissions, hasPermission, permissionsRequest, setPermissionsRequest, confirmPermissionRequest } =
useSafePermissions()
const appName = useMemo(() => (remoteApp ? remoteApp.name : appUrl), [appUrl, remoteApp])
@@ -130,7 +132,7 @@ const AppFrame = ({ appUrl, allowedFeaturesList }: AppFrameProps): ReactElement
}, [appUrl, iframeRef, setAppIsLoading, router])
useEffect(() => {
- if (!appIsLoading) {
+ if (!appIsLoading && !isBackendAppsLoading) {
trackSafeAppEvent(
{
...SAFE_APPS_EVENTS.OPEN_APP,
@@ -138,7 +140,7 @@ const AppFrame = ({ appUrl, allowedFeaturesList }: AppFrameProps): ReactElement
appName,
)
}
- }, [appIsLoading, appName])
+ }, [appIsLoading, isBackendAppsLoading, appName])
useEffect(() => {
const unsubscribe = txSubscribe(TxEvent.SAFE_APPS_REQUEST, async ({ txId, safeAppRequestId }) => {
diff --git a/src/components/safe-apps/AppFrame/useFromAppAnalytics.ts b/src/components/safe-apps/AppFrame/useFromAppAnalytics.ts
new file mode 100644
index 0000000000..e9f2da31ae
--- /dev/null
+++ b/src/components/safe-apps/AppFrame/useFromAppAnalytics.ts
@@ -0,0 +1,52 @@
+import type { MutableRefObject } from 'react'
+import { useCallback, useEffect } from 'react'
+
+import type { AnalyticsEvent } from '@/services/analytics'
+import { EventType, trackSafeAppEvent } from '@/services/analytics'
+import { SAFE_APPS_ANALYTICS_CATEGORY } from '@/services/analytics/events/safeApps'
+
+//TODO: Update apps domain to the new one when decided
+const ALLOWED_DOMAINS: RegExp[] = [
+ /^http:\/\/localhost:[0-9]{4}$/,
+ /^https:\/\/safe-apps\.dev\.5afe\.dev$/,
+ /^https:\/\/apps\.gnosis-safe\.io$/,
+]
+
+const useAnalyticsFromSafeApp = (iframeRef: MutableRefObject
): void => {
+ const isValidMessage = useCallback(
+ (msg: MessageEvent) => {
+ const isFromIframe = iframeRef.current?.contentWindow === msg.source
+ const isCategoryAllowed = msg.data.category === SAFE_APPS_ANALYTICS_CATEGORY
+ const isDomainAllowed = ALLOWED_DOMAINS.find((regExp) => regExp.test(msg.origin)) !== undefined
+
+ return isFromIframe && isCategoryAllowed && isDomainAllowed
+ },
+ [iframeRef],
+ )
+
+ const handleIncomingMessage = useCallback(
+ (msg: MessageEvent) => {
+ if (!isValidMessage(msg)) {
+ return
+ }
+
+ const { action, label, safeAppName } = msg.data
+
+ trackSafeAppEvent(
+ { event: EventType.SAFE_APP, category: SAFE_APPS_ANALYTICS_CATEGORY, action, label },
+ safeAppName,
+ )
+ },
+ [isValidMessage],
+ )
+
+ useEffect(() => {
+ window.addEventListener('message', handleIncomingMessage)
+
+ return () => {
+ window.removeEventListener('message', handleIncomingMessage)
+ }
+ }, [handleIncomingMessage])
+}
+
+export default useAnalyticsFromSafeApp
diff --git a/src/components/safe-apps/SafeAppIcon/index.tsx b/src/components/safe-apps/SafeAppIcon/index.tsx
new file mode 100644
index 0000000000..45f1577cd4
--- /dev/null
+++ b/src/components/safe-apps/SafeAppIcon/index.tsx
@@ -0,0 +1,42 @@
+import { type ReactElement, memo } from 'react'
+
+const getIframeContent = (src: string): string => {
+ return ``
+}
+
+const SafeAppIcon = ({
+ src,
+ alt,
+ width = 40,
+ height = 40,
+}: {
+ src: string
+ alt: string
+ width?: number
+ height?: number
+}): ReactElement => {
+ return (
+
+ )
+}
+
+export default memo(SafeAppIcon)
diff --git a/src/components/safe-apps/SafeAppLandingPage/SafeAppDetails.tsx b/src/components/safe-apps/SafeAppLandingPage/SafeAppDetails.tsx
index da1e9d393c..87ba5ff2ab 100644
--- a/src/components/safe-apps/SafeAppLandingPage/SafeAppDetails.tsx
+++ b/src/components/safe-apps/SafeAppLandingPage/SafeAppDetails.tsx
@@ -5,6 +5,7 @@ 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 SafeAppIcon from '../SafeAppIcon'
type DetailsProps = {
app: SafeAppData
@@ -14,7 +15,8 @@ type DetailsProps = {
const SafeAppDetails = ({ app, showDefaultListWarning }: DetailsProps) => (
-
+
+
{app.name}
diff --git a/src/components/safe-apps/SafeAppsModalLabel/index.tsx b/src/components/safe-apps/SafeAppsModalLabel/index.tsx
index 595589be3a..de6e9d8e31 100644
--- a/src/components/safe-apps/SafeAppsModalLabel/index.tsx
+++ b/src/components/safe-apps/SafeAppsModalLabel/index.tsx
@@ -1,8 +1,6 @@
import type { SafeAppData } from '@gnosis.pm/safe-react-gateway-sdk'
import { Typography, Box } from '@mui/material'
-
-import css from './styles.module.css'
-import ImageFallback from '@/components/common/ImageFallback'
+import SafeAppIcon from '../SafeAppIcon'
const APP_LOGO_FALLBACK_IMAGE = '/images/apps/apps-icon.svg'
@@ -13,14 +11,9 @@ const SafeAppsModalLabel = ({ app }: { app?: SafeAppData }) => {
return (
-
+
+
+
{app.name}
)
diff --git a/src/components/safe-apps/SafeAppsModalLabel/styles.module.css b/src/components/safe-apps/SafeAppsModalLabel/styles.module.css
deleted file mode 100644
index 0648598791..0000000000
--- a/src/components/safe-apps/SafeAppsModalLabel/styles.module.css
+++ /dev/null
@@ -1,4 +0,0 @@
-.modalLabel {
- height: 24px;
- margin-right: 10px;
-}
diff --git a/src/components/settings/ImportAllDialog/documentation.md b/src/components/settings/ImportAllDialog/documentation.md
new file mode 100644
index 0000000000..6d8b8446ab
--- /dev/null
+++ b/src/components/settings/ImportAllDialog/documentation.md
@@ -0,0 +1,91 @@
+## Data Import / Export
+
+Currently we only support the importing of data from our old web interface (safe-react) to the new one (web-core).
+
+### How does the export work?
+
+In the old interface navigate to `Settings -> Details -> Download your data`. This button will download a `.json` file which contains the **entire localStorage**.
+The export files have this format:
+
+```json
+{
+ "version": "1.0",
+ "data": {
+
+ }
+}
+```
+
+### How does the import work?
+
+In the new interface navigate to `/import` or `Settings -> Data` and open the _Import all data_ modal.
+
+This will only import specific data:
+
+- The added Safes
+- The (valid\*) address book entries
+
+* Only named, checksummed address book entries will be added.
+
+#### Address book
+
+All address book entries are stored under the key `SAFE__addressBook`.
+This entry contains a stringified address book with the following format:
+
+```ts
+{
+ address: string
+ name: string
+ chainId: string
+}
+;[]
+```
+
+Example:
+
+```json
+{
+ "version": "1.0",
+ "data": {
+ "SAFE__addressBook": "[{\"address\":\"0xB5E64e857bb7b5350196C5BAc8d639ceC1072745\",\"name\":\"Testname\",\"chainId\":\"5\"},{\"address\":\"0x08f6466dD7891ac9A60C769c7521b0CF2F60c153\",\"name\":\"authentic-goerli-safe\",\"chainId\":\"5\"}]"
+ }
+}
+```
+
+#### Added safes
+
+Added safes are stored under one entry per chain.
+Each entry has a key in following format: `_immortal|v2___SAFES`
+The chain prefix is either the chain ID or prefix, as follows:
+
+```
+ '1': 'MAINNET',
+ '56': 'BSC',
+ '100': 'XDAI',
+ '137': 'POLYGON',
+ '246': 'ENERGY_WEB_CHAIN',
+ '42161': 'ARBITRUM',
+ '73799': 'VOLTA',
+```
+
+Examples:
+
+- `_immortal|v2_MAINNET__SAFES` for mainnet
+- `_immortal|v2_5__SAFES` for goerli (chainId 5)
+
+Inside each of these keys the full Safe information (including balances) is stored in stringified format.
+Example:
+
+```json
+{
+ "version": "1.0",
+ "data": {
+ "_immortal|v2_5__SAFES": "{\"0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b\":{\"address\":\"0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b\",\"chainId\":\"5\",\"threshold\":2,\"ethBalance\":\"0.3\",\"totalFiatBalance\":\"435.08\",\"owners\":[\"0x3819b800c67Be64029C1393c8b2e0d0d627dADE2\",\"0x954cD69f0E902439f99156e3eeDA080752c08401\",\"0xB5E64e857bb7b5350196C5BAc8d639ceC1072745\"],\"modules\":[],\"spendingLimits\":[],\"balances\":[{\"tokenAddress\":\"0x0000000000000000000000000000000000000000\",\"fiatBalance\":\"435.08100\",\"tokenBalance\":\"0.3\"},{\"tokenAddress\":\"0x61fD3b6d656F39395e32f46E2050953376c3f5Ff\",\"fiatBalance\":\"0.00000\",\"tokenBalance\":\"22405.086233211233211233\"}],\"implementation\":{\"value\":\"0x3E5c63644E683549055b9Be8653de26E0B4CD36E\"},\"loaded\":true,\"nonce\":1,\"currentVersion\":\"1.3.0+L2\",\"needsUpdate\":false,\"featuresEnabled\":[\"CONTRACT_INTERACTION\",\"DOMAIN_LOOKUP\",\"EIP1559\",\"ERC721\",\"SAFE_APPS\",\"SAFE_TX_GAS_OPTIONAL\",\"SPENDING_LIMIT\",\"TX_SIMULATION\",\"WARNING_BANNER\"],\"loadedViaUrl\":false,\"guard\":\"\",\"collectiblesTag\":\"1667921524\",\"txQueuedTag\":\"1667921524\",\"txHistoryTag\":\"1667400927\"}}"
+ }
+}
+```
+
+### Noteworthy
+
+- Only address book entries with names and checksummed addresses will be imported.
+- Rinkeby data will be ignored as it's not supported anymore.
diff --git a/src/components/settings/ImportAllDialog/index.tsx b/src/components/settings/ImportAllDialog/index.tsx
index 60cc08c3d3..a8f2031592 100644
--- a/src/components/settings/ImportAllDialog/index.tsx
+++ b/src/components/settings/ImportAllDialog/index.tsx
@@ -1,7 +1,6 @@
import DialogContent from '@mui/material/DialogContent'
import DialogActions from '@mui/material/DialogActions'
import Button from '@mui/material/Button'
-import HighlightOffIcon from '@mui/icons-material/HighlightOff'
import Typography from '@mui/material/Typography'
import { type ReactElement, useState } from 'react'
@@ -13,24 +12,17 @@ import { addedSafesSlice } from '@/store/addedSafesSlice'
import { addressBookSlice } from '@/store/addressBookSlice'
import css from './styles.module.css'
-import Box from '@mui/material/Box'
-import Grid from '@mui/material/Grid'
-import IconButton from '@mui/material/IconButton'
import type { MouseEventHandler } from 'react'
import { useGlobalImportJsonParser } from './useGlobalImportFileParser'
import { showNotification } from '@/store/notificationsSlice'
-import { Alert, AlertTitle, Link, SvgIcon, type SvgIconTypeMap } from '@mui/material'
-import FileIcon from '@/public/images/settings/data/file.svg'
+import { Alert, AlertTitle } from '@mui/material'
import { SETTINGS_EVENTS, trackEvent } from '@/services/analytics'
+import FileUpload, { FileTypes, type FileInfo } from '@/components/common/FileUpload'
const AcceptedMimeTypes = {
'application/json': ['.json'],
}
-const ColoredFileIcon = ({ color }: { color: SvgIconTypeMap['props']['color'] }) => (
-
-)
-
const ImportAllDialog = ({ handleClose }: { handleClose: () => void }): ReactElement => {
const [jsonData, setJsonData] = useState()
const [fileName, setFileName] = useState()
@@ -113,102 +105,43 @@ const ImportAllDialog = ({ handleClose }: { handleClose: () => void }): ReactEle
handleClose()
}
+ const fileInfo: FileInfo | undefined = fileName
+ ? {
+ name: fileName,
+ error,
+ summary: [
+ ...(addedSafesCount > 0 && addedSafes
+ ? [
+
+ Found {addedSafesCount} Added Safes entries on{' '}
+ {Object.keys(addedSafes).length} chain(s)
+ ,
+ ]
+ : []),
+ ...(addressBookEntriesCount > 0 && addressBook
+ ? [
+
+ Found {addressBookEntriesCount} Address book entries on{' '}
+ {Object.keys(addressBook).length} chain(s)
+ ,
+ ]
+ : []),
+ ],
+ }
+ : undefined
+
return (
-
- {fileName ? (
-
-
-
-
-
-
- {fileName}
-
-
-
-
-
-
-
-
-
-
-
- <>
- {addressBook && (
-
-
-
-
-
-
- Found {addressBookEntriesCount} Address book entries on{' '}
- {Object.keys(addressBook).length} chain(s)
-
-
-
- )}
- {addedSafes && (
-
-
-
-
-
-
- Found {addedSafesCount} Added Safes entries on{' '}
- {Object.keys(addedSafes).length} chain(s)
-
-
-
- )}
- {error && (
-
-
-
-
-
-
- {error}
-
-
-
- )}
- >
-
- ) : (
-
`${isDragReject ? palette.error.light : undefined} !important`,
- border: ({ palette }) =>
- `1px dashed ${
- isDragReject ? palette.error.dark : isDragActive ? palette.primary.main : palette.secondary.dark
- }`,
- }}
- >
-
-
-
- palette.primary.light }}
- />
-
- Drag and drop a JSON file or{' '}
-
- choose a file
-
-
-
-
- )}
-
+
diff --git a/src/components/settings/ImportAllDialog/styles.module.css b/src/components/settings/ImportAllDialog/styles.module.css
index 3e393fc9ad..4891aa8b6b 100644
--- a/src/components/settings/ImportAllDialog/styles.module.css
+++ b/src/components/settings/ImportAllDialog/styles.module.css
@@ -1,24 +1,3 @@
-.dropbox {
- align-items: center;
- border-radius: 8px;
- display: flex;
- flex-direction: column;
- justify-content: center;
- cursor: pointer;
- padding: var(--space-3) var(--space-5);
- margin: var(--space-3) 0;
- background: var(--color-secondary-background);
- color: var(--color-primary-light);
-}
-
-.verticalLine {
- display: flex;
- height: 18px;
- border: 1px solid var(--color-primary-main);
- margin-left: var(--space-1);
- margin-top: -8px;
-}
-
.horizontalDivider {
display: flex;
margin: 24px -24px;
diff --git a/src/components/sidebar/Sidebar/styles.module.css b/src/components/sidebar/Sidebar/styles.module.css
index ddbd1a2f70..2babd12323 100644
--- a/src/components/sidebar/Sidebar/styles.module.css
+++ b/src/components/sidebar/Sidebar/styles.module.css
@@ -1,6 +1,7 @@
.container,
.drawer {
- height: 100%;
+ height: 100vh;
+ padding-top: var(--header-height);
display: flex;
overflow: hidden;
flex-direction: column;
@@ -19,7 +20,6 @@
position: relative;
overflow-y: auto;
overflow-x: hidden;
- max-height: calc(100vh - var(--header-height)); /* needed for scroll */
}
.drawer {
diff --git a/src/components/sidebar/SidebarHeader/index.tsx b/src/components/sidebar/SidebarHeader/index.tsx
index b0c6d50549..f15c3179f8 100644
--- a/src/components/sidebar/SidebarHeader/index.tsx
+++ b/src/components/sidebar/SidebarHeader/index.tsx
@@ -101,9 +101,9 @@ const SafeHeader = (): ReactElement => {
diff --git a/src/components/sidebar/SidebarHeader/styles.module.css b/src/components/sidebar/SidebarHeader/styles.module.css
index 100b9dbb02..ab42716092 100644
--- a/src/components/sidebar/SidebarHeader/styles.module.css
+++ b/src/components/sidebar/SidebarHeader/styles.module.css
@@ -32,10 +32,6 @@
background-color: var(--color-secondary-background);
}
-.iconButton svg path {
- fill: var(--color-primary-main);
-}
-
.address {
width: 100%;
overflow: hidden;
diff --git a/src/components/transactions/TxDetails/index.tsx b/src/components/transactions/TxDetails/index.tsx
index 3c16b6fffa..6fceb66e2a 100644
--- a/src/components/transactions/TxDetails/index.tsx
+++ b/src/components/transactions/TxDetails/index.tsx
@@ -12,6 +12,7 @@ import {
isAwaitingExecution,
isModuleExecutionInfo,
isMultiSendTxInfo,
+ isMultisigDetailedExecutionInfo,
isMultisigExecutionInfo,
isSupportedMultiSendAddress,
isTxQueued,
@@ -46,6 +47,12 @@ const TxDetailsBlock = ({ txSummary, txDetails }: TxDetailsProps): ReactElement
const isUnsigned =
isMultisigExecutionInfo(txSummary.executionInfo) && txSummary.executionInfo.confirmationsSubmitted === 0
+ // FIXME: remove "&& false" after https://github.com/safe-global/web-core/issues/1261 is fixed
+ const isUntrusted =
+ isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) &&
+ txDetails.detailedExecutionInfo.trusted === false &&
+ false
+
return (
<>
{/* /Details */}
@@ -75,7 +82,7 @@ const TxDetailsBlock = ({ txSummary, txDetails }: TxDetailsProps): ReactElement
)}
- {isUnsigned &&
}
+ {isUntrusted &&
}
{txDetails.txData?.operation === Operation.DELEGATE && (
diff --git a/src/components/tx/TxStepper/useTxStepper.ts b/src/components/tx/TxStepper/useTxStepper.ts
index a9eb96039c..e7d73696dd 100644
--- a/src/components/tx/TxStepper/useTxStepper.ts
+++ b/src/components/tx/TxStepper/useTxStepper.ts
@@ -42,12 +42,12 @@ export const useTxStepper = ({
const handleNext = () => {
setActiveStep((prevActiveStep) => prevActiveStep + 1)
- trackEvent({ category: eventCategory, action: lastStep ? 'Submit' : 'Next' })
+ trackEvent({ category: eventCategory, action: lastStep ? 'Submit' : 'Next', label: activeStep })
}
const handleBack = (data?: unknown) => {
setActiveStep((prevActiveStep) => prevActiveStep - 1)
- trackEvent({ category: eventCategory, action: firstStep ? 'Cancel' : 'Back' })
+ trackEvent({ category: eventCategory, action: firstStep ? 'Cancel' : 'Back', label: activeStep })
if (data) {
updateStepData(data)
diff --git a/src/components/tx/modals/NewTxModal/index.tsx b/src/components/tx/modals/NewTxModal/index.tsx
index b725dc6491..ff2f500403 100644
--- a/src/components/tx/modals/NewTxModal/index.tsx
+++ b/src/components/tx/modals/NewTxModal/index.tsx
@@ -15,6 +15,7 @@ import { SendAssetsField } from '../TokenTransferModal/SendAssetsForm'
import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps'
import { AppRoutes } from '@/config/routes'
import { SafeAppsTag } from '@/config/constants'
+import SafeAppIcon from '@/components/safe-apps/SafeAppIcon'
const TxButton = (props: ButtonProps) => (
@@ -57,7 +58,7 @@ const NewTxModal = ({ onClose, recipient }: { onClose: () => void; recipient?: s
return (
<>
-
+
}>
@@ -72,7 +73,9 @@ const NewTxModal = ({ onClose, recipient }: { onClose: () => void; recipient?: s
{txBuilder.app && !recipient && (
}
+ startIcon={
+
+ }
variant="outlined"
onClick={onContractInteraction}
>
diff --git a/src/components/tx/modals/NftTransferModal/SendNftForm.tsx b/src/components/tx/modals/NftTransferModal/SendNftForm.tsx
index 4a386d7097..009286f2fe 100644
--- a/src/components/tx/modals/NftTransferModal/SendNftForm.tsx
+++ b/src/components/tx/modals/NftTransferModal/SendNftForm.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react'
+import { useEffect, useState } from 'react'
import {
Box,
Button,
@@ -6,7 +6,6 @@ import {
DialogContent,
FormControl,
Grid,
- InputAdornment,
InputLabel,
MenuItem,
Select,
@@ -23,6 +22,7 @@ import type { SafeCollectibleResponse } from '@gnosis.pm/safe-react-gateway-sdk'
import ImageFallback from '@/components/common/ImageFallback'
import useAddressBook from '@/hooks/useAddressBook'
import EthHashInfo from '@/components/common/EthHashInfo'
+import InfiniteScroll from '@/components/common/InfiniteScroll'
enum Field {
recipient = 'recipient',
@@ -42,14 +42,17 @@ export type SendNftFormProps = {
}
const NftMenuItem = ({ image, name, description }: { image: string; name: string; description?: string }) => (
-
+
- {name}
+
+ {name}
+
+
{description && (
{description}
@@ -62,10 +65,9 @@ const NftMenuItem = ({ image, name, description }: { image: string; name: string
const SendNftForm = ({ params, onSubmit }: SendNftFormProps) => {
const addressBook = useAddressBook()
const [pageUrl, setPageUrl] = useState()
- const [combinedNfts, setCombinedNfts] = useState()
+ const [combinedNfts, setCombinedNfts] = useState(params?.token ? [params.token] : [])
const [nftData, nftError, nftLoading] = useCollectibles(pageUrl)
- const allNfts = useMemo(() => combinedNfts ?? (params?.token ? [params.token] : []), [combinedNfts, params?.token])
- const disabled = nftLoading && !allNfts.length
+ const disabled = nftLoading && !combinedNfts.length
const formMethods = useForm({
defaultValues: {
@@ -83,18 +85,8 @@ const SendNftForm = ({ params, onSubmit }: SendNftFormProps) => {
const recipient = watch(Field.recipient)
- // Collections
- const collections = useMemo(() => uniqBy(allNfts, 'address'), [allNfts])
-
- // Individual tokens
- const selectedCollection = watch(Field.tokenAddress)
- const selectedTokens = useMemo(
- () => allNfts.filter((item) => item.address === selectedCollection),
- [allNfts, selectedCollection],
- )
-
const onFormSubmit = (data: FormData) => {
- const token = selectedTokens.find((item) => item.id === data.tokenId)
+ const token = combinedNfts.find((item) => item.id === data.tokenId)
if (!token) return
onSubmit({
recipient: data.recipient,
@@ -102,16 +94,12 @@ const SendNftForm = ({ params, onSubmit }: SendNftFormProps) => {
})
}
- // Repeatedly load all NFTs page by page
+ // Accumulate all loaded NFT pages in one array
useEffect(() => {
if (nftData?.results?.length) {
- setCombinedNfts((prev) => (prev || []).concat(nftData.results))
- }
-
- if (nftData?.next) {
- setPageUrl(nftData.next)
+ setCombinedNfts((prev) => uniqBy(prev.concat(nftData.results), (item) => item.address + item.id))
}
- }, [nftData])
+ }, [nftData?.results])
return (
@@ -129,49 +117,11 @@ const SendNftForm = ({ params, onSubmit }: SendNftFormProps) => {
)}
-
-
- Select an NFT collection
-
- setValue(Field.tokenId, '') }}
- name={Field.tokenAddress}
- render={({ field }) => (
-
- )}
- />
-
-
Select an NFT
+
{
labelId="asset-label"
label={errors.tokenId?.message || 'Select an NFT'}
error={!!errors.tokenId}
- sx={{ '&.MuiMenu-paper': { overflow: 'hidden' } }}
>
- {selectedTokens.map((item) => (
+ {combinedNfts.map((item) => (
))}
+
+ {(nftLoading || nftData?.next) && (
+
+ )}
)}
/>
diff --git a/src/components/tx/modals/TokenTransferModal/SendAssetsForm.tsx b/src/components/tx/modals/TokenTransferModal/SendAssetsForm.tsx
index a775c06ff6..e1a74a1807 100644
--- a/src/components/tx/modals/TokenTransferModal/SendAssetsForm.tsx
+++ b/src/components/tx/modals/TokenTransferModal/SendAssetsForm.tsx
@@ -11,6 +11,7 @@ import {
TextField,
DialogContent,
Box,
+ SvgIcon,
} from '@mui/material'
import { type TokenInfo } from '@gnosis.pm/safe-react-gateway-sdk'
@@ -25,6 +26,11 @@ import SpendingLimitRow from '@/components/tx/SpendingLimitRow'
import useSpendingLimit from '@/hooks/useSpendingLimit'
import EthHashInfo from '@/components/common/EthHashInfo'
import useAddressBook from '@/hooks/useAddressBook'
+import { getSafeTokenAddress } from '@/components/common/SafeTokenWidget'
+import useChainId from '@/hooks/useChainId'
+import { sameAddress } from '@/utils/addresses'
+import InfoIcon from '@/public/images/notifications/info.svg'
+import useIsSafeTokenPaused from '@/components/tx/modals/TokenTransferModal/useIsSafeTokenPaused'
export const AutocompleteItem = (item: { tokenInfo: TokenInfo; balance: string }): ReactElement => (
@@ -67,6 +73,9 @@ type SendAssetsFormProps = {
const SendAssetsForm = ({ onSubmit, formData }: SendAssetsFormProps): ReactElement => {
const { balances } = useBalances()
const addressBook = useAddressBook()
+ const chainId = useChainId()
+ const safeTokenAddress = getSafeTokenAddress(chainId)
+ const isSafeTokenPaused = useIsSafeTokenPaused()
const formMethods = useForm({
defaultValues: {
@@ -98,6 +107,8 @@ const SendAssetsForm = ({ onSubmit, formData }: SendAssetsFormProps): ReactEleme
const spendingLimit = useSpendingLimit(selectedToken?.tokenInfo)
const isSpendingLimitType = type === SendTxType.spendingLimit
+ const isSafeTokenSelected = sameAddress(safeTokenAddress, tokenAddress)
+
const onMaxAmountClick = () => {
if (!selectedToken) return
@@ -111,6 +122,8 @@ const SendAssetsForm = ({ onSubmit, formData }: SendAssetsFormProps): ReactEleme
})
}
+ const isDisabled = isSafeTokenSelected && isSafeTokenPaused
+
return (
-
+
Select an asset
@@ -149,11 +162,20 @@ const SendAssetsForm = ({ onSubmit, formData }: SendAssetsFormProps): ReactEleme
+ {isDisabled && (
+
+
+
+ $SAFE is currently non-transferable.
+
+
+ )}
+
{!!spendingLimit && (
)}
-
+
-