From 26fed9e8dab3956cb9804201db9c766a0eeb3ef6 Mon Sep 17 00:00:00 2001 From: Danica Shen Date: Fri, 31 Mar 2023 11:00:44 +0200 Subject: [PATCH 01/12] =?UTF-8?q?fix(18194):=20Redirect=20to=20extension?= =?UTF-8?q?=20expanded=20view=20when=20click=20back=20to=20sa=E2=80=A6=20(?= =?UTF-8?q?#18376)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(18194): Redirect to extension expanded view when click back to safety button * Bump phishing warning version --------- Co-authored-by: Dan J Miller --- app/scripts/metamask-controller.js | 11 +++++- app/scripts/platforms/extension.js | 25 +++++++++++--- app/scripts/platforms/extension.test.js | 30 ++++++++++++++++ package.json | 2 +- test/e2e/tests/phishing-detection.spec.js | 42 +++++++++++++++++++++++ yarn.lock | 10 +++--- 6 files changed, 108 insertions(+), 12 deletions(-) diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index d4fff36bb4f3..426fc9058195 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -3581,7 +3581,11 @@ export default class MetamaskController extends EventEmitter { phishingStream.on( 'data', createMetaRPCHandler( - { safelistPhishingDomain: this.safelistPhishingDomain.bind(this) }, + { + safelistPhishingDomain: this.safelistPhishingDomain.bind(this), + backToSafetyPhishingWarning: + this.backToSafetyPhishingWarning.bind(this), + }, phishingStream, ), ); @@ -4342,6 +4346,11 @@ export default class MetamaskController extends EventEmitter { return this.phishingController.bypass(hostname); } + async backToSafetyPhishingWarning() { + const extensionURL = this.platform.getExtensionURL(); + await this.platform.switchToAnotherURL(undefined, extensionURL); + } + /** * Locks MetaMask */ diff --git a/app/scripts/platforms/extension.js b/app/scripts/platforms/extension.js index 098c69efdbd9..1ac0c853e020 100644 --- a/app/scripts/platforms/extension.js +++ b/app/scripts/platforms/extension.js @@ -77,11 +77,7 @@ export default class ExtensionPlatform { return version; } - openExtensionInBrowser( - route = null, - queryString = null, - keepWindowOpen = false, - ) { + getExtensionURL(route = null, queryString = null) { let extensionURL = browser.runtime.getURL('home.html'); if (route) { @@ -92,7 +88,22 @@ export default class ExtensionPlatform { extensionURL += `?${queryString}`; } + return extensionURL; + } + + openExtensionInBrowser( + route = null, + queryString = null, + keepWindowOpen = false, + ) { + const extensionURL = this.getExtensionURL( + route, + queryString, + keepWindowOpen, + ); + this.openTab({ url: extensionURL }); + if ( getEnvironmentType() !== ENVIRONMENT_TYPE_BACKGROUND && !keepWindowOpen @@ -153,6 +164,10 @@ export default class ExtensionPlatform { return tab; } + async switchToAnotherURL(tabId, url) { + await browser.tabs.update(tabId, { url }); + } + async closeTab(tabId) { await browser.tabs.remove(tabId); } diff --git a/app/scripts/platforms/extension.test.js b/app/scripts/platforms/extension.test.js index 1b2067d783ac..27cc0e8b9052 100644 --- a/app/scripts/platforms/extension.test.js +++ b/app/scripts/platforms/extension.test.js @@ -1,10 +1,14 @@ import browser from 'webextension-polyfill'; import ExtensionPlatform from './extension'; +const TEST_URL = + 'chrome-extension://jjlgkphpeekojaidfeknpknnimdbleaf/home.html'; + jest.mock('webextension-polyfill', () => { return { runtime: { getManifest: jest.fn(), + getURL: jest.fn(), }, }; }); @@ -91,4 +95,30 @@ describe('extension platform', () => { ); }); }); + + describe('getExtensionURL', () => { + let extensionPlatform; + beforeEach(() => { + browser.runtime.getURL.mockReturnValue(TEST_URL); + extensionPlatform = new ExtensionPlatform(); + }); + + it('should return URL itself if no route or queryString is provided', () => { + expect(extensionPlatform.getExtensionURL()).toStrictEqual(TEST_URL); + }); + + it('should return URL with route when provided', () => { + const TEST_ROUTE = 'test-route'; + expect(extensionPlatform.getExtensionURL(TEST_ROUTE)).toStrictEqual( + `${TEST_URL}#${TEST_ROUTE}`, + ); + }); + + it('should return URL with queryString when provided', () => { + const QUERY_STRING = 'name=ferret'; + expect( + extensionPlatform.getExtensionURL(null, QUERY_STRING), + ).toStrictEqual(`${TEST_URL}?${QUERY_STRING}`); + }); + }); }); diff --git a/package.json b/package.json index b0cdf96a7fad..c7aa8f8ed698 100644 --- a/package.json +++ b/package.json @@ -375,7 +375,7 @@ "@metamask/eslint-config-nodejs": "^9.0.0", "@metamask/eslint-config-typescript": "^9.0.1", "@metamask/forwarder": "^1.1.0", - "@metamask/phishing-warning": "^2.0.1", + "@metamask/phishing-warning": "^2.1.0", "@metamask/test-dapp": "^5.6.0", "@sentry/cli": "^1.58.0", "@storybook/addon-a11y": "^6.5.13", diff --git a/test/e2e/tests/phishing-detection.spec.js b/test/e2e/tests/phishing-detection.spec.js index cc30a5ca0342..c82645e1f85f 100644 --- a/test/e2e/tests/phishing-detection.spec.js +++ b/test/e2e/tests/phishing-detection.spec.js @@ -289,4 +289,46 @@ describe('Phishing Detection', function () { }, ); }); + + it('should open a new extension expanded view when clicking back to safety button', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder().build(), + ganacheOptions, + title: this.test.title, + testSpecificMock: mockPhishingDetection, + dapp: true, + dappPaths: ['mock-page-with-disallowed-iframe'], + dappOptions: { + numberOfDapps: 2, + }, + failOnConsoleError: false, + }, + async ({ driver }) => { + await driver.navigate(); + await driver.fill('#password', 'correct horse battery staple'); + await driver.press('#password', driver.Key.ENTER); + await driver.openNewPage( + `http://localhost:8080?extensionUrl=${driver.extensionUrl}`, + ); + + const iframe = await driver.findElement('iframe'); + + await driver.switchToFrame(iframe); + await driver.clickElement({ + text: 'Open this warning in a new tab', + }); + await driver.switchToWindowWithTitle('MetaMask Phishing Detection'); + await driver.clickElement({ + text: 'Back to safety', + }); + + // Ensure we're redirected to wallet home page + const homePage = await driver.findElement('.home__main-view'); + const homePageDisplayed = await homePage.isDisplayed(); + + assert.equal(homePageDisplayed, true); + }, + ); + }); }); diff --git a/yarn.lock b/yarn.lock index e1de27f6859a..5f398c68d577 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4204,9 +4204,9 @@ __metadata: languageName: node linkType: hard -"@metamask/phishing-warning@npm:^2.0.1": - version: 2.0.1 - resolution: "@metamask/phishing-warning@npm:2.0.1" +"@metamask/phishing-warning@npm:^2.1.0": + version: 2.1.0 + resolution: "@metamask/phishing-warning@npm:2.1.0" dependencies: "@metamask/design-tokens": ^1.6.0 "@metamask/post-message-stream": ^6.0.0 @@ -4217,7 +4217,7 @@ __metadata: pump: ^3.0.0 punycode: ^2.1.1 ses: ^0.18.1 - checksum: caa3e596c3a67188e457307b43724c89121d60734353922d369932093f8618f96465ba7613b194dc2c57754399783dcdf1777c900afeff21bd5137f02688b686 + checksum: d04b3f817deafa077028f2d235ae694fa772a5ee6a02fc73c6f1fed6dbd1a7491370a25b9484157835f9e1a1773e738a1306ce0c854604eba99af86f1624f453 languageName: node linkType: hard @@ -24292,7 +24292,7 @@ __metadata: "@metamask/obs-store": ^5.0.0 "@metamask/permission-controller": ^3.1.0 "@metamask/phishing-controller": ^2.0.0 - "@metamask/phishing-warning": ^2.0.1 + "@metamask/phishing-warning": ^2.1.0 "@metamask/post-message-stream": ^6.0.0 "@metamask/providers": ^10.2.1 "@metamask/rate-limit-controller": ^1.0.0 From e895ff33f994a0252a44b03d8807a099ad0205fe Mon Sep 17 00:00:00 2001 From: David Walsh Date: Fri, 31 Mar 2023 04:11:07 -0500 Subject: [PATCH 02/12] NFTs: Use Unknown Collection instead of first NFT name (#18388) * NFTs: Use Unknown Collection instead of first NFT name * Use localization in hook * Get localization for previously owned * Fix tests --------- Co-authored-by: Dan Miller --- app/_locales/en/messages.json | 3 +++ test/e2e/nft/import-erc1155.spec.js | 2 +- ui/hooks/useNftsCollections.js | 12 +++++++++--- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 82873ab64c6f..882de250d805 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -2309,6 +2309,9 @@ "nfts": { "message": "NFTs" }, + "nftsPreviouslyOwned": { + "message": "Previously Owned" + }, "nickname": { "message": "Nickname" }, diff --git a/test/e2e/nft/import-erc1155.spec.js b/test/e2e/nft/import-erc1155.spec.js index 9be71fd05325..95992d770438 100644 --- a/test/e2e/nft/import-erc1155.spec.js +++ b/test/e2e/nft/import-erc1155.spec.js @@ -51,7 +51,7 @@ describe('Import ERC1155 NFT', function () { // Check the imported ERC1155 and its image are displayed in the ERC1155 tab const importedERC1155 = await driver.waitForSelector({ css: 'h5', - text: 'Rocks', + text: 'Unnamed collection', }); assert.equal(await importedERC1155.isDisplayed(), true); diff --git a/ui/hooks/useNftsCollections.js b/ui/hooks/useNftsCollections.js index d6b7251b730c..17d639a15287 100644 --- a/ui/hooks/useNftsCollections.js +++ b/ui/hooks/useNftsCollections.js @@ -4,11 +4,16 @@ import { isEqual } from 'lodash'; import { getNfts, getNftContracts } from '../ducks/metamask/metamask'; import { getCurrentChainId, getSelectedAddress } from '../selectors'; import { usePrevious } from './usePrevious'; +import { useI18nContext } from './useI18nContext'; export function useNftsCollections() { + const t = useI18nContext(); + const previouslyOwnedText = t('nftsPreviouslyOwned'); + const unknownCollectionText = t('unknownCollection'); + const [collections, setCollections] = useState({}); const [previouslyOwnedCollection, setPreviouslyOwnedCollection] = useState({ - collectionName: 'Previously Owned', + collectionName: previouslyOwnedText, nfts: [], }); const nfts = useSelector(getNfts); @@ -19,6 +24,7 @@ export function useNftsCollections() { const prevNfts = usePrevious(nfts); const prevChainId = usePrevious(chainId); const prevSelectedAddress = usePrevious(selectedAddress); + useEffect(() => { const getCollections = () => { setNftsLoading(true); @@ -27,7 +33,7 @@ export function useNftsCollections() { } const newCollections = {}; const newPreviouslyOwnedCollections = { - collectionName: 'Previously Owned', + collectionName: previouslyOwnedText, nfts: [], }; @@ -41,7 +47,7 @@ export function useNftsCollections() { ({ address }) => address === nft.address, ); newCollections[nft.address] = { - collectionName: collectionContract?.name || nft.name, + collectionName: collectionContract?.name || unknownCollectionText, collectionImage: collectionContract?.logo || nft.image, nfts: [nft], }; From 234fb4ac5db5ad588739dd6e12038b40a2889716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Fri, 31 Mar 2023 10:30:10 +0100 Subject: [PATCH 03/12] [MMI] confirm-add-custodian-token component (#18261) * adds component with locales and test * adds feedback from review * adds storeis file * update snapshots * prettier * clean up stories file * relocation * review fixes * Update ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.js Co-authored-by: George Marshall * Update ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.js Co-authored-by: George Marshall * Update ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.js Co-authored-by: George Marshall * Button path remove * Update ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.js Co-authored-by: George Marshall * pulled * stories file update location --------- Co-authored-by: George Marshall --- app/_locales/en/messages.json | 15 + .../__snapshots__/jwt-url-form.test.js.snap | 10 +- .../jwt-url-form/jwt-url-form.js | 25 +- .../jwt-url-form/jwt-url-form.scss | 28 -- .../jwt-url-form/jwt-url-form.test.js | 17 +- ui/helpers/constants/routes.ts | 6 + .../confirm-add-custodian-token.js | 268 ++++++++++++++++++ .../confirm-add-custodian-token.stories.js | 43 +++ .../confirm-add-custodian-token.test.js | 126 ++++++++ .../confirm-add-custodian-token/index.js | 1 + .../confirm-add-custodian-token/index.scss | 8 + 11 files changed, 501 insertions(+), 46 deletions(-) create mode 100644 ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.js create mode 100644 ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.stories.js create mode 100644 ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.test.js create mode 100644 ui/pages/institutional/confirm-add-custodian-token/index.js create mode 100644 ui/pages/institutional/confirm-add-custodian-token/index.scss diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 882de250d805..c9669541ecc0 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -103,6 +103,9 @@ "SIWEWarningTitle": { "message": "Are you sure?" }, + "ShowMore": { + "message": "Show more" + }, "about": { "message": "About" }, @@ -348,6 +351,9 @@ "amount": { "message": "Amount" }, + "apiUrl": { + "message": "API URL" + }, "appDescription": { "message": "An Ethereum Wallet in your Browser", "description": "The description of the application" @@ -908,6 +914,12 @@ "curveMediumGasEstimate": { "message": "Market gas estimate graph" }, + "custodian": { + "message": "Custodian" + }, + "custodianAccount": { + "message": "Custodian account" + }, "custom": { "message": "Advanced" }, @@ -2118,6 +2130,9 @@ "missingToken": { "message": "Don't see your token?" }, + "mmiAddToken": { + "message": "The page at $1 would like to authorise the following custodian token in MetaMask Institutional" + }, "mobileSyncWarning": { "message": "The 'Sync with extension' feature is temporarily disabled. If you want to use your extension wallet on MetaMask mobile, then on your mobile app: go back to the wallet setup options and select the 'Import with Secret Recovery Phrase' option. Use your extension wallet's secret phrase to then import your wallet into mobile." }, diff --git a/ui/components/institutional/jwt-url-form/__snapshots__/jwt-url-form.test.js.snap b/ui/components/institutional/jwt-url-form/__snapshots__/jwt-url-form.test.js.snap index 6c15c0327d35..cdf419d8938e 100644 --- a/ui/components/institutional/jwt-url-form/__snapshots__/jwt-url-form.test.js.snap +++ b/ui/components/institutional/jwt-url-form/__snapshots__/jwt-url-form.test.js.snap @@ -3,16 +3,16 @@ exports[`JwtUrlForm shows JWT text area when no jwt token exists 1`] = `

input text

@@ -25,10 +25,10 @@ exports[`JwtUrlForm shows JWT text area when no jwt token exists 1`] = `

{ const showJwtDropdown = props.jwtList.length >= 1; return ( - + {showJwtDropdown && ( { )} {showJwtDropdown && !showAddNewToken && ( {t('or')} + + + )} + + + ); +}; + +export default ConfirmAddCustodianToken; diff --git a/ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.stories.js b/ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.stories.js new file mode 100644 index 000000000000..01055b785d8d --- /dev/null +++ b/ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.stories.js @@ -0,0 +1,43 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import configureStore from '../../../store/store'; +import testData from '../../../../.storybook/test-data'; +import ConfirmAddCustodianToken from '.'; + +const customData = { + ...testData, + metamask: { + ...testData.metamask, + institutionalFeatures: { + complianceProjectId: '', + connectRequests: [ + { + labels: [ + { + key: 'service', + value: 'test', + }, + ], + origin: 'origin', + token: 'awesomeTestToken', + feature: 'custodian', + service: 'Saturn', + apiUrl: 'https://www.apiurl.net/v1', + chainId: 1, + }, + ], + }, + }, +}; + +const store = configureStore(customData); + +export default { + title: 'Pages/Institutional/ConfirmAddCustodianToken', + decorators: [(story) => {story()}], + component: ConfirmAddCustodianToken, +}; + +export const DefaultStory = () => ; + +DefaultStory.storyName = 'ConfirmAddCustodianToken'; diff --git a/ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.test.js b/ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.test.js new file mode 100644 index 000000000000..37660c0da7f9 --- /dev/null +++ b/ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.test.js @@ -0,0 +1,126 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import configureMockStore from 'redux-mock-store'; +import { renderWithProvider } from '../../../../test/lib/render-helpers'; +import ConfirmAddCustodianToken from './confirm-add-custodian-token'; + +describe('Confirm Add Custodian Token', () => { + const mockStore = { + metamask: { + provider: { + type: 'test', + }, + preferences: { + useNativeCurrencyAsPrimaryCurrency: true, + }, + institutionalFeatures: { + complianceProjectId: '', + connectRequests: [ + { + labels: [ + { + key: 'service', + value: 'test', + }, + ], + origin: 'origin', + token: 'testToken', + feature: 'custodian', + service: 'Jupiter', + apiUrl: 'https://', + chainId: 1, + }, + ], + }, + }, + history: { + push: '/', + mostRecentOverviewPage: '/', + }, + }; + + const store = configureMockStore()(mockStore); + + it('opens confirm add custodian token with correct token', () => { + renderWithProvider(, store); + + const tokenContainer = screen.getByText('...testToken'); + expect(tokenContainer).toBeInTheDocument(); + }); + + it('shows the custodian on cancel click', () => { + renderWithProvider(, store); + + const cancelButton = screen.getByTestId('cancel-btn'); + + fireEvent.click(cancelButton); + + expect(screen.getByText('Custodian')).toBeInTheDocument(); + }); + + it('tries to connect to custodian with empty token', async () => { + const customMockedStore = { + metamask: { + provider: { + type: 'test', + }, + preferences: { + useNativeCurrencyAsPrimaryCurrency: true, + }, + institutionalFeatures: { + complianceProjectId: '', + connectRequests: [ + { + labels: [ + { + key: 'service', + value: 'test', + }, + ], + origin: 'origin', + token: '', + feature: 'custodian', + service: 'Jupiter', + apiUrl: 'https://', + chainId: 1, + }, + ], + }, + }, + history: { + push: '/', + mostRecentOverviewPage: '/', + }, + }; + + const customStore = configureMockStore()(customMockedStore); + + renderWithProvider(, customStore); + + const confirmButton = screen.getByTestId('confirm-btn'); + fireEvent.click(confirmButton); + + const errorMessage = screen.getByTestId('connect-custodian-token-error'); + + expect(errorMessage).toBeVisible(); + }); + + it('clicks the confirm button and shows the test value', async () => { + renderWithProvider(, store); + + const confirmButton = screen.getByTestId('confirm-btn'); + fireEvent.click(confirmButton); + + expect(screen.getByText('test')).toBeInTheDocument(); + }); + + it('shows the error area', () => { + renderWithProvider(, store); + + const confirmButton = screen.getByTestId('confirm-btn'); + + fireEvent.click(confirmButton); + + expect(screen.getByTestId('error-message')).toBeVisible(); + }); +}); diff --git a/ui/pages/institutional/confirm-add-custodian-token/index.js b/ui/pages/institutional/confirm-add-custodian-token/index.js new file mode 100644 index 000000000000..39b01e55ee6c --- /dev/null +++ b/ui/pages/institutional/confirm-add-custodian-token/index.js @@ -0,0 +1 @@ +export { default } from './confirm-add-custodian-token'; diff --git a/ui/pages/institutional/confirm-add-custodian-token/index.scss b/ui/pages/institutional/confirm-add-custodian-token/index.scss new file mode 100644 index 000000000000..17845c049631 --- /dev/null +++ b/ui/pages/institutional/confirm-add-custodian-token/index.scss @@ -0,0 +1,8 @@ +.add_custodian_token_confirm { + &__token, + &__url { + height: auto; + overflow-wrap: break-word; + opacity: 0.8; + } +} From c2618176f64cc985787ad2728e85a91e7b791e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Albert=20Oliv=C3=A9?= Date: Fri, 31 Mar 2023 13:23:11 +0200 Subject: [PATCH 04/12] [MMI] Added code fencing in info tab component (#17910) * Added code fencing in info tab component * Fixed storybook urls * Removed isBeta mmi code fencing --- ui/helpers/constants/common.ts | 4 +- .../settings/info-tab/info-tab.component.js | 64 +++++++++++++++---- 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/ui/helpers/constants/common.ts b/ui/helpers/constants/common.ts index da6e58a345a9..62e8077b8c38 100644 --- a/ui/helpers/constants/common.ts +++ b/ui/helpers/constants/common.ts @@ -12,8 +12,8 @@ _supportRequestLink = ///: BEGIN:ONLY_INCLUDE_IN(mmi) _supportRequestLink = 'https://mmi-support.zendesk.com/hc/en-us/requests/new'; -const _mmiWebSite = 'https://metamask.io/institutions/'; -export const MMI_WEB_SITE = _mmiWebSite; +export const SUPPORT_LINK = 'https://mmi-support.zendesk.com/hc/en-us'; +export const MMI_WEB_SITE = 'https://metamask.io/institutions/'; ///: END:ONLY_INCLUDE_IN export const SUPPORT_REQUEST_LINK = _supportRequestLink; diff --git a/ui/pages/settings/info-tab/info-tab.component.js b/ui/pages/settings/info-tab/info-tab.component.js index 5c6e4b66ecc4..8fef74d1210a 100644 --- a/ui/pages/settings/info-tab/info-tab.component.js +++ b/ui/pages/settings/info-tab/info-tab.component.js @@ -4,7 +4,12 @@ import PropTypes from 'prop-types'; import Button from '../../../components/ui/button'; import { Tag } from '../../../components/component-library'; -import { SUPPORT_REQUEST_LINK } from '../../../helpers/constants/common'; +import { + SUPPORT_REQUEST_LINK, + ///: BEGIN:ONLY_INCLUDE_IN(mmi) + MMI_WEB_SITE, + ///: END:ONLY_INCLUDE_IN +} from '../../../helpers/constants/common'; import { isBeta } from '../../../helpers/utils/build-types'; import { getNumberOfSettingsInSection, @@ -47,6 +52,17 @@ export default class InfoTab extends PureComponent { renderInfoLinks() { const { t } = this.context; + let privacyUrl, siteUrl; + + ///: BEGIN:ONLY_INCLUDE_IN(mmi) + privacyUrl = 'https://consensys.net/codefi/about/privacy-policy/'; + siteUrl = MMI_WEB_SITE; + ///: END:ONLY_INCLUDE_IN + + ///: BEGIN:ONLY_INCLUDE_IN(main,beta,flask) + privacyUrl = 'https://metamask.io/privacy.html'; + siteUrl = 'https://metamask.io/'; + ///: END:ONLY_INCLUDE_IN return (
@@ -56,7 +72,7 @@ export default class InfoTab extends PureComponent {
); } diff --git a/ui/components/app/menu-bar/menu-bar.js b/ui/components/app/menu-bar/menu-bar.js index bf80cad5da6e..3ddc301af563 100644 --- a/ui/components/app/menu-bar/menu-bar.js +++ b/ui/components/app/menu-bar/menu-bar.js @@ -30,7 +30,7 @@ export default function MenuBar() { return (
- {showStatus ? ( + {showStatus ? ( // TODO: Move the connection status menu icon to the correct position in header once we implement the new header history.push(CONNECTED_ACCOUNTS_ROUTE)} /> diff --git a/ui/components/multichain/index.js b/ui/components/multichain/index.js index b686c0948873..debaef289f7b 100644 --- a/ui/components/multichain/index.js +++ b/ui/components/multichain/index.js @@ -7,3 +7,4 @@ export { GlobalMenu } from './global-menu'; export { MultichainImportTokenLink } from './multichain-import-token-link'; export { MultichainTokenListItem } from './multichain-token-list-item'; export { AddressCopyButton } from './address-copy-button'; +export { MultichainConnectedSiteMenu } from './multichain-connected-site-menu'; diff --git a/ui/components/multichain/multichain-components.scss b/ui/components/multichain/multichain-components.scss index a2a2dbd20258..ea41f990e2f8 100644 --- a/ui/components/multichain/multichain-components.scss +++ b/ui/components/multichain/multichain-components.scss @@ -8,4 +8,5 @@ @import 'account-list-item/index'; @import 'account-list-menu/index'; @import 'account-picker/index'; +@import 'multichain-connected-site-menu/index'; @import 'multichain-token-list-item/multichain-token-list-item'; diff --git a/ui/components/multichain/multichain-connected-site-menu/__snapshots__/multichain-connected-site-menu.test.js.snap b/ui/components/multichain/multichain-connected-site-menu/__snapshots__/multichain-connected-site-menu.test.js.snap new file mode 100644 index 000000000000..876d9d2c350f --- /dev/null +++ b/ui/components/multichain/multichain-connected-site-menu/__snapshots__/multichain-connected-site-menu.test.js.snap @@ -0,0 +1,112 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Multichain Connected Site Menu should render the site menu in connected state 1`] = ` +
+
+
+
+
+ +
+
+
+
+
+
+
+
+`; + +exports[`Multichain Connected Site Menu should render the site menu in not connected state 1`] = ` +
+
+
+
+
+ +
+
+
+
+
+
+
+
+`; + +exports[`Multichain Connected Site Menu should render the site menu in not connected to current account state 1`] = ` +
+
+
+
+
+ +
+
+
+
+
+
+
+
+`; diff --git a/ui/components/multichain/multichain-connected-site-menu/index.js b/ui/components/multichain/multichain-connected-site-menu/index.js new file mode 100644 index 000000000000..a7724dd6b9ee --- /dev/null +++ b/ui/components/multichain/multichain-connected-site-menu/index.js @@ -0,0 +1 @@ +export { MultichainConnectedSiteMenu } from './multichain-connected-site-menu'; diff --git a/ui/components/multichain/multichain-connected-site-menu/index.scss b/ui/components/multichain/multichain-connected-site-menu/index.scss new file mode 100644 index 000000000000..3b7c363c46fd --- /dev/null +++ b/ui/components/multichain/multichain-connected-site-menu/index.scss @@ -0,0 +1,23 @@ +.multichain-connected-site-menu { + &__badge { + height: 16px; + width: 16px; + } + + &__badge.not-connected { + height: 10px; + width: 10px; + } + + &__badge.not-connected::after { + content: ''; + position: absolute; + top: -3px; + left: -3px; + right: -3px; + bottom: -3px; + background: var(--color-background-default); + z-index: -1; + border-radius: 50%; + } +} diff --git a/ui/components/multichain/multichain-connected-site-menu/multichain-connected-site-menu.js b/ui/components/multichain/multichain-connected-site-menu/multichain-connected-site-menu.js new file mode 100644 index 000000000000..23f8076ecf18 --- /dev/null +++ b/ui/components/multichain/multichain-connected-site-menu/multichain-connected-site-menu.js @@ -0,0 +1,98 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { useSelector } from 'react-redux'; +import { + STATUS_CONNECTED_TO_ANOTHER_ACCOUNT, + STATUS_NOT_CONNECTED, +} from '../../../helpers/constants/connected-sites'; +import { + BackgroundColor, + BorderColor, + BorderRadius, + IconColor, + Size, +} from '../../../helpers/constants/design-system'; +import { BadgeWrapper, Icon, ICON_NAMES } from '../../component-library'; +import Box from '../../ui/box'; +import { getSelectedIdentity } from '../../../selectors'; +import Tooltip from '../../ui/tooltip'; +import { useI18nContext } from '../../../hooks/useI18nContext'; + +export const MultichainConnectedSiteMenu = ({ + className, + globalMenuColor, + status, + text, +}) => { + const t = useI18nContext(); + const selectedAccount = useSelector(getSelectedIdentity); + return ( + + + + } + > + + + + + ); +}; + +MultichainConnectedSiteMenu.propTypes = { + /** + * Additional classNames to be added to the MultichainConnectedSiteMenu + */ + className: PropTypes.string, + /** + * Background color based on the connection status + */ + globalMenuColor: PropTypes.string.isRequired, + /** + * Connection status string + */ + status: PropTypes.string.isRequired, + /** + * Connection status message + */ + text: PropTypes.string, +}; diff --git a/ui/components/multichain/multichain-connected-site-menu/multichain-connected-site-menu.stories.js b/ui/components/multichain/multichain-connected-site-menu/multichain-connected-site-menu.stories.js new file mode 100644 index 000000000000..d959caa3a50b --- /dev/null +++ b/ui/components/multichain/multichain-connected-site-menu/multichain-connected-site-menu.stories.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { + STATUS_CONNECTED, + STATUS_CONNECTED_TO_ANOTHER_ACCOUNT, + STATUS_NOT_CONNECTED, +} from '../../../helpers/constants/connected-sites'; +import { + BackgroundColor, + Color, +} from '../../../helpers/constants/design-system'; +import { MultichainConnectedSiteMenu } from './multichain-connected-site-menu'; + +export default { + title: 'Components/Multichain/MultichainConnectedSiteMenu', + component: MultichainConnectedSiteMenu, + argTypes: { + globalMenuColor: { + control: 'text', + }, + text: { + control: 'text', + }, + status: { + control: 'text', + }, + }, + args: { + globalMenuColor: Color.iconAlternative, + status: STATUS_NOT_CONNECTED, + }, +}; + +const Template = (args) => { + return ; +}; + +export const DefaultStory = Template.bind({}); + +export const ConnectedStory = Template.bind({}); +ConnectedStory.args = { + globalMenuColor: Color.successDefault, + text: 'connected', + status: STATUS_CONNECTED, +}; + +export const ConnectedtoAnotherAccountStory = Template.bind({}); +ConnectedtoAnotherAccountStory.args = { + globalMenuColor: BackgroundColor.backgroundDefault, + text: 'not connected', + status: STATUS_CONNECTED_TO_ANOTHER_ACCOUNT, +}; diff --git a/ui/components/multichain/multichain-connected-site-menu/multichain-connected-site-menu.test.js b/ui/components/multichain/multichain-connected-site-menu/multichain-connected-site-menu.test.js new file mode 100644 index 000000000000..6f2a711e3ee5 --- /dev/null +++ b/ui/components/multichain/multichain-connected-site-menu/multichain-connected-site-menu.test.js @@ -0,0 +1,87 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { renderWithProvider } from '../../../../test/jest'; +import { + STATUS_CONNECTED, + STATUS_CONNECTED_TO_ANOTHER_ACCOUNT, + STATUS_NOT_CONNECTED, +} from '../../../helpers/constants/connected-sites'; +import { + BackgroundColor, + Color, +} from '../../../helpers/constants/design-system'; +import { MultichainConnectedSiteMenu } from './multichain-connected-site-menu'; + +describe('Multichain Connected Site Menu', () => { + const selectedAddress = '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b'; + + const identities = { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': { + address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + name: 'Account 1', + }, + '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b': { + address: '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b', + name: 'Account 2', + }, + }; + + const accounts = { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': { + address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + balance: '0x0', + }, + '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b': { + address: '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b', + balance: '0x0', + }, + }; + const mockStore = { + metamask: { + selectedAddress, + identities, + accounts, + }, + }; + it('should render the site menu in connected state', () => { + const props = { + globalMenuColor: Color.successDefault, + text: 'connected', + status: STATUS_CONNECTED, + }; + const store = configureMockStore()(mockStore); + const { getByTestId, container } = renderWithProvider( + , + store, + ); + expect(getByTestId('connection-menu')).toBeDefined(); + expect(container).toMatchSnapshot(); + }); + it('should render the site menu in not connected state', () => { + const props = { + globalMenuColor: Color.iconAlternative, + status: STATUS_NOT_CONNECTED, + }; + const store = configureMockStore()(mockStore); + const { getByTestId, container } = renderWithProvider( + , + store, + ); + expect(getByTestId('connection-menu')).toBeDefined(); + expect(container).toMatchSnapshot(); + }); + it('should render the site menu in not connected to current account state', () => { + const props = { + globalMenuColor: BackgroundColor.backgroundDefault, + text: 'not connected', + status: STATUS_CONNECTED_TO_ANOTHER_ACCOUNT, + }; + const store = configureMockStore()(mockStore); + const { getByTestId, container } = renderWithProvider( + , + store, + ); + expect(getByTestId('connection-menu')).toBeDefined(); + expect(container).toMatchSnapshot(); + }); +}); From a71a06965ca3a4399a759434ffd0704a6b6528fc Mon Sep 17 00:00:00 2001 From: David Walsh Date: Fri, 31 Mar 2023 12:58:25 -0500 Subject: [PATCH 12/12] UX: Multichain: Network Menu (#18229) --- app/_locales/en/messages.json | 3 + ui/components/multichain/index.js | 2 + .../multichain/multichain-components.scss | 3 + .../network-list-item.test.js.snap | 41 ++++++ .../multichain/network-list-item/index.js | 1 + .../multichain/network-list-item/index.scss | 51 ++++++++ .../network-list-item/network-list-item.js | 108 ++++++++++++++++ .../network-list-item.stories.js | 67 ++++++++++ .../network-list-item.test.js | 81 ++++++++++++ .../multichain/network-list-menu/index.js | 1 + .../multichain/network-list-menu/index.scss | 4 + .../network-list-menu/network-list-menu.js | 122 ++++++++++++++++++ .../network-list-menu.stories.js | 58 +++++++++ .../network-list-menu.test.js | 61 +++++++++ ui/selectors/selectors.js | 61 +++++++++ ui/selectors/selectors.test.js | 50 +++++++ 16 files changed, 714 insertions(+) create mode 100644 ui/components/multichain/network-list-item/__snapshots__/network-list-item.test.js.snap create mode 100644 ui/components/multichain/network-list-item/index.js create mode 100644 ui/components/multichain/network-list-item/index.scss create mode 100644 ui/components/multichain/network-list-item/network-list-item.js create mode 100644 ui/components/multichain/network-list-item/network-list-item.stories.js create mode 100644 ui/components/multichain/network-list-item/network-list-item.test.js create mode 100644 ui/components/multichain/network-list-menu/index.js create mode 100644 ui/components/multichain/network-list-menu/index.scss create mode 100644 ui/components/multichain/network-list-menu/network-list-menu.js create mode 100644 ui/components/multichain/network-list-menu/network-list-menu.stories.js create mode 100644 ui/components/multichain/network-list-menu/network-list-menu.test.js diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index a14cbc3e1aaf..64fe6aa05f2a 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -2184,6 +2184,9 @@ "networkIsBusy": { "message": "Network is busy. Gas prices are high and estimates are less accurate." }, + "networkMenuHeading": { + "message": "Select a network" + }, "networkName": { "message": "Network name" }, diff --git a/ui/components/multichain/index.js b/ui/components/multichain/index.js index debaef289f7b..a4703b15887c 100644 --- a/ui/components/multichain/index.js +++ b/ui/components/multichain/index.js @@ -8,3 +8,5 @@ export { MultichainImportTokenLink } from './multichain-import-token-link'; export { MultichainTokenListItem } from './multichain-token-list-item'; export { AddressCopyButton } from './address-copy-button'; export { MultichainConnectedSiteMenu } from './multichain-connected-site-menu'; +export { NetworkListItem } from './network-list-item'; +export { NetworkListMenu } from './network-list-menu'; diff --git a/ui/components/multichain/multichain-components.scss b/ui/components/multichain/multichain-components.scss index ea41f990e2f8..63b19d690c5d 100644 --- a/ui/components/multichain/multichain-components.scss +++ b/ui/components/multichain/multichain-components.scss @@ -9,4 +9,7 @@ @import 'account-list-menu/index'; @import 'account-picker/index'; @import 'multichain-connected-site-menu/index'; +@import 'account-list-menu/'; @import 'multichain-token-list-item/multichain-token-list-item'; +@import 'network-list-item/'; +@import 'network-list-menu/'; diff --git a/ui/components/multichain/network-list-item/__snapshots__/network-list-item.test.js.snap b/ui/components/multichain/network-list-item/__snapshots__/network-list-item.test.js.snap new file mode 100644 index 000000000000..f71dd760578c --- /dev/null +++ b/ui/components/multichain/network-list-item/__snapshots__/network-list-item.test.js.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NetworkListItem renders properly 1`] = ` +
+
+
+ Polygon logo +
+
+ +
+ +
+
+`; diff --git a/ui/components/multichain/network-list-item/index.js b/ui/components/multichain/network-list-item/index.js new file mode 100644 index 000000000000..7fc23e245348 --- /dev/null +++ b/ui/components/multichain/network-list-item/index.js @@ -0,0 +1 @@ +export { NetworkListItem } from './network-list-item'; diff --git a/ui/components/multichain/network-list-item/index.scss b/ui/components/multichain/network-list-item/index.scss new file mode 100644 index 000000000000..a7a1e6aa158e --- /dev/null +++ b/ui/components/multichain/network-list-item/index.scss @@ -0,0 +1,51 @@ +.multichain-network-list-item { + position: relative; + cursor: pointer; + + &:not(.multichain-network-list-item--selected) { + &:hover, + &:focus-within { + background: var(--color-background-default-hover); + } + } + + a:hover, + a:focus { + color: inherit; + } + + &:hover, + &:focus, + &:focus-within { + .multichain-network-list-item__delete { + visibility: visible; + } + } + + &__network-name { + width: 100%; + flex: 1; + overflow: hidden; + text-align: start; + + button:hover { + opacity: 1; + } + } + + &__tooltip { + display: inline; + } + + &__selected-indicator { + width: 4px; + height: calc(100% - 8px); + position: absolute; + top: 4px; + left: 4px; + } + + &__delete { + visibility: hidden; + } +} diff --git a/ui/components/multichain/network-list-item/network-list-item.js b/ui/components/multichain/network-list-item/network-list-item.js new file mode 100644 index 000000000000..02f030e83847 --- /dev/null +++ b/ui/components/multichain/network-list-item/network-list-item.js @@ -0,0 +1,108 @@ +import React from 'react'; +import classnames from 'classnames'; +import PropTypes from 'prop-types'; +import Box from '../../ui/box/box'; +import { + AlignItems, + IconColor, + BorderRadius, + Color, + Size, + JustifyContent, + TextColor, + BLOCK_SIZES, +} from '../../../helpers/constants/design-system'; +import { + AvatarNetwork, + ButtonIcon, + ButtonLink, + ICON_NAMES, +} from '../../component-library'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import Tooltip from '../../ui/tooltip/tooltip'; + +const MAXIMUM_CHARACTERS_WITHOUT_TOOLTIP = 17; + +export const NetworkListItem = ({ + name, + iconSrc, + selected = false, + onClick, + onDeleteClick, +}) => { + const t = useI18nContext(); + return ( + + {selected && ( + + )} + + + + {name.length > MAXIMUM_CHARACTERS_WITHOUT_TOOLTIP ? ( + + {name} + + ) : ( + name + )} + + + {onDeleteClick ? ( + { + e.stopPropagation(); + onDeleteClick(); + }} + /> + ) : null} + + ); +}; + +NetworkListItem.propTypes = { + /** + * The name of the network + */ + name: PropTypes.string.isRequired, + /** + * Path to the Icon image + */ + iconSrc: PropTypes.string, + /** + * Represents if the network item is selected + */ + selected: PropTypes.bool, + /** + * Executes when the item is clicked + */ + onClick: PropTypes.func.isRequired, + /** + * Executes when the delete icon is clicked + */ + onDeleteClick: PropTypes.func, +}; diff --git a/ui/components/multichain/network-list-item/network-list-item.stories.js b/ui/components/multichain/network-list-item/network-list-item.stories.js new file mode 100644 index 000000000000..51b15f9823cc --- /dev/null +++ b/ui/components/multichain/network-list-item/network-list-item.stories.js @@ -0,0 +1,67 @@ +import React from 'react'; +import { NetworkListItem } from '.'; + +export default { + title: 'Components/Multichain/NetworkListItem', + component: NetworkListItem, + argTypes: { + name: { + control: 'text', + }, + selected: { + control: 'boolean', + }, + onClick: { + action: 'onClick', + }, + onDeleteClick: { + action: 'onDeleteClick', + }, + iconSrc: { + action: 'text', + }, + }, + args: { + name: 'Ethereum', + iconSrc: '', + selected: false, + }, +}; + +export const DefaultStory = (args) => ( +
+ +
+); + +export const IconStory = (args) => ( +
+ +
+); +IconStory.args = { iconSrc: './images/matic-token.png', name: 'Polygon' }; + +export const SelectedStory = (args) => ( +
+ +
+); +SelectedStory.args = { selected: true }; + +export const ChaosStory = (args) => ( +
+ +
+); +ChaosStory.args = { + name: 'This is a super long network name that should be ellipsized', + selected: true, +}; diff --git a/ui/components/multichain/network-list-item/network-list-item.test.js b/ui/components/multichain/network-list-item/network-list-item.test.js new file mode 100644 index 000000000000..4c4215d02b10 --- /dev/null +++ b/ui/components/multichain/network-list-item/network-list-item.test.js @@ -0,0 +1,81 @@ +/* eslint-disable jest/require-top-level-describe */ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { + MATIC_TOKEN_IMAGE_URL, + POLYGON_DISPLAY_NAME, +} from '../../../../shared/constants/network'; +import { NetworkListItem } from '.'; + +const DEFAULT_PROPS = { + name: POLYGON_DISPLAY_NAME, + iconSrc: MATIC_TOKEN_IMAGE_URL, + selected: false, + onClick: () => undefined, + onDeleteClick: () => undefined, +}; + +describe('NetworkListItem', () => { + it('renders properly', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('does not render the delete icon when no onDeleteClick is clicked', () => { + const { container } = render( + , + ); + expect( + container.querySelector('.multichain-network-list-item__delete'), + ).toBeNull(); + }); + + it('shows as selected when selected', () => { + const { container } = render( + , + ); + expect( + container.querySelector( + '.multichain-network-list-item__selected-indicator', + ), + ).toBeInTheDocument(); + }); + + it('renders a tooltip when the network name is very long', () => { + const { container } = render( + , + ); + expect( + container.querySelector('.multichain-network-list-item__tooltip'), + ).toBeInTheDocument(); + }); + + it('executes onClick when the item is clicked', () => { + const onClick = jest.fn(); + const { container } = render( + , + ); + fireEvent.click(container.querySelector('.multichain-network-list-item')); + expect(onClick).toHaveBeenCalled(); + }); + + it('executes onDeleteClick when the delete button is clicked', () => { + const onDeleteClick = jest.fn(); + const onClick = jest.fn(); + const { container } = render( + , + ); + fireEvent.click( + container.querySelector('.multichain-network-list-item__delete'), + ); + expect(onDeleteClick).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledTimes(0); + }); +}); diff --git a/ui/components/multichain/network-list-menu/index.js b/ui/components/multichain/network-list-menu/index.js new file mode 100644 index 000000000000..6e11c426a24d --- /dev/null +++ b/ui/components/multichain/network-list-menu/index.js @@ -0,0 +1 @@ +export { NetworkListMenu } from './network-list-menu'; diff --git a/ui/components/multichain/network-list-menu/index.scss b/ui/components/multichain/network-list-menu/index.scss new file mode 100644 index 000000000000..fbd2ed7ba617 --- /dev/null +++ b/ui/components/multichain/network-list-menu/index.scss @@ -0,0 +1,4 @@ +.multichain-network-list-menu { + max-height: 200px; + overflow: auto; +} diff --git a/ui/components/multichain/network-list-menu/network-list-menu.js b/ui/components/multichain/network-list-menu/network-list-menu.js new file mode 100644 index 000000000000..0cf1818ca69c --- /dev/null +++ b/ui/components/multichain/network-list-menu/network-list-menu.js @@ -0,0 +1,122 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import Popover from '../../ui/popover/popover.component'; +import { NetworkListItem } from '../network-list-item'; +import { + setActiveNetwork, + showModal, + setShowTestNetworks, + setProviderType, +} from '../../../store/actions'; +import { CHAIN_IDS, TEST_CHAINS } from '../../../../shared/constants/network'; +import { + getShowTestNetworks, + getAllNetworks, + getCurrentChainId, +} from '../../../selectors'; +import Box from '../../ui/box/box'; +import ToggleButton from '../../ui/toggle-button'; +import { + DISPLAY, + JustifyContent, +} from '../../../helpers/constants/design-system'; +import { Button, BUTTON_TYPES, Text } from '../../component-library'; +import { ADD_POPULAR_CUSTOM_NETWORK } from '../../../helpers/constants/routes'; +import { getEnvironmentType } from '../../../../app/scripts/lib/util'; +import { ENVIRONMENT_TYPE_FULLSCREEN } from '../../../../shared/constants/app'; + +const UNREMOVABLE_CHAIN_IDS = [CHAIN_IDS.MAINNET, ...TEST_CHAINS]; + +export const NetworkListMenu = ({ closeMenu }) => { + const t = useI18nContext(); + const networks = useSelector(getAllNetworks); + const showTestNetworks = useSelector(getShowTestNetworks); + const currentChainId = useSelector(getCurrentChainId); + const dispatch = useDispatch(); + const history = useHistory(); + + const environmentType = getEnvironmentType(); + const isFullScreen = environmentType === ENVIRONMENT_TYPE_FULLSCREEN; + + return ( + + <> + + {networks.map((network) => { + const isCurrentNetwork = currentChainId === network.chainId; + const canDeleteNetwork = + !isCurrentNetwork && + !UNREMOVABLE_CHAIN_IDS.includes(network.chainId); + + return ( + { + if (network.providerType) { + dispatch(setProviderType(network.providerType)); + } else { + dispatch(setActiveNetwork(network.id)); + } + closeMenu(); + }} + onDeleteClick={ + canDeleteNetwork + ? () => { + dispatch( + showModal({ + name: 'CONFIRM_DELETE_NETWORK', + target: network.id || network.chainId, + onConfirm: () => undefined, + }), + ); + closeMenu(); + } + : null + } + /> + ); + })} + + + {t('showTestnetNetworks')} + dispatch(setShowTestNetworks(!value))} + /> + + + + + + + ); +}; + +NetworkListMenu.propTypes = { + /** + * Executes when the menu should be closed + */ + closeMenu: PropTypes.func.isRequired, +}; diff --git a/ui/components/multichain/network-list-menu/network-list-menu.stories.js b/ui/components/multichain/network-list-menu/network-list-menu.stories.js new file mode 100644 index 000000000000..0629cd8e670e --- /dev/null +++ b/ui/components/multichain/network-list-menu/network-list-menu.stories.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import testData from '../../../../.storybook/test-data'; +import configureStore from '../../../store/store'; +import { + OPTIMISM_DISPLAY_NAME, + CHAIN_IDS, + OPTIMISM_TOKEN_IMAGE_URL, + BSC_DISPLAY_NAME, + BNB_TOKEN_IMAGE_URL, +} from '../../../../shared/constants/network'; +import { NetworkListMenu } from '.'; + +const customNetworkStore = configureStore({ + ...testData, + metamask: { + ...testData.metamask, + preferences: { + showTestNetworks: true, + }, + networkConfigurations: { + ...testData.metamask.networkConfigurations, + ...{ + 'test-networkConfigurationId-3': { + rpcUrl: 'https://testrpc.com', + chainId: CHAIN_IDS.OPTIMISM, + nickname: OPTIMISM_DISPLAY_NAME, + rpcPrefs: { imageUrl: OPTIMISM_TOKEN_IMAGE_URL }, + }, + 'test-networkConfigurationId-4': { + rpcUrl: 'https://testrpc.com', + chainId: CHAIN_IDS.BSC, + nickname: BSC_DISPLAY_NAME, + rpcPrefs: { imageUrl: BNB_TOKEN_IMAGE_URL }, + }, + }, + }, + }, +}); + +export default { + title: 'Components/Multichain/NetworkListMenu', + component: NetworkListMenu, + argTypes: { + closeMenu: { + action: 'closeMenu', + }, + }, +}; + +export const DefaultStory = (args) => ; +DefaultStory.decorators = [ + (Story) => ( + + + + ), +]; diff --git a/ui/components/multichain/network-list-menu/network-list-menu.test.js b/ui/components/multichain/network-list-menu/network-list-menu.test.js new file mode 100644 index 000000000000..e87876f39940 --- /dev/null +++ b/ui/components/multichain/network-list-menu/network-list-menu.test.js @@ -0,0 +1,61 @@ +/* eslint-disable jest/require-top-level-describe */ +import React from 'react'; +import { fireEvent, renderWithProvider } from '../../../../test/jest'; +import configureStore from '../../../store/store'; +import mockState from '../../../../test/data/mock-state.json'; +import { + MAINNET_DISPLAY_NAME, + SEPOLIA_DISPLAY_NAME, +} from '../../../../shared/constants/network'; +import { NetworkListMenu } from '.'; + +const mockSetShowTestNetworks = jest.fn(); +const mockSetProviderType = jest.fn(); +jest.mock('../../../store/actions.ts', () => ({ + setShowTestNetworks: () => mockSetShowTestNetworks, + setProviderType: () => mockSetProviderType, +})); + +const render = (showTestNetworks = false) => { + const store = configureStore({ + metamask: { + ...mockState.metamask, + preferences: { + showTestNetworks, + }, + }, + }); + return renderWithProvider(, store); +}; + +describe('NetworkListMenu', () => { + it('displays important controls', () => { + const { getByText } = render(); + + expect(getByText('Add network')).toBeInTheDocument(); + expect(getByText('Show test networks')).toBeInTheDocument(); + }); + + it('renders mainnet item', () => { + const { getByText } = render(); + expect(getByText(MAINNET_DISPLAY_NAME)).toBeInTheDocument(); + }); + + it('renders test networks when it should', () => { + const { getByText } = render(true); + expect(getByText(SEPOLIA_DISPLAY_NAME)).toBeInTheDocument(); + }); + + it('toggles showTestNetworks when toggle is clicked', () => { + const { queryAllByRole } = render(); + const [testNetworkToggle] = queryAllByRole('checkbox'); + fireEvent.click(testNetworkToggle); + expect(mockSetShowTestNetworks).toHaveBeenCalled(); + }); + + it('switches networks when an item is clicked', () => { + const { getByText } = render(); + fireEvent.click(getByText(MAINNET_DISPLAY_NAME)); + expect(mockSetProviderType).toHaveBeenCalled(); + }); +}); diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index be2ced685ac0..7c07865bec1e 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -25,6 +25,10 @@ import { CHAIN_IDS, NETWORK_TYPES, NetworkStatus, + SEPOLIA_DISPLAY_NAME, + GOERLI_DISPLAY_NAME, + ETH_TOKEN_IMAGE_URL, + LINEA_TESTNET_DISPLAY_NAME, } from '../../shared/constants/network'; import { WebHIDConnectedStatuses, @@ -1116,6 +1120,63 @@ export function getNetworkConfigurations(state) { return state.metamask.networkConfigurations; } +export function getAllNetworks(state) { + const networkConfigurations = getNetworkConfigurations(state) || {}; + const showTestnetNetworks = getShowTestNetworks(state); + const localhostFilter = (network) => network.chainId === CHAIN_IDS.LOCALHOST; + + const networks = []; + // Mainnet always first + networks.push({ + chainId: CHAIN_IDS.MAINNET, + nickname: MAINNET_DISPLAY_NAME, + rpcUrl: CHAIN_ID_TO_RPC_URL_MAP[CHAIN_IDS.MAINNET], + rpcPrefs: { + imageUrl: ETH_TOKEN_IMAGE_URL, + }, + providerType: NETWORK_TYPES.MAINNET, + }); + // Custom networks added + networks.push( + ...Object.entries(networkConfigurations) + .filter( + ([, network]) => + !localhostFilter(network) && network.chainId !== CHAIN_IDS.MAINNET, + ) + .map(([, network]) => network), + ); + // Test networks if flag is on + if (showTestnetNetworks) { + networks.push( + ...[ + { + chainId: CHAIN_IDS.GOERLI, + nickname: GOERLI_DISPLAY_NAME, + rpcUrl: CHAIN_ID_TO_RPC_URL_MAP[CHAIN_IDS.GOERLI], + providerType: NETWORK_TYPES.GOERLI, + }, + { + chainId: CHAIN_IDS.SEPOLIA, + nickname: SEPOLIA_DISPLAY_NAME, + rpcUrl: CHAIN_ID_TO_RPC_URL_MAP[CHAIN_IDS.SEPOLIA], + providerType: NETWORK_TYPES.SEPOLIA, + }, + { + chainId: CHAIN_IDS.LINEA_TESTNET, + nickname: LINEA_TESTNET_DISPLAY_NAME, + rpcUrl: CHAIN_ID_TO_RPC_URL_MAP[CHAIN_IDS.LINEA_TESTNET], + provderType: NETWORK_TYPES.LINEA_TESTNET, + }, + ], // Localhosts + ...Object.entries(networkConfigurations) + .filter(([, network]) => localhostFilter(network)) + .map(([, network]) => network), + ); + } + + return networks; +} + export function getIsOptimism(state) { return ( getCurrentChainId(state) === CHAIN_IDS.OPTIMISM || diff --git a/ui/selectors/selectors.test.js b/ui/selectors/selectors.test.js index f1f7bfe5e61f..edd495ec88c2 100644 --- a/ui/selectors/selectors.test.js +++ b/ui/selectors/selectors.test.js @@ -1,5 +1,10 @@ import mockState from '../../test/data/mock-state.json'; import { KeyringType } from '../../shared/constants/keyring'; +import { + CHAIN_IDS, + LOCALHOST_DISPLAY_NAME, + MAINNET_DISPLAY_NAME, +} from '../../shared/constants/network'; import * as selectors from './selectors'; describe('Selectors', () => { @@ -103,6 +108,51 @@ describe('Selectors', () => { }); }); + describe('#getAllNetworks', () => { + it('returns an array even if there are no custom networks', () => { + const networks = selectors.getAllNetworks({ + metamask: { + preferences: { + showTestNetworks: false, + }, + }, + }); + expect(networks instanceof Array).toBe(true); + // The only returning item should be Ethereum Mainnet + expect(networks).toHaveLength(1); + expect(networks[0].nickname).toStrictEqual(MAINNET_DISPLAY_NAME); + }); + + it('returns more test networks with showTestNetworks on', () => { + const networks = selectors.getAllNetworks({ + metamask: { + preferences: { + showTestNetworks: true, + }, + }, + }); + expect(networks.length).toBeGreaterThan(1); + }); + + it('sorts Localhost to the bottom of the test lists', () => { + const networks = selectors.getAllNetworks({ + metamask: { + preferences: { + showTestNetworks: true, + }, + networkConfigurations: { + 'some-config-name': { + chainId: CHAIN_IDS.LOCALHOST, + nickname: LOCALHOST_DISPLAY_NAME, + }, + }, + }, + }); + const lastItem = networks.pop(); + expect(lastItem.nickname.toLowerCase()).toContain('localhost'); + }); + }); + describe('#isHardwareWallet', () => { it('returns false if it is not a HW wallet', () => { mockState.metamask.keyrings[0].type = KeyringType.imported;