diff --git a/CHANGELOG.md b/CHANGELOG.md index c8e73c286..77dcc53c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Current Master - Allow sending to ENS names in send form on Ropsten. +- Added an address book functionality that remembers the last 15 unique addresses sent to. - Can now change network to custom RPC URL from lock screen. ## 3.4.0 2017-3-8 diff --git a/app/scripts/controllers/address-book.js b/app/scripts/controllers/address-book.js new file mode 100644 index 000000000..c66eb2bd4 --- /dev/null +++ b/app/scripts/controllers/address-book.js @@ -0,0 +1,79 @@ +const ObservableStore = require('obs-store') +const extend = require('xtend') + +class AddressBookController { + + + // Controller in charge of managing the address book functionality from the + // recipients field on the send screen. Manages a history of all saved + // addresses and all currently owned addresses. + constructor (opts = {}, keyringController) { + const initState = extend({ + addressBook: [], + }, opts.initState) + this.store = new ObservableStore(initState) + this.keyringController = keyringController + } + + // + // PUBLIC METHODS + // + + // Sets a new address book in store by accepting a new address and nickname. + setAddressBook (address, name) { + return this._addToAddressBook(address, name) + .then((addressBook) => { + this.store.updateState({ + addressBook, + }) + return Promise.resolve() + }) + } + + // + // PRIVATE METHODS + // + + + // Performs the logic to add the address and name into the address book. The + // pushed object is an object of two fields. Current behavior does not set an + // upper limit to the number of addresses. + _addToAddressBook (address, name) { + let addressBook = this._getAddressBook() + let identities = this._getIdentities() + + let addressBookIndex = addressBook.findIndex((element) => { return element.address.toLowerCase() === address.toLowerCase() || element.name === name }) + let identitiesIndex = Object.keys(identities).findIndex((element) => { return element.toLowerCase() === address.toLowerCase() }) + // trigger this condition if we own this address--no need to overwrite. + if (identitiesIndex !== -1) { + return Promise.resolve(addressBook) + // trigger this condition if we've seen this address before--may need to update nickname. + } else if (addressBookIndex !== -1) { + addressBook.splice(addressBookIndex, 1) + } else if (addressBook.length > 15) { + addressBook.shift() + } + + + addressBook.push({ + address: address, + name, + }) + return Promise.resolve(addressBook) + } + + // Internal method to get the address book. Current persistence behavior + // should not require that this method be called from the UI directly. + _getAddressBook () { + return this.store.getState().addressBook + } + + // Retrieves identities from the keyring controller in order to avoid + // duplication + _getIdentities () { + return this.keyringController.memStore.getState().identities + } + +} + +module.exports = AddressBookController diff --git a/app/scripts/controllers/preferences.js b/app/scripts/controllers/preferences.js index 18fccf11b..c7f675a41 100644 --- a/app/scripts/controllers/preferences.js +++ b/app/scripts/controllers/preferences.js @@ -5,7 +5,9 @@ const extend = require('xtend') class PreferencesController { constructor (opts = {}) { - const initState = extend({ frequentRpcList: [] }, opts.initState) + const initState = extend({ + frequentRpcList: [], + }, opts.initState) this.store = new ObservableStore(initState) } diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 2eaa53200..25f9d9e5d 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -15,6 +15,7 @@ const PreferencesController = require('./controllers/preferences') const CurrencyController = require('./controllers/currency') const NoticeController = require('./notice-controller') const ShapeShiftController = require('./controllers/shapeshift') +const AddressBookController = require('./controllers/address-book') const MessageManager = require('./lib/message-manager') const PersonalMessageManager = require('./lib/personal-message-manager') const TxManager = require('./transaction-manager') @@ -80,6 +81,11 @@ module.exports = class MetamaskController extends EventEmitter { autoFaucet(address) }) + // address book controller + this.addressBookController = new AddressBookController({ + initState: initState.AddressBookController, + }, this.keyringController) + // tx mgmt this.txManager = new TxManager({ initState: initState.TransactionManager, @@ -124,6 +130,9 @@ module.exports = class MetamaskController extends EventEmitter { this.preferencesController.store.subscribe((state) => { this.store.updateState({ PreferencesController: state }) }) + this.addressBookController.store.subscribe((state) => { + this.store.updateState({ AddressBookController: state }) + }) this.currencyController.store.subscribe((state) => { this.store.updateState({ CurrencyController: state }) }) @@ -142,6 +151,7 @@ module.exports = class MetamaskController extends EventEmitter { this.personalMessageManager.memStore.subscribe(this.sendUpdate.bind(this)) this.keyringController.memStore.subscribe(this.sendUpdate.bind(this)) this.preferencesController.store.subscribe(this.sendUpdate.bind(this)) + this.addressBookController.store.subscribe(this.sendUpdate.bind(this)) this.currencyController.store.subscribe(this.sendUpdate.bind(this)) this.noticeController.memStore.subscribe(this.sendUpdate.bind(this)) this.shapeshiftController.store.subscribe(this.sendUpdate.bind(this)) @@ -219,6 +229,7 @@ module.exports = class MetamaskController extends EventEmitter { this.personalMessageManager.memStore.getState(), this.keyringController.memStore.getState(), this.preferencesController.store.getState(), + this.addressBookController.store.getState(), this.currencyController.store.getState(), this.noticeController.memStore.getState(), // config manager @@ -240,6 +251,7 @@ module.exports = class MetamaskController extends EventEmitter { const preferencesController = this.preferencesController const txManager = this.txManager const noticeController = this.noticeController + const addressBookController = this.addressBookController return { // etc @@ -267,6 +279,9 @@ module.exports = class MetamaskController extends EventEmitter { setDefaultRpc: nodeify(this.setDefaultRpc).bind(this), setCustomRpc: nodeify(this.setCustomRpc).bind(this), + // AddressController + setAddressBook: nodeify(addressBookController.setAddressBook).bind(addressBookController), + // KeyringController setLocked: nodeify(keyringController.setLocked).bind(keyringController), createNewVaultAndKeychain: nodeify(keyringController.createNewVaultAndKeychain).bind(keyringController), diff --git a/test/unit/address-book-controller.js b/test/unit/address-book-controller.js new file mode 100644 index 000000000..f345b0328 --- /dev/null +++ b/test/unit/address-book-controller.js @@ -0,0 +1,56 @@ +const assert = require('assert') +const extend = require('xtend') +const AddressBookController = require('../../app/scripts/controllers/address-book') + +const mockKeyringController = { + memStore: { + getState: function () { + return { + identities: { + '0x0aaa' : { + address: '0x0aaa', + name: 'owned', + } + } + } + } + } +} + + +describe('address-book-controller', function() { + var addressBookController + + beforeEach(function() { + addressBookController = new AddressBookController({}, mockKeyringController) + }) + + describe('addres book management', function () { + describe('#_getAddressBook', function () { + it('should be empty by default.', function () { + assert.equal(addressBookController._getAddressBook().length, 0) + }) + }) + describe('#setAddressBook', function () { + it('should properly set a new address.', function () { + addressBookController.setAddressBook('0x01234', 'test') + var addressBook = addressBookController._getAddressBook() + assert.equal(addressBook.length, 1, 'incorrect address book length.') + assert.equal(addressBook[0].address, '0x01234', 'incorrect addresss') + assert.equal(addressBook[0].name, 'test', 'incorrect nickname') + }) + + it('should reject duplicates.', function () { + addressBookController.setAddressBook('0x01234', 'test') + addressBookController.setAddressBook('0x01234', 'test') + var addressBook = addressBookController._getAddressBook() + assert.equal(addressBook.length, 1, 'incorrect address book length.') + }) + it('should not add any identities that are under user control', function () { + addressBookController.setAddressBook('0x0aaa', ' ') + var addressBook = addressBookController._getAddressBook() + assert.equal(addressBook.length, 0, 'incorrect address book length.') + }) + }) + }) +}) diff --git a/test/unit/currency-controller-test.js b/test/unit/currency-controller-test.js index dd7fa91e0..079f8b488 100644 --- a/test/unit/currency-controller-test.js +++ b/test/unit/currency-controller-test.js @@ -7,7 +7,7 @@ const rp = require('request-promise') const nock = require('nock') const CurrencyController = require('../../app/scripts/controllers/currency') -describe('config-manager', function() { +describe('currency-controller', function() { var currencyController beforeEach(function() { diff --git a/ui/app/actions.js b/ui/app/actions.js index 0027a5f67..b09021577 100644 --- a/ui/app/actions.js +++ b/ui/app/actions.js @@ -75,6 +75,8 @@ var actions = { // account detail screen SHOW_SEND_PAGE: 'SHOW_SEND_PAGE', showSendPage: showSendPage, + ADD_TO_ADDRESS_BOOK: 'ADD_TO_ADDRESS_BOOK', + addToAddressBook: addToAddressBook, REQUEST_ACCOUNT_EXPORT: 'REQUEST_ACCOUNT_EXPORT', requestExportAccount: requestExportAccount, EXPORT_ACCOUNT: 'EXPORT_ACCOUNT', @@ -696,6 +698,19 @@ function setRpcTarget (newRpc) { } } +// Calls the addressBookController to add a new address. +function addToAddressBook (recipient, nickname) { + log.debug(`background.addToAddressBook`) + return (dispatch) => { + background.setAddressBook(recipient, nickname, (err, result) => { + if (err) { + log.error(err) + return dispatch(self.displayWarning('Address book failed to update')) + } + }) + } +} + function setProviderType (type) { log.debug(`background.setProviderType`) background.setProviderType(type) diff --git a/ui/app/components/ens-input.js b/ui/app/components/ens-input.js index ffc4eab4a..facf29d97 100644 --- a/ui/app/components/ens-input.js +++ b/ui/app/components/ens-input.js @@ -21,6 +21,7 @@ function EnsInput () { EnsInput.prototype.render = function () { const props = this.props const opts = extend(props, { + list: 'addresses', onChange: () => { const network = this.props.network let resolverAddress = networkResolvers[network] @@ -46,6 +47,25 @@ EnsInput.prototype.render = function () { style: { width: '100%' }, }, [ h('input.large-input', opts), + // The address book functionality. + h('datalist#addresses', + [ + // Corresponds to the addresses owned. + Object.keys(props.identities).map((key) => { + let identity = props.identities[key] + return h('option', { + value: identity.address, + label: identity.name, + }) + }), + // Corresponds to previously sent-to addresses. + props.addressBook.map((identity) => { + return h('option', { + value: identity.address, + label: identity.name, + }) + }), + ]), this.ensIcon(), ]) } @@ -80,11 +100,13 @@ EnsInput.prototype.lookupEnsName = function () { this.setState({ loadingEns: false, ensResolution: address, + nickname: recipient.trim(), hoverText: address + '\nClick to Copy', }) } }) .catch((reason) => { + log.error(reason) return this.setState({ loadingEns: false, ensFailure: true, @@ -95,10 +117,13 @@ EnsInput.prototype.lookupEnsName = function () { EnsInput.prototype.componentDidUpdate = function (prevProps, prevState) { const state = this.state || {} - const { ensResolution } = 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 (ensResolution && this.props.onChange && ensResolution !== prevState.ensResolution) { - this.props.onChange(ensResolution) + this.props.onChange(ensResolution, nickname) } } diff --git a/ui/app/reducers/metamask.js b/ui/app/reducers/metamask.js index 269f8d272..2b5151466 100644 --- a/ui/app/reducers/metamask.js +++ b/ui/app/reducers/metamask.js @@ -16,6 +16,7 @@ function reduceMetamask (state, action) { noActiveNotices: true, lastUnreadNotice: undefined, frequentRpcList: [], + addressBook: [], }, state.metamask) switch (action.type) { diff --git a/ui/app/send.js b/ui/app/send.js index a281a5fcf..eb32d5e06 100644 --- a/ui/app/send.js +++ b/ui/app/send.js @@ -20,6 +20,7 @@ function mapStateToProps (state) { identities: state.metamask.identities, warning: state.appState.warning, network: state.metamask.network, + addressBook: state.metamask.addressBook, } result.error = result.warning && result.warning.split('.')[0] @@ -44,6 +45,8 @@ SendTransactionScreen.prototype.render = function () { var account = state.account var identity = state.identity var network = state.network + var identities = state.identities + var addressBook = state.addressBook return ( @@ -153,6 +156,8 @@ SendTransactionScreen.prototype.render = function () { placeholder: 'Recipient Address', onChange: this.recipientDidChange.bind(this), network, + identities, + addressBook, }), ]), @@ -222,13 +227,17 @@ SendTransactionScreen.prototype.back = function () { this.props.dispatch(actions.backToAccountDetail(address)) } -SendTransactionScreen.prototype.recipientDidChange = function (recipient) { - this.setState({ recipient }) +SendTransactionScreen.prototype.recipientDidChange = function (recipient, nickname) { + this.setState({ + recipient: recipient, + nickname: nickname, + }) } SendTransactionScreen.prototype.onSubmit = function () { const state = this.state || {} const recipient = state.recipient || document.querySelector('input[name="address"]').value + const nickname = state.nickname || ' ' const input = document.querySelector('input[name="amount"]').value const value = util.normalizeEthStringToWei(input) const txData = document.querySelector('input[name="txData"]').value @@ -257,6 +266,8 @@ SendTransactionScreen.prototype.onSubmit = function () { this.props.dispatch(actions.hideWarning()) + this.props.dispatch(actions.addToAddressBook(recipient, nickname)) + var txParams = { from: this.props.address, value: '0x' + value.toString(16),