Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: contact names should not allow duplication #28249

Merged
merged 20 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d690771
fix: prevent duplicate names when adding a contact
Matt561 Oct 31, 2024
48658be
fix: prevent duplicate names when editing an existing contact
Matt561 Oct 31, 2024
95f69b2
fix: perform name validation on ENS input when adding contact
Matt561 Oct 31, 2024
bdfd821
feat: added placeholder to username input when adding new contact
Matt561 Oct 31, 2024
2958a91
chore: added tests for duplicate username input
Matt561 Nov 1, 2024
289042c
feat: added duplicate contact warning banner to contact list
Matt561 Nov 1, 2024
05c9725
Merge branch 'develop' into fix/26621-contact-names-should-not-allow-…
Matt561 Nov 1, 2024
a01ed8a
Merge branch 'develop' into fix/26621-contact-names-should-not-allow-…
Matt561 Nov 4, 2024
f4e5925
fix: fixed breaking edit-contact.test.js
Matt561 Nov 4, 2024
ebabefa
Merge branch 'develop' into fix/26621-contact-names-should-not-allow-…
Matt561 Nov 4, 2024
e632185
fix: mock lookupDomainName in add-contact.test.js
Matt561 Nov 4, 2024
16e2f38
fix: remove unit from zero integer in duplicate-contact-banner style
Matt561 Nov 4, 2024
66945ee
Merge branch 'develop' into fix/26621-contact-names-should-not-allow-…
Matt561 Nov 4, 2024
ef8cd1b
fix: improvements based on PR review comments
Matt561 Nov 5, 2024
c0a6baf
Merge remote-tracking branch 'origin/develop' into fix/26621-contact-…
Matt561 Nov 6, 2024
3ac63bc
fix: replaced type anys in contact-list utils
Matt561 Nov 6, 2024
f1351c6
fix: fixed contact-list-tab story that was failing due to missing int…
Matt561 Nov 6, 2024
b73e844
feat: added duplicate contact warning icon in contact list
Matt561 Nov 15, 2024
e8b47de
Merge branch 'develop' into fix/26621-contact-names-should-not-allow-…
Matt561 Nov 15, 2024
5d7fcc6
Merge branch 'develop' into fix/26621-contact-names-should-not-allow-…
Matt561 Nov 20, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/_locales/en/messages.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions test/data/mock-data.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 54 additions & 7 deletions ui/components/app/contact-list/contact-list.component.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { sortBy } from 'lodash';
import Button from '../../ui/button';
import { BannerAlert, BannerAlertSeverity } from '../../component-library';
import RecipientGroup from './recipient-group/recipient-group.component';
import { hasDuplicateContacts, buildDuplicateContactMap } from './utils';

export default class ContactList extends PureComponent {
static propTypes = {
addressBook: PropTypes.array,
internalAccounts: PropTypes.array,
searchForContacts: PropTypes.func,
searchForRecents: PropTypes.func,
searchForMyAccounts: PropTypes.func,
Expand All @@ -22,6 +26,19 @@ export default class ContactList extends PureComponent {
isShowingAllRecent: false,
};

renderDuplicateContactWarning() {
const { t } = this.context;

return (
<div className="send__select-recipient-wrapper__list__duplicate-contact-banner">
<BannerAlert
severity={BannerAlertSeverity.Warning}
description={t('duplicateContactWarning')}
/>
</div>
);
}

renderRecents() {
const { t } = this.context;
const { isShowingAllRecent } = this.state;
Expand All @@ -45,15 +62,40 @@ export default class ContactList extends PureComponent {
}

renderAddressBook() {
const unsortedContactsByLetter = this.props
.searchForContacts()
.reduce((obj, contact) => {
const {
addressBook,
internalAccounts,
searchForContacts,
selectRecipient,
selectedAddress,
} = this.props;

const duplicateContactMap = buildDuplicateContactMap(
addressBook,
internalAccounts,
);

const unsortedContactsByLetter = searchForContacts().reduce(
(obj, contact) => {
const firstLetter = contact.name[0].toUpperCase();

const isDuplicate =
(duplicateContactMap.get(contact.name.trim().toLowerCase()) ?? [])
.length > 1;

return {
...obj,
[firstLetter]: [...(obj[firstLetter] || []), contact],
[firstLetter]: [
...(obj[firstLetter] || []),
{
...contact,
isDuplicate,
},
],
};
}, {});
},
{},
);

const letters = Object.keys(unsortedContactsByLetter).sort();

Expand All @@ -71,8 +113,8 @@ export default class ContactList extends PureComponent {
key={`${letter}-contact-group`}
label={letter}
items={groupItems}
onSelect={this.props.selectRecipient}
selectedAddress={this.props.selectedAddress}
onSelect={selectRecipient}
selectedAddress={selectedAddress}
/>
));
}
Expand All @@ -95,11 +137,16 @@ export default class ContactList extends PureComponent {
searchForRecents,
searchForContacts,
searchForMyAccounts,
addressBook,
internalAccounts,
} = this.props;

return (
<div className="send__select-recipient-wrapper__list">
{children || null}
{hasDuplicateContacts(addressBook, internalAccounts)
? this.renderDuplicateContactWarning()
: null}
{searchForRecents ? this.renderRecents() : null}
{searchForContacts ? this.renderAddressBook() : null}
{searchForMyAccounts ? this.renderMyAccounts() : null}
Expand Down
46 changes: 46 additions & 0 deletions ui/components/app/contact-list/contact-list.test.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,57 @@
import React from 'react';
import configureMockStore from 'redux-mock-store';
import { renderWithProvider } from '../../../../test/jest/rendering';
import { MOCK_ADDRESS_BOOK } from '../../../../test/data/mock-data';
import { createMockInternalAccount } from '../../../../test/jest/mocks';
import ContactList from '.';

describe('Contact List', () => {
const store = configureMockStore([])({
metamask: {},
});

const mockInternalAccounts = [createMockInternalAccount()];

it('displays the warning banner when multiple contacts have the same name', () => {
const mockAddressBook = [...MOCK_ADDRESS_BOOK, MOCK_ADDRESS_BOOK[0]]; // Adding duplicate contact

const { getByText } = renderWithProvider(
<ContactList
addressBook={mockAddressBook}
internalAccounts={mockInternalAccounts}
/>,
store,
);

const duplicateContactBanner = getByText('You have duplicate contacts');

expect(duplicateContactBanner).toBeVisible();
});

it('displays the warning banner when contact has same name as an existing account', () => {
const mockContactWithAccountName = {
address: '0x2f318C334780961FB129D2a6c30D0763d9a5C970',
chainId: '0x1',
isEns: false,
memo: '',
name: mockInternalAccounts[0].metadata.name,
};

const mockAddressBook = [...MOCK_ADDRESS_BOOK, mockContactWithAccountName];

const { getByText } = renderWithProvider(
<ContactList
addressBook={mockAddressBook}
internalAccounts={mockInternalAccounts}
/>,
store,
);

const duplicateContactBanner = getByText('You have duplicate contacts');

expect(duplicateContactBanner).toBeVisible();
});

describe('given searchForContacts', () => {
const selectRecipient = () => null;
const selectedAddress = null;
Expand Down Expand Up @@ -37,6 +81,8 @@ describe('Contact List', () => {
searchForContacts={() => contacts}
selectRecipient={selectRecipient}
selectedAddress={selectedAddress}
addressBook={MOCK_ADDRESS_BOOK}
internalAccounts={mockInternalAccounts}
/>,
store,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ export default function RecipientGroup({ items, onSelect }) {
return null;
}

return items.map(({ address, name }) => (
return items.map(({ address, name, isDuplicate }) => (
<AddressListItem
address={address}
label={name}
onClick={() => onSelect(address, name)}
key={address}
isDuplicate={isDuplicate}
/>
));
}
Expand Down
61 changes: 61 additions & 0 deletions ui/components/app/contact-list/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { AddressBookEntry } from '@metamask/address-book-controller';
import { InternalAccount } from '@metamask/keyring-api';

export const buildDuplicateContactMap = (
addressBook: AddressBookEntry[],
internalAccounts: InternalAccount[],
) => {
const contactMap = new Map<string, string[]>(
internalAccounts.map((account) => [
account.metadata.name.trim().toLowerCase(),
[`account-id-${account.id}`],
]),
);

addressBook.forEach((entry) => {
const { name, address } = entry;

const sanitizedName = name.trim().toLowerCase();

const currentArray = contactMap.get(sanitizedName) ?? [];
currentArray.push(address);

contactMap.set(sanitizedName, currentArray);
});

return contactMap;
};

export const hasDuplicateContacts = (
addressBook: AddressBookEntry[],
internalAccounts: InternalAccount[],
) => {
const uniqueContactNames = Array.from(
new Set(addressBook.map(({ name }) => name.toLowerCase().trim())),
);

const hasAccountNameCollision = internalAccounts.some((account) =>
uniqueContactNames.includes(account.metadata.name.toLowerCase().trim()),
);

return (
uniqueContactNames.length !== addressBook.length || hasAccountNameCollision
);
};

export const isDuplicateContact = (
addressBook: AddressBookEntry[],
internalAccounts: InternalAccount[],
newName: string,
) => {
const nameExistsInAddressBook = addressBook.some(
({ name }) => name.toLowerCase().trim() === newName.toLowerCase().trim(),
);

const nameExistsInAccountList = internalAccounts.some(
({ metadata }) =>
metadata.name.toLowerCase().trim() === newName.toLowerCase().trim(),
);

return nameExistsInAddressBook || nameExistsInAccountList;
};
Loading
Loading