diff options
Diffstat (limited to 'ui/app/components')
16 files changed, 406 insertions, 192 deletions
diff --git a/ui/app/components/app/app-header/index.scss b/ui/app/components/app/app-header/index.scss index d3f37b7a2..0ea1793ca 100644 --- a/ui/app/components/app/app-header/index.scss +++ b/ui/app/components/app/app-header/index.scss @@ -10,7 +10,6 @@ @media screen and (max-width: 575px) { padding: 1rem; - box-shadow: 0 0 0 1px rgba(0, 0, 0, .08); z-index: $mobile-header-z-index; } @@ -24,7 +23,7 @@ position: absolute; width: 100%; height: 32px; - background: $gallery; + background: $Grey-000; bottom: -32px; } } diff --git a/ui/app/components/app/contact-list/contact-list.component.js b/ui/app/components/app/contact-list/contact-list.component.js new file mode 100644 index 000000000..ec9b5f8eb --- /dev/null +++ b/ui/app/components/app/contact-list/contact-list.component.js @@ -0,0 +1,114 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import RecipientGroup from './recipient-group/recipient-group.component' + +export default class ContactList extends PureComponent { + static propTypes = { + searchForContacts: PropTypes.func, + searchForRecents: PropTypes.func, + searchForMyAccounts: PropTypes.func, + selectRecipient: PropTypes.func, + children: PropTypes.node, + selectedAddress: PropTypes.string, + } + + static contextTypes = { + t: PropTypes.func, + } + + state = { + isShowingAllRecent: false, + } + + renderRecents () { + const { t } = this.context + const { isShowingAllRecent } = this.state + const nonContacts = this.props.searchForRecents() + + const showLoadMore = !isShowingAllRecent && nonContacts.length > 2 + + return ( + <div className="send__select-recipient-wrapper__recent-group-wrapper"> + <RecipientGroup + label={t('recents')} + items={showLoadMore ? nonContacts.slice(0, 2) : nonContacts} + onSelect={this.props.selectRecipient} + selectedAddress={this.props.selectedAddress} + /> + { + showLoadMore && ( + <div + className="send__select-recipient-wrapper__recent-group-wrapper__load-more" + onClick={() => this.setState({ isShowingAllRecent: true })} + > + {t('loadMore')} + </div> + ) + } + </div> + ) + } + + renderAddressBook () { + const contacts = this.props.searchForContacts() + + const contactGroups = contacts.reduce((acc, contact) => { + const firstLetter = contact.name.slice(0, 1).toUpperCase() + acc[firstLetter] = acc[firstLetter] || [] + const bucket = acc[firstLetter] + bucket.push(contact) + return acc + }, {}) + + return Object + .entries(contactGroups) + .sort(([letter1], [letter2]) => { + if (letter1 > letter2) { + return 1 + } else if (letter1 === letter2) { + return 0 + } else if (letter1 < letter2) { + return -1 + } + }) + .map(([letter, groupItems]) => ( + <RecipientGroup + key={`${letter}-contract-group`} + label={letter} + items={groupItems} + onSelect={this.props.selectRecipient} + selectedAddress={this.props.selectedAddress} + /> + )) + } + + renderMyAccounts () { + const myAccounts = this.props.searchForMyAccounts() + + return ( + <RecipientGroup + items={myAccounts} + onSelect={this.props.selectRecipient} + selectedAddress={this.props.selectedAddress} + /> + ) + } + + render () { + const { + children, + searchForRecents, + searchForContacts, + searchForMyAccounts, + } = this.props + + return ( + <div className="send__select-recipient-wrapper__list"> + { children || null } + { searchForRecents && this.renderRecents() } + { searchForContacts && this.renderAddressBook() } + { searchForMyAccounts && this.renderMyAccounts() } + </div> + ) + } +} diff --git a/ui/app/components/app/contact-list/index.js b/ui/app/components/app/contact-list/index.js new file mode 100644 index 000000000..d90c29b2b --- /dev/null +++ b/ui/app/components/app/contact-list/index.js @@ -0,0 +1 @@ +export { default } from './contact-list.component' diff --git a/ui/app/components/app/contact-list/recipient-group/index.js b/ui/app/components/app/contact-list/recipient-group/index.js new file mode 100644 index 000000000..7d827523f --- /dev/null +++ b/ui/app/components/app/contact-list/recipient-group/index.js @@ -0,0 +1 @@ +export { default } from './recipient-group.component' diff --git a/ui/app/components/app/contact-list/recipient-group/recipient-group.component.js b/ui/app/components/app/contact-list/recipient-group/recipient-group.component.js new file mode 100644 index 000000000..a2248326e --- /dev/null +++ b/ui/app/components/app/contact-list/recipient-group/recipient-group.component.js @@ -0,0 +1,59 @@ +import React from 'react' +import PropTypes from 'prop-types' +import Identicon from '../../../ui/identicon' +import classnames from 'classnames' +import { ellipsify } from '../../../../pages/send/send.utils' + +function addressesEqual (address1, address2) { + return String(address1).toLowerCase() === String(address2).toLowerCase() +} + +export default function RecipientGroup ({ label, items, onSelect, selectedAddress }) { + if (!items || !items.length) { + return null + } + + return ( + <div className="send__select-recipient-wrapper__group"> + {label && <div className="send__select-recipient-wrapper__group-label"> + {label} + </div>} + { + items.map(({ address, name }) => ( + <div + key={address} + onClick={() => onSelect(address, name)} + className={classnames({ + 'send__select-recipient-wrapper__group-item': !addressesEqual(address, selectedAddress), + 'send__select-recipient-wrapper__group-item--selected': addressesEqual(address, selectedAddress), + })} + > + <Identicon address={address} diameter={28} /> + <div className="send__select-recipient-wrapper__group-item__content"> + <div className="send__select-recipient-wrapper__group-item__title"> + {name || ellipsify(address)} + </div> + { + name && ( + <div className="send__select-recipient-wrapper__group-item__subtitle"> + {ellipsify(address)} + </div> + ) + } + </div> + </div> + )) + } + </div> + ) +} + +RecipientGroup.propTypes = { + label: PropTypes.string, + items: PropTypes.arrayOf(PropTypes.shape({ + address: PropTypes.string, + name: PropTypes.string, + })), + onSelect: PropTypes.func.isRequired, + selectedAddress: PropTypes.string, +} diff --git a/ui/app/components/app/ens-input.js b/ui/app/components/app/ens-input.js deleted file mode 100644 index 5eea0dd90..000000000 --- a/ui/app/components/app/ens-input.js +++ /dev/null @@ -1,181 +0,0 @@ -const Component = require('react').Component -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const inherits = require('util').inherits -const extend = require('xtend') -const debounce = require('debounce') -const copyToClipboard = require('copy-to-clipboard') -const ENS = require('ethjs-ens') -const networkMap = require('ethjs-ens/lib/network-map.json') -const ensRE = /.+\..+$/ -const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' -const connect = require('react-redux').connect -const ToAutoComplete = require('../../pages/send/to-autocomplete').default -const log = require('loglevel') -const { isValidENSAddress } = require('../../helpers/utils/util') - -EnsInput.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect()(EnsInput) - - -inherits(EnsInput, Component) -function EnsInput () { - Component.call(this) -} - -EnsInput.prototype.onChange = function (recipient) { - - const network = this.props.network - const networkHasEnsSupport = getNetworkEnsSupport(network) - - this.props.onChange({ toAddress: recipient }) - - if (!networkHasEnsSupport) return - - if (recipient.match(ensRE) === null) { - return this.setState({ - loadingEns: false, - ensResolution: null, - ensFailure: null, - toError: null, - }) - } - - this.setState({ - loadingEns: true, - }) - this.checkName(recipient) -} - -EnsInput.prototype.render = function () { - const props = this.props - const opts = extend(props, { - list: 'addresses', - onChange: this.onChange.bind(this), - qrScanner: true, - }) - return h('div', { - style: { width: '100%', position: 'relative' }, - }, [ - h(ToAutoComplete, { ...opts }), - this.ensIcon(), - ]) -} - -EnsInput.prototype.componentDidMount = function () { - const network = this.props.network - const networkHasEnsSupport = getNetworkEnsSupport(network) - this.setState({ ensResolution: ZERO_ADDRESS }) - - if (networkHasEnsSupport) { - const provider = global.ethereumProvider - this.ens = new ENS({ provider, network }) - this.checkName = debounce(this.lookupEnsName.bind(this), 200) - } -} - -EnsInput.prototype.lookupEnsName = function (recipient) { - const { ensResolution } = this.state - - log.info(`ENS attempting to resolve name: ${recipient}`) - this.ens.lookup(recipient.trim()) - .then((address) => { - if (address === ZERO_ADDRESS) throw new Error(this.context.t('noAddressForName')) - if (address !== ensResolution) { - this.setState({ - loadingEns: false, - ensResolution: address, - nickname: recipient.trim(), - hoverText: address + '\n' + this.context.t('clickCopy'), - ensFailure: false, - toError: null, - }) - } - }) - .catch((reason) => { - const setStateObj = { - loadingEns: false, - ensResolution: recipient, - ensFailure: true, - toError: null, - } - if (isValidENSAddress(recipient) && reason.message === 'ENS name not defined.') { - setStateObj.hoverText = this.context.t('ensNameNotFound') - setStateObj.toError = 'ensNameNotFound' - setStateObj.ensFailure = false - } else { - log.error(reason) - setStateObj.hoverText = reason.message - } - - return this.setState(setStateObj) - }) -} - -EnsInput.prototype.componentDidUpdate = function (prevProps, prevState) { - const state = this.state || {} - const ensResolution = state.ensResolution - // If an address is sent without a nickname, meaning not from ENS or from - // the user's own accounts, a default of a one-space string is used. - const nickname = state.nickname || ' ' - if (prevProps.network !== this.props.network) { - const provider = global.ethereumProvider - this.ens = new ENS({ provider, network: this.props.network }) - this.onChange(ensResolution) - } - if (prevState && ensResolution && this.props.onChange && - ensResolution !== prevState.ensResolution) { - this.props.onChange({ toAddress: ensResolution, nickname, toError: state.toError, toWarning: state.toWarning }) - } -} - -EnsInput.prototype.ensIcon = function (recipient) { - const { hoverText } = this.state || {} - return h('span.#ensIcon', { - title: hoverText, - style: { - position: 'absolute', - top: '16px', - left: '-25px', - }, - }, this.ensIconContents(recipient)) -} - -EnsInput.prototype.ensIconContents = function () { - const { loadingEns, ensFailure, ensResolution, toError } = this.state || { ensResolution: ZERO_ADDRESS } - - if (toError) return - - if (loadingEns) { - return h('img', { - src: 'images/loading.svg', - style: { - width: '30px', - height: '30px', - transform: 'translateY(-6px)', - }, - }) - } - - if (ensFailure) { - return h('i.fa.fa-warning.fa-lg.warning') - } - - if (ensResolution && (ensResolution !== ZERO_ADDRESS)) { - return h('i.fa.fa-check-circle.fa-lg.cursor-pointer', { - style: { color: 'green' }, - onClick: (event) => { - event.preventDefault() - event.stopPropagation() - copyToClipboard(ensResolution) - }, - }) - } -} - -function getNetworkEnsSupport (network) { - return Boolean(networkMap[network]) -} diff --git a/ui/app/components/app/modals/add-to-addressbook-modal/add-to-addressbook-modal.component.js b/ui/app/components/app/modals/add-to-addressbook-modal/add-to-addressbook-modal.component.js new file mode 100644 index 000000000..1ce9e8a06 --- /dev/null +++ b/ui/app/components/app/modals/add-to-addressbook-modal/add-to-addressbook-modal.component.js @@ -0,0 +1,79 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import Button from '../../../ui/button/button.component' + +export default class AddToAddressBookModal extends Component { + + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + hideModal: PropTypes.func.isRequired, + addToAddressBook: PropTypes.func.isRequired, + recipient: PropTypes.string.isRequired, + } + + state = { + alias: '', + } + + onSave = () => { + const { recipient, addToAddressBook, hideModal } = this.props + addToAddressBook(recipient, this.state.alias) + hideModal() + } + + onChange = e => { + this.setState({ + alias: e.target.value, + }) + } + + onKeyPress = e => { + if (e.keyCode === 13 && this.state.alias) { + this.onSave() + } + } + + render () { + const { t } = this.context + + return ( + <div className="add-to-address-book-modal"> + <div className="add-to-address-book-modal__content"> + <div className="add-to-address-book-modal__content__header"> + {t('addToAddressBook')} + </div> + <div className="add-to-address-book-modal__input-label"> + {t('enterAnAlias')} + </div> + <input + type="text" + className="add-to-address-book-modal__input" + placeholder={t('addToAddressBookModalPlaceholder')} + onChange={this.onChange} + onKeyPress={this.onKeyPress} + value={this.state.alias} + autoFocus + /> + </div> + <div className="add-to-address-book-modal__footer"> + <Button + type="secondary" + onClick={this.props.hideModal} + > + {t('cancel')} + </Button> + <Button + type="primary" + onClick={this.onSave} + disabled={!this.state.alias} + > + {t('save')} + </Button> + </div> + </div> + ) + } +} diff --git a/ui/app/components/app/modals/add-to-addressbook-modal/add-to-addressbook-modal.container.js b/ui/app/components/app/modals/add-to-addressbook-modal/add-to-addressbook-modal.container.js new file mode 100644 index 000000000..413d4aa4a --- /dev/null +++ b/ui/app/components/app/modals/add-to-addressbook-modal/add-to-addressbook-modal.container.js @@ -0,0 +1,18 @@ +import { connect } from 'react-redux' +import AddToAddressBookModal from './add-to-addressbook-modal.component' +import actions from '../../../../store/actions' + +function mapStateToProps (state) { + return { + ...state.appState.modal.modalState.props || {}, + } +} + +function mapDispatchToProps (dispatch) { + return { + hideModal: () => dispatch(actions.hideModal()), + addToAddressBook: (recipient, nickname) => dispatch(actions.addToAddressBook(recipient, nickname)), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(AddToAddressBookModal) diff --git a/ui/app/components/app/modals/add-to-addressbook-modal/index.js b/ui/app/components/app/modals/add-to-addressbook-modal/index.js new file mode 100644 index 000000000..9ed4f018f --- /dev/null +++ b/ui/app/components/app/modals/add-to-addressbook-modal/index.js @@ -0,0 +1 @@ +export { default } from './add-to-addressbook-modal.container' diff --git a/ui/app/components/app/modals/add-to-addressbook-modal/index.scss b/ui/app/components/app/modals/add-to-addressbook-modal/index.scss new file mode 100644 index 000000000..f6bf85a0a --- /dev/null +++ b/ui/app/components/app/modals/add-to-addressbook-modal/index.scss @@ -0,0 +1,37 @@ +.add-to-address-book-modal { + @extend %col-nowrap; + @extend %modal; + + &__content { + @extend %col-nowrap; + padding: 1.5rem; + border-bottom: 1px solid $Grey-100; + + &__header { + @extend %h3; + } + } + + &__input-label { + color: $Grey-600; + margin-top: 1.25rem; + } + + &__input { + @extend %input; + margin-top: 0.75rem; + + &::placeholder { + color: $Grey-300; + } + } + + &__footer { + @extend %row-nowrap; + padding: 1rem; + + button + button { + margin-left: 1rem; + } + } +} diff --git a/ui/app/components/app/modals/index.scss b/ui/app/components/app/modals/index.scss index 09b0bb73c..1bbfd2d07 100644 --- a/ui/app/components/app/modals/index.scss +++ b/ui/app/components/app/modals/index.scss @@ -9,3 +9,5 @@ @import 'transaction-confirmed/index'; @import 'metametrics-opt-in-modal/index'; + +@import './add-to-addressbook-modal/index'; diff --git a/ui/app/components/app/modals/modal.js b/ui/app/components/app/modals/modal.js index cd8ec0c7d..4044ded8c 100644 --- a/ui/app/components/app/modals/modal.js +++ b/ui/app/components/app/modals/modal.js @@ -30,6 +30,7 @@ import RejectTransactions from './reject-transactions' import ClearApprovedOrigins from './clear-approved-origins' import ConfirmCustomizeGasModal from '../gas-customization/gas-modal-page-container' import ConfirmDeleteNetwork from './confirm-delete-network' +import AddToAddressBookModal from './add-to-addressbook-modal' const modalContainerBaseStyle = { transform: 'translate3d(-50%, 0, 0px)', @@ -167,6 +168,35 @@ const MODALS = { }, }, + ADD_TO_ADDRESSBOOK: { + contents: [ + h(AddToAddressBookModal, {}, []), + ], + mobileModalStyle: { + width: '95%', + top: '10%', + boxShadow: 'rgba(0, 0, 0, 0.15) 0px 2px 2px 2px', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + borderRadius: '10px', + }, + laptopModalStyle: { + width: '375px', + top: '10%', + boxShadow: 'rgba(0, 0, 0, 0.15) 0px 2px 2px 2px', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + borderRadius: '10px', + }, + contentStyle: { + borderRadius: '10px', + }, + }, + ACCOUNT_DETAILS: { contents: [ h(AccountDetailsModal, {}, []), @@ -466,7 +496,6 @@ module.exports = connect(mapStateToProps, mapDispatchToProps)(Modal) Modal.prototype.render = function () { const modal = MODALS[this.props.modalState.name || 'DEFAULT'] - const { contents: children, disableBackdropClick = false } = modal const modalStyle = modal[isMobileView() ? 'mobileModalStyle' : 'laptopModalStyle'] const contentStyle = modal.contentStyle || {} diff --git a/ui/app/components/ui/dialog/dialog.scss b/ui/app/components/ui/dialog/dialog.scss new file mode 100644 index 000000000..68b5ce329 --- /dev/null +++ b/ui/app/components/ui/dialog/dialog.scss @@ -0,0 +1,26 @@ +.dialog { + font-size: .75rem; + line-height: 1rem; + padding: 1rem; + border: 1px solid $black; + box-sizing: border-box; + border-radius: 8px; + + &--message { + border-color: $Blue-200; + color: $Blue-600; + background-color: $Blue-000; + } + + &--error { + border-color: $Red-300; + color: $Red-600; + background-color: $Red-000; + } + + &--warning { + border-color: $Orange-300; + color: $Orange-600; + background-color: $Orange-000; + } +} diff --git a/ui/app/components/ui/dialog/index.js b/ui/app/components/ui/dialog/index.js new file mode 100644 index 000000000..d7e522b22 --- /dev/null +++ b/ui/app/components/ui/dialog/index.js @@ -0,0 +1,26 @@ +import React from 'react' +import PropTypes from 'prop-types' +import c from 'classnames' + +export default function Dialog (props) { + const { children, type, className, onClick } = props + return ( + <div + className={c('dialog', className, { + 'dialog--message': type === 'message', + 'dialog--error': type === 'error', + 'dialog--warning': type === 'warning', + })} + onClick={onClick} + > + { children } + </div> + ) +} + +Dialog.propTypes = { + className: PropTypes.string, + children: PropTypes.node, + type: PropTypes.oneOf(['message', 'error', 'warning']), + onClick: PropTypes.func, +} diff --git a/ui/app/components/ui/page-container/page-container-header/page-container-header.component.js b/ui/app/components/ui/page-container/page-container-header/page-container-header.component.js index 08f9c7544..f1e15f10f 100644 --- a/ui/app/components/ui/page-container/page-container-header/page-container-header.component.js +++ b/ui/app/components/ui/page-container/page-container-header/page-container-header.component.js @@ -1,6 +1,6 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import classnames from 'classnames' +import c from 'classnames' export default class PageContainerHeader extends Component { static propTypes = { @@ -13,6 +13,7 @@ export default class PageContainerHeader extends Component { backButtonString: PropTypes.string, tabs: PropTypes.node, headerCloseText: PropTypes.string, + className: PropTypes.string, } renderTabs () { @@ -42,15 +43,14 @@ export default class PageContainerHeader extends Component { } render () { - const { title, subtitle, onClose, tabs, headerCloseText } = this.props + const { title, subtitle, onClose, tabs, headerCloseText, className } = this.props return ( - <div className={ - classnames( - 'page-container__header', - { 'page-container__header--no-padding-bottom': Boolean(tabs) } - ) - }> + <div + className={c('page-container__header', className, { + 'page-container__header--no-padding-bottom': Boolean(tabs), + })} + > { this.renderHeaderRow() } diff --git a/ui/app/components/ui/text-field/text-field.component.js b/ui/app/components/ui/text-field/text-field.component.js index 1153a595b..ac7712c65 100644 --- a/ui/app/components/ui/text-field/text-field.component.js +++ b/ui/app/components/ui/text-field/text-field.component.js @@ -61,6 +61,9 @@ const styles = { ...inputLabelBase, fontSize: '.75rem', }, + inputMultiline: { + lineHeight: 'initial !important', + }, } const TextField = props => { |