diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json
index 6092721aa172..a948be94930b 100644
--- a/app/_locales/en/messages.json
+++ b/app/_locales/en/messages.json
@@ -714,6 +714,10 @@
"coingecko": {
"message": "CoinGecko"
},
+ "comboNoOptions": {
+ "message": "No options found",
+ "description": "Default text shown in the combo field dropdown if no options."
+ },
"configureSnapPopupDescription": {
"message": "You're now leaving MetaMask to configure this snap."
},
@@ -2368,6 +2372,38 @@
"name": {
"message": "Name"
},
+ "nameAddressLabel": {
+ "message": "Address",
+ "description": "Label above address field in name component modal."
+ },
+ "nameInstructionsNew": {
+ "message": "You are interacting with an unknown contract address. If you trust this author, set a personal display name to identify it going forward.",
+ "description": "Instruction text in name component modal when value is not recognised."
+ },
+ "nameInstructionsSaved": {
+ "message": "Interactions with this address will always be identified using this personal display name.",
+ "description": "Instruction text in name component modal when value is saved."
+ },
+ "nameLabel": {
+ "message": "Display name",
+ "description": "Label above name input field in name component modal."
+ },
+ "nameModalTitleNew": {
+ "message": "Unknown address",
+ "description": "Title of the modal created by the name component when value is not recognised."
+ },
+ "nameModalTitleSaved": {
+ "message": "Saved address",
+ "description": "Title of the modal created by the name component when value is saved."
+ },
+ "nameNoProposedNames": {
+ "message": "No proposed names found",
+ "description": "Text shown in the proposed name dropdown if none found."
+ },
+ "nameSetPlaceholder": {
+ "message": "Set a personal display name...",
+ "description": "Placeholder text for name input field in name component modal."
+ },
"nativeToken": {
"message": "The native token on this network is $1. It is the token used for gas fees.",
"description": "$1 represents the name of the native token on the current network"
diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json
index 0530d84fa850..e25e02879fd4 100644
--- a/lavamoat/browserify/beta/policy.json
+++ b/lavamoat/browserify/beta/policy.json
@@ -1677,6 +1677,14 @@
"browserify>url": true
}
},
+ "@metamask/name-controller": {
+ "globals": {
+ "fetch": true
+ },
+ "packages": {
+ "@metamask/base-controller": true
+ }
+ },
"@metamask/network-controller": {
"globals": {
"URL": true,
diff --git a/lavamoat/browserify/desktop/policy.json b/lavamoat/browserify/desktop/policy.json
index bdd2ba925bb9..7ede24c7bbb7 100644
--- a/lavamoat/browserify/desktop/policy.json
+++ b/lavamoat/browserify/desktop/policy.json
@@ -1828,6 +1828,14 @@
"browserify>url": true
}
},
+ "@metamask/name-controller": {
+ "globals": {
+ "fetch": true
+ },
+ "packages": {
+ "@metamask/base-controller": true
+ }
+ },
"@metamask/network-controller": {
"globals": {
"URL": true,
diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json
index 2f97fd964747..d807734aedf6 100644
--- a/lavamoat/browserify/flask/policy.json
+++ b/lavamoat/browserify/flask/policy.json
@@ -1828,6 +1828,14 @@
"browserify>url": true
}
},
+ "@metamask/name-controller": {
+ "globals": {
+ "fetch": true
+ },
+ "packages": {
+ "@metamask/base-controller": true
+ }
+ },
"@metamask/network-controller": {
"globals": {
"URL": true,
diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json
index 8f1f171aa469..9eae2c148db8 100644
--- a/lavamoat/browserify/main/policy.json
+++ b/lavamoat/browserify/main/policy.json
@@ -1677,6 +1677,14 @@
"browserify>url": true
}
},
+ "@metamask/name-controller": {
+ "globals": {
+ "fetch": true
+ },
+ "packages": {
+ "@metamask/base-controller": true
+ }
+ },
"@metamask/network-controller": {
"globals": {
"URL": true,
diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json
index 578f541b1d3e..da430864f696 100644
--- a/lavamoat/browserify/mmi/policy.json
+++ b/lavamoat/browserify/mmi/policy.json
@@ -1818,6 +1818,14 @@
"browserify>url": true
}
},
+ "@metamask/name-controller": {
+ "globals": {
+ "fetch": true
+ },
+ "packages": {
+ "@metamask/base-controller": true
+ }
+ },
"@metamask/network-controller": {
"globals": {
"URL": true,
diff --git a/package.json b/package.json
index 63bd13ec55a4..66a2804d8f03 100644
--- a/package.json
+++ b/package.json
@@ -256,6 +256,7 @@
"@metamask/logo": "^3.1.1",
"@metamask/message-manager": "^7.3.0",
"@metamask/metamask-eth-abis": "^3.0.0",
+ "@metamask/name-controller": "^1.0.0",
"@metamask/network-controller": "^12.1.1",
"@metamask/notification-controller": "^3.0.0",
"@metamask/obs-store": "^8.1.0",
diff --git a/ui/components/app/app-components.scss b/ui/components/app/app-components.scss
index c4dc26714527..c5c610d5dc51 100644
--- a/ui/components/app/app-components.scss
+++ b/ui/components/app/app-components.scss
@@ -95,6 +95,8 @@
@import 'network-account-balance-header/index';
@import 'approve-content-card/index';
@import 'transaction-alerts/transaction-alerts';
+@import 'name/index';
+@import 'name/name-details/index';
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
@import '../institutional/interactive-replacement-token-notification/index';
@import '../institutional/confirm-remove-jwt-modal/index';
diff --git a/ui/components/app/name/__snapshots__/name.test.tsx.snap b/ui/components/app/name/__snapshots__/name.test.tsx.snap
new file mode 100644
index 000000000000..5769227e4286
--- /dev/null
+++ b/ui/components/app/name/__snapshots__/name.test.tsx.snap
@@ -0,0 +1,95 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Name renders address with proposed name 1`] = `
+
+
+
+
+
+ 0xc0f...4978
+
+
+ “
+ TestProposedName
+ ”
+
+
+
+
+`;
+
+exports[`Name renders address with proposed name according to source priority 1`] = `
+
+
+
+
+
+ 0xc0f...4978
+
+
+ “
+ TestProposedName
+ ”
+
+
+
+
+`;
+
+exports[`Name renders address with saved name 1`] = `
+
+`;
+
+exports[`Name renders address without proposed name 1`] = `
+
+
+
+
+
+ 0xc0f...4979
+
+
+
+
+`;
diff --git a/ui/components/app/name/index.scss b/ui/components/app/name/index.scss
new file mode 100644
index 000000000000..becff5641d2a
--- /dev/null
+++ b/ui/components/app/name/index.scss
@@ -0,0 +1,37 @@
+.name {
+ border-radius: 36px;
+ padding: 6px 9px 6px 9px;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 12px;
+
+ &__missing {
+ background-color: var(--color-warning-muted);
+ }
+
+ &__saved {
+ background-color: var(--color-info-muted);
+ }
+
+ &__missing &__icon {
+ color: var(--color-warning-default);
+ }
+
+ &__saved &__icon {
+ color: var(--color-info-default);
+ }
+
+ &__value,
+ &__proposed {
+ color: var(--color-warning-default);
+ }
+
+ &__name {
+ color: var(--color-info-default);
+ }
+
+ &__proposed {
+ font-style: italic;
+ }
+}
diff --git a/ui/components/app/name/index.ts b/ui/components/app/name/index.ts
new file mode 100644
index 000000000000..f3e2e4fe0a86
--- /dev/null
+++ b/ui/components/app/name/index.ts
@@ -0,0 +1 @@
+export { default } from './name';
diff --git a/ui/components/app/name/name-details/__snapshots__/name-details.test.tsx.snap b/ui/components/app/name/name-details/__snapshots__/name-details.test.tsx.snap
new file mode 100644
index 000000000000..2f4fc518007d
--- /dev/null
+++ b/ui/components/app/name/name-details/__snapshots__/name-details.test.tsx.snap
@@ -0,0 +1,383 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`NameDetails renders with no saved name 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0xc0f...4979
+
+
+
+
+
+ You are interacting with an unknown contract address. If you trust this author, set a personal display name to identify it going forward.
+
+
+
+
+ Address
+
+
+
+
+
+
+
+
+
+ Display name
+
+
+
+
+
+ Save
+
+
+
+
+
+
+
+`;
+
+exports[`NameDetails renders with saved name 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+ Interactions with this address will always be identified using this personal display name.
+
+
+
+
+ Address
+
+
+
+
+
+
+
+
+
+ Display name
+
+
+
+
+ Ok
+
+
+
+
+
+
+
+`;
diff --git a/ui/components/app/name/name-details/index.scss b/ui/components/app/name/name-details/index.scss
new file mode 100644
index 000000000000..8e3d287b38c3
--- /dev/null
+++ b/ui/components/app/name/name-details/index.scss
@@ -0,0 +1,15 @@
+.name-details {
+ &__display-name {
+ width: 100%;
+ }
+
+ &__line {
+ margin-left: -16px;
+ margin-right: -16px;
+ margin-bottom: 12px;
+ }
+
+ &__address .mm-text-field {
+ padding-right: 5px;
+ }
+}
diff --git a/ui/components/app/name/name-details/index.ts b/ui/components/app/name/name-details/index.ts
new file mode 100644
index 000000000000..4cbae82fe570
--- /dev/null
+++ b/ui/components/app/name/name-details/index.ts
@@ -0,0 +1 @@
+export { default } from './name-details';
diff --git a/ui/components/app/name/name-details/name-details.test.tsx b/ui/components/app/name/name-details/name-details.test.tsx
new file mode 100644
index 000000000000..086c3a893e26
--- /dev/null
+++ b/ui/components/app/name/name-details/name-details.test.tsx
@@ -0,0 +1,221 @@
+import * as React from 'react';
+import { NameType } from '@metamask/name-controller';
+import configureStore from 'redux-mock-store';
+import { fireEvent } from '@testing-library/react';
+import { act } from 'react-dom/test-utils';
+import { renderWithProvider } from '../../../../../test/lib/render-helpers';
+import { setName } from '../../../../store/actions';
+import NameDetails from './name-details';
+
+jest.mock('../../../../store/actions', () => ({
+ setName: jest.fn(),
+ updateProposedNames: jest.fn(),
+}));
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useDispatch: () => jest.fn(),
+}));
+
+const ADDRESS_NO_NAME_MOCK = '0xc0ffee254729296a45a3885639AC7E10F9d54979';
+const ADDRESS_SAVED_NAME_MOCK = '0xc0ffee254729296a45a3885639AC7E10F9d54977';
+const CHAIN_ID_MOCK = '0x1';
+const SAVED_NAME_MOCK = 'TestName';
+const SAVED_NAME_2_MOCK = 'TestName2';
+const SOURCE_ID_MOCK = 'TestSourceId';
+const SOURCE_ID_2_MOCK = 'TestSourceId2';
+const PROPOSED_NAME_MOCK = 'TestProposedName';
+const PROPOSED_NAME_2_MOCK = 'TestProposedName2';
+const PROPOSED_NAME_3_MOCK = 'TestProposedName3';
+
+const STATE_MOCK = {
+ metamask: {
+ providerConfig: {
+ chainId: CHAIN_ID_MOCK,
+ },
+ names: {
+ [NameType.ETHEREUM_ADDRESS]: {
+ [ADDRESS_SAVED_NAME_MOCK]: {
+ [CHAIN_ID_MOCK]: {
+ proposedNames: {
+ [SOURCE_ID_MOCK]: [PROPOSED_NAME_MOCK, PROPOSED_NAME_2_MOCK],
+ [SOURCE_ID_2_MOCK]: [PROPOSED_NAME_3_MOCK],
+ },
+ name: SAVED_NAME_MOCK,
+ sourceId: SOURCE_ID_MOCK,
+ },
+ },
+ [ADDRESS_NO_NAME_MOCK]: {
+ [CHAIN_ID_MOCK]: {
+ proposedNames: {
+ [SOURCE_ID_MOCK]: [PROPOSED_NAME_MOCK, PROPOSED_NAME_2_MOCK],
+ [SOURCE_ID_2_MOCK]: [PROPOSED_NAME_3_MOCK],
+ },
+ name: null,
+ },
+ },
+ },
+ },
+ },
+};
+
+describe('NameDetails', () => {
+ const store = configureStore()(STATE_MOCK);
+ const setNameMock = jest.mocked(setName);
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('renders with no saved name', () => {
+ const { baseElement } = renderWithProvider(
+ undefined}
+ />,
+ store,
+ );
+
+ expect(baseElement).toMatchSnapshot();
+ });
+
+ it('renders with saved name', () => {
+ const { baseElement } = renderWithProvider(
+ undefined}
+ />,
+ store,
+ );
+
+ expect(baseElement).toMatchSnapshot();
+ });
+
+ it('saves current name on save button click', async () => {
+ const { getByPlaceholderText, getByText } = renderWithProvider(
+ undefined}
+ />,
+ store,
+ );
+
+ const nameInput = getByPlaceholderText('Set a personal display name...');
+ const saveButton = getByText('Save', { exact: false });
+
+ await act(async () => {
+ fireEvent.change(nameInput, { target: { value: SAVED_NAME_MOCK } });
+ });
+
+ await act(async () => {
+ fireEvent.click(saveButton);
+ });
+
+ expect(setNameMock).toHaveBeenCalledTimes(1);
+ expect(setNameMock).toHaveBeenCalledWith({
+ value: ADDRESS_NO_NAME_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ name: SAVED_NAME_MOCK,
+ sourceId: undefined,
+ });
+ });
+
+ it('saves selected source on save button click', async () => {
+ const { getByPlaceholderText, getByText } = renderWithProvider(
+ undefined}
+ />,
+ store,
+ );
+
+ const nameInput = getByPlaceholderText('Set a personal display name...');
+ const saveButton = getByText('Save', { exact: false });
+
+ await act(async () => {
+ fireEvent.click(nameInput);
+ });
+
+ const providerOption = getByText(PROPOSED_NAME_MOCK);
+
+ await act(async () => {
+ fireEvent.click(providerOption);
+ });
+
+ await act(async () => {
+ fireEvent.click(saveButton);
+ });
+
+ expect(setNameMock).toHaveBeenCalledTimes(1);
+ expect(setNameMock).toHaveBeenCalledWith({
+ value: ADDRESS_NO_NAME_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ name: PROPOSED_NAME_MOCK,
+ sourceId: SOURCE_ID_MOCK,
+ });
+ });
+
+ it('clears current name on save button click if name is empty', async () => {
+ const { getByPlaceholderText, getByText } = renderWithProvider(
+ undefined}
+ />,
+ store,
+ );
+
+ const nameInput = getByPlaceholderText('Set a personal display name...');
+ const saveButton = getByText('Ok');
+
+ await act(async () => {
+ fireEvent.change(nameInput, { target: { value: '' } });
+ });
+
+ await act(async () => {
+ fireEvent.click(saveButton);
+ });
+
+ expect(setNameMock).toHaveBeenCalledTimes(1);
+ expect(setNameMock).toHaveBeenCalledWith({
+ value: ADDRESS_SAVED_NAME_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ name: '',
+ sourceId: undefined,
+ });
+ });
+
+ it('clears selected source when name changed', async () => {
+ const { getByPlaceholderText, getByText } = renderWithProvider(
+ undefined}
+ />,
+ store,
+ );
+
+ const nameInput = getByPlaceholderText('Set a personal display name...');
+ const saveButton = getByText('Ok');
+
+ await act(async () => {
+ fireEvent.change(nameInput, { target: { value: SAVED_NAME_2_MOCK } });
+ });
+
+ await act(async () => {
+ fireEvent.click(saveButton);
+ });
+
+ expect(setNameMock).toHaveBeenCalledTimes(1);
+ expect(setNameMock).toHaveBeenCalledWith({
+ value: ADDRESS_SAVED_NAME_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ name: SAVED_NAME_2_MOCK,
+ sourceId: undefined,
+ });
+ });
+});
diff --git a/ui/components/app/name/name-details/name-details.tsx b/ui/components/app/name/name-details/name-details.tsx
new file mode 100644
index 000000000000..f1ec9006066f
--- /dev/null
+++ b/ui/components/app/name/name-details/name-details.tsx
@@ -0,0 +1,203 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment */
+
+import React, {
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
+import { NameType } from '@metamask/name-controller';
+import { useDispatch, useSelector } from 'react-redux';
+import { isEqual } from 'lodash';
+import {
+ Box,
+ Button,
+ ButtonIcon,
+ ButtonIconSize,
+ ButtonVariant,
+ FormTextField,
+ IconName,
+ Label,
+ Modal,
+ ModalContent,
+ ModalHeader,
+ ModalOverlay,
+ Text,
+} from '../../../component-library';
+import {
+ AlignItems,
+ BlockSize,
+ Display,
+ FlexDirection,
+ IconColor,
+ JustifyContent,
+} from '../../../../helpers/constants/design-system';
+import Name from '../name';
+import FormComboField from '../../../ui/form-combo-field/form-combo-field';
+import { getNameSources } from '../../../../selectors';
+import { setName as saveName } from '../../../../store/actions';
+import { useCopyToClipboard } from '../../../../hooks/useCopyToClipboard';
+import { useName } from '../../../../hooks/useName';
+import { I18nContext } from '../../../../contexts/i18n';
+
+export interface NameDetailsProps {
+ onClose: () => void;
+ type: NameType;
+ value: string;
+}
+
+export default function NameDetails({
+ onClose,
+ type,
+ value,
+}: NameDetailsProps) {
+ const {
+ name: savedName,
+ proposedNames,
+ sourceId: savedSourceId,
+ } = useName(value, type);
+
+ const nameSources = useSelector(getNameSources, isEqual);
+ const [name, setName] = useState('');
+ const [selectedSourceId, setSelectedSourceId] = useState();
+ const dispatch = useDispatch();
+ const t = useContext(I18nContext);
+
+ const [copiedAddress, handleCopyAddress] = useCopyToClipboard() as [
+ boolean,
+ (value: string) => void,
+ ];
+
+ const handleSaveClick = useCallback(async () => {
+ await dispatch(saveName({ value, type, name, sourceId: selectedSourceId }));
+ onClose();
+ }, [name, selectedSourceId, onClose]);
+
+ const handleClose = useCallback(() => {
+ onClose();
+ }, [onClose]);
+
+ const handleNameChange = useCallback(
+ (newName: string) => {
+ setName(newName);
+
+ const selectedProposedName =
+ proposedNames?.[selectedSourceId as string]?.[0];
+
+ if (newName !== selectedProposedName) {
+ setSelectedSourceId(undefined);
+ }
+ },
+ [setName, selectedSourceId],
+ );
+
+ const handleProposedNameClick = useCallback(
+ (option: any) => {
+ setSelectedSourceId(option.sourceId);
+ },
+ [setSelectedSourceId],
+ );
+
+ const proposedNameOptions = useMemo(() => {
+ const sourceIds = Object.keys(proposedNames);
+
+ const sourceIdsWithProposedNames = sourceIds.filter(
+ (sourceId) => proposedNames[sourceId]?.length,
+ );
+
+ const options = sourceIdsWithProposedNames
+ .map((sourceId: string) => {
+ const sourceProposedNames = proposedNames[sourceId] ?? [];
+
+ return sourceProposedNames.map((proposedName: any) => ({
+ primaryLabel: proposedName,
+ secondaryLabel: nameSources[sourceId]?.label ?? sourceId,
+ sourceId,
+ }));
+ })
+ .flat();
+
+ return options.sort((a, b) =>
+ a.primaryLabel.toLowerCase().localeCompare(b.primaryLabel.toLowerCase()),
+ );
+ }, [proposedNames, nameSources]);
+
+ useEffect(() => {
+ setName(savedName ?? '');
+ setSelectedSourceId(savedSourceId ?? undefined);
+ }, [savedName, savedSourceId, setName, setSelectedSourceId]);
+
+ const hasSavedName = Boolean(savedName);
+
+ return (
+
+
+
+
+
+ {hasSavedName ? t('nameModalTitleSaved') : t('nameModalTitleNew')}
+
+
+
+
+
+ {hasSavedName
+ ? t('nameInstructionsSaved')
+ : t('nameInstructionsNew')}
+
+
+ {/* @ts-ignore */}
+ handleCopyAddress(value)}
+ color={IconColor.iconMuted}
+ ariaLabel={t('copyAddress')}
+ />
+ }
+ />
+
+ {t('nameLabel')}
+
+
+
+
+ {hasSavedName ? t('ok') : t('save')}
+
+
+
+
+ );
+}
diff --git a/ui/components/app/name/name.stories.tsx b/ui/components/app/name/name.stories.tsx
new file mode 100644
index 000000000000..f0815764e22a
--- /dev/null
+++ b/ui/components/app/name/name.stories.tsx
@@ -0,0 +1,170 @@
+/* eslint-disable import/no-anonymous-default-export */
+import React from 'react';
+import { NameType } from '@metamask/name-controller';
+import { Provider } from 'react-redux';
+import configureStore from '../../../store/store';
+import Name from './name';
+
+const addressProposedMock = '0xc0ffee254729296a45a3885639AC7E10F9d54979';
+const addressNoProposedMock = '0xc0ffee254729296a45a3885639AC7E10F9d54978';
+const addressSavedNameMock = '0xc0ffee254729296a45a3885639AC7E10F9d54977';
+const chainIdMock = '0x1';
+
+const storeMock = configureStore({
+ metamask: {
+ providerConfig: {
+ chainId: chainIdMock,
+ },
+ names: {
+ [NameType.ETHEREUM_ADDRESS]: {
+ [addressProposedMock]: {
+ [chainIdMock]: {
+ proposedNames: {
+ ens: ['test.eth'],
+ etherscan: ['TestContract'],
+ token: ['TestToken'],
+ lens: ['test.lens'],
+ },
+ },
+ },
+ [addressSavedNameMock]: {
+ [chainIdMock]: {
+ proposedNames: {
+ ens: ['test.eth'],
+ etherscan: ['TestContract'],
+ token: ['TestToken'],
+ lens: ['test.lens'],
+ },
+ name: 'TestToken',
+ sourceId: 'token',
+ },
+ },
+ },
+ },
+ nameSources: {
+ ens: { label: 'Ethereum Name Service (ENS)' },
+ etherscan: { label: 'Etherscan (Verified Contract Name)' },
+ token: { label: 'Blockchain (Token Name)' },
+ lens: { label: 'Lens Protocol' },
+ },
+ },
+});
+
+/**
+ * Displays the saved or proposed name for a raw value such as an Ethereum address.
+ * Proposed names are populated in the state using the `NameController` and the attached `NameProvider` instances.
+ * These name providers use multiple sources such as ENS, Etherscan, and the Blockchain itself.
+ * Clicking the component will display a modal to select a proposed name or enter a custom name.
+ */
+export default {
+ title: 'Components/App/Name',
+ component: Name,
+ argTypes: {
+ value: {
+ control: 'text',
+ description: 'The raw value to display the name of.',
+ },
+ type: {
+ options: [NameType.ETHEREUM_ADDRESS],
+ control: 'select',
+ description: `The type of value.
+ Limited to the values in the \`NameType\` enum.`,
+ },
+ sourcePriority: {
+ control: 'object',
+ description: `The order of priority to use when choosing which proposed name to display.
+ The available source IDs are defined by the \`NameProvider\` instances passed to the \`NameController\`.
+ Current options include:
+ \`ens\`
+ \`etherscan\`
+ \`lens\`
+ \`token\``,
+ },
+ disableEdit: {
+ control: 'boolean',
+ description: `Whether to prevent the modal from opening when the component is clicked.`,
+ table: {
+ defaultValue: { summary: false },
+ },
+ },
+ disableUpdate: {
+ control: 'boolean',
+ description: `Whether to disable updating the proposed names on render.`,
+ table: {
+ defaultValue: { summary: false },
+ },
+ },
+ updateDelay: {
+ control: 'number',
+ description: `The minimum number of seconds to wait between updates of the proposed names on render.`,
+ table: {
+ defaultValue: { summary: 300 },
+ },
+ },
+ },
+ args: {
+ value: addressProposedMock,
+ type: NameType.ETHEREUM_ADDRESS,
+ sourcePriority: ['ens'],
+ disableEdit: false,
+ disableUpdate: false,
+ updateDelay: 300,
+ },
+ decorators: [(story) => {story()} ],
+};
+
+// eslint-disable-next-line jsdoc/require-param
+/**
+ * A proposed name matching the value and type has been found in the state.
+ * Which proposed name is displayed is configurable by the `sourcePriority` property.
+ */
+export const DefaultStory = (args) => {
+ return ;
+};
+
+DefaultStory.storyName = 'Proposed Name';
+
+/** No proposed name matching the value and type has been found in the state. */
+export const NoProposedNameStory = () => {
+ return (
+
+ );
+};
+
+NoProposedNameStory.storyName = 'No Proposed Name';
+
+/**
+ * A name was previously saved for this value and type.
+ * The component will still display a modal when clicked to edit the name.
+ */
+export const SavedNameStory = () => {
+ return (
+
+ );
+};
+
+SavedNameStory.storyName = 'Saved Name';
+
+/**
+ * Clicking the component will not display a modal to edit the name.
+ */
+export const EditDisabledStory = () => {
+ return (
+
+ );
+};
+
+EditDisabledStory.storyName = 'Edit Disabled';
diff --git a/ui/components/app/name/name.test.tsx b/ui/components/app/name/name.test.tsx
new file mode 100644
index 000000000000..96c59637d598
--- /dev/null
+++ b/ui/components/app/name/name.test.tsx
@@ -0,0 +1,210 @@
+import * as React from 'react';
+import { NameType } from '@metamask/name-controller';
+import configureStore from 'redux-mock-store';
+import { renderWithProvider } from '../../../../test/lib/render-helpers';
+import { updateProposedNames } from '../../../store/actions';
+import Name from './name';
+
+jest.mock('../../../store/actions', () => ({
+ updateProposedNames: jest.fn(),
+}));
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useDispatch: () => jest.fn(),
+}));
+
+const ADDRESS_NO_PROPOSED_NAME_MOCK =
+ '0xc0ffee254729296a45a3885639AC7E10F9d54979';
+const ADDRESS_PROPOSED_NAME_MOCK = '0xc0ffee254729296a45a3885639AC7E10F9d54978';
+const ADDRESS_SAVED_NAME_MOCK = '0xc0ffee254729296a45a3885639AC7E10F9d54977';
+const ADDRESS_LAST_UPDATED_MOCK = '0xc0ffee254729296a45a3885639AC7E10F9d54976';
+const CHAIN_ID_MOCK = '0x1';
+const PROPOSED_NAME_MOCK = 'TestProposedName';
+const PROPOSED_NAME_2_MOCK = 'TestProposedName2';
+const SAVED_NAME_MOCK = 'TestName';
+const SOURCE_ID_MOCK = 'TestSourceId';
+const SOURCE_ID_2_MOCK = 'TestSourceId2';
+const SOURCE_ID_EMPTY_MOCK = 'TestSourceIdEmpty';
+const SOURCE_ID_UNDEFINED_MOCK = 'TestSourceIdUndefined';
+const LAST_UPDATED_MOCK = 150;
+const DEFAULT_UPDATE_DELAY = 300;
+
+const STATE_MOCK = {
+ metamask: {
+ providerConfig: {
+ chainId: CHAIN_ID_MOCK,
+ },
+ names: {
+ [NameType.ETHEREUM_ADDRESS]: {
+ [ADDRESS_PROPOSED_NAME_MOCK]: {
+ [CHAIN_ID_MOCK]: {
+ proposedNames: {
+ [SOURCE_ID_MOCK]: [PROPOSED_NAME_MOCK],
+ [SOURCE_ID_2_MOCK]: [PROPOSED_NAME_2_MOCK],
+ [SOURCE_ID_EMPTY_MOCK]: [],
+ [SOURCE_ID_UNDEFINED_MOCK]: undefined,
+ },
+ },
+ },
+ [ADDRESS_SAVED_NAME_MOCK]: {
+ [CHAIN_ID_MOCK]: {
+ proposedNames: null,
+ name: SAVED_NAME_MOCK,
+ },
+ },
+ [ADDRESS_LAST_UPDATED_MOCK]: {
+ [CHAIN_ID_MOCK]: {
+ proposedNamesLastUpdated: LAST_UPDATED_MOCK,
+ },
+ },
+ },
+ },
+ },
+};
+
+describe('Name', () => {
+ const store = configureStore()(STATE_MOCK);
+ const updateProposedNamesMock = jest.mocked(updateProposedNames);
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('renders address without proposed name', () => {
+ const { container } = renderWithProvider(
+ ,
+ store,
+ );
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it('renders address with proposed name', () => {
+ const { container } = renderWithProvider(
+ ,
+ store,
+ );
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it('renders address with saved name', () => {
+ const { container } = renderWithProvider(
+ ,
+ store,
+ );
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it('renders address with proposed name according to source priority', () => {
+ const { container } = renderWithProvider(
+ ,
+ store,
+ );
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it('updates proposed names on render', () => {
+ renderWithProvider(
+ ,
+ store,
+ );
+
+ expect(updateProposedNamesMock).toHaveBeenCalledWith({
+ value: ADDRESS_NO_PROPOSED_NAME_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ });
+ });
+
+ it('does not update proposed names on render if disabled', () => {
+ renderWithProvider(
+ ,
+ store,
+ );
+
+ expect(updateProposedNamesMock).toHaveBeenCalledTimes(0);
+ });
+
+ it.each([
+ ['default', undefined],
+ ['custom', 10000],
+ ])(
+ 'does not update proposed names on subsequent render until %s delay has elapsed',
+ async (_, updateDelay) => {
+ jest
+ .spyOn(Date, 'now')
+ .mockReturnValue(
+ (LAST_UPDATED_MOCK + (updateDelay ?? DEFAULT_UPDATE_DELAY) - 1) *
+ 1000,
+ );
+
+ const { rerender } = renderWithProvider(
+ ,
+ store,
+ );
+
+ expect(updateProposedNamesMock).toHaveBeenCalledTimes(0);
+
+ rerender(
+ ,
+ );
+
+ expect(updateProposedNamesMock).toHaveBeenCalledTimes(0);
+
+ jest
+ .spyOn(Date, 'now')
+ .mockReturnValue(
+ (LAST_UPDATED_MOCK + (updateDelay ?? DEFAULT_UPDATE_DELAY)) * 1000,
+ );
+
+ rerender(
+ ,
+ );
+
+ expect(updateProposedNamesMock).toHaveBeenCalledTimes(1);
+ },
+ );
+});
diff --git a/ui/components/app/name/name.tsx b/ui/components/app/name/name.tsx
new file mode 100644
index 000000000000..dd6a62e9504d
--- /dev/null
+++ b/ui/components/app/name/name.tsx
@@ -0,0 +1,115 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { NameType } from '@metamask/name-controller';
+import classnames from 'classnames';
+import { useDispatch } from 'react-redux';
+import { Icon, IconName, IconSize } from '../../component-library';
+import { shortenAddress } from '../../../helpers/utils/util';
+import { useName } from '../../../hooks/useName';
+import { updateProposedNames } from '../../../store/actions';
+import NameDetails from './name-details/name-details';
+
+const DEFAULT_UPDATE_DELAY = 60 * 5; // 5 Minutes
+
+export interface NameProps {
+ /** Whether to prevent the modal from opening when the component is clicked. */
+ disableEdit?: boolean;
+
+ /** Whether to disable updating the proposed names on render. */
+ disableUpdate?: boolean;
+
+ /** The order of source IDs to prioritise when choosing which proposed name to display. */
+ sourcePriority?: string[];
+
+ /** The type of value, e.g. NameType.ETHEREUM_ADDRESS */
+ type: NameType;
+
+ /** The minimum number of seconds to wait between updates of the proposed names on render. */
+ updateDelay?: number;
+
+ /** The raw value to display the name of. */
+ value: string;
+}
+
+export default function Name({
+ value,
+ type,
+ sourcePriority,
+ disableEdit,
+ updateDelay,
+ disableUpdate,
+}: NameProps) {
+ const [modalOpen, setModalOpen] = useState(false);
+ const dispatch = useDispatch();
+
+ const { name, proposedNames, proposedNamesLastUpdated } = useName(
+ value,
+ type,
+ );
+
+ useEffect(() => {
+ if (disableUpdate) {
+ return;
+ }
+
+ const nowMilliseconds = Date.now();
+ const nowSeconds = Math.floor(nowMilliseconds / 1000);
+ const secondsSinceLastUpdate = nowSeconds - (proposedNamesLastUpdated ?? 0);
+ const delay = updateDelay ?? DEFAULT_UPDATE_DELAY;
+
+ if (secondsSinceLastUpdate < delay) {
+ return;
+ }
+
+ dispatch(updateProposedNames({ value, type }));
+ });
+
+ const handleClick = useCallback(() => {
+ setModalOpen(true);
+ }, [setModalOpen]);
+
+ const handleModalClose = useCallback(() => {
+ setModalOpen(false);
+ }, [setModalOpen]);
+
+ const proposedName = useMemo((): string | undefined => {
+ for (const sourceId of sourcePriority ?? []) {
+ const sourceProposedNames = proposedNames[sourceId] ?? [];
+
+ if (sourceProposedNames.length) {
+ return sourceProposedNames[0];
+ }
+ }
+
+ return undefined;
+ }, [proposedNames, sourcePriority]);
+
+ const formattedValue =
+ type === NameType.ETHEREUM_ADDRESS ? shortenAddress(value) : value;
+
+ const hasName = Boolean(name);
+ const hasProposedName = Boolean(proposedName);
+ const iconName = hasName ? IconName.Save : IconName.Warning;
+
+ return (
+
+ {!disableEdit && modalOpen && (
+
+ )}
+
+
+ {!hasName && {formattedValue} }
+ {hasName && {name} }
+ {!hasName && hasProposedName && (
+ “{proposedName}”
+ )}
+
+
+ );
+}
diff --git a/ui/components/ui/form-combo-field/__snapshots__/form-combo-field.test.tsx.snap b/ui/components/ui/form-combo-field/__snapshots__/form-combo-field.test.tsx.snap
new file mode 100644
index 000000000000..74b98f93ee20
--- /dev/null
+++ b/ui/components/ui/form-combo-field/__snapshots__/form-combo-field.test.tsx.snap
@@ -0,0 +1,133 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`FormComboField renders with no options 1`] = `
+
+
+
+
+`;
+
+exports[`FormComboField renders with options 1`] = `
+
+
+
+
+`;
diff --git a/ui/components/ui/form-combo-field/form-combo-field.stories.tsx b/ui/components/ui/form-combo-field/form-combo-field.stories.tsx
new file mode 100644
index 000000000000..bd16b4fc29c9
--- /dev/null
+++ b/ui/components/ui/form-combo-field/form-combo-field.stories.tsx
@@ -0,0 +1,105 @@
+import React, { useCallback, useState } from 'react';
+import FormComboField from './form-combo-field';
+
+/**
+ * A form field that supports free text entry or the selection of a value from an attached dropdown list.
+ */
+// eslint-disable-next-line import/no-anonymous-default-export
+export default {
+ title: 'Components/UI/FormComboField',
+ component: FormComboField,
+ argTypes: {
+ value: {
+ control: 'text',
+ description: 'The value to display in the field.',
+ },
+ options: {
+ control: 'object',
+ description: `The options to display in the dropdown.
+ Must be an array of objects with a \`primaryLabel\` and optionally a \`secondaryLabel\` property.`,
+ },
+ placeholder: {
+ control: 'text',
+ description:
+ 'The placeholder text to display in the field when the value is empty.',
+ },
+ noOptionsText: {
+ control: 'text',
+ description: `The text to display in the dropdown when there are no options to display.`,
+ table: {
+ defaultValue: { summary: 'No options found' },
+ },
+ },
+ maxDropdownHeight: {
+ control: 'number',
+ description: 'The maximum height of the dropdown in pixels.',
+ table: {
+ defaultValue: { summary: 179 },
+ },
+ },
+ onChange: {
+ description: `Optional callback function to invoke when the value changes.`,
+ },
+ onOptionClick: {
+ description: `Optional callback function to invoke when a dropdown option is clicked.`,
+ },
+ },
+ args: {
+ value: undefined,
+ options: [
+ { primaryLabel: 'Berlin', secondaryLabel: 'Germany' },
+ { primaryLabel: 'London', secondaryLabel: 'United Kingdom' },
+ { primaryLabel: 'Lisbon', secondaryLabel: 'Portugal' },
+ { primaryLabel: 'Paris', secondaryLabel: 'France' },
+ ],
+ placeholder: 'Specify a city...',
+ noOptionsText: undefined,
+ maxDropdownHeight: undefined,
+ onChange: undefined,
+ onOptionClick: undefined,
+ },
+};
+
+export const DefaultStory = (args) => {
+ const [value, setValue] = useState('');
+
+ const handleChange = useCallback(
+ (newValue: string) => {
+ setValue(newValue);
+ },
+ [setValue],
+ );
+
+ return (
+
+
+
+ );
+};
+
+DefaultStory.storyName = 'With Options';
+
+export const NoOptionsStory = () => {
+ const [value, setValue] = useState('');
+
+ const handleChange = useCallback(
+ (newValue: string) => {
+ setValue(newValue);
+ },
+ [setValue],
+ );
+
+ return (
+
+
+
+ );
+};
+
+NoOptionsStory.storyName = 'No Options';
diff --git a/ui/components/ui/form-combo-field/form-combo-field.test.tsx b/ui/components/ui/form-combo-field/form-combo-field.test.tsx
new file mode 100644
index 000000000000..46ea515d9168
--- /dev/null
+++ b/ui/components/ui/form-combo-field/form-combo-field.test.tsx
@@ -0,0 +1,109 @@
+import * as React from 'react';
+import { act } from 'react-dom/test-utils';
+import { fireEvent } from '@testing-library/react';
+import { renderWithProvider } from '../../../../test/lib/render-helpers';
+import FormComboField from './form-combo-field';
+
+const VALUE_MOCK = 'TestValue';
+const PLACEHOLDER_MOCK = 'TestPlaceholder';
+const NO_OPTIONS_TEXT_MOCK = 'TestNoOptionsText';
+
+const OPTIONS_MOCK = [
+ { primaryLabel: 'TestPrimaryLabel', secondaryLabel: 'TestSecondaryLabel' },
+ { primaryLabel: 'TestPrimaryLabel2', secondaryLabel: 'TestSecondaryLabel2' },
+];
+
+describe('FormComboField', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('renders with options', async () => {
+ const { baseElement, getByPlaceholderText } = renderWithProvider(
+ ,
+ );
+
+ const input = getByPlaceholderText(PLACEHOLDER_MOCK);
+
+ await act(async () => {
+ fireEvent.click(input);
+ });
+
+ expect(baseElement).toMatchSnapshot();
+ });
+
+ it('renders with no options', async () => {
+ const { baseElement, getByPlaceholderText } = renderWithProvider(
+ ,
+ );
+
+ const input = getByPlaceholderText(PLACEHOLDER_MOCK);
+
+ await act(async () => {
+ fireEvent.click(input);
+ });
+
+ expect(baseElement).toMatchSnapshot();
+ });
+
+ it('calls onChange with primary label on option click', async () => {
+ const onChangeMock = jest.fn();
+
+ const { getByPlaceholderText, getByText } = renderWithProvider(
+ ,
+ );
+
+ const input = getByPlaceholderText(PLACEHOLDER_MOCK);
+
+ await act(async () => {
+ fireEvent.click(input);
+ });
+
+ const option = getByText(OPTIONS_MOCK[0].primaryLabel);
+
+ await act(async () => {
+ fireEvent.click(option);
+ });
+
+ expect(onChangeMock).toHaveBeenCalledTimes(1);
+ expect(onChangeMock).toHaveBeenCalledWith(OPTIONS_MOCK[0].primaryLabel);
+ });
+
+ it('calls onChange with empty string on clear button click', async () => {
+ const onChangeMock = jest.fn();
+
+ const { getByLabelText } = renderWithProvider(
+ ,
+ );
+
+ const clearButton = getByLabelText('[clear]');
+
+ await act(async () => {
+ fireEvent.click(clearButton);
+ });
+
+ expect(onChangeMock).toHaveBeenCalledTimes(1);
+ expect(onChangeMock).toHaveBeenCalledWith('');
+ });
+});
diff --git a/ui/components/ui/form-combo-field/form-combo-field.tsx b/ui/components/ui/form-combo-field/form-combo-field.tsx
new file mode 100644
index 000000000000..78cf9f19818a
--- /dev/null
+++ b/ui/components/ui/form-combo-field/form-combo-field.tsx
@@ -0,0 +1,238 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment */
+
+import React, {
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import classnames from 'classnames';
+import {
+ ButtonIcon,
+ ButtonIconSize,
+ FormTextField,
+ IconName,
+} from '../../component-library';
+import { I18nContext } from '../../../contexts/i18n';
+import { Display, IconColor } from '../../../helpers/constants/design-system';
+
+export interface FormComboFieldOption {
+ primaryLabel: string;
+ secondaryLabel?: string;
+}
+
+export interface FormComboFieldProps {
+ /** The maximum height of the dropdown in pixels. */
+ maxDropdownHeight?: number;
+
+ /** The text to display in the dropdown when there are no options to display. */
+ noOptionsText?: string;
+
+ /** Callback function to invoke when the value changes. */
+ onChange?: (value: string) => void;
+
+ /** Callback function to invoke when a dropdown option is clicked. */
+ onOptionClick?: (option: FormComboFieldOption) => void;
+
+ /**
+ * The options to display in the dropdown.
+ * An array of objects with a 'primaryLabel' and optionally a 'secondaryLabel' property.`
+ */
+ options: FormComboFieldOption[];
+
+ /** The placeholder text to display in the field when the value is empty. */
+ placeholder?: string;
+
+ /** The value to display in the field. */
+ value: string;
+}
+
+function Option({
+ option,
+ onClick,
+}: {
+ option: FormComboFieldOption;
+ onClick: (option: FormComboFieldOption) => void;
+}) {
+ const handleClick = useCallback(
+ (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ e.nativeEvent.stopImmediatePropagation();
+
+ onClick(option);
+ },
+ [onClick, option],
+ );
+
+ const { primaryLabel, secondaryLabel } = option;
+
+ return (
+
+ {primaryLabel}
+ {secondaryLabel ? (
+
+ {secondaryLabel}
+
+ ) : null}
+
+ );
+}
+
+function Dropdown({
+ maxDropdownHeight,
+ noOptionsText,
+ onOptionClick,
+ options,
+ width,
+}: {
+ maxDropdownHeight?: number;
+ noOptionsText?: string;
+ onOptionClick: (option?: FormComboFieldOption) => void;
+ options: FormComboFieldOption[];
+ width: number;
+}) {
+ const t = useContext(I18nContext);
+ const ref = useRef();
+ const maxHeight = maxDropdownHeight ?? 179;
+ const [dropdownHeight, setDropdownHeight] = useState(0);
+
+ useEffect(() => {
+ setDropdownHeight(ref.current?.scrollHeight ?? 0);
+ });
+
+ return (
+ maxHeight,
+ })}
+ >
+ {options.length === 0 && (
+ onOptionClick(undefined)}
+ />
+ )}
+ {options.map((option, index) => (
+ {
+ onOptionClick(option);
+ }}
+ />
+ ))}
+
+ );
+}
+
+export default function FormComboField({
+ maxDropdownHeight,
+ noOptionsText,
+ onChange,
+ onOptionClick,
+ options,
+ placeholder,
+ value,
+}: FormComboFieldProps) {
+ const [dropdownVisible, setDropdownVisible] = useState(false);
+ const valueRef = useRef();
+ const [valueWidth, setValueWidth] = useState(0);
+ const inputRef = useRef(null);
+ const t = useContext(I18nContext);
+
+ useEffect(() => {
+ setValueWidth(valueRef.current?.offsetWidth);
+ });
+
+ const handleBlur = useCallback(
+ (e?: any) => {
+ if (e?.relatedTarget?.className !== 'form-combo-field__option') {
+ setDropdownVisible(false);
+ }
+ },
+ [setDropdownVisible],
+ );
+
+ const handleChange = useCallback(
+ (e: any) => {
+ onChange?.(e.target.value);
+ },
+ [onChange],
+ );
+
+ const handleOptionClick = useCallback(
+ (option) => {
+ setDropdownVisible(false);
+
+ if (option) {
+ handleChange({ target: { value: option.primaryLabel } });
+ onOptionClick?.(option);
+ }
+
+ inputRef.current?.focus();
+ },
+ [setDropdownVisible, handleChange],
+ );
+
+ const handleClearClick = useCallback(() => {
+ handleChange({ target: { value: '' } });
+ inputRef.current?.focus();
+ }, [handleChange]);
+
+ return (
+
+
{
+ setDropdownVisible(true);
+ }}
+ >
+ {/* @ts-ignore */}
+ {
+ if (e.key === 'Enter') {
+ handleBlur();
+ }
+ }}
+ value={value}
+ onChange={handleChange}
+ className={classnames({
+ 'form-combo-field__value': true,
+ 'form-combo-field__value-dropdown-visible': dropdownVisible,
+ })}
+ endAccessory={
+ handleClearClick()}
+ color={IconColor.iconMuted}
+ ariaLabel={t('clear')}
+ />
+ }
+ />
+
+ {dropdownVisible && (
+
+ )}
+
+ );
+}
diff --git a/ui/components/ui/form-combo-field/index.scss b/ui/components/ui/form-combo-field/index.scss
new file mode 100644
index 000000000000..37d8325654df
--- /dev/null
+++ b/ui/components/ui/form-combo-field/index.scss
@@ -0,0 +1,57 @@
+.form-combo-field {
+ width: 100%;
+
+ ::-webkit-scrollbar-thumb {
+ -webkit-border-radius: 8px;
+ border-radius: 8px;
+ background: var(--color-icon-muted);
+ }
+
+ ::-webkit-scrollbar {
+ width: 0;
+ }
+
+ &__value > div {
+ outline: 0;
+ width: 100%;
+ }
+
+ &__value-dropdown-visible > div {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+
+ &__dropdown {
+ border: 1px solid var(--color-border-default);
+ border-top-width: 0;
+ position: absolute;
+ overflow-y: scroll;
+ z-index: 1;
+ background-color: var(--color-background-default);
+
+ &__scroll::-webkit-scrollbar {
+ width: 8px;
+ }
+ }
+
+ &__option {
+ display: flex;
+ flex-direction: column;
+ padding: 10px 16px 10px 16px;
+ line-height: normal;
+ font-weight: normal;
+ }
+
+ &__option:hover {
+ background-color: var(--color-background-default-hover);
+ }
+
+ &__option-primary {
+ padding-bottom: 4px;
+ }
+
+ &__option-secondary {
+ color: var(--color-text-alternative);
+ font-size: 12px;
+ }
+}
diff --git a/ui/components/ui/form-combo-field/index.ts b/ui/components/ui/form-combo-field/index.ts
new file mode 100644
index 000000000000..e554bd055f28
--- /dev/null
+++ b/ui/components/ui/form-combo-field/index.ts
@@ -0,0 +1 @@
+export { default } from './form-combo-field';
diff --git a/ui/components/ui/ui-components.scss b/ui/components/ui/ui-components.scss
index 776538e712c5..20d90b2826f6 100644
--- a/ui/components/ui/ui-components.scss
+++ b/ui/components/ui/ui-components.scss
@@ -60,3 +60,4 @@
@import 'nft-collection-image/index';
@import '../institutional/custody-labels/index';
@import '../institutional/note-to-trader/index';
+@import 'form-combo-field/index';
diff --git a/ui/hooks/useName.test.ts b/ui/hooks/useName.test.ts
new file mode 100644
index 000000000000..d5a5b17f36ff
--- /dev/null
+++ b/ui/hooks/useName.test.ts
@@ -0,0 +1,145 @@
+import { NameType } from '@metamask/name-controller';
+import { getCurrentChainId, getNames } from '../selectors';
+import { useName } from './useName';
+
+jest.mock('react-redux', () => ({
+ useSelector: (selector: any) => selector(),
+}));
+
+jest.mock('../selectors', () => ({
+ getCurrentChainId: jest.fn(),
+ getNames: jest.fn(),
+}));
+
+const CHAIN_ID_MOCK = '0x1';
+const CHAIN_ID_2_MOCK = '0x2';
+const VALUE_MOCK = '0x0';
+const TYPE_MOCK = NameType.ETHEREUM_ADDRESS;
+const NAME_MOCK = 'TestName';
+const SOURCE_ID_MOCK = 'TestSourceId';
+const PROPOSED_NAMES_MOCK = {
+ [SOURCE_ID_MOCK]: ['TestProposedName', 'TestProposedName2'],
+};
+const PROPOSED_NAMES_LAST_UPDATED_MOCK = 1234567890;
+
+describe('useName', () => {
+ const getCurrentChainIdMock = jest.mocked(getCurrentChainId);
+ const getNamesMock = jest.mocked(getNames);
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+
+ getCurrentChainIdMock.mockReturnValue(CHAIN_ID_MOCK);
+ });
+
+ it('returns default values if no state', () => {
+ getNamesMock.mockReturnValue({});
+
+ const nameEntry = useName(VALUE_MOCK, TYPE_MOCK);
+
+ expect(nameEntry).toStrictEqual({
+ name: null,
+ sourceId: null,
+ proposedNames: {},
+ proposedNamesLastUpdated: null,
+ });
+ });
+
+ it('returns default values if no entry', () => {
+ getNamesMock.mockReturnValue({
+ [TYPE_MOCK]: {
+ [VALUE_MOCK]: {
+ [CHAIN_ID_2_MOCK]: {
+ name: NAME_MOCK,
+ proposedNames: PROPOSED_NAMES_MOCK,
+ sourceId: SOURCE_ID_MOCK,
+ proposedNamesLastUpdated: PROPOSED_NAMES_LAST_UPDATED_MOCK,
+ },
+ },
+ },
+ });
+
+ const nameEntry = useName(VALUE_MOCK, TYPE_MOCK);
+
+ expect(nameEntry).toStrictEqual({
+ name: null,
+ sourceId: null,
+ proposedNames: {},
+ proposedNamesLastUpdated: null,
+ });
+ });
+
+ it('returns entry if found', () => {
+ getNamesMock.mockReturnValue({
+ [TYPE_MOCK]: {
+ [VALUE_MOCK]: {
+ [CHAIN_ID_MOCK]: {
+ name: NAME_MOCK,
+ proposedNames: PROPOSED_NAMES_MOCK,
+ sourceId: SOURCE_ID_MOCK,
+ proposedNamesLastUpdated: PROPOSED_NAMES_LAST_UPDATED_MOCK,
+ },
+ },
+ },
+ });
+
+ const nameEntry = useName(VALUE_MOCK, TYPE_MOCK);
+
+ expect(nameEntry).toStrictEqual({
+ name: NAME_MOCK,
+ sourceId: SOURCE_ID_MOCK,
+ proposedNames: PROPOSED_NAMES_MOCK,
+ proposedNamesLastUpdated: PROPOSED_NAMES_LAST_UPDATED_MOCK,
+ });
+ });
+
+ it('uses variation if specified', () => {
+ getNamesMock.mockReturnValue({
+ [TYPE_MOCK]: {
+ [VALUE_MOCK]: {
+ [CHAIN_ID_2_MOCK]: {
+ name: NAME_MOCK,
+ proposedNames: PROPOSED_NAMES_MOCK,
+ sourceId: SOURCE_ID_MOCK,
+ proposedNamesLastUpdated: PROPOSED_NAMES_LAST_UPDATED_MOCK,
+ },
+ },
+ },
+ });
+
+ const nameEntry = useName(VALUE_MOCK, TYPE_MOCK, CHAIN_ID_2_MOCK);
+
+ expect(nameEntry).toStrictEqual({
+ name: NAME_MOCK,
+ sourceId: SOURCE_ID_MOCK,
+ proposedNames: PROPOSED_NAMES_MOCK,
+ proposedNamesLastUpdated: PROPOSED_NAMES_LAST_UPDATED_MOCK,
+ });
+ });
+
+ it('uses empty string as variation if not specified and type is not address', () => {
+ const alternateType = 'alternateType' as NameType;
+
+ getNamesMock.mockReturnValue({
+ [alternateType]: {
+ [VALUE_MOCK]: {
+ '': {
+ name: NAME_MOCK,
+ proposedNames: PROPOSED_NAMES_MOCK,
+ sourceId: SOURCE_ID_MOCK,
+ proposedNamesLastUpdated: PROPOSED_NAMES_LAST_UPDATED_MOCK,
+ },
+ },
+ },
+ });
+
+ const nameEntry = useName(VALUE_MOCK, alternateType);
+
+ expect(nameEntry).toStrictEqual({
+ name: NAME_MOCK,
+ sourceId: SOURCE_ID_MOCK,
+ proposedNames: PROPOSED_NAMES_MOCK,
+ proposedNamesLastUpdated: PROPOSED_NAMES_LAST_UPDATED_MOCK,
+ });
+ });
+});
diff --git a/ui/hooks/useName.ts b/ui/hooks/useName.ts
new file mode 100644
index 000000000000..67a43031a23f
--- /dev/null
+++ b/ui/hooks/useName.ts
@@ -0,0 +1,25 @@
+import { NameEntry, NameType } from '@metamask/name-controller';
+import { useSelector } from 'react-redux';
+import { isEqual } from 'lodash';
+import { getCurrentChainId, getNames } from '../selectors';
+
+export function useName(
+ value: string,
+ type: NameType,
+ variation?: string,
+): NameEntry {
+ const names = useSelector(getNames, isEqual);
+ const chainId = useSelector(getCurrentChainId);
+
+ const variationKey =
+ variation ?? (type === NameType.ETHEREUM_ADDRESS ? chainId : '');
+
+ const nameEntry = names[type]?.[value]?.[variationKey];
+
+ return {
+ name: nameEntry?.name ?? null,
+ sourceId: nameEntry?.sourceId ?? null,
+ proposedNames: nameEntry?.proposedNames ?? {},
+ proposedNamesLastUpdated: nameEntry?.proposedNamesLastUpdated ?? null,
+ };
+}
diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js
index cf89a648ac3d..5f554f54fbbd 100644
--- a/ui/selectors/selectors.js
+++ b/ui/selectors/selectors.js
@@ -8,6 +8,7 @@ import {
///: END:ONLY_INCLUDE_IN
} from 'lodash';
import { createSelector } from 'reselect';
+import { NameType } from '@metamask/name-controller';
import { addHexPrefix } from '../../app/scripts/lib/util';
import {
TEST_CHAINS,
@@ -1618,6 +1619,18 @@ export function getUseCurrencyRateCheck(state) {
return Boolean(state.metamask.useCurrencyRateCheck);
}
+export function getNames(state) {
+ return state.metamask.names || {};
+}
+
+export function getEthereumAddressNames(state) {
+ return state.metamask.names?.[NameType.ETHEREUM_ADDRESS] || {};
+}
+
+export function getNameSources(state) {
+ return state.metamask.nameSources || {};
+}
+
///: BEGIN:ONLY_INCLUDE_IN(desktop)
/**
* To get the `desktopEnabled` value which determines whether we use the desktop app
diff --git a/ui/store/actions.ts b/ui/store/actions.ts
index ee1398dd1aea..33585cf88f99 100644
--- a/ui/store/actions.ts
+++ b/ui/store/actions.ts
@@ -22,6 +22,11 @@ import { NonEmptyArray } from '@metamask/controller-utils';
///: BEGIN:ONLY_INCLUDE_IN(keyring-snaps)
import { HandlerType } from '@metamask/snaps-utils';
///: END:ONLY_INCLUDE_IN
+import {
+ SetNameRequest,
+ UpdateProposedNamesRequest,
+ UpdateProposedNamesResult,
+} from '@metamask/name-controller';
import { getMethodDataAsync } from '../helpers/utils/transactions.util';
import switchDirection from '../../shared/lib/switch-direction';
import {
@@ -4509,6 +4514,32 @@ export async function getCurrentNetworkEIP1559Compatibility(): Promise<
return networkEIP1559Compatibility;
}
+export function updateProposedNames(
+ request: UpdateProposedNamesRequest,
+): ThunkAction<
+ UpdateProposedNamesResult,
+ MetaMaskReduxState,
+ unknown,
+ AnyAction
+> {
+ return (async () => {
+ const data = await submitRequestToBackground(
+ 'updateProposedNames',
+ [request],
+ );
+
+ return data;
+ }) as any;
+}
+
+export function setName(
+ request: SetNameRequest,
+): ThunkAction {
+ return (async () => {
+ await submitRequestToBackground('setName', [request]);
+ }) as any;
+}
+
/**
* Throw an error in the background for testing purposes.
*
diff --git a/yarn.lock b/yarn.lock
index 701f81bc2f04..371021672510 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4358,6 +4358,16 @@ __metadata:
languageName: node
linkType: hard
+"@metamask/name-controller@npm:^1.0.0":
+ version: 1.0.0
+ resolution: "@metamask/name-controller@npm:1.0.0"
+ dependencies:
+ "@metamask/base-controller": "npm:^3.2.1"
+ immer: "npm:^9.0.6"
+ checksum: 2366672299a6cf4f7ad78dd023e110fb175606911018a8b2efd1243898d2e74f9eb4fd59b1579c386441b21f000f74e700dd47b955aa99b6f0ba08686dcf2104
+ languageName: node
+ linkType: hard
+
"@metamask/network-controller@npm:^10.2.0, @metamask/network-controller@npm:^10.3.0":
version: 10.3.1
resolution: "@metamask/network-controller@npm:10.3.1"
@@ -24215,6 +24225,7 @@ __metadata:
"@metamask/logo": "npm:^3.1.1"
"@metamask/message-manager": "npm:^7.3.0"
"@metamask/metamask-eth-abis": "npm:^3.0.0"
+ "@metamask/name-controller": "npm:^1.0.0"
"@metamask/network-controller": "npm:^12.1.1"
"@metamask/notification-controller": "npm:^3.0.0"
"@metamask/obs-store": "npm:^8.1.0"