diff --git a/CHANGELOG.md b/CHANGELOG.md index ee9548606..009cd5f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Current Master +## 3.12.1 2017-11-29 + +- Fix bug where a user could be shown two different seed phrases. +- Detect when multiple web3 extensions are active, and provide useful error. +- Adds notice about seed phrase backup. + ## 3.12.0 2017-10-25 - Add support for alternative ENS TLDs (Ethereum Name Service Top-Level Domains). diff --git a/ISSUE_TEMPLATE b/ISSUE_TEMPLATE index d0ff3c08e..b56d08d95 100644 --- a/ISSUE_TEMPLATE +++ b/ISSUE_TEMPLATE @@ -1,7 +1,7 @@ +
+ + + + +
+ +
+ + + diff --git a/old-ui/index.js b/old-ui/index.js new file mode 100644 index 000000000..ae05cbe67 --- /dev/null +++ b/old-ui/index.js @@ -0,0 +1,58 @@ +const render = require('react-dom').render +const h = require('react-hyperscript') +const Root = require('./app/root') +const actions = require('./app/actions') +const configureStore = require('./app/store') +const txHelper = require('./lib/tx-helper') +global.log = require('loglevel') + +module.exports = launchMetamaskUi + + +log.setLevel(global.METAMASK_DEBUG ? 'debug' : 'warn') + +function launchMetamaskUi (opts, cb) { + var accountManager = opts.accountManager + actions._setBackgroundConnection(accountManager) + // check if we are unlocked first + accountManager.getState(function (err, metamaskState) { + if (err) return cb(err) + const store = startApp(metamaskState, accountManager, opts) + cb(null, store) + }) +} + +function startApp (metamaskState, accountManager, opts) { + // parse opts + const store = configureStore({ + + // metamaskState represents the cross-tab state + metamask: metamaskState, + + // appState represents the current tab's popup state + appState: {}, + + // Which blockchain we are using: + networkVersion: opts.networkVersion, + }) + + // if unconfirmed txs, start on txConf page + const unapprovedTxsAll = txHelper(metamaskState.unapprovedTxs, metamaskState.unapprovedMsgs, metamaskState.unapprovedPersonalMsgs, metamaskState.unapprovedTypedMessages, metamaskState.network) + if (unapprovedTxsAll.length > 0) { + store.dispatch(actions.showConfTxPage()) + } + + accountManager.on('update', function (metamaskState) { + store.dispatch(actions.updateMetamaskState(metamaskState)) + }) + + // start app + render( + h(Root, { + // inject initial state + store: store, + } + ), opts.container) + + return store +} diff --git a/old-ui/lib/contract-namer.js b/old-ui/lib/contract-namer.js new file mode 100644 index 000000000..f05e770cc --- /dev/null +++ b/old-ui/lib/contract-namer.js @@ -0,0 +1,33 @@ +/* CONTRACT NAMER + * + * Takes an address, + * Returns a nicname if we have one stored, + * otherwise returns null. + */ + +const contractMap = require('eth-contract-metadata') +const ethUtil = require('ethereumjs-util') + +module.exports = function (addr, identities = {}) { + const checksummed = ethUtil.toChecksumAddress(addr) + if (contractMap[checksummed] && contractMap[checksummed].name) { + return contractMap[checksummed].name + } + + const address = addr.toLowerCase() + const ids = hashFromIdentities(identities) + return addrFromHash(address, ids) +} + +function hashFromIdentities (identities) { + const result = {} + for (const key in identities) { + result[key] = identities[key].name + } + return result +} + +function addrFromHash (addr, hash) { + const address = addr.toLowerCase() + return hash[address] || null +} diff --git a/old-ui/lib/etherscan-prefix-for-network.js b/old-ui/lib/etherscan-prefix-for-network.js new file mode 100644 index 000000000..2c1904f1c --- /dev/null +++ b/old-ui/lib/etherscan-prefix-for-network.js @@ -0,0 +1,21 @@ +module.exports = function (network) { + const net = parseInt(network) + let prefix + switch (net) { + case 1: // main net + prefix = '' + break + case 3: // ropsten test net + prefix = 'ropsten.' + break + case 4: // rinkeby test net + prefix = 'rinkeby.' + break + case 42: // kovan test net + prefix = 'kovan.' + break + default: + prefix = '' + } + return prefix +} diff --git a/old-ui/lib/icon-factory.js b/old-ui/lib/icon-factory.js new file mode 100644 index 000000000..27a74de66 --- /dev/null +++ b/old-ui/lib/icon-factory.js @@ -0,0 +1,65 @@ +var iconFactory +const isValidAddress = require('ethereumjs-util').isValidAddress +const toChecksumAddress = require('ethereumjs-util').toChecksumAddress +const contractMap = require('eth-contract-metadata') + +module.exports = function (jazzicon) { + if (!iconFactory) { + iconFactory = new IconFactory(jazzicon) + } + return iconFactory +} + +function IconFactory (jazzicon) { + this.jazzicon = jazzicon + this.cache = {} +} + +IconFactory.prototype.iconForAddress = function (address, diameter) { + const addr = toChecksumAddress(address) + if (iconExistsFor(addr)) { + return imageElFor(addr) + } + + return this.generateIdenticonSvg(address, diameter) +} + +// returns svg dom element +IconFactory.prototype.generateIdenticonSvg = function (address, diameter) { + var cacheId = `${address}:${diameter}` + // check cache, lazily generate and populate cache + var identicon = this.cache[cacheId] || (this.cache[cacheId] = this.generateNewIdenticon(address, diameter)) + // create a clean copy so you can modify it + var cleanCopy = identicon.cloneNode(true) + return cleanCopy +} + +// creates a new identicon +IconFactory.prototype.generateNewIdenticon = function (address, diameter) { + var numericRepresentation = jsNumberForAddress(address) + var identicon = this.jazzicon(diameter, numericRepresentation) + return identicon +} + +// util + +function iconExistsFor (address) { + return contractMap[address] && isValidAddress(address) && contractMap[address].logo +} + +function imageElFor (address) { + const contract = contractMap[address] + const fileName = contract.logo + const path = `images/contract/${fileName}` + const img = document.createElement('img') + img.src = path + img.style.width = '75%' + return img +} + +function jsNumberForAddress (address) { + var addr = address.slice(2, 10) + var seed = parseInt(addr, 16) + return seed +} + diff --git a/old-ui/lib/lost-accounts-notice.js b/old-ui/lib/lost-accounts-notice.js new file mode 100644 index 000000000..948b13db6 --- /dev/null +++ b/old-ui/lib/lost-accounts-notice.js @@ -0,0 +1,23 @@ +const summary = require('../app/util').addressSummary + +module.exports = function (lostAccounts) { + return { + date: new Date().toDateString(), + title: 'Account Problem Caught', + body: `MetaMask has fixed a bug where some accounts were previously mis-generated. This was a rare issue, but you were affected! + +We have successfully imported the accounts that were mis-generated, but they will no longer be recovered with your normal seed phrase. + +We have marked the affected accounts as "Loose", and recommend you transfer ether and tokens away from those accounts, or export & back them up elsewhere. + +Your affected accounts are: +${lostAccounts.map(acct => ` - ${summary(acct)}`).join('\n')} + +These accounts have been marked as "Loose" so they will be easy to recognize in the account list. + +For more information, please read [our blog post.][1] + +[1]: https://medium.com/metamask/metamask-3-migration-guide-914b79533cdd#.7d8ktj4h3 + `, + } +} diff --git a/old-ui/lib/persistent-form.js b/old-ui/lib/persistent-form.js new file mode 100644 index 000000000..d4dc20b03 --- /dev/null +++ b/old-ui/lib/persistent-form.js @@ -0,0 +1,61 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const defaultKey = 'persistent-form-default' +const eventName = 'keyup' + +module.exports = PersistentForm + +function PersistentForm () { + Component.call(this) +} + +inherits(PersistentForm, Component) + +PersistentForm.prototype.componentDidMount = function () { + const fields = document.querySelectorAll('[data-persistent-formid]') + const store = this.getPersistentStore() + + for (var i = 0; i < fields.length; i++) { + const field = fields[i] + const key = field.getAttribute('data-persistent-formid') + const cached = store[key] + if (cached !== undefined) { + field.value = cached + } + + field.addEventListener(eventName, this.persistentFieldDidUpdate.bind(this)) + } +} + +PersistentForm.prototype.getPersistentStore = function () { + let store = window.localStorage[this.persistentFormParentId || defaultKey] + if (store && store !== 'null') { + store = JSON.parse(store) + } else { + store = {} + } + return store +} + +PersistentForm.prototype.setPersistentStore = function (newStore) { + window.localStorage[this.persistentFormParentId || defaultKey] = JSON.stringify(newStore) +} + +PersistentForm.prototype.persistentFieldDidUpdate = function (event) { + const field = event.target + const store = this.getPersistentStore() + const key = field.getAttribute('data-persistent-formid') + const val = field.value + store[key] = val + this.setPersistentStore(store) +} + +PersistentForm.prototype.componentWillUnmount = function () { + const fields = document.querySelectorAll('[data-persistent-formid]') + for (var i = 0; i < fields.length; i++) { + const field = fields[i] + field.removeEventListener(eventName, this.persistentFieldDidUpdate.bind(this)) + } + this.setPersistentStore({}) +} + diff --git a/old-ui/lib/tx-helper.js b/old-ui/lib/tx-helper.js new file mode 100644 index 000000000..de3f00d2d --- /dev/null +++ b/old-ui/lib/tx-helper.js @@ -0,0 +1,27 @@ +const valuesFor = require('../app/util').valuesFor + +module.exports = function (unapprovedTxs, unapprovedMsgs, personalMsgs, typedMessages, network) { + log.debug('tx-helper called with params:') + log.debug({ unapprovedTxs, unapprovedMsgs, personalMsgs, typedMessages, network }) + + const txValues = network ? valuesFor(unapprovedTxs).filter(txMeta => txMeta.metamaskNetworkId === network) : valuesFor(unapprovedTxs) + log.debug(`tx helper found ${txValues.length} unapproved txs`) + + const msgValues = valuesFor(unapprovedMsgs) + log.debug(`tx helper found ${msgValues.length} unsigned messages`) + let allValues = txValues.concat(msgValues) + + const personalValues = valuesFor(personalMsgs) + log.debug(`tx helper found ${personalValues.length} unsigned personal messages`) + allValues = allValues.concat(personalValues) + + const typedValues = valuesFor(typedMessages) + log.debug(`tx helper found ${typedValues.length} unsigned typed messages`) + allValues = allValues.concat(typedValues) + + allValues = allValues.sort((a, b) => { + return a.time > b.time + }) + + return allValues +} diff --git a/package.json b/package.json index 39d5df75c..72b4951db 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ "lodash.debounce": "^4.0.8", "lodash.memoize": "^4.1.2", "lodash.shuffle": "^4.2.0", + "lodash.uniqby": "^4.7.0", "loglevel": "^1.4.1", "metamascara": "^1.3.1", "metamask-logo": "^2.1.2", @@ -126,7 +127,7 @@ "multiplex": "^6.7.0", "number-to-bn": "^1.7.0", "obj-multiplex": "^1.0.0", - "obs-store": "^2.3.1", + "obs-store": "^3.0.0", "once": "^1.3.3", "ping-pong-stream": "^1.0.0", "pojo-migrator": "^2.1.0", @@ -142,7 +143,7 @@ "react-addons-css-transition-group": "^15.6.0", "react-dom": "^15.6.2", "react-hyperscript": "^3.0.0", - "react-markdown": "^2.3.0", + "react-markdown": "^3.0.0", "react-redux": "^5.0.5", "react-select": "^1.0.0", "react-simple-file-input": "^2.0.0", @@ -160,6 +161,7 @@ "sandwich-expando": "^1.1.3", "semaphore": "^1.0.5", "shallow-copy": "0.0.1", + "semver": "^5.4.1", "sw-stream": "^2.0.0", "textarea-caret": "^3.0.1", "through2": "^2.0.3", diff --git a/test/integration/lib/mascara-first-time.js b/test/integration/lib/mascara-first-time.js index 398ecea0e..515c7f383 100644 --- a/test/integration/lib/mascara-first-time.js +++ b/test/integration/lib/mascara-first-time.js @@ -6,23 +6,7 @@ async function runFirstTimeUsageTest (assert, done) { const app = $('#app-content') - // recurse notices - while (true) { - const button = app.find('button') - if (button.html() === 'Accept') { - // still notices to accept - const termsPage = app.find('.markdown')[0] - termsPage.scrollTop = termsPage.scrollHeight - await timeout() - console.log('Clearing notice') - button.click() - await timeout() - } else { - // exit loop - console.log('No more notices...') - break - } - } + await skipNotices(app) await timeout() @@ -51,28 +35,13 @@ async function runFirstTimeUsageTest (assert, done) { assert.equal(created.textContent, 'Your unique account image', 'unique image screen') // Agree button - const button = app.find('button')[0] + let button = app.find('button')[0] assert.ok(button, 'button present') button.click() await timeout(1000) - // Privacy Screen - const detail = app.find('.tou__title')[0] - assert.equal(detail.textContent, 'Privacy Notice', 'privacy notice screen') - app.find('button').click() - - await timeout(1000) - - - // terms of service screen - const tou = app.find('.tou__title')[0] - assert.equal(tou.textContent, 'Terms of Use', 'terms of use screen') - app.find('.tou__body').scrollTop(100000) - await timeout(1000) - - app.find('.first-time-flow__button').click() - await timeout(1000) + await skipNotices(app) // secret backup phrase const seedTitle = app.find('.backup-phrase__title')[0] @@ -156,4 +125,24 @@ function timeout (time) { return new Promise((resolve, reject) => { setTimeout(resolve, time || 1500) }) -} \ No newline at end of file +} + +async function skipNotices (app) { + while (true) { + const button = app.find('button') + if (button && button.html() === 'Accept') { + // still notices to accept + const termsPage = app.find('.markdown')[0] + if (!termsPage) { + break + } + termsPage.scrollTop = termsPage.scrollHeight + await timeout() + button.click() + await timeout() + } else { + console.log('No more notices...') + break + } + } +} diff --git a/test/unit/metamask-controller-test.js b/test/unit/metamask-controller-test.js index ef6cae758..fd420a70f 100644 --- a/test/unit/metamask-controller-test.js +++ b/test/unit/metamask-controller-test.js @@ -11,6 +11,15 @@ describe('MetaMaskController', function () { unlockAccountMessage: noop, showUnapprovedTx: noop, platform: {}, + encryptor: { + encrypt: function(password, object) { + this.object = object + return Promise.resolve() + }, + decrypt: function () { + return Promise.resolve(this.object) + } + }, // initial state initState: clone(firstTimeState), }) @@ -27,6 +36,30 @@ describe('MetaMaskController', function () { describe('Metamask Controller', function () { assert(metamaskController) + + beforeEach(function () { + sinon.spy(metamaskController.keyringController, 'createNewVaultAndKeychain') + }) + + afterEach(function () { + metamaskController.keyringController.createNewVaultAndKeychain.restore() + }) + + describe('#createNewVaultAndKeychain', function () { + it('can only create new vault on keyringController once', async function () { + + const selectStub = sinon.stub(metamaskController, 'selectFirstIdentity') + + const password = 'a-fake-password' + + const first = await metamaskController.createNewVaultAndKeychain(password) + const second = await metamaskController.createNewVaultAndKeychain(password) + + assert(metamaskController.keyringController.createNewVaultAndKeychain.calledOnce) + + selectStub.reset() + }) + }) }) }) diff --git a/test/unit/pending-tx-test.js b/test/unit/pending-tx-test.js index 32117a194..961fa6baf 100644 --- a/test/unit/pending-tx-test.js +++ b/test/unit/pending-tx-test.js @@ -207,6 +207,7 @@ describe('PendingTransactionTracker', function () { }) describe('#resubmitPendingTxs', function () { + const blockStub = { number: '0x0' }; beforeEach(function () { const txMeta2 = txMeta3 = txMeta txList = [txMeta, txMeta2, txMeta3].map((tx) => { @@ -224,7 +225,7 @@ describe('PendingTransactionTracker', function () { Promise.all(txList.map((tx) => tx.processed)) .then((txCompletedList) => done()) .catch(done) - pendingTxTracker.resubmitPendingTxs() + pendingTxTracker.resubmitPendingTxs(blockStub) }) it('should not emit \'tx:failed\' if the txMeta throws a known txError', function (done) { knownErrors =[ @@ -251,7 +252,7 @@ describe('PendingTransactionTracker', function () { .then((txCompletedList) => done()) .catch(done) - pendingTxTracker.resubmitPendingTxs() + pendingTxTracker.resubmitPendingTxs(blockStub) }) it('should emit \'tx:warning\' if it encountered a real error', function (done) { pendingTxTracker.once('tx:warning', (txMeta, err) => { @@ -269,28 +270,74 @@ describe('PendingTransactionTracker', function () { .then((txCompletedList) => done()) .catch(done) - pendingTxTracker.resubmitPendingTxs() + pendingTxTracker.resubmitPendingTxs(blockStub) }) }) describe('#_resubmitTx', function () { - it('should publishing the transaction', function (done) { - const enoughBalance = '0x100000' - pendingTxTracker.getBalance = (address) => { - assert.equal(address, txMeta.txParams.from, 'Should pass the address') - return enoughBalance - } - pendingTxTracker.publishTransaction = async (rawTx) => { - assert.equal(rawTx, txMeta.rawTx, 'Should pass the rawTx') - } + const mockFirstRetryBlockNumber = '0x1' + let txMetaToTestExponentialBackoff - // Stubbing out current account state: - // Adding the fake tx: - pendingTxTracker._resubmitTx(txMeta) - .then(() => done()) - .catch((err) => { - assert.ifError(err, 'should not throw an error') - done(err) + beforeEach(() => { + pendingTxTracker.getBalance = (address) => { + assert.equal(address, txMeta.txParams.from, 'Should pass the address') + return enoughBalance + } + pendingTxTracker.publishTransaction = async (rawTx) => { + assert.equal(rawTx, txMeta.rawTx, 'Should pass the rawTx') + } + sinon.spy(pendingTxTracker, 'publishTransaction') + + txMetaToTestExponentialBackoff = Object.assign({}, txMeta, { + retryCount: 4, + firstRetryBlockNumber: mockFirstRetryBlockNumber, + }) + }) + + afterEach(() => { + pendingTxTracker.publishTransaction.reset() + }) + + it('should publish the transaction', function (done) { + const enoughBalance = '0x100000' + + // Stubbing out current account state: + // Adding the fake tx: + pendingTxTracker._resubmitTx(txMeta) + .then(() => done()) + .catch((err) => { + assert.ifError(err, 'should not throw an error') + done(err) + }) + + assert.equal(pendingTxTracker.publishTransaction.callCount, 1, 'Should call publish transaction') + }) + + it('should not publish the transaction if the limit of retries has been exceeded', function (done) { + const enoughBalance = '0x100000' + const mockLatestBlockNumber = '0x5' + + pendingTxTracker._resubmitTx(txMetaToTestExponentialBackoff, mockLatestBlockNumber) + .then(() => done()) + .catch((err) => { + assert.ifError(err, 'should not throw an error') + done(err) + }) + + assert.equal(pendingTxTracker.publishTransaction.callCount, 0, 'Should NOT call publish transaction') + }) + + it('should publish the transaction if the number of blocks since last retry exceeds the last set limit', function (done) { + const enoughBalance = '0x100000' + const mockLatestBlockNumber = '0x11' + + pendingTxTracker._resubmitTx(txMetaToTestExponentialBackoff, mockLatestBlockNumber) + .then(() => done()) + .catch((err) => { + assert.ifError(err, 'should not throw an error') + done(err) + }) + + assert.equal(pendingTxTracker.publishTransaction.callCount, 1, 'Should call publish transaction') }) - }) }) }) diff --git a/ui/app/actions.js b/ui/app/actions.js index e79f4373e..ed0518184 100644 --- a/ui/app/actions.js +++ b/ui/app/actions.js @@ -149,6 +149,7 @@ var actions = { UPDATE_SEND_AMOUNT: 'UPDATE_SEND_AMOUNT', UPDATE_SEND_MEMO: 'UPDATE_SEND_MEMO', UPDATE_SEND_ERRORS: 'UPDATE_SEND_ERRORS', + UPDATE_MAX_MODE: 'UPDATE_MAX_MODE', UPDATE_SEND: 'UPDATE_SEND', CLEAR_SEND: 'CLEAR_SEND', updateGasLimit, @@ -160,6 +161,7 @@ var actions = { updateSendAmount, updateSendMemo, updateSendErrors, + setMaxModeTo, updateSend, clearSend, setSelectedAddress, @@ -237,6 +239,11 @@ var actions = { SET_USE_BLOCKIE: 'SET_USE_BLOCKIE', setUseBlockie, + + // Feature Flags + setFeatureFlag, + updateFeatureFlags, + UPDATE_FEATURE_FLAGS: 'UPDATE_FEATURE_FLAGS', } module.exports = actions @@ -637,6 +644,13 @@ function updateSendErrors (error) { } } +function setMaxModeTo (bool) { + return { + type: actions.UPDATE_MAX_MODE, + value: bool, + } +} + function updateSend (newSend) { return { type: actions.UPDATE_SEND, @@ -988,9 +1002,10 @@ function showConfigPage (transitionForward = true) { } } -function showAddTokenPage () { +function showAddTokenPage (transitionForward = true) { return { type: actions.SHOW_ADD_TOKEN_PAGE, + value: transitionForward, } } @@ -1272,7 +1287,8 @@ function exportAccount (password, address) { return reject(err) } - dispatch(self.exportAccountComplete()) + // dispatch(self.exportAccountComplete()) + dispatch(self.showPrivateKey(result)) return resolve(result) }) @@ -1439,7 +1455,7 @@ function reshowQrCode (data, coin) { dispatch(actions.showLoadingIndication()) shapeShiftRequest('marketinfo', {pair: `${coin.toLowerCase()}_eth`}, (mktResponse) => { if (mktResponse.error) return dispatch(actions.displayWarning(mktResponse.error)) - + var message = [ `Deposit your ${coin} to the address bellow:`, `Deposit Limit: ${mktResponse.limit}`, @@ -1447,10 +1463,11 @@ function reshowQrCode (data, coin) { ] dispatch(actions.hideLoadingIndication()) - return dispatch(actions.showModal({ - name: 'SHAPESHIFT_DEPOSIT_TX', - Qr: { data, message }, - })) + return dispatch(actions.showQrView(data, message)) + // return dispatch(actions.showModal({ + // name: 'SHAPESHIFT_DEPOSIT_TX', + // Qr: { data, message }, + // })) }) } } @@ -1506,6 +1523,34 @@ function updateTokenExchangeRate (token = '') { } } +function setFeatureFlag (feature, activated) { + const notificationType = activated + ? 'BETA_UI_NOTIFICATION_MODAL' + : 'OLD_UI_NOTIFICATION_MODAL' + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + return new Promise((resolve, reject) => { + background.setFeatureFlag(feature, activated, (err, updatedFeatureFlags) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + dispatch(actions.displayWarning(err.message)) + reject(err) + } + dispatch(actions.updateFeatureFlags(updatedFeatureFlags)) + dispatch(actions.showModal({ name: notificationType })) + resolve(updatedFeatureFlags) + }) + }) + } +} + +function updateFeatureFlags (updatedFeatureFlags) { + return { + type: actions.UPDATE_FEATURE_FLAGS, + value: updatedFeatureFlags, + } +} + // Call Background Then Update // // A function generator for a common pattern wherein: diff --git a/ui/app/app.js b/ui/app/app.js index e90c3e98e..88a5c8458 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -116,40 +116,41 @@ App.prototype.render = function () { log.debug('Main ui render function') return ( + h('.new-ui', [ + h('.flex-column.full-height', { + style: { + overflowX: 'hidden', + position: 'relative', + alignItems: 'center', + }, + }, [ - h('.flex-column.full-height', { - style: { - overflowX: 'hidden', - position: 'relative', - alignItems: 'center', - }, - }, [ + // global modal + h(Modal, {}, []), - // global modal - h(Modal, {}, []), + // app bar + this.renderAppBar(), - // app bar - this.renderAppBar(), + // sidebar + this.renderSidebar(), - // sidebar - this.renderSidebar(), + // network dropdown + h(NetworkDropdown, { + provider: this.props.provider, + frequentRpcList: this.props.frequentRpcList, + }, []), - // network dropdown - h(NetworkDropdown, { - provider: this.props.provider, - frequentRpcList: this.props.frequentRpcList, - }, []), + h(AccountMenu), - h(AccountMenu), + (isLoading || isLoadingNetwork) && h(Loading, { + loadingMessage: loadMessage, + }), - (isLoading || isLoadingNetwork) && h(Loading, { - loadingMessage: loadMessage, - }), + // this.renderLoadingIndicator({ isLoading, isLoadingNetwork, loadMessage }), - // this.renderLoadingIndicator({ isLoading, isLoadingNetwork, loadMessage }), - - // content - this.renderPrimary(), + // content + this.renderPrimary(), + ]), ]) ) } diff --git a/ui/app/components/customize-gas-modal/index.js b/ui/app/components/customize-gas-modal/index.js index 485dacf90..826d2cd4b 100644 --- a/ui/app/components/customize-gas-modal/index.js +++ b/ui/app/components/customize-gas-modal/index.js @@ -5,6 +5,8 @@ const connect = require('react-redux').connect const actions = require('../../actions') const GasModalCard = require('./gas-modal-card') +const ethUtil = require('ethereumjs-util') + const { MIN_GAS_PRICE_DEC, MIN_GAS_LIMIT_DEC, @@ -19,6 +21,7 @@ const { conversionUtil, multiplyCurrencies, conversionGreaterThan, + subtractCurrencies, } = require('../../conversion-util') const { @@ -30,6 +33,7 @@ const { getSendFrom, getCurrentAccountWithSendEtherInfo, getSelectedTokenToFiatRate, + getSendMaxModeState, } = require('../../selectors') function mapStateToProps (state) { @@ -42,6 +46,7 @@ function mapStateToProps (state) { gasLimit: getGasLimit(state), conversionRate, amount: getSendAmount(state), + maxModeOn: getSendMaxModeState(state), balance: currentAccount.balance, primaryCurrency: selectedToken && selectedToken.symbol, selectedToken, @@ -55,6 +60,7 @@ function mapDispatchToProps (dispatch) { updateGasPrice: newGasPrice => dispatch(actions.updateGasPrice(newGasPrice)), updateGasLimit: newGasLimit => dispatch(actions.updateGasLimit(newGasLimit)), updateGasTotal: newGasTotal => dispatch(actions.updateGasTotal(newGasTotal)), + updateSendAmount: newAmount => dispatch(actions.updateSendAmount(newAmount)), } } @@ -93,8 +99,21 @@ CustomizeGasModal.prototype.save = function (gasPrice, gasLimit, gasTotal) { updateGasLimit, hideModal, updateGasTotal, + maxModeOn, + selectedToken, + balance, + updateSendAmount, } = this.props + if (maxModeOn && !selectedToken) { + const maxAmount = subtractCurrencies( + ethUtil.addHexPrefix(balance), + ethUtil.addHexPrefix(gasTotal), + { toNumericBase: 'hex' } + ) + updateSendAmount(maxAmount) + } + updateGasPrice(gasPrice) updateGasLimit(gasLimit) updateGasTotal(gasTotal) @@ -112,12 +131,13 @@ CustomizeGasModal.prototype.validate = function ({ gasTotal, gasLimit }) { selectedToken, amountConversionRate, conversionRate, + maxModeOn, } = this.props let error = null const balanceIsSufficient = isBalanceSufficient({ - amount: selectedToken ? '0' : amount, + amount: selectedToken || maxModeOn ? '0' : amount, gasTotal, balance, selectedToken, diff --git a/ui/app/components/modals/modal.js b/ui/app/components/modals/modal.js index f2909f3c3..2ff6accaa 100644 --- a/ui/app/components/modals/modal.js +++ b/ui/app/components/modals/modal.js @@ -16,6 +16,7 @@ const NewAccountModal = require('./new-account-modal') const ShapeshiftDepositTxModal = require('./shapeshift-deposit-tx-modal.js') const HideTokenConfirmationModal = require('./hide-token-confirmation-modal') const CustomizeGasModal = require('../customize-gas-modal') +const NotifcationModal = require('./notification-modal') const accountModalStyle = { mobileModalStyle: { @@ -133,6 +134,42 @@ const MODALS = { }, }, + BETA_UI_NOTIFICATION_MODAL: { + contents: [ + h(NotifcationModal, { + header: 'Welcome to the New UI (Beta)', + message: `You are now using the new Metamask UI. Take a look around, try out new features like sending tokens, + and let us know if you have any issues.`, + }), + ], + mobileModalStyle: { + width: '95%', + top: isPopupOrNotification() === 'popup' ? '52vh' : '36.5vh', + }, + laptopModalStyle: { + width: '449px', + top: 'calc(33% + 45px)', + }, + }, + + OLD_UI_NOTIFICATION_MODAL: { + contents: [ + h(NotifcationModal, { + header: 'Old UI', + message: `You have returned to the old UI. You can switch back to the New UI through the option in the top + right dropdown menu.`, + }), + ], + mobileModalStyle: { + width: '95%', + top: isPopupOrNotification() === 'popup' ? '52vh' : '36.5vh', + }, + laptopModalStyle: { + width: '449px', + top: 'calc(33% + 45px)', + }, + }, + NEW_ACCOUNT: { contents: [ h(NewAccountModal, {}, []), diff --git a/ui/app/components/modals/notification-modal.js b/ui/app/components/modals/notification-modal.js new file mode 100644 index 000000000..239144b0c --- /dev/null +++ b/ui/app/components/modals/notification-modal.js @@ -0,0 +1,51 @@ +const { Component } = require('react') +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const { connect } = require('react-redux') +const actions = require('../../actions') + +class NotificationModal extends Component { + render () { + const { + header, + message, + } = this.props + + return h('div', [ + h('div.notification-modal-wrapper', { + }, [ + + h('div.notification-modal-header', {}, [ + header, + ]), + + h('div.notification-modal-message-wrapper', {}, [ + h('div.notification-modal-message', {}, [ + message, + ]), + ]), + + h('div.modal-close-x', { + onClick: this.props.hideModal, + }), + + ]), + ]) + } +} + +NotificationModal.propTypes = { + hideModal: PropTypes.func, + header: PropTypes.string, + message: PropTypes.string, +} + +const mapDispatchToProps = dispatch => { + return { + hideModal: () => { + dispatch(actions.hideModal()) + }, + } +} + +module.exports = connect(null, mapDispatchToProps)(NotificationModal) diff --git a/ui/app/components/send/send-v2-container.js b/ui/app/components/send/send-v2-container.js index 4451a6113..655de8897 100644 --- a/ui/app/components/send/send-v2-container.js +++ b/ui/app/components/send/send-v2-container.js @@ -78,5 +78,6 @@ function mapDispatchToProps (dispatch) { goHome: () => dispatch(actions.goHome()), clearSend: () => dispatch(actions.clearSend()), backToConfirmScreen: editingTransactionId => dispatch(actions.showConfTxPage({ id: editingTransactionId })), + setMaxModeTo: bool => dispatch(actions.setMaxModeTo(bool)), } } diff --git a/ui/app/css/index.scss b/ui/app/css/index.scss index 01899ccad..445c819ff 100644 --- a/ui/app/css/index.scss +++ b/ui/app/css/index.scss @@ -4,6 +4,7 @@ http://www.creativebloq.com/web-design/manage-large-css-projects-itcss-101517528 https://www.xfive.co/blog/itcss-scalable-maintainable-css-architecture/ */ + @import './itcss/settings/index.scss'; @import './itcss/tools/index.scss'; @import './itcss/generic/index.scss'; diff --git a/ui/app/css/itcss/components/menu.scss b/ui/app/css/itcss/components/menu.scss index 7953834ee..eb92a1b70 100644 --- a/ui/app/css/itcss/components/menu.scss +++ b/ui/app/css/itcss/components/menu.scss @@ -11,8 +11,8 @@ flex-flow: row nowrap; align-items: center; position: relative; - z-index: 200; font-weight: 300; + z-index: 201; @media screen and (max-width: 575px) { padding: 14px; diff --git a/ui/app/css/itcss/components/modal.scss b/ui/app/css/itcss/components/modal.scss index b69bd5c7e..9b64564d6 100644 --- a/ui/app/css/itcss/components/modal.scss +++ b/ui/app/css/itcss/components/modal.scss @@ -563,3 +563,39 @@ } } } + +//Notification Modal + +.notification-modal-wrapper { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + position: relative; + border: 1px solid $alto; + box-shadow: 0 0 2px 2px $alto; + font-family: Roboto; +} + +.notification-modal-header { + background: $wild-sand; + width: 100%; + display: flex; + justify-content: center; + padding: 30px; + font-size: 22px; + color: $nile-blue; + height: 79px; +} + +.notification-modal-message { + padding: 20px; +} + +.notification-modal-message { + width: 100%; + display: flex; + justify-content: center; + font-size: 17px; + color: $nile-blue; +} \ No newline at end of file diff --git a/ui/app/css/itcss/components/settings.scss b/ui/app/css/itcss/components/settings.scss index 2f29d8017..d60ebd934 100644 --- a/ui/app/css/itcss/components/settings.scss +++ b/ui/app/css/itcss/components/settings.scss @@ -145,6 +145,11 @@ color: $monzo; } +.settings__clear-button--orange { + border: 1px solid rgba(247, 134, 28, 1); + color: rgba(247, 134, 28, 1); +} + .settings__info-logo-wrapper { height: 80px; margin-bottom: 20px; diff --git a/ui/app/first-time/init-menu.js b/ui/app/first-time/init-menu.js index cc7c51bd3..b4587f1ee 100644 --- a/ui/app/first-time/init-menu.js +++ b/ui/app/first-time/init-menu.js @@ -8,6 +8,8 @@ const actions = require('../actions') const Tooltip = require('../components/tooltip') const getCaretCoordinates = require('textarea-caret') +let isSubmitting = false + module.exports = connect(mapStateToProps)(InitializeMenuScreen) inherits(InitializeMenuScreen, Component) @@ -164,7 +166,10 @@ InitializeMenuScreen.prototype.createNewVaultAndKeychain = function () { return } - this.props.dispatch(actions.createNewVaultAndKeychain(password)) + if (!isSubmitting) { + isSubmitting = true + this.props.dispatch(actions.createNewVaultAndKeychain(password)) + } } InitializeMenuScreen.prototype.inputChanged = function (event) { diff --git a/ui/app/reducers/metamask.js b/ui/app/reducers/metamask.js index fb53bbaef..95b41e5f3 100644 --- a/ui/app/reducers/metamask.js +++ b/ui/app/reducers/metamask.js @@ -33,10 +33,12 @@ function reduceMetamask (state, action) { amount: '0x0', memo: '', errors: {}, + maxModeOn: false, editingTransactionId: null, }, coinOptions: {}, useBlockie: false, + featureFlags: {}, }, state.metamask) switch (action.type) { @@ -258,6 +260,14 @@ function reduceMetamask (state, action) { }, }) + case actions.UPDATE_MAX_MODE: + return extend(metamaskState, { + send: { + ...metamaskState.send, + maxModeOn: action.value, + }, + }) + case actions.UPDATE_SEND: return extend(metamaskState, { send: { @@ -310,7 +320,7 @@ function reduceMetamask (state, action) { return extend(metamaskState, { tokenExchangeRates: { ...metamaskState.tokenExchangeRates, - [marketinfo.pair]: ssMarketInfo, + [ssMarketInfo.pair]: ssMarketInfo, }, coinOptions, }) @@ -320,6 +330,11 @@ function reduceMetamask (state, action) { useBlockie: action.value, }) + case actions.UPDATE_FEATURE_FLAGS: + return extend(metamaskState, { + featureFlags: action.value, + }) + default: return metamaskState diff --git a/ui/app/root.js b/ui/app/root.js index 9e7314b20..21d6d1829 100644 --- a/ui/app/root.js +++ b/ui/app/root.js @@ -2,7 +2,7 @@ const inherits = require('util').inherits const Component = require('react').Component const Provider = require('react-redux').Provider const h = require('react-hyperscript') -const App = require('./app') +const SelectedApp = require('./select-app') module.exports = Root @@ -15,7 +15,7 @@ Root.prototype.render = function () { h(Provider, { store: this.props.store, }, [ - h(App), + h(SelectedApp), ]) ) diff --git a/ui/app/select-app.js b/ui/app/select-app.js new file mode 100644 index 000000000..ffa31b767 --- /dev/null +++ b/ui/app/select-app.js @@ -0,0 +1,47 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const connect = require('react-redux').connect +const h = require('react-hyperscript') +const App = require('./app') +const OldApp = require('../../old-ui/app/app') +const { autoAddToBetaUI } = require('./selectors') +const { setFeatureFlag } = require('./actions') + +function mapStateToProps (state) { + return { + betaUI: state.metamask.featureFlags.betaUI, + autoAdd: autoAddToBetaUI(state), + isUnlocked: state.metamask.isUnlocked, + } +} + +function mapDispatchToProps (dispatch) { + return { + setFeatureFlagToBeta: () => dispatch(setFeatureFlag('betaUI', true)), + } +} +module.exports = connect(mapStateToProps, mapDispatchToProps)(SelectedApp) + +inherits(SelectedApp, Component) +function SelectedApp () { + this.state = { + autoAdd: false, + } + Component.call(this) +} + +SelectedApp.prototype.componentWillReceiveProps = function (nextProps) { + const { isUnlocked, setFeatureFlagToBeta } = this.props + + if (!isUnlocked && nextProps.isUnlocked && nextProps.autoAdd) { + this.setState({ autoAdd: nextProps.autoAdd }) + setFeatureFlagToBeta() + } +} + +SelectedApp.prototype.render = function () { + const { betaUI } = this.props + const { autoAdd } = this.state + const Selected = betaUI ? App : OldApp + return h(Selected) +} diff --git a/ui/app/selectors.js b/ui/app/selectors.js index a5f9a75d8..22ef439c4 100644 --- a/ui/app/selectors.js +++ b/ui/app/selectors.js @@ -24,6 +24,8 @@ const selectors = { getSendAmount, getSelectedTokenToFiatRate, getSelectedTokenContract, + autoAddToBetaUI, + getSendMaxModeState, } module.exports = selectors @@ -135,6 +137,10 @@ function getSendAmount (state) { return state.metamask.send.amount } +function getSendMaxModeState (state) { + return state.metamask.send.maxModeOn +} + function getCurrentCurrency (state) { return state.metamask.currentCurrency } @@ -158,3 +164,20 @@ function getSelectedTokenContract (state) { ? global.eth.contract(abi).at(selectedToken.address) : null } + +function autoAddToBetaUI (state) { + const autoAddTransactionThreshold = 12 + const autoAddAccountsThreshold = 2 + const autoAddTokensThreshold = 1 + + const numberOfTransactions = state.metamask.selectedAddressTxList.length + const numberOfAccounts = Object.keys(state.metamask.accounts).length + const numberOfTokensAdded = state.metamask.tokens.length + + const userPassesThreshold = (numberOfTransactions > autoAddTransactionThreshold) && + (numberOfAccounts > autoAddAccountsThreshold) && + (numberOfTokensAdded > autoAddTokensThreshold) + const userIsNotInBeta = !state.metamask.featureFlags.betaUI + + return userIsNotInBeta && userPassesThreshold +} \ No newline at end of file diff --git a/ui/app/send-v2.js b/ui/app/send-v2.js index 788ae87b4..e1b88f0db 100644 --- a/ui/app/send-v2.js +++ b/ui/app/send-v2.js @@ -13,8 +13,6 @@ const GasFeeDisplay = require('./components/send/gas-fee-display-v2') const { MIN_GAS_TOTAL, - MIN_GAS_PRICE_HEX, - MIN_GAS_LIMIT_HEX, } = require('./components/send/send-constants') const { @@ -313,8 +311,9 @@ SendTransactionScreen.prototype.renderToRow = function () { SendTransactionScreen.prototype.handleAmountChange = function (value) { const amount = value - const { updateSendAmount } = this.props + const { updateSendAmount, setMaxModeTo } = this.props + setMaxModeTo(false) this.validateAmount(amount) updateSendAmount(amount) } @@ -324,11 +323,9 @@ SendTransactionScreen.prototype.setAmountToMax = function () { from: { balance }, updateSendAmount, updateSendErrors, - updateGasPrice, - updateGasLimit, - updateGasTotal, tokenBalance, selectedToken, + gasTotal, } = this.props const { decimals } = selectedToken || {} const multiplier = Math.pow(10, Number(decimals || 0)) @@ -337,16 +334,12 @@ SendTransactionScreen.prototype.setAmountToMax = function () { ? multiplyCurrencies(tokenBalance, multiplier, {toNumericBase: 'hex'}) : subtractCurrencies( ethUtil.addHexPrefix(balance), - ethUtil.addHexPrefix(MIN_GAS_TOTAL), + ethUtil.addHexPrefix(gasTotal), { toNumericBase: 'hex' } ) updateSendErrors({ amount: null }) - if (!selectedToken) { - updateGasPrice(MIN_GAS_PRICE_HEX) - updateGasLimit(MIN_GAS_LIMIT_HEX) - updateGasTotal(MIN_GAS_TOTAL) - } + updateSendAmount(maxAmount) } @@ -407,19 +400,22 @@ SendTransactionScreen.prototype.renderAmountRow = function () { amountConversionRate, errors, amount, + setMaxModeTo, + maxModeOn, } = this.props return h('div.send-v2__form-row', [ - h('div.send-v2__form-label', [ + h('div.send-v2__form-label', [ 'Amount:', this.renderErrorMessage('amount'), !errors.amount && h('div.send-v2__amount-max', { onClick: (event) => { event.preventDefault() + setMaxModeTo(true) this.setAmountToMax() }, - }, [ 'Max' ]), + }, [ !maxModeOn ? 'Max' : '' ]), ]), h('div.send-v2__form-field', [ diff --git a/ui/app/settings.js b/ui/app/settings.js index caa36d2b8..ca7535d26 100644 --- a/ui/app/settings.js +++ b/ui/app/settings.js @@ -228,6 +228,26 @@ class Settings extends Component { ]) ) } + + renderOldUI () { + const { setFeatureFlagToBeta } = this.props + + return ( + h('div.settings__content-row', [ + h('div.settings__content-item', 'Use old UI'), + h('div.settings__content-item', [ + h('div.settings__content-item-col', [ + h('button.settings__clear-button.settings__clear-button--orange', { + onClick (event) { + event.preventDefault() + setFeatureFlagToBeta() + }, + }, 'Use old UI'), + ]), + ]), + ]) + ) + } renderSettingsContent () { const { warning } = this.props @@ -241,10 +261,11 @@ class Settings extends Component { this.renderNewRpcUrl(), this.renderStateLogs(), this.renderSeedWords(), + this.renderOldUI(), ]) ) } - + renderLogo () { return ( h('div.settings__info-logo-wrapper', [ @@ -362,6 +383,7 @@ Settings.propTypes = { setRpcTarget: PropTypes.func, displayWarning: PropTypes.func, revealSeedConfirmation: PropTypes.func, + setFeatureFlagToBeta: PropTypes.func, warning: PropTypes.string, goHome: PropTypes.func, } @@ -381,6 +403,7 @@ const mapDispatchToProps = dispatch => { displayWarning: warning => dispatch(actions.displayWarning(warning)), revealSeedConfirmation: () => dispatch(actions.revealSeedConfirmation()), setUseBlockie: value => dispatch(actions.setUseBlockie(value)), + setFeatureFlagToBeta: () => dispatch(actions.setFeatureFlag('betaUI', false)), } }