diff options
Diffstat (limited to 'ui/app/add-token.js')
-rw-r--r-- | ui/app/add-token.js | 504 |
1 files changed, 314 insertions, 190 deletions
diff --git a/ui/app/add-token.js b/ui/app/add-token.js index 9354a4cad..3a806d34b 100644 --- a/ui/app/add-token.js +++ b/ui/app/add-token.js @@ -1,238 +1,362 @@ const inherits = require('util').inherits const Component = require('react').Component +const classnames = require('classnames') const h = require('react-hyperscript') const connect = require('react-redux').connect +const R = require('ramda') +const Fuse = require('fuse.js') +const contractMap = require('eth-contract-metadata') +const TokenBalance = require('./components/token-balance') +const Identicon = require('./components/identicon') +const contractList = Object.entries(contractMap) + .map(([ _, tokenData]) => tokenData) + .filter(tokenData => Boolean(tokenData.erc20)) +const fuse = new Fuse(contractList, { + shouldSort: true, + threshold: 0.45, + location: 0, + distance: 100, + maxPatternLength: 32, + minMatchCharLength: 1, + keys: [ + { name: 'name', weight: 0.5 }, + { name: 'symbol', weight: 0.5 }, + ], +}) const actions = require('./actions') -const Tooltip = require('./components/tooltip.js') - - const ethUtil = require('ethereumjs-util') -const abi = require('human-standard-token-abi') -const Eth = require('ethjs-query') -const EthContract = require('ethjs-contract') +const { tokenInfoGetter } = require('./token-util') const emptyAddr = '0x0000000000000000000000000000000000000000' -module.exports = connect(mapStateToProps)(AddTokenScreen) +module.exports = connect(mapStateToProps, mapDispatchToProps)(AddTokenScreen) function mapStateToProps (state) { + const { identities, tokens } = state.metamask return { - identities: state.metamask.identities, + identities, + tokens, + } +} + +function mapDispatchToProps (dispatch) { + return { + goHome: () => dispatch(actions.goHome()), + addTokens: tokens => dispatch(actions.addTokens(tokens)), } } inherits(AddTokenScreen, Component) function AddTokenScreen () { this.state = { - warning: null, - address: null, - symbol: 'TOKEN', - decimals: 18, + isShowingConfirmation: false, + customAddress: '', + customSymbol: '', + customDecimals: 0, + searchQuery: '', + isCollapsed: true, + selectedTokens: {}, + errors: {}, } + this.tokenAddressDidChange = this.tokenAddressDidChange.bind(this) + this.onNext = this.onNext.bind(this) Component.call(this) } -AddTokenScreen.prototype.render = function () { - const state = this.state - const props = this.props - const { warning, symbol, decimals } = state - - return ( - h('.flex-column.flex-grow', [ - - // subtitle and nav - h('.section-title.flex-row.flex-center', [ - h('i.fa.fa-arrow-left.fa-lg.cursor-pointer', { - onClick: (event) => { - props.dispatch(actions.goHome()) - }, - }), - h('h2.page-subtitle', 'Add Token'), - ]), - - h('.error', { - style: { - display: warning ? 'block' : 'none', - padding: '0 20px', - textAlign: 'center', - }, - }, warning), - - // conf view - h('.flex-column.flex-justify-center.flex-grow.select-none', [ - h('.flex-space-around', { - style: { - padding: '20px', - }, - }, [ - - h('div', [ - h(Tooltip, { - position: 'top', - title: 'The contract of the actual token contract. Click for more info.', - }, [ - h('a', { - style: { fontWeight: 'bold', paddingRight: '10px'}, - href: 'https://support.metamask.io/kb/article/24-what-is-a-token-contract-address', - target: '_blank', - }, [ - h('span', 'Token Contract Address '), - h('i.fa.fa-question-circle'), - ]), - ]), - ]), - - h('section.flex-row.flex-center', [ - h('input#token-address', { - name: 'address', - placeholder: 'Token Contract Address', - onChange: this.tokenAddressDidChange.bind(this), - style: { - width: 'inherit', - flex: '1 0 auto', - height: '30px', - margin: '8px', - }, - }), - ]), - - h('div', [ - h('span', { - style: { fontWeight: 'bold', paddingRight: '10px'}, - }, 'Token Symbol'), - ]), - - h('div', { style: {display: 'flex'} }, [ - h('input#token_symbol', { - placeholder: `Like "ETH"`, - value: symbol, - style: { - width: 'inherit', - flex: '1 0 auto', - height: '30px', - margin: '8px', - }, - onChange: (event) => { - var element = event.target - var symbol = element.value - this.setState({ symbol }) - }, - }), - ]), - - h('div', [ - h('span', { - style: { fontWeight: 'bold', paddingRight: '10px'}, - }, 'Decimals of Precision'), - ]), - - h('div', { style: {display: 'flex'} }, [ - h('input#token_decimals', { - value: decimals, - type: 'number', - min: 0, - max: 36, - style: { - width: 'inherit', - flex: '1 0 auto', - height: '30px', - margin: '8px', - }, - onChange: (event) => { - var element = event.target - var decimals = element.value.trim() - this.setState({ decimals }) - }, - }), - ]), - - h('button', { - style: { - alignSelf: 'center', - }, - onClick: (event) => { - const valid = this.validateInputs() - if (!valid) return +AddTokenScreen.prototype.componentWillMount = function () { + this.tokenInfoGetter = tokenInfoGetter() +} - const { address, symbol, decimals } = this.state - this.props.dispatch(actions.addToken(address.trim(), symbol.trim(), decimals)) - }, - }, 'Add'), - ]), - ]), - ]) - ) +AddTokenScreen.prototype.toggleToken = function (address, token) { + const { selectedTokens, errors } = this.state + const { [address]: selectedToken } = selectedTokens + this.setState({ + selectedTokens: { + ...selectedTokens, + [address]: selectedToken ? null : token, + }, + errors: { + ...errors, + tokenSelector: null, + }, + }) } -AddTokenScreen.prototype.componentWillMount = function () { - if (typeof global.ethereumProvider === 'undefined') return +AddTokenScreen.prototype.onNext = function () { + const { isValid, errors } = this.validate() - this.eth = new Eth(global.ethereumProvider) - this.contract = new EthContract(this.eth) - this.TokenContract = this.contract(abi) + return !isValid + ? this.setState({ errors }) + : this.setState({ isShowingConfirmation: true }) } -AddTokenScreen.prototype.tokenAddressDidChange = function (event) { - const el = event.target - const address = el.value.trim() - if (ethUtil.isValidAddress(address) && address !== emptyAddr) { - this.setState({ address }) - this.attemptToAutoFillTokenParams(address) +AddTokenScreen.prototype.tokenAddressDidChange = function (e) { + const customAddress = e.target.value.trim() + this.setState({ customAddress }) + if (ethUtil.isValidAddress(customAddress) && customAddress !== emptyAddr) { + this.attemptToAutoFillTokenParams(customAddress) + } else { + this.setState({ + customSymbol: '', + customDecimals: 0, + }) } } -AddTokenScreen.prototype.validateInputs = function () { - let msg = '' - const state = this.state - const identitiesList = Object.keys(this.props.identities) - const { address, symbol, decimals } = state - const standardAddress = ethUtil.addHexPrefix(address).toLowerCase() - - const validAddress = ethUtil.isValidAddress(address) - if (!validAddress) { - msg += 'Address is invalid. ' +AddTokenScreen.prototype.checkExistingAddresses = function (address) { + if (!address) return false + const tokensList = this.props.tokens + const matchesAddress = existingToken => { + return existingToken.address.toLowerCase() === address.toLowerCase() } - const validDecimals = decimals >= 0 && decimals < 36 - if (!validDecimals) { - msg += 'Decimals must be at least 0, and not over 36. ' - } + return R.any(matchesAddress)(tokensList) +} - const symbolLen = symbol.trim().length - const validSymbol = symbolLen > 0 && symbolLen < 10 - if (!validSymbol) { - msg += 'Symbol must be between 0 and 10 characters.' +AddTokenScreen.prototype.validate = function () { + const errors = {} + const identitiesList = Object.keys(this.props.identities) + const { customAddress, customSymbol, customDecimals, selectedTokens } = this.state + const standardAddress = ethUtil.addHexPrefix(customAddress).toLowerCase() + + if (customAddress) { + const validAddress = ethUtil.isValidAddress(customAddress) + if (!validAddress) { + errors.customAddress = 'Address is invalid. ' + } + + const validDecimals = customDecimals >= 0 && customDecimals < 36 + if (!validDecimals) { + errors.customDecimals = 'Decimals must be at least 0, and not over 36.' + } + + const symbolLen = customSymbol.trim().length + const validSymbol = symbolLen > 0 && symbolLen < 10 + if (!validSymbol) { + errors.customSymbol = 'Symbol must be between 0 and 10 characters.' + } + + const ownAddress = identitiesList.includes(standardAddress) + if (ownAddress) { + errors.customAddress = 'Personal address detected. Input the token contract address.' + } + + const tokenAlreadyAdded = this.checkExistingAddresses(customAddress) + if (tokenAlreadyAdded) { + errors.customAddress = 'Token has already been added.' + } + } else if ( + Object.entries(selectedTokens) + .reduce((isEmpty, [ symbol, isSelected ]) => ( + isEmpty && !isSelected + ), true) + ) { + errors.tokenSelector = 'Must select at least 1 token.' } - const ownAddress = identitiesList.includes(standardAddress) - if (ownAddress) { - msg = 'Personal address detected. Input the token contract address.' + return { + isValid: !Object.keys(errors).length, + errors, } +} - const isValid = validAddress && validDecimals && !ownAddress - - if (!isValid) { +AddTokenScreen.prototype.attemptToAutoFillTokenParams = async function (address) { + const { symbol, decimals } = await this.tokenInfoGetter(address) + if (symbol && decimals) { this.setState({ - warning: msg, + customSymbol: symbol, + customDecimals: decimals.toString(), }) - } else { - this.setState({ warning: null }) } +} + +AddTokenScreen.prototype.renderCustomForm = function () { + const { customAddress, customSymbol, customDecimals, errors } = this.state - return isValid + return !this.state.isCollapsed && ( + h('div.add-token__add-custom-form', [ + h('div', { + className: classnames('add-token__add-custom-field', { + 'add-token__add-custom-field--error': errors.customAddress, + }), + }, [ + h('div.add-token__add-custom-label', 'Token Address'), + h('input.add-token__add-custom-input', { + type: 'text', + onChange: this.tokenAddressDidChange, + value: customAddress, + }), + h('div.add-token__add-custom-error-message', errors.customAddress), + ]), + h('div', { + className: classnames('add-token__add-custom-field', { + 'add-token__add-custom-field--error': errors.customSymbol, + }), + }, [ + h('div.add-token__add-custom-label', 'Token Symbol'), + h('input.add-token__add-custom-input', { + type: 'text', + value: customSymbol, + disabled: true, + }), + h('div.add-token__add-custom-error-message', errors.customSymbol), + ]), + h('div', { + className: classnames('add-token__add-custom-field', { + 'add-token__add-custom-field--error': errors.customDecimals, + }), + }, [ + h('div.add-token__add-custom-label', 'Decimals of Precision'), + h('input.add-token__add-custom-input', { + type: 'number', + value: customDecimals, + disabled: true, + }), + h('div.add-token__add-custom-error-message', errors.customDecimals), + ]), + ]) + ) } -AddTokenScreen.prototype.attemptToAutoFillTokenParams = async function (address) { - const contract = this.TokenContract.at(address) +AddTokenScreen.prototype.renderTokenList = function () { + const { searchQuery = '', selectedTokens } = this.state + const fuseSearchResult = fuse.search(searchQuery) + const addressSearchResult = contractList.filter(token => { + return token.address.toLowerCase() === searchQuery.toLowerCase() + }) + const results = [...addressSearchResult, ...fuseSearchResult] + + return Array(6).fill(undefined) + .map((_, i) => { + const { logo, symbol, name, address } = results[i] || {} + const tokenAlreadyAdded = this.checkExistingAddresses(address) + return Boolean(logo || symbol || name) && ( + h('div.add-token__token-wrapper', { + className: classnames({ + 'add-token__token-wrapper--selected': selectedTokens[address], + 'add-token__token-wrapper--disabled': tokenAlreadyAdded, + }), + onClick: () => !tokenAlreadyAdded && this.toggleToken(address, results[i]), + }, [ + h('div.add-token__token-icon', { + style: { + backgroundImage: `url(images/contract/${logo})`, + }, + }), + h('div.add-token__token-data', [ + h('div.add-token__token-symbol', symbol), + h('div.add-token__token-name', name), + ]), + // tokenAlreadyAdded && ( + // h('div.add-token__token-message', 'Already added') + // ), + ]) + ) + }) +} - const results = await Promise.all([ - contract.symbol(), - contract.decimals(), - ]) +AddTokenScreen.prototype.renderConfirmation = function () { + const { + customAddress: address, + customSymbol: symbol, + customDecimals: decimals, + selectedTokens, + } = this.state - const [ symbol, decimals ] = results - if (symbol && decimals) { - console.log('SETTING SYMBOL AND DECIMALS', { symbol, decimals }) - this.setState({ symbol: symbol[0], decimals: decimals[0].toString() }) + const { addTokens, goHome } = this.props + + const customToken = { + address, + symbol, + decimals, } + + const tokens = address && symbol && decimals + ? { ...selectedTokens, [address]: customToken } + : selectedTokens + + return ( + h('div.add-token', [ + h('div.add-token__wrapper', [ + h('div.add-token__title-container.add-token__confirmation-title', [ + h('div.add-token__title', 'Add Token'), + h('div.add-token__description', 'Would you like to add these tokens?'), + ]), + h('div.add-token__content-container.add-token__confirmation-content', [ + h('div.add-token__description.add-token__confirmation-description', 'Your balances'), + h('div.add-token__confirmation-token-list', + Object.entries(tokens) + .map(([ address, token ]) => ( + h('span.add-token__confirmation-token-list-item', [ + h(Identicon, { + className: 'add-token__confirmation-token-icon', + diameter: 75, + address, + }), + h(TokenBalance, { token }), + ]) + )) + ), + ]), + ]), + h('div.add-token__buttons', [ + h('button.btn-cancel.add-token__button', { + onClick: () => this.setState({ isShowingConfirmation: false }), + }, 'Back'), + h('button.btn-clear.add-token__button', { + onClick: () => addTokens(tokens).then(goHome), + }, 'Add Tokens'), + ]), + ]) + ) +} + +AddTokenScreen.prototype.render = function () { + const { isCollapsed, errors, isShowingConfirmation } = this.state + const { goHome } = this.props + + return isShowingConfirmation + ? this.renderConfirmation() + : ( + h('div.add-token', [ + h('div.add-token__wrapper', [ + h('div.add-token__title-container', [ + h('div.add-token__title', 'Add Token'), + h('div.add-token__description', 'Keep track of the tokens you’ve bought with your MetaMask account. If you bought tokens using a different account, those tokens will not appear here.'), + h('div.add-token__description', 'Search for tokens or select from our list of popular tokens.'), + ]), + h('div.add-token__content-container', [ + h('div.add-token__input-container', [ + h('input.add-token__input', { + type: 'text', + placeholder: 'Search', + onChange: e => this.setState({ searchQuery: e.target.value }), + }), + h('div.add-token__search-input-error-message', errors.tokenSelector), + ]), + h( + 'div.add-token__token-icons-container', + this.renderTokenList(), + ), + ]), + h('div.add-token__footers', [ + h('div.add-token__add-custom', { + onClick: () => this.setState({ isCollapsed: !isCollapsed }), + }, [ + 'Add custom token', + h(`i.fa.fa-angle-${isCollapsed ? 'down' : 'up'}`), + ]), + this.renderCustomForm(), + ]), + ]), + h('div.add-token__buttons', [ + h('button.btn-cancel.add-token__button', { + onClick: goHome, + }, 'Cancel'), + h('button.btn-clear.add-token__button', { + onClick: this.onNext, + }, 'Next'), + ]), + ]) + ) } |