aboutsummaryrefslogtreecommitdiffstats
path: root/ui/app/pages/settings/contact-list-tab
diff options
context:
space:
mode:
authorDan J Miller <danjm.com@gmail.com>2019-08-01 03:56:44 +0800
committerGitHub <noreply@github.com>2019-08-01 03:56:44 +0800
commite9c7df28ed88f6dc3a5074cf873f3920429d1803 (patch)
tree6075769d6a43cda8ce7de0a141b8f55065f05727 /ui/app/pages/settings/contact-list-tab
parent1fd3dc9ecf16f00d721078e114138e529a7b8e16 (diff)
downloadtangerine-wallet-browser-e9c7df28ed88f6dc3a5074cf873f3920429d1803.tar.gz
tangerine-wallet-browser-e9c7df28ed88f6dc3a5074cf873f3920429d1803.tar.zst
tangerine-wallet-browser-e9c7df28ed88f6dc3a5074cf873f3920429d1803.zip
Address book send plus contact list (#6914)
* Style Send Header * Move Send to-row to send view and restyle * Add "Recents" group to select recipient view * Rename SendToRow to AddRecipient * Basic UI and Layout * New ENSInput component * wip - fuzzy search for input * small refactor * Add Dialog * contact list initial * initial error on invalid address * clean up edit * Click to open modal * Create AddToAddressBookModal component * Modal styling and layout * modal i18n * Add to Addressbook * ens wip * ens wip * ENS Resolution * Reset input * Send to explicit address * Happy Path Complete * Add back error checking * Reset send-to when emptying input * Add back warning object * Fix linter * Fix unit test #1 - fix import paths * Remove dead tests * One more to go * Fix all unit tests * add unit test for reducers and actions * test rendering AddRecipient * Add tests for dialog boxes in AddRecipient * Add test for validating * Fix linter * Fix e2e tests * Token send e2e fix * Style View Contact * Style edit-contact * Fix e2e * Fix from-import-beta-ui e2e spec * Make section header say "add recipient” by default * Auto-focus add recipient input * Update placeholder text * Update input title font size * Auto advance to next step if user paste a valid address * Ellipsify address when recipient is selected * Fix app header background color on desktop * Give each form row a margin of 16px * Use .container/.component naming pattern for ens-input * Auto-focus on input when add to addressbook modal is opened; Save on Enter * Fix and add unit test * Fix selectors name in e2e tests * Correct e2e test token amount for address-book-send changes * Adds e2e test for editing a transaction * Delete test/integration/lib/send-new-ui.js * Add tests for amount max button and high value error on send screen to test/e2e/metamask-ui.spec.js * lint and revert to address as object keys * add chainId based on current network to address book entry * fix test * only display contacts for the current network * Improve ENS message when not found on current network * Add error to indicate when network does not support ENS * bump gaba * address book, resolve comments * Move contact-list to its own component * De-duplicate getaddressbook selector and refactor name selection logic in contact-list-tab/ * Use contact-list component in contact-list-tab.component (i.e. in settings) * Improve/fix settings headers for popup and browser views * Lint fixes related to address book updates * Add 'My accounts' page to settings address book * Update add new contact button in settings to match floating circular design * Improve styles of view contact page * Improve styles and labels of the add-contact.component * Further lint fixes related to address book updates * Update unit tests as per address book updates * Ensure that contact list groups are sorted alphabetically * Refactor settings component to use a container for connection to redux; allow display of addressbook name in settings header * Decouple ens-input.component from send context * Add ens resolution to add contact screen in settings * Switching networks when an ens address is shown on send form removes the ens address. * Resolve send screen search for ensAddress to matching address book entry if it exists * Show resolved ens icon and address if exists (settings: add-contact.component) * Make the displayed and copied address in view-contact.component the checksummed address * Default alias state prop in AddToAddressBookModal to empty string * Use keyCode to detect enter key in AddToAddressBookModal * Ensure add-contact component properly updates after QR code detection * Fix display of all recents after clicking 'Load More' in contact list * Fix send screen contact searching after network switching * Code cleanup related to address book changes * Update unit tests for address book changes * Update ENS name not found on network message * Add ens registration error message * Cancel on edit mode takes user back to view screen * Adds support for memo to settings contact list view and edit screens * Modify designs of edit and view contact in popup environment * Update settings content list UX to show split columns in fullscreen and proper internal navigation * Correct background address book API usages in UI
Diffstat (limited to 'ui/app/pages/settings/contact-list-tab')
-rw-r--r--ui/app/pages/settings/contact-list-tab/add-contact/add-contact.component.js131
-rw-r--r--ui/app/pages/settings/contact-list-tab/add-contact/add-contact.container.js30
-rw-r--r--ui/app/pages/settings/contact-list-tab/add-contact/index.js1
-rw-r--r--ui/app/pages/settings/contact-list-tab/contact-list-tab.component.js132
-rw-r--r--ui/app/pages/settings/contact-list-tab/contact-list-tab.container.js54
-rw-r--r--ui/app/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js135
-rw-r--r--ui/app/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js47
-rw-r--r--ui/app/pages/settings/contact-list-tab/edit-contact/index.js1
-rw-r--r--ui/app/pages/settings/contact-list-tab/index.js1
-rw-r--r--ui/app/pages/settings/contact-list-tab/index.scss234
-rw-r--r--ui/app/pages/settings/contact-list-tab/my-accounts/index.js1
-rw-r--r--ui/app/pages/settings/contact-list-tab/my-accounts/my-accounts.component.js39
-rw-r--r--ui/app/pages/settings/contact-list-tab/my-accounts/my-accounts.container.js18
-rw-r--r--ui/app/pages/settings/contact-list-tab/view-contact/index.js1
-rw-r--r--ui/app/pages/settings/contact-list-tab/view-contact/view-contact.component.js78
-rw-r--r--ui/app/pages/settings/contact-list-tab/view-contact/view-contact.container.js43
16 files changed, 946 insertions, 0 deletions
diff --git a/ui/app/pages/settings/contact-list-tab/add-contact/add-contact.component.js b/ui/app/pages/settings/contact-list-tab/add-contact/add-contact.component.js
new file mode 100644
index 000000000..871b2128b
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/add-contact/add-contact.component.js
@@ -0,0 +1,131 @@
+import React, { PureComponent } from 'react'
+import PropTypes from 'prop-types'
+import Identicon from '../../../../components/ui/identicon'
+import TextField from '../../../../components/ui/text-field'
+import { CONTACT_LIST_ROUTE } from '../../../../helpers/constants/routes'
+import { isValidAddress, isValidENSAddress } from '../../../../helpers/utils/util'
+import EnsInput from '../../../../pages/send/send-content/add-recipient/ens-input'
+import PageContainerFooter from '../../../../components/ui/page-container/page-container-footer'
+import debounce from 'lodash.debounce'
+
+export default class AddContact extends PureComponent {
+
+ static contextTypes = {
+ t: PropTypes.func,
+ }
+
+ static propTypes = {
+ addToAddressBook: PropTypes.func,
+ history: PropTypes.object,
+ scanQrCode: PropTypes.func,
+ qrCodeData: PropTypes.object,
+ qrCodeDetected: PropTypes.func,
+ }
+
+ state = {
+ nickname: '',
+ ethAddress: '',
+ ensAddress: '',
+ error: '',
+ ensError: '',
+ }
+
+ constructor (props) {
+ super(props)
+ this.dValidate = debounce(this.validate, 1000)
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.qrCodeData) {
+ if (nextProps.qrCodeData.type === 'address') {
+ const scannedAddress = nextProps.qrCodeData.values.address.toLowerCase()
+ const currentAddress = this.state.ensAddress || this.state.ethAddress
+ if (currentAddress.toLowerCase() !== scannedAddress) {
+ this.setState({ ethAddress: scannedAddress, ensAddress: '' })
+ // Clean up QR code data after handling
+ this.props.qrCodeDetected(null)
+ }
+ }
+ }
+ }
+
+ validate = address => {
+ const valid = isValidAddress(address)
+ const validEnsAddress = isValidENSAddress(address)
+ if (valid || validEnsAddress || address === '') {
+ this.setState({ error: '', ethAddress: address })
+ } else {
+ this.setState({ error: 'Invalid Address' })
+ }
+ }
+
+ renderInput () {
+ return (
+ <EnsInput
+ className="send__to-row"
+ scanQrCode={_ => { this.props.scanQrCode() }}
+ onChange={this.dValidate}
+ onPaste={text => this.setState({ ethAddress: text })}
+ onReset={() => this.setState({ ethAddress: '', ensAddress: '' })}
+ updateEnsResolution={address => {
+ this.setState({ ensAddress: address, error: '', ensError: '' })
+ }}
+ updateEnsResolutionError={message => this.setState({ ensError: message })}
+ />
+ )
+ }
+
+ render () {
+ const { t } = this.context
+ const { history, addToAddressBook } = this.props
+
+ const errorToRender = this.state.ensError || this.state.error
+
+ return (
+ <div className="settings-page__content-row address-book__add-contact">
+ {this.state.ensAddress && <div className="address-book__view-contact__group">
+ <Identicon address={this.state.ensAddress} diameter={60} />
+ <div className="address-book__view-contact__group__value">
+ { this.state.ensAddress }
+ </div>
+ </div>}
+ <div className="address-book__add-contact__content">
+ <div className="address-book__view-contact__group">
+ <div className="address-book__view-contact__group__label">
+ { t('userName') }
+ </div>
+ <TextField
+ type="text"
+ id="nickname"
+ value={this.state.newName}
+ onChange={e => this.setState({ newName: e.target.value })}
+ fullWidth
+ margin="dense"
+ />
+ </div>
+
+ <div className="address-book__view-contact__group">
+ <div className="address-book__view-contact__group__label">
+ { t('ethereumPublicAddress') }
+ </div>
+ { this.renderInput() }
+ { errorToRender && <div className="address-book__add-contact__error">{errorToRender}</div>}
+ </div>
+ </div>
+ <PageContainerFooter
+ cancelText={this.context.t('cancel')}
+ disabled={Boolean(this.state.error)}
+ onSubmit={() => {
+ addToAddressBook(this.state.ensAddress || this.state.ethAddress, this.state.newName)
+ history.push(CONTACT_LIST_ROUTE)
+ }}
+ onCancel={() => {
+ history.push(CONTACT_LIST_ROUTE)
+ }}
+ submitText={this.context.t('save')}
+ submitButtonType={'confirm'}
+ />
+ </div>
+ )
+ }
+}
diff --git a/ui/app/pages/settings/contact-list-tab/add-contact/add-contact.container.js b/ui/app/pages/settings/contact-list-tab/add-contact/add-contact.container.js
new file mode 100644
index 000000000..0a0fc450c
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/add-contact/add-contact.container.js
@@ -0,0 +1,30 @@
+import AddContact from './add-contact.component'
+import { compose } from 'recompose'
+import { connect } from 'react-redux'
+import { withRouter } from 'react-router-dom'
+import { addToAddressBook, showQrScanner, qrCodeDetected } from '../../../../store/actions'
+import {
+ CONTACT_ADD_ROUTE,
+} from '../../../../helpers/constants/routes'
+import {
+ getQrCodeData,
+} from '../../../../pages/send/send.selectors'
+
+const mapStateToProps = state => {
+ return {
+ qrCodeData: getQrCodeData(state),
+ }
+}
+
+const mapDispatchToProps = dispatch => {
+ return {
+ addToAddressBook: (recipient, nickname) => dispatch(addToAddressBook(recipient, nickname)),
+ scanQrCode: () => dispatch(showQrScanner(CONTACT_ADD_ROUTE)),
+ qrCodeDetected: (data) => dispatch(qrCodeDetected(data)),
+ }
+}
+
+export default compose(
+ withRouter,
+ connect(mapStateToProps, mapDispatchToProps)
+)(AddContact)
diff --git a/ui/app/pages/settings/contact-list-tab/add-contact/index.js b/ui/app/pages/settings/contact-list-tab/add-contact/index.js
new file mode 100644
index 000000000..ce73025a3
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/add-contact/index.js
@@ -0,0 +1 @@
+export { default } from './add-contact.container'
diff --git a/ui/app/pages/settings/contact-list-tab/contact-list-tab.component.js b/ui/app/pages/settings/contact-list-tab/contact-list-tab.component.js
new file mode 100644
index 000000000..f7a01d672
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/contact-list-tab.component.js
@@ -0,0 +1,132 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import ContactList from '../../../components/app/contact-list'
+import EditContact from './edit-contact'
+import AddContact from './add-contact'
+import ViewContact from './view-contact'
+import MyAccounts from './my-accounts'
+import {
+ CONTACT_ADD_ROUTE,
+ CONTACT_VIEW_ROUTE,
+ CONTACT_MY_ACCOUNTS_ROUTE,
+} from '../../../helpers/constants/routes'
+
+export default class ContactListTab extends Component {
+ static contextTypes = {
+ t: PropTypes.func,
+ }
+
+ static propTypes = {
+ addressBook: PropTypes.array,
+ history: PropTypes.object,
+ selectedAddress: PropTypes.string,
+ viewingContact: PropTypes.bool,
+ editingContact: PropTypes.bool,
+ addingContact: PropTypes.bool,
+ showContactContent: PropTypes.bool,
+ hideAddressBook: PropTypes.bool,
+ showingMyAccounts: PropTypes.bool,
+ }
+
+ renderAddresses () {
+ const { addressBook, history, selectedAddress } = this.props
+ const contacts = addressBook.filter(({ name }) => !!name)
+ const nonContacts = addressBook.filter(({ name }) => !name)
+
+ return (
+ <div>
+ <ContactList
+ searchForContacts={() => contacts}
+ searchForRecents={() => nonContacts}
+ selectRecipient={(address) => {
+ history.push(`${CONTACT_VIEW_ROUTE}/${address}`)
+ }}
+ selectedAddress={selectedAddress}
+ />
+ </div>
+ )
+ }
+
+ renderAddButton () {
+ const { history } = this.props
+ return <div
+ className="address-book-add-button__button"
+ onClick={() => {
+ history.push(CONTACT_ADD_ROUTE)
+ }}>
+ <img
+ className="account-menu__item-icon"
+ src="images/plus-btn-white.svg"
+ />
+ </div>
+ }
+
+ renderMyAccountsButton () {
+ const { history } = this.props
+ const { t } = this.context
+ return (
+ <div
+ className="address-book__my-accounts-button"
+ onClick={() => {
+ history.push(CONTACT_MY_ACCOUNTS_ROUTE)
+ }}
+ >
+ <div className="address-book__my-accounts-button__header">{t('myWalletAccounts')}</div>
+ <div className="address-book__my-accounts-button__content">
+ <div className="address-book__my-accounts-button__text">
+ { t('myWalletAccountsDescription') }
+ </div>
+ <div className="address-book__my-accounts-button__caret" />
+ </div>
+ </div>
+ )
+ }
+
+ renderContactContent () {
+ const { viewingContact, editingContact, addingContact, showContactContent } = this.props
+
+ if (!showContactContent) {
+ return null
+ }
+
+ let ContactContentComponent = null
+ if (viewingContact) {
+ ContactContentComponent = ViewContact
+ } else if (editingContact) {
+ ContactContentComponent = EditContact
+ } else if (addingContact) {
+ ContactContentComponent = AddContact
+ }
+
+ return (ContactContentComponent && <div className="address-book-contact-content">
+ <ContactContentComponent />
+ </div>)
+ }
+
+ renderAddressBookContent () {
+ const { hideAddressBook, showingMyAccounts } = this.props
+
+ if (!hideAddressBook && !showingMyAccounts) {
+ return (<div className="address-book">
+ { this.renderMyAccountsButton() }
+ { this.renderAddresses() }
+ </div>)
+ } else if (!hideAddressBook && showingMyAccounts) {
+ return (<MyAccounts />)
+ }
+ }
+
+ render () {
+ const { addingContact } = this.props
+
+ return (
+ <div className="address-book-wrapper">
+ { this.renderAddressBookContent() }
+ { this.renderContactContent() }
+ {!addingContact && <div className="address-book-add-button">
+ { this.renderAddButton() }
+ </div>}
+ </div>
+ )
+ }
+}
diff --git a/ui/app/pages/settings/contact-list-tab/contact-list-tab.container.js b/ui/app/pages/settings/contact-list-tab/contact-list-tab.container.js
new file mode 100644
index 000000000..2c7139b5d
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/contact-list-tab.container.js
@@ -0,0 +1,54 @@
+import ContactListTab from './contact-list-tab.component'
+import { compose } from 'recompose'
+import { connect } from 'react-redux'
+import { withRouter } from 'react-router-dom'
+import { getAddressBook } from '../../../selectors/selectors'
+import { ENVIRONMENT_TYPE_POPUP } from '../../../../../app/scripts/lib/enums'
+import { getEnvironmentType } from '../../../../../app/scripts/lib/util'
+
+import {
+ CONTACT_ADD_ROUTE,
+ CONTACT_EDIT_ROUTE,
+ CONTACT_VIEW_ROUTE,
+ CONTACT_MY_ACCOUNTS_ROUTE,
+ CONTACT_MY_ACCOUNTS_VIEW_ROUTE,
+ CONTACT_MY_ACCOUNTS_EDIT_ROUTE,
+} from '../../../helpers/constants/routes'
+
+
+const mapStateToProps = (state, ownProps) => {
+ const { location } = ownProps
+ const { pathname } = location
+
+ const pathNameTail = pathname.match(/[^/]+$/)[0]
+ const pathNameTailIsAddress = pathNameTail.includes('0x')
+
+ const viewingContact = Boolean(pathname.match(CONTACT_VIEW_ROUTE) || pathname.match(CONTACT_MY_ACCOUNTS_VIEW_ROUTE))
+ const editingContact = Boolean(pathname.match(CONTACT_EDIT_ROUTE) || pathname.match(CONTACT_MY_ACCOUNTS_EDIT_ROUTE))
+ const addingContact = Boolean(pathname.match(CONTACT_ADD_ROUTE))
+ const showingMyAccounts = Boolean(
+ pathname.match(CONTACT_MY_ACCOUNTS_ROUTE) ||
+ pathname.match(CONTACT_MY_ACCOUNTS_VIEW_ROUTE) ||
+ pathname.match(CONTACT_MY_ACCOUNTS_EDIT_ROUTE)
+ )
+ const envIsPopup = getEnvironmentType() === ENVIRONMENT_TYPE_POPUP
+
+ const hideAddressBook = envIsPopup && (viewingContact || editingContact || addingContact)
+
+ return {
+ viewingContact,
+ editingContact,
+ addingContact,
+ showingMyAccounts,
+ addressBook: getAddressBook(state),
+ selectedAddress: pathNameTailIsAddress ? pathNameTail : '',
+ hideAddressBook,
+ envIsPopup,
+ showContactContent: !envIsPopup || hideAddressBook,
+ }
+}
+
+export default compose(
+ withRouter,
+ connect(mapStateToProps)
+)(ContactListTab)
diff --git a/ui/app/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js b/ui/app/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js
new file mode 100644
index 000000000..e9c2fed6f
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js
@@ -0,0 +1,135 @@
+import React, { PureComponent } from 'react'
+import PropTypes from 'prop-types'
+import Identicon from '../../../../components/ui/identicon'
+import Button from '../../../../components/ui/button/button.component'
+import TextField from '../../../../components/ui/text-field'
+import { isValidAddress } from '../../../../helpers/utils/util'
+import PageContainerFooter from '../../../../components/ui/page-container/page-container-footer'
+
+export default class EditContact extends PureComponent {
+
+ static contextTypes = {
+ t: PropTypes.func,
+ }
+
+ static propTypes = {
+ addToAddressBook: PropTypes.func,
+ removeFromAddressBook: PropTypes.func,
+ history: PropTypes.object,
+ name: PropTypes.string,
+ address: PropTypes.string,
+ memo: PropTypes.string,
+ viewRoute: PropTypes.string,
+ listRoute: PropTypes.string,
+ setAccountLabel: PropTypes.func,
+ }
+
+ state = {
+ newName: '',
+ newAddress: '',
+ newMemo: '',
+ error: '',
+ }
+
+ render () {
+ const { t } = this.context
+ const { history, name, addToAddressBook, removeFromAddressBook, address, memo, viewRoute, listRoute, setAccountLabel } = this.props
+
+ return (
+ <div className="settings-page__content-row address-book__edit-contact">
+ <div className="settings-page__header address-book__header--edit">
+ <Identicon address={address} diameter={60}/>
+ <Button
+ type="link"
+ className="settings-page__address-book-button"
+ onClick={() => {
+ removeFromAddressBook(address)
+ history.push(listRoute)
+ }}
+ >
+ {t('deleteAccount')}
+ </Button>
+ </div>
+ <div className="address-book__edit-contact__content">
+ <div className="address-book__view-contact__group">
+ <div className="address-book__view-contact__group__label">
+ { t('userName') }
+ </div>
+ <TextField
+ type="text"
+ id="nickname"
+ placeholder={this.context.t('addAlias')}
+ value={this.state.newName || name}
+ onChange={e => this.setState({ newName: e.target.value })}
+ fullWidth
+ margin="dense"
+ />
+ </div>
+
+ <div className="address-book__view-contact__group">
+ <div className="address-book__view-contact__group__label">
+ { t('ethereumPublicAddress') }
+ </div>
+ <TextField
+ type="text"
+ id="address"
+ placeholder={address}
+ value={this.state.newAddress || address}
+ error={this.state.error}
+ onChange={e => this.setState({ newAddress: e.target.value })}
+ fullWidth
+ margin="dense"
+ />
+ </div>
+
+ <div className="address-book__view-contact__group">
+ <div className="address-book__view-contact__group__label--capitalized">
+ { t('memo') }
+ </div>
+ <TextField
+ type="text"
+ id="memo"
+ placeholder={memo}
+ value={this.state.newMemo || memo}
+ onChange={e => this.setState({ newMemo: e.target.value })}
+ fullWidth
+ margin="dense"
+ multiline={true}
+ rows={3}
+ classes={{
+ inputMultiline: 'address-book__view-contact__text-area',
+ inputRoot: 'address-book__view-contact__text-area-wrapper',
+ }}
+ />
+ </div>
+ </div>
+ <PageContainerFooter
+ cancelText={this.context.t('cancel')}
+ onSubmit={() => {
+ if (this.state.newAddress !== '' && this.state.newAddress !== address) {
+ // if the user makes a valid change to the address field, remove the original address
+ if (isValidAddress(this.state.newAddress)) {
+ removeFromAddressBook(address)
+ addToAddressBook(this.state.newAddress, this.state.newName || name, this.state.newMemo || memo)
+ setAccountLabel(this.state.newAddress, this.state.newName || name)
+ history.push(listRoute)
+ } else {
+ this.setState({ error: 'invalid address' })
+ }
+ } else {
+ // update name
+ addToAddressBook(address, this.state.newName || name, this.state.newMemo || memo)
+ setAccountLabel(address, this.state.newName || name)
+ history.push(listRoute)
+ }
+ }}
+ onCancel={() => {
+ history.push(`${viewRoute}/${address}`)
+ }}
+ submitText={this.context.t('save')}
+ submitButtonType={'confirm'}
+ />
+ </div>
+ )
+ }
+}
diff --git a/ui/app/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js b/ui/app/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js
new file mode 100644
index 000000000..8841ff791
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js
@@ -0,0 +1,47 @@
+import EditContact from './edit-contact.component'
+import { compose } from 'recompose'
+import { connect } from 'react-redux'
+import { withRouter } from 'react-router-dom'
+import { getAddressBookEntry } from '../../../../selectors/selectors'
+import {
+ CONTACT_VIEW_ROUTE,
+ CONTACT_MY_ACCOUNTS_ROUTE,
+ CONTACT_MY_ACCOUNTS_VIEW_ROUTE,
+ CONTACT_MY_ACCOUNTS_EDIT_ROUTE,
+ CONTACT_LIST_ROUTE,
+} from '../../../../helpers/constants/routes'
+import { addToAddressBook, removeFromAddressBook, setAccountLabel } from '../../../../store/actions'
+
+const mapStateToProps = (state, ownProps) => {
+ const { location } = ownProps
+ const { pathname } = location
+ const pathNameTail = pathname.match(/[^/]+$/)[0]
+ const pathNameTailIsAddress = pathNameTail.includes('0x')
+ const address = pathNameTailIsAddress ? pathNameTail.toLowerCase() : ownProps.match.params.id
+
+ const { memo, name } = getAddressBookEntry(state, address) || state.metamask.identities[address]
+
+ const showingMyAccounts = Boolean(pathname.match(CONTACT_MY_ACCOUNTS_EDIT_ROUTE))
+
+ return {
+ address,
+ name,
+ memo,
+ viewRoute: showingMyAccounts ? CONTACT_MY_ACCOUNTS_VIEW_ROUTE : CONTACT_VIEW_ROUTE,
+ listRoute: showingMyAccounts ? CONTACT_MY_ACCOUNTS_ROUTE : CONTACT_LIST_ROUTE,
+ showingMyAccounts,
+ }
+}
+
+const mapDispatchToProps = dispatch => {
+ return {
+ addToAddressBook: (recipient, nickname, memo) => dispatch(addToAddressBook(recipient, nickname, memo)),
+ removeFromAddressBook: (addressToRemove) => dispatch(removeFromAddressBook(addressToRemove)),
+ setAccountLabel: (address, label) => dispatch(setAccountLabel(address, label)),
+ }
+}
+
+export default compose(
+ withRouter,
+ connect(mapStateToProps, mapDispatchToProps)
+)(EditContact)
diff --git a/ui/app/pages/settings/contact-list-tab/edit-contact/index.js b/ui/app/pages/settings/contact-list-tab/edit-contact/index.js
new file mode 100644
index 000000000..fe5ee206a
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/edit-contact/index.js
@@ -0,0 +1 @@
+export { default } from './edit-contact.container'
diff --git a/ui/app/pages/settings/contact-list-tab/index.js b/ui/app/pages/settings/contact-list-tab/index.js
new file mode 100644
index 000000000..c09e9787b
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/index.js
@@ -0,0 +1 @@
+export { default } from './contact-list-tab.container'
diff --git a/ui/app/pages/settings/contact-list-tab/index.scss b/ui/app/pages/settings/contact-list-tab/index.scss
new file mode 100644
index 000000000..c7e99095f
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/index.scss
@@ -0,0 +1,234 @@
+.address-book-wrapper {
+ display: flex;
+ justify-content: space-between;
+ height: 100%;
+}
+
+.address-book {
+ flex: 0.4 1 40%;
+ max-width: 40%;
+
+ @media screen and (max-width: 576px) {
+ flex: 1;
+ max-width: 100%;
+ }
+
+ &__entry {
+ display: flex;
+ flex-flow: row nowrap;
+ padding: 16px 14px;
+ flex: 0 0 auto;
+ border-bottom: 1px solid #dedede;
+
+ &:hover {
+ border: 1px solid #037DD6;
+ cursor: pointer;
+ }
+ }
+
+ &__name {
+ padding: 3px;
+ }
+
+ &__header, &__header--edit {
+ &__name {
+ font-family: Roboto;
+ font-style: normal;
+ font-weight: normal;
+ font-size: 24px;
+ line-height: 34px;
+ margin-left: 24px;
+ }
+ }
+
+ &__header--edit {
+ display: flex;
+ justify-content: space-between;
+
+ .button {
+ justify-content: flex-end;
+ color: #D73A49;
+ font-size: 14px;
+ }
+ }
+
+ &__input {
+ @extend %input-2;
+ margin-top: .25rem;
+
+ &--address {
+ font-size: 0.875rem;
+ }
+ }
+
+ &__view-contact {
+ &__text-area-wrapper {
+ height: 96px !important;
+ }
+
+ &__text-area {
+ line-height: initial !important;
+ }
+
+ &__group {
+ display: flex;
+ flex-flow: column nowrap;
+ padding: 1.5rem 1.5rem 0 1.5rem;
+
+ &__label, &__label--capitalized {
+ font-size: .75rem;
+ color: $Grey-500;
+ margin-bottom: .25rem;
+ }
+
+ &__label--capitalized {
+ text-transform: capitalize;
+ }
+
+ &__value, &__static-address {
+ display: flex;
+ flex-flow: row nowrap;
+ font-size: 1.125rem;
+ color: $Grey-800;
+ word-break: break-word;
+
+ &--address {
+ font-size: 0.875rem;
+ }
+
+ &--copy-icon {
+ padding-left: 4px;
+ }
+ }
+
+ &__static-address {
+ font-size: 0.875rem;
+ &--copy-icon {
+ cursor: pointer;
+
+ &:hover {
+ color: black;
+ }
+ }
+ }
+
+ .unit-input__input {
+ max-width: 100%;
+ width: 100%;
+ }
+ }
+ }
+
+ &__edit-contact {
+ display: flex;
+ flex-flow: column nowrap;
+ padding-bottom: 0 !important;
+ height: 100%;
+
+ &__content {
+ flex: 1 1 auto;
+
+ > div {
+ padding-top: 0;
+ }
+
+ }
+
+ .page-container__footer {
+ border-top: none;
+ }
+ }
+
+ &__add-contact {
+ display: flex;
+ flex-flow: column nowrap;
+ padding-bottom: 0 !important;
+ height: 100%;
+
+ &__content {
+ flex: 1 1 auto;
+ height: 100%;
+ }
+
+ &__error {
+ font-size: 12px;
+ line-height: 12px;
+ left: 8px;
+ color: $red;
+ }
+ }
+
+ &__my-accounts-button {
+ display: flex;
+ flex-flow: column;
+ cursor: pointer;
+ padding: 15px;
+
+ &:hover {
+ background-color: rgba(222, 222, 222, 0.2);
+ }
+
+ &__header {
+ font-family: Roboto;
+ font-style: normal;
+ font-weight: normal;
+ font-size: 18px;
+ line-height: 25px;
+ color: #000000;
+ }
+
+ &__content {
+ display: flex;
+ justify-content: space-between;
+ }
+
+ &__text {
+ font-family: Roboto;
+ font-style: normal;
+ font-weight: normal;
+ font-size: 14px;
+ line-height: 20px;
+ color: #6A737D;
+ }
+
+ &__caret {
+ display: block;
+ background-image: url(/images/caret-right.svg);
+ width: 30px;
+ opacity: .5;
+ background-repeat: no-repeat;
+ }
+ }
+}
+
+.address-book-add-button {
+ &__button {
+ position: absolute;
+ top: 10px;
+ right: 16px;
+ height: 56px;
+ width: 56px;
+ border-radius: 18px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-radius: 50%;
+ border-width: 2px;
+ background: #037DD6;
+ margin-right: 5px;
+ cursor: pointer;
+ box-shadow: 0px 2px 16px rgba(0, 0, 0, 0.25);
+ }
+}
+
+.address-book--hidden {
+ display: none;
+}
+
+.address-book-contact-content {
+ flex: 0.4 1 40%;
+
+ @media screen and (max-width: 576px) {
+ flex: 1
+ }
+}
diff --git a/ui/app/pages/settings/contact-list-tab/my-accounts/index.js b/ui/app/pages/settings/contact-list-tab/my-accounts/index.js
new file mode 100644
index 000000000..13a7a9cbf
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/my-accounts/index.js
@@ -0,0 +1 @@
+export { default } from './my-accounts.container'
diff --git a/ui/app/pages/settings/contact-list-tab/my-accounts/my-accounts.component.js b/ui/app/pages/settings/contact-list-tab/my-accounts/my-accounts.component.js
new file mode 100644
index 000000000..f43b59e07
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/my-accounts/my-accounts.component.js
@@ -0,0 +1,39 @@
+import React, { PureComponent } from 'react'
+import PropTypes from 'prop-types'
+import ContactList from '../../../../components/app/contact-list'
+import { CONTACT_MY_ACCOUNTS_VIEW_ROUTE } from '../../../../helpers/constants/routes'
+
+export default class ViewContact extends PureComponent {
+
+ static contextTypes = {
+ t: PropTypes.func,
+ }
+
+ static propTypes = {
+ myAccounts: PropTypes.array,
+ history: PropTypes.object,
+ }
+
+ renderMyAccounts () {
+ const { myAccounts, history } = this.props
+
+ return (
+ <div>
+ <ContactList
+ searchForMyAccounts={() => myAccounts}
+ selectRecipient={(address) => {
+ history.push(`${CONTACT_MY_ACCOUNTS_VIEW_ROUTE}/${address}`)
+ }}
+ />
+ </div>
+ )
+ }
+
+ render () {
+ return (
+ <div className="address-book">
+ { this.renderMyAccounts() }
+ </div>
+ )
+ }
+}
diff --git a/ui/app/pages/settings/contact-list-tab/my-accounts/my-accounts.container.js b/ui/app/pages/settings/contact-list-tab/my-accounts/my-accounts.container.js
new file mode 100644
index 000000000..6380c9d4c
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/my-accounts/my-accounts.container.js
@@ -0,0 +1,18 @@
+import ViewContact from './my-accounts.component'
+import { compose } from 'recompose'
+import { connect } from 'react-redux'
+import { withRouter } from 'react-router-dom'
+import { accountsWithSendEtherInfoSelector } from '../../../../selectors/selectors'
+
+const mapStateToProps = (state,) => {
+ const myAccounts = accountsWithSendEtherInfoSelector(state)
+
+ return {
+ myAccounts,
+ }
+}
+
+export default compose(
+ withRouter,
+ connect(mapStateToProps)
+)(ViewContact)
diff --git a/ui/app/pages/settings/contact-list-tab/view-contact/index.js b/ui/app/pages/settings/contact-list-tab/view-contact/index.js
new file mode 100644
index 000000000..78bf19d18
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/view-contact/index.js
@@ -0,0 +1 @@
+export { default } from './view-contact.container'
diff --git a/ui/app/pages/settings/contact-list-tab/view-contact/view-contact.component.js b/ui/app/pages/settings/contact-list-tab/view-contact/view-contact.component.js
new file mode 100644
index 000000000..4f37b853b
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/view-contact/view-contact.component.js
@@ -0,0 +1,78 @@
+import React, { PureComponent } from 'react'
+import PropTypes from 'prop-types'
+import Identicon from '../../../../components/ui/identicon'
+
+import Button from '../../../../components/ui/button/button.component'
+import copyToClipboard from 'copy-to-clipboard'
+
+function quadSplit (address) {
+ return '0x ' + address.slice(2).match(/.{1,4}/g).join(' ')
+}
+
+export default class ViewContact extends PureComponent {
+
+ static contextTypes = {
+ t: PropTypes.func,
+ }
+
+ static propTypes = {
+ removeFromAddressBook: PropTypes.func,
+ name: PropTypes.string,
+ address: PropTypes.string,
+ history: PropTypes.object,
+ checkSummedAddress: PropTypes.string,
+ memo: PropTypes.string,
+ editRoute: PropTypes.string,
+ }
+
+ render () {
+ const { t } = this.context
+ const { history, name, address, checkSummedAddress, memo, editRoute } = this.props
+
+ return (
+ <div className="settings-page__content-row">
+ <div className="settings-page__content-item">
+ <div className="settings-page__header address-book__header">
+ <Identicon address={address} diameter={60} />
+ <div className="address-book__header__name">{ name }</div>
+ </div>
+ <div className="address-book__view-contact__group">
+ <Button
+ type="secondary"
+ onClick={() => {
+ history.push(`${editRoute}/${address}`)
+ }}
+ >
+ {t('edit')}
+ </Button>
+ </div>
+ <div className="address-book__view-contact__group">
+ <div className="address-book__view-contact__group__label">
+ { t('ethereumPublicAddress') }
+ </div>
+ <div className="address-book__view-contact__group__value">
+ <div
+ className="address-book__view-contact__group__static-address"
+ >
+ { quadSplit(checkSummedAddress) }
+ </div>
+ <img
+ className="address-book__view-contact__group__static-address--copy-icon"
+ onClick={() => copyToClipboard(checkSummedAddress)}
+ src="/images/copy-to-clipboard.svg"
+ />
+ </div>
+ </div>
+ <div className="address-book__view-contact__group">
+ <div className="address-book__view-contact__group__label--capitalized">
+ { t('memo') }
+ </div>
+ <div className="address-book__view-contact__group__static-address">
+ { memo }
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+ }
+}
diff --git a/ui/app/pages/settings/contact-list-tab/view-contact/view-contact.container.js b/ui/app/pages/settings/contact-list-tab/view-contact/view-contact.container.js
new file mode 100644
index 000000000..b1196d936
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/view-contact/view-contact.container.js
@@ -0,0 +1,43 @@
+import ViewContact from './view-contact.component'
+import { compose } from 'recompose'
+import { connect } from 'react-redux'
+import { withRouter } from 'react-router-dom'
+import { getAddressBookEntry } from '../../../../selectors/selectors'
+import { removeFromAddressBook } from '../../../../store/actions'
+import { checksumAddress } from '../../../../helpers/utils/util'
+import {
+ CONTACT_EDIT_ROUTE,
+ CONTACT_MY_ACCOUNTS_EDIT_ROUTE,
+ CONTACT_MY_ACCOUNTS_VIEW_ROUTE,
+} from '../../../../helpers/constants/routes'
+
+const mapStateToProps = (state, ownProps) => {
+ const { location } = ownProps
+ const { pathname } = location
+ const pathNameTail = pathname.match(/[^/]+$/)[0]
+ const pathNameTailIsAddress = pathNameTail.includes('0x')
+ const address = pathNameTailIsAddress ? pathNameTail.toLowerCase() : ownProps.match.params.id
+
+ const { memo, name } = getAddressBookEntry(state, address) || state.metamask.identities[address]
+
+ const showingMyAccounts = Boolean(pathname.match(CONTACT_MY_ACCOUNTS_VIEW_ROUTE))
+
+ return {
+ name,
+ address,
+ checkSummedAddress: checksumAddress(address),
+ memo,
+ editRoute: showingMyAccounts ? CONTACT_MY_ACCOUNTS_EDIT_ROUTE : CONTACT_EDIT_ROUTE,
+ }
+}
+
+const mapDispatchToProps = dispatch => {
+ return {
+ removeFromAddressBook: (addressToRemove) => dispatch(removeFromAddressBook(addressToRemove)),
+ }
+}
+
+export default compose(
+ withRouter,
+ connect(mapStateToProps, mapDispatchToProps)
+)(ViewContact)