diff options
Diffstat (limited to 'ui/app/components/pages/add-token.js')
-rw-r--r-- | ui/app/components/pages/add-token.js | 431 |
1 files changed, 431 insertions, 0 deletions
diff --git a/ui/app/components/pages/add-token.js b/ui/app/components/pages/add-token.js new file mode 100644 index 000000000..566e42450 --- /dev/null +++ b/ui/app/components/pages/add-token.js @@ -0,0 +1,431 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const classnames = require('classnames') +const h = require('react-hyperscript') +const PropTypes = require('prop-types') +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 ethUtil = require('ethereumjs-util') +const { tokenInfoGetter } = require('../../token-util') +const { DEFAULT_ROUTE } = require('../../routes') + +const emptyAddr = '0x0000000000000000000000000000000000000000' + +AddTokenScreen.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(AddTokenScreen) + + +function mapStateToProps (state) { + const { identities, tokens } = state.metamask + return { + identities, + tokens, + } +} + +function mapDispatchToProps (dispatch) { + return { + addTokens: tokens => dispatch(actions.addTokens(tokens)), + } +} + +inherits(AddTokenScreen, Component) +function AddTokenScreen () { + this.state = { + isShowingConfirmation: false, + isShowingInfoBox: true, + customAddress: '', + customSymbol: '', + customDecimals: '', + searchQuery: '', + selectedTokens: {}, + errors: {}, + autoFilled: false, + displayedTab: 'SEARCH', + } + this.tokenAddressDidChange = this.tokenAddressDidChange.bind(this) + this.tokenSymbolDidChange = this.tokenSymbolDidChange.bind(this) + this.tokenDecimalsDidChange = this.tokenDecimalsDidChange.bind(this) + this.onNext = this.onNext.bind(this) + Component.call(this) +} + +AddTokenScreen.prototype.componentWillMount = function () { + this.tokenInfoGetter = tokenInfoGetter() +} + +AddTokenScreen.prototype.toggleToken = function (address, token) { + const { selectedTokens = {}, errors } = this.state + const selectedTokensCopy = { ...selectedTokens } + + if (address in selectedTokensCopy) { + delete selectedTokensCopy[address] + } else { + selectedTokensCopy[address] = token + } + + this.setState({ + selectedTokens: selectedTokensCopy, + errors: { + ...errors, + tokenSelector: null, + }, + }) +} + +AddTokenScreen.prototype.onNext = function () { + const { isValid, errors } = this.validate() + + return !isValid + ? this.setState({ errors }) + : this.setState({ isShowingConfirmation: true }) +} + +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.tokenSymbolDidChange = function (e) { + const customSymbol = e.target.value.trim() + this.setState({ customSymbol }) +} + +AddTokenScreen.prototype.tokenDecimalsDidChange = function (e) { + const customDecimals = e.target.value.trim() + this.setState({ customDecimals }) +} + +AddTokenScreen.prototype.checkExistingAddresses = function (address) { + if (!address) return false + const tokensList = this.props.tokens + const matchesAddress = existingToken => { + return existingToken.address.toLowerCase() === address.toLowerCase() + } + + return R.any(matchesAddress)(tokensList) +} + +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 = this.context.t('invalidAddress') + } + + const validDecimals = customDecimals !== null + && customDecimals !== '' + && customDecimals >= 0 + && customDecimals < 36 + if (!validDecimals) { + errors.customDecimals = this.context.t('decimalsMustZerotoTen') + } + + const symbolLen = customSymbol.trim().length + const validSymbol = symbolLen > 0 && symbolLen < 10 + if (!validSymbol) { + errors.customSymbol = this.context.t('symbolBetweenZeroTen') + } + + const ownAddress = identitiesList.includes(standardAddress) + if (ownAddress) { + errors.customAddress = this.context.t('personalAddressDetected') + } + + const tokenAlreadyAdded = this.checkExistingAddresses(customAddress) + if (tokenAlreadyAdded) { + errors.customAddress = this.context.t('tokenAlreadyAdded') + } + } else if ( + Object.entries(selectedTokens) + .reduce((isEmpty, [ symbol, isSelected ]) => ( + isEmpty && !isSelected + ), true) + ) { + errors.tokenSelector = this.context.t('mustSelectOne') + } + + return { + isValid: !Object.keys(errors).length, + errors, + } +} + +AddTokenScreen.prototype.attemptToAutoFillTokenParams = async function (address) { + const { symbol, decimals } = await this.tokenInfoGetter(address) + if (symbol && decimals) { + this.setState({ + customSymbol: symbol, + customDecimals: decimals.toString(), + autoFilled: true, + }) + } +} + +AddTokenScreen.prototype.renderCustomForm = function () { + const { autoFilled, customAddress, customSymbol, customDecimals, errors } = this.state + + return ( + 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', this.context.t('tokenAddress')), + 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', this.context.t('tokenSymbol')), + h('input.add-token__add-custom-input', { + type: 'text', + onChange: this.tokenSymbolDidChange, + value: customSymbol, + disabled: autoFilled, + }), + 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', this.context.t('decimal')), + h('input.add-token__add-custom-input', { + type: 'number', + onChange: this.tokenDecimalsDidChange, + value: customDecimals, + disabled: autoFilled, + }), + h('div.add-token__add-custom-error-message', errors.customDecimals), + ]), + ]) + ) +} + +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 h('div', [ + results.length > 0 && h('div.add-token__token-icons-title', this.context.t('popularTokens')), + h('div.add-token__token-icons-container', 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: logo && `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') + // ), + ]) + ) + })), + ]) +} + +AddTokenScreen.prototype.renderConfirmation = function () { + const { + customAddress: address, + customSymbol: symbol, + customDecimals: decimals, + selectedTokens, + } = this.state + + const { addTokens, history } = 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__content-container.add-token__confirmation-content', [ + h('div.add-token__description.add-token__confirmation-description', this.context.t('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-secondary--lg.add-token__cancel-button', { + onClick: () => this.setState({ isShowingConfirmation: false }), + }, this.context.t('back')), + h('button.btn-primary--lg', { + onClick: () => addTokens(tokens).then(() => history.push(DEFAULT_ROUTE)), + }, this.context.t('addTokens')), + ]), + ]) + ) +} + +AddTokenScreen.prototype.displayTab = function (selectedTab) { + this.setState({ displayedTab: selectedTab }) +} + +AddTokenScreen.prototype.renderTabs = function () { + const { isShowingInfoBox, displayedTab, errors } = this.state + + return displayedTab === 'CUSTOM_TOKEN' + ? this.renderCustomForm() + : h('div', [ + h('div.add-token__wrapper', [ + h('div.add-token__content-container', [ + isShowingInfoBox && h('div.add-token__info-box', [ + h('div.add-token__info-box__close', { + onClick: () => this.setState({ isShowingInfoBox: false }), + }), + h('div.add-token__info-box__title', this.context.t('whatsThis')), + h('div.add-token__info-box__copy', this.context.t('keepTrackTokens')), + h('a.add-token__info-box__copy--blue', { + href: 'http://metamask.helpscoutdocs.com/article/16-managing-erc20-tokens', + target: '_blank', + }, this.context.t('learnMore')), + ]), + h('div.add-token__input-container', [ + h('input.add-token__input', { + type: 'text', + placeholder: this.context.t('searchTokens'), + onChange: e => this.setState({ searchQuery: e.target.value }), + }), + h('div.add-token__search-input-error-message', errors.tokenSelector), + ]), + this.renderTokenList(), + ]), + ]), + ]) +} + +AddTokenScreen.prototype.render = function () { + const { + isShowingConfirmation, + displayedTab, + } = this.state + const { history } = this.props + + return h('div.add-token', [ + h('div.add-token__header', [ + h('div.add-token__header__cancel', { + onClick: () => history.push(DEFAULT_ROUTE), + }, [ + h('i.fa.fa-angle-left.fa-lg'), + h('span', this.context.t('cancel')), + ]), + h('div.add-token__header__title', this.context.t('addTokens')), + isShowingConfirmation && h('div.add-token__header__subtitle', this.context.t('likeToAddTokens')), + !isShowingConfirmation && h('div.add-token__header__tabs', [ + + h('div.add-token__header__tabs__tab', { + className: classnames('add-token__header__tabs__tab', { + 'add-token__header__tabs__selected': displayedTab === 'SEARCH', + 'add-token__header__tabs__unselected': displayedTab !== 'SEARCH', + }), + onClick: () => this.displayTab('SEARCH'), + }, this.context.t('search')), + + h('div.add-token__header__tabs__tab', { + className: classnames('add-token__header__tabs__tab', { + 'add-token__header__tabs__selected': displayedTab === 'CUSTOM_TOKEN', + 'add-token__header__tabs__unselected': displayedTab !== 'CUSTOM_TOKEN', + }), + onClick: () => this.displayTab('CUSTOM_TOKEN'), + }, this.context.t('customToken')), + + ]), + ]), + + isShowingConfirmation + ? this.renderConfirmation() + : this.renderTabs(), + + !isShowingConfirmation && h('div.add-token__buttons', [ + h('button.btn-secondary--lg.add-token__cancel-button', { + onClick: () => history.push(DEFAULT_ROUTE), + }, this.context.t('cancel')), + h('button.btn-primary--lg.add-token__confirm-button', { + onClick: this.onNext, + }, this.context.t('next')), + ]), + ]) +} |