diff --git a/.env.example b/.env.example index b2804bb7d4..6642c2c52c 100644 --- a/.env.example +++ b/.env.example @@ -32,5 +32,12 @@ NEXT_PUBLIC_CYPRESS_MNEMONIC= NEXT_PUBLIC_SAFE_GELATO_RELAY_SERVICE_URL_PRODUCTION= NEXT_PUBLIC_SAFE_GELATO_RELAY_SERVICE_URL_STAGING= +# Firebase Cloud Messaging +NEXT_PUBLIC_FIREBASE_OPTIONS_PRODUCTION= +NEXT_PUBLIC_FIREBASE_VAPID_KEY_PRODUCTION= + +NEXT_PUBLIC_FIREBASE_OPTIONS_STAGING= +NEXT_PUBLIC_FIREBASE_VAPID_KEY_STAGING= + # Redefine NEXT_PUBLIC_REDEFINE_API= \ No newline at end of file diff --git a/.github/workflows/build/action.yml b/.github/workflows/build/action.yml index 0cfb040d50..fd57aada4f 100644 --- a/.github/workflows/build/action.yml +++ b/.github/workflows/build/action.yml @@ -43,3 +43,7 @@ runs: NEXT_PUBLIC_SAFE_RELAY_SERVICE_URL_STAGING: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_SAFE_GELATO_RELAY_SERVICE_URL_STAGING }} NEXT_PUBLIC_IS_OFFICIAL_HOST: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_IS_OFFICIAL_HOST }} NEXT_PUBLIC_REDEFINE_API: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_REDEFINE_API }} + NEXT_PUBLIC_FIREBASE_OPTIONS_PRODUCTION: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_FIREBASE_OPTIONS_PRODUCTION }} + NEXT_PUBLIC_FIREBASE_OPTIONS_STAGING: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_FIREBASE_OPTIONS_STAGING }} + NEXT_PUBLIC_FIREBASE_VAPID_KEY_PRODUCTION: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_FIREBASE_VAPID_KEY_PRODUCTION }} + NEXT_PUBLIC_FIREBASE_VAPID_KEY_STAGING: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_FIREBASE_VAPID_KEY_STAGING }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6a05f029ff..f33c09fb11 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -67,9 +67,9 @@ jobs: # Extract branch name - name: Extract branch name shell: bash - ## Cut off "refs/heads/" and only allow alphanumeric characters, + ## Cut off "refs/heads/", only allow alphanumeric characters and convert to lower case, ## e.g. "refs/heads/features/hello-1.2.0" -> "features_hello_1_2_0" - run: echo "branch=$(echo $GITHUB_HEAD_REF | sed 's/refs\/heads\///' | sed 's/[^a-z0-9]/_/ig')" >> $GITHUB_OUTPUT + run: echo "branch=$(echo $GITHUB_HEAD_REF | sed 's/refs\/heads\///' | sed 's/[^a-z0-9]/_/ig' | sed 's/[A-Z]/\L&/g')" >> $GITHUB_OUTPUT id: extract_branch # Deploy to S3 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d978694270..6af7316dbc 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -9,7 +9,7 @@ concurrency: jobs: e2e: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 name: Smoke E2E tests steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/yarn/action.yml b/.github/workflows/yarn/action.yml index 4d3112fa22..bff04d3a30 100644 --- a/.github/workflows/yarn/action.yml +++ b/.github/workflows/yarn/action.yml @@ -13,3 +13,6 @@ runs: - name: Yarn install shell: bash run: yarn install --frozen-lockfile + - name: Yarn after install + shell: bash + run: yarn after-install diff --git a/.gitignore b/.gitignore index 85fdc04a8a..97916164f6 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ yalc.lock /public/sw.js /public/sw.js.map +/public/worker-*.js /public/workbox-*.js /public/workbox-*.js.map /public/fallback* \ No newline at end of file diff --git a/README.md b/README.md index 73e3af1fe1..c2bdf1146f 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ Here's the list of all the required and optional variables: | `NEXT_PUBLIC_SAFE_GELATO_RELAY_SERVICE_URL_STAGING` | optional | Relay URL on staging | `NEXT_PUBLIC_IS_OFFICIAL_HOST` | optional | Whether it's the official distribution of the app, or a fork; has legal implications. Set to true only if you also update the legal pages like Imprint and Terms of use | `NEXT_PUBLIC_REDEFINE_API` | optional | Redefine API base URL +| `NEXT_PUBLIC_FIREBASE_OPTIONS_PRODUCTION` | optional | Firebase Cloud Messaging (FCM) `initializeApp` options on production +| `NEXT_PUBLIC_FIREBASE_VAPID_KEY_PRODUCTION` | optional | FCM vapid key on production +| `NEXT_PUBLIC_FIREBASE_OPTIONS_STAGING` | optional | FCM `initializeApp` options on staging +| `NEXT_PUBLIC_FIREBASE_VAPID_KEY_STAGING` | optional | FCM vapid key on staging If you don't provide some of the optional vars, the corresponding features will be disabled in the UI. diff --git a/cypress.config.js b/cypress.config.js index 16a5083407..753ec36daa 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -11,6 +11,9 @@ export default defineConfig({ e2e: { baseUrl: 'http://localhost:3000', + testIsolation: false, + hideXHR: true, + defaultCommandTimeout: 10000, }, chromeWebSecurity: false, diff --git a/cypress/e2e/add_owner.cy.js b/cypress/e2e/add_owner.cy.js deleted file mode 100644 index c354e86a0c..0000000000 --- a/cypress/e2e/add_owner.cy.js +++ /dev/null @@ -1,102 +0,0 @@ -import * as constants from '../../support/constants' - -const offset = 7 - -describe('Adding an owner', () => { - before(() => { - cy.visit(`/${constants.GOERLI_TEST_SAFE}/settings/setup`) - cy.contains('button', 'Accept selection').click() - - // Advanced Settings page is loaded - cy.contains('Safe nonce', { timeout: 10000 }) - }) - - describe('Add new owner', () => { - it('should add a new owner and change the threshold', () => { - // "add owner" tx so funds are not needed in the safe. - // Open the add new owner modal - cy.contains('Add new owner').click() - cy.contains('h2', 'Add new owner').should('be.visible') - cy.contains('h2', 'Step 1 out of 3').should('be.visible') - - // Fills new owner data - cy.get('input[placeholder="New owner"]').type('New Owner Name') - cy.get('input[name="address"]').type(constants.EOA) - - // Advances to step 2 - cy.contains('Next').click() - cy.contains('h2', 'Set threshold').should('be.visible') - cy.contains('h2', 'Step 2 out of 3').should('be.visible') - - // Select 2 owners - cy.get('div[aria-haspopup="listbox"]').contains('1').click() - cy.get('li[role="option"]').contains('2').click() - - // Input should have value 3 - cy.get('div[aria-haspopup="listbox"]').contains('2').should('be.visible') - cy.contains('out of 2 owner(s)').should('be.visible') - - // Review step - cy.contains('Next').click() - cy.contains('h2', 'Review transaction').should('be.visible') - cy.contains('h2', 'Step 3 out of 3').should('be.visible') - - // Chosen threshold should be in the review modal - cy.contains('h2', 'Review transaction') - .next() - .contains('Any transaction requires the confirmation of:') - .next() - .find('b', '2') - // 2 out of 2 - .should('have.length', 2) - }) - - it('should open Edit estimation information', () => { - // Estimated gas price is loaded - cy.contains('Estimated fee').next().should('not.have.text', '> 0.001 ETH') - - // Open the accordion if gas is loaded - cy.contains('Estimated fee').click() - - //Checking that gas limit is not a 0, usually a sign that the estimation failed - cy.contains('Gas limit').next().should('not.to.be.empty').and('to.not.equal', '0') - - cy.contains('button', 'Edit').click() - }) - - describe('Edit Advanced parameters form', () => { - //The following values are fixed to get a specific estimation value in ETH - it('should show validation errors in the form', () => { - // Gas limit - cy.get('label').contains('Gas limit').next().clear().type('-100') - cy.findByText('Gas limit must be at least 21000') - cy.get('label').contains('Gas limit').next().clear().type('200000') - - // Max gas price - cy.get('label').contains('Max fee').next().clear().type('-100') - cy.get('label').contains('Max fee').next().get('input[aria-invalid="true"]') - cy.get('label').contains('Max fee').next().clear().type('5') - cy.get('label').contains('Max fee').next().get('input[aria-invalid="false"]') - - // Max prio fee - cy.get('label').contains('Max priority fee').next().clear().type('-100') - cy.get('label').contains('Max priority fee').next().get('input[aria-invalid="true"]') - cy.get('label').contains('Max priority fee').next().clear().type('7') - cy.get('label').contains('Max priority fee').next().get('input[aria-invalid="false"]') - - // Accepts the values - cy.contains('Confirm').click() - }) - - it('should show the edited values', () => { - //Verifying that in the dropdown all the values are there - cy.contains('Estimated fee').click() - - // Verify the accordion details fields kept the edited values - cy.contains('div', 'Gas limit').next().should('have.text', '200000') - cy.contains('Max fee').next().should('have.text', '5') - cy.contains('Max priority fee').next().should('have.text', '7') - }) - }) - }) -}) diff --git a/cypress/e2e/pages/address_book.page.js b/cypress/e2e/pages/address_book.page.js index 0114cdd13c..29ea8e69de 100644 --- a/cypress/e2e/pages/address_book.page.js +++ b/cypress/e2e/pages/address_book.page.js @@ -2,6 +2,8 @@ export const acceptSelection = 'Accept selection' export const addressBook = 'Address book' const createEntryBtn = 'Create entry' +const beameriFrameContainer = '#beamerOverlay .iframeCointaner' +const beamerInput = 'input[id="beamer"]' const nameInput = 'input[name="name"]' const addressInput = 'input[name="address"]' const saveBtn = 'Save' @@ -13,6 +15,8 @@ const exportFileModalBtnSection = '.MuiDialogActions-root' const exportFileModalExportBtn = 'Export' const importBtn = 'Import' const exportBtn = 'Export' +const whatsNewBtnStr = "What's new" +const beamrCookiesStr = 'accept the "Beamer" cookies' export function clickOnImportFileBtn() { cy.contains(importBtn).click() @@ -46,7 +50,7 @@ export function clickOnCreateEntryBtn() { cy.contains(createEntryBtn).click() } -export function tyeInName(name) { +export function typeInName(name) { cy.get(nameInput).type(name) } @@ -91,3 +95,20 @@ export function clickDeleteEntryModalDeleteButton() { export function verifyEditedNameNotExists(name) { cy.get(name).should('not.exist') } + +export function clickOnWhatsNewBtn(force = false) { + cy.contains(whatsNewBtnStr).click({ force: force }) +} + +export function acceptBeamerCookies() { + cy.contains(beamrCookiesStr) +} + +export function verifyBeamerIsChecked() { + cy.get(beamerInput).should('be.checked') +} + +export function verifyBeameriFrameExists() { + cy.wait(1000) + cy.get(beameriFrameContainer).should('exist') +} diff --git a/cypress/e2e/pages/balances.pages.js b/cypress/e2e/pages/balances.pages.js index f648706209..4c640272b2 100644 --- a/cypress/e2e/pages/balances.pages.js +++ b/cypress/e2e/pages/balances.pages.js @@ -7,6 +7,7 @@ const hideAssetBtn = 'button[aria-label="Hide asset"]' const hiddeTokensBtn = '[data-testid="toggle-hidden-assets"]' const hiddenTokenCheckbox = 'input[type="checkbox"]' const paginationPageList = 'ul[role="listbox"]' +const currencyDropDown = 'div[id="currency"]' const hiddenTokenSaveBtn = 'Save' const hideTokenDefaultString = 'Hide tokens' diff --git a/cypress/e2e/pages/batches.pages.js b/cypress/e2e/pages/batches.pages.js index 77829aeaf5..1b5631c6fb 100644 --- a/cypress/e2e/pages/batches.pages.js +++ b/cypress/e2e/pages/batches.pages.js @@ -23,6 +23,7 @@ const tokenAddressInput = 'input[name="tokenAddress"]' const listBox = 'ul[role="listbox"]' const amountInput = '[name="amount"]' const nonceInput = 'input[name="nonce"]' +const executeOptionsContainer = 'div[role="radiogroup"]' export function addToBatch(EOA, currentNonce, amount, verify = false) { fillTransactionData(EOA, amount) @@ -49,8 +50,12 @@ function setNonceAndProceed(currentNonce) { } function executeTransaction() { - cy.contains(yesExecuteString, { timeout: 4000 }).click() - cy.contains(addToBatchBtn).should('not.exist') + cy.waitForSelector(() => { + return cy.get(executeOptionsContainer).then(() => { + cy.contains(yesExecuteString, { timeout: 4000 }).click() + cy.contains(addToBatchBtn).should('not.exist') + }) + }) } function addToBatchButton() { diff --git a/cypress/e2e/pages/create_tx.pages.js b/cypress/e2e/pages/create_tx.pages.js index b657939bac..11b7bb78e7 100644 --- a/cypress/e2e/pages/create_tx.pages.js +++ b/cypress/e2e/pages/create_tx.pages.js @@ -8,6 +8,7 @@ const amountInput = 'input[name="amount"]' const nonceInput = 'input[name="nonce"]' const gasLimitInput = '[name="gasLimit"]' const rotateLeftIcon = '[data-testid="RotateLeftIcon"]' +const transactionItemExpandable = 'div[id^="transfer"]' const viewTransactionBtn = 'View transaction' const transactionDetailsTitle = 'Transaction details' @@ -26,6 +27,8 @@ const editBtnStr = 'Edit' const executionParamsStr = 'Execution parameters' const noLaterStr = 'No, later' const signBtnStr = 'Sign' +const expandAllBtnStr = 'Expand all' +const collapseAllBtnStr = 'Collapse all' export function clickOnNewtransactionBtn() { // Assert that "New transaction" button is visible @@ -90,7 +93,7 @@ export function changeNonce(value) { } export function verifyConfirmTransactionData() { - cy.contains(yesStr).should('exist') + cy.contains(yesStr).should('exist').click() cy.contains(estimatedFeeStr).should('exist') // Asserting the sponsored info is present @@ -130,7 +133,7 @@ export function clickOnSignTransactionBtn() { } export function waitForProposeRequest() { - cy.intercept('POST', constants.proposeEndPoint).as('ProposeTx') + cy.intercept('POST', constants.proposeEndpoint).as('ProposeTx') cy.wait('@ProposeTx') } @@ -149,3 +152,40 @@ export function verifyQueueLabel() { export function verifyTransactionSummary(sendValue) { cy.contains(TransactionSummary + `${sendValue} ${constants.tokenAbbreviation.gor}`).should('exist') } + +export function verifyDateExists(date) { + cy.contains('div', date).should('exist') +} + +export function verifyImageAltTxt(index, text) { + cy.get('img').eq(index).should('have.attr', 'alt', text).should('be.visible') +} + +export function verifyStatus(status) { + cy.contains('div', status).should('exist') +} + +export function verifyTransactionStrExists(str) { + cy.contains(str).should('exist') +} + +export function verifyTransactionStrNotVible(str) { + cy.contains(str).should('not.be.visible') +} + +export function clickOnTransactionExpandableItem(name, actions) { + cy.contains('div', name) + .next() + .click() + .within(() => { + actions() + }) +} + +export function clickOnExpandAllBtn() { + cy.contains(expandAllBtnStr).click() +} + +export function clickOnCollapseAllBtn() { + cy.contains(collapseAllBtnStr).click() +} diff --git a/cypress/e2e/pages/create_wallet.pages.js b/cypress/e2e/pages/create_wallet.pages.js index 57ad95b318..e00147261f 100644 --- a/cypress/e2e/pages/create_wallet.pages.js +++ b/cypress/e2e/pages/create_wallet.pages.js @@ -19,8 +19,9 @@ export function typeWalletName(name) { } export function selectNetwork(network, regex = false) { - cy.get(selectNetworkBtn).click() - cy.contains(network).click() + cy.wait(1000) + cy.get(selectNetworkBtn).should('exist').click() + cy.get('li').contains(network).click() if (regex) { regex = constants.networks.goerli diff --git a/cypress/e2e/pages/dashboard.pages.js b/cypress/e2e/pages/dashboard.pages.js index d862ab987c..e6a3f216cb 100644 --- a/cypress/e2e/pages/dashboard.pages.js +++ b/cypress/e2e/pages/dashboard.pages.js @@ -45,10 +45,8 @@ export function verifyTxQueueWidget() { cy.contains(noTransactionStr).should('not.exist') // Queued txns - cy.contains( - `a[href^="/transactions/tx?id=multisig_0x"]`, - '13' + 'Send' + '0.00002 GOR' + 'to' + 'gor:0xE297...9665' + '1/1', - ).should('exist') + cy.contains(`a[href^="/transactions/tx?id=multisig_0x"]`, '13' + 'Send' + '-0.00002 GOR' + '1/1').should('exist') + cy.contains(`a[href="${constants.transactionQueueUrl}${encodeURIComponent(constants.TEST_SAFE)}"]`, viewAllStr) }) } diff --git a/cypress/e2e/pages/import_export.pages.js b/cypress/e2e/pages/import_export.pages.js index 2a0c82fd52..bbd76cdcbe 100644 --- a/cypress/e2e/pages/import_export.pages.js +++ b/cypress/e2e/pages/import_export.pages.js @@ -1,12 +1,14 @@ import { format } from 'date-fns' const path = require('path') +const enablePushNotificationsStr = 'Enable push notifications' const addressBookBtnStr = 'Address book' const dataImportModalStr = 'Data import' const appsBtnStr = 'Apps' const bookmarkedAppsBtnStr = 'Bookmarked apps' const settingsBtnStr = 'Settings' const appearenceTabStr = 'Appearance' +const showMoreTabsBtn = '[data-testid="KeyboardArrowRightIcon"]' const dataTabStr = 'Data' const tab = 'div[role="tablist"] a' export const prependChainPrefixStr = 'Prepend chain prefix to addresses' @@ -41,6 +43,12 @@ export function clickOnImportedSafe(safe) { cy.contains(safe).click() } +export function clickOnClosePushNotificationsBanner() { + cy.waitForSelector(() => { + return cy.get('h6').contains(enablePushNotificationsStr).siblings('.MuiButtonBase-root').click({ force: true }) + }) +} + export function clickOnAddressBookBtn() { cy.contains(addressBookBtnStr).click() } @@ -82,6 +90,13 @@ export function clickOnAppearenceBtn() { cy.contains(tab, appearenceTabStr).click() } +export function clickOnShowMoreTabsBtn() { + cy.get(showMoreTabsBtn).click() +} + +export function verifDataTabBtnIsVisible() { + cy.contains(tab, dataTabStr).should('be.visible') +} export function clickOnDataTab() { cy.contains(tab, dataTabStr).click() } diff --git a/cypress/e2e/pages/load_safe.pages.js b/cypress/e2e/pages/load_safe.pages.js index 2f5b22e618..3d36669e48 100644 --- a/cypress/e2e/pages/load_safe.pages.js +++ b/cypress/e2e/pages/load_safe.pages.js @@ -29,6 +29,11 @@ export function selectGoerli() { cy.contains('span', constants.networks.goerli) } +export function selectPolygon() { + cy.get('ul li').contains(constants.networks.polygon).click() + cy.contains('span', constants.networks.polygon) +} + export function verifyNameInputHasPlceholder() { cy.get(nameInput).should('have.attr', 'placeholder').should('match', constants.goerlySafeName) } diff --git a/cypress/e2e/pages/main.page.js b/cypress/e2e/pages/main.page.js index c4c628e053..0046c4dbc4 100644 --- a/cypress/e2e/pages/main.page.js +++ b/cypress/e2e/pages/main.page.js @@ -3,9 +3,18 @@ import * as constants from '../../support/constants' const acceptSelection = 'Accept selection' export function acceptCookies() { - cy.contains(acceptSelection).click() - cy.contains(acceptSelection).should('not.exist') - cy.wait(500) + cy.wait(1000) + cy.get('button') + .contains(acceptSelection) + .should(() => {}) + .then(($button) => { + if (!$button.length) { + return + } + cy.wrap($button).click() + cy.contains(acceptSelection).should('not.exist') + cy.wait(500) + }) } export function verifyGoerliWalletHeader() { @@ -15,3 +24,32 @@ export function verifyGoerliWalletHeader() { export function verifyHomeSafeUrl(safe) { cy.location('href', { timeout: 10000 }).should('include', constants.homeUrl + safe) } + +export function checkTextsExistWithinElement(element, texts) { + texts.forEach((text) => { + cy.wrap(element).findByText(text).should('exist') + }) +} + +export function verifyCheckboxeState(element, index, state) { + cy.get(element).eq(index).should(state) +} + +export function verifyInputValue(selector, value) { + cy.get(selector) + .invoke('val') + .should(($value) => { + console.log($value) + }) +} + +export function generateRandomString(length) { + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZz0123456789' + let result = '' + + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * characters.length)) + } + + return result +} diff --git a/cypress/e2e/pages/owners.pages.js b/cypress/e2e/pages/owners.pages.js new file mode 100644 index 0000000000..d885273f84 --- /dev/null +++ b/cypress/e2e/pages/owners.pages.js @@ -0,0 +1,164 @@ +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' + +const copyToClipboardBtn = 'button[aria-label="Copy to clipboard"]' +const tooltipLabel = (label) => `span[aria-label="${label}"]` +const removeOwnerBtn = 'button[aria-label="Remove owner"]' +const replaceOwnerBtn = 'button[aria-label="Replace owner"]' +const addOwnerBtn = 'span[data-track="settings: Add owner"]' +const tooltip = 'div[role="tooltip"]' +const expandMoreIcon = 'svg[data-testid="ExpandMoreIcon"]' +const sentinelStart = 'div[data-testid="sentinelStart"]' +const newOwnerName = 'input[name="newOwner.name"]' +const newOwnerAddress = 'input[name="newOwner.address"]' +const newOwnerNonceInput = 'input[name="nonce"]' +const thresholdInput = 'input[name="threshold"]' +const thresHoldDropDownIcon = 'svg[data-testid="ArrowDropDownIcon"]' +const thresholdList = 'ul[role="listbox"]' +const thresholdDropdown = 'div[aria-haspopup="listbox"]' +const thresholdOption = 'li[role="option"]' + +const disconnectBtnStr = 'Disconnect' +const notConnectedStatus = 'Connect' +const e2eWalletStr = 'E2E Wallet' +const max50charsLimitStr = 'Maximum 50 symbols' +const nextBtnStr = 'Next' +const executeBtnStr = 'Execute' +const backbtnStr = 'Back' +const removeOwnerStr = 'Remove owner' +const selectedOwnerStr = 'Selected owner' +const addNewOwnerStr = 'Add new owner' + +export const safeAccountNonceStr = 'Safe Account nonce' +export const nonOwnerErrorMsg = 'Your connected wallet is not an owner of this Safe Account' +export const disconnectedUserErrorMsg = 'Please connect your wallet' + +export function verifyOwnerDeletionWindowDisplayed() { + cy.get('div').contains(constants.transactionStatus.confirm).should('exist') + cy.get('button').contains(backbtnStr).should('exist') + cy.get('p').contains(selectedOwnerStr) +} + +function clickOnThresholdDropdown() { + cy.get(thresholdDropdown).eq(1).click() +} + +function getThresholdOptions() { + return cy.get('ul').find(thresholdOption) +} + +export function verifyThresholdLimit(startValue, endValue) { + cy.get('p').contains(`out of ${endValue} owner(s)`) + clickOnThresholdDropdown() + getThresholdOptions().should('have.length', 1) + getThresholdOptions().eq(0).should('have.text', startValue) +} + +export function verifyRemoveBtnIsEnabled() { + return cy.get(removeOwnerBtn).should('exist') +} + +export function hoverOverDeleteOwnerBtn(index) { + cy.get(removeOwnerBtn).eq(index).trigger('mouseover', { force: true }) +} + +export function openRemoveOwnerWindow(btn) { + cy.get(removeOwnerBtn).eq(btn).click({ force: true }) + cy.get(copyToClipboardBtn).parent().eq(2).find('span').contains('0x').should('be.visible') + cy.get('div').contains(removeOwnerStr).should('exist') +} + +export function openReplaceOwnerWindow() { + cy.get(replaceOwnerBtn).click({ force: true }) + cy.get(newOwnerName).should('be.visible') + cy.get(newOwnerAddress).should('be.visible') + cy.get(copyToClipboardBtn).parent().eq(2).find('span').contains('0x').should('be.visible') +} +export function verifyTooltipLabel(label) { + cy.get(tooltipLabel(label)).should('be.visible') +} +export function verifyReplaceBtnIsEnabled() { + cy.get(replaceOwnerBtn).should('exist').and('not.be.disabled') +} + +export function hoverOverReplaceOwnerBtn() { + cy.get(replaceOwnerBtn).trigger('mouseover', { force: true }) +} + +export function verifyAddOwnerBtnIsEnabled() { + cy.get(addOwnerBtn).should('exist').and('not.be.disabled') +} + +export function hoverOverAddOwnerBtn() { + cy.get(addOwnerBtn).trigger('mouseover') +} + +export function verifyTooltiptext(text) { + cy.get(tooltip).should('have.text', text) +} + +export function clickOnWalletExpandMoreIcon() { + cy.get(expandMoreIcon).eq(0).click() + cy.get(sentinelStart).next().should('be.visible') +} + +export function clickOnDisconnectBtn() { + cy.get('button').contains(disconnectBtnStr).click() + cy.get('button').contains(notConnectedStatus) +} + +export function waitForConnectionStatus() { + cy.get('div').contains(e2eWalletStr) +} + +export function openAddOwnerWindow() { + cy.get('span').contains(addNewOwnerStr).click() + cy.wait(1000) + cy.get(newOwnerName).should('be.visible') + cy.get(newOwnerAddress).should('be.visible') +} + +export function verifyNonceInputValue(value) { + cy.get(newOwnerNonceInput).should('not.be.disabled') + main.verifyInputValue(newOwnerNonceInput, value) +} + +export function verifyErrorMsgInvalidAddress(errorMsg) { + cy.get('label').contains(errorMsg).should('be.visible') +} + +export function typeOwnerAddress(address) { + cy.get(newOwnerAddress).clear().type(address) + main.verifyInputValue(newOwnerAddress, address) + cy.wait(1000) +} + +export function typeOwnerName(name) { + cy.get(newOwnerName).clear().type(name) + main.verifyInputValue(newOwnerName, name) +} + +export function selectNewOwner(name) { + cy.contains(name).click() +} + +export function verifyNewOwnerName(name) { + cy.get(newOwnerName).should('have.attr', 'placeholder', name) +} + +export function clickOnNextBtn() { + cy.get('button').contains(nextBtnStr).click() +} + +export function verifyConfirmTransactionWindowDisplayed() { + cy.get('div').contains(constants.transactionStatus.confirm).should('exist') + cy.get('button').contains(executeBtnStr).should('exist') + cy.get('button').contains(backbtnStr).should('exist') +} + +export function verifyThreshold(startValue, endValue) { + main.verifyInputValue(thresholdInput, startValue) + cy.get('p').contains(`out of ${endValue} owner(s)`).should('be.visible') + cy.get(thresholdInput).parent().click() + cy.get(thresholdList).contains(endValue).should('be.visible') +} diff --git a/cypress/e2e/pages/safeapps.pages.js b/cypress/e2e/pages/safeapps.pages.js new file mode 100644 index 0000000000..4700348f6b --- /dev/null +++ b/cypress/e2e/pages/safeapps.pages.js @@ -0,0 +1,193 @@ +import * as constants from '../../support/constants' + +const searchAppInput = 'input[id="search-by-name"]' +const appUrlInput = 'input[name="appUrl"]' +const closePreviewWindowBtn = 'button[aria-label*="Close"][aria-label*="preview"]' + +const addBtnStr = /add/i +const noAppsStr = /no Safe Apps found/i +const bookmarkedAppsStr = /bookmarked Apps/i +const customAppsStr = /my custom Apps/i +const addCustomAppBtnStr = /add custom Safe App/i +const openSafeAppBtnStr = /open Safe App/i +const disclaimerTtle = /disclaimer/i +const continueBtnStr = /continue/i +const cameraCheckBoxStr = /camera/i +const microfoneCheckBoxStr = /microphone/i +const permissionRequestStr = /permissions request/i +const accessToAddressBookStr = /access to your address book/i +const acceptBtnStr = /accept/i +const clearAllBtnStr = /clear all/i +const allowAllPermissions = /allow all/i + +const appNotSupportedMsg = "The app doesn't support Safe App functionality" + +export const pinWalletConnectStr = /pin walletconnect/i +export const transactionBuilderStr = /pin transaction builder/i +export const logoWalletConnect = /logo.*walletconnect/i +export const walletConnectHeadlinePreview = /walletconnect/i +export const availableNetworksPreview = /available networks/i +export const connecttextPreview = 'Connect your Safe to any dApp that supports WalletConnect' +export const localStorageItem = + '{"https://safe-test-app.com":[{"feature":"camera","status":"granted"},{"feature":"microphone","status":"denied"}]}' +export const gridItem = 'main .MuiPaper-root > .MuiGrid-item' +export const linkNames = { + logo: /logo/i, +} + +export const permissionCheckboxes = { + camera: 'input[name="camera"]', + addressbook: 'input[name="requestAddressBook"]', + microphone: 'input[name="microphone"]', + geolocation: 'input[name="geolocation"]', + fullscreen: 'input[name="fullscreen"]', +} + +export const permissionCheckboxNames = { + camera: 'Camera', + addressbook: 'Address Book', + microphone: 'Microphone', + geolocation: 'Geolocation', + fullscreen: 'Fullscreen', +} +export function typeAppName(name) { + cy.get(searchAppInput).clear().type(name) +} + +export function clearSearchAppInput() { + cy.get(searchAppInput).clear() +} + +export function verifyLinkName(name) { + cy.findAllByRole('link', { name: name }).should('have.length', 1) +} + +export function clickOnApp(app) { + cy.findByRole('link', { name: app }).click() +} + +export function verifyNoAppsTextPresent() { + cy.contains(noAppsStr).should('exist') +} + +export function pinApp(app, pin = true) { + let str = 'Unpin' + if (!pin) str = 'Pin' + cy.findByLabelText(app) + .click() + .should(($el) => { + const ariaLabel = $el.attr('aria-label') + expect(ariaLabel).to.include(str) + }) +} + +export function clickOnBookmarkedAppsTab() { + cy.findByText(bookmarkedAppsStr).click() +} + +export function verifyAppCount(count) { + cy.findByText(`ALL (${count})`).should('be.visible') +} + +export function clickOnCustomAppsTab() { + cy.findByText(customAppsStr).click() +} + +export function clickOnAddCustomApp() { + cy.findByText(addCustomAppBtnStr).click() +} + +export function typeCustomAppUrl(url) { + cy.get(appUrlInput).clear().type(url) +} + +export function verifyAppNotSupportedMsg() { + cy.contains(appNotSupportedMsg).should('be.visible') +} + +export function verifyAppTitle(title) { + cy.findByRole('heading', { name: title }).should('exist') +} + +export function acceptTC() { + cy.findByRole('checkbox').click() +} + +export function clickOnAddBtn() { + cy.findByRole('button', { name: addBtnStr }).click() +} + +export function verifyAppDescription(descr) { + cy.findByText(descr).should('exist') +} + +export function clickOnOpenSafeAppBtn() { + cy.findByRole('link', { name: openSafeAppBtnStr }).click() + cy.wait(500) + verifyDisclaimerIsVisible() + cy.wait(500) +} + +function verifyDisclaimerIsVisible() { + cy.findByRole('heading', { name: disclaimerTtle }).should('be.visible') +} + +export function clickOnContinueBtn() { + return cy.findByRole('button', { name: continueBtnStr }).click() +} + +export function verifyCameraCheckBoxExists() { + cy.findByRole('checkbox', { name: cameraCheckBoxStr }).should('exist') +} + +export function verifyMicrofoneCheckBoxExists() { + return cy.findByRole('checkbox', { name: microfoneCheckBoxStr }).should('exist') +} + +export function storeAndVerifyPermissions() { + cy.waitForSelector(() => { + return cy + .findByRole('button', { name: continueBtnStr }) + .click() + .wait(500) + .should(() => { + const storedBrowserPermissions = JSON.parse(localStorage.getItem(constants.BROWSER_PERMISSIONS_KEY)) + const browserPermissions = Object.values(storedBrowserPermissions)[0][0] + const storedInfoModal = JSON.parse(localStorage.getItem(constants.INFO_MODAL_KEY)) + + expect(browserPermissions.feature).to.eq('camera') + expect(browserPermissions.status).to.eq('granted') + expect(storedInfoModal['5'].consentsAccepted).to.eq(true) + }) + }) +} + +export function verifyPreviewWindow(str1, str2, str3) { + cy.findByRole('heading', { name: str1 }).should('exist') + cy.findByText(str2).should('exist') + cy.findByText(str3).should('exist') +} + +export function closePreviewWindow() { + cy.get(closePreviewWindowBtn).click() +} + +export function verifyPermissionsRequestExists() { + cy.findByRole('heading', { name: permissionRequestStr }).should('exist') +} + +export function verifyAccessToAddressBookExists() { + cy.findByText(accessToAddressBookStr).should('exist') +} + +export function clickOnAcceptBtn() { + cy.findByRole('button', { name: acceptBtnStr }).click() +} + +export function uncheckAllPermissions(element) { + cy.wrap(element).findByText(clearAllBtnStr).click() +} + +export function checkAllPermissions(element) { + cy.wrap(element).findByText(allowAllPermissions).click() +} diff --git a/cypress/e2e/safe-apps/apps_list.cy.js b/cypress/e2e/safe-apps/apps_list.cy.js index 7a73abe742..a6db25138e 100644 --- a/cypress/e2e/safe-apps/apps_list.cy.js +++ b/cypress/e2e/safe-apps/apps_list.cy.js @@ -1,79 +1,77 @@ import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as safeapps from '../pages/safeapps.pages' + +const myCustomAppTitle = 'Cypress Test App' +const myCustomAppDescrAdded = 'Cypress Test App Description' describe('The Safe Apps list', () => { before(() => { - cy.visit(`/${constants.TEST_SAFE_2}/apps`, { failOnStatusCode: false }) - cy.findByText(/accept selection/i).click() + cy.clearLocalStorage() + cy.visit(constants.TEST_SAFE_2 + constants.appsUrl, { failOnStatusCode: false }) + main.acceptCookies() }) describe('When searching apps', () => { it('should filter the list by app name', () => { // 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) + cy.intercept('GET', constants.appsEndpoint).then(() => { + safeapps.typeAppName(constants.appNames.walletConnect) + safeapps.verifyLinkName(safeapps.linkNames.logo) }) }) it('should filter the list by app description', () => { - cy.findByRole('textbox').clear().type('compose custom contract') - cy.findAllByRole('link', { name: /logo/i }).should('have.length', 1) + safeapps.typeAppName(constants.appNames.customContract) + safeapps.verifyLinkName(safeapps.linkNames.logo) }) it('should show a not found text when no match', () => { - cy.findByRole('textbox').clear().type('atextwithoutresults') - cy.findByText(/no Safe Apps found/i).should('exist') + safeapps.typeAppName(constants.appNames.noResults) + safeapps.verifyNoAppsTextPresent() }) }) describe('When browsing the apps list', () => { it('should allow to pin apps', () => { - cy.findByRole('textbox').clear() - cy.findByLabelText(/pin walletconnect/i).click() - cy.findByLabelText(/pin transaction builder/i).click() - cy.findByText(/bookmarked Apps/i).click() - cy.findByText('ALL (2)').should('exist') + safeapps.clearSearchAppInput() + safeapps.pinApp(safeapps.pinWalletConnectStr) + safeapps.pinApp(safeapps.transactionBuilderStr) + safeapps.clickOnBookmarkedAppsTab() + safeapps.verifyAppCount(2) }) it('should allow to unpin apps', () => { - cy.findAllByLabelText(/unpin walletConnect/i) - .first() - .click() - cy.findAllByLabelText(/unpin transaction builder/i) - .first() - .click() - cy.findByText('ALL (0)').should('exist') + safeapps.pinApp(safeapps.pinWalletConnectStr) + safeapps.pinApp(safeapps.transactionBuilderStr) + safeapps.verifyAppCount(0) }) }) describe('When adding a custom app', () => { it('should show an error when the app manifest is invalid', () => { - cy.intercept('GET', 'https://my-invalid-custom-app.com/manifest.json', { - name: 'My Custom App', + cy.intercept('GET', constants.invalidAppUrl, { + name: constants.testAppData.name, }) - cy.findByText(/my custom Apps/i).click() - cy.findByText(/add custom Safe App/i).click({ force: true }) - cy.findByLabelText(/Safe App url/i) - .clear() - .type('https://my-invalid-custom-app.com') - cy.contains("The app doesn't support Safe App functionality").should('exist') + safeapps.clickOnCustomAppsTab() + safeapps.clickOnAddCustomApp() + safeapps.typeCustomAppUrl(constants.invalidAppUrl) + safeapps.verifyAppNotSupportedMsg() }) it('should be added to the list within the custom apps section', () => { - cy.intercept('GET', 'https://my-valid-custom-app.com/manifest.json', { - name: 'My Custom App', - description: 'My Custom App Description', + cy.intercept('GET', constants.validAppUrlJson, { + name: constants.testAppData.name, + description: constants.testAppData.descr, icons: [{ src: 'logo.svg', sizes: 'any', type: 'image/svg+xml' }], }) - cy.findByLabelText(/Safe App url/i) - .clear() - .type('https://my-valid-custom-app.com') - cy.findByRole('heading', { name: /my custom app/i }).should('exist') - cy.findByRole('checkbox').click() - cy.findByRole('button', { name: /add/i }).click() - cy.findByText('ALL (1)').should('exist') - cy.findByText(/my custom app description/i).should('exist') + safeapps.typeCustomAppUrl(constants.validAppUrl) + safeapps.verifyAppTitle(myCustomAppTitle) + safeapps.acceptTC() + safeapps.clickOnAddBtn() + safeapps.verifyAppCount(1) + safeapps.verifyAppDescription(myCustomAppDescrAdded) }) }) }) diff --git a/cypress/e2e/safe-apps/browser_permissions.cy.js b/cypress/e2e/safe-apps/browser_permissions.cy.js index 8e1d3d72a6..fc11c368c6 100644 --- a/cypress/e2e/safe-apps/browser_permissions.cy.js +++ b/cypress/e2e/safe-apps/browser_permissions.cy.js @@ -1,13 +1,16 @@ import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as safeapps from '../pages/safeapps.pages' describe('The Browser permissions system', () => { describe('When the safe app requires permissions', () => { beforeEach(() => { + cy.clearLocalStorage() cy.fixture('safe-app').then((html) => { - cy.intercept('GET', `${constants.appUrlProd}/*`, html) + cy.intercept('GET', `${constants.testAppUrl}/*`, html) cy.intercept('GET', `*/manifest.json`, { - name: 'Cypress Test App', - description: 'Cypress Test App Description', + name: constants.testAppData.name, + description: constants.testAppData.descr, icons: [{ src: 'logo.svg', sizes: 'any', type: 'image/svg+xml' }], safe_apps_permissions: ['camera', 'microphone'], }) @@ -15,23 +18,18 @@ describe('The Browser permissions system', () => { }) it('should show a permissions slide to the user', () => { - cy.visitSafeApp(`${constants.appUrlProd}/app`) - - cy.findByRole('checkbox', { name: /camera/i }).should('exist') - cy.findByRole('checkbox', { name: /microphone/i }).should('exist') + cy.visitSafeApp(`${constants.testAppUrl}/app`) + safeapps.verifyCameraCheckBoxExists() + safeapps.verifyMicrofoneCheckBoxExists() }) it('should allow to change, accept and store the selection', () => { - cy.findByText(/accept selection/i).click() + main.acceptCookies() + safeapps.verifyMicrofoneCheckBoxExists().click() - cy.findByRole('checkbox', { name: /microphone/i }).click() - cy.findByRole('button', { name: /continue/i }) - .click() - .should(() => { - expect(window.localStorage.getItem(constants.BROWSER_PERMISSIONS_KEY)).to.eq( - '{"https://safe-test-app.com":[{"feature":"camera","status":"granted"},{"feature":"microphone","status":"denied"}]}', - ) - }) + safeapps.clickOnContinueBtn().should(() => { + expect(window.localStorage.getItem(constants.BROWSER_PERMISSIONS_KEY)).to.eq(safeapps.localStorageItem) + }) }) }) }) diff --git a/cypress/e2e/safe-apps/info_modal.cy.js b/cypress/e2e/safe-apps/info_modal.cy.js index 1c73118b65..3ea79a148c 100644 --- a/cypress/e2e/safe-apps/info_modal.cy.js +++ b/cypress/e2e/safe-apps/info_modal.cy.js @@ -1,36 +1,28 @@ import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as safeapps from '../pages/safeapps.pages' describe('The Safe Apps info modal', () => { before(() => { - cy.visit(`/${constants.TEST_SAFE_2}/apps`, { failOnStatusCode: false }) - cy.findByText(/accept selection/i).click() + cy.clearLocalStorage() + cy.visit(constants.TEST_SAFE_2 + constants.appsUrl, { failOnStatusCode: false }) + main.acceptCookies() }) describe('when opening a Safe App', () => { it('should show the disclaimer', () => { - cy.findByRole('link', { name: /logo.*walletconnect/i }).click() - cy.findByRole('link', { name: /open Safe App/i }).click() - cy.findByRole('heading', { name: /disclaimer/i }).should('exist') + safeapps.clickOnApp(safeapps.logoWalletConnect) + safeapps.clickOnOpenSafeAppBtn() }) it('should show the permissions slide if the app require permissions', () => { - cy.findByRole('button', { name: /continue/i }).click() + safeapps.clickOnContinueBtn() cy.wait(500) // wait for the animation to finish - cy.findByRole('checkbox', { name: /camera/i }).should('exist') + safeapps.verifyCameraCheckBoxExists() }) it('should store the permissions and consents decision when accepted', () => { - cy.findByRole('button', { name: /continue/i }) - .click() - .should(() => { - const storedBrowserPermissions = JSON.parse(localStorage.getItem(constants.BROWSER_PERMISSIONS_KEY)) - const browserPermissions = Object.values(storedBrowserPermissions)[0][0] - const storedInfoModal = JSON.parse(localStorage.getItem(constants.INFO_MODAL_KEY)) - - expect(browserPermissions.feature).to.eq('camera') - expect(browserPermissions.status).to.eq('granted') - expect(storedInfoModal['5'].consentsAccepted).to.eq(true) - }) + safeapps.storeAndVerifyPermissions() }) }) }) diff --git a/cypress/e2e/safe-apps/permissions_settings.cy.js b/cypress/e2e/safe-apps/permissions_settings.cy.js index d1696f2931..629f117dc3 100644 --- a/cypress/e2e/safe-apps/permissions_settings.cy.js +++ b/cypress/e2e/safe-apps/permissions_settings.cy.js @@ -1,36 +1,41 @@ import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as safeapps from '../pages/safeapps.pages' let $dapps = [] +const app1 = 'https://app1.com' +const app3 = 'https://app3.com' describe('The Safe Apps permissions settings section', () => { before(() => { + cy.clearLocalStorage() cy.on('window:before:load', (window) => { window.localStorage.setItem( constants.BROWSER_PERMISSIONS_KEY, JSON.stringify({ - 'https://app1.com': [ + app1: [ { feature: 'camera', status: 'granted' }, { feature: 'fullscreen', status: 'granted' }, { feature: 'geolocation', status: 'granted' }, ], - 'https://app2.com': [{ feature: 'microphone', status: 'granted' }], - 'https://app3.com': [{ feature: 'camera', status: 'denied' }], + app2: [{ feature: 'microphone', status: 'granted' }], + app3: [{ feature: 'camera', status: 'denied' }], }), ) window.localStorage.setItem( constants.SAFE_PERMISSIONS_KEY, JSON.stringify({ - 'https://app2.com': [ + app2: [ { - invoker: 'https://app1.com', + invoker: app1, parentCapability: 'requestAddressBook', date: 1666103778276, caveats: [], }, ], - 'https://app4.com': [ + app4: [ { - invoker: 'https://app3.com', + invoker: app3, parentCapability: 'requestAddressBook', date: 1666103787026, caveats: [], @@ -40,8 +45,8 @@ describe('The Safe Apps permissions settings section', () => { ) }) - cy.visit(`${constants.TEST_SAFE_2}/settings/safe-apps`, { failOnStatusCode: false }) - cy.findByText(/accept selection/i).click() + cy.visit(constants.TEST_SAFE_2 + constants.appSettingsUrl, { failOnStatusCode: false }) + main.acceptCookies() }) it('should show the permissions configuration for each stored app', () => { @@ -50,78 +55,59 @@ describe('The Safe Apps permissions settings section', () => { describe('For each app', () => { before(() => { - cy.get('main .MuiPaper-root > .MuiGrid-item').then((items) => { + cy.get(safeapps.gridItem).then((items) => { $dapps = items }) }) it('app1 should have camera, full screen and geo permissions', () => { - cy.wrap($dapps[0]) - .findByText(/https:\/\/app1.com/i) - .should('exist') - cy.wrap($dapps[0]) - .findByText(/camera/i) - .should('exist') - cy.wrap($dapps[0]) - .findByText(/fullscreen/i) - .should('exist') - cy.wrap($dapps[0]) - .findByText(/geolocation/i) - .should('exist') - - cy.wrap($dapps[0]).findAllByRole('checkbox').should('have.checked') + const app1Data = [ + 'app1', + safeapps.permissionCheckboxNames.camera, + safeapps.permissionCheckboxNames.fullscreen, + safeapps.permissionCheckboxNames.geolocation, + ] + + main.checkTextsExistWithinElement($dapps[0], app1Data) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.camera, 0, constants.checkboxStates.checked) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.geolocation, 0, constants.checkboxStates.checked) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.fullscreen, 0, constants.checkboxStates.checked) }) it('app2 should have address book and microphone permissions', () => { - cy.wrap($dapps[1]) - .findByText(/https:\/\/app2.com/i) - .should('exist') - cy.wrap($dapps[1]) - .findByText(/address book/i) - .should('exist') - cy.wrap($dapps[1]) - .findByText(/microphone/i) - .should('exist') - - cy.wrap($dapps[1]).findAllByRole('checkbox').should('have.checked') + const app2Data = [ + 'app2', + safeapps.permissionCheckboxNames.addressbook, + safeapps.permissionCheckboxNames.microphone, + ] + + main.checkTextsExistWithinElement($dapps[1], app2Data) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.microphone, 0, constants.checkboxStates.checked) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.addressbook, 0, constants.checkboxStates.checked) }) it('app3 should have camera permissions', () => { - cy.wrap($dapps[2]) - .findByText(/https:\/\/app3.com/i) - .should('exist') - cy.wrap($dapps[2]) - .findByText(/camera/i) - .should('exist') + const app3Data = ['app3', safeapps.permissionCheckboxNames.camera] - cy.wrap($dapps[2]) - .findByLabelText(/camera/i) - .should('have.not.checked') + main.checkTextsExistWithinElement($dapps[2], app3Data) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.camera, 1, constants.checkboxStates.unchecked) }) it('app4 should have address book permissions', () => { - cy.wrap($dapps[3]) - .findByText(/https:\/\/app4.com/i) - .should('exist') - cy.wrap($dapps[3]) - .findByText(/address book/i) - .should('exist') - - cy.wrap($dapps[3]) - .findByLabelText(/address book/i) - .should('have.checked') + const app4Data = ['app4', safeapps.permissionCheckboxNames.addressbook] + + main.checkTextsExistWithinElement($dapps[3], app4Data) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.addressbook, 1, constants.checkboxStates.checked) }) it('should allow to allow all or clear all the checkboxes at once', () => { - cy.wrap($dapps[1]) - .findByText(/clear all/i) - .click() - cy.wrap($dapps[1]).findAllByRole('checkbox').should('have.not.checked') + safeapps.uncheckAllPermissions($dapps[1]) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.addressbook, 0, constants.checkboxStates.unchecked) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.microphone, 0, constants.checkboxStates.unchecked) - cy.wrap($dapps[1]) - .findByText(/allow all/i) - .click() - cy.wrap($dapps[1]).findAllByRole('checkbox').should('have.checked') + safeapps.checkAllPermissions($dapps[1]) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.addressbook, 0, constants.checkboxStates.checked) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.microphone, 0, constants.checkboxStates.checked) }) it('should allow to remove apps and reflect it in the localStorage', () => { diff --git a/cypress/e2e/safe-apps/preview_drawer.cy.js b/cypress/e2e/safe-apps/preview_drawer.cy.js index 0514e8e49b..ed0b366935 100644 --- a/cypress/e2e/safe-apps/preview_drawer.cy.js +++ b/cypress/e2e/safe-apps/preview_drawer.cy.js @@ -1,24 +1,27 @@ import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as safeapps from '../pages/safeapps.pages' describe('The Safe Apps info modal', () => { before(() => { + cy.clearLocalStorage() cy.visit(`/${constants.TEST_SAFE_2}/apps`, { failOnStatusCode: false }) - cy.findByText(/accept selection/i).click() + main.acceptCookies() }) describe('when opening a Safe App from the app list', () => { it('should show the preview drawer', () => { - cy.findByRole('link', { name: /logo.*walletconnect/i }).click() - cy.findByRole('presentation').within((presentation) => { - cy.findByRole('heading', { name: /walletconnect/i }).should('exist') - cy.findByText('Connect your Safe to any dApp that supports WalletConnect').should('exist') - cy.findByText(/available networks/i).should('exist') - cy.findByLabelText(/pin walletconnect/i).click() - cy.findByLabelText(/unpin walletconnect/i) - .should('exist') - .click() - cy.findByLabelText(/pin walletconnect/i).should('exist') - cy.findByLabelText(/close walletconnect preview/i).click() + safeapps.clickOnApp(safeapps.logoWalletConnect) + + cy.findByRole('presentation').within(() => { + safeapps.verifyPreviewWindow( + safeapps.walletConnectHeadlinePreview, + safeapps.connecttextPreview, + safeapps.availableNetworksPreview, + ) + safeapps.pinApp(safeapps.pinWalletConnectStr) + safeapps.pinApp(safeapps.pinWalletConnectStr, false) + safeapps.closePreviewWindow() }) cy.findByRole('presentation').should('not.exist') }) diff --git a/cypress/e2e/safe-apps/safe_permissions.cy.js b/cypress/e2e/safe-apps/safe_permissions.cy.js index 26b775b669..33e4f74c49 100644 --- a/cypress/e2e/safe-apps/safe_permissions.cy.js +++ b/cypress/e2e/safe-apps/safe_permissions.cy.js @@ -1,12 +1,16 @@ import * as constants from '../../support/constants' +import * as safeapps from '../pages/safeapps.pages' describe('The Safe permissions system', () => { + before(() => { + cy.clearLocalStorage() + }) beforeEach(() => { cy.fixture('safe-app').then((html) => { - cy.intercept('GET', `${constants.appUrlProd}/*`, html) + cy.intercept('GET', `${constants.testAppUrl}/*`, html) cy.intercept('GET', `*/manifest.json`, { - name: 'Cypress Test App', - description: 'Cypress Test App Description', + name: constants.testAppData.name, + description: constants.testAppData.descr, icons: [{ src: 'logo.svg', sizes: 'any', type: 'image/svg+xml' }], }) }) @@ -14,17 +18,15 @@ describe('The Safe permissions system', () => { describe('When requesting permissions with wallet_requestPermissions', () => { it('should show the permissions prompt and return the permissions on accept', () => { - cy.visitSafeApp(`${constants.appUrlProd}/request-permissions`) - - cy.findByRole('heading', { name: /permissions request/i }).should('exist') - cy.findByText(/access to your address book/i).should('exist') - - cy.findByRole('button', { name: /accept/i }).click() + cy.visitSafeApp(constants.testAppUrl + constants.requestPermissionsUrl) + safeapps.verifyPermissionsRequestExists() + safeapps.verifyAccessToAddressBookExists() + safeapps.clickOnAcceptBtn() cy.get('@safeAppsMessage').should('have.been.calledWithMatch', { data: [ { - invoker: 'https://safe-test-app.com', + invoker: constants.testAppUrl, parentCapability: 'requestAddressBook', date: Cypress.sinon.match.number, caveats: [], @@ -40,9 +42,9 @@ describe('The Safe permissions system', () => { window.localStorage.setItem( constants.SAFE_PERMISSIONS_KEY, JSON.stringify({ - [constants.appUrlProd]: [ + [constants.testAppUrl]: [ { - invoker: constants.appUrlProd, + invoker: constants.testAppUrl, parentCapability: 'requestAddressBook', date: 1111111111111, caveats: [], @@ -52,12 +54,12 @@ describe('The Safe permissions system', () => { ) }) - cy.visitSafeApp(`${constants.appUrlProd}/get-permissions`) + cy.visitSafeApp(constants.testAppUrl + constants.getPermissionsUrl) cy.get('@safeAppsMessage').should('have.been.calledWithMatch', { data: [ { - invoker: constants.appUrlProd, + invoker: constants.testAppUrl, parentCapability: 'requestAddressBook', date: Cypress.sinon.match.number, caveats: [], diff --git a/cypress/e2e/safe-apps/tx_modal.cy.js b/cypress/e2e/safe-apps/tx_modal.cy.js index 9ddc34b2a2..abbaced9c2 100644 --- a/cypress/e2e/safe-apps/tx_modal.cy.js +++ b/cypress/e2e/safe-apps/tx_modal.cy.js @@ -1,12 +1,19 @@ import * as constants from '../../support/constants' +import * as main from '../pages/main.page' + +const testAppName = 'Cypress Test App' +const testAppDescr = 'Cypress Test App Description' describe('The transaction modal', () => { + before(() => { + cy.clearLocalStorage() + }) beforeEach(() => { cy.fixture('safe-app').then((html) => { - cy.intercept('GET', `${constants.appUrlProd}/*`, html) + cy.intercept('GET', `${constants.testAppUrl}/*`, html) cy.intercept('GET', `*/manifest.json`, { - name: 'Cypress Test App', - description: 'Cypress Test App Description', + name: testAppName, + description: testAppDescr, icons: [{ src: 'logo.svg', sizes: 'any', type: 'image/svg+xml' }], }) }) @@ -14,11 +21,11 @@ describe('The transaction modal', () => { describe('When sending a transaction from an app', () => { it('should show the transaction popup', { defaultCommandTimeout: 12000 }, () => { - cy.visitSafeApp(`${constants.appUrlProd}/dummy`) + cy.visitSafeApp(`${constants.testAppUrl}/dummy`) - cy.findByText(/accept selection/i).click() + main.acceptCookies() cy.findByRole('dialog').within(() => { - cy.findByText(/Cypress Test App/i) + cy.findByText(testAppName) }) }) }) diff --git a/cypress/e2e/smoke/add_owner.cy.js b/cypress/e2e/smoke/add_owner.cy.js new file mode 100644 index 0000000000..e47893e9c1 --- /dev/null +++ b/cypress/e2e/smoke/add_owner.cy.js @@ -0,0 +1,111 @@ +import * as constants from '../../support/constants' +import * as main from '../../e2e/pages/main.page' +import * as owner from '../pages/owners.pages' +import * as addressBook from '../pages/address_book.page' + +// TODO: Need to add tests to testRail +describe('Adding an owner', () => { + beforeEach(() => { + cy.visit(constants.setupUrl + constants.SEPOLIA_TEST_SAFE_1) + cy.clearLocalStorage() + main.acceptCookies() + cy.contains(owner.safeAccountNonceStr, { timeout: 10000 }) + }) + + describe('Add new owner tests', () => { + it('Verify the presence of "Add Owner" button', () => { + owner.verifyAddOwnerBtnIsEnabled() + }) + + it('Verify “Add new owner” button tooltip displays correct message for Non-Owner', () => { + cy.visit(constants.setupUrl + constants.SEPOLIA_TEST_SAFE_2) + owner.hoverOverAddOwnerBtn() + owner.verifyTooltiptext(owner.nonOwnerErrorMsg) + }) + + it('Verify Tooltip displays correct message for disconnected user', () => { + owner.waitForConnectionStatus() + owner.clickOnWalletExpandMoreIcon() + owner.clickOnDisconnectBtn() + owner.hoverOverAddOwnerBtn() + owner.verifyTooltiptext(owner.disconnectedUserErrorMsg) + }) + it('Verify the Add New Owner Form can be opened', () => { + owner.waitForConnectionStatus() + owner.openAddOwnerWindow() + }) + + it('Verify error message displayed if character limit is exceeded in Name input', () => { + owner.waitForConnectionStatus() + owner.openAddOwnerWindow() + owner.typeOwnerName(main.generateRandomString(51)) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.exceedChars) + }) + + it('Verify that the "Name" field is auto-filled with the relevant name from Address Book', () => { + cy.visit(constants.addressBookUrl + constants.SEPOLIA_TEST_SAFE_1) + addressBook.clickOnCreateEntryBtn() + addressBook.typeInName(constants.addresBookContacts.user1.name) + addressBook.typeInAddress(constants.addresBookContacts.user1.address) + addressBook.clickOnSaveEntryBtn() + addressBook.verifyNewEntryAdded( + constants.addresBookContacts.user1.name, + constants.addresBookContacts.user1.address, + ) + cy.visit(constants.setupUrl + constants.SEPOLIA_TEST_SAFE_1) + owner.waitForConnectionStatus() + owner.openAddOwnerWindow() + owner.typeOwnerAddress(constants.addresBookContacts.user1.address) + owner.selectNewOwner(constants.addresBookContacts.user1.name) + owner.verifyNewOwnerName(constants.addresBookContacts.user1.name) + }) + + it('Verify that Name field not mandatory', () => { + owner.waitForConnectionStatus() + owner.openAddOwnerWindow() + owner.typeOwnerAddress(constants.SEPOLIA_OWNER_2) + owner.clickOnNextBtn() + owner.verifyConfirmTransactionWindowDisplayed() + }) + + it('Verify relevant error messages are displayed in Address input ', () => { + owner.waitForConnectionStatus() + owner.openAddOwnerWindow() + owner.typeOwnerAddress(main.generateRandomString(10)) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidFormat) + + owner.typeOwnerAddress(constants.addresBookContacts.user1.address.toUpperCase()) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidChecksum) + + owner.typeOwnerAddress(constants.SEPOLIA_TEST_SAFE_1) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.ownSafe) + + owner.typeOwnerAddress(constants.addresBookContacts.user1.address.replace('F', 'f')) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidChecksum) + + owner.typeOwnerAddress(constants.DEFAULT_OWNER_ADDRESS) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.alreadyAdded) + }) + + it('Verify default threshold value. Verify correct threshold calculation', () => { + owner.waitForConnectionStatus() + owner.openAddOwnerWindow() + owner.typeOwnerAddress(constants.DEFAULT_OWNER_ADDRESS) + owner.verifyThreshold(1, 2) + }) + + it('Verify valid Address validation', () => { + owner.waitForConnectionStatus() + owner.openAddOwnerWindow() + owner.typeOwnerAddress(constants.SEPOLIA_OWNER_2) + owner.clickOnNextBtn() + owner.verifyConfirmTransactionWindowDisplayed() + cy.reload() + owner.waitForConnectionStatus() + owner.openAddOwnerWindow() + owner.typeOwnerAddress(constants.SEPOLIA_TEST_SAFE_2) + owner.clickOnNextBtn() + owner.verifyConfirmTransactionWindowDisplayed() + }) + }) +}) diff --git a/cypress/e2e/smoke/address_book.cy.js b/cypress/e2e/smoke/address_book.cy.js index b64b9a72a7..6fcabfe2e7 100644 --- a/cypress/e2e/smoke/address_book.cy.js +++ b/cypress/e2e/smoke/address_book.cy.js @@ -10,6 +10,7 @@ const EDITED_NAME = 'Edited Owner1' describe('Address book tests', () => { before(() => { + cy.clearLocalStorage() cy.visit(constants.addressBookUrl + constants.GOERLI_TEST_SAFE) main.acceptCookies() }) @@ -17,7 +18,7 @@ describe('Address book tests', () => { describe('should add remove and edit entries in the address book', () => { it('should add an entry', () => { addressBook.clickOnCreateEntryBtn() - addressBook.tyeInName(NAME) + addressBook.typeInName(NAME) addressBook.typeInAddress(constants.RECIPIENT_ADDRESS) addressBook.clickOnSaveEntryBtn() addressBook.verifyNewEntryAdded(NAME, constants.RECIPIENT_ADDRESS) diff --git a/cypress/e2e/smoke/balances.cy.js b/cypress/e2e/smoke/balances.cy.js index 320aa52c6a..bc6d0468b7 100644 --- a/cypress/e2e/smoke/balances.cy.js +++ b/cypress/e2e/smoke/balances.cy.js @@ -1,5 +1,6 @@ import * as constants from '../../support/constants' import * as balances from '../pages/balances.pages' +import * as main from '../../e2e/pages/main.page' const ASSETS_LENGTH = 8 const ASSET_NAME_COLUMN = 0 @@ -11,9 +12,10 @@ describe('Assets > Coins', () => { const fiatRegex = new RegExp(`([0-9]{1,3},)*[0-9]{1,3}.[0-9]{2}`) before(() => { + cy.clearLocalStorage() // Open the Safe used for testing - cy.visit(`/balances?safe=${constants.GOERLI_TEST_SAFE}`) - cy.contains('button', 'Accept selection').click() + cy.visit(constants.BALANCE_URL + constants.GOERLI_TEST_SAFE) + main.acceptCookies() // Table is loaded cy.contains('Görli Ether') diff --git a/cypress/e2e/smoke/balances_pagination.cy.js b/cypress/e2e/smoke/balances_pagination.cy.js index ceaeca7652..63202a9bd4 100644 --- a/cypress/e2e/smoke/balances_pagination.cy.js +++ b/cypress/e2e/smoke/balances_pagination.cy.js @@ -5,6 +5,7 @@ const ASSETS_LENGTH = 8 describe('Balance pagination tests', () => { before(() => { + cy.clearLocalStorage() // Open the Safe used for testing cy.visit(constants.BALANCE_URL + constants.PAGINATION_TEST_SAFE) cy.contains('button', 'Accept selection').click() diff --git a/cypress/e2e/smoke/batch_tx.cy.js b/cypress/e2e/smoke/batch_tx.cy.js index a410ae6986..ef9baa461b 100644 --- a/cypress/e2e/smoke/batch_tx.cy.js +++ b/cypress/e2e/smoke/batch_tx.cy.js @@ -8,6 +8,7 @@ const funds_second_tx = '0.002' describe('Create batch transaction', () => { before(() => { + cy.clearLocalStorage() cy.visit(constants.homeUrl + constants.TEST_SAFE) main.acceptCookies() cy.contains(constants.goerlyE2EWallet, { timeout: 10000 }) diff --git a/cypress/e2e/smoke/beamer.cy.js b/cypress/e2e/smoke/beamer.cy.js index 7266dea955..412a8b5d8a 100644 --- a/cypress/e2e/smoke/beamer.cy.js +++ b/cypress/e2e/smoke/beamer.cy.js @@ -1,31 +1,22 @@ import * as constants from '../../support/constants' +import * as addressbook from '../pages/address_book.page' +import * as main from '../../e2e/pages/main.page' describe('Beamer', () => { - it('should require accept "Updates" cookies to display Beamer', () => { - // Disable PWA, otherwise it will throw a security error - cy.visit(`/address-book?safe=${constants.GOERLI_TEST_SAFE}`) - - // Way to select the cookies banner without an id - cy.contains('Accept selection').click() - - // Open What's new - cy.contains("What's new").click() - - // Tells that the user has to accept "Beamer" cookies - cy.contains('accept the "Beamer" cookies') - - // "Beamer" is checked when the banner opens - cy.get('input[id="beamer"]').should('be.checked') - // Accept "Updates & Feedback" cookies - cy.contains('Accept selection').click() - cy.contains('Accept selection').should('not.exist') + before(() => { + cy.clearLocalStorage() + cy.visit(constants.addressBookUrl + constants.GOERLI_TEST_SAFE) + main.acceptCookies() + }) + it.skip('should require accept "Updates" cookies to display Beamer', () => { + addressbook.clickOnWhatsNewBtn() + addressbook.acceptBeamerCookies() + addressbook.verifyBeamerIsChecked() + main.acceptCookies() // wait for Beamer cookies to be set - cy.wait(600) - - // Open What's new - cy.contains("What's new").click({ force: true }) // clicks through the "lastPostTitle" - - cy.get('#beamerOverlay .iframeCointaner').should('exist') + cy.wait(1000) + addressbook.clickOnWhatsNewBtn(true) // clicks through the "lastPostTitle" + addressbook.verifyBeameriFrameExists() }) }) diff --git a/cypress/e2e/smoke/create_safe_simple.cy.js b/cypress/e2e/smoke/create_safe_simple.cy.js index da63618b5e..7d7b78dfe4 100644 --- a/cypress/e2e/smoke/create_safe_simple.cy.js +++ b/cypress/e2e/smoke/create_safe_simple.cy.js @@ -8,6 +8,7 @@ const ownerName2 = 'Test Owner Name 2' describe('Create Safe form', () => { it('should navigate to the form', () => { + cy.clearLocalStorage() cy.visit(constants.welcomeUrl) main.acceptCookies() main.verifyGoerliWalletHeader() diff --git a/cypress/e2e/smoke/create_tx.cy.js b/cypress/e2e/smoke/create_tx.cy.js index ab7d6b3d29..0e1dab05cb 100644 --- a/cypress/e2e/smoke/create_tx.cy.js +++ b/cypress/e2e/smoke/create_tx.cy.js @@ -7,6 +7,7 @@ const currentNonce = 3 describe('Queue a transaction on 1/N', () => { before(() => { + cy.clearLocalStorage() cy.visit(constants.homeUrl + constants.TEST_SAFE) main.acceptCookies() }) diff --git a/cypress/e2e/smoke/dashboard.cy.js b/cypress/e2e/smoke/dashboard.cy.js index 88f4249aa5..f4ce1b6ec1 100644 --- a/cypress/e2e/smoke/dashboard.cy.js +++ b/cypress/e2e/smoke/dashboard.cy.js @@ -4,6 +4,7 @@ import * as main from '../pages/main.page' describe('Dashboard', () => { before(() => { + cy.clearLocalStorage() cy.visit(constants.homeUrl + constants.TEST_SAFE) main.acceptCookies() dashboard.verifyConnectTransactStrIsVisible() diff --git a/cypress/e2e/smoke/import_export_data.cy.js b/cypress/e2e/smoke/import_export_data.cy.js index 5cc2636ca9..55ea6fedae 100644 --- a/cypress/e2e/smoke/import_export_data.cy.js +++ b/cypress/e2e/smoke/import_export_data.cy.js @@ -5,6 +5,7 @@ import * as constants from '../../support/constants' describe('Import Export Data', () => { before(() => { + cy.clearLocalStorage() cy.visit(constants.welcomeUrl) main.acceptCookies() file.verifyImportBtnIsVisible() @@ -19,6 +20,7 @@ describe('Import Export Data', () => { file.verifyImportModalData() file.clickOnImportBtnDataImportModal() file.clickOnImportedSafe(safe) + file.clickOnClosePushNotificationsBanner() }) it("Verify safe's address book imported data", () => { @@ -44,6 +46,8 @@ describe('Import Export Data', () => { }) it('Verifies data for export in Data tab', () => { + file.clickOnShowMoreTabsBtn() + file.verifDataTabBtnIsVisible() file.clickOnDataTab() file.verifyImportModalData() file.verifyFileDownload() diff --git a/cypress/e2e/smoke/landing.cy.js b/cypress/e2e/smoke/landing.cy.js index 18da5edffb..751f1e6cfa 100644 --- a/cypress/e2e/smoke/landing.cy.js +++ b/cypress/e2e/smoke/landing.cy.js @@ -1,6 +1,7 @@ import * as constants from '../../support/constants' describe('Landing page', () => { it('redirects to welcome page', () => { + cy.clearLocalStorage() cy.visit('/') cy.url().should('include', constants.welcomeUrl) }) diff --git a/cypress/e2e/smoke/load_safe.cy.js b/cypress/e2e/smoke/load_safe.cy.js index 4456d60a6c..b346898ccb 100644 --- a/cypress/e2e/smoke/load_safe.cy.js +++ b/cypress/e2e/smoke/load_safe.cy.js @@ -20,12 +20,17 @@ const OWNER_ADDRESS = constants.EOA describe('Load existing Safe', () => { before(() => { - cy.visit(constants.chainMaticUrl) + cy.clearLocalStorage() + cy.visit(constants.welcomeUrl) main.acceptCookies() safe.openLoadSafeForm() + cy.wait(2000) }) it('should allow choosing the network where the Safe exists', () => { + safe.clickNetworkSelector(constants.networks.goerli) + safe.selectPolygon() + cy.wait(2000) safe.clickNetworkSelector(constants.networks.polygon) safe.selectGoerli() }) diff --git a/cypress/e2e/smoke/nfts.cy.js b/cypress/e2e/smoke/nfts.cy.js index b920c6c1ed..55205fadb6 100644 --- a/cypress/e2e/smoke/nfts.cy.js +++ b/cypress/e2e/smoke/nfts.cy.js @@ -9,6 +9,7 @@ const nftsLink = 'https://testnets.opensea.io/assets/0x000000000faE8c6069596c9C8 describe('Assets > NFTs', () => { before(() => { + cy.clearLocalStorage() cy.visit(constants.balanceNftsUrl + constants.GOERLI_TEST_SAFE) main.acceptCookies() cy.contains(constants.goerlyE2EWallet) diff --git a/cypress/e2e/smoke/pending_actions.cy.js b/cypress/e2e/smoke/pending_actions.cy.js index 73f7a6a9ef..6f8a9edc81 100644 --- a/cypress/e2e/smoke/pending_actions.cy.js +++ b/cypress/e2e/smoke/pending_actions.cy.js @@ -1,13 +1,14 @@ import * as constants from '../../support/constants' import * as safe from '../pages/load_safe.pages' -import * as main from '../pages/main.page' describe('Pending actions', () => { before(() => { cy.visit(constants.welcomeUrl) - main.acceptCookies() + // main.acceptCookies() }) + //TODO: Discuss test logic + beforeEach(() => { // Uses the previously saved local storage // to preserve the wallet connection between tests @@ -18,7 +19,7 @@ describe('Pending actions', () => { cy.saveLocalStorageCache() }) - it('should add the Safe with the pending actions', () => { + it.skip('should add the Safe with the pending actions', () => { safe.openLoadSafeForm() safe.inputAddress(constants.TEST_SAFE) safe.clickOnNextBtn() @@ -27,7 +28,7 @@ describe('Pending actions', () => { safe.clickOnAddBtn() }) - it('should display the pending actions in the Safe list sidebar', () => { + it.skip('should display the pending actions in the Safe list sidebar', () => { safe.openSidebar() safe.verifyAddressInsidebar(constants.SIDEBAR_ADDRESS) safe.verifySidebarIconNumber(1) @@ -35,7 +36,7 @@ describe('Pending actions', () => { //cy.get('img[alt="E2E Wallet logo"]').next().contains('2').should('exist') }) - it('should have the right number of queued and signable transactions', () => { + it.skip('should have the right number of queued and signable transactions', () => { safe.verifyTransactionSectionIsVisible() safe.verifyNumberOfTransactions(1, 1) }) diff --git a/cypress/e2e/smoke/remove_owner.cy.js b/cypress/e2e/smoke/remove_owner.cy.js new file mode 100644 index 0000000000..4f2ac62d60 --- /dev/null +++ b/cypress/e2e/smoke/remove_owner.cy.js @@ -0,0 +1,54 @@ +import * as constants from '../../support/constants' +import * as main from '../../e2e/pages/main.page' +import * as owner from '../pages/owners.pages' + +describe('Remove an owner tests', () => { + beforeEach(() => { + cy.visit(constants.setupUrl + constants.SEPOLIA_TEST_SAFE_1) + cy.clearLocalStorage() + main.acceptCookies() + cy.contains(owner.safeAccountNonceStr, { timeout: 10000 }) + }) + + it('Verify that "Remove" icon is visible', () => { + cy.visit(constants.setupUrl + constants.SEPOLIA_TEST_SAFE_3) + owner.verifyRemoveBtnIsEnabled().should('have.length', 2) + }) + + it('Verify Tooltip displays correct message for Non-Owner', () => { + cy.visit(constants.setupUrl + constants.SEPOLIA_TEST_SAFE_4) + owner.waitForConnectionStatus() + owner.hoverOverDeleteOwnerBtn(0) + owner.verifyTooltipLabel(owner.nonOwnerErrorMsg) + }) + + it('Verify Tooltip displays correct message for disconnected user', () => { + cy.visit(constants.setupUrl + constants.SEPOLIA_TEST_SAFE_3) + owner.waitForConnectionStatus() + owner.clickOnWalletExpandMoreIcon() + owner.clickOnDisconnectBtn() + owner.hoverOverDeleteOwnerBtn(0) + owner.verifyTooltipLabel(owner.disconnectedUserErrorMsg) + }) + + it('Verify owner removal form can be opened', () => { + cy.visit(constants.setupUrl + constants.SEPOLIA_TEST_SAFE_3) + owner.waitForConnectionStatus() + owner.openRemoveOwnerWindow(1) + }) + + it('Verify threshold input displays the upper limit as the current safe number of owners minus one', () => { + cy.visit(constants.setupUrl + constants.SEPOLIA_TEST_SAFE_3) + owner.waitForConnectionStatus() + owner.openRemoveOwnerWindow(1) + owner.verifyThresholdLimit(1, 1) + }) + + it('Verify owner deletion confirmation is displayed ', () => { + cy.visit(constants.setupUrl + constants.SEPOLIA_TEST_SAFE_3) + owner.waitForConnectionStatus() + owner.openRemoveOwnerWindow(1) + owner.clickOnNextBtn() + owner.verifyOwnerDeletionWindowDisplayed() + }) +}) diff --git a/cypress/e2e/smoke/replace_owner.cy.js b/cypress/e2e/smoke/replace_owner.cy.js new file mode 100644 index 0000000000..dab3cd3e59 --- /dev/null +++ b/cypress/e2e/smoke/replace_owner.cy.js @@ -0,0 +1,86 @@ +import * as constants from '../../support/constants' +import * as main from '../../e2e/pages/main.page' +import * as owner from '../pages/owners.pages' +import * as addressBook from '../pages/address_book.page' + +describe('Replace an owner tests', () => { + beforeEach(() => { + cy.visit(constants.setupUrl + constants.SEPOLIA_TEST_SAFE_1) + cy.clearLocalStorage() + main.acceptCookies() + cy.contains(owner.safeAccountNonceStr, { timeout: 10000 }) + }) + + it('Verify that "Replace" icon is visible', () => { + owner.verifyReplaceBtnIsEnabled() + }) + + it('Verify Tooltip displays correct message for Non-Owner', () => { + cy.visit(constants.setupUrl + constants.SEPOLIA_TEST_SAFE_2) + owner.waitForConnectionStatus() + owner.hoverOverReplaceOwnerBtn() + owner.verifyTooltipLabel(owner.nonOwnerErrorMsg) + }) + + it('Verify Tooltip displays correct message for disconnected user', () => { + owner.waitForConnectionStatus() + owner.clickOnWalletExpandMoreIcon() + owner.clickOnDisconnectBtn() + owner.hoverOverReplaceOwnerBtn() + owner.verifyTooltipLabel(owner.disconnectedUserErrorMsg) + }) + + it('Verify that the owner replacement form is opened', () => { + owner.waitForConnectionStatus() + owner.openReplaceOwnerWindow() + }) + + it('Verify max characters in name field', () => { + owner.waitForConnectionStatus() + owner.openReplaceOwnerWindow() + owner.typeOwnerName(main.generateRandomString(51)) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.exceedChars) + }) + + it('Verify that Address input auto-fills with related value', () => { + cy.visit(constants.addressBookUrl + constants.SEPOLIA_TEST_SAFE_1) + addressBook.clickOnCreateEntryBtn() + addressBook.typeInName(constants.addresBookContacts.user1.name) + addressBook.typeInAddress(constants.addresBookContacts.user1.address) + addressBook.clickOnSaveEntryBtn() + addressBook.verifyNewEntryAdded(constants.addresBookContacts.user1.name, constants.addresBookContacts.user1.address) + cy.visit(constants.setupUrl + constants.SEPOLIA_TEST_SAFE_1) + owner.waitForConnectionStatus() + owner.openReplaceOwnerWindow() + owner.typeOwnerAddress(constants.addresBookContacts.user1.address) + owner.selectNewOwner(constants.addresBookContacts.user1.name) + owner.verifyNewOwnerName(constants.addresBookContacts.user1.name) + }) + + it('Verify that Name field not mandatory. Verify confirmation for owner replacement is displayed', () => { + owner.waitForConnectionStatus() + owner.openReplaceOwnerWindow() + owner.typeOwnerAddress(constants.SEPOLIA_OWNER_2) + owner.clickOnNextBtn() + owner.verifyConfirmTransactionWindowDisplayed() + }) + + it('Verify that Name field not mandatory', () => { + owner.waitForConnectionStatus() + owner.openReplaceOwnerWindow() + owner.typeOwnerAddress(main.generateRandomString(10)) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidFormat) + + owner.typeOwnerAddress(constants.addresBookContacts.user1.address.toUpperCase()) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidChecksum) + + owner.typeOwnerAddress(constants.SEPOLIA_TEST_SAFE_1) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.ownSafe) + + owner.typeOwnerAddress(constants.addresBookContacts.user1.address.replace('F', 'f')) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidChecksum) + + owner.typeOwnerAddress(constants.DEFAULT_OWNER_ADDRESS) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.alreadyAdded) + }) +}) diff --git a/cypress/e2e/smoke/tx_history.cy.js b/cypress/e2e/smoke/tx_history.cy.js index 8f61bcf5fc..482840fc1e 100644 --- a/cypress/e2e/smoke/tx_history.cy.js +++ b/cypress/e2e/smoke/tx_history.cy.js @@ -1,25 +1,37 @@ import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as createTx from '../pages/create_tx.pages' -const INCOMING = 'Receive' -const OUTGOING = 'Send' +const INCOMING = 'Received' +const OUTGOING = 'Sent' const CONTRACT_INTERACTION = 'Contract interaction' +const str1 = 'True' +const str2 = '1337' +const str3 = '5688' + describe('Transaction history', () => { before(() => { + cy.clearLocalStorage() // Go to the test Safe transaction history - cy.visit(`/transactions/history?safe=${constants.GOERLI_TEST_SAFE}`) - cy.contains('button', 'Accept selection').click() + cy.visit(constants.transactionsHistoryUrl + constants.GOERLI_TEST_SAFE) + main.acceptCookies() }) it('should display October 9th transactions', () => { const DATE = 'Oct 9, 2022' const NEXT_DATE_LABEL = 'Feb 8, 2022' - - // Date label - cy.contains('div', DATE).should('exist') - - // Next date label - cy.contains('div', NEXT_DATE_LABEL).scrollIntoView() + const amount = '0.25 GOR' + const amount2 = '0.11 WETH' + const amount3 = '120,497.61 DAI' + const time = '4:56 PM' + const time2 = '4:59 PM' + const time3 = '5:00 PM' + const time4 = '5:01 PM' + const success = 'Success' + + createTx.verifyDateExists(DATE) + createTx.verifyDateExists(NEXT_DATE_LABEL) // Transaction summaries from October 9th const rows = cy.contains('div', DATE).nextUntil(`div:contains(${NEXT_DATE_LABEL})`) @@ -31,130 +43,77 @@ describe('Transaction history', () => { .last() .within(() => { // Type - cy.get('img').should('have.attr', 'alt', 'Received') - cy.contains('div', INCOMING).should('exist') + createTx.verifyImageAltTxt(0, INCOMING) + createTx.verifyStatus(constants.transactionStatus.received) // Info - cy.get('img[alt="GOR"]').should('be.visible') - cy.contains('span', '0.25 GOR').should('exist') - - // Time - cy.contains('span', '4:56 PM').should('exist') - - // Status - cy.contains('span', 'Success').should('exist') + createTx.verifyImageAltTxt(1, constants.tokenAbbreviation.gor) + createTx.verifyTransactionStrExists(amount) + createTx.verifyTransactionStrExists(time) + createTx.verifyTransactionStrExists(success) }) // CowSwap deposit of Wrapped Ether .prev() .within(() => { - // Nonce - cy.contains('0') - - // Type + createTx.verifyTransactionStrExists('0') // TODO: update next line after fixing the logo // cy.find('img').should('have.attr', 'src').should('include', WRAPPED_ETH) - cy.contains('div', 'Wrapped Ether').should('exist') - - // Info - cy.contains('div', 'deposit').should('exist') - - // Time - cy.contains('span', '4:59 PM').should('exist') - - // Status - cy.contains('span', 'Success').should('exist') + createTx.verifyTransactionStrExists(constants.tokenNames.wrappedEther) + createTx.verifyTransactionStrExists(constants.transactionStatus.deposit) + createTx.verifyTransactionStrExists(time2) + createTx.verifyTransactionStrExists(constants.transactionStatus.success) }) // CowSwap approval of Wrapped Ether .prev() .within(() => { - // Nonce - cy.contains('1') - + createTx.verifyTransactionStrExists('1') // Type // TODO: update next line after fixing the logo // cy.find('img').should('have.attr', 'src').should('include', WRAPPED_ETH) - cy.contains('div', 'WETH').should('exist') - - cy.contains('div', 'unlimited').should('exist') - - // Info - cy.contains('div', 'Approve').should('exist') - - // Time - cy.contains('span', '5:00 PM').should('exist') - - // Status - cy.contains('span', 'Success').should('exist') + createTx.verifyTransactionStrExists(constants.transactionStatus.approve) + createTx.verifyTransactionStrExists(time3) + createTx.verifyTransactionStrExists(constants.transactionStatus.success) }) // Contract interaction .prev() .within(() => { - // Nonce - cy.contains('2') - - // Type - cy.contains('div', 'Contract interaction').should('exist') - - // Time - cy.contains('span', '5:01 PM').should('exist') - - // Status - cy.contains('span', 'Success').should('exist') + createTx.verifyTransactionStrExists('2') + createTx.verifyTransactionStrExists(constants.transactionStatus.interaction) + createTx.verifyTransactionStrExists(time4) + createTx.verifyTransactionStrExists(constants.transactionStatus.success) }) // Send 0.11 WETH .prev() .within(() => { - // Type - cy.get('img').should('have.attr', 'alt', 'Sent') - cy.contains('div', 'Send').should('exist') - - // Info - cy.contains('span', '0.11 WETH').should('exist') - - // Time - cy.contains('span', '5:01 PM').should('exist') - - // Status - cy.contains('span', 'Success').should('exist') + createTx.verifyImageAltTxt(0, OUTGOING) + createTx.verifyTransactionStrExists(constants.transactionStatus.sent) + createTx.verifyTransactionStrExists(amount2) + createTx.verifyTransactionStrExists(time4) + createTx.verifyTransactionStrExists(constants.transactionStatus.success) }) // Receive 120 DAI .prev() .within(() => { - // Type - cy.contains('div', INCOMING).should('exist') - - // Info - cy.contains('span', '120,497.61 DAI').should('exist') - - // Time - cy.contains('span', '5:01 PM').should('exist') - - // Status - cy.contains('span', 'Success').should('exist') + createTx.verifyTransactionStrExists(constants.transactionStatus.received) + createTx.verifyTransactionStrExists(amount3) + createTx.verifyTransactionStrExists(time4) + createTx.verifyTransactionStrExists(constants.transactionStatus.success) }) }) it('should expand/collapse all actions', () => { - // Open the tx details - cy.contains('div', 'Mar 24, 2023') - .next() - .click() - .within(() => { - cy.contains('True').should('not.be.visible') - cy.contains('1337').should('not.be.visible') - cy.contains('5688').should('not.be.visible') - cy.contains('Expand all').click() - - // All the values in the actions must be visible - cy.contains('True').should('exist') - cy.contains('1337').should('exist') - cy.contains('5688').should('exist') - - // After collapse all the same values should not be visible - cy.contains('Collapse all').click() - cy.contains('True').should('not.be.visible') - cy.contains('1337').should('not.be.visible') - cy.contains('5688').should('not.be.visible') - }) + createTx.clickOnTransactionExpandableItem('Mar 24, 2023', () => { + createTx.verifyTransactionStrNotVible(str1) + createTx.verifyTransactionStrNotVible(str2) + createTx.verifyTransactionStrNotVible(str3) + createTx.clickOnExpandAllBtn() + createTx.verifyTransactionStrExists(str1) + createTx.verifyTransactionStrExists(str2) + createTx.verifyTransactionStrExists(str3) + createTx.clickOnCollapseAllBtn() + createTx.verifyTransactionStrNotVible(str1) + createTx.verifyTransactionStrNotVible(str2) + createTx.verifyTransactionStrNotVible(str3) + }) }) }) diff --git a/cypress/support/constants.js b/cypress/support/constants.js index feea0642a2..4d779ddba3 100644 --- a/cypress/support/constants.js +++ b/cypress/support/constants.js @@ -1,11 +1,17 @@ import { LS_NAMESPACE } from '../../src/config/constants' export const RECIPIENT_ADDRESS = '0x6a5602335a878ADDCa4BF63a050E34946B56B5bC' export const GOERLI_TEST_SAFE = 'gor:0x97d314157727D517A706B5D08507A1f9B44AaaE9' +export const SEPOLIA_TEST_SAFE_1 = 'sep:0xBb26E3717172d5000F87DeFd391994f789D80aEB' +// SEPOLIA_TEST_SAFE_2 Has no transactions, 1 owner, using for verificatons only +export const SEPOLIA_TEST_SAFE_2 = 'sep:0x33C4AA5729D91FfB3B87AEf8a324bb6304Fb905c' +export const SEPOLIA_TEST_SAFE_3 = 'sep:0x6E834E9D04ad6b26e1525dE1a37BFd9b215f40B7' +export const SEPOLIA_TEST_SAFE_4 = 'sep:0x03042B890b99552b60A073F808100517fb148F60' export const GNO_TEST_SAFE = 'gno:0xB8d760a90a5ed54D3c2b3EFC231277e99188642A' export const PAGINATION_TEST_SAFE = 'gor:0x850493a15914aAC05a821A3FAb973b4598889A7b' export const TEST_SAFE = 'gor:0x04f8b1EA3cBB315b87ced0E32deb5a43cC151a91' export const EOA = '0xE297437d6b53890cbf004e401F3acc67c8b39665' export const DEFAULT_OWNER_ADDRESS = '0xC16Db0251654C0a72E91B190d81eAD367d2C6fED' +export const SEPOLIA_OWNER_2 = '0x96D4c6fFC338912322813a77655fCC926b9A5aC5' export const TEST_SAFE_2 = 'gor:0xE96C43C54B08eC528e9e815fC3D02Ea94A320505' export const SIDEBAR_ADDRESS = '0x04f8...1a91' @@ -17,16 +23,27 @@ export const goerlyE2EWallet = /E2E Wallet @ G(ö|oe)rli/ export const goerlySafeName = /g(ö|oe)rli-safe/ export const goerliToken = /G(ö|oe)rli Ether/ -export const appUrlProd = 'https://safe-test-app.com' +export const testAppUrl = 'https://safe-test-app.com' export const addressBookUrl = '/address-book?safe=' export const BALANCE_URL = '/balances?safe=' export const balanceNftsUrl = '/balances/nfts?safe=' export const transactionQueueUrl = '/transactions/queue?safe=' +export const transactionsHistoryUrl = '/transactions/history?safe=' export const openAppsUrl = '/apps/open?safe=' export const homeUrl = '/home?safe=' export const welcomeUrl = '/welcome' export const chainMaticUrl = '/welcome?chain=matic' -export const proposeEndPoint = '/**/propose' +export const appsUrl = '/apps' +export const requestPermissionsUrl = '/request-permissions' +export const getPermissionsUrl = '/get-permissions' +export const appSettingsUrl = '/settings/safe-apps' +export const setupUrl = '/settings/setup?safe=' +export const invalidAppUrl = 'https://my-invalid-custom-app.com/manifest.json' +export const validAppUrlJson = 'https://my-valid-custom-app.com/manifest.json' +export const validAppUrl = 'https://my-valid-custom-app.com' + +export const proposeEndpoint = '/**/propose' +export const appsEndpoint = '/**/safe-apps' export const GOERLI_CSV_ENTRY = { name: 'goerli user 1', @@ -47,3 +64,55 @@ export const networks = { export const tokenAbbreviation = { gor: 'GOR', } + +export const appNames = { + walletConnect: 'walletconnect', + customContract: 'compose custom contract', + noResults: 'atextwithoutresults', +} + +export const testAppData = { + name: 'Cypress Test App', + descr: 'Cypress Test App Description', +} + +export const checkboxStates = { + unchecked: 'not.be.checked', + checked: 'be.checked', +} + +export const transactionStatus = { + received: 'Receive', + sent: 'Send', + deposit: 'deposit', + approve: 'Approve', + success: 'Success', + interaction: 'Contract interaction', + confirm: 'Confirm transaction', +} + +export const tokenNames = { + wrappedEther: 'Wrapped Ether', +} + +export const addressBookErrrMsg = { + invalidFormat: 'Invalid address format', + invalidChecksum: 'Invalid address checksum', + exceedChars: 'Maximum 50 symbols', + ownSafe: 'Cannot use Safe Account itself as owner', + alreadyAdded: 'Address already added', +} +export const addresBookContacts = { + user1: { + address: '0x01A9F68e339da12565cfBc47fe7D6EdEcB11C46f', + name: 'David', + }, + user2: { + address: 'francotest.eth', + name: 'Franco ESN', + }, +} + +export const localStorageKeys = { + SAFE_v2__addressBook: 'SAFE_v2__addressBook', +} diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js index cf9e218a4b..302c6df05d 100644 --- a/cypress/support/e2e.js +++ b/cypress/support/e2e.js @@ -26,7 +26,21 @@ import './safe-apps-commands' which displays the terms banner even though it shouldn't so we need to globally hide it in our tests. */ before(() => { + cy.on('log:added', (ev) => { + if (Cypress.config('hideXHR')) { + const app = window.top + if (app && !app.document.head.querySelector('[data-hide-command-log-request]')) { + const style = app.document.createElement('style') + style.innerHTML = '.command-name-request, .command-name-xhr { display: none }' + style.setAttribute('data-hide-command-log-request', '') + app.document.head.appendChild(style) + } + } + }) + cy.on('window:before:load', (window) => { window.localStorage.setItem('SAFE_v2__show_terms', false) + // So that tests that rely on this feature don't randomly fail + window.localStorage.setItem('SAFE_v2__AB_human-readable', true) }) }) diff --git a/next.config.mjs b/next.config.mjs index 8aa55005a7..5e568cd377 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,14 +1,21 @@ import path from 'path' import withBundleAnalyzer from '@next/bundle-analyzer' -import NextPwa from 'next-pwa' +import withPWAInit from '@ducanh2912/next-pwa' -const withPWA = NextPwa({ - disable: process.env.NODE_ENV === 'development', +const SERVICE_WORKERS_PATH = './src/service-workers' + +const withPWA = withPWAInit({ dest: 'public', + workboxOptions: { + mode: 'production', + }, reloadOnOnline: false, /* Do not precache anything */ publicExcludes: ['**/*'], buildExcludes: [/./], + customWorkerSrc: SERVICE_WORKERS_PATH, + // Prefer InjectManifest for Web Push + swSrc: `${SERVICE_WORKERS_PATH}/index.ts`, }) /** @type {import('next').NextConfig} */ diff --git a/package.json b/package.json index 2d65cd9a14..d8d54a097b 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "homepage": "https://github.com/safe-global/safe-wallet-web", "license": "GPL-3.0", "type": "module", - "version": "1.18.0", + "version": "1.19.1", "scripts": { "dev": "next dev", "start": "next dev", @@ -19,7 +19,8 @@ "routes": "node scripts/generate-routes.js > src/config/routes.ts && prettier -w src/config/routes.ts && cat src/config/routes.ts", "css-vars": "ts-node-esm ./scripts/css-vars.ts > ./src/styles/vars.css && prettier -w src/styles/vars.css", "generate-types": "typechain --target ethers-v5 --out-dir src/types/contracts ./node_modules/@safe-global/safe-deployments/dist/assets/**/*.json ./node_modules/@safe-global/safe-modules-deployments/dist/assets/**/*.json ./node_modules/@openzeppelin/contracts/build/contracts/ERC20.json ./node_modules/@openzeppelin/contracts/build/contracts/ERC721.json", - "postinstall": "yarn generate-types && yarn css-vars", + "after-install": "yarn patch-package && yarn generate-types && yarn css-vars", + "postinstall": "yarn after-install", "analyze": "cross-env ANALYZE=true yarn build", "cypress:open": "cross-env TZ=UTC cypress open --e2e", "cypress:canary": "cross-env TZ=UTC cypress open --e2e -b chrome:canary", @@ -39,6 +40,7 @@ }, "dependencies": { "@date-io/date-fns": "^2.15.0", + "@ducanh2912/next-pwa": "9.5.0", "@emotion/cache": "^11.10.1", "@emotion/react": "^11.10.0", "@emotion/server": "^11.10.0", @@ -59,24 +61,25 @@ "@sentry/react": "^7.28.1", "@sentry/tracing": "^7.28.1", "@truffle/hdwallet-provider": "^2.1.4", - "@web3-onboard/coinbase": "^2.2.4", - "@web3-onboard/core": "^2.21.0", - "@web3-onboard/injected-wallets": "^2.10.0", + "@web3-onboard/coinbase": "^2.2.6", + "@web3-onboard/core": "^2.21.2", + "@web3-onboard/injected-wallets": "^2.10.7", "@web3-onboard/keystone": "^2.3.7", "@web3-onboard/ledger": "2.3.2", "@web3-onboard/trezor": "^2.4.2", - "@web3-onboard/walletconnect": "^2.4.5", + "@web3-onboard/walletconnect": "^2.4.7", + "blo": "^1.1.1", "classnames": "^2.3.1", "date-fns": "^2.29.2", - "ethereum-blockies-base64": "^1.0.2", "ethers": "5.7.2", "exponential-backoff": "^3.1.0", + "firebase": "^10.3.1", "framer-motion": "^10.13.1", "fuse.js": "^6.6.2", + "idb-keyval": "^6.2.1", "js-cookie": "^3.0.1", "lodash": "^4.17.21", "next": "^13.2.0", - "next-pwa": "^5.6.0", "papaparse": "^5.3.2", "qrcode.react": "^3.1.0", "react": "18.2.0", @@ -112,21 +115,24 @@ "@types/semver": "^7.3.10", "@typescript-eslint/eslint-plugin": "^5.47.1", "cross-env": "^7.0.3", - "cypress": "^11.1.0", + "cypress": "^12.15.0", "cypress-file-upload": "^5.0.8", "eslint": "8.31.0", "eslint-config-next": "13.1.1", "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-unused-imports": "^2.0.0", + "fake-indexeddb": "^4.0.2", "jest": "^29.6.2", "jest-environment-jsdom": "^29.6.2", + "patch-package": "^8.0.0", "pre-commit": "^1.2.2", "prettier": "^2.7.0", "ts-node": "^10.8.2", "ts-prune": "^0.10.3", "typechain": "^8.0.0", "typescript": "4.9.4", - "typescript-plugin-css-modules": "^4.2.2" + "typescript-plugin-css-modules": "^4.2.2", + "webpack": "^5.88.2" } } diff --git a/patches/@ducanh2912+next-pwa+9.5.0.patch b/patches/@ducanh2912+next-pwa+9.5.0.patch new file mode 100644 index 0000000000..4daf23e504 --- /dev/null +++ b/patches/@ducanh2912+next-pwa+9.5.0.patch @@ -0,0 +1,24 @@ ++ Allow Webpack to resolve ECMAScript modules without explicit extensions ++ Currently required for firebase to compile in next-pwa custom worker ++ https://webpack.js.org/configuration/module/#resolvefullyspecified + +diff --git a/node_modules/@ducanh2912/next-pwa/dist/index.cjs b/node_modules/@ducanh2912/next-pwa/dist/index.cjs +index 3a9d49b..08ab877 100644 +--- a/node_modules/@ducanh2912/next-pwa/dist/index.cjs ++++ b/node_modules/@ducanh2912/next-pwa/dist/index.cjs +@@ -1,2 +1,2 @@ + 'use strict';Object.defineProperty(exports,'__esModule',{value:true});var r=require('path'),url=require('url'),module$1=require('module'),e$2=require('fs'),n$1=require('process'),l=require('os'),i=require('tty'),semver=require('semver'),cleanWebpackPlugin=require('clean-webpack-plugin'),s$1=require('fast-glob'),t$1=require('workbox-webpack-plugin'),e$3=require('crypto'),n$2=require('webpack'),s=require('terser-webpack-plugin');let e$1;let a$1=(e,r,t)=>{e.jsc||(e.jsc={}),e.jsc.baseUrl=r,e.jsc.paths=t;},c=(e,r)=>{for(let t of e){let e=r(t);if(e)return e}},u=module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (document.currentScript && document.currentScript.src || new URL('index.cjs', document.baseURI).href))),p$1=e=>{try{return u(`${e}/package.json`).version}catch{return}},f=(e,r$1)=>{try{let n=c([r$1??"tsconfig.json","jsconfig.json"],r$1=>{let n=r.join(e,r$1);return e$2.existsSync(n)?n:void 0});if(!n)return;return JSON.parse(e$2.readFileSync(n,"utf-8"))}catch{return}},m$1=(e=0)=>r=>`\u001B[${r+e}m`,b=(e=0)=>r=>`\u001B[${38+e};5;${r}m`,g=(e=0)=>(r,t,o)=>`\u001B[${38+e};2;${r};${t};${o}m`,h={modifier:{reset:[0,0],bold:[1,22],dim:[2,22],italic:[3,23],underline:[4,24],overline:[53,55],inverse:[7,27],hidden:[8,28],strikethrough:[9,29]},color:{black:[30,39],red:[31,39],green:[32,39],yellow:[33,39],blue:[34,39],magenta:[35,39],cyan:[36,39],white:[37,39],blackBright:[90,39],gray:[90,39],grey:[90,39],redBright:[91,39],greenBright:[92,39],yellowBright:[93,39],blueBright:[94,39],magentaBright:[95,39],cyanBright:[96,39],whiteBright:[97,39]},bgColor:{bgBlack:[40,49],bgRed:[41,49],bgGreen:[42,49],bgYellow:[43,49],bgBlue:[44,49],bgMagenta:[45,49],bgCyan:[46,49],bgWhite:[47,49],bgBlackBright:[100,49],bgGray:[100,49],bgGrey:[100,49],bgRedBright:[101,49],bgGreenBright:[102,49],bgYellowBright:[103,49],bgBlueBright:[104,49],bgMagentaBright:[105,49],bgCyanBright:[106,49],bgWhiteBright:[107,49]}};Object.keys(h.modifier);let d$1=Object.keys(h.color),O=Object.keys(h.bgColor);[...d$1,...O];let y=function(){let e=new Map;for(let[r,t]of Object.entries(h)){for(let[r,o]of Object.entries(t))h[r]={open:`\u001B[${o[0]}m`,close:`\u001B[${o[1]}m`},t[r]=h[r],e.set(o[0],o[1]);Object.defineProperty(h,r,{value:t,enumerable:!1});}return Object.defineProperty(h,"codes",{value:e,enumerable:!1}),h.color.close="\x1b[39m",h.bgColor.close="\x1b[49m",h.color.ansi=m$1(),h.color.ansi256=b(),h.color.ansi16m=g(),h.bgColor.ansi=m$1(10),h.bgColor.ansi256=b(10),h.bgColor.ansi16m=g(10),Object.defineProperties(h,{rgbToAnsi256:{value:(e,r,t)=>e===r&&r===t?e<8?16:e>248?231:Math.round((e-8)/247*24)+232:16+36*Math.round(e/255*5)+6*Math.round(r/255*5)+Math.round(t/255*5),enumerable:!1},hexToRgb:{value(e){let r=/[a-f\d]{6}|[a-f\d]{3}/i.exec(e.toString(16));if(!r)return [0,0,0];let[t]=r;3===t.length&&(t=[...t].map(e=>e+e).join(""));let o=Number.parseInt(t,16);return [o>>16&255,o>>8&255,255&o]},enumerable:!1},hexToAnsi256:{value:e=>h.rgbToAnsi256(...h.hexToRgb(e)),enumerable:!1},ansi256ToAnsi:{value(e){let r,t,o;if(e<8)return 30+e;if(e<16)return 90+(e-8);if(e>=232)t=r=((e-232)*10+8)/255,o=r;else {let n=(e-=16)%36;r=Math.floor(e/36)/5,t=Math.floor(n/6)/5,o=n%6/5;}let n=2*Math.max(r,t,o);if(0===n)return 30;let l=30+(Math.round(o)<<2|Math.round(t)<<1|Math.round(r));return 2===n&&(l+=60),l},enumerable:!1},rgbToAnsi:{value:(e,r,t)=>h.ansi256ToAnsi(h.rgbToAnsi256(e,r,t)),enumerable:!1},hexToAnsi:{value:e=>h.ansi256ToAnsi(h.hexToAnsi256(e)),enumerable:!1}}),h}();function v(e,r=globalThis.Deno?globalThis.Deno.args:n$1.argv){let t=e.startsWith("-")?"":1===e.length?"-":"--",o=r.indexOf(t+e),l=r.indexOf("--");return -1!==o&&(-1===l||o=10&&Number(e[2])>=10586?Number(e[2])>=14931?3:2:1}if("CI"in T)return "GITHUB_ACTIONS"in T||"GITEA_ACTIONS"in T?3:["TRAVIS","CIRCLECI","APPVEYOR","GITLAB_CI","BUILDKITE","DRONE"].some(e=>e in T)||"codeship"===T.CI_NAME?1:a;if("TEAMCITY_VERSION"in T)return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(T.TEAMCITY_VERSION)?1:0;if("truecolor"===T.COLORTERM||"xterm-kitty"===T.TERM)return 3;if("TERM_PROGRAM"in T){let e=Number.parseInt((T.TERM_PROGRAM_VERSION||"").split(".")[0],10);switch(T.TERM_PROGRAM){case"iTerm.app":return e>=3?3:2;case"Apple_Terminal":return 2}}return /-256(color)?$/i.test(T.TERM)?2:/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(T.TERM)||"COLORTERM"in T?1:a}(r,{streamIsTTY:r&&r.isTTY,...t});return 0!==o&&{level:o,hasBasic:!0,has256:o>=2,has16m:o>=3}}v("no-color")||v("no-colors")||v("color=false")||v("color=never")?e$1=0:(v("color")||v("colors")||v("color=true")||v("color=always"))&&(e$1=1);let{stdout:j,stderr:R}={stdout:M({isTTY:i.isatty(1)}),stderr:M({isTTY:i.isatty(2)})},w=Symbol("GENERATOR"),C=Symbol("STYLER"),A=Symbol("IS_EMPTY"),E=["ansi","ansi","ansi256","ansi16m"],B=Object.create(null),S=(e,r={})=>{if(r.level&&!(Number.isInteger(r.level)&&r.level>=0&&r.level<=3))throw Error("The `level` option should be an integer from 0 to 3");let t=j?j.level:0;e.level=void 0===r.level?t:r.level;},x=e=>{let r=(...e)=>e.join(" ");return S(r,e),Object.setPrototypeOf(r,I.prototype),r};function I(e){return x(e)}for(let[e,r]of(Object.setPrototypeOf(I.prototype,Function.prototype),Object.entries(y)))B[e]={get(){let t=$(this,_(r.open,r.close,this[C]),this[A]);return Object.defineProperty(this,e,{value:t}),t}};B.visible={get(){let e=$(this,this[C],!0);return Object.defineProperty(this,"visible",{value:e}),e}};let P=(e,r,t,...o)=>"rgb"===e?"ansi16m"===r?y[t].ansi16m(...o):"ansi256"===r?y[t].ansi256(y.rgbToAnsi256(...o)):y[t].ansi(y.rgbToAnsi(...o)):"hex"===e?P("rgb",r,t,...y.hexToRgb(...o)):y[t][e](...o);for(let e of ["rgb","hex","ansi256"])B[e]={get(){let{level:r}=this;return function(...t){return $(this,_(P(e,E[r],"color",...t),y.color.close,this[C]),this[A])}}},B["bg"+e[0].toUpperCase()+e.slice(1)]={get(){let{level:r}=this;return function(...t){return $(this,_(P(e,E[r],"bgColor",...t),y.bgColor.close,this[C]),this[A])}}};let N=Object.defineProperties(()=>{},{...B,level:{enumerable:!0,get(){return this[w].level},set(e){this[w].level=e;}}}),_=(e,r,t)=>{let o,n;return void 0===t?(o=e,n=r):(o=t.openAll+e,n=r+t.closeAll),{open:e,close:r,openAll:o,closeAll:n,parent:t}},$=(e,r,t)=>{let o=(...e)=>k(o,1===e.length?""+e[0]:e.join(" "));return Object.setPrototypeOf(o,N),o[w]=e,o[C]=r,o[A]=t,o},k=(e,r)=>{if(e.level<=0||!r)return e[A]?"":r;let t=e[C];if(void 0===t)return r;let{openAll:o,closeAll:n}=t;if(r.includes("\x1b"))for(;void 0!==t;)r=function(e,r,t){let o=e.indexOf(r);if(-1===o)return e;let n=r.length,l=0,i="";do i+=e.slice(l,o)+r+t,l=o+n,o=e.indexOf(r,l);while(-1!==o)return i+e.slice(l)}(r,t.close,t.open),t=t.parent;let l=r.indexOf("\n");return -1!==l&&(r=function(e,r,t,o){let n=0,l="";do{let i="\r"===e[o-1];l+=e.slice(n,i?o-1:o)+r+(i?"\r\n":"\n")+t,n=o+1,o=e.indexOf("\n",n);}while(-1!==o)return l+e.slice(n)}(r,n,o,l)),o+r+n};Object.defineProperties(I.prototype,B);let L=x(void 0);x({level:R?R.level:0});let F=p$1("next"),G=!!F&&semver.gte(F,"13.4.1"),Y=(e,r=0)=>G?`- ${e} (pwa)`:`${e}${" ".repeat(r)}- (PWA)`,D={wait:Y(L.cyan("wait"),2),error:Y(L.red("error"),1),warn:Y(L.yellow("warn"),2),info:Y(L.cyan("info"),2)};var V=Object.freeze({__proto__:null,error:(...e)=>{console.error(D.error,...e);},info:(...e)=>{console.log(D.info,...e);},prefixes:D,wait:(...e)=>{console.log(D.wait,...e);},warn:(...e)=>{console.warn(D.warn,...e);}});let J=()=>{let e;for(let r of ["@swc/core","next/dist/build/swc"])try{e=require(r);break}catch{}if(!e)throw Error("Failed to resolve swc. Please install @swc/core if you haven't.");return e};module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (document.currentScript && document.currentScript.src || new URL('index.cjs', document.baseURI).href)));let q=async(e,r,t,o)=>{let{resolveSwc:n,useSwcMinify:l,...i}=t,s=()=>require("terser-webpack-plugin").terserMinify(e,r,i,o);if(l){let t,o;try{t=n();}catch{return s()}if(!t.minify)return s();let l={...i,compress:"boolean"==typeof i.compress?!!i.compress&&{}:{...i.compress},mangle:null==i.mangle||("boolean"==typeof i.mangle?i.mangle:{...i.mangle}),sourceMap:void 0};r&&(l.sourceMap=!0),l.compress&&(void 0===l.compress.ecma&&(l.compress.ecma=l.ecma),5===l.ecma&&void 0===l.compress.arrows&&(l.compress.arrows=!1));let[[a,c]]=Object.entries(e),u=await t.minify(c,l);return u.map&&((o=JSON.parse(u.map)).sources=[a],delete o.sourcesContent),{code:u.code,map:o}}return s()};var t = [{urlPattern:/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,handler:"CacheFirst",options:{cacheName:"google-fonts-webfonts",expiration:{maxEntries:4,maxAgeSeconds:31536e3}}},{urlPattern:/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,handler:"StaleWhileRevalidate",options:{cacheName:"google-fonts-stylesheets",expiration:{maxEntries:4,maxAgeSeconds:604800}}},{urlPattern:/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,handler:"StaleWhileRevalidate",options:{cacheName:"static-font-assets",expiration:{maxEntries:4,maxAgeSeconds:604800}}},{urlPattern:/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,handler:"StaleWhileRevalidate",options:{cacheName:"static-image-assets",expiration:{maxEntries:64,maxAgeSeconds:2592e3}}},{urlPattern:/\/_next\/static.+\.js$/i,handler:"CacheFirst",options:{cacheName:"next-static-js-assets",expiration:{maxEntries:64,maxAgeSeconds:86400}}},{urlPattern:/\/_next\/image\?url=.+$/i,handler:"StaleWhileRevalidate",options:{cacheName:"next-image",expiration:{maxEntries:64,maxAgeSeconds:86400}}},{urlPattern:/\.(?:mp3|wav|ogg)$/i,handler:"CacheFirst",options:{rangeRequests:!0,cacheName:"static-audio-assets",expiration:{maxEntries:32,maxAgeSeconds:86400}}},{urlPattern:/\.(?:mp4)$/i,handler:"CacheFirst",options:{rangeRequests:!0,cacheName:"static-video-assets",expiration:{maxEntries:32,maxAgeSeconds:86400}}},{urlPattern:/\.(?:js)$/i,handler:"StaleWhileRevalidate",options:{cacheName:"static-js-assets",expiration:{maxEntries:48,maxAgeSeconds:86400}}},{urlPattern:/\.(?:css|less)$/i,handler:"StaleWhileRevalidate",options:{cacheName:"static-style-assets",expiration:{maxEntries:32,maxAgeSeconds:86400}}},{urlPattern:/\/_next\/data\/.+\/.+\.json$/i,handler:"StaleWhileRevalidate",options:{cacheName:"next-data",expiration:{maxEntries:32,maxAgeSeconds:86400}}},{urlPattern:/\.(?:json|xml|csv)$/i,handler:"NetworkFirst",options:{cacheName:"static-data-assets",expiration:{maxEntries:32,maxAgeSeconds:86400}}},{urlPattern:({sameOrigin:e,url:{pathname:t}})=>!(!e||t.startsWith("/api/auth/"))&&!!t.startsWith("/api/"),handler:"NetworkFirst",method:"GET",options:{cacheName:"apis",expiration:{maxEntries:16,maxAgeSeconds:86400},networkTimeoutSeconds:10}},{urlPattern:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&"1"===e.headers.get("Next-Router-Prefetch")&&a&&!t.startsWith("/api/"),handler:"NetworkFirst",options:{cacheName:"pages-rsc-prefetch",expiration:{maxEntries:32,maxAgeSeconds:86400}}},{urlPattern:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&a&&!t.startsWith("/api/"),handler:"NetworkFirst",options:{cacheName:"pages-rsc",expiration:{maxEntries:32,maxAgeSeconds:86400}}},{urlPattern:({url:{pathname:e},sameOrigin:t})=>t&&!e.startsWith("/api/"),handler:"NetworkFirst",options:{cacheName:"pages",expiration:{maxEntries:32,maxAgeSeconds:86400}}},{urlPattern:({sameOrigin:e})=>!e,handler:"NetworkFirst",options:{cacheName:"cross-origin",expiration:{maxEntries:32,maxAgeSeconds:3600},networkTimeoutSeconds:10}}];const resolveWorkboxCommon=({dest:e,sw:a,dev:r$1,buildId:n,buildExcludes:s,manifestEntries:i,manifestTransforms:l,modifyURLPrefix:o,publicPath:m})=>({swDest:r.join(e,a),additionalManifestEntries:r$1?[]:i,exclude:[...s,({asset:t})=>!!(t.name.startsWith("server/")||t.name.match(/^((app-|^)build-manifest\.json|react-loadable-manifest\.json)$/))||!!r$1&&!t.name.startsWith("static/runtime/")],modifyURLPrefix:{...o,"/_next/../public/":"/"},manifestTransforms:[...l,async(t,e)=>{let a=t.map(t=>{if(t.url=t.url.replace("/_next//static/image","/_next/static/image"),t.url=t.url.replace("/_next//static/media","/_next/static/media"),null===t.revision){let a=t.url;"string"==typeof m&&a.startsWith(m)&&(a=t.url.substring(m.length));let r=e.assetsInfo.get(a);t.revision=r&&r.contenthash||n;}return t.url=t.url.replace(/\[/g,"%5B").replace(/\]/g,"%5D"),t});return {manifest:a,warnings:[]}}]}); +-const resolveRuntimeCaching=(o,n)=>{if(!o)return t;if(!n)return V.info("Custom runtimeCaching array found, using it instead of the default one."),o;V.info("Custom runtimeCaching array found, using it to extend the default one.");let a=[],i=new Set;for(let e of o)a.push(e),e.options?.cacheName&&i.add(e.options.cacheName);for(let e of t)e.options?.cacheName&&i.has(e.options.cacheName)||a.push(e);return a};const overrideAfterCalledMethod=e=>{Object.defineProperty(e,"alreadyCalled",{get:()=>!1,set(){}});};const isInjectManifestConfig=e=>void 0!==e&&"string"==typeof e.swSrc;const convertBoolean=(e,t=!0)=>{switch(typeof e){case"boolean":return e;case"number":case"bigint":return e>0;case"object":return null!==e;case"string":if(!t){if("false"===e||"0"===e)return !1;return !0}return "true"===e||"1"===e;case"function":case"symbol":return !0;case"undefined":return !1}};const getFileHash=r=>e$3.createHash("md5").update(e$2.readFileSync(r)).digest("hex");const getContentHash=(e,t)=>t?"development":getFileHash(e).slice(0,16);const resolveWorkboxPlugin=({rootDir:s,basePath:a,isDev:p,workboxCommon:l,workboxOptions:c,importScripts:u,extendDefaultRuntimeCaching:d,dynamicStartUrl:h,hasFallbacks:f})=>{if(isInjectManifestConfig(c)){let o=r.join(s,c.swSrc);V.info(`Using InjectManifest with ${o}`);let r$1=new t$1.InjectManifest({...l,...c,swSrc:o});return p&&overrideAfterCalledMethod(r$1),r$1}{let e;let{skipWaiting:r=!0,clientsClaim:s=!0,cleanupOutdatedCaches:m=!0,ignoreURLParametersMatching:g=[],importScripts:w,runtimeCaching:b}=c;w&&u.push(...w);let k=!1;p?(V.info("Building in development mode, caching and precaching are disabled for the most part. This means that offline support is disabled, but you can continue developing other functions in service worker."),g.push(/ts/),e=[{urlPattern:/.*/i,handler:"NetworkOnly",options:{cacheName:"dev"}}],k=!0):e=resolveRuntimeCaching(b,d),h&&e.unshift({urlPattern:a,handler:"NetworkFirst",options:{cacheName:"start-url",plugins:[{cacheWillUpdate:async({response:e})=>e&&"opaqueredirect"===e.type?new Response(e.body,{status:200,statusText:"OK",headers:e.headers}):e}]}}),f&&e.forEach(e=>{!e.options||e.options.precacheFallback||e.options.plugins?.find(e=>"handlerDidError"in e)||(e.options.plugins||(e.options.plugins=[]),e.options.plugins.push({handlerDidError:async({request:e})=>"undefined"!=typeof self?self.fallback(e):Response.error()}));});let y=new t$1.GenerateSW({...l,skipWaiting:r,clientsClaim:s,cleanupOutdatedCaches:m,ignoreURLParametersMatching:g,importScripts:u,...c,runtimeCaching:e});return k&&overrideAfterCalledMethod(y),y}};const defaultSwcRc={module:{type:"es6",lazy:!0,noInterop:!0},jsc:{parser:{syntax:"typescript",tsx:!0,dynamicImport:!0,decorators:!1},transform:{react:{runtime:"automatic"}},target:"es2022",loose:!1},minify:!1};let e=(t,e)=>{if(t)return e?.(t)};const NextPWAContext={shouldMinify:e(process.env.NEXT_PWA_MINIFY,convertBoolean),useSwcMinify:e(process.env.NEXT_PWA_SWC_MINIFY,convertBoolean)};const setDefaultContext=(t,e)=>{void 0===NextPWAContext[t]&&(NextPWAContext[t]=e);};let n=url.fileURLToPath(new URL(".",(typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (document.currentScript && document.currentScript.src || new URL('index.cjs', document.baseURI).href)))),a=()=>({compress:{ecma:5,comparisons:!1,inline:2},mangle:{safari10:!0},format:{ecma:5,safari10:!0,comments:!1,ascii_only:!0},resolveSwc:J,useSwcMinify:NextPWAContext.useSwcMinify});const getSharedWebpackConfig=({swcRc:o=defaultSwcRc})=>{let i=NextPWAContext.shouldMinify&&{minimize:!0,minimizer:[new s({minify:q,terserOptions:a()})]};return {resolve:{extensions:[".js",".ts"],fallback:{module:!1,dgram:!1,dns:!1,path:!1,fs:!1,os:!1,crypto:!1,stream:!1,http2:!1,net:!1,tls:!1,zlib:!1,child_process:!1}},resolveLoader:{alias:{"swc-loader":r.join(n,"swc-loader.cjs")}},module:{rules:[{test:/\.(t|j)s$/i,use:[{loader:"swc-loader",options:o}]}]},optimization:i||void 0}};const buildCustomWorker=({isDev:c$1,baseDir:a,customWorkerSrc:f,customWorkerDest:d,customWorkerPrefix:j,plugins:h=[],tsconfig:w,basePath:k})=>{let $=c([f,r.join("src",f)],t=>{t=r.join(a,t);let e=["ts","js"].map(o=>r.join(t,`index.${o}`)).filter(r=>e$2.existsSync(r));if(0===e.length)return;let n=e[0];return e.length>1&&V.info(`More than one custom worker found, ${n} will be used.`),n});if(!$)return;V.info(`Found a custom worker implementation at ${$}.`),w&&w.compilerOptions&&w.compilerOptions.paths&&a$1(defaultSwcRc,r.join(a,w.compilerOptions.baseUrl??"."),w.compilerOptions.paths);let b=`${j}-${getContentHash($,c$1)}.js`;return V.info(`Building custom worker to ${r.join(d,b)}...`),n$2({...getSharedWebpackConfig({swcRc:defaultSwcRc}),mode:NextPWAContext.shouldMinify?"production":"development",target:"webworker",entry:{main:$},output:{path:d,filename:b,chunkFilename:"sw-chunks/[id]-[chunkhash].js"},plugins:[new cleanWebpackPlugin.CleanWebpackPlugin({cleanOnceBeforeBuildPatterns:[r.join(d,`${j}-*.js`),r.join(d,`${j}-*.js.map`)]}),...h]}).run((o,r)=>{(o||r?.hasErrors())&&(V.error("Failed to build custom worker."),V.error(r?.toString({colors:!0})),process.exit(-1));}),r.posix.join(k,b)};const getFallbackEnvs=({fallbacks:L,buildId:e})=>{let t=L.data;t&&t.endsWith(".json")&&(t=r.posix.join("/_next/data",e,t));let o={__PWA_FALLBACK_DOCUMENT__:L.document||!1,__PWA_FALLBACK_IMAGE__:L.image||!1,__PWA_FALLBACK_AUDIO__:L.audio||!1,__PWA_FALLBACK_VIDEO__:L.video||!1,__PWA_FALLBACK_FONT__:L.font||!1,__PWA_FALLBACK_DATA__:t||!1};if(0!==Object.values(o).filter(_=>!!_).length)return V.info("This app will fallback to these precached routes when fetching from the cache and the network fails:"),o.__PWA_FALLBACK_DOCUMENT__&&V.info(` Documents (pages): ${o.__PWA_FALLBACK_DOCUMENT__}`),o.__PWA_FALLBACK_IMAGE__&&V.info(` Images: ${o.__PWA_FALLBACK_IMAGE__}`),o.__PWA_FALLBACK_AUDIO__&&V.info(` Audio: ${o.__PWA_FALLBACK_AUDIO__}`),o.__PWA_FALLBACK_VIDEO__&&V.info(` Videos: ${o.__PWA_FALLBACK_VIDEO__}`),o.__PWA_FALLBACK_FONT__&&V.info(` Fonts: ${o.__PWA_FALLBACK_FONT__}`),o.__PWA_FALLBACK_DATA__&&V.info(` Data (/_next/data/**/*.json): ${o.__PWA_FALLBACK_DATA__}`),o};let m=url.fileURLToPath(new URL(".",(typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (document.currentScript && document.currentScript.src || new URL('index.cjs', document.baseURI).href))));const buildFallbackWorker=({isDev:e,buildId:c,fallbacks:p,destDir:u,basePath:f})=>{p=Object.keys(p).reduce((e,o)=>{let t=p[o];return t&&(e[o]=r.posix.join(f,t)),e},{});let j=getFallbackEnvs({fallbacks:p,buildId:c});if(!j)return;let k=r.join(m,"fallback.js"),b=`fallback-${getContentHash(k,e)}.js`;return n$2({...getSharedWebpackConfig({}),mode:NextPWAContext.shouldMinify?"production":"development",target:"webworker",entry:{main:k},output:{path:u,filename:b,chunkFilename:"sw-chunks/[id]-[chunkhash].js"},plugins:[new cleanWebpackPlugin.CleanWebpackPlugin({cleanOnceBeforeBuildPatterns:[r.join(u,"fallback-*.js"),r.join(u,"fallback-*.js.map")]}),new n$2.EnvironmentPlugin(j)]}).run((r,e)=>{(r||e?.hasErrors())&&(V.error("Failed to build fallback worker."),V.error(e?.toString({colors:!0})),process.exit(-1));}),{name:r.posix.join(f,b),precaches:Object.values(j).filter(r=>!!r)}};let p=url.fileURLToPath(new URL(".",(typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (document.currentScript && document.currentScript.src || new URL('index.cjs', document.baseURI).href))));const buildSWEntryWorker=({isDev:e,destDir:u,shouldGenSWEWorker:l,basePath:a})=>{if(!l)return;let w=r.join(p,"sw-entry-worker.js"),c=`swe-worker-${getContentHash(w,e)}.js`;return n$2({...getSharedWebpackConfig({}),mode:NextPWAContext.shouldMinify?"production":"development",target:"webworker",entry:{main:w},output:{path:u,filename:c,chunkFilename:"sw-chunks/[id]-[chunkhash].js"},plugins:[new cleanWebpackPlugin.CleanWebpackPlugin({cleanOnceBeforeBuildPatterns:[r.join(u,"swe-worker-*.js"),r.join(u,"swe-worker-*.js.map")]})]}).run((r,e)=>{(r||e?.hasErrors())&&(V.error("Failed to build the service worker's sub-worker."),V.error(e?.toString({colors:!0})),process.exit(-1));}),r.posix.join(a,c)};const getDefaultDocumentPage=(t,f,n)=>{let s;let r$1=c(["pages","src/pages"],o=>(o=r.join(t,o),e$2.existsSync(o)?o:void 0));if(n&&(s=c(["app","src/app"],o=>(o=r.join(t,o),e$2.existsSync(o)?o:void 0))),r$1||s)for(let o of f){if(s){let t=r.join(s,`~offline/page.${o}`);if(e$2.existsSync(t))return "/~offline"}if(r$1){let t=r.join(r$1,`_offline.${o}`);if(t&&e$2.existsSync(t))return "/_offline"}}};let d=url.fileURLToPath(new URL(".",(typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (document.currentScript && document.currentScript.src || new URL('index.cjs', document.baseURI).href))));var index = ((e={})=>(t={})=>({...t,webpack(w,j){let h;try{h=require("next/dist/server/config-shared").defaultConfig;}catch{}let b=t.experimental?.appDir??h?.experimental?.appDir??!0,g=j.webpack,{buildId:k,dev:x,config:{distDir:y=".next",pageExtensions:A=["tsx","ts","jsx","js","mdx"]}}=j,v=j.config.basePath||"/",D=f(j.dir,t?.typescript?.tsconfigPath),{disable:P=!1,register:E=!0,dest:W=y,sw:$="sw.js",cacheStartUrl:S=!0,dynamicStartUrl:O=!0,dynamicStartUrlRedirect:R,publicExcludes:N=["!noprecache/**/*"],buildExcludes:C=[],fallbacks:L={},cacheOnFrontEndNav:M=!1,aggressiveFrontEndNavCaching:T=!1,reloadOnOnline:G=!0,scope:B=v,customWorkerDir:U,customWorkerSrc:F=U||"worker",customWorkerDest:H=W,customWorkerPrefix:I="worker",workboxOptions:Y={},extendDefaultRuntimeCaching:q=!1,swcMinify:K=t.swcMinify??h?.swcMinify??!1}=e;if("function"==typeof t.webpack&&(w=t.webpack(w,j)),P)return j.isServer&&V.info("PWA support is disabled."),w;let V$1=[];w.plugins||(w.plugins=[]),V.info(`Compiling for ${j.isServer?"server":"client (static)"}...`);let z=r.posix.join(v,$),J=r.posix.join(B,"/");w.plugins.push(new g.DefinePlugin({__PWA_SW__:`'${z}'`,__PWA_SCOPE__:`'${J}'`,__PWA_ENABLE_REGISTER__:`${!!E}`,__PWA_START_URL__:O?`'${v}'`:void 0,__PWA_CACHE_ON_FRONT_END_NAV__:`${!!M}`,__PWA_AGGRFEN_CACHE__:`${!!T}`,__PWA_RELOAD_ON_ONLINE__:`${!!G}`}));let Q=r.join(d,"sw-entry.js"),X=w.entry;if(w.entry=()=>X().then(i=>(i["main.js"]&&!i["main.js"].includes(Q)&&(Array.isArray(i["main.js"])?i["main.js"].unshift(Q):"string"==typeof i["main.js"]&&(i["main.js"]=[Q,i["main.js"]])),i["main-app"]&&!i["main-app"].includes(Q)&&(Array.isArray(i["main-app"])?i["main-app"].unshift(Q):"string"==typeof i["main-app"]&&(i["main-app"]=[Q,i["main-app"]])),i)),!j.isServer){setDefaultContext("shouldMinify",!x),setDefaultContext("useSwcMinify",K);let e=r.join(j.dir,W),o=r.join(j.dir,H),t=buildSWEntryWorker({isDev:x,destDir:e,shouldGenSWEWorker:M,basePath:v});w.plugins.push(new g.DefinePlugin({__PWA_SW_ENTRY_WORKER__:t&&`'${t}'`})),E||(V.info("Service worker won't be automatically registered as per the config, please call the following code in componentDidMount or useEffect:"),V.info(" window.workbox.register()"),D?.compilerOptions?.types?.includes("@ducanh2912/next-pwa/workbox")||V.info("You may also want to add @ducanh2912/next-pwa/workbox to compilerOptions.types in your tsconfig.json/jsconfig.json.")),V.info(`Service worker: ${r.join(e,$)}`),V.info(` URL: ${z}`),V.info(` Scope: ${J}`),w.plugins.push(new cleanWebpackPlugin.CleanWebpackPlugin({cleanOnceBeforeBuildPatterns:[r.join(e,"workbox-*.js"),r.join(e,"workbox-*.js.map"),r.join(e,$),r.join(e,`${$}.map`),r.join(e,"sw-chunks/**")]}));let d=buildCustomWorker({isDev:x,baseDir:j.dir,swDest:e,customWorkerSrc:F,customWorkerDest:o,customWorkerPrefix:I,plugins:w.plugins.filter(i=>i instanceof g.DefinePlugin),tsconfig:D,basePath:v});d&&V$1.unshift(d);let{additionalManifestEntries:h,modifyURLPrefix:y={},manifestTransforms:P=[],exclude:T,...G}=Y,B=h??[];B||(B=s$1.sync(["**/*","!workbox-*.js","!workbox-*.js.map","!worker-*.js","!worker-*.js.map","!fallback-*.js","!fallback-*.js.map",`!${$.replace(/^\/+/,"")}`,`!${$.replace(/^\/+/,"")}.map`,...N],{cwd:"public"}).map(e=>({url:r.posix.join(v,e),revision:getFileHash(`public/${e}`)}))),S&&(O?"string"==typeof R&&R.length>0&&B.push({url:R,revision:k}):B.push({url:v,revision:k})),Object.keys(G).forEach(i=>void 0===G[i]&&delete G[i]);let U=!1;if(L){L.document||(L.document=getDefaultDocumentPage(j.dir,A,b));let i=buildFallbackWorker({isDev:x,buildId:k,fallbacks:L,destDir:e,basePath:v});i&&(U=!0,V$1.unshift(i.name),i.precaches.forEach(i=>{i&&"boolean"!=typeof i&&!B.find(e=>"string"!=typeof e&&e.url.startsWith(i))&&B.push({url:i,revision:k});}));}let Q=resolveWorkboxCommon({dest:e,sw:$,dev:x,buildId:k,buildExcludes:C,manifestEntries:B,manifestTransforms:P,modifyURLPrefix:y,publicPath:w.output?.publicPath}),X=resolveWorkboxPlugin({rootDir:j.dir,basePath:v,isDev:x,workboxCommon:Q,workboxOptions:G,importScripts:V$1,extendDefaultRuntimeCaching:q,dynamicStartUrl:O,hasFallbacks:U});w.plugins.push(X);}return w}}));exports.default=index;exports.runtimeCaching=t; +\ No newline at end of file ++const resolveRuntimeCaching=(o,n)=>{if(!o)return t;if(!n)return V.info("Custom runtimeCaching array found, using it instead of the default one."),o;V.info("Custom runtimeCaching array found, using it to extend the default one.");let a=[],i=new Set;for(let e of o)a.push(e),e.options?.cacheName&&i.add(e.options.cacheName);for(let e of t)e.options?.cacheName&&i.has(e.options.cacheName)||a.push(e);return a};const overrideAfterCalledMethod=e=>{Object.defineProperty(e,"alreadyCalled",{get:()=>!1,set(){}});};const isInjectManifestConfig=e=>void 0!==e&&"string"==typeof e.swSrc;const convertBoolean=(e,t=!0)=>{switch(typeof e){case"boolean":return e;case"number":case"bigint":return e>0;case"object":return null!==e;case"string":if(!t){if("false"===e||"0"===e)return !1;return !0}return "true"===e||"1"===e;case"function":case"symbol":return !0;case"undefined":return !1}};const getFileHash=r=>e$3.createHash("md5").update(e$2.readFileSync(r)).digest("hex");const getContentHash=(e,t)=>t?"development":getFileHash(e).slice(0,16);const resolveWorkboxPlugin=({rootDir:s,basePath:a,isDev:p,workboxCommon:l,workboxOptions:c,importScripts:u,extendDefaultRuntimeCaching:d,dynamicStartUrl:h,hasFallbacks:f})=>{if(isInjectManifestConfig(c)){let o=r.join(s,c.swSrc);V.info(`Using InjectManifest with ${o}`);let r$1=new t$1.InjectManifest({...l,...c,swSrc:o});return p&&overrideAfterCalledMethod(r$1),r$1}{let e;let{skipWaiting:r=!0,clientsClaim:s=!0,cleanupOutdatedCaches:m=!0,ignoreURLParametersMatching:g=[],importScripts:w,runtimeCaching:b}=c;w&&u.push(...w);let k=!1;p?(V.info("Building in development mode, caching and precaching are disabled for the most part. This means that offline support is disabled, but you can continue developing other functions in service worker."),g.push(/ts/),e=[{urlPattern:/.*/i,handler:"NetworkOnly",options:{cacheName:"dev"}}],k=!0):e=resolveRuntimeCaching(b,d),h&&e.unshift({urlPattern:a,handler:"NetworkFirst",options:{cacheName:"start-url",plugins:[{cacheWillUpdate:async({response:e})=>e&&"opaqueredirect"===e.type?new Response(e.body,{status:200,statusText:"OK",headers:e.headers}):e}]}}),f&&e.forEach(e=>{!e.options||e.options.precacheFallback||e.options.plugins?.find(e=>"handlerDidError"in e)||(e.options.plugins||(e.options.plugins=[]),e.options.plugins.push({handlerDidError:async({request:e})=>"undefined"!=typeof self?self.fallback(e):Response.error()}));});let y=new t$1.GenerateSW({...l,skipWaiting:r,clientsClaim:s,cleanupOutdatedCaches:m,ignoreURLParametersMatching:g,importScripts:u,...c,runtimeCaching:e});return k&&overrideAfterCalledMethod(y),y}};const defaultSwcRc={module:{type:"es6",lazy:!0,noInterop:!0},jsc:{parser:{syntax:"typescript",tsx:!0,dynamicImport:!0,decorators:!1},transform:{react:{runtime:"automatic"}},target:"es2022",loose:!1},minify:!1};let e=(t,e)=>{if(t)return e?.(t)};const NextPWAContext={shouldMinify:e(process.env.NEXT_PWA_MINIFY,convertBoolean),useSwcMinify:e(process.env.NEXT_PWA_SWC_MINIFY,convertBoolean)};const setDefaultContext=(t,e)=>{void 0===NextPWAContext[t]&&(NextPWAContext[t]=e);};let n=url.fileURLToPath(new URL(".",(typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (document.currentScript && document.currentScript.src || new URL('index.cjs', document.baseURI).href)))),a=()=>({compress:{ecma:5,comparisons:!1,inline:2},mangle:{safari10:!0},format:{ecma:5,safari10:!0,comments:!1,ascii_only:!0},resolveSwc:J,useSwcMinify:NextPWAContext.useSwcMinify});const getSharedWebpackConfig=({swcRc:o=defaultSwcRc})=>{let i=NextPWAContext.shouldMinify&&{minimize:!0,minimizer:[new s({minify:q,terserOptions:a()})]};return {resolve:{extensions:[".js",".ts"],fallback:{module:!1,dgram:!1,dns:!1,path:!1,fs:!1,os:!1,crypto:!1,stream:!1,http2:!1,net:!1,tls:!1,zlib:!1,child_process:!1}},resolveLoader:{alias:{"swc-loader":r.join(n,"swc-loader.cjs")}},module:{rules:[{test:/\.(t|j)s$/i,use:[{loader:"swc-loader",options:o}]},{test:/\.m?js$/,resolve:{fullySpecified:false}}]},optimization:i||void 0}};const buildCustomWorker=({isDev:c$1,baseDir:a,customWorkerSrc:f,customWorkerDest:d,customWorkerPrefix:j,plugins:h=[],tsconfig:w,basePath:k})=>{let $=c([f,r.join("src",f)],t=>{t=r.join(a,t);let e=["ts","js"].map(o=>r.join(t,`index.${o}`)).filter(r=>e$2.existsSync(r));if(0===e.length)return;let n=e[0];return e.length>1&&V.info(`More than one custom worker found, ${n} will be used.`),n});if(!$)return;V.info(`Found a custom worker implementation at ${$}.`),w&&w.compilerOptions&&w.compilerOptions.paths&&a$1(defaultSwcRc,r.join(a,w.compilerOptions.baseUrl??"."),w.compilerOptions.paths);let b=`${j}-${getContentHash($,c$1)}.js`;return V.info(`Building custom worker to ${r.join(d,b)}...`),n$2({...getSharedWebpackConfig({swcRc:defaultSwcRc}),mode:NextPWAContext.shouldMinify?"production":"development",target:"webworker",entry:{main:$},output:{path:d,filename:b,chunkFilename:"sw-chunks/[id]-[chunkhash].js"},plugins:[new cleanWebpackPlugin.CleanWebpackPlugin({cleanOnceBeforeBuildPatterns:[r.join(d,`${j}-*.js`),r.join(d,`${j}-*.js.map`)]}),...h]}).run((o,r)=>{(o||r?.hasErrors())&&(V.error("Failed to build custom worker."),V.error(r?.toString({colors:!0})),process.exit(-1));}),r.posix.join(k,b)};const getFallbackEnvs=({fallbacks:L,buildId:e})=>{let t=L.data;t&&t.endsWith(".json")&&(t=r.posix.join("/_next/data",e,t));let o={__PWA_FALLBACK_DOCUMENT__:L.document||!1,__PWA_FALLBACK_IMAGE__:L.image||!1,__PWA_FALLBACK_AUDIO__:L.audio||!1,__PWA_FALLBACK_VIDEO__:L.video||!1,__PWA_FALLBACK_FONT__:L.font||!1,__PWA_FALLBACK_DATA__:t||!1};if(0!==Object.values(o).filter(_=>!!_).length)return V.info("This app will fallback to these precached routes when fetching from the cache and the network fails:"),o.__PWA_FALLBACK_DOCUMENT__&&V.info(` Documents (pages): ${o.__PWA_FALLBACK_DOCUMENT__}`),o.__PWA_FALLBACK_IMAGE__&&V.info(` Images: ${o.__PWA_FALLBACK_IMAGE__}`),o.__PWA_FALLBACK_AUDIO__&&V.info(` Audio: ${o.__PWA_FALLBACK_AUDIO__}`),o.__PWA_FALLBACK_VIDEO__&&V.info(` Videos: ${o.__PWA_FALLBACK_VIDEO__}`),o.__PWA_FALLBACK_FONT__&&V.info(` Fonts: ${o.__PWA_FALLBACK_FONT__}`),o.__PWA_FALLBACK_DATA__&&V.info(` Data (/_next/data/**/*.json): ${o.__PWA_FALLBACK_DATA__}`),o};let m=url.fileURLToPath(new URL(".",(typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (document.currentScript && document.currentScript.src || new URL('index.cjs', document.baseURI).href))));const buildFallbackWorker=({isDev:e,buildId:c,fallbacks:p,destDir:u,basePath:f})=>{p=Object.keys(p).reduce((e,o)=>{let t=p[o];return t&&(e[o]=r.posix.join(f,t)),e},{});let j=getFallbackEnvs({fallbacks:p,buildId:c});if(!j)return;let k=r.join(m,"fallback.js"),b=`fallback-${getContentHash(k,e)}.js`;return n$2({...getSharedWebpackConfig({}),mode:NextPWAContext.shouldMinify?"production":"development",target:"webworker",entry:{main:k},output:{path:u,filename:b,chunkFilename:"sw-chunks/[id]-[chunkhash].js"},plugins:[new cleanWebpackPlugin.CleanWebpackPlugin({cleanOnceBeforeBuildPatterns:[r.join(u,"fallback-*.js"),r.join(u,"fallback-*.js.map")]}),new n$2.EnvironmentPlugin(j)]}).run((r,e)=>{(r||e?.hasErrors())&&(V.error("Failed to build fallback worker."),V.error(e?.toString({colors:!0})),process.exit(-1));}),{name:r.posix.join(f,b),precaches:Object.values(j).filter(r=>!!r)}};let p=url.fileURLToPath(new URL(".",(typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (document.currentScript && document.currentScript.src || new URL('index.cjs', document.baseURI).href))));const buildSWEntryWorker=({isDev:e,destDir:u,shouldGenSWEWorker:l,basePath:a})=>{if(!l)return;let w=r.join(p,"sw-entry-worker.js"),c=`swe-worker-${getContentHash(w,e)}.js`;return n$2({...getSharedWebpackConfig({}),mode:NextPWAContext.shouldMinify?"production":"development",target:"webworker",entry:{main:w},output:{path:u,filename:c,chunkFilename:"sw-chunks/[id]-[chunkhash].js"},plugins:[new cleanWebpackPlugin.CleanWebpackPlugin({cleanOnceBeforeBuildPatterns:[r.join(u,"swe-worker-*.js"),r.join(u,"swe-worker-*.js.map")]})]}).run((r,e)=>{(r||e?.hasErrors())&&(V.error("Failed to build the service worker's sub-worker."),V.error(e?.toString({colors:!0})),process.exit(-1));}),r.posix.join(a,c)};const getDefaultDocumentPage=(t,f,n)=>{let s;let r$1=c(["pages","src/pages"],o=>(o=r.join(t,o),e$2.existsSync(o)?o:void 0));if(n&&(s=c(["app","src/app"],o=>(o=r.join(t,o),e$2.existsSync(o)?o:void 0))),r$1||s)for(let o of f){if(s){let t=r.join(s,`~offline/page.${o}`);if(e$2.existsSync(t))return "/~offline"}if(r$1){let t=r.join(r$1,`_offline.${o}`);if(t&&e$2.existsSync(t))return "/_offline"}}};let d=url.fileURLToPath(new URL(".",(typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (document.currentScript && document.currentScript.src || new URL('index.cjs', document.baseURI).href))));var index = ((e={})=>(t={})=>({...t,webpack(w,j){let h;try{h=require("next/dist/server/config-shared").defaultConfig;}catch{}let b=t.experimental?.appDir??h?.experimental?.appDir??!0,g=j.webpack,{buildId:k,dev:x,config:{distDir:y=".next",pageExtensions:A=["tsx","ts","jsx","js","mdx"]}}=j,v=j.config.basePath||"/",D=f(j.dir,t?.typescript?.tsconfigPath),{disable:P=!1,register:E=!0,dest:W=y,sw:$="sw.js",cacheStartUrl:S=!0,dynamicStartUrl:O=!0,dynamicStartUrlRedirect:R,publicExcludes:N=["!noprecache/**/*"],buildExcludes:C=[],fallbacks:L={},cacheOnFrontEndNav:M=!1,aggressiveFrontEndNavCaching:T=!1,reloadOnOnline:G=!0,scope:B=v,customWorkerDir:U,customWorkerSrc:F=U||"worker",customWorkerDest:H=W,customWorkerPrefix:I="worker",workboxOptions:Y={},extendDefaultRuntimeCaching:q=!1,swcMinify:K=t.swcMinify??h?.swcMinify??!1}=e;if("function"==typeof t.webpack&&(w=t.webpack(w,j)),P)return j.isServer&&V.info("PWA support is disabled."),w;let V$1=[];w.plugins||(w.plugins=[]),V.info(`Compiling for ${j.isServer?"server":"client (static)"}...`);let z=r.posix.join(v,$),J=r.posix.join(B,"/");w.plugins.push(new g.DefinePlugin({__PWA_SW__:`'${z}'`,__PWA_SCOPE__:`'${J}'`,__PWA_ENABLE_REGISTER__:`${!!E}`,__PWA_START_URL__:O?`'${v}'`:void 0,__PWA_CACHE_ON_FRONT_END_NAV__:`${!!M}`,__PWA_AGGRFEN_CACHE__:`${!!T}`,__PWA_RELOAD_ON_ONLINE__:`${!!G}`}));let Q=r.join(d,"sw-entry.js"),X=w.entry;if(w.entry=()=>X().then(i=>(i["main.js"]&&!i["main.js"].includes(Q)&&(Array.isArray(i["main.js"])?i["main.js"].unshift(Q):"string"==typeof i["main.js"]&&(i["main.js"]=[Q,i["main.js"]])),i["main-app"]&&!i["main-app"].includes(Q)&&(Array.isArray(i["main-app"])?i["main-app"].unshift(Q):"string"==typeof i["main-app"]&&(i["main-app"]=[Q,i["main-app"]])),i)),!j.isServer){setDefaultContext("shouldMinify",!x),setDefaultContext("useSwcMinify",K);let e=r.join(j.dir,W),o=r.join(j.dir,H),t=buildSWEntryWorker({isDev:x,destDir:e,shouldGenSWEWorker:M,basePath:v});w.plugins.push(new g.DefinePlugin({__PWA_SW_ENTRY_WORKER__:t&&`'${t}'`})),E||(V.info("Service worker won't be automatically registered as per the config, please call the following code in componentDidMount or useEffect:"),V.info(" window.workbox.register()"),D?.compilerOptions?.types?.includes("@ducanh2912/next-pwa/workbox")||V.info("You may also want to add @ducanh2912/next-pwa/workbox to compilerOptions.types in your tsconfig.json/jsconfig.json.")),V.info(`Service worker: ${r.join(e,$)}`),V.info(` URL: ${z}`),V.info(` Scope: ${J}`),w.plugins.push(new cleanWebpackPlugin.CleanWebpackPlugin({cleanOnceBeforeBuildPatterns:[r.join(e,"workbox-*.js"),r.join(e,"workbox-*.js.map"),r.join(e,$),r.join(e,`${$}.map`),r.join(e,"sw-chunks/**")]}));let d=buildCustomWorker({isDev:x,baseDir:j.dir,swDest:e,customWorkerSrc:F,customWorkerDest:o,customWorkerPrefix:I,plugins:w.plugins.filter(i=>i instanceof g.DefinePlugin),tsconfig:D,basePath:v});d&&V$1.unshift(d);let{additionalManifestEntries:h,modifyURLPrefix:y={},manifestTransforms:P=[],exclude:T,...G}=Y,B=h??[];B||(B=s$1.sync(["**/*","!workbox-*.js","!workbox-*.js.map","!worker-*.js","!worker-*.js.map","!fallback-*.js","!fallback-*.js.map",`!${$.replace(/^\/+/,"")}`,`!${$.replace(/^\/+/,"")}.map`,...N],{cwd:"public"}).map(e=>({url:r.posix.join(v,e),revision:getFileHash(`public/${e}`)}))),S&&(O?"string"==typeof R&&R.length>0&&B.push({url:R,revision:k}):B.push({url:v,revision:k})),Object.keys(G).forEach(i=>void 0===G[i]&&delete G[i]);let U=!1;if(L){L.document||(L.document=getDefaultDocumentPage(j.dir,A,b));let i=buildFallbackWorker({isDev:x,buildId:k,fallbacks:L,destDir:e,basePath:v});i&&(U=!0,V$1.unshift(i.name),i.precaches.forEach(i=>{i&&"boolean"!=typeof i&&!B.find(e=>"string"!=typeof e&&e.url.startsWith(i))&&B.push({url:i,revision:k});}));}let Q=resolveWorkboxCommon({dest:e,sw:$,dev:x,buildId:k,buildExcludes:C,manifestEntries:B,manifestTransforms:P,modifyURLPrefix:y,publicPath:w.output?.publicPath}),X=resolveWorkboxPlugin({rootDir:j.dir,basePath:v,isDev:x,workboxCommon:Q,workboxOptions:G,importScripts:V$1,extendDefaultRuntimeCaching:q,dynamicStartUrl:O,hasFallbacks:U});w.plugins.push(X);}return w}}));exports.default=index;exports.runtimeCaching=t; +\ No newline at end of file +diff --git a/node_modules/@ducanh2912/next-pwa/dist/index.module.js b/node_modules/@ducanh2912/next-pwa/dist/index.module.js +index 4389609..82ce509 100644 +--- a/node_modules/@ducanh2912/next-pwa/dist/index.module.js ++++ b/node_modules/@ducanh2912/next-pwa/dist/index.module.js +@@ -1,2 +1,2 @@ + import r from'path';import {fileURLToPath}from'url';import {createRequire}from'module';import e$2 from'fs';import n$1 from'process';import l from'os';import i from'tty';import {gte}from'semver';import {CleanWebpackPlugin}from'clean-webpack-plugin';import s$1 from'fast-glob';import t$1 from'workbox-webpack-plugin';import e$3 from'crypto';import n$2 from'webpack';import s from'terser-webpack-plugin';let e$1;let a$1=(e,r,t)=>{e.jsc||(e.jsc={}),e.jsc.baseUrl=r,e.jsc.paths=t;},c=(e,r)=>{for(let t of e){let e=r(t);if(e)return e}},u=createRequire(import.meta.url),p$1=e=>{try{return u(`${e}/package.json`).version}catch{return}},f=(e,r$1)=>{try{let n=c([r$1??"tsconfig.json","jsconfig.json"],r$1=>{let n=r.join(e,r$1);return e$2.existsSync(n)?n:void 0});if(!n)return;return JSON.parse(e$2.readFileSync(n,"utf-8"))}catch{return}},m$1=(e=0)=>r=>`\u001B[${r+e}m`,b=(e=0)=>r=>`\u001B[${38+e};5;${r}m`,g=(e=0)=>(r,t,o)=>`\u001B[${38+e};2;${r};${t};${o}m`,h={modifier:{reset:[0,0],bold:[1,22],dim:[2,22],italic:[3,23],underline:[4,24],overline:[53,55],inverse:[7,27],hidden:[8,28],strikethrough:[9,29]},color:{black:[30,39],red:[31,39],green:[32,39],yellow:[33,39],blue:[34,39],magenta:[35,39],cyan:[36,39],white:[37,39],blackBright:[90,39],gray:[90,39],grey:[90,39],redBright:[91,39],greenBright:[92,39],yellowBright:[93,39],blueBright:[94,39],magentaBright:[95,39],cyanBright:[96,39],whiteBright:[97,39]},bgColor:{bgBlack:[40,49],bgRed:[41,49],bgGreen:[42,49],bgYellow:[43,49],bgBlue:[44,49],bgMagenta:[45,49],bgCyan:[46,49],bgWhite:[47,49],bgBlackBright:[100,49],bgGray:[100,49],bgGrey:[100,49],bgRedBright:[101,49],bgGreenBright:[102,49],bgYellowBright:[103,49],bgBlueBright:[104,49],bgMagentaBright:[105,49],bgCyanBright:[106,49],bgWhiteBright:[107,49]}};Object.keys(h.modifier);let d$1=Object.keys(h.color),O=Object.keys(h.bgColor);[...d$1,...O];let y=function(){let e=new Map;for(let[r,t]of Object.entries(h)){for(let[r,o]of Object.entries(t))h[r]={open:`\u001B[${o[0]}m`,close:`\u001B[${o[1]}m`},t[r]=h[r],e.set(o[0],o[1]);Object.defineProperty(h,r,{value:t,enumerable:!1});}return Object.defineProperty(h,"codes",{value:e,enumerable:!1}),h.color.close="\x1b[39m",h.bgColor.close="\x1b[49m",h.color.ansi=m$1(),h.color.ansi256=b(),h.color.ansi16m=g(),h.bgColor.ansi=m$1(10),h.bgColor.ansi256=b(10),h.bgColor.ansi16m=g(10),Object.defineProperties(h,{rgbToAnsi256:{value:(e,r,t)=>e===r&&r===t?e<8?16:e>248?231:Math.round((e-8)/247*24)+232:16+36*Math.round(e/255*5)+6*Math.round(r/255*5)+Math.round(t/255*5),enumerable:!1},hexToRgb:{value(e){let r=/[a-f\d]{6}|[a-f\d]{3}/i.exec(e.toString(16));if(!r)return [0,0,0];let[t]=r;3===t.length&&(t=[...t].map(e=>e+e).join(""));let o=Number.parseInt(t,16);return [o>>16&255,o>>8&255,255&o]},enumerable:!1},hexToAnsi256:{value:e=>h.rgbToAnsi256(...h.hexToRgb(e)),enumerable:!1},ansi256ToAnsi:{value(e){let r,t,o;if(e<8)return 30+e;if(e<16)return 90+(e-8);if(e>=232)t=r=((e-232)*10+8)/255,o=r;else {let n=(e-=16)%36;r=Math.floor(e/36)/5,t=Math.floor(n/6)/5,o=n%6/5;}let n=2*Math.max(r,t,o);if(0===n)return 30;let l=30+(Math.round(o)<<2|Math.round(t)<<1|Math.round(r));return 2===n&&(l+=60),l},enumerable:!1},rgbToAnsi:{value:(e,r,t)=>h.ansi256ToAnsi(h.rgbToAnsi256(e,r,t)),enumerable:!1},hexToAnsi:{value:e=>h.ansi256ToAnsi(h.hexToAnsi256(e)),enumerable:!1}}),h}();function v(e,r=globalThis.Deno?globalThis.Deno.args:n$1.argv){let t=e.startsWith("-")?"":1===e.length?"-":"--",o=r.indexOf(t+e),l=r.indexOf("--");return -1!==o&&(-1===l||o=10&&Number(e[2])>=10586?Number(e[2])>=14931?3:2:1}if("CI"in T)return "GITHUB_ACTIONS"in T||"GITEA_ACTIONS"in T?3:["TRAVIS","CIRCLECI","APPVEYOR","GITLAB_CI","BUILDKITE","DRONE"].some(e=>e in T)||"codeship"===T.CI_NAME?1:a;if("TEAMCITY_VERSION"in T)return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(T.TEAMCITY_VERSION)?1:0;if("truecolor"===T.COLORTERM||"xterm-kitty"===T.TERM)return 3;if("TERM_PROGRAM"in T){let e=Number.parseInt((T.TERM_PROGRAM_VERSION||"").split(".")[0],10);switch(T.TERM_PROGRAM){case"iTerm.app":return e>=3?3:2;case"Apple_Terminal":return 2}}return /-256(color)?$/i.test(T.TERM)?2:/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(T.TERM)||"COLORTERM"in T?1:a}(r,{streamIsTTY:r&&r.isTTY,...t});return 0!==o&&{level:o,hasBasic:!0,has256:o>=2,has16m:o>=3}}v("no-color")||v("no-colors")||v("color=false")||v("color=never")?e$1=0:(v("color")||v("colors")||v("color=true")||v("color=always"))&&(e$1=1);let{stdout:j,stderr:R}={stdout:M({isTTY:i.isatty(1)}),stderr:M({isTTY:i.isatty(2)})},w=Symbol("GENERATOR"),C=Symbol("STYLER"),A=Symbol("IS_EMPTY"),E=["ansi","ansi","ansi256","ansi16m"],B=Object.create(null),S=(e,r={})=>{if(r.level&&!(Number.isInteger(r.level)&&r.level>=0&&r.level<=3))throw Error("The `level` option should be an integer from 0 to 3");let t=j?j.level:0;e.level=void 0===r.level?t:r.level;},x=e=>{let r=(...e)=>e.join(" ");return S(r,e),Object.setPrototypeOf(r,I.prototype),r};function I(e){return x(e)}for(let[e,r]of(Object.setPrototypeOf(I.prototype,Function.prototype),Object.entries(y)))B[e]={get(){let t=$(this,_(r.open,r.close,this[C]),this[A]);return Object.defineProperty(this,e,{value:t}),t}};B.visible={get(){let e=$(this,this[C],!0);return Object.defineProperty(this,"visible",{value:e}),e}};let P=(e,r,t,...o)=>"rgb"===e?"ansi16m"===r?y[t].ansi16m(...o):"ansi256"===r?y[t].ansi256(y.rgbToAnsi256(...o)):y[t].ansi(y.rgbToAnsi(...o)):"hex"===e?P("rgb",r,t,...y.hexToRgb(...o)):y[t][e](...o);for(let e of ["rgb","hex","ansi256"])B[e]={get(){let{level:r}=this;return function(...t){return $(this,_(P(e,E[r],"color",...t),y.color.close,this[C]),this[A])}}},B["bg"+e[0].toUpperCase()+e.slice(1)]={get(){let{level:r}=this;return function(...t){return $(this,_(P(e,E[r],"bgColor",...t),y.bgColor.close,this[C]),this[A])}}};let N=Object.defineProperties(()=>{},{...B,level:{enumerable:!0,get(){return this[w].level},set(e){this[w].level=e;}}}),_=(e,r,t)=>{let o,n;return void 0===t?(o=e,n=r):(o=t.openAll+e,n=r+t.closeAll),{open:e,close:r,openAll:o,closeAll:n,parent:t}},$=(e,r,t)=>{let o=(...e)=>k(o,1===e.length?""+e[0]:e.join(" "));return Object.setPrototypeOf(o,N),o[w]=e,o[C]=r,o[A]=t,o},k=(e,r)=>{if(e.level<=0||!r)return e[A]?"":r;let t=e[C];if(void 0===t)return r;let{openAll:o,closeAll:n}=t;if(r.includes("\x1b"))for(;void 0!==t;)r=function(e,r,t){let o=e.indexOf(r);if(-1===o)return e;let n=r.length,l=0,i="";do i+=e.slice(l,o)+r+t,l=o+n,o=e.indexOf(r,l);while(-1!==o)return i+e.slice(l)}(r,t.close,t.open),t=t.parent;let l=r.indexOf("\n");return -1!==l&&(r=function(e,r,t,o){let n=0,l="";do{let i="\r"===e[o-1];l+=e.slice(n,i?o-1:o)+r+(i?"\r\n":"\n")+t,n=o+1,o=e.indexOf("\n",n);}while(-1!==o)return l+e.slice(n)}(r,n,o,l)),o+r+n};Object.defineProperties(I.prototype,B);let L=x(void 0);x({level:R?R.level:0});let F=p$1("next"),G=!!F&>e(F,"13.4.1"),Y=(e,r=0)=>G?`- ${e} (pwa)`:`${e}${" ".repeat(r)}- (PWA)`,D={wait:Y(L.cyan("wait"),2),error:Y(L.red("error"),1),warn:Y(L.yellow("warn"),2),info:Y(L.cyan("info"),2)};var V=Object.freeze({__proto__:null,error:(...e)=>{console.error(D.error,...e);},info:(...e)=>{console.log(D.info,...e);},prefixes:D,wait:(...e)=>{console.log(D.wait,...e);},warn:(...e)=>{console.warn(D.warn,...e);}});let J=()=>{let e;for(let r of ["@swc/core","next/dist/build/swc"])try{e=require(r);break}catch{}if(!e)throw Error("Failed to resolve swc. Please install @swc/core if you haven't.");return e};createRequire(import.meta.url);let q=async(e,r,t,o)=>{let{resolveSwc:n,useSwcMinify:l,...i}=t,s=()=>require("terser-webpack-plugin").terserMinify(e,r,i,o);if(l){let t,o;try{t=n();}catch{return s()}if(!t.minify)return s();let l={...i,compress:"boolean"==typeof i.compress?!!i.compress&&{}:{...i.compress},mangle:null==i.mangle||("boolean"==typeof i.mangle?i.mangle:{...i.mangle}),sourceMap:void 0};r&&(l.sourceMap=!0),l.compress&&(void 0===l.compress.ecma&&(l.compress.ecma=l.ecma),5===l.ecma&&void 0===l.compress.arrows&&(l.compress.arrows=!1));let[[a,c]]=Object.entries(e),u=await t.minify(c,l);return u.map&&((o=JSON.parse(u.map)).sources=[a],delete o.sourcesContent),{code:u.code,map:o}}return s()};var t = [{urlPattern:/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,handler:"CacheFirst",options:{cacheName:"google-fonts-webfonts",expiration:{maxEntries:4,maxAgeSeconds:31536e3}}},{urlPattern:/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,handler:"StaleWhileRevalidate",options:{cacheName:"google-fonts-stylesheets",expiration:{maxEntries:4,maxAgeSeconds:604800}}},{urlPattern:/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,handler:"StaleWhileRevalidate",options:{cacheName:"static-font-assets",expiration:{maxEntries:4,maxAgeSeconds:604800}}},{urlPattern:/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,handler:"StaleWhileRevalidate",options:{cacheName:"static-image-assets",expiration:{maxEntries:64,maxAgeSeconds:2592e3}}},{urlPattern:/\/_next\/static.+\.js$/i,handler:"CacheFirst",options:{cacheName:"next-static-js-assets",expiration:{maxEntries:64,maxAgeSeconds:86400}}},{urlPattern:/\/_next\/image\?url=.+$/i,handler:"StaleWhileRevalidate",options:{cacheName:"next-image",expiration:{maxEntries:64,maxAgeSeconds:86400}}},{urlPattern:/\.(?:mp3|wav|ogg)$/i,handler:"CacheFirst",options:{rangeRequests:!0,cacheName:"static-audio-assets",expiration:{maxEntries:32,maxAgeSeconds:86400}}},{urlPattern:/\.(?:mp4)$/i,handler:"CacheFirst",options:{rangeRequests:!0,cacheName:"static-video-assets",expiration:{maxEntries:32,maxAgeSeconds:86400}}},{urlPattern:/\.(?:js)$/i,handler:"StaleWhileRevalidate",options:{cacheName:"static-js-assets",expiration:{maxEntries:48,maxAgeSeconds:86400}}},{urlPattern:/\.(?:css|less)$/i,handler:"StaleWhileRevalidate",options:{cacheName:"static-style-assets",expiration:{maxEntries:32,maxAgeSeconds:86400}}},{urlPattern:/\/_next\/data\/.+\/.+\.json$/i,handler:"StaleWhileRevalidate",options:{cacheName:"next-data",expiration:{maxEntries:32,maxAgeSeconds:86400}}},{urlPattern:/\.(?:json|xml|csv)$/i,handler:"NetworkFirst",options:{cacheName:"static-data-assets",expiration:{maxEntries:32,maxAgeSeconds:86400}}},{urlPattern:({sameOrigin:e,url:{pathname:t}})=>!(!e||t.startsWith("/api/auth/"))&&!!t.startsWith("/api/"),handler:"NetworkFirst",method:"GET",options:{cacheName:"apis",expiration:{maxEntries:16,maxAgeSeconds:86400},networkTimeoutSeconds:10}},{urlPattern:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&"1"===e.headers.get("Next-Router-Prefetch")&&a&&!t.startsWith("/api/"),handler:"NetworkFirst",options:{cacheName:"pages-rsc-prefetch",expiration:{maxEntries:32,maxAgeSeconds:86400}}},{urlPattern:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&a&&!t.startsWith("/api/"),handler:"NetworkFirst",options:{cacheName:"pages-rsc",expiration:{maxEntries:32,maxAgeSeconds:86400}}},{urlPattern:({url:{pathname:e},sameOrigin:t})=>t&&!e.startsWith("/api/"),handler:"NetworkFirst",options:{cacheName:"pages",expiration:{maxEntries:32,maxAgeSeconds:86400}}},{urlPattern:({sameOrigin:e})=>!e,handler:"NetworkFirst",options:{cacheName:"cross-origin",expiration:{maxEntries:32,maxAgeSeconds:3600},networkTimeoutSeconds:10}}];const resolveWorkboxCommon=({dest:e,sw:a,dev:r$1,buildId:n,buildExcludes:s,manifestEntries:i,manifestTransforms:l,modifyURLPrefix:o,publicPath:m})=>({swDest:r.join(e,a),additionalManifestEntries:r$1?[]:i,exclude:[...s,({asset:t})=>!!(t.name.startsWith("server/")||t.name.match(/^((app-|^)build-manifest\.json|react-loadable-manifest\.json)$/))||!!r$1&&!t.name.startsWith("static/runtime/")],modifyURLPrefix:{...o,"/_next/../public/":"/"},manifestTransforms:[...l,async(t,e)=>{let a=t.map(t=>{if(t.url=t.url.replace("/_next//static/image","/_next/static/image"),t.url=t.url.replace("/_next//static/media","/_next/static/media"),null===t.revision){let a=t.url;"string"==typeof m&&a.startsWith(m)&&(a=t.url.substring(m.length));let r=e.assetsInfo.get(a);t.revision=r&&r.contenthash||n;}return t.url=t.url.replace(/\[/g,"%5B").replace(/\]/g,"%5D"),t});return {manifest:a,warnings:[]}}]}); +-const resolveRuntimeCaching=(o,n)=>{if(!o)return t;if(!n)return V.info("Custom runtimeCaching array found, using it instead of the default one."),o;V.info("Custom runtimeCaching array found, using it to extend the default one.");let a=[],i=new Set;for(let e of o)a.push(e),e.options?.cacheName&&i.add(e.options.cacheName);for(let e of t)e.options?.cacheName&&i.has(e.options.cacheName)||a.push(e);return a};const overrideAfterCalledMethod=e=>{Object.defineProperty(e,"alreadyCalled",{get:()=>!1,set(){}});};const isInjectManifestConfig=e=>void 0!==e&&"string"==typeof e.swSrc;const convertBoolean=(e,t=!0)=>{switch(typeof e){case"boolean":return e;case"number":case"bigint":return e>0;case"object":return null!==e;case"string":if(!t){if("false"===e||"0"===e)return !1;return !0}return "true"===e||"1"===e;case"function":case"symbol":return !0;case"undefined":return !1}};const getFileHash=r=>e$3.createHash("md5").update(e$2.readFileSync(r)).digest("hex");const getContentHash=(e,t)=>t?"development":getFileHash(e).slice(0,16);const resolveWorkboxPlugin=({rootDir:s,basePath:a,isDev:p,workboxCommon:l,workboxOptions:c,importScripts:u,extendDefaultRuntimeCaching:d,dynamicStartUrl:h,hasFallbacks:f})=>{if(isInjectManifestConfig(c)){let o=r.join(s,c.swSrc);V.info(`Using InjectManifest with ${o}`);let r$1=new t$1.InjectManifest({...l,...c,swSrc:o});return p&&overrideAfterCalledMethod(r$1),r$1}{let e;let{skipWaiting:r=!0,clientsClaim:s=!0,cleanupOutdatedCaches:m=!0,ignoreURLParametersMatching:g=[],importScripts:w,runtimeCaching:b}=c;w&&u.push(...w);let k=!1;p?(V.info("Building in development mode, caching and precaching are disabled for the most part. This means that offline support is disabled, but you can continue developing other functions in service worker."),g.push(/ts/),e=[{urlPattern:/.*/i,handler:"NetworkOnly",options:{cacheName:"dev"}}],k=!0):e=resolveRuntimeCaching(b,d),h&&e.unshift({urlPattern:a,handler:"NetworkFirst",options:{cacheName:"start-url",plugins:[{cacheWillUpdate:async({response:e})=>e&&"opaqueredirect"===e.type?new Response(e.body,{status:200,statusText:"OK",headers:e.headers}):e}]}}),f&&e.forEach(e=>{!e.options||e.options.precacheFallback||e.options.plugins?.find(e=>"handlerDidError"in e)||(e.options.plugins||(e.options.plugins=[]),e.options.plugins.push({handlerDidError:async({request:e})=>"undefined"!=typeof self?self.fallback(e):Response.error()}));});let y=new t$1.GenerateSW({...l,skipWaiting:r,clientsClaim:s,cleanupOutdatedCaches:m,ignoreURLParametersMatching:g,importScripts:u,...c,runtimeCaching:e});return k&&overrideAfterCalledMethod(y),y}};const defaultSwcRc={module:{type:"es6",lazy:!0,noInterop:!0},jsc:{parser:{syntax:"typescript",tsx:!0,dynamicImport:!0,decorators:!1},transform:{react:{runtime:"automatic"}},target:"es2022",loose:!1},minify:!1};let e=(t,e)=>{if(t)return e?.(t)};const NextPWAContext={shouldMinify:e(process.env.NEXT_PWA_MINIFY,convertBoolean),useSwcMinify:e(process.env.NEXT_PWA_SWC_MINIFY,convertBoolean)};const setDefaultContext=(t,e)=>{void 0===NextPWAContext[t]&&(NextPWAContext[t]=e);};let n=fileURLToPath(new URL(".",import.meta.url)),a=()=>({compress:{ecma:5,comparisons:!1,inline:2},mangle:{safari10:!0},format:{ecma:5,safari10:!0,comments:!1,ascii_only:!0},resolveSwc:J,useSwcMinify:NextPWAContext.useSwcMinify});const getSharedWebpackConfig=({swcRc:o=defaultSwcRc})=>{let i=NextPWAContext.shouldMinify&&{minimize:!0,minimizer:[new s({minify:q,terserOptions:a()})]};return {resolve:{extensions:[".js",".ts"],fallback:{module:!1,dgram:!1,dns:!1,path:!1,fs:!1,os:!1,crypto:!1,stream:!1,http2:!1,net:!1,tls:!1,zlib:!1,child_process:!1}},resolveLoader:{alias:{"swc-loader":r.join(n,"swc-loader.cjs")}},module:{rules:[{test:/\.(t|j)s$/i,use:[{loader:"swc-loader",options:o}]}]},optimization:i||void 0}};const buildCustomWorker=({isDev:c$1,baseDir:a,customWorkerSrc:f,customWorkerDest:d,customWorkerPrefix:j,plugins:h=[],tsconfig:w,basePath:k})=>{let $=c([f,r.join("src",f)],t=>{t=r.join(a,t);let e=["ts","js"].map(o=>r.join(t,`index.${o}`)).filter(r=>e$2.existsSync(r));if(0===e.length)return;let n=e[0];return e.length>1&&V.info(`More than one custom worker found, ${n} will be used.`),n});if(!$)return;V.info(`Found a custom worker implementation at ${$}.`),w&&w.compilerOptions&&w.compilerOptions.paths&&a$1(defaultSwcRc,r.join(a,w.compilerOptions.baseUrl??"."),w.compilerOptions.paths);let b=`${j}-${getContentHash($,c$1)}.js`;return V.info(`Building custom worker to ${r.join(d,b)}...`),n$2({...getSharedWebpackConfig({swcRc:defaultSwcRc}),mode:NextPWAContext.shouldMinify?"production":"development",target:"webworker",entry:{main:$},output:{path:d,filename:b,chunkFilename:"sw-chunks/[id]-[chunkhash].js"},plugins:[new CleanWebpackPlugin({cleanOnceBeforeBuildPatterns:[r.join(d,`${j}-*.js`),r.join(d,`${j}-*.js.map`)]}),...h]}).run((o,r)=>{(o||r?.hasErrors())&&(V.error("Failed to build custom worker."),V.error(r?.toString({colors:!0})),process.exit(-1));}),r.posix.join(k,b)};const getFallbackEnvs=({fallbacks:L,buildId:e})=>{let t=L.data;t&&t.endsWith(".json")&&(t=r.posix.join("/_next/data",e,t));let o={__PWA_FALLBACK_DOCUMENT__:L.document||!1,__PWA_FALLBACK_IMAGE__:L.image||!1,__PWA_FALLBACK_AUDIO__:L.audio||!1,__PWA_FALLBACK_VIDEO__:L.video||!1,__PWA_FALLBACK_FONT__:L.font||!1,__PWA_FALLBACK_DATA__:t||!1};if(0!==Object.values(o).filter(_=>!!_).length)return V.info("This app will fallback to these precached routes when fetching from the cache and the network fails:"),o.__PWA_FALLBACK_DOCUMENT__&&V.info(` Documents (pages): ${o.__PWA_FALLBACK_DOCUMENT__}`),o.__PWA_FALLBACK_IMAGE__&&V.info(` Images: ${o.__PWA_FALLBACK_IMAGE__}`),o.__PWA_FALLBACK_AUDIO__&&V.info(` Audio: ${o.__PWA_FALLBACK_AUDIO__}`),o.__PWA_FALLBACK_VIDEO__&&V.info(` Videos: ${o.__PWA_FALLBACK_VIDEO__}`),o.__PWA_FALLBACK_FONT__&&V.info(` Fonts: ${o.__PWA_FALLBACK_FONT__}`),o.__PWA_FALLBACK_DATA__&&V.info(` Data (/_next/data/**/*.json): ${o.__PWA_FALLBACK_DATA__}`),o};let m=fileURLToPath(new URL(".",import.meta.url));const buildFallbackWorker=({isDev:e,buildId:c,fallbacks:p,destDir:u,basePath:f})=>{p=Object.keys(p).reduce((e,o)=>{let t=p[o];return t&&(e[o]=r.posix.join(f,t)),e},{});let j=getFallbackEnvs({fallbacks:p,buildId:c});if(!j)return;let k=r.join(m,"fallback.js"),b=`fallback-${getContentHash(k,e)}.js`;return n$2({...getSharedWebpackConfig({}),mode:NextPWAContext.shouldMinify?"production":"development",target:"webworker",entry:{main:k},output:{path:u,filename:b,chunkFilename:"sw-chunks/[id]-[chunkhash].js"},plugins:[new CleanWebpackPlugin({cleanOnceBeforeBuildPatterns:[r.join(u,"fallback-*.js"),r.join(u,"fallback-*.js.map")]}),new n$2.EnvironmentPlugin(j)]}).run((r,e)=>{(r||e?.hasErrors())&&(V.error("Failed to build fallback worker."),V.error(e?.toString({colors:!0})),process.exit(-1));}),{name:r.posix.join(f,b),precaches:Object.values(j).filter(r=>!!r)}};let p=fileURLToPath(new URL(".",import.meta.url));const buildSWEntryWorker=({isDev:e,destDir:u,shouldGenSWEWorker:l,basePath:a})=>{if(!l)return;let w=r.join(p,"sw-entry-worker.js"),c=`swe-worker-${getContentHash(w,e)}.js`;return n$2({...getSharedWebpackConfig({}),mode:NextPWAContext.shouldMinify?"production":"development",target:"webworker",entry:{main:w},output:{path:u,filename:c,chunkFilename:"sw-chunks/[id]-[chunkhash].js"},plugins:[new CleanWebpackPlugin({cleanOnceBeforeBuildPatterns:[r.join(u,"swe-worker-*.js"),r.join(u,"swe-worker-*.js.map")]})]}).run((r,e)=>{(r||e?.hasErrors())&&(V.error("Failed to build the service worker's sub-worker."),V.error(e?.toString({colors:!0})),process.exit(-1));}),r.posix.join(a,c)};const getDefaultDocumentPage=(t,f,n)=>{let s;let r$1=c(["pages","src/pages"],o=>(o=r.join(t,o),e$2.existsSync(o)?o:void 0));if(n&&(s=c(["app","src/app"],o=>(o=r.join(t,o),e$2.existsSync(o)?o:void 0))),r$1||s)for(let o of f){if(s){let t=r.join(s,`~offline/page.${o}`);if(e$2.existsSync(t))return "/~offline"}if(r$1){let t=r.join(r$1,`_offline.${o}`);if(t&&e$2.existsSync(t))return "/_offline"}}};let d=fileURLToPath(new URL(".",import.meta.url));var index = ((e={})=>(t={})=>({...t,webpack(w,j){let h;try{h=require("next/dist/server/config-shared").defaultConfig;}catch{}let b=t.experimental?.appDir??h?.experimental?.appDir??!0,g=j.webpack,{buildId:k,dev:x,config:{distDir:y=".next",pageExtensions:A=["tsx","ts","jsx","js","mdx"]}}=j,v=j.config.basePath||"/",D=f(j.dir,t?.typescript?.tsconfigPath),{disable:P=!1,register:E=!0,dest:W=y,sw:$="sw.js",cacheStartUrl:S=!0,dynamicStartUrl:O=!0,dynamicStartUrlRedirect:R,publicExcludes:N=["!noprecache/**/*"],buildExcludes:C=[],fallbacks:L={},cacheOnFrontEndNav:M=!1,aggressiveFrontEndNavCaching:T=!1,reloadOnOnline:G=!0,scope:B=v,customWorkerDir:U,customWorkerSrc:F=U||"worker",customWorkerDest:H=W,customWorkerPrefix:I="worker",workboxOptions:Y={},extendDefaultRuntimeCaching:q=!1,swcMinify:K=t.swcMinify??h?.swcMinify??!1}=e;if("function"==typeof t.webpack&&(w=t.webpack(w,j)),P)return j.isServer&&V.info("PWA support is disabled."),w;let V$1=[];w.plugins||(w.plugins=[]),V.info(`Compiling for ${j.isServer?"server":"client (static)"}...`);let z=r.posix.join(v,$),J=r.posix.join(B,"/");w.plugins.push(new g.DefinePlugin({__PWA_SW__:`'${z}'`,__PWA_SCOPE__:`'${J}'`,__PWA_ENABLE_REGISTER__:`${!!E}`,__PWA_START_URL__:O?`'${v}'`:void 0,__PWA_CACHE_ON_FRONT_END_NAV__:`${!!M}`,__PWA_AGGRFEN_CACHE__:`${!!T}`,__PWA_RELOAD_ON_ONLINE__:`${!!G}`}));let Q=r.join(d,"sw-entry.js"),X=w.entry;if(w.entry=()=>X().then(i=>(i["main.js"]&&!i["main.js"].includes(Q)&&(Array.isArray(i["main.js"])?i["main.js"].unshift(Q):"string"==typeof i["main.js"]&&(i["main.js"]=[Q,i["main.js"]])),i["main-app"]&&!i["main-app"].includes(Q)&&(Array.isArray(i["main-app"])?i["main-app"].unshift(Q):"string"==typeof i["main-app"]&&(i["main-app"]=[Q,i["main-app"]])),i)),!j.isServer){setDefaultContext("shouldMinify",!x),setDefaultContext("useSwcMinify",K);let e=r.join(j.dir,W),o=r.join(j.dir,H),t=buildSWEntryWorker({isDev:x,destDir:e,shouldGenSWEWorker:M,basePath:v});w.plugins.push(new g.DefinePlugin({__PWA_SW_ENTRY_WORKER__:t&&`'${t}'`})),E||(V.info("Service worker won't be automatically registered as per the config, please call the following code in componentDidMount or useEffect:"),V.info(" window.workbox.register()"),D?.compilerOptions?.types?.includes("@ducanh2912/next-pwa/workbox")||V.info("You may also want to add @ducanh2912/next-pwa/workbox to compilerOptions.types in your tsconfig.json/jsconfig.json.")),V.info(`Service worker: ${r.join(e,$)}`),V.info(` URL: ${z}`),V.info(` Scope: ${J}`),w.plugins.push(new CleanWebpackPlugin({cleanOnceBeforeBuildPatterns:[r.join(e,"workbox-*.js"),r.join(e,"workbox-*.js.map"),r.join(e,$),r.join(e,`${$}.map`),r.join(e,"sw-chunks/**")]}));let d=buildCustomWorker({isDev:x,baseDir:j.dir,swDest:e,customWorkerSrc:F,customWorkerDest:o,customWorkerPrefix:I,plugins:w.plugins.filter(i=>i instanceof g.DefinePlugin),tsconfig:D,basePath:v});d&&V$1.unshift(d);let{additionalManifestEntries:h,modifyURLPrefix:y={},manifestTransforms:P=[],exclude:T,...G}=Y,B=h??[];B||(B=s$1.sync(["**/*","!workbox-*.js","!workbox-*.js.map","!worker-*.js","!worker-*.js.map","!fallback-*.js","!fallback-*.js.map",`!${$.replace(/^\/+/,"")}`,`!${$.replace(/^\/+/,"")}.map`,...N],{cwd:"public"}).map(e=>({url:r.posix.join(v,e),revision:getFileHash(`public/${e}`)}))),S&&(O?"string"==typeof R&&R.length>0&&B.push({url:R,revision:k}):B.push({url:v,revision:k})),Object.keys(G).forEach(i=>void 0===G[i]&&delete G[i]);let U=!1;if(L){L.document||(L.document=getDefaultDocumentPage(j.dir,A,b));let i=buildFallbackWorker({isDev:x,buildId:k,fallbacks:L,destDir:e,basePath:v});i&&(U=!0,V$1.unshift(i.name),i.precaches.forEach(i=>{i&&"boolean"!=typeof i&&!B.find(e=>"string"!=typeof e&&e.url.startsWith(i))&&B.push({url:i,revision:k});}));}let Q=resolveWorkboxCommon({dest:e,sw:$,dev:x,buildId:k,buildExcludes:C,manifestEntries:B,manifestTransforms:P,modifyURLPrefix:y,publicPath:w.output?.publicPath}),X=resolveWorkboxPlugin({rootDir:j.dir,basePath:v,isDev:x,workboxCommon:Q,workboxOptions:G,importScripts:V$1,extendDefaultRuntimeCaching:q,dynamicStartUrl:O,hasFallbacks:U});w.plugins.push(X);}return w}}));export{index as default,t as runtimeCaching}; +\ No newline at end of file ++const resolveRuntimeCaching=(o,n)=>{if(!o)return t;if(!n)return V.info("Custom runtimeCaching array found, using it instead of the default one."),o;V.info("Custom runtimeCaching array found, using it to extend the default one.");let a=[],i=new Set;for(let e of o)a.push(e),e.options?.cacheName&&i.add(e.options.cacheName);for(let e of t)e.options?.cacheName&&i.has(e.options.cacheName)||a.push(e);return a};const overrideAfterCalledMethod=e=>{Object.defineProperty(e,"alreadyCalled",{get:()=>!1,set(){}});};const isInjectManifestConfig=e=>void 0!==e&&"string"==typeof e.swSrc;const convertBoolean=(e,t=!0)=>{switch(typeof e){case"boolean":return e;case"number":case"bigint":return e>0;case"object":return null!==e;case"string":if(!t){if("false"===e||"0"===e)return !1;return !0}return "true"===e||"1"===e;case"function":case"symbol":return !0;case"undefined":return !1}};const getFileHash=r=>e$3.createHash("md5").update(e$2.readFileSync(r)).digest("hex");const getContentHash=(e,t)=>t?"development":getFileHash(e).slice(0,16);const resolveWorkboxPlugin=({rootDir:s,basePath:a,isDev:p,workboxCommon:l,workboxOptions:c,importScripts:u,extendDefaultRuntimeCaching:d,dynamicStartUrl:h,hasFallbacks:f})=>{if(isInjectManifestConfig(c)){let o=r.join(s,c.swSrc);V.info(`Using InjectManifest with ${o}`);let r$1=new t$1.InjectManifest({...l,...c,swSrc:o});return p&&overrideAfterCalledMethod(r$1),r$1}{let e;let{skipWaiting:r=!0,clientsClaim:s=!0,cleanupOutdatedCaches:m=!0,ignoreURLParametersMatching:g=[],importScripts:w,runtimeCaching:b}=c;w&&u.push(...w);let k=!1;p?(V.info("Building in development mode, caching and precaching are disabled for the most part. This means that offline support is disabled, but you can continue developing other functions in service worker."),g.push(/ts/),e=[{urlPattern:/.*/i,handler:"NetworkOnly",options:{cacheName:"dev"}}],k=!0):e=resolveRuntimeCaching(b,d),h&&e.unshift({urlPattern:a,handler:"NetworkFirst",options:{cacheName:"start-url",plugins:[{cacheWillUpdate:async({response:e})=>e&&"opaqueredirect"===e.type?new Response(e.body,{status:200,statusText:"OK",headers:e.headers}):e}]}}),f&&e.forEach(e=>{!e.options||e.options.precacheFallback||e.options.plugins?.find(e=>"handlerDidError"in e)||(e.options.plugins||(e.options.plugins=[]),e.options.plugins.push({handlerDidError:async({request:e})=>"undefined"!=typeof self?self.fallback(e):Response.error()}));});let y=new t$1.GenerateSW({...l,skipWaiting:r,clientsClaim:s,cleanupOutdatedCaches:m,ignoreURLParametersMatching:g,importScripts:u,...c,runtimeCaching:e});return k&&overrideAfterCalledMethod(y),y}};const defaultSwcRc={module:{type:"es6",lazy:!0,noInterop:!0},jsc:{parser:{syntax:"typescript",tsx:!0,dynamicImport:!0,decorators:!1},transform:{react:{runtime:"automatic"}},target:"es2022",loose:!1},minify:!1};let e=(t,e)=>{if(t)return e?.(t)};const NextPWAContext={shouldMinify:e(process.env.NEXT_PWA_MINIFY,convertBoolean),useSwcMinify:e(process.env.NEXT_PWA_SWC_MINIFY,convertBoolean)};const setDefaultContext=(t,e)=>{void 0===NextPWAContext[t]&&(NextPWAContext[t]=e);};let n=fileURLToPath(new URL(".",import.meta.url)),a=()=>({compress:{ecma:5,comparisons:!1,inline:2},mangle:{safari10:!0},format:{ecma:5,safari10:!0,comments:!1,ascii_only:!0},resolveSwc:J,useSwcMinify:NextPWAContext.useSwcMinify});const getSharedWebpackConfig=({swcRc:o=defaultSwcRc})=>{let i=NextPWAContext.shouldMinify&&{minimize:!0,minimizer:[new s({minify:q,terserOptions:a()})]};return {resolve:{extensions:[".js",".ts"],fallback:{module:!1,dgram:!1,dns:!1,path:!1,fs:!1,os:!1,crypto:!1,stream:!1,http2:!1,net:!1,tls:!1,zlib:!1,child_process:!1}},resolveLoader:{alias:{"swc-loader":r.join(n,"swc-loader.cjs")}},module:{rules:[{test:/\.(t|j)s$/i,use:[{loader:"swc-loader",options:o}]},{test:/\.m?js$/,resolve:{fullySpecified:false}}]},optimization:i||void 0}};const buildCustomWorker=({isDev:c$1,baseDir:a,customWorkerSrc:f,customWorkerDest:d,customWorkerPrefix:j,plugins:h=[],tsconfig:w,basePath:k})=>{let $=c([f,r.join("src",f)],t=>{t=r.join(a,t);let e=["ts","js"].map(o=>r.join(t,`index.${o}`)).filter(r=>e$2.existsSync(r));if(0===e.length)return;let n=e[0];return e.length>1&&V.info(`More than one custom worker found, ${n} will be used.`),n});if(!$)return;V.info(`Found a custom worker implementation at ${$}.`),w&&w.compilerOptions&&w.compilerOptions.paths&&a$1(defaultSwcRc,r.join(a,w.compilerOptions.baseUrl??"."),w.compilerOptions.paths);let b=`${j}-${getContentHash($,c$1)}.js`;return V.info(`Building custom worker to ${r.join(d,b)}...`),n$2({...getSharedWebpackConfig({swcRc:defaultSwcRc}),mode:NextPWAContext.shouldMinify?"production":"development",target:"webworker",entry:{main:$},output:{path:d,filename:b,chunkFilename:"sw-chunks/[id]-[chunkhash].js"},plugins:[new CleanWebpackPlugin({cleanOnceBeforeBuildPatterns:[r.join(d,`${j}-*.js`),r.join(d,`${j}-*.js.map`)]}),...h]}).run((o,r)=>{(o||r?.hasErrors())&&(V.error("Failed to build custom worker."),V.error(r?.toString({colors:!0})),process.exit(-1));}),r.posix.join(k,b)};const getFallbackEnvs=({fallbacks:L,buildId:e})=>{let t=L.data;t&&t.endsWith(".json")&&(t=r.posix.join("/_next/data",e,t));let o={__PWA_FALLBACK_DOCUMENT__:L.document||!1,__PWA_FALLBACK_IMAGE__:L.image||!1,__PWA_FALLBACK_AUDIO__:L.audio||!1,__PWA_FALLBACK_VIDEO__:L.video||!1,__PWA_FALLBACK_FONT__:L.font||!1,__PWA_FALLBACK_DATA__:t||!1};if(0!==Object.values(o).filter(_=>!!_).length)return V.info("This app will fallback to these precached routes when fetching from the cache and the network fails:"),o.__PWA_FALLBACK_DOCUMENT__&&V.info(` Documents (pages): ${o.__PWA_FALLBACK_DOCUMENT__}`),o.__PWA_FALLBACK_IMAGE__&&V.info(` Images: ${o.__PWA_FALLBACK_IMAGE__}`),o.__PWA_FALLBACK_AUDIO__&&V.info(` Audio: ${o.__PWA_FALLBACK_AUDIO__}`),o.__PWA_FALLBACK_VIDEO__&&V.info(` Videos: ${o.__PWA_FALLBACK_VIDEO__}`),o.__PWA_FALLBACK_FONT__&&V.info(` Fonts: ${o.__PWA_FALLBACK_FONT__}`),o.__PWA_FALLBACK_DATA__&&V.info(` Data (/_next/data/**/*.json): ${o.__PWA_FALLBACK_DATA__}`),o};let m=fileURLToPath(new URL(".",import.meta.url));const buildFallbackWorker=({isDev:e,buildId:c,fallbacks:p,destDir:u,basePath:f})=>{p=Object.keys(p).reduce((e,o)=>{let t=p[o];return t&&(e[o]=r.posix.join(f,t)),e},{});let j=getFallbackEnvs({fallbacks:p,buildId:c});if(!j)return;let k=r.join(m,"fallback.js"),b=`fallback-${getContentHash(k,e)}.js`;return n$2({...getSharedWebpackConfig({}),mode:NextPWAContext.shouldMinify?"production":"development",target:"webworker",entry:{main:k},output:{path:u,filename:b,chunkFilename:"sw-chunks/[id]-[chunkhash].js"},plugins:[new CleanWebpackPlugin({cleanOnceBeforeBuildPatterns:[r.join(u,"fallback-*.js"),r.join(u,"fallback-*.js.map")]}),new n$2.EnvironmentPlugin(j)]}).run((r,e)=>{(r||e?.hasErrors())&&(V.error("Failed to build fallback worker."),V.error(e?.toString({colors:!0})),process.exit(-1));}),{name:r.posix.join(f,b),precaches:Object.values(j).filter(r=>!!r)}};let p=fileURLToPath(new URL(".",import.meta.url));const buildSWEntryWorker=({isDev:e,destDir:u,shouldGenSWEWorker:l,basePath:a})=>{if(!l)return;let w=r.join(p,"sw-entry-worker.js"),c=`swe-worker-${getContentHash(w,e)}.js`;return n$2({...getSharedWebpackConfig({}),mode:NextPWAContext.shouldMinify?"production":"development",target:"webworker",entry:{main:w},output:{path:u,filename:c,chunkFilename:"sw-chunks/[id]-[chunkhash].js"},plugins:[new CleanWebpackPlugin({cleanOnceBeforeBuildPatterns:[r.join(u,"swe-worker-*.js"),r.join(u,"swe-worker-*.js.map")]})]}).run((r,e)=>{(r||e?.hasErrors())&&(V.error("Failed to build the service worker's sub-worker."),V.error(e?.toString({colors:!0})),process.exit(-1));}),r.posix.join(a,c)};const getDefaultDocumentPage=(t,f,n)=>{let s;let r$1=c(["pages","src/pages"],o=>(o=r.join(t,o),e$2.existsSync(o)?o:void 0));if(n&&(s=c(["app","src/app"],o=>(o=r.join(t,o),e$2.existsSync(o)?o:void 0))),r$1||s)for(let o of f){if(s){let t=r.join(s,`~offline/page.${o}`);if(e$2.existsSync(t))return "/~offline"}if(r$1){let t=r.join(r$1,`_offline.${o}`);if(t&&e$2.existsSync(t))return "/_offline"}}};let d=fileURLToPath(new URL(".",import.meta.url));var index = ((e={})=>(t={})=>({...t,webpack(w,j){let h;try{h=require("next/dist/server/config-shared").defaultConfig;}catch{}let b=t.experimental?.appDir??h?.experimental?.appDir??!0,g=j.webpack,{buildId:k,dev:x,config:{distDir:y=".next",pageExtensions:A=["tsx","ts","jsx","js","mdx"]}}=j,v=j.config.basePath||"/",D=f(j.dir,t?.typescript?.tsconfigPath),{disable:P=!1,register:E=!0,dest:W=y,sw:$="sw.js",cacheStartUrl:S=!0,dynamicStartUrl:O=!0,dynamicStartUrlRedirect:R,publicExcludes:N=["!noprecache/**/*"],buildExcludes:C=[],fallbacks:L={},cacheOnFrontEndNav:M=!1,aggressiveFrontEndNavCaching:T=!1,reloadOnOnline:G=!0,scope:B=v,customWorkerDir:U,customWorkerSrc:F=U||"worker",customWorkerDest:H=W,customWorkerPrefix:I="worker",workboxOptions:Y={},extendDefaultRuntimeCaching:q=!1,swcMinify:K=t.swcMinify??h?.swcMinify??!1}=e;if("function"==typeof t.webpack&&(w=t.webpack(w,j)),P)return j.isServer&&V.info("PWA support is disabled."),w;let V$1=[];w.plugins||(w.plugins=[]),V.info(`Compiling for ${j.isServer?"server":"client (static)"}...`);let z=r.posix.join(v,$),J=r.posix.join(B,"/");w.plugins.push(new g.DefinePlugin({__PWA_SW__:`'${z}'`,__PWA_SCOPE__:`'${J}'`,__PWA_ENABLE_REGISTER__:`${!!E}`,__PWA_START_URL__:O?`'${v}'`:void 0,__PWA_CACHE_ON_FRONT_END_NAV__:`${!!M}`,__PWA_AGGRFEN_CACHE__:`${!!T}`,__PWA_RELOAD_ON_ONLINE__:`${!!G}`}));let Q=r.join(d,"sw-entry.js"),X=w.entry;if(w.entry=()=>X().then(i=>(i["main.js"]&&!i["main.js"].includes(Q)&&(Array.isArray(i["main.js"])?i["main.js"].unshift(Q):"string"==typeof i["main.js"]&&(i["main.js"]=[Q,i["main.js"]])),i["main-app"]&&!i["main-app"].includes(Q)&&(Array.isArray(i["main-app"])?i["main-app"].unshift(Q):"string"==typeof i["main-app"]&&(i["main-app"]=[Q,i["main-app"]])),i)),!j.isServer){setDefaultContext("shouldMinify",!x),setDefaultContext("useSwcMinify",K);let e=r.join(j.dir,W),o=r.join(j.dir,H),t=buildSWEntryWorker({isDev:x,destDir:e,shouldGenSWEWorker:M,basePath:v});w.plugins.push(new g.DefinePlugin({__PWA_SW_ENTRY_WORKER__:t&&`'${t}'`})),E||(V.info("Service worker won't be automatically registered as per the config, please call the following code in componentDidMount or useEffect:"),V.info(" window.workbox.register()"),D?.compilerOptions?.types?.includes("@ducanh2912/next-pwa/workbox")||V.info("You may also want to add @ducanh2912/next-pwa/workbox to compilerOptions.types in your tsconfig.json/jsconfig.json.")),V.info(`Service worker: ${r.join(e,$)}`),V.info(` URL: ${z}`),V.info(` Scope: ${J}`),w.plugins.push(new CleanWebpackPlugin({cleanOnceBeforeBuildPatterns:[r.join(e,"workbox-*.js"),r.join(e,"workbox-*.js.map"),r.join(e,$),r.join(e,`${$}.map`),r.join(e,"sw-chunks/**")]}));let d=buildCustomWorker({isDev:x,baseDir:j.dir,swDest:e,customWorkerSrc:F,customWorkerDest:o,customWorkerPrefix:I,plugins:w.plugins.filter(i=>i instanceof g.DefinePlugin),tsconfig:D,basePath:v});d&&V$1.unshift(d);let{additionalManifestEntries:h,modifyURLPrefix:y={},manifestTransforms:P=[],exclude:T,...G}=Y,B=h??[];B||(B=s$1.sync(["**/*","!workbox-*.js","!workbox-*.js.map","!worker-*.js","!worker-*.js.map","!fallback-*.js","!fallback-*.js.map",`!${$.replace(/^\/+/,"")}`,`!${$.replace(/^\/+/,"")}.map`,...N],{cwd:"public"}).map(e=>({url:r.posix.join(v,e),revision:getFileHash(`public/${e}`)}))),S&&(O?"string"==typeof R&&R.length>0&&B.push({url:R,revision:k}):B.push({url:v,revision:k})),Object.keys(G).forEach(i=>void 0===G[i]&&delete G[i]);let U=!1;if(L){L.document||(L.document=getDefaultDocumentPage(j.dir,A,b));let i=buildFallbackWorker({isDev:x,buildId:k,fallbacks:L,destDir:e,basePath:v});i&&(U=!0,V$1.unshift(i.name),i.precaches.forEach(i=>{i&&"boolean"!=typeof i&&!B.find(e=>"string"!=typeof e&&e.url.startsWith(i))&&B.push({url:i,revision:k});}));}let Q=resolveWorkboxCommon({dest:e,sw:$,dev:x,buildId:k,buildExcludes:C,manifestEntries:B,manifestTransforms:P,modifyURLPrefix:y,publicPath:w.output?.publicPath}),X=resolveWorkboxPlugin({rootDir:j.dir,basePath:v,isDev:x,workboxCommon:Q,workboxOptions:G,importScripts:V$1,extendDefaultRuntimeCaching:q,dynamicStartUrl:O,hasFallbacks:U});w.plugins.push(X);}return w}}));export{index as default,t as runtimeCaching}; +\ No newline at end of file diff --git a/public/images/notifications/push-notification.svg b/public/images/notifications/push-notification.svg new file mode 100644 index 0000000000..7e5de5fd23 --- /dev/null +++ b/public/images/notifications/push-notification.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/components/batch/BatchIndicator/BatchTooltip.tsx b/src/components/batch/BatchIndicator/BatchTooltip.tsx index ebd71ddc74..9f9718886f 100644 --- a/src/components/batch/BatchIndicator/BatchTooltip.tsx +++ b/src/components/batch/BatchIndicator/BatchTooltip.tsx @@ -1,28 +1,9 @@ import { type ReactElement, useEffect, useState } from 'react' import { Box, SvgIcon } from '@mui/material' -import Tooltip, { type TooltipProps, tooltipClasses } from '@mui/material/Tooltip' -import { styled } from '@mui/material/styles' + import SuccessIcon from '@/public/images/common/success.svg' import { TxEvent, txSubscribe } from '@/services/tx/txEvents' - -const StyledTooltip = styled(({ className, ...props }: TooltipProps) => ( - -))(({ theme }) => ({ - [`& .${tooltipClasses.tooltip}`]: { - backgroundColor: theme.palette.background.paper, - color: theme.palette.text.primary, - fontSize: theme.typography.pxToRem(16), - fontWeight: 700, - border: `1px solid ${theme.palette.border.light}`, - marginTop: theme.spacing(2) + ' !important', - }, - [`& .${tooltipClasses.arrow}`]: { - color: theme.palette.background.paper, - }, - [`& .${tooltipClasses.arrow}:before`]: { - border: `1px solid ${theme.palette.border.light}`, - }, -})) +import { CustomTooltip } from '@/components/common/CustomTooltip' const BatchTooltip = ({ children }: { children: ReactElement }) => { const [showTooltip, setShowTooltip] = useState(false) @@ -40,10 +21,9 @@ const BatchTooltip = ({ children }: { children: ReactElement }) => { }, []) return ( - setShowTooltip(false)} - arrow title={ @@ -54,7 +34,7 @@ const BatchTooltip = ({ children }: { children: ReactElement }) => { } >
{children}
-
+ ) } diff --git a/src/components/batch/BatchSidebar/BatchTxItem.tsx b/src/components/batch/BatchSidebar/BatchTxItem.tsx index 3a538ba83c..afd633a1af 100644 --- a/src/components/batch/BatchSidebar/BatchTxItem.tsx +++ b/src/components/batch/BatchSidebar/BatchTxItem.tsx @@ -12,6 +12,9 @@ import { MethodDetails } from '@/components/transactions/TxDetails/TxData/Decode import { TxDataRow } from '@/components/transactions/TxDetails/Summary/TxDataRow' import { dateString } from '@/utils/formatters' import { BATCH_EVENTS, trackEvent } from '@/services/analytics' +import { TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk' +import useABTesting from '@/services/tracking/useAbTesting' +import { AbTest } from '@/services/tracking/abTesting' type BatchTxItemProps = DraftBatchItem & { id: string @@ -30,6 +33,8 @@ const BatchTxItem = ({ dragging = false, draggable = false, }: BatchTxItemProps) => { + const shouldDisplayHumanDescription = useABTesting(AbTest.HUMAN_DESCRIPTION) + const txSummary = useMemo( () => ({ timestamp, @@ -55,6 +60,9 @@ const BatchTxItem = ({ const handleExpand = () => { trackEvent(BATCH_EVENTS.BATCH_EXPAND_TX) } + const displayInfo = + (!txDetails.txInfo.richDecodedInfo && txDetails.txInfo.type !== TransactionInfoType.TRANSFER) || + !shouldDisplayHumanDescription return ( @@ -75,9 +83,7 @@ const BatchTxItem = ({ - - - + {displayInfo && } {onDelete && ( <> diff --git a/src/components/common/ConnectWallet/ConnectWalletButton.tsx b/src/components/common/ConnectWallet/ConnectWalletButton.tsx new file mode 100644 index 0000000000..48269a3aad --- /dev/null +++ b/src/components/common/ConnectWallet/ConnectWalletButton.tsx @@ -0,0 +1,26 @@ +import { Button } from '@mui/material' +import useConnectWallet from '@/components/common/ConnectWallet/useConnectWallet' + +const ConnectWalletButton = ({ onConnect }: { onConnect?: () => void }): React.ReactElement => { + const connectWallet = useConnectWallet() + + const handleConnect = () => { + onConnect?.() + connectWallet() + } + + return ( + + ) +} + +export default ConnectWalletButton diff --git a/src/components/common/ConnectWallet/ConnectionCenter.tsx b/src/components/common/ConnectWallet/ConnectionCenter.tsx deleted file mode 100644 index 074f7673b0..0000000000 --- a/src/components/common/ConnectWallet/ConnectionCenter.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { Popover, ButtonBase, Typography, Paper, Divider, Box } from '@mui/material' -import ExpandMoreIcon from '@mui/icons-material/ExpandMore' -import ExpandLessIcon from '@mui/icons-material/ExpandLess' -import { useState, type MouseEvent, type ReactElement } from 'react' - -import KeyholeIcon from '@/components/common/icons/KeyholeIcon' -import WalletDetails from '@/components/common/ConnectWallet/WalletDetails' -import PairingDetails from '@/components/common/PairingDetails' - -import css from '@/components/common/ConnectWallet/styles.module.css' -import { useCurrentChain } from '@/hooks/useChains' -import { isPairingSupported } from '@/services/pairing/utils' - -const ConnectionCenter = (): ReactElement => { - const chain = useCurrentChain() - - const [anchorEl, setAnchorEl] = useState(null) - const open = !!anchorEl - - const isSupported = isPairingSupported(chain?.disabledWallets) - - const handleClick = (event: MouseEvent) => { - setAnchorEl(event.currentTarget) - } - - const handleClose = () => { - setAnchorEl(null) - } - - const ExpandIcon = open ? ExpandLessIcon : ExpandMoreIcon - - return ( - <> - - - - - Not connected -
- palette.error.main }}> - Connect wallet - -
- - -
- - - - - - {isSupported && ( - - - - - - )} - - - - ) -} - -export default ConnectionCenter diff --git a/src/components/common/ConnectWallet/WalletDetails.tsx b/src/components/common/ConnectWallet/WalletDetails.tsx deleted file mode 100644 index e91f11973e..0000000000 --- a/src/components/common/ConnectWallet/WalletDetails.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Button, Typography } from '@mui/material' -import type { ReactElement } from 'react' - -import KeyholeIcon from '@/components/common/icons/KeyholeIcon' -import useConnectWallet from '@/components/common/ConnectWallet/useConnectWallet' - -const WalletDetails = ({ onConnect }: { onConnect?: () => void }): ReactElement => { - const connectWallet = useConnectWallet() - - const handleConnect = () => { - onConnect?.() - connectWallet() - } - - return ( - <> - Connect a wallet - - - - - - ) -} - -export default WalletDetails diff --git a/src/components/common/ConnectWallet/index.tsx b/src/components/common/ConnectWallet/index.tsx index 8059f60eb3..225a875ca9 100644 --- a/src/components/common/ConnectWallet/index.tsx +++ b/src/components/common/ConnectWallet/index.tsx @@ -1,12 +1,12 @@ import type { ReactElement } from 'react' import useWallet from '@/hooks/wallets/useWallet' import AccountCenter from '@/components/common/ConnectWallet/AccountCenter' -import ConnectionCenter from '@/components/common/ConnectWallet/ConnectionCenter' +import ConnectWalletButton from './ConnectWalletButton' const ConnectWallet = (): ReactElement => { const wallet = useWallet() - return wallet ? : + return wallet ? : } export default ConnectWallet diff --git a/src/components/common/ConnectWallet/styles.module.css b/src/components/common/ConnectWallet/styles.module.css index e6ec039da1..fe3bad0514 100644 --- a/src/components/common/ConnectWallet/styles.module.css +++ b/src/components/common/ConnectWallet/styles.module.css @@ -1,8 +1,3 @@ -.connectedContainer { - display: flex; - align-items: center; -} - .buttonContainer { display: flex; align-items: center; @@ -46,21 +41,3 @@ .row:last-of-type { border-bottom: 1px solid var(--color-border-light); } - -.pairingDetails { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--space-2); -} - -@media (max-width: 599.95px) { - .buttonContainer { - transform: scale(0.8); - } - - .notConnected, - .pairingDetails { - display: none; - } -} diff --git a/src/components/common/CustomTooltip/index.tsx b/src/components/common/CustomTooltip/index.tsx new file mode 100644 index 0000000000..108c3bebea --- /dev/null +++ b/src/components/common/CustomTooltip/index.tsx @@ -0,0 +1,22 @@ +import { styled } from '@mui/material/styles' +import Tooltip, { tooltipClasses } from '@mui/material/Tooltip' +import { type TooltipProps } from '@mui/material/Tooltip' + +export const CustomTooltip = styled(({ className, ...props }: TooltipProps) => ( + +))(({ theme }) => ({ + [`& .${tooltipClasses.tooltip}`]: { + backgroundColor: theme.palette.background.paper, + color: theme.palette.text.primary, + fontSize: theme.typography.pxToRem(16), + fontWeight: 700, + border: `1px solid ${theme.palette.border.light}`, + marginTop: theme.spacing(2) + ' !important', + }, + [`& .${tooltipClasses.arrow}`]: { + color: theme.palette.background.paper, + }, + [`& .${tooltipClasses.arrow}:before`]: { + border: `1px solid ${theme.palette.border.light}`, + }, +})) diff --git a/src/components/common/EthHashInfo/index.test.tsx b/src/components/common/EthHashInfo/index.test.tsx index 0fd8825c81..adaf9172c6 100644 --- a/src/components/common/EthHashInfo/index.test.tsx +++ b/src/components/common/EthHashInfo/index.test.tsx @@ -1,4 +1,4 @@ -import makeBlockie from 'ethereum-blockies-base64' +import { blo } from 'blo' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { act, fireEvent, render, waitFor } from '@/tests/test-utils' @@ -179,7 +179,7 @@ describe('EthHashInfo', () => { expect(container.querySelector('.icon')).toHaveAttribute( 'style', - `background-image: url(${makeBlockie(MOCK_SAFE_ADDRESS)}); width: 40px; height: 40px;`, + `background-image: url(${blo(MOCK_SAFE_ADDRESS)}); width: 40px; height: 40px;`, ) }) @@ -188,7 +188,7 @@ describe('EthHashInfo', () => { expect(container.querySelector('.icon')).toHaveAttribute( 'style', - `background-image: url(${makeBlockie(MOCK_SAFE_ADDRESS)}); width: 100px; height: 100px;`, + `background-image: url(${blo(MOCK_SAFE_ADDRESS)}); width: 100px; height: 100px;`, ) }) diff --git a/src/components/common/Header/index.tsx b/src/components/common/Header/index.tsx index f507662409..6930e44840 100644 --- a/src/components/common/Header/index.tsx +++ b/src/components/common/Header/index.tsx @@ -15,6 +15,7 @@ import SafeLogo from '@/public/images/logo.svg' import Link from 'next/link' import useSafeAddress from '@/hooks/useSafeAddress' import BatchIndicator from '@/components/batch/BatchIndicator' +import { PushNotificationsBanner } from '@/components/settings/PushNotifications/PushNotificationsBanner' type HeaderProps = { onMenuToggle?: Dispatch> @@ -71,7 +72,9 @@ const Header = ({ onMenuToggle, onBatchToggle }: HeaderProps): ReactElement => { )}
- + + +
diff --git a/src/components/common/Identicon/index.tsx b/src/components/common/Identicon/index.tsx index fe25818a37..6b3d4d7623 100644 --- a/src/components/common/Identicon/index.tsx +++ b/src/components/common/Identicon/index.tsx @@ -1,6 +1,6 @@ import type { ReactElement, CSSProperties } from 'react' import { useMemo } from 'react' -import makeBlockie from 'ethereum-blockies-base64' +import { blo } from 'blo' import Skeleton from '@mui/material/Skeleton' import css from './styles.module.css' @@ -13,7 +13,7 @@ export interface IdenticonProps { const Identicon = ({ address, size = 40 }: IdenticonProps): ReactElement => { const style = useMemo(() => { try { - const blockie = makeBlockie(address) + const blockie = blo(address as `0x${string}`) return { backgroundImage: `url(${blockie})`, width: `${size}px`, diff --git a/src/components/common/PairingDetails/PairingDeprecationWarning.tsx b/src/components/common/PairingDetails/PairingDeprecationWarning.tsx new file mode 100644 index 0000000000..dd720461e0 --- /dev/null +++ b/src/components/common/PairingDetails/PairingDeprecationWarning.tsx @@ -0,0 +1,12 @@ +import { Alert } from '@mui/material' + +const PairingDeprecationWarning = (): React.ReactElement => { + return ( + + The {'Safe{Wallet}'} web-mobile pairing feature will be discontinued from 15th November 2023. Please migrate to a + different signer wallet before this date. + + ) +} + +export default PairingDeprecationWarning diff --git a/src/components/common/PairingDetails/index.tsx b/src/components/common/PairingDetails/index.tsx deleted file mode 100644 index 4ae6e82de8..0000000000 --- a/src/components/common/PairingDetails/index.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Typography } from '@mui/material' -import type { ReactElement } from 'react' - -import PairingQRCode from './PairingQRCode' -import PairingDescription from './PairingDescription' - -const PairingDetails = ({ vertical = false }: { vertical?: boolean }): ReactElement => { - const title = Connect to mobile - - const description = - - const qr = - - return ( - <> - {vertical ? ( - <> - {title} - {qr} - {description} - - ) : ( - <> - {qr} -
- {title} - {description} -
- - )} - - ) -} - -export default PairingDetails diff --git a/src/components/dashboard/PendingTxs/PendingTxListItem.tsx b/src/components/dashboard/PendingTxs/PendingTxListItem.tsx index 1ac2855c73..639822fb08 100644 --- a/src/components/dashboard/PendingTxs/PendingTxListItem.tsx +++ b/src/components/dashboard/PendingTxs/PendingTxListItem.tsx @@ -2,7 +2,6 @@ import NextLink from 'next/link' import { useRouter } from 'next/router' import type { ReactElement } from 'react' import { useMemo } from 'react' -import { TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk' import ChevronRight from '@mui/icons-material/ChevronRight' import type { TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' import { Box, SvgIcon, Typography } from '@mui/material' @@ -40,22 +39,18 @@ const PendingTx = ({ transaction }: PendingTxType): ReactElement => { [router, id], ) - const displayInfo = !transaction.txInfo.richDecodedInfo && transaction.txInfo.type !== TransactionInfoType.TRANSFER - return ( {isMultisigExecutionInfo(transaction.executionInfo) && transaction.executionInfo.nonce} - + - {displayInfo && ( - - - - )} + + + {isMultisigExecutionInfo(transaction.executionInfo) ? ( diff --git a/src/components/new-safe/create/steps/ConnectWalletStep/index.tsx b/src/components/new-safe/create/steps/ConnectWalletStep/index.tsx index a955d2cb4d..c8bc71fa5a 100644 --- a/src/components/new-safe/create/steps/ConnectWalletStep/index.tsx +++ b/src/components/new-safe/create/steps/ConnectWalletStep/index.tsx @@ -1,8 +1,6 @@ import { useEffect, useState } from 'react' -import { Box, Button, Grid, Typography } from '@mui/material' +import { Box, Button } from '@mui/material' import useWallet from '@/hooks/wallets/useWallet' -import { useCurrentChain } from '@/hooks/useChains' -import { isPairingSupported } from '@/services/pairing/utils' import type { NewSafeFormData } from '@/components/new-safe/create' import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper' @@ -10,15 +8,11 @@ import useSyncSafeCreationStep from '@/components/new-safe/create/useSyncSafeCre import layoutCss from '@/components/new-safe/create/styles.module.css' import useConnectWallet from '@/components/common/ConnectWallet/useConnectWallet' import KeyholeIcon from '@/components/common/icons/KeyholeIcon' -import PairingDescription from '@/components/common/PairingDetails/PairingDescription' -import PairingQRCode from '@/components/common/PairingDetails/PairingQRCode' import { usePendingSafe } from '../StatusStep/usePendingSafe' const ConnectWalletStep = ({ onSubmit, setStep }: StepRenderProps) => { const [pendingSafe] = usePendingSafe() const wallet = useWallet() - const chain = useCurrentChain() - const isSupported = isPairingSupported(chain?.disabledWallets) const handleConnect = useConnectWallet() const [, setSubmitted] = useState(false) useSyncSafeCreationStep(setStep) @@ -34,31 +28,12 @@ const ConnectWalletStep = ({ onSubmit, setStep }: StepRenderProps - - - - - - - - - - - {isSupported && ( - - - - Connect to {'Safe{Wallet}'} mobile - - - - )} - - - + + + + ) } diff --git a/src/components/notification-center/NotificationCenter/index.tsx b/src/components/notification-center/NotificationCenter/index.tsx index 7664287965..c89d72f015 100644 --- a/src/components/notification-center/NotificationCenter/index.tsx +++ b/src/components/notification-center/NotificationCenter/index.tsx @@ -3,8 +3,8 @@ import ButtonBase from '@mui/material/ButtonBase' import Popover from '@mui/material/Popover' import Paper from '@mui/material/Paper' import Typography from '@mui/material/Typography' -import Button from '@mui/material/Button' import IconButton from '@mui/material/IconButton' +import MuiLink from '@mui/material/Link' import BellIcon from '@/public/images/notifications/bell.svg' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import ExpandLessIcon from '@mui/icons-material/ExpandLess' @@ -17,6 +17,10 @@ import { } from '@/store/notificationsSlice' import NotificationCenterList from '@/components/notification-center/NotificationCenterList' import UnreadBadge from '@/components/common/UnreadBadge' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { AppRoutes } from '@/config/routes' +import SettingsIcon from '@/public/images/sidebar/settings.svg' import css from './styles.module.css' import { trackEvent, OVERVIEW_EVENTS } from '@/services/analytics' @@ -25,6 +29,7 @@ import SvgIcon from '@mui/icons-material/ExpandLess' const NOTIFICATION_CENTER_LIMIT = 4 const NotificationCenter = (): ReactElement => { + const router = useRouter() const [showAll, setShowAll] = useState(false) const [anchorEl, setAnchorEl] = useState(null) const open = Boolean(anchorEl) @@ -37,7 +42,7 @@ const NotificationCenter = (): ReactElement => { return notifications.slice().sort((a, b) => b.timestamp - a.timestamp) }, [notifications]) - const canExpand = notifications.length > NOTIFICATION_CENTER_LIMIT + const canExpand = notifications.length > NOTIFICATION_CENTER_LIMIT + 1 const notificationsToShow = canExpand && showAll ? chronologicalNotifications : chronologicalNotifications.slice(0, NOTIFICATION_CENTER_LIMIT) @@ -124,32 +129,46 @@ const NotificationCenter = (): ReactElement => { )}
{notifications.length > 0 && ( - + )}
- {canExpand && ( -
- setShowAll((prev) => !prev)} disableRipple className={css.expandButton}> - - - - - palette.border.main }}> - {showAll ? 'Hide' : `${notifications.length - NOTIFICATION_CENTER_LIMIT} other notifications`} - -
- )} +
+ {canExpand && ( + <> + setShowAll((prev) => !prev)} disableRipple className={css.expandButton}> + + + + + palette.border.main }}> + {showAll ? 'Hide' : `${notifications.length - NOTIFICATION_CENTER_LIMIT} other notifications`} + + + )} + + + Settings + + +
diff --git a/src/components/notification-center/NotificationCenter/styles.module.css b/src/components/notification-center/NotificationCenter/styles.module.css index 4994b0d952..dab2d17faf 100644 --- a/src/components/notification-center/NotificationCenter/styles.module.css +++ b/src/components/notification-center/NotificationCenter/styles.module.css @@ -28,7 +28,7 @@ } .popoverFooter { - padding: var(--space-2); + padding: var(--space-2) var(--space-3); display: flex; align-items: center; } @@ -57,3 +57,11 @@ width: 18px; height: 18px; } + +.settingsLink { + margin-left: auto; + display: flex; + align-items: center; + text-decoration: unset; + gap: var(--space-1); +} diff --git a/src/components/privacy/index.tsx b/src/components/privacy/index.tsx index b5db88f24e..db0f7efd39 100644 --- a/src/components/privacy/index.tsx +++ b/src/components/privacy/index.tsx @@ -6,7 +6,7 @@ const SafePrivacyPolicy = () => { return (

Privacy Policy

-

Last updated in August 2023.

+

Last updated in September 2023.

Your privacy is important to us. It is our policy to respect your privacy and comply with any applicable law and regulation regarding any personal information we may collect about you, including across our website,{' '} @@ -327,23 +327,35 @@ const SafePrivacyPolicy = () => { GIVEN TIME.

4.2. Tracking

-

4.2.1 We may store the following personal data to analyze your behavior:

+

4.2.1 We will process the following personal data to analyze your behavior:

    -
  1. IP address (except for EU users),
  2. +
  3. IP address (will not be stored for EU users),
  4. session tracking,
  5. user behavior,
  6. wallet type,
  7. +
  8. Safe Account address,
  9. device and browser user agent,
  10. user consent,
  11. operating system,
  12. referrers,
  13. user behavior: subpage, duration, and revisit, the date and time of access,
-

This data may be processed in order to improve the product and user experience.

- We may additionally store an analytics cookie on your device to identify you as a user and to track the app - usage across browsing sessions. The lawful basis for this processing is your consent (GDPR Art.6.1a) when - agreeing to accept cookies. + In the case you have given consent, we will additionally store an analytics cookie on your device to identify + you as a user across browsing sessions. The lawful basis for this processing is your consent (GDPR Art.6.1a) + when agreeing to accept cookies. +

+

+ The collected data is solely used in the legitimate interest of improving our product and user experience. The + data is stored only temporarily and is deleted after 14 months. +

+

+ We do not track any of the following: +

    +
  1. Signer wallet addresses
  2. +
  3. Wallet signatures
  4. +
  5. Granular transaction details
  6. +

4.2.2 We conduct technical monitoring of your activity on the platform in order to ensure availability, @@ -359,6 +371,22 @@ const SafePrivacyPolicy = () => { The lawful basis for this processing is our legitimate interest (GDPR Art.6.1f) in ensuring the correctness of the service.

+

4.2.3. Anonymized tracking

+

+ We will anonymize the following personal data to gather anonymous user statistics on your browsing behavior on + our website: +

    +
  1. daily active users,
  2. +
  3. new users acquired from a specific campaign,
  4. +
  5. user journeys,
  6. +
  7. number of users per country,
  8. +
  9. difference in user behavior between mobile vs. web visitors.
  10. +
+

+

+ The lawful basis for this processing is our legitimate interest (GDPR Art.6.1f) in improving our product and + user experience. +

4.3. When Participating in User Experience Research (UXR)

When you participate in our user experience research we may collect and process some personal data. This data diff --git a/src/components/safe-apps/AppFrame/index.tsx b/src/components/safe-apps/AppFrame/index.tsx index d81b5cc835..fb738f289d 100644 --- a/src/components/safe-apps/AppFrame/index.tsx +++ b/src/components/safe-apps/AppFrame/index.tsx @@ -122,7 +122,7 @@ const AppFrame = ({ appUrl, allowedFeaturesList, safeAppFromManifest }: AppFrame sdkVersion: string, ) => { const isOffChainSigningSupported = isOffchainEIP1271Supported(safe, chain, sdkVersion) - const signOffChain = isOffChainSigningSupported && !onChainSigning + const signOffChain = isOffChainSigningSupported && !onChainSigning && !!settings.offChainSigning setCurrentRequestId(requestId) diff --git a/src/components/safe-messages/MsgSummary/index.tsx b/src/components/safe-messages/MsgSummary/index.tsx index 21651cd01b..87fe2b9752 100644 --- a/src/components/safe-messages/MsgSummary/index.tsx +++ b/src/components/safe-messages/MsgSummary/index.tsx @@ -34,7 +34,7 @@ const MsgSummary = ({ msg }: { msg: SafeMessage }): ReactElement => { const isConfirmed = msg.status === SafeMessageStatus.CONFIRMED return ( - + diff --git a/src/components/settings/PushNotifications/GlobalPushNotifications.tsx b/src/components/settings/PushNotifications/GlobalPushNotifications.tsx new file mode 100644 index 0000000000..88b789fda9 --- /dev/null +++ b/src/components/settings/PushNotifications/GlobalPushNotifications.tsx @@ -0,0 +1,473 @@ +import { + Box, + Grid, + Paper, + Typography, + Checkbox, + Button, + Divider, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + CircularProgress, +} from '@mui/material' +import { Fragment, useEffect, useMemo, useState } from 'react' +import type { ReactElement } from 'react' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' + +import EthHashInfo from '@/components/common/EthHashInfo' +import { sameAddress } from '@/utils/addresses' +import useChains from '@/hooks/useChains' +import { useAppSelector } from '@/store' +import { useNotificationPreferences } from './hooks/useNotificationPreferences' +import { useNotificationRegistrations } from './hooks/useNotificationRegistrations' +import { selectAllAddedSafes } from '@/store/addedSafesSlice' +import { trackEvent } from '@/services/analytics' +import { PUSH_NOTIFICATION_EVENTS } from '@/services/analytics/events/push-notifications' +import { requestNotificationPermission } from './logic' +import { useDismissPushNotificationsBanner } from './PushNotificationsBanner' +import type { NotifiableSafes } from './logic' +import type { AddedSafesState } from '@/store/addedSafesSlice' +import type { PushNotificationPreferences } from '@/services/push-notifications/preferences' +import CheckWallet from '@/components/common/CheckWallet' + +import css from './styles.module.css' + +// UI logic + +// Convert data structure of added Safes +export const _transformAddedSafes = (addedSafes: AddedSafesState): NotifiableSafes => { + return Object.entries(addedSafes).reduce((acc, [chainId, addedSafesOnChain]) => { + acc[chainId] = Object.keys(addedSafesOnChain) + return acc + }, {}) +} + +// Convert data structure of currently notified Safes +export const _transformCurrentSubscribedSafes = ( + allPreferences?: PushNotificationPreferences, +): NotifiableSafes | undefined => { + if (!allPreferences) { + return + } + + return Object.values(allPreferences).reduce((acc, { chainId, safeAddress }) => { + if (!acc[chainId]) { + acc[chainId] = [] + } + + acc[chainId].push(safeAddress) + return acc + }, {}) +} + +// Remove Safes that are not on a supported chain +export const _sanitizeNotifiableSafes = ( + chains: Array, + notifiableSafes: NotifiableSafes, +): NotifiableSafes => { + return Object.entries(notifiableSafes).reduce((acc, [chainId, safeAddresses]) => { + const chain = chains.find((chain) => chain.chainId === chainId) + + if (chain) { + acc[chainId] = safeAddresses + } + + return acc + }, {}) +} + +// Merges added Safes and currently notified Safes into a single data structure without duplicates +export const _mergeNotifiableSafes = ( + addedSafes: AddedSafesState, + currentSubscriptions?: NotifiableSafes, +): NotifiableSafes => { + const notifiableSafes = _transformAddedSafes(addedSafes) + + if (!currentSubscriptions) { + return notifiableSafes + } + + // Locally registered Safes (if not already added) + for (const [chainId, safeAddresses] of Object.entries(currentSubscriptions)) { + const notifiableSafesOnChain = notifiableSafes[chainId] ?? [] + const uniqueSafeAddresses = Array.from(new Set([...notifiableSafesOnChain, ...safeAddresses])) + + notifiableSafes[chainId] = uniqueSafeAddresses + } + + return notifiableSafes +} + +export const _getTotalNotifiableSafes = (notifiableSafes: NotifiableSafes): number => { + return Object.values(notifiableSafes).reduce((acc, safeAddresses) => { + return (acc += safeAddresses.length) + }, 0) +} + +export const _areAllSafesSelected = (notifiableSafes: NotifiableSafes, selectedSafes: NotifiableSafes): boolean => { + const entries = Object.entries(notifiableSafes) + + if (entries.length === 0) { + return false + } + + return Object.entries(notifiableSafes).every(([chainId, safeAddresses]) => { + const hasChain = Object.keys(selectedSafes).includes(chainId) + const hasEverySafe = safeAddresses?.every((safeAddress) => selectedSafes[chainId]?.includes(safeAddress)) + return hasChain && hasEverySafe + }) +} + +// Total number of signatures required to register selected Safes +export const _getTotalSignaturesRequired = ( + selectedSafes: NotifiableSafes, + currentNotifiedSafes?: NotifiableSafes, +): number => { + return Object.entries(selectedSafes) + .filter(([, safeAddresses]) => safeAddresses.length > 0) + .reduce((acc, [chainId, safeAddresses]) => { + const isNewChain = !currentNotifiedSafes?.[chainId] + const isNewSafe = safeAddresses.some((safeAddress) => !currentNotifiedSafes?.[chainId]?.includes(safeAddress)) + + if (isNewChain || isNewSafe) { + acc += 1 + } + return acc + }, 0) +} + +export const _shouldRegisterSelectedSafes = ( + selectedSafes: NotifiableSafes, + currentNotifiedSafes?: NotifiableSafes, +): boolean => { + return Object.entries(selectedSafes).some(([chainId, safeAddresses]) => { + return safeAddresses.some((safeAddress) => !currentNotifiedSafes?.[chainId]?.includes(safeAddress)) + }) +} + +export const _shouldUnregsiterSelectedSafes = ( + selectedSafes: NotifiableSafes, + currentNotifiedSafes?: NotifiableSafes, +) => { + return Object.entries(currentNotifiedSafes || {}).some(([chainId, safeAddresses]) => { + return safeAddresses.some((safeAddress) => !selectedSafes[chainId]?.includes(safeAddress)) + }) +} + +// onSave logic + +// Safes that need to be registered with the service +export const _getSafesToRegister = ( + selectedSafes: NotifiableSafes, + currentNotifiedSafes?: NotifiableSafes, +): NotifiableSafes | undefined => { + const safesToRegister = Object.entries(selectedSafes).reduce((acc, [chainId, safeAddresses]) => { + const safesToRegisterOnChain = safeAddresses.filter( + (safeAddress) => !currentNotifiedSafes?.[chainId]?.includes(safeAddress), + ) + + if (safesToRegisterOnChain.length > 0) { + acc[chainId] = safesToRegisterOnChain + } + + return acc + }, {}) + + const shouldRegister = Object.values(safesToRegister).some((safeAddresses) => safeAddresses.length > 0) + + if (shouldRegister) { + return safesToRegister + } +} + +// Safes that need to be unregistered with the service +export const _getSafesToUnregister = ( + selectedSafes: NotifiableSafes, + currentNotifiedSafes?: NotifiableSafes, +): NotifiableSafes | undefined => { + if (!currentNotifiedSafes) { + return + } + + const safesToUnregister = Object.entries(currentNotifiedSafes).reduce( + (acc, [chainId, safeAddresses]) => { + const safesToUnregisterOnChain = safeAddresses.filter( + (safeAddress) => !selectedSafes[chainId]?.includes(safeAddress), + ) + + if (safesToUnregisterOnChain.length > 0) { + acc[chainId] = safesToUnregisterOnChain + } + return acc + }, + {}, + ) + + const shouldUnregister = Object.values(safesToUnregister).some((safeAddresses) => safeAddresses.length > 0) + + if (shouldUnregister) { + return safesToUnregister + } +} + +// Whether the device needs to be unregistered from the service +export const _shouldUnregisterDevice = ( + chainId: string, + safeAddresses: Array, + currentNotifiedSafes?: NotifiableSafes, +): boolean => { + if (!currentNotifiedSafes) { + return false + } + + if (safeAddresses.length !== currentNotifiedSafes[chainId].length) { + return false + } + + return safeAddresses.every((safeAddress) => { + return currentNotifiedSafes[chainId]?.includes(safeAddress) + }) +} + +export const GlobalPushNotifications = (): ReactElement | null => { + const chains = useChains() + const addedSafes = useAppSelector(selectAllAddedSafes) + const [isLoading, setIsLoading] = useState(false) + + const { dismissPushNotificationBanner } = useDismissPushNotificationsBanner() + const { getAllPreferences } = useNotificationPreferences() + const { unregisterDeviceNotifications, unregisterSafeNotifications, registerNotifications } = + useNotificationRegistrations() + + // Safes selected in the UI + const [selectedSafes, setSelectedSafes] = useState({}) + + // Current Safes registered for notifications in indexedDB + const currentNotifiedSafes = useMemo(() => { + const allPreferences = getAllPreferences() + return _transformCurrentSubscribedSafes(allPreferences) + }, [getAllPreferences]) + + // `currentNotifiedSafes` is initially undefined until indexedDB resolves + useEffect(() => { + let isMounted = true + + if (currentNotifiedSafes && isMounted) { + setSelectedSafes(currentNotifiedSafes) + } + + return () => { + isMounted = false + } + }, [currentNotifiedSafes]) + + // Merged added Safes and `currentNotifiedSafes` (in case subscriptions aren't added) + const notifiableSafes = useMemo(() => { + const safes = _mergeNotifiableSafes(addedSafes, currentNotifiedSafes) + return _sanitizeNotifiableSafes(chains.configs, safes) + }, [chains.configs, addedSafes, currentNotifiedSafes]) + + const totalNotifiableSafes = useMemo(() => { + return _getTotalNotifiableSafes(notifiableSafes) + }, [notifiableSafes]) + + const isAllSelected = useMemo(() => { + return _areAllSafesSelected(notifiableSafes, selectedSafes) + }, [notifiableSafes, selectedSafes]) + + const onSelectAll = () => { + setSelectedSafes(() => { + if (isAllSelected) { + return [] + } + + return Object.entries(notifiableSafes).reduce((acc, [chainId, safeAddresses]) => { + return { + ...acc, + [chainId]: safeAddresses, + } + }, {}) + }) + } + + const totalSignaturesRequired = useMemo(() => { + return _getTotalSignaturesRequired(selectedSafes, currentNotifiedSafes) + }, [currentNotifiedSafes, selectedSafes]) + + const canSave = useMemo(() => { + return ( + _shouldRegisterSelectedSafes(selectedSafes, currentNotifiedSafes) || + _shouldUnregsiterSelectedSafes(selectedSafes, currentNotifiedSafes) + ) + }, [selectedSafes, currentNotifiedSafes]) + + const onSave = async () => { + if (!canSave) { + return + } + + setIsLoading(true) + + // Although the (un-)registration functions will request permission in getToken we manually + // check beforehand to prevent multiple promises in registrationPromises from throwing + const isGranted = await requestNotificationPermission() + + if (!isGranted) { + setIsLoading(false) + return + } + + const registrationPromises: Array> = [] + + const safesToRegister = _getSafesToRegister(selectedSafes, currentNotifiedSafes) + if (safesToRegister) { + registrationPromises.push(registerNotifications(safesToRegister)) + + // Dismiss the banner for all chains that have been registered + Object.keys(safesToRegister).forEach((chainId) => { + dismissPushNotificationBanner(chainId) + }) + } + + const safesToUnregister = _getSafesToUnregister(selectedSafes, currentNotifiedSafes) + if (safesToUnregister) { + const unregistrationPromises = Object.entries(safesToUnregister).flatMap(([chainId, safeAddresses]) => { + if (_shouldUnregisterDevice(chainId, safeAddresses, currentNotifiedSafes)) { + return unregisterDeviceNotifications(chainId) + } + return safeAddresses.map((safeAddress) => unregisterSafeNotifications(chainId, safeAddress)) + }) + + registrationPromises.push(...unregistrationPromises) + } + + await Promise.all(registrationPromises) + + trackEvent(PUSH_NOTIFICATION_EVENTS.SAVE_SETTINGS) + + setIsLoading(false) + } + + if (totalNotifiableSafes === 0) { + return palette.primary.light}>No Safes added + } + + return ( + + + + My Safes Accounts ({totalNotifiableSafes}) + + + + {totalSignaturesRequired > 0 && ( + + We'll ask you to verify ownership of each Safe Account with your signature per chain{' '} + {totalSignaturesRequired} time{totalSignaturesRequired > 1 ? 's' : ''} + + )} + + + {(isOk) => ( + + )} + + + + + + `1px solid ${palette.border.light}` }}> + + + + + + + + + + + + + + {Object.entries(notifiableSafes).map(([chainId, safeAddresses], i, arr) => { + const chain = chains.configs?.find((chain) => chain.chainId === chainId) + + const isChainSelected = safeAddresses.every((address) => { + return selectedSafes[chainId]?.includes(address) + }) + + const onSelectChain = () => { + setSelectedSafes((prev) => { + return { + ...prev, + [chainId]: isChainSelected ? [] : safeAddresses, + } + }) + } + + return ( + + + + + + + + + + + + + {safeAddresses.map((safeAddress) => { + const isSafeSelected = selectedSafes[chainId]?.includes(safeAddress) ?? false + + const onSelectSafe = () => { + setSelectedSafes((prev) => { + return { + ...prev, + [chainId]: isSafeSelected + ? prev[chainId]?.filter((addr) => !sameAddress(addr, safeAddress)) + : [...(prev[chainId] ?? []), safeAddress], + } + }) + } + + return ( + + + + + + + + + ) + })} + + + + {i !== arr.length - 1 ? : null} + + ) + })} + + + + ) +} diff --git a/src/components/settings/PushNotifications/PushNotificationsBanner/PushNotificationsBanner.test.ts b/src/components/settings/PushNotifications/PushNotificationsBanner/PushNotificationsBanner.test.ts new file mode 100644 index 0000000000..e091df0af6 --- /dev/null +++ b/src/components/settings/PushNotifications/PushNotificationsBanner/PushNotificationsBanner.test.ts @@ -0,0 +1,67 @@ +import { _getSafesToRegister } from '.' +import type { AddedSafesOnChain } from '@/store/addedSafesSlice' +import type { PushNotificationPreferences } from '@/services/push-notifications/preferences' + +describe('PushNotificationsBanner', () => { + describe('getSafesToRegister', () => { + it('should return all added safes if no preferences exist', () => { + const addedSafesOnChain = { + '0x123': {}, + '0x456': {}, + } as unknown as AddedSafesOnChain + const allPreferences = undefined + + const result = _getSafesToRegister('1', addedSafesOnChain, allPreferences) + + expect(result).toEqual({ + '1': ['0x123', '0x456'], + }) + }) + + it('should return only newly added safes if preferences exist', () => { + const addedSafesOnChain = { + '0x123': {}, + '0x456': {}, + } as unknown as AddedSafesOnChain + const allPreferences = { + '1:0x123': { + safeAddress: '0x123', + chainId: '1', + }, + '4:0x789': { + safeAddress: '0x789', + chainId: '4', + }, + } as unknown as PushNotificationPreferences + + const result = _getSafesToRegister('1', addedSafesOnChain, allPreferences) + + expect(result).toEqual({ + '1': ['0x456'], + }) + }) + + it('should return all added safes if no preferences match', () => { + const addedSafesOnChain = { + '0x123': {}, + '0x456': {}, + } as unknown as AddedSafesOnChain + const allPreferences = { + '1:0x111': { + safeAddress: '0x111', + chainId: '1', + }, + '4:0x222': { + safeAddress: '0x222', + chainId: '4', + }, + } as unknown as PushNotificationPreferences + + const result = _getSafesToRegister('1', addedSafesOnChain, allPreferences) + + expect(result).toEqual({ + '1': ['0x123', '0x456'], + }) + }) + }) +}) diff --git a/src/components/settings/PushNotifications/PushNotificationsBanner/index.tsx b/src/components/settings/PushNotifications/PushNotificationsBanner/index.tsx new file mode 100644 index 0000000000..96332f1d2b --- /dev/null +++ b/src/components/settings/PushNotifications/PushNotificationsBanner/index.tsx @@ -0,0 +1,202 @@ +import { Button, Chip, Grid, SvgIcon, Typography, IconButton } from '@mui/material' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useCallback, useEffect } from 'react' +import type { ReactElement } from 'react' + +import { CustomTooltip } from '@/components/common/CustomTooltip' +import { AppRoutes } from '@/config/routes' +import { useAppSelector } from '@/store' +import { selectAddedSafes, selectAllAddedSafes, selectTotalAdded } from '@/store/addedSafesSlice' +import PushNotificationIcon from '@/public/images/notifications/push-notification.svg' +import useLocalStorage from '@/services/local-storage/useLocalStorage' +import { useNotificationRegistrations } from '../hooks/useNotificationRegistrations' +import { PUSH_NOTIFICATION_EVENTS } from '@/services/analytics/events/push-notifications' +import { trackEvent } from '@/services/analytics' +import useSafeInfo from '@/hooks/useSafeInfo' +import CheckWallet from '@/components/common/CheckWallet' +import CloseIcon from '@/public/images/common/close.svg' +import { useNotificationPreferences } from '../hooks/useNotificationPreferences' +import { sameAddress } from '@/utils/addresses' +import useOnboard from '@/hooks/wallets/useOnboard' +import { assertWalletChain } from '@/services/tx/tx-sender/sdk' +import { useCurrentChain, useHasFeature } from '@/hooks/useChains' +import { FEATURES } from '@/utils/chains' +import type { AddedSafesOnChain } from '@/store/addedSafesSlice' +import type { PushNotificationPreferences } from '@/services/push-notifications/preferences' +import type { NotifiableSafes } from '../logic' + +import css from './styles.module.css' + +const DISMISS_PUSH_NOTIFICATIONS_KEY = 'dismissPushNotifications' + +export const useDismissPushNotificationsBanner = () => { + const addedSafes = useAppSelector(selectAllAddedSafes) + const { safe } = useSafeInfo() + + const [dismissedBannerPerChain = {}, setDismissedBannerPerChain] = useLocalStorage<{ + [chainId: string]: { [safeAddress: string]: boolean } + }>(DISMISS_PUSH_NOTIFICATIONS_KEY) + + const dismissPushNotificationBanner = (chainId: string) => { + const safesOnChain = Object.keys(addedSafes[chainId] || {}) + + if (safesOnChain.length === 0) { + return + } + + const dismissedSafesOnChain = safesOnChain.reduce<{ [safeAddress: string]: boolean }>((acc, safeAddress) => { + acc[safeAddress] = true + return acc + }, {}) + + setDismissedBannerPerChain((prev) => ({ + ...prev, + [safe.chainId]: dismissedSafesOnChain, + })) + } + + const isPushNotificationBannerDismissed = !!dismissedBannerPerChain[safe.chainId]?.[safe.address.value] + + return { + dismissPushNotificationBanner, + isPushNotificationBannerDismissed, + } +} + +export const _getSafesToRegister = ( + chainId: string, + addedSafesOnChain: AddedSafesOnChain, + allPreferences: PushNotificationPreferences | undefined, +): NotifiableSafes => { + const addedSafeAddressesOnChain = Object.keys(addedSafesOnChain) + + if (!allPreferences) { + return { [chainId]: addedSafeAddressesOnChain } + } + + const notificationRegistrations = Object.values(allPreferences) + + const newlyAddedSafes = addedSafeAddressesOnChain.filter((safeAddress) => { + return !notificationRegistrations.some( + (registration) => chainId === registration.chainId && sameAddress(registration.safeAddress, safeAddress), + ) + }) + + return { [chainId]: newlyAddedSafes } +} + +export const PushNotificationsBanner = ({ children }: { children: ReactElement }): ReactElement => { + const isNotificationsEnabled = useHasFeature(FEATURES.PUSH_NOTIFICATIONS) + const chain = useCurrentChain() + const totalAddedSafes = useAppSelector(selectTotalAdded) + const { safe, safeAddress } = useSafeInfo() + const addedSafesOnChain = useAppSelector((state) => selectAddedSafes(state, safe.chainId)) + const { query } = useRouter() + const onboard = useOnboard() + + const { dismissPushNotificationBanner, isPushNotificationBannerDismissed } = useDismissPushNotificationsBanner() + + const isSafeAdded = !!addedSafesOnChain?.[safeAddress] + const shouldShowBanner = isNotificationsEnabled && !isPushNotificationBannerDismissed && isSafeAdded + + const { registerNotifications } = useNotificationRegistrations() + const { getAllPreferences } = useNotificationPreferences() + + const dismissBanner = useCallback(() => { + trackEvent(PUSH_NOTIFICATION_EVENTS.DISMISS_BANNER) + dismissPushNotificationBanner(safe.chainId) + }, [dismissPushNotificationBanner, safe.chainId]) + + useEffect(() => { + if (shouldShowBanner) { + trackEvent(PUSH_NOTIFICATION_EVENTS.DISPLAY_BANNER) + } + }, [dismissBanner, shouldShowBanner]) + + const onEnableAll = async () => { + if (!onboard || !addedSafesOnChain) { + return + } + + trackEvent(PUSH_NOTIFICATION_EVENTS.ENABLE_ALL) + + const allPreferences = getAllPreferences() + const safesToRegister = _getSafesToRegister(safe.chainId, addedSafesOnChain, allPreferences) + + try { + await assertWalletChain(onboard, safe.chainId) + } catch { + return + } + + await registerNotifications(safesToRegister) + + dismissBanner() + } + + const onCustomize = () => { + trackEvent(PUSH_NOTIFICATION_EVENTS.CUSTOMIZE_SETTINGS) + + dismissBanner() + } + + if (!shouldShowBanner) { + return children + } + + return ( + + + + + + + + Enable push notifications + + + + + + Get notified about pending signatures, incoming and outgoing transactions for all Safe Accounts on{' '} + {chain?.chainName} when Safe + {`{Wallet}`} is in the background or closed. + + {/* Cannot wrap singular button as it causes style inconsistencies */} + + {(isOk) => ( +

+ {totalAddedSafes > 0 && ( + + )} + {safe && ( + + + + )} +
+ )} + + + + } + open + > + {children} + + ) +} diff --git a/src/components/settings/PushNotifications/PushNotificationsBanner/styles.module.css b/src/components/settings/PushNotifications/PushNotificationsBanner/styles.module.css new file mode 100644 index 0000000000..f3e6c1ecd8 --- /dev/null +++ b/src/components/settings/PushNotifications/PushNotificationsBanner/styles.module.css @@ -0,0 +1,60 @@ +.banner :global .MuiTooltip-tooltip { + min-width: 384px !important; + padding: var(--space-3); +} + +.banner :global .MuiTooltip-tooltip, +.banner :global .MuiTooltip-arrow::before { + border-color: var(--color-secondary-main); +} + +[data-theme='dark'] .banner :global .MuiTooltip-tooltip, +[data-theme='dark'] .banner :global .MuiTooltip-arrow::before { + border-color: var(--color-primary-main); +} + +.container { + min-width: 100%; +} + +.close { + position: absolute; + top: var(--space-2); + right: var(--space-2); + color: var(--color-border-main); +} + +.button { + padding: 4px 10px; +} + +.buttons { + display: flex; + gap: var(--space-2); +} + +.chip { + border-radius: 4px; + background-color: var(--color-secondary-main); + font-weight: 400; + font-size: 12px; + width: var(--space-5); + height: 24px; + position: relative; +} + +[data-theme='dark'] .chip { + color: var(--color-static-main); + background-color: var(--color-primary-main); +} + +.chip :global .MuiChip-label { + padding: 0; + text-overflow: unset; +} + +.icon { + margin-top: -12px; + width: 64px; + height: 64px; +} diff --git a/src/components/settings/PushNotifications/__tests__/GlobalPushNotifications.test.ts b/src/components/settings/PushNotifications/__tests__/GlobalPushNotifications.test.ts new file mode 100644 index 0000000000..42d1f5a6e4 --- /dev/null +++ b/src/components/settings/PushNotifications/__tests__/GlobalPushNotifications.test.ts @@ -0,0 +1,525 @@ +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' + +import { + _transformAddedSafes, + _mergeNotifiableSafes, + _transformCurrentSubscribedSafes, + _getTotalNotifiableSafes, + _areAllSafesSelected, + _getTotalSignaturesRequired, + _shouldRegisterSelectedSafes, + _shouldUnregsiterSelectedSafes, + _getSafesToRegister, + _getSafesToUnregister, + _shouldUnregisterDevice, + _sanitizeNotifiableSafes, +} from '../GlobalPushNotifications' +import type { AddedSafesState } from '@/store/addedSafesSlice' + +describe('GlobalPushNotifications', () => { + describe('transformAddedSafes', () => { + it('should transform added safes into notifiable safes', () => { + const addedSafes = { + '1': { + '0x123': {}, + '0x456': {}, + }, + '4': { + '0x789': {}, + }, + } as unknown as AddedSafesState + + const expectedNotifiableSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + expect(_transformAddedSafes(addedSafes)).toEqual(expectedNotifiableSafes) + }) + }) + + describe('mergeNotifiableSafes', () => { + it('should merge added safes and current subscriptions', () => { + const addedSafes = { + '1': { + '0x123': {}, + '0x456': {}, + }, + '4': { + '0x789': {}, + }, + } as unknown as AddedSafesState + + const currentSubscriptions = { + '1': ['0x123', '0x789'], + '4': ['0x789'], + } + + const expectedNotifiableSafes = { + '1': ['0x123', '0x456', '0x789'], + '4': ['0x789'], + } + + expect(_mergeNotifiableSafes(addedSafes, currentSubscriptions)).toEqual(expectedNotifiableSafes) + }) + + it('should return added safes if there are no current subscriptions', () => { + const addedSafes = { + '1': { + '0x123': {}, + '0x456': {}, + }, + '4': { + '0x789': {}, + }, + } as unknown as AddedSafesState + + expect(_mergeNotifiableSafes(addedSafes)).toEqual(_transformAddedSafes(addedSafes)) + }) + }) + + describe('sanitizeNotifiableSafes', () => { + it('should remove Safes that are not on a supported chain', () => { + const chains = [{ chainId: '1', name: 'Mainnet' }] as unknown as Array + + const notifiableSafes = { + '1': ['0x123', '0x456'], + '4': ['0xabc'], + } + + const expected = { + '1': ['0x123', '0x456'], + } + + expect(_sanitizeNotifiableSafes(chains, notifiableSafes)).toEqual(expected) + }) + }) + + describe('transformCurrentSubscribedSafes', () => { + it('should transform current subscriptions into notifiable safes', () => { + const currentSubscriptions = { + '0x123': { + chainId: '1', + safeAddress: '0x123', + }, + '0x456': { + chainId: '1', + safeAddress: '0x456', + }, + '0x789': { + chainId: '4', + safeAddress: '0x789', + }, + } + + const expectedNotifiableSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + expect(_transformCurrentSubscribedSafes(currentSubscriptions)).toEqual(expectedNotifiableSafes) + }) + + it('should return undefined if there are no current subscriptions', () => { + expect(_transformCurrentSubscribedSafes()).toBeUndefined() + }) + }) + + describe('getTotalNotifiableSafes', () => { + it('should return the total number of notifiable safes', () => { + const notifiableSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + expect(_getTotalNotifiableSafes(notifiableSafes)).toEqual(3) + }) + + it('should return 0 if there are no notifiable safes', () => { + expect(_getTotalNotifiableSafes({})).toEqual(0) + }) + }) + + describe('areAllSafesSelected', () => { + it('should return true if all notifiable safes are selected', () => { + const notifiableSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + const selectedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + expect(_areAllSafesSelected(notifiableSafes, selectedSafes)).toEqual(true) + }) + + it('should return false if not all notifiable safes are selected', () => { + const notifiableSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + const selectedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x123'], + } + + expect(_areAllSafesSelected(notifiableSafes, selectedSafes)).toEqual(false) + }) + + it('should return false if there are no notifiable safes', () => { + const notifiableSafes = {} + + const selectedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + expect(_areAllSafesSelected(notifiableSafes, selectedSafes)).toEqual(false) + }) + }) + + describe('getTotalSignaturesRequired', () => { + it('should return the total number of signatures required to register a new chain', () => { + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + const selectedSafes = { + ...currentNotifiedSafes, + '5': ['0xabc'], + } + + expect(_getTotalSignaturesRequired(selectedSafes, currentNotifiedSafes)).toEqual(1) + }) + + it('should return the total number of signatures required to register a new Safe', () => { + const currentNotifiedSafes = { + '1': ['0x123'], + '4': ['0x789'], + } + + const selectedSafes = { + '1': ['0x123'], + '4': [...currentNotifiedSafes['4'], '0xabc'], + } + + expect(_getTotalSignaturesRequired(selectedSafes, currentNotifiedSafes)).toEqual(1) + }) + + it('should return the total number of signatures required to register new chains/Safes', () => { + const currentNotifiedSafes = {} + + const selectedSafes = { + '1': ['0x123'], + '4': ['0x789', '0xabc'], + } + + expect(_getTotalSignaturesRequired(selectedSafes, currentNotifiedSafes)).toEqual(2) + }) + + it('should not increase the count if a new chain is empty', () => { + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + } + + const selectedSafes = { + '1': currentNotifiedSafes['1'], + '5': [], + } + + expect(_getTotalSignaturesRequired(selectedSafes, currentNotifiedSafes)).toEqual(0) + }) + + it('should not increase the count if a chain was removed', () => { + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + const selectedSafes = { + '1': currentNotifiedSafes['1'], + } + + expect(_getTotalSignaturesRequired(selectedSafes, currentNotifiedSafes)).toEqual(0) + }) + + it('should not increase the count if a Safe was removed', () => { + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + const selectedSafes = { + '1': currentNotifiedSafes['1'].slice(0, 1), + '4': ['0x789'], + } + + expect(_getTotalSignaturesRequired(selectedSafes, currentNotifiedSafes)).toEqual(0) + }) + + it('should not increase the count if a chain/Safe was removed', () => { + const currentNotifiedSafes = { + '1': ['0x123'], + '4': ['0x789', '0xabc'], + } + + const selectedSafes = {} + + expect(_getTotalSignaturesRequired(selectedSafes, currentNotifiedSafes)).toEqual(0) + }) + + it('should return 0 if there are no selected safes', () => { + const currentNotifiedSafes = { + '1': ['0x123'], + '4': ['0x789'], + } + + const selectedSafes = {} + + expect(_getTotalSignaturesRequired(selectedSafes, currentNotifiedSafes)).toEqual(0) + }) + }) + + describe('shouldRegisterSelectedSafes', () => { + it('should return true if there are safes to register', () => { + const currentNotifiedSafes = { + '1': ['0x123'], + '4': ['0x789'], + } + + const selectedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + const result = _shouldRegisterSelectedSafes(selectedSafes, currentNotifiedSafes) + expect(result).toBe(true) + }) + + it('should return true if there are chains to register', () => { + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + } + + const selectedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + const result = _shouldRegisterSelectedSafes(selectedSafes, currentNotifiedSafes) + expect(result).toBe(true) + }) + + it('should return true if there are safes/chains to register', () => { + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + const selectedSafes = { + '1': ['0x123', '0x456', '0x789'], + '4': ['0x789'], + } + + const result = _shouldRegisterSelectedSafes(selectedSafes, currentNotifiedSafes) + expect(result).toBe(true) + }) + + it('should return false if there are no safes to register', () => { + const selectedSafes = { + '1': ['0x123'], + '4': ['0x789'], + } + + const currentNotifiedSafes = { + '1': ['0x123'], + '4': ['0x789'], + } + + const result = _shouldRegisterSelectedSafes(selectedSafes, currentNotifiedSafes) + expect(result).toBe(false) + }) + }) + + describe('shouldUnregisterSelectedSafes', () => { + it('should return true if there are safes to unregister', () => { + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + const selectedSafes = { + '1': ['0x123'], + '4': ['0x789'], + } + + const result = _shouldUnregsiterSelectedSafes(selectedSafes, currentNotifiedSafes) + expect(result).toBe(true) + }) + + it('should return true if there are chains to unregister', () => { + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789', '0xabc'], + } + + const selectedSafes = { + '1': ['0x123', '0x456'], + } + + const result = _shouldUnregsiterSelectedSafes(selectedSafes, currentNotifiedSafes) + expect(result).toBe(true) + }) + + it('should return true if there are safes/chains to unregister', () => { + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789', '0xabc'], + } + + const selectedSafes = { + '1': ['0x123'], + } + + const result = _shouldUnregsiterSelectedSafes(selectedSafes, currentNotifiedSafes) + expect(result).toBe(true) + }) + + it('should return false if there are no safes to unregister', () => { + const currentNotifiedSafes = { + '1': ['0x123'], + '4': ['0x789'], + } + + const selectedSafes = { + '1': ['0x123'], + '4': ['0x789'], + } + + const result = _shouldUnregsiterSelectedSafes(selectedSafes, currentNotifiedSafes) + expect(result).toBe(false) + }) + }) + + describe('getSafesToRegister', () => { + it('returns the safes to register', () => { + const currentNotifiedSafes = { + 1: ['0x123'], + 2: ['0xabc'], + 4: ['0x789', '0xdef'], + } + const selectedSafes = { + 1: ['0x123', '0x456'], + 4: ['0x789'], + } + + const result = _getSafesToRegister(selectedSafes, currentNotifiedSafes) + + expect(result).toEqual({ + 1: ['0x456'], + }) + }) + + it('returns undefined if there are no safes to register', () => { + const currentNotifiedSafes = { + 1: ['0x123'], + 2: ['0xabc'], + 4: ['0x789', '0xdef'], + } + const selectedSafes = { + 1: ['0x123'], + 2: ['0xabc'], + 4: ['0x789', '0xdef'], + } + + const result = _getSafesToRegister(selectedSafes, currentNotifiedSafes) + + expect(result).toBeUndefined() + }) + }) + + describe('getSafesToUnregister', () => { + it('returns undefined if there are no current notified safes', () => { + const currentNotifiedSafes = undefined + const selectedSafes = { + 1: ['0x123', '0x456'], + 4: ['0x789'], + } + + const result = _getSafesToUnregister(selectedSafes, currentNotifiedSafes) + + expect(result).toBeUndefined() + }) + + it('returns the safes to unregister', () => { + const currentNotifiedSafes = { + 1: ['0x123'], + 2: ['0xabc'], + 4: ['0x789', '0xdef'], + } + const selectedSafes = { + 1: ['0x123', '0x456'], + 4: ['0x789'], + } + + const result = _getSafesToUnregister(selectedSafes, currentNotifiedSafes) + + expect(result).toEqual({ + 2: ['0xabc'], + 4: ['0xdef'], + }) + }) + + it('returns undefined if there are no safes to unregister', () => { + const currentNotifiedSafes = { + 1: ['0x123'], + 2: ['0xabc'], + 4: ['0x789', '0xdef'], + } + const selectedSafes = { + 1: ['0x123'], + 2: ['0xabc'], + 4: ['0x789', '0xdef'], + } + + const result = _getSafesToUnregister(selectedSafes, currentNotifiedSafes) + + expect(result).toBeUndefined() + }) + }) + + describe('shouldUnregisterDevice', () => { + const chainId = '1' + const safeAddresses = ['0x123', '0x456'] + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + it('returns true if all safe addresses are included in currentNotifiedSafes', () => { + const result = _shouldUnregisterDevice(chainId, safeAddresses, currentNotifiedSafes) + expect(result).toBe(true) + }) + + it('returns false if not all safe addresses are included in currentNotifiedSafes', () => { + const invalidSafeAddresses = ['0x123', '0x789'] + const result = _shouldUnregisterDevice(chainId, invalidSafeAddresses, currentNotifiedSafes) + expect(result).toBe(false) + }) + + it('returns false if currentNotifiedSafes is undefined', () => { + const result = _shouldUnregisterDevice(chainId, safeAddresses) + expect(result).toBe(false) + }) + + it('returns false if the length of safeAddresses is different from the length of currentNotifiedSafes', () => { + const invalidSafeAddresses = ['0x123'] + const result = _shouldUnregisterDevice(chainId, invalidSafeAddresses, currentNotifiedSafes) + expect(result).toBe(false) + }) + }) +}) diff --git a/src/components/settings/PushNotifications/__tests__/logic.test.ts b/src/components/settings/PushNotifications/__tests__/logic.test.ts new file mode 100644 index 0000000000..f709addb8d --- /dev/null +++ b/src/components/settings/PushNotifications/__tests__/logic.test.ts @@ -0,0 +1,221 @@ +import * as firebase from 'firebase/messaging' +import { DeviceType } from '@safe-global/safe-gateway-typescript-sdk/dist/types/notifications' +import { hexZeroPad } from 'ethers/lib/utils' +import { Web3Provider } from '@ethersproject/providers' +import type { JsonRpcSigner } from '@ethersproject/providers' + +import * as logic from '../logic' +import * as web3 from '@/hooks/wallets/web3' +import packageJson from '../../../../../package.json' +import type { ConnectedWallet } from '@/services/onboard' + +jest.mock('firebase/messaging') + +Object.defineProperty(globalThis, 'crypto', { + value: { + randomUUID: () => Math.random().toString(), + }, +}) + +Object.defineProperty(globalThis, 'navigator', { + value: { + serviceWorker: { + getRegistrations: () => [], + }, + }, +}) + +Object.defineProperty(globalThis, 'location', { + value: { + origin: 'https://app.safe.global', + }, +}) + +const MM_SIGNATURE = + '0x844ba559793a122c5742e9d922ed1f4650d4efd8ea35191105ddaee6a604000165c14f56278bda8d52c9400cdaeaf5cdc38d3596264cc5ccd8f03e5619d5d9d41b' +const LEDGER_SIGNATURE = + '0xb1274687aea0d8b8578a3eb6da57979eee0a64225e04445a0858e6f8d0d1b5870cdff961513992d849e47e9b0a8d432019829f1e4958837fd86e034656766a4e00' +const ADJUSTED_LEDGER_SIGNATURE = + '0xb1274687aea0d8b8578a3eb6da57979eee0a64225e04445a0858e6f8d0d1b5870cdff961513992d849e47e9b0a8d432019829f1e4958837fd86e034656766a4e1b' + +describe('Notifications', () => { + let alertMock = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + + window.alert = alertMock + }) + + describe('requestNotificationPermission', () => { + let requestPermissionMock = jest.fn() + + beforeEach(() => { + globalThis.Notification = { + requestPermission: requestPermissionMock, + permission: 'default', + } as unknown as jest.Mocked + }) + + it('should return true and not request permission again if already granted', async () => { + globalThis.Notification = { + requestPermission: requestPermissionMock, + permission: 'granted', + } as unknown as jest.Mocked + + const result = await logic.requestNotificationPermission() + + expect(requestPermissionMock).not.toHaveBeenCalled() + expect(result).toBe(true) + }) + + it('should return false if permission is denied', async () => { + requestPermissionMock.mockResolvedValue('denied') + + const result = await logic.requestNotificationPermission() + + expect(requestPermissionMock).toHaveBeenCalledTimes(1) + expect(result).toBe(false) + }) + + it('should return false if permission request throw', async () => { + requestPermissionMock.mockImplementation(Promise.reject) + + const result = await logic.requestNotificationPermission() + + expect(requestPermissionMock).toHaveBeenCalledTimes(1) + expect(result).toBe(false) + }) + + it('should return true if permission are granted', async () => { + requestPermissionMock.mockResolvedValue('granted') + + const result = await logic.requestNotificationPermission() + + expect(requestPermissionMock).toHaveBeenCalledTimes(1) + expect(result).toBe(true) + }) + }) + + describe('adjustLegerSignature', () => { + it('should return the same signature if not that of a Ledger', () => { + const adjustedSignature = logic._adjustLedgerSignatureV(MM_SIGNATURE) + + expect(adjustedSignature).toBe(MM_SIGNATURE) + }) + + it('should return an adjusted signature if is that of a Ledger and v is 0 or 1', () => { + const adjustedSignature = logic._adjustLedgerSignatureV(LEDGER_SIGNATURE) + + expect(adjustedSignature).toBe(ADJUSTED_LEDGER_SIGNATURE) + }) + + it('should return the same signature if v is 27 or 28', () => { + const adjustedSignature = logic._adjustLedgerSignatureV(MM_SIGNATURE) + + expect(adjustedSignature).toBe(MM_SIGNATURE) + }) + }) + + describe('getRegisterDevicePayload', () => { + it('should return the payload with signature', async () => { + const token = crypto.randomUUID() + jest.spyOn(firebase, 'getToken').mockImplementation(() => Promise.resolve(token)) + + const mockProvider = new Web3Provider(jest.fn()) + + jest.spyOn(mockProvider, 'getSigner').mockImplementation( + () => + ({ + signMessage: jest.fn().mockResolvedValueOnce(MM_SIGNATURE), + } as unknown as JsonRpcSigner), + ) + jest.spyOn(web3, 'createWeb3').mockImplementation(() => mockProvider) + + const uuid = crypto.randomUUID() + + const payload = await logic.getRegisterDevicePayload({ + safesToRegister: { + ['1']: [hexZeroPad('0x1', 20), hexZeroPad('0x2', 20)], + ['2']: [hexZeroPad('0x1', 20)], + }, + uuid, + wallet: { + label: 'MetaMask', + } as ConnectedWallet, + }) + + expect(payload).toStrictEqual({ + uuid, + cloudMessagingToken: token, + buildNumber: '0', + bundle: 'safe', + deviceType: DeviceType.WEB, + version: packageJson.version, + timestamp: expect.any(String), + safeRegistrations: [ + { + chainId: '1', + safes: [hexZeroPad('0x1', 20), hexZeroPad('0x2', 20)], + signatures: [MM_SIGNATURE], + }, + { + chainId: '2', + safes: [hexZeroPad('0x1', 20)], + signatures: [MM_SIGNATURE], + }, + ], + }) + }) + + it('should return the payload with a Ledger adjusted signature', async () => { + const token = crypto.randomUUID() + jest.spyOn(firebase, 'getToken').mockImplementation(() => Promise.resolve(token)) + + const mockProvider = new Web3Provider(jest.fn()) + + jest.spyOn(mockProvider, 'getSigner').mockImplementation( + () => + ({ + signMessage: jest.fn().mockResolvedValueOnce(LEDGER_SIGNATURE), + } as unknown as JsonRpcSigner), + ) + jest.spyOn(web3, 'createWeb3').mockImplementation(() => mockProvider) + + const uuid = crypto.randomUUID() + + const payload = await logic.getRegisterDevicePayload({ + safesToRegister: { + ['1']: [hexZeroPad('0x1', 20), hexZeroPad('0x2', 20)], + ['2']: [hexZeroPad('0x1', 20)], + }, + uuid, + wallet: { + label: 'Ledger', + } as ConnectedWallet, + }) + + expect(payload).toStrictEqual({ + uuid, + cloudMessagingToken: token, + buildNumber: '0', + bundle: 'safe', + deviceType: DeviceType.WEB, + version: packageJson.version, + timestamp: expect.any(String), + safeRegistrations: [ + { + chainId: '1', + safes: [hexZeroPad('0x1', 20), hexZeroPad('0x2', 20)], + signatures: [ADJUSTED_LEDGER_SIGNATURE], + }, + { + chainId: '2', + safes: [hexZeroPad('0x1', 20)], + signatures: [ADJUSTED_LEDGER_SIGNATURE], + }, + ], + }) + }) + }) +}) diff --git a/src/components/settings/PushNotifications/hooks/__tests__/useNotificationPreferences.test.ts b/src/components/settings/PushNotifications/hooks/__tests__/useNotificationPreferences.test.ts new file mode 100644 index 0000000000..d4f5cba746 --- /dev/null +++ b/src/components/settings/PushNotifications/hooks/__tests__/useNotificationPreferences.test.ts @@ -0,0 +1,456 @@ +import 'fake-indexeddb/auto' +import { set, setMany } from 'idb-keyval' +import { renderHook, waitFor } from '@/tests/test-utils' +import { hexZeroPad } from 'ethers/lib/utils' + +import { + createPushNotificationUuidIndexedDb, + createPushNotificationPrefsIndexedDb, +} from '@/services/push-notifications/preferences' +import { + useNotificationPreferences, + DEFAULT_NOTIFICATION_PREFERENCES, + _setPreferences, + _setUuid, +} from '../useNotificationPreferences' +import { WebhookType } from '@/service-workers/firebase-messaging/webhook-types' + +Object.defineProperty(globalThis, 'crypto', { + value: { + randomUUID: () => Math.random().toString(), + }, +}) + +describe('useNotificationPreferences', () => { + beforeEach(() => { + // Reset indexedDB + indexedDB = new IDBFactory() + }) + + describe('uuidStore', () => { + beforeEach(() => { + _setUuid(undefined) + }) + + it('should initialise uuid if it does not exist', async () => { + const { result } = renderHook(() => useNotificationPreferences()) + + await waitFor(() => { + expect(result.current.uuid).toEqual(expect.any(String)) + }) + }) + + it('return uuid if it exists', async () => { + const uuid = 'test-uuid' + + await set('uuid', uuid, createPushNotificationUuidIndexedDb()) + + const { result } = renderHook(() => useNotificationPreferences()) + + await waitFor(() => { + expect(result.current.uuid).toEqual(uuid) + }) + }) + }) + + describe('preferencesStore', () => { + beforeEach(() => { + _setPreferences(undefined) + }) + + describe('_getAllPreferenceEntries', () => { + it('should get all preference entries', async () => { + const chainId1 = '1' + const safeAddress1 = hexZeroPad('0x1', 20) + const safeAddress2 = hexZeroPad('0x1', 20) + + const chainId2 = '2' + + const preferences = { + [`${chainId1}:${safeAddress1}`]: { + chainId: chainId1, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId1}:${safeAddress2}`]: { + chainId: chainId1, + safeAddress: safeAddress2, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + } + + await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + + const { result } = renderHook(() => useNotificationPreferences()) + + await waitFor(async () => { + const _preferences = await result.current._getAllPreferenceEntries() + expect(_preferences).toEqual(Object.entries(preferences)) + }) + }) + }) + + describe('_deleteManyPreferenceKeys', () => { + it('should delete many preference keys', async () => { + const chainId1 = '1' + const safeAddress1 = hexZeroPad('0x1', 20) + const safeAddress2 = hexZeroPad('0x1', 20) + + const chainId2 = '2' + + const preferences = { + [`${chainId1}:${safeAddress1}`]: { + chainId: chainId1, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId1}:${safeAddress2}`]: { + chainId: chainId1, + safeAddress: safeAddress2, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + } + + await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + + const { result } = renderHook(() => useNotificationPreferences()) + + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual(preferences) + }) + + const keysToDelete = Object.entries(preferences).map(([key]) => key) + + result.current._deleteManyPreferenceKeys(keysToDelete as `${string}:${string}`[]) + + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual({}) + }) + }) + }) + + describe('getAllPreferences', () => { + it('should return all existing preferences', async () => { + const chainId = '1' + const safeAddress = hexZeroPad('0x1', 20) + + const preferences = { + [`${chainId}:${safeAddress}`]: { + chainId, + safeAddress, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + } + + await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + + const { result } = renderHook(() => useNotificationPreferences()) + + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual(preferences) + }) + }) + }) + + describe('getPreferences', () => { + it('should return existing Safe preferences', async () => { + const chainId = '1' + const safeAddress = hexZeroPad('0x1', 20) + + const preferences = { + [`${chainId}:${safeAddress}`]: { + chainId, + safeAddress, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + } + + await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + + const { result } = renderHook(() => useNotificationPreferences()) + + await waitFor(() => { + expect(result.current.getPreferences(chainId, safeAddress)).toEqual( + preferences[`${chainId}:${safeAddress}`].preferences, + ) + }) + }) + }) + + describe('createPreferences', () => { + it('should create preferences, then hydrate the preferences state', async () => { + const { result } = renderHook(() => useNotificationPreferences()) + + const chainId1 = '1' + const safeAddress1 = hexZeroPad('0x1', 20) + const safeAddress2 = hexZeroPad('0x1', 20) + + const chainId2 = '2' + + result.current.createPreferences({ + [chainId1]: [safeAddress1, safeAddress2], + [chainId2]: [safeAddress1], + }) + + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual({ + [`${chainId1}:${safeAddress1}`]: { + chainId: chainId1, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId1}:${safeAddress2}`]: { + chainId: chainId1, + safeAddress: safeAddress2, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + }) + }) + }) + + it('should not create preferences when passed an empty object', async () => { + const { result } = renderHook(() => useNotificationPreferences()) + + result.current.createPreferences({}) + + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual({}) + }) + }) + + it('should not create preferences when passed an empty array of Safes', async () => { + const { result } = renderHook(() => useNotificationPreferences()) + + result.current.createPreferences({ ['1']: [] }) + + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual({}) + }) + }) + + it('should hydrate accross instances', async () => { + const chainId1 = '1' + const safeAddress1 = hexZeroPad('0x1', 20) + const safeAddress2 = hexZeroPad('0x1', 20) + + const chainId2 = '2' + const { result: instance1 } = renderHook(() => useNotificationPreferences()) + const { result: instance2 } = renderHook(() => useNotificationPreferences()) + + instance1.current.createPreferences({ + [chainId1]: [safeAddress1, safeAddress2], + [chainId2]: [safeAddress1], + }) + + const expectedPreferences = { + [`${chainId1}:${safeAddress1}`]: { + chainId: chainId1, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId1}:${safeAddress2}`]: { + chainId: chainId1, + safeAddress: safeAddress2, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + } + + await waitFor(() => { + expect(instance1.current.getAllPreferences()).toEqual(expectedPreferences) + expect(instance2.current.getAllPreferences()).toEqual(expectedPreferences) + }) + }) + }) + + describe('updatePreferences', () => { + it('should update preferences, then hydrate the preferences state', async () => { + const chainId = '1' + const safeAddress = hexZeroPad('0x1', 20) + + const preferences = { + [`${chainId}:${safeAddress}`]: { + chainId: chainId, + safeAddress: safeAddress, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + } + + await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + + const { result } = renderHook(() => useNotificationPreferences()) + + result.current.updatePreferences(chainId, safeAddress, { + ...DEFAULT_NOTIFICATION_PREFERENCES, + [WebhookType.CONFIRMATION_REQUEST]: false, + }) + + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual({ + [`${chainId}:${safeAddress}`]: { + chainId: chainId, + safeAddress: safeAddress, + preferences: { + ...DEFAULT_NOTIFICATION_PREFERENCES, + [WebhookType.CONFIRMATION_REQUEST]: false, + }, + }, + }) + }) + }) + }) + + describe('deletePreferences', () => { + it('should delete preferences, then hydrate the preferences state', async () => { + const chainId1 = '1' + const safeAddress1 = hexZeroPad('0x1', 20) + const safeAddress2 = hexZeroPad('0x1', 20) + + const chainId2 = '2' + + const preferences = { + [`${chainId1}:${safeAddress1}`]: { + chainId: chainId1, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId1}:${safeAddress2}`]: { + chainId: chainId1, + safeAddress: safeAddress2, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + } + + await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + + const { result } = renderHook(() => useNotificationPreferences()) + + result.current.deletePreferences({ + [chainId1]: [safeAddress1, safeAddress2], + }) + + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual({ + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + }) + }) + }) + + it('should delete preferences, then hydrate the preferences state', async () => { + const chainId1 = '1' + const safeAddress1 = hexZeroPad('0x1', 20) + const safeAddress2 = hexZeroPad('0x1', 20) + + const chainId2 = '2' + + const preferences = { + [`${chainId1}:${safeAddress1}`]: { + chainId: chainId1, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId1}:${safeAddress2}`]: { + chainId: chainId1, + safeAddress: safeAddress2, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + } + + await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + + const { result } = renderHook(() => useNotificationPreferences()) + + result.current.deletePreferences({ + [chainId1]: [safeAddress1, safeAddress2], + }) + + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual({ + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + }) + }) + }) + }) + + describe('deleteAllChainPreferences', () => { + it('should delete per chain, then hydrate the preferences state', async () => { + const chainId1 = '1' + const safeAddress1 = hexZeroPad('0x1', 20) + const safeAddress2 = hexZeroPad('0x1', 20) + + const chainId2 = '2' + + const preferences = { + [`${chainId1}:${safeAddress1}`]: { + chainId: chainId1, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId1}:${safeAddress2}`]: { + chainId: chainId1, + safeAddress: safeAddress2, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + } + + await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + + const { result } = renderHook(() => useNotificationPreferences()) + + result.current.deleteAllChainPreferences(chainId1) + + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual({ + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + }) + }) + }) + }) + }) +}) diff --git a/src/components/settings/PushNotifications/hooks/__tests__/useNotificationRegistrations.test.ts b/src/components/settings/PushNotifications/hooks/__tests__/useNotificationRegistrations.test.ts new file mode 100644 index 0000000000..7ac47cdedf --- /dev/null +++ b/src/components/settings/PushNotifications/hooks/__tests__/useNotificationRegistrations.test.ts @@ -0,0 +1,378 @@ +import { hexZeroPad } from 'ethers/lib/utils' +import { DeviceType } from '@safe-global/safe-gateway-typescript-sdk/dist/types/notifications' +import { Web3Provider } from '@ethersproject/providers' +import * as sdk from '@safe-global/safe-gateway-typescript-sdk' + +import { renderHook } from '@/tests/test-utils' +import { useNotificationRegistrations } from '../useNotificationRegistrations' +import * as web3 from '@/hooks/wallets/web3' +import * as wallet from '@/hooks/wallets/useWallet' +import * as logic from '../../logic' +import * as preferences from '../useNotificationPreferences' +import * as notificationsSlice from '@/store/notificationsSlice' +import type { ConnectedWallet } from '@/services/onboard' + +jest.mock('@safe-global/safe-gateway-typescript-sdk') + +jest.mock('../useNotificationPreferences') + +Object.defineProperty(globalThis, 'crypto', { + value: { + randomUUID: () => Math.random().toString(), + }, +}) + +describe('useNotificationRegistrations', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('registerNotifications', () => { + beforeEach(() => { + const mockProvider = new Web3Provider(jest.fn()) + jest.spyOn(web3, 'createWeb3').mockImplementation(() => mockProvider) + jest.spyOn(wallet, 'default').mockImplementation( + () => + ({ + label: 'MetaMask', + } as ConnectedWallet), + ) + }) + + const registerDeviceSpy = jest.spyOn(sdk, 'registerDevice') + + const getExampleRegisterDevicePayload = ( + safesToRegister: logic.NotifiableSafes, + ): logic.NotificationRegistration => { + const safeRegistrations = Object.entries(safesToRegister).reduce< + logic.NotificationRegistration['safeRegistrations'] + >((acc, [chainId, safeAddresses]) => { + const safeRegistration: logic.NotificationRegistration['safeRegistrations'][number] = { + chainId, + safes: safeAddresses, + signatures: [hexZeroPad('0x69420', 65)], + } + + acc.push(safeRegistration) + + return acc + }, []) + + return { + uuid: self.crypto.randomUUID(), + cloudMessagingToken: 'token', + buildNumber: '0', + bundle: 'https://app.safe.global', + deviceType: DeviceType.WEB, + version: '1.17.0', + timestamp: Math.floor(new Date().getTime() / 1000).toString(), + safeRegistrations, + } + } + + it('does not register if no uuid is present', async () => { + ;(preferences.useNotificationPreferences as jest.Mock).mockImplementation( + () => + ({ + uuid: undefined, + } as unknown as ReturnType), + ) + + const { result } = renderHook(() => useNotificationRegistrations()) + + await result.current.registerNotifications({}) + + expect(registerDeviceSpy).not.toHaveBeenCalled() + }) + + it('does not create preferences/notify if registration does not succeed', async () => { + const safesToRegister: logic.NotifiableSafes = { + '1': [hexZeroPad('0x1', 20)], + '2': [hexZeroPad('0x2', 20)], + } + + const payload = getExampleRegisterDevicePayload(safesToRegister) + + jest.spyOn(logic, 'getRegisterDevicePayload').mockImplementation(() => Promise.resolve(payload)) + + // @ts-expect-error + registerDeviceSpy.mockImplementation(() => Promise.resolve('Registration could not be completed.')) + + const createPreferencesMock = jest.fn() + + ;(preferences.useNotificationPreferences as jest.Mock).mockImplementation( + () => + ({ + uuid: self.crypto.randomUUID(), + createPreferences: createPreferencesMock, + } as unknown as ReturnType), + ) + + const { result } = renderHook(() => useNotificationRegistrations()) + + await result.current.registerNotifications(safesToRegister) + + expect(registerDeviceSpy).toHaveBeenCalledWith(payload) + + expect(createPreferencesMock).not.toHaveBeenCalled() + }) + + it('does not create preferences/notify if registration throws', async () => { + const safesToRegister: logic.NotifiableSafes = { + '1': [hexZeroPad('0x1', 20)], + '2': [hexZeroPad('0x2', 20)], + } + + const payload = getExampleRegisterDevicePayload(safesToRegister) + + jest.spyOn(logic, 'getRegisterDevicePayload').mockImplementation(() => Promise.resolve(payload)) + + // @ts-expect-error + registerDeviceSpy.mockImplementation(() => Promise.resolve('Registration could not be completed.')) + + const createPreferencesMock = jest.fn() + + ;(preferences.useNotificationPreferences as jest.Mock).mockImplementation( + () => + ({ + uuid: self.crypto.randomUUID(), + createPreferences: createPreferencesMock, + } as unknown as ReturnType), + ) + + const { result } = renderHook(() => useNotificationRegistrations()) + + await result.current.registerNotifications(safesToRegister) + + expect(registerDeviceSpy).toHaveBeenCalledWith(payload) + + expect(createPreferencesMock).not.toHaveBeenCalledWith() + }) + + it('creates preferences/notifies if registration succeeded', async () => { + const safesToRegister: logic.NotifiableSafes = { + '1': [hexZeroPad('0x1', 20)], + '2': [hexZeroPad('0x2', 20)], + } + + const payload = getExampleRegisterDevicePayload(safesToRegister) + + jest.spyOn(logic, 'getRegisterDevicePayload').mockImplementation(() => Promise.resolve(payload)) + + registerDeviceSpy.mockImplementation(() => Promise.resolve()) + + const createPreferencesMock = jest.fn() + + ;(preferences.useNotificationPreferences as jest.Mock).mockImplementation( + () => + ({ + uuid: self.crypto.randomUUID(), + createPreferences: createPreferencesMock, + } as unknown as ReturnType), + ) + + const showNotificationSpy = jest.spyOn(notificationsSlice, 'showNotification') + + const { result } = renderHook(() => useNotificationRegistrations()) + + await result.current.registerNotifications(safesToRegister, true) + + expect(registerDeviceSpy).toHaveBeenCalledWith(payload) + + expect(createPreferencesMock).toHaveBeenCalled() + + expect(showNotificationSpy).toHaveBeenCalledWith({ + groupKey: 'notifications', + message: 'You will now receive notifications for these Safe Accounts in your browser.', + variant: 'success', + }) + }) + }) + + describe('unregisterSafeNotifications', () => { + const unregisterSafeSpy = jest.spyOn(sdk, 'unregisterSafe') + + it('does not unregister if no uuid is present', async () => { + ;(preferences.useNotificationPreferences as jest.Mock).mockImplementation( + () => + ({ + uuid: undefined, + } as unknown as ReturnType), + ) + + const { result } = renderHook(() => useNotificationRegistrations()) + + await result.current.unregisterSafeNotifications('1', hexZeroPad('0x1', 20)) + + expect(unregisterSafeSpy).not.toHaveBeenCalled() + }) + + it('does not delete preferences if unregistration does not succeed', async () => { + // @ts-expect-error + unregisterSafeSpy.mockImplementation(() => Promise.resolve('Unregistration could not be completed.')) + + const uuid = self.crypto.randomUUID() + const deletePreferencesMock = jest.fn() + + ;(preferences.useNotificationPreferences as jest.Mock).mockImplementation( + () => + ({ + uuid, + deletePreferences: deletePreferencesMock, + } as unknown as ReturnType), + ) + + const { result } = renderHook(() => useNotificationRegistrations()) + + const chainId = '1' + const safeAddress = hexZeroPad('0x1', 20) + + await result.current.unregisterSafeNotifications(chainId, safeAddress) + + expect(unregisterSafeSpy).toHaveBeenCalledWith(chainId, safeAddress, uuid) + + expect(deletePreferencesMock).not.toHaveBeenCalled() + }) + + it('does not delete preferences if unregistration throws', async () => { + unregisterSafeSpy.mockImplementation(() => Promise.reject()) + + const uuid = self.crypto.randomUUID() + const deletePreferencesMock = jest.fn() + + ;(preferences.useNotificationPreferences as jest.Mock).mockImplementation( + () => + ({ + uuid, + deletePreferences: deletePreferencesMock, + } as unknown as ReturnType), + ) + + const { result } = renderHook(() => useNotificationRegistrations()) + + const chainId = '1' + const safeAddress = hexZeroPad('0x1', 20) + + await result.current.unregisterSafeNotifications(chainId, safeAddress) + + expect(unregisterSafeSpy).toHaveBeenCalledWith(chainId, safeAddress, uuid) + + expect(deletePreferencesMock).not.toHaveBeenCalled() + }) + + it('deletes preferences if unregistration succeeds', async () => { + unregisterSafeSpy.mockImplementation(() => Promise.resolve()) + + const uuid = self.crypto.randomUUID() + const deletePreferencesMock = jest.fn() + + ;(preferences.useNotificationPreferences as jest.Mock).mockImplementation( + () => + ({ + uuid, + deletePreferences: deletePreferencesMock, + } as unknown as ReturnType), + ) + + const { result } = renderHook(() => useNotificationRegistrations()) + + const chainId = '1' + const safeAddress = hexZeroPad('0x1', 20) + + await result.current.unregisterSafeNotifications(chainId, safeAddress) + + expect(unregisterSafeSpy).toHaveBeenCalledWith(chainId, safeAddress, uuid) + + expect(deletePreferencesMock).toHaveBeenCalledWith({ [chainId]: [safeAddress] }) + }) + }) + + describe('unregisterDeviceNotifications', () => { + const unregisterDeviceSpy = jest.spyOn(sdk, 'unregisterDevice') + + it('does not unregister device if no uuid is present', async () => { + ;(preferences.useNotificationPreferences as jest.Mock).mockImplementation( + () => + ({ + uuid: undefined, + } as unknown as ReturnType), + ) + + const { result } = renderHook(() => useNotificationRegistrations()) + + await result.current.unregisterDeviceNotifications('1') + + expect(unregisterDeviceSpy).not.toHaveBeenCalled() + }) + + it('does not clear preferences if unregistration does not succeed', async () => { + // @ts-expect-error + unregisterDeviceSpy.mockImplementation(() => Promise.resolve('Unregistration could not be completed.')) + + const uuid = self.crypto.randomUUID() + const deleteAllChainPreferencesMock = jest.fn() + + ;(preferences.useNotificationPreferences as jest.Mock).mockImplementation( + () => + ({ + uuid, + deleteAllChainPreferences: deleteAllChainPreferencesMock, + } as unknown as ReturnType), + ) + + const { result } = renderHook(() => useNotificationRegistrations()) + + await result.current.unregisterDeviceNotifications('1') + + expect(unregisterDeviceSpy).toHaveBeenCalledWith('1', uuid) + + expect(deleteAllChainPreferencesMock).not.toHaveBeenCalled() + }) + + it('does not clear preferences if unregistration throws', async () => { + unregisterDeviceSpy.mockImplementation(() => Promise.reject()) + + const uuid = self.crypto.randomUUID() + const deleteAllChainPreferencesMock = jest.fn() + + ;(preferences.useNotificationPreferences as jest.Mock).mockImplementation( + () => + ({ + uuid, + deleteAllChainPreferences: deleteAllChainPreferencesMock, + } as unknown as ReturnType), + ) + + const { result } = renderHook(() => useNotificationRegistrations()) + + await result.current.unregisterDeviceNotifications('1') + + expect(unregisterDeviceSpy).toHaveBeenCalledWith('1', uuid) + + expect(deleteAllChainPreferencesMock).not.toHaveBeenCalledWith() + }) + + it('clears chain preferences if unregistration succeeds', async () => { + unregisterDeviceSpy.mockImplementation(() => Promise.resolve()) + + const uuid = self.crypto.randomUUID() + const deleteAllChainPreferencesMock = jest.fn() + + ;(preferences.useNotificationPreferences as jest.Mock).mockImplementation( + () => + ({ + uuid, + deleteAllChainPreferences: deleteAllChainPreferencesMock, + } as unknown as ReturnType), + ) + + const { result } = renderHook(() => useNotificationRegistrations()) + + await result.current.unregisterDeviceNotifications('1') + + expect(unregisterDeviceSpy).toHaveBeenCalledWith('1', uuid) + + expect(deleteAllChainPreferencesMock).toHaveBeenCalledWith('1') + }) + }) +}) diff --git a/src/components/settings/PushNotifications/hooks/__tests__/useNotificationTracking.test.ts b/src/components/settings/PushNotifications/hooks/__tests__/useNotificationTracking.test.ts new file mode 100644 index 0000000000..1e8e834ea0 --- /dev/null +++ b/src/components/settings/PushNotifications/hooks/__tests__/useNotificationTracking.test.ts @@ -0,0 +1,97 @@ +import 'fake-indexeddb/auto' +import { entries, setMany } from 'idb-keyval' + +import * as tracking from '@/services/analytics' +import * as useChains from '@/hooks/useChains' +import { PUSH_NOTIFICATION_EVENTS } from '@/services/analytics/events/push-notifications' +import { createNotificationTrackingIndexedDb } from '@/services/push-notifications/tracking' +import { WebhookType } from '@/service-workers/firebase-messaging/webhook-types' +import { renderHook, waitFor } from '@/tests/test-utils' +import { useNotificationTracking } from '../useNotificationTracking' + +jest.mock('@/services/analytics', () => ({ + trackEvent: jest.fn(), +})) + +describe('useNotificationTracking', () => { + beforeEach(() => { + // Reset indexedDB + indexedDB = new IDBFactory() + jest.clearAllMocks() + }) + + it('should not track if the feature flag is disabled', async () => { + jest.spyOn(useChains, 'useHasFeature').mockReturnValue(false) + jest.spyOn(tracking, 'trackEvent') + + renderHook(() => useNotificationTracking()) + + expect(tracking.trackEvent).not.toHaveBeenCalled() + }) + + it('should track all cached events and clear the cache', async () => { + jest.spyOn(useChains, 'useHasFeature').mockReturnValue(true) + jest.spyOn(tracking, 'trackEvent') + + const cache = { + [`1:${WebhookType.INCOMING_ETHER}`]: { + shown: 1, + opened: 0, + }, + [`3:${WebhookType.INCOMING_TOKEN}`]: { + shown: 1, + opened: 1, + }, + } + + await setMany(Object.entries(cache), createNotificationTrackingIndexedDb()) + + renderHook(() => useNotificationTracking()) + + await waitFor(() => { + expect(tracking.trackEvent).toHaveBeenCalledTimes(3) + + expect(tracking.trackEvent).toHaveBeenCalledWith({ + ...PUSH_NOTIFICATION_EVENTS.SHOW_NOTIFICATION, + label: WebhookType.INCOMING_ETHER, + chainId: '1', + }) + + expect(tracking.trackEvent).toHaveBeenCalledWith({ + ...PUSH_NOTIFICATION_EVENTS.SHOW_NOTIFICATION, + label: WebhookType.INCOMING_TOKEN, + chainId: '3', + }) + + expect(tracking.trackEvent).toHaveBeenCalledWith({ + ...PUSH_NOTIFICATION_EVENTS.OPEN_NOTIFICATION, + label: WebhookType.INCOMING_TOKEN, + chainId: '3', + }) + }) + + const _entries = await entries(createNotificationTrackingIndexedDb()) + expect(Object.fromEntries(_entries)).toStrictEqual({ + [`1:${WebhookType.INCOMING_ETHER}`]: { + shown: 0, + opened: 0, + }, + [`3:${WebhookType.INCOMING_TOKEN}`]: { + shown: 0, + opened: 0, + }, + }) + }) + + it('should not track if no cache exists', async () => { + jest.spyOn(useChains, 'useHasFeature').mockReturnValue(true) + jest.spyOn(tracking, 'trackEvent') + + const _entries = await entries(createNotificationTrackingIndexedDb()) + expect(_entries).toStrictEqual([]) + + renderHook(() => useNotificationTracking()) + + expect(tracking.trackEvent).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/settings/PushNotifications/hooks/useNotificationPreferences.ts b/src/components/settings/PushNotifications/hooks/useNotificationPreferences.ts new file mode 100644 index 0000000000..4cc26e7cc7 --- /dev/null +++ b/src/components/settings/PushNotifications/hooks/useNotificationPreferences.ts @@ -0,0 +1,257 @@ +import { + set as setIndexedDb, + entries as getEntriesFromIndexedDb, + delMany as deleteManyFromIndexedDb, + setMany as setManyIndexedDb, + update as updateIndexedDb, +} from 'idb-keyval' +import { useCallback, useEffect, useMemo } from 'react' + +import { WebhookType } from '@/service-workers/firebase-messaging/webhook-types' +import ExternalStore from '@/services/ExternalStore' +import { + createPushNotificationPrefsIndexedDb, + createPushNotificationUuidIndexedDb, + getPushNotificationPrefsKey, +} from '@/services/push-notifications/preferences' +import { logError } from '@/services/exceptions' +import ErrorCodes from '@/services/exceptions/ErrorCodes' +import type { PushNotificationPreferences, PushNotificationPrefsKey } from '@/services/push-notifications/preferences' +import type { NotifiableSafes } from '../logic' + +export const DEFAULT_NOTIFICATION_PREFERENCES: PushNotificationPreferences[PushNotificationPrefsKey]['preferences'] = { + [WebhookType.EXECUTED_MULTISIG_TRANSACTION]: true, + [WebhookType.INCOMING_ETHER]: true, + [WebhookType.INCOMING_TOKEN]: true, + [WebhookType.MODULE_TRANSACTION]: true, + [WebhookType.CONFIRMATION_REQUEST]: true, // Requires signature + [WebhookType.SAFE_CREATED]: false, // We do not preemptively subscribe to Safes before they are created + // Disabled on the Transaction Service but kept here for completeness + [WebhookType._PENDING_MULTISIG_TRANSACTION]: true, + [WebhookType._NEW_CONFIRMATION]: true, + [WebhookType._OUTGOING_ETHER]: true, + [WebhookType._OUTGOING_TOKEN]: true, +} + +// ExternalStores are used to keep indexedDB state synced across hook instances +const { useStore: useUuid, setStore: setUuid } = new ExternalStore() +const { useStore: usePreferences, setStore: setPreferences } = new ExternalStore() + +// Used for testing +export const _setUuid = setUuid +export const _setPreferences = setPreferences + +export const useNotificationPreferences = (): { + uuid: string | undefined + getAllPreferences: () => PushNotificationPreferences | undefined + getPreferences: (chainId: string, safeAddress: string) => typeof DEFAULT_NOTIFICATION_PREFERENCES | undefined + updatePreferences: ( + chainId: string, + safeAddress: string, + preferences: PushNotificationPreferences[PushNotificationPrefsKey]['preferences'], + ) => void + createPreferences: (safesToRegister: NotifiableSafes) => void + deletePreferences: (safesToUnregister: NotifiableSafes) => void + deleteAllChainPreferences: (chainId: string) => void + _getAllPreferenceEntries: () => Promise< + [PushNotificationPrefsKey, PushNotificationPreferences[PushNotificationPrefsKey]][] + > + _deleteManyPreferenceKeys: (keysToDelete: PushNotificationPrefsKey[]) => void +} => { + // State + const uuid = useUuid() + const preferences = usePreferences() + + // Getters + const getPreferences = (chainId: string, safeAddress: string) => { + const key = getPushNotificationPrefsKey(chainId, safeAddress) + return preferences?.[key]?.preferences + } + + const getAllPreferences = useCallback(() => { + return preferences + }, [preferences]) + + // idb-keyval stores + const uuidStore = useMemo(() => { + if (typeof indexedDB !== 'undefined') { + return createPushNotificationUuidIndexedDb() + } + }, []) + + const preferencesStore = useMemo(() => { + if (typeof indexedDB !== 'undefined') { + return createPushNotificationPrefsIndexedDb() + } + }, []) + + // UUID state hydrator + const hydrateUuidStore = useCallback(() => { + if (!uuidStore) { + return + } + + const UUID_KEY = 'uuid' + + let _uuid: string + + updateIndexedDb( + UUID_KEY, + (storedUuid) => { + // Initialise UUID if it doesn't exist + _uuid = storedUuid || self.crypto.randomUUID() + return _uuid + }, + uuidStore, + ) + .then(() => { + setUuid(_uuid) + }) + .catch((e) => { + logError(ErrorCodes._705, e) + }) + }, [uuidStore]) + + // Hydrate UUID state + useEffect(() => { + hydrateUuidStore() + }, [hydrateUuidStore, uuidStore]) + + const _getAllPreferenceEntries = useCallback(() => { + return getEntriesFromIndexedDb( + preferencesStore, + ) + }, [preferencesStore]) + + // Preferences state hydrator + const hydratePreferences = useCallback(() => { + if (!preferencesStore) { + return + } + + _getAllPreferenceEntries() + .then((preferencesEntries) => { + setPreferences(Object.fromEntries(preferencesEntries)) + }) + .catch((e) => { + logError(ErrorCodes._705, e) + }) + }, [_getAllPreferenceEntries, preferencesStore]) + + // Delete array of preferences store keys + const _deleteManyPreferenceKeys = useCallback( + (keysToDelete: PushNotificationPrefsKey[]) => { + deleteManyFromIndexedDb(keysToDelete, preferencesStore) + .then(hydratePreferences) + .catch((e) => { + logError(ErrorCodes._706, e) + }) + }, + [hydratePreferences, preferencesStore], + ) + + // Hydrate preferences state + useEffect(() => { + hydratePreferences() + }, [hydratePreferences]) + + // Add store entry with default preferences for specified Safe(s) + const createPreferences = (safesToRegister: NotifiableSafes) => { + if (!preferencesStore) { + return + } + + const defaultPreferencesEntries = Object.entries(safesToRegister).flatMap(([chainId, safeAddresses]) => { + return safeAddresses.map( + (safeAddress): [PushNotificationPrefsKey, PushNotificationPreferences[PushNotificationPrefsKey]] => { + const key = getPushNotificationPrefsKey(chainId, safeAddress) + + const defaultPreferences: PushNotificationPreferences[PushNotificationPrefsKey] = { + chainId, + safeAddress, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + } + + return [key, defaultPreferences] + }, + ) + }) + + setManyIndexedDb(defaultPreferencesEntries, preferencesStore) + .then(hydratePreferences) + .catch((e) => { + logError(ErrorCodes._706, e) + }) + } + + // Update preferences for specified Safe + const updatePreferences = ( + chainId: string, + safeAddress: string, + preferences: PushNotificationPreferences[PushNotificationPrefsKey]['preferences'], + ) => { + if (!preferencesStore) { + return + } + + const key = getPushNotificationPrefsKey(chainId, safeAddress) + + const newPreferences: PushNotificationPreferences[PushNotificationPrefsKey] = { + safeAddress, + chainId, + preferences, + } + + setIndexedDb(key, newPreferences, preferencesStore) + .then(hydratePreferences) + .catch((e) => { + logError(ErrorCodes._706, e) + }) + } + + // Delete preferences store entry for specified Safe(s) + const deletePreferences = (safesToUnregister: NotifiableSafes) => { + if (!preferencesStore) { + return + } + + const keysToDelete = Object.entries(safesToUnregister).flatMap(([chainId, safeAddresses]) => { + return safeAddresses.map((safeAddress) => getPushNotificationPrefsKey(chainId, safeAddress)) + }) + + _deleteManyPreferenceKeys(keysToDelete) + } + + // Delete all preferences store entries + const deleteAllChainPreferences = (chainId: string) => { + if (!preferencesStore) { + return + } + + _getAllPreferenceEntries() + .then((preferencesEntries) => { + const keysToDelete = preferencesEntries + .filter(([, prefs]) => { + return prefs.chainId === chainId + }) + .map(([key]) => key) + + _deleteManyPreferenceKeys(keysToDelete) + }) + .catch((e) => { + logError(ErrorCodes._705, e) + }) + } + + return { + uuid, + getAllPreferences, + getPreferences, + updatePreferences, + createPreferences, + deletePreferences, + deleteAllChainPreferences, + _getAllPreferenceEntries, + _deleteManyPreferenceKeys, + } +} diff --git a/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts b/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts new file mode 100644 index 0000000000..7bde23e3dc --- /dev/null +++ b/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts @@ -0,0 +1,107 @@ +import { registerDevice, unregisterDevice, unregisterSafe } from '@safe-global/safe-gateway-typescript-sdk' + +import { useAppDispatch } from '@/store' +import { showNotification } from '@/store/notificationsSlice' +import { useNotificationPreferences } from './useNotificationPreferences' +import { trackEvent } from '@/services/analytics' +import { PUSH_NOTIFICATION_EVENTS } from '@/services/analytics/events/push-notifications' +import { getRegisterDevicePayload } from '../logic' +import { logError } from '@/services/exceptions' +import ErrorCodes from '@/services/exceptions/ErrorCodes' +import useWallet from '@/hooks/wallets/useWallet' +import type { NotifiableSafes } from '../logic' + +const registrationFlow = async (registrationFn: Promise, callback: () => void): Promise => { + let success = false + + try { + const response = await registrationFn + + // Gateway will return 200 with an empty payload if the device was (un-)registered successfully + // @see https://github.com/safe-global/safe-client-gateway-nest/blob/27b6b3846b4ecbf938cdf5d0595ca464c10e556b/src/routes/notifications/notifications.service.ts#L29 + success = response == null + } catch (e) { + logError(ErrorCodes._633, e) + } + + if (success) { + callback() + } + + return success +} + +export const useNotificationRegistrations = (): { + registerNotifications: (safesToRegister: NotifiableSafes, withSignature?: boolean) => Promise + unregisterSafeNotifications: (chainId: string, safeAddress: string) => Promise + unregisterDeviceNotifications: (chainId: string) => Promise +} => { + const dispatch = useAppDispatch() + const wallet = useWallet() + + const { uuid, createPreferences, deletePreferences, deleteAllChainPreferences } = useNotificationPreferences() + + const registerNotifications = async (safesToRegister: NotifiableSafes) => { + if (!uuid || !wallet) { + return + } + + const register = async () => { + const payload = await getRegisterDevicePayload({ + uuid, + safesToRegister, + wallet, + }) + + return registerDevice(payload) + } + + return registrationFlow(register(), () => { + createPreferences(safesToRegister) + + const totalRegistered = Object.values(safesToRegister).reduce( + (acc, safeAddresses) => acc + safeAddresses.length, + 0, + ) + + trackEvent({ + ...PUSH_NOTIFICATION_EVENTS.REGISTER_SAFES, + label: totalRegistered, + }) + + dispatch( + showNotification({ + message: `You will now receive notifications for ${ + totalRegistered > 1 ? 'these Safe Accounts' : 'this Safe Account' + } in your browser.`, + variant: 'success', + groupKey: 'notifications', + }), + ) + }) + } + + const unregisterSafeNotifications = async (chainId: string, safeAddress: string) => { + if (uuid) { + return registrationFlow(unregisterSafe(chainId, safeAddress, uuid), () => { + deletePreferences({ [chainId]: [safeAddress] }) + trackEvent(PUSH_NOTIFICATION_EVENTS.UNREGISTER_SAFE) + }) + } + } + + const unregisterDeviceNotifications = async (chainId: string) => { + if (uuid) { + return registrationFlow(unregisterDevice(chainId, uuid), () => { + deleteAllChainPreferences(chainId) + trackEvent(PUSH_NOTIFICATION_EVENTS.UNREGISTER_DEVICE) + }) + } + } + + return { + registerNotifications, + unregisterSafeNotifications, + unregisterDeviceNotifications, + } +} diff --git a/src/components/settings/PushNotifications/hooks/useNotificationTracking.ts b/src/components/settings/PushNotifications/hooks/useNotificationTracking.ts new file mode 100644 index 0000000000..5c0500422e --- /dev/null +++ b/src/components/settings/PushNotifications/hooks/useNotificationTracking.ts @@ -0,0 +1,80 @@ +import { keys as keysFromIndexedDb, update as updateIndexedDb } from 'idb-keyval' +import { useEffect } from 'react' + +import { + DEFAULT_WEBHOOK_TRACKING, + createNotificationTrackingIndexedDb, + parseNotificationTrackingKey, +} from '@/services/push-notifications/tracking' +import { trackEvent } from '@/services/analytics' +import { PUSH_NOTIFICATION_EVENTS } from '@/services/analytics/events/push-notifications' +import ErrorCodes from '@/services/exceptions/ErrorCodes' +import { logError } from '@/services/exceptions' +import type { NotificationTracking, NotificationTrackingKey } from '@/services/push-notifications/tracking' +import type { WebhookType } from '@/service-workers/firebase-messaging/webhook-types' +import { useHasFeature } from '@/hooks/useChains' +import { FEATURES } from '@/utils/chains' + +const trackNotificationEvents = ( + chainId: string, + type: WebhookType, + notificationCount: NotificationTracking[NotificationTrackingKey], +) => { + // Shown notifications + for (let i = 0; i < notificationCount.shown; i++) { + trackEvent({ + ...PUSH_NOTIFICATION_EVENTS.SHOW_NOTIFICATION, + label: type, + chainId, + }) + } + + // Opened notifications + for (let i = 0; i < notificationCount.opened; i++) { + trackEvent({ + ...PUSH_NOTIFICATION_EVENTS.OPEN_NOTIFICATION, + label: type, + chainId, + }) + } +} + +const handleTrackCachedNotificationEvents = async ( + trackingStore: ReturnType, +) => { + try { + // Get all tracked webhook events by chainId, e.g. "1:NEW_CONFIRMATION" + const trackedNotificationKeys = await keysFromIndexedDb(trackingStore) + + // Get the number of notifications shown/opened and track then clear the cache + const promises = trackedNotificationKeys.map((key) => { + return updateIndexedDb( + key, + (notificationCount) => { + if (notificationCount) { + const { chainId, type } = parseNotificationTrackingKey(key) + trackNotificationEvents(chainId, type, notificationCount) + } + + // Return the default cache with 0 shown/opened events + return DEFAULT_WEBHOOK_TRACKING + }, + trackingStore, + ) + }) + + await Promise.all(promises) + } catch (e) { + logError(ErrorCodes._401, e) + } +} + +export const useNotificationTracking = (): void => { + const isNotificationsEnabled = useHasFeature(FEATURES.PUSH_NOTIFICATIONS) + + useEffect(() => { + if (typeof indexedDB !== 'undefined' && isNotificationsEnabled) { + handleTrackCachedNotificationEvents(createNotificationTrackingIndexedDb()) + } + }, [isNotificationsEnabled]) +} diff --git a/src/components/settings/PushNotifications/index.tsx b/src/components/settings/PushNotifications/index.tsx new file mode 100644 index 0000000000..652aea2288 --- /dev/null +++ b/src/components/settings/PushNotifications/index.tsx @@ -0,0 +1,268 @@ +import { + Grid, + Paper, + Typography, + Checkbox, + FormControlLabel, + FormGroup, + Alert, + Switch, + Divider, + Link as MuiLink, +} from '@mui/material' +import Link from 'next/link' +import { useState } from 'react' +import type { ReactElement } from 'react' + +import useSafeInfo from '@/hooks/useSafeInfo' +import EthHashInfo from '@/components/common/EthHashInfo' +import { WebhookType } from '@/service-workers/firebase-messaging/webhook-types' +import { useNotificationRegistrations } from './hooks/useNotificationRegistrations' +import { useNotificationPreferences } from './hooks/useNotificationPreferences' +import { GlobalPushNotifications } from './GlobalPushNotifications' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import { HelpCenterArticle, IS_DEV } from '@/config/constants' +import { trackEvent } from '@/services/analytics' +import { PUSH_NOTIFICATION_EVENTS } from '@/services/analytics/events/push-notifications' +import { AppRoutes } from '@/config/routes' +import CheckWallet from '@/components/common/CheckWallet' +import { useIsMac } from '@/hooks/useIsMac' +import useOnboard from '@/hooks/wallets/useOnboard' +import { assertWalletChain } from '@/services/tx/tx-sender/sdk' +import ExternalLink from '@/components/common/ExternalLink' + +import css from './styles.module.css' + +export const PushNotifications = (): ReactElement => { + const { safe, safeLoaded } = useSafeInfo() + const isOwner = useIsSafeOwner() + const isMac = useIsMac() + const [isRegistering, setIsRegistering] = useState(false) + const [isUpdatingIndexedDb, setIsUpdatingIndexedDb] = useState(false) + const onboard = useOnboard() + + const { updatePreferences, getPreferences, getAllPreferences } = useNotificationPreferences() + const { unregisterSafeNotifications, unregisterDeviceNotifications, registerNotifications } = + useNotificationRegistrations() + + const preferences = getPreferences(safe.chainId, safe.address.value) + + const setPreferences = (newPreferences: NonNullable>) => { + setIsUpdatingIndexedDb(true) + + updatePreferences(safe.chainId, safe.address.value, newPreferences) + + setIsUpdatingIndexedDb(false) + } + + const shouldShowMacHelper = isMac || IS_DEV + + const handleOnChange = async () => { + if (!onboard) { + return + } + + setIsRegistering(true) + + try { + await assertWalletChain(onboard, safe.chainId) + } catch { + return + } + + if (!preferences) { + await registerNotifications({ [safe.chainId]: [safe.address.value] }) + trackEvent(PUSH_NOTIFICATION_EVENTS.ENABLE_SAFE) + setIsRegistering(false) + return + } + + const allPreferences = getAllPreferences() + const totalRegisteredSafesOnChain = allPreferences + ? Object.values(allPreferences).filter(({ chainId }) => chainId === safe.chainId).length + : 0 + const shouldUnregisterDevice = totalRegisteredSafesOnChain === 1 + + if (shouldUnregisterDevice) { + await unregisterDeviceNotifications(safe.chainId) + } else { + await unregisterSafeNotifications(safe.chainId, safe.address.value) + } + + trackEvent(PUSH_NOTIFICATION_EVENTS.DISABLE_SAFE) + setIsRegistering(false) + } + + return ( + <> + + + + + Push notifications + + + + + + + Enable push notifications for {safeLoaded ? 'this Safe Account' : 'your Safe Accounts'} in your browser + with your signature. You will need to enable them again if you clear your browser cache. Learn more + about push notifications here + + + {shouldShowMacHelper && ( + + + For macOS users + + + Double-check that you have enabled your browser notifications under System Settings >{' '} + Notifications > Application Notifications (path may vary depending on OS version). + + + )} + + {safeLoaded ? ( + <> + + +
+ + + {(isOk) => ( + } + label={preferences ? 'On' : 'Off'} + disabled={!isOk || isRegistering} + /> + )} + +
+ + + + Want to setup notifications for different or all Safe Accounts? You can do so in your{' '} + + global preferences + + . + + + + ) : ( + + )} +
+
+
+
+ {preferences && ( + + + + + Notification + + + + + + { + setPreferences({ + ...preferences, + [WebhookType.INCOMING_ETHER]: checked, + [WebhookType.INCOMING_TOKEN]: checked, + }) + + trackEvent({ ...PUSH_NOTIFICATION_EVENTS.TOGGLE_INCOMING_TXS, label: checked }) + }} + /> + } + label="Incoming transactions" + /> + + { + setPreferences({ + ...preferences, + [WebhookType.MODULE_TRANSACTION]: checked, + [WebhookType.EXECUTED_MULTISIG_TRANSACTION]: checked, + }) + + trackEvent({ ...PUSH_NOTIFICATION_EVENTS.TOGGLE_OUTGOING_TXS, label: checked }) + }} + /> + } + label="Outgoing transactions" + /> + + { + const updateConfirmationRequestPreferences = () => { + setPreferences({ + ...preferences, + [WebhookType.CONFIRMATION_REQUEST]: checked, + }) + + trackEvent({ ...PUSH_NOTIFICATION_EVENTS.TOGGLE_CONFIRMATION_REQUEST, label: checked }) + } + + if (checked) { + registerNotifications({ + [safe.chainId]: [safe.address.value], + }) + .then((registered) => { + if (registered) { + updateConfirmationRequestPreferences() + } + }) + .catch(() => null) + } else { + updateConfirmationRequestPreferences() + } + }} + /> + } + label={ + <> + Confirmation requests + {!preferences[WebhookType.CONFIRMATION_REQUEST] && ( + + {isOwner ? 'Requires your signature' : 'Only owners'} + + )} + + } + disabled={!isOwner || !preferences} + /> + + + + + )} + + ) +} diff --git a/src/components/settings/PushNotifications/logic.ts b/src/components/settings/PushNotifications/logic.ts new file mode 100644 index 0000000000..8ee435cf1b --- /dev/null +++ b/src/components/settings/PushNotifications/logic.ts @@ -0,0 +1,155 @@ +import { arrayify, joinSignature, keccak256, splitSignature, toUtf8Bytes } from 'ethers/lib/utils' +import { getToken, getMessaging } from 'firebase/messaging' +import { DeviceType } from '@safe-global/safe-gateway-typescript-sdk' +import type { RegisterNotificationsRequest } from '@safe-global/safe-gateway-typescript-sdk' +import type { Web3Provider } from '@ethersproject/providers' + +import { FIREBASE_VAPID_KEY, initializeFirebaseApp } from '@/services/push-notifications/firebase' +import packageJson from '../../../../package.json' +import { logError } from '@/services/exceptions' +import ErrorCodes from '@/services/exceptions/ErrorCodes' +import { checksumAddress } from '@/utils/addresses' +import { isLedger } from '@/utils/wallets' +import { createWeb3 } from '@/hooks/wallets/web3' +import type { ConnectedWallet } from '@/services/onboard' + +type WithRequired = T & { [P in K]-?: T[P] } + +// We store UUID locally to track device registration +export type NotificationRegistration = WithRequired + +export const requestNotificationPermission = async (): Promise => { + if (Notification.permission === 'granted') { + return true + } + + let permission: NotificationPermission | undefined + + try { + permission = await Notification.requestPermission() + } catch (e) { + logError(ErrorCodes._400, e) + } + + return permission === 'granted' +} + +// Ledger produces vrs signatures with a canonical v value of {0,1} +// Ethereum's ecrecover call only accepts a non-standard v value of {27,28}. + +// @see https://github.com/ethereum/go-ethereum/issues/19751 +export const _adjustLedgerSignatureV = (signature: string): string => { + const split = splitSignature(signature) + + if (split.v === 0 || split.v === 1) { + split.v += 27 + } + + return joinSignature(split) +} + +const getSafeRegistrationSignature = async ({ + safeAddresses, + web3, + timestamp, + uuid, + token, + isLedger, +}: { + safeAddresses: Array + web3: Web3Provider + timestamp: string + uuid: string + token: string + isLedger: boolean +}) => { + const MESSAGE_PREFIX = 'gnosis-safe' + + // Signature must sign `keccack256('gnosis-safe{timestamp-epoch}{uuid}{cloud_messaging_token}{safes_sorted}': + // - `{timestamp-epoch}` must be an integer (no milliseconds) + // - `{safes_sorted}` must be checksummed safe addresses sorted and joined with no spaces + + // @see https://github.com/safe-global/safe-transaction-service/blob/3644c08ac4b01b6a1c862567bc1d1c81b1a8c21f/safe_transaction_service/notifications/views.py#L19-L24 + + const message = MESSAGE_PREFIX + timestamp + uuid + token + safeAddresses.sort().join('') + const hashedMessage = keccak256(toUtf8Bytes(message)) + + const signature = await web3.getSigner().signMessage(arrayify(hashedMessage)) + + if (!isLedger) { + return signature + } + + return _adjustLedgerSignatureV(signature) +} + +export type NotifiableSafes = { [chainId: string]: Array } + +export const getRegisterDevicePayload = async ({ + safesToRegister, + uuid, + wallet, +}: { + safesToRegister: NotifiableSafes + uuid: string + wallet: ConnectedWallet +}): Promise => { + const BUILD_NUMBER = '0' // Required value, but does not exist on web + const BUNDLE = 'safe' + + const [serviceWorkerRegistration] = await navigator.serviceWorker.getRegistrations() + + // Get Firebase token + const app = initializeFirebaseApp() + const messaging = getMessaging(app) + + const token = await getToken(messaging, { + vapidKey: FIREBASE_VAPID_KEY, + serviceWorkerRegistration, + }) + + const web3 = createWeb3(wallet.provider) + const isLedgerWallet = isLedger(wallet) + + // If uuid is not provided a new device will be created. + // If a uuid for an existing Safe is provided the FirebaseDevice will be updated with all the new data provided. + // Safes provided on the request are always added and never removed/replaced + + // @see https://github.com/safe-global/safe-transaction-service/blob/3644c08ac4b01b6a1c862567bc1d1c81b1a8c21f/safe_transaction_service/notifications/views.py#L19-L24 + + const timestamp = Math.floor(new Date().getTime() / 1000).toString() + + let safeRegistrations: RegisterNotificationsRequest['safeRegistrations'] = [] + + // We cannot `Promise.all` here as Ledger/Trezor return a "busy" error when signing multiple messages at once + for await (const [chainId, safeAddresses] of Object.entries(safesToRegister)) { + const checksummedSafeAddresses = safeAddresses.map((address) => checksumAddress(address)) + + // We require a signature for confirmation request notifications + const signature = await getSafeRegistrationSignature({ + safeAddresses: checksummedSafeAddresses, + web3, + uuid, + timestamp, + token, + isLedger: isLedgerWallet, + }) + + safeRegistrations.push({ + chainId, + safes: checksummedSafeAddresses, + signatures: [signature], + }) + } + + return { + uuid, + cloudMessagingToken: token, + buildNumber: BUILD_NUMBER, + bundle: BUNDLE, + deviceType: DeviceType.WEB, + version: packageJson.version, + timestamp, + safeRegistrations, + } +} diff --git a/src/components/settings/PushNotifications/styles.module.css b/src/components/settings/PushNotifications/styles.module.css new file mode 100644 index 0000000000..963f986c7a --- /dev/null +++ b/src/components/settings/PushNotifications/styles.module.css @@ -0,0 +1,29 @@ +.macOsInfo { + border-color: var(--color-border-light); + background-color: var(--color-background-main); + padding: var(--space-2); +} + +.macOsInfo :global .MuiAlert-icon { + color: var(--color-text-main); + padding: 0; +} + +.macOsInfo :global .MuiAlert-message { + padding: 0; +} + +.item { + padding-left: var(--space-1); +} + +.icon { + min-width: 38px; +} + +.globalInfo { + border-radius: 6px; + border: 1px solid var(--color-secondary-light); + background-color: var(--color-secondary-background); + padding: var(--space-2); +} diff --git a/src/components/settings/SettingsHeader/index.tsx b/src/components/settings/SettingsHeader/index.tsx index 9efcd616d7..63f3e54be8 100644 --- a/src/components/settings/SettingsHeader/index.tsx +++ b/src/components/settings/SettingsHeader/index.tsx @@ -5,16 +5,25 @@ import PageHeader from '@/components/common/PageHeader' import { generalSettingsNavItems, settingsNavItems } from '@/components/sidebar/SidebarNavigation/config' import css from '@/components/common/PageHeader/styles.module.css' import useSafeAddress from '@/hooks/useSafeAddress' +import { AppRoutes } from '@/config/routes' +import { useHasFeature } from '@/hooks/useChains' +import { FEATURES } from '@/utils/chains' const SettingsHeader = (): ReactElement => { const safeAddress = useSafeAddress() + const isNotificationsEnabled = useHasFeature(FEATURES.PUSH_NOTIFICATIONS) + + const navItems = safeAddress ? settingsNavItems : generalSettingsNavItems + const filteredNavItems = isNotificationsEnabled + ? navItems + : navItems.filter((item) => item.href !== AppRoutes.settings.notifications) return ( - +
} /> diff --git a/src/components/sidebar/SidebarNavigation/config.tsx b/src/components/sidebar/SidebarNavigation/config.tsx index f42ef665b8..de827af086 100644 --- a/src/components/sidebar/SidebarNavigation/config.tsx +++ b/src/components/sidebar/SidebarNavigation/config.tsx @@ -84,6 +84,10 @@ export const settingsNavItems = [ label: 'Appearance', href: AppRoutes.settings.appearance, }, + { + label: 'Notifications', + href: AppRoutes.settings.notifications, + }, { label: 'Modules', href: AppRoutes.settings.modules, @@ -119,6 +123,10 @@ export const generalSettingsNavItems = [ label: 'Appearance', href: AppRoutes.settings.appearance, }, + { + label: 'Notifications', + href: AppRoutes.settings.notifications, + }, { label: 'Data', href: AppRoutes.settings.data, diff --git a/src/components/transactions/TxDetails/index.tsx b/src/components/transactions/TxDetails/index.tsx index 5506754491..97008f38f8 100644 --- a/src/components/transactions/TxDetails/index.tsx +++ b/src/components/transactions/TxDetails/index.tsx @@ -29,6 +29,7 @@ import { DelegateCallWarning, UnsignedWarning } from '@/components/transactions/ import Multisend from '@/components/transactions/TxDetails/TxData/DecodedData/Multisend' import useSafeInfo from '@/hooks/useSafeInfo' import useIsPending from '@/hooks/useIsPending' +import { trackEvent, TX_LIST_EVENTS } from '@/services/analytics' export const NOT_AVAILABLE = 'n/a' @@ -125,6 +126,8 @@ const TxDetails = ({ const [txDetailsData, error, loading] = useAsync( async () => { + trackEvent(TX_LIST_EVENTS.FETCH_DETAILS) + return txDetails || getTransactionDetails(chainId, txSummary.id) }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/components/transactions/TxSummary/index.tsx b/src/components/transactions/TxSummary/index.tsx index fb640452c6..496d750290 100644 --- a/src/components/transactions/TxSummary/index.tsx +++ b/src/components/transactions/TxSummary/index.tsx @@ -15,6 +15,8 @@ import useTransactionStatus from '@/hooks/useTransactionStatus' import TxType from '@/components/transactions/TxType' import TxConfirmations from '../TxConfirmations' import useIsPending from '@/hooks/useIsPending' +import useABTesting from '@/services/tracking/useAbTesting' +import { AbTest } from '@/services/tracking/abTesting' const getStatusColor = (value: TransactionStatus, palette: Palette) => { switch (value) { @@ -41,6 +43,7 @@ const TxSummary = ({ item, isGrouped }: TxSummaryProps): ReactElement => { const wallet = useWallet() const txStatusLabel = useTransactionStatus(tx) const isPending = useIsPending(tx.id) + const shouldDisplayHumanDescription = useABTesting(AbTest.HUMAN_DESCRIPTION) const isQueue = isTxQueued(tx.txStatus) const awaitingExecution = isAwaitingExecution(tx.txStatus) const nonce = isMultisigExecutionInfo(tx.executionInfo) ? tx.executionInfo.nonce : undefined @@ -52,7 +55,8 @@ const TxSummary = ({ item, isGrouped }: TxSummaryProps): ReactElement => { : undefined const displayConfirmations = isQueue && !!submittedConfirmations && !!requiredConfirmations - const displayInfo = !tx.txInfo.richDecodedInfo && tx.txInfo.type !== TransactionInfoType.TRANSFER + const displayInfo = + (!tx.txInfo.richDecodedInfo && tx.txInfo.type !== TransactionInfoType.TRANSFER) || !shouldDisplayHumanDescription return ( { +const TxType = ({ tx, short = false }: TxTypeProps) => { const type = useTransactionType(tx) + const shouldDisplayHumanDescription = useABTesting(AbTest.HUMAN_DESCRIPTION) const humanDescription = tx.txInfo.richDecodedInfo?.fragments @@ -24,9 +28,9 @@ const TxType = ({ tx }: TxTypeProps) => { height={16} fallback="/images/transactions/custom.svg" /> - {humanDescription ? ( + {humanDescription && shouldDisplayHumanDescription && !short ? ( - ) : tx.txInfo.type === TransactionInfoType.TRANSFER ? ( + ) : tx.txInfo.type === TransactionInfoType.TRANSFER && shouldDisplayHumanDescription && !short ? ( ) : ( type.text diff --git a/src/components/transactions/TxType/styles.module.css b/src/components/transactions/TxType/styles.module.css index 2a8aa827b3..e4e9a2cf4e 100644 --- a/src/components/transactions/TxType/styles.module.css +++ b/src/components/transactions/TxType/styles.module.css @@ -1,7 +1,7 @@ .txType { display: flex; align-items: center; - flex-wrap: wrap; + flex-wrap: nowrap; gap: var(--space-1); color: var(--color-text-primary); } diff --git a/src/components/tx-flow/SafeTxProvider.tsx b/src/components/tx-flow/SafeTxProvider.tsx index 06c3b95574..cdc263d815 100644 --- a/src/components/tx-flow/SafeTxProvider.tsx +++ b/src/components/tx-flow/SafeTxProvider.tsx @@ -4,7 +4,6 @@ import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import { createTx } from '@/services/tx/tx-sender' import { useRecommendedNonce, useSafeTxGas } from '../tx/SignOrExecuteForm/hooks' import { Errors, logError } from '@/services/exceptions' -import useSafeInfo from '@/hooks/useSafeInfo' export const SafeTxContext = createContext<{ safeTx?: SafeTransaction @@ -36,13 +35,12 @@ const SafeTxProvider = ({ children }: { children: ReactNode }): ReactElement => const [nonce, setNonce] = useState() const [nonceNeeded, setNonceNeeded] = useState(true) const [safeTxGas, setSafeTxGas] = useState() - const { safe } = useSafeInfo() // Signed txs cannot be updated const isSigned = safeTx && safeTx.signatures.size > 0 // Recommended nonce and safeTxGas - const recommendedNonce = Math.max(safe.nonce, useRecommendedNonce() ?? 0) + const recommendedNonce = useRecommendedNonce() const recommendedSafeTxGas = useSafeTxGas(safeTx) // Priority to external nonce, then to the recommended one diff --git a/src/components/tx-flow/common/TxButton.tsx b/src/components/tx-flow/common/TxButton.tsx index 032437807c..c0f03ac000 100644 --- a/src/components/tx-flow/common/TxButton.tsx +++ b/src/components/tx-flow/common/TxButton.tsx @@ -6,6 +6,8 @@ import { useTxBuilderApp } from '@/hooks/safe-apps/useTxBuilderApp' import { AppRoutes } from '@/config/routes' import Track from '@/components/common/Track' import { MODALS_EVENTS } from '@/services/analytics' +import { useContext } from 'react' +import { TxModalContext } from '..' const buttonSx = { height: '58px', @@ -24,11 +26,15 @@ export const SendTokensButton = ({ onClick, sx }: { onClick: () => void; sx?: Bu export const SendNFTsButton = () => { const router = useRouter() + const { setTxFlow } = useContext(TxModalContext) + + const isNftPage = router.pathname === AppRoutes.balances.nfts + const onClick = isNftPage ? () => setTxFlow(undefined) : undefined return ( - @@ -38,12 +44,18 @@ export const SendNFTsButton = () => { export const TxBuilderButton = () => { const txBuilder = useTxBuilderApp() + const router = useRouter() + const { setTxFlow } = useContext(TxModalContext) + if (!txBuilder?.app) return null + const isTxBuilder = typeof txBuilder.link.query === 'object' && router.query.appUrl === txBuilder.link.query?.appUrl + const onClick = isTxBuilder ? () => setTxFlow(undefined) : undefined + return ( - diff --git a/src/components/tx-flow/common/TxNonce/index.tsx b/src/components/tx-flow/common/TxNonce/index.tsx index 18f031f93c..f2d540443d 100644 --- a/src/components/tx-flow/common/TxNonce/index.tsx +++ b/src/components/tx-flow/common/TxNonce/index.tsx @@ -14,6 +14,7 @@ import { ListSubheader, type ListSubheaderProps, } from '@mui/material' +import { createFilterOptions } from '@mui/material/Autocomplete' import { Controller, useForm } from 'react-hook-form' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' @@ -81,13 +82,22 @@ const NonceFormOption = memo(function NonceFormOption({ ) }) -const getFieldMinWidth = (value: string): string => { +const getFieldMinWidth = (value: string, showRecommendedNonceButton = false): string => { const MIN_CHARS = 5 const MAX_WIDTH = '200px' + const ADORNMENT_PADDING = '24px' - return `clamp(calc(${MIN_CHARS}ch + 6px), calc(${Math.max(MIN_CHARS, value.length)}ch + 6px), ${MAX_WIDTH})` + const clamped = `clamp(calc(${MIN_CHARS}ch + 6px), calc(${Math.max(MIN_CHARS, value.length)}ch + 6px), ${MAX_WIDTH})` + + if (showRecommendedNonceButton) { + return `calc(${clamped} + ${ADORNMENT_PADDING})` + } + + return clamped } +const filter = createFilterOptions() + enum TxNonceFormFieldNames { NONCE = 'nonce', } @@ -104,7 +114,7 @@ const TxNonceForm = ({ nonce, recommendedNonce }: { nonce: string; recommendedNo defaultValues: { [TxNonceFormFieldNames.NONCE]: nonce, }, - mode: 'onTouched', + mode: 'all', values: { [TxNonceFormFieldNames.NONCE]: nonce, }, @@ -122,6 +132,9 @@ const TxNonceForm = ({ nonce, recommendedNonce }: { nonce: string; recommendedNo required: 'Nonce is required', // Validation must be async to allow resetting invalid values onBlur validate: async (value) => { + // nonce is always valid so no need to validate if the input is the same + if (value === nonce) return + const newNonce = Number(value) if (isNaN(newNonce)) { @@ -166,6 +179,19 @@ const TxNonceForm = ({ nonce, recommendedNonce }: { nonce: string; recommendedNo }} options={[recommendedNonce, ...previousNonces]} getOptionLabel={(option) => option.toString()} + filterOptions={(options, params) => { + const filtered = filter(options, params) + + // Prevent segments from showing recommended, e.g. if recommended is 250, don't show for 2, 5 or 25 + const shouldShow = !recommendedNonce.includes(params.inputValue) + const isQueued = options.some((option) => params.inputValue === option) + + if (params.inputValue !== '' && !isQueued && shouldShow) { + filtered.push(recommendedNonce) + } + + return filtered + }} renderOption={(props, option) => { const isRecommendedNonce = option === recommendedNonce const isInitialPreviousNonce = option === previousNonces[0] @@ -210,7 +236,7 @@ const TxNonceForm = ({ nonce, recommendedNonce }: { nonce: string; recommendedNo }, ])} sx={{ - minWidth: getFieldMinWidth(field.value), + minWidth: getFieldMinWidth(field.value, showRecommendedNonceButton), }} />
diff --git a/src/components/tx/SignOrExecuteForm/hooks.ts b/src/components/tx/SignOrExecuteForm/hooks.ts index a7ca048452..a0a4a712c0 100644 --- a/src/components/tx/SignOrExecuteForm/hooks.ts +++ b/src/components/tx/SignOrExecuteForm/hooks.ts @@ -165,10 +165,12 @@ export const useRecommendedNonce = (): number | undefined => { const { safeAddress, safe } = useSafeInfo() const [recommendedNonce] = useAsync( - () => { + async () => { if (!safe.chainId || !safeAddress) return - return getRecommendedNonce(safe.chainId, safeAddress) + const recommendedNonce = await getRecommendedNonce(safe.chainId, safeAddress) + + return recommendedNonce !== undefined ? Math.max(safe.nonce, recommendedNonce) : undefined }, // eslint-disable-next-line react-hooks/exhaustive-deps [safeAddress, safe.chainId, safe.txQueuedTag], // update when tx queue changes diff --git a/src/config/constants.ts b/src/config/constants.ts index 577265e35f..7edd0dd2e1 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -1,6 +1,6 @@ import chains from './chains' -export const IS_PRODUCTION = !!process.env.NEXT_PUBLIC_IS_PRODUCTION +export const IS_PRODUCTION = process.env.NEXT_PUBLIC_IS_PRODUCTION === 'true' export const IS_DEV = process.env.NODE_ENV === 'development' export const GATEWAY_URL_PRODUCTION = @@ -84,6 +84,7 @@ export const HelpCenterArticle = { TRANSACTION_GUARD: `${HELP_CENTER_URL}/en/articles/40809-what-is-a-transaction-guard`, UNEXPECTED_DELEGATE_CALL: `${HELP_CENTER_URL}/en/articles/40794-why-do-i-see-an-unexpected-delegate-call-warning-in-my-transaction`, DELEGATES: `${HELP_CENTER_URL}/en/articles/40799-what-is-a-delegate-key`, + PUSH_NOTIFICATIONS: `${HELP_CENTER_URL}/en/articles/99197-how-to-start-receiving-web-push-notifications-in-the-web-wallet`, } as const // Social @@ -91,7 +92,7 @@ export const DISCORD_URL = 'https://chat.safe.global' export const TWITTER_URL = 'https://twitter.com/safe' // Legal -export const IS_OFFICIAL_HOST = process.env.NEXT_PUBLIC_IS_OFFICIAL_HOST || false +export const IS_OFFICIAL_HOST = process.env.NEXT_PUBLIC_IS_OFFICIAL_HOST === 'true' // Risk mitigation (Redefine) export const REDEFINE_SIMULATION_URL = 'https://dashboard.redefine.net/reports/' diff --git a/src/config/routes.ts b/src/config/routes.ts index fcf04266cf..a3f245cd3b 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -29,6 +29,7 @@ export const AppRoutes = { spendingLimits: '/settings/spending-limits', setup: '/settings/setup', recovery: '/settings/recovery', + notifications: '/settings/notifications', modules: '/settings/modules', index: '/settings', environmentVariables: '/settings/environment-variables', diff --git a/src/hooks/Beamer/useBeamer.ts b/src/hooks/Beamer/useBeamer.ts index b6af7e3cf6..96c774fd68 100644 --- a/src/hooks/Beamer/useBeamer.ts +++ b/src/hooks/Beamer/useBeamer.ts @@ -4,15 +4,12 @@ import { useAppSelector } from '@/store' import { CookieType, selectCookies } from '@/store/cookiesSlice' import { loadBeamer, unloadBeamer, updateBeamer } from '@/services/beamer' import { useCurrentChain } from '@/hooks/useChains' -import { useBeamerNps } from '@/hooks/Beamer/useBeamerNps' const useBeamer = () => { const cookies = useAppSelector(selectCookies) const isBeamerEnabled = cookies[CookieType.UPDATES] const chain = useCurrentChain() - useBeamerNps() - useEffect(() => { if (!chain?.shortName) { return diff --git a/src/hooks/Beamer/useBeamerNps.ts b/src/hooks/Beamer/useBeamerNps.ts deleted file mode 100644 index e51138aa65..0000000000 --- a/src/hooks/Beamer/useBeamerNps.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useEffect } from 'react' - -import { TxEvent, txSubscribe } from '@/services/tx/txEvents' -import { useAppSelector } from '@/store' -import { selectCookies, CookieType } from '@/store/cookiesSlice' -import { shouldShowBeamerNps } from '@/services/beamer' - -export const useBeamerNps = (): void => { - const cookies = useAppSelector(selectCookies) - const isBeamerEnabled = cookies[CookieType.UPDATES] - - useEffect(() => { - if (typeof window === 'undefined' || !isBeamerEnabled) { - return - } - - const unsubscribe = txSubscribe(TxEvent.PROPOSED, () => { - // Cannot check at the top of effect as Beamer may not have loaded yet - if (shouldShowBeamerNps()) { - // We "force" the NPS banner as we have it globally disabled in Beamer to prevent it - // randomly showing on pages that we don't want it to - // Note: this is not documented but confirmed by Beamer support - window.Beamer?.forceShowNPS() - } - - unsubscribe() - }) - - return unsubscribe - }, [isBeamerEnabled]) -} diff --git a/src/hooks/useIsMac.ts b/src/hooks/useIsMac.ts new file mode 100644 index 0000000000..e324257c69 --- /dev/null +++ b/src/hooks/useIsMac.ts @@ -0,0 +1,13 @@ +import { useState, useEffect } from 'react' + +export const useIsMac = (): boolean => { + const [isMac, setIsMac] = useState(false) + + useEffect(() => { + if (typeof navigator !== 'undefined') { + setIsMac(navigator.userAgent.includes('Mac')) + } + }, []) + + return isMac +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 568fa4f1e5..cc753328ae 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -37,6 +37,9 @@ import useSafeMessageNotifications from '@/hooks/messages/useSafeMessageNotifica import useSafeMessagePendingStatuses from '@/hooks/messages/useSafeMessagePendingStatuses' import useChangedValue from '@/hooks/useChangedValue' import { TxModalProvider } from '@/components/tx-flow' +import useABTesting from '@/services/tracking/useAbTesting' +import { AbTest } from '@/services/tracking/abTesting' +import { useNotificationTracking } from '@/components/settings/PushNotifications/hooks/useNotificationTracking' const GATEWAY_URL = IS_PRODUCTION || cgwDebugStorage.get() ? GATEWAY_URL_PRODUCTION : GATEWAY_URL_STAGING @@ -44,6 +47,7 @@ const InitApp = (): null => { setGatewayBaseUrl(GATEWAY_URL) useAdjustUrl() useGtm() + useNotificationTracking() useInitSession() useLoadableStores() useInitOnboard() @@ -57,6 +61,7 @@ const InitApp = (): null => { useTxTracking() useSafeMsgTracking() useBeamer() + useABTesting(AbTest.HUMAN_DESCRIPTION) return null } diff --git a/src/pages/settings/notifications.tsx b/src/pages/settings/notifications.tsx new file mode 100644 index 0000000000..a739f3d5f4 --- /dev/null +++ b/src/pages/settings/notifications.tsx @@ -0,0 +1,31 @@ +import Head from 'next/head' +import type { NextPage } from 'next' + +import SettingsHeader from '@/components/settings/SettingsHeader' +import { PushNotifications } from '@/components/settings/PushNotifications' +import { useHasFeature } from '@/hooks/useChains' +import { FEATURES } from '@/utils/chains' + +const NotificationsPage: NextPage = () => { + const isNotificationsEnabled = useHasFeature(FEATURES.PUSH_NOTIFICATIONS) + + if (!isNotificationsEnabled) { + return null + } + + return ( + <> + + {'Safe{Wallet} – Settings – Notifications'} + + + + +
+ +
+ + ) +} + +export default NotificationsPage diff --git a/src/service-workers/firebase-messaging/__tests__/notifications.test.ts b/src/service-workers/firebase-messaging/__tests__/notifications.test.ts new file mode 100644 index 0000000000..a9a22074b6 --- /dev/null +++ b/src/service-workers/firebase-messaging/__tests__/notifications.test.ts @@ -0,0 +1,674 @@ +import { hexZeroPad } from 'ethers/lib/utils' +import * as sdk from '@safe-global/safe-gateway-typescript-sdk' + +import { _parseServiceWorkerWebhookPushNotification } from '../notifications' +import { WebhookType } from '../webhook-types' +import type { + ConfirmationRequestEvent, + ExecutedMultisigTransactionEvent, + IncomingEtherEvent, + IncomingTokenEvent, + ModuleTransactionEvent, + NewConfirmationEvent, + OutgoingEtherEvent, + OutgoingTokenEvent, + PendingMultisigTransactionEvent, + SafeCreatedEvent, +} from '../webhook-types' + +jest.mock('@safe-global/safe-gateway-typescript-sdk') + +Object.defineProperty(self, 'location', { + value: { + origin: 'https://app.safe.global', + }, +}) + +describe('parseWebhookPushNotification', () => { + let getChainsConfigSpy: jest.SpyInstance> + let getBalancesMockSpy: jest.SpyInstance> + + beforeEach(() => { + getChainsConfigSpy = jest.spyOn(sdk, 'getChainsConfig') + getBalancesMockSpy = jest.spyOn(sdk, 'getBalances') + }) + + describe('should parse EXECUTED_MULTISIG_TRANSACTION payloads', () => { + const payload: Omit = { + type: WebhookType.EXECUTED_MULTISIG_TRANSACTION, + chainId: '1', + address: hexZeroPad('0x1', 20), + safeTxHash: hexZeroPad('0x3', 32), + txHash: hexZeroPad('0x4', 32), + } + + describe('successful transactions', () => { + it('with chain info', async () => { + getChainsConfigSpy.mockResolvedValue({ + results: [{ chainName: 'Mainnet', chainId: payload.chainId, shortName: 'eth' } as sdk.ChainInfo], + }) + + const notification = await _parseServiceWorkerWebhookPushNotification({ + ...payload, + failed: 'false', + }) + + expect(notification).toEqual({ + title: 'Transaction executed', + body: 'Safe 0x0000...0001 on Mainnet executed transaction 0x0000...0004.', + link: 'https://app.safe.global/transactions/tx?safe=eth:0x0000000000000000000000000000000000000001&id=0x0000000000000000000000000000000000000000000000000000000000000003', + }) + }) + + it('without chain info', async () => { + getChainsConfigSpy.mockImplementationOnce(() => Promise.reject()) // chains + + const notification = await _parseServiceWorkerWebhookPushNotification({ + ...payload, + failed: 'false', + }) + + expect(notification).toEqual({ + title: 'Transaction executed', + body: 'Safe 0x0000...0001 on chain 1 executed transaction 0x0000...0004.', + link: 'https://app.safe.global', + }) + }) + }) + + describe('failed transactions', () => { + it('with chain info', async () => { + getChainsConfigSpy.mockResolvedValue({ + results: [{ chainName: 'Mainnet', chainId: payload.chainId, shortName: 'eth' } as sdk.ChainInfo], + }) + + const notification = await _parseServiceWorkerWebhookPushNotification({ + ...payload, + failed: 'true', + }) + + expect(notification).toEqual({ + title: 'Transaction failed', + body: 'Safe 0x0000...0001 on Mainnet failed to execute transaction 0x0000...0004.', + link: 'https://app.safe.global/transactions/tx?safe=eth:0x0000000000000000000000000000000000000001&id=0x0000000000000000000000000000000000000000000000000000000000000003', + }) + }) + + it('without chain info', async () => { + getChainsConfigSpy.mockImplementationOnce(() => Promise.reject()) // chains + + const notification = await _parseServiceWorkerWebhookPushNotification({ + ...payload, + failed: 'true', + }) + + expect(notification).toEqual({ + title: 'Transaction failed', + body: 'Safe 0x0000...0001 on chain 1 failed to execute transaction 0x0000...0004.', + link: 'https://app.safe.global', + }) + }) + }) + }) + + describe('should parse INCOMING_ETHER payloads', () => { + const payload: IncomingEtherEvent = { + type: WebhookType.INCOMING_ETHER, + chainId: '137', + address: hexZeroPad('0x1', 20), + txHash: hexZeroPad('0x3', 32), + value: '1000000000000000000', + } + + it('with chain info', async () => { + getChainsConfigSpy.mockResolvedValue({ + results: [ + { + chainName: 'Polygon', + chainId: payload.chainId, + shortName: 'matic', + nativeCurrency: { name: 'Matic', symbol: 'MATIC', decimals: 18 }, + } as sdk.ChainInfo, + ], + }) + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual({ + title: 'Matic received', + body: 'Safe 0x0000...0001 on Polygon received 1.0 MATIC in transaction 0x0000...0003.', + link: 'https://app.safe.global/transactions/history?safe=matic:0x0000000000000000000000000000000000000001', + }) + }) + + it('without chain info', async () => { + getChainsConfigSpy.mockImplementationOnce(() => Promise.reject()) // chains + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual({ + title: 'Ether received', + body: 'Safe 0x0000...0001 on chain 137 received 1.0 ETH in transaction 0x0000...0003.', + link: 'https://app.safe.global', + }) + }) + }) + + describe('should parse INCOMING_TOKEN payloads', () => { + const payload: IncomingTokenEvent = { + type: WebhookType.INCOMING_TOKEN, + chainId: '1', + address: hexZeroPad('0x1', 20), + tokenAddress: hexZeroPad('0x2', 20), + txHash: hexZeroPad('0x3', 32), + } + + const erc20Payload: IncomingTokenEvent = { + ...payload, + value: '1000000000000000000', + } + + it('with chain and token info', async () => { + getChainsConfigSpy.mockResolvedValue({ + results: [{ chainName: 'Mainnet', chainId: payload.chainId, shortName: 'eth' } as sdk.ChainInfo], + }) + getBalancesMockSpy.mockResolvedValue({ + items: [ + { + tokenInfo: { + address: payload.tokenAddress, + decimals: 18, + name: 'Fake', + symbol: 'FAKE', + }, + }, + ], + } as sdk.SafeBalanceResponse) + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual({ + title: 'Fake received', + body: 'Safe 0x0000...0001 on Mainnet received some FAKE in transaction 0x0000...0003.', + link: 'https://app.safe.global/transactions/history?safe=eth:0x0000000000000000000000000000000000000001', + }) + + getChainsConfigSpy.mockResolvedValue({ + results: [{ chainName: 'Mainnet', chainId: payload.chainId, shortName: 'eth' } as sdk.ChainInfo], + }) + getBalancesMockSpy.mockResolvedValue({ + items: [ + { + tokenInfo: { + address: payload.tokenAddress, + decimals: 18, + name: 'Fake', + symbol: 'FAKE', + }, + }, + ], + } as sdk.SafeBalanceResponse) + + const erc20Notification = await _parseServiceWorkerWebhookPushNotification(erc20Payload) + + expect(erc20Notification).toEqual({ + title: 'Fake received', + body: 'Safe 0x0000...0001 on Mainnet received 1.0 FAKE in transaction 0x0000...0003.', + link: 'https://app.safe.global/transactions/history?safe=eth:0x0000000000000000000000000000000000000001', + }) + }) + + it('without chain info', async () => { + getChainsConfigSpy.mockImplementation(() => Promise.reject()) // chains + getBalancesMockSpy.mockResolvedValue({ + items: [ + { + tokenInfo: { + address: payload.tokenAddress, + decimals: 18, + name: 'Fake', + symbol: 'FAKE', + }, + }, + ], + } as sdk.SafeBalanceResponse) + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual({ + title: 'Fake received', + body: 'Safe 0x0000...0001 on chain 1 received some FAKE in transaction 0x0000...0003.', + link: 'https://app.safe.global', + }) + + getChainsConfigSpy.mockImplementation(() => Promise.reject()) // chains + getBalancesMockSpy.mockResolvedValue({ + items: [ + { + tokenInfo: { + address: payload.tokenAddress, + decimals: 18, + name: 'Fake', + symbol: 'FAKE', + }, + }, + ], + } as sdk.SafeBalanceResponse) + + const erc20Notification = await _parseServiceWorkerWebhookPushNotification(erc20Payload) + + expect(erc20Notification).toEqual({ + title: 'Fake received', + body: 'Safe 0x0000...0001 on chain 1 received 1.0 FAKE in transaction 0x0000...0003.', + link: 'https://app.safe.global', + }) + }) + + it('without token info', async () => { + getChainsConfigSpy.mockResolvedValue({ + results: [{ chainName: 'Mainnet', chainId: payload.chainId, shortName: 'eth' } as sdk.ChainInfo], + }) + getBalancesMockSpy.mockImplementation(() => Promise.reject()) // tokens + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual({ + title: 'Token received', + body: 'Safe 0x0000...0001 on Mainnet received some tokens in transaction 0x0000...0003.', + link: 'https://app.safe.global/transactions/history?safe=eth:0x0000000000000000000000000000000000000001', + }) + + getChainsConfigSpy.mockResolvedValue({ + results: [{ chainName: 'Mainnet', chainId: payload.chainId, shortName: 'eth' } as sdk.ChainInfo], + }) + getBalancesMockSpy.mockImplementation(() => Promise.reject()) // tokens + + const erc20Notification = await _parseServiceWorkerWebhookPushNotification(erc20Payload) + + expect(erc20Notification).toEqual({ + title: 'Token received', + body: 'Safe 0x0000...0001 on Mainnet received some tokens in transaction 0x0000...0003.', + link: 'https://app.safe.global/transactions/history?safe=eth:0x0000000000000000000000000000000000000001', + }) + }) + + it('without chain and balance info', async () => { + getChainsConfigSpy.mockImplementation(() => Promise.reject()) // chains + getBalancesMockSpy.mockImplementation(() => Promise.reject()) // tokens + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual({ + title: 'Token received', + body: 'Safe 0x0000...0001 on chain 1 received some tokens in transaction 0x0000...0003.', + link: 'https://app.safe.global', + }) + + getChainsConfigSpy.mockImplementation(() => Promise.reject()) // chains + getBalancesMockSpy.mockImplementation(() => Promise.reject()) // tokens + + const erc20Notification = await _parseServiceWorkerWebhookPushNotification(erc20Payload) + + expect(erc20Notification).toEqual({ + title: 'Token received', + body: 'Safe 0x0000...0001 on chain 1 received some tokens in transaction 0x0000...0003.', + link: 'https://app.safe.global', + }) + }) + }) + + describe('should parse MODULE_TRANSACTION payloads', () => { + const payload: ModuleTransactionEvent = { + type: WebhookType.MODULE_TRANSACTION, + chainId: '1', + address: hexZeroPad('0x1', 20), + module: hexZeroPad('0x2', 20), + txHash: hexZeroPad('0x3', 32), + } + + it('with chain info', async () => { + getChainsConfigSpy.mockResolvedValue({ + results: [{ chainName: 'Mainnet', chainId: '1', shortName: 'eth' } as sdk.ChainInfo], + }) + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual({ + title: 'Module transaction', + body: 'Safe 0x0000...0001 on Mainnet executed a module transaction 0x0000...0003 from module 0x0000...0002.', + link: 'https://app.safe.global/transactions/history?safe=eth:0x0000000000000000000000000000000000000001', + }) + }) + + it('without chain info', async () => { + getChainsConfigSpy.mockImplementation(() => Promise.reject()) // chains + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual({ + title: 'Module transaction', + body: 'Safe 0x0000...0001 on chain 1 executed a module transaction 0x0000...0003 from module 0x0000...0002.', + link: 'https://app.safe.global', + }) + }) + }) + + describe('should parse CONFIRMATION_REQUEST payloads', () => { + const payload: ConfirmationRequestEvent = { + type: WebhookType.CONFIRMATION_REQUEST, + chainId: '1', + address: hexZeroPad('0x1', 20), + safeTxHash: hexZeroPad('0x3', 32), + } + + it('with chain info', async () => { + getChainsConfigSpy.mockResolvedValue({ + results: [{ chainName: 'Mainnet', chainId: '1', shortName: 'eth' } as sdk.ChainInfo], + }) + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual({ + title: 'Confirmation request', + body: 'Safe 0x0000...0001 on Mainnet has a new confirmation request for transaction 0x0000...0003.', + link: 'https://app.safe.global/transactions/tx?safe=eth:0x0000000000000000000000000000000000000001&id=0x0000000000000000000000000000000000000000000000000000000000000003', + }) + }) + + it('without chain info', async () => { + getChainsConfigSpy.mockImplementation(() => Promise.reject()) // chains + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual({ + title: 'Confirmation request', + body: 'Safe 0x0000...0001 on chain 1 has a new confirmation request for transaction 0x0000...0003.', + link: 'https://app.safe.global', + }) + }) + }) + + // We do not pre-emptively subscribe to Safes before they are created + describe('should not parse SAFE_CREATED payloads', () => { + const payload: SafeCreatedEvent = { + type: WebhookType.SAFE_CREATED, + chainId: '1', + address: hexZeroPad('0x1', 20), + txHash: hexZeroPad('0x3', 32), + blockNumber: '1', + } + it('with chain info', async () => { + getChainsConfigSpy.mockResolvedValue({ + results: [{ chainName: 'Mainnet', chainId: '1', shortName: 'eth' } as sdk.ChainInfo], + }) + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toBe(undefined) + }) + + it('without chain info', async () => { + getChainsConfigSpy.mockImplementation(() => Promise.reject()) // chains + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toBe(undefined) + }) + }) + + // Not enabled in the Transaction Service + describe('should not parse NEW_CONFIRMATION payloads', () => { + const payload: NewConfirmationEvent = { + type: WebhookType._NEW_CONFIRMATION, + chainId: '1', + address: hexZeroPad('0x1', 20), + owner: hexZeroPad('0x2', 20), + safeTxHash: hexZeroPad('0x3', 32), + } + + it('with chain info', async () => { + getChainsConfigSpy.mockResolvedValue({ + results: [{ chainName: 'Mainnet', chainId: payload.chainId, shortName: 'eth' } as sdk.ChainInfo], + }) + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual(undefined) + }) + + it('without chain info', async () => { + getChainsConfigSpy.mockImplementationOnce(() => Promise.reject()) // chains + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual(undefined) + }) + }) + + // Not enabled in the Transaction Service + describe('should not parse PENDING_MULTISIG_TRANSACTION payloads', () => { + const payload: PendingMultisigTransactionEvent = { + type: WebhookType._PENDING_MULTISIG_TRANSACTION, + chainId: '1', + address: hexZeroPad('0x1', 20), + safeTxHash: hexZeroPad('0x3', 32), + } + + it('with chain info', async () => { + getChainsConfigSpy.mockResolvedValue({ + results: [{ chainName: 'Mainnet', chainId: payload.chainId, shortName: 'eth' } as sdk.ChainInfo], + }) + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual(undefined) + }) + + it('without chain info', async () => { + getChainsConfigSpy.mockImplementationOnce(() => Promise.reject()) // chains + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual(undefined) + }) + }) + + // Not enabled in the Transaction Service + describe('should not parse OUTGOING_ETHER payloads', () => { + const payload: OutgoingEtherEvent = { + type: WebhookType._OUTGOING_ETHER, + chainId: '137', + address: hexZeroPad('0x1', 20), + txHash: hexZeroPad('0x3', 32), + value: '1000000000000000000', + } + + it('with chain info', async () => { + getChainsConfigSpy.mockResolvedValue({ + results: [ + { + chainName: 'Polygon', + chainId: payload.chainId, + shortName: 'matic', + nativeCurrency: { name: 'Matic', symbol: 'MATIC', decimals: 18 }, + } as sdk.ChainInfo, + ], + }) + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual(undefined) + }) + + it('without chain info', async () => { + getChainsConfigSpy.mockImplementationOnce(() => Promise.reject()) // chains + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual(undefined) + }) + }) + + // Not enabled in the Transaction Service + describe('should not parse OUTGOING_TOKEN payloads', () => { + const payload: OutgoingTokenEvent = { + type: WebhookType._OUTGOING_TOKEN, + chainId: '1', + address: hexZeroPad('0x1', 20), + tokenAddress: hexZeroPad('0x2', 20), + txHash: hexZeroPad('0x3', 32), + } + + const erc20Payload: OutgoingTokenEvent = { + ...payload, + value: '1000000000000000000', + } + + it('with chain and token info', async () => { + getChainsConfigSpy.mockResolvedValue({ + results: [{ chainName: 'Mainnet', chainId: payload.chainId, shortName: 'eth' } as sdk.ChainInfo], + }) + getBalancesMockSpy.mockResolvedValue({ + items: [ + { + tokenInfo: { + address: payload.tokenAddress, + decimals: 18, + name: 'Fake', + symbol: 'FAKE', + }, + }, + ], + } as sdk.SafeBalanceResponse) + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual(undefined) + + getChainsConfigSpy.mockResolvedValue({ + results: [{ chainName: 'Mainnet', chainId: payload.chainId, shortName: 'eth' } as sdk.ChainInfo], + }) + getBalancesMockSpy.mockResolvedValue({ + items: [ + { + tokenInfo: { + address: payload.tokenAddress, + decimals: 18, + name: 'Fake', + symbol: 'FAKE', + }, + }, + ], + } as sdk.SafeBalanceResponse) + + const erc20Notification = await _parseServiceWorkerWebhookPushNotification(erc20Payload) + + expect(erc20Notification).toEqual(undefined) + }) + + it('with chain and empty token info', async () => { + getChainsConfigSpy.mockResolvedValue({ + results: [{ chainName: 'Mainnet', chainId: payload.chainId, shortName: 'eth' } as sdk.ChainInfo], + }) + getBalancesMockSpy.mockResolvedValue({ + items: [] as sdk.SafeBalanceResponse['items'], // Transaction sent all of the tokens + } as sdk.SafeBalanceResponse) + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual(undefined) + + getChainsConfigSpy.mockResolvedValue({ + results: [{ chainName: 'Mainnet', chainId: payload.chainId, shortName: 'eth' } as sdk.ChainInfo], + }) + getBalancesMockSpy.mockResolvedValue({ + items: [ + { + tokenInfo: { + address: payload.tokenAddress, + decimals: 18, + name: 'Fake', + symbol: 'FAKE', + }, + }, + ], + } as sdk.SafeBalanceResponse) + + const erc20Notification = await _parseServiceWorkerWebhookPushNotification(erc20Payload) + + expect(erc20Notification).toEqual(undefined) + }) + + it('without chain info', async () => { + getChainsConfigSpy.mockImplementation(() => Promise.reject()) // chains + getBalancesMockSpy.mockResolvedValue({ + items: [ + { + tokenInfo: { + address: payload.tokenAddress, + decimals: 18, + name: 'Fake', + symbol: 'FAKE', + }, + }, + ], + } as sdk.SafeBalanceResponse) + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual(undefined) + + getChainsConfigSpy.mockImplementation(() => Promise.reject()) // chains + getBalancesMockSpy.mockResolvedValue({ + items: [ + { + tokenInfo: { + address: payload.tokenAddress, + decimals: 18, + name: 'Fake', + symbol: 'FAKE', + }, + }, + ], + } as sdk.SafeBalanceResponse) + + const erc20Notification = await _parseServiceWorkerWebhookPushNotification(erc20Payload) + + expect(erc20Notification).toEqual(undefined) + }) + + it('without token info', async () => { + getChainsConfigSpy.mockResolvedValue({ + results: [{ chainName: 'Mainnet', chainId: payload.chainId, shortName: 'eth' } as sdk.ChainInfo], + }) + getBalancesMockSpy.mockImplementation(() => Promise.reject()) // tokens + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual(undefined) + + getChainsConfigSpy.mockResolvedValue({ + results: [{ chainName: 'Mainnet', chainId: payload.chainId, shortName: 'eth' } as sdk.ChainInfo], + }) + getBalancesMockSpy.mockImplementation(() => Promise.reject()) // tokens + + const erc20Notification = await _parseServiceWorkerWebhookPushNotification(erc20Payload) + + expect(erc20Notification).toEqual(undefined) + }) + + it('without chain and balance info', async () => { + getChainsConfigSpy.mockImplementation(() => Promise.reject()) // chains + getBalancesMockSpy.mockImplementation(() => Promise.reject()) // tokens + + const notification = await _parseServiceWorkerWebhookPushNotification(payload) + + expect(notification).toEqual(undefined) + + getChainsConfigSpy.mockImplementation(() => Promise.reject()) // chains + getBalancesMockSpy.mockImplementation(() => Promise.reject()) // tokens + + const erc20Notification = await _parseServiceWorkerWebhookPushNotification(erc20Payload) + + expect(erc20Notification).toEqual(undefined) + }) + }) +}) diff --git a/src/service-workers/firebase-messaging/firebase-messaging-sw.ts b/src/service-workers/firebase-messaging/firebase-messaging-sw.ts new file mode 100644 index 0000000000..e7c59aa109 --- /dev/null +++ b/src/service-workers/firebase-messaging/firebase-messaging-sw.ts @@ -0,0 +1,74 @@ +// Be careful what you import here as it will increase the service worker bundle size + +/// + +import { getMessaging, onBackgroundMessage } from 'firebase/messaging/sw' +import type { MessagePayload } from 'firebase/messaging/sw' + +import { initializeFirebaseApp } from '@/services/push-notifications/firebase' +import { + shouldShowServiceWorkerPushNotification, + parseServiceWorkerPushNotification, +} from '@/service-workers/firebase-messaging/notifications' +import { cacheServiceWorkerPushNotificationTrackingEvent } from '@/services/push-notifications/tracking' + +declare const self: ServiceWorkerGlobalScope + +type NotificationData = MessagePayload['data'] & { + link: string +} + +export function firebaseMessagingSw() { + const ICON_PATH = '/images/safe-logo-green.png' + + const app = initializeFirebaseApp() + + if (!app) { + return + } + + // Must be called before `onBackgroundMessage` as Firebase embeds a `notificationclick` listener + self.addEventListener( + 'notificationclick', + (event) => { + event.notification.close() + + const data: NotificationData = event.notification.data + + cacheServiceWorkerPushNotificationTrackingEvent('opened', data) + + self.clients.openWindow(data.link) + }, + false, + ) + + const messaging = getMessaging(app) + + onBackgroundMessage(messaging, async (payload) => { + const shouldShow = await shouldShowServiceWorkerPushNotification(payload) + + if (!shouldShow) { + return + } + + const notification = await parseServiceWorkerPushNotification(payload) + + if (!notification) { + return + } + + const data: NotificationData = { + ...payload.data, + link: notification.link ?? self.location.origin, + } + + cacheServiceWorkerPushNotificationTrackingEvent('shown', data) + + self.registration.showNotification(notification.title || '', { + icon: ICON_PATH, + body: notification.body, + image: notification.image, + data, + }) + }) +} diff --git a/src/service-workers/firebase-messaging/notification-mapper.ts b/src/service-workers/firebase-messaging/notification-mapper.ts new file mode 100644 index 0000000000..e5c8773ab0 --- /dev/null +++ b/src/service-workers/firebase-messaging/notification-mapper.ts @@ -0,0 +1,146 @@ +// Be careful what you import here as it will increase the service worker bundle size + +import { formatUnits } from '@ethersproject/units' // Increases bundle significantly but unavoidable +import { getBalances } from '@safe-global/safe-gateway-typescript-sdk' +import type { ChainInfo, TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' + +import { WebhookType } from './webhook-types' +import type { WebhookEvent } from './webhook-types' + +type PushNotificationsMap = { + [P in T['type']]: ( + data: Extract, + chain?: ChainInfo, + ) => Promise<{ title: string; body: string }> | { title: string; body: string } | null +} + +const getChainName = (chainId: string, chain?: ChainInfo): string => { + return chain?.chainName ?? `chain ${chainId}` +} + +const getCurrencyName = (chain?: ChainInfo): string => { + return chain?.nativeCurrency?.name ?? 'Ether' +} + +const getCurrencySymbol = (chain?: ChainInfo): string => { + return chain?.nativeCurrency?.symbol ?? 'ETH' +} + +const getTokenInfo = async ( + chainId: string, + safeAddress: string, + tokenAddress: string, + tokenValue?: string, +): Promise<{ symbol: string; value: string; name: string }> => { + const DEFAULT_CURRENCY = 'USD' + + const DEFAULT_INFO = { + symbol: 'tokens', + value: 'some', + name: 'Token', + } + + let tokenInfo: TokenInfo | undefined + + try { + const balances = await getBalances(chainId, safeAddress, DEFAULT_CURRENCY) + tokenInfo = balances.items.find((token) => token.tokenInfo.address === tokenAddress)?.tokenInfo + } catch { + // Swallow error + } + + if (!tokenInfo) { + return DEFAULT_INFO + } + + const symbol = tokenInfo?.symbol ?? DEFAULT_INFO.symbol + const value = tokenValue && tokenInfo ? formatUnits(tokenValue, tokenInfo.decimals).toString() : DEFAULT_INFO.value + const name = tokenInfo?.name ?? DEFAULT_INFO.name + + return { + symbol, + value, + name, + } +} + +const shortenAddress = (address: string, length = 4): string => { + if (!address) { + return '' + } + + return `${address.slice(0, length + 2)}...${address.slice(-length)}` +} + +export const Notifications: PushNotificationsMap = { + [WebhookType.EXECUTED_MULTISIG_TRANSACTION]: ({ address, failed, txHash, chainId }, chain) => { + const didFail = failed === 'true' + return { + title: `Transaction ${didFail ? 'failed' : 'executed'}`, + body: `Safe ${shortenAddress(address)} on ${getChainName(chainId, chain)} ${ + didFail ? 'failed to execute' : 'executed' + } transaction ${shortenAddress(txHash)}.`, + } + }, + [WebhookType.INCOMING_ETHER]: ({ address, txHash, value, chainId }, chain) => { + return { + title: `${getCurrencyName(chain)} received`, + body: `Safe ${shortenAddress(address)} on ${getChainName(chainId, chain)} received ${formatUnits( + value, + chain?.nativeCurrency?.decimals, + ).toString()} ${getCurrencySymbol(chain)} in transaction ${shortenAddress(txHash)}.`, + } + }, + [WebhookType.INCOMING_TOKEN]: async ({ address, txHash, tokenAddress, value, chainId }, chain) => { + const token = await getTokenInfo(chainId, address, tokenAddress, value) + return { + title: `${token.name} received`, + body: `Safe ${shortenAddress(address)} on ${getChainName(chainId, chain)} received ${token.value} ${ + token.symbol + } in transaction ${shortenAddress(txHash)}.`, + } + }, + [WebhookType.MODULE_TRANSACTION]: ({ address, module, txHash, chainId }, chain) => { + return { + title: 'Module transaction', + body: `Safe ${shortenAddress(address)} on ${getChainName( + chainId, + chain, + )} executed a module transaction ${shortenAddress(txHash)} from module ${shortenAddress(module)}.`, + } + }, + [WebhookType.CONFIRMATION_REQUEST]: ({ address, safeTxHash, chainId }, chain) => { + return { + title: 'Confirmation request', + body: `Safe ${shortenAddress(address)} on ${getChainName( + chainId, + chain, + )} has a new confirmation request for transaction ${shortenAddress(safeTxHash)}.`, + } + }, + [WebhookType.SAFE_CREATED]: () => { + // We do not preemptively subscribe to Safes before they are created + return null + }, + // Disabled on the Transaction Service + [WebhookType._PENDING_MULTISIG_TRANSACTION]: () => { + // We don't send notifications for pending transactions + // @see https://github.com/safe-global/safe-transaction-service/blob/master/safe_transaction_service/notifications/tasks.py#L34 + return null + }, + [WebhookType._NEW_CONFIRMATION]: () => { + // Disabled for now + // @see https://github.com/safe-global/safe-transaction-service/blob/master/safe_transaction_service/notifications/tasks.py#L43 + return null + }, + [WebhookType._OUTGOING_TOKEN]: () => { + // We don't sen as we have execution notifications + // @see https://github.com/safe-global/safe-transaction-service/blob/master/safe_transaction_service/notifications/tasks.py#L48 + return null + }, + [WebhookType._OUTGOING_ETHER]: () => { + // We don't sen as we have execution notifications + // @see https://github.com/safe-global/safe-transaction-service/blob/master/safe_transaction_service/notifications/tasks.py#L48 + return null + }, +} diff --git a/src/service-workers/firebase-messaging/notifications.ts b/src/service-workers/firebase-messaging/notifications.ts new file mode 100644 index 0000000000..c447632b1b --- /dev/null +++ b/src/service-workers/firebase-messaging/notifications.ts @@ -0,0 +1,94 @@ +// Be careful what you import here as it will increase the service worker bundle size + +import { get as getFromIndexedDb } from 'idb-keyval' +import { getChainsConfig, setBaseUrl } from '@safe-global/safe-gateway-typescript-sdk' +import type { MessagePayload } from 'firebase/messaging' + +import { AppRoutes } from '@/config/routes' // Has no internal imports +import { isWebhookEvent } from './webhook-types' +import { + getPushNotificationPrefsKey, + createPushNotificationPrefsIndexedDb, +} from '@/services/push-notifications/preferences' +import { FIREBASE_IS_PRODUCTION } from '@/services/push-notifications/firebase' +import { Notifications } from './notification-mapper' +import type { WebhookEvent } from './webhook-types' +import type { PushNotificationPreferences, PushNotificationPrefsKey } from '@/services/push-notifications/preferences' + +const GATEWAY_URL_PRODUCTION = process.env.NEXT_PUBLIC_GATEWAY_URL_PRODUCTION || 'https://safe-client.safe.global' +const GATEWAY_URL_STAGING = process.env.NEXT_PUBLIC_GATEWAY_URL_STAGING || 'https://safe-client.staging.5afe.dev' + +// localStorage cannot be accessed in service workers so we reference the flag from the environment +const GATEWAY_URL = FIREBASE_IS_PRODUCTION ? GATEWAY_URL_PRODUCTION : GATEWAY_URL_STAGING + +setBaseUrl(GATEWAY_URL) + +export const shouldShowServiceWorkerPushNotification = async (payload: MessagePayload): Promise => { + if (!isWebhookEvent(payload.data)) { + return true + } + + const { chainId, address, type } = payload.data + + const key = getPushNotificationPrefsKey(chainId, address) + const store = createPushNotificationPrefsIndexedDb() + + const preferencesStore = await getFromIndexedDb( + key, + store, + ).catch(() => null) + + if (!preferencesStore) { + return false + } + + return preferencesStore.preferences[type] +} + +const getLink = (data: WebhookEvent, shortName?: string) => { + const URL = self.location.origin + + if (!shortName) { + return URL + } + + const withRoute = (route: string) => { + return `${URL}${route}?safe=${shortName}:${data.address}` + } + + if ('safeTxHash' in data) { + return `${withRoute(AppRoutes.transactions.tx)}&id=${data.safeTxHash}` + } + + return withRoute(AppRoutes.transactions.history) +} + +export const _parseServiceWorkerWebhookPushNotification = async ( + data: WebhookEvent, +): Promise<{ title: string; body: string; link: string } | undefined> => { + const chain = await getChainsConfig() + .then(({ results }) => results.find((chain) => chain.chainId === data.chainId)) + .catch(() => undefined) + + // Can be safely casted as `data.type` is a mapped type of `NotificationsMap` + const notification = await Notifications[data.type](data as any, chain) + + if (notification) { + return { + ...notification, + link: getLink(data, chain?.shortName), + } + } +} + +export const parseServiceWorkerPushNotification = async ( + payload: MessagePayload, +): Promise<({ title?: string; link?: string } & NotificationOptions) | undefined> => { + // Manually dispatched notifications from the Firebase admin panel; displayed as is + if (!isWebhookEvent(payload.data)) { + return payload.notification + } + + // Transaction Service-dispatched notification + return _parseServiceWorkerWebhookPushNotification(payload.data) +} diff --git a/src/service-workers/firebase-messaging/webhook-types.ts b/src/service-workers/firebase-messaging/webhook-types.ts new file mode 100644 index 0000000000..c774a0f60a --- /dev/null +++ b/src/service-workers/firebase-messaging/webhook-types.ts @@ -0,0 +1,114 @@ +// Be careful what you import here as it will increase the service worker bundle size + +import type { MessagePayload } from 'firebase/messaging' + +export const isWebhookEvent = (data: MessagePayload['data']): data is WebhookEvent => { + return Object.values(WebhookType).some((type) => type === data?.type) +} + +export enum WebhookType { + EXECUTED_MULTISIG_TRANSACTION = 'EXECUTED_MULTISIG_TRANSACTION', + INCOMING_ETHER = 'INCOMING_ETHER', + INCOMING_TOKEN = 'INCOMING_TOKEN', + MODULE_TRANSACTION = 'MODULE_TRANSACTION', + CONFIRMATION_REQUEST = 'CONFIRMATION_REQUEST', // Notification-specific webhook + SAFE_CREATED = 'SAFE_CREATED', + // Disabled on the Transaction Service + _PENDING_MULTISIG_TRANSACTION = 'PENDING_MULTISIG_TRANSACTION', + _NEW_CONFIRMATION = 'NEW_CONFIRMATION', + _OUTGOING_ETHER = 'OUTGOING_ETHER', + _OUTGOING_TOKEN = 'OUTGOING_TOKEN', +} + +export type PendingMultisigTransactionEvent = { + type: WebhookType._PENDING_MULTISIG_TRANSACTION + chainId: string + address: string + safeTxHash: string +} + +export type NewConfirmationEvent = { + type: WebhookType._NEW_CONFIRMATION + chainId: string + address: string + owner: string + safeTxHash: string +} + +export type OutgoingEtherEvent = { + type: WebhookType._OUTGOING_ETHER + chainId: string + address: string + txHash: string + value: string +} + +export type OutgoingTokenEvent = { + type: WebhookType._OUTGOING_TOKEN + chainId: string + address: string + tokenAddress: string + txHash: string + value?: string // If ERC-20 token +} + +export type ExecutedMultisigTransactionEvent = { + type: WebhookType.EXECUTED_MULTISIG_TRANSACTION + chainId: string + address: string + safeTxHash: string + failed: 'true' | 'false' + txHash: string +} + +export type IncomingEtherEvent = { + type: WebhookType.INCOMING_ETHER + chainId: string + address: string + txHash: string + value: string +} + +export type IncomingTokenEvent = { + type: WebhookType.INCOMING_TOKEN + chainId: string + address: string + tokenAddress: string + txHash: string + value?: string // If ERC-20 token +} + +export type ModuleTransactionEvent = { + type: WebhookType.MODULE_TRANSACTION + chainId: string + address: string + module: string + txHash: string +} + +export type ConfirmationRequestEvent = { + type: WebhookType.CONFIRMATION_REQUEST + chainId: string + address: string + safeTxHash: string +} + +export type SafeCreatedEvent = { + type: WebhookType.SAFE_CREATED + chainId: string + address: string + txHash: string + blockNumber: string +} + +export type WebhookEvent = + | NewConfirmationEvent + | ExecutedMultisigTransactionEvent + | PendingMultisigTransactionEvent + | IncomingEtherEvent + | OutgoingEtherEvent + | IncomingTokenEvent + | OutgoingTokenEvent + | ModuleTransactionEvent + | ConfirmationRequestEvent + | SafeCreatedEvent diff --git a/src/service-workers/index.ts b/src/service-workers/index.ts new file mode 100644 index 0000000000..0ed829a135 --- /dev/null +++ b/src/service-workers/index.ts @@ -0,0 +1,7 @@ +// Be careful what you import here as it will increase the service worker bundle size + +/// + +import { firebaseMessagingSw } from './firebase-messaging/firebase-messaging-sw' + +firebaseMessagingSw() diff --git a/src/services/analytics/events/push-notifications.ts b/src/services/analytics/events/push-notifications.ts new file mode 100644 index 0000000000..08e6a89a3b --- /dev/null +++ b/src/services/analytics/events/push-notifications.ts @@ -0,0 +1,81 @@ +export const category = 'push-notifications' + +export const PUSH_NOTIFICATION_EVENTS = { + // Browser notification shown to user + SHOW_NOTIFICATION: { + action: 'Show notification', + category, + }, + // User opened on notification + OPEN_NOTIFICATION: { + action: 'Open notification', + category, + }, + // User registered Safe(s) for notifications + REGISTER_SAFES: { + action: 'Register Safe(s) notifications', + category, + }, + // User unregistered Safe from notifications + UNREGISTER_SAFE: { + action: 'Unregister Safe notifications', + category, + }, + // User unregistered device from notifications + UNREGISTER_DEVICE: { + action: 'Unregister device notifications', + category, + }, + // Notification banner displayed + DISPLAY_BANNER: { + action: 'Display notification banner', + category, + }, + // User dismissed notfication banner + DISMISS_BANNER: { + action: 'Dismiss notification banner', + category, + }, + // User enabled all notifications from banner + ENABLE_ALL: { + action: 'Enable all notifications', + category, + }, + // User opened Safe notification settings from banner + CUSTOMIZE_SETTINGS: { + action: 'Customize notifications', + category, + }, + // User turned notifications on for a Safe from settings + ENABLE_SAFE: { + action: 'Turn notifications on', + category, + }, + // User turned notifications off for a Safe from settings + DISABLE_SAFE: { + action: 'Turn notifications off', + category, + }, + // Save button clicked in global notification settings + SAVE_SETTINGS: { + action: 'Save notification settings', + category, + }, + // User changed the incoming transactions notifications setting + // (incoming native currency/tokens) + TOGGLE_INCOMING_TXS: { + action: 'Toggle incoming transactions notifications', + category, + }, + // User changed the outgoing transactions notifications setting + // (module/executed multisig transactions) + TOGGLE_OUTGOING_TXS: { + action: 'Toggle outgoing assets notifications', + category, + }, + // User changed the confirmation request notifications setting + TOGGLE_CONFIRMATION_REQUEST: { + action: 'Toggle confirmation request notifications', + category, + }, +} diff --git a/src/services/analytics/events/txList.ts b/src/services/analytics/events/txList.ts index b2cb84766e..bff4e34bd8 100644 --- a/src/services/analytics/events/txList.ts +++ b/src/services/analytics/events/txList.ts @@ -40,6 +40,10 @@ export const TX_LIST_EVENTS = { action: 'Batch Execute', category: TX_LIST_CATEGORY, }, + FETCH_DETAILS: { + action: 'Fetch transaction details', + category: TX_LIST_CATEGORY, + }, } export const MESSAGE_EVENTS = { diff --git a/src/services/analytics/gtm.ts b/src/services/analytics/gtm.ts index c1d59fb67c..fe77d3b26c 100644 --- a/src/services/analytics/gtm.ts +++ b/src/services/analytics/gtm.ts @@ -21,6 +21,7 @@ import { EventType, DeviceType } from './types' import { SAFE_APPS_SDK_CATEGORY } from './events' import { getAbTest } from '../tracking/abTesting' import type { AbTest } from '../tracking/abTesting' +import { AppRoutes } from '@/config/routes' type GTMEnvironment = 'LIVE' | 'LATEST' | 'DEVELOPMENT' type GTMEnvironmentArgs = Required> @@ -109,6 +110,7 @@ export const gtmTrack = (eventData: AnalyticsEvent): void => { event: eventData.event || EventType.CLICK, eventCategory: eventData.category, eventAction: eventData.action, + chainId: eventData.chainId || commonEventParams.chainId, } if (eventData.event) { @@ -154,6 +156,10 @@ export const normalizeAppName = (appName?: string): string => { } export const gtmTrackSafeApp = (eventData: AnalyticsEvent, appName?: string, sdkEventData?: SafeAppSDKEvent): void => { + if (!location.pathname.startsWith(AppRoutes.apps.index)) { + return + } + const safeAppGtmEvent: SafeAppGtmEvent = { ...commonEventParams, event: EventType.SAFE_APP, diff --git a/src/services/analytics/types.ts b/src/services/analytics/types.ts index 62e43d6d50..b2df3d6c6e 100644 --- a/src/services/analytics/types.ts +++ b/src/services/analytics/types.ts @@ -15,6 +15,7 @@ export type AnalyticsEvent = { category: string action: string label?: EventLabel + chainId?: string } export type SafeAppSDKEvent = { diff --git a/src/services/analytics/useGtm.ts b/src/services/analytics/useGtm.ts index 5f5164c1e5..d55ee9da60 100644 --- a/src/services/analytics/useGtm.ts +++ b/src/services/analytics/useGtm.ts @@ -71,8 +71,7 @@ const useGtm = () => { gtmSetSafeAddress(safeAddress) }, [safeAddress]) - // Track page views – anononimized by default. - // Sensitive info, like the safe address or tx id, is always in the query string, which we DO NOT track. + // Track page views – anonymized by default. useEffect(() => { // Don't track 404 because it's not a real page, it immediately does a client-side redirect if (router.pathname === AppRoutes['404']) return diff --git a/src/services/contracts/__tests__/safeContracts.test.ts b/src/services/contracts/__tests__/safeContracts.test.ts index 729baad7bf..2ae282a360 100644 --- a/src/services/contracts/__tests__/safeContracts.test.ts +++ b/src/services/contracts/__tests__/safeContracts.test.ts @@ -1,5 +1,5 @@ import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' -import { _getValidatedGetContractProps, isValidMasterCopy } from '../safeContracts' +import { _getValidatedGetContractProps, isValidMasterCopy, _getMinimumMultiSendCallOnlyVersion } from '../safeContracts' describe('safeContracts', () => { describe('isValidMasterCopy', () => { @@ -53,4 +53,18 @@ describe('safeContracts', () => { expect(() => _getValidatedGetContractProps('1', '')).toThrow(' is not a valid Safe Account version') }) }) + + describe('_getMinimumMultiSendCallOnlyVersion', () => { + it('should return the initial version if the Safe version is null', () => { + expect(_getMinimumMultiSendCallOnlyVersion(null)).toBe('1.3.0') + }) + + it('should return the initial version if the Safe version is lower than the initial version', () => { + expect(_getMinimumMultiSendCallOnlyVersion('1.0.0')).toBe('1.3.0') + }) + + it('should return the Safe version if the Safe version is higher than the initial version', () => { + expect(_getMinimumMultiSendCallOnlyVersion('1.4.1')).toBe('1.4.1') + }) + }) }) diff --git a/src/services/contracts/safeContracts.ts b/src/services/contracts/safeContracts.ts index 5da8cdd528..ada870c7db 100644 --- a/src/services/contracts/safeContracts.ts +++ b/src/services/contracts/safeContracts.ts @@ -15,6 +15,7 @@ import type CompatibilityFallbackHandlerEthersContract from '@safe-global/safe-e import type { Web3Provider } from '@ethersproject/providers' import type GnosisSafeContractEthers from '@safe-global/safe-ethers-lib/dist/src/contracts/GnosisSafe/GnosisSafeContractEthers' import type EthersAdapter from '@safe-global/safe-ethers-lib' +import semver from 'semver' // `UNKNOWN` is returned if the mastercopy does not match supported ones // @see https://github.com/safe-global/safe-client-gateway/blob/main/src/routes/safes/handlers/safes.rs#L28-L31 @@ -69,24 +70,36 @@ export const getReadOnlyGnosisSafeContract = (chain: ChainInfo, safeVersion: str // MultiSend +export const _getMinimumMultiSendCallOnlyVersion = (safeVersion: SafeInfo['version']) => { + const INITIAL_CALL_ONLY_VERSION = '1.3.0' + + if (!safeVersion) { + return INITIAL_CALL_ONLY_VERSION + } + + return semver.gte(safeVersion, INITIAL_CALL_ONLY_VERSION) ? safeVersion : INITIAL_CALL_ONLY_VERSION +} + export const getMultiSendCallOnlyContract = ( chainId: string, safeVersion: SafeInfo['version'], provider: Web3Provider, ) => { const ethAdapter = createEthersAdapter(provider) + const multiSendVersion = _getMinimumMultiSendCallOnlyVersion(safeVersion) return ethAdapter.getMultiSendCallOnlyContract({ - singletonDeployment: getMultiSendCallOnlyContractDeployment(chainId, safeVersion), + singletonDeployment: getMultiSendCallOnlyContractDeployment(chainId, multiSendVersion), ..._getValidatedGetContractProps(chainId, safeVersion), }) } export const getReadOnlyMultiSendCallOnlyContract = (chainId: string, safeVersion: SafeInfo['version']) => { const ethAdapter = createReadOnlyEthersAdapter() + const multiSendVersion = _getMinimumMultiSendCallOnlyVersion(safeVersion) return ethAdapter.getMultiSendCallOnlyContract({ - singletonDeployment: getMultiSendCallOnlyContractDeployment(chainId, safeVersion), + singletonDeployment: getMultiSendCallOnlyContractDeployment(chainId, multiSendVersion), ..._getValidatedGetContractProps(chainId, safeVersion), }) } diff --git a/src/services/exceptions/ErrorCodes.ts b/src/services/exceptions/ErrorCodes.ts index 61ea99456c..d3ef38f305 100644 --- a/src/services/exceptions/ErrorCodes.ts +++ b/src/services/exceptions/ErrorCodes.ts @@ -17,6 +17,9 @@ enum ErrorCodes { _302 = '302: Error connecting to the wallet', _303 = '303: Error creating pairing session', + _400 = '400: Error requesting browser notification permissions', + _401 = '401: Error tracking push notifications', + _600 = '600: Error fetching Safe info', _601 = '601: Error fetching balances', _602 = '602: Error fetching history txs', @@ -35,12 +38,15 @@ enum ErrorCodes { _630 = '630: Error fetching remaining hourly relays', _631 = '631: Transaction failed to be relayed', _632 = '632: Error fetching relay task status', + _633 = '633: Notification (un-)registration failed', _700 = '700: Failed to read from local/session storage', _701 = '701: Failed to write to local/session storage', _702 = '702: Failed to remove from local/session storage', _703 = '703: Error importing an address book', _704 = '704: Error importing global data', + _705 = '705: Failed to read from IndexedDB', + _706 = '706: Failed to write to IndexedDB', _800 = '800: Safe creation tx failed', _801 = '801: Failed to send a tx with a spending limit', diff --git a/src/services/pairing/QRModal.tsx b/src/services/pairing/QRModal.tsx index df2e3285eb..b363838046 100644 --- a/src/services/pairing/QRModal.tsx +++ b/src/services/pairing/QRModal.tsx @@ -8,6 +8,7 @@ import { StoreHydrator } from '@/store' import { AppProviders } from '@/pages/_app' import { PAIRING_MODULE_LABEL } from '@/services/pairing/module' import css from './styles.module.css' +import PairingDeprecationWarning from '@/components/common/PairingDetails/PairingDeprecationWarning' const WRAPPER_ID = 'safe-mobile-qr-modal' const QR_CODE_SIZE = 200 @@ -75,6 +76,7 @@ const Modal = ({ uri, cb }: { uri: string; cb: () => void }) => { +
diff --git a/src/services/push-notifications/firebase.ts b/src/services/push-notifications/firebase.ts new file mode 100644 index 0000000000..909fa8209d --- /dev/null +++ b/src/services/push-notifications/firebase.ts @@ -0,0 +1,38 @@ +// Be careful what you import here as it will increase the service worker bundle size + +import { initializeApp } from 'firebase/app' +import type { FirebaseApp, FirebaseOptions } from 'firebase/app' + +export const FIREBASE_IS_PRODUCTION = process.env.NEXT_PUBLIC_IS_PRODUCTION === 'true' + +const FIREBASE_VALID_KEY_PRODUCTION = process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY_PRODUCTION || '' +const FIREBASE_VALID_KEY_STAGING = process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY_STAGING +export const FIREBASE_VAPID_KEY = FIREBASE_IS_PRODUCTION ? FIREBASE_VALID_KEY_PRODUCTION : FIREBASE_VALID_KEY_STAGING + +export const FIREBASE_OPTIONS: FirebaseOptions = (() => { + const FIREBASE_OPTIONS_PRODUCTION = process.env.NEXT_PUBLIC_FIREBASE_OPTIONS_PRODUCTION || '' + const FIREBASE_OPTIONS_STAGING = process.env.NEXT_PUBLIC_FIREBASE_OPTIONS_STAGING || '' + try { + return JSON.parse(FIREBASE_IS_PRODUCTION ? FIREBASE_OPTIONS_PRODUCTION : FIREBASE_OPTIONS_STAGING) + } catch { + return {} + } +})() + +export const initializeFirebaseApp = () => { + const hasFirebaseOptions = Object.values(FIREBASE_OPTIONS).every(Boolean) + + if (!hasFirebaseOptions) { + return + } + + let app: FirebaseApp | undefined + + try { + app = initializeApp(FIREBASE_OPTIONS) + } catch (e) { + console.error('[Firebase] Initialization failed', e) + } + + return app +} diff --git a/src/services/push-notifications/preferences.ts b/src/services/push-notifications/preferences.ts new file mode 100644 index 0000000000..fbc96cb23a --- /dev/null +++ b/src/services/push-notifications/preferences.ts @@ -0,0 +1,33 @@ +// Be careful what you import here as it will increase the service worker bundle size + +import { createStore as createIndexedDb } from 'idb-keyval' + +import type { WebhookType } from '@/service-workers/firebase-messaging/webhook-types' + +export type PushNotificationPrefsKey = `${string}:${string}` + +export type PushNotificationPreferences = { + [safeKey: PushNotificationPrefsKey]: { + chainId: string + safeAddress: string + preferences: { [key in WebhookType]: boolean } + } +} + +export const getPushNotificationPrefsKey = (chainId: string, safeAddress: string): PushNotificationPrefsKey => { + return `${chainId}:${safeAddress}` +} + +export const createPushNotificationUuidIndexedDb = () => { + const DB_NAME = 'notifications-uuid-database' + const STORE_NAME = 'notifications-uuid-store' + + return createIndexedDb(DB_NAME, STORE_NAME) +} + +export const createPushNotificationPrefsIndexedDb = () => { + const DB_NAME = 'notifications-preferences-database' + const STORE_NAME = 'notifications-preferences-store' + + return createIndexedDb(DB_NAME, STORE_NAME) +} diff --git a/src/services/push-notifications/tracking.ts b/src/services/push-notifications/tracking.ts new file mode 100644 index 0000000000..3e695bfd4e --- /dev/null +++ b/src/services/push-notifications/tracking.ts @@ -0,0 +1,71 @@ +// Be careful what you import here as it will increase the service worker bundle size + +import { createStore as createIndexedDb, update as updateIndexedDb } from 'idb-keyval' +import type { MessagePayload } from 'firebase/messaging/sw' + +import { isWebhookEvent, WebhookType } from '@/service-workers/firebase-messaging/webhook-types' + +export type NotificationTrackingKey = `${string}:${WebhookType}` + +export type NotificationTracking = { + [chainKey: NotificationTrackingKey]: { + shown: number + opened: number + } +} + +export const getNotificationTrackingKey = (chainId: string, type: WebhookType): NotificationTrackingKey => { + return `${chainId}:${type}` +} + +export const parseNotificationTrackingKey = (key: string): { chainId: string; type: WebhookType } => { + const [chainId, type] = key.split(':') as [string, WebhookType] + + if (!Object.values(WebhookType).includes(type)) { + throw new Error(`Invalid notification tracking key: ${key}`) + } + + return { + chainId, + type: type as WebhookType, + } +} + +export const createNotificationTrackingIndexedDb = () => { + const DB_NAME = 'notifications-tracking-database' + const STORE_NAME = 'notifications-tracking-store' + + return createIndexedDb(DB_NAME, STORE_NAME) +} + +export const DEFAULT_WEBHOOK_TRACKING: NotificationTracking[NotificationTrackingKey] = { + shown: 0, + opened: 0, +} + +export const cacheServiceWorkerPushNotificationTrackingEvent = ( + property: keyof NotificationTracking[NotificationTrackingKey], + data: MessagePayload['data'], +) => { + if (!isWebhookEvent(data)) { + return + } + + const key = getNotificationTrackingKey(data.chainId, data.type) + const store = createNotificationTrackingIndexedDb() + + updateIndexedDb( + key, + (notificationCount) => { + if (notificationCount) { + return { + ...notificationCount, + [property]: (notificationCount[property] ?? 0) + 1, + } + } + + return DEFAULT_WEBHOOK_TRACKING + }, + store, + ).catch(() => null) +} diff --git a/src/services/tracking/abTesting.ts b/src/services/tracking/abTesting.ts index 60bdc34e31..aae072fac1 100644 --- a/src/services/tracking/abTesting.ts +++ b/src/services/tracking/abTesting.ts @@ -1,7 +1,9 @@ /** * Holds current A/B test identifiers. */ -export const enum AbTest {} +export const enum AbTest { + HUMAN_DESCRIPTION = 'human-readable', +} let _abTest: AbTest | null = null diff --git a/src/tests/pages/apps-share.test.tsx b/src/tests/pages/apps-share.test.tsx index 598c6bb95b..383ca58183 100644 --- a/src/tests/pages/apps-share.test.tsx +++ b/src/tests/pages/apps-share.test.tsx @@ -4,16 +4,67 @@ import ShareSafeApp from '@/pages/share/safe-app' import { CONFIG_SERVICE_CHAINS } from '@/tests/mocks/chains' import * as useWalletHook from '@/hooks/wallets/useWallet' import * as useOwnedSafesHook from '@/hooks/useOwnedSafes' +import * as manifest from '@/services/safe-apps/manifest' +import * as sdk from '@safe-global/safe-gateway-typescript-sdk' import crypto from 'crypto' import type { EIP1193Provider } from '@web3-onboard/core' -const FETCH_TIMEOUT = 5000 const TX_BUILDER = 'https://apps-portal.safe.global/tx-builder' describe('Share Safe App Page', () => { + let fetchSafeAppFromManifestSpy: jest.SpyInstance> + let getSafeAppsSpy: jest.SpyInstance> + beforeEach(() => { jest.restoreAllMocks() window.localStorage.clear() + + fetchSafeAppFromManifestSpy = jest.spyOn(manifest, 'fetchSafeAppFromManifest').mockResolvedValue({ + id: Math.random(), + url: TX_BUILDER, + name: 'Transaction Builder', + description: 'A Safe app to compose custom transactions', + accessControl: { type: sdk.SafeAppAccessPolicyTypes.NoRestrictions }, + tags: [], + features: [], + socialProfiles: [], + developerWebsite: '', + chainIds: ['1'], + iconUrl: `${TX_BUILDER}/tx-builder.png`, + safeAppsPermissions: [], + }) + + getSafeAppsSpy = jest.spyOn(sdk, 'getSafeApps').mockResolvedValue([ + { + id: 29, + url: TX_BUILDER, + name: 'Transaction Builder', + iconUrl: `${TX_BUILDER}/tx-builder.png`, + description: 'Compose custom contract interactions and batch them into a single transaction', + chainIds: ['1'], + provider: undefined, + accessControl: { + type: sdk.SafeAppAccessPolicyTypes.NoRestrictions, + }, + tags: ['dashboard-widgets', 'Infrastructure', 'transaction-builder'], + features: [sdk.SafeAppFeatures.BATCHED_TRANSACTIONS], + developerWebsite: 'https://safe.global', + socialProfiles: [ + { + platform: sdk.SafeAppSocialPlatforms.DISCORD, + url: 'https://chat.safe.global', + }, + { + platform: sdk.SafeAppSocialPlatforms.GITHUB, + url: 'https://github.com/safe-global', + }, + { + platform: sdk.SafeAppSocialPlatforms.TWITTER, + url: 'https://twitter.com/safe', + }, + ], + }, + ]) }) it('Should show the app name, description and URL', async () => { @@ -33,16 +84,16 @@ describe('Share Safe App Page', () => { }, }) - await waitFor( - () => { - expect(screen.getByText('Transaction Builder')).toBeInTheDocument() - expect( - screen.getByText('Compose custom contract interactions and batch them into a single transaction'), - ).toBeInTheDocument() - expect(screen.getByText(TX_BUILDER)).toBeInTheDocument() - }, - { timeout: FETCH_TIMEOUT }, - ) + await waitFor(() => { + expect(fetchSafeAppFromManifestSpy).toHaveBeenCalledWith(TX_BUILDER, '1') + expect(getSafeAppsSpy).toHaveBeenCalledWith('1', { url: TX_BUILDER }) + + expect(screen.getByText('Transaction Builder')).toBeInTheDocument() + expect( + screen.getByText('Compose custom contract interactions and batch them into a single transaction'), + ).toBeInTheDocument() + expect(screen.getByText(TX_BUILDER)).toBeInTheDocument() + }) }) it("Should suggest to connect a wallet when user hasn't connected one", async () => { @@ -62,12 +113,12 @@ describe('Share Safe App Page', () => { }, }) - await waitFor( - () => { - expect(screen.getByText('Connect wallet')).toBeInTheDocument() - }, - { timeout: FETCH_TIMEOUT }, - ) + await waitFor(() => { + expect(fetchSafeAppFromManifestSpy).toHaveBeenCalledWith(TX_BUILDER, '1') + expect(getSafeAppsSpy).toHaveBeenCalledWith('1', { url: TX_BUILDER }) + + expect(screen.getByText('Connect wallet')).toBeInTheDocument() + }) }) it('Should show a link to the demo on mainnet', async () => { @@ -87,12 +138,12 @@ describe('Share Safe App Page', () => { }, }) - await waitFor( - () => { - expect(screen.getByText('Try demo')).toBeInTheDocument() - }, - { timeout: FETCH_TIMEOUT }, - ) + await waitFor(() => { + expect(fetchSafeAppFromManifestSpy).toHaveBeenCalledWith(TX_BUILDER, '1') + expect(getSafeAppsSpy).toHaveBeenCalledWith('1', { url: TX_BUILDER }) + + expect(screen.getByText('Try demo')).toBeInTheDocument() + }) }) it('Should link to Safe Creation flow when the connected wallet has no owned Safes', async () => { @@ -108,7 +159,7 @@ describe('Share Safe App Page', () => { render(, { routerProps: { query: { - appUrl: 'https://apps-portal.safe.global/tx-builder/', + appUrl: TX_BUILDER, chain: 'rin', }, }, @@ -121,12 +172,12 @@ describe('Share Safe App Page', () => { }, }) - await waitFor( - () => { - expect(screen.getByText('Create new Safe Account')).toBeInTheDocument() - }, - { timeout: FETCH_TIMEOUT }, - ) + await waitFor(() => { + expect(fetchSafeAppFromManifestSpy).toHaveBeenCalledWith(TX_BUILDER, '4') + expect(getSafeAppsSpy).toHaveBeenCalledWith('4', { url: TX_BUILDER }) + + expect(screen.getByText('Create new Safe Account')).toBeInTheDocument() + }) }) it('Should show a select input with owned safes when the connected wallet owns Safes', async () => { @@ -159,11 +210,11 @@ describe('Share Safe App Page', () => { }, }) - await waitFor( - () => { - expect(screen.getByLabelText('Select a Safe Account')).toBeInTheDocument() - }, - { timeout: FETCH_TIMEOUT }, - ) + await waitFor(() => { + expect(fetchSafeAppFromManifestSpy).toHaveBeenCalledWith(TX_BUILDER, '1') + expect(getSafeAppsSpy).toHaveBeenCalledWith('1', { url: TX_BUILDER }) + + expect(screen.getByLabelText('Select a Safe Account')).toBeInTheDocument() + }) }) }) diff --git a/src/utils/chains.ts b/src/utils/chains.ts index bbecb4d5f4..7ab92eed6c 100644 --- a/src/utils/chains.ts +++ b/src/utils/chains.ts @@ -14,6 +14,7 @@ export enum FEATURES { RELAYING = 'RELAYING', EIP1271 = 'EIP1271', RISK_MITIGATION = 'RISK_MITIGATION', + PUSH_NOTIFICATIONS = 'PUSH_NOTIFICATIONS', } export const hasFeature = (chain: ChainInfo, feature: FEATURES): boolean => { diff --git a/src/utils/wallets.ts b/src/utils/wallets.ts index cfb527189b..d149609fc8 100644 --- a/src/utils/wallets.ts +++ b/src/utils/wallets.ts @@ -17,6 +17,10 @@ export const isWalletRejection = (err: EthersError | Error): boolean => { return isEthersRejection(err as EthersError) || isWCRejection(err) } +export const isLedger = (wallet: ConnectedWallet): boolean => { + return wallet.label.toUpperCase() === WALLET_KEYS.LEDGER +} + export const isHardwareWallet = (wallet: ConnectedWallet): boolean => { return [WALLET_KEYS.LEDGER, WALLET_KEYS.TREZOR, WALLET_KEYS.KEYSTONE].includes( wallet.label.toUpperCase() as WALLET_KEYS, diff --git a/yarn.lock b/yarn.lock index 7381ceb0f9..e497917211 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1081,10 +1081,10 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@coinbase/wallet-sdk@^3.7.1": - version "3.7.1" - resolved "https://registry.yarnpkg.com/@coinbase/wallet-sdk/-/wallet-sdk-3.7.1.tgz#44b3b7a925ff5cc974e4cbf7a44199ffdcf03541" - integrity sha512-LjyoDCB+7p0waQXfK+fUgcAs3Ezk6S6e+LYaoFjpJ6c9VTop3NyZF40Pi7df4z7QJohCwzuIDjz0Rhtig6Y7Pg== +"@coinbase/wallet-sdk@^3.7.2": + version "3.7.2" + resolved "https://registry.yarnpkg.com/@coinbase/wallet-sdk/-/wallet-sdk-3.7.2.tgz#7a89bd9e3a06a1f26d4480d8642af33fb0c7e3aa" + integrity sha512-lIGvXMsgpsQWci/XOMQIJ2nIZ8JUy/L+bvC0wkRaYarr0YylwpXrJ2gRM3hCXPS477pkyO7N/kSiAoRgEXUdJQ== dependencies: "@metamask/safe-event-emitter" "2.0.0" "@solana/web3.js" "^1.70.1" @@ -1116,7 +1116,7 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" -"@cypress/request@^2.88.10": +"@cypress/request@2.88.12": version "2.88.12" resolved "https://registry.yarnpkg.com/@cypress/request/-/request-2.88.12.tgz#ba4911431738494a85e93fb04498cb38bc55d590" integrity sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA== @@ -1181,6 +1181,20 @@ dependencies: "@date-io/core" "^2.17.0" +"@ducanh2912/next-pwa@9.5.0": + version "9.5.0" + resolved "https://registry.yarnpkg.com/@ducanh2912/next-pwa/-/next-pwa-9.5.0.tgz#7d70dec2f6b44ace19695c32edf1501028d2bfe1" + integrity sha512-+c+Ni4A51Y+W3MWNG5l6OO629kfByEdiVR8TdqN2I3/cuFXzLzRwgKZZvXKV4ilemrzWm844hny5lP81RG18/Q== + dependencies: + clean-webpack-plugin "4.0.0" + fast-glob "3.3.1" + semver "7.5.4" + terser-webpack-plugin "5.3.9" + workbox-build "7.0.0" + workbox-core "7.0.0" + workbox-webpack-plugin "7.0.0" + workbox-window "7.0.0" + "@emotion/babel-plugin@^11.11.0": version "11.11.0" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz#c2d872b6a7767a9d176d007f5b31f7d504bb5d6c" @@ -2139,6 +2153,378 @@ "@ethersproject/properties" "^5.7.0" "@ethersproject/strings" "^5.7.0" +"@firebase/analytics-compat@0.2.6": + version "0.2.6" + resolved "https://registry.yarnpkg.com/@firebase/analytics-compat/-/analytics-compat-0.2.6.tgz#50063978c42f13eb800e037e96ac4b17236841f4" + integrity sha512-4MqpVLFkGK7NJf/5wPEEP7ePBJatwYpyjgJ+wQHQGHfzaCDgntOnl9rL2vbVGGKCnRqWtZDIWhctB86UWXaX2Q== + dependencies: + "@firebase/analytics" "0.10.0" + "@firebase/analytics-types" "0.8.0" + "@firebase/component" "0.6.4" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/analytics-types@0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@firebase/analytics-types/-/analytics-types-0.8.0.tgz#551e744a29adbc07f557306530a2ec86add6d410" + integrity sha512-iRP+QKI2+oz3UAh4nPEq14CsEjrjD6a5+fuypjScisAh9kXKFvdJOZJDwk7kikLvWVLGEs9+kIUS4LPQV7VZVw== + +"@firebase/analytics@0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@firebase/analytics/-/analytics-0.10.0.tgz#9c6986acd573c6c6189ffb52d0fd63c775db26d7" + integrity sha512-Locv8gAqx0e+GX/0SI3dzmBY5e9kjVDtD+3zCFLJ0tH2hJwuCAiL+5WkHuxKj92rqQj/rvkBUCfA1ewlX2hehg== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/installations" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/app-check-compat@0.3.7": + version "0.3.7" + resolved "https://registry.yarnpkg.com/@firebase/app-check-compat/-/app-check-compat-0.3.7.tgz#e150f61d653a0f2043a34dcb995616a717161839" + integrity sha512-cW682AxsyP1G+Z0/P7pO/WT2CzYlNxoNe5QejVarW2o5ZxeWSSPAiVEwpEpQR/bUlUmdeWThYTMvBWaopdBsqw== + dependencies: + "@firebase/app-check" "0.8.0" + "@firebase/app-check-types" "0.5.0" + "@firebase/component" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/app-check-interop-types@0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.0.tgz#b27ea1397cb80427f729e4bbf3a562f2052955c4" + integrity sha512-xAxHPZPIgFXnI+vb4sbBjZcde7ZluzPPaSK7Lx3/nmuVk4TjZvnL8ONnkd4ERQKL8WePQySU+pRcWkh8rDf5Sg== + +"@firebase/app-check-types@0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@firebase/app-check-types/-/app-check-types-0.5.0.tgz#1b02826213d7ce6a1cf773c329b46ea1c67064f4" + integrity sha512-uwSUj32Mlubybw7tedRzR24RP8M8JUVR3NPiMk3/Z4bCmgEKTlQBwMXrehDAZ2wF+TsBq0SN1c6ema71U/JPyQ== + +"@firebase/app-check@0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@firebase/app-check/-/app-check-0.8.0.tgz#b531ec40900af9c3cf1ec63de9094a0ddd733d6a" + integrity sha512-dRDnhkcaC2FspMiRK/Vbp+PfsOAEP6ZElGm9iGFJ9fDqHoPs0HOPn7dwpJ51lCFi1+2/7n5pRPGhqF/F03I97g== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/app-compat@0.2.19": + version "0.2.19" + resolved "https://registry.yarnpkg.com/@firebase/app-compat/-/app-compat-0.2.19.tgz#ba0651166924fa344b4591a746ea493fdd609f13" + integrity sha512-QkJDqYqjhvs4fTMcRVXQkP9hbo5yfoJXDWkhU4VA5Vzs8Qsp76VPzYbqx5SD5OmBy+bz/Ot1UV8qySPGI4aKuw== + dependencies: + "@firebase/app" "0.9.19" + "@firebase/component" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/app-types@0.9.0": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@firebase/app-types/-/app-types-0.9.0.tgz#35b5c568341e9e263b29b3d2ba0e9cfc9ec7f01e" + integrity sha512-AeweANOIo0Mb8GiYm3xhTEBVCmPwTYAu9Hcd2qSkLuga/6+j9b1Jskl5bpiSQWy9eJ/j5pavxj6eYogmnuzm+Q== + +"@firebase/app@0.9.19": + version "0.9.19" + resolved "https://registry.yarnpkg.com/@firebase/app/-/app-0.9.19.tgz#d2b8a4cf47eb429e441dd661c291dd7312fd69de" + integrity sha512-t/SHyZ3xWkR77ZU9VMoobDNFLdDKQ5xqoCAn4o16gTsA1C8sJ6ZOMZ02neMOPxNHuQXVE4tA8ukilnDbnK7uJA== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + idb "7.1.1" + tslib "^2.1.0" + +"@firebase/auth-compat@0.4.6": + version "0.4.6" + resolved "https://registry.yarnpkg.com/@firebase/auth-compat/-/auth-compat-0.4.6.tgz#413568be48d23a17aa14438b8aad86556bd1e132" + integrity sha512-pKp1d4fSf+yoy1EBjTx9ISxlunqhW0vTICk0ByZ3e+Lp6ZIXThfUy4F1hAJlEafD/arM0oepRiAh7LXS1xn/BA== + dependencies: + "@firebase/auth" "1.3.0" + "@firebase/auth-types" "0.12.0" + "@firebase/component" "0.6.4" + "@firebase/util" "1.9.3" + node-fetch "2.6.7" + tslib "^2.1.0" + +"@firebase/auth-interop-types@0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@firebase/auth-interop-types/-/auth-interop-types-0.2.1.tgz#78884f24fa539e34a06c03612c75f222fcc33742" + integrity sha512-VOaGzKp65MY6P5FI84TfYKBXEPi6LmOCSMMzys6o2BN2LOsqy7pCuZCup7NYnfbk5OkkQKzvIfHOzTm0UDpkyg== + +"@firebase/auth-types@0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@firebase/auth-types/-/auth-types-0.12.0.tgz#f28e1b68ac3b208ad02a15854c585be6da3e8e79" + integrity sha512-pPwaZt+SPOshK8xNoiQlK5XIrS97kFYc3Rc7xmy373QsOJ9MmqXxLaYssP5Kcds4wd2qK//amx/c+A8O2fVeZA== + +"@firebase/auth@1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@firebase/auth/-/auth-1.3.0.tgz#514d77309fdef5cc0ae81d5f57cb07bdaf6822d7" + integrity sha512-vjK4CHbY9aWdiVOrKi6mpa8z6uxeaf7LB/MZTHuZOiGHMcUoTGB6TeMbRShyqk1uaMrxhhZ5Ar/dR0965E1qyA== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + node-fetch "2.6.7" + tslib "^2.1.0" + +"@firebase/component@0.6.4": + version "0.6.4" + resolved "https://registry.yarnpkg.com/@firebase/component/-/component-0.6.4.tgz#8981a6818bd730a7554aa5e0516ffc9b1ae3f33d" + integrity sha512-rLMyrXuO9jcAUCaQXCMjCMUsWrba5fzHlNK24xz5j2W6A/SRmK8mZJ/hn7V0fViLbxC0lPMtrK1eYzk6Fg03jA== + dependencies: + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/database-compat@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@firebase/database-compat/-/database-compat-1.0.1.tgz#ab0acbbfb0031080cc16504cef6d00c95cf27ff1" + integrity sha512-ky82yLIboLxtAIWyW/52a6HLMVTzD2kpZlEilVDok73pNPLjkJYowj8iaIWK5nTy7+6Gxt7d00zfjL6zckGdXQ== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/database" "1.0.1" + "@firebase/database-types" "1.0.0" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/database-types@1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@firebase/database-types/-/database-types-1.0.0.tgz#3f7f71c2c3fd1e29d15fce513f14dae2e7543f2a" + integrity sha512-SjnXStoE0Q56HcFgNQ+9SsmJc0c8TqGARdI/T44KXy+Ets3r6x/ivhQozT66bMnCEjJRywYoxNurRTMlZF8VNg== + dependencies: + "@firebase/app-types" "0.9.0" + "@firebase/util" "1.9.3" + +"@firebase/database@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@firebase/database/-/database-1.0.1.tgz#28830f1d0c05ec2f7014658a3165129cec891bcb" + integrity sha512-VAhF7gYwunW4Lw/+RQZvW8dlsf2r0YYqV9W0Gi2Mz8+0TGg1mBJWoUtsHfOr8kPJXhcLsC4eP/z3x6L/Fvjk/A== + dependencies: + "@firebase/auth-interop-types" "0.2.1" + "@firebase/component" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + faye-websocket "0.11.4" + tslib "^2.1.0" + +"@firebase/firestore-compat@0.3.18": + version "0.3.18" + resolved "https://registry.yarnpkg.com/@firebase/firestore-compat/-/firestore-compat-0.3.18.tgz#f087d65cbd175e2340beb87527f24482b651e12e" + integrity sha512-hkqv4mb1oScKbEtzfcK8Go8c0VpDWmbAvbD6B6XnphLqi27pkXgo9Rp+aSKlD7cBL29VMEekP5bEm9lSVfZpNw== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/firestore" "4.2.0" + "@firebase/firestore-types" "3.0.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/firestore-types@3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@firebase/firestore-types/-/firestore-types-3.0.0.tgz#f3440d5a1cc2a722d361b24cefb62ca8b3577af3" + integrity sha512-Meg4cIezHo9zLamw0ymFYBD4SMjLb+ZXIbuN7T7ddXN6MGoICmOTq3/ltdCGoDCS2u+H1XJs2u/cYp75jsX9Qw== + +"@firebase/firestore@4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@firebase/firestore/-/firestore-4.2.0.tgz#637e21eadee5e8b6e75c1d5bf4741385dd1e128e" + integrity sha512-iKZqIdOBJpJUcwY5airLX0W04TLrQSJuActOP1HG5WoIY5oyGTQE4Ml7hl5GW7mBqFieT4ojtUuDXj6MLrn1lA== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + "@firebase/webchannel-wrapper" "0.10.3" + "@grpc/grpc-js" "~1.9.0" + "@grpc/proto-loader" "^0.7.8" + node-fetch "2.6.7" + tslib "^2.1.0" + +"@firebase/functions-compat@0.3.5": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@firebase/functions-compat/-/functions-compat-0.3.5.tgz#7a532d3a9764c6d5fbc1ec5541a989a704326647" + integrity sha512-uD4jwgwVqdWf6uc3NRKF8cSZ0JwGqSlyhPgackyUPe+GAtnERpS4+Vr66g0b3Gge0ezG4iyHo/EXW/Hjx7QhHw== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/functions" "0.10.0" + "@firebase/functions-types" "0.6.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/functions-types@0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@firebase/functions-types/-/functions-types-0.6.0.tgz#ccd7000dc6fc668f5acb4e6a6a042a877a555ef2" + integrity sha512-hfEw5VJtgWXIRf92ImLkgENqpL6IWpYaXVYiRkFY1jJ9+6tIhWM7IzzwbevwIIud/jaxKVdRzD7QBWfPmkwCYw== + +"@firebase/functions@0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@firebase/functions/-/functions-0.10.0.tgz#c630ddf12cdf941c25bc8d554e30c3226cd560f6" + integrity sha512-2U+fMNxTYhtwSpkkR6WbBcuNMOVaI7MaH3cZ6UAeNfj7AgEwHwMIFLPpC13YNZhno219F0lfxzTAA0N62ndWzA== + dependencies: + "@firebase/app-check-interop-types" "0.3.0" + "@firebase/auth-interop-types" "0.2.1" + "@firebase/component" "0.6.4" + "@firebase/messaging-interop-types" "0.2.0" + "@firebase/util" "1.9.3" + node-fetch "2.6.7" + tslib "^2.1.0" + +"@firebase/installations-compat@0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@firebase/installations-compat/-/installations-compat-0.2.4.tgz#b5557c897b4cd3635a59887a8bf69c3731aaa952" + integrity sha512-LI9dYjp0aT9Njkn9U4JRrDqQ6KXeAmFbRC0E7jI7+hxl5YmRWysq5qgQl22hcWpTk+cm3es66d/apoDU/A9n6Q== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/installations" "0.6.4" + "@firebase/installations-types" "0.5.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/installations-types@0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@firebase/installations-types/-/installations-types-0.5.0.tgz#2adad64755cd33648519b573ec7ec30f21fb5354" + integrity sha512-9DP+RGfzoI2jH7gY4SlzqvZ+hr7gYzPODrbzVD82Y12kScZ6ZpRg/i3j6rleto8vTFC8n6Len4560FnV1w2IRg== + +"@firebase/installations@0.6.4": + version "0.6.4" + resolved "https://registry.yarnpkg.com/@firebase/installations/-/installations-0.6.4.tgz#20382e33e6062ac5eff4bede8e468ed4c367609e" + integrity sha512-u5y88rtsp7NYkCHC3ElbFBrPtieUybZluXyzl7+4BsIz4sqb4vSAuwHEUgCgCeaQhvsnxDEU6icly8U9zsJigA== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/util" "1.9.3" + idb "7.0.1" + tslib "^2.1.0" + +"@firebase/logger@0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@firebase/logger/-/logger-0.4.0.tgz#15ecc03c452525f9d47318ad9491b81d1810f113" + integrity sha512-eRKSeykumZ5+cJPdxxJRgAC3G5NknY2GwEbKfymdnXtnT0Ucm4pspfR6GT4MUQEDuJwRVbVcSx85kgJulMoFFA== + dependencies: + tslib "^2.1.0" + +"@firebase/messaging-compat@0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@firebase/messaging-compat/-/messaging-compat-0.2.4.tgz#323ca48deef77065b4fcda3cfd662c4337dffcfd" + integrity sha512-lyFjeUhIsPRYDPNIkYX1LcZMpoVbBWXX4rPl7c/rqc7G+EUea7IEtSt4MxTvh6fDfPuzLn7+FZADfscC+tNMfg== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/messaging" "0.12.4" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/messaging-interop-types@0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.0.tgz#6056f8904a696bf0f7fdcf5f2ca8f008e8f6b064" + integrity sha512-ujA8dcRuVeBixGR9CtegfpU4YmZf3Lt7QYkcj693FFannwNuZgfAYaTmbJ40dtjB81SAu6tbFPL9YLNT15KmOQ== + +"@firebase/messaging@0.12.4": + version "0.12.4" + resolved "https://registry.yarnpkg.com/@firebase/messaging/-/messaging-0.12.4.tgz#ccb49df5ab97d5650c9cf5b8c77ddc34daafcfe0" + integrity sha512-6JLZct6zUaex4g7HI3QbzeUrg9xcnmDAPTWpkoMpd/GoSVWH98zDoWXMGrcvHeCAIsLpFMe4MPoZkJbrPhaASw== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/installations" "0.6.4" + "@firebase/messaging-interop-types" "0.2.0" + "@firebase/util" "1.9.3" + idb "7.0.1" + tslib "^2.1.0" + +"@firebase/performance-compat@0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@firebase/performance-compat/-/performance-compat-0.2.4.tgz#95cbf32057b5d9f0c75d804bc50e6ed3ba486274" + integrity sha512-nnHUb8uP9G8islzcld/k6Bg5RhX62VpbAb/Anj7IXs/hp32Eb2LqFPZK4sy3pKkBUO5wcrlRWQa6wKOxqlUqsg== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/performance" "0.6.4" + "@firebase/performance-types" "0.2.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/performance-types@0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@firebase/performance-types/-/performance-types-0.2.0.tgz#400685f7a3455970817136d9b48ce07a4b9562ff" + integrity sha512-kYrbr8e/CYr1KLrLYZZt2noNnf+pRwDq2KK9Au9jHrBMnb0/C9X9yWSXmZkFt4UIdsQknBq8uBB7fsybZdOBTA== + +"@firebase/performance@0.6.4": + version "0.6.4" + resolved "https://registry.yarnpkg.com/@firebase/performance/-/performance-0.6.4.tgz#0ad766bfcfab4f386f4fe0bef43bbcf505015069" + integrity sha512-HfTn/bd8mfy/61vEqaBelNiNnvAbUtME2S25A67Nb34zVuCSCRIX4SseXY6zBnOFj3oLisaEqhVcJmVPAej67g== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/installations" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/remote-config-compat@0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@firebase/remote-config-compat/-/remote-config-compat-0.2.4.tgz#1f494c81a6c9560b1f9ca1b4fbd4bbbe47cf4776" + integrity sha512-FKiki53jZirrDFkBHglB3C07j5wBpitAaj8kLME6g8Mx+aq7u9P7qfmuSRytiOItADhWUj7O1JIv7n9q87SuwA== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/remote-config" "0.4.4" + "@firebase/remote-config-types" "0.3.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/remote-config-types@0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@firebase/remote-config-types/-/remote-config-types-0.3.0.tgz#689900dcdb3e5c059e8499b29db393e4e51314b4" + integrity sha512-RtEH4vdcbXZuZWRZbIRmQVBNsE7VDQpet2qFvq6vwKLBIQRQR5Kh58M4ok3A3US8Sr3rubYnaGqZSurCwI8uMA== + +"@firebase/remote-config@0.4.4": + version "0.4.4" + resolved "https://registry.yarnpkg.com/@firebase/remote-config/-/remote-config-0.4.4.tgz#6a496117054de58744bc9f382d2a6d1e14060c65" + integrity sha512-x1ioTHGX8ZwDSTOVp8PBLv2/wfwKzb4pxi0gFezS5GCJwbLlloUH4YYZHHS83IPxnua8b6l0IXUaWd0RgbWwzQ== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/installations" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/storage-compat@0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@firebase/storage-compat/-/storage-compat-0.3.2.tgz#51a97170fd652a516f729f82b97af369e5a2f8d7" + integrity sha512-wvsXlLa9DVOMQJckbDNhXKKxRNNewyUhhbXev3t8kSgoCotd1v3MmqhKKz93ePhDnhHnDs7bYHy+Qa8dRY6BXw== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/storage" "0.11.2" + "@firebase/storage-types" "0.8.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/storage-types@0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@firebase/storage-types/-/storage-types-0.8.0.tgz#f1e40a5361d59240b6e84fac7fbbbb622bfaf707" + integrity sha512-isRHcGrTs9kITJC0AVehHfpraWFui39MPaU7Eo8QfWlqW7YPymBmRgjDrlOgFdURh6Cdeg07zmkLP5tzTKRSpg== + +"@firebase/storage@0.11.2": + version "0.11.2" + resolved "https://registry.yarnpkg.com/@firebase/storage/-/storage-0.11.2.tgz#c5e0316543fe1c4026b8e3910f85ad73f5b77571" + integrity sha512-CtvoFaBI4hGXlXbaCHf8humajkbXhs39Nbh6MbNxtwJiCqxPy9iH3D3CCfXAvP0QvAAwmJUTK3+z9a++Kc4nkA== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/util" "1.9.3" + node-fetch "2.6.7" + tslib "^2.1.0" + +"@firebase/util@1.9.3": + version "1.9.3" + resolved "https://registry.yarnpkg.com/@firebase/util/-/util-1.9.3.tgz#45458dd5cd02d90e55c656e84adf6f3decf4b7ed" + integrity sha512-DY02CRhOZwpzO36fHpuVysz6JZrscPiBXD0fXp6qSrL9oNOx5KWICKdR95C0lSITzxp0TZosVyHqzatE8JbcjA== + dependencies: + tslib "^2.1.0" + +"@firebase/webchannel-wrapper@0.10.3": + version "0.10.3" + resolved "https://registry.yarnpkg.com/@firebase/webchannel-wrapper/-/webchannel-wrapper-0.10.3.tgz#c894a21e8c911830e36bbbba55903ccfbc7a7e25" + integrity sha512-+ZplYUN3HOpgCfgInqgdDAbkGGVzES1cs32JJpeqoh87SkRobGXElJx+1GZSaDqzFL+bYiX18qEcBK76mYs8uA== + "@floating-ui/core@^1.4.1": version "1.4.1" resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.4.1.tgz#0d633f4b76052668afb932492ac452f7ebe97f17" @@ -2226,6 +2612,24 @@ "@openzeppelin/contracts-upgradeable" "^4.8.1" ethers "^5.7.1" +"@grpc/grpc-js@~1.9.0": + version "1.9.5" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.9.5.tgz#22e283754b7b10d1ad26c3fb21849028dcaabc53" + integrity sha512-iouYNlPxRAwZ2XboDT+OfRKHuaKHiqjB5VFYZ0NFrHkbEF+AV3muIUY9olQsp8uxU4VvRCMiRk9ftzFDGb61aw== + dependencies: + "@grpc/proto-loader" "^0.7.8" + "@types/node" ">=12.12.47" + +"@grpc/proto-loader@^0.7.8": + version "0.7.10" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.7.10.tgz#6bf26742b1b54d0a473067743da5d3189d06d720" + integrity sha512-CAqDfoaQ8ykFd9zqBDn4k6iWT9loLAlc2ETmDFS9JCD70gDcnA4L3AFEo2iV7KyAtAAHFW9ftq1Fz+Vsgq80RQ== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.2.4" + yargs "^17.7.2" + "@hapi/hoek@^9.0.0": version "9.3.0" resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" @@ -4136,6 +4540,27 @@ dependencies: "@types/ms" "*" +"@types/eslint-scope@^3.7.3": + version "3.7.5" + resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.5.tgz#e28b09dbb1d9d35fdfa8a884225f00440dfc5a3e" + integrity sha512-JNvhIEyxVW6EoMIFIvj93ZOywYFatlpu9deeH6eSx6PE3WHYvHaQtmHmQeNw7aA81bYGBPPQqdtBm6b1SsQMmA== + dependencies: + "@types/eslint" "*" + "@types/estree" "*" + +"@types/eslint@*": + version "8.44.3" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.44.3.tgz#96614fae4875ea6328f56de38666f582d911d962" + integrity sha512-iM/WfkwAhwmPff3wZuPLYiHX18HI24jU8k1ZSH7P8FHwxTjZ2P6CoX2wnF43oprR+YXJM6UUxATkNvyv/JHd+g== + dependencies: + "@types/estree" "*" + "@types/json-schema" "*" + +"@types/estree@*", "@types/estree@^1.0.0": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.2.tgz#ff02bc3dc8317cd668dfec247b750ba1f1d62453" + integrity sha512-VeiPZ9MMwXjO32/Xu7+OwflfmeoRwkE/qzndw42gGtgJwZopBnzy2gD//NN1+go1mADzkDcqf/KnFRSjTJ8xJA== + "@types/estree@0.0.39": version "0.0.39" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" @@ -4217,7 +4642,12 @@ "@types/tough-cookie" "*" parse5 "^7.0.0" -"@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": +"@types/json-schema@*": + version "7.0.13" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.13.tgz#02c24f4363176d2d18fc8b70b9f3c54aba178a85" + integrity sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ== + +"@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": version "7.0.12" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.12.tgz#d70faba7039d5fca54c83c7dbab41051d2b6f6cb" integrity sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA== @@ -4259,15 +4689,20 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.18.tgz#8dfb97f0da23c2293e554c5a50d61ef134d7697f" integrity sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA== +"@types/node@>=12.12.47": + version "20.8.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.2.tgz#d76fb80d87d0d8abfe334fc6d292e83e5524efc4" + integrity sha512-Vvycsc9FQdwhxE3y3DzeIxuEJbWGDsnrxvMADzTDF/lcdR9/K+AQIeAghTQsHtotg/q0j3WEOYS/jQgSdWue3w== + "@types/node@^12.12.54", "@types/node@^12.12.6": version "12.20.55" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.55.tgz#c329cbd434c42164f846b909bd6f85b5537f6240" integrity sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ== -"@types/node@^14.14.31": - version "14.18.56" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.56.tgz#09e092d684cd8cfbdb3c5e5802672712242f2600" - integrity sha512-+k+57NVS9opgrEn5l9c0gvD1r6C+PtyhVE4BTnMMRwiEA8ZO8uFcs6Yy2sXIy0eC95ZurBtRSvhZiHXBysbl6w== +"@types/node@^16.18.39": + version "16.18.57" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.57.tgz#1ba31c0e5c403aab90a3b7826576e6782ded779b" + integrity sha512-piPoDozdPaX1hNWFJQzzgWqE40gh986VvVx/QO9RU4qYRE55ld7iepDVgZ3ccGUw0R4wge0Oy1dd+3xOQNkkUQ== "@types/papaparse@^5.3.1": version "5.3.8" @@ -4618,10 +5053,10 @@ "@walletconnect/types" "^1.8.0" "@walletconnect/utils" "^1.8.0" -"@walletconnect/core@2.9.2": - version "2.9.2" - resolved "https://registry.yarnpkg.com/@walletconnect/core/-/core-2.9.2.tgz#c46734ca63771b28fd77606fd521930b7ecfc5e1" - integrity sha512-VARMPAx8sIgodeyngDHbealP3B621PQqjqKsByFUTOep8ZI1/R/20zU+cmq6j9RCrL+kLKZcrZqeVzs8Z7OlqQ== +"@walletconnect/core@2.10.1": + version "2.10.1" + resolved "https://registry.yarnpkg.com/@walletconnect/core/-/core-2.10.1.tgz#d1fb442bd77424666bacdb0f5a07f7708fb3d984" + integrity sha512-WAoXfmj+Zy5q48TnrKUjmHXJCBahzKwbul+noepRZf7JDtUAZ9IOWpUjg+UPRbfK5EiWZ0TF42S6SXidf7EHoQ== dependencies: "@walletconnect/heartbeat" "1.2.1" "@walletconnect/jsonrpc-provider" "1.0.13" @@ -4634,8 +5069,8 @@ "@walletconnect/relay-auth" "^1.0.4" "@walletconnect/safe-json" "^1.0.2" "@walletconnect/time" "^1.0.2" - "@walletconnect/types" "2.9.2" - "@walletconnect/utils" "2.9.2" + "@walletconnect/types" "2.10.1" + "@walletconnect/utils" "2.10.1" events "^3.3.0" lodash.isequal "4.5.0" uint8arrays "^3.1.0" @@ -4677,19 +5112,19 @@ dependencies: tslib "1.14.1" -"@walletconnect/ethereum-provider@2.9.2": - version "2.9.2" - resolved "https://registry.yarnpkg.com/@walletconnect/ethereum-provider/-/ethereum-provider-2.9.2.tgz#fb3a6fca279bb4e98e75baa2fb9730545d41bb99" - integrity sha512-eO1dkhZffV1g7vpG19XUJTw09M/bwGUwwhy1mJ3AOPbOSbMPvwiCuRz2Kbtm1g9B0Jv15Dl+TvJ9vTgYF8zoZg== +"@walletconnect/ethereum-provider@^2.10.1": + version "2.10.1" + resolved "https://registry.yarnpkg.com/@walletconnect/ethereum-provider/-/ethereum-provider-2.10.1.tgz#4733a98f0b388cf5ae6c2b269f50da87da432ee5" + integrity sha512-Yhoz8EXkKzxOlBT6G+elphqCx/gkH6RxD9/ZAiy9lLc8Ng5p1gvKCVVP5zsGNE9FbkKmHd+J9JJRzn2Bw2yqtQ== dependencies: "@walletconnect/jsonrpc-http-connection" "^1.0.7" "@walletconnect/jsonrpc-provider" "^1.0.13" "@walletconnect/jsonrpc-types" "^1.0.3" "@walletconnect/jsonrpc-utils" "^1.0.8" - "@walletconnect/sign-client" "2.9.2" - "@walletconnect/types" "2.9.2" - "@walletconnect/universal-provider" "2.9.2" - "@walletconnect/utils" "2.9.2" + "@walletconnect/sign-client" "2.10.1" + "@walletconnect/types" "2.10.1" + "@walletconnect/universal-provider" "2.10.1" + "@walletconnect/utils" "2.10.1" events "^3.3.0" "@walletconnect/events@^1.0.1": @@ -4786,30 +5221,30 @@ resolved "https://registry.yarnpkg.com/@walletconnect/mobile-registry/-/mobile-registry-1.4.0.tgz#502cf8ab87330841d794819081e748ebdef7aee5" integrity sha512-ZtKRio4uCZ1JUF7LIdecmZt7FOLnX72RPSY7aUVu7mj7CSfxDwUn6gBuK6WGtH+NZCldBqDl5DenI5fFSvkKYw== -"@walletconnect/modal-core@2.6.1": - version "2.6.1" - resolved "https://registry.yarnpkg.com/@walletconnect/modal-core/-/modal-core-2.6.1.tgz#bc76055d0b644a2d4b98024324825c108a700905" - integrity sha512-f2hYlJ5pwzGvjyaZ6BoGR5uiMgXzWXt6w6ktt1N8lmY6PiYp8whZgqx2hTxVWwVlsGnaIfh6UHp1hGnANx0eTQ== +"@walletconnect/modal-core@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@walletconnect/modal-core/-/modal-core-2.6.2.tgz#d73e45d96668764e0c8668ea07a45bb8b81119e9" + integrity sha512-cv8ibvdOJQv2B+nyxP9IIFdxvQznMz8OOr/oR/AaUZym4hjXNL/l1a2UlSQBXrVjo3xxbouMxLb3kBsHoYP2CA== dependencies: - valtio "1.11.0" + valtio "1.11.2" -"@walletconnect/modal-ui@2.6.1": - version "2.6.1" - resolved "https://registry.yarnpkg.com/@walletconnect/modal-ui/-/modal-ui-2.6.1.tgz#200c54c8dfe3c71321abb2724e18bb357dfd6371" - integrity sha512-RFUOwDAMijSK8B7W3+KoLKaa1l+KEUG0LCrtHqaB0H0cLnhEGdLR+kdTdygw+W8+yYZbkM5tXBm7MlFbcuyitA== +"@walletconnect/modal-ui@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@walletconnect/modal-ui/-/modal-ui-2.6.2.tgz#fa57c087c57b7f76aaae93deab0f84bb68b59cf9" + integrity sha512-rbdstM1HPGvr7jprQkyPggX7rP4XiCG85ZA+zWBEX0dVQg8PpAgRUqpeub4xQKDgY7pY/xLRXSiCVdWGqvG2HA== dependencies: - "@walletconnect/modal-core" "2.6.1" - lit "2.7.6" + "@walletconnect/modal-core" "2.6.2" + lit "2.8.0" motion "10.16.2" qrcode "1.5.3" -"@walletconnect/modal@2.6.1": - version "2.6.1" - resolved "https://registry.yarnpkg.com/@walletconnect/modal/-/modal-2.6.1.tgz#066fdbfcff83b58c8a9da66ab4af0eb93e3626de" - integrity sha512-G84tSzdPKAFk1zimgV7JzIUFT5olZUVtI3GcOk77OeLYjlMfnDT23RVRHm5EyCrjkptnvpD0wQScXePOFd2Xcw== +"@walletconnect/modal@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@walletconnect/modal/-/modal-2.6.2.tgz#4b534a836f5039eeb3268b80be7217a94dd12651" + integrity sha512-eFopgKi8AjKf/0U4SemvcYw9zlLpx9njVN8sf6DAkowC2Md0gPU/UNEbH1Wwj407pEKnEds98pKWib1NN1ACoA== dependencies: - "@walletconnect/modal-core" "2.6.1" - "@walletconnect/modal-ui" "2.6.1" + "@walletconnect/modal-core" "2.6.2" + "@walletconnect/modal-ui" "2.6.2" "@walletconnect/qrcode-modal@^1.8.0": version "1.8.0" @@ -4865,19 +5300,19 @@ dependencies: tslib "1.14.1" -"@walletconnect/sign-client@2.9.2": - version "2.9.2" - resolved "https://registry.yarnpkg.com/@walletconnect/sign-client/-/sign-client-2.9.2.tgz#ff4c81c082c2078878367d07f24bcb20b1f7ab9e" - integrity sha512-anRwnXKlR08lYllFMEarS01hp1gr6Q9XUgvacr749hoaC/AwGVlxYFdM8+MyYr3ozlA+2i599kjbK/mAebqdXg== +"@walletconnect/sign-client@2.10.1": + version "2.10.1" + resolved "https://registry.yarnpkg.com/@walletconnect/sign-client/-/sign-client-2.10.1.tgz#db60bc9400cd79f0cb2380067343512b21ee4749" + integrity sha512-iG3eJGi1yXeG3xGeVSSMf8wDFyx239B0prLQfy1uYDtYFb2ynnH/09oqAZyKn96W5nfQzUgM2Mz157PVdloH3Q== dependencies: - "@walletconnect/core" "2.9.2" + "@walletconnect/core" "2.10.1" "@walletconnect/events" "^1.0.1" "@walletconnect/heartbeat" "1.2.1" "@walletconnect/jsonrpc-utils" "1.0.8" "@walletconnect/logger" "^2.0.1" "@walletconnect/time" "^1.0.2" - "@walletconnect/types" "2.9.2" - "@walletconnect/utils" "2.9.2" + "@walletconnect/types" "2.10.1" + "@walletconnect/utils" "2.10.1" events "^3.3.0" "@walletconnect/socket-transport@^1.8.0": @@ -4896,10 +5331,10 @@ dependencies: tslib "1.14.1" -"@walletconnect/types@2.9.2": - version "2.9.2" - resolved "https://registry.yarnpkg.com/@walletconnect/types/-/types-2.9.2.tgz#d5fd5a61dc0f41cbdca59d1885b85207ac7bf8c5" - integrity sha512-7Rdn30amnJEEal4hk83cdwHUuxI1SWQ+K7fFFHBMqkuHLGi3tpMY6kpyfDxnUScYEZXqgRps4Jo5qQgnRqVM7A== +"@walletconnect/types@2.10.1": + version "2.10.1" + resolved "https://registry.yarnpkg.com/@walletconnect/types/-/types-2.10.1.tgz#1355bce236f3eef575716ea3efe4beed98a873ef" + integrity sha512-7pccAhajQdiH2kYywjE1XI64IqRI+4ioyGy0wvz8d0UFQ/DSG3MLKR8jHf5aTOafQQ/HRLz6xvlzN4a7gIVkUQ== dependencies: "@walletconnect/events" "^1.0.1" "@walletconnect/heartbeat" "1.2.1" @@ -4913,25 +5348,25 @@ resolved "https://registry.yarnpkg.com/@walletconnect/types/-/types-1.8.0.tgz#3f5e85b2d6b149337f727ab8a71b8471d8d9a195" integrity sha512-Cn+3I0V0vT9ghMuzh1KzZvCkiAxTq+1TR2eSqw5E5AVWfmCtECFkVZBP6uUJZ8YjwLqXheI+rnjqPy7sVM4Fyg== -"@walletconnect/universal-provider@2.9.2": - version "2.9.2" - resolved "https://registry.yarnpkg.com/@walletconnect/universal-provider/-/universal-provider-2.9.2.tgz#40e54e98bc48b1f2f5f77eb5b7f05462093a8506" - integrity sha512-JmaolkO8D31UdRaQCHwlr8uIFUI5BYhBzqYFt54Mc6gbIa1tijGOmdyr6YhhFO70LPmS6gHIjljwOuEllmlrxw== +"@walletconnect/universal-provider@2.10.1": + version "2.10.1" + resolved "https://registry.yarnpkg.com/@walletconnect/universal-provider/-/universal-provider-2.10.1.tgz#c4a77bd2eed1a335edae5b2b298636092fff63ef" + integrity sha512-81QxTH/X4dRoYCz0U9iOrBYOcj7N897ONcB57wsGhEkV7Rc9htmWJq2CzeOuxvVZ+pNZkE+/aw9LrhizO1Ltxg== dependencies: "@walletconnect/jsonrpc-http-connection" "^1.0.7" "@walletconnect/jsonrpc-provider" "1.0.13" "@walletconnect/jsonrpc-types" "^1.0.2" "@walletconnect/jsonrpc-utils" "^1.0.7" "@walletconnect/logger" "^2.0.1" - "@walletconnect/sign-client" "2.9.2" - "@walletconnect/types" "2.9.2" - "@walletconnect/utils" "2.9.2" + "@walletconnect/sign-client" "2.10.1" + "@walletconnect/types" "2.10.1" + "@walletconnect/utils" "2.10.1" events "^3.3.0" -"@walletconnect/utils@2.9.2": - version "2.9.2" - resolved "https://registry.yarnpkg.com/@walletconnect/utils/-/utils-2.9.2.tgz#035bdb859ee81a4bcc6420f56114cc5ec3e30afb" - integrity sha512-D44hwXET/8JhhIjqljY6qxSu7xXnlPrf63UN/Qfl98vDjWlYVcDl2+JIQRxD9GPastw0S8XZXdRq59XDXLuZBg== +"@walletconnect/utils@2.10.1": + version "2.10.1" + resolved "https://registry.yarnpkg.com/@walletconnect/utils/-/utils-2.10.1.tgz#65b37c9800eb0e80a08385b6987471fb46e1e22e" + integrity sha512-DM0dKgm9O58l7VqJEyV2OVv16XRePhDAReI23let6WdW1dSpw/Y/A89Lp99ZJOjLm2FxyblMRF3YRaZtHwBffw== dependencies: "@stablelib/chacha20poly1305" "1.0.1" "@stablelib/hkdf" "1.0.1" @@ -4941,7 +5376,7 @@ "@walletconnect/relay-api" "^1.0.9" "@walletconnect/safe-json" "^1.0.2" "@walletconnect/time" "^1.0.2" - "@walletconnect/types" "2.9.2" + "@walletconnect/types" "2.10.1" "@walletconnect/window-getters" "^1.0.1" "@walletconnect/window-metadata" "^1.0.1" detect-browser "5.3.0" @@ -4988,12 +5423,12 @@ "@walletconnect/window-getters" "^1.0.1" tslib "1.14.1" -"@web3-onboard/coinbase@^2.2.4": - version "2.2.5" - resolved "https://registry.yarnpkg.com/@web3-onboard/coinbase/-/coinbase-2.2.5.tgz#fb7a57e5456323c0ee107ce48ea0cc80acbb6e07" - integrity sha512-mEiaK+K+nB2TwxUpkyAZmb4AHguymsJrHFbsZDdAolFTgZizCSjGHBhYlCEfxLL4fh3CpUryTa/AaNxxhdG6OQ== +"@web3-onboard/coinbase@^2.2.6": + version "2.2.6" + resolved "https://registry.yarnpkg.com/@web3-onboard/coinbase/-/coinbase-2.2.6.tgz#2690bc70a0b28ee6784ba7ffaba658101207c69b" + integrity sha512-ALvTN8VuAwRwSK87mPEN2h1Tam3r1d9aJg6dloPS9z5T101n4RqwqB++3D8NEOvOZw8832ZV9kH+nU/wvtGZJA== dependencies: - "@coinbase/wallet-sdk" "^3.7.1" + "@coinbase/wallet-sdk" "^3.7.2" "@web3-onboard/common" "^2.3.3" "@web3-onboard/common@^2.2.3", "@web3-onboard/common@^2.3.3": @@ -5005,10 +5440,10 @@ ethers "5.5.4" joi "17.9.1" -"@web3-onboard/core@^2.21.0": - version "2.21.0" - resolved "https://registry.yarnpkg.com/@web3-onboard/core/-/core-2.21.0.tgz#9055a9320f862911453010ba9a430a361c66202c" - integrity sha512-owxmSbCILFV0nQA46OGxcRrVdeZGL8SabBXll/PR0GAGsrZBYqNSE608ISd0luPt2Npy+dT8f8xUruFau+k/BA== +"@web3-onboard/core@^2.21.2": + version "2.21.2" + resolved "https://registry.yarnpkg.com/@web3-onboard/core/-/core-2.21.2.tgz#962683efc87b29ee9150ab8d7ea9568ea3b41dd5" + integrity sha512-apzVi2zWqs4ktZBBJ60x1e4odI1mSoZ2c69bXUg36A0xI0iRFQ9Od44peI3mfTDEru7hWsr81Nv6l+v3HRSKLw== dependencies: "@web3-onboard/common" "^2.3.3" bignumber.js "^9.0.0" @@ -5035,10 +5470,10 @@ joi "17.9.1" rxjs "^7.5.2" -"@web3-onboard/injected-wallets@^2.10.0": - version "2.10.5" - resolved "https://registry.yarnpkg.com/@web3-onboard/injected-wallets/-/injected-wallets-2.10.5.tgz#dde00818d184ba5d75034ef425676e8dfe01739b" - integrity sha512-pbjCrGHs2ydrGJ3gzm1mf6mhqNpnptWpbEr3hUOwyUZVnnQNGiWOP0bZHVSNqG2Xj061z2jhprugmjO4kenqBQ== +"@web3-onboard/injected-wallets@^2.10.7": + version "2.10.7" + resolved "https://registry.yarnpkg.com/@web3-onboard/injected-wallets/-/injected-wallets-2.10.7.tgz#4cabd563d358cda268a3c34aa004609ecfffea93" + integrity sha512-bxJgyJh3I4xcO3Z8PYwtJp/fg+40VxZgNgUZ/ZQOgTCcmHowenf0EwU7/FfZiqxnVkvJ3YO+agIiHtva3IipKA== dependencies: "@web3-onboard/common" "^2.3.3" joi "17.9.1" @@ -5086,20 +5521,156 @@ ethereumjs-util "^7.1.3" hdkey "^2.0.1" -"@web3-onboard/walletconnect@^2.4.5": - version "2.4.5" - resolved "https://registry.yarnpkg.com/@web3-onboard/walletconnect/-/walletconnect-2.4.5.tgz#72b62fc18720cd3871ec0be946e9393516025e51" - integrity sha512-uTAW15sx71VKjpLNJD9GJvXj39M4iR7S/hlFlccy9b8ANIAEZqf2Gm3AF3VHHiED1TIkvWJOT4ur6JFuU07b1Q== +"@web3-onboard/walletconnect@^2.4.7": + version "2.4.7" + resolved "https://registry.yarnpkg.com/@web3-onboard/walletconnect/-/walletconnect-2.4.7.tgz#e7b0c6494b5414fbf6450689941a01cb1eb7898c" + integrity sha512-Fh0tej56icsjTu0t0njoebXa/yZ7KGIQxfaTD31s/LsbUXMiyMFwqvyYQEaD1dvKlPMWLj/4AgRQlYYHTqjLLg== dependencies: "@ethersproject/providers" "5.5.0" "@walletconnect/client" "^1.8.0" - "@walletconnect/ethereum-provider" "2.9.2" - "@walletconnect/modal" "2.6.1" + "@walletconnect/ethereum-provider" "^2.10.1" + "@walletconnect/modal" "2.6.2" "@walletconnect/qrcode-modal" "^1.8.0" "@web3-onboard/common" "^2.3.3" joi "17.9.1" rxjs "^7.5.2" +"@webassemblyjs/ast@1.11.6", "@webassemblyjs/ast@^1.11.5": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.6.tgz#db046555d3c413f8966ca50a95176a0e2c642e24" + integrity sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q== + dependencies: + "@webassemblyjs/helper-numbers" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + +"@webassemblyjs/floating-point-hex-parser@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz#dacbcb95aff135c8260f77fa3b4c5fea600a6431" + integrity sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw== + +"@webassemblyjs/helper-api-error@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz#6132f68c4acd59dcd141c44b18cbebbd9f2fa768" + integrity sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q== + +"@webassemblyjs/helper-buffer@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz#b66d73c43e296fd5e88006f18524feb0f2c7c093" + integrity sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA== + +"@webassemblyjs/helper-numbers@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz#cbce5e7e0c1bd32cf4905ae444ef64cea919f1b5" + integrity sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g== + dependencies: + "@webassemblyjs/floating-point-hex-parser" "1.11.6" + "@webassemblyjs/helper-api-error" "1.11.6" + "@xtuc/long" "4.2.2" + +"@webassemblyjs/helper-wasm-bytecode@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz#bb2ebdb3b83aa26d9baad4c46d4315283acd51e9" + integrity sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA== + +"@webassemblyjs/helper-wasm-section@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz#ff97f3863c55ee7f580fd5c41a381e9def4aa577" + integrity sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g== + dependencies: + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-buffer" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/wasm-gen" "1.11.6" + +"@webassemblyjs/ieee754@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz#bb665c91d0b14fffceb0e38298c329af043c6e3a" + integrity sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg== + dependencies: + "@xtuc/ieee754" "^1.2.0" + +"@webassemblyjs/leb128@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.6.tgz#70e60e5e82f9ac81118bc25381a0b283893240d7" + integrity sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ== + dependencies: + "@xtuc/long" "4.2.2" + +"@webassemblyjs/utf8@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.6.tgz#90f8bc34c561595fe156603be7253cdbcd0fab5a" + integrity sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA== + +"@webassemblyjs/wasm-edit@^1.11.5": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz#c72fa8220524c9b416249f3d94c2958dfe70ceab" + integrity sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw== + dependencies: + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-buffer" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/helper-wasm-section" "1.11.6" + "@webassemblyjs/wasm-gen" "1.11.6" + "@webassemblyjs/wasm-opt" "1.11.6" + "@webassemblyjs/wasm-parser" "1.11.6" + "@webassemblyjs/wast-printer" "1.11.6" + +"@webassemblyjs/wasm-gen@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz#fb5283e0e8b4551cc4e9c3c0d7184a65faf7c268" + integrity sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA== + dependencies: + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/ieee754" "1.11.6" + "@webassemblyjs/leb128" "1.11.6" + "@webassemblyjs/utf8" "1.11.6" + +"@webassemblyjs/wasm-opt@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz#d9a22d651248422ca498b09aa3232a81041487c2" + integrity sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g== + dependencies: + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-buffer" "1.11.6" + "@webassemblyjs/wasm-gen" "1.11.6" + "@webassemblyjs/wasm-parser" "1.11.6" + +"@webassemblyjs/wasm-parser@1.11.6", "@webassemblyjs/wasm-parser@^1.11.5": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz#bb85378c527df824004812bbdb784eea539174a1" + integrity sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ== + dependencies: + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-api-error" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/ieee754" "1.11.6" + "@webassemblyjs/leb128" "1.11.6" + "@webassemblyjs/utf8" "1.11.6" + +"@webassemblyjs/wast-printer@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz#a7bf8dd7e362aeb1668ff43f35cb849f188eff20" + integrity sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A== + dependencies: + "@webassemblyjs/ast" "1.11.6" + "@xtuc/long" "4.2.2" + +"@xtuc/ieee754@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" + integrity sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA== + +"@xtuc/long@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" + integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== + +"@yarnpkg/lockfile@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" + integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== + JSONStream@^1.3.5: version "1.3.5" resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" @@ -5153,6 +5724,11 @@ acorn-globals@^7.0.0: acorn "^8.1.0" acorn-walk "^8.0.2" +acorn-import-assertions@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz#507276249d684797c84e0734ef84860334cfb1ac" + integrity sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA== + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -5168,7 +5744,7 @@ acorn@7.1.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.1.tgz#e35668de0b402f359de515c5482a1ab9f89a69bf" integrity sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg== -acorn@^8.0.4, acorn@^8.1.0, acorn@^8.4.1, acorn@^8.8.1, acorn@^8.8.2, acorn@^8.9.0: +acorn@^8.0.4, acorn@^8.1.0, acorn@^8.4.1, acorn@^8.7.1, acorn@^8.8.1, acorn@^8.8.2, acorn@^8.9.0: version "8.10.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5" integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw== @@ -5571,16 +6147,6 @@ babel-jest@^29.6.4: graceful-fs "^4.2.9" slash "^3.0.0" -babel-loader@^8.2.5: - version "8.3.0" - resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.3.0.tgz#124936e841ba4fe8176786d6ff28add1f134d6a8" - integrity sha512-H8SvsMF+m9t15HNLMipppzkC+Y2Yq+v3SonZyU70RBL/h1gxPkH08Ot8pEE9Z4Kd+czyWJClmFS8qzIP9OZ04Q== - dependencies: - find-cache-dir "^3.3.1" - loader-utils "^2.0.0" - make-dir "^3.1.0" - schema-utils "^2.6.5" - babel-plugin-istanbul@^6.1.1: version "6.1.1" resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz#fa88ec59232fd9b4e36dbbc540a8ec9a9b47da73" @@ -5685,6 +6251,11 @@ base-x@^4.0.0: resolved "https://registry.yarnpkg.com/base-x/-/base-x-4.0.0.tgz#d0e3b7753450c73f8ad2389b5c018a4af7b2224a" integrity sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw== +base64-arraybuffer-es6@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/base64-arraybuffer-es6/-/base64-arraybuffer-es6-0.7.0.tgz#dbe1e6c87b1bf1ca2875904461a7de40f21abc86" + integrity sha512-ESyU/U1CFZDJUdr+neHRhNozeCv72Y7Vm0m1DCbjX3KBjT6eYocvAJlSk6+8+HkVwXlT1FNxhGW6q3UKAlCvvw== + base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -5727,11 +6298,6 @@ big-integer@^1.6.48: resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686" integrity sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg== -big.js@^5.2.2: - version "5.2.2" - resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" - integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== - bigint-buffer@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/bigint-buffer/-/bigint-buffer-1.1.5.tgz#d038f31c8e4534c1f8d0015209bf34b4fa6dd442" @@ -5792,6 +6358,11 @@ blakejs@^1.1.0, blakejs@^1.2.1: resolved "https://registry.yarnpkg.com/blakejs/-/blakejs-1.2.1.tgz#5057e4206eadb4a97f7c0b6e197a505042fc3814" integrity sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ== +blo@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/blo/-/blo-1.1.1.tgz#ed781c5c516fba484ec8ec86105dc27f6c553209" + integrity sha512-1uGZInlRD4X1WQP2G1QjDGwGZ8HdGgFKqnzyRdA2TYYc0MOQCmCi37RTQ8oJuI0UF6DYFKXHwV/t1kZkO/fTaA== + blob-util@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/blob-util/-/blob-util-2.0.2.tgz#3b4e3c281111bb7f11128518006cdc60b403a1eb" @@ -5926,6 +6497,16 @@ browserify-aes@^1.0.6, browserify-aes@^1.2.0: inherits "^2.0.1" safe-buffer "^5.0.1" +browserslist@^4.14.5: + version "4.22.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.22.1.tgz#ba91958d1a59b87dab6fed8dfbcb3da5e2e9c619" + integrity sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ== + dependencies: + caniuse-lite "^1.0.30001541" + electron-to-chromium "^1.4.535" + node-releases "^2.0.13" + update-browserslist-db "^1.0.13" + browserslist@^4.21.10, browserslist@^4.21.9: version "4.21.10" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.10.tgz#dbbac576628c13d3b2231332cb2ec5a46e015bb0" @@ -6125,6 +6706,11 @@ caniuse-lite@^1.0.30001406, caniuse-lite@^1.0.30001517: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001524.tgz#1e14bce4f43c41a7deaeb5ebfe86664fe8dadb80" integrity sha512-Jj917pJtYg9HSJBF95HVX3Cdr89JUyLT4IZ8SvM5aDRni95swKgYi3TgYLH5hnGfPE/U1dg6IfZ50UsIlLkwSA== +caniuse-lite@^1.0.30001541: + version "1.0.30001546" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001546.tgz#10fdad03436cfe3cc632d3af7a99a0fb497407f0" + integrity sha512-zvtSJwuQFpewSyRrI3AsftF6rM0X80mZkChIt1spBGEvRglCrjTniXvinc8JKRoqTwXAgvqTImaN9igfSMtUBw== + caseless@~0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" @@ -6159,7 +6745,7 @@ chalk@^3.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0: +chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -6204,11 +6790,21 @@ chownr@^1.1.4: resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== +chrome-trace-event@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac" + integrity sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg== + ci-info@^3.2.0: version "3.8.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91" integrity sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw== +ci-info@^3.7.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" + integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== + cids@^0.7.1: version "0.7.5" resolved "https://registry.yarnpkg.com/cids/-/cids-0.7.5.tgz#60a08138a99bfb69b6be4ceb63bfef7a396b28b2" @@ -6248,7 +6844,7 @@ clean-stack@^2.0.0: resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== -clean-webpack-plugin@^4.0.0: +clean-webpack-plugin@4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/clean-webpack-plugin/-/clean-webpack-plugin-4.0.0.tgz#72947d4403d452f38ed61a9ff0ada8122aacd729" integrity sha512-WuWE1nyTNAyW5T7oNyys2EN0cfP2fdRxhxnIQWiAp0bMabPdHhoGxM8A6YL2GhqwgrPnnaemVE7nv5XJ2Fhh2w== @@ -6420,11 +7016,6 @@ commander@^2.20.0, commander@^2.20.3: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== -commander@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" - integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== - commander@^6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" @@ -6440,11 +7031,6 @@ common-tags@^1.8.0: resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.2.tgz#94ebb3c076d26032745fd54face7f688ef5ac9c6" integrity sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA== -commondir@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" - integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== - concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -6714,14 +7300,14 @@ cypress-file-upload@^5.0.8: resolved "https://registry.yarnpkg.com/cypress-file-upload/-/cypress-file-upload-5.0.8.tgz#d8824cbeaab798e44be8009769f9a6c9daa1b4a1" integrity sha512-+8VzNabRk3zG6x8f8BWArF/xA/W0VK4IZNx3MV0jFWrJS/qKn8eHfa5nU73P9fOQAgwHFJx7zjg4lwOnljMO8g== -cypress@^11.1.0: - version "11.2.0" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-11.2.0.tgz#63edef8c387b687066c5493f6f0ad7b9ced4b2b7" - integrity sha512-u61UGwtu7lpsNWLUma/FKNOsrjcI6wleNmda/TyKHe0dOBcVjbCPlp1N6uwFZ0doXev7f/91YDpU9bqDCFeBLA== +cypress@^12.15.0: + version "12.17.4" + resolved "https://registry.yarnpkg.com/cypress/-/cypress-12.17.4.tgz#b4dadf41673058493fa0d2362faa3da1f6ae2e6c" + integrity sha512-gAN8Pmns9MA5eCDFSDJXWKUpaL3IDd89N9TtIupjYnzLSmlpVr+ZR+vb4U/qaMp+lB6tBvAmt7504c3Z4RU5KQ== dependencies: - "@cypress/request" "^2.88.10" + "@cypress/request" "2.88.12" "@cypress/xvfb" "^1.2.4" - "@types/node" "^14.14.31" + "@types/node" "^16.18.39" "@types/sinonjs__fake-timers" "8.1.1" "@types/sizzle" "^2.3.2" arch "^2.2.0" @@ -6733,10 +7319,10 @@ cypress@^11.1.0: check-more-types "^2.24.0" cli-cursor "^3.1.0" cli-table3 "~0.6.1" - commander "^5.1.0" + commander "^6.2.1" common-tags "^1.8.0" dayjs "^1.10.4" - debug "^4.3.2" + debug "^4.3.4" enquirer "^2.3.6" eventemitter2 "6.4.7" execa "4.1.0" @@ -6751,12 +7337,13 @@ cypress@^11.1.0: listr2 "^3.8.3" lodash "^4.17.21" log-symbols "^4.0.0" - minimist "^1.2.6" + minimist "^1.2.8" ospath "^1.2.2" pretty-bytes "^5.6.0" + process "^0.11.10" proxy-from-env "1.0.0" request-progress "^3.0.0" - semver "^7.3.2" + semver "^7.5.3" supports-color "^8.1.1" tmp "~0.2.1" untildify "^4.0.0" @@ -7038,6 +7625,13 @@ domelementtype@^2.0.1, domelementtype@^2.2.0: resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== +domexception@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90" + integrity sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug== + dependencies: + webidl-conversions "^4.0.2" + domexception@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/domexception/-/domexception-4.0.0.tgz#4ad1be56ccadc86fc76d033353999a8037d03673" @@ -7141,6 +7735,11 @@ electron-to-chromium@^1.4.477: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.503.tgz#7bd43927ea9b4198697672d28d8fbd0da016a7a1" integrity sha512-LF2IQit4B0VrUHFeQkWhZm97KuJSGF2WJqq1InpY+ECpFRkXd8yTIaTtJxsO0OKDmiBYwWqcrNaXOurn2T2wiA== +electron-to-chromium@^1.4.535: + version "1.4.543" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.543.tgz#51116ffc9fba1ee93514d6a40d34676aa6d7d1c4" + integrity sha512-t2ZP4AcGE0iKCCQCBx/K2426crYdxD3YU6l0uK2EO3FZH0pbC4pFz/sZm2ruZsND6hQBTcDWWlo/MLpiOdif5g== + elliptic@6.5.4, elliptic@^6.4.0, elliptic@^6.4.1, elliptic@^6.5.2, elliptic@^6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb" @@ -7174,11 +7773,6 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== -emojis-list@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" - integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== - encode-utf8@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda" @@ -7196,7 +7790,7 @@ end-of-stream@^1.1.0, end-of-stream@^1.4.1: dependencies: once "^1.4.0" -enhanced-resolve@^5.12.0: +enhanced-resolve@^5.12.0, enhanced-resolve@^5.15.0: version "5.15.0" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz#1af946c7d93603eb88e9896cee4904dc012e9c35" integrity sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg== @@ -7316,6 +7910,11 @@ es-iterator-helpers@^1.0.12: iterator.prototype "^1.1.0" safe-array-concat "^1.0.0" +es-module-lexer@^1.2.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.3.1.tgz#c1b0dd5ada807a3b3155315911f364dc4e909db1" + integrity sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q== + es-set-tostringtag@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz#338d502f6f674301d710b80c8592de8a15f09cd8" @@ -7570,7 +8169,7 @@ eslint-rule-composer@^0.3.0: resolved "https://registry.yarnpkg.com/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz#79320c927b0c5c0d3d3d2b76c8b4a488f25bbaf9" integrity sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg== -eslint-scope@^5.1.1: +eslint-scope@5.1.1, eslint-scope@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== @@ -8195,7 +8794,7 @@ eventemitter3@^4.0.7: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== -events@^3.0.0, events@^3.3.0: +events@^3.0.0, events@^3.2.0, events@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== @@ -8346,6 +8945,13 @@ eyes@^0.1.8: resolved "https://registry.yarnpkg.com/eyes/-/eyes-0.1.8.tgz#62cf120234c683785d902348a800ef3e0cc20bc0" integrity sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ== +fake-indexeddb@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-4.0.2.tgz#e7a884158fa576e00f03e973b9874619947013e4" + integrity sha512-SdTwEhnakbgazc7W3WUXOJfGmhH0YfG4d+dRPOFoYDRTL6U5t8tvrmkf2W/C3W1jk2ylV7Wrnj44RASqpX/lEw== + dependencies: + realistic-structured-clone "^3.0.0" + fake-merkle-patricia-tree@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/fake-merkle-patricia-tree/-/fake-merkle-patricia-tree-1.0.1.tgz#4b8c3acfb520afadf9860b1f14cd8ce3402cddd3" @@ -8363,7 +8969,7 @@ fast-diff@^1.1.2: resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0" integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== -fast-glob@^3.2.7, fast-glob@^3.2.9, fast-glob@^3.3.1: +fast-glob@3.3.1, fast-glob@^3.2.7, fast-glob@^3.2.9, fast-glob@^3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.1.tgz#784b4e897340f3dbbef17413b3f11acf03c874c4" integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg== @@ -8406,6 +9012,13 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" +faye-websocket@0.11.4: + version "0.11.4" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da" + integrity sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g== + dependencies: + websocket-driver ">=0.5.1" + fb-watchman@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" @@ -8478,15 +9091,6 @@ finalhandler@1.2.0: statuses "2.0.1" unpipe "~1.0.0" -find-cache-dir@^3.3.1: - version "3.3.2" - resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" - integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig== - dependencies: - commondir "^1.0.1" - make-dir "^3.0.2" - pkg-dir "^4.1.0" - find-replace@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38" @@ -8522,6 +9126,45 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" +find-yarn-workspace-root@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz#f47fb8d239c900eb78179aa81b66673eac88f7bd" + integrity sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ== + dependencies: + micromatch "^4.0.2" + +firebase@^10.3.1: + version "10.4.0" + resolved "https://registry.yarnpkg.com/firebase/-/firebase-10.4.0.tgz#8b3c94765d69ebe706ff02e6bb0ed48092900fa6" + integrity sha512-3Z8WsNwA7kbcKGZ+nrTZ/ES518pk0K440ZJYD8nUNKN5hV6ll+unhUw30t1msedN6yIFjhsC/9OwT4Z0ohwO2w== + dependencies: + "@firebase/analytics" "0.10.0" + "@firebase/analytics-compat" "0.2.6" + "@firebase/app" "0.9.19" + "@firebase/app-check" "0.8.0" + "@firebase/app-check-compat" "0.3.7" + "@firebase/app-compat" "0.2.19" + "@firebase/app-types" "0.9.0" + "@firebase/auth" "1.3.0" + "@firebase/auth-compat" "0.4.6" + "@firebase/database" "1.0.1" + "@firebase/database-compat" "1.0.1" + "@firebase/firestore" "4.2.0" + "@firebase/firestore-compat" "0.3.18" + "@firebase/functions" "0.10.0" + "@firebase/functions-compat" "0.3.5" + "@firebase/installations" "0.6.4" + "@firebase/installations-compat" "0.2.4" + "@firebase/messaging" "0.12.4" + "@firebase/messaging-compat" "0.2.4" + "@firebase/performance" "0.6.4" + "@firebase/performance-compat" "0.2.4" + "@firebase/remote-config" "0.4.4" + "@firebase/remote-config-compat" "0.2.4" + "@firebase/storage" "0.11.2" + "@firebase/storage-compat" "0.3.2" + "@firebase/util" "1.9.3" + flat-cache@^3.0.4: version "3.1.0" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.1.0.tgz#0e54ab4a1a60fe87e2946b6b00657f1c99e1af3f" @@ -8613,7 +9256,7 @@ fs-extra@^7.0.0: jsonfile "^4.0.0" universalify "^0.1.0" -fs-extra@^9.0.1, fs-extra@^9.1.0: +fs-extra@^9.0.0, fs-extra@^9.0.1, fs-extra@^9.1.0: version "9.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== @@ -8823,7 +9466,7 @@ globalyzer@0.1.0: resolved "https://registry.yarnpkg.com/globalyzer/-/globalyzer-0.1.0.tgz#cb76da79555669a1519d5a8edf093afaa0bf1465" integrity sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q== -globby@^11.0.4, globby@^11.1.0: +globby@^11.1.0: version "11.1.0" resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== @@ -8894,7 +9537,7 @@ got@^11.8.5: p-cancelable "^2.0.0" responselike "^2.0.0" -graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.9: +graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -9067,6 +9710,11 @@ http-https@^1.0.0: resolved "https://registry.yarnpkg.com/http-https/-/http-https-1.0.0.tgz#2f908dd5f1db4068c058cd6e6d4ce392c913389b" integrity sha512-o0PWwVCSp3O0wS6FvNr6xfBCHgt0m1tvPLFOCc2iFDKTRAXhB7m8klDf7ErowFH8POa6dVdGatKU5I1YYwzUyg== +http-parser-js@>=0.5.1: + version "0.5.8" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.8.tgz#af23090d9ac4e24573de6f6aecc9d84a48bf20e3" + integrity sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q== + http-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" @@ -9154,7 +9802,17 @@ icss-utils@^5.0.0, icss-utils@^5.1.0: resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== -idb@^7.0.1: +idb-keyval@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/idb-keyval/-/idb-keyval-6.2.1.tgz#94516d625346d16f56f3b33855da11bfded2db33" + integrity sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg== + +idb@7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/idb/-/idb-7.0.1.tgz#d2875b3a2f205d854ee307f6d196f246fea590a7" + integrity sha512-UUxlE7vGWK5RfB/fDwEGgRf84DY/ieqNha6msMV99UsEMQhJ1RwbCd8AYBj3QMgnE3VZnfQvm4oKVCJTYlqIgg== + +idb@7.1.1, idb@^7.0.1: version "7.1.1" resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b" integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ== @@ -9358,6 +10016,11 @@ is-date-object@^1.0.1, is-date-object@^1.0.5: dependencies: has-tostringtag "^1.0.0" +is-docker@^2.0.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" + integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -9582,6 +10245,13 @@ is-what@^3.14.1: resolved "https://registry.yarnpkg.com/is-what/-/is-what-3.14.1.tgz#e1222f46ddda85dead0fd1c9df131760e77755c1" integrity sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA== +is-wsl@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" + integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + dependencies: + is-docker "^2.0.0" + isarray@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" @@ -10208,7 +10878,7 @@ json-buffer@3.0.1: resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== -json-parse-even-better-errors@^2.3.0: +json-parse-even-better-errors@^2.3.0, json-parse-even-better-errors@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== @@ -10273,7 +10943,7 @@ json5@^1.0.2: dependencies: minimist "^1.2.0" -json5@^2.1.2, json5@^2.1.3, json5@^2.2.0, json5@^2.2.2, json5@^2.2.3: +json5@^2.1.3, json5@^2.2.0, json5@^2.2.2, json5@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== @@ -10379,6 +11049,13 @@ keyvaluestorage-interface@^1.0.0: resolved "https://registry.yarnpkg.com/keyvaluestorage-interface/-/keyvaluestorage-interface-1.0.0.tgz#13ebdf71f5284ad54be94bd1ad9ed79adad515ff" integrity sha512-8t6Q3TclQ4uZynJY9IGr2+SsIGwK9JHcO6ootkHCGA0CrQCRy+VkouYNO2xicET6b9al7QKzpebNow+gkpCL8g== +klaw-sync@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/klaw-sync/-/klaw-sync-6.0.0.tgz#1fd2cfd56ebb6250181114f0a581167099c2b28c" + integrity sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ== + dependencies: + graceful-fs "^4.1.11" + kleur@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" @@ -10514,30 +11191,26 @@ lit-element@^3.3.0: "@lit/reactive-element" "^1.3.0" lit-html "^2.8.0" -lit-html@^2.7.0, lit-html@^2.8.0: +lit-html@^2.8.0: version "2.8.0" resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.8.0.tgz#96456a4bb4ee717b9a7d2f94562a16509d39bffa" integrity sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q== dependencies: "@types/trusted-types" "^2.0.2" -lit@2.7.6: - version "2.7.6" - resolved "https://registry.yarnpkg.com/lit/-/lit-2.7.6.tgz#810007b876ed43e0c70124de91831921598b1665" - integrity sha512-1amFHA7t4VaaDe+vdQejSVBklwtH9svGoG6/dZi9JhxtJBBlqY5D1RV7iLUYY0trCqQc4NfhYYZilZiVHt7Hxg== +lit@2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/lit/-/lit-2.8.0.tgz#4d838ae03059bf9cafa06e5c61d8acc0081e974e" + integrity sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA== dependencies: "@lit/reactive-element" "^1.6.0" lit-element "^3.3.0" - lit-html "^2.7.0" + lit-html "^2.8.0" -loader-utils@^2.0.0: - version "2.0.4" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" - integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw== - dependencies: - big.js "^5.2.2" - emojis-list "^3.0.0" - json5 "^2.1.2" +loader-runner@^4.2.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" + integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== locate-path@^3.0.0: version "3.0.0" @@ -10606,7 +11279,7 @@ lodash.uniqby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz#d99c07a669e9e6d24e1362dfe266c67616af1302" integrity sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww== -lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4: +lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -10720,13 +11393,6 @@ make-dir@^2.1.0: pify "^4.0.1" semver "^5.6.0" -make-dir@^3.0.2, make-dir@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" - integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== - dependencies: - semver "^6.0.0" - make-dir@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" @@ -10830,7 +11496,7 @@ micro-ftch@^0.3.1: resolved "https://registry.yarnpkg.com/micro-ftch/-/micro-ftch-0.3.1.tgz#6cb83388de4c1f279a034fb0cf96dfc050853c5f" integrity sha512-/0LLxhzP0tfiR5hcQebtudP56gUurs2CLkGarnCiB/OqEyUFQ6U3paQi/tgLv0hBJYt2rnr9MNpxz4fiiugstg== -micromatch@^4.0.4: +micromatch@^4.0.2, micromatch@^4.0.4: version "4.0.5" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== @@ -10843,7 +11509,7 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@^2.1.12, mime-types@^2.1.16, mime-types@~2.1.19, mime-types@~2.1.24, mime-types@~2.1.34: +mime-types@^2.1.12, mime-types@^2.1.16, mime-types@^2.1.27, mime-types@~2.1.19, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== @@ -10906,7 +11572,7 @@ minimatch@^5.0.1: dependencies: brace-expansion "^2.0.1" -minimist@^1.2.0, minimist@^1.2.6, minimist@~1.2.5: +minimist@^1.2.0, minimist@^1.2.6, minimist@^1.2.8, minimist@~1.2.5: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -11094,17 +11760,10 @@ negotiator@0.6.3: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== -next-pwa@^5.6.0: - version "5.6.0" - resolved "https://registry.yarnpkg.com/next-pwa/-/next-pwa-5.6.0.tgz#f7b1960c4fdd7be4253eb9b41b612ac773392bf4" - integrity sha512-XV8g8C6B7UmViXU8askMEYhWwQ4qc/XqJGnexbLV68hzKaGHZDMtHsm2TNxFcbR7+ypVuth/wwpiIlMwpRJJ5A== - dependencies: - babel-loader "^8.2.5" - clean-webpack-plugin "^4.0.0" - globby "^11.0.4" - terser-webpack-plugin "^5.3.3" - workbox-webpack-plugin "^6.5.4" - workbox-window "^6.5.4" +neo-async@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== next-tick@1, next-tick@^1.1.0: version "1.1.0" @@ -11155,6 +11814,13 @@ node-addon-api@^7.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.0.0.tgz#8136add2f510997b3b94814f4af1cce0b0e3962e" integrity sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA== +node-fetch@2.6.7: + version "2.6.7" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" + integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== + dependencies: + whatwg-url "^5.0.0" + node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.12, node-fetch@^2.6.7: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" @@ -11335,6 +12001,14 @@ onetime@^5.1.0, onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +open@^7.4.2: + version "7.4.2" + resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321" + integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q== + dependencies: + is-docker "^2.0.0" + is-wsl "^2.1.1" + opener@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" @@ -11357,6 +12031,11 @@ os-shim@^0.1.2: resolved "https://registry.yarnpkg.com/os-shim/-/os-shim-0.1.3.tgz#6b62c3791cf7909ea35ed46e17658bb417cb3917" integrity sha512-jd0cvB8qQ5uVt0lvCIexBaROw1KyKm5sbulg2fWOHjETisuCzWyt+eTZKEMs8v6HwzoGs8xik26jg7eCM6pS+A== +os-tmpdir@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== + ospath@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/ospath/-/ospath-1.2.2.tgz#1276639774a3f8ef2572f7fe4280e0ea4550c07b" @@ -11468,6 +12147,27 @@ parseurl@~1.3.3: resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== +patch-package@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-8.0.0.tgz#d191e2f1b6e06a4624a0116bcb88edd6714ede61" + integrity sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA== + dependencies: + "@yarnpkg/lockfile" "^1.1.0" + chalk "^4.1.2" + ci-info "^3.7.0" + cross-spawn "^7.0.3" + find-yarn-workspace-root "^2.0.0" + fs-extra "^9.0.0" + json-stable-stringify "^1.0.2" + klaw-sync "^6.0.0" + minimist "^1.2.6" + open "^7.4.2" + rimraf "^2.6.3" + semver "^7.5.3" + slash "^2.0.0" + tmp "^0.0.33" + yaml "^2.2.2" + path-browserify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" @@ -11611,7 +12311,7 @@ pirates@^4.0.4: resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg== -pkg-dir@^4.1.0, pkg-dir@^4.2.0: +pkg-dir@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== @@ -12224,6 +12924,15 @@ real-require@^0.1.0: resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.1.0.tgz#736ac214caa20632847b7ca8c1056a0767df9381" integrity sha512-r/H9MzAWtrv8aSVjPCMFpDMl5q66GqtmmRkRjpHTsp4zBAa+snZyiQNlMONiUmEJcsnaw0wCauJ2GWODr/aFkg== +realistic-structured-clone@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/realistic-structured-clone/-/realistic-structured-clone-3.0.0.tgz#7b518049ce2dad41ac32b421cd297075b00e3e35" + integrity sha512-rOjh4nuWkAqf9PWu6JVpOWD4ndI+JHfgiZeMmujYcPi+fvILUu7g6l26TC1K5aBIp34nV+jE1cDO75EKOfHC5Q== + dependencies: + domexception "^1.0.1" + typeson "^6.1.0" + typeson-registry "^1.0.0-alpha.20" + redent@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" @@ -12631,7 +13340,7 @@ safe-array-concat@^1.0.0: has-symbols "^1.0.3" isarray "^2.0.5" -safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@^5.2.1, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@^5.2.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -12716,16 +13425,7 @@ scheduler@^0.23.0: dependencies: loose-envify "^1.1.0" -schema-utils@^2.6.5: - version "2.7.1" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.1.tgz#1ca4f32d1b24c590c203b8e7a50bf0ea4cd394d7" - integrity sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg== - dependencies: - "@types/json-schema" "^7.0.5" - ajv "^6.12.4" - ajv-keywords "^3.5.2" - -schema-utils@^3.1.1: +schema-utils@^3.1.1, schema-utils@^3.2.0: version "3.3.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe" integrity sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg== @@ -12781,23 +13481,23 @@ semaphore@>=1.0.1, semaphore@^1.0.3: resolved "https://registry.yarnpkg.com/semaphore/-/semaphore-1.1.0.tgz#aaad8b86b20fe8e9b32b16dc2ee682a8cd26a8aa" integrity sha512-O4OZEaNtkMd/K0i6js9SL+gqy0ZCBMgUvlSqHKi4IBdjhe7wB8pwztUk1BbZ1fmrvpwFrPbHzqd2w5pTcJH6LA== +semver@7.5.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4: + version "7.5.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" + integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== + dependencies: + lru-cache "^6.0.0" + semver@^5.6.0: version "5.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== -semver@^6.0.0, semver@^6.3.0, semver@^6.3.1: +semver@^6.3.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4: - version "7.5.4" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" - integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== - dependencies: - lru-cache "^6.0.0" - semver@~5.4.1: version "5.4.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.4.1.tgz#e059c09d8571f0540823733433505d3a2f00b18e" @@ -12951,6 +13651,11 @@ sisteransi@^1.0.5: resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== +slash@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" + integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== + slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" @@ -13432,7 +14137,7 @@ table-layout@^1.0.2: typical "^5.2.0" wordwrapjs "^4.0.0" -tapable@^2.2.0: +tapable@^2.1.1, tapable@^2.2.0: version "2.2.1" resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== @@ -13465,7 +14170,7 @@ tempy@^0.6.0: type-fest "^0.16.0" unique-string "^2.0.0" -terser-webpack-plugin@^5.3.3: +terser-webpack-plugin@5.3.9, terser-webpack-plugin@^5.3.7: version "5.3.9" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz#832536999c51b46d468067f9e37662a3b96adfe1" integrity sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA== @@ -13570,6 +14275,13 @@ tiny-secp256k1@^1.1.6: elliptic "^6.4.0" nan "^2.13.2" +tmp@^0.0.33: + version "0.0.33" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" + integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== + dependencies: + os-tmpdir "~1.0.2" + tmp@~0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" @@ -13634,6 +14346,13 @@ tr46@^1.0.1: dependencies: punycode "^2.1.0" +tr46@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240" + integrity sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw== + dependencies: + punycode "^2.1.1" + tr46@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9" @@ -13912,6 +14631,20 @@ typescript@^4.6.2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== +typeson-registry@^1.0.0-alpha.20: + version "1.0.0-alpha.39" + resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz#9e0f5aabd5eebfcffd65a796487541196f4b1211" + integrity sha512-NeGDEquhw+yfwNhguLPcZ9Oj0fzbADiX4R0WxvoY8nGhy98IbzQy1sezjoEFWOywOboj/DWehI+/aUlRVrJnnw== + dependencies: + base64-arraybuffer-es6 "^0.7.0" + typeson "^6.0.0" + whatwg-url "^8.4.0" + +typeson@^6.0.0, typeson@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/typeson/-/typeson-6.1.0.tgz#5b2a53705a5f58ff4d6f82f965917cabd0d7448b" + integrity sha512-6FTtyGr8ldU0pfbvW/eOZrEtEkczHRUtduBnA90Jh9kMPCiFNnXIon3vF41N0S4tV1HHQt4Hk1j4srpESziCaA== + typical@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4" @@ -14022,6 +14755,14 @@ update-browserslist-db@^1.0.11: escalade "^3.1.1" picocolors "^1.0.0" +update-browserslist-db@^1.0.13: + version "1.0.13" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4" + integrity sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg== + dependencies: + escalade "^3.1.1" + picocolors "^1.0.0" + uri-js@^4.2.2: version "4.4.1" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" @@ -14123,10 +14864,10 @@ v8-to-istanbul@^9.0.1: "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^1.6.0" -valtio@1.11.0: - version "1.11.0" - resolved "https://registry.yarnpkg.com/valtio/-/valtio-1.11.0.tgz#c029dcd17a0f99d2fbec933721fe64cfd32a31ed" - integrity sha512-65Yd0yU5qs86b5lN1eu/nzcTgQ9/6YnD6iO+DDaDbQLn1Zv2w12Gwk43WkPlUBxk5wL/6cD5YMFf7kj6HZ1Kpg== +valtio@1.11.2: + version "1.11.2" + resolved "https://registry.yarnpkg.com/valtio/-/valtio-1.11.2.tgz#b8049c02dfe65620635d23ebae9121a741bb6530" + integrity sha512-1XfIxnUXzyswPAPXo1P3Pdx2mq/pIqZICkWN60Hby0d9Iqb+MEIpqgYVlbflvHdrp2YR/q3jyKWRPJJ100yxaw== dependencies: proxy-compare "2.5.1" use-sync-external-store "1.2.0" @@ -14194,7 +14935,7 @@ warning@^4.0.3: dependencies: loose-envify "^1.0.0" -watchpack@2.4.0: +watchpack@2.4.0, watchpack@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg== @@ -14576,6 +15317,11 @@ webidl-conversions@^4.0.2: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== +webidl-conversions@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" + integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== + webidl-conversions@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" @@ -14604,6 +15350,41 @@ webpack-sources@^1.4.3: source-list-map "^2.0.0" source-map "~0.6.1" +webpack-sources@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" + integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== + +webpack@^5.88.2: + version "5.88.2" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.88.2.tgz#f62b4b842f1c6ff580f3fcb2ed4f0b579f4c210e" + integrity sha512-JmcgNZ1iKj+aiR0OvTYtWQqJwq37Pf683dY9bVORwVbUrDhLhdn/PlO2sHsFHPkj7sHNQF3JwaAkp49V+Sq1tQ== + dependencies: + "@types/eslint-scope" "^3.7.3" + "@types/estree" "^1.0.0" + "@webassemblyjs/ast" "^1.11.5" + "@webassemblyjs/wasm-edit" "^1.11.5" + "@webassemblyjs/wasm-parser" "^1.11.5" + acorn "^8.7.1" + acorn-import-assertions "^1.9.0" + browserslist "^4.14.5" + chrome-trace-event "^1.0.2" + enhanced-resolve "^5.15.0" + es-module-lexer "^1.2.1" + eslint-scope "5.1.1" + events "^3.2.0" + glob-to-regexp "^0.4.1" + graceful-fs "^4.2.9" + json-parse-even-better-errors "^2.3.1" + loader-runner "^4.2.0" + mime-types "^2.1.27" + neo-async "^2.6.2" + schema-utils "^3.2.0" + tapable "^2.1.1" + terser-webpack-plugin "^5.3.7" + watchpack "^2.4.0" + webpack-sources "^3.2.3" + webrtc-adapter@^7.2.1: version "7.7.1" resolved "https://registry.yarnpkg.com/webrtc-adapter/-/webrtc-adapter-7.7.1.tgz#b2c227a6144983b35057df67bd984a7d4bfd17f1" @@ -14612,6 +15393,20 @@ webrtc-adapter@^7.2.1: rtcpeerconnection-shim "^1.2.15" sdp "^2.12.0" +websocket-driver@>=0.5.1: + version "0.7.4" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" + integrity sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg== + dependencies: + http-parser-js ">=0.5.1" + safe-buffer ">=5.1.0" + websocket-extensions ">=0.1.1" + +websocket-extensions@>=0.1.1: + version "0.1.4" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" + integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== + websocket@^1.0.32: version "1.0.34" resolved "https://registry.yarnpkg.com/websocket/-/websocket-1.0.34.tgz#2bdc2602c08bf2c82253b730655c0ef7dcab3111" @@ -14666,6 +15461,15 @@ whatwg-url@^7.0.0: tr46 "^1.0.1" webidl-conversions "^4.0.2" +whatwg-url@^8.4.0: + version "8.7.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77" + integrity sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg== + dependencies: + lodash "^4.7.0" + tr46 "^2.1.0" + webidl-conversions "^6.1.0" + which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" @@ -14757,25 +15561,25 @@ wordwrapjs@^4.0.0: reduce-flatten "^2.0.0" typical "^5.2.0" -workbox-background-sync@6.6.1: - version "6.6.1" - resolved "https://registry.yarnpkg.com/workbox-background-sync/-/workbox-background-sync-6.6.1.tgz#08d603a33717ce663e718c30cc336f74909aff2f" - integrity sha512-trJd3ovpWCvzu4sW0E8rV3FUyIcC0W8G+AZ+VcqzzA890AsWZlUGOTSxIMmIHVusUw/FDq1HFWfy/kC/WTRqSg== +workbox-background-sync@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-background-sync/-/workbox-background-sync-7.0.0.tgz#2b84b96ca35fec976e3bd2794b70e4acec46b3a5" + integrity sha512-S+m1+84gjdueM+jIKZ+I0Lx0BDHkk5Nu6a3kTVxP4fdj3gKouRNmhO8H290ybnJTOPfBDtTMXSQA/QLTvr7PeA== dependencies: idb "^7.0.1" - workbox-core "6.6.1" + workbox-core "7.0.0" -workbox-broadcast-update@6.6.1: - version "6.6.1" - resolved "https://registry.yarnpkg.com/workbox-broadcast-update/-/workbox-broadcast-update-6.6.1.tgz#0fad9454cf8e4ace0c293e5617c64c75d8a8c61e" - integrity sha512-fBhffRdaANdeQ1V8s692R9l/gzvjjRtydBOvR6WCSB0BNE2BacA29Z4r9/RHd9KaXCPl6JTdI9q0bR25YKP8TQ== +workbox-broadcast-update@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-broadcast-update/-/workbox-broadcast-update-7.0.0.tgz#7f611ca1a94ba8ac0aa40fa171c9713e0f937d22" + integrity sha512-oUuh4jzZrLySOo0tC0WoKiSg90bVAcnE98uW7F8GFiSOXnhogfNDGZelPJa+6KpGBO5+Qelv04Hqx2UD+BJqNQ== dependencies: - workbox-core "6.6.1" + workbox-core "7.0.0" -workbox-build@6.6.1: - version "6.6.1" - resolved "https://registry.yarnpkg.com/workbox-build/-/workbox-build-6.6.1.tgz#6010e9ce550910156761448f2dbea8cfcf759cb0" - integrity sha512-INPgDx6aRycAugUixbKgiEQBWD0MPZqU5r0jyr24CehvNuLPSXp/wGOpdRJmts656lNiXwqV7dC2nzyrzWEDnw== +workbox-build@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-build/-/workbox-build-7.0.0.tgz#02ab5ef2991b3369b8b9395703f08912212769b4" + integrity sha512-CttE7WCYW9sZC+nUYhQg3WzzGPr4IHmrPnjKiu3AMXsiNQKx+l4hHl63WTrnicLmKEKHScWDH8xsGBdrYgtBzg== dependencies: "@apideck/better-ajv-errors" "^0.3.1" "@babel/core" "^7.11.1" @@ -14799,132 +15603,132 @@ workbox-build@6.6.1: strip-comments "^2.0.1" tempy "^0.6.0" upath "^1.2.0" - workbox-background-sync "6.6.1" - workbox-broadcast-update "6.6.1" - workbox-cacheable-response "6.6.1" - workbox-core "6.6.1" - workbox-expiration "6.6.1" - workbox-google-analytics "6.6.1" - workbox-navigation-preload "6.6.1" - workbox-precaching "6.6.1" - workbox-range-requests "6.6.1" - workbox-recipes "6.6.1" - workbox-routing "6.6.1" - workbox-strategies "6.6.1" - workbox-streams "6.6.1" - workbox-sw "6.6.1" - workbox-window "6.6.1" - -workbox-cacheable-response@6.6.1: - version "6.6.1" - resolved "https://registry.yarnpkg.com/workbox-cacheable-response/-/workbox-cacheable-response-6.6.1.tgz#284c2b86be3f4fd191970ace8c8e99797bcf58e9" - integrity sha512-85LY4veT2CnTCDxaVG7ft3NKaFbH6i4urZXgLiU4AiwvKqS2ChL6/eILiGRYXfZ6gAwDnh5RkuDbr/GMS4KSag== - dependencies: - workbox-core "6.6.1" - -workbox-core@6.6.1: - version "6.6.1" - resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-6.6.1.tgz#7184776d4134c5ed2f086878c882728fc9084265" - integrity sha512-ZrGBXjjaJLqzVothoE12qTbVnOAjFrHDXpZe7coCb6q65qI/59rDLwuFMO4PcZ7jcbxY+0+NhUVztzR/CbjEFw== - -workbox-expiration@6.6.1: - version "6.6.1" - resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-6.6.1.tgz#a841fa36676104426dbfb9da1ef6a630b4f93739" - integrity sha512-qFiNeeINndiOxaCrd2DeL1Xh1RFug3JonzjxUHc5WkvkD2u5abY3gZL1xSUNt3vZKsFFGGORItSjVTVnWAZO4A== + workbox-background-sync "7.0.0" + workbox-broadcast-update "7.0.0" + workbox-cacheable-response "7.0.0" + workbox-core "7.0.0" + workbox-expiration "7.0.0" + workbox-google-analytics "7.0.0" + workbox-navigation-preload "7.0.0" + workbox-precaching "7.0.0" + workbox-range-requests "7.0.0" + workbox-recipes "7.0.0" + workbox-routing "7.0.0" + workbox-strategies "7.0.0" + workbox-streams "7.0.0" + workbox-sw "7.0.0" + workbox-window "7.0.0" + +workbox-cacheable-response@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-cacheable-response/-/workbox-cacheable-response-7.0.0.tgz#ee27c036728189eed69d25a135013053277482d2" + integrity sha512-0lrtyGHn/LH8kKAJVOQfSu3/80WDc9Ma8ng0p2i/5HuUndGttH+mGMSvOskjOdFImLs2XZIimErp7tSOPmu/6g== + dependencies: + workbox-core "7.0.0" + +workbox-core@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-7.0.0.tgz#dec114ec923cc2adc967dd9be1b8a0bed50a3545" + integrity sha512-81JkAAZtfVP8darBpfRTovHg8DGAVrKFgHpOArZbdFd78VqHr5Iw65f2guwjE2NlCFbPFDoez3D3/6ZvhI/rwQ== + +workbox-expiration@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-7.0.0.tgz#3d90bcf2a7577241de950f89784f6546b66c2baa" + integrity sha512-MLK+fogW+pC3IWU9SFE+FRStvDVutwJMR5if1g7oBJx3qwmO69BNoJQVaMXq41R0gg3MzxVfwOGKx3i9P6sOLQ== dependencies: idb "^7.0.1" - workbox-core "6.6.1" + workbox-core "7.0.0" -workbox-google-analytics@6.6.1: - version "6.6.1" - resolved "https://registry.yarnpkg.com/workbox-google-analytics/-/workbox-google-analytics-6.6.1.tgz#a07a6655ab33d89d1b0b0a935ffa5dea88618c5d" - integrity sha512-1TjSvbFSLmkpqLcBsF7FuGqqeDsf+uAXO/pjiINQKg3b1GN0nBngnxLcXDYo1n/XxK4N7RaRrpRlkwjY/3ocuA== +workbox-google-analytics@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-google-analytics/-/workbox-google-analytics-7.0.0.tgz#603b2c4244af1e85de0fb26287d4e17d3293452a" + integrity sha512-MEYM1JTn/qiC3DbpvP2BVhyIH+dV/5BjHk756u9VbwuAhu0QHyKscTnisQuz21lfRpOwiS9z4XdqeVAKol0bzg== dependencies: - workbox-background-sync "6.6.1" - workbox-core "6.6.1" - workbox-routing "6.6.1" - workbox-strategies "6.6.1" + workbox-background-sync "7.0.0" + workbox-core "7.0.0" + workbox-routing "7.0.0" + workbox-strategies "7.0.0" -workbox-navigation-preload@6.6.1: - version "6.6.1" - resolved "https://registry.yarnpkg.com/workbox-navigation-preload/-/workbox-navigation-preload-6.6.1.tgz#61a34fe125558dd88cf09237f11bd966504ea059" - integrity sha512-DQCZowCecO+wRoIxJI2V6bXWK6/53ff+hEXLGlQL4Rp9ZaPDLrgV/32nxwWIP7QpWDkVEtllTAK5h6cnhxNxDA== +workbox-navigation-preload@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-navigation-preload/-/workbox-navigation-preload-7.0.0.tgz#4913878dbbd97057181d57baa18d2bbdde085c6c" + integrity sha512-juWCSrxo/fiMz3RsvDspeSLGmbgC0U9tKqcUPZBCf35s64wlaLXyn2KdHHXVQrb2cqF7I0Hc9siQalainmnXJA== dependencies: - workbox-core "6.6.1" + workbox-core "7.0.0" -workbox-precaching@6.6.1: - version "6.6.1" - resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-6.6.1.tgz#dedeeba10a2d163d990bf99f1c2066ac0d1a19e2" - integrity sha512-K4znSJ7IKxCnCYEdhNkMr7X1kNh8cz+mFgx9v5jFdz1MfI84pq8C2zG+oAoeE5kFrUf7YkT5x4uLWBNg0DVZ5A== +workbox-precaching@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-7.0.0.tgz#3979ba8033aadf3144b70e9fe631d870d5fbaa03" + integrity sha512-EC0vol623LJqTJo1mkhD9DZmMP604vHqni3EohhQVwhJlTgyKyOkMrZNy5/QHfOby+39xqC01gv4LjOm4HSfnA== dependencies: - workbox-core "6.6.1" - workbox-routing "6.6.1" - workbox-strategies "6.6.1" + workbox-core "7.0.0" + workbox-routing "7.0.0" + workbox-strategies "7.0.0" -workbox-range-requests@6.6.1: - version "6.6.1" - resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-6.6.1.tgz#ddaf7e73af11d362fbb2f136a9063a4c7f507a39" - integrity sha512-4BDzk28govqzg2ZpX0IFkthdRmCKgAKreontYRC5YsAPB2jDtPNxqx3WtTXgHw1NZalXpcH/E4LqUa9+2xbv1g== +workbox-range-requests@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-7.0.0.tgz#97511901e043df27c1aa422adcc999a7751f52ed" + integrity sha512-SxAzoVl9j/zRU9OT5+IQs7pbJBOUOlriB8Gn9YMvi38BNZRbM+RvkujHMo8FOe9IWrqqwYgDFBfv6sk76I1yaQ== dependencies: - workbox-core "6.6.1" + workbox-core "7.0.0" -workbox-recipes@6.6.1: - version "6.6.1" - resolved "https://registry.yarnpkg.com/workbox-recipes/-/workbox-recipes-6.6.1.tgz#ea70d2b2b0b0bce8de0a9d94f274d4a688e69fae" - integrity sha512-/oy8vCSzromXokDA+X+VgpeZJvtuf8SkQ8KL0xmRivMgJZrjwM3c2tpKTJn6PZA6TsbxGs3Sc7KwMoZVamcV2g== +workbox-recipes@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-recipes/-/workbox-recipes-7.0.0.tgz#1a6a01c8c2dfe5a41eef0fed3fe517e8a45c6514" + integrity sha512-DntcK9wuG3rYQOONWC0PejxYYIDHyWWZB/ueTbOUDQgefaeIj1kJ7pdP3LZV2lfrj8XXXBWt+JDRSw1lLLOnww== dependencies: - workbox-cacheable-response "6.6.1" - workbox-core "6.6.1" - workbox-expiration "6.6.1" - workbox-precaching "6.6.1" - workbox-routing "6.6.1" - workbox-strategies "6.6.1" + workbox-cacheable-response "7.0.0" + workbox-core "7.0.0" + workbox-expiration "7.0.0" + workbox-precaching "7.0.0" + workbox-routing "7.0.0" + workbox-strategies "7.0.0" -workbox-routing@6.6.1: - version "6.6.1" - resolved "https://registry.yarnpkg.com/workbox-routing/-/workbox-routing-6.6.1.tgz#cba9a1c7e0d1ea11e24b6f8c518840efdc94f581" - integrity sha512-j4ohlQvfpVdoR8vDYxTY9rA9VvxTHogkIDwGdJ+rb2VRZQ5vt1CWwUUZBeD/WGFAni12jD1HlMXvJ8JS7aBWTg== +workbox-routing@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-routing/-/workbox-routing-7.0.0.tgz#6668438a06554f60645aedc77244a4fe3a91e302" + integrity sha512-8YxLr3xvqidnbVeGyRGkaV4YdlKkn5qZ1LfEePW3dq+ydE73hUUJJuLmGEykW3fMX8x8mNdL0XrWgotcuZjIvA== dependencies: - workbox-core "6.6.1" + workbox-core "7.0.0" -workbox-strategies@6.6.1: - version "6.6.1" - resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-6.6.1.tgz#38d0f0fbdddba97bd92e0c6418d0b1a2ccd5b8bf" - integrity sha512-WQLXkRnsk4L81fVPkkgon1rZNxnpdO5LsO+ws7tYBC6QQQFJVI6v98klrJEjFtZwzw/mB/HT5yVp7CcX0O+mrw== +workbox-strategies@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-7.0.0.tgz#dcba32b3f3074476019049cc490fe1a60ea73382" + integrity sha512-dg3qJU7tR/Gcd/XXOOo7x9QoCI9nk74JopaJaYAQ+ugLi57gPsXycVdBnYbayVj34m6Y8ppPwIuecrzkpBVwbA== dependencies: - workbox-core "6.6.1" + workbox-core "7.0.0" -workbox-streams@6.6.1: - version "6.6.1" - resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-6.6.1.tgz#b2f7ba7b315c27a6e3a96a476593f99c5d227d26" - integrity sha512-maKG65FUq9e4BLotSKWSTzeF0sgctQdYyTMq529piEN24Dlu9b6WhrAfRpHdCncRS89Zi2QVpW5V33NX8PgH3Q== +workbox-streams@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-7.0.0.tgz#36722aecd04785f88b6f709e541c094fc658c0f9" + integrity sha512-moVsh+5to//l6IERWceYKGiftc+prNnqOp2sgALJJFbnNVpTXzKISlTIsrWY+ogMqt+x1oMazIdHj25kBSq/HQ== dependencies: - workbox-core "6.6.1" - workbox-routing "6.6.1" + workbox-core "7.0.0" + workbox-routing "7.0.0" -workbox-sw@6.6.1: - version "6.6.1" - resolved "https://registry.yarnpkg.com/workbox-sw/-/workbox-sw-6.6.1.tgz#d4c4ca3125088e8b9fd7a748ed537fa0247bd72c" - integrity sha512-R7whwjvU2abHH/lR6kQTTXLHDFU2izht9kJOvBRYK65FbwutT4VvnUAJIgHvfWZ/fokrOPhfoWYoPCMpSgUKHQ== +workbox-sw@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-sw/-/workbox-sw-7.0.0.tgz#7350126411e3de1409f7ec243df8d06bb5b08b86" + integrity sha512-SWfEouQfjRiZ7GNABzHUKUyj8pCoe+RwjfOIajcx6J5mtgKkN+t8UToHnpaJL5UVVOf5YhJh+OHhbVNIHe+LVA== -workbox-webpack-plugin@^6.5.4: - version "6.6.1" - resolved "https://registry.yarnpkg.com/workbox-webpack-plugin/-/workbox-webpack-plugin-6.6.1.tgz#4f81cc1ad4e5d2cd7477a86ba83c84ee2d187531" - integrity sha512-zpZ+ExFj9NmiI66cFEApyjk7hGsfJ1YMOaLXGXBoZf0v7Iu6hL0ZBe+83mnDq3YYWAfA3fnyFejritjOHkFcrA== +workbox-webpack-plugin@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-webpack-plugin/-/workbox-webpack-plugin-7.0.0.tgz#6c61661a2cacde1239192a5877a041a2943d1a55" + integrity sha512-R1ZzCHPfzeJjLK2/TpKUhxSQ3fFDCxlWxgRhhSjMQLz3G2MlBnyw/XeYb34e7SGgSv0qG22zEhMIzjMNqNeKbw== dependencies: fast-json-stable-stringify "^2.1.0" pretty-bytes "^5.4.1" upath "^1.2.0" webpack-sources "^1.4.3" - workbox-build "6.6.1" + workbox-build "7.0.0" -workbox-window@6.6.1, workbox-window@^6.5.4: - version "6.6.1" - resolved "https://registry.yarnpkg.com/workbox-window/-/workbox-window-6.6.1.tgz#f22a394cbac36240d0dadcbdebc35f711bb7b89e" - integrity sha512-wil4nwOY58nTdCvif/KEZjQ2NP8uk3gGeRNy2jPBbzypU4BT4D9L8xiwbmDBpZlSgJd2xsT9FvSNU0gsxV51JQ== +workbox-window@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-window/-/workbox-window-7.0.0.tgz#a683ab33c896e4f16786794eac7978fc98a25d08" + integrity sha512-j7P/bsAWE/a7sxqTzXo3P2ALb1reTfZdvVp6OJ/uLr/C2kZAMvjeWGm8V4htQhor7DOvYg0sSbFN2+flT5U0qA== dependencies: "@types/trusted-types" "^2.0.2" - workbox-core "6.6.1" + workbox-core "7.0.0" wrap-ansi@^5.1.0: version "5.1.0" @@ -15094,6 +15898,11 @@ yaml@^1.10.0, yaml@^1.10.2: resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== +yaml@^2.2.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.2.tgz#f522db4313c671a0ca963a75670f1c12ea909144" + integrity sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg== + yargs-parser@^13.1.2: version "13.1.2" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" @@ -15148,7 +15957,7 @@ yargs@^15.3.1: y18n "^4.0.0" yargs-parser "^18.1.2" -yargs@^17.3.1: +yargs@^17.3.1, yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==