From faaf7434e9114cb56fc4df9bf00804a91c71ed4b Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 17 Nov 2022 14:54:39 +0100 Subject: [PATCH 01/28] fix: improve reliability of E2E tests (#1182) * fix: use `intercept` callback in tx creation test * test: assert button + await `/safe-apps` * fix: move `timeout` to selector --- cypress/e2e/safe-apps/apps_list.cy.js | 7 +- cypress/e2e/smoke/create_tx.cy.js | 147 +++++++++++++------------- 2 files changed, 78 insertions(+), 76 deletions(-) diff --git a/cypress/e2e/safe-apps/apps_list.cy.js b/cypress/e2e/safe-apps/apps_list.cy.js index e72aad43ef..7c63a44a5a 100644 --- a/cypress/e2e/safe-apps/apps_list.cy.js +++ b/cypress/e2e/safe-apps/apps_list.cy.js @@ -8,8 +8,11 @@ describe('The Safe Apps list', () => { describe('When searching apps', () => { it('should filter the list by app name', () => { - cy.findByRole('textbox').type('walletconnect') - cy.findAllByRole('link', { name: /logo/i }).should('have.length', 1) + // Wait for /safe-apps response + cy.intercept('GET', '/**/safe-apps').then(() => { + cy.findByRole('textbox').type('walletconnect') + cy.findAllByRole('link', { name: /logo/i }).should('have.length', 1) + }) }) it('should filter the list by app description', () => { diff --git a/cypress/e2e/smoke/create_tx.cy.js b/cypress/e2e/smoke/create_tx.cy.js index 30b756088c..f310a4e4d8 100644 --- a/cypress/e2e/smoke/create_tx.cy.js +++ b/cypress/e2e/smoke/create_tx.cy.js @@ -15,8 +15,13 @@ describe('Queue a transaction on 1/N', () => { }) it('should create and queue a transaction', () => { + // Assert that "New transaction" button is visible + cy.contains('New transaction', { + timeout: 60_000, // `lastWallet` takes a while initialize in CI + }).should('be.visible') + // Open the new transaction modal - cy.contains('New transaction', { timeout: 10000 }).click() + cy.contains('New transaction').click() // Modal is open cy.contains('h2', 'New transaction').should('be.visible') @@ -35,89 +40,83 @@ describe('Queue a transaction on 1/N', () => { }) it('should create a queued transaction', () => { - // Spy the /estimations request - cy.intercept('POST', '/**/multisig-transactions/estimations').as('estimations') - // Alias for New transaction modal cy.contains('h2', 'Review transaction').parents('div').as('modal') // Wait for /estimations response - cy.wait('@estimations', { timeout: 30_000 }) - - // Estimation is loaded - cy.get('button[type="submit"]').should('not.be.disabled') - - // Gets the recommended nonce - cy.contains('Signing the transaction with nonce').should(($div) => { - // get the number in the string - recommendedNonce = $div.text().match(/\d+$/)[0] + cy.intercept('POST', '/**/multisig-transactions/estimations').then(() => { + // Estimation is loaded + cy.get('button[type="submit"]').should('not.be.disabled') + + // Gets the recommended nonce + cy.contains('Signing the transaction with nonce').should(($div) => { + // get the number in the string + recommendedNonce = $div.text().match(/\d+$/)[0] + }) + + // Changes nonce to next one + cy.contains('Signing the transaction with nonce').click() + cy.contains('button', 'Edit').click() + cy.get('label').contains('Safe transaction nonce').next().clear().type('3') + cy.contains('Confirm').click() + + // Asserts the execute checkbox exists + cy.get('@modal').within(() => { + cy.get('input[type="checkbox"]') + .parent('span') + .should(($div) => { + // Turn the classList into a string + const classListString = Array.from($div[0].classList).join() + // Check if it contains the error class + expect(classListString).to.include('checked') + }) + }) + cy.contains('Estimated fee').should('exist') + + // Asserts the execute checkbox is uncheckable + cy.contains('Execute transaction').click() + cy.get('@modal').within(() => { + cy.get('input[type="checkbox"]') + .parent('span') + .should(($div) => { + // Turn the classList into a string + const classListString = Array.from($div[0].classList).join() + // Check if it contains the error class + expect(classListString).not.to.include('checked') + }) + }) + cy.contains('Signing the transaction with nonce').should('exist') + + // Changes back to recommended nonce + cy.contains('Signing the transaction with nonce').click() + cy.contains('Edit').click() + cy.get('button[aria-label="Reset to recommended nonce"]').click() + + // Accepts the values + cy.contains('Confirm').click() + + cy.get('@modal').within(() => { + cy.get('input[type="checkbox"]').should('not.exist') + }) + + cy.contains('Submit').click() }) - - // Changes nonce to next one - cy.contains('Signing the transaction with nonce').click() - cy.contains('button', 'Edit').click() - cy.get('label').contains('Safe transaction nonce').next().clear().type('3') - cy.contains('Confirm').click() - - // Asserts the execute checkbox exists - cy.get('@modal').within(() => { - cy.get('input[type="checkbox"]') - .parent('span') - .should(($div) => { - // Turn the classList into a string - const classListString = Array.from($div[0].classList).join() - // Check if it contains the error class - expect(classListString).to.include('checked') - }) - }) - cy.contains('Estimated fee').should('exist') - - // Asserts the execute checkbox is uncheckable - cy.contains('Execute transaction').click() - cy.get('@modal').within(() => { - cy.get('input[type="checkbox"]') - .parent('span') - .should(($div) => { - // Turn the classList into a string - const classListString = Array.from($div[0].classList).join() - // Check if it contains the error class - expect(classListString).not.to.include('checked') - }) - }) - cy.contains('Signing the transaction with nonce').should('exist') - - // Changes back to recommended nonce - cy.contains('Signing the transaction with nonce').click() - cy.contains('Edit').click() - cy.get('button[aria-label="Reset to recommended nonce"]').click() - - // Accepts the values - cy.contains('Confirm').click() - - cy.get('@modal').within(() => { - cy.get('input[type="checkbox"]').should('not.exist') - }) - - cy.contains('Submit').click() - - // Spy the /propose request and give it an alias - cy.intercept('POST', '/**/propose').as('propose') - - // Wait for the /propose request - cy.wait('@propose', { timeout: 30_000 }) }) it('should click the notification and see the transaction queued', () => { - // Click on the notification - cy.contains('View transaction').click() + // Wait for the /propose request + cy.intercept('POST', '/**/propose').then(() => { + // Click on the notification + cy.contains('View transaction').click() - // Single Tx page - cy.contains('h3', 'Transaction details').should('be.visible') + // Single Tx page + cy.contains('h3', 'Transaction details').should('be.visible') - // Queue label - cy.contains('Queued - transaction with nonce 3 needs to be executed first').should('be.visible') + // Queue label + cy.contains('Queued - transaction with nonce 3 needs to be executed first').should('be.visible') - // Transaction summary - cy.contains(`${recommendedNonce}` + 'Send' + '-' + `${sendValue} GOR`).should('exist') + // Transaction summary + cy.contains(`${recommendedNonce}` + 'Send' + '-' + `${sendValue} GOR`).should('exist') + }) }) }) From 219a813d1fb8cf9458a95c9afa67b99a693c36d4 Mon Sep 17 00:00:00 2001 From: katspaugh <381895+katspaugh@users.noreply.github.com> Date: Fri, 18 Nov 2022 07:28:59 +0100 Subject: [PATCH 02/28] Fix: MUI Switch default color (#1189) * Fix: MUI Switch default color * Keep primary color in dark mode --- src/styles/theme.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/styles/theme.ts b/src/styles/theme.ts index 7eebc1f5c6..49db5e7614 100644 --- a/src/styles/theme.ts +++ b/src/styles/theme.ts @@ -473,6 +473,9 @@ const initTheme = (darkMode: boolean) => { }, }, MuiSwitch: { + defaultProps: { + color: darkMode ? undefined : 'success', + }, styleOverrides: { thumb: ({ theme }) => ({ boxShadow: From f95d7bec9925ebac6e700d4348fe45ef0347e879 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Fri, 18 Nov 2022 07:52:36 +0100 Subject: [PATCH 03/28] add: docs for import all feature (#1152) * add: docs for import all feature * Rephrasing and additions Co-authored-by: Aaron Cook * rephrase sentence Co-authored-by: Aaron Cook --- .../settings/ImportAllDialog/documentation.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 src/components/settings/ImportAllDialog/documentation.md 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. From d3edb2d86854cf470918a76167716645ab70ea3d Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Fri, 18 Nov 2022 10:36:48 +0100 Subject: [PATCH 04/28] fix: handle invalid shortname in url (#1160) * fix: handle invalid shortname in url * fix: failing test * fix: Move logic into usePathRewrite and add error logging * fix: Add additional test for usePathRewrite * refactor: Change error code * refactor: Reuse useUrlChainId * fix: trackError instead of just logging --- src/hooks/__tests__/useChainId.test.ts | 17 ----------------- src/hooks/__tests__/usePathRewrite.test.ts | 14 ++++++++++++++ src/hooks/useChainId.ts | 8 +------- src/hooks/usePathRewrite.ts | 13 ++++++++++++- src/services/exceptions/ErrorCodes.ts | 1 + 5 files changed, 28 insertions(+), 25 deletions(-) diff --git a/src/hooks/__tests__/useChainId.test.ts b/src/hooks/__tests__/useChainId.test.ts index 520c2017ea..f0338dca23 100644 --- a/src/hooks/__tests__/useChainId.test.ts +++ b/src/hooks/__tests__/useChainId.test.ts @@ -105,23 +105,6 @@ describe('useChainId hook', () => { expect(result.current).toBe('137') }) - it('should throw when the chain query is invalid', () => { - ;(useRouter as any).mockImplementation(() => ({ - query: { - chain: 'invalid', - }, - })) - - // Mock console error because the hook will throw and show a huge error message in test output - const consoleErrorMock = jest.spyOn(console, 'error').mockImplementation() - try { - renderHook(() => useChainId()) - } catch (error) { - expect((error as Error).message).toBe('Invalid chain short name in the URL') - } - consoleErrorMock.mockRestore() - }) - it('should return the last used chain id if no chain in the URL', () => { ;(useRouter as any).mockImplementation(() => ({ query: {}, diff --git a/src/hooks/__tests__/usePathRewrite.test.ts b/src/hooks/__tests__/usePathRewrite.test.ts index ee5514f033..bd33280ec6 100644 --- a/src/hooks/__tests__/usePathRewrite.test.ts +++ b/src/hooks/__tests__/usePathRewrite.test.ts @@ -102,4 +102,18 @@ describe('usePathRewrite', () => { '/rin:0x0000000000000000000000000000000000000000/hello?hi=hello&count=1', ) }) + + it('should navigate to the welcome page when the chain query is invalid', () => { + const mockFn = jest.fn() + + renderHook(() => usePathRewrite(), { + routerProps: { + query: { + safe: 'undefined:0x0000000000000000000000000000000000000000', + }, + push: mockFn, + }, + }) + expect(mockFn).toHaveBeenCalledWith('/welcome') + }) }) diff --git a/src/hooks/useChainId.ts b/src/hooks/useChainId.ts index b7a66bb131..c8e54b3ffd 100644 --- a/src/hooks/useChainId.ts +++ b/src/hooks/useChainId.ts @@ -38,13 +38,7 @@ export const useUrlChainId = (): string | undefined => { const { prefix } = parsePrefixedAddress(safe) const shortName = prefix || chain - if (shortName) { - const chainId = Object.entries(chains).find(([key]) => key === shortName)?.[1] - if (chainId == null) { - throw Error('Invalid chain short name in the URL') - } - return chainId - } + return Object.entries(chains).find(([key]) => key === shortName)?.[1] } export const useChainId = (): string => { diff --git a/src/hooks/usePathRewrite.ts b/src/hooks/usePathRewrite.ts index 15bcf47168..cab8b1c780 100644 --- a/src/hooks/usePathRewrite.ts +++ b/src/hooks/usePathRewrite.ts @@ -1,5 +1,9 @@ import { useRouter } from 'next/router' import { useEffect } from 'react' +import { AppRoutes } from '@/config/routes' +import { trackError } from '@/services/exceptions' +import ErrorCodes from '@/services/exceptions/ErrorCodes' +import { useUrlChainId } from '@/hooks/useChainId' // Next.js needs to know all the static paths in advance when doing SSG (static site generation) // @see https://nextjs.org/docs/api-reference/next.config.js/runtime-config-static-paths @@ -11,12 +15,19 @@ import { useEffect } from 'react' // Next.js doesn't care because dynamic path params are internally represented as query params anyway. const usePathRewrite = () => { const router = useRouter() + const chainId = useUrlChainId() useEffect(() => { let { safe = '', ...restQuery } = router.query if (Array.isArray(safe)) safe = safe[0] if (!safe) return + if (!chainId) { + trackError(ErrorCodes._104) + router.push(AppRoutes.welcome) + return + } + // Move the Safe address to the path let newPath = router.pathname.replace(/^\//, `/${safe}/`) @@ -36,7 +47,7 @@ const usePathRewrite = () => { // This just changes what you see in the URL bar w/o triggering any rendering or route change history.replaceState(history.state, '', newPath) } - }, [router]) + }, [chainId, router]) } export default usePathRewrite diff --git a/src/services/exceptions/ErrorCodes.ts b/src/services/exceptions/ErrorCodes.ts index 6a855d480a..674999e0ae 100644 --- a/src/services/exceptions/ErrorCodes.ts +++ b/src/services/exceptions/ErrorCodes.ts @@ -10,6 +10,7 @@ enum ErrorCodes { _100 = '100: Invalid input in the address field', _101 = '101: Failed to resolve the address', _103 = '103: Error creating a SafeTransaction', + _104 = '104: Invalid chain short name in the URL', _302 = '302: Error connecting to the wallet', _303 = '303: Error creating pairing session', From e3556a2d6de975f41234c1fa09e7f3304fe94b79 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Fri, 18 Nov 2022 13:22:53 +0100 Subject: [PATCH 05/28] feat: redesign address book import (#1137) --- .../address-book/ImportDialog/index.tsx | 58 +++----- .../ImportDialog/styles.module.css | 12 +- src/components/common/FileUpload/index.tsx | 126 ++++++++++++++++ .../common/FileUpload/styles.module.css | 21 +++ .../settings/ImportAllDialog/index.tsx | 139 +++++------------- .../ImportAllDialog/styles.module.css | 21 --- 6 files changed, 209 insertions(+), 168 deletions(-) create mode 100644 src/components/common/FileUpload/index.tsx create mode 100644 src/components/common/FileUpload/styles.module.css diff --git a/src/components/address-book/ImportDialog/index.tsx b/src/components/address-book/ImportDialog/index.tsx index dd8f9dde4b..87ad7a8274 100644 --- a/src/components/address-book/ImportDialog/index.tsx +++ b/src/components/address-book/ImportDialog/index.tsx @@ -10,13 +10,13 @@ import { type ReactElement, useState, type MouseEvent, useMemo } from 'react' import ModalDialog from '@/components/common/ModalDialog' import { upsertAddressBookEntry } from '@/store/addressBookSlice' import { useAppDispatch } from '@/store' -import { Box, Grid, IconButton } from '@mui/material' import css from './styles.module.css' import { trackEvent, ADDRESS_BOOK_EVENTS } from '@/services/analytics' import { abCsvReaderValidator, abOnUploadValidator } from './validation' import ErrorMessage from '@/components/tx/ErrorMessage' import { Errors, logError } from '@/services/exceptions' +import FileUpload, { FileTypes, type FileInfo } from '@/components/common/FileUpload' type AddressBookCSVRow = ['address', 'name', 'chainId'] @@ -114,7 +114,7 @@ const ImportDialog = ({ handleClose }: { handleClose: () => void }): ReactElemen > {/* https://github.com/Bunlong/react-papaparse/blob/master/src/useCSVReader.tsx */} {({ getRootProps, acceptedFile, ProgressBar, getRemoveFileProps, Remove }: any) => { - const { onClick, ...removeProps } = getRemoveFileProps() + const { onClick } = getRemoveFileProps() const onRemove = (e: MouseEvent) => { setCsvData(undefined) @@ -122,44 +122,32 @@ const ImportDialog = ({ handleClose }: { handleClose: () => void }): ReactElemen onClick(e) } + const fileInfo: FileInfo | undefined = acceptedFile + ? { + name: acceptedFile.name, + additionalInfo: formatFileSize(acceptedFile.size), + summary: [ + + {`Found ${entryCount} entries on ${chainCount} ${chainCount > 1 ? 'chains' : 'chain'}`} + , + ], + } + : undefined + return ( - `2px dashed ${zoneHover ? palette.primary.main : palette.border.light}`, - }} - > - {acceptedFile ? ( -
- - - {acceptedFile.name} - {formatFileSize(acceptedFile.size)} - - - - - - - - - - - - {entryCount > 0 && ( - - {`Found ${entryCount} entries on ${chainCount} ${chainCount > 1 ? 'chains' : 'chain'}`} - - )} -
- ) : ( - 'Drop your CSV file here or click to upload.' - )} -
+ ) }} +
+ {error && {error}} diff --git a/src/components/address-book/ImportDialog/styles.module.css b/src/components/address-book/ImportDialog/styles.module.css index 0cd198fa64..4891aa8b6b 100644 --- a/src/components/address-book/ImportDialog/styles.module.css +++ b/src/components/address-book/ImportDialog/styles.module.css @@ -1,11 +1,5 @@ -.dropbox { - align-items: center; - border-radius: 8px; +.horizontalDivider { display: flex; - flex-direction: column; - justify-content: center; - cursor: pointer; - padding: var(--space-2) 0; - margin: var(--space-3) 0; - min-height: 200px; + margin: 24px -24px; + border-top: 2px solid rgba(0, 0, 0, 0.12); } diff --git a/src/components/common/FileUpload/index.tsx b/src/components/common/FileUpload/index.tsx new file mode 100644 index 0000000000..5589b293b8 --- /dev/null +++ b/src/components/common/FileUpload/index.tsx @@ -0,0 +1,126 @@ +import css from './styles.module.css' +import { Box, Grid, IconButton, Link, SvgIcon, type SvgIconTypeMap, Typography } from '@mui/material' +import HighlightOffIcon from '@mui/icons-material/HighlightOff' +import FileIcon from '@/public/images/settings/data/file.svg' +import type { MouseEventHandler, ReactElement } from 'react' +import type { DropzoneInputProps, DropzoneRootProps } from 'react-dropzone' + +export type FileInfo = { + name: string + additionalInfo?: string + summary: ReactElement[] + error?: string +} + +export enum FileTypes { + JSON = 'JSON', + CSV = 'CSV', +} + +const ColoredFileIcon = ({ color }: { color: SvgIconTypeMap['props']['color'] }) => ( + +) + +const UploadSummary = ({ fileInfo, onRemove }: { fileInfo: FileInfo; onRemove: (() => void) | MouseEventHandler }) => { + return ( + + + + + + + {fileInfo.name} + {fileInfo.additionalInfo && ` - ${fileInfo.additionalInfo}`} + + + + + + + + + +
+ + <> + {fileInfo.summary.map((summaryItem, idx) => ( + + + + + + {summaryItem} + + + ))} + {fileInfo.error && ( + + + + + + + {fileInfo.error} + + + + )} + + + ) +} + +const FileUpload = ({ + getRootProps, + getInputProps, + isDragReject = false, + isDragActive = false, + fileType, + fileInfo, + onRemove, +}: { + isDragReject?: boolean + isDragActive?: boolean + fileType: FileTypes + getInputProps?: (props?: T | undefined) => T + getRootProps: (props?: T | undefined) => T + fileInfo?: FileInfo + onRemove: (() => void) | MouseEventHandler +}) => { + if (fileInfo) { + return + } + return ( + `${isDragReject ? palette.error.light : undefined} !important`, + border: ({ palette }) => + `1px dashed ${ + isDragReject ? palette.error.dark : isDragActive ? palette.primary.main : palette.secondary.dark + }`, + }} + > + {getInputProps && } + + + palette.primary.light }} + /> + + Drag and drop a {fileType} file or{' '} + + choose a file + + + + + ) +} + +export default FileUpload diff --git a/src/components/common/FileUpload/styles.module.css b/src/components/common/FileUpload/styles.module.css new file mode 100644 index 0000000000..1225fc1433 --- /dev/null +++ b/src/components/common/FileUpload/styles.module.css @@ -0,0 +1,21 @@ +.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); + transition: border 0.5s, background 0.5s; +} + +.verticalLine { + display: flex; + height: 18px; + border-right: 1px solid var(--color-primary-main); + margin-left: 7px; + margin-top: -8px; +} 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; From d5cea5d509a5d14064cabdc3f3c9cb7afcb2d739 Mon Sep 17 00:00:00 2001 From: katspaugh <381895+katspaugh@users.noreply.github.com> Date: Fri, 18 Nov 2022 16:45:22 +0100 Subject: [PATCH 06/28] Fix: backdrop color (#1186) * Fix: backdrop color * Close new tx modal when a submodal is open --- src/components/tx/modals/NewTxModal/index.tsx | 2 +- src/styles/colors-dark.ts | 3 +++ src/styles/colors.ts | 3 +++ src/styles/onboard.css | 3 +++ src/styles/theme.ts | 4 +++- src/styles/vars.css | 3 +++ 6 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/components/tx/modals/NewTxModal/index.tsx b/src/components/tx/modals/NewTxModal/index.tsx index b725dc6491..c0abca1766 100644 --- a/src/components/tx/modals/NewTxModal/index.tsx +++ b/src/components/tx/modals/NewTxModal/index.tsx @@ -57,7 +57,7 @@ const NewTxModal = ({ onClose, recipient }: { onClose: () => void; recipient?: s return ( <> - + }> diff --git a/src/styles/colors-dark.ts b/src/styles/colors-dark.ts index fca60293e1..aaacd0f169 100644 --- a/src/styles/colors-dark.ts +++ b/src/styles/colors-dark.ts @@ -50,6 +50,9 @@ const darkPalette = { paper: '#1C1C1C', light: '#1B2A22', }, + backdrop: { + main: '#636669', + }, logo: { main: '#FFFFFF', background: '#303033', diff --git a/src/styles/colors.ts b/src/styles/colors.ts index 3897cf1d6b..4eff2aae30 100644 --- a/src/styles/colors.ts +++ b/src/styles/colors.ts @@ -50,6 +50,9 @@ const palette = { paper: '#FFFFFF', light: '#EFFFF4', }, + backdrop: { + main: '#636669', + }, logo: { main: '#121312', background: '#EEEFF0', diff --git a/src/styles/onboard.css b/src/styles/onboard.css index a4f665a41d..d72bcb4f46 100644 --- a/src/styles/onboard.css +++ b/src/styles/onboard.css @@ -32,6 +32,9 @@ --onboard-warning-600: var(--color-error-main); --onboard-warning-700: var(--color-error-dark); + /* var(--color-backdrop-main) + opacity */ + --onboard-modal-backdrop: rgba(99, 102, 105, 0.75); + --account-select-modal-white: var(--color-background-paper); --account-select-modal-black: var(--color-primary-main); --account-select-modal-primary-100: var(--color-secondary-background); diff --git a/src/styles/theme.ts b/src/styles/theme.ts index 49db5e7614..0f72f5a981 100644 --- a/src/styles/theme.ts +++ b/src/styles/theme.ts @@ -11,11 +11,13 @@ declare module '@mui/material/styles' { interface Palette { border: Palette['primary'] logo: Palette['primary'] + backdrop: Palette['primary'] static: Palette['primary'] } interface PaletteOptions { border: PaletteOptions['primary'] logo: PaletteOptions['primary'] + backdrop: PaletteOptions['primary'] static: PaletteOptions['primary'] } @@ -468,7 +470,7 @@ const initTheme = (darkMode: boolean) => { MuiBackdrop: { styleOverrides: { root: ({ theme }) => ({ - backgroundColor: alpha(theme.palette.background.main, 0.75), + backgroundColor: alpha(theme.palette.backdrop.main, 0.75), }), }, }, diff --git a/src/styles/vars.css b/src/styles/vars.css index 38043f17b3..54f81e18ad 100644 --- a/src/styles/vars.css +++ b/src/styles/vars.css @@ -34,6 +34,7 @@ --color-background-main: #f4f4f4; --color-background-paper: #ffffff; --color-background-light: #effff4; + --color-backdrop-main: #636669; --color-logo-main: #121312; --color-logo-background: #eeeff0; --color-static-main: #121312; @@ -85,6 +86,7 @@ --color-background-main: #121312; --color-background-paper: #1c1c1c; --color-background-light: #1b2a22; + --color-backdrop-main: #636669; --color-logo-main: #ffffff; --color-logo-background: #303033; --color-static-main: #121312; @@ -126,6 +128,7 @@ --color-background-main: #121312; --color-background-paper: #1c1c1c; --color-background-light: #1b2a22; + --color-backdrop-main: #636669; --color-logo-main: #ffffff; --color-logo-background: #303033; --color-static-main: #121312; From 5e70da0ecb9268eb96b92d78af77094551ae0479 Mon Sep 17 00:00:00 2001 From: katspaugh <381895+katspaugh@users.noreply.github.com> Date: Fri, 18 Nov 2022 16:50:14 +0100 Subject: [PATCH 07/28] Fix: added safes across chains (#1167) --- src/services/analytics/events/overview.ts | 4 +-- src/services/analytics/gtm.ts | 1 - src/services/analytics/useGtm.ts | 39 +++++++++++++++++++++-- src/store/addedSafesSlice.ts | 31 ++++-------------- src/store/txQueueSlice.ts | 24 ++------------ 5 files changed, 47 insertions(+), 52 deletions(-) diff --git a/src/services/analytics/events/overview.ts b/src/services/analytics/events/overview.ts index 47aa8c7cbd..668d1d10e5 100644 --- a/src/services/analytics/events/overview.ts +++ b/src/services/analytics/events/overview.ts @@ -31,9 +31,9 @@ export const OVERVIEW_EVENTS = { action: 'Sidebar', category: OVERVIEW_CATEGORY, }, - ADDED_SAFES_ON_NETWORK: { + TOTAL_ADDED_SAFES: { event: EventType.META, - action: 'Added Safes on', // Safe name is appended trackEvent on SafeList + action: 'Total added Safes', category: OVERVIEW_CATEGORY, }, WHATS_NEW: { diff --git a/src/services/analytics/gtm.ts b/src/services/analytics/gtm.ts index 3c5655ff40..aea2d4b122 100644 --- a/src/services/analytics/gtm.ts +++ b/src/services/analytics/gtm.ts @@ -25,7 +25,6 @@ type GTMEnvironment = 'LIVE' | 'LATEST' | 'DEVELOPMENT' type GTMEnvironmentArgs = Required> const GOOGLE_ANALYTICS_COOKIE_LIST = ['_ga', '_gat', '_gid'] -const EMPTY_SAFE_APP = 'unknown' const GTM_ENV_AUTH: Record = { LIVE: { diff --git a/src/services/analytics/useGtm.ts b/src/services/analytics/useGtm.ts index 10fd29db21..86c9fb451e 100644 --- a/src/services/analytics/useGtm.ts +++ b/src/services/analytics/useGtm.ts @@ -3,13 +3,45 @@ * It won't initialize GTM if a consent wasn't given for analytics cookies. * The hook needs to be called when the app starts. */ -import { useEffect } from 'react' -import { gtmClear, gtmInit, gtmTrackPageview, gtmSetChainId } from '@/services/analytics/gtm' +import { useEffect, useMemo } from 'react' +import { gtmClear, gtmInit, gtmTrackPageview, gtmSetChainId, gtmTrack } from '@/services/analytics/gtm' import { useAppSelector } from '@/store' import { CookieType, selectCookies } from '@/store/cookiesSlice' import useChainId from '@/hooks/useChainId' import { useRouter } from 'next/router' import { AppRoutes } from '@/config/routes' +import { OVERVIEW_EVENTS, TX_LIST_EVENTS } from './events' +import { selectTotalAdded } from '@/store/addedSafesSlice' +import useSafeAddress from '@/hooks/useSafeAddress' +import { selectQueuedTransactions } from '@/store/txQueueSlice' + +// Track meta events on app load +const useMetaEvents = (isAnalyticsEnabled: boolean) => { + // Track total added safes + const totalAddedSafes = useAppSelector(selectTotalAdded) + useEffect(() => { + if (!isAnalyticsEnabled || totalAddedSafes === 0) return + + gtmTrack({ + ...OVERVIEW_EVENTS.TOTAL_ADDED_SAFES, + label: totalAddedSafes.toString(), + }) + }, [isAnalyticsEnabled, totalAddedSafes]) + + // Track queue size + const safeAddress = useSafeAddress() + const queue = useAppSelector(selectQueuedTransactions) + // eslint-disable-next-line react-hooks/exhaustive-deps + const safeQueue = useMemo(() => queue, [safeAddress, queue !== undefined]) + useEffect(() => { + if (!isAnalyticsEnabled || !safeQueue) return + + gtmTrack({ + ...TX_LIST_EVENTS.QUEUED_TXS, + label: safeQueue.length.toString(), + }) + }, [isAnalyticsEnabled, safeQueue]) +} const useGtm = () => { const chainId = useChainId() @@ -37,6 +69,9 @@ const useGtm = () => { gtmTrackPageview(router.pathname) } }, [isAnalyticsEnabled, router.pathname]) + + // Track meta events on app load + useMetaEvents(isAnalyticsEnabled) } export default useGtm diff --git a/src/store/addedSafesSlice.ts b/src/store/addedSafesSlice.ts index b4dbb847be..671c10cca2 100644 --- a/src/store/addedSafesSlice.ts +++ b/src/store/addedSafesSlice.ts @@ -7,8 +7,6 @@ import { selectSafeInfo, safeInfoSlice } from '@/store/safeInfoSlice' import { balancesSlice } from './balancesSlice' import { safeFormatUnits } from '@/utils/formatters' import type { Loadable } from './common' -import { selectChainById } from '@/store/chainsSlice' -import { trackEvent, OVERVIEW_EVENTS } from '@/services/analytics' export type AddedSafesOnChain = { [safeAddress: string]: { @@ -106,6 +104,12 @@ export const selectAllAddedSafes = (state: RootState): AddedSafesState => { return state[addedSafesSlice.name] } +export const selectTotalAdded = (state: RootState): number => { + return Object.values(state[addedSafesSlice.name]) + .map((item) => Object.keys(item)) + .flat().length +} + export const selectAddedSafes = createSelector( [selectAllAddedSafes, (_: RootState, chainId: string) => chainId], (allAddedSafes, chainId): AddedSafesOnChain | undefined => { @@ -119,29 +123,6 @@ export const addedSafesMiddleware: Middleware<{}, RootState> = (store) => (next) const state = store.getState() switch (action.type) { - // Track number of total Safes (when a new one is added) - case addedSafesSlice.actions.addOrUpdateSafe.type: { - const { chainId, address } = action.payload.safe - - const addedSafes = selectAllAddedSafes(state) - const addedSafeAddresses = Object.keys(addedSafes?.[chainId] || {}) - - if (isAddedSafe(addedSafes, chainId, address.value) || addedSafeAddresses.length === 0) { - return - } - - const event = OVERVIEW_EVENTS.ADDED_SAFES_ON_NETWORK - - const currentChain = selectChainById(state, chainId) - const { chainName } = currentChain || {} - - trackEvent({ - ...event, - action: `${event.action} ${chainName}`, - label: addedSafeAddresses.length, - }) - } - // Update added Safe balances when balance polling occurs case balancesSlice.actions.set.type: { const { data } = selectSafeInfo(state) diff --git a/src/store/txQueueSlice.ts b/src/store/txQueueSlice.ts index 787122d219..8bad995094 100644 --- a/src/store/txQueueSlice.ts +++ b/src/store/txQueueSlice.ts @@ -1,11 +1,9 @@ import type { Middleware } from '@reduxjs/toolkit' import { createSelector } from '@reduxjs/toolkit' import type { TransactionListPage } from '@gnosis.pm/safe-react-gateway-sdk' -import { isEqual } from 'lodash' import type { RootState } from '@/store' import { makeLoadableSlice } from './common' import { isMultisigExecutionInfo, isTransactionListItem } from '@/utils/transaction-guards' -import { trackEvent, TX_LIST_EVENTS } from '@/services/analytics' import { PendingStatus, selectPendingTxs } from './pendingTxsSlice' import { sameAddress } from '@/utils/addresses' import { txDispatch, TxEvent } from '@/services/tx/txEvents' @@ -16,42 +14,24 @@ export const txQueueSlice = slice export const selectTxQueue = selector export const selectQueuedTransactions = createSelector(selectTxQueue, (txQueue) => { - return txQueue.data?.results.filter(isTransactionListItem) || [] + return txQueue.data?.results.filter(isTransactionListItem) }) export const selectQueuedTransactionsByNonce = createSelector( selectQueuedTransactions, (_: RootState, nonce?: number) => nonce, (queuedTransactions, nonce?: number) => { - return queuedTransactions.filter((item) => { + return (queuedTransactions || []).filter((item) => { return isMultisigExecutionInfo(item.transaction.executionInfo) && item.transaction.executionInfo.nonce === nonce }) }, ) -const trackQueueSize = (prevState: RootState, { payload }: ReturnType) => { - const txQueue = selectTxQueue(prevState) - if (isEqual(txQueue.data?.results, payload.data?.results)) { - return - } - - const transactions = payload.data?.results.filter(isTransactionListItem) || [] - - trackEvent({ - ...TX_LIST_EVENTS.QUEUED_TXS, - label: transactions.length.toString(), - }) -} - export const txQueueMiddleware: Middleware<{}, RootState> = (store) => (next) => (action) => { - const prevState = store.getState() - const result = next(action) switch (action.type) { case txQueueSlice.actions.set.type: { - trackQueueSize(prevState, action) - // Update proposed txs if signature was added successfully const state = store.getState() const pendingTxs = selectPendingTxs(state) From 35f8d34ad12df11468203386c21ea39b27aa4c5a Mon Sep 17 00:00:00 2001 From: katspaugh <381895+katspaugh@users.noreply.github.com> Date: Tue, 22 Nov 2022 11:26:49 +0100 Subject: [PATCH 08/28] Chore: PR deployment action (#1197) * Chore: PR deployment action * On pull * Fix syntax * Dev bucket * AWS_REVIEW_BUCKET_NAME * hr -> br * Change link title * Refactor: extract upload script --- .github/workflows/deploy.yml | 44 +++++++++++++++++++++-------------- .github/workflows/release.yml | 17 ++++---------- scripts/github/s3_upload.sh | 19 +++++++++++++++ 3 files changed, 49 insertions(+), 31 deletions(-) create mode 100755 scripts/github/s3_upload.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index eaf61d5458..12671e8a46 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,10 +1,12 @@ name: Deploy to dev/staging on: + pull_request: + push: branches: - - main - dev + - main jobs: deploy: @@ -46,27 +48,33 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ secrets.AWS_DEFAULT_REGION }} + # Staging - name: Deploy to the staging S3 if: github.ref == 'refs/heads/main' - run: | - cd out - - aws s3 sync . s3://${{ secrets.AWS_STAGING_BUCKET_NAME }}/current - aws s3 sync . s3://${{ secrets.AWS_STAGING_BUCKET_NAME }}/current --delete - - for file in $(find . -name '*.html' | sed 's|^\./||'); do - aws s3 cp ${file%} s3://${{ secrets.AWS_STAGING_BUCKET_NAME }}/current/${file%.*} --content-type 'text/html' - done + env: + BUCKET: s3://${{ secrets.AWS_STAGING_BUCKET_NAME }}/current + run: bash ./scripts/github/s3_upload.sh + # Dev - name: Deploy to the dev S3 if: github.ref == 'refs/heads/dev' - run: | - cd out - - aws s3 sync . s3://${{ secrets.AWS_DEVELOPMENT_BUCKET_NAME }} - aws s3 sync . s3://${{ secrets.AWS_DEVELOPMENT_BUCKET_NAME }} --delete + env: + BUCKET: s3://${{ secrets.AWS_DEVELOPMENT_BUCKET_NAME }} + run: bash ./scripts/github/s3_upload.sh - for file in $(find . -name '*.html' | sed 's|^\./||'); do - aws s3 cp ${file%} s3://${{ secrets.AWS_DEVELOPMENT_BUCKET_NAME }}/${file%.*} --content-type 'text/html' - done + # PRs + - name: Deploy PR branch + if: github.ref != 'refs/heads/dev' && github.ref != 'refs/heads/main' + env: + BUCKET: s3://${{ secrets.AWS_REVIEW_BUCKET_NAME }}/webcore/pr${{ github.event.number }} + run: bash ./scripts/github/s3_upload.sh + - name: 'Post the deployment links in the PR' + if: success() && github.ref != 'refs/heads/dev' && github.ref != 'refs/heads/main' + uses: mshick/add-pr-comment@v1 + with: + message: | + ## Branch preview + https://pr${{ github.event.number }}--webcore.review-react-br.5afe.dev + repo-token: ${{ secrets.GITHUB_TOKEN }} + repo-token-user-login: 'github-actions[bot]' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7adb12fa5..6dd00c161b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,20 +45,11 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ secrets.AWS_DEFAULT_REGION }} - # Script to upload release files. - # First it uploads new files, so both the previous version and the new one co-exist and there’s no downtime. - # Then it uploads a second time, with --delete, to delete the old files. - # Finally, it uploads HTML files w/o the `.html` extension so that URLs like `/balances` work. + # Script to upload release files - name: 'Upload release build files for production' - run: | - cd out - - aws s3 sync . s3://${{ secrets.AWS_STAGING_BUCKET_NAME }}/releases/${{ github.event.release.tag_name }} - aws s3 sync . s3://${{ secrets.AWS_STAGING_BUCKET_NAME }}/releases/${{ github.event.release.tag_name }} --delete - - for file in $(find . -name '*.html' | sed 's|^\./||'); do - aws s3 cp ${file%} s3://${{ secrets.AWS_STAGING_BUCKET_NAME }}/releases/${{ github.event.release.tag_name }}/${file%.*} --content-type 'text/html' - done + env: + BUCKET: s3://${{ secrets.AWS_STAGING_BUCKET_NAME }}/releases/${{ github.event.release.tag_name }} + run: bash ./scripts/github/s3_upload.sh # Script to prepare production deployments - run: bash ./scripts/github/prepare_production_deployment.sh diff --git a/scripts/github/s3_upload.sh b/scripts/github/s3_upload.sh new file mode 100755 index 0000000000..be34c9f500 --- /dev/null +++ b/scripts/github/s3_upload.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +set -ev + +cd out + +# First, upload the new files w/o deleting the old ones +aws s3 sync . $BUCKET + +# Second, upload them again but delete the old files this time +# This allows for a no-downtime deployment +aws s3 sync . $BUCKET --delete + +# Finally, upload all HTML files again but w/o an extention so that URLs like /welcome open the right page +for file in $(find . -name '*.html' | sed 's|^\./||'); do + aws s3 cp ${file%} $BUCKET/${file%.*} --content-type 'text/html' +done + +cd - From 19b5e8fdeeae4842e46c4726d8ee7462fdfe2ea9 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Tue, 22 Nov 2022 11:48:41 +0100 Subject: [PATCH 09/28] improve reliability of create_tx cypress test (#1196) --- cypress/e2e/smoke/create_tx.cy.js | 134 +++++++++++++++--------------- 1 file changed, 68 insertions(+), 66 deletions(-) diff --git a/cypress/e2e/smoke/create_tx.cy.js b/cypress/e2e/smoke/create_tx.cy.js index f310a4e4d8..fec94dd206 100644 --- a/cypress/e2e/smoke/create_tx.cy.js +++ b/cypress/e2e/smoke/create_tx.cy.js @@ -44,79 +44,81 @@ describe('Queue a transaction on 1/N', () => { cy.contains('h2', 'Review transaction').parents('div').as('modal') // Wait for /estimations response - cy.intercept('POST', '/**/multisig-transactions/estimations').then(() => { - // Estimation is loaded - cy.get('button[type="submit"]').should('not.be.disabled') - - // Gets the recommended nonce - cy.contains('Signing the transaction with nonce').should(($div) => { - // get the number in the string - recommendedNonce = $div.text().match(/\d+$/)[0] - }) - - // Changes nonce to next one - cy.contains('Signing the transaction with nonce').click() - cy.contains('button', 'Edit').click() - cy.get('label').contains('Safe transaction nonce').next().clear().type('3') - cy.contains('Confirm').click() - - // Asserts the execute checkbox exists - cy.get('@modal').within(() => { - cy.get('input[type="checkbox"]') - .parent('span') - .should(($div) => { - // Turn the classList into a string - const classListString = Array.from($div[0].classList).join() - // Check if it contains the error class - expect(classListString).to.include('checked') - }) - }) - cy.contains('Estimated fee').should('exist') - - // Asserts the execute checkbox is uncheckable - cy.contains('Execute transaction').click() - cy.get('@modal').within(() => { - cy.get('input[type="checkbox"]') - .parent('span') - .should(($div) => { - // Turn the classList into a string - const classListString = Array.from($div[0].classList).join() - // Check if it contains the error class - expect(classListString).not.to.include('checked') - }) - }) - cy.contains('Signing the transaction with nonce').should('exist') - - // Changes back to recommended nonce - cy.contains('Signing the transaction with nonce').click() - cy.contains('Edit').click() - cy.get('button[aria-label="Reset to recommended nonce"]').click() - - // Accepts the values - cy.contains('Confirm').click() - - cy.get('@modal').within(() => { - cy.get('input[type="checkbox"]').should('not.exist') - }) - - cy.contains('Submit').click() + cy.intercept('POST', '/**/multisig-transactions/estimations').as('EstimationRequest') + cy.wait('@EstimationRequest') + + // Estimation is loaded + cy.get('button[type="submit"]').should('not.be.disabled') + + // Gets the recommended nonce + cy.contains('Signing the transaction with nonce').should(($div) => { + // get the number in the string + recommendedNonce = $div.text().match(/\d+$/)[0] + }) + + // Changes nonce to next one + cy.contains('Signing the transaction with nonce').click() + cy.contains('button', 'Edit').click() + cy.get('label').contains('Safe transaction nonce').next().clear().type('3') + cy.contains('Confirm').click() + + // Asserts the execute checkbox exists + cy.get('@modal').within(() => { + cy.get('input[type="checkbox"]') + .parent('span') + .should(($div) => { + // Turn the classList into a string + const classListString = Array.from($div[0].classList).join() + // Check if it contains the error class + expect(classListString).to.include('checked') + }) + }) + cy.contains('Estimated fee').should('exist') + + // Asserts the execute checkbox is uncheckable + cy.contains('Execute transaction').click() + cy.get('@modal').within(() => { + cy.get('input[type="checkbox"]') + .parent('span') + .should(($div) => { + // Turn the classList into a string + const classListString = Array.from($div[0].classList).join() + // Check if it contains the error class + expect(classListString).not.to.include('checked') + }) + }) + cy.contains('Signing the transaction with nonce').should('exist') + + // Changes back to recommended nonce + cy.contains('Signing the transaction with nonce').click() + cy.contains('Edit').click() + cy.get('button[aria-label="Reset to recommended nonce"]').click() + + // Accepts the values + cy.contains('Confirm').click() + + cy.get('@modal').within(() => { + cy.get('input[type="checkbox"]').should('not.exist') }) + + cy.contains('Submit').click() }) it('should click the notification and see the transaction queued', () => { // Wait for the /propose request - cy.intercept('POST', '/**/propose').then(() => { - // Click on the notification - cy.contains('View transaction').click() + cy.intercept('POST', '/**/propose').as('ProposeTx') + cy.wait('@ProposeTx') - // Single Tx page - cy.contains('h3', 'Transaction details').should('be.visible') + // Click on the notification + cy.contains('View transaction').click() - // Queue label - cy.contains('Queued - transaction with nonce 3 needs to be executed first').should('be.visible') + // Single Tx page + cy.contains('h3', 'Transaction details').should('be.visible') - // Transaction summary - cy.contains(`${recommendedNonce}` + 'Send' + '-' + `${sendValue} GOR`).should('exist') - }) + // Queue label + cy.contains('Queued - transaction with nonce 3 needs to be executed first').should('be.visible') + + // Transaction summary + cy.contains(`${recommendedNonce}` + 'Send' + '-' + `${sendValue} GOR`).should('exist') }) }) From 03882c597685b1edf55a7937f6a07bdec7026edc Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 22 Nov 2022 14:08:47 +0100 Subject: [PATCH 10/28] fix: redirect to `/home` of Safe present in URL (#1183) --- src/pages/index.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 561a8d2080..2a52c4c6fa 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -6,18 +6,19 @@ import { AppRoutes } from '@/config/routes' const IndexPage: NextPage = () => { const router = useRouter() - const { chain } = router.query + const { safe, chain } = router.query const lastSafe = useLastSafe() + const safeAddress = safe || lastSafe useLayoutEffect(() => { router.replace( - lastSafe - ? `${AppRoutes.home}?safe=${lastSafe}` + safeAddress + ? `${AppRoutes.home}?safe=${safeAddress}` : chain ? `${AppRoutes.welcome}?chain=${chain}` : AppRoutes.welcome, ) - }, [router, lastSafe, chain]) + }, [router, safeAddress, chain]) return <> } From 0e36eb269b9a1c72d6301ebdae868ff5bbd4fda0 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 22 Nov 2022 14:37:34 +0100 Subject: [PATCH 11/28] refactor: simplify GTM logic + unmount on permission removal (#1047) * feat: add `TagManager.destroy` function * refactor: GTM * fix: add dev log * Merge branch 'dev' into refactor-gtm * fix: extract constant * fix: remove all GTM scripts * fix: disable GTM/GA programmatically * fix: remove cookies * fix: adjust tests * fix: reload instead of using flag * fix: tests --- src/services/analytics/TagManager.ts | 125 ++++++------- .../analytics/__tests__/TagManager.test.ts | 174 ++++++++++++------ src/services/analytics/gtm.ts | 28 +-- 3 files changed, 185 insertions(+), 142 deletions(-) diff --git a/src/services/analytics/TagManager.ts b/src/services/analytics/TagManager.ts index 5c19dbf11f..bbe796dee6 100644 --- a/src/services/analytics/TagManager.ts +++ b/src/services/analytics/TagManager.ts @@ -1,94 +1,89 @@ -// Based on https://github.com/alinemorelli/react-gtm +import Cookies from 'js-cookie' + +import { IS_PRODUCTION } from '@/config/constants' type DataLayer = Record export type TagManagerArgs = { - /** - * GTM id, must be something like GTM-000000. - */ + // GTM id, e.g. GTM-000000 gtmId: string - /** - * Used to set environments. - */ - auth?: string | undefined - /** - * Used to set environments, something like env-00. - */ - preview?: string | undefined - /** - * Object that contains all of the information that you want to pass to Google Tag Manager. - */ - dataLayer?: DataLayer | undefined -} - -export const DATA_LAYER_NAME = 'dataLayer' - -export const _getRequiredGtmArgs = ({ gtmId, dataLayer = undefined, auth = '', preview = '' }: TagManagerArgs) => { - return { - gtmId, - dataLayer, - auth: auth ? `>m_auth=${auth}` : '', - preview: preview ? `>m_preview=${preview}` : '', - } + // GTM authetication key + auth: string + // GTM environment, e.g. env-00. + preview: string + // Object that contains all of the information that you want to pass to GTM + dataLayer?: DataLayer } -// Initialization scripts - -export const _getGtmScript = (args: TagManagerArgs) => { - const { gtmId, auth, preview } = _getRequiredGtmArgs(args) +const DATA_LAYER_NAME = 'dataLayer' - const script = document.createElement('script') +const TagManager = { + // `jest.spyOn` is not possible if outside of `TagManager` + _getScript: ({ gtmId, auth, preview }: TagManagerArgs) => { + const script = document.createElement('script') - const gtmScript = ` + const gtmScript = ` (function (w, d, s, l, i) { w[l] = w[l] || []; w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' }); var f = d.getElementsByTagName(s)[0], - j = d.createElement(s), + j = d.createElement(s), dl = l != 'dataLayer' ? '&l=' + l : ''; - j.async = true; - j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl + '${auth}${preview}>m_cookies_win=x'; - f.parentNode.insertBefore(j, f); - })(window, document, 'script', '${DATA_LAYER_NAME}', '${gtmId}');` + j.async = true; + j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl + '>m_auth=${auth}>m_preview=${preview}>m_cookies_win=x'; + f.parentNode.insertBefore(j, f); + })(window, document, 'script', '${DATA_LAYER_NAME}', '${gtmId}');` - script.innerHTML = gtmScript + script.innerHTML = gtmScript - return script -} - -// Data layer scripts + return script + }, + isInitialized: () => { + const GTM_SCRIPT = 'https://www.googletagmanager.com/gtm.js' -export const _getGtmDataLayerScript = (dataLayer: DataLayer) => { - const script = document.createElement('script') + return !!document.querySelector(`[src^="${GTM_SCRIPT}"]`) + }, + initialize: (args: TagManagerArgs) => { + if (TagManager.isInitialized()) { + return + } - const gtmDataLayerScript = ` - window.${DATA_LAYER_NAME} = window.${DATA_LAYER_NAME} || []; - window.${DATA_LAYER_NAME}.push(${JSON.stringify(dataLayer)})` + // Initialize dataLayer (with configuration) + window[DATA_LAYER_NAME] = args.dataLayer ? [args.dataLayer] : [] - script.innerHTML = gtmDataLayerScript + const script = TagManager._getScript(args) - return script -} + // Initialize GTM. This pushes the default dataLayer event: + // { "gtm.start": new Date().getTime(), event: "gtm.js" } + document.head.insertBefore(script, document.head.childNodes[0]) + }, + dataLayer: (dataLayer: DataLayer) => { + if (!TagManager.isInitialized()) { + return + } -const TagManager = { - initialize: (args: TagManagerArgs) => { - const { dataLayer } = _getRequiredGtmArgs(args) + window[DATA_LAYER_NAME].push(dataLayer) - if (dataLayer) { - const gtmDataLayerScript = _getGtmDataLayerScript(dataLayer) - document.head.appendChild(gtmDataLayerScript) + if (!IS_PRODUCTION) { + console.info('[GTM] -', dataLayer) } - - const gtmScript = _getGtmScript(args) - document.head.insertBefore(gtmScript, document.head.childNodes[0]) }, - dataLayer: (dataLayer: DataLayer) => { - if (window[DATA_LAYER_NAME]) { - return window[DATA_LAYER_NAME].push(dataLayer) + disable: () => { + if (!TagManager.isInitialized()) { + return } - const gtmDataLayerScript = _getGtmDataLayerScript(dataLayer) - document.head.insertBefore(gtmDataLayerScript, document.head.childNodes[0]) + const GTM_COOKIE_LIST = ['_ga', '_gat', '_gid'] + + GTM_COOKIE_LIST.forEach((cookie) => { + Cookies.remove(cookie, { + path: '/', + domain: `.${location.host.split('.').slice(-2).join('.')}`, + }) + }) + + // Injected script will remain in memory until new session + location.reload() }, } diff --git a/src/services/analytics/__tests__/TagManager.test.ts b/src/services/analytics/__tests__/TagManager.test.ts index b112fa1da0..7ff218a23c 100644 --- a/src/services/analytics/__tests__/TagManager.test.ts +++ b/src/services/analytics/__tests__/TagManager.test.ts @@ -1,90 +1,160 @@ -import TagManager, { _getGtmDataLayerScript, _getGtmScript, _getRequiredGtmArgs } from '../TagManager' +import Cookies from 'js-cookie' -const MOCK_ID = 'GTM-123456' +import * as gtm from '../TagManager' -describe('TagManager', () => { - beforeEach(() => { - delete window.dataLayer - }) +const { default: TagManager } = gtm - describe('getRequiredGtmArgs', () => { - it('should assign default arguments', () => { - const result1 = _getRequiredGtmArgs({ gtmId: MOCK_ID }) +const MOCK_ID = 'GTM-123456' +const MOCK_AUTH = 'key123' +const MOCK_PREVIEW = 'env-0' - expect(result1).toStrictEqual({ - gtmId: MOCK_ID, - dataLayer: undefined, - auth: '', - preview: '', - }) +jest.mock('js-cookie', () => ({ + remove: jest.fn(), +})) - const result2 = _getRequiredGtmArgs({ gtmId: MOCK_ID, auth: 'abcdefg', preview: 'env-1' }) +describe('TagManager', () => { + const originalLocation = window.location + + // Mock `location.reload` + beforeAll(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...originalLocation, + reload: jest.fn(), + }, + }) + }) - expect(result2).toStrictEqual({ - gtmId: MOCK_ID, - dataLayer: undefined, - auth: '>m_auth=abcdefg', - preview: '>m_preview=env-1', - }) + // Remove mock + afterAll(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, }) }) - describe('getGtmScript', () => { - it('should use the id', () => { - const script1 = _getGtmScript({ gtmId: MOCK_ID }) + // Clear GTM between tests + afterEach(() => { + document.head.innerHTML = '' + delete window.dataLayer + }) + + describe('TagManager._getScript', () => { + it('should use the id, auth and preview', () => { + const script1 = TagManager._getScript({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) expect(script1.innerHTML).toContain(MOCK_ID) + expect(script1.innerHTML).toContain(`>m_auth=${MOCK_AUTH}`) + expect(script1.innerHTML).toContain(`>m_preview=${MOCK_PREVIEW}`) expect(script1.innerHTML).toContain('dataLayer') }) + }) - it('should use the auth and preview if present', () => { - const script1 = _getGtmScript({ - gtmId: MOCK_ID, - }) - - expect(script1.innerHTML).not.toContain('>m_auth') - expect(script1.innerHTML).not.toContain('>m_preview') - - const script2 = _getGtmScript({ - gtmId: MOCK_ID, - auth: 'abcdefg', - preview: 'env-1', - }) - - expect(script2.innerHTML).toContain('>m_auth=abcdefg>m_preview=env-1') + describe('TagManager.isInitialized', () => { + it('should return false if no script is found', () => { + expect(TagManager.isInitialized()).toBe(false) }) - }) - describe('getGtmDataLayerScript', () => { - it('should use the `dataLayer` for the script', () => { - const dataLayerScript = _getGtmDataLayerScript({ - gtmId: MOCK_ID, - dataLayer: { foo: 'bar' }, - }) + it('should return true if a script is found', () => { + TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) - expect(dataLayerScript.innerHTML).toContain('{"foo":"bar"}') + expect(TagManager.isInitialized()).toBe(true) }) }) describe('TagManager.initialize', () => { it('should initialize TagManager', () => { - TagManager.initialize({ gtmId: MOCK_ID }) + TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) + + expect(document.head.childNodes).toHaveLength(2) + + // Script added by `TagManager._getScript` + // @ts-expect-error + expect(document.head.childNodes[0].src).toBe( + `https://www.googletagmanager.com/gtm.js?id=${MOCK_ID}>m_auth=${MOCK_AUTH}>m_preview=${MOCK_PREVIEW}>m_cookies_win=x`, + ) + + // Manually added script + expect(document.head.childNodes[1]).toStrictEqual( + TagManager._getScript({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }), + ) expect(window.dataLayer).toHaveLength(1) expect(window.dataLayer[0]).toStrictEqual({ event: 'gtm.js', 'gtm.start': expect.any(Number) }) }) + + it('should not re-initialize the scripts if previously enabled', async () => { + const getScriptSpy = jest.spyOn(gtm.default, '_getScript') + + TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) + TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) + + expect(getScriptSpy).toHaveBeenCalledTimes(1) + }) + + it('should push to the dataLayer if povided', () => { + TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW, dataLayer: { test: '456' } }) + + expect(window.dataLayer).toHaveLength(2) + expect(window.dataLayer[0]).toStrictEqual({ test: '456' }) + expect(window.dataLayer[1]).toStrictEqual({ event: 'gtm.js', 'gtm.start': expect.any(Number) }) + }) }) describe('TagManager.dataLayer', () => { + it('should not push to the dataLayer if not initialized', () => { + TagManager.dataLayer({ test: '456' }) + + expect(window.dataLayer).toBeUndefined() + }) + it('should push data to the dataLayer', () => { - TagManager.dataLayer({ - test: '123', + expect(window.dataLayer).toBeUndefined() + + TagManager.initialize({ + gtmId: MOCK_ID, + auth: MOCK_AUTH, + preview: MOCK_PREVIEW, }) expect(window.dataLayer).toHaveLength(1) - expect(window.dataLayer[0]).toStrictEqual({ + expect(window.dataLayer[0]).toStrictEqual({ event: 'gtm.js', 'gtm.start': expect.any(Number) }) + + TagManager.dataLayer({ test: '123', }) + + expect(window.dataLayer).toHaveLength(2) + expect(window.dataLayer[1]).toStrictEqual({ test: '123' }) + }) + }) + + describe('TagManager.disable', () => { + it('should not remove GA cookies and reload if not mounted', () => { + TagManager.disable() + + expect(Cookies.remove).not.toHaveBeenCalled() + + expect(global.location.reload).not.toHaveBeenCalled() + }) + it('should remove GA cookies and reload if mounted', () => { + TagManager.initialize({ + gtmId: MOCK_ID, + auth: MOCK_AUTH, + preview: MOCK_PREVIEW, + }) + + TagManager.disable() + + const path = '/' + const domain = '.localhost' + + expect(Cookies.remove).toHaveBeenCalledWith('_ga', { path, domain }) + expect(Cookies.remove).toHaveBeenCalledWith('_gat', { path, domain }) + expect(Cookies.remove).toHaveBeenCalledWith('_gid', { path, domain }) + + expect(global.location.reload).toHaveBeenCalled() }) }) }) diff --git a/src/services/analytics/gtm.ts b/src/services/analytics/gtm.ts index aea2d4b122..93cbdde7a9 100644 --- a/src/services/analytics/gtm.ts +++ b/src/services/analytics/gtm.ts @@ -8,8 +8,7 @@ */ import type { TagManagerArgs } from './TagManager' -import TagManager, { DATA_LAYER_NAME } from './TagManager' -import Cookies from 'js-cookie' +import TagManager from './TagManager' import { IS_PRODUCTION, GOOGLE_TAG_MANAGER_ID, @@ -24,8 +23,6 @@ import { SAFE_APPS_SDK_CATEGORY } from './events' type GTMEnvironment = 'LIVE' | 'LATEST' | 'DEVELOPMENT' type GTMEnvironmentArgs = Required> -const GOOGLE_ANALYTICS_COOKIE_LIST = ['_ga', '_gat', '_gid'] - const GTM_ENV_AUTH: Record = { LIVE: { auth: GOOGLE_TAG_MANAGER_AUTH_LIVE, @@ -68,20 +65,7 @@ export const gtmInit = (pagePath: string): void => { }) } -const isGtmLoaded = (): boolean => { - return typeof window !== 'undefined' && !!window[DATA_LAYER_NAME] -} - -export const gtmClear = (): void => { - if (!isGtmLoaded()) return - - // Delete GA cookies - const path = '/' - const domain = `.${location.host.split('.').slice(-2).join('.')}` - GOOGLE_ANALYTICS_COOKIE_LIST.forEach((cookie) => { - Cookies.remove(cookie, { path, domain }) - }) -} +export const gtmClear = TagManager.disable type GtmEvent = { event: EventType @@ -106,13 +90,7 @@ type SafeAppGtmEvent = ActionGtmEvent & { safeAppSDKVersion?: string } -const gtmSend = (event: GtmEvent): void => { - console.info('[Analytics]', event) - - if (!isGtmLoaded()) return - - TagManager.dataLayer(event) -} +const gtmSend = TagManager.dataLayer export const gtmTrack = (eventData: AnalyticsEvent): void => { const gtmEvent: ActionGtmEvent = { From 35a95264edb7d3dbab229d827a49bf106aed36d9 Mon Sep 17 00:00:00 2001 From: Dani Somoza Date: Wed, 23 Nov 2022 12:48:50 +0100 Subject: [PATCH 12/28] feat: Collapsable sidebar in Safe App view (#1169) * Added collapsable sidebar in Safe App view * show sidebar only in desktop resolution and added some hover animations * added extra timeout to EstimationRequest --- cypress/e2e/smoke/create_tx.cy.js | 4 +- .../common/PageLayout/SideDrawer.tsx | 64 +++++++++++++++++++ src/components/common/PageLayout/index.tsx | 35 +++------- .../common/PageLayout/styles.module.css | 40 +++++++++--- .../TransactionQueueBar/styles.module.css | 2 +- .../sidebar/Sidebar/styles.module.css | 4 +- 6 files changed, 111 insertions(+), 38 deletions(-) create mode 100644 src/components/common/PageLayout/SideDrawer.tsx diff --git a/cypress/e2e/smoke/create_tx.cy.js b/cypress/e2e/smoke/create_tx.cy.js index fec94dd206..05df615f5b 100644 --- a/cypress/e2e/smoke/create_tx.cy.js +++ b/cypress/e2e/smoke/create_tx.cy.js @@ -45,7 +45,9 @@ describe('Queue a transaction on 1/N', () => { // Wait for /estimations response cy.intercept('POST', '/**/multisig-transactions/estimations').as('EstimationRequest') - cy.wait('@EstimationRequest') + cy.wait('@EstimationRequest', { + timeout: 30_000, // EstimationRequest takes a while initialize in CI + }) // Estimation is loaded cy.get('button[type="submit"]').should('not.be.disabled') diff --git a/src/components/common/PageLayout/SideDrawer.tsx b/src/components/common/PageLayout/SideDrawer.tsx new file mode 100644 index 0000000000..a6e0272efa --- /dev/null +++ b/src/components/common/PageLayout/SideDrawer.tsx @@ -0,0 +1,64 @@ +import { useEffect, type ReactElement } from 'react' +import { IconButton, Drawer, useMediaQuery } from '@mui/material' +import { useTheme } from '@mui/material/styles' +import DoubleArrowRightIcon from '@mui/icons-material/KeyboardDoubleArrowRightRounded' +import DoubleArrowLeftIcon from '@mui/icons-material/KeyboardDoubleArrowLeftRounded' +import { useRouter } from 'next/router' +import classnames from 'classnames' +import { type ParsedUrlQuery } from 'querystring' + +import Sidebar from '@/components/sidebar/Sidebar' +import css from './styles.module.css' +import { AppRoutes } from '@/config/routes' + +type SideDrawerProps = { + isOpen: boolean + onToggle: (isOpen: boolean) => void +} + +const isAppShareRoute = (pathname: string): boolean => { + return pathname === AppRoutes.share.safeApp +} + +const isSafeAppRoute = (pathname: string, query: ParsedUrlQuery): boolean => { + return pathname === AppRoutes.apps && !!query.appUrl +} + +const SideDrawer = ({ isOpen, onToggle }: SideDrawerProps): ReactElement => { + const { pathname, query } = useRouter() + const { breakpoints } = useTheme() + const isSmallScreen = useMediaQuery(`(max-width: ${breakpoints.values.md}px)`) + const showSidebarToggle = isSafeAppRoute(pathname, query) && !isSmallScreen + + useEffect(() => { + const closeSidebar = isSmallScreen || isSafeAppRoute(pathname, query) || isAppShareRoute(pathname) + onToggle(!closeSidebar) + }, [isSmallScreen, onToggle, pathname, query]) + + return ( + <> + onToggle(false)} + > + + + + {showSidebarToggle && ( +
+
onToggle(!isOpen)}> + + {isOpen ? : } + +
+
+ )} + + ) +} + +export default SideDrawer diff --git a/src/components/common/PageLayout/index.tsx b/src/components/common/PageLayout/index.tsx index 4f8e2613d5..d5f3f42fe3 100644 --- a/src/components/common/PageLayout/index.tsx +++ b/src/components/common/PageLayout/index.tsx @@ -1,45 +1,28 @@ -import { useEffect, useState, type ReactElement } from 'react' -import cn from 'classnames' -import { Drawer } from '@mui/material' -import { useRouter } from 'next/router' +import { useState, type ReactElement } from 'react' +import classnames from 'classnames' -import Sidebar from '@/components/sidebar/Sidebar' import Header from '@/components/common//Header' import css from './styles.module.css' import SafeLoadingError from '../SafeLoadingError' import Footer from '../Footer' -import { AppRoutes } from '@/config/routes' +import SideDrawer from './SideDrawer' const PageLayout = ({ children }: { children: ReactElement }): ReactElement => { - const router = useRouter() - const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false) - const hideSidebar = router.pathname === AppRoutes.share.safeApp + const [isSidebarOpen, setSidebarOpen] = useState(false) - const onMenuToggle = (): void => { - setIsMobileDrawerOpen((prev) => !prev) + const toggleSidebar = () => { + setSidebarOpen((prev) => !prev) } - const sidebar = - - useEffect(() => { - setIsMobileDrawerOpen(false) - }, [router.pathname, router.query.safe]) - return ( <>
-
+
- {/* Desktop sidebar */} - {!hideSidebar && } - - {/* Mobile sidebar */} - - {sidebar} - + -
+
{children}
diff --git a/src/components/common/PageLayout/styles.module.css b/src/components/common/PageLayout/styles.module.css index d8b3367541..030a6f910a 100644 --- a/src/components/common/PageLayout/styles.module.css +++ b/src/components/common/PageLayout/styles.module.css @@ -13,6 +13,7 @@ min-height: 100vh; display: flex; flex-direction: column; + transition: padding 225ms cubic-bezier(0, 0, 0.2, 1) 0ms; } .mainNoSidebar { @@ -31,24 +32,47 @@ padding: var(--space-3); } -.sidebar { +.sidebarTogglePosition { position: fixed; - height: 100vh; + z-index: 2; left: 0; top: 0; - /* the z-index is required for the sidebar not to be covered by the transaction accordion on the Safe app page */ + /* mimics MUI drawer animation */ + transition: transform 225ms cubic-bezier(0, 0, 0.2, 1) 0ms; +} + +.sidebarTogglePosition.sidebarOpen { + transform: translateX(230px); +} + +.sidebarToggle { + height: 100vh; + width: var(--space-1); + background-color: var(--color-border-light); + transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + cursor: pointer; +} + +.sidebarToggle button { + position: absolute; z-index: 1; - padding-top: var(--header-height); + top: 50%; + left: -3px; + transform: translateY(-50%); + background-color: var(--color-border-light); + clip-path: inset(0 -14px 0 0); + transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; +} + +.sidebarToggle:hover, +.sidebarToggle:hover button { + background-color: var(--color-background-light); } @media (max-width: 900px) { .main { padding-left: 0; } - - .sidebar { - display: none; - } } @media (max-width: 600px) { diff --git a/src/components/safe-apps/AppFrame/TransactionQueueBar/styles.module.css b/src/components/safe-apps/AppFrame/TransactionQueueBar/styles.module.css index b213384cfd..64b6840879 100644 --- a/src/components/safe-apps/AppFrame/TransactionQueueBar/styles.module.css +++ b/src/components/safe-apps/AppFrame/TransactionQueueBar/styles.module.css @@ -3,7 +3,7 @@ bottom: 0; right: 0; width: 100%; - z-index: 1; + z-index: var(--onboard-modal-z-index, var(--modal-z-index)); /*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/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 { From 9cf5c6a48de973a57d611129c9d503863a0f28e7 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 23 Nov 2022 13:49:08 +0100 Subject: [PATCH 13/28] fix: flag Keystone wallets as hardware wallets + adjust modal `z-index` (#1194) * fix: flag Keystone wallets as hardware wallets * fix: adjust `z-index` of modals * fix: adjust `z-index` of onboard * fix: increase Keystone modal by `1` * fix: revert `z-index` * fix: increase `z-index` value --- src/hooks/wallets/wallets.ts | 4 +++- src/styles/onboard.css | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/hooks/wallets/wallets.ts b/src/hooks/wallets/wallets.ts index c41a6470a8..bf03e5398a 100644 --- a/src/hooks/wallets/wallets.ts +++ b/src/hooks/wallets/wallets.ts @@ -92,7 +92,9 @@ export const getSupportedWallets = (chain: ChainInfo): WalletInit[] => { } export const isHardwareWallet = (wallet: ConnectedWallet): boolean => { - return [WALLET_KEYS.LEDGER, WALLET_KEYS.TREZOR].includes(wallet.label.toUpperCase() as WALLET_KEYS) + return [WALLET_KEYS.LEDGER, WALLET_KEYS.TREZOR, WALLET_KEYS.KEYSTONE].includes( + wallet.label.toUpperCase() as WALLET_KEYS, + ) } export const isWalletConnect = (wallet: ConnectedWallet): boolean => { diff --git a/src/styles/onboard.css b/src/styles/onboard.css index d72bcb4f46..f26882f2b7 100644 --- a/src/styles/onboard.css +++ b/src/styles/onboard.css @@ -86,5 +86,5 @@ /* Keystone modal */ #kv_sdk_container + .ReactModalPortal > div { - z-index: var(--onboard-modal-z-index) !important; + z-index: 1301 !important; } From 343edd46ac0b3837ab185ab51908ff18d2920f0a Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 23 Nov 2022 14:10:07 +0100 Subject: [PATCH 14/28] fix: show warning based on `trusted` flag (#1226) * fix: show warning based on flag * fix: flag * fix: revert wording * fix: flag * fix: update lock file --- package.json | 2 +- src/components/transactions/TxDetails/index.tsx | 6 +++++- yarn.lock | 8 ++++---- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 73439012b2..c64326ffc1 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@gnosis.pm/safe-deployments": "^1.15.0", "@gnosis.pm/safe-ethers-lib": "^1.6.1", "@gnosis.pm/safe-modules-deployments": "^1.0.0", - "@gnosis.pm/safe-react-gateway-sdk": "^3.4.5", + "@gnosis.pm/safe-react-gateway-sdk": "^3.4.6", "@mui/icons-material": "^5.8.4", "@mui/material": "^5.9.3", "@mui/x-date-pickers": "^5.0.0-beta.6", diff --git a/src/components/transactions/TxDetails/index.tsx b/src/components/transactions/TxDetails/index.tsx index 3c16b6fffa..13ef550f96 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, @@ -45,6 +46,9 @@ const TxDetailsBlock = ({ txSummary, txDetails }: TxDetailsProps): ReactElement const awaitingExecution = isAwaitingExecution(txSummary.txStatus) const isUnsigned = isMultisigExecutionInfo(txSummary.executionInfo) && txSummary.executionInfo.confirmationsSubmitted === 0 + const isUntrusted = + isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) && + txDetails.detailedExecutionInfo.trusted === false return ( <> @@ -75,7 +79,7 @@ const TxDetailsBlock = ({ txSummary, txDetails }: TxDetailsProps): ReactElement )}
- {isUnsigned && } + {isUntrusted && } {txDetails.txData?.operation === Operation.DELEGATE && (
diff --git a/yarn.lock b/yarn.lock index 67bc2ae199..a98e6d8fb6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2470,10 +2470,10 @@ dependencies: cross-fetch "^3.1.5" -"@gnosis.pm/safe-react-gateway-sdk@^3.4.5": - version "3.4.5" - resolved "https://registry.yarnpkg.com/@gnosis.pm/safe-react-gateway-sdk/-/safe-react-gateway-sdk-3.4.5.tgz#bd59ca973721991f54a2df6b05baeb884434208f" - integrity sha512-b8ZaYaQ1apS/4R9sEEUCtrg0AwTf8tfqS0Jp6JAopLxHdX0NItCvs5/Y1+rjDodv2l+I/Zf9vJJyUDC0XABfMA== +"@gnosis.pm/safe-react-gateway-sdk@^3.4.6": + version "3.4.6" + resolved "https://registry.yarnpkg.com/@gnosis.pm/safe-react-gateway-sdk/-/safe-react-gateway-sdk-3.4.6.tgz#14ae3cd6557eb2a3d0f165228b95f16d24aebfa8" + integrity sha512-9JeALF5j4cGF8a9NBZTJP2owGruG7w3bw8O6xR8JY2ZyJw4l5f0MHbPU/7rGPHwN33Q9W5c5u1tkXHiHkjkReQ== dependencies: cross-fetch "^3.1.5" From 9c07ab8c3839f7dc57fc4a0923bcdc28f0bcf094 Mon Sep 17 00:00:00 2001 From: Dani Somoza Date: Wed, 23 Nov 2022 15:32:04 +0100 Subject: [PATCH 15/28] Chore: updated sidebar to open by default (#1228) * updated sidebar to be opened by default * moved create_tx.cy.js test file out of the smoke folder --- cypress/e2e/{smoke => }/create_tx.cy.js | 21 ++++++++++++--------- src/components/common/PageLayout/index.tsx | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) rename cypress/e2e/{smoke => }/create_tx.cy.js (96%) diff --git a/cypress/e2e/smoke/create_tx.cy.js b/cypress/e2e/create_tx.cy.js similarity index 96% rename from cypress/e2e/smoke/create_tx.cy.js rename to cypress/e2e/create_tx.cy.js index 05df615f5b..ab099b0c7d 100644 --- a/cypress/e2e/smoke/create_tx.cy.js +++ b/cypress/e2e/create_tx.cy.js @@ -35,20 +35,21 @@ describe('Queue a transaction on 1/N', () => { // Insert amount cy.get('input[name="amount"]').type(`${sendValue}`) - - cy.contains('Next').click() }) it('should create a queued transaction', () => { - // Alias for New transaction modal - cy.contains('h2', 'Review transaction').parents('div').as('modal') - // Wait for /estimations response cy.intercept('POST', '/**/multisig-transactions/estimations').as('EstimationRequest') + + cy.contains('Next').click() + cy.wait('@EstimationRequest', { - timeout: 30_000, // EstimationRequest takes a while initialize in CI + timeout: 30_000, // EstimationRequest takes a while in CI }) + // Alias for New transaction modal + cy.contains('h2', 'Review transaction').parents('div').as('modal') + // Estimation is loaded cy.get('button[type="submit"]').should('not.be.disabled') @@ -102,14 +103,16 @@ describe('Queue a transaction on 1/N', () => { cy.get('@modal').within(() => { cy.get('input[type="checkbox"]').should('not.exist') }) - - cy.contains('Submit').click() }) it('should click the notification and see the transaction queued', () => { + cy.contains('Submit').click() + // Wait for the /propose request cy.intercept('POST', '/**/propose').as('ProposeTx') - cy.wait('@ProposeTx') + cy.wait('@ProposeTx', { + timeout: 30_000, // ProposeTx takes a while in CI + }) // Click on the notification cy.contains('View transaction').click() diff --git a/src/components/common/PageLayout/index.tsx b/src/components/common/PageLayout/index.tsx index d5f3f42fe3..31d07bc016 100644 --- a/src/components/common/PageLayout/index.tsx +++ b/src/components/common/PageLayout/index.tsx @@ -8,7 +8,7 @@ import Footer from '../Footer' import SideDrawer from './SideDrawer' const PageLayout = ({ children }: { children: ReactElement }): ReactElement => { - const [isSidebarOpen, setSidebarOpen] = useState(false) + const [isSidebarOpen, setSidebarOpen] = useState(true) const toggleSidebar = () => { setSidebarOpen((prev) => !prev) From 0179201186348981cd16e8f3abe3070ffc2a06f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20P=C3=A9rez=20V=C3=A1zquez?= Date: Wed, 23 Nov 2022 15:41:47 +0100 Subject: [PATCH 16/28] feat(safe-apps): Add support for receiving analytics from our Safe Apps (#1066) --- src/components/safe-apps/AppFrame/index.tsx | 2 + .../safe-apps/AppFrame/useFromAppAnalytics.ts | 52 +++++++++++++++++++ src/services/analytics/events/safeApps.ts | 1 + 3 files changed, 55 insertions(+) create mode 100644 src/components/safe-apps/AppFrame/useFromAppAnalytics.ts diff --git a/src/components/safe-apps/AppFrame/index.tsx b/src/components/safe-apps/AppFrame/index.tsx index d0286e8bf0..6b696be5a4 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' @@ -66,6 +67,7 @@ const AppFrame = ({ appUrl, allowedFeaturesList }: AppFrameProps): ReactElement 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]) 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/services/analytics/events/safeApps.ts b/src/services/analytics/events/safeApps.ts index c2720d54c4..7af092137d 100644 --- a/src/services/analytics/events/safeApps.ts +++ b/src/services/analytics/events/safeApps.ts @@ -2,6 +2,7 @@ import { EventType } from '@/services/analytics/types' export const SAFE_APPS_CATEGORY = 'safe-apps' export const SAFE_APPS_SDK_CATEGORY = 'safe-apps-sdk' +export const SAFE_APPS_ANALYTICS_CATEGORY = 'safe-apps-analytics' const SAFE_APPS_EVENT_DATA = { event: EventType.SAFE_APP, From 36f37145edee289773d1ba29981cde6f2461e9aa Mon Sep 17 00:00:00 2001 From: katspaugh <381895+katspaugh@users.noreply.github.com> Date: Thu, 24 Nov 2022 09:08:55 +0100 Subject: [PATCH 17/28] Fix: sandbox safe app icons (#1193) * Fix: sandbox safe app icons * Fix tests * Srcdoc * Use SandboxedIcon for all app icons * Rename component * Fix: make the icon clickable --- .../dashboard/FeaturedApps/FeaturedApps.tsx | 8 +--- .../safe-apps/AddCustomAppModal/CustomApp.tsx | 3 +- .../AddCustomAppModal/styles.module.css | 4 -- src/components/safe-apps/AppCard/index.tsx | 26 ++---------- .../safe-apps/SafeAppIcon/index.tsx | 42 +++++++++++++++++++ .../SafeAppLandingPage/SafeAppDetails.tsx | 4 +- .../safe-apps/SafeAppsModalLabel/index.tsx | 15 ++----- .../SafeAppsModalLabel/styles.module.css | 4 -- src/components/tx/modals/NewTxModal/index.tsx | 5 ++- src/tests/pages/apps.test.tsx | 4 +- 10 files changed, 62 insertions(+), 53 deletions(-) create mode 100644 src/components/safe-apps/SafeAppIcon/index.tsx delete mode 100644 src/components/safe-apps/SafeAppsModalLabel/styles.module.css diff --git a/src/components/dashboard/FeaturedApps/FeaturedApps.tsx b/src/components/dashboard/FeaturedApps/FeaturedApps.tsx index b21e358c39..4f93a150c6 100644 --- a/src/components/dashboard/FeaturedApps/FeaturedApps.tsx +++ b/src/components/dashboard/FeaturedApps/FeaturedApps.tsx @@ -7,11 +7,7 @@ import NextLink from 'next/link' import { AppRoutes } from '@/config/routes' import { SafeAppsTag } from '@/config/constants' import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps' - -const StyledImage = styled.img` - width: 64px; - height: 64px; -` +import SafeAppIcon from '@/components/safe-apps/SafeAppIcon' const StyledGrid = styled(Grid)` gap: 24px; @@ -42,7 +38,7 @@ export const FeaturedApps = (): ReactElement | null => { - + diff --git a/src/components/safe-apps/AddCustomAppModal/CustomApp.tsx b/src/components/safe-apps/AddCustomAppModal/CustomApp.tsx index b09bb1a0ba..65f2bb08e2 100644 --- a/src/components/safe-apps/AddCustomAppModal/CustomApp.tsx +++ b/src/components/safe-apps/AddCustomAppModal/CustomApp.tsx @@ -7,6 +7,7 @@ import { SAFE_APPS_EVENTS, trackSafeAppEvent } from '@/services/analytics' import CopyButton from '@/components/common/CopyButton' import ShareIcon from '@/public/images/common/share.svg' import css from './styles.module.css' +import SafeAppIcon from '../SafeAppIcon' type CustomAppProps = { safeApp: SafeAppData @@ -20,7 +21,7 @@ const CustomApp = ({ safeApp, shareUrl }: CustomAppProps) => { return (
- {safeApp.name} + {safeApp.name} diff --git a/src/components/safe-apps/AddCustomAppModal/styles.module.css b/src/components/safe-apps/AddCustomAppModal/styles.module.css index a6e00c77de..14973bb764 100644 --- a/src/components/safe-apps/AddCustomAppModal/styles.module.css +++ b/src/components/safe-apps/AddCustomAppModal/styles.module.css @@ -41,10 +41,6 @@ right: 25px; } -.customAppIcon { - width: 48px; -} - .customAppPlaceholderContainer { width: 100%; display: flex; diff --git a/src/components/safe-apps/AppCard/index.tsx b/src/components/safe-apps/AppCard/index.tsx index 96e0365d85..fbc1248566 100644 --- a/src/components/safe-apps/AppCard/index.tsx +++ b/src/components/safe-apps/AppCard/index.tsx @@ -3,7 +3,6 @@ import { useCallback } from 'react' import { useRouter } from 'next/router' import Link from 'next/link' import type { LinkProps } from 'next/link' -import Avatar from '@mui/material/Avatar' import Card from '@mui/material/Card' import CardContent from '@mui/material/CardContent' import CardHeader from '@mui/material/CardHeader' @@ -23,6 +22,7 @@ import { SvgIcon } from '@mui/material' import type { UrlObject } from 'url' import { resolveHref } from 'next/dist/shared/lib/router/router' import { SAFE_APPS_EVENTS, trackSafeAppEvent } from '@/services/analytics' +import SafeAppIcon from '../SafeAppIcon' export type SafeAppCardVariants = 'default' | 'compact' @@ -168,16 +168,7 @@ const CompactAppCard = ({ url, safeApp, onPin, pinned, shareUrl }: CompactSafeAp
{/* App logo */} - + {/* TODO No share button per design. Only info button. Leaving the code for reusing the styles */} {/* Share button */} @@ -224,18 +215,7 @@ const AppCard = ({ safeApp, pinned, onPin, onDelete, variant = 'default' }: AppC return ( - } + avatar={} action={
{/* Share button */} 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 ( +