aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md1
-rw-r--r--app/scripts/controllers/preferences.js29
-rw-r--r--app/scripts/metamask-controller.js1
-rw-r--r--ui/app/account-detail.js10
-rw-r--r--ui/app/actions.js25
-rw-r--r--ui/app/add-token.js218
-rw-r--r--ui/app/app.js5
-rw-r--r--ui/app/components/token-list.js106
-rw-r--r--ui/app/reducers/app.js10
9 files changed, 367 insertions, 38 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 754f076f8..8452b141d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
## Current Master
- Add list of popular tokens held to the account detail view.
+- Add ability to add Tokens to token list.
- 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.
diff --git a/app/scripts/controllers/preferences.js b/app/scripts/controllers/preferences.js
index aa8e05fcc..e45224593 100644
--- a/app/scripts/controllers/preferences.js
+++ b/app/scripts/controllers/preferences.js
@@ -8,13 +8,11 @@ class PreferencesController {
const initState = extend({
frequentRpcList: [],
currentAccountTab: 'history',
+ tokens: [],
}, opts.initState)
this.store = new ObservableStore(initState)
}
-
- //
- // PUBLIC METHODS
- //
+// PUBLIC METHODS
setSelectedAddress (_address) {
return new Promise((resolve, reject) => {
@@ -28,6 +26,29 @@ class PreferencesController {
return this.store.getState().selectedAddress
}
+ addToken (rawAddress, symbol, decimals) {
+ const address = normalizeAddress(rawAddress)
+ const newEntry = { address, symbol, decimals }
+
+ const tokens = this.store.getState().tokens
+ const previousIndex = tokens.find((token, index) => {
+ return token.address === address
+ })
+
+ if (previousIndex) {
+ tokens[previousIndex] = newEntry
+ } else {
+ tokens.push(newEntry)
+ }
+
+ this.store.updateState({ tokens })
+ return Promise.resolve()
+ }
+
+ getTokens () {
+ return this.store.getState().tokens
+ }
+
updateFrequentRpcList (_url) {
return this.addToFrequentRpcList(_url)
.then((rpcList) => {
diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js
index f33da5213..39d22f278 100644
--- a/app/scripts/metamask-controller.js
+++ b/app/scripts/metamask-controller.js
@@ -280,6 +280,7 @@ module.exports = class MetamaskController extends EventEmitter {
// PreferencesController
setSelectedAddress: nodeify(preferencesController.setSelectedAddress).bind(preferencesController),
+ addToken: nodeify(preferencesController.addToken).bind(preferencesController),
setCurrentAccountTab: nodeify(preferencesController.setCurrentAccountTab).bind(preferencesController),
setDefaultRpc: nodeify(this.setDefaultRpc).bind(this),
setCustomRpc: nodeify(this.setCustomRpc).bind(this),
diff --git a/ui/app/account-detail.js b/ui/app/account-detail.js
index 836032b3c..bed05a7fb 100644
--- a/ui/app/account-detail.js
+++ b/ui/app/account-detail.js
@@ -35,6 +35,7 @@ function mapStateToProps (state) {
conversionRate: state.metamask.conversionRate,
currentCurrency: state.metamask.currentCurrency,
currentAccountTab: state.metamask.currentAccountTab,
+ tokens: state.metamask.tokens,
}
}
@@ -273,11 +274,16 @@ AccountDetailScreen.prototype.tabSections = function () {
AccountDetailScreen.prototype.tabSwitchView = function () {
const props = this.props
const { address, network } = props
- const { currentAccountTab } = this.props
+ const { currentAccountTab, tokens } = this.props
switch (currentAccountTab) {
case 'tokens':
- return h(TokenList, { userAddress: address, network })
+ return h(TokenList, {
+ userAddress: address,
+ network,
+ tokens,
+ addToken: () => this.props.dispatch(actions.showAddTokenPage()),
+ })
default:
return this.transactionList()
}
diff --git a/ui/app/actions.js b/ui/app/actions.js
index b6b5d6eb1..6ff28f32f 100644
--- a/ui/app/actions.js
+++ b/ui/app/actions.js
@@ -121,7 +121,10 @@ var actions = {
SET_PROVIDER_TYPE: 'SET_PROVIDER_TYPE',
USE_ETHERSCAN_PROVIDER: 'USE_ETHERSCAN_PROVIDER',
useEtherscanProvider: useEtherscanProvider,
- showConfigPage: showConfigPage,
+ showConfigPage,
+ SHOW_ADD_TOKEN_PAGE: 'SHOW_ADD_TOKEN_PAGE',
+ showAddTokenPage,
+ addToken,
setRpcTarget: setRpcTarget,
setDefaultRpcTarget: setDefaultRpcTarget,
setProviderType: setProviderType,
@@ -627,6 +630,26 @@ function showConfigPage (transitionForward = true) {
}
}
+function showAddTokenPage (transitionForward = true) {
+ return {
+ type: actions.SHOW_ADD_TOKEN_PAGE,
+ value: transitionForward,
+ }
+}
+
+function addToken (address, symbol, decimals) {
+ return (dispatch) => {
+ dispatch(actions.showLoadingIndication())
+ background.addToken(address, symbol, decimals, (err) => {
+ dispatch(actions.hideLoadingIndication())
+ if (err) {
+ return dispatch(actions.displayWarning(err.message))
+ }
+ dispatch(actions.goHome())
+ })
+ }
+}
+
function goBackToInitView () {
return {
type: actions.BACK_TO_INIT_MENU,
diff --git a/ui/app/add-token.js b/ui/app/add-token.js
new file mode 100644
index 000000000..025cfacb5
--- /dev/null
+++ b/ui/app/add-token.js
@@ -0,0 +1,218 @@
+const inherits = require('util').inherits
+const Component = require('react').Component
+const h = require('react-hyperscript')
+const connect = require('react-redux').connect
+const actions = require('./actions')
+
+const ethUtil = require('ethereumjs-util')
+const abi = require('human-standard-token-abi')
+const Eth = require('ethjs-query')
+const EthContract = require('ethjs-contract')
+
+const emptyAddr = '0x0000000000000000000000000000000000000000'
+
+module.exports = connect(mapStateToProps)(AddTokenScreen)
+
+function mapStateToProps (state) {
+ return {
+ }
+}
+
+inherits(AddTokenScreen, Component)
+function AddTokenScreen () {
+ this.state = {
+ warning: null,
+ address: null,
+ symbol: 'TOKEN',
+ decimals: 18,
+ }
+ Component.call(this)
+}
+
+AddTokenScreen.prototype.render = function () {
+ const state = this.state
+ 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) => {
+ state.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('span', {
+ style: { fontWeight: 'bold', paddingRight: '10px'},
+ }, 'Token Address'),
+ ]),
+
+ h('section.flex-row.flex-center', [
+ h('input#token-address', {
+ name: 'address',
+ placeholder: 'Token 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 Sybmol'),
+ ]),
+
+ 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
+
+ const { address, symbol, decimals } = this.state
+ this.props.dispatch(actions.addToken(address.trim(), symbol.trim(), decimals))
+ },
+ }, 'Add'),
+ ]),
+ ]),
+ ])
+ )
+}
+
+AddTokenScreen.prototype.componentWillMount = function () {
+ if (typeof global.ethereumProvider === 'undefined') return
+
+ this.eth = new Eth(global.ethereumProvider)
+ this.contract = new EthContract(this.eth)
+ this.TokenContract = this.contract(abi)
+}
+
+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.validateInputs = function () {
+ let msg = ''
+ const state = this.state
+ const { address, symbol, decimals } = state
+
+ const validAddress = ethUtil.isValidAddress(address)
+ if (!validAddress) {
+ msg += 'Address is invalid. '
+ }
+
+ const validDecimals = decimals >= 0 && decimals < 36
+ if (!validDecimals) {
+ msg += 'Decimals must be at least 0, and not over 36. '
+ }
+
+ const symbolLen = symbol.trim().length
+ const validSymbol = symbolLen > 0 && symbolLen < 10
+ if (!validSymbol) {
+ msg += 'Symbol must be between 0 and 10 characters.'
+ }
+
+ const isValid = validAddress && validDecimals
+
+ if (!isValid) {
+ this.setState({
+ warning: msg,
+ })
+ } else {
+ this.setState({ warning: null })
+ }
+
+ return isValid
+}
+
+AddTokenScreen.prototype.attemptToAutoFillTokenParams = async function (address) {
+ const contract = this.TokenContract.at(address)
+
+ const results = await Promise.all([
+ contract.symbol(),
+ contract.decimals(),
+ ])
+
+ const [ symbol, decimals ] = results
+ if (symbol && decimals) {
+ console.log('SETTING SYMBOL AND DECIMALS', { symbol, decimals })
+ this.setState({ symbol: symbol[0], decimals: decimals[0].toString() })
+ }
+}
+
diff --git a/ui/app/app.js b/ui/app/app.js
index d444a8349..8bf69b5ad 100644
--- a/ui/app/app.js
+++ b/ui/app/app.js
@@ -19,6 +19,7 @@ const NoticeScreen = require('./components/notice')
const generateLostAccountsNotice = require('../lib/lost-accounts-notice')
// other views
const ConfigScreen = require('./config')
+const AddTokenScreen = require('./add-token')
const Import = require('./accounts/import')
const InfoScreen = require('./info')
const Loading = require('./components/loading')
@@ -458,6 +459,10 @@ App.prototype.renderPrimary = function () {
log.debug('rendering confirm tx screen')
return h(ConfirmTxScreen, {key: 'confirm-tx'})
+ case 'add-token':
+ log.debug('rendering add-token screen from unlock screen.')
+ return h(AddTokenScreen, {key: 'add-token'})
+
case 'config':
log.debug('rendering config screen')
return h(ConfigScreen, {key: 'config'})
diff --git a/ui/app/components/token-list.js b/ui/app/components/token-list.js
index 633d3ccfe..ac7ab8309 100644
--- a/ui/app/components/token-list.js
+++ b/ui/app/components/token-list.js
@@ -4,13 +4,14 @@ const inherits = require('util').inherits
const TokenTracker = require('eth-token-tracker')
const TokenCell = require('./token-cell.js')
const contracts = require('eth-contract-metadata')
+const normalizeAddress = require('eth-sig-util').normalize
-const tokens = []
+const defaultTokens = []
for (const address in contracts) {
const contract = contracts[address]
if (contract.erc20) {
contract.address = address
- tokens.push(contract)
+ defaultTokens.push(contract)
}
}
@@ -18,15 +19,18 @@ module.exports = TokenList
inherits(TokenList, Component)
function TokenList () {
- this.state = { tokens, isLoading: true, network: null }
+ 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
+ const { userAddress, network } = this.props
if (isLoading) {
return this.message('Loading')
@@ -37,40 +41,65 @@ TokenList.prototype.render = function () {
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 (
+ return h('div', [
h('ol', {
style: {
- height: '302px',
+ height: '260px',
overflowY: 'auto',
+ display: 'flex',
+ flexDirection: 'column',
},
- }, [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;
- }
+ }, [
+ 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;
+ }
+
+ `),
+ ...tokenViews,
+ tokenViews.length ? null : this.message('No Tokens Found.'),
+ ]),
+ this.addTokenButtonElement(),
+ ])
+}
- `)].concat(tokenViews.length ? tokenViews : this.message('No Tokens Found.')))
- )
+TokenList.prototype.addTokenButtonElement = function () {
+ return h('div', [
+ h('div.footer.hover-white.pointer', {
+ key: 'reveal-account-bar',
+ onClick: () => {
+ this.props.addToken()
+ },
+ style: {
+ display: 'flex',
+ height: '40px',
+ padding: '10px',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ }, [
+ h('i.fa.fa-plus.fa-lg'),
+ ]),
+ ])
}
TokenList.prototype.message = function (body) {
@@ -101,7 +130,7 @@ TokenList.prototype.createFreshTokenTracker = function () {
this.tracker = new TokenTracker({
userAddress,
provider: global.ethereumProvider,
- tokens: tokens,
+ tokens: uniqueMergeTokens(defaultTokens, this.props.tokens),
pollingInterval: 8000,
})
@@ -135,8 +164,10 @@ TokenList.prototype.componentWillUpdate = function (nextProps) {
}
}
-TokenList.prototype.updateBalances = function (tokenData) {
- const heldTokens = tokenData.filter(token => token.balance !== '0' && token.string !== '0.000')
+TokenList.prototype.updateBalances = function (tokens) {
+ const heldTokens = tokens.filter(token => {
+ return token.balance !== '0' && token.string !== '0.000'
+ })
this.setState({ tokens: heldTokens, isLoading: false })
}
@@ -145,3 +176,16 @@ TokenList.prototype.componentWillUnmount = function () {
this.tracker.stop()
}
+function uniqueMergeTokens (tokensA, tokensB) {
+ const uniqueAddresses = []
+ const result = []
+ tokensA.concat(tokensB).forEach((token) => {
+ const normal = normalizeAddress(token.address)
+ if (!uniqueAddresses.includes(normal)) {
+ uniqueAddresses.push(normal)
+ result.push(token)
+ }
+ })
+ return result
+}
+
diff --git a/ui/app/reducers/app.js b/ui/app/reducers/app.js
index deacad0a7..2fcc9bfe0 100644
--- a/ui/app/reducers/app.js
+++ b/ui/app/reducers/app.js
@@ -103,7 +103,17 @@ function reduceApp (state, action) {
transForward: action.value,
})
+ case actions.SHOW_ADD_TOKEN_PAGE:
+ return extend(appState, {
+ currentView: {
+ name: 'add-token',
+ context: appState.currentView.context,
+ },
+ transForward: action.value,
+ })
+
case actions.SHOW_IMPORT_PAGE:
+
return extend(appState, {
currentView: {
name: 'import-menu',