diff --git a/.babelrc b/.babelrc index 3ca197980..bca3364fc 100644 --- a/.babelrc +++ b/.babelrc @@ -1,4 +1,4 @@ { - "presets": ["es2015"], - "plugins": ["transform-runtime"] + "presets": ["es2015", "stage-0"], + "plugins": ["transform-runtime", "transform-async-to-generator"] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d3e86342..96dc79d9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,13 @@ ## Current Master +- Add list of popular tokens held to the account detail view. - Add a warning to JSON file import. - Fix bug where slowly mined txs would sometimes be incorrectly marked as failed. +- Fix bug where badge count did not reflect personal_sign pending messages. +- Seed word confirmation wording is now scarier. +- Fix error for invalid seed words. +- Prevent users from submitting two duplicate transactions by disabling submit. ## 3.7.8 2017-6-12 diff --git a/app/scripts/background.js b/app/scripts/background.js index 1dbfb1b98..e8987394f 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -116,13 +116,15 @@ function setupController (initState) { updateBadge() controller.txController.on('updateBadge', updateBadge) controller.messageManager.on('updateBadge', updateBadge) + controller.personalMessageManager.on('updateBadge', updateBadge) // plugin badge text function updateBadge () { var label = '' var unapprovedTxCount = controller.txController.unapprovedTxCount var unapprovedMsgCount = controller.messageManager.unapprovedMsgCount - var count = unapprovedTxCount + unapprovedMsgCount + var unapprovedPersonalMsgs = controller.personalMessageManager.unapprovedPersonalMsgCount + var count = unapprovedTxCount + unapprovedMsgCount + unapprovedPersonalMsgs if (count) { label = String(count) } diff --git a/app/scripts/controllers/infura.js b/app/scripts/controllers/infura.js new file mode 100644 index 000000000..98375b446 --- /dev/null +++ b/app/scripts/controllers/infura.js @@ -0,0 +1,42 @@ +const ObservableStore = require('obs-store') +const extend = require('xtend') + +// every ten minutes +const POLLING_INTERVAL = 300000 + +class InfuraController { + + constructor (opts = {}) { + const initState = extend({ + infuraNetworkStatus: {}, + }, opts.initState) + this.store = new ObservableStore(initState) + } + + // + // PUBLIC METHODS + // + + // Responsible for retrieving the status of Infura's nodes. Can return either + // ok, degraded, or down. + checkInfuraNetworkStatus () { + return fetch('https://api.infura.io/v1/status/metamask') + .then(response => response.json()) + .then((parsedResponse) => { + this.store.updateState({ + infuraNetworkStatus: parsedResponse, + }) + }) + } + + scheduleInfuraNetworkCheck () { + if (this.conversionInterval) { + clearInterval(this.conversionInterval) + } + this.conversionInterval = setInterval(() => { + this.checkInfuraNetworkStatus() + }, POLLING_INTERVAL) + } +} + +module.exports = InfuraController diff --git a/app/scripts/controllers/preferences.js b/app/scripts/controllers/preferences.js index 7212c7c43..aa8e05fcc 100644 --- a/app/scripts/controllers/preferences.js +++ b/app/scripts/controllers/preferences.js @@ -7,6 +7,7 @@ class PreferencesController { constructor (opts = {}) { const initState = extend({ frequentRpcList: [], + currentAccountTab: 'history', }, opts.initState) this.store = new ObservableStore(initState) } @@ -35,6 +36,13 @@ class PreferencesController { }) } + setCurrentAccountTab (currentAccountTab) { + return new Promise((resolve, reject) => { + this.store.updateState({ currentAccountTab }) + resolve() + }) + } + addToFrequentRpcList (_url) { const rpcList = this.getFrequentRpcList() const index = rpcList.findIndex((element) => { return element === _url }) diff --git a/app/scripts/controllers/transactions.js b/app/scripts/controllers/transactions.js index e3c2d74d3..1efa96610 100644 --- a/app/scripts/controllers/transactions.js +++ b/app/scripts/controllers/transactions.js @@ -384,13 +384,13 @@ module.exports = class TransactionController extends EventEmitter { // - `'signed'` the tx is signed // - `'submitted'` the tx is sent to a server // - `'confirmed'` the tx has been included in a block. + // - `'failed'` the tx failed for some reason, included on tx data. _setTxStatus (txId, status) { var txMeta = this.getTx(txId) txMeta.status = status this.emit(`${txMeta.id}:${status}`, txId) if (status === 'submitted' || status === 'rejected') { this.emit(`${txMeta.id}:finished`, txMeta) - } this.updateTx(txMeta) this.emit('updateBadge') diff --git a/app/scripts/keyring-controller.js b/app/scripts/keyring-controller.js index 5b3c80e40..2edc8060e 100644 --- a/app/scripts/keyring-controller.js +++ b/app/scripts/keyring-controller.js @@ -87,7 +87,7 @@ class KeyringController extends EventEmitter { } if (!bip39.validateMnemonic(seed)) { - return Promise.reject('Seed phrase is invalid.') + return Promise.reject(new Error('Seed phrase is invalid.')) } this.clearKeyrings() diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index de9a15924..1a83c70f5 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -15,6 +15,7 @@ const CurrencyController = require('./controllers/currency') const NoticeController = require('./notice-controller') const ShapeShiftController = require('./controllers/shapeshift') const AddressBookController = require('./controllers/address-book') +const InfuraController = require('./controllers/infura') const MessageManager = require('./lib/message-manager') const PersonalMessageManager = require('./lib/personal-message-manager') const TransactionController = require('./controllers/transactions') @@ -44,8 +45,8 @@ module.exports = class MetamaskController extends EventEmitter { this.store = new ObservableStore(initState) // network store - this.networkController = new NetworkController(initState.NetworkController) + // config manager this.configManager = new ConfigManager({ store: this.store, @@ -63,6 +64,13 @@ module.exports = class MetamaskController extends EventEmitter { this.currencyController.updateConversionRate() this.currencyController.scheduleConversionInterval() + // infura controller + this.infuraController = new InfuraController({ + initState: initState.InfuraController, + }) + this.infuraController.scheduleInfuraNetworkCheck() + + // rpc provider this.provider = this.initializeProvider() @@ -147,6 +155,9 @@ module.exports = class MetamaskController extends EventEmitter { this.networkController.store.subscribe((state) => { this.store.updateState({ NetworkController: state }) }) + this.infuraController.store.subscribe((state) => { + this.store.updateState({ InfuraController: state }) + }) // manual mem state subscriptions this.networkController.store.subscribe(this.sendUpdate.bind(this)) @@ -160,6 +171,7 @@ module.exports = class MetamaskController extends EventEmitter { this.currencyController.store.subscribe(this.sendUpdate.bind(this)) this.noticeController.memStore.subscribe(this.sendUpdate.bind(this)) this.shapeshiftController.store.subscribe(this.sendUpdate.bind(this)) + this.infuraController.store.subscribe(this.sendUpdate.bind(this)) } // @@ -237,6 +249,7 @@ module.exports = class MetamaskController extends EventEmitter { this.addressBookController.store.getState(), this.currencyController.store.getState(), this.noticeController.memStore.getState(), + this.infuraController.store.getState(), // config manager this.configManager.getConfig(), this.shapeshiftController.store.getState(), @@ -280,6 +293,7 @@ module.exports = class MetamaskController extends EventEmitter { // PreferencesController setSelectedAddress: nodeify(preferencesController.setSelectedAddress).bind(preferencesController), + setCurrentAccountTab: nodeify(preferencesController.setCurrentAccountTab).bind(preferencesController), setDefaultRpc: nodeify(this.setDefaultRpc).bind(this), setCustomRpc: nodeify(this.setCustomRpc).bind(this), diff --git a/circle.yml b/circle.yml index 4305ca3b4..1f018ac24 100644 --- a/circle.yml +++ b/circle.yml @@ -1,6 +1,6 @@ machine: node: - version: 7.6.0 + version: 8.0.0 dependencies: pre: - "npm i -g testem" diff --git a/gulpfile.js b/gulpfile.js index 5bba1b9b3..cc723704a 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -20,7 +20,7 @@ var gulpif = require('gulp-if') var replace = require('gulp-replace') var mkdirp = require('mkdirp') -var disableLiveReload = gutil.env.disableLiveReload +var disableDebugTools = gutil.env.disableDebugTools var debug = gutil.env.debug // browser reload @@ -53,7 +53,7 @@ gulp.task('copy:images', copyTask({ ], })) gulp.task('copy:contractImages', copyTask({ - source: './node_modules/ethereum-contract-icons/images/', + source: './node_modules/eth-contract-metadata/images/', destinations: [ './dist/firefox/images/contract', './dist/chrome/images/contract', @@ -121,7 +121,7 @@ gulp.task('manifest:production', function() { './dist/chrome/manifest.json', './dist/edge/manifest.json', ],{base: './dist/'}) - .pipe(gulpif(disableLiveReload,jsoneditor(function(json) { + .pipe(gulpif(!debug,jsoneditor(function(json) { json.background.scripts = ["scripts/background.js"] return json }))) @@ -138,7 +138,7 @@ const staticFiles = [ var copyStrings = staticFiles.map(staticFile => `copy:${staticFile}`) copyStrings.push('copy:contractImages') -if (!disableLiveReload) { +if (debug) { copyStrings.push('copy:reload') } @@ -234,7 +234,7 @@ function copyTask(opts){ destinations.forEach(function(destination) { stream = stream.pipe(gulp.dest(destination)) }) - stream.pipe(gulpif(!disableLiveReload,livereload())) + stream.pipe(gulpif(debug,livereload())) return stream } @@ -314,16 +314,16 @@ function bundleTask(opts) { .pipe(buffer()) // sourcemaps // loads map from browserify file - .pipe(sourcemaps.init({loadMaps: true})) + .pipe(gulpif(debug, sourcemaps.init({loadMaps: true}))) // writes .map file - .pipe(sourcemaps.write('./')) + .pipe(gulpif(debug, sourcemaps.write('./'))) // write completed bundles .pipe(gulp.dest('./dist/firefox/scripts')) .pipe(gulp.dest('./dist/chrome/scripts')) .pipe(gulp.dest('./dist/edge/scripts')) .pipe(gulp.dest('./dist/opera/scripts')) // finally, trigger live reload - .pipe(gulpif(!disableLiveReload, livereload())) + .pipe(gulpif(debug, livereload())) ) } diff --git a/package.json b/package.json index 9ed2e7ae0..3f62923f8 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "start": "npm run dev", "dev": "gulp dev --debug", "disc": "gulp disc --debug", - "dist": "npm install && gulp dist --disableLiveReload", + "dist": "npm install && gulp dist", "test": "npm run lint && npm run test-unit && npm run test-integration", "test-unit": "METAMASK_ENV=test mocha --require test/helper.js --recursive \"test/unit/**/*.js\"", "test-integration": "npm run buildMock && npm run buildCiUnits && testem ci -P 2", @@ -62,11 +62,12 @@ "end-of-stream": "^1.1.0", "ensnare": "^1.0.0", "eth-bin-to-ops": "^1.0.1", - "eth-contract-metadata": "^1.0.0", + "eth-contract-metadata": "^1.1.3", "eth-hd-keyring": "^1.1.1", "eth-query": "^2.1.2", "eth-sig-util": "^1.1.1", "eth-simple-keyring": "^1.1.1", + "eth-token-tracker": "^1.0.9", "ethereumjs-tx": "^1.3.0", "ethereumjs-util": "ethereumjs/ethereumjs-util#ac5d0908536b447083ea422b435da27f26615de9", "ethereumjs-wallet": "^0.6.0", @@ -128,8 +129,11 @@ "xtend": "^4.0.1" }, "devDependencies": { + "babel-core": "^6.24.1", "babel-eslint": "^6.0.5", + "babel-plugin-transform-async-to-generator": "^6.24.1", "babel-plugin-transform-runtime": "^6.23.0", + "babel-polyfill": "^6.23.0", "babel-preset-stage-0": "^6.24.1", "babel-register": "^6.7.2", "babelify": "^7.2.0", diff --git a/test/integration/index.js b/test/integration/index.js index f2d656b0b..85f91d92b 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -9,13 +9,15 @@ var b = browserify() // Remove old bundle try { fs.unlinkSync(bundlePath) -} catch (e) {} -var writeStream = fs.createWriteStream(bundlePath) + var writeStream = fs.createWriteStream(bundlePath) -tests.forEach(function (fileName) { - b.add(path.join(__dirname, 'lib', fileName)) -}) + tests.forEach(function (fileName) { + b.add(path.join(__dirname, 'lib', fileName)) + }) -b.bundle().pipe(writeStream) + b.bundle().pipe(writeStream) +} catch (e) { + console.error('Integration build failure', e) +} diff --git a/test/unit/infura-controller-test.js b/test/unit/infura-controller-test.js new file mode 100644 index 000000000..7a2a114f9 --- /dev/null +++ b/test/unit/infura-controller-test.js @@ -0,0 +1,34 @@ +// polyfill fetch +global.fetch = function () {return Promise.resolve({ + json: () => { return Promise.resolve({"mainnet": "ok", "ropsten": "degraded", "kovan": "down", "rinkeby": "ok"}) }, + }) +} +const assert = require('assert') +const InfuraController = require('../../app/scripts/controllers/infura') + +describe('infura-controller', function () { + var infuraController + + beforeEach(function () { + infuraController = new InfuraController() + }) + + describe('network status queries', function () { + describe('#checkInfuraNetworkStatus', function () { + it('should return an object reflecting the network statuses', function (done) { + this.timeout(15000) + infuraController.checkInfuraNetworkStatus() + .then(() => { + const networkStatus = infuraController.store.getState().infuraNetworkStatus + assert.equal(Object.keys(networkStatus).length, 4) + assert.equal(networkStatus.mainnet, 'ok') + assert.equal(networkStatus.ropsten, 'degraded') + assert.equal(networkStatus.kovan, 'down') + }) + .then(() => done()) + .catch(done) + + }) + }) + }) +}) diff --git a/ui/app/account-detail.js b/ui/app/account-detail.js index 7a78a360c..836032b3c 100644 --- a/ui/app/account-detail.js +++ b/ui/app/account-detail.js @@ -16,6 +16,9 @@ const ExportAccountView = require('./components/account-export') const ethUtil = require('ethereumjs-util') const EditableLabel = require('./components/editable-label') const Tooltip = require('./components/tooltip') +const TabBar = require('./components/tab-bar') +const TokenList = require('./components/token-list') + module.exports = connect(mapStateToProps)(AccountDetailScreen) function mapStateToProps (state) { @@ -31,6 +34,7 @@ function mapStateToProps (state) { transactions: state.metamask.selectedAddressTxList || [], conversionRate: state.metamask.conversionRate, currentCurrency: state.metamask.currentCurrency, + currentAccountTab: state.metamask.currentAccountTab, } } @@ -237,10 +241,43 @@ AccountDetailScreen.prototype.subview = function () { switch (subview) { case 'transactions': - return this.transactionList() + return this.tabSections() case 'export': var state = extend({key: 'export'}, this.props) return h(ExportAccountView, state) + default: + return this.tabSections() + } +} + +AccountDetailScreen.prototype.tabSections = function () { + const { currentAccountTab } = this.props + + return h('section.tabSection', [ + + h(TabBar, { + tabs: [ + { content: 'Sent', key: 'history' }, + { content: 'Tokens', key: 'tokens' }, + ], + defaultTab: currentAccountTab || 'history', + tabSelected: (key) => { + this.props.dispatch(actions.setCurrentAccountTab(key)) + }, + }), + + this.tabSwitchView(), + ]) +} + +AccountDetailScreen.prototype.tabSwitchView = function () { + const props = this.props + const { address, network } = props + const { currentAccountTab } = this.props + + switch (currentAccountTab) { + case 'tokens': + return h(TokenList, { userAddress: address, network }) default: return this.transactionList() } @@ -249,6 +286,7 @@ AccountDetailScreen.prototype.subview = function () { AccountDetailScreen.prototype.transactionList = function () { const {transactions, unapprovedMsgs, address, network, shapeShiftTxList, conversionRate } = this.props + return h(TransactionList, { transactions: transactions.sort((a, b) => b.time - a.time), network, diff --git a/ui/app/actions.js b/ui/app/actions.js index 1a3557cb4..b6b5d6eb1 100644 --- a/ui/app/actions.js +++ b/ui/app/actions.js @@ -74,6 +74,7 @@ var actions = { SHOW_CONF_MSG_PAGE: 'SHOW_CONF_MSG_PAGE', SET_CURRENT_FIAT: 'SET_CURRENT_FIAT', setCurrentCurrency: setCurrentCurrency, + setCurrentAccountTab, // account detail screen SHOW_SEND_PAGE: 'SHOW_SEND_PAGE', showSendPage: showSendPage, @@ -218,7 +219,7 @@ function confirmSeedWords () { return dispatch(actions.displayWarning(err.message)) } - console.log('Seed word cache cleared. ' + account) + log.info('Seed word cache cleared. ' + account) dispatch(actions.showAccountDetail(account)) }) } @@ -338,7 +339,7 @@ function setCurrentCurrency (currencyCode) { background.setCurrentCurrency(currencyCode, (err, data) => { dispatch(this.hideLoadingIndication()) if (err) { - console.error(err.stack) + log.error(err.stack) return dispatch(actions.displayWarning(err.message)) } dispatch({ @@ -409,7 +410,7 @@ function sendTx (txData) { background.approveTransaction(txData.id, (err) => { if (err) { dispatch(actions.txError(err)) - return console.error(err.message) + return log.error(err.message) } dispatch(actions.completedTx(txData.id)) }) @@ -424,7 +425,7 @@ function updateAndApproveTx (txData) { dispatch(actions.hideLoadingIndication()) if (err) { dispatch(actions.txError(err)) - return console.error(err.message) + return log.error(err.message) } dispatch(actions.completedTx(txData.id)) }) @@ -558,6 +559,11 @@ function lockMetamask () { return callBackgroundThenUpdate(background.setLocked) } +function setCurrentAccountTab (newTabName) { + log.debug(`background.setCurrentAccountTab: ${newTabName}`) + return callBackgroundThenUpdateNoSpinner(background.setCurrentAccountTab, newTabName) +} + function showAccountDetail (address) { return (dispatch) => { dispatch(actions.showLoadingIndication()) @@ -965,6 +971,17 @@ function shapeShiftRequest (query, options, cb) { // We hide loading indication. // If it errored, we show a warning. // If it didn't, we update the state. +function callBackgroundThenUpdateNoSpinner (method, ...args) { + return (dispatch) => { + method.call(background, ...args, (err) => { + if (err) { + return dispatch(actions.displayWarning(err.message)) + } + forceUpdateMetamaskState(dispatch) + }) + } +} + function callBackgroundThenUpdate (method, ...args) { return (dispatch) => { dispatch(actions.showLoadingIndication()) diff --git a/ui/app/app.js b/ui/app/app.js index 53dbc3354..d444a8349 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -21,7 +21,7 @@ const generateLostAccountsNotice = require('../lib/lost-accounts-notice') const ConfigScreen = require('./config') const Import = require('./accounts/import') const InfoScreen = require('./info') -const LoadingIndicator = require('./components/loading') +const Loading = require('./components/loading') const SandwichExpando = require('sandwich-expando') const MenuDroppo = require('menu-droppo') const DropMenuItem = require('./components/drop-menu-item') @@ -64,7 +64,11 @@ function mapStateToProps (state) { App.prototype.render = function () { var props = this.props - const { isLoading, loadingMessage, transForward } = props + const { isLoading, loadingMessage, transForward, network } = props + const isLoadingNetwork = network === 'loading' + const loadMessage = loadingMessage || isLoadingNetwork ? + 'Searching for Network' : null + log.debug('Main ui render function') return ( @@ -77,13 +81,16 @@ App.prototype.render = function () { }, }, [ - h(LoadingIndicator, { isLoading, loadingMessage }), - // app bar this.renderAppBar(), this.renderNetworkDropdown(), this.renderDropdown(), + h(Loading, { + isLoading: isLoading || isLoadingNetwork, + loadingMessage: loadMessage, + }), + // panel content h('.app-primary.flex-grow' + (transForward ? '.from-right' : '.from-left'), { style: { @@ -124,7 +131,7 @@ App.prototype.renderAppBar = function () { background: props.isUnlocked ? 'white' : 'none', height: '36px', position: 'relative', - zIndex: 10, + zIndex: 12, }, }, [ @@ -221,7 +228,7 @@ App.prototype.renderNetworkDropdown = function () { onClickOutside: (event) => { this.setState({ isNetworkMenuOpen: !isOpen }) }, - zIndex: 1, + zIndex: 11, style: { position: 'absolute', left: 0, @@ -300,7 +307,7 @@ App.prototype.renderDropdown = function () { return h(MenuDroppo, { isOpen: isOpen, - zIndex: 1, + zIndex: 11, onClickOutside: (event) => { this.setState({ isMainMenuOpen: !isOpen }) }, diff --git a/ui/app/components/account-export.js b/ui/app/components/account-export.js index 888196c5d..394d878f7 100644 --- a/ui/app/components/account-export.js +++ b/ui/app/components/account-export.js @@ -20,8 +20,6 @@ function mapStateToProps (state) { } ExportAccountView.prototype.render = function () { - console.log('EXPORT VIEW') - console.dir(this.props) var state = this.props var accountDetail = state.accountDetail diff --git a/ui/app/components/balance.js b/ui/app/components/balance.js new file mode 100644 index 000000000..57ca84564 --- /dev/null +++ b/ui/app/components/balance.js @@ -0,0 +1,89 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const formatBalance = require('../util').formatBalance +const generateBalanceObject = require('../util').generateBalanceObject +const Tooltip = require('./tooltip.js') +const FiatValue = require('./fiat-value.js') + +module.exports = EthBalanceComponent + +inherits(EthBalanceComponent, Component) +function EthBalanceComponent () { + Component.call(this) +} + +EthBalanceComponent.prototype.render = function () { + var props = this.props + let { value } = props + var style = props.style + var needsParse = this.props.needsParse !== undefined ? this.props.needsParse : true + value = value ? formatBalance(value, 6, needsParse) : '...' + var width = props.width + + return ( + + h('.ether-balance.ether-balance-amount', { + style: style, + }, [ + h('div', { + style: { + display: 'inline', + width: width, + }, + }, this.renderBalance(value)), + ]) + + ) +} +EthBalanceComponent.prototype.renderBalance = function (value) { + var props = this.props + if (value === 'None') return value + if (value === '...') return value + var balanceObj = generateBalanceObject(value, props.shorten ? 1 : 3) + var balance + var splitBalance = value.split(' ') + var ethNumber = splitBalance[0] + var ethSuffix = splitBalance[1] + const showFiat = 'showFiat' in props ? props.showFiat : true + + if (props.shorten) { + balance = balanceObj.shortBalance + } else { + balance = balanceObj.balance + } + + var label = balanceObj.label + + return ( + h(Tooltip, { + position: 'bottom', + title: `${ethNumber} ${ethSuffix}`, + }, h('div.flex-column', [ + h('.flex-row', { + style: { + alignItems: 'flex-end', + lineHeight: '13px', + fontFamily: 'Montserrat Light', + textRendering: 'geometricPrecision', + }, + }, [ + h('div', { + style: { + width: '100%', + textAlign: 'right', + }, + }, this.props.incoming ? `+${balance}` : balance), + h('div', { + style: { + color: ' #AEAEAE', + fontSize: '12px', + marginLeft: '5px', + }, + }, label), + ]), + + showFiat ? h(FiatValue, { value: props.value }) : null, + ])) + ) +} diff --git a/ui/app/components/identicon.js b/ui/app/components/identicon.js index 9de854b54..c754bc6ba 100644 --- a/ui/app/components/identicon.js +++ b/ui/app/components/identicon.js @@ -23,7 +23,9 @@ IdenticonComponent.prototype.render = function () { h('div', { key: 'identicon-' + this.props.address, style: { - display: 'inline-block', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', height: diameter, width: diameter, borderRadius: diameter / 2, @@ -35,21 +37,22 @@ IdenticonComponent.prototype.render = function () { IdenticonComponent.prototype.componentDidMount = function () { var props = this.props - var address = props.address + const { address } = props if (!address) return var container = findDOMNode(this) + var diameter = props.diameter || this.defaultDiameter if (!isNode) { - var img = iconFactory.iconForAddress(address, diameter, false) + var img = iconFactory.iconForAddress(address, diameter) container.appendChild(img) } } IdenticonComponent.prototype.componentDidUpdate = function () { var props = this.props - var address = props.address + const { address } = props if (!address) return @@ -62,7 +65,8 @@ IdenticonComponent.prototype.componentDidUpdate = function () { var diameter = props.diameter || this.defaultDiameter if (!isNode) { - var img = iconFactory.iconForAddress(address, diameter, false) + var img = iconFactory.iconForAddress(address, diameter) container.appendChild(img) } } + diff --git a/ui/app/components/loading.js b/ui/app/components/loading.js index 88dc535df..87d6f5d20 100644 --- a/ui/app/components/loading.js +++ b/ui/app/components/loading.js @@ -26,18 +26,21 @@ LoadingIndicator.prototype.render = function () { style: { zIndex: 10, position: 'absolute', + flexDirection: 'column', display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', width: '100%', - background: 'rgba(255, 255, 255, 0.5)', + background: 'rgba(255, 255, 255, 0.8)', }, }, [ h('img', { src: 'images/loading.svg', }), + h('br'), + showMessageIfAny(loadingMessage), ]) : null, ]) diff --git a/ui/app/components/pending-tx.js b/ui/app/components/pending-tx.js index 4b1a00eca..f33a5d948 100644 --- a/ui/app/components/pending-tx.js +++ b/ui/app/components/pending-tx.js @@ -27,6 +27,7 @@ function PendingTx () { this.state = { valid: true, txData: null, + submitting: false, } } @@ -316,7 +317,7 @@ PendingTx.prototype.render = function () { type: 'submit', value: 'ACCEPT', style: { marginLeft: '10px' }, - disabled: insufficientBalance || !this.state.valid || !isValidAddress, + disabled: insufficientBalance || !this.state.valid || !isValidAddress || this.state.submitting, }), h('button.cancel.btn-red', { @@ -412,11 +413,12 @@ PendingTx.prototype.onSubmit = function (event) { event.preventDefault() const txMeta = this.gatherTxMeta() const valid = this.checkValidity() - this.setState({ valid }) + this.setState({ valid, submitting: true }) if (valid && this.verifyGasParams()) { this.props.sendTransaction(txMeta, event) } else { this.props.dispatch(actions.displayWarning('Invalid Gas Parameters')) + this.setState({ submitting: false }) } } diff --git a/ui/app/components/tab-bar.js b/ui/app/components/tab-bar.js index 65078e0a4..6295e7dd9 100644 --- a/ui/app/components/tab-bar.js +++ b/ui/app/components/tab-bar.js @@ -33,3 +33,4 @@ TabBar.prototype.render = function () { })) ) } + diff --git a/ui/app/components/token-cell.js b/ui/app/components/token-cell.js new file mode 100644 index 000000000..d3a895d36 --- /dev/null +++ b/ui/app/components/token-cell.js @@ -0,0 +1,46 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const Identicon = require('./identicon') + +module.exports = TokenCell + +inherits(TokenCell, Component) +function TokenCell () { + Component.call(this) +} + +TokenCell.prototype.render = function () { + const props = this.props + const { address, symbol, string, network, userAddress } = props + + return ( + h('li.token-cell', { + style: { cursor: network === '1' ? 'pointer' : 'default' }, + onClick: (event) => { + const url = urlFor(address, userAddress, network) + if (url) { + navigateTo(url) + } + }, + }, [ + + h(Identicon, { + diameter: 50, + address, + network, + }), + + h('h3', `${string || 0} ${symbol}`), + ]) + ) +} + +function navigateTo (url) { + global.platform.openWindow({ url }) +} + +function urlFor (tokenAddress, address, network) { + return `https://etherscan.io/token/${tokenAddress}?a=${address}` +} + diff --git a/ui/app/components/token-list.js b/ui/app/components/token-list.js new file mode 100644 index 000000000..633d3ccfe --- /dev/null +++ b/ui/app/components/token-list.js @@ -0,0 +1,147 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const TokenTracker = require('eth-token-tracker') +const TokenCell = require('./token-cell.js') +const contracts = require('eth-contract-metadata') + +const tokens = [] +for (const address in contracts) { + const contract = contracts[address] + if (contract.erc20) { + contract.address = address + tokens.push(contract) + } +} + +module.exports = TokenList + +inherits(TokenList, Component) +function TokenList () { + this.state = { tokens, isLoading: true, network: null } + Component.call(this) +} + +TokenList.prototype.render = function () { + const state = this.state + const { tokens, isLoading, error } = state + + const { userAddress } = this.props + + if (isLoading) { + return this.message('Loading') + } + + if (error) { + log.error(error) + return this.message('There was a problem loading your token balances.') + } + + const network = this.props.network + + const tokenViews = tokens.map((tokenData) => { + tokenData.network = network + tokenData.userAddress = userAddress + return h(TokenCell, tokenData) + }) + + return ( + h('ol', { + style: { + height: '302px', + overflowY: 'auto', + }, + }, [h('style', ` + + li.token-cell { + display: flex; + flex-direction: row; + align-items: center; + padding: 10px; + } + + li.token-cell > h3 { + margin-left: 12px; + } + + li.token-cell:hover { + background: white; + cursor: pointer; + } + + `)].concat(tokenViews.length ? tokenViews : this.message('No Tokens Found.'))) + ) +} + +TokenList.prototype.message = function (body) { + return h('div', { + style: { + display: 'flex', + height: '250px', + alignItems: 'center', + justifyContent: 'center', + }, + }, body) +} + +TokenList.prototype.componentDidMount = function () { + this.createFreshTokenTracker() +} + +TokenList.prototype.createFreshTokenTracker = function () { + if (this.tracker) { + // Clean up old trackers when refreshing: + this.tracker.stop() + this.tracker.removeListener('update', this.balanceUpdater) + this.tracker.removeListener('error', this.showError) + } + + if (!global.ethereumProvider) return + const { userAddress } = this.props + this.tracker = new TokenTracker({ + userAddress, + provider: global.ethereumProvider, + tokens: tokens, + pollingInterval: 8000, + }) + + + // Set up listener instances for cleaning up + this.balanceUpdater = this.updateBalances.bind(this) + this.showError = (error) => { + this.setState({ error, isLoading: false }) + } + this.tracker.on('update', this.balanceUpdater) + this.tracker.on('error', this.showError) + + this.tracker.updateBalances() + .then(() => { + this.updateBalances(this.tracker.serialize()) + }) + .catch((reason) => { + log.error(`Problem updating balances`, reason) + this.setState({ isLoading: false }) + }) +} + +TokenList.prototype.componentWillUpdate = function (nextProps) { + if (nextProps.network === 'loading') return + const oldNet = this.props.network + const newNet = nextProps.network + + if (oldNet && newNet && newNet !== oldNet) { + this.setState({ isLoading: true }) + this.createFreshTokenTracker() + } +} + +TokenList.prototype.updateBalances = function (tokenData) { + const heldTokens = tokenData.filter(token => token.balance !== '0' && token.string !== '0.000') + this.setState({ tokens: heldTokens, isLoading: false }) +} + +TokenList.prototype.componentWillUnmount = function () { + if (!this.tracker) return + this.tracker.stop() +} + diff --git a/ui/app/components/transaction-list.js b/ui/app/components/transaction-list.js index 37a757309..3b4ba741e 100644 --- a/ui/app/components/transaction-list.js +++ b/ui/app/components/transaction-list.js @@ -36,17 +36,6 @@ TransactionList.prototype.render = function () { } `), - h('h3.flex-center.text-transform-uppercase', { - style: { - background: '#EBEBEB', - color: '#AEAEAE', - paddingTop: '4px', - paddingBottom: '4px', - }, - }, [ - 'History', - ]), - h('.tx-list', { style: { overflowY: 'auto', diff --git a/ui/app/info.js b/ui/app/info.js index aa4503b62..e8470de97 100644 --- a/ui/app/info.js +++ b/ui/app/info.js @@ -101,14 +101,12 @@ InfoScreen.prototype.render = function () { h('a.info', { href: 'https://github.com/MetaMask/faq', target: '_blank', - onClick (event) { this.navigateTo(event.target.href) }, }, 'Need Help? Read our FAQ!'), ]), h('div', [ h('a', { href: 'https://metamask.io/', target: '_blank', - onClick (event) { this.navigateTo(event.target.href) }, }, [ h('img.icon-size', { src: 'images/icon-128.png', @@ -126,7 +124,6 @@ InfoScreen.prototype.render = function () { h('a.info', { href: 'http://slack.metamask.io', target: '_blank', - onClick (event) { this.navigateTo(event.target.href) }, }, 'Join the conversation on Slack'), ]), @@ -134,7 +131,6 @@ InfoScreen.prototype.render = function () { h('a.info', { href: 'https://twitter.com/metamask_io', target: '_blank', - onClick (event) { this.navigateTo(event.target.href) }, }, 'Follow us on Twitter'), ]), @@ -142,7 +138,7 @@ InfoScreen.prototype.render = function () { h('a.info', { target: '_blank', style: { width: '85vw' }, - onClick () { this.navigateTo('mailto:help@metamask.io?subject=Feedback') }, + href: 'mailto:help@metamask.io?subject=Feedback', }, 'Email us!'), ]), ]), @@ -155,3 +151,4 @@ InfoScreen.prototype.render = function () { InfoScreen.prototype.navigateTo = function (url) { global.platform.openWindow({ url }) } + diff --git a/ui/app/keychains/hd/create-vault-complete.js b/ui/app/keychains/hd/create-vault-complete.js index 5230797ad..9741155f7 100644 --- a/ui/app/keychains/hd/create-vault-complete.js +++ b/ui/app/keychains/hd/create-vault-complete.js @@ -54,7 +54,7 @@ CreateVaultCompleteScreen.prototype.render = function () { textAlign: 'center', }, }, [ - h('span.error', 'These 12 words can restore all of your MetaMask accounts for this vault.\nSave them somewhere safe and secret.'), + h('span.error', 'These 12 words are the only way to restore your MetaMask accounts.\nSave them somewhere safe and secret.'), ]), h('textarea.twelve-word-phrase', { diff --git a/ui/lib/icon-factory.js b/ui/lib/icon-factory.js index 45be47b7a..27a74de66 100644 --- a/ui/lib/icon-factory.js +++ b/ui/lib/icon-factory.js @@ -20,6 +20,7 @@ IconFactory.prototype.iconForAddress = function (address, diameter) { if (iconExistsFor(addr)) { return imageElFor(addr) } + return this.generateIdenticonSvg(address, diameter) } @@ -43,7 +44,7 @@ IconFactory.prototype.generateNewIdenticon = function (address, diameter) { // util function iconExistsFor (address) { - return (contractMap.address) && isValidAddress(address) && (contractMap[address].logo) + return contractMap[address] && isValidAddress(address) && contractMap[address].logo } function imageElFor (address) { @@ -52,7 +53,7 @@ function imageElFor (address) { const path = `images/contract/${fileName}` const img = document.createElement('img') img.src = path - img.style.width = '100%' + img.style.width = '75%' return img }