From d814a45dffa6a872f6e336cad33ca41ffb102887 Mon Sep 17 00:00:00 2001 From: Dan Finlay Date: Wed, 13 Apr 2016 15:28:44 -0700 Subject: Moved UI into repo with its own dependency stack --- ui/.gitignore | 66 +++ ui/README.md | 14 + ui/app/account-detail.js | 154 +++++++ ui/app/accounts.js | 116 +++++ ui/app/actions.js | 418 ++++++++++++++++++ ui/app/app.js | 242 +++++++++++ ui/app/components/account-panel.js | 93 ++++ ui/app/components/mascot.js | 65 +++ ui/app/conf-tx.js | 140 +++++++ ui/app/config.js | 103 +++++ ui/app/css/debug.css | 21 + ui/app/css/fonts.css | 2 + ui/app/css/index.css | 489 ++++++++++++++++++++++ ui/app/css/lib.css | 143 +++++++ ui/app/css/reset.css | 48 +++ ui/app/css/transitions.css | 47 +++ ui/app/first-time/create-vault-complete.js | 57 +++ ui/app/first-time/create-vault.js | 123 ++++++ ui/app/first-time/init-menu.js | 123 ++++++ ui/app/first-time/restore-vault.js | 116 +++++ ui/app/img/identicon-tardigrade.png | Bin 0 -> 141119 bytes ui/app/img/identicon-walrus.png | Bin 0 -> 388973 bytes ui/app/info.js | 90 ++++ ui/app/loading.js | 51 +++ ui/app/reducers.js | 41 ++ ui/app/reducers/app.js | 281 +++++++++++++ ui/app/reducers/identities.js | 18 + ui/app/reducers/metamask.js | 73 ++++ ui/app/root.js | 24 ++ ui/app/send.js | 139 ++++++ ui/app/settings.js | 69 +++ ui/app/store.js | 19 + ui/app/template.js | 31 ++ ui/app/unlock.js | 101 +++++ ui/app/util.js | 102 +++++ ui/css.js | 26 ++ ui/design/1st_time_use.png | Bin 0 -> 937556 bytes ui/design/metamask_wfs_jan_13.pdf | Bin 0 -> 452413 bytes ui/design/metamask_wfs_jan_13.png | Bin 0 -> 419066 bytes ui/design/metamask_wfs_jan_18.pdf | Bin 0 -> 612778 bytes ui/example.js | 123 ++++++ ui/index.html | 38 ++ ui/index.js | 55 +++ ui/package.json | 58 +++ ui/test/setup.js | 8 + ui/test/unit/actions/config_test.js | 43 ++ ui/test/unit/actions/restore_vault_test.js | 54 +++ ui/test/unit/actions/set_selected_account_test.js | 28 ++ ui/test/unit/actions/tx_test.js | 168 ++++++++ ui/test/unit/actions/view_info_test.js | 23 + ui/test/unit/actions/warning_test.js | 24 ++ ui/test/unit/util_test.js | 102 +++++ 52 files changed, 4369 insertions(+) create mode 100644 ui/.gitignore create mode 100644 ui/README.md create mode 100644 ui/app/account-detail.js create mode 100644 ui/app/accounts.js create mode 100644 ui/app/actions.js create mode 100644 ui/app/app.js create mode 100644 ui/app/components/account-panel.js create mode 100644 ui/app/components/mascot.js create mode 100644 ui/app/conf-tx.js create mode 100644 ui/app/config.js create mode 100644 ui/app/css/debug.css create mode 100644 ui/app/css/fonts.css create mode 100644 ui/app/css/index.css create mode 100644 ui/app/css/lib.css create mode 100644 ui/app/css/reset.css create mode 100644 ui/app/css/transitions.css create mode 100644 ui/app/first-time/create-vault-complete.js create mode 100644 ui/app/first-time/create-vault.js create mode 100644 ui/app/first-time/init-menu.js create mode 100644 ui/app/first-time/restore-vault.js create mode 100644 ui/app/img/identicon-tardigrade.png create mode 100644 ui/app/img/identicon-walrus.png create mode 100644 ui/app/info.js create mode 100644 ui/app/loading.js create mode 100644 ui/app/reducers.js create mode 100644 ui/app/reducers/app.js create mode 100644 ui/app/reducers/identities.js create mode 100644 ui/app/reducers/metamask.js create mode 100644 ui/app/root.js create mode 100644 ui/app/send.js create mode 100644 ui/app/settings.js create mode 100644 ui/app/store.js create mode 100644 ui/app/template.js create mode 100644 ui/app/unlock.js create mode 100644 ui/app/util.js create mode 100644 ui/css.js create mode 100644 ui/design/1st_time_use.png create mode 100644 ui/design/metamask_wfs_jan_13.pdf create mode 100644 ui/design/metamask_wfs_jan_13.png create mode 100644 ui/design/metamask_wfs_jan_18.pdf create mode 100644 ui/example.js create mode 100644 ui/index.html create mode 100644 ui/index.js create mode 100644 ui/package.json create mode 100644 ui/test/setup.js create mode 100644 ui/test/unit/actions/config_test.js create mode 100644 ui/test/unit/actions/restore_vault_test.js create mode 100644 ui/test/unit/actions/set_selected_account_test.js create mode 100644 ui/test/unit/actions/tx_test.js create mode 100644 ui/test/unit/actions/view_info_test.js create mode 100644 ui/test/unit/actions/warning_test.js create mode 100644 ui/test/unit/util_test.js (limited to 'ui') diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 000000000..c6b1254b5 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,66 @@ + +# Created by https://www.gitignore.io/api/osx,node + +### OSX ### +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + + +### Node ### +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git +node_modules + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 000000000..fdac37f02 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,14 @@ +## Installation + +``` +git clone git@github.com:MetaMask/metamask-ui.git +cd metamask-ui +npm install +grunt dev +``` + +## Testing + +Requires `mocha` installed. Run `npm install -g mocha`. + +You can either run the test suite once with `npm test`, or you can reload on file changes, by running `mocha watch test/**/**`. diff --git a/ui/app/account-detail.js b/ui/app/account-detail.js new file mode 100644 index 000000000..6ed23d482 --- /dev/null +++ b/ui/app/account-detail.js @@ -0,0 +1,154 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const h = require('react-hyperscript') +const connect = require('react-redux').connect +const copyToClipboard = require('copy-to-clipboard') +const actions = require('./actions') +const AccountPanel = require('./components/account-panel') + +module.exports = connect(mapStateToProps)(AccountDetailScreen) + +function mapStateToProps(state) { + var accountDetail = state.appState.accountDetail + return { + identities: state.metamask.identities, + accounts: state.metamask.accounts, + address: state.appState.currentView.context, + accountDetail: accountDetail, + } +} + +inherits(AccountDetailScreen, Component) +function AccountDetailScreen() { + Component.call(this) +} + + +AccountDetailScreen.prototype.render = function() { + var state = this.props + var identity = state.identities[state.address] + var account = state.accounts[state.address] + var accountDetail = state.accountDetail + + return ( + + h('.account-detail-section.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: this.navigateToAccounts.bind(this), + }), + h('h2.page-subtitle', 'Account Detail'), + ]), + + // account summary, with embedded action buttons + h(AccountPanel, { + showFullAddress: true, + identity: identity, + account: account, + }, [ + h('.flex-row.flex-space-around', [ + // h('button', 'GET ETH'), DISABLED UNTIL WORKING + + h('button', { + onClick: () => { + copyToClipboard(identity.address) + }, + }, 'COPY ADDR'), + + h('button', { + onClick: () => { + this.props.dispatch(actions.showSendPage()) + }, + }, 'SEND'), + + h('button', { + onClick: () => { + this.requestAccountExport(identity.address) + }, + }, 'EXPORT'), + ]), + ]), + + this.exportedAccount(accountDetail), + + // transaction table + /* + h('section.flex-column', [ + h('span', 'your transaction history will go here.'), + ]), + */ + ]) + ) +} + +AccountDetailScreen.prototype.navigateToAccounts = function(event){ + event.stopPropagation() + this.props.dispatch(actions.showAccountsPage()) +} + +AccountDetailScreen.prototype.exportAccount = function(address) { + this.props.dispatch(actions.exportAccount(address)) +} + +AccountDetailScreen.prototype.requestAccountExport = function() { + this.props.dispatch(actions.requestExportAccount()) +} + +AccountDetailScreen.prototype.exportedAccount = function(accountDetail) { + if (!accountDetail) return + var accountExport = accountDetail.accountExport + + var notExporting = accountExport === 'none' + var exportRequested = accountExport === 'requested' + var accountExported = accountExport === 'completed' + + if (notExporting) return + + if (exportRequested) { + var warning = `Exporting your private key is very dangerous, + and you should only do it if you know what you're doing.` + var confirmation = `If you're absolutely sure, type "I understand" below and + hit Enter.` + return h('div', {}, [ + h('p.error', warning), + h('p', confirmation), + h('input#exportAccount', { + onKeyPress: this.onExportKeyPress.bind(this), + }) + ]) + } + + if (accountExported) { + return h('div.privateKey', { + + }, [ + h('label', 'Your private key (click to copy):'), + h('p.error.cursor-pointer', { + style: { + textOverflow: 'ellipsis', + overflow: 'hidden', + webkitUserSelect: 'text', + width: '100%', + }, + onClick: function(event) { + copyToClipboard(accountDetail.privateKey) + } + }, accountDetail.privateKey), + ]) + } +} + +AccountDetailScreen.prototype.onExportKeyPress = function(event) { + if (event.key !== 'Enter') return + event.preventDefault() + + var input = document.getElementById('exportAccount') + if (input.value === 'I understand') { + this.props.dispatch(actions.exportAccount(this.props.address)) + } else { + input.value = '' + input.placeholder = 'Please retype "I understand" exactly.' + } +} diff --git a/ui/app/accounts.js b/ui/app/accounts.js new file mode 100644 index 000000000..d35e80678 --- /dev/null +++ b/ui/app/accounts.js @@ -0,0 +1,116 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const h = require('react-hyperscript') +const connect = require('react-redux').connect +const extend = require('xtend') +const actions = require('./actions') +const AccountPanel = require('./components/account-panel') +const valuesFor = require('./util').valuesFor + +module.exports = connect(mapStateToProps)(AccountsScreen) + + +function mapStateToProps(state) { + return { + accounts: state.metamask.accounts, + identities: state.metamask.identities, + unconfTxs: state.metamask.unconfTxs, + selectedAddress: state.metamask.selectedAddress, + currentDomain: state.appState.currentDomain, + } +} + +inherits(AccountsScreen, Component) +function AccountsScreen() { + Component.call(this) +} + + +AccountsScreen.prototype.render = function() { + var state = this.props + var identityList = valuesFor(state.identities) + var unconfTxList = valuesFor(state.unconfTxs) + var actions = { + onSelect: this.onSelect.bind(this), + onShowDetail: this.onShowDetail.bind(this), + } + return ( + + h('.accounts-section.flex-column.flex-grow', [ + + // subtitle and nav + h('.section-title.flex-column.flex-center', [ + h('h2.page-subtitle', 'Accounts'), + ]), + + // current domain + /* AUDIT + * Temporarily removed + * since accounts are currently injected + * regardless of the current domain. + */ + h('.current-domain-panel.flex-center.font-small', [ + h('spam', 'Selected address is visible to all sites you visit.'), + // h('span', state.currentDomain), + ]), + + // identity selection + h('section.identity-section.flex-column', { + style: { + maxHeight: '290px', + overflowY: 'auto', + overflowX: 'hidden', + } + }, + identityList.map(renderAccountPanel) + ), + + unconfTxList.length ? ( + + h('.unconftx-link.flex-row.flex-center', { + onClick: this.navigateToConfTx.bind(this), + }, [ + h('span', 'Unconfirmed Txs'), + h('i.fa.fa-arrow-right.fa-lg'), + ]) + + ) : ( + null + ), + + + ]) + + ) + + function renderAccountPanel(identity){ + var mayBeFauceting = identity.mayBeFauceting + var isSelected = state.selectedAddress === identity.address + var account = state.accounts[identity.address] + var isFauceting = mayBeFauceting && account.balance === '0x0' + var componentState = extend(actions, { + identity: identity, + account: account, + isSelected: isSelected, + isFauceting: isFauceting, + }) + return h(AccountPanel, componentState) + } +} + +AccountsScreen.prototype.navigateToConfTx = function(){ + event.stopPropagation() + this.props.dispatch(actions.showConfTxPage()) +} + +AccountsScreen.prototype.onSelect = function(address, event){ + event.stopPropagation() + // if already selected, deselect + if (this.props.selectedAddress === address) address = null + this.props.dispatch(actions.setSelectedAddress(address)) +} + +AccountsScreen.prototype.onShowDetail = function(address, event){ + event.stopPropagation() + this.props.dispatch(actions.showAccountDetail(address)) +} diff --git a/ui/app/actions.js b/ui/app/actions.js new file mode 100644 index 000000000..339c28be3 --- /dev/null +++ b/ui/app/actions.js @@ -0,0 +1,418 @@ +var actions = { + // remote state + UPDATE_METAMASK_STATE: 'UPDATE_METAMASK_STATE', + updateMetamaskState: updateMetamaskState, + // intialize screen + CREATE_NEW_VAULT_IN_PROGRESS: 'CREATE_NEW_VAULT_IN_PROGRESS', + SHOW_CREATE_VAULT: 'SHOW_CREATE_VAULT', + SHOW_RESTORE_VAULT: 'SHOW_RESTORE_VAULT', + SHOW_INIT_MENU: 'SHOW_INIT_MENU', + SHOW_NEW_VAULT_SEED: 'SHOW_NEW_VAULT_SEED', + SHOW_INFO_PAGE: 'SHOW_INFO_PAGE', + RECOVER_FROM_SEED: 'RECOVER_FROM_SEED', + CLEAR_SEED_WORD_CACHE: 'CLEAR_SEED_WORD_CACHE', + clearSeedWordCache: clearSeedWordCache, + recoverFromSeed: recoverFromSeed, + unlockMetamask: unlockMetamask, + unlockFailed: unlockFailed, + showCreateVault: showCreateVault, + showRestoreVault: showRestoreVault, + showInitializeMenu: showInitializeMenu, + createNewVault: createNewVault, + createNewVaultInProgress: createNewVaultInProgress, + showNewVaultSeed: showNewVaultSeed, + showInfoPage: showInfoPage, + // unlock screen + UNLOCK_IN_PROGRESS: 'UNLOCK_IN_PROGRESS', + UNLOCK_FAILED: 'UNLOCK_FAILED', + UNLOCK_METAMASK: 'UNLOCK_METAMASK', + LOCK_METAMASK: 'LOCK_METAMASK', + tryUnlockMetamask: tryUnlockMetamask, + lockMetamask: lockMetamask, + unlockInProgress: unlockInProgress, + // error handling + displayWarning: displayWarning, + DISPLAY_WARNING: 'DISPLAY_WARNING', + HIDE_WARNING: 'HIDE_WARNING', + hideWarning: hideWarning, + // accounts screen + SET_SELECTED_ACCOUNT: 'SET_SELECTED_ACCOUNT', + SHOW_ACCOUNT_DETAIL: 'SHOW_ACCOUNT_DETAIL', + SHOW_ACCOUNTS_PAGE: 'SHOW_ACCOUNTS_PAGE', + SHOW_CONF_TX_PAGE: 'SHOW_CONF_TX_PAGE', + // account detail screen + SHOW_SEND_PAGE: 'SHOW_SEND_PAGE', + showSendPage: showSendPage, + REQUEST_ACCOUNT_EXPORT: 'REQUEST_ACCOUNT_EXPORT', + requestExportAccount: requestExportAccount, + EXPORT_ACCOUNT: 'EXPORT_ACCOUNT', + exportAccount: exportAccount, + SHOW_PRIVATE_KEY: 'SHOW_PRIVATE_KEY', + showPrivateKey: showPrivateKey, + // tx conf screen + COMPLETED_TX: 'COMPLETED_TX', + TRANSACTION_ERROR: 'TRANSACTION_ERROR', + NEXT_TX: 'NEXT_TX', + PREVIOUS_TX: 'PREV_TX', + setSelectedAddress: setSelectedAddress, + signTx: signTx, + sendTx: sendTx, + cancelTx: cancelTx, + completedTx: completedTx, + txError: txError, + nextTx: nextTx, + previousTx: previousTx, + // app messages + showAccountDetail: showAccountDetail, + BACK_TO_ACCOUNT_DETAIL: 'BACK_TO_ACCOUNT_DETAIL', + backToAccountDetail: backToAccountDetail, + showAccountsPage: showAccountsPage, + showConfTxPage: showConfTxPage, + confirmSeedWords: confirmSeedWords, + // config screen + SHOW_CONFIG_PAGE: 'SHOW_CONFIG_PAGE', + SET_RPC_TARGET: 'SET_RPC_TARGET', + USE_ETHERSCAN_PROVIDER: 'USE_ETHERSCAN_PROVIDER', + useEtherscanProvider: useEtherscanProvider, + showConfigPage: showConfigPage, + setRpcTarget: setRpcTarget, + // hacky - need a way to get a reference to account manager + _setAccountManager: _setAccountManager, + // loading overlay + SHOW_LOADING: 'SHOW_LOADING_INDICATION', + HIDE_LOADING: 'HIDE_LOADING_INDICATION', + showLoadingIndication: showLoadingIndication, + hideLoadingIndication: hideLoadingIndication, +} + +module.exports = actions + + +var _accountManager = null +function _setAccountManager(accountManager){ + _accountManager = accountManager +} + +// async actions + +function tryUnlockMetamask(password) { + return (dispatch) => { + dispatch(this.unlockInProgress()) + _accountManager.submitPassword(password, (err) => { + dispatch(this.hideLoadingIndication()) + if (err) { + dispatch(this.unlockFailed()) + } else { + dispatch(this.unlockMetamask()) + dispatch(this.setSelectedAddress()) + } + }) + } +} + +function createNewVault(password, entropy) { + return (dispatch) => { + dispatch(this.createNewVaultInProgress()) + _accountManager.createNewVault(password, entropy, (err, result) => { + dispatch(this.showNewVaultSeed(result)) + }) + } +} + +function recoverFromSeed(password, seed) { + return (dispatch) => { + // dispatch(this.createNewVaultInProgress()) + dispatch(this.showLoadingIndication()) + _accountManager.recoverFromSeed(password, seed, (err, result) => { + if (err) { + dispatch(this.hideLoadingIndication()) + var message = err.message + return dispatch(this.displayWarning(err.message)) + } + + dispatch(this.unlockMetamask()) + dispatch(this.setSelectedAddress()) + dispatch(this.updateMetamaskState(result)) + dispatch(this.hideLoadingIndication()) + dispatch(this.showAccountsPage()) + }) + } +} + +function showInfoPage() { + return { + type: this.SHOW_INFO_PAGE, + } +} + +function setSelectedAddress(address) { + return (dispatch) => { + _accountManager.setSelectedAddress(address) + } +} + +function signTx(txData) { + return (dispatch) => { + dispatch(this.showLoadingIndication()) + + web3.eth.sendTransaction(txData, (err, data) => { + dispatch(this.hideLoadingIndication()) + + if (err) return dispatch(this.displayWarning(err.message)) + dispatch(this.hideWarning()) + dispatch(this.showAccountsPage()) + }) + } +} + +function sendTx(txData) { + return (dispatch) => { + _accountManager.approveTransaction(txData.id, (err) => { + if (err) { + alert(err.message) + dispatch(this.txError(err)) + return console.error(err.message) + } + dispatch(this.completedTx(txData.id)) + }) + } +} + +function completedTx(id) { + return { + type: this.COMPLETED_TX, + id, + } +} + +function txError(err) { + return { + type: this.TRANSACTION_ERROR, + message: err.message, + } +} + +function cancelTx(txData){ + return (dispatch) => { + _accountManager.cancelTransaction(txData.id) + dispatch(this.showAccountsPage()) + } +} + +// +// initialize screen +// + + +function showCreateVault() { + return { + type: this.SHOW_CREATE_VAULT, + } +} + +function showRestoreVault() { + return { + type: this.SHOW_RESTORE_VAULT, + } +} + +function showInitializeMenu() { + return { + type: this.SHOW_INIT_MENU, + } +} + +function createNewVaultInProgress() { + return { + type: this.CREATE_NEW_VAULT_IN_PROGRESS, + } +} + +function showNewVaultSeed(seed) { + return { + type: this.SHOW_NEW_VAULT_SEED, + value: seed, + } +} + +// +// unlock screen +// + +function unlockInProgress() { + return { + type: this.UNLOCK_IN_PROGRESS, + } +} + +function unlockFailed() { + return { + type: this.UNLOCK_FAILED, + } +} + +function unlockMetamask() { + return { + type: this.UNLOCK_METAMASK, + } +} + +function updateMetamaskState(newState) { + return { + type: this.UPDATE_METAMASK_STATE, + value: newState, + } +} + +function lockMetamask() { + return (dispatch) => { + _accountManager.setLocked((err) => { + dispatch({ + type: this.LOCK_METAMASK, + }) + dispatch(this.hideLoadingIndication()) + }) + } +} + +function showAccountDetail(address) { + return { + type: this.SHOW_ACCOUNT_DETAIL, + value: address, + } +} + +function backToAccountDetail(address) { + return { + type: this.BACK_TO_ACCOUNT_DETAIL, + value: address, + } +} +function clearSeedWordCache() { + return { + type: this.CLEAR_SEED_WORD_CACHE + } +} + +function confirmSeedWords() { + return (dispatch) => { + dispatch(this.showLoadingIndication()) + _accountManager.clearSeedWordCache((err) => { + dispatch(this.clearSeedWordCache()) + console.log('Seed word cache cleared.') + dispatch(this.setSelectedAddress()) + }) + } +} + +function showAccountsPage() { + return { + type: this.SHOW_ACCOUNTS_PAGE, + } +} + +function showConfTxPage() { + return { + type: this.SHOW_CONF_TX_PAGE, + } +} + +function nextTx() { + return { + type: this.NEXT_TX, + } +} + +function previousTx() { + return { + type: this.PREVIOUS_TX, + } +} + +function showConfigPage() { + return { + type: this.SHOW_CONFIG_PAGE, + } +} + +// +// config +// + +function setRpcTarget(newRpc) { + _accountManager.setRpcTarget(newRpc) + return { + type: this.SET_RPC_TARGET, + value: newRpc, + } +} + +function useEtherscanProvider() { + _accountManager.useEtherscanProvider() + return { + type: this.USE_ETHERSCAN_PROVIDER, + } +} + +function showLoadingIndication() { + return { + type: this.SHOW_LOADING, + } +} + +function hideLoadingIndication() { + return { + type: this.HIDE_LOADING, + } +} + +function displayWarning(text) { + return { + type: this.DISPLAY_WARNING, + value: text, + } +} + +function hideWarning() { + return { + type: this.HIDE_WARNING, + } +} + +function requestExportAccount() { + return { + type: this.REQUEST_ACCOUNT_EXPORT, + } +} + +function exportAccount(address) { + var self = this + + return function(dispatch) { + dispatch(self.showLoadingIndication()) + + _accountManager.exportAccount(address, function(err, result) { + dispatch(self.hideLoadingIndication()) + + if (err) { + console.error(err) + return dispatch(self.displayWarning('Had a problem exporting the account.')) + } + + dispatch(self.showPrivateKey(result)) + }) + } +} + +function showPrivateKey(key) { + return { + type: this.SHOW_PRIVATE_KEY, + value: key, + } +} + +function showSendPage() { + return { + type: this.SHOW_SEND_PAGE, + } +} diff --git a/ui/app/app.js b/ui/app/app.js new file mode 100644 index 000000000..fa375fb7f --- /dev/null +++ b/ui/app/app.js @@ -0,0 +1,242 @@ +const inherits = require('util').inherits +const React = require('react') +const Component = require('react').Component +const PropTypes = require('react').PropTypes +const connect = require('react-redux').connect +const h = require('react-hyperscript') +const extend = require('xtend') +const actions = require('./actions') +const ReactCSSTransitionGroup = require('react-addons-css-transition-group') +// init +const InitializeMenuScreen = require('./first-time/init-menu') +const CreateVaultScreen = require('./first-time/create-vault') +const CreateVaultCompleteScreen = require('./first-time/create-vault-complete') +const RestoreVaultScreen = require('./first-time/restore-vault') +// unlock +const UnlockScreen = require('./unlock') +// accounts +const AccountsScreen = require('./accounts') +const AccountDetailScreen = require('./account-detail') +const SendTransactionScreen = require('./send') +const ConfirmTxScreen = require('./conf-tx') +// other views +const ConfigScreen = require('./config') +const InfoScreen = require('./info') +const LoadingIndicator = require('./loading') + +module.exports = connect(mapStateToProps)(App) + + +inherits(App, Component) +function App() { Component.call(this) } + +function mapStateToProps(state) { + return { + // state from plugin + isInitialized: state.metamask.isInitialized, + isUnlocked: state.metamask.isUnlocked, + currentView: state.appState.currentView, + activeAddress: state.appState.activeAddress, + transForward: state.appState.transForward, + seedWords: state.metamask.seedWords, + } +} + +App.prototype.render = function() { + // const { selectedReddit, posts, isFetching, lastUpdated } = this.props + var state = this.props + var view = state.currentView.name + var transForward = state.transForward + var shouldHaveFooter = true + switch (view) { + case 'restoreVault': + shouldHaveFooter = false; + case 'createVault': + shouldHaveFooter = false; + case 'createVaultComplete': + shouldHaveFooter = false; + } + + return ( + + h('.flex-column.flex-grow.full-height', { + style: { + // Windows was showing a vertical scroll bar: + overflowY: 'hidden', + } + }, + [ + + h(LoadingIndicator), + + // top row + h('.app-header.flex-column.flex-center', { + }, [ + h('h1', 'MetaMask'), + ]), + + // panel content + h('.app-primary.flex-grow' + (transForward ? '.from-right' : '.from-left'), { + style: { + height: '380px', + } + }, [ + h(ReactCSSTransitionGroup, { + transitionName: "main", + transitionEnterTimeout: 300, + transitionLeaveTimeout: 300, + }, [ + this.renderPrimary(), + ]), + ]), + + // footer + h('.app-footer.flex-row.flex-space-around', { + style: { + display: shouldHaveFooter ? 'flex' : 'none', + alignItems: 'center', + height: '56px', + } + }, [ + + // settings icon + h('i.fa.fa-cog.fa-lg' + (view === 'config' ? '.active' : '.cursor-pointer'), { + style: { + opacity: state.isUnlocked ? '1.0' : '0.0', + transition: 'opacity 200ms ease-in', + //transform: `translateX(${state.isUnlocked ? '0px' : '-100px'})`, + }, + onClick: function(ev) { + state.dispatch(actions.showConfigPage()) + }, + }), + + // toggle + onOffToggle({ + toggleMetamaskActive: this.toggleMetamaskActive.bind(this), + isUnlocked: state.isUnlocked, + }), + + // help + h('i.fa.fa-question.fa-lg.cursor-pointer', { + style: { + opacity: state.isUnlocked ? '1.0' : '0.0', + }, + onClick() { state.dispatch(actions.showInfoPage()) } + }), + ]), + ]) + ) +} + +App.prototype.toggleMetamaskActive = function(){ + if (!this.props.isUnlocked) { + // currently inactive: redirect to password box + var passwordBox = document.querySelector('input[type=password]') + if (!passwordBox) return + passwordBox.focus() + } else { + // currently active: deactivate + this.props.dispatch(actions.lockMetamask(false)) + } +} + +App.prototype.renderPrimary = function(state){ + var state = this.props + + // If seed words haven't been dismissed yet, show them still. + /* + if (state.seedWords) { + return h(CreateVaultCompleteScreen, {key: 'createVaultComplete'}) + } + */ + + // show initialize screen + if (!state.isInitialized) { + + // show current view + switch (state.currentView.name) { + + case 'createVault': + return h(CreateVaultScreen, {key: 'createVault'}) + + case 'restoreVault': + return h(RestoreVaultScreen, {key: 'restoreVault'}) + + default: + return h(InitializeMenuScreen, {key: 'menuScreenInit'}) + + } + } + + // show unlock screen + if (!state.isUnlocked) { + return h(UnlockScreen, {key: 'locked'}) + } + + // show current view + switch (state.currentView.name) { + + case 'createVaultComplete': + return h(CreateVaultCompleteScreen, {key: 'created-vault'}) + + case 'accounts': + return h(AccountsScreen, {key: 'accounts'}) + + case 'accountDetail': + return h(AccountDetailScreen, {key: 'account-detail'}) + + case 'sendTransaction': + return h(SendTransactionScreen, {key: 'send-transaction'}) + + case 'confTx': + return h(ConfirmTxScreen, {key: 'confirm-tx'}) + + case 'config': + return h(ConfigScreen, {key: 'config'}) + + case 'info': + return h(InfoScreen, {key: 'info'}) + + case 'createVault': + return h(CreateVaultScreen, {key: 'createVault'}) + + default: + return h(AccountsScreen, {key: 'accounts'}) + } +} + +function onOffToggle(state){ + var buttonSize = '50px'; + var lockWidth = '20px'; + return ( + h('.app-toggle.flex-row.flex-center.lock' + (state.isUnlocked ? '.unlocked' : '.locked'), { + width: buttonSize, + height: buttonSize, + }, [ + h('div', { + onClick: state.toggleMetamaskActive, + style: { + width: lockWidth, + height: '' + parseInt(lockWidth) * 1.5 + 'px', + position: 'relative', + } + }, [ + h('img.lock-top', { + src: 'images/lock-top.png', + style: { + width: lockWidth, + position: 'absolute', + } + }), + h('img', { + src: 'images/lock-base.png', + style: { + width: lockWidth, + position: 'absolute', + } + }), + ]) + ]) + ) +} diff --git a/ui/app/components/account-panel.js b/ui/app/components/account-panel.js new file mode 100644 index 000000000..4e433b87d --- /dev/null +++ b/ui/app/components/account-panel.js @@ -0,0 +1,93 @@ +const inherits = require('util').inherits +const ethUtil = require('ethereumjs-util') +const Component = require('react').Component +const h = require('react-hyperscript') +const addressSummary = require('../util').addressSummary +const formatBalance = require('../util').formatBalance + +module.exports = AccountPanel + + +inherits(AccountPanel, Component) +function AccountPanel() { + Component.call(this) +} + +AccountPanel.prototype.render = function() { + var state = this.props + var identity = state.identity || {} + var account = state.account || {} + var isFauceting = state.isFauceting + + return ( + + h('.identity-panel.flex-row.flex-space-between'+(state.isSelected?'.selected':''), { + style: { + flex: '1 0 auto', + }, + onClick: state.onSelect && state.onSelect.bind(null, identity.address), + }, [ + + // account identicon + h('.identicon-wrapper.flex-column.select-none', [ + h('.identicon', { + style: { backgroundImage: 'url("https://ipfs.io/ipfs/'+identity.img+'")' } + }), + h('span.font-small', identity.name), + ]), + + // account address, balance + h('.identity-data.flex-column.flex-justify-center.flex-grow.select-none', [ + + h('.flex-row.flex-space-between', [ + h('label.font-small', 'ADDRESS'), + h('span.font-small', addressSummary(identity.address)), + ]), + + balanceOrFaucetingIndication(account, isFauceting), + + // outlet for inserting additional stuff + state.children, + + ]), + + // navigate to account detail + !state.onShowDetail ? null : + h('.arrow-right.cursor-pointer', { + onClick: state.onShowDetail && state.onShowDetail.bind(null, identity.address), + }, [ + h('i.fa.fa-chevron-right.fa-lg'), + ]), + ]) + ) +} + +function balanceOrFaucetingIndication(account, isFauceting) { + + // Temporarily deactivating isFauceting indication + // because it shows fauceting for empty restored accounts. + if (/*isFauceting*/ false) { + + return h('.flex-row.flex-space-between', [ + h('span.font-small', { + }, [ + 'Account is auto-funding,', + h('br'), + 'please wait.' + ]), + ]) + + } else { + + return h('.flex-row.flex-space-between', [ + h('label.font-small', 'BALANCE'), + h('span.font-small', { + style: { + overflowX: 'hidden', + maxWidth: '136px', + } + }, formatBalance(account.balance)), + ]) + + } +} diff --git a/ui/app/components/mascot.js b/ui/app/components/mascot.js new file mode 100644 index 000000000..e043caca1 --- /dev/null +++ b/ui/app/components/mascot.js @@ -0,0 +1,65 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const h = require('react-hyperscript') +const metamaskLogo = require('metamask-logo') +const getCaretCoordinates = require('textarea-caret') +const debounce = require('debounce') + +module.exports = Mascot + + +inherits(Mascot, Component) +function Mascot() { + Component.call(this) + this.logo = metamaskLogo({ + followMouse: true, + pxNotRatio: true, + width: 200, + height: 200, + }) + if (!this.logo) return + this.refollowMouse = debounce(this.logo.setFollowMouse.bind(this.logo, true), 1000) + this.unfollowMouse = this.logo.setFollowMouse.bind(this.logo, false) +} + + +Mascot.prototype.render = function() { + // this is a bit hacky + // the event emitter is on `this.props` + // and we dont get that until render + this.handleAnimationEvents() + + return ( + + h('#metamask-mascot-container') + + ) +} + +Mascot.prototype.componentDidMount = function() { + if (!this.logo) return + var targetDivId = 'metamask-mascot-container' + var container = document.getElementById(targetDivId) + container.appendChild(this.logo.canvas) +} + +Mascot.prototype.componentWillUnmount = function() { + if (!this.logo) return + this.logo.canvas.remove() +} + +Mascot.prototype.handleAnimationEvents = function(){ + if (!this.logo) return + // only setup listeners once + if (this.animations) return + this.animations = this.props.animationEventEmitter + this.animations.on('point', this.lookAt.bind(this)) + this.animations.on('setFollowMouse', this.logo.setFollowMouse.bind(this.logo)) +} + +Mascot.prototype.lookAt = function(target){ + if (!this.logo) return + this.unfollowMouse() + this.logo.lookAt(target) + this.refollowMouse() +} diff --git a/ui/app/conf-tx.js b/ui/app/conf-tx.js new file mode 100644 index 000000000..983070013 --- /dev/null +++ b/ui/app/conf-tx.js @@ -0,0 +1,140 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const ReactCSSTransitionGroup = require('react-addons-css-transition-group') +const h = require('react-hyperscript') +const connect = require('react-redux').connect +const copyToClipboard = require('copy-to-clipboard') +const actions = require('./actions') +const AccountPanel = require('./components/account-panel') +const valuesFor = require('./util').valuesFor +const addressSummary = require('./util').addressSummary +const readableDate = require('./util').readableDate +const formatBalance = require('./util').formatBalance +const dataSize = require('./util').dataSize + +module.exports = connect(mapStateToProps)(ConfirmTxScreen) + +function mapStateToProps(state) { + return { + identities: state.metamask.identities, + accounts: state.metamask.accounts, + selectedAddress: state.metamask.selectedAddress, + unconfTxs: state.metamask.unconfTxs, + index: state.appState.currentView.context, + } +} + +inherits(ConfirmTxScreen, Component) +function ConfirmTxScreen() { + Component.call(this) +} + + +ConfirmTxScreen.prototype.render = function() { + var state = this.props + var unconfTxList = valuesFor(state.unconfTxs).sort(tx => tx.time) + var txData = unconfTxList[state.index] || {} + var txParams = txData.txParams || {} + var address = txParams.from || state.selectedAddress + var identity = state.identities[address] || { address: address } + var account = state.accounts[address] || { address: address } + + return ( + + h('.unconftx-section.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: this.navigateToAccounts.bind(this), + }), + h('h2.page-subtitle', 'Confirm Transaction'), + ]), + + h('h3', { + style: { + alignSelf: 'center', + display: unconfTxList.length > 1 ? 'block' : 'none', + }, + }, [ + h('i.fa.fa-arrow-left.fa-lg.cursor-pointer', { + style: { + display: state.index === 0 ? 'none' : 'inline-block', + }, + onClick: () => state.dispatch(actions.previousTx()), + }), + ` Transaction ${state.index + 1} of ${unconfTxList.length} `, + h('i.fa.fa-arrow-right.fa-lg.cursor-pointer', { + style: { + display: state.index + 1 === unconfTxList.length ? 'none' : 'inline-block', + }, + onClick: () => state.dispatch(actions.nextTx()), + }), + ]), + + h(ReactCSSTransitionGroup, { + transitionName: "main", + transitionEnterTimeout: 300, + transitionLeaveTimeout: 300, + }, [ + + h('.transaction', { + key: txData.id, + }, [ + + // account that will sign + h(AccountPanel, { + showFullAddress: true, + identity: identity, + account: account, + }), + + // tx data + h('.tx-data.flex-column.flex-justify-center.flex-grow.select-none', [ + + h('.flex-row.flex-space-between', [ + h('label.font-small', 'TO ADDRESS'), + h('span.font-small', addressSummary(txParams.to)), + ]), + + h('.flex-row.flex-space-between', [ + h('label.font-small', 'DATE'), + h('span.font-small', readableDate(txData.time)), + ]), + + h('.flex-row.flex-space-between', [ + h('label.font-small', 'AMOUNT'), + h('span.font-small', formatBalance(txParams.value)), + ]), + + ]), + + // send + cancel + h('.flex-row.flex-space-around', [ + h('button', { + onClick: this.cancelTransaction.bind(this, txData), + }, 'Cancel'), + h('button', { + onClick: this.sendTransaction.bind(this, txData), + }, 'Send'), + ]), + ]), + ]), + ]) // No comma or semicolon can go here + ) +} + +ConfirmTxScreen.prototype.sendTransaction = function(txData, event){ + event.stopPropagation() + this.props.dispatch(actions.sendTx(txData)) +} + +ConfirmTxScreen.prototype.cancelTransaction = function(txData, event){ + event.stopPropagation() + this.props.dispatch(actions.cancelTx(txData)) +} + +ConfirmTxScreen.prototype.navigateToAccounts = function(event){ + event.stopPropagation() + this.props.dispatch(actions.showAccountsPage()) +} diff --git a/ui/app/config.js b/ui/app/config.js new file mode 100644 index 000000000..33d87bcc2 --- /dev/null +++ b/ui/app/config.js @@ -0,0 +1,103 @@ +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') + +module.exports = connect(mapStateToProps)(ConfigScreen) + +function mapStateToProps(state) { + return { + rpc: state.metamask.rpcTarget, + metamask: state.metamask, + } +} + +inherits(ConfigScreen, Component) +function ConfigScreen() { + Component.call(this) +} + + +ConfigScreen.prototype.render = function() { + var state = this.props + var rpc = state.rpc + var metamaskState = state.metamask + + 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.showAccountsPage()) + } + }), + h('h2.page-subtitle', 'Configuration'), + ]), + + // conf view + h('.flex-column.flex-justify-center.flex-grow.select-none', [ + h('.flex-space-around', { + style: { + padding: '20px', + } + }, [ + + currentProviderDisplay(metamaskState), + + + h('div', [ + h('input', { + placeholder: 'New RPC URL', + style: { + width: '100%', + }, + onKeyPress(event) { + if (event.key === 'Enter') { + var element = event.target + var newRpc = element.value + state.dispatch(actions.setRpcTarget(newRpc)) + } + } + }), + ]), + + h('div', [ + h('button', { + style: { + alignSelf: 'center', + }, + onClick(event) { + event.preventDefault() + state.dispatch(actions.setRpcTarget('https://rpc.metamask.io/')) + } + }, 'Use Main Network') + ]), + + h('div', [ + h('button', { + style: { + alignSelf: 'center', + }, + onClick(event) { + event.preventDefault() + state.dispatch(actions.setRpcTarget('https://testrpc.metamask.io/')) + } + }, 'Use Morden Test Network') + ]), + + ]), + ]), + ]) + ) +} + +function currentProviderDisplay(metamaskState) { + var rpc = metamaskState.rpcTarget + return h('div', [ + h('h3', {style: { fontWeight: 'bold' }}, 'Currently using RPC'), + h('p', rpc) + ]) +} diff --git a/ui/app/css/debug.css b/ui/app/css/debug.css new file mode 100644 index 000000000..3e125bcd4 --- /dev/null +++ b/ui/app/css/debug.css @@ -0,0 +1,21 @@ +/* +debug / dev +*/ + +#app-content { + border: 2px solid green; +} + +#design-container { + position: absolute; + left: 360px; + top: -42px; + width: calc(100vw - 360px); + height: 100vh; + overflow: scroll; +} + +#design-container img { + width: 2000px; + margin-right: 600px; +} \ No newline at end of file diff --git a/ui/app/css/fonts.css b/ui/app/css/fonts.css new file mode 100644 index 000000000..dd1a755fb --- /dev/null +++ b/ui/app/css/fonts.css @@ -0,0 +1,2 @@ +@import url(https://fonts.googleapis.com/css?family=Roboto:300,500); +@import url(https://maxcdn.bootstrapcdn.com/font-awesome/4.4.0/css/font-awesome.min.css); \ No newline at end of file diff --git a/ui/app/css/index.css b/ui/app/css/index.css new file mode 100644 index 000000000..4871a650f --- /dev/null +++ b/ui/app/css/index.css @@ -0,0 +1,489 @@ +/* +faint orange (textfield shades) #FAF6F0 +light orange (button shades): #F5C26D +dark orange (text): #F5A623 +borders/font/any gray: #4A4A4A +*/ + +/* +application specific styles +*/ + +* { + box-sizing: border-box; +} + +html, body { + /*font-family: 'Open Sans', Arial, sans-serif;*/ + font-family: 'Roboto', 'Noto', sans-serif; + color: #4D4D4D; + font-weight: 300; + line-height: 1.4em; +} + +#app-content { + overflow-x: hidden; + min-width: 357px; + width: 360px; + height: 500px; +} + +button { + outline: none; + cursor: pointer; + margin: 10px; + padding: 6px; + border: none; + border-radius: 3px; + background: #F7861C; + font-weight: 500; + color: white; + transform-origin: center center; + transition: transform 50ms ease-in; +} +button:hover { + transform: scale(1.1); +} +button:active { + transform: scale(0.95); +} + +button.primary { + margin: 10px; + padding: 6px; + border: none; + border-radius: 3px; + background: #F7861C; + font-weight: 500; + color: white; +} + +input, textarea { + width: 300px; + padding: 6px; + border-radius: 6px; + border-style: solid; + outline: none; + border: 1px solid #F5A623; + background: #FAF6F0; +} + +a { + text-decoration: none; + color: inherit; +} + +a:hover{ + color: #df6b0e; +} + +/* +app +*/ + +.active { + color: #909090; +} + +button.btn-thin { + border: 1px solid; + border-color: #4D4D4D; + color: #4D4D4D; + background: rgb(255, 174, 41); + border-radius: 4px; + min-width: 200px; + margin: 12px 0; + padding: 6px; + font-size: 13px; +} + +.app-header { + padding-top: 20px; +} + +.app-header h1 { + font-size: 2em; + font-weight: 300; + height: 42px; +} + +h2.page-subtitle { + font-size: 1em; + font-weight: 500; + height: 24px; + color: #F3C83E; +} + +.app-primary { +} + +.app-footer { + padding-bottom: 10px; + align-items: center; +} + +.identicon { + height: 46px; + width: 46px; + background-size: cover; + border-radius: 100%; + border: 3px solid gray; +} + +textarea.twelve-word-phrase { + margin-top: 20px; + width: 300px; + height: 180px; + font-size: 16px; + background: #FAF6F0; + resize: none; +} + +/* +app sections +*/ + +/* initialize */ + +.initialize-screen hr { + width: 60px; + margin: 12px; + border-color: #F3C83E; + border-style: solid; +} + +.initialize-screen input[type="password"], .initialize-screen textarea { + width: 300px; + padding: 6px; + border-radius: 6px; + border-style: solid; + outline: none; + border: 1px solid #F5A623; + background: #FAF6F0; +} + +.initialize-screen label { + margin-top: 20px; +} + +.initialize-screen button.create-vault { + margin-top: 40px; +} + +.initialize-screen .warning { + font-size: 14px; + margin: 0 16px; +} + +/* unlock */ +.error { + color: #E20202; +} +.lock { + width: 50px; + height: 50px; +} + +.lock.locked { + transform: scale(1.5); + opacity: 0.0; + transition: opacity 400ms ease-in, transform 400ms ease-in; +} +.lock.unlocked { + transform: scale(1); + opacity: 1; + transition: opacity 500ms ease-out, transform 500ms ease-out, background 200ms ease-in; +} + +.lock.locked .lock-top { + transform: scaleX(1) translateX(0); + transition: transform 250ms ease-in; +} +.lock.unlocked .lock-top { + transform: scaleX(-1) translateX(-12px); + transition: transform 250ms ease-in; +} +.lock.unlocked:hover { + border-radius: 4px; + background: #e5e5e5; + border: 1px solid #b1b1b1; +} +.lock.unlocked:active { + background: #c3c3c3; +} + +.section-title .fa-arrow-left { + margin: -2px 8px 0px -8px; +} + +.unlock-screen label { + color: #F3C83E; + font-weight: 500; +} + +.unlock-screen input[type=password] { + width: 60%; + height: 22px; + padding: 2px; + border-radius: 4px; + border: 2px solid #F3C83E; + background: #FAF6F0; +} + +.unlock-screen input[type=password]:focus { + outline: none; + border: 3px solid #F3C83E; +} + +/* accounts */ + +.accounts-section { + margin: 0 20px; +} + +.current-domain-panel { + border: 1px solid #B7B7B7; +} + +.unconftx-link { + margin-top: 24px; + cursor: pointer; +} + +.unconftx-link .fa-arrow-right { + margin: 0px -8px 0px 8px; +} + +/* identity panel */ + +.identity-panel { + font-weight: 500; +} + +.identity-panel .identicon-wrapper { + margin: 4px; + margin-top: 8px; +} + +.identity-panel .identicon-wrapper span { + margin: 0 auto; +} + +.identity-panel .identity-data { + margin: 8px 8px 8px 18px; +} + +.identity-panel i { + margin-top: 32px; + margin-right: 6px; + color: #B9B9B9; +} + +.identity-panel .arrow-right { + padding-left: 18px; + width: 42px; + min-width: 18px; + height: 100%; +} + +/* accounts screen */ + +.identity-section { + border: 2px solid #4D4D4D; + margin: 0; +} + +.identity-section .identity-panel { + background: #E9E9E9; + border-bottom: 1px solid #B1B1B1; + cursor: pointer; +} +.identity-section .identity-panel:hover { + background: #F9F9F9; +} + +.identity-section .identity-panel.selected { + background: white; + color: #F3C83E; +} + +.identity-section .identity-panel.selected .identicon { + border-color: orange; +} + +/* account detail screen */ + +.account-detail-section { + margin: 0 20px; +} + +/* tx confirm */ + +.unconftx-section { + margin: 0 20px; +} + +.unconftx-section input[type=password] { + height: 22px; + padding: 2px; + margin: 12px; + margin-bottom: 24px; + border-radius: 4px; + border: 2px solid #F3C83E; + background: #FAF6F0; +} + + +/* +react toggle +*/ + +/* overrides */ + +.react-toggle-track-check { + display: none; +} +.react-toggle-track-x { + display: none; +} + +/* modified original */ + +.react-toggle { + display: inline-block; + position: relative; + cursor: pointer; + background-color: transparent; + border: 0; + padding: 0; + + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + + -webkit-tap-highlight-color: rgba(0,0,0,0); + -webkit-tap-highlight-color: transparent; +} + +.react-toggle-screenreader-only { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} + +.react-toggle--disabled { + opacity: 0.5; + -webkit-transition: opacity 0.25s; + transition: opacity 0.25s; +} + +.react-toggle-track { + width: 50px; + height: 24px; + padding: 0; + border-radius: 30px; + background-color: #4D4D4D; + -webkit-transition: all 0.2s ease; + -moz-transition: all 0.2s ease; + transition: all 0.2s ease; +} + +.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track { + background-color: #000000; +} + +.react-toggle--checked .react-toggle-track { + background-color: rgb(255, 174, 41); +} + +.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track { + background-color: rgb(243, 151, 0); +} + +.react-toggle-track-check { + position: absolute; + width: 14px; + height: 10px; + top: 0px; + bottom: 0px; + margin-top: auto; + margin-bottom: auto; + line-height: 0; + left: 8px; + opacity: 0; + -webkit-transition: opacity 0.25s ease; + -moz-transition: opacity 0.25s ease; + transition: opacity 0.25s ease; +} + +.react-toggle--checked .react-toggle-track-check { + opacity: 1; + -webkit-transition: opacity 0.25s ease; + -moz-transition: opacity 0.25s ease; + transition: opacity 0.25s ease; +} + +.react-toggle-track-x { + position: absolute; + width: 10px; + height: 10px; + top: 0px; + bottom: 0px; + margin-top: auto; + margin-bottom: auto; + line-height: 0; + right: 10px; + opacity: 1; + -webkit-transition: opacity 0.25s ease; + -moz-transition: opacity 0.25s ease; + transition: opacity 0.25s ease; +} + +.react-toggle--checked .react-toggle-track-x { + opacity: 0; +} + +.react-toggle-thumb { + transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms; + position: absolute; + top: 1px; + left: 1px; + width: 22px; + height: 22px; + border: 1px solid #4D4D4D; + border-radius: 50%; + background-color: #FAFAFA; + + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + + -webkit-transition: all 0.25s ease; + -moz-transition: all 0.25s ease; + transition: all 0.25s ease; +} + +.react-toggle--checked .react-toggle-thumb { + left: 27px; + border-color: #828282; +} +/* + .react-toggle--focus .react-toggle-thumb { + -webkit-box-shadow: 0px 0px 3px 2px #0099E0; + -moz-box-shadow: 0px 0px 3px 2px #0099E0; + box-shadow: 0px 0px 2px 3px #0099E0; + } + + .react-toggle:active .react-toggle-thumb { + -webkit-box-shadow: 0px 0px 5px 5px #0099E0; + -moz-box-shadow: 0px 0px 5px 5px #0099E0; + box-shadow: 0px 0px 5px 5px #0099E0; + } diff --git a/ui/app/css/lib.css b/ui/app/css/lib.css new file mode 100644 index 000000000..b6b26402b --- /dev/null +++ b/ui/app/css/lib.css @@ -0,0 +1,143 @@ +/* lib */ + +.full-width { + width: 100%; +} + +.full-height { + height: 100%; +} + +.flex-column { + display: flex; + flex-direction: column; +} + +.flex-column-bottom { + display: flex; + flex-direction: column-reverse; +} + +.flex-row { + display: flex; + flex-direction: row; +} + +.flex-space-between { + justify-content: space-between; +} + +.flex-space-around { + justify-content: space-around; +} + +.flex-right { + display: flex; + flex-direction: row; + justify-content: flex-end; +} + +.flex-left { + display: flex; + flex-direction: row; + justify-content: flex-start; +} + +.flex-fixed { + flex: none; +} + +.flex-grow { + flex: 1 1 auto; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.flex-center { + display: flex; + justify-content: center; + align-items: center; +} + +.flex-justify-center { + justify-content: center; +} + +.flex-align-center { + align-items: center; +} + +.flex-self-end { + align-self: flex-end; +} + +.flex-self-stretch { + align-self: stretch; +} + +.flex-vertical { + flex-direction: column; +} + +.z-bump { + z-index: 1; +} + +.select-none { + cursor: default; + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.cursor-pointer { + cursor: pointer; + transform-origin: center center; + transition: transform 50ms ease-in-out; +} +.cursor-pointer:hover { + transform: scale(1.1); +} +.cursor-pointer:active { + transform: scale(0.95); +} + +.margin-bottom-sml { + margin-bottom: 20px; +} + +.margin-bottom-med { + margin-bottom: 40px; +} + +.margin-right-left { + margin: 0 20px; +} + +.bold { + font-weight: bold; +} + +.font-small { + font-size: 12px; +} + +/* Send Screen */ +.send-screen { + margin: 0 20px; +} +.send-screen section { + margin: 7px; + display: flex; + flex-direction: row; + justify-content: center; +} +.send-screen details { + width: 100%; +} +.send-screen section input { + width: 100%; +} diff --git a/ui/app/css/reset.css b/ui/app/css/reset.css new file mode 100644 index 000000000..9ce89e8bc --- /dev/null +++ b/ui/app/css/reset.css @@ -0,0 +1,48 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} +body { + line-height: 1; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} \ No newline at end of file diff --git a/ui/app/css/transitions.css b/ui/app/css/transitions.css new file mode 100644 index 000000000..3d0bf46a1 --- /dev/null +++ b/ui/app/css/transitions.css @@ -0,0 +1,47 @@ +/* initial positions */ +.app-primary.from-right .main-enter { + transform: translateX(400px); + position: absolute; + width: 100%; + transition: transform 300ms ease-in-out; +} +.app-primary.from-left .main-enter { + transform: translateX(-400px); + position: absolute; + width: 100%; + transition: transform 300ms ease-in-out; +} + +/* center position */ +.app-primary .main-enter.main-enter-active, +.app-primary .main-leave { + transform: translateX(0px); + position: absolute; + width: 100%; + transition: transform 300ms ease-in-out; +} + +/* final positions */ +.app-primary.from-left .main-leave-active { + transform: translateX(400px); + position: absolute; + width: 100%; + transition: transform 300ms ease-in-out; +} +.app-primary.from-right .main-leave-active { + transform: translateX(-400px); + position: absolute; + width: 100%; + transition: transform 300ms ease-in-out; +} + +/* loader transitions */ +.loader-enter, .loader-leave-active { + opacity: 0.0; + transition: opacity 150 ease-in-out; +} +.loader-enter-active, .loader-leave { + opacity: 1.0; + transition: opacity 150 ease-in-out; +} + diff --git a/ui/app/first-time/create-vault-complete.js b/ui/app/first-time/create-vault-complete.js new file mode 100644 index 000000000..cd062effe --- /dev/null +++ b/ui/app/first-time/create-vault-complete.js @@ -0,0 +1,57 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const connect = require('react-redux').connect +const h = require('react-hyperscript') +const actions = require('../actions') + +module.exports = connect(mapStateToProps)(CreateVaultCompleteScreen) + + +inherits(CreateVaultCompleteScreen, Component) +function CreateVaultCompleteScreen() { + Component.call(this) +} + +function mapStateToProps(state) { + return { + seed: state.appState.currentView.context, + cachedSeed: state.metamask.seedWords, + } +} + +CreateVaultCompleteScreen.prototype.render = function() { + var state = this.props + var seed = state.seed || state.cachedSeed + + return ( + + h('.initialize-screen.flex-column.flex-center.flex-grow', [ + + // subtitle and nav + h('.section-title.flex-row.flex-center', [ + h('h2.page-subtitle', 'Vault Created'), + ]), + + h('span.error', { // Error for the right red + style: { + padding: '12px 20px 0px 20px', + textAlign: 'center', + } + }, 'These 12 words can restore all of your MetaMask accounts for this vault.\nSave them somewhere safe and secret.'), + + h('textarea.twelve-word-phrase', { + readOnly: true, + value: seed, + }), + + h('button.btn-thin', { + onClick: () => this.confirmSeedWords(), + }, 'I\'ve copied it somewhere safe.'), + ]) + ) +} + +CreateVaultCompleteScreen.prototype.confirmSeedWords = function() { + this.props.dispatch(actions.confirmSeedWords()) +} + diff --git a/ui/app/first-time/create-vault.js b/ui/app/first-time/create-vault.js new file mode 100644 index 000000000..d7bf9cd5d --- /dev/null +++ b/ui/app/first-time/create-vault.js @@ -0,0 +1,123 @@ +const inherits = require('util').inherits + +const Component = require('react').Component +const connect = require('react-redux').connect +const h = require('react-hyperscript') +const actions = require('../actions') + +module.exports = connect(mapStateToProps)(CreateVaultScreen) + + +inherits(CreateVaultScreen, Component) +function CreateVaultScreen() { + Component.call(this) +} + +function mapStateToProps(state) { + return { + warning: state.appState.warning, + } +} + +CreateVaultScreen.prototype.render = function() { + var state = this.props + return ( + + h('.initialize-screen.flex-column.flex-center.flex-grow', [ + + // subtitle and nav + h('.section-title.flex-row.flex-center', [ + h('i.fa.fa-arrow-left.fa-lg.cursor-pointer', { + onClick: this.showInitializeMenu.bind(this), + }), + h('h2.page-subtitle', 'Create Vault'), + ]), + + // password + h('label', { + htmlFor: 'password-box', + }, 'Enter Password (min 8 chars):'), + + h('input', { + type: 'password', + id: 'password-box', + }), + + // confirm password + h('label', { + htmlFor: 'password-box-confirm', + }, 'Confirm Password:'), + + h('input', { + type: 'password', + id: 'password-box-confirm', + onKeyPress: this.createVaultOnEnter.bind(this), + }), + + /* ENTROPY TEXT INPUT CURRENTLY DISABLED + // entropy + h('label', { + htmlFor: 'entropy-text-entry', + }, 'Enter random text (optional)'), + + h('textarea', { + id: 'entropy-text-entry', + style: { resize: 'none' }, + onKeyPress: this.createVaultOnEnter.bind(this), + }), + */ + + // submit + h('button.create-vault.btn-thin', { + onClick: this.createNewVault.bind(this), + }, 'OK'), + + (!state.inProgress && state.warning) && ( + h('span.in-progress-notification', state.warning) + + ), + + state.inProgress && ( + h('span.in-progress-notification', 'Generating Seed...') + ), + ]) + ) +} + +CreateVaultScreen.prototype.componentDidMount = function(){ + document.getElementById('password-box').focus() +} + +CreateVaultScreen.prototype.showInitializeMenu = function() { + this.props.dispatch(actions.showInitializeMenu()) +} + +// create vault + +CreateVaultScreen.prototype.createVaultOnEnter = function(event) { + if (event.key === 'Enter') { + event.preventDefault() + this.createNewVault() + } +} + +CreateVaultScreen.prototype.createNewVault = function(){ + var passwordBox = document.getElementById('password-box') + var password = passwordBox.value + var passwordConfirmBox = document.getElementById('password-box-confirm') + var passwordConfirm = passwordConfirmBox.value + // var entropy = document.getElementById('entropy-text-entry').value + + if (password.length < 8) { + this.warning = 'password not long enough' + this.props.dispatch(actions.displayWarning(this.warning)) + return + } + if (password !== passwordConfirm) { + this.warning = 'passwords dont match' + this.props.dispatch(actions.displayWarning(this.warning)) + return + } + + this.props.dispatch(actions.createNewVault(password, ''/*entropy*/)) +} diff --git a/ui/app/first-time/init-menu.js b/ui/app/first-time/init-menu.js new file mode 100644 index 000000000..11b01a88b --- /dev/null +++ b/ui/app/first-time/init-menu.js @@ -0,0 +1,123 @@ +const inherits = require('util').inherits +const EventEmitter = require('events').EventEmitter +const Component = require('react').Component +const connect = require('react-redux').connect +const h = require('react-hyperscript') +const getCaretCoordinates = require('textarea-caret') +const Mascot = require('../components/mascot') +const actions = require('../actions') +const CreateVaultScreen = require('./create-vault') +const CreateVaultCompleteScreen = require('./create-vault-complete') + +module.exports = connect(mapStateToProps)(InitializeMenuScreen) + +inherits(InitializeMenuScreen, Component) +function InitializeMenuScreen() { + Component.call(this) + this.animationEventEmitter = new EventEmitter() +} + +function mapStateToProps(state) { + return { + // state from plugin + currentView: state.appState.currentView, + } +} + +InitializeMenuScreen.prototype.render = function() { + var state = this.props + + switch (state.currentView.name) { + + case 'createVault': + return h(CreateVaultScreen) + + case 'createVaultComplete': + return h(CreateVaultCompleteScreen) + + case 'restoreVault': + return this.renderRestoreVault() + + default: + return this.renderMenu() + + } + +} + +// InitializeMenuScreen.prototype.componentDidMount = function(){ +// document.getElementById('password-box').focus() +// } + +InitializeMenuScreen.prototype.renderMenu = function() { + var state = this.props + return ( + + h('.initialize-screen.flex-column.flex-center.flex-grow', [ + + h('h2.page-subtitle', 'Welcome!'), + + h(Mascot, { + animationEventEmitter: this.animationEventEmitter, + }), + + h('button.btn-thin', { + onClick: this.showCreateVault.bind(this), + }, 'Create New Vault'), + + h('.flex-row.flex-center.flex-grow', [ + h('hr'), + h('div', 'OR'), + h('hr'), + ]), + + h('button.btn-thin', { + onClick: this.showRestoreVault.bind(this), + }, 'Restore Existing Vault'), + + ]) + + ) +} + +InitializeMenuScreen.prototype.renderRestoreVault = function() { + var state = this.props + return ( + + h('.initialize-screen.flex-column.flex-center.flex-grow', [ + + // subtitle and nav + h('.section-title.flex-row.flex-center', [ + h('i.fa.fa-arrow-left.fa-lg.cursor-pointer', { + onClick: this.showInitializeMenu.bind(this), + }), + h('h2.page-subtitle', 'Restore Vault'), + ]), + + + h('h3', 'Coming soon....'), + // h('textarea.twelve-word-phrase', { + // value: 'hey ho what the actual hello rubber duck bumbersnatch crumplezone frankenfurter', + // }), + + ]) + + ) +} + +// InitializeMenuScreen.prototype.splitWor = function() { +// this.props.dispatch(actions.showInitializeMenu()) +// } + +InitializeMenuScreen.prototype.showInitializeMenu = function() { + this.props.dispatch(actions.showInitializeMenu()) +} + +InitializeMenuScreen.prototype.showCreateVault = function() { + this.props.dispatch(actions.showCreateVault()) +} + +InitializeMenuScreen.prototype.showRestoreVault = function() { + this.props.dispatch(actions.showRestoreVault()) +} + diff --git a/ui/app/first-time/restore-vault.js b/ui/app/first-time/restore-vault.js new file mode 100644 index 000000000..55041e8c0 --- /dev/null +++ b/ui/app/first-time/restore-vault.js @@ -0,0 +1,116 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const connect = require('react-redux').connect +const h = require('react-hyperscript') +const actions = require('../actions') + +module.exports = connect(mapStateToProps)(RestoreVaultScreen) + + +inherits(RestoreVaultScreen, Component) +function RestoreVaultScreen() { + Component.call(this) +} + +function mapStateToProps(state) { + return { + warning: state.appState.warning, + } +} + + +RestoreVaultScreen.prototype.render = function() { + var state = this.props + return ( + + h('.initialize-screen.flex-column.flex-center.flex-grow', [ + + // subtitle and nav + h('.section-title.flex-row.flex-center', [ + h('i.fa.fa-arrow-left.fa-lg.cursor-pointer', { + onClick: this.showInitializeMenu.bind(this), + }), + h('h2.page-subtitle', 'Restore Vault'), + ]), + + // wallet seed entry + h('h3', 'Wallet Seed'), + h('textarea.twelve-word-phrase', { + placeholder: 'Enter your secret twelve word phrase here to restore your vault.' + }), + + // password + h('label', { + htmlFor: 'password-box', + }, 'New Password (min 8 chars):'), + + h('input', { + type: 'password', + id: 'password-box', + }), + + // confirm password + h('label', { + htmlFor: 'password-box-confirm', + }, 'Confirm Password:'), + + h('input', { + type: 'password', + id: 'password-box-confirm', + onKeyPress: this.onMaybeCreate.bind(this), + }), + + (state.warning) && ( + h('span.error.in-progress-notification', state.warning) + ), + + // submit + h('button.btn-thin', { + onClick: this.restoreVault.bind(this), + }, 'I\'ve double checked the 12 word phrase.'), + + ]) + + ) +} + +RestoreVaultScreen.prototype.showInitializeMenu = function() { + this.props.dispatch(actions.showInitializeMenu()) +} + +RestoreVaultScreen.prototype.onMaybeCreate = function(event) { + if (event.key === 'Enter') { + this.restoreVault() + } +} + +RestoreVaultScreen.prototype.restoreVault = function(){ + // check password + var passwordBox = document.getElementById('password-box') + var password = passwordBox.value + var passwordConfirmBox = document.getElementById('password-box-confirm') + var passwordConfirm = passwordConfirmBox.value + if (password.length < 8) { + this.warning = 'Password not long enough' + + this.props.dispatch(actions.displayWarning(this.warning)) + return + } + if (password !== passwordConfirm) { + this.warning = 'Passwords don\'t match' + this.props.dispatch(actions.displayWarning(this.warning)) + return + } + // check seed + var seedBox = document.querySelector('textarea.twelve-word-phrase') + var seed = seedBox.value.trim() + if (seed.split(' ').length !== 12) { + this.warning = 'seed phrases are 12 words long' + this.props.dispatch(actions.displayWarning(this.warning)) + return + } + // submit + this.warning = null + this.props.dispatch(actions.displayWarning(this.warning)) + this.props.dispatch(actions.recoverFromSeed(password, seed)) +} diff --git a/ui/app/img/identicon-tardigrade.png b/ui/app/img/identicon-tardigrade.png new file mode 100644 index 000000000..1742a32b8 Binary files /dev/null and b/ui/app/img/identicon-tardigrade.png differ diff --git a/ui/app/img/identicon-walrus.png b/ui/app/img/identicon-walrus.png new file mode 100644 index 000000000..d58fae912 Binary files /dev/null and b/ui/app/img/identicon-walrus.png differ diff --git a/ui/app/info.js b/ui/app/info.js new file mode 100644 index 000000000..ae8c6efc5 --- /dev/null +++ b/ui/app/info.js @@ -0,0 +1,90 @@ +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') + +module.exports = connect(mapStateToProps)(InfoScreen) + +function mapStateToProps(state) { + return {} +} + +inherits(InfoScreen, Component) +function InfoScreen() { + Component.call(this) +} + +InfoScreen.prototype.render = function() { + var state = this.props + var rpc = state.rpc + + 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.showAccountsPage()) + } + }), + h('h2.page-subtitle', 'Info'), + ]), + + // main view + h('.flex-column.flex-justify-center.flex-grow.select-none', [ + h('.flex-space-around', { + style: { + padding: '20px', + } + }, [ + + h('div', [ + h('a', { + href: 'https://consensys.slack.com/archives/team-metamask', + target: '_blank', + onClick(event) { this.navigateTo(event.target.href) }, + }, 'Join the conversation on Slack'), + ]), + + h('div', [ + h('a', { + href: 'https://metamask.io/', + target: '_blank', + onClick(event) { this.navigateTo(event.target.href) }, + }, 'Visit our web site'), + ]), + + h('div', [ + h('a', { + href: 'https://twitter.com/metamask_io', + target: '_blank', + onClick(event) { this.navigateTo(event.target.href) }, + }, 'Follow us on Twitter'), + ]), + + h('div', [ + h('a', { + href: 'mailto:hello@metamask.io?subject=Feedback', + target: '_blank', + }, 'Email us any questions or comments!'), + ]), + + h('div', [ + h('a', { + href: 'https://github.com/metamask/talk/issues', + target: '_blank', + onClick(event) { this.navigateTo(event.target.href) }, + }, 'Start a thread on Github'), + ]), + + ]), + ]), + ]) + ) +} + +InfoScreen.prototype.navigateTo = function(url) { + chrome.tabs.create({ url }); +} diff --git a/ui/app/loading.js b/ui/app/loading.js new file mode 100644 index 000000000..47b758cb6 --- /dev/null +++ b/ui/app/loading.js @@ -0,0 +1,51 @@ +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 ReactCSSTransitionGroup = require('react-addons-css-transition-group') + +module.exports = connect(mapStateToProps)(LoadingIndicator) + +function mapStateToProps(state) { + return { + isLoading: state.appState.isLoading, + } +} + +inherits(LoadingIndicator, Component) +function LoadingIndicator() { + Component.call(this) +} + +LoadingIndicator.prototype.render = function() { + console.dir(this.props) + var isLoading = this.props.isLoading + + return ( + h(ReactCSSTransitionGroup, { + transitionName: "loader", + transitionEnterTimeout: 150, + transitionLeaveTimeout: 150, + }, [ + + isLoading ? h('div', { + style: { + position: 'absolute', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + height: '100%', + width: '100%', + background: 'rgba(255, 255, 255, 0.5)', + } + }, [ + h('img', { + src: 'images/loading.svg', + }), + ]) : null, + + ]) + ) +} + diff --git a/ui/app/reducers.js b/ui/app/reducers.js new file mode 100644 index 000000000..0f2ad4c21 --- /dev/null +++ b/ui/app/reducers.js @@ -0,0 +1,41 @@ +const combineReducers = require('redux').combineReducers +const actions = require('./actions') +const extend = require('xtend') + +// +// Sub-Reducers take in the complete state and return their sub-state +// +const reduceIdentities = require('./reducers/identities') +const reduceMetamask = require('./reducers/metamask') +const reduceApp = require('./reducers/app') + +module.exports = rootReducer + +function rootReducer(state, action) { + + // clone + state = extend(state) + + // + // Identities + // + + state.identities = reduceIdentities(state, action) + + // + // MetaMask + // + + state.metamask = reduceMetamask(state, action) + + // + // AppState + // + + state.appState = reduceApp(state, action) + + + return state + +} + diff --git a/ui/app/reducers/app.js b/ui/app/reducers/app.js new file mode 100644 index 000000000..582583185 --- /dev/null +++ b/ui/app/reducers/app.js @@ -0,0 +1,281 @@ +const extend = require('xtend') +const actions = require('../actions') + +module.exports = reduceApp + +function reduceApp(state, action) { + + // clone and defaults + var defaultView = { + name: 'accounts', + detailView: null, + } + + // confirm seed words + var seedConfView = { + name: 'createVaultComplete', + } + var seedWords = state.metamask.seedWords + + var appState = extend({ + currentView: seedWords ? seedConfView : defaultView, + currentDomain: 'example.com', + transForward: true, // Used to render transition direction + isLoading: false, // Used to display loading indicator + warning: null, // Used to display error text + }, state.appState) + + switch (action.type) { + + // intialize + + case actions.SHOW_CREATE_VAULT: + return extend(appState, { + currentView: { + name: 'createVault', + }, + transForward: true, + warning: null, + }) + + case actions.SHOW_RESTORE_VAULT: + return extend(appState, { + currentView: { + name: 'restoreVault', + }, + transForward: true, + }) + + case actions.SHOW_INIT_MENU: + return extend(appState, { + currentView: defaultView, + transForward: false, + }) + + case actions.SHOW_CONFIG_PAGE: + return extend(appState, { + currentView: { + name: 'config', + }, + transForward: true, + }) + + case actions.SHOW_INFO_PAGE: + return extend(appState, { + currentView: { + name: 'info', + }, + transForward: true, + }) + + case actions.CREATE_NEW_VAULT_IN_PROGRESS: + return extend(appState, { + currentView: { + name: 'createVault', + inProgress: true, + }, + transForward: true, + isLoading: true, + }) + + case actions.SHOW_NEW_VAULT_SEED: + return extend(appState, { + currentView: { + name: 'createVaultComplete', + context: action.value, + }, + transForward: true, + isLoading: false, + }) + + case actions.SHOW_SEND_PAGE: + return extend(appState, { + currentView: { + name: 'sendTransaction', + context: appState.currentView.context, + }, + transForward: true, + warning: null, + }) + + // unlock + + case actions.UNLOCK_METAMASK: + return extend(appState, { + transForward: true, + warning: null, + }) + + case actions.LOCK_METAMASK: + return extend(appState, { + currentView: defaultView, + transForward: false, + warning: null, + }) + + // accounts + + case actions.SET_SELECTED_ACCOUNT: + return extend(appState, { + activeAddress: action.value, + }) + + case actions.SHOW_ACCOUNT_DETAIL: + return extend(appState, { + currentView: { + name: 'accountDetail', + context: action.value, + }, + accountDetail: { + accountExport: 'none', + privateKey: '', + }, + transForward: true, + }) + + case actions.BACK_TO_ACCOUNT_DETAIL: + return extend(appState, { + currentView: { + name: 'accountDetail', + context: action.value, + }, + accountDetail: { + accountExport: 'none', + privateKey: '', + }, + transForward: false, + }) + + case actions.SHOW_ACCOUNTS_PAGE: + var seedWords = state.metamask.seedWords + return extend(appState, { + currentView: { + name: seedWords ? 'createVaultComplete' : 'accounts', + }, + transForward: appState.currentView.name == 'locked', + isLoading: false, + warning: null, + }) + + case actions.SHOW_CONF_TX_PAGE: + return extend(appState, { + currentView: { + name: 'confTx', + context: 0, + }, + transForward: true, + warning: null, + }) + + case actions.COMPLETED_TX: + var unconfTxs = Object.keys(state.metamask.unconfTxs).filter(tx => tx !== tx.id) + if (unconfTxs && unconfTxs.length > 0) { + return extend(appState, { + transForward: false, + currentView: { + name: 'confTx', + context: 0, + }, + warning: null, + }) + } else { + return extend(appState, { + transForward: false, + currentView: { + name: 'accounts', + context: 0, + }, + transForward: false, + warning: null, + }) + } + + case actions.NEXT_TX: + return extend(appState, { + transForward: true, + currentView: { + name: 'confTx', + context: ++appState.currentView.context, + warning: null, + } + }) + + case actions.PREVIOUS_TX: + return extend(appState, { + transForward: false, + currentView: { + name: 'confTx', + context: --appState.currentView.context, + warning: null, + } + }) + + case actions.TRANSACTION_ERROR: + return extend(appState, { + currentView: { + name: 'confTx', + errorMessage: 'There was a problem submitting this transaction.', + }, + }) + + case actions.UNLOCK_FAILED: + return extend(appState, { + warning: 'Incorrect password. Try again.' + }) + + case actions.SHOW_LOADING: + return extend(appState, { + isLoading: true, + }) + + case actions.HIDE_LOADING: + return extend(appState, { + isLoading: false, + }) + + case actions.CLEAR_SEED_WORD_CACHE: + return extend(appState, { + transForward: true, + currentView: { + name: 'accounts', + }, + isLoading: false, + }) + + case actions.DISPLAY_WARNING: + return extend(appState, { + warning: action.value, + }) + + case actions.HIDE_WARNING: + return extend(appState, { + warning: undefined, + }) + + case actions.REQUEST_ACCOUNT_EXPORT: + return extend(appState, { + accountDetail: { + accountExport: 'requested', + }, + }) + + case actions.EXPORT_ACCOUNT: + return extend(appState, { + accountDetail: { + accountExport: 'completed', + }, + }) + + case actions.SHOW_PRIVATE_KEY: + return extend(appState, { + accountDetail: { + accountExport: 'completed', + privateKey: action.value, + }, + }) + + default: + return appState + + } +} diff --git a/ui/app/reducers/identities.js b/ui/app/reducers/identities.js new file mode 100644 index 000000000..95ecd23f9 --- /dev/null +++ b/ui/app/reducers/identities.js @@ -0,0 +1,18 @@ +const extend = require('xtend') +const actions = require('../actions') + +module.exports = reduceIdentities + +function reduceIdentities(state, action) { + + // clone + defaults + var idState = extend({ + + }, state.identities) + + switch (action.type) { + default: + return idState + } + +} diff --git a/ui/app/reducers/metamask.js b/ui/app/reducers/metamask.js new file mode 100644 index 000000000..43bb3f761 --- /dev/null +++ b/ui/app/reducers/metamask.js @@ -0,0 +1,73 @@ +const extend = require('xtend') +const actions = require('../actions') + +module.exports = reduceMetamask + +function reduceMetamask(state, action) { + + // clone + defaults + var metamaskState = extend({ + isInitialized: false, + isUnlocked: false, + currentDomain: 'example.com', + rpcTarget: 'https://rawtestrpc.metamask.io/', + identities: {}, + unconfTxs: {}, + }, state.metamask) + + switch (action.type) { + + case actions.SHOW_ACCOUNTS_PAGE: + var state = extend(metamaskState) + delete state.seedWords + return state + + case actions.UPDATE_METAMASK_STATE: + return extend(metamaskState, action.value) + + case actions.UNLOCK_METAMASK: + return extend(metamaskState, { + isUnlocked: true, + isInitialized: true, + }) + + case actions.LOCK_METAMASK: + return extend(metamaskState, { + isUnlocked: false, + }) + + case actions.SET_RPC_TARGET: + return extend(metamaskState, { + rpcTarget: action.value, + }) + + case actions.COMPLETED_TX: + var stringId = String(action.id) + var newState = extend(metamaskState, { + unconfTxs: {} + }) + for (var id in metamaskState.unconfTxs) { + if (id !== stringId) { + newState.unconfTxs[id] = metamaskState.unconfTxs[id] + } + } + return newState + + case actions.CLEAR_SEED_WORD_CACHE: + var newState = extend(metamaskState, { + isInitialized: true, + }) + delete newState.seedWords + return newState + + case actions.CREATE_NEW_VAULT_IN_PROGRESS: + return extend(metamaskState, { + isUnlocked: true, + isInitialized: true, + }) + + default: + return metamaskState + + } +} diff --git a/ui/app/root.js b/ui/app/root.js new file mode 100644 index 000000000..9fedf625f --- /dev/null +++ b/ui/app/root.js @@ -0,0 +1,24 @@ +const inherits = require('util').inherits +const React = require('react') +const Component = require('react').Component +const Provider = require('react-redux').Provider +const h = require('react-hyperscript') +const App = require('./app') + +module.exports = Root + + +inherits(Root, Component) +function Root() { Component.call(this) } + +Root.prototype.render = function() { + return ( + + h(Provider, { + store: this.props.store, + }, [ + h(App) + ]) + + ) +} diff --git a/ui/app/send.js b/ui/app/send.js new file mode 100644 index 000000000..3cb56cb6e --- /dev/null +++ b/ui/app/send.js @@ -0,0 +1,139 @@ +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 util = require('./util') +const numericBalance = require('./util').numericBalance +const AccountPanel = require('./components/account-panel') +const ethUtil = require('ethereumjs-util') + +module.exports = connect(mapStateToProps)(SendTransactionScreen) + +function mapStateToProps(state) { + var result = { + address: state.appState.currentView.context, + accounts: state.metamask.accounts, + identities: state.metamask.identities, + warning: state.appState.warning, + } + + result.account = result.accounts[result.address] + result.identity = result.identities[result.address] + result.balance = result.account ? numericBalance(result.account.balance) : null + + return result +} + +inherits(SendTransactionScreen, Component) +function SendTransactionScreen() { + Component.call(this) +} + +SendTransactionScreen.prototype.render = function() { + var state = this.props + var account = state.account + var identity = state.identity + + return ( + h('.send-screen.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: this.back.bind(this), + }), + h('h2.page-subtitle', 'Send Transaction'), + ]), + + h(AccountPanel, { + showFullAddress: true, + identity: identity, + account: account, + }), + + h('section.recipient', [ + h('input.address', { + placeholder: 'Recipient Address', + }) + ]), + + h('section.ammount', [ + h('input.ether', { + placeholder: 'Amount', + type: 'number', + style: { marginRight: '6px' } + }), + h('select.currency', { + name: 'currency', + }, [ + h('option', { value: 'ether' }, 'Ether (1e18 wei)'), + h('option', { value: 'wei' }, 'Wei'), + ]), + ]), + + h('section.data', [ + h('details', [ + h('summary', { + style: {cursor: 'pointer'}, + }, 'Advanced'), + h('textarea.txData', { + type: 'textarea', + placeholder: 'Transaction data (optional)', + style: { + height: '100px', + width: '100%', + resize: 'none', + } + }) + ]) + ]), + + h('section', { + }, [ + h('button', { + onClick: this.onSubmit.bind(this), + }, 'Send') + ]), + + state.warning ? h('span.error', state.warning) : null, + ]) + ) +} + +SendTransactionScreen.prototype.back = function() { + var address = this.props.address + this.props.dispatch(actions.backToAccountDetail(address)) +} + +SendTransactionScreen.prototype.onSubmit = function(event) { + var recipient = document.querySelector('input.address').value + var amount = new ethUtil.BN(document.querySelector('input.ether').value, 10) + var currency = document.querySelector('select.currency').value + var txData = document.querySelector('textarea.txData').value + + var value = util.normalizeToWei(amount, currency) + var balance = this.props.balance + + if (value.gt(balance)) { + var message = 'Insufficient funds.' + return this.props.dispatch(actions.displayWarning(message)) + } + if (recipient.length !== 42) { + var message = 'Recipient address is the incorrect length.' + return this.props.dispatch(actions.displayWarning(message)) + } + + this.props.dispatch(actions.hideWarning()) + this.props.dispatch(actions.showLoadingIndication()) + + var txParams = { + to: recipient, + from: this.props.address, + value: '0x' + value.toString(16), + } + if (txData) txParams.data = txData + + this.props.dispatch(actions.signTx(txParams)) +} + diff --git a/ui/app/settings.js b/ui/app/settings.js new file mode 100644 index 000000000..9a11ef680 --- /dev/null +++ b/ui/app/settings.js @@ -0,0 +1,69 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const h = require('react-hyperscript') +const connect = require('react-redux').connect +const copyToClipboard = require('copy-to-clipboard') +const actions = require('./actions') +const AccountPanel = require('./components/account-panel') + +module.exports = connect(mapStateToProps)(AppSettingsPage) + +function mapStateToProps(state) { + return { + identities: state.metamask.identities, + address: state.appState.currentView.context, + } +} + +inherits(AppSettingsPage, Component) +function AppSettingsPage() { + Component.call(this) +} + + +AppSettingsPage.prototype.render = function() { + var state = this.props + var identity = state.identities[state.address] + return ( + + h('.account-detail-section.flex-column.flex-grow', [ + + // subtitle and nav + h('.flex-row.flex-center', [ + h('i.fa.fa-arrow-left.fa-lg.cursor-pointer', { + onClick: this.navigateToAccounts.bind(this), + }), + h('h2.page-subtitle', 'Settings'), + ]), + + h('label', { + htmlFor: 'settings-rpc-endpoint', + }, 'RPC Endpoint:'), + h('input', { + // value: '//testrpc.metamask.io', + type: 'url', + id: 'settings-rpc-endpoint', + onKeyPress: this.onKeyPress.bind(this), + }), + + ]) + + ) +} + +AppSettingsPage.prototype.componentDidMount = function(){ + document.querySelector('input').focus() +} + +AppSettingsPage.prototype.onKeyPress = function(event) { + // get submit event + if (event.key === 'Enter') { + // this.submitPassword(event) + } +} + + +AppSettingsPage.prototype.navigateToAccounts = function(event){ + event.stopPropagation() + this.props.dispatch(actions.showAccountsPage()) +} diff --git a/ui/app/store.js b/ui/app/store.js new file mode 100644 index 000000000..a738f1a12 --- /dev/null +++ b/ui/app/store.js @@ -0,0 +1,19 @@ +const createStore = require('redux').createStore +const applyMiddleware = require('redux').applyMiddleware +const thunkMiddleware = require('redux-thunk') +const createLogger = require('redux-logger') +const rootReducer = require('./reducers') + +module.exports = configureStore + + +const loggerMiddleware = createLogger() + +const createStoreWithMiddleware = applyMiddleware( + thunkMiddleware, + loggerMiddleware +)(createStore) + +function configureStore(initialState) { + return createStoreWithMiddleware(rootReducer, initialState) +} diff --git a/ui/app/template.js b/ui/app/template.js new file mode 100644 index 000000000..3c2d902b5 --- /dev/null +++ b/ui/app/template.js @@ -0,0 +1,31 @@ +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') + +module.exports = connect(mapStateToProps)(COMPONENTNAME) + +function mapStateToProps(state) { + return {} +} + +inherits(COMPONENTNAME, Component) +function COMPONENTNAME() { + Component.call(this) +} + +COMPONENTNAME.prototype.render = function() { + var state = this.props + var rpc = state.rpc + + return ( + h('div', { + style: { + display: 'none', + } + }, [ + ]) + ) +} + diff --git a/ui/app/unlock.js b/ui/app/unlock.js new file mode 100644 index 000000000..8aac1b1ff --- /dev/null +++ b/ui/app/unlock.js @@ -0,0 +1,101 @@ +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 Mascot = require('./components/mascot') +const getCaretCoordinates = require('textarea-caret') +const EventEmitter = require('events').EventEmitter + +module.exports = connect(mapStateToProps)(UnlockScreen) + + +inherits(UnlockScreen, Component) +function UnlockScreen() { + Component.call(this) + this.animationEventEmitter = new EventEmitter() +} + +function mapStateToProps(state) { + return { + warning: state.appState.warning, + } +} + +UnlockScreen.prototype.render = function() { + const state = this.props + const warning = state.warning + return ( + + h('.unlock-screen.flex-column.flex-center.flex-grow', [ + + h('h2.page-subtitle', 'Welcome!'), + + h(Mascot, { + animationEventEmitter: this.animationEventEmitter, + }), + + h('label', { + htmlFor: 'password-box', + }, 'Enter Password:'), + + h('input', { + type: 'password', + id: 'password-box', + onKeyPress: this.onKeyPress.bind(this), + onInput: this.inputChanged.bind(this), + }), + + h('.error', { + style: { + display: warning ? 'block' : 'none', + } + }, warning), + + h('button.primary.cursor-pointer', { + onClick: this.onSubmit.bind(this), + }, 'Unlock'), + + ]) + + ) +} + +UnlockScreen.prototype.componentDidMount = function(){ + document.getElementById('password-box').focus() +} + +UnlockScreen.prototype.onSubmit = function(event) { + const input = document.getElementById('password-box') + const password = input.value + this.props.dispatch(actions.tryUnlockMetamask(password)) +} + +UnlockScreen.prototype.onKeyPress = function(event) { + if (event.key === 'Enter') { + this.submitPassword(event) + } +} + +UnlockScreen.prototype.submitPassword = function(event){ + var element = event.target + var password = element.value + // reset input + element.value = '' + this.props.dispatch(actions.tryUnlockMetamask(password)) +} + +UnlockScreen.prototype.inputChanged = function(event){ + // tell mascot to look at page action + var element = event.target + var boundingRect = element.getBoundingClientRect() + var coordinates = getCaretCoordinates(element, element.selectionEnd) + this.animationEventEmitter.emit('point', { + x: boundingRect.left + coordinates.left - element.scrollLeft, + y: boundingRect.top + coordinates.top - element.scrollTop, + }) +} + +UnlockScreen.prototype.emitAnim = function(name, a, b, c){ + this.animationEventEmitter.emit(name, a, b, c) +} diff --git a/ui/app/util.js b/ui/app/util.js new file mode 100644 index 000000000..4c31e54f4 --- /dev/null +++ b/ui/app/util.js @@ -0,0 +1,102 @@ +const ethUtil = require('ethereumjs-util') + +var valueTable = { + wei: '1000000000000000000', + kwei: '1000000000000000', + mwei: '1000000000000', + gwei: '1000000000', + szabo: '1000000', + finney:'1000', + ether: '1', + kether:'0.001', + mether:'0.000001', + gether:'0.000000001', + tether:'0.000000000001', +} +var bnTable = {} +for (var currency in valueTable) { + bnTable[currency] = new ethUtil.BN(valueTable[currency], 10) +} + +module.exports = { + valuesFor: valuesFor, + addressSummary: addressSummary, + numericBalance: numericBalance, + formatBalance: formatBalance, + dataSize: dataSize, + readableDate: readableDate, + ethToWei: ethToWei, + weiToEth: weiToEth, + normalizeToWei: normalizeToWei, + valueTable: valueTable, + bnTable: bnTable, +} + + +function valuesFor(obj) { + if (!obj) return [] + return Object.keys(obj) + .map(function(key){ return obj[key] }) +} + +function addressSummary(address) { + return address ? address.slice(0,2+8)+'...'+address.slice(-4) : '...' +} + +// Takes wei Hex, returns wei BN, even if input is null +function numericBalance(balance) { + if (!balance) return new ethUtil.BN(0, 16) + var stripped = ethUtil.stripHexPrefix(balance) + return new ethUtil.BN(stripped, 16) +} + +// Takes eth BN, returns BN wei +function ethToWei(bn) { + var eth = new ethUtil.BN('1000000000000000000') + var wei = bn.mul(eth) + return wei +} + +// Takes BN in Wei, returns BN in eth +function weiToEth(bn) { + var diff = new ethUtil.BN('1000000000000000000') + var eth = bn.div(diff) + return eth +} + +function formatBalance(balance) { + if (!balance) return 'None' + var wei = numericBalance(balance) + var eth = weiToEth(wei) + return eth.toString(10) + ' ETH' +} + +function dataSize(data) { + var size = data ? ethUtil.stripHexPrefix(data).length : 0 + return size+' bytes' +} + +// Takes a BN and an ethereum currency name, +// returns a BN in wei +function normalizeToWei(amount, currency) { + try { + var ether = amount.div(bnTable[currency]) + var wei = ether.mul(bnTable.wei) + return wei + } catch (e) {} + return amount +} + +function readableDate(ms) { + var date = new Date(ms) + var month = date.getMonth() + var day = date.getDate() + var year = date.getFullYear() + var hours = date.getHours() + var minutes = "0" + date.getMinutes() + var seconds = "0" + date.getSeconds() + + var date = `${month}/${day}/${year}` + var time = `${hours}:${minutes.substr(-2)}:${seconds.substr(-2)}` + return `${date} ${time}` +} diff --git a/ui/css.js b/ui/css.js new file mode 100644 index 000000000..b7bc7d363 --- /dev/null +++ b/ui/css.js @@ -0,0 +1,26 @@ +const fs = require('fs') + +module.exports = bundleCss + +var cssFiles = { + 'fonts.css': fs.readFileSync(__dirname+'/app/css/fonts.css', 'utf8'), + 'reset.css': fs.readFileSync(__dirname+'/app/css/reset.css', 'utf8'), + 'lib.css': fs.readFileSync(__dirname+'/app/css/lib.css', 'utf8'), + 'index.css': fs.readFileSync(__dirname+'/app/css/index.css', 'utf8'), + 'transitions.css': fs.readFileSync(__dirname+'/app/css/transitions.css', 'utf8'), +} + +function bundleCss() { + var cssBundle = Object.keys(cssFiles).reduce(function(bundle, fileName){ + var fileContent = cssFiles[fileName] + var output = String() + + output += '/*========== '+fileName+' ==========*/\n\n' + output += fileContent + output += '\n\n' + + return bundle+output + }, String()) + + return cssBundle +} diff --git a/ui/design/1st_time_use.png b/ui/design/1st_time_use.png new file mode 100644 index 000000000..c18ced5e2 Binary files /dev/null and b/ui/design/1st_time_use.png differ diff --git a/ui/design/metamask_wfs_jan_13.pdf b/ui/design/metamask_wfs_jan_13.pdf new file mode 100644 index 000000000..c77c9274a Binary files /dev/null and b/ui/design/metamask_wfs_jan_13.pdf differ diff --git a/ui/design/metamask_wfs_jan_13.png b/ui/design/metamask_wfs_jan_13.png new file mode 100644 index 000000000..d71d7bdb4 Binary files /dev/null and b/ui/design/metamask_wfs_jan_13.png differ diff --git a/ui/design/metamask_wfs_jan_18.pdf b/ui/design/metamask_wfs_jan_18.pdf new file mode 100644 index 000000000..592ba8532 Binary files /dev/null and b/ui/design/metamask_wfs_jan_18.pdf differ diff --git a/ui/example.js b/ui/example.js new file mode 100644 index 000000000..b32da4be4 --- /dev/null +++ b/ui/example.js @@ -0,0 +1,123 @@ +const injectCss = require('inject-css') +const MetaMaskUi = require('./index.js') +const MetaMaskUiCss = require('./css.js') +const EventEmitter = require('events').EventEmitter + +// account management + +var identities = { + '0x1113462427bcc9133bb46e88bcbe39cd7ef0e111': { + name: 'Walrus', + img: 'QmW6hcwYzXrNkuHrpvo58YeZvbZxUddv69ATSHY3BHpPdd', + address: '0x1113462427bcc9133bb46e88bcbe39cd7ef0e111', + balance: 220, + txCount: 4, + }, + '0x222462427bcc9133bb46e88bcbe39cd7ef0e7222': { + name: 'Tardus', + img: 'QmQYaRdrf2EhRhJWaHnts8Meu1mZiXrNib5W1P6cYmXWRL', + address: '0x222462427bcc9133bb46e88bcbe39cd7ef0e7222', + balance: 10.005, + txCount: 16, + }, + '0x333462427bcc9133bb46e88bcbe39cd7ef0e7333': { + name: 'Gambler', + img: 'QmW6hcwYzXrNkuHrpvo58YeZvbZxUddv69ATSHY3BHpPdd', + address: '0x333462427bcc9133bb46e88bcbe39cd7ef0e7333', + balance: 0.000001, + txCount: 1, + } +} + +var unconfTxs = {} +addUnconfTx({ + from: '0x222462427bcc9133bb46e88bcbe39cd7ef0e7222', + to: '0x1113462427bcc9133bb46e88bcbe39cd7ef0e111', + value: '0x123', +}) +addUnconfTx({ + from: '0x1113462427bcc9133bb46e88bcbe39cd7ef0e111', + to: '0x333462427bcc9133bb46e88bcbe39cd7ef0e7333', + value: '0x0000', + data: '0x000462427bcc9133bb46e88bcbe39cd7ef0e7000', +}) + +function addUnconfTx(txParams){ + var time = (new Date()).getTime() + var id = createRandomId() + unconfTxs[id] = { + id: id, + txParams: txParams, + time: time, + } +} + +var isUnlocked = false +var selectedAddress = null + +function getState(){ + return { + isUnlocked: isUnlocked, + identities: isUnlocked ? identities : {}, + unconfTxs: isUnlocked ? unconfTxs : {}, + selectedAddress: selectedAddress, + } +} + +var accountManager = new EventEmitter() + +accountManager.getState = function(cb){ + cb(null, getState()) +} + +accountManager.setLocked = function(){ + isUnlocked = false + this._didUpdate() +} + +accountManager.submitPassword = function(password, cb){ + if (password === 'test') { + isUnlocked = true + cb(null, getState()) + this._didUpdate() + } else { + cb(new Error('Bad password -- try "test"')) + } +} + +accountManager.setSelectedAddress = function(address, cb){ + selectedAddress = address + cb(null, getState()) + this._didUpdate() +} + +accountManager.signTransaction = function(txParams, cb){ + alert('signing tx....') +} + +accountManager._didUpdate = function(){ + this.emit('update', getState()) +} + +// start app + +var container = document.getElementById('app-content') + +var css = MetaMaskUiCss() +injectCss(css) + +var app = MetaMaskUi({ + container: container, + accountManager: accountManager +}) + +// util + +function createRandomId(){ + // 13 time digits + var datePart = new Date().getTime()*Math.pow(10, 3) + // 3 random digits + var extraPart = Math.floor(Math.random()*Math.pow(10, 3)) + // 16 digits + return datePart+extraPart +} \ No newline at end of file diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 000000000..ba8a8baac --- /dev/null +++ b/ui/index.html @@ -0,0 +1,38 @@ + + + + + MetaMask + + + + +
+ + + + +
+ + + +
+ + + \ No newline at end of file diff --git a/ui/index.js b/ui/index.js new file mode 100644 index 000000000..05d30d8d3 --- /dev/null +++ b/ui/index.js @@ -0,0 +1,55 @@ +const React = require('react') +const render = require('react-dom').render +const h = require('react-hyperscript') +const extend = require('xtend') +const Root = require('./app/root') +const actions = require('./app/actions') +const configureStore = require('./app/store') + +module.exports = launchApp + +function launchApp(opts) { + + var accountManager = opts.accountManager + actions._setAccountManager(accountManager) + + // check if we are unlocked first + accountManager.getState(function(err, metamaskState){ + if (err) throw err + startApp(metamaskState, accountManager, opts) + }) + +} + +function startApp(metamaskState, accountManager, opts){ + + // parse opts + var store = configureStore({ + + // metamaskState represents the cross-tab state + metamask: metamaskState, + + // appState represents the current tab's popup state + appState: { + currentDomain: opts.currentDomain, + } + }) + + // if unconfirmed txs, start on txConf page + if (Object.keys(metamaskState.unconfTxs || {}).length) { + 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) + +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 000000000..73c9b527d --- /dev/null +++ b/ui/package.json @@ -0,0 +1,58 @@ +{ + "name": "metamask-ui", + "version": "1.5.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "mocha test/**/*test.js", + "watch": "mocha watch test/**/*test.js", + "start": "beefy example.js:bundle.js --live --open", + "build": "browserify example.js -g uglifyify -o bundle.js" + }, + "author": "", + "license": "ISC", + "devDependencies": { + "beefy": "^2.1.5", + "chai": "^3.5.0", + "deep-freeze-strict": "^1.1.1", + "jsdom": "^8.1.0", + "mocha": "^2.4.5", + "mocha-jsdom": "^1.1.0", + "sinon": "^1.17.3", + "uglifyify": "^3.0.1" + }, + "browserify": { + "transform": [ + [ + "babelify", + { + "presets": [ + "es2015" + ] + } + ], + "brfs" + ] + }, + "dependencies": { + "babel-preset-es2015": "^6.3.13", + "babelify": "^7.2.0", + "brfs": "^1.4.2", + "browserify": "^12.0.1", + "copy-to-clipboard": "^1.1.1", + "debounce": "^1.0.0", + "ethereumjs-util": "^2.6.0", + "inject-css": "^0.1.1", + "metamask-logo": "^1.1.3", + "react": "^0.14.3", + "react-addons-css-transition-group": "^0.14.7", + "react-dom": "^0.14.3", + "react-hyperscript": "^2.2.2", + "react-redux": "^4.0.3", + "redux": "^3.0.5", + "redux-logger": "^2.3.1", + "redux-thunk": "^1.0.2", + "textarea-caret": "^3.0.1", + "xtend": "^4.0.1" + } +} diff --git a/ui/test/setup.js b/ui/test/setup.js new file mode 100644 index 000000000..7985e9a00 --- /dev/null +++ b/ui/test/setup.js @@ -0,0 +1,8 @@ +if (typeof process === 'object') { + // Initialize node environment + global.expect = require('chai').expect + require('mocha-jsdom')() +} else { + window.expect = window.chai.expect + window.require = function () { /* noop */ } +} diff --git a/ui/test/unit/actions/config_test.js b/ui/test/unit/actions/config_test.js new file mode 100644 index 000000000..d38210bfc --- /dev/null +++ b/ui/test/unit/actions/config_test.js @@ -0,0 +1,43 @@ +var jsdom = require('mocha-jsdom') +var assert = require('assert') +var freeze = require('deep-freeze-strict') +var path = require('path') + +var actions = require(path.join(__dirname, '..', '..', '..', 'app', 'actions.js')) +var reducers = require(path.join(__dirname, '..', '..', '..', 'app', 'reducers.js')) + +describe ('config view actions', function() { + + var initialState = { + metamask: { + rpcTarget: 'foo', + }, + appState: { + currentView: { + name: 'accounts', + } + } + } + freeze(initialState) + + describe('SHOW_CONFIG_PAGE', function() { + it('should set appState.currentView.name to config', function() { + var result = reducers(initialState, actions.showConfigPage()) + assert.equal(result.appState.currentView.name, 'config') + }) + }) + + describe('SET_RPC_TARGET', function() { + + it('sets the state.metamask.rpcTarget property of the state to the action.value', function() { + const action = { + type: actions.SET_RPC_TARGET, + value: 'bar', + } + + var result = reducers(initialState, action) + assert.equal(result.metamask.rpcTarget, action.value) + }) + }) +}) + diff --git a/ui/test/unit/actions/restore_vault_test.js b/ui/test/unit/actions/restore_vault_test.js new file mode 100644 index 000000000..da0d71ce7 --- /dev/null +++ b/ui/test/unit/actions/restore_vault_test.js @@ -0,0 +1,54 @@ +var jsdom = require('mocha-jsdom') +var assert = require('assert') +var freeze = require('deep-freeze-strict') +var path = require('path') +var sinon = require('sinon') + +var actions = require(path.join(__dirname, '..', '..', '..', 'app', 'actions.js')) +var reducers = require(path.join(__dirname, '..', '..', '..', 'app', 'reducers.js')) + +describe('#recoverFromSeed(password, seed)', function() { + + beforeEach(function() { + // sinon allows stubbing methods that are easily verified + this.sinon = sinon.sandbox.create() + }) + + afterEach(function() { + // sinon requires cleanup otherwise it will overwrite context + this.sinon.restore() + }) + + // stub out account manager + actions._setAccountManager({ + recoverFromSeed(pw, seed, cb) { cb() }, + }) + + it('sets metamask.isUnlocked to true', function() { + var initialState = { + metamask: { + isUnlocked: false, + isInitialized: false, + } + } + freeze(initialState) + + const restorePhrase = 'invite heavy among daring outdoor dice jelly coil stable note seat vicious' + const password = 'foo' + const dispatchFunc = actions.recoverFromSeed(password, restorePhrase) + + var dispatchStub = this.sinon.stub() + dispatchStub.withArgs({ TYPE: actions.unlockMetamask() }).onCall(0) + dispatchStub.withArgs({ TYPE: actions.showAccountsPage() }).onCall(1) + + var action + var resultingState = initialState + dispatchFunc((newAction) => { + action = newAction + resultingState = reducers(resultingState, action) + }) + + assert.equal(resultingState.metamask.isUnlocked, true, 'was unlocked') + assert.equal(resultingState.metamask.isInitialized, true, 'was initialized') + }); +}); diff --git a/ui/test/unit/actions/set_selected_account_test.js b/ui/test/unit/actions/set_selected_account_test.js new file mode 100644 index 000000000..1af6c964f --- /dev/null +++ b/ui/test/unit/actions/set_selected_account_test.js @@ -0,0 +1,28 @@ +var jsdom = require('mocha-jsdom') +var assert = require('assert') +var freeze = require('deep-freeze-strict') +var path = require('path') + +var actions = require(path.join(__dirname, '..', '..', '..', 'app', 'actions.js')) +var reducers = require(path.join(__dirname, '..', '..', '..', 'app', 'reducers.js')) + +describe('SET_SELECTED_ACCOUNT', function() { + + it('sets the state.appState.activeAddress property of the state to the action.value', function() { + var initialState = { + appState: { + activeAddress: 'foo', + } + } + freeze(initialState) + + const action = { + type: actions.SET_SELECTED_ACCOUNT, + value: 'bar', + } + freeze(action) + + var resultingState = reducers(initialState, action) + assert.equal(resultingState.appState.activeAddress, action.value) + }); +}); diff --git a/ui/test/unit/actions/tx_test.js b/ui/test/unit/actions/tx_test.js new file mode 100644 index 000000000..d83ae16c0 --- /dev/null +++ b/ui/test/unit/actions/tx_test.js @@ -0,0 +1,168 @@ +var jsdom = require('mocha-jsdom') +var assert = require('assert') +var freeze = require('deep-freeze-strict') +var path = require('path') + +var actions = require(path.join(__dirname, '..', '..', '..', 'app', 'actions.js')) +var reducers = require(path.join(__dirname, '..', '..', '..', 'app', 'reducers.js')) + +describe('tx confirmation screen', function() { + var initialState, result + + describe('when there is only one tx', function() { + var firstTxId = 1457634084250832 + + beforeEach(function() { + + initialState = { + appState: { + currentView: { + name: 'confTx', + }, + }, + metamask: { + unconfTxs: { + '1457634084250832': { + id: 1457634084250832, + status: "unconfirmed", + time: 1457634084250, + } + }, + } + } + freeze(initialState) + }) + + describe('cancelTx', function() { + + before(function(done) { + actions._setAccountManager({ + approveTransaction(txId, cb) { cb('An error!') }, + cancelTransaction(txId) { /* noop */ }, + clearSeedWordCache(cb) { cb() }, + }) + + actions.cancelTx({id: firstTxId})(function(action) { + result = reducers(initialState, action) + done() + }) + }) + + it('should transition to the accounts list', function() { + assert.equal(result.appState.currentView.name, 'accounts') + }) + + it('should have no unconfirmed txs remaining', function() { + var count = getUnconfirmedTxCount(result) + assert.equal(count, 0) + }) + }) + + describe('sendTx', function() { + var result + + describe('when there is an error', function() { + + before(function(done) { + alert = () => {/* noop */} + + actions._setAccountManager({ + approveTransaction(txId, cb) { cb('An error!') }, + }) + + actions.sendTx({id: firstTxId})(function(action) { + result = reducers(initialState, action) + done() + }) + }) + + it('should stay on the page', function() { + assert.equal(result.appState.currentView.name, 'confTx') + }) + + it('should set errorMessage on the currentView', function() { + assert(result.appState.currentView.errorMessage) + }) + }) + + describe('when there is success', function() { + before(function(done) { + actions._setAccountManager({ + approveTransaction(txId, cb) { cb() }, + }) + + actions.sendTx({id: firstTxId})(function(action) { + result = reducers(initialState, action) + done() + }) + }) + + it('should navigate away from the tx page', function() { + assert.equal(result.appState.currentView.name, 'accounts') + }) + + it('should clear the tx from the unconfirmed transactions', function() { + assert(!(firstTxId in result.metamask.unconfTxs), 'tx is cleared') + }) + }) + }) + + describe('when there are two pending txs', function() { + var firstTxId = 1457634084250832 + var result, initialState + before(function(done) { + initialState = { + appState: { + currentView: { + name: 'confTx', + }, + }, + metamask: { + unconfTxs: { + '1457634084250832': { + id: 1457634084250832, + status: "unconfirmed", + time: 1457634084250, + }, + '1457634084250833': { + id: 1457634084250833, + status: "unconfirmed", + time: 1457634084255, + }, + }, + } + } + freeze(initialState) + + + actions._setAccountManager({ + approveTransaction(txId, cb) { cb() }, + }) + + actions.sendTx({id: firstTxId})(function(action) { + result = reducers(initialState, action) + done() + }) + }) + + it('should stay on the confTx view', function() { + assert.equal(result.appState.currentView.name, 'confTx') + }) + + it('should transition to the first tx', function() { + assert.equal(result.appState.currentView.context, 0) + }) + + it('should only have one unconfirmed tx remaining', function() { + var count = getUnconfirmedTxCount(result) + assert.equal(count, 1) + }) + }) + }) +}); + +function getUnconfirmedTxCount(state) { + var txs = state.metamask.unconfTxs + var count = Object.keys(txs).length + return count +} diff --git a/ui/test/unit/actions/view_info_test.js b/ui/test/unit/actions/view_info_test.js new file mode 100644 index 000000000..888712c67 --- /dev/null +++ b/ui/test/unit/actions/view_info_test.js @@ -0,0 +1,23 @@ +var jsdom = require('mocha-jsdom') +var assert = require('assert') +var freeze = require('deep-freeze-strict') +var path = require('path') + +var actions = require(path.join(__dirname, '..', '..', '..', 'app', 'actions.js')) +var reducers = require(path.join(__dirname, '..', '..', '..', 'app', 'reducers.js')) + +describe('SHOW_INFO_PAGE', function() { + + it('sets the state.appState.currentView.name property to info', function() { + var initialState = { + appState: { + activeAddress: 'foo', + } + } + freeze(initialState) + + const action = actions.showInfoPage() + var resultingState = reducers(initialState, action) + assert.equal(resultingState.appState.currentView.name, 'info') + }); +}); diff --git a/ui/test/unit/actions/warning_test.js b/ui/test/unit/actions/warning_test.js new file mode 100644 index 000000000..eee198656 --- /dev/null +++ b/ui/test/unit/actions/warning_test.js @@ -0,0 +1,24 @@ +var jsdom = require('mocha-jsdom') +var assert = require('assert') +var freeze = require('deep-freeze-strict') +var path = require('path') + +var actions = require(path.join(__dirname, '..', '..', '..', 'app', 'actions.js')) +var reducers = require(path.join(__dirname, '..', '..', '..', 'app', 'reducers.js')) + +describe('action DISPLAY_WARNING', function() { + + it('sets appState.warning to provided value', function() { + var initialState = { + appState: {}, + } + freeze(initialState) + + const warningText = 'This is a sample warning message' + + const action = actions.displayWarning(warningText) + const resultingState = reducers(initialState, action) + + assert.equal(resultingState.appState.warning, warningText, 'warning text set') + }); +}); diff --git a/ui/test/unit/util_test.js b/ui/test/unit/util_test.js new file mode 100644 index 000000000..52635eb89 --- /dev/null +++ b/ui/test/unit/util_test.js @@ -0,0 +1,102 @@ +var assert = require('assert') +var sinon = require('sinon') +const ethUtil = require('ethereumjs-util') + +var path = require('path') +var util = require(path.join(__dirname, '..', '..', 'app', 'util.js')) + +describe('util', function() { + var ethInWei = '1' + for (var i = 0; i < 18; i++ ) { ethInWei += '0' } + + beforeEach(function() { + this.sinon = sinon.sandbox.create() + }) + + afterEach(function() { + this.sinon.restore() + }) + + describe('numericBalance', function() { + + it('should return a BN 0 if given nothing', function() { + var result = util.numericBalance() + assert.equal(result.toString(10), 0) + }) + + it('should work with hex prefix', function() { + var result = util.numericBalance('0x012') + assert.equal(result.toString(10), '18') + }) + + it('should work with no hex prefix', function() { + var result = util.numericBalance('012') + assert.equal(result.toString(10), '18') + }) + + }) + + describe('#ethToWei', function() { + + it('should take an eth BN, returns wei BN', function() { + var input = new ethUtil.BN(1, 10) + var result = util.ethToWei(input) + assert.equal(result, ethInWei, '18 zeroes') + }) + + }) + + describe('#weiToEth', function() { + + it('should take a wei BN and return an eth BN', function() { + var result = util.weiToEth(new ethUtil.BN(ethInWei)) + assert.equal(result, '1', 'equals 1 eth') + }) + + }) + + describe('#formatBalance', function() { + + it('when given nothing', function() { + var result = util.formatBalance() + assert.equal(result, 'None', 'should return "None"') + }) + + it('should return eth as string followed by ETH', function() { + var input = new ethUtil.BN(ethInWei).toJSON() + var result = util.formatBalance(input) + assert.equal(result, '1 ETH') + }) + + }) + + describe('#normalizeToWei', function() { + it('should convert an eth to the appropriate equivalent values', function() { + var valueTable = { + wei: '1000000000000000000', + kwei: '1000000000000000', + mwei: '1000000000000', + gwei: '1000000000', + szabo: '1000000', + finney:'1000', + ether: '1', + kether:'0.001', + mether:'0.000001', + // AUDIT: We're getting BN numbers on these ones. + // I think they're big enough to ignore for now. + // gether:'0.000000001', + // tether:'0.000000000001', + } + var oneEthBn = new ethUtil.BN(ethInWei, 10) + + for(var currency in valueTable) { + + var value = new ethUtil.BN(valueTable[currency], 10) + var output = util.normalizeToWei(value, currency) + assert.equal(output.toString(10), valueTable.wei, `value of ${output.toString(10)} ${currency} should convert to ${oneEthBn}`) + + } + }) + }) + +}) -- cgit