diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json
index a14cbc3e1..64fe6aa05 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 debaef289..a4703b158 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 ea41f990e..63b19d690 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 000000000..f71dd7605
--- /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`] = `
+
+
+
+

+
+
+
+
+
+
+
+`;
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 000000000..7fc23e245
--- /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 000000000..a7a1e6aa1
--- /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 000000000..02f030e83
--- /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 000000000..51b15f982
--- /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 000000000..4c4215d02
--- /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 000000000..6e11c426a
--- /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 000000000..fbd2ed7ba
--- /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 000000000..0cf1818ca
--- /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 000000000..0629cd8e6
--- /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 000000000..e87876f39
--- /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 be2ced685..7c07865be 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 f1f7bfe5e..edd495ec8 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;